尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Java线程安全深度解析:从三大问题到锁与并发容器

Java线程安全深度解析:从三大问题到锁与并发容器 1. 线程安全的本质并发三大问题的底层拆解先说一个我印象特别深的故障。几年前线上有个库存扣减接口高峰期偶尔会超卖最诡异的是压测环境复现不出来只有凌晨大促流量冲上来才偶尔冒一次。后来加了日志一查发现是多个线程同时读到了同一个库存值各自扣减后写回把别的线程的更新覆盖掉了。那种明明代码看起来没毛病数据就是会乱的憋屈感做过并发的人应该都懂。这个问题的根子就是线程安全。所谓线程安全说白了就是一段代码被多个线程同时执行不管线程怎么交错结果都和单线程执行一致。要搞懂这一点必须先把并发编程的三大问题拆开看——原子性、可见性、有序性。这三位才是线程安全背后的幕后黑手。1.1 原子性一行i其实藏了三个步骤很多人写代码的时候默认一行代码是原子操作这是个巨大的误区。拿Java里的i来说JVM执行它其实拆成了三步读取i的当前值、把值加1、把新值写回i。三步之间任何一步都可能被线程调度打断。举个最经典的例子两个线程同时执行i理论上i应该从0变成2但实际上可能两个线程同时读到0各自加1再同时写回最后i还是1。这就是典型的丢了更新。Java里保证原子性的手段主要有四种手段适用场景说明synchronized块/方法需要保护一段逻辑进入同步块必须拿到锁拿不到就阻塞ReentrantLock需要更灵活的锁控制支持中断、超时、公平性选择AtomicInteger等原子类单变量计数、累加基于CAS无锁volatile单变量读写注意只能保证单个读/写的原子性不保证复合操作有一点得说清楚volatile只能保证单个变量、单个步骤的原子性i这种复合操作它是管不了的。你要是用volatile int i然后多线程i照样丢更新。很多人栽在这个坑里。1.2 可见性一个线程改了值另一个线程看得见吗第二个问题是可见性。每个线程在执行的时候会把主内存中的数据复制到自己的工作内存寄存器和缓存里操作操作完再刷回主内存。这个刷回的时机是不确定的。这意味着什么线程A改了某个变量的值但还没来得及刷回主内存线程B去读这个变量读到的是自己工作内存里的旧副本。** A的修改对B不可见**。这个现象在单核CPU上不明显多核服务器上简直不要太常见。有个经典问题能帮你判断自己是否理解了可见性一个boolean标志位控制线程是否继续跑主线程把它置为false子线程却一直死循环停不下来。原因就是子线程工作内存里的标志位副本一直没更新它压根不知道主线程已经改了值。volatile能解决这个场景因为它的语义就是每次读都强制从主内存读每次写都强制刷回主内存。1.3 有序性JVM偷偷帮你重排了指令第三个问题是有序性。出于优化目的编译器和CPU都可能对指令进行重排序。在单线程里重排序不会影响最终结果因为JMMJava内存模型保证as-if-serial语义——单线程下不管怎么重排结果是一致的。但多线程环境下一个线程的指令重排可能让另一个线程看到未来的操作发生在过去的假象。最典型的案例是双重检查锁DCL单例的陷阱public class Singleton { private static volatile Singleton instance; public static Singleton getInstance() { if (instance null) { // 第一次检查 synchronized (Singleton.class) { if (instance null) { // 第二次检查 instance new Singleton(); // 非原子操作 } } } return instance; } }new Singleton()不是原子操作它分为分配内存、在内存上初始化对象、把引用赋值给instance。如果指令被重排成先赋值引用、再初始化对象另一个线程可能在第一层判断时发现instance ! null直接返回一个还没初始化的半成品对象。volatile在这里的作用就是禁止指令重排。1.4 三大问题之间的逻辑关系原子性、可见性、有序性不是孤立的三件事它们之间有层级关系。如果一段代码在原子性上有保证通常也意味着排除了可见性问题的某些场景但反过来不成立。比如synchronized同时保证了原子性、可见性和有序性——锁的获取与释放天然建立了happens-before关系。volatile只保证可见性和有序性不保证原子性。在实际开发中判断一段代码是否线程安全我习惯按这个顺序查先看有没有共享可变状态再看对状态的复合操作是否原子最后确认跨线程的状态变化是否需要即时可见。三步扫下来问题基本能定位个八九不离十。2. synchronized与Lock互斥同步的两条主线聊完了线程安全的三大本质问题就到了正题怎么解决。互斥是最直白的手段——同一时刻只允许一个线程进入临界区人为消灭竞争。2.1 synchronized的锁升级与性能真相直到JDK 1.5之前synchronized因为开销大被很多人嫌弃。但后来的版本引入了锁升级机制它现在的性能在大多数场景下都已经够用。锁升级路径是无锁 → 偏向锁 → 轻量级锁 → 重量级锁。偏向锁同一个线程反复进入同步块时直接在对象头里记录线程ID之后进入连CAS都不用性能开销极低。轻量级锁一旦有第二个线程来竞争偏向锁撤销并升级为轻量级锁通过自旋CAS获取锁。重量级锁自旋超过阈值或线程数太多就会升级为依赖操作系统互斥量的重量级锁拿不到锁的线程进入阻塞状态。这个设计从根本上优化了锁竞争不激烈时的场景。我在实际项目中观察到很多服务端的同步方法在低并发下锁基本停留在轻量级甚至偏向锁阶段性能完全不是瓶颈。真正拖垮系统的是锁竞争本身带来的线程阻塞和上下文切换而不是synchronized关键字本身。2.2 Lock接口什么时候值得换用ReentrantLock可重入锁相比synchronized多了几个关键能力可中断lockInterruptibly()方法允许线程在被阻塞时响应中断。可超时tryLock(3, TimeUnit.SECONDS)在规定时间内拿不到锁就放弃避免死锁。公平性构造时传true实现公平锁让等待时间最长的线程优先获得锁。多个条件队列newCondition()可以创建多个等待条件实现更加精细的线程协作。举一个我用tryLock解决过的实际问题。有个定时任务和多处业务入口都要更新同一份用户缓存原来用synchronized一旦某个入口处理时间过长其他入口全部卡死。改造后Lock lock cacheLock.get(cacheKey); // 每个key一把锁 if (lock.tryLock(3, TimeUnit.SECONDS)) { try { // 更新缓存逻辑 } finally { lock.unlock(); } } else { // 拿不到锁就走降级直接用旧缓存 }这样最长也就等3秒系统不至于被一个慢任务拖死。Lock和synchronized的选择原则我给个自己的经验99%的互斥需求synchronized就够了只有当你需要超时、中断、公平性或者精细条件控制时才上Lock。2.3 锁的粒度和死锁两个让人头秃的细节锁粒度是一门权衡学问。锁加得太大并发度低所有线程都在门口排队锁加得太小同步逻辑拆得七零八落可能破坏操作的整体原子性还容易引入死锁。我踩过的最典型的死锁是经典两个账户互相转账场景public void transfer(Account from, Account to, int amount) { synchronized (from) { synchronized (to) { from.debit(amount); to.credit(amount); } } }如果线程A执行transfer(a, b, 100)线程B执行transfer(b, a, 50)就是经典的A拿着a的锁等bB拿着b的锁等a两边谁也不放手直接死锁。排查死锁靠jstack看线程栈一查就能看到Waiting for lock互相指向。避免死锁的经验就三条按固定顺序加锁、加锁尽量只覆盖必要的代码段、超时获取锁作为兜底。上面转账的例子正确的做法是统一按accountId排序后再加锁或者用tryLock避免无限等待。这些都是在实际业务里总结出来的血泪教训。3. volatile与Atomic轻量级方案的适用边界synchronized和Lock能解决几乎所有同步问题但加锁这个行为本身有代价。有时候我们只是想安全地读写一个变量为这个上个锁就有点杀鸡用牛刀了。volatile和Atomic系列就是为这类场景设计的轻量级方案但它们的边界比很多新手以为的窄得多。3.1 volatile的正确使用场景volatile的官方语义有两个一是写线程把修改立即刷回主内存二是读线程每次从主内存重新读取。所以它适用于一个线程写、多个线程读的状态标志场景。最标准的用法是控制线程启停public class Worker implements Runnable { private volatile boolean running true; Override public void run() { while (running) { // 任务逻辑 } } public void stop() { running false; } }这个我实际测试过不加volatile时主线程调用stop()后工作线程通常几秒到几分钟内不会退出甚至永不退出加了之后立竿见影。这就是可见性问题的真实表现。但volatile有三个明显的管不了管不了i这种复合操作需要原子性、管不了多个变量之间的约束关系比如两个volatile变量要保持一致性、管不了先检查后执行的竞态例如if (flag) { doSomething(); }两个线程可能同时通过检查。在这些场景下volatile帮不了你老老实实用锁或原子类。3.2 AtomicInteger到底线程安全吗这是最近几年面试八股文里出现频率最高的题之一。答案是保证单个方法级别的原子性但不保证业务逻辑级别的线程安全。AtomicInteger的内部基于CASCompare And Swap它的incrementAndGet()通过CPU级别的指令完成比较并交换这一步是原子的。我拿多个线程同时对同一个AtomicInteger执行10万次自增测试过结果永远是准确的10万说明它确实能避免丢更新问题。但注意它的安全边界是单个方法。如果你把这个保证错误地推广到多个调用的组合上就会出问题。看这个例子AtomicInteger count new AtomicInteger(0); public void unsafeIncrement() { if (count.get() 100) { // 第一步读取 count.incrementAndGet(); // 第二步修改 } }两个线程可能同时通过count.get() 100的判断然后都执行自增。单看每一步都是线程安全的但组合起来就是非线程安全的。AtomicInteger只保证每个方法不被打断不保证方法序列的整体性。需要复合操作安全时还是要用锁或者AtomicInteger的updateAndGet()这类原子组合方法。3.3 CAS的ABA问题一个很少人真正踩过的坑谈到CAS就绕不开ABA问题。简单说线程A读取变量值是X执行期间线程B把它改成Y又改回X最后线程A做CAS比较时发现值还是X以为没人动过就提交了。对很多业务来说ABA无伤大雅但对于依赖版本号、指针引用的场景这就是致命的。解决ABA的标准方案是引入版本号。AtomicStampedReference就是干这个的它把引用和版本号打包在一起进行CAS。我在一个缓存刷新场景用过它多个线程竞争刷新某个配置项原来只判断缓存对象引用是否变化偶尔出现明明已经被更新过一轮旧线程还拿着旧引用去覆盖。换成AtomicStampedReference后每次刷新都带版本号版本对不上就放弃操作问题彻底消失。4. 并发容器与集合线程安全集合类的正确打开方式多线程共享容器是高频场景。很多人习惯用Vector、Hashtable或者用Collections.synchronizedXxx()包装一下但这几样在真实并发场景下都有各自的适用边界不能一把梭。4.1 先搞清楚底层数据结构在并发下的表现ArrayList在多线程并发读写下会抛ConcurrentModificationException更隐蔽的是并发扩容时可能把数组结构写坏表现为偶发的ArrayIndexOutOfBoundsException。HashMap在并发put时可能形成环形链表下次get会死循环——JDK 7时代这个bug让不少人吃过苦头JDK 8改成尾插法后规避了环链但依然会丢数据。所以第一步就是记住普通的ArrayList、HashMap、HashSet都不适合直接放到多线程共享的环境里这不是用法问题是底层数据结构决定的。4.2 CopyOnWriteArrayList读多写少场景的利器CopyOnWriteArrayList的思想是写时复制读操作不加锁直接读数组写操作会先复制一份新数组、在副本上修改然后volatile替换数组引用。所以它的读性能极高无锁无阻塞写性能较差每次写都全量复制。它在读多写少的场景下特别合适比如一个配置项的监听器列表、一个缓存的key集合。我在项目里用它管理事件监听器注册表高频读、低频增删效果很好。需要提醒的是写操作代价大数据量大时不要用CopyOnWriteArrayList。我之前在一个节点列表有上千个元素、每秒频繁增删的场景用它结果写操作频繁触发全量复制GC压力直接上去了。后来换成ConcurrentHashMap.newKeySet()性能立刻正常。4.3 ConcurrentHashMap并发Map的首选ConcurrentHashMap的设计经历了从分段锁到CASsynchronized的演进。它把整个Map按桶进行拆分不同线程操作不同桶时互不阻塞只有操作同一个桶的线程才会竞争锁。实际使用中它几乎完全替代了Hashtable和Collections.synchronizedMap()。需要注意一点ConcurrentHashMap的单个方法put、get等是线程安全的但复合操作同样不安全。经典例子是如果key不存在才放进去ConcurrentHashMapString, Integer map new ConcurrentHashMap(); // 下面这种写法在多线程下仍然可能覆盖 if (!map.containsKey(key)) { map.put(key, value); }正确做法是用它的原子方法putIfAbsent()、computeIfAbsent()。这些方法内部把检查和写入合并在一个原子操作里才能保证复合逻辑安全。实际用过computeIfAbsent做缓存初始化的应该深有体会——一段代码存value的耗时较长时多线程同时进来不加原子方法就会重复初始化好多次加了之后只有一个线程执行初始化逻辑其余的拿到同一个结果。4.4 Collections.synchronizedXxx的坑Collections.synchronizedList()、Collections.synchronizedMap()这些包装类本质是在每个方法上加synchronized锁锁对象是包装类实例。它们能保证单个方法的原子性但迭代遍历时加了锁也不安全必须在外层手动加锁ListString list Collections.synchronizedList(new ArrayList()); synchronized (list) { for (String s : list) { // 手动加锁才能安全的迭代 // ... } }这个细节文档上其实有写但不少人没注意。另外synchronized包装类的锁是单锁级别的并发度远不如ConcurrentHashMap的分桶设计在高竞争场景下性能差距会被放大。我实测过8线程并发写10万次数据Collections.synchronizedMap比ConcurrentHashMap慢了一个数量级。5. 生产者消费者线程协作与通信的经典实战线程安全不只是互斥还有协作。生产者消费者模式是几乎所有多线程系统都会用到的协作骨架它要求生产者和消费者之间能安全地传递数据、优雅地处理缓冲区满/空的条件。5.1 用wait/notify手写时最容易犯的错教学里最常见的做法是用wait和notify配合一个共享队列。这段代码看着简单实际手写着坑极多public class MyQueue { private final LinkedListInteger queue new LinkedList(); private final int capacity; public synchronized void put(int value) throws InterruptedException { while (queue.size() capacity) { wait(); } queue.add(value); notifyAll(); } public synchronized int take() throws InterruptedException { while (queue.isEmpty()) { wait(); } int value queue.removeFirst(); notifyAll(); return value; } }几个容易踩的坑条件判断必须用while而不是if。因为wait被唤醒后条件可能又被其他线程改掉了需要重新检查。写成if就可能在队列满的时候仍然往里放。调用wait时不能丢锁。wait必须在持锁状态下调用它会在等待时释放锁被唤醒后重新抢锁。这就是为什么整个方法需要synchronized。用notify还是notifyAll。单生产者单消费者用notify可能没问题但多者场景下notify只唤醒一个线程有可能唤醒的是同类型线程导致死等统一用notifyAll更稳妥。用完必须notifyAll。没有通知的话另一端的线程可能永远阻塞在wait上。这套写法能帮你彻底理解锁和条件等待的关系但真正放在生产环境我推荐直接用BlockingQueue。5.2 用BlockingQueue三行替代一整套wait/notifyBlockingQueue接口定义了put满则阻塞、take空则阻塞、offer带超时的入队这些语义底层已经处理好了锁和条件等待。最常用的实现是LinkedBlockingQueue链表可指定容量和ArrayBlockingQueue数组容量固定可指定公平性。用LinkedBlockingQueue实现生产者消费者核心代码精炼到不行BlockingQueueInteger queue new LinkedBlockingQueue(10); // 生产者 new Thread(() - { for (int i 0; ; i) { try { queue.put(i); // 队列满时阻塞 TimeUnit.MILLISECONDS.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } }).start(); // 消费者 new Thread(() - { while (true) { try { Integer value queue.take(); // 队列空时阻塞 System.out.println(consumed: value); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } }).start();这段代码的并发安全由LinkedBlockingQueue内部保证不需要额外的synchronized或Lock。我实际做过压测对比手写wait/notify版本和BlockingQueue版本在正确性上没有差别但BlockingQueue的代码量少一半出错概率也低一半。生产环境能直接用的组件就别自己造这是并发编程里的重要经验。5.3 完整示例多个生产者和多个消费者的均衡处理下面给出一个生产级的示例多个生产者、多个消费者共享同一个队列注意中断处理和线程优雅退出public class ProducerConsumerDemo { private static final int CAPACITY 100; private static final BlockingQueueTask queue new LinkedBlockingQueue(CAPACITY); static class Task { final int id; Task(int id) { this.id id; } } static class Producer implements Runnable { private final int producerId; Producer(int id) { this.producerId id; } Override public void run() { int taskId 0; while (!Thread.currentThread().isInterrupted()) { try { queue.put(new Task(producerId * 1000 taskId)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } } static class Consumer implements Runnable { private final int consumerId; Consumer(int id) { this.consumerId id; } Override public void run() { while (!Thread.currentThread().isInterrupted()) { try { Task task queue.take(); process(task); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } } private void process(Task task) { System.out.println(Consumer consumerId processed task.id); } } public static void main(String[] args) throws InterruptedException { int producerCount 3; int consumerCount 2; for (int i 0; i producerCount; i) { new Thread(new Producer(i)).start(); } for (int i 0; i consumerCount; i) { new Thread(new Consumer(i)).start(); } TimeUnit.SECONDS.sleep(5); System.exit(0); // 简化演示生产环境应当用shutdownHook优雅退出 } }要点是put和take都会响应中断所以在catch中恢复中断标志并退出线程这在应用关闭时至关重要能让线程池优雅停机而不是一直卡在阻塞队列上。6. 排查线程安全问题的完整链路从线上告警到定位根因线程安全问题的排查是实战中难度最高的一环。它不是编译期报错而是偶尔出现、复现困难、影响诡异的幽灵问题。下面分享一套我自己实践下来的排查链路希望能从方法论层面帮到你。6.1 第一步用jstack把现场留下来发现线上异常后的第一件事不是改代码而是抓现场。用jstack获取线程快照jstack pid thread_dump_$(date %s).txt这个操作要连续多次执行间隔5~10秒因为单次快照可能恰好错过了问题发生的瞬间。抓完快照后重点看BLOCKED状态的线程它们卡在哪把锁上锁对象是什么谁持有了锁。WAITING状态的线程它们在等什么条件是和业务逻辑相关还是资源池饥饿。RUNNABLE状态且有CPU消耗的线程结合JFRJava Flight Recorder或async-profiler确定热点。线程死锁时jstack输出末尾通常有Found one Java-level deadlock的明确提示。内存溢出或GC问题、线程数量暴涨的问题jstack也能反映出线程总数和各状态的分布情况。6.2 第二步分析业务操作锁定临界区的竞态拿到线程栈后我一般会列一张共享变量-操作点-锁关系的表格帮助分析共享变量写操作位置读操作位置是否有统一锁保护用户缓存MapCacheService.refresh()OrderService.query()无库存CountStockService.deduct()StockService.get()synchronized (this) 但两处锁不同任务状态TaskExecutor.run()TaskAdmin.check()volatile 但复合操作未保护排查的时候这张表能快速暴露写的地方加了锁读的地方没加锁这类最隐蔽的问题。读操作不加锁同样会导致线程安全问题比如读到写了一半的数据这在多线程场景下一样危险。6.3 第三步代码走查习惯和典型症状对照很多时候线程安全问题在代码审查阶段就能发现。我自己的走查习惯碰到下面这些特征会格外警惕静态变量、实例共享字段被多个线程读写特别是集合类和计数器。单例模式配合了有状态的字段。Spring默认单例的Bean里如果你塞了一个HashMap做缓存且没有同步处理高并发下必然出问题。用SimpleDateFormat作为共享字段格式化日期。这个话题老生常谈但依然有人踩SimpleDateFormat非线程安全并发调用format会抛异常或产出脏数据正确做法是用DateTimeFormatter线程安全或ThreadLocal包装。先检查后执行的代码结构比如if (map.containsKey(key)) map.put(...)、if (obj ! null) obj.process()。典型症状也是很明显的信号数据无故丢失或重复、偶发的ClassCastException本质是读到写了一半的引用、线程死循环CPU飙升可能是有环数据结构或可见性问题导致条件永远不满足、接口偶发超时大概率是重量级锁高竞争线程都在排队。6.4 复现与验证并发故障的照妖镜线程安全问题最大的难点是复现。我常用的复现工具是并发测试框架和压测工具但真正好用的其实是一小段多线程循环代码把操作反复执行几万次让竞争发生的概率放大// 复现计数器丢更新问题 int threadCount 8; int perThreadCount 100_000; AtomicInteger total new AtomicInteger(0); ListThread threads new ArrayList(); for (int i 0; i threadCount; i) { threads.add(new Thread(() - { for (int j 0; j perThreadCount; j) { // unsafeCounter.increment(); // 需要复现的不安全方法 total.incrementAndGet(); } })); } threads.forEach(Thread::start); for (Thread t : threads) { t.join(); } System.out.println(expected: threadCount * perThreadCount , actual: total.get());当total的值明显小于期望值时问题就实锤了。修改代码后再用同样的脚本跑一遍确认修复有效这才是完整的验证闭环。这是我认为排查线程安全问题时最重要的习惯每次修复后都要用同一个复现脚本验证而不是凭感觉认为应该没问题了。6.5 我踩过的一个真实案例复盘最后说一个给我教训最深的案例。有一个用户积分变更服务每次变更会先读用户当前积分加上变更值再写回去。服务被两个接口复用一个是用户主动签到一个是管理员手工调整积分。某天业务方反馈积分偶尔对不上账多了或少了。一开始我怀疑是MySQL事务隔离级别问题排查了半天发现数据库逻辑没问题。后来用jstack连续抓了三次线程快照发现两个线程同时处在update user_points set points? where user_id?的执行路径上但前面的原生points操作根本没加锁。真相很简单两个请求同时读到相同旧积分各自计算后写回后写的覆盖了先写的。修复方式也很直接改造前先查一次DB然后把读-加-写整个操作放进synchronized方法或使用AtomicLong字段缓存或者直接在SQL层做set points points ?从数据库层面保证原子性。我最后选了SQL原子更新因为性能最高、代码最简单而且绕开了Java层面的并发问题。这个案例让我明白一件事排查线程安全问题不要一开始就扎进复杂的技术猜测里而是从共享可变状态在哪、哪些操作复合、有没有统一锁这条主线走绝大多数问题都能顺藤摸瓜找到根。
返回列表