龙空技术网

谈谈 Java 内存模型

爱音乐的程序员小新人 462

前言:

目前我们对“java直接内存”大约比较珍视,各位老铁们都需要知道一些“java直接内存”的相关文章。那么小编也在网上网罗了一些关于“java直接内存””的相关资讯,希望兄弟们能喜欢,小伙伴们快快来了解一下吧!

0. 前言

Java 并发程序要比单线程串行程序复杂很多,很重要的原因在于并发环境下的共享数据一致性和安全性将受到严重挑战。

Java 内存模型 (JMM) 定义了 JVM 如何正确访问计算机主内存。JMM 指定了不同线程如何以及何时可以看到其他线程写入到共享变量的值,以及如何在必要时同步访问共享变量。

作为一名开发者,如果想要设计出能够正确运行的并行程序,理解 JMM 是必要条件。

Java 多线程之间通信一般有两种方式: 共享内存和消息传递 。Java 的并发采用共享内存的方式,共享内存通信方式对于程序员而言总是透明隐式进行的。

JMM 关键技术点都是围绕着多线程的原子性、可见性、有序来讨论的。JMM 解决了 可见性和有序性 的问题,而 锁 解决了 原子性 的问题。

作为一名有追求的 Java 程序猿,你必须要去了解 Java 内存模型(JMM)。通过学习 JMM,你对 java 语言会有种拨开云雾见天日的感觉,这 n 难道还是我最初认识的 Java 吗 ?

1. 什么是 Java 内存模型 (JMM)

假设一个线程为一个共享变量赋值:

count = 1;

那么 Java 内存模型就是为了解决这个问题,” 在什么情况下,读取共享变量 count 的线程能够读取到值 1”。

这似乎看起来是一个很傻的问题。如果在单线程串行环境下的确不算一个问题,但在多线程环境下,若没有内存模型提供正确的同步机制,那么很多情况下线程是不能马上读取到共享变量 count 最新值。

一方面 Java 程序书写顺序与编译后的指令顺序不一定相同,指令存在重排序情况;另一方面每个 CPU 处理器存在缓存,缓存存储了线程读写共享变量的副本。

2. Java 内存模型的抽象

Java 内存模型 (JMM) 定义了 JVM 如何正确访问计算机主内存。JMM 指定了不同线程如何以及何时可以看到其他线程写入到共享变量的值,以及如何在必要时同步访问共享变量。

早期 JDK 的 Java 内存模型不够完善,所以 Java 内存模型在 Java 1.5 中进行了修改。目前,该版本的 Java 内存模型仍然在 Java 8 中使用。

现代计算机硬件内存模型如下所示:

Java 内存模型抽象示意图如下所示:

工作内存在 Java 内存中是一个抽象的概念,一般是指 CPU 高速缓存、寄存器等。

在现代多核处理器系统中处理器一般会存在多层的高速缓存,因为访问内存数据的速度远落后于直接从寄存器、高速缓存获取数据。

高速缓存减少了共享内存总线的访问流量冲突,极大改善了 CPU 的访问性能。缓存使性能得到优化同时也带来的新的具有挑战性的问题。

例如,当两个线程 (处理器) 同时访问同一内存位置的共享变量,在什么条件下双方可以看到相同的值。这其实就是多线程并发中的 内存可见性 问题。

Java 内存模型的可见性问题的底层实现是通过内存屏障 (memory barriers) 实现。

3. JVM 指令重排序 ( Instruction Reorder)

对于一段代码而言,我么习惯性认为代码总是从前而后执行的,依次执行的。在单线程串行环境下,这么理解也是没有错的。

但在多线程并发环境下,那么就有可能出现乱序的情况。从直观感觉可以知道,后面的代码先于之前代码执行。这似乎有点让人有点难以理解?

根本原因在于 JVM 编译器为了提高程序的执行效率,一般会对代码进行优化。

因此,不能保证程序中的代码顺序一定是按照书写顺序执行的,也就是编译器和处理器会对指令进行重排序。

指令重排序不会对单线程程序有影响,但是在多线程并发环境下就会存在很多问题。

3.1 指令重排序对并发程序的影响

我们来看看指令重排序对并发程序的影响,假设有两个线程 A 和线程 B,线程 A 首先执行 write() 方法,仅接着线程 B 再执行 read() 方法。

