并发编程实战:从互斥锁到线程同步的完整解决方案
1. 从一次线上事故说起为什么我们需要“锁”去年我负责的一个服务上线后经历了一次典型的“幽灵”故障。这个服务负责处理用户积分变动逻辑很简单用户完成一个任务系统查询其当前积分加上本次奖励再更新回数据库。在低并发测试下一切正常但上线后不久客服就收到了零星用户反馈说积分莫名其妙少了一部分。查看日志没有报错每个请求都“成功”执行了。问题出在哪经过压测复现和日志分析我们抓到了元凶两个几乎同时到达的请求线程A和线程B都读取到了用户旧的积分值比如100分A计算后更新为120分B计算后也更新为120分。用户本该得到140分最终却只得到了120分。这就是典型的线程安全问题多个线程并发访问共享资源这里是数据库中的用户积分记录且至少有一个线程在执行写操作最终结果依赖于线程执行的时序导致了数据的不一致。这次事故让我深刻体会到在多线程编程中光有“能跑”的代码是远远不够的。当多个执行流线程交织在一起它们对共享数据的访问就像十字路口没有红绿灯的车流碰撞和混乱是必然的。而“互斥”与“同步”就是为这片混乱疆域建立秩序的两大基石。互斥解决的是“同一时刻只能有一个线程进入”的问题防止数据被同时改写同步解决的则是“你等我我等你”的协作问题确保线程按照预期的顺序执行。今天我们就抛开教科书式的定义从实战角度深入聊聊这两个并发编程中的核心概念以及如何用好它们。2. 互斥守护共享资源的“独木桥”互斥的核心理念是“排他性访问”。对于某一共享资源或称临界区在任何时刻最多只允许一个线程持有访问权。这就像一座独木桥一次只能过一个人。实现互斥的机制我们通常称之为“锁”。2.1 互斥锁最基础的守卫以最常见的互斥锁为例。在Java中就是synchronized关键字或ReentrantLock类。public class Counter { private int value 0; private final Object lock new Object(); // 锁对象 public void unsafeIncrement() { value; // 非原子操作可能出问题 } public void safeIncrement() { synchronized (lock) { // 进入临界区前加锁 value; } // 离开临界区后自动释放锁 } }synchronized包裹的代码块就是临界区。当一个线程进入时它会尝试获取lock对象的监视器锁。如果锁未被占用则获取成功并进入如果已被其他线程占用则当前线程会被阻塞进入等待队列直到锁被释放。注意锁对象的选择至关重要。必须保证所有需要互斥的线程都竞争同一个锁对象。如果线程A用lock1线程B用lock2那等于没加锁。通常锁应该是一个private final的成员变量或者直接使用this但需谨慎这可能扩大锁的范围。2.2 可重入性自己家的门自己可以重复进一个常见的误解是线程拿到锁后如果在锁内再次尝试获取同一把锁会被自己阻塞死锁。实际上主流的互斥锁都是可重入的。这意味着一个线程可以多次成功获取它已经持有的锁相应的也需要释放相同次数才能真正释放锁。public class ReentrantExample { private final Object lock new Object(); public void methodA() { synchronized (lock) { System.out.println(In methodA); methodB(); // 在锁内调用另一个也需要同一把锁的方法 } } public void methodB() { synchronized (lock) { // 线程在此可以再次获取lock不会阻塞 System.out.println(In methodB); } } }可重入性极大地简化了面向对象编程因为我们可以放心地在同步方法中调用另一个同步方法而不用担心死锁。ReentrantLock顾名思义就是可重入锁。2.3 死锁当多把锁形成闭环互斥锁使用不当最著名的副作用就是死锁。死锁通常需要四个条件同时满足互斥、持有并等待、不可剥夺、循环等待。一个经典的死锁场景是“哲学家就餐问题”在代码中则常表现为锁顺序不一致。// 线程1 synchronized (lockA) { Thread.sleep(100); // 模拟一些操作增加死锁概率 synchronized (lockB) { // 尝试获取lockB // do something } } // 线程2 synchronized (lockB) { Thread.sleep(100); synchronized (lockA) { // 尝试获取lockA // do something } }线程1持有lockA等lockB线程2持有lockB等lockA双方无限期等待程序卡死。解决死锁的黄金法则全局固定的锁获取顺序。无论哪个线程需要获取多把锁时都必须按照事先约定好的全局顺序例如按锁对象的哈希值或一个唯一ID排序去申请。这样就能破坏“循环等待”条件。在实际项目中我习惯使用一个工具类来管理锁的顺序或者尽量缩小锁的范围使用更细粒度的锁减少需要同时持有多个锁的场景。3. 同步线程间的“信号灯”与“等待室”如果说互斥是让线程“别同时进来”那么同步就是让线程“按顺序来”或者“等到某个条件再行动”。典型的场景是生产者-消费者模型缓冲区空时消费者必须等待缓冲区满时生产者必须等待。3.1 等待/通知机制Object类的基石Java中每个对象都内置了等待/通知机制通过wait(),notify(),notifyAll()方法实现。它们必须在synchronized同步块内调用因为它们需要操作对象的监视器锁。public class SimpleBlockingQueueT { private final QueueT queue new LinkedList(); private final int maxSize; private final Object lock new Object(); public SimpleBlockingQueue(int maxSize) { this.maxSize maxSize; } public void put(T item) throws InterruptedException { synchronized (lock) { while (queue.size() maxSize) { // 必须用while循环检查条件 lock.wait(); // 释放lock锁并进入等待集 } queue.offer(item); lock.notifyAll(); // 通知所有等待的线程包括生产者和消费者 } } public T take() throws InterruptedException { synchronized (lock) { while (queue.isEmpty()) { lock.wait(); } T item queue.poll(); lock.notifyAll(); return item; } } }这里有三个关键点条件判断必须用while不能用if。因为当线程被notify()唤醒时条件可能再次变得不满足比如多个消费者被唤醒但只有一个能取走数据。while循环会进行“重新检查”确保条件真正满足后才继续执行。wait()调用会释放当前持有的锁本例中的lock这是它能实现协作的核心。线程进入该对象的“等待集”。notify()随机唤醒等待集中的一个线程notifyAll()唤醒所有。通常更推荐使用notifyAll()因为它更简单能避免某些线程被“饿死”的情况尽管可能带来一些性能开销唤醒的线程竞争锁后不满足条件的会再次wait。3.2 Condition对象更精细的线程协作Lock框架下的Condition接口提供了比Object监视器更灵活的等待/通知机制。一个Lock可以创建多个Condition对象用于不同的等待条件。public class ImprovedBlockingQueueT { private final Lock lock new ReentrantLock(); private final Condition notFull lock.newCondition(); // 队列“未满”条件 private final Condition notEmpty lock.newCondition(); // 队列“非空”条件 private final QueueT queue new LinkedList(); private final int maxSize; public void put(T item) throws InterruptedException { lock.lock(); try { while (queue.size() maxSize) { notFull.await(); // 在“未满”条件上等待 } queue.offer(item); notEmpty.signal(); // 唤醒一个在“非空”条件上等待的消费者 } finally { lock.unlock(); // 确保锁被释放 } } public T take() throws InterruptedException { lock.lock(); try { while (queue.isEmpty()) { notEmpty.await(); } T item queue.poll(); notFull.signal(); // 唤醒一个在“未满”条件上等待的生产者 return item; } finally { lock.unlock(); } } }使用Condition的优势在于定向通知。生产者只需要唤醒可能正在等待的消费者notEmpty.signal()消费者只需要唤醒可能正在等待的生产者notFull.signal()。这比notifyAll()更高效减少了不必要的线程竞争和上下文切换。在复杂的多条件等待场景下Condition是更好的选择。4. 实战中的高级武器与性能陷阱掌握了基础的互斥锁和等待/通知我们来看看更高级的工具和那些容易踩的坑。4.1 读写锁读多写少场景的性能利器我们之前的锁都是“排他锁”无论读写同一时间只允许一个线程访问。但在很多场景下数据读取的频率远高于写入。读操作本身并不修改数据允许多个线程并发读是安全的。读写锁ReadWriteLock如ReentrantReadWriteLock应运而生它维护了一对锁读锁和写锁。读锁共享锁允许多个线程同时持有只要没有线程持有写锁。写锁排他锁一次只允许一个线程持有并且持有写锁时不能持有任何读锁。public class CachedData { private Object data; private volatile boolean cacheValid; private final ReentrantReadWriteLock rwLock new ReentrantReadWriteLock(); public void processCachedData() { rwLock.readLock().lock(); // 先加读锁 if (!cacheValid) { // 必须在获取写锁之前释放读锁 rwLock.readLock().unlock(); rwLock.writeLock().lock(); // 加写锁 try { // 再次检查状态因为可能已经有其他线程更新了缓存 if (!cacheValid) { data fetchDataFromDB(); // 模拟耗时操作 cacheValid true; } // 在释放写锁前降级为读锁 rwLock.readLock().lock(); } finally { rwLock.writeLock().unlock(); // 释放写锁保持读锁 } } try { use(data); // 使用数据 } finally { rwLock.readLock().unlock(); // 释放读锁 } } }这里演示了一个经典的“锁降级”过程在持有写锁更新完数据后先获取读锁再释放写锁。这样能保证数据更新后其他读线程能立刻看到新数据同时当前线程仍能以共享模式读取数据。但需要注意ReentrantReadWriteLock不支持锁升级读锁直接升级为写锁因为这极易导致死锁。实操心得读写锁在读占比极高例如超过90%的场景下才能带来显著的性能提升。因为读写锁本身的实现比普通互斥锁更复杂开销也更大。如果写操作频繁或者临界区代码执行时间极短使用普通互斥锁可能反而更简单高效。一定要基于实际压测数据做选择。4.2 volatile关键字轻量级的可见性保证它常常被误解为一种“轻量级锁”。实际上volatile不提供原子性只提供两大保证可见性对一个volatile变量的写会立即刷新到主内存对一个volatile变量的读会从主内存读取最新值。禁止指令重排序防止JVM和处理器为了优化性能而对指令进行重排这在单例模式的双重检查锁定中至关重要。// 经典的双重检查锁定单例模式 public class Singleton { private static volatile Singleton instance; // 必须volatile private Singleton() {} public static Singleton getInstance() { if (instance null) { // 第一次检查 synchronized (Singleton.class) { if (instance null) { // 第二次检查 instance new Singleton(); // 非原子操作可能发生重排序 } } } return instance; } }instance new Singleton()这行代码并非原子操作它大致分为1.分配内存空间2.初始化对象3.将引用指向该空间。如果没有volatileJVM可能将步骤2和3重排序。导致其他线程在第一次检查时看到instance不为null但拿到的是一个未初始化完全的对象。volatile通过禁止这种重排序确保了安全性。volatile的适用场景非常有限通常用于修饰状态标志位如boolean isRunning或者用于一次性安全发布如上述单例。它不能替代锁来保证复合操作的原子性。4.3 线程局部存储彻底避免共享有时最高效的同步就是“不同步”。如果你能为每个线程创建一份变量的独立副本那么就不存在共享自然也不需要互斥。ThreadLocal就是干这个的。public class UserContextHolder { private static final ThreadLocalUser currentUser ThreadLocal.withInitial(() - null); public static void set(User user) { currentUser.set(user); } public static User get() { return currentUser.get(); } public static void clear() { currentUser.remove(); // 非常重要尤其是在线程池环境中 } } // 在Web请求的过滤器或拦截器中 public void doFilter(...) { try { User user authenticate(request); UserContextHolder.set(user); chain.doFilter(request, response); } finally { UserContextHolder.clear(); // 务必清理防止内存泄漏和用户信息串用 } }ThreadLocal将变量绑定到当前线程每个线程都有自己独立的实例。这在传递上下文信息如用户会话、数据库连接、事务ID时非常方便。但最大的坑在于内存泄漏如果使用线程池线程会被复用。一个线程处理完一个请求后如果其ThreadLocal变量没有被remove()那么这个变量会一直持有对对象的引用导致对象无法被GC回收。因此使用ThreadLocal的黄金法则就是用完后一定要remove()通常放在finally块中执行。5. 并发工具包站在巨人的肩膀上Java的java.util.concurrent包提供了大量高质量、高性能的并发工具能解决绝大多数并发问题避免重复造轮子。5.1 原子类无锁化的线程安全计数器对于简单的原子操作如i使用锁开销太大。原子类如AtomicInteger,AtomicLong,AtomicReference利用CPU的CAS指令实现无锁的线程安全更新。public class AtomicCounter { private final AtomicInteger count new AtomicInteger(0); public void increment() { count.incrementAndGet(); // 原子性的 i } public int get() { return count.get(); } }CASCompare-And-Swap操作包含三个参数内存位置V、旧的预期值A、新值B。当且仅当V的值等于A时处理器才会用B更新V的值否则不执行更新。这是一个原子指令。incrementAndGet()内部通常是一个自旋循环读取当前值计算新值尝试CAS失败则重试。在低至中度竞争下性能远优于锁。但CAS并非银弹在高并发场景下如果多个线程反复CAS失败会导致大量的CPU空转自旋消耗资源。这就是著名的“ABA问题”的源头之一虽然对于计数器通常不是问题。对于复杂的聚合操作可能需要使用LongAdder它在高并发下性能更好通过内部维护多个单元格来分散竞争。5.2 并发容器线程安全的集合不要再用Collections.synchronizedList(new ArrayList())了JUC提供了更高效的并发容器。ConcurrentHashMap分段锁JDK7或CASsynchronizedJDK8实现并发读和部分写操作可以并行。CopyOnWriteArrayList/CopyOnWriteArraySet写时复制。每次修改时都会创建底层数组的新副本。读操作无锁性能极高写操作开销大适合读多写极少如监听器列表的场景。ConcurrentLinkedQueue基于链接节点的无界非阻塞队列。使用CAS实现性能很高。BlockingQueue接口的各种实现如ArrayBlockingQueue有界数组、LinkedBlockingQueue可选有界链表、SynchronousQueue不存储元素直接传递等是生产者-消费者模式的直接实现。选择哪个容器完全取决于你的使用场景是重读还是重写是否需要阻塞操作容量是否有界5.3 CountDownLatch与CyclicBarrier多线程协调的“发令枪”和“集合点”CountDownLatch倒计时闩一个线程或多个等待其他一组线程完成操作。初始化时设定一个计数。其他线程完成任务后调用countDown()计数减一。等待线程调用await()当计数减到0时被唤醒。一次性使用。场景主线程等待所有数据加载线程完成模拟并发测试让所有线程同时开始执行。// 主线程等待5个Worker线程完成 CountDownLatch latch new CountDownLatch(5); for (int i 0; i 5; i) { new Thread(() - { try { // 执行任务 } finally { latch.countDown(); // 任务完成计数减1 } }).start(); } latch.await(); // 主线程在此等待直到计数为0 System.out.println(All workers finished.);CyclicBarrier循环屏障一组线程互相等待到达一个公共屏障点后才继续执行。初始化时设定参与线程数和一个可选的屏障动作Runnable。每个线程调用await()后会被阻塞直到所有线程都调用了await()然后所有线程被释放屏障重置可以重复使用。场景多阶段任务每个阶段需要所有线程都完成才能进入下一阶段复杂的并行计算。// 4个线程分阶段并行处理数据 CyclicBarrier barrier new CyclicBarrier(4, () - System.out.println(All reached barrier, next phase)); for (int i 0; i 4; i) { new Thread(() - { processPhase1(); barrier.await(); processPhase2(); barrier.await(); }).start(); }6. 设计层面规避并发问题思想比工具更重要工具和技术是手段但良好的设计能从源头减少并发复杂度。6.1 不可变对象最简单的线程安全对象就是不可变对象。一旦创建其状态永不改变。由于没有写操作自然不存在线程安全问题。Java中String、BigInteger就是典型的不可变类。设计时尽量将类声明为final所有字段设为private final不提供任何修改状态的方法。6.2 线程封闭将对象限制在单个线程内使用。除了ThreadLocal还有栈封闭局部变量和特定线程持有的对象如Swing的EDT线程。这是最有效的“同步”方式。6.3 委托线程安全如果一个类由多个线程安全的组件组合而成并且这些组件之间没有状态关联那么这个类本身也是线程安全的。例如一个Servlet类中只包含对AtomicLong的引用和操作。6.4 同步策略文档化在你的类文档中明确写出它的线程安全属性。是“线程安全的”、“条件线程安全的”如某些方法需要外部同步、“非线程安全的”还是“不可变的”这能极大帮助使用者正确、安全地使用你的代码。在我经历的那个积分事故后我们最终重构了系统。对于核心的积分变更操作我们没有简单地在应用层加锁因为那无法应对分布式多实例的场景。我们采用了两种策略结合对于实时性要求高、并发量大的场景使用数据库的乐观锁通过版本号或时间戳对于强一致性要求、允许短暂延迟的场景将积分变更请求发送到消息队列由单线程消费者顺序处理。这告诉我们解决并发问题首先要准确定义问题边界是单机多线程还是分布式然后选择与场景匹配的技术方案没有一种方案是万能的。并发编程就像驾驶一辆高性能跑车互斥与同步是它的刹车和方向盘。不懂得使用迟早会车毁人亡但若使用得当它们能让你在复杂路况下安全、高效地飞驰。从理解最基本的锁和条件变量开始到熟练运用JUC工具箱里的各种利器再到从设计层面思考如何规避竞争这条路没有捷径唯有多读、多写、多踩坑、多总结。每一次线上事故都是最好的老师。