在Java并发编程中,我们经常会遇到这样的困惑:明明代码逻辑没问题,多线程运行时却出现数据错乱、结果不一致;明明加了锁,却依然有线程安全问题;明明修改了变量值,其他线程却看不到最新结果。这些问题的根源,并非代码逻辑漏洞,而是我们对JMM(Java Memory Model,Java内存模型)的理解不足——JMM定义了多线程访问内存的规则,而三大并发问题(可见性、原子性、有序性),本质上都是破坏了JMM的规则导致的。

很多开发者在写多线程代码时,只关注“实现功能”,却忽略了JMM这个“底层规则”,导致代码在单线程环境下正常运行,在多线程环境下就出现各种诡异问题。今天,我们就从JMM的核心定义入手,一步步拆解其底层架构、工作机制,再深入剖析三大并发问题的成因、表现,最后给出对应的解决方案,用通俗的语言+代码案例,把抽象的并发原理讲透,让你彻底摆脱并发问题的困扰。

一、先搞懂:什么是JMM内存模型?(底层核心解析)

JMM并非真实存在的物理内存,而是Java虚拟机(JVM)定义的一套抽象规范——它规定了所有变量(实例变量、静态变量、数组元素)的存储位置、线程如何访问这些变量,以及变量在内存中的读写规则。其核心目的是:解决多线程环境下,CPU缓存、指令重排序导致的内存可见性、原子性、有序性问题,保证多线程并发访问的安全性。

在深入JMM之前,我们先明确一个前提:现代计算机为了提升性能,会在CPU和主内存之间增加高速缓存(Cache),同时CPU会对指令进行重排序(优化执行效率)。这两个优化在单线程环境下不会有问题,但在多线程环境下,会导致线程之间“数据不同步”,而JMM的核心作用,就是规范这些行为,让多线程访问内存的过程“可预测、可控制”。

(一)JMM的核心架构:主内存与工作内存

JMM将内存分为两个部分,这是理解所有并发问题的基础,必须吃透:

  1. 主内存(Main Memory):所有线程共享的内存区域,用于存储所有的变量(包括实例变量、静态变量、数组元素)。主内存是“全局共享”的,任何线程对变量的修改,最终都要同步到主内存中;任何线程读取变量,都要从主内存中加载。

  2. 工作内存(Working Memory):每个线程独有的内存区域,用于存储该线程从主内存中加载的变量副本。线程对变量的所有操作(读取、修改),都只能在自己的工作内存中进行,不能直接操作主内存中的变量。操作完成后,线程会将修改后的变量副本同步回主内存,供其他线程使用。

举个通俗的例子:主内存就像“公共仓库”,所有线程都能访问;工作内存就像每个线程自己的“私人储物柜”,线程要使用仓库里的物品(变量),必须先把物品拿到自己的储物柜里,修改完成后,再放回公共仓库。如果线程A修改了储物柜里的物品,却没有放回仓库,那么线程B从仓库里拿到的依然是旧的物品——这就是后续要讲的“可见性问题”的根源。

补充:JMM对工作内存的实现没有强制规定,不同的JVM(如HotSpot)可以有不同的实现方式,通常工作内存会对应CPU的寄存器、高速缓存。这也解释了为什么多线程环境下,变量的读写会有延迟:CPU操作高速缓存的速度远快于操作主内存,线程修改工作内存中的副本后,不会立即同步到主内存,导致其他线程无法及时看到最新值。

(二)JMM的核心规则:happens-before 先行发生原则

JMM通过“happens-before 先行发生原则”,定义了线程之间的内存可见性——如果一个操作A happens-before 操作B,那么操作A的执行结果,对操作B是可见的。简单来说,就是“先执行的操作A,其修改的变量值,后执行的操作B一定能看到”。

