写并发编程内容最容易犯的毛病就是“学了一堆锁和容器但遇到线上问题还是抓瞎”。这篇Java并发编程篇二我会把重点放在底层机制的拆解上volatile和JMM到底解决了什么、synchronized锁升级的触发条件、CAS的缺陷、AQS的设计思路以及线程池参数背后那些容易踩坑的细节。内容适合正在准备Java面试、或者在工作中需要排查并发问题的朋友也适合想从“会写”到“懂原理”的Java开发者。1. 从可见性说起volatile与Java内存模型1.1 缓存一致性为什么会造成“幽灵读取”我见过不少同学写过这样的代码主线程修改一个布尔变量子线程循环等待这个变量结果程序永远卡死。原因就是可见性问题。Java内存模型JMM规定每个线程有自己的工作内存线程对共享变量的读写不一定会立刻同步到主内存。当一个线程更新了变量另一个线程可能读到的还是旧值这就出现了“幽灵读取”。并不是Java的bug而是为了性能CPU和编译器在底层做了大量优化。public class VisibilityDemo { private static boolean flag false; public static void main(String[] args) throws InterruptedException { new Thread(() - { while (!flag) { // 理论上空转 } System.out.println(子线程感知到flag变化); }).start(); Thread.sleep(100); flag true; System.out.println(主线程设置flag为true); } }这段代码不加volatile时子线程的循环很可能永远无法退出。因为子线程一直读的是自己工作内存里的旧值。1.2 happens-before规则真正的并发安全逻辑很多人以为volatile只是“强制读主内存”这不够准确。JMM真正依赖的核心是happens-before规则它定义了两个操作之间的可见性顺序。如果一个操作happens-before另一个操作那么前者的结果对后者可见并且前者的执行顺序不会随意重排到后者之后。几条关键规则程序顺序规则同一个线程内前面的操作happens-before后面的操作。监视器锁规则解锁操作happens-before后续的加锁操作。volatile规则volatile变量的写操作happens-before后续对该变量的读操作。传递性如果A happens-before BB happens-before C那么A happens-before C。所以volatile的意义不只是“可见性”它还禁止了编译器对相关操作的指令重排。这也是为什么单例模式的双重检查锁Double-Checked Locking必须依赖volatile——为了避免对象初始化被重排导致的“半初始化对象泄漏”。1.3 volatile实战轻量级状态开关volatile适合修饰“状态标记”类变量比如开关、线程中断标志、缓存初始化标记等。它不适合做复合运算比如count因为可见性解决不了原子性问题。我平时用得最多的场景是一个后台轮询任务需要优雅停掉就用volatile布尔变量做开关。这样既不会引入锁的性能开销又能保证主线程修改后后台线程一定能感知到。注意volatile只能保证单个读/写操作的原子性像i这种“读-改-写”复合操作仍然需要用synchronized或AtomicInteger。2. synchronized深度解析锁升级不是玄学2.1 三种锁形态与升级路径HotSpot虚拟机对synchronized做了一系列优化锁的状态依次是无锁、偏向锁、轻量级锁、重量级锁。理解这条升级路线是读懂Java并发编程的关键。偏向锁偏向第一个获得锁的线程后续再次进入同步块不需要做任何同步操作只需要做一个偏向锁标记的检查适合只有一个线程访问同步块的场景。轻量级锁当发生第二个线程竞争时偏向锁会撤销膨胀为轻量级锁。线程通过CAS把锁对象的Mark Word替换为指向自己栈帧中锁记录的指针如果替换失败就自旋。重量级锁自旋超过一定次数或者竞争线程数过多锁会膨胀为重量级锁底层依赖操作系统互斥量Mutex未抢到锁的线程会进入阻塞状态。升级是单向的只能从偏向锁往上升不能降级。JDK 15之后偏向锁被废弃并默认关闭原因是偏向锁在复杂竞争场景下的撤销成本太高尤其是老年代对象的偏向锁回收会触发STW。2.2 偏向锁撤销、轻量级锁自旋的实际表现我一个真实的观察在低并发的CRUD接口里synchronized经过JIT优化之后性能并不比重入锁差甚至因为没有委托给操作系统的原因性能更好。但是一旦线程竞争变得激烈锁升级到重量级线程的上下文切换开销会非常明显此时吞吐量会大幅下降。所以在写代码时不要盲目所有的锁都上重量级方案。短小的同步块、锁对象无非共享竞争的场景直接synchronized就能跑得很好。锁的粒度控制比换锁的实现方式更重要。2.3 写一个利用锁升级的优化案例看一段简单的计数代码public class SynchronizedCounter { private Object lock new Object(); private int count 0; public void increment() { synchronized (lock) { count; } } public int get() { synchronized (lock) { return count; } } }这段代码在单线程下偏向锁几乎无开销在两个线程交替访问的场景锁会在轻量级锁自旋中快速完成在超高并发下才升级为重量级锁。很多时候你要做的不是换成ReentrantLock而是保证同步块尽量只包含核心操作不要在里面做IO、睡眠、远程调用。经验不要把锁对象声明成字符串字面量或者Integer等包装类否则可能出现“不同逻辑但同一对象值”的锁误伤这在生产环境里并不罕见。3. CAS乐观锁从Unsafe到原子类3.1 CAS的底层机制与ABA问题CASCompare And Swap是很多并发工具类的底层支撑。它的逻辑是一个内存地址的值如果等于预期值就更新为新值否则就重新读取再试。这个过程是硬件级别的原子操作不依赖锁。Java里对应的是Unsafe类的compareAndSwapInt方法。我们平时不直接碰Unsafe而是通过AtomicInteger、AtomicReference等原子类使用。CAS最著名的坑是ABA问题线程1读到了值A线程2把值改成B又改回A线程1在执行CAS时发现内存值还是ACAS成功。但A并不是原来的那个“语义上的A”了比如一个链表头节点被删除又添加了新的节点节点地址恰好相同就会出问题。解决ABA常用AtomicStampedReference内部维护了一个版本号每次修改不光比较值还比较版本号。3.2 原子类实战与批次更新场景我推荐在“热更新”场景里用原子类。比如接口网关的规则配置需要无锁更新就可以使用AtomicReferenceConfig每次发布新配置就构造新的Config对象然后原子替换引用。AtomicReferenceConfig currentConfig new AtomicReference(new Config()); public void updateConfig(Config newConfig) { currentConfig.set(newConfig); } public Config getConfig() { return currentConfig.get(); }读者线程每次拿到的都是完整的内存可见的Config对象不需要加锁。这里要注意Config里的字段必须用final修饰否则发布对象时可能因为指令重排出现“半发布”问题。这是使用不可变对象模式时最容易忽略的细节。高并发计数器场景如果是单点计数AtomicLong在高竞争下性能很差因为所有线程都在同一个内存地址上CAS自旋。Java 8之后的LongAdder采用了分槽思路每个线程分散到不同的槽位做累加最后sum时再合并吞吐量提升非常明显。我实际压测过在多线程高并发场景下LongAdder比AtomicLong快一个量级。4. AQS框架并发工具类的“发动机”4.1 AQS的核心结构state与CLH变体队列AQSAbstractQueuedSynchronizer是ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock等工具的公共底座。它的核心是一个volatile int类型的state变量和一个FIFO双向队列。队列里的节点代表了等待获取资源的线程。AQS的设计很巧妙它把“同步状态的管理”和“线程阻塞唤醒”剥离开来。子类只需要重写tryAcquire、tryRelease相关的逻辑来决定什么条件下允许获取资源。线程挂起和唤醒的细节由AQS内部队列和LockSupport来管理。4.2 ReentrantLock公平与非公平怎么选ReentrantLock有公平锁和非公平锁两种模式。公平锁的意思是线程获取锁时先检查队列中是否有等待者如果有就排队。非公平锁则允许新来的线程直接抢锁抢不到再进队。从性能角度非公平锁通常优于公平锁。因为当一个线程释放锁时刚来竞争的线程可能已经在CPU上热着直接运行减少了线程的切换成本。而公平锁能保证排队顺序适合要求严格业务顺序的场景比如任务分发。我自己的建议是默认非公平。除非你有明确的“必须按请求顺序处理”之类的需求再考虑公平锁。为了公平性付出的代价可能比你想象的更高。4.3 用AQS实现一个简单的限流器AQS的魅力在于你可以自定义同步器。下面是一个简单的非阻塞限流器同一时间最多允许N个线程通过超出则直接返回失败。public class SimpleLimiter { private static class Sync extends AbstractQueuedSynchronizer { Sync(int permits) { setState(permits); } Override protected int tryAcquireShared(int arg) { while (true) { int cur getState(); if (cur 0) { return -1; } int remain cur - arg; if (compareAndSetState(cur, remain)) { return remain; } } } Override protected boolean tryReleaseShared(int arg) { while (true) { int cur getState(); int next cur arg; if (compareAndSetState(cur, next)) { return true; } } } } private final Sync sync; public SimpleLimiter(int permits) { sync new Sync(permits); } public boolean tryAcquire() { return sync.tryAcquireShared(1) 0; } public void release() { sync.releaseShared(1); } }这个限流器适合做“本地限流”如果是分布式场景还是得依赖Redis或中间件。但理解这个实现能让你深入读懂Semaphore的源码。5. 线程池参数设计的铁律与源码细节5.1 核心参数之间的关系线程池的构造函数有七个参数但核心问题就三个核心线程数、最大线程数、阻塞队列大小。corePoolSize平时保持运行的线程数。maximumPoolSize极端情况下最多能创建多少线程。keepAliveTime非核心线程空闲多久后被回收。workQueue任务等待队列的类型和容量。线程池添加任务的逻辑是如果当前线程数小于核心线程数直接创建新线程执行任务如果大于等于核心线程数优先把任务放入队列如果队列满了再创建非核心线程如果线程数已经达到最大值执行拒绝策略。很多人想反了以为队列满了才会创建新线程顺序应该是核心线程-工作队列-最大线程-拒绝策略。5.2 workerCount与线程池状态编码ThreadPoolExecutor有一个非常巧妙的状态管理用一个int类型同时保存线程池运行状态runState和线程数量workerCount。高3位表示runState低29位表示workerCount。通过位运算一次性CAS这两个值避免了并发下状态不一致的问题。实际排查问题时我经常看dump文件里的线程池线程数来判断是否打满。如果线程数一直是maximumPoolSize且队列持续积压说明这个线程池已经过载需要调整参数或者升级处理能力。5.3 实际压测得到的调参经验I/O密集型任务和CPU密集型任务的参差很大。CPU密集型线程数建议设置为CPU核心数的1~2倍I/O密集型则要看阻塞时间占比经验公式大约是线程数 CPU核心数 * (1 等待时间 / 计算时间)。但公式只是起点压测才是终点。我举一个实际案例一个数据处理服务每个任务需要查询一次数据库并做少量计算线程池初始配置是核心线程数5、最大线程数10、无界队列。压测发现吞吐量上不去因为无界队列让任务全部堆积在队列里线程数量始终稳定在5CPU根本没用满。后来改成有界队列200最大线程数20吞吐量提升了近3倍。注意无界队列在极端情况下会吃掉所有内存导致系统OOM。即使是“懒人做法”也建议给队列设一个合理的上限配合拒绝策略兜底。6. 并发容器并发场景下的数据结构选型6.1 ConcurrentHashMap的锁粒度演进Java 7的ConcurrentHashMap用分段锁技术默认分成16个Segment每个Segment一把锁。Java 8重写了实现放弃了Segment改为数组链表红黑树并发控制也从锁分组变成了CASsynchronized锁粒度从“一段”细化为“一个桶”。为什么要这么改分段锁的问题在于当你需要计算size或者做跨段操作时需要串行化全局锁而Java 8对同一个桶的put操作使用synchronized锁不同桶的线程完全不会互相干扰。加上红黑树优化哈希冲突严重时查询复杂度从O(n)降低到O(log n)。6.2 CopyOnWriteArrayList适合什么场景CopyOnWriteArrayList的核心思路是“读写分离”读操作不加锁写操作先复制一份新的底层数组修改完成后再把引用指向新数组。因为每次写都会复制整个数组所以写操作很重但是读操作非常快且无锁。适用场景读多写少、集合体量小、能容忍短暂的数据不一致。典型如白名单、黑名单配置广播监听器的回调列表。我踩过一个坑把大容量的缓存列表用CopyOnWriteArrayList存储每次更新都会复制几万条数据的数组YGC频率暴涨。后来改成了读写锁实现问题才解决。所以容器选型必须结合业务量级。6.3 队列对比LinkedBlockingQueue与ArrayBlockingQueue线程池生产环境最常用的阻塞队列是这两者。ArrayBlockingQueue内部是数组必须指定容量有界。先进先出底层用一把锁控制读写入队出队互斥。LinkedBlockingQueue内部是链表可以指定容量也可以无界。入队和出队各自持有一把锁理论上并发读写吞吐量更高但链表节点对象会带来额外的内存开销。实际项目中我看到大多数执行异步任务的生产线程池选的都是LinkedBlockingQueue加有界容量。原因很简单读写锁分离在任务提交和执行频率高的时候吞吐量更稳。但如果你追求极低的GC压力ArrayBlockingQueue由于数组复用会更友好。7. 高频并发问题排查实录7.1 死锁复现与定位的完整步骤死锁的本质是两个或多个线程各自持有一把锁又互相等待对方的锁。定位死锁最标准的方法是拿到线程dump。命令是jstack pid。拿到dump后搜Found one Java-level deadlock下面会直接列出互相等待的锁和线程栈。示例代码public class DeadLockDemo { private static final Object A new Object(); private static final Object B new Object(); public static void main(String[] args) { new Thread(() - { synchronized (A) { try { Thread.sleep(100); } catch (InterruptedException e) {} synchronized (B) { System.out.println(线程1拿到两把锁); } } }).start(); new Thread(() - { synchronized (B) { try { Thread.sleep(100); } catch (InterruptedException e) {} synchronized (A) { System.out.println(线程2拿到两把锁); } } }).start(); } }排查时我喜欢结合jvisualvm或者arthas工具。后者在线上定位锁竞争时特别好用可以实时看哪个线程被阻塞甚至能看到锁对象的监控器。对于强制修改这种问题核心解法是调整加锁顺序保证所有线程都按全局统一的顺序获取锁实在没法统一顺序时用tryLock加超时来避免无限等待。7.2 线程池任务丢失排查线程池执行任务时如果任务抛了非受检异常且没有捕获异常会吞掉线程池会把该线程移除并新建一个线程任务也没有任何记录。看起来就像任务消失了。排查这类问题我常用的手段是在提交的Runnable里包一层try-catch统一捕获异常打日志。自定义ThreadFactory给线程统一命名比如biz-pool-thread-%d方便排查。重写afterExecute方法在任务执行结束后检查异常。对于execute方式提交的任务可以从参数里拿到抛出异常。另外execute和submit有本质区别execute不返回结果异常会被线程池“吃掉”submit返回Future异常会封装在Future内部只有调用future.get()时才会抛出。所以很多人用submit之后又不取结果等于把错误彻底藏了起来。7.3 常见并发面试题速查并发编程的面试考察重点整理成一张速查表供大家临考前翻阅问题核心解答思路volatile如何保证可见性写操作强制刷新主内存读操作强制从主内存获取并禁止指令重排。synchronized与ReentrantLock区别一个是JVM层面的锁一个是JDK层面的锁后者支持可中断、超时、公平锁、多个条件变量。CAS的缺陷CPU开销高、ABA问题、只能保证单个变量的原子性。ThreadLocal原理每个Thread有一个ThreadLocalMapkey是ThreadLocal弱引用注意remove避免内存泄漏。线程池拒绝策略AbortPolicy丢弃并抛异常、CallerRunsPolicy让提交线程执行、DiscardPolicy直接丢弃、DiscardOldestPolicy丢弃队列最旧任务。我个人在实际排查中最大的感受是不要把并发问题总往“高级锁”上想先确认可见性和原子性再考虑锁粒度和容器选型。多数生产事故根源都是非常基础的问题——比如共享变量没加volatile、List用了非线程安全的ArrayList、线程池队列容量设置不合理。如果你也能按这个顺序去分析很多“疑难杂症”会自动变得清晰。最后再分享一个日常提效的小技巧写多线程代码时务必给每个线程起一个有业务含义的名字并在同步块内打上不带参的日志。真到出问题时这份信息比任何高级分析工具都要管用。
企业数字化 ERP 产品动态
相关推荐
电控岗秋招必备:10个可写进简历的开源项目详解 1. 先搞懂:招聘方在你的简历里翻什么投电控岗,简历上全是“学过电路、模电、数电、自动控制原理”,这基本等于没写。我当年也犯过这个错。校招HR一天筛几百份简历,电控方向的JD里永远写着“熟悉BLDC/PMSM电机控制”“熟悉PID等控制… · 2026/9/26 18:03:21
Java 8到21全链路升级:语法、JVM、GC与兼容性实战指南 公司里还有大批服务跑在 Java 8 上,平时没人敢动。可一旦你发现新员工写代码已经在用 record、switch 表达式、虚拟线程,老项目的技术债就躲不掉了。Java 8 到 21 这一次升级,不是换一个 JDK 版本那么简单,它牵扯到代码语法、字节… · 2026/9/26 18:03:15
Codex vs DeepSeek-Coder:AI编程助手选型实战指南 1. 项目概述:这不是一场模型参数的数字游戏,而是一次面向真实开发场景的工具选型实战Codex 和 DeepSeek,这两个名字最近频繁出现在工程师的 Slack 频道、技术分享会和深夜调试日志里。但如果你刚点开它们的 GitHub 仓库或官网,第一… · 2026/9/26 22:18:33
140年自博弈训练:人形机器人足球背后的具身智能引擎 1. 这不是科幻片,是正在发生的机器人足球实验最近刷到“Skild AI 用140年自博弈训练人形机器人踢足球”这个标题,很多人第一反应是——又一个AI营销噱头?140年?人形机器人踢球?听着像把《机械姬》和《胜利大逃亡》剪辑… · 2026/9/26 22:18:26
工业机器人长时序任务规划:多智能体框架实现手册理解与闭环反馈 工业机器人做长时序任务,最让人头疼的从来不是单点动作能不能做出来,而是"做完一步之后,下一步还记不记得自己要干什么"。我见过太多演示视频里机械臂行云流水地抓取、装配、放置,一旦把任务拉长到二三十个步骤、中间再… · 2026/9/26 22:18:26
科技创意PPT模板实操指南:母版、字体与批量替换 简介:科技创意演示文稿模板,面向科技公司、研发团队、设计师及产品经理等群体,常用于产品发布会、科研项目汇报、创新实验展示、设计概念宣讲等场合。模板以灰色主背景为基础,融合科幻、数字艺术与机械感元素,营造出专… · 2026/9/26 22:18:19
数据库课后习题答案别硬背:当测试用例集刷,效率翻倍 简介:万常选版《数据库原理与设计》课后习题答案资源,覆盖第2至6章及第9章,适合正在学习关系模型、数据库建模、关系数据理论与模式求精的本科生、自学者作为复习与自测材料。压缩包共7个文件,含3个doc参考答案、2个sql示例脚本、… · 2026/9/26 0:00:21
OpenClaw 替代品?Hermes Agent 踩坑实录:macOS 飞书接入 TaoToken 配置 /* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views … · 2026/9/26 0:00:40
向下兼容与向上兼容:接口设计中的兼容性策略与工程实践 一次版本升级事故,是很多团队绕不过去的坎。线上环境里,服务端明明已经上线了新版接口,老的移动端还在照着旧文档传参数。请求一到网关,校验直接拒绝,用户操作失败,客服群炸了锅,开发群里开始互… · 2026/9/26 0:00:46