第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());
    }
}
上一章:并发性能调优 返回目录 下一章:并发编程实战