无需死记硬背所有规则,掌握以下4个最常用的即可,覆盖90%的并发场景:

  1. 程序次序规则:在单线程中,代码的执行顺序是按程序编写的顺序执行的(注意:这里是“逻辑上的顺序”,CPU可能会重排序,但最终的执行结果会和按顺序执行一致)。比如单线程中,先执行a=1,再执行b=a,那么b的结果一定是1。

  2. volatile变量规则:对一个volatile变量的写操作,happens-before 后续对该变量的读操作。也就是说,一个线程修改了volatile变量的值,其他线程立即能看到最新值(解决可见性问题)。

  3. 锁规则:对一个锁的解锁操作,happens-before 后续对该锁的加锁操作。比如线程A释放锁,线程B获取同一个锁,那么线程A在释放锁前的所有操作,对线程B都是可见的。

  4. 线程启动规则:线程A调用线程B的start()方法,那么start()方法执行完成后,线程B的所有操作,对线程A都是可见的。

happens-before原则的核心价值:它为我们提供了“多线程环境下,变量可见性”的判断标准,不需要深入理解CPU缓存、指令重排序,只要遵循这些规则,就能保证多线程访问的可见性。

(三)JMM的核心作用:屏蔽底层硬件差异

不同的硬件(CPU、内存),其缓存架构、指令重排序规则是不同的。比如,Intel CPU和AMD CPU的缓存策略不同,指令重排序的优化方向也不同。JMM的核心作用,就是“屏蔽这些底层硬件差异”,让Java开发者不需要关注底层硬件的细节,只需要遵循JMM的规则,就能写出在不同硬件环境下都能正确运行的并发代码。

简单来说,JMM就像一个“翻译官”,它将Java并发代码的逻辑,翻译成不同硬件能理解的指令,保证了Java并发程序的“跨平台性”和“正确性”。

二、三大并发问题:成因、表现与案例(结合JMM拆解)

了解了JMM的底层架构和规则后,我们再来看三大并发问题——可见性、原子性、有序性。这三个问题,本质上都是破坏了JMM的规则,导致多线程访问内存时出现“数据不一致”。我们逐一拆解,结合代码案例,让你直观看到问题的表现和根源。

(一)可见性问题:“我改了,你却看不到”

1. 问题定义

可见性:当一个线程修改了主内存中的变量值,其他线程能够立即看到这个修改后的最新值。如果其他线程无法及时看到,就称为“可见性问题”。

2. 成因(结合JMM)

结合JMM的主内存与工作内存架构,可见性问题的根源有两个:

  • 线程对变量的操作,都是在自己的工作内存中进行的,修改后的变量副本,不会立即同步回主内存。

  • 其他线程读取变量时,会从主内存中加载变量到自己的工作内存,若主内存中的变量没有被同步更新,其他线程加载的依然是旧的副本。

再加上CPU缓存的优化:CPU会将工作内存中的数据缓存到高速缓存中,修改后的数据会先存在高速缓存中,再批量同步到主内存,这个过程会有延迟,进一步加剧了可见性问题。

3. 代码案例(直观感受可见性问题)

我们写一个简单的多线程案例,模拟可见性问题:

public class VisibilityDemo {
    // 未使用volatile修饰的变量
    private static boolean flag = false;

    public static void main(String[] args) throws InterruptedException {
        // 线程1:修改flag的值为true
        new Thread(() -> {
            try {
                Thread.sleep(1000); // 让线程2先开始执行
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            flag = true;
            System.out.println("线程1修改flag为:" + flag);
        }).start();

        // 线程2:循环读取flag的值,直到flag为true才退出
        new Thread(() -> {
            while (!flag) {
                // 循环体为空,不断读取flag
            }
            System.out.println("线程2读取到flag为:" + flag);
        }).start();
    }
}

预期结果:线程1修改flag为true后,线程2读取到flag为true,退出循环并打印。

实际结果:线程1打印“线程1修改flag为:true”后,线程2会一直循环,永远无法退出。

问题分析:线程2启动后,会从主内存中加载flag=false到自己的工作内存,之后一直循环读取自己工作内存中的flag副本;线程1修改flag为true后,只是修改了自己工作内存中的副本,没有及时同步回主内存(或者同步回主内存后,线程2没有重新从主内存加载最新值),导致线程2一直读取到的是旧值false,无法退出循环——这就是典型的可见性问题。

(二)原子性问题:“要么全做,要么全不做”被打破

1. 问题定义

原子性:一个操作(可能是多个步骤),要么全部执行完成,要么全部不执行,中间不会被其他线程打断。如果一个操作被打断,就会导致数据错乱,这就是“原子性问题”。

注意:Java中的基本数据类型(int、long等)的赋值操作(如a=1)是原子性的,但复合操作(如a++)不是原子性的——因为a++本质上是三个步骤:读取a的值、计算a+1、将结果赋值给a,这三个步骤可能会被其他线程打断。

2. 成因(结合JMM)

原子性问题的根源:多线程环境下,线程的执行是“抢占式”的(CPU随机分配时间片),一个线程执行复合操作时,可能会被其他线程打断,导致操作只执行了一部分,从而破坏原子性。

结合JMM来看:线程执行复合操作时,会先从主内存加载变量到工作内存,执行操作后同步回主内存。如果在“加载-执行-同步”的过程中,被其他线程打断,其他线程就会读取到“未完成操作”的中间值,导致数据错乱。

3. 代码案例(直观感受原子性问题)

模拟多线程累加操作,看看原子性问题的表现:

public class AtomicityDemo {
    // 共享变量,用于累加
    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        // 启动1000个线程,每个线程累加1000次
        for (int i = 0; i < 1000; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    count++; // 复合操作,非原子性
                }
            }).start();
        }

        // 等待所有线程执行完成
        Thread.sleep(2000);
        System.out.println("最终count值:" + count);
    }
}

