
很多人误以为JMM就是JVM的内存划分(堆、栈)。其实不然,Java内存模型(JMM)是一套关于可见性、有序性、原子性的抽象规范,它屏蔽了不同操作系统和CPU缓存架构的差异。
现代CPU(如Intel Xeon)为了解决寄存器与内存的速度鸿沟,引入了L1/L2/L3缓存。当多个核心同时操作主内存中的同一变量时,每个核心先将变量拷贝到自己的缓存行(Cache Line),修改后再同步回主存。这就导致了“缓存一致性”问题。
JMM的抽象规则:
深度洞见:这里的“工作内存”并不是真实存在的物理内存,而是指CPU寄存器、高速缓存以及编译器的优化暂存区的统称。
我们看下面这段引发线上故障的代码逻辑:
// 共享变量
static boolean running = true;
// 线程A(消费者)
while (running) {
// 业务逻辑,未加锁
}
// 线程B(生产者)
running = false; 为什么线程A看不见修改?
running = false 对应 putstatic 指令。putstatic 解释为 assign 操作,但并未强制冲刷本地缓存。true。解决方案(volatile的玄机):给 running 加上 volatile。观察HotSpot源码(bytecodeInterpreter.cpp),volatile 字段的写操作在汇编层面会被追加一条 lock addl $0x0,(%rsp) 指令。这条指令的作用是:
I 状态),迫使线程A重新从主内存读取。happens-before的严谨证明在线程A的 while (running) {} 内部,JVM为了执行效率,可能会发生指令重排(编译器重排 + 处理器乱序执行)。
public class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton(); // 隐患行
}
}
}
return instance;
}
}instance = new Singleton(); 在汇编层面分解为三步:
alloc)。invokespecial)。astore)。如果不加 volatile,步骤2和3可能发生重排。线程A执行了1和3,此时引用已非空,但对象未初始化;线程B进来,发现 instance != null,直接返回未初始化的对象,导致业务异常。
happens-before 的严苛逻辑volatile 屏蔽了重排。但更深度的保障是 happens-before 规则:
volatile 规则:对 volatile 的写入操作,一定 happens-before 后续对该 volatile 的读操作。unlock)操作一定 happens-before 后续的加锁(lock)操作。面试官常问:
synchronized保证了原子性,那它保证可见性吗? 答:是的。进入synchronized前,线程会清空工作内存(从主存重新加载);退出synchronized时,会将工作内存的修改立即刷入主存。这满足happens-before锁规则。
回到开头的线上故障,最终定位是 “嵌套锁 + 超时机制缺失” 导致的死锁。但死锁的本质并非只限于“互相等待”,它涉及操作系统层面的线程阻塞与唤醒。
synchronized 死锁的 JVM 内部机制当线程尝试获取已被占用的 synchronized 锁时,JVM 会调用 ObjectMonitor 对象的 enter 方法(objectMonitor.cpp)。
ObjectWaiter 节点,挂入 _cxq(竞争队列)或 _EntryList。park() 方法(调用 pthread_mutex_lock)进入内核态阻塞。经典死锁代码重现:
// 线程T1: 持有A,等待B
// 线程T2: 持有B,等待A此时操作系统视角:
mutex)陷入 TASK_UNINTERRUPTIBLE 状态。ReentrantLock 的 tryLock(long timeout, TimeUnit unit) 之所以能破解死锁,是因为它底层调用了 LockSupport.parkNanos()。该方法的实现依赖于 pthread_cond_timedwait,当超时时间到达但未获取锁时,线程会主动从等待队列中脱离(unpark),抛出 TimeoutException 并释放已持有的锁。
改造策略(破坏循环等待——有序资源分配法):
为每一个锁分配一个唯一的 int 类型ID。强制规定:只有当线程获取的锁ID大于另一个锁ID时,才能继续请求。
// 伪代码逻辑
if (idA > idB) {
lockA.lock();
lockB.lock();
} else {
lockB.lock();
lockA.lock();
}这种策略从根本上杜绝了“循环等待”的拓扑环。
AbstractQueuedSynchronizer(AQS)为例与其手动写 synchronized 复杂逻辑,更推荐使用 JUC 包下的高级工具,但必须理解其底层设计哲学。
ReentrantLock 内部维护了一个 volatile int state 作为同步状态。加锁流程:
compareAndSetState(0, 1)尝试抢占,这是原子操作,依赖 Unsafe 类的 compareAndSwapInt(映射为CPU的 cmpxchg 指令)。Node 节点,加入双向链表(CLH队列),通过 LockSupport.park() 挂起。为什么要自旋? 因为线程的挂起和唤醒涉及用户态与内核态的切换,代价高昂。短暂自旋等待锁释放,比立即阻塞效率更高(adaptive spinning 自适应自旋)。
那个CPU飙升的案例,其实是因为使用了无界队列的线程池(Executors.newFixedThreadPool),导致请求堆积,上下文切换频繁,连带影响了锁的释放唤醒。最终修正为:
ThreadPoolExecutor 自定义参数。workQueue 使用有界队列(ArrayBlockingQueue)。CallerRunsPolicy,将任务回退给主线程执行,利用反馈机制实现降流。Java并发并不是简单的API调用,它是一场关于 “内存可见性契约”、“锁契约”和“调度契约” 的精密游戏。
volatile 保证的是可见性契约,但不保证原子性。synchronized 保证的是互斥契约,但可能导致优先级反转。Lock 接口提供的是灵活的锁契约,但必须手动解除,否则死锁。理解这些底层原理(cmpxchg 指令、MESI协议、pthread_mutex),不是为了死记硬背,而是为了在发生诡异的“假死”、“CPU飙升”、“数据错乱”时,能像破案一样,从 hs_err_pid.log 和 jstack 日志中定位到那条致命的汇编指令或 ObjectWaiter 节点。
多线程的世界,真相永远藏在“主内存”与“工作内存”的狭小缝隙里。希望这篇从原理到崩溃现场的文章,能为你提供真正的避坑指南。在实际工作中,尽量封装并发细节,向业务上层提供无感的并发工具,这才是并发编程的最高境界。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。