JMM and Troubleshooting⚓︎
约 1393 个字 68 行代码 预计阅读时间 8 分钟
并发存在的 bug:
- 可见性:变量永远读不到新值
- 工具:
volatile
- 工具:
- 有序性:指令重排导致异常
- 工具:内存屏障
- 死锁:线程永久被阻塞
- 工具:
jstack
- 工具:
Java 内存模型(Java memory model, JMM):
- 定义线程间如何、何时看到共享变量
- 8 条 happens-before 规则(重点:
volatile、锁、传递性)- 程序顺序规则
volatile写 -> 读- 锁解锁 -> 加锁
start()-> 线程内- 线程内 ->
join() - 线程中断
- 终结器
- 传递性
- 屏蔽不同 CPU 内存模型的差异
- 不写汇编,还能跨平台并发
可见性 Demo:
class Holder {
/* volatile */ int x = 0; // 去掉 volatile 可见 Bug
void write() { x = 1; }
void read() { while (x == 0); }
}
运行:写线程修改后读线程死循环(无 volatile)
关于 volatile:
- 用于标记 Java 变量为“存储在主存中”
- 所有对
counter变量的写入都将立即写回主内存 - 此外,所有对
counter变量的读取都将直接从主存读取 -
内存语义:
- 写时发布,读时获取
- 可见性:写后立即刷主存,写完就让别人看的见
- 有序性:禁止重排,读写前后插入内存屏障,读前先把自己对齐
- 保证原子性(但
i++仍不行)
-
字节码层面:
volatile写 ->StoreStore+StoreLoad屏障volatile读 ->LoadLoad+LoadStore屏障
完整的 volatile 可见性保证:
- 如果线程 A 写入一个
volatile变量,并且线程 B 随后读取相同的volatile变量,那么在写入volatile变量之前对线程 A 可见的所有变量,在读取volatile变量之后也将对线程 B 可见 - 如果线程 A 读取一个
volatile变量,那么在读取变量时对线程 A 可见的所有变量也将从主存中重新读取
例子
public class MyClass {
private int years;
private int months
private volatile int days;
public void update(int years, int months, int days){
this.years = years;
this.months = months;
this.days = days;
}
}
完整的 volatile 可见性保证意味着:当一个值写入 days 时,线程可见的所有变量(即 years 和 months 的值)也会写入主存。
指令重排:出于性能考虑,Java VM 和 CPU 允许在程序中为指令重新排序,并保持指令语义不变。
有序性 / 重排 Demo:
int a = 0, b = 0;
int x = 0, y = 0;
// 线程 1
a = 1;
x = b; // 可能读到 0(重排)
// 线程 2
b = 1;
y = a; // 可能读到 0
结果:(x, y) = (0, 0) 出现 -> 重排成功
happens-before 保障:
- 如果这些读取 / 写入最初发生在对该
volatile变量的写入之前,对其他变量的读取和写入不能被重排到发生在对一个volatile变量的写入之后;对volatile变量的写入之前的读取 / 写入保证会“先于”对该volatile变量的写入 -如果这些读取/写入最初发生在对该volatile变量的读取之后,对其他变量的读取和写入不能被重排到发生在对一个volatile变量的读取之前
可见性保障:
- 对同一个
volatile变量- 写具备
release语义:把本线程此前的写入“发布出去” - 读具备
acquire语义:读取到最新值,并使后续读取可见这些发布过来的写
- 写具备
- 建立的关系:对
v的写 happens-before 随后对v的读 - 写者先刷到公共区,读者先对齐再读取,因此看到的是最新的
有序性保障:
- 编译器 / JVM 会在
volatile前后放置内存屏障以约束指令重排- 在
volatile写之前的普通读写,不能被移到写之后 - 在
volatile读之后的普通读写,不能被移到读之前
- 在
- 结果:以
volatile访问为栅栏,保证边界内外保持正确的先后关系
内存屏障与 CPU 指令:
LoadLoad:读读禁止重排StoreStore:写写禁止重排LoadStore:读写禁止重排StoreLoad:写读禁止重排- 对应 x86 的
MFENCE
- 对应 x86 的
原子操作:
- 任何对
double和long的操作都不是原子操作,除非变量被声明为volatile - 或者使用原子类型
long 对象?
s指针是易变的,而不是整个对象- 所有成员都是
long的?- 连续的原子操作不是原子的
单例:
- 将构造函数设为
private,并提供一个静态方法来获取那个唯一对象 - 在加载时实例化,或在首次检索时实例化
-
问题:
class Singleton { private static /*volatile*/ Singleton instance; static Singleton get() { if (instance == null) { // ① 读 synchronized(Singleton.class) { if (instance == null) // ② 读 instance = new Singleton(); // ③ 写 } } return instance; } }- 无
volatile-> ③ 与 ① 重排 -> 返回半初始化对象
- 不是原子操作
- 在这个检索方法中添加锁太重了
- 这是一个罕见的情况
- 无
两阶段测试:
- 如果对象已经生成,则无需生成新的
- 否则使用一种锁来保护它
- 使用
volatile避免指令重排 - 静态实例化
- 使用
enum
volatile vs synchronized
synchronized负责“一次只让一个人进屋”,即负责互斥volatile负责“新消息立即可见、顺序不乱”,即负责可见性和有序性- 用锁可能导致阻塞 / 唤醒 / 上下文切换,高并发下多余的互斥会放大尾延迟
- 必须用锁的场合:
- 复合原子性:
i++,check-then-act 等 - 临界区保护
- 多字段一致性
- 复合原子性:
死锁的四个必要条件(破坏任一即可预防死锁
- 互斥
- 占有且等待
- 非抢占
- 循环等待
现场死锁 Demo:
static final Object A = new Object();
static final Object B = new Object();
Thread t1 = new Thread(() -> {
synchronized (A) {
sleep(100);
synchronized (B) { /*do*/ }
}
},
"Dead-1");
Thread t2 = new Thread(() -> {
synchronized (B) {
sleep(100);
synchronized (A) { /*do*/ }
}
},
"Dead-2");
启动 -> 永久被阻塞
jstack 定位死锁:
输出示例(循环依赖图一目了然
Found one Java-level deadlock
=============================
"Dead-2":
waiting to lock monitor 0x... (object=B)
which is held by "Dead-1"
"Dead-1":
waiting to lock monitor 0x... (object=A)
which is held by "Dead-2"
可视化工具:VisualVM + ThreadMXBean
ThreadMXBean bean = ManagementFactory.getThreadMXBean();
long[] ids = bean.findDeadlockedThreads(); // 返回死锁线程 ID
VisualVM:
- 线程页 -> Detect Deadlock 按钮
- 红色显示死锁链,可导出图
预防死锁策略
- 固定顺序加锁(哈希排序)
- 尝试锁(
tryLock(timeout)) - 开放调用(缩短锁范围)
- 锁分段 / 分离(
ConcurrentHashMap桶锁)
评论区
如果大家有什么问题或想法,欢迎在下方留言~