| 概念层级 | 共享区域 (Shared) | 线程私有/工作区 (Working) |
|---|---|---|
| JMM (抽象规范) | 主存 (Main Memory) |
(存实例字段, 静态字段, 数组) |本地内存 (Working Memory)
(存共享变量的副本) |
|JVM (具体实现)|Java 堆 (Java Heap)
(存对象实例, 数组) |(无对应区域)“工作内存” 并不指 JVM 栈帧里的局部变量表
JMM 本地内存不是 JVM 栈|
|硬件 (物理真实)|物理内存 (RAM)|CPU 高速缓存 (L1/L2/L3)
CPU 寄存器 (Registers)|
JMM 内存模型
区别于JVM内存结构 可以看作是 Java 定义的并发编程相关的一组规范
主要定义了对于一个共享变量,当另一个线程对这个共享变量执行写操作后,这个线程对这个共享变量的可见性
原子性
在 Java 中,可以借助synchronized、各种Lock以及各种原子类实现原子性。
synchronized和各种Lock可以保证任一时刻只有一个线程访问该代码块,因此可以保障原子性。各种原子类是利用 CAS (compare and swap) 操作(可能也会用到volatile或者final关键字)来保证原子操作。
可见性
由JVM缓存优化引起的
在 Java 中,可以借助synchronized、volatile以及各种Lock实现可见性。
譬如我们将变量声明为volatile,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。
有序性
由于CPU指令重排序优化,代码的执行顺序未必就是编写代码时候的顺序。
指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致,所以在多线程下,指令重排序可能会导致一些问题。
volatile关键字可以禁止重排序优化
volatile关键字
- 可见性:线程每次读取都会从主内存重新获取最新值,立刻把值从工作内存刷新到主内存;
- 有序性:禁止某些指令重排序优化,确保读写顺序在多线程间一致。
- 单独不能保证原子性
- 故适用于一写多读
原理 内存屏障
对 volatile 变量读操作后面加上屏障,防止之后的代码跑前面、对 volatile 变量写操作前面加上屏障,防止之前的代码跑后面
DCL 线程问题
为
uniqueInstance分配内存空间初始化
uniqueInstance将 uniqueInstance 指向分配的内存地址 但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1->3->2
因为
uniqueInstance没有被synchronized“完全包含”,故不能保证有序性
解决方法:uniqueInstance加上 volatile即可
happens-before原则
可见性和有序性的规则,描述哪些操作对其他操作可见、哪些操作必须在其他操作之前执行
简单来说就是描述两个操作之间的内存可见性
| 概念 | 含义 |
|---|---|
| happens-before | 定义多线程操作的“逻辑先后关系”,保证可见性与有序性 |
| 目的 | 限制编译器与 CPU 的指令重排,确保程序语义正确 |
| 实现方式 | 语言内存模型(如 JMM)+ 内存屏障(Memory Barrier) |
| 本质 | 一种抽象的逻辑时间顺序,不等同于物理执行顺序 |
一共8条
- 程序顺序规则:一个线程内,按照代码顺序,书写在前面的操作 happens-before 于书写在后面的操作;
- 解锁规则:解锁 happens-before 于加锁;
- volatile 变量规则:对一个 volatile 变量的写操作 happens-before 于后面对这个 volatile 变量的读操作。说白了就是对 volatile 变量的写操作的结果对于发生于其后的任何操作都是可见的。
- 传递规则:如果 A happens-before B,且 B happens-before C,那么 A happens-before C;
- 线程启动规则:Thread 对象的
start()方法 happens-before 于此线程的每一个动作。
如果两个操作不满足上述任意一个 happens-before 规则,那么这两个操作就没有顺序的保障,JVM 可以对这两个操作进行重排序。
synchronized优化
完整的锁升级路径是:无锁 -> (偏向锁 )-> 轻量级锁 -> 重量级锁
轻量级加锁
锁记录 (Lock Record) 存在于线程的栈帧上,在轻量级锁“加锁”时用于备份对象头的 Mark Word,在“解锁”时用于恢复 Mark Word。这个“备份”和“恢复”的动作都是通过 CAS 原子操作完成的
当线程 A 准备进入一个synchronized(obj)代码块时:
- 创建锁记录:JVM 在线程 A 的当前栈帧中创建一个 Lock Record。
- 复制 Mark Word:JVM 将
obj对象头中的 Mark Word 复制到这个 Lock Record 的displaced_header字段中。 - CAS 尝试替换:JVM 使用CAS原子操作,将
obj对象头中的 Mark Word 替换为一个指向这个 Lock Record 的指针,最后两位锁标志位从“01”变为 “00”(表示轻量级锁状态)。- CAS 成功:
- 线程 A 成功获得轻量级锁,可以执行同步代码块。
- CAS 失败:1锁竞争:这说明在线程 A 尝试 CAS 的瞬间,有其他线程已经抢先一步持有了
obj的轻量级锁.锁膨胀为重量级锁。 2锁重入:新增锁记录 但内容为null
- CAS 成功:
- 解锁 先消除null值锁记录 cas还原锁对象的Mark Word
锁膨胀
轻量锁发生锁竞争 则锁对象申请monitor 把锁对象的markword置为指向monitor的指针
锁对象 的 Mark Word 会被修改为指向一个重量级锁的 Monitor 对象,而竞争失败的线程会进入阻塞状态。 当持有锁的线程释放时候就明白了锁已经膨胀了,于是它会按照重量级锁的释放流程
monitorenter是synchronized代码块的唯一入口指令。JVM 在执行这条指令时,会“智能地”决定在内部是使用 CAS(轻量级锁)还是使用 Mutex(重量级锁),而Lock Record只是在 CAS 过程中用到的一种栈上数据结构
重量级锁优化
因为阻塞需要线程上下文切换 导致巨大的性能开销
自旋优化(单核则无意义):就是不直接阻塞,先重试几次/busy-wating循环
弊端:失败了总开销比直接阻塞还要大 自适应自旋,JVM动态决定重试次数
偏向锁优化
偏向锁在JDK 15后默认关闭(Deprecated)
只有第一次CAS将操作系统的线程ID设置到锁对象的mark word头(而非锁记录),将对象头中是否偏向锁标志位置为1,后续当前线程再次访问这个共享资源时,会根据偏向锁标识跟线程ID进行比对是否相同,比对成功则直接获取到锁
弱点:昂贵的“撤销”成本
偏向锁的乐观假设一旦被打破,就会付出沉重代价
- 竞争出现:当另一个线程(线程 B)尝试获取这个“已偏向线程 A”的锁时,竞争就出现了。
- 触发撤销(Revocation):此时,偏向锁必须被撤销。这个过程非常昂贵:
- JVM 必须等待一个全局安全点 (Safepoint),这会暂停所有用户线程(Stop-The-World, STW)。
- JVM 检查持有锁的线程 A 是否还在同步块中。
- 如果 A 还在:锁不能撤销,必须“膨胀”为轻量级锁。
- 如果 A 已退出:锁恢复为“可偏向”或“无锁”状态,以便线程 B 尝试获取。
- 性能反噬:如果一个锁在不同线程间频繁切换(即存在竞争),那么偏向锁的获取和撤销开销(尤其是 Safepoint)会导致性能急剧下降,远不如一开始就使用轻量级锁。
特性:
- 批量重偏向:如果发现某一“类”的对象(不是单个对象)上的偏向锁被频繁撤销,JVM 会认为这个类的锁不再适合偏向“第一个线程”。它会等到下一次安全点,将这个类所有实例的锁批量地重偏向给“当前正在竞争的线程”。 批量重偏向不改变任何已存在对象的状态,它只是“递增
Klass的epoch”,使得所有旧的偏向在下一次访问时“失效”,从而允许新的线程直接对它们进行重新偏向 - 批量撤销:如果批量重偏向后,竞争依然非常激烈,JVM 会最终放弃,将这个类的所有实例的偏向锁功能批量地撤销掉,使其新建的实例默认就是“不可偏向”的(直接进入轻量级锁)。
锁消除
JVM 虚拟机如果检测不到某段代码被共享和竞争的可能性,将同步锁消除掉
原文地址:JMM
本文持续更新,最新内容及相关资料请访问原文。