/** * @author pez1420@gmail.com * @version $Id: ReorderSample.java v 0.1 2018/1/6 18:38 pez1420 Exp $$ */public class ReorderService { private int x = 0; private boolean flag = false; public void write() { x = 1; //1 flag = true;//2 } public void read() { if (flag == true) { //3 x = x * 1; //4 } if (x == 0) { System.out.println("x==0"); } } }public class ThreadA extends Thread { private ReorderService reorderService; public ThreadA(ReorderService reorderService) { this.reorderService = reorderService; } @Override public void run() { reorderService.write(); } }public class ThreadB extends Thread { private ReorderService reorderService; public ThreadB(ReorderService reorderService) { this.reorderService = reorderService; } @Override public void run() { reorderService.read(); } }public class StartMain { public static void main(String[] args) throws InterruptedException { for (int i = 0; i < 10000; i++) { ReorderService reorderService = new ReorderService(); ThreadA threadA = new ThreadA(reorderService); ThreadB threadB = new ThreadB(reorderService); threadA.start(); threadB.start(); threadA.join(); threadB.join(); } } }

步骤 1 和步骤 2,步骤 3 和步骤 4 都可能存在重排序的情况。如果发生了重排序,那么线程 B 在执行到步骤 4 时,不一定能看到 x 被赋值为 1。

步骤 1 和步骤 2 重排序

从上图我们可以看到线程 A 的步骤 1 和步骤 2 进行了重排序。

程序运行时,线程 A 首先执行步骤 2flag 的赋值操作将其设置为 true;接着线程 B 执行步骤 3,由于条件为真,线程 B 将读取共享变量 x 的值,此时 x 的值还未被线程 A 写入,所有多线程程序语义由于程序重排序被破坏了!

这是一个让然觉得很神奇的现象,不过它的确可能存在。但指令重排序有一个基本前提:指令重排序需要确保串行语义一致,但不能够确保多线程间的语义也是一致的。

步骤 3 和步骤 4 重排序如果步骤 3 和步骤 4 重排序会产生什么奇怪的结果?步骤 3 和步骤 4 存在依赖关系,因此编译器和 CPU 会采用一种猜测方式来克服控制相关性对并行度的影响。处理器会提前读取 x * 1,然后把计算结果保存至一个缓冲区中。当操作 3 条件为真时,就将缓冲区结果写入共享变量 x 中。

从上图可以看出,编译器和处理器将步骤 3 和步骤 4 进行了重排序。

3.2 为什么要指令重排序 ?

对于处理器而言,一条汇编指令的执行时分为很多步骤的。在多处理器下,一个汇编指令不一定是原子操作的。

指令重排序涉及到一些计算机组成原理课程的知识,想深入研究的同学可以回去翻翻课本。为提高 CPU 利用率,加快执行速度,将指令分为若干个阶段,可并行执行不同指令的不同阶段,从而多个指令可以同时执行。

在有效地控制了流水线阻塞的情况下,流水线可大大提高指令执行速度。经典的五级流水线,也就是一条指令可以分为 5 个步骤:

取址 (IF,Instruction Fetch, 取指 );译码 / 读寄存器 (ID,Instruction Decode, 译码);执行 / 计算有效地址 (EX,Execute, 执行);访问内存(读或写)(MEM,Memory Access, 内存数据读或者写);结果写回寄存器 (WB,Write Back, 数据写回到通用寄存器中)。

上图左边操作为汇编指令,LW 表示把值 B 加载至寄存器 R1;ADD 指令表示加法,将寄存器 R1 和 R2 值相加写入寄存器 R3;SW 表示将寄存器 R3 的值写入变量 A 中。

ADD 指令和 SW 指令分别有个红叉,表示流水线中断。ADD 指令中断原因是等待 R2 的结果,SW 指令中断原因是等待 R3 的结果。

我们再分析一个复杂的例子:

a = b + c; d = e - f;

上面的代码指令流水线为:

由于中断流水线的存在导致停顿, 哪是否有消除停顿的方法?显然是有的,我们可以将

LW Re,e LW Rf,f

移动前面执行,理解起来很简单,因为这两个操作对程序的执行语义是没有影响的。

指令进行重排序之后的结果,可以看到所有的中断都已经被消除掉。

显然指令重排序对于提高 CPU 的吞吐能力是有极大的提升的。但也带来了程序运行乱序的负面问题,不过与性能相比较这点牺牲是值得的。

指令重排序还有一个非常经典的例子,就是单例模式与双重检查锁问题 (double-checked locking)。

3.3 双重检查锁问题 (double-checked locking)

单例模式是所有设计模式中最为简单最好理解的设计方式。通过单例模式与多线程指令重排序、内存可见性相结合,你能够考虑到之前许多重来未考虑的问题。

3.3.1 饿汉模式

饿汉单例模式是在单例类被加载时,对象实例已经被初始化。

public class SingleInstance { private static final SingleInstance INSTANCE = new SingleInstance(); private SingleInstance() { } public static SingleInstance getInstance() { return INSTANCE; } }

3.3.2 懒汉模式

懒汉模式是指在调用 getInstance 方法时,实例对象才会被创建,最为常见的方法是在 getInstance 方法中进行实例化。

public class SingleInstance { private static SingleInstance INSTANCE; private SingleInstance() { } public static SingleInstance getInstance() { if (INSTANCE != null) { return INSTANCE; } else { INSTANCE = new SingleInstance(); } return INSTANCE; } }

懒汉模式延迟加载模式在多线程并发环境中,就会出现获取多个实例的情况,与单例模式初衷是相违背的。

3.3.3 懒汉模式解决方案

既然多个线程可以同时进入 getInstance 方法,需要对该方法进行同步,在方法增加同步关键字 synchronized。

public class SingleInstance { private static SingleInstance INSTANCE; private SingleInstance() { } public static synchronized SingleInstance getInstance() { if (INSTANCE != null) { return INSTANCE; } else { INSTANCE = new SingleInstance(); } return INSTANCE; } }

显然,每次调用 getInstance 方法都需要进行同步,效率太低了。。那有没有更好的办好呢?我们想到可以用同步代码块的方式减小同步代码的粒度。

public static SingleInstance getInstance() { synchronized (SingleInstance.class) { if (INSTANCE != null) { return INSTANCE; } else { INSTANCE = new SingleInstance(); } } return INSTANCE; }

需要指出的是上面这种优化方式并没有减小同步代码的粒度与 public static synchronized SingleInstance 几乎是一样的,效率并没有提升。

后来又有大神提出使用 DCL 双检查锁机制来实现多线程并发环境的单例对象模式。

public class SingleInstance { private static SingleInstance INSTANCE; private SingleInstance() { } public static synchronized SingleInstance getInstance() { if (INSTANCE == null) { //1 第一次检查 synchronized (SingleInstance.class) { //2 加锁 if (INSTANCE == null) { //3 第二次检查 INSTANCE = new SingleInstance(); //4 对象实例化 , 问题所在 } } } return INSTANCE; } }

上诉的解决方案在第一次检查 INSTANCE 不为 null,则不需要进行加锁操作和对象实例化操作,可极大降低 synchronized 带来的性能消耗。

解决方案看起来似乎是完美的,但这是一个完全错误的优化。

问题的根源出在第 4 个步骤对象实例化,在线程运行至该步骤时,可能拿到的对象的引用为 null。这其中有什么令人诡异的事情发生。

让我们好好回忆之前的指令重排序问题。

实际上,INSTANCE = new SingleInstance();这一步操作可以分解为 3 个步骤:

memory = allocateMemory(); //1-分配内存 initInstance(memory); //2-初始化内存 instance = memory; //3-实例对象 instance 指向 memory 位置

上诉三个步骤中 2 与 3 是存在指令重排序的可能性 (有些 JIT 编译器就会这么干)。2 和 3 指令重排序之后的执行顺序如下:

memory = allocateMemory(); //1-分配内存 instance = memory; //3-实例对象 instance 指向 memory 位置 initInstance(memory); //2-初始化内存

显然此时的 instance 还未进行初始化 ,因此 getInstance() 返回给程序的可能的 值为 null。那么解决这个问题呢?

由于单例模式采用双重检查锁 (DCL) 时存在指令重排问题,Java 语言中 final、synchronized、volatile、lock 等都能保证有序性。

public class SingleInstance { private static volatile SingleInstance INSTANCE; private SingleInstance() { } public static synchronized SingleInstance getInstance() { if (INSTANCE == null) { synchronized (SingleInstance.class) { if (INSTANCE == null) { INSTANCE = new SingleInstance(); } } } return INSTANCE; } }

这个方法需要 JDK5 以及更高版本的支持,JDK5 开始使用 JSR-133 内存模型

3.3.4 volatile 关键字说明

volatile 变量修饰的共享变量进行写操作前会在汇编代码前增加 lock 前缀:

将当前处理器缓存行的数据写回到系统内存;这个写会内存的操作会使其它 cpu 缓存该内存地址的数据无效。

Java 语言 volatile 关键字可以用一句贴切的话来描述 “ 人皆用之,莫见其形 “。理解 volatile 对理解它对理解 Java 的整个多线程的机制非常有帮助。

JVM 内存结构中有一个非常重要的内存区域叫做线程栈 , 每个线程的栈大小可以通过设置 JVM 参数-Xss, -Xss128k 表示每个线程堆栈大小为 128K,JDK1.5 默认值为 1M。

线程栈内存存储了基本类型变量和对象引用,当访问了对象的某一实例变量时,通过在栈中获得对象引用再获取变量的值,然后将变量的值拷贝至线程的工作内存。

每个线程 (处理器) 都有工作内存,工作内存存了该线程以读写共享变量的副本。工作内存是 JMM 抽象概念 , 并不真实存在。

它涵盖了缓存、写缓冲区、寄存器和其它硬件和编译器优化。

read and load 从主存复制变量到当前工作内存;use and assign 执行代码,改变共享变量值;store and write 用工作内存数据刷新主存相关内容;其中 use and assign 可以多次出现。

但是这一些操作并不是原子性,也就是在 read load 之后,如果主内存 count 变量发生修改之后,线程工作内存中的值由于已经加载,不会产生对应的变化,所以计算出来的结果会和预期不一样。

可见性指的是一个线程对变量的写操作对其他线程后续的读操作可见。

由于现代 CPU 都有多级缓存,CPU 的操作都是基于高速缓存的,而线程通信是基于内存的,这中间有一个 Gap, 可见性的关键还是在对变量的写操作之后能够在某个时间点显示地写回到主内存,这样其他线程就能从主内存中看到最新的写的值。

volatile,synchronized(隐式锁), 显式锁,原子变量这些同步手段都可以保证可见性。可见性底层的实现是通过加内存屏障实现的:

写变量后加写屏障,保证 CPU 写缓冲区的值强制刷新回主内存;读变量之前加读屏障,使缓存失效,从而强制从主内存读取变量最新值。

总结: 在并发环境下,volatile 能够保证有序性、可见性,但原子性没办法保证。

4. happens-before 原则

《JSR-133:Java Memory Model and Thread Specfication》定了如下的 happen-before 规则。

happen 与 before 规则阐述操作之间的内存可见性,目的都是为了在不改变程序的语义情况下提高程序的并行度。在 JMM 中,如果一个操作执行的结果需要对另一个操作线程,那么这两个操作之间必须存在 happen-before 关系。

程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;锁定规则:一个 unLock 操作先行发生于后面对同一个锁的 lock 操作;volatile 变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;传递规则:如果操作 A 先行发生于操作 B,而操作 B 又先行发生于操作 C,则可以得出操作 A 先行发生于操作 C;线程启动规则:Thread 对象的 start() 方法先行发生于此线程的每个一个动作;线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生;线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值手段检测到线程已经终止执行;对象终结规则:一个对象的初始化完成先行发生于他的 finalize() 方法的开始;程序顺序规则:一个线程中的每个操作,happen-before 于该线程中的任意后续操作;监视器锁规则:对一个锁的解锁,happens-before 于随后对这个锁的加锁;Volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读;传递性:如果 A happens-beforeB , 且 B happens-before C, 那么 A happens-before C;Start 规则: 如果线程 A 执行操作 ThreadB.start()(启动线程 B), 那么 A 线程的 ThreadB.start() 操作 happens-before 于线程 B 中的任意操作。

标签: #java直接内存 #java的内存模型有哪些 #java的模型