Java 并发编程中的常见问题(死锁、竞态条件等)与 JMM 有什么关系?

Java内存模型(JMM)是 “法律和物理规则”,而死锁、竞态条件等并发问题是 “违反规则后导致的事故”。

下面我们来详细拆解这个关系。


第一部分:什么是Java内存模型(JMM)?

首先,要理解JMM不是一块真实的物理内存,而是一套抽象的规范和规则。它的存在是为了解决一个核心问题:在多核、多缓存的现代计算机体系结构下,如何保证一个线程对共享变量的修改能被其他线程正确地、及时地看到。

JMM围绕三个核心特性来定义这些规则:

  1. 原子性(Atomicity):一个或多个操作,要么全部执行且执行过程不被任何因素打断,要么就都不执行。JMM只保证基本数据类型的读取和赋值是原子性的(longdouble除外,它们是64位,在32位系统上可能被拆分为两次操作)。像 i++ 这种操作就不是原子性的。

  2. 可见性(Visibility):当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。由于CPU缓存、寄存器、指令重排序的存在,可见性并不能被默认保证。

  3. 有序性(Ordering):程序执行的顺序按照代码的先后顺序执行。但在实际执行中,编译器和处理器为了优化性能,可能会对指令进行重排序。JMM规定了哪些情况下可以重排序,哪些情况下不行。

JMM通过 volatile, synchronized, final, java.util.concurrent 包中的类等工具,为开发者提供了遵守这些规则的手段。其中,Happens-Before 原则是JMM中描述可见性和有序性的核心。


第二部分:并发问题与JMM的关系

现在,我们来看看具体的并发问题是如何与JMM的这些特性产生关联的。

1. 竞态条件 (Race Condition)

问题描述:当多个线程访问和修改同一个共享资源时,最终的结果取决于线程的执行时序。最经典的例子就是 count++

与JMM的关系

  • 原子性问题count++ 实际上是三个步骤:1) 读取 count 的值;2) 将值加一;3) 将新值写回 count。JMM本身不保证这个复合操作的原子性。因此,在多线程环境下,一个线程可能在另一个线程完成这三步中的任意一步时插入执行,导致结果错误。
    • 例如:线程A读取count为0,线程B也读取count为0。线程A计算出1并写回,线程B也计算出1并写回。结果count是1,而不是预期的2。
  • 可见性问题:即使一个线程完成了count++,并将新值(比如1)写回了主内存,另一个线程可能由于CPU缓存的原因,仍然读取到的是旧值0,从而导致计算错误。

如何解决(利用JMM规则)
使用 synchronizedjava.util.concurrent.atomic.AtomicInteger

  • synchronized:它能保证代码块的原子性(只有一个线程能执行)和可见性(进入synchronized块前会清空工作内存,从主内存加载;退出时会将修改刷新到主内存)。这满足了JMM的Happens-Before规则。
  • AtomicInteger:它使用CAS(Compare-And-Swap)这种硬件级别的原子操作来保证getAndIncrement()原子性可见性
2. 内存可见性问题 (Memory Visibility Issue)

问题描述:一个线程修改了共享变量,但其他线程没有看到更新后的值,导致它们在旧值上继续工作。

与JMM的关系

  • 这直接就是JMM的可见性(Visibility)问题。每个线程都有自己的工作内存(通常是CPU缓存的抽象)。当线程修改变量时,它首先是在自己的工作内存中修改,JMM并不保证这个修改会立即刷新到主内存,也不保证其他线程会立即从主内存读取新值。

经典案例

class VisibilityExample {
    private boolean stop = false;

    public void start() {
        new Thread(() -> {
            while (!stop) {
                // do something
            }
            System.out.println("Thread stopped.");
        }).start();
    }

    public void stop() {
        this.stop = true;
    }
}

另一个线程调用 stop() 方法将 stop 设置为 true,但循环中的线程可能永远看不到这个变化,导致死循环。

如何解决(利用JMM规则)
stop 变量加上 volatile 关键字。

  • volatile:这是JMM提供的轻量级同步机制。它保证了对该变量的写操作会立即刷新到主内存,并且任何读操作都会直接从主内存读取。同时,它还能禁止指令重排序(保证了有序性)。它确保了对volatile变量的写操作 Happens-Before 于后续对这个变量的读操作。
