Java多线程学习三十六:Java内存结构与Java内存模型的区别

本课时我们主要介绍什么是 Java 内存模型?

如果你想了解 Java 并发的底层原理,那么 Java 内存模型的知识非常重要,同时也是一个分水岭,可以区分出我们是仅停留在如何使用并发工具,还是能更进一步,知其所以然。

VM 内存结构 VS Java 内存模型

Java 作为一种面向对象的语言,有很多概念,从名称上看起来比较相似,比如 JVM 内存结构、Java 内存模型,这是两个截然不同的概念,但是很容易混淆。网络上也有不少讲 Java 内存模型的文章,其实写的是 JVM 内存结构。

所以我们就先从整体上概括一下这两者的主要作用:

JVM 内存结构和 Java 虚拟机的运行时区域有关

Java 内存模型和 Java 的并发编程有关

所以可以看出,这两个概念其实是有很大区别的。下面我们先来简要介绍一下 JVM 内存结构。

JVM 内存结构

我们都知道,Java 代码是要运行在虚拟机上的,而虚拟机在执行 Java 程序的过程中会把所管理的内存划分为若干个不同的数据区域,这些区域都有各自的用途。在《Java 虚拟机规范(Java SE 8)》中描述了 JVM 运行时内存区域结构可分为以下 6 个区。

堆区(Heap):堆是存储类实例和数组的,通常是内存中最大的一块。实例很好理解,比如 new Object() 就会生成一个实例;而数组也是保存在堆上面的,因为在 Java 中,数组也是对象。

虚拟机栈(Java Virtual Machine Stacks):它保存局部变量和部分结果,并在方法调用和返回中起作用

方法区(Method Area):它存储每个类的结构,例如运行时的常量池、字段和方法数据,以及方法和构造函数的代码,包括用于类初始化以及接口初始化的特殊方法。

本地方法栈(Native Method Stacks):与虚拟机栈基本类似,区别在于虚拟机栈为虚拟机执行的 Java 方法服务,而本地方法栈则是为 Native 方法服务。

程序计数器(The PC Register):是最小的一块内存区域,它的作用通常是保存当前正在执行的 JVM 指令地址。

运行时常量池(Run-Time Constant Pool):是方法区的一部分,包含多种常量,范围从编译时已知的数字到必须在运行时解析的方法和字段引用。

注意,以上是 Java 虚拟机规范,不同的虚拟机实现会各有不同,一般会遵守规范。

从 Java 代码到 CPU 指令

看完了 JVM 内存结构,就让我们回到 Java 内存模型上来。我们都知道,编写的 Java 代码,最终还是要转化为 CPU 指令才能执行的。为了理解 Java 内存模型的作用,我们首先就来回顾一下从 Java 代码到最终执行的 CPU 指令的大致流程:

最开始,我们编写的 Java 代码,是 *.java 文件;

在编译(包含词法分析、语义分析等步骤)后,在刚才的 *.java 文件之外,会多出一个新的 Java 字节码文件(*.class);

JVM 会分析刚才生成的字节码文件(*.class),并根据平台等因素,把字节码文件转化为具体平台上的机器指令;

机器指令则可以直接在 CPU 上运行,也就是最终的程序执行。

为什么需要 JMM(Java Memory Model,Java 内存模型)

在更早期的语言中,其实是不存在内存模型的概念的。

所以程序最终执行的效果会依赖于具体的处理器,而不同的处理器的规则又不一样,不同的处理器之间可能差异很大,因此同样的一段代码,可能在处理器 A 上运行正常,而在处理器 B 上运行的结果却不一致。同理,在没有 JMM 之前,不同的 JVM 的实现,也会带来不一样的“翻译”结果。

所以 Java 非常需要一个标准,来让 Java 开发者、编译器工程师和 JVM 工程师能够达成一致。达成一致后,我们就可以很清楚的知道什么样的代码最终可以达到什么样的运行效果,让多线程运行结果可以预期,这个标准就是 JMM,这就是需要 JMM 的原因

