java并发编程(二十三)——深入探究Java内存模型

本文详细探讨了Java内存模型的三个关键概念:重排序优化及其影响、原子性与原子操作示例,以及内存可见性与多级缓存的关系。通过实例揭示内存模型在并发编程中的实际作用和潜在问题,以及如何通过volatile和synchronized确保数据一致性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

前言

java并发编程(二十二)——初识Java内存模型 中我们了解了Java内存模型主要包含了重排序、原子性、内存可见性三方面最主要的内容,本文主要对这三方面内容进行深入探究,以对Java内存模型有更深层次的认识。

重排序

假设我们写了一个 Java 程序,包含一系列的语句,我们会默认期望这些语句的实际运行顺序和写的代码顺序一致。但实际上,编译器、JVM 或者 CPU 都有可能出于优化等目的,对于实际指令执行的顺序进行调整,这就是重排序。

为什么要重排序呢?重排序的好处:提高处理速度

举例:

重排序-1

图中左侧是 3 行 Java 代码,右侧是这 3 行代码可能被转化成的指令。可以看出 a = 100 对应的是 Load a、Set to 100、Store a,意味着从主存中读取 a 的值,然后把值设置为 100,并存储回去,同理, b = 5 对应的是下面三行 Load b、Set to 5、Store b,最后的 a = a + 10,对应的是 Load a、Set to 110、Store a。如果你仔细观察,会发现这里有两次“Load a”和两次“Store a”,说明存在一定的重排序的优化空间。

经过重排序之后,情况如下图所示:

重排序-2

重排序后, a 的两次操作被放到一起,指令执行情况变为 Load a、Set to 100、Set to 110、 Store a。下面和 b 相关的指令不变,仍对应 Load b、 Set to 5、Store b。

可以看出,重排序后 a 的相关指令发生了变化,节省了一次 Load a 和一次 Store a。重排序通过减少执行指令,从而提高整体的运行速度,这就是重排序带来的优化和好处。

重排序的三种情况

编译器优化

编译器(包括 JVM、JIT 编译器等)出于优化的目的,例如当前有了数据 a,把对 a 的操作放到一起效率会更高,避免读取 b 后又返回来重新读取 a 的时间开销,此时在编译的过程中会进行一定程度的重排。不过重排序并不意味着可以任意排序,它需要需要保证重排序后,不改变单线程内的语义,否则如果能任意排序的话,程序早就逻辑混乱了。

CPU 重排序

CPU 同样会有优化行为,这里的优化和编译器优化类似,都是通过乱序执行的技术来提高整体的执行效率。所以即使之前编译器不发生重排,CPU 也可能进行重排,我们在开发中,一定要考虑到重排序带来的后果。

内存的“重排序”

内存系统内不存在真正的重排序,但是内存会带来看上去和重排序一样的效果,所以这里的“重排序”打了双引号。由于内存有缓存的存在,在 JMM 里表现为主存和本地内存,而主存和本地内存的内容可能不一致,所以这也会导致程序表现出乱序的行为。

举个例子,线程 1 修改了 a 的值,但是修改后没有来得及把新结果写回主存或者线程 2 没来得及读到最新的值,所以线程 2 看不到刚才线程 1 对 a 的修改,此时线程 2 看到的 a 还是等于初始值。但是线程 2 却可能看到线程 1 修改 a 之后的代码执行效果,表面上看起来像是发生了重顺序。

原子性

什么是原子性和原子操作

在编程中,具备原子性的操作被称为原子操作。原子操作是指一系列的操作,要么全部发生,要么全部不发生,不会出现执行一半就终止的情况。

比如转账行为就是一个原子操作,该过程包含扣除余额、银行系统生成转账记录、对方余额增加等一系列操作。虽然整个过程包含多个操作,但由于这一系列操作被合并成一个原子操作,所以它们要么全部执行成功,要么全部不执行,不会出现执行一半的情况。比如我的余额已经扣除,但是对方的余额却不增加,这种情况是不会出现的,所以说转账行为是具备原子性的。而具有原子性的原子操作,天然具备线程安全的特性。

下面我们举一个不具备原子性的例子,比如 i++ 这一行代码在 CPU 中执行时,可能会从一行代码变为以下的 3 个指令:

第一个步骤是读取;

第二个步骤是增加;

第三个步骤是保存。

这就说明 i++ 是不具备原子性的,同时也证明了 i++ 不是线程安全的。

