Java多线程与高并发专题——什么是 Java 内存模型?

引入

本文我们回顾并拓展一下JMM的相关内容,在阅读前,最好先看一下前面的如下文章:

  •  线程安全问题与性能问题
  •  JMM
  •  保障原子性
  •  保障可见性和有序性

JMM 是什么

JMM 是规范

JMM 是和多线程相关的一组规范,需要各个 JVM 的实现来遵守 JMM 规范,以便于开发者可以利用这些规范,更方便地开发多线程程序。这样一来,即便同一个程序在不同的虚拟机上运行,得到的程序结果也是一致的。

如果没有 JMM 内存模型来规范,那么很可能在经过了不同 JVM 的“翻译”之后,导致在不同的虚拟机上运行的结果不一样,那是很大的问题。

因此,JMM 与处理器、缓存、并发、编译器有关。它解决了 CPU 多级缓存、处理器优化、指令重排等导致的结果不可预期的问题。

JMM 是工具类和关键字的原理

之前我们使用了各种同步工具和关键字,包括 volatile、synchronized、Lock 等,其实它们的原理都涉及 JMM。正是 JMM 的参与和帮忙,才让各个同步工具和关键字能够发挥作用,帮我们开发出并发安全的程序。

比如我们写了关键字 synchronized,JVM 就会在 JMM 的规则下,“翻译”出合适的指令,包括限制指令之间的顺序,以便在即使发生了重排序的情况下,也能保证必要的“可见性”,这样一来,不同的 JVM 对于相同的代码的执行结果就变得可预期了,我们 Java 程序员就只需要用同步工具和关键字就可以开发出正确的并发程序了,这都要感谢 JMM。

JMM 里最重要 3 点内容,分别是:重排序、原子性、内存可见性。

重排序

什么是重排序

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

重排序的好处:提高处理速度

重排序的 3 种情况

下面我们来看一下重排序的 3 种情况。

  1. 编译器优化
    编译器(包括 JVM、JIT 编译器等)出于优化的目的,例如当前有了数据 a,把对 a的操作放到一起效率会更高,避免读取 b 后又返回来重新读取 a 的时间开销,此时在编译的过程中会进行一定程度的重排。不过重排序并不意味着可以任意排序,它需要需要保证重排序后,不改变单线程内的语义,否则如果能任意排序的话,程序早就逻辑混乱了。
  2. CPU 重排序
    CPU 同样会有优化行为,这里的优化和编译器优化类似,都是通过乱序执行的技术来提高整体的执行效率。所以即使之前编译器不发生重排,CPU 也可能进行重排,我们在开发中,一定要考虑到重排序带来的后果。
  3. 内存的“重排序”
    内存系统内不存在真正的重排序,但是内存会带来看上去和重排序一样的效果,所以这里的“重排序”打了双引号。由于内存有缓存的存在,在 JMM 里表现为主存和本地内存,而主存和本地内存的内容可能不一致,所以这也会导致程序表现出乱序的行为。
    举个例子,线程 1 修改了 a 的值,但是修改后没有来得及把新结果写回主存或者线程 2 没来得及读到最新的值,所以线程 2 看不到刚才线程 1 对 a 的修改,此时线程2 看到的 a 还是等于初始值。但是线程 2 却可能看到线程 1 修改 a 之后的代码执行效果,表面上看起来像是发生了重顺序。

原子性

什么是原子性和原子操作

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

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

Java 中的原子操作有哪些

在了解了原子操作的特性之后,让我们来看一下 Java 中有哪些操作是具备原子性的。Java 中的以下几种操作是具备原子性的,属于原子操作:

  • 除了 long 和 double 之外的基本类型(int、byte、boolean、short、char、float)的读/写操作,都天然的具备原子性;
  • 所有引用 reference 的读/写操作;
  • 加了 volatile 后,所有变量的读/写操作(包含 long 和 double)。这也就意味着 long 和 double 加了 volatile 关键字之后,对它们的读写操作同样具备原子性;
  • 在 java.concurrent.Atomic 包中的一部分类的一部分方法是具备原子性的,比如 AtomicInteger 的 incrementAndGet 方法。

long 和 double 的原子性

在前面,我们讲述了 long 和 double 和其他的基本类型不太一样,好像不具备原子性,这是什么原因造成的呢? 官方文档对于上述问题的描述,如下所示:

Non-Atomic Treatment of double and long For the purposes of the Java programming language memory model, a single write to a non-volatile long or double value is treated as two separate writes: one to each 32-bit half. This can result in a situation where a thread sees the first 32 bits of a 64-bit value from one write, and the second 32 bits from another write. Writes and reads of volatile long and double values are always atomic. Writes to and reads of references are always atomic, regardless of whether they are implemented as 32-bit or 64-bit values. Some implementations may find it convenient to divide a single write action on a 64-bit long or double value into two write actions on adjacent 32-bit values. For efficiency's sake, this behavior is implementation-specific; an implementation of the Java Virtual Machine is free to perform writes to long and double values atomically or in two parts. Implementations of the Java Virtual Machine are encouraged to avoid splitting 64-bit values where possible. Programmers are encouraged to declare shared 64-bit values as volatile or synchronize their programs correctly to avoid possible complications.

翻译:

双精度浮点数(double)和长整型(long)的非原子处理
就 Java 编程语言内存模型而言,对非易失性的 long 或 double 值的单次写入会被视为两次独立的写入:分别对 64 位值的两个 32 位部分进行写入。这可能导致一种情况,即一个线程从一次写入中看到 64 位值的前 32 位,而从另一次写入中看到后 32 位。
对 volatile 修饰的 long 和 double 值的写入和读取始终是原子操作。
对引用的写入和读取始终是原子操作,无论它们是以 32 位还是 64 位值实现的。
某些实现可能会觉得将对 64 位 long 或 double 值的单个写入操作拆分为对相邻 32 位值的两个写入操作更为便捷。出于效率考虑,这种行为因实现而异;Java 虚拟机的实现可以自由选择以原子方式或分两部分对 long 和 double 值执行写入操作。
鼓励 Java 虚拟机的实现尽可能避免拆分 64 位值。同时也建议程序员将共享的 64 位值声明为 volatile 类型,或者正确地同步他们的程序,以避免可能出现的复杂问题。

从上面的 JVM 规范中我们可以知道,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 的。

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

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

内存可见性

什么时内存可见性

我们先从两个案例来入手,看一看什么是可见性问题。

案例一

我们来看看下面的代码,有一个变量 x,它是 int 类型的,如下所示:

/**
 * 该类用于演示变量的可见性问题。
 * 包含一个共享变量 x,以及写操作和读操作方法。
 */
public class Visibility {
    // 定义一个整型变量 x 并初始化为 0
    int x = 0;

    /**
     * 写操作方法,将共享变量 x 的值设置为 1。
     */
    public void write() {
        // 将变量 x 的值设置为 1
        x = 1;
    }

    /**
     * 读操作方法,读取共享变量 x 的值并赋值给局部变量 y。
     */
    public void read() {
        // 将变量 x 的值赋给变量 y
        int y = x;
    }
}

这是一段很简单的代码,类中有两个方法:

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

我们假设有两个线程来执行上述代码,第 1 个线程执行的是 write 方法,第 2 个线程执行的是 read 方法。

下面我们来分析一下,代码在实际运行过程中的情景是怎么样的:由于 x 的初始值为 0,所以对于第 1 个线程和第 2 个线程而言,它们都可以从主内存中去获取到这个信息,对两个线程来说 x 都是 0。可是此时我们假设第 1 个线程先去执行write 方法,它就把 x 的值从 0 改为了 1,但是它改动的动作并不是直接发生在主内存中的,而是会发生在第 1 个线程的工作内存中。那么假设线程 1 的工作内存还未同步给主内存,此时假设线程 2 开始读取,那么它读到的 x 值不是 1,而是 0,也就是说虽然此时线程 1 已经把 x 的值改动了,但是对于第 2 个线程而言,根本感知不到 x 的这个变化,这就产生了可见性问题。

案例二

下面我们再来看一个案例。在如下所示的代码中,有两个变量 a 和 b, 并且把它们赋初始值为 10 和 20。

/**
 * 该类用于演示Java中的可见性问题。
 * 它创建了两个线程,一个线程用于修改变量的值,另一个线程用于打印变量的值。
 */
public class VisibilityProblem {
    // 初始化变量 a 为 10
    int a = 10;
    // 初始化变量 b 为 20
    int b = 20;

    /**
     * 此方法用于改变变量 a 和 b 的值。
     * 将变量 a 的值改为 30,并将变量 b 的值设置为 a 的值。
     */
    private void change() {
        // 将变量 a 的值修改为 30
        a = 30;
        // 将变量 b 的值设置为变量 a 的值
        b = a;
    }

    /**
     * 此方法用于打印变量 a 和 b 的值。
     * 打印格式为 "b=值;a=值"。
     */
    private void print() {
        // 打印变量 b 和 a 的值
        System.out.println("b=" + b + ";a=" + a);
    }

    /**
     * 程序的主入口点。
     * 创建一个无限循环,在每次循环中创建两个线程:一个用于修改变量的值,另一个用于打印变量的值。
     *
     * @param args 命令行参数
     */
    public static void main(String[] args) {
        // 无限循环,持续创建并启动线程
        while (true) {
            // 创建一个 VisibilityProblem 类的实例
            VisibilityProblem problem = new VisibilityProblem();
            // 创建一个新线程,用于调用 change 方法
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        // 线程休眠 1 毫秒
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
                        // 打印异常堆栈信息
                        e.printStackTrace();
                    }
                    // 调用 change 方法修改变量的值
                    problem.change();
                }
            }).start();
            // 创建一个新线程,用于调用 print 方法
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        // 线程休眠 1 毫秒
                        Thread.sleep(1);
                    } catch (InterruptedException e) {
                        // 打印异常堆栈信息
                        e.printStackTrace();
                    }
                    // 调用 print 方法打印变量的值
                    problem.print();
                }
            }).start();
        }
    }
}

在类中,有两个方法:

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

接下来我们来看一下 main 函数,在 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 这里有一个特别值得说的点,我们之前可能一致认为,使用了 synchronized 之后,它会设立一个临界区,这样在一个线程操作临界区内的数据的时候,另一个线程无法进来同时操作,所以保证了线程安全。

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

你可能感兴趣的:(Java并发编程,java,开发语言,并发编程)