我们本课时将突破 Java 代码的层次,开始往下钻研,研究从 Java 代码到 CPU 指令的这个转化过程要遵守哪些和并发相关的原则和规范,这就是 JMM 的重点内容。如果不加以规范,那么同样的 Java 代码,完全可能产生不一样的执行效果,那是不可接受的,这也违背了 Java “书写一次、到处运行”的特点

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 行 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”,说明存在一定的重排序的优化空间。

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

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

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

重排序的 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 中的原子操作有哪些注意事项========

 

什么是原子性和原子操作

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

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

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

第一个步骤是读取;

第二个步骤是增加;

第三个步骤是保存。

这就说明 i++ 是不具备原子性的,同时也证明了 i++ 不是线程安全的,正如第 06 课时中所介绍的那样。下面我们简单的复习一下,如何发生的线程不安全问题,如下所示:

我们根据箭头指向依次看,线程 1 首先拿到 i=1 的结果,然后进行 i+1 操作,但假设此时 i+1 的结果还没有来得及被保存下来,线程 1 就被切换走了,于是 CPU 开始执行线程 2,它所做的事情和线程 1 是一样的 i++ 操作,但此时我们想一下,它拿到的 i 是多少?实际上和线程 1 拿到的 i 结果一样,同样是 1,为什么呢?因为线程 1 虽然对 i 进行了 +1 操作,但结果没有保存,所以线程 2 看不到修改后的结果。

然后假设等线程 2 对 i 进行 +1 操作后,又切换到线程 1,让线程 1 完成未完成的操作,即将 i+1 的结果 2 保存下来,然后又切换到线程 2 完成 i=2 的保存操作,虽然两个线程都执行了对 i 进行 +1 的操作,但结果却最终保存了 i=2,而不是我们期望的 i=3,这样就发生了线程安全问题,导致数据结果错误,这也是最典型的线程安全问题。

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 和其他的基本类型不太一样,好像不具备原子性,这是什么原因造成的呢? 官方文档对于上述问题的描述,如下所示:

 

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.

从刚才的 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 的。

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

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

以上就是本课时的内容,我们介绍了什么是原子性,Java 中的原子操作有哪些,并且还对 long 和 double 这一具有特殊性的情况进行了详细说明,最后我们还介绍了简单地把原子操作组合在一起,并不能保证整体依然具备原子性。

==========什么是“内存可见性”问题?========

 

 

什么是“可见性”问题?

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

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

复制代码
public class Visibility {

    int x = 0;

    public void write() {
        x = 1;
    }

    public void read() {
        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 个线程的工作内存中,如下图所示。

 

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

 

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

复制代码
/**
 * 描述:     演示可见性带来的问题
 */
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 函数,在 main 函数中同样非常简单。首先有一个 while 的死循环,在这个循环中,我们新建两个线程,并且让它们先休眠一毫秒,然后再分别去执行 change 方法和 print 方法。休眠一毫秒的目的是让它们执行这两个方法的时间,尽可能的去靠近。

下面我们运行这段代码并分析一下可能出现的情况。

  1. 第 1 种情况:是最普通的情况了。假设第 1 个线程,也就是执行 change 的线程先运行,并且运行完毕了,然后第 2 个线程开始运行,那么第 2 个线程自然会打印出 b = 30;a = 30 的结果。
  2. 第 2 种情况:与第 1 种情况相反。因为线程先 start,并不代表它真的先执行,所以第 2 种情况是第 2 个线程先打印,然后第 1 个线程再去进行 change,那么此时打印出来的就是 a 和 b 的初始值,打印结果为 b = 20;a = 10。
  3. 第 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、并发集合等一系列工具都可以在一定程度上保证可见性,具体保证可见性的时机和手段,我将在第 61 课时 happens-before 原则中详细展开讲解。

synchronized 不仅保证了原子性,还保证了可见性
下面我们再来分析一下之前所使用过的 synchronized 关键字,在理解了可见性问题之后,相信你对 synchronized 的理解会更加深入。

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

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

引用:https://kaiwu.lagou.com/course/courseInfo.htm?courseId=16#/detail/pc?id=294

 

 

 

 

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值