i++的这个问题在Java并发编程(七)——常见的三种线程安全问题一文中我们有过分析。

Java中的原子操作

Java 中的以下几种操作是具备原子性的,属于原子操作:

  1. 除了 long 和 double 之外的基本类型(int、byte、boolean、short、char、float)的读/写操作,都天然的具备原子性;

  2. 所有引用 reference 的读/写操作;

  3. 加了 volatile 后,所有变量的读/写操作(包含 long 和 double)。这也就意味着 long 和 double 加了 volatile 关键字之后,对它们的读写操作同样具备原子性;

  4. 在 java.concurrent.Atomic 包中的一部分类的一部分方法是具备原子性的,比如 AtomicInteger 的 incrementAndGet 方法。

long和double的原子性

long 和 double 和其他的基本类型不太一样,long 和 double 的值需要占用 64 位的内存空间,而对于 64 位值的写入,可以分为两个 32 位的操作来进行。

这样一来,本来是一个整体的赋值操作,就可能被拆分为低 32 位和高 32 位的两个操作。如果在这两个操作之间发生了其他线程对这个值的读操作,就可能会读到一个错误、不完整的值。

JVM 的开发者可以自由选择是否把 64 位的 long 和 double 的读写操作作为原子操作去实现,并且规范推荐 JVM 将其实现为原子操作。当然,JVM 的开发者也有权利不这么做,这同样是符合规范的。

规范同样规定,如果使用 volatile 修饰了 long 和 double,那么其读写操作就必须具备原子性了。同时,规范鼓励程序员使用 volatile 关键字对这个问题加以控制,由于规范规定了对于 volatile long 和 volatile double 而言,JVM 必须保证其读写操作的原子性,所以加了 volatile 之后,对于程序员而言,就可以确保程序正确。

思考:在实际开发过程中没有给 long 和 double 加 volatile,好像也没有出现过问题?而且,在以后的开发过程中,是不是必须给 long 和 double 加 volatile 才是安全的?

其实在实际开发中,读取到“半个变量”的情况非常罕见,这个情况在目前主流的 Java 虚拟机中不会出现。因为 JVM 规范虽然不强制虚拟机把 long 和 double 的变量写操作实现为原子操作,但它其实是“强烈建议”虚拟机去把该操作作为原子操作来实现的。

而在目前各种平台下的主流虚拟机的实现中,几乎都会把 64 位数据的读写操作作为原子操作来对待,因此我们在编写代码时一般不需要为了避免读到“半个变量”而把 long 和 double 声明为 volatile 的。

原子操作 + 原子操作 != 原子操作

值得注意的是,简单地把原子操作组合在一起,并不能保证整体依然具备原子性。比如连续转账两次的操作行为,显然不能合并当做一个原子操作,虽然每一次转账操作都是具备原子性的,但是将两次转账合为一次的操作,这个组合就不具备原子性了,因为在两次转账之间可能会插入一些其他的操作,例如系统自动扣费等,导致第二次转账失败,而且第二次转账失败并不会影响第一次转账成功。

内存可见性

CPU的多级缓存

由于 CPU 的处理速度很快,相比之下,内存的速度就显得很慢,所以为了提高 CPU 的整体运行效率,减少空闲时间,在 CPU 和内存之间会有 cache 层,也就是缓存层的存在。虽然缓存的容量比内存小,但是缓存的速度却比内存的速度要快得多,其中 L1 缓存的速度仅次于寄存器的速度。结构示意图如下所示:

主内存-1

在图中,从下往上分别是内存,L3 缓存、L2 缓存、L1 缓存,寄存器,然后最上层是 CPU 的 4个核心。从内存,到 L3 缓存,再到 L2 和 L1 缓存,它们距离 CPU 的核心越来越近了,越靠近核心,其容量就越小,但是速度也越快。正是由于缓存层的存在,才让我们的 CPU 能发挥出更好的性能。

其实,线程间对于共享变量的可见性问题,并不是直接由多核引起的,而是由我们刚才讲到的这些 L3 缓存、L2 缓存、L1 缓存,也就是多级缓存引起的:每个核心在获取数据时,都会将数据从内存一层层往上读取,同样,后续对于数据的修改也是先写入到自己的 L1 缓存中,然后等待时机再逐层往下同步,直到最终刷回内存。

