跳转至

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 变量为“存储在主存中”
public class SharedObject {
    public volatile int counter = 0;
}
  • 所有对 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 时,线程可见的所有变量(即 yearsmonths 的值)也会写入主存。

指令重排:出于性能考虑,Java VM CPU 允许在程序中为指令重新排序,并保持指令语义不变。

int a = 1;                      int a = 1;
int b = 2;                      a++;
                   -->
a++;                            int b = 2;
b++;                            b++;

有序性 / 重排 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

原子操作:

  • 任何对 doublelong 的操作都不是原子操作,除非变量被声明为 volatile
  • 或者使用原子类型

long 对象?

volatile Student s ...
  • 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 -> ③ 与 ① 重排 -> 返回半初始化对象
    if ( theObject == null )
        theObject = new Server();
    
    • 不是原子操作
    • 在这个检索方法中添加锁太重了
    • 这是一个罕见的情况

两阶段测试:

  • 如果对象已经生成,则无需生成新的
  • 否则使用一种锁来保护它
  • 使用 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 定位死锁:

jstack <pid> | grep -A 20 "Found one Java-level deadlock"

输出示例(循环依赖图一目了然

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 桶锁)

评论区

如果大家有什么问题或想法,欢迎在下方留言~