预期结果:1000个线程 × 1000次 = 1000000。

实际结果:每次运行的结果都不一样,且一定小于1000000(比如998765、999123等)。

问题分析:count++是复合操作(读取count、count+1、赋值给count)。假设线程A读取count=100,准备执行count+1时,CPU时间片被线程B抢占;线程B读取count=100,执行count+1后,count=101,同步回主内存;之后线程A继续执行,将自己计算的101赋值给count,同步回主内存——此时,两个线程都执行了一次累加,但count只增加了1(从100到101),而非2,导致最终结果小于预期,这就是原子性问题。

(三)有序性问题:“代码顺序,不等于执行顺序”

1. 问题定义

有序性:程序代码的执行顺序,与CPU实际执行的指令顺序一致。但为了提升执行效率,CPU和JVM会对指令进行“重排序”(在不影响单线程执行结果的前提下,调整指令的执行顺序)。在多线程环境下,指令重排序可能会导致线程之间的执行顺序混乱,从而引发有序性问题。

2. 成因(结合JMM)

有序性问题的根源:JMM允许CPU和JVM进行指令重排序,只要重排序后的结果,在单线程环境下与原代码顺序执行的结果一致即可。但在多线程环境下,重排序会打破线程之间的执行顺序约定,导致数据不一致。

举个通俗的例子:单线程中,代码顺序是“a=1; b=a;”,JVM可能会重排序为“b=a; a=1;”吗?不会——因为重排序后,b的结果会变成0(默认值),与原顺序执行的结果(b=1)不一致。但如果代码是“a=1; b=2;”,JVM可以重排序为“b=2; a=1;”,因为单线程下,无论顺序如何,a和b的最终值都是1和2,不影响结果。但在多线程环境下,这种重排序就可能引发问题。

3. 代码案例(直观感受有序性问题)

模拟多线程下的指令重排序问题:

public class OrderingDemo {
    private static int a = 0;
    private static boolean flag = false;

    public static void main(String[] args) throws InterruptedException {
        // 线程1:先给a赋值,再设置flag为true
        new Thread(() -> {
            a = 1;          // 操作1
            flag = true;    // 操作2
        }).start();

        // 线程2:先判断flag,若为true,读取a的值
        new Thread(() -> {
            if (flag) {     // 操作3
                System.out.println("a的值:" + a); // 操作4
            }
        }).start();
    }
}

预期结果:要么打印“a的值:1”(线程1先执行完操作1和2,线程2再执行),要么不打印(线程2先执行,flag为false)。

实际结果:可能会打印“a的值:0”。

问题分析:JVM对线程1的操作1和操作2进行了重排序,将操作2(flag=true)排在了操作1(a=1)前面。此时,线程1先执行flag=true,线程2立即检测到flag为true,读取a的值(此时a还未被赋值,还是默认值0),打印出“a的值:0”——这就是有序性问题,指令重排序导致线程2读取到了错误的数据。