假设 core 1 修改了变量 a 的值,并写入到了 core 1 的 L1 缓存里,但是还没来得及继续往下同步,由于 core 1 有它自己的的 L1 缓存,core 4 是无法直接读取 core 1 的 L1 缓存的值的,那么此时对于 core 4 而言,变量 a 的值就不是 core 1 修改后的最新的值,core 4 读取到的值可能是一个过期的值,从而引起多线程时可见性问题的发生。

一句话总结,就是因为CPU 有多级缓存,导致读的数据过期。

主内存和工作内存

Java 作为高级语言,屏蔽了 L1 缓存、L2 缓存、L3 缓存,也就是多层缓存的这些底层细节,用 JMM 定义了一套读写数据的规范。我们不再需要关心 L1 缓存、L2 缓存、L3 缓存等多层缓存的问题,我们只需要关心 JMM 抽象出来的主内存和工作内存的概念。为了更方便的理解,可参考下图:

主内存-2

每个线程只能够直接接触到工作内存,无法直接操作主内存,而工作内存中所保存的正是主内存的共享变量的副本,主内存和工作内存之间的通信是由 JMM 控制的。

主内存和工作内存的关系

JMM 有以下规定:

(1)所有的变量都存储在主内存中,同时每个线程拥有自己独立的工作内存,而工作内存中的变量的内容是主内存中该变量的拷贝;

(2)线程不能直接读 / 写主内存中的变量,但可以操作自己工作内存中的变量,然后再同步到主内存中,这样,其他线程就可以看到本次修改;

(3) 主内存是由多个线程所共享的,但线程间不共享各自的工作内存,如果线程间需要通信,则必须借助主内存中转来完成。

从上图中可以看出,每个工作内存中的变量都是对主内存变量的一个拷贝,相当于是一个副本。而且图中没有一条线是可以直接连接各个工作内存的,因为工作内存之间的通信,都需要通过主内存来中转。

正是由于所有的共享变量都存在于主内存中,每个线程有自己的工作内存,其中存储的是变量的副本,所以这个副本就有可能是过期的,我们来举个例子:如果一个变量 x 被线程 A 修改了,只要还没同步到主内存中,线程 B 就看不到,所以此时线程 B 读取到的 x 值就是一个过期的值,这就导致了可见性问题。

案例分析

案例一

先来看一个案例:

public class Visibility {

    int x = 0;


    public void write() {
        x = 1;
    }

    public void read() {
        int y = x;
    }
}

在上面的代码中有一个变量 x,它是 int 类型的,两个方法:

  • write 方法,作用是给 x 赋值,代码中,把 x 赋值为 1,由于 x 的初始值是 0,所以执行 write 方法相当于改变了 x 的值;
  • read 方法,作用是把 x 读取出来,读取的时候我们用了一个新的 int 类型变量的 y 来接收 x 的值。

我们假设有两个线程来执行上述代码,第 1 个线程执行的是 write 方法,第 2 个线程执行的是 read 方法。下面我们来分析一下,代码在实际运行过程中的情景是怎么样的,如下图所示:

内存可见性-1

在图中可以看出,由于 x 的初始值为 0,所以对于左边的第 1 个线程和右边的第 2 个线程而言,它们都可以从主内存中去获取到这个信息,对两个线程来说 x 都是 0。可是此时我们假设第 1 个线程先去执行 write 方法,它就把 x 的值从 0 改为了 1,但是它改动的动作并不是直接发生在主内存中的,而是会发生在第 1 个线程的工作内存中,如下图所示。

内存可见性-2

那么,假设线程 1 的工作内存还未同步给主内存,此时假设线程 2 开始读取,那么它读到的 x 值不是 1,而是 0,也就是说虽然此时线程 1 已经把 x 的值改动了,但是对于第 2 个线程而言,根本感知不到 x 的这个变化,这就产生了可见性问题。

案例二

再来看另一个案例:

/**

 * 描述:     演示可见性带来的问题

 */

public class VisibilityProblem {



    int a = 10;

    int b = 20;

    private void change() {
       a = 30;
       b = a;
    }


    private void print() {
        System.out.println("b=" + b + ";a=" + a);
    }

