第19章
⚠️ 并发编程陷阱
识别和避免并发编程中的常见陷阱,掌握调试并发程序的技巧
学习目标
- 识别常见的竞态条件
- 掌握死锁的预防和检测
- 了解活锁和饥饿问题
- 解决伪共享问题
- 学会调试并发程序
竞态条件识别
竞态条件(Race Condition)是并发编程中最常见的问题之一。当多个线程同时访问共享资源,且至少有一个线程在修改该资源时,就可能发生竞态条件。
危险警告
竞态条件可能导致数据不一致、程序崩溃或安全漏洞,是并发编程中必须重点关注的问题。
常见的竞态条件类型
数据竞争
多个线程同时读写同一内存位置,没有适当的同步机制保护。
检查后执行
在检查条件和执行操作之间,其他线程可能改变了条件状态。
复合操作
多个原子操作组合成的非原子复合操作可能被其他线程中断。
竞态条件示例
不安全的计数器
public class UnsafeCounter {
private int count = 0;
// 存在竞态条件的方法
public void increment() {
count++; // 非原子操作:读取 -> 增加 -> 写入
}
public int getCount() {
return count;
}
}
// 测试竞态条件
public class RaceConditionDemo {
public static void main(String[] args) throws InterruptedException {
UnsafeCounter counter = new UnsafeCounter();
int threadCount = 10;
int incrementsPerThread = 1000;
Thread[] threads = new Thread[threadCount];
for (int i = 0; i < threadCount; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < incrementsPerThread; j++) {
counter.increment();
}
});
}
for (Thread thread : threads) {
thread.start();
}
for (Thread thread : threads) {
thread.join();
}
// 期望值:10000,实际值可能小于10000
System.out.println("期望值: " + (threadCount * incrementsPerThread));
System.out.println("实际值: " + counter.getCount());
}
}
解决方案
- 使用synchronized:同步方法或同步块
- 使用原子类:AtomicInteger、AtomicLong等
- 使用Lock:ReentrantLock等显式锁
- 使用volatile:对于简单的标志位
死锁预防和检测
死锁是指两个或多个线程永久阻塞,每个线程都在等待其他线程释放资源。死锁是并发编程中最严重的问题之一。
死锁的四个必要条件
互斥条件
资源不能被多个线程同时使用。
持有并等待
线程持有资源的同时等待其他资源。
不可剥夺
资源不能被强制从线程中剥夺。
循环等待
存在线程资源的循环等待链。
死锁示例
经典的死锁场景
public class DeadlockDemo {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("Thread 1: 持有 lock1");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Thread 1: 等待 lock2");
synchronized (lock2) {
System.out.println("Thread 1: 持有 lock1 和 lock2");
}
}
});
Thread thread2 = new Thread(() -> {
synchronized (lock2) {
System.out.println("Thread 2: 持有 lock2");
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Thread 2: 等待 lock1");
synchronized (lock1) {
System.out.println("Thread 2: 持有 lock1 和 lock2");
}
}
});
thread1.start();
thread2.start();
}
}
死锁预防策略
预防方法
- 资源排序:所有线程按相同顺序获取锁
- 超时机制:使用tryLock(timeout)避免无限等待
- 银行家算法:在分配资源前检查是否会导致死锁
- 避免嵌套锁:尽量避免在持有一个锁时获取另一个锁
使用资源排序避免死锁
public class DeadlockPrevention {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
// 定义锁的顺序
private static void acquireLocksInOrder(Object firstLock, Object secondLock) {
synchronized (firstLock) {
synchronized (secondLock) {
// 执行需要两个锁的操作
System.out.println(Thread.currentThread().getName() + ": 获取了两个锁");
}
}
}
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
acquireLocksInOrder(lock1, lock2);
}, "Thread-1");
Thread thread2 = new Thread(() -> {
acquireLocksInOrder(lock1, lock2); // 相同的顺序
}, "Thread-2");
thread1.start();
thread2.start();
}
}
活锁和饥饿问题
除了死锁,并发编程中还存在活锁和饥饿问题。这些问题虽然不会导致程序完全停止,但会严重影响程序的性能和响应性。
活锁(Livelock)
活锁特征
线程没有被阻塞,但由于某些条件没有满足,导致线程持续重试而无法继续执行。
活锁示例
public class LivelockDemo {
static class Spoon {
private Diner owner;
public Spoon(Diner owner) {
this.owner = owner;
}
public Diner getOwner() {
return owner;
}
public synchronized void setOwner(Diner owner) {
this.owner = owner;
}
public synchronized void use() {
System.out.printf("%s 使用了勺子!%n", owner.name);
}
}
static class Diner {
private String name;
private boolean isHungry;
public Diner(String name) {
this.name = name;
this.isHungry = true;
}
public void eatWith(Spoon spoon, Diner spouse) {
while (isHungry) {
// 如果勺子不属于自己
if (spoon.getOwner() != this) {
try {
Thread.sleep(1);
} catch (InterruptedException e) {
continue;
}
continue;
}
// 如果配偶饿了,让出勺子
if (spouse.isHungry) {
System.out.printf("%s: 你先吃吧,亲爱的 %s!%n", name, spouse.name);
spoon.setOwner(spouse);
continue;
}
// 使用勺子吃饭
spoon.use();
isHungry = false;
System.out.printf("%s: 我吃饱了!%n", name);
spoon.setOwner(spouse);
}
}
}
public static void main(String[] args) {
Diner husband = new Diner("丈夫");
Diner wife = new Diner("妻子");
Spoon spoon = new Spoon(husband);
new Thread(() -> husband.eatWith(spoon, wife)).start();
new Thread(() -> wife.eatWith(spoon, husband)).start();
}
}
饥饿(Starvation)
饥饿是指线程长时间无法获得所需资源,通常是由于资源分配不公平导致的。
优先级倒置
高优先级线程被低优先级线程阻塞。
资源竞争激烈
某些线程总是抢不到资源。
调度不公平
线程调度器分配时间不均匀。
解决方案
- 公平锁:使用ReentrantLock(true)确保公平性
- 优先级管理:合理设置线程优先级
- 资源池:使用连接池等技术避免资源竞争
- 超时机制:设置获取资源的超时时间
伪共享问题
伪共享(False Sharing)是一个性能问题,当多个线程修改同一缓存行中的不同变量时,会导致缓存行在CPU核心之间频繁传输,严重影响性能。
性能影响
伪共享可能导致性能下降10倍甚至更多,是高性能并发程序必须考虑的问题。
伪共享示例
存在伪共享的代码
public class FalseSharingDemo {
// 这两个变量可能在同一个缓存行中
private volatile long value1 = 0L;
private volatile long value2 = 0L;
public void incrementValue1() {
value1++;
}
public void incrementValue2() {
value2++;
}
public static void main(String[] args) throws InterruptedException {
FalseSharingDemo demo = new FalseSharingDemo();
int iterations = 100_000_000;
long startTime = System.currentTimeMillis();
Thread thread1 = new Thread(() -> {
for (int i = 0; i < iterations; i++) {
demo.incrementValue1();
}
});
Thread thread2 = new Thread(() -> {
for (int i = 0; i < iterations; i++) {
demo.incrementValue2();
}
});
thread1.start();
thread2.start();
thread1.join();
thread2.join();
long endTime = System.currentTimeMillis();
System.out.println("执行时间: " + (endTime - startTime) + "ms");
}
}
解决伪共享
使用缓存行填充
public class FalseSharingSolution {
// 使用填充避免伪共享
private volatile long value1 = 0L;
private long p1, p2, p3, p4, p5, p6, p7; // 缓存行填充
private volatile long value2 = 0L;
private long p8, p9, p10, p11, p12, p13, p14; // 缓存行填充
// 或者使用@Contended注解(需要JVM参数 -XX:-RestrictContended)
@sun.misc.Contended
private volatile long value3 = 0L;
@sun.misc.Contended
private volatile long value4 = 0L;
public void incrementValue1() {
value1++;
}
public void incrementValue2() {
value2++;
}
}
最佳实践
- 识别热点数据:找出频繁修改的共享变量
- 缓存行填充:使用填充字段分离变量
- @Contended注解:Java 8+提供的官方解决方案
- 数据结构设计:合理设计数据结构避免伪共享
调试并发程序
调试并发程序比调试单线程程序更加困难,因为并发问题往往具有不确定性和难以重现的特点。
调试工具和技术
JConsole
Java自带的监控工具,可以检测死锁和线程状态。
VisualVM
强大的性能分析工具,支持线程分析和内存分析。
ThreadSanitizer
检测数据竞争的工具,可以发现潜在的并发问题。
调试技巧
实用技巧
- 日志记录:记录线程ID、时间戳和关键操作
- 断言检查:使用assert验证并发不变量
- 压力测试:增加线程数量和执行时间暴露问题
- 代码审查:重点检查共享变量的访问模式
- 单元测试:编写专门的并发测试用例
并发测试示例
public class ConcurrentTester {
private final AtomicInteger errorCount = new AtomicInteger(0);
private final CountDownLatch startLatch = new CountDownLatch(1);
private final CountDownLatch endLatch;
public ConcurrentTester(int threadCount) {
this.endLatch = new CountDownLatch(threadCount);
}
public void testConcurrentOperation(Runnable operation, int threadCount) {
for (int i = 0; i < threadCount; i++) {
new Thread(() -> {
try {
startLatch.await(); // 等待所有线程就绪
operation.run();
} catch (Exception e) {
errorCount.incrementAndGet();
e.printStackTrace();
} finally {
endLatch.countDown();
}
}).start();
}
startLatch.countDown(); // 开始测试
try {
endLatch.await(); // 等待所有线程完成
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("错误数量: " + errorCount.get());
}
}