补充:这种情况发生的概率不高(因为重排序是随机的),但在高并发场景下,一旦发生,就会导致数据错乱,难以排查。

三、三大并发问题的解决方案(结合JMM规则,根治问题)

三大并发问题的根源,都是破坏了JMM的规则,因此解决方案也围绕“遵循JMM规则”展开。我们针对每个问题,给出具体的解决方案,结合代码案例,让你知道如何落地。

(一)解决可见性问题:使用volatile关键字

volatile关键字是JMM提供的最直接的可见性解决方案,其核心作用有两个:

  1. 禁止CPU缓存:线程对volatile变量的修改,会立即同步回主内存;线程读取volatile变量时,会立即从主内存加载最新值,不会使用工作内存中的副本。

  2. 禁止指令重排序:volatile变量的读写操作,不会被JVM和CPU重排序(部分重排序被禁止),保证执行顺序与代码顺序一致。

修改前面的可见性案例,使用volatile修饰flag:

public class VisibilityDemo {
    // 使用volatile修饰变量,解决可见性问题
    private static volatile boolean flag = false;

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            flag = true;
            System.out.println("线程1修改flag为:" + flag);
        }).start();

        new Thread(() -> {
            while (!flag) {
                // 循环体为空
            }
            System.out.println("线程2读取到flag为:" + flag);
        }).start();
    }
}

运行结果:线程1修改flag为true后,线程2立即读取到最新值,退出循环并打印——可见性问题解决。

注意:volatile只能解决可见性和有序性问题,不能解决原子性问题。比如,用volatile修饰count,执行count++,依然会出现原子性问题(因为count++是复合操作,volatile无法保证其原子性)。

(二)解决原子性问题:使用锁或原子类

原子性问题的核心是“复合操作被打断”,因此解决方案的核心是“保证复合操作的原子性”,主要有两种方式:

1. 使用synchronized锁(悲观锁)

synchronized是Java中的内置锁,其核心作用是:保证同一时刻,只有一个线程能执行被锁保护的代码块,从而保证代码块内操作的原子性。同时,synchronized也能解决可见性问题(遵循JMM的锁规则:解锁操作happens-before加锁操作)。

修改前面的原子性案例,使用synchronized锁:

public class AtomicityDemo {
    private static int count = 0;

    public static void main(String[] args) throws InterruptedException {
        // 定义一个锁对象
        Object lock = new Object();

        for (int i = 0; i < 1000; i++) {
            new Thread(() -> {
                synchronized (lock) { // 加锁,保证count++的原子性
                    for (int j = 0; j < 1000; j++) {
                        count++;
                    }
                }
            }).start();
        }

        Thread.sleep(2000);
        System.out.println("最终count值:" + count); // 输出1000000
    }
}

运行结果:最终count值为1000000,原子性问题解决。

原理:synchronized锁保证了同一时刻,只有一个线程能进入代码块执行count++操作,避免了多个线程同时执行复合操作被打断的问题,从而保证了原子性。

2. 使用原子类(java.util.concurrent.atomic包)

JDK提供了一系列原子类(如AtomicInteger、AtomicLong),其底层通过“CAS(Compare And Swap,比较并交换)”机制实现原子操作,无需加锁,效率更高(乐观锁思想)。

用AtomicInteger修改原子性案例:

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicityDemo2 {
    // 使用AtomicInteger替代int,保证原子性
    private static AtomicInteger count = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 1000; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    count.incrementAndGet(); // 原子性的累加操作
                }
            }).start();
        }

        Thread.sleep(2000);
        System.out.println("最终count值:" + count); // 输出1000000
    }
}

运行结果:最终count值为1000000,原子性问题解决。

原理:CAS机制的核心是“先比较,再交换”——线程读取原子类的值后,计算新值,再比较当前原子类的值是否与读取的值一致(若一致,说明没有被其他线程修改,执行交换;若不一致,重新读取,重复操作),从而保证原子性。

(三)解决有序性问题:使用volatile或synchronized

有序性问题的核心是“指令重排序”,因此解决方案的核心是“禁止指令重排序”,主要有两种方式:

1. 使用volatile关键字

前面提到,volatile不仅能解决可见性问题,还能禁止指令重排序——对于volatile变量的读写操作,JVM会禁止其与其他操作进行重排序,从而保证执行顺序与代码顺序一致。