    public static void main(String[] args) {

        while (true) {

            VisibilityProblem problem = new VisibilityProblem();

            new Thread(new Runnable() {

                @Override
                public void run() {

                    try {
                         Thread.sleep(1);
                    } catch (InterruptedException e) {
                       e.printStackTrace();
                    }
                    problem.change();
                }

            }).start();


            new Thread(new Runnable() {

                @Override
                public void run() {

                    try {
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    problem.print();
                }
            }).start();
        }
    }
}

在类中,有两个方法:

  • change 方法,把 a 改成 30,然后把 b 赋值为 a 的值;
  • print 方法,先打印出 b 的值,然后再打印出 a 的值。

在 main 函数中同样非常简单。首先有一个 while 的死循环,在这个循环中,我们新建两个线程,并且让它们先休眠一毫秒,然后再分别去执行 change 方法和 print 方法。休眠一毫秒的目的是让它们执行这两个方法的时间,尽可能的去靠近。
下面我们运行这段代码并分析一下可能出现的情况:

  1. 第一种情况,假设第 1 个线程,也就是执行 change 的线程先运行,并且运行完毕了,然后第 2 个线程开始运行,那么第 2 个线程自然会打印出 b = 30;a = 30 的结果。
  2. 第二种情况,与第 1 种情况相反。因为线程先 start,并不代表它真的先执行,所以第 2 种情况是第 2 个线程先打印,然后第 1 个线程再去进行 change,那么此时打印出来的就是 a 和 b 的初始值,打印结果为 b = 20;a = 10。
  3. 第三种情况,它们几乎同时运行,所以会出现交叉的情况。比如说当第 1 个线程的 change 执行到一半,已经把 a 的值改为 30 了,而 b 的值还未来得及修改,此时第 2 个线程就开始打印了,所以此时打印出来的 b 还是原始值 20,而 a 已经变为了 30, 即打印结果为 b = 20;a = 30。

这些都很好理解,但是有一种情况不是特别容易理解,那就是打印结果为 b = 30;a = 10,我们来想一下,为什么会发生这种情况?

首先打印出来的是 b = 30,这意味着 b 的值被改变了,也就是说 b = a 这个语句已经执行了;

如果 b = a 要想执行,那么前面 a = 30 也需要执行,此时 b 才能等于 a 的值,也就是 30;

这也就意味着 change 方法已经执行完毕了。

可是在这种情况下再打印 a,结果应该是 a = 30,而不应该打印出 a = 10。因为在刚才 change 执行的过程中,a 的值已经被改成 30 了,不再是初始值的 10。所以,如果出现了打印结果为 b = 30;a = 10 这种情况,就意味着发生了可见性问题:a 的值已经被第 1 个线程修改了,但是其他线程却看不到,由于 a 的最新值却没能及时同步过来,所以才会打印出 a 的旧值。发生上述情况的几率不高。

解决方案

我们应该如何避免可见性问题呢?在案例一中,我们可以使用 volatile 来解决问题,我们在原来的代码的基础上给 x 变量加上 volatile 修饰,其他的代码不变。加了 volatile 关键字之后,只要第 1 个线程修改完了 x 的值,那么当第 2 个线程想读取 x 的时候,它一定可以读取到 x 的最新的值,而不可能读取到旧值。

同理,我们也可以用 volatile 关键字来解决案例二的问题,如果我们给 a 和 b 加了 volatile 关键字后,无论运行多长时间,也不会出现 b = 30;a = 10 的情况,这是因为 volatile 保证了只要 a 和 b 的值发生了变化,那么读取的线程一定能感知到。

保证可见性的措施

除了 volatile 关键字可以让变量保证可见性外,synchronized、Lock、并发集合等一系列工具都可以在一定程度上保证可见性。

从可见性来看synchronized

关于 synchronized 这里有一个特别值得说的点,我们之前可能一直认为,使用了 synchronized 之后,它会设立一个临界区,这样在一个线程操作临界区内的数据的时候,另一个线程无法进来同时操作,所以保证了线程安全。

其实这是不全面的,这种说法没有考虑到可见性问题,完整的说法是:synchronized 不仅保证了临界区内最多同时只有一个线程执行操作,同时还保证了在前一个线程释放锁之后,之前所做的所有修改,都能被获得同一个锁的下一个线程所看到,也就是能读取到最新的值。因为如果其他线程看不到之前所做的修改,依然也会发生线程安全问题。

总结

本文主要从重排序、原子性、内存可见性三方面,通过具体的代码实例出发,一步步进行深入探究剖析,从而对Java内存模型有了更深层次的认识。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

程序员资料站

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值