3. 指令重排序 (Instruction Reordering)

问题描述:代码的执行顺序与编写的顺序不一致,在单线程下通常没问题,但在多线程下可能导致意想不到的后果。

与JMM的关系

  • 这直接就是JMM的有序性(Ordering)问题。JMM允许在不影响单线程最终结果的前提下,对指令进行重排序以提高性能。但在多线程环境中,这种重排序可能破坏程序逻辑。

经典案例:双重检查锁定(DCL)的单例模式(在不使用volatile的情况下)。

class Singleton {
    private static Singleton instance; // 如果没有 volatile
    private Singleton() {}

    public static Singleton getInstance() {
        if (instance == null) {                         // 1. 第一次检查
            synchronized (Singleton.class) {
                if (instance == null) {                 // 2. 第二次检查
                    instance = new Singleton();         // 3. 创建实例
                }
            }
        }
        return instance;
    }
}

instance = new Singleton() 这行代码不是原子性的,它大致分为三步:

  1. 分配内存空间。
  2. 初始化对象。
  3. instance 引用指向分配的内存地址。

由于指令重排序,步骤2和3的顺序可能被颠倒,变成 1 -> 3 -> 2。

  • 线程A执行到instance = new Singleton(),执行了1和3,但还没执行2。此时 instance 已经不为 null
  • 线程B进入 getInstance(),在第一次检查时发现 instance != null,于是直接返回 instance
  • 但此时的 instance 是一个未完全初始化的对象,线程B使用它可能会导致程序崩溃。

如何解决(利用JMM规则)
instance 变量加上 volatile 关键字。

  • volatile 会禁止第2步和第3步的重排序,确保只有在对象完全初始化之后,instance 引用才会被赋值。
4. 死锁 (Deadlock)

问题描述:两个或多个线程无限期地互相等待对方持有的资源,导致所有线程都无法继续执行。

与JMM的关系

  • 关系是间接的。死锁本身是一个逻辑问题,而不是JMM直接定义的内存一致性问题。JMM本身不关心你如何获取锁,也不关心锁的顺序。
  • 但是,死锁通常是由于滥用或错误使用JMM提供的同步工具(如 synchronizedjava.util.concurrent.locks.Lock)造成的。这些工具是用来解决JMM层面上的原子性和可见性问题的。
  • 所以可以说:为了遵循JMM的规则(保证原子性/可见性),我们使用了锁;而对锁的逻辑管理不当,导致了死锁。 JMM提供了锤子(锁),而死锁是我们用锤子砸到了自己的脚。

总结

并发问题 核心原因 与 JMM 的关系 如何利用 JMM 规则解决
竞态条件 复合操作的非原子性、结果可见性延迟 直接违反了JMM的原子性可见性保证。 使用 synchronizedLock (提供原子性和可见性),或 Atomic* 类 (提供原子操作)。
内存可见性 线程工作内存与主内存数据不一致 直接是JMM要解决的核心问题——可见性 使用 volatile 保证可见性,或使用 synchronized/Lock 在块的边界强制刷新内存。
指令重排序 编译器和处理器优化 直接是JMM规范的一部分——有序性。JMM允许重排序,但也提供了禁止重排序的手段。 使用 volatilesynchronized 插入内存屏障,禁止特定区域的指令重排序。
死锁 锁的获取顺序不当,资源循环等待 间接关系。死锁是应用层逻辑错误,通常由错误使用JMM提供的同步工具(synchronized, Lock)引起。 JMM本身不解决死锁。需要开发者在代码逻辑层面保证锁的顺序、使用try-finally释放锁、使用定时锁等。

总结:JMM定义了多线程编程中内存操作的“游戏规则”,而竞态条件、可见性问题、重排序问题是由于开发者没有正确利用 volatile, synchronized 等工具来遵循这些规则而产生的“犯规”行为。死锁则是更高层面的“战术失误”,虽然也与这些工具有关,但根源在于逻辑设计。

你可能感兴趣的:(JVM,常见问题汇总,java,死锁)