ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java并发编程实战:从synchronized到线程池,核心原理与排查方法一次讲透

Java并发编程实战:从synchronized到线程池,核心原理与排查方法一次讲透 “你这 synchronized 用的没问题但要是让你说说 ConcurrentHashMap 在 JDK 1.7 和 1.8 之间到底改了啥或者 volatile 能不能保证原子性十有八九会卡壳。”这是我带新人时最喜欢抛出去的问题。Java 线程并发安全几乎是所有后端面试的必考题也是线上故障的重灾区。很多人背了一堆八股文一碰到真实场景还是不知道锁该加在哪线程池参数怎么调甚至分不清 ConcurrentHashMap 的 size() 在并发下到底准不准。这篇文章不打算按教科书顺序讲我以这些年实际写并发代码、排查线上问题踩过的坑为线索把常见并发安全问题的核心原理、工具选型、实操配置和排查方法一次说透。适合准备 Java 面试的人也适合写业务代码时被并发问题折磨的开发者从原理到落地尽量讲人话。1. 并发安全问题的根源共享可变状态才是万恶之源1.1 原子性、可见性、有序性是怎么被破坏的咱们先别急着背“原子性、可见性、有序性”这三个词我换个说法。并发环境里多个线程同时操作同一份数据本质上就是在同一张纸上写写画画。如果A线程写了一半B线程过来读了读到的是个残缺的数据这就叫原子性被破坏了。Java里 i 这条语句看源码是“读-改-写”三步不是一步到位的原子操作两个线程同时执行最后的结果往往比预期小。再比如可见性问题。每个线程都有自己的工作内存它改了变量可能还没来得及刷回主内存其他线程读到的还是旧值。这个不是玄学是 CPU 缓存和 Java 内存模型JMM共同造成的。你写了一个 boolean 标记线程A把它改成 true线程B的循环却一直停不下来多半就是可见性问题。有序性更容易被忽略。编译器和 CPU 为了效率会重排序指令单线程下没问题多线程下就可能出幺蛾子。经典案例是单例模式里的双重检查锁如果没有 volatile对象初始化的指令重排会让另一个线程拿到半初始化的实例。这三个问题统一指向一个核心矛盾多个线程共享了可变状态却没有一套可靠的协同机制。解决并发安全问题的核心思路归根结底就两条路要么不共享要么共享时做好同步。1.2 为什么“加锁”不是万能的很多人一听并发安全就想到加锁这没错但锁不是银弹。加锁的本质是通过“互斥”让多个线程串行访问临界区但它带来的副作用很直接性能损耗和死锁风险。我见过不少业务代码为了“保险”把整个方法都加上 synchronized结果并发能力直线下降。比如一个购物车接口里面只有最后一步操作共享数据结果全程加锁把所有读操作也挡在外面了。锁的粒度太粗性能必然受损。还有人在一个方法里先锁 this再锁另一个对象两个线程交叉持有锁互相等待死锁就来了。死锁的课程里讲过四个必要条件互斥、持有并等待、不可剥夺、循环等待。实际排查的时候这四个条件就能帮你定位问题。加锁的思路应该更精细优先考虑缩小临界区、无锁方案比如 ThreadLocal 让每个线程持有自己的副本、读写锁分离、以及后面会讲到的并发容器。锁是最后的兜底方案不是第一选择。2. 核心细节解析synchronized、volatile 和 Lock 到底怎么选2.1 synchronized 的底层原理与锁升级过程synchronized 是 Java 里最基础的同步手段。很多人只背了“它是悲观锁”但面试官真正想听的是它的底层实现。早期 JDK 的 synchronized 是重量级锁线程阻塞和唤醒要经过操作系统性能很差。后来 Java 6 做了锁升级的优化锁有四种状态无锁、偏向锁、轻量级锁、重量级锁。偏向锁的意思是同一个线程反复持有这把锁就不再需要 CAS 操作了直接在对象头里记录线程 ID。轻量级锁则是用 CAS 自旋等待适合锁竞争不激烈的场景。如果自旋超过一定次数或者线程数过多就膨胀成重量级锁线程进入阻塞状态。说到对象头这里有个细节值得琢磨对象头里有一个 Mark Word存储了 hashcode、GC 分代年龄、锁状态等信息锁升级的过程就在 Mark Word 里做标记。这也是为什么 synchronized 是“可重入”的——同一个线程再次进入时锁计数器会累加退出时递减直到归零才真正释放。实际项目里synchronized 用起来比 Lock 简单不用手动释放锁方法异常时也会自动释放。所以能用 synchronized 解决的场景尽量不要引入 Lock减少出错的可能。2.2 volatile 的适用边界保证可见性但不保证原子性volatile 是并发安全讨论里最容易被人误解的关键字。它的作用有两个保证变量修改后对其他线程可见以及禁止指令重排序。但它不保证原子性也就是说i 这种“读-改-写”操作用 volatile 修饰也没用两个线程同时执行还是会丢更新。我把 volatile 比喻成“黑板通知”——一个线程改了值其他线程立刻能看到但“改”这个过程本身不能被打断。所以 volatile 适合的场景是一个线程写多个线程读的状态标记。比如常用的 shutdownFlag用它控制线程优雅退出非常好用。再比如单例模式的双重检查锁里instance 用 volatile 修饰就是为了防止指令重排序导致拿到未初始化完成的对象。那什么情况下不能用 volatile只要涉及到依赖当前值做计算的操作比如计数器累加、余额扣减都不行。这些场景要么加锁要么用 AtomicInteger。2.3 Lock 接口与 ReentrantLock 的关键特性ReentrantLock 对比 synchronized有几个独有的优势可以中断等待锁的线程可以设置超时时间可以声明公平锁还可以配合 Condition 实现精确唤醒。这些在复杂场景里很有用。最常用的地方是 tryLock。比如扣库存场景如果加锁超过一定时间直接放弃避免线程无限期等下去。这在接口性能优化里很常见毕竟高并发下等待锁本身就是在消耗资源。公平锁和非公平锁的区别也要说清楚非公平锁是等锁的线程先来的不一定先获得可能出现“插队”优点是吞吐量高公平锁严格按照请求顺序来但性能相对低。ReentrantLock 默认是非公平锁如果你的业务里每个线程都等了同样长的队列非要公平反而只能把吞吐量压下来。真实线上绝大多数场景非公平锁就够了。还有个冷门但重要的点Lock 必须在 finally 里 unlock。一旦代码中途抛异常锁没释放轻则死锁重则导致整个系统不可用。这一点写代码时要像条件反射一样记住。3. 并发工具类与线程池配置实操3.1 ConcurrentHashMap从分段锁到 CAS synchronizedConcurrentHashMap 是并发容器里最常问的类。JDK 1.7 版本用分段锁Segment实现结构是多个 Segment 组成每个 Segment 是一把独立的 ReentrantLock锁粒度是 Segment 级别。JDK 1.8 换成了更细的锁粒度取消 Segment直接用 Node 数组 CAS synchronized 实现。锁的粒度降到单个桶也就是只有哈希冲突激烈时才对某个桶加锁。还有个细节值得面试和实战都注意ConcurrentHashMap 的 size() 和 isEmpty() 不是实时准确的。在并发中它是一个估计值是用 baseCount CounterCell 数组累加计算出来的。很多初学者以为它是精确的结果做统计功能时被坑了。另外为什么 ConcurrentHashMap 不允许 key 和 value 为 null而 HashMap 允许这一点大家记结论很多但理解不深。源头在于并发的歧义如果用 get(key) 返回 null你没法区分是 key 不存在还是 value 为 null在多线程场景下语义不清晰所以作者直接帮你把 null 禁掉了。3.2 AQS看懂 AbstractQueuedSynchronizer 的加锁逻辑说到并发工具类AQS 是避不开的。ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock 都基于它实现。AQS 的核心是维护一个 int 类型的 state 状态值以及一个 FIFO 双向队列。线程获取不到资源时会被包装成 Node 入队等待资源释放时会唤醒队列头部的后继节点。我给新人讲 AQS 时喜欢用“银行取号排队”来类比state 是号码资源队列是等待区每个线程拿不到号就去排队前面的号处理完了后面的线程被叫号。公平锁就是完全按队列顺序来非公平锁呢就是新来的线程忍不住插队试一把没抢到再老实排队。不过 AQS 框架本身是抽象类自定义同步器只需要重写 tryAcquire 和 tryRelease 等方法定义 state 的获取和释放逻辑。比如写一个简单的自定义锁state1 表示未被占用tryAcquire 里用 CAS 把 1 改成 0成功就拿到锁。看起来简单但理解了这套骨架后面看 CountDownLatch 的实现就很轻松了。3.3 线程池参数核心线程数、最大线程数、队列长度怎么配ThreadPoolExecutor 是日常开发使用频率最高的并发工具也是面试八股文的重灾区。七个参数要记清楚核心线程数、最大线程数、空闲存活时间、时间单位、阻塞队列、线程工厂、拒绝策略。我最常被问的问题就是核心线程数到底怎么定。这个问题没有标准答案跟任务类型强相关。CPU 密集型任务一般按 CPU 核数 1 设置因为线程跑到 CPU 上限了再加线程也没用IO 密集型任务线程大多在等网络或磁盘可以适当放大常见的经验公式是 CPU 核数 * 2 1或者结合 IO 等待比例做更细的估算线程数 CPU 核数 / (1 - 阻塞系数)。如果系统是混合型任务就按更细的维度拆分给不同业务用不同的线程池。队列选择的坑比参数更隐蔽。LinkedBlockingQueue 不设上限的话可能积压海量任务内存被拖垮而且线程数永远升不到最大线程数。SynchronousQueue 则完全不缓冲直接“手递手”把任务交给新线程。实际项目里我最常用的是 ArrayBlockingQueue 有界容量并且配好拒绝策略。拒绝策略有四种AbortPolicy直接抛异常、CallerRunsPolicy调用者自己执行、DiscardPolicy静默丢弃、DiscardOldestPolicy丢最老的。业务上要小心静默丢弃线上丢任务是最难排查的问题之一宁可抛异常 alert 出来。3.4 线程池的优雅关闭与虚拟线程的补充线程池的关闭也是个细节问题。shutdown() 和 shutdownNow() 的区别前者等所有已提交任务执行完后者立即打断正在执行的任务并返回未执行的任务列表。正确做法是先用 shutdown()再 awaitTermination() 等待一段时间额外处理后继续等待强制 shutdownNow()这样能最大程度保证任务不丢失。最近 Java 21 Spring Boot 3.5 出现了一个新趋势虚拟线程。虚拟线程由 JVM 调度不映射到操作系统线程创建成本极低适合大量 IO 阻塞场景。在某些业务里直接把 Tomcat 的 executor 换成虚拟线程后吞吐量提升非常明显。但要注意虚拟线程不是万能的不能随意替代 CPU 密集型任务的配置方式而且使用时要留意线程池的定位方式——很多依赖线程上下文变量的代码在虚拟线程下行为会变。4. 常见并发问题排查与避坑实录4.1 死锁、活锁、线程饥饿的识别与处理死锁好识别jstack 一抓就能看到线程状态是 BLOCKED 且互相等待。真正的难点在于活锁和线程饥饿。活锁是线程一直在运行但在反复重试同一个操作始终拿不到资源看起来像死锁但线程状态是 RUNNABLE。线程饥饿则是低优先级线程长期得不到 CPU 调度典型的错误案例是 ReentrantLock 的公平锁配不准导致某些线程等不到锁。我处理过一次线上事故两个服务互相调用A 服务持有数据库连接等待 B 服务响应B 服务又等待 A 服务释放连接jstack 一查全是 BLOCKED。排查结论就是 A、B 之间出现的跨资源死锁。解决办法是统一加锁顺序所有并发操作按同一个全局顺序获取锁破坏循环等待。另外排查死锁时千万别只看线程 dump要结合堆内存、GC 日志一起看。因为很多时候看起来像是死锁其实是某个线程被某个特别久的外部调用卡住了堆栈信息具有迷惑性只有深挖才能定位真实根因。4.2 从 jstack、VisualVM 到 arthas线上并发问题排查工具箱排查并发问题我有一套固定的工具组合。先说 jstack它能导出线程 dump看线程状态是 RUNNABLE、BLOCKED 还是 WAITING。遇到死锁jstack 末尾会直接标示出 Found one Java-level deadlock就很好定位。VisualVM 和 JConsole 适合看线程使用率和锁等待的宏观趋势。arthas 这种阿里开源的线上诊断工具在无法停服的情况下非常好用能实时查看线程栈、反编译类、甚至直接调用某个方法。我在排查一个线程池任务堆积问题时就是用 arthas 的 thread 命令查出每个线程在跑什么定位到一个数据库查询慢导致线程全被占住业务请求全部排队。还有一个冷门但实用的命令jcmdJDK 自带的诊断工具比 jstack 更轻量适合在 docker 容器里用脚本快速抓取线程信息。排查并发故障的首要原则是先抓现场线程 dump、GC 日志再分析代码最后动手改。4.3 并发安全相关的代码设计规范这一小节是实操经验总结我会在带团队时把几条规范写进落地清单里。第一条尽量避免修改共享状态。能用不可变对象就用不可变对象比如用 final 修饰字段避免暴露 setter。能用局部变量解决就不用实例变量这是最简单也最容易被忽视的一点。第二条锁的粒度要“宽思窄用”。想清楚哪些数据必须同一把锁保护再决定锁的范围。不要图省事直接把整个方法锁住那一锁就是整片业务的性能瓶颈。第三条线程安全的容器和工具类要选对。多线程往 ArrayList 里 put 是不安全的要么用 CopyOnWriteArrayList要么用 Collections.synchronizedList。再比如 StringBuilder 在多线程拼字符串时要用 StringBuffer 或手动加锁很多人写了一个多线程日志组件悄悄用了 StringBuilder结果日志内容偶尔串行错乱查了半天就是这里的问题。第四条线程池必须监控。我在项目里给线程池包装了一层定期抓取 activeCount、queueSize、completedTaskCount 等指标写入监控系统。一旦队列积压或拒绝任务的数量突然上涨监控立刻报警。并发问题不是写完代码就结束了线上运行期的指标能帮你提前发现风险。4.4 问题速查表与判断标准把最典型的并发安全场景做成一个速查表供大家拿到问题先对照一遍。现象可能原因排查手段处理建议循环读不到最新值可见性问题看变量是否加 volatile加 volatile 或加锁计数器结果偏小原子性问题检查 i 是否被并发操作用 AtomicInteger 或加锁死锁循环等待资源jstack 查 BLOCKED 状态统一加锁顺序用 tryLock线程池任务堆积队列过深监控 queueSize调队列容量加拒绝策略报警内存溢出无界队列看堆内存占用换有界队列单例对象状态异常指令重排看 volatile 是否缺失加 volatile日志内容串行错乱多线程共用 StringBuilder排查核心代码对共享对象的使用换线程安全类4.5 个人实测建议并发代码的测试与压测方法并发代码单写单元测试很难暴露问题很多 bug 得靠并发压测才能逼出来。前面讲过 jstack 线程状态要想测试真的有效建议做这么几件事。先写一个并发测试类用一个固定大小的线程池模拟几百个线程同时执行同一个方法把执行结果和预期值做对比。这个固定的线程池规模建议等于 CPU 核数的两倍到三倍太小跑不出临界区竞争太大测试本身也在干扰结果。再配合压测工具比如 JMH 可以做微基准测试看某个加锁方案的吞吐量、延迟分布。普通业务接口压测可以用 wrk 或者 Gatling直接在压测环境里观察各种指标。我自己踩过的坑是测试时线程太少结果大规模并发才发现问题。还有一次是并发测试没有搭配唯一 ID 标记导致问题无法复现后面花了半天才定位到原因。并发问题的复现往往依赖时间窗口和负载模式建议在测试环境里刻意加慢一些 IO 操作增大冲突概率这样更容易把问题暴露出来。5. 额外补充从 AQS 到并发编程设计思想5.1 AQS 的 CLH 队列如何解决公平与唤醒问题说 AQS 就必须把 CLH 队列说清楚。CLH 是一种基于链表的自旋锁队列每个节点保存前驱节点引用线程通过检查前驱状态判断自己是否可以获得锁。AQS 改进了它虽然节点会阻塞但本质思路一样新来的线程先构建一个 Node 节点追加到队尾然后自旋或休眠等待前驱节点释放。这个队列设计最巧妙的地方在于它天然解决了“公平排队的唤醒顺序”问题。头节点释放锁后只会唤醒后继节点不会像 synchronized 那样所有线程一窝蜂竞争给了队列靠前的线程更高优先级。这也是公平锁 ReentrantLock(true) 实现里新线程不会插队的原因——如果队列非空新线程只能乖乖入队。从 CLH 队列延伸出来的一个面试题就是AQS 为什么用双向队列而不用单向队列因为线程被中断或超时后需要从队列中移除双向链表才能高效地找到前驱和后继单向链表做这个操作效率低得多。5.2 并发设计思想的取舍乐观锁、悲观锁与无锁方案锁的分类不仅要背概念更重要的是在编码时选择得当。悲观锁synchronized、ReentrantLock前提假设冲突严重直接先锁资源再操作乐观锁CAS假设冲突少先尝试操作冲突了再重试。CAS 实现里最典型的是 AtomicInteger 的 compareAndSet它直接调用 UnSafe 的 compareAndSwapInt利用 CPU 原语实现。CAS 有个经典问题就是 ABA。也就是一个线程把变量从 A 改到 B 再改回 A另一个线程的 CAS 会认为没变过于是操作成功但场景中可能已经被“动过手脚”。解决 ABA 的办法是使用 AtomicStampedReference给变量再加一个版本号来区分。底层原理并不复杂但如果你在处理类似资源回收复用的逻辑时没关注 ABA很容易埋雷。实际开发中我总结了一套简单的选型思路读多写少的场景用 CopyOnWriteArrayList 或 ReadWriteLock写多读少且冲突不高的优先用 ConcurrentHashMap 和原子类冲突比较厉害的直接上锁并发高到一定程度反而要考虑削峰和限流而不是把宝全押在锁上——因为锁本身是串行化的并发再大也只是排队。6. 写在最后的经验并发安全问题是一个没有终点的修炼。我见过有人背了一整本八股文但接手线上故障时依然手忙脚乱也见过有人能把 JMM、AQS 原理讲得头头是道但在代码评审时看不出三个线程竞争同一个 List 的隐患。说到底并发编程考验的不只是记忆而是对共享状态、锁粒度和调度机制的直觉。根据我个人多年的排障经验最值得花时间的地方不是把锁的 API 背熟而是养成三个习惯一是写代码前先画清楚哪些数据是共享的变化频率有多高二是写完并发代码必须做压测不能只跑通功能三是线上问题要用 jstack 加监控数据互相印证别凭猜。这三点真正帮我避免了很多生产事故也让团队里的新人少走了不少弯路。这个主题如果你愿意往深了挖后面的并发设计模式、响应式编程模型、以及 Java 21 虚拟线程对传统线程池模型的冲击都是很有趣的扩展方向。下次有机会可以专门把 Java 虚拟线程的实际压测数据拿出来聊聊那个话题的实战颗粒度更适合拿出数据说话。
返回列表