修改前面的有序性案例,使用volatile修饰flag:

public class OrderingDemo {
    private static int a = 0;
    // 使用volatile修饰flag,禁止指令重排序
    private static volatile boolean flag = false;

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            a = 1;          // 操作1
            flag = true;    // 操作2(volatile变量写操作)
        }).start();

        new Thread(() -> {
            if (flag) {     // 操作3(volatile变量读操作)
                System.out.println("a的值:" + a); // 操作4
            }
        }).start();
    }
}

运行结果:要么打印“a的值:1”,要么不打印,不会再出现“a的值:0”的情况——有序性问题解决。

原理:volatile禁止了操作1和操作2的重排序,保证操作1一定在操作2之前执行;同时,根据volatile变量规则,操作2(写)happens-before操作3(读),保证线程2读取到flag=true时,一定能看到操作1修改后的a=1。

2. 使用synchronized锁

synchronized锁也能解决有序性问题——被synchronized保护的代码块,会被JVM保证“原子性、可见性、有序性”。因为同一时刻只有一个线程能执行代码块,代码块内的指令不会被其他线程打断,也不会被重排序(相对于其他线程而言)。

修改有序性案例,使用synchronized锁:

public class OrderingDemo2 {
    private static int a = 0;
    private static boolean flag = false;
    private static Object lock = new Object();

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            synchronized (lock) {
                a = 1;
                flag = true;
            }
        }).start();

        new Thread(() -> {
            synchronized (lock) {
                if (flag) {
                    System.out.println("a的值:" + a);
                }
            }
        }).start();
    }
}

运行结果:要么打印“a的值:1”,要么不打印,有序性问题解决。

四、常见误区:volatile、synchronized、原子类的使用陷阱

很多开发者虽然知道如何解决三大并发问题,但在实际使用中,容易陷入以下误区,导致问题依然存在,我们逐一梳理:

误区1:volatile能解决原子性问题

错误认知:认为用volatile修饰变量,就能保证所有操作的原子性。

正确认知:volatile只能解决可见性和有序性问题,不能解决原子性问题。比如,volatile int count = 0; count++依然会出现原子性问题,因为count++是复合操作,volatile无法保证其原子性。

误区2:synchronized锁越细越好

错误认知:为了提升效率,将锁的粒度拆得越细越好。

正确认知:锁的粒度过细,会导致锁竞争频繁,反而降低效率;锁的粒度过粗,会导致并发度下降。应根据实际场景,合理设计锁的粒度(比如,多个线程操作不同的变量,可使用不同的锁;操作同一变量,必须使用同一把锁)。

误区3:原子类能解决所有原子性问题

错误认知:认为原子类可以替代锁,解决所有原子性问题。

正确认知:原子类只能解决“单一变量”的原子操作(如累加、赋值),无法解决“多个变量的复合操作”的原子性问题。比如,要实现“a++且b--”的原子操作,原子类无法实现,必须使用synchronized锁。

五、总结:JMM与三大并发问题的核心关联

我们用一句话总结核心逻辑:JMM是多线程访问内存的“规则手册”,三大并发问题(可见性、原子性、有序性)是破坏了这本规则手册的结果,而volatile、synchronized、原子类,是遵循规则手册、解决问题的“工具”

再梳理一遍核心关联:

  1. JMM定义了主内存与工作内存的架构,规定了变量的读写规则,通过happens-before原则保证多线程的可见性。

  2. 可见性问题:源于工作内存与主内存的同步延迟,用volatile解决。

  3. 原子性问题:源于复合操作被线程打断,用synchronized锁或原子类解决。

  4. 有序性问题:源于CPU和JVM的指令重排序,用volatile或synchronized解决。

对于Java开发者而言,掌握JMM与三大并发问题,是写出安全、高效并发代码的基础。在实际开发中,不要盲目使用锁或volatile,而是要先分析问题的根源(是可见性、原子性还是有序性问题),再选择合适的解决方案。

最后记住:并发编程的核心,不是“会用多线程”,而是“理解JMM规则,避免并发问题”。只有吃透JMM,才能从根源上解决三大并发问题,写出稳定、可靠的并发代码。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