
1. 线程安全到底在解决什么问题很多刚接触Java服务端开发的朋友写单线程业务逻辑时顺风顺水一旦引入多线程或者部署到并发环境就开始遇到各种“玄学”问题明明代码逻辑没问题跑出来的结果却对不上某个变量在A线程改了B线程死活读不到新值加了个标志位想优雅停止线程结果循环死活不退出。这些现象背后基本都指向同一个核心议题——线程安全。线程安全说白了就是当多个线程同时访问同一份共享资源时程序还能按照我们预期的逻辑正确执行不会因为调度顺序的随机性而产生错误结果。这里的关键词是“共享”和“同时”。如果每个线程只碰自己的私有变量那永远不会有线程安全问题如果所有操作都是串行执行的也不会有问题。问题恰恰出在多个线程读写同一块内存区域且执行顺序不可控的场景。这篇文章我会围绕线程安全这条主线把锁机制、线程间通信、内存可见性、CAS无锁编程以及线程状态流转这几个核心知识点串起来讲。适合已经写过一些Java代码、但对并发编程还停留在“知道synchronized但说不清原理”阶段的开发者。我会尽量用实际场景和类比把原理讲透同时给出可以直接参考的代码示例和排查思路。读完之后你至少能做到看到一段并发代码能判断它有没有线程安全问题遇到并发bug知道从哪些方向去排查自己写并发代码时知道该选哪种同步手段。2. 锁机制从互斥到协作的完整拆解2.1 为什么需要锁一个计数器引发的血案先看一个最经典的例子。假设我们有一个计数器多个线程同时对它做自增操作public class Counter { private int count 0; public void increment() { count; } public int getCount() { return count; } }单线程跑一万次结果就是一万没问题。但开十个线程各跑一千次最终结果大概率小于一万。原因在于count这个操作看起来是一行代码实际上在底层至少分三步读取count的当前值到寄存器、将值加一、把新值写回内存。两个线程如果同时读到相同的旧值各自加一后写回就会丢失一次自增。这就是典型的竞态条件。解决方式就是加锁让这三步操作变成原子操作同一时刻只有一个线程能执行。2.2 synchronized的三种用法与底层原理Java内置的锁就是synchronized关键字它可以修饰三种东西修饰实例方法锁的是当前对象实例this修饰静态方法锁的是当前类的Class对象修饰代码块锁的是括号里指定的对象// 修饰实例方法 public synchronized void increment() { count; } // 修饰代码块锁指定对象 public void increment() { synchronized (this) { count; } }底层实现上synchronized在JVM层面依赖对象头中的Mark Word来记录锁状态。锁的状态会经历无锁、偏向锁、轻量级锁、重量级锁的升级过程。偏向锁适用于只有一个线程反复进入同步块的场景此时不需要真正的互斥操作只是在对象头里记一下线程ID。当出现第二个线程竞争时升级为轻量级锁通过CAS自旋尝试获取锁。如果自旋失败或者竞争激烈最终升级为重量级锁依赖操作系统的互斥量实现线程会进入阻塞状态。注意偏向锁在JDK 15之后默认被禁用了因为它在高并发场景下撤销偏向锁的开销反而成了负担。如果你用的是较新的JDK版本不用太纠结偏向锁的细节。2.3 ReentrantLock更灵活的显式锁synchronized用起来简单但灵活性有限。比如它不支持超时获取锁、不支持中断响应、不支持公平锁。这些场景下就需要ReentrantLock。private final ReentrantLock lock new ReentrantLock(); public void increment() { lock.lock(); try { count; } finally { lock.unlock(); } }ReentrantLock的核心优势体现在几个方面。第一它可以指定公平性构造函数传入true就变成公平锁线程按等待顺序获取锁避免饥饿。第二它提供tryLock(long timeout, TimeUnit unit)方法在指定时间内拿不到锁就返回false不会无限等待。第三它支持lockInterruptibly()等待锁的过程中可以响应中断。底层实现上ReentrantLock基于AQSAbstractQueuedSynchronizer框架内部维护一个volatile的state变量表示锁的持有计数以及一个CLH双向队列存放等待的线程。每次加锁就是对state做CAS操作成功则获取锁失败则入队等待。2.4 读写锁读多写少场景的优化利器互斥锁的问题在于多个读操作之间其实并不需要互斥它们不会修改数据。如果读操作远多于写操作用互斥锁就浪费了大量并发能力。ReentrantReadWriteLock解决了这个问题读锁之间共享写锁与其他所有锁互斥。private final ReentrantReadWriteLock rwLock new ReentrantReadWriteLock(); private final Lock readLock rwLock.readLock(); private final Lock writeLock rwLock.writeLock(); public int getCount() { readLock.lock(); try { return count; } finally { readLock.unlock(); } } public void increment() { writeLock.lock(); try { count; } finally { writeLock.unlock(); } }这里有个容易踩的坑读锁不能升级为写锁。也就是说一个线程持有读锁的同时去获取写锁会导致死锁。反过来写锁可以降级为读锁但一般也不推荐这么用因为降级后的读锁可能被其他等待写锁的线程阻塞。2.5 锁的选择与性能考量实际开发中怎么选锁我的经验是这样的场景推荐方案理由简单互斥代码块小synchronizedJVM内置优化好代码简洁不易出错需要超时/中断/公平锁ReentrantLock功能更灵活读多写少ReentrantReadWriteLock提升读并发度无锁并发简单计数AtomicInteger等原子类CAS无锁性能更好高并发复杂状态考虑分段锁或LongAdder减少竞争热点实操心得不要一上来就追求“高性能锁”。大部分业务场景下synchronized经过JVM优化后性能已经足够好。过早引入ReentrantLock反而增加了代码复杂度和出错概率。先用最简单的方案把功能做对压测发现锁竞争确实是瓶颈了再考虑优化。3. 线程间通信等待与唤醒的正确姿势3.1 wait/notify机制的本质多个线程操作共享资源时经常需要协调生产者发现队列满了要等消费者消费消费者发现队列空了要等生产者生产。这种协调就是线程间通信。Java的Object类提供了wait()、notify()、notifyAll()三个方法来实现这种协调。它们必须在synchronized块中调用否则会抛IllegalMonitorStateException。public class BlockingQueueT { private final QueueT queue new LinkedList(); private final int capacity; public BlockingQueue(int capacity) { this.capacity capacity; } public synchronized void put(T item) throws InterruptedException { while (queue.size() capacity) { wait(); // 队列满释放锁并等待 } queue.offer(item); notifyAll(); // 唤醒等待的消费者 } public synchronized T take() throws InterruptedException { while (queue.isEmpty()) { wait(); // 队列空释放锁并等待 } T item queue.poll(); notifyAll(); // 唤醒等待的生产者 return item; } }这段代码有几个关键点值得展开。第一wait()会释放锁这是它和sleep()最本质的区别。sleep()只是让线程暂停不释放任何锁。第二wait()被唤醒后需要重新竞争锁拿到锁之后从wait()下一行继续执行。第三判断条件必须用while而不是if因为线程被唤醒后条件可能又变了需要重新检查。3.2 为什么用while而不是if这是面试高频题也是实际写代码最容易出错的地方。假设用if判断if (queue.isEmpty()) { wait(); } // 被唤醒后直接往下执行 T item queue.poll();问题在于线程被notifyAll唤醒后它需要重新获取锁。在它拿到锁之前可能有另一个消费者先拿到锁并把唯一的元素取走了。等它拿到锁继续执行时队列又空了poll()返回null程序出错。用while的话被唤醒后会重新检查条件发现队列还是空的就继续wait不会出错。注意notify()只唤醒一个等待线程notifyAll()唤醒所有。大多数情况下应该用notifyAll()因为notify()可能唤醒一个不满足条件的线程导致信号丢失。只有在确定只有一个等待线程且条件明确时才用notify()。3.3 Condition更精细的等待通知ReentrantLock配合Condition可以实现更精细的控制。一个锁可以创建多个Condition不同条件上等待的线程互不干扰。private final ReentrantLock lock new ReentrantLock(); private final Condition notFull lock.newCondition(); private final Condition notEmpty lock.newCondition(); public void put(T item) throws InterruptedException { lock.lock(); try { while (queue.size() capacity) { 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(); } }这样生产者只在notFull上等待消费者只在notEmpty上等待生产者唤醒的是消费者消费者唤醒的是生产者避免了notifyAll唤醒所有线程带来的无效竞争。3.4 管道通信与BlockingQueue除了wait/notifyJava还提供了管道流PipedInputStream/PipedOutputStream用于线程间字节流通信以及java.util.concurrent包下的BlockingQueue系列。实际开发中BlockingQueue是最常用的线程间通信方式它内部已经封装好了等待通知逻辑用起来非常省心。BlockingQueueTask queue new LinkedBlockingQueue(100); // 生产者线程 queue.put(task); // 队列满时自动阻塞 // 消费者线程 Task task queue.take(); // 队列空时自动阻塞ArrayBlockingQueue基于数组实现容量固定支持公平锁LinkedBlockingQueue基于链表默认容量是Integer.MAX_VALUE吞吐量通常更高SynchronousQueue不存储元素每个put必须等待一个take适合传递性场景PriorityBlockingQueue支持优先级排序。选哪个取决于你的具体需求。4. 内存可见性看不见的并发陷阱4.1 一个永远停不下来的循环先看一段代码public class VisibilityDemo { private static boolean running true; public static void main(String[] args) throws InterruptedException { new Thread(() - { while (running) { // do something } System.out.println(线程结束); }).start(); Thread.sleep(1000); running false; System.out.println(已设置running为false); } }直觉上主线程把running设为false后子线程的循环应该退出。但实际运行中子线程可能永远看不到这个变化循环一直执行下去。原因在于Java内存模型JMM允许线程把共享变量缓存到自己的工作内存中子线程一直读的是自己工作内存里的副本主线程的修改对它不可见。4.2 JMM与happens-before原则JMM定义了线程和主内存之间的抽象关系每个线程有自己的工作内存共享变量存储在主内存中。线程对变量的操作必须先在主内存中读取到工作内存修改后再写回主内存。不同线程之间无法直接访问对方的工作内存必须通过主内存传递。happens-before是JMM的核心规则它定义了哪些操作的结果对后续操作可见。几条关键规则程序顺序规则一个线程内前面的操作happens-before后面的操作监视器锁规则解锁操作happens-before后续对同一锁的加锁操作volatile变量规则对volatile变量的写happens-before后续对该变量的读传递性A happens-before BB happens-before C则A happens-before C4.3 volatile轻量级的可见性保证给running加上volatile修饰循环就能正常退出了private static volatile boolean running true;volatile保证了两件事第一写volatile变量时会立即刷新到主内存第二读volatile变量时会从主内存读取最新值。这样就保证了可见性。但volatile不保证原子性。volatile int count; count;这样的操作依然有线程安全问题因为自增不是原子操作。volatile适合用在状态标志位、一次性安全发布等场景。实操心得很多并发bug的根源就是可见性问题但它的表现往往很隐蔽——在开发机上跑没问题一到生产环境多核服务器就出问题。因为单核CPU下线程切换时缓存会同步多核下各核缓存独立可见性问题才暴露出来。所以并发代码一定要在多核环境测试。4.4 final与安全发布final字段也有特殊的可见性保证。被final修饰的字段在构造函数中初始化完成后其他线程就能看到正确的值不需要额外同步。这被称为安全发布。public class SafeObject { private final int value; private final String name; public SafeObject(int value, String name) { this.value value; this.name name; } }只要对象在构造过程中没有让this引用逸出其他线程拿到这个对象引用时就能看到final字段的正确值。这也是为什么推荐尽量把字段声明为final的原因之一。5. CAS与无锁编程乐观并发的艺术5.1 CAS的基本原理CAS全称Compare-And-Swap翻译过来就是“比较并交换”。它包含三个操作数内存位置V、预期旧值A、新值B。当且仅当V的当前值等于A时才把V更新为B否则不做任何操作。整个比较和交换是一个原子操作由CPU指令直接支持。Java中通过Unsafe类提供CAS能力但普通开发者一般用java.util.concurrent.atomic包下的原子类。AtomicInteger count new AtomicInteger(0); count.incrementAndGet(); // 原子自增incrementAndGet的底层就是一个CAS循环先读取当前值加一然后CAS尝试写回。如果失败说明有其他线程改了就重新读取再试直到成功。5.2 ABA问题与解决方案CAS有一个经典问题叫ABA线程1读取到值A准备改成C此时线程2把值从A改成B又改回A线程1的CAS发现值还是A就成功改成了C。但中间状态B被忽略了某些场景下这会导致问题。解决方案是加版本号每次修改版本号递增。Java提供了AtomicStampedReferenceAtomicStampedReferenceInteger ref new AtomicStampedReference(100, 0); int stamp ref.getStamp(); ref.compareAndSet(100, 200, stamp, stamp 1);只有当引用和版本号都匹配时才更新避免了ABA问题。5.3 LongAdder高并发计数的终极方案AtomicLong在并发极高时所有线程都CAS同一个变量失败重试的次数会急剧增加性能下降。LongAdder的思路是分散热点内部维护一个Cell数组不同线程更新不同的Cell最后求和时把所有Cell的值加起来。LongAdder adder new LongAdder(); adder.increment(); long sum adder.sum();在写多读少的计数场景下LongAdder的吞吐量可以比AtomicLong高一个数量级。代价是sum()方法需要遍历所有Cell不是常量时间。所以如果读操作也很频繁AtomicLong可能更合适。5.4 CAS的适用场景与局限CAS适合竞争不太激烈、操作简单的场景。它的优点是无需加锁不会引起线程阻塞和上下文切换。缺点是竞争激烈时自旋次数多CPU开销大只能保证单个变量的原子性多个变量需要额外处理ABA问题需要版本号解决代码复杂度比直接加锁高我的经验是能用原子类解决的简单计数、状态标记优先用原子类涉及多个变量需要保持一致性的复合操作老老实实用锁。不要为了追求“无锁”而把代码写得晦涩难懂。6. 线程状态与生命周期看清线程的每一步6.1 六种线程状态Java中Thread.State枚举定义了六种线程状态状态含义进入方式NEW新建new Thread()后未调用start()RUNNABLE可运行调用start()后包括正在运行和就绪等待CPUBLOCKED阻塞等待获取synchronized锁WAITING无限等待调用wait()、join()、park()TIMED_WAITING限时等待调用sleep(ms)、wait(ms)、join(ms)TERMINATED终止run()方法执行完毕或异常退出注意RUNNABLE包含了操作系统层面的就绪和运行两种状态因为JVM不区分这两种都交给操作系统调度。6.2 状态转换的实际场景理解状态转换对排查问题很有帮助。比如你发现线程dump里大量线程处于BLOCKED状态说明锁竞争严重需要检查是不是有线程持有锁时间过长。如果大量线程处于WAITING状态可能是wait()后没有被正确唤醒或者线程池任务提交后没有执行。Thread t new Thread(() - { synchronized (lock) { try { lock.wait(); // WAITING } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }); t.start(); // 此时t的状态是WAITING6.3 如何优雅地停止线程Thread.stop()已经被废弃因为它会强制释放锁导致数据不一致。正确的做法是使用中断机制public class StoppableTask implements Runnable { private volatile boolean running true; public void shutdown() { running false; } Override public void run() { while (running !Thread.currentThread().isInterrupted()) { try { // 阻塞操作响应中断 Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 恢复中断标志 break; } // 执行任务 } } }这里有两个关键点第一用volatile标志位配合中断检查双重保险第二捕获InterruptedException后要恢复中断标志让上层调用者知道发生了中断。6.4 线程dump的实战分析线上环境排查并发问题线程dump是最重要的工具之一。通过jstack pid或者kill -3 pid可以导出所有线程的堆栈信息。拿到dump后重点看几个东西有没有死锁会明确提示Found one Java-level deadlock哪些线程处于BLOCKED且等待同一个锁线程池的线程是不是都在WAITING且任务队列堆积。我遇到过一次线上接口超时dump出来发现所有业务线程都BLOCKED在一个数据库连接池的锁上原因是连接池配置太小大量线程排队等连接。这种问题看dump一眼就能定位。7. 常见问题与排查技巧实录7.1 并发问题速查表现象可能原因排查方向计数结果偏小竞态条件自增非原子检查是否加锁或用原子类循环不退出内存可见性问题检查标志位是否volatile线程卡死不动死锁或锁顺序不一致jstack看BLOCKED和死锁提示数据读到旧值缺少happens-before保证检查同步措施是否覆盖读写性能急剧下降锁竞争激烈或CAS自旋过多考虑分段锁、LongAdder或减小锁粒度wait后不醒信号丢失或notify用错检查是否用while、是否notifyAll7.2 死锁的四个必要条件与预防死锁需要同时满足四个条件互斥、持有并等待、不可剥夺、循环等待。破坏其中任意一个就能避免死锁。最常用的是破坏循环等待给所有锁定义全局顺序所有线程按相同顺序获取锁。// 错误示范不同线程以不同顺序获取锁 // 线程1: lockA - lockB // 线程2: lockB - lockA // 正确做法统一按id顺序获取 if (lockA.getId() lockB.getId()) { lockA.lock(); lockB.lock(); } else { lockB.lock(); lockA.lock(); }7.3 伪共享一个容易被忽视的性能杀手CPU缓存以缓存行为单位通常是64字节。如果两个volatile变量位于同一个缓存行一个线程修改变量A会导致另一个线程的变量B缓存失效这就是伪共享。解决方案是用填充字段把变量隔开public class PaddedAtomicLong { public volatile long value; public long p1, p2, p3, p4, p5, p6, p7; // 填充 }JDK 8之后可以用sun.misc.Contended注解但需要开启JVM参数-XX:-RestrictContended。实际业务开发中很少需要手动处理伪共享但了解这个概念有助于理解为什么某些高性能框架要这么设计。7.4 并发测试的正确姿势并发bug很难复现因为涉及线程调度顺序。写并发测试时不要只跑一两次就下结论。可以用CountDownLatch让所有线程同时开始增加竞争概率int threadCount 100; CountDownLatch startGate new CountDownLatch(1); CountDownLatch endGate new CountDownLatch(threadCount); for (int i 0; i threadCount; i) { new Thread(() - { try { startGate.await(); // 所有线程在此等待 // 执行并发操作 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { endGate.countDown(); } }).start(); } startGate.countDown(); // 同时放行 endGate.await(); // 等待全部完成另外可以借助一些压力测试工具反复跑或者用Thread.sleep()在关键位置插入随机延迟来打乱执行顺序提高bug复现概率。7.5 我踩过的几个坑第一个坑是过度同步。早期写代码时为了“保险”把整个方法都加上synchronized结果性能极差。后来学会只锁必要的代码块把不需要同步的操作移出同步区域。第二个坑是锁对象选错。用字符串常量或者Integer做锁对象因为常量池和缓存的原因不同地方的锁可能意外地是同一个对象导致无关的线程互相阻塞。正确做法是用专门的Object实例或者类对象做锁。第三个坑是忽略了线程池的队列容量。用Executors.newFixedThreadPool创建线程池时任务队列是无界的任务堆积过多会导致内存溢出。后来都改成手动创建ThreadPoolExecutor指定有界队列和拒绝策略。第四个坑是在synchronized块中调用外部方法。外部方法可能获取其他锁或者执行耗时操作导致锁持有时间过长甚至死锁。原则是同步块里只做必要的共享变量操作不要调用不可控的外部代码。8. 从理论到落地构建线程安全代码的检查清单写并发代码时我习惯按这个清单过一遍共享变量是否都用private修饰通过方法暴露访问复合操作检查再执行、读改写是否保证了原子性状态标志位是否用了volatile锁的粒度是否足够小同步块里有没有耗时操作多个锁的获取顺序是否全局一致是否优先使用了java.util.concurrent包下的现成工具线程池是否配置了合理的队列容量和拒绝策略是否考虑了中断响应和优雅停止这套清单不能保证百分之百不出问题但能覆盖大部分常见陷阱。并发编程的难点不在于记住API而在于理解背后的内存模型和调度机制。多写多调试遇到问题用工具去分析而不是靠猜慢慢就能建立起对并发代码的直觉。