并发编程实战:从互斥锁到线程同步的完整解决方案
2026/8/12 11:50:06 网站建设 项目流程

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持有lockAlockB,线程2持有lockBlockA,双方无限期等待,程序卡死。解决死锁的黄金法则:全局固定的锁获取顺序。无论哪个线程,需要获取多把锁时,都必须按照事先约定好的全局顺序(例如,按锁对象的哈希值或一个唯一ID排序)去申请。这样就能破坏“循环等待”条件。

在实际项目中,我习惯使用一个工具类来管理锁的顺序,或者尽量缩小锁的范围,使用更细粒度的锁,减少需要同时持有多个锁的场景。

3. 同步:线程间的“信号灯”与“等待室”

如果说互斥是让线程“别同时进来”,那么同步就是让线程“按顺序来”或者“等到某个条件再行动”。典型的场景是生产者-消费者模型:缓冲区空时,消费者必须等待;缓冲区满时,生产者必须等待。

3.1 等待/通知机制:Object类的基石

Java中,每个对象都内置了等待/通知机制,通过wait(),notify(),notifyAll()方法实现。它们必须在synchronized同步块内调用,因为它们需要操作对象的监视器锁。

public class SimpleBlockingQueue<T> { private final Queue<T> 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; } } }

这里有三个关键点:

  1. 条件判断必须用while,不能用if。因为当线程被notify()唤醒时,条件可能再次变得不满足(比如,多个消费者被唤醒,但只有一个能取走数据)。while循环会进行“重新检查”,确保条件真正满足后才继续执行。
  2. wait()调用会释放当前持有的锁(本例中的lock),这是它能实现协作的核心。线程进入该对象的“等待集”。
  3. notify()随机唤醒等待集中的一个线程,notifyAll()唤醒所有。通常更推荐使用notifyAll(),因为它更简单,能避免某些线程被“饿死”的情况,尽管可能带来一些性能开销(唤醒的线程竞争锁后,不满足条件的会再次wait)。

3.2 Condition对象:更精细的线程协作

Lock框架下的Condition接口提供了比Object监视器更灵活的等待/通知机制。一个Lock可以创建多个Condition对象,用于不同的等待条件。

public class ImprovedBlockingQueue<T> { private final Lock lock = new ReentrantLock(); private final Condition notFull = lock.newCondition(); // 队列“未满”条件 private final Condition notEmpty = lock.newCondition(); // 队列“非空”条件 private final Queue<T> 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不提供原子性,只提供两大保证:

  1. 可见性:对一个volatile变量的写,会立即刷新到主内存;对一个volatile变量的读,会从主内存读取最新值。
  2. 禁止指令重排序:防止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.将引用指向该空间。如果没有volatile,JVM可能将步骤2和3重排序。导致其他线程在第一次检查时看到instance不为null,但拿到的是一个未初始化完全的对象。volatile通过禁止这种重排序,确保了安全性。

volatile的适用场景非常有限:通常用于修饰状态标志位(如boolean isRunning),或者用于一次性安全发布(如上述单例)。它不能替代锁来保证复合操作的原子性。

4.3 线程局部存储:彻底避免共享

有时,最高效的同步就是“不同步”。如果你能为每个线程创建一份变量的独立副本,那么就不存在共享,自然也不需要互斥。ThreadLocal就是干这个的。

public class UserContextHolder { private static final ThreadLocal<User> 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(); } }

CAS(Compare-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)或CAS+synchronized(JDK8+)实现,并发读和部分写操作可以并行。
  • 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中StringBigInteger就是典型的不可变类。设计时,尽量将类声明为final,所有字段设为private final,不提供任何修改状态的方法。

6.2 线程封闭将对象限制在单个线程内使用。除了ThreadLocal,还有栈封闭(局部变量)和特定线程持有的对象(如Swing的EDT线程)。这是最有效的“同步”方式。

6.3 委托线程安全如果一个类由多个线程安全的组件组合而成,并且这些组件之间没有状态关联,那么这个类本身也是线程安全的。例如,一个Servlet类中只包含对AtomicLong的引用和操作。

6.4 同步策略文档化在你的类文档中,明确写出它的线程安全属性。是“线程安全的”、“条件线程安全的”(如某些方法需要外部同步)、“非线程安全的”,还是“不可变的”?这能极大帮助使用者正确、安全地使用你的代码。

在我经历的那个积分事故后,我们最终重构了系统。对于核心的积分变更操作,我们没有简单地在应用层加锁,因为那无法应对分布式多实例的场景。我们采用了两种策略结合:对于实时性要求高、并发量大的场景,使用数据库的乐观锁(通过版本号或时间戳);对于强一致性要求、允许短暂延迟的场景,将积分变更请求发送到消息队列,由单线程消费者顺序处理。这告诉我们,解决并发问题,首先要准确定义问题边界(是单机多线程还是分布式?),然后选择与场景匹配的技术方案,没有一种方案是万能的。

并发编程就像驾驶一辆高性能跑车,互斥与同步是它的刹车和方向盘。不懂得使用,迟早会车毁人亡;但若使用得当,它们能让你在复杂路况下安全、高效地飞驰。从理解最基本的锁和条件变量开始,到熟练运用JUC工具箱里的各种利器,再到从设计层面思考如何规避竞争,这条路没有捷径,唯有多读、多写、多踩坑、多总结。每一次线上事故,都是最好的老师。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询