一、JVM 内存模型概述
(1) 为什么会出现 JVM 内存模型呢?
JVM 内存模型是为规范描述 Java 虚拟机在执行 Java 程序时,将程序中的数据和代码存储到计算机内存中的方式和规则。JVM 内存模型定义 Java 虚拟机所使用的内存结构以及内存区域之间的关系,使得 Java 程序能够更高效地运行。
Java 虚拟机是一种基于栈式架构的虚拟机,不同于物理机器上的基于寄存器的架构
。因此,Java 虚拟机需要一个内存模型来处理 Java 程序所涉及的数据和代码存储
,以及执行期间的内存管理
。JVM 内存模型的出现,使得 Java程序能够更加规范、高效、灵活地运行。
JVM 内存模型图,如下所示:
- 程序计数器(Program Counter Register):每个线程都有一个独立的程序计数器,用于存储线程当前执行的字节码指令的地址。
- Java 虚拟机栈(JVM Stack):每个线程都有一个独立的Java虚拟机栈,用于存储局部变量、操作数栈、返回值等信息。
- 本地方法栈(Native Method Stack):与Java虚拟机栈类似,但用于执行本地方法。
- 堆(Heap):用于存储Java对象实例,是所有线程共享的。
- 方法区(Method Area):用于存储类的结构信息、静态变量、常量池等信息,是所有线程共享的。
- 运行时常量池(Runtime Constant Pool):方法区的一部分,用于存储编译时生成的各种字面量和符号引用。
- 直接内存(Direct Memory):一种使用非JVM管理的内存,但通过JVM的API使用它,它通常用于提高I/O操作的性能。
(2) JVM 内存为什么要这样划分?主要有以下几点:
-
分离程序数据和 JVM 内部数据结构:Java虚拟机需要存储很多内部结构和状态信息,同时还需要存储Java程序的数据和代码。为了让这些不同类型的数据结构能够互相独立,Java虚拟机将其存储在不同的内存区域中。
-
内存管理需要:JVM 需要对内存进行管理和优化。划分不同的内存区域,有助于JVM更精确地控制内存的分配、回收、整理等操作,从而提高程序的运行效率和稳定性。比如说,Java程序中会有大量的对象创建和销毁,为了避免频繁地进行垃圾回收,JVM 使用了新生代和老年代两个区域,其中新生代用于存储新创建的对象,老年代用于存储生命周期较长的对象。此外,JVM还提供了方法区和虚拟机栈等区域,用于存储类信息和线程执行时的栈帧信息。通过划分内存区域,JVM 可以更精确地控制内存的分配和释放,避免了内存泄漏和内存碎片等问题。同时,这种设计还可以使得GC算法更高效,因为不同的区域使用不同的 GC 策略,可以根据各自的特点进行优化,提高GC 的效率和响应速度。
-
灵活管理内存:在不同的应用场景下,JVM需要为Java程序分配不同大小、不同生命周期的内存空间。划分不同的内存区域,可以让JVM更灵活地进行内存管理,满足不同的需求。
-
内存安全:JVM的内存模型具有强大的安全机制,可以保护Java程序的数据和代码不被恶意程序所破坏。通过划分不同的内存区域,JVM可以更好地实现内存安全。
综上所述,JVM 划分不同的内存区域主要是为了让 Java 程序的数据和代码存储在不同的内存区域中,更好地管理内存,提高程序的运行效率和稳定性,满足不同的内存需求,以及保障程序的内存安全。
(3) GC 垃圾回收器对应回收算法,如下列表:
-
Serial 收集器:采用标记-清除算法。
-
Parallel 收集器:采用标记-清除算法或标记-整理算法。
-
CMS 收集器:采用标记-清除算法和标记-清除-整理算法(在 CMS 的 initial mark 和 concurrent sweep 阶段使用标记-清除,而在 concurrent mark 和 concurrent sweep 阶段使用标记-清除-整理)。
-
G1 收集器:采用标记-整理算法和复制算法(通过 region 之间的内存拷贝来实现对象的移动和清理)。
需要注意的是,这只是一些常见的垃圾回收器和对应的回收算法,实际上还有很多其他的垃圾回收器和回收算法,而且不同的垃圾回收器也可能采用不同的组合方式来进行回收。
二、JMM 内存模型概述
JMM(Java 内存模型 Java Memory Model,简称 JMM)是一种抽象的概念并不是真实存在,它是描述的一组约定或者说是规范,通过这组规范定义程序中各个变量读写访问方式并且决定一个线程对共享变量的读写何时让另一个线程可见,关键技术点就是围绕多线程的原子性
、可见性
和有序性
三个特性展开,下面会说到什么是原子性
、可见性
和有序性
。
由于 JVM 运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而 Java 内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝,不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图:
程序运行都是靠线程驱动,可以说程序运行载体就是线程,JMM 规范能用来干啥?
- 通过 JMM 来实现线程和主内存之间的抽象关系
- 屏蔽各个硬件平台和操作系统内存访问差异以实现让 Java 程序在各个平台下都能够达到一致访问内存的效果。
假设没有 JMM 控制存在,因为线程之间的副本是不能访问的,并且共享数据实在主内存中的,用以上方式操作变量,必然存在一种数据一致性问题,举个例子如下:
主内存中有个变量 count,初始值为 0,现在线程 A 要将其加 1 操作,那么肯定先要从主内存中先读取到 count 变量拷贝到私有内存中,然后将 count 加 1 操作。线程 A 更新私有内存中的 count 变量值时,准备将其同步会主内存,但是时间是不固定的。假设线程 A 还没有来得及将 count 刷会主内存中,线程 B 也将之前的 count 拷贝到自己私有内存中,此时 count 还是初始值 0,也将其加 1 操作,最终都刷回主内存中,期望值是 count 为 2,但是由于没有可见性保证,导致最终 count 值为 1。
这个就是线程数据脏读
。那么解决这个问题就需要 JMM 规范(可见性
约束)。它的关键技术点就是围绕多线程的原子性
、可见性
和有序性
三个特性展开,下面会说到什么是原子性
、可见性
和有序性
。
三、JMM 三大特性
1、可见性
什么是可见性,是指当一个线程修改了某个共享变量的值,其他线程是否能够立马知道变更,JMM 规定了所有的变量都是存储在主内存中。
一般一个线程想直接去修改主内存中的值是不允许的,需将主内存中值拷贝到当前线程本地内存中,线程对这个共享内存副本做修改,修改完通过 JMM 控制写回主内存中,然后通知其他线程该变量的变更操作。线程之间是无法直接访问对方工作内存中的变量,线程间变量值的传递均通过主内存来完成。
比如下面这个例子:
public class VisibilityDemo {
private static boolean flag = true;
public static void main(String[] args) throws InterruptedException {
Thread thread1 = new Thread(() -> {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
flag = false;
System.out.println("Thread1 set flag to true");
});
Thread thread2 = new Thread(() -> {
while (flag) {
// 此处不断循环等待,直到flag变量被修改
}
System.out.println("Thread2 detected flag change");
});
thread1.start();
thread2.start();
}
}
上面的代码中,有两个线程分别对共享变量 flag 进行读写。在主线程中启动了这两个线程,其中 thread1 线程会在休眠500ms后将 flag 设置为true,而 thread2 线程会在不断循环中等待 flag 变量被修改,一旦检测到 flag 变为true,则会输出 Thread2 detected flag change。
但是,由于没有同步机制,第二个线程可能永远无法检测到第一个线程对共享变量的修改,因为第一个线程对共享变量的修改可能一直存在于该线程的本地缓存中,而没有及时刷新到主内存中,导致第二个线程看不到这个修改。因此,程序的输出结果可能是以下两种情况之一:
程序无法正常退出,一直处于等待状态,因为第二个线程无法检测到第一个线程的修改。
输出 Thread2 detected flag change,但这并不是每次运行都能出现的结果,因为 flag 变量的修改可能一直存在于第一个线程的本地缓存中,没有及时刷新到主内存中。
为了解决这个问题,我们需要使用一些同步机制来保证可见性,例如使用 synchronized
关键字或 volatile
关键字。
2、有序性
对于一个线程代码而言,我们可能习惯性认为是从上往下执行,有序执行,但为了提高性能,编译器和处理器会对指令集进行重排序
。指令重排可以保证串行语义一致
,但是没有义务保证多线程间的语义也一致,极可能产生数据脏读
,换句话说,两行上下不相干的代码在执行有可能先执行的不是第一条,不见得是从上往下执行,执行顺序有可能会被优化。指令集优化可能会发生在以下阶段,如下图示:
1、编译器优化重排序:编译器在不改变程序在单线程环境下运行的语义前提下,可以重新安排语句执行顺序。目的是能够减少寄存器的读取、存储次数,复用寄存器的数据。
比如下面有三条代码,假设此时 A、B 在栈内刚好就是同一个地址空间,那么 B 会覆盖原来 A 的值,在 C 使用 A 时需要重新去读取一遍 A 的值,造成性能下降:
第一步:A = 某个计算的结果值
第二步:B = 某个计算的结果值
第三步:C = 需要使用 A 结果值来进行计算
优化重排之后如下:
第一步:A = 某个计算的结果值
第二步:C = 需要使用 A 结果值来进行计算
第三步:B = 某个计算的结果值
这样就可以 C 就可以复用寄存器中存放的 A 值。
2、指令级并行重排序:处理器多条指令并行执行,如果不存在数据依赖性,处理器可以改变语句对应指令执行顺序。
比如下面这两条指令,完全没有数据依赖,就可以并行执行,提高执行效率:
int a = 5
int b = 6
3、内存系统重排序:处理器使用缓存和读写缓冲区,使得数据的加载、存储操作,看上去是乱序的。
单线程没啥说的,最终执行结果和代码顺序执行的结果肯定是一致的。
处理器进行重排序时必须要考虑指令之间的数据依赖性
,那么什么是数据依赖性
?
比如下面这段代码:
public static synchronized void sop() {
int x = 15; // 语句1
int y = 20; // 语句2
x = x + 100;// 语句3
y = x * x; // 语句4
}
执行语句可以按照 1234、2134、1324 执行,但是你不能把语句4放到语句3之前执行,因为语句4需要依赖语句3先执行,不能够本末倒置,否则结果出错。像语句3、4之间就存在数据依赖性
,如果违背数据依赖性必须禁止指令重排。
数据依赖定义:如果两个操作访问同一个共享变量,而且,这两个操作里面有一个为写操作,那么这个两个操作之间就存在数据依赖,是不允许重排序的。
数据依赖分成三类,先假设有两个 int 变量 a、b,然后看一下分类:
- 读后写:读一个变量,然后再写这个变量
a = b;
b = 1;
- 写后写:写一个变量,然后继续写这个变量
a = 10;
a = 100;
- 写后读:写一个变量后,在读这个变量
a = 10;
b = a;
注意没有读后读,因为数据依赖必须有一个写操作,这些有数据依赖的指令是不会重排序。
2.1、什么是 as-if-serial 语义?
不管有没有重排序,也不关心如何进行重排序,单线程环境下,程序的执行结果不会改变。编译器、JVM、处理器必须最终这个语义。as-if-serial
语义保证单线程环境下执行结果不被改变,happens-before
更多是保证多线程环境下执行结果不被改变。
多线程环境下存在上下交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性就无法确定,结果无法预测。那么要怎么解决这个问题呢?可以遵守 happens-before
规则。
2.2、happens-before 规则
在 JMM 内存模型中,happens-before
是一种规则,用于确定两,个操作之间的执行顺序。如果一个操作的执行结果需要影响另一个操作的执行,那么这两个操作之间必须满足 happens-before
关系,以确保它们之间的顺序。
具体来说,如果操作A happens-before 操作B,那么操作A的执行结果对操作B可见,而且操作A的执行顺序早于操作B的执行顺序。happens-before
关系可以通过多种方式建立,例如:同步、volatile变量、线程启动和终止、线程join等。
Java 中,happens-before 规则包括以下几个方面:
-
程序次序规则:在一个线程中,按照程序代码的顺序,前面的操作 happens-before 于后续的任何操作。
-
锁定规则:在一个线程中,释放锁之前的所有操作都 happens-before 于后续线程获取同一个锁时的所有操作。
-
volatile 变量规则:对一个volatile变量的写操作 happens-before 于后续对该变量的读操作。
-
传递性规则:如果A happens-before B,B happens-before C,则A happens-before C。
-
线程启动规则:在一个线程内,Thread对象的start()方法 happens-before 于此线程的任何操作。
-
线程中断规则:对一个线程 interrupt() 方法的调用 happens-before 于在该线程中被中断线程的任何操作。
-
线程终止规则:在一个线程内,线程中的任何操作 happens-before 于其他线程检查到该线程已经终止的操作。
-
对象终结规则:一个对象的初始化完成(构造方法执行结束)happens-before 于它的finalize()方法的开始。
这些规则描述了在 JMM 内存模型中的操作之间的 happens-before
关系,保证了Java 并发程序中的正确性。
举个例子说明八个规则使用:
private int value = 0;
public void setValue(int value){
this.value = value;
}
public void getValue(){
return this.value;
}
假设有两个线程A、B,线程A 去掉用 setValue() 方法,线程B 调用 getValue() 方法,那么线程B 收到的返回值是什么?答案:不确定
简单按以上8个 happens-before
规则分析下(规则 5,6,7,8 可以忽略,没有涉及到他们的操作)
- 由于两个方法时不同的线程调用,不在同一个线程中,不满足
程序次序规则
- 两个方法都没有使用锁,不满足
锁定规则
- 变量也不是使用 volatile 关键字修饰,不满足
volatile 变量规则
传递规则
更是不满足
所以无法通过 happens-before 原则推导出线程A happens-before 线程B,所以这段代码时不安全的,该怎么修复这段代码呢?
- 可以给 getValue()、setValue() 两个方法都加上 synchornized 锁
- 把 value 变量定义成 volatile 关键字
问题:如果 JMM 内存模型中的有序性
都要靠 volatile
、synchornized
关键字来完成,那么很多程序都会变得非常啰嗦。但是一般情况下我们在编写代码时并没有时时刻刻添加这两个关键字,最多在并发编程时下使用,这是因为在 Java 语言中, JMM 原则下的 happens-before
原则限制和规定。
总结 happens-before 就两个总原则:
1. 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果将会对第二个操作可见,而且第一个操作的执行顺序顺序排在第二个执行之前 (对程序员的一种约束)。
2. 两个操作之间存在 happens-before 关系,并不意味着一定要按照 happens-before 原子执行的顺序来执行,如果重排序可以让性能更好的执行结果与它按照 happens-before 关系执行的结果一致。那么这种重排序并不非法。比如: 1+2 = 2+1、或者轮班值日等最终结果是一致的 (对编译器、处理器重排序的一种约束)。
2.3、理解 volatile 语义?
对于指令重排导致的可见性问题和有序性问题,JMM 内存模型定义一套上述8个 happens-before
原则来保证多线程环境下两个操作间的原子性、可见性以及有序性。除了上述这8个原则,还给开发人员开发提供 volatile
、synchornized
关键字解决原子性、可见性以及有序性。volatile
另一个非常重要的作用就是禁止指令重排序
。
总结成两句话如下:
1. 当写一个 volatile 变量时,JMM 会把线程对应的本地变量立即刷新回主内存中
2. 当读一个 volatile 变量时,JMM 会把线程对应的本地变量设置无效,直接从主内存中读取共享变量
2.3.1、volatile
内存语义的实现:
1.字节码层面
volatile 是修饰变量的,在字节码层面会添加一个标识 ACC_VOLATILE
2.JMM 层面
在哪些位置上插入内存屏障指令
,以及插入什么内存屏障指令
。看到 volatile 变量规则表如下:
2.1.volatile 变量规则:
第一个操作 | 第二个操作:普通读写 | 第二个操作:volatile 读 | 第二个操作:volatile 写 |
---|---|---|---|
普通读写 | 可以重排 | 可以重排 | 不可以重排 |
volatile 读 | 不可以重排 | 不可以重排 | 不可以重排 |
volatile 写 | 可以重排 | 不可以重排 | 不可以重排 |
-
当第一个操作为
volatile
读时,第二个操作无论什么都不允许重排序,这保证volatile
读之后的操作不会被排到volatile
读之前。 -
当第二个操作为
volatile
写时,第一个操作不论是什么都不允许重排序,这保证volatile
写之前的操作不会被重排序到volatile
写之后。 -
当第一个操作为
volatile
写时,第二个操作为volatile
读时,不能重排。
2.2.屏障指令插入策略:
为什么会出现以上这种效果,是因为 volatile
关键字底层自动帮你在读写操作 volatile
变量处插入了上面的四大内存屏障指令:loadload、storestore、loadstore、storeload
,具体插入策略如下所示:
-
volatile 写操作:在每个volatile写操作之前插入storestore屏障,在每个volatile写操作之后插入storeload屏障。
-
volatile 读操作:在每个 volatile 读操作之后插入loadload屏障,在每个volatile读操作之后插入loadstore屏障。
为什么要这样插入呢?下面来解释下:
先来看到为什么在每个volatile写操作之前插入storestore屏障,在每个volatile写操作之后插入storeload屏障。如下图示:
在看到为什么在每个 volatile 读操作之后插入loadload屏障,在每个volatile读操作之后插入loadstore屏障。如下图示:
但是为什么没有禁止普通读和 volatile
写之间的重排序?
(1) volatile 写:针对的是被 volatile
修饰的变量,在写的时候,是把 volatile
变量的值,从工作内存中刷新回到主内存中。
(2) 普通读: 读的一定不是被 volatile
修饰的变量。
所以普通读不会对 volatile 变量的值造成影响。也不影响相应的内存。不会去破坏 volatile 的内存语义。
简单一句话:volatile
写时直接刷回主内存中,读时直接从主内存中去取。那么 volatile 关键字是怎么保证程序可见性和有序性呢,底层是通过内存屏障指令
实现的,具体什么是内存屏障指令
呢?
3.处理器层面
cpu 执行机器指令的时候,是使用 lock
前缀执行来实现 volatile
功能的。
(1) 首先对总线/缓存加锁,然后去执行后面的指令,最后,释放锁,同时把高速缓存的数据刷新回到主内存中。
(2) 在 lock 锁住总线/缓存的时候,其他 cpu 的读写请求就会被阻塞,直到锁释放。lock 过后的写操作,会让其他 cpu 的高速缓存中相应的数据失效,这样后续这些 cpu 在读取数据时候,就需要从主内存中加载最新的数据。
补充:Cache line 是指缓存中的最小存储单元,一般是64个字节(或更多)的数据块,也称为cache block。当处理器需要访问某个内存地址的时候,会首先检查对应的cache line是否存在,如果存在,处理器会直接从cache line中读取数据,这样的情况被称为cache hit;如果不存在,处理器需要先从内存中读取相应的数据块并将其放入缓存中,这个过程被称为cache miss。由于cache line的大小一般比较大,一次读取可以获取到多个数据,从而提高了读取效率。同时,缓存的访问速度比内存快很多,因此使用缓存可以有效地减少处理器对内存的访问次数,提高程序的运行效率。
2.4、内存屏障指令
先看一个例子,比如在节假日西湖,没有人管控,显得特别乱,还有的人可能掉进河里,如下图示:
就是因为没有管控,顺序难保,所以需要设定规则,禁止乱序——上海南京人墙隔离,如下图示:
这样就可以防止上面的人和下边的人乱窜在一起,这个人墙就相当于是现在要说的屏障,只不过现在是在内存中,所以称之为内存屏障
。所以 volatile 关键字是靠内存屏障
保证可见性和有序性。
内存屏障(Memory Barrier)是指一组处理器指令
,用于实现对内存操作的顺序限制。内存屏障可以分为多种类型,包括读屏障
、写屏障
、全屏障
等。
在 Java 中,内存屏障被广泛应用于实现内存可见性和 happens-before 规则。Java 内存模型规定,在执行某个操作之前或之后插入适当的内存屏障指令,可以确保程序正确性,保证各个线程对共享变量的操作能够按照一定的顺序进行。
例如,对于 volatile 变量的写入操作,Java 内存模型要求在写入操作之后插入一个写屏障(store barrier),以确保该操作的结果对其他线程可见。而对于 volatile 变量的读取操作,需要在读取操作之前插入一个读屏障(load barrier),以确保读取操作看到的是最新值。
总之,内存屏障是一种非常重要的硬件和编译器技术,在实现多线程编程和共享内存时扮演着关键的角色。
2.5、内存屏障分类
内存屏障可以分为多种类型,包括读屏障
、写屏障
、全屏障
。
-
读屏障(Load Barrier):在读操作之前插入读屏障,可以保证读操作之前的所有写操作对该读操作可见,即读操作看到的是最新的写操作结果。换句话说,该读指令会让其工作内存/CPU高速缓存中的数据直接失效,重新从主内存中读取最新数据。
-
写屏障(Store Barrier):在写操作之后插入写屏障,可以保证该写操作对于其他线程的读操作是可见的,即其他线程看到的是最新的写操作结果。换句话说,该写指令会让强制把缓存区的数据刷回到主内存中。
-
全屏障(fullFence):全屏障是读屏障和写屏障的结合,它不仅保证了写操作对于其他线程的读操作是可见的,也保证了读操作看到的是最新的写操作结果。
需要注意的是,内存屏障
的使用需要慎重,过多的使用会影响程序的性能。
再来看看内存屏障
长啥样子:
- Unsafe.java 源码如下:Java 中不安全类中有三个 native 修饰的方法,都是去调用 JVM 的代码执行
public final class Unsafe {
@HotSpotIntrinsicCandidate
public native void loadFence();
@HotSpotIntrinsicCandidate
public native void storeFence();
@HotSpotIntrinsicCandidate
public native void fullFence();
}
- Unsafe.cpp 源码如下图示:在 HotSport 源码中,底层分别调用 OrderAccess::acquire()、OrderAccess::release()、OrderAccess::fence() 方法
- 在继续进入到 OrderAccess 类方法内,如下图示:
上面这四个就是我们经常说的四大内存屏障指令: loadload、storestore、loadstore、storeload
- 继续往下追到
orderAccess_linux_x64.inline.hpp
Linux 操作系统文件中,如下图示:
2.6、内存屏障指令
对于上述中看到的四大屏障指令 loadload
、storestore
、loadstore
、storeload
,具体表示什么意思呢?其实就是写屏障前后顺序
、读屏障前后顺序
进行排列组合,就组成四组屏障指令
。
屏障类型 | 指令实例 | 说明 |
---|---|---|
loadload | load1;loadload;load2 | 1、禁止重排序: 保证 load1 读取操作在 load2 以及后续 读取之前。2、在这里 loadload 就相当于是一个 读屏障 ,要知道在一个读指令(load2)之前插入读屏障会发生什么效果,会具备读屏障功能(保证让 Load2 工作内存缓存中的数据直接失效,重新从主内存中读取最新数据) 这里有疑问,为什么只保证后面这个 load2 而不保证前面这个 load1? 如果按照程序顺序执行,总有一个开头去执行获取数据的,一开始 load1 缓存中根本没有数据,只能去主内存中获取,所以只有第二次再来读取时才需要让自己的缓存失效,从主内存中重新读取数据 |
storestore | store1;storestore;store2 | 1、禁止重排序: 在 store2 写操作以及后续 操作之前,必须保证 store1 已经刷回主内存中2、在这里 storestore 就相当于是一个 写屏障 ,要知道在一个写指令(store1)之后插入写屏障会发生啥效果,那就会具备写屏障的功能(保证 store1 指令写出的数据,强制新回主内存中) |
loadstore | load1;loadstore;store2 | 1、禁止重排序: 在 store2 写操作以及后续 操作之前,必须保证 load1 已经读取完成2、这个指令刚好和读写屏障顺序相反,所以不具备读写屏障功能,就只有禁止重排序功能 |
storeload | store1;storeload;load2 | 1、禁止重排序:保证 store1 写操作已经刷回主内存中,load2 读操作以及后续 操作才能执行2、storeload 指令非常强大,细品会发现它有这样的特点,在写操作(store1)之后插入 写屏障 ,在读操作(load2)之前插入读屏障 ,那么必然同时具备上面读屏障 和写屏障 的功能 |
上述表格中可以发现 storeload
屏障指令比较重:loadload 就相当于一个读屏障,storestore 相当于写屏障,loadstore 只有禁止重排序功能,因为和读写屏障顺序刚好相反,只有 storeload 具备读屏障、写屏障、禁止重排序功能,它和内存交互次数多、交互延迟大、消耗资源多。
扩展:这些屏障指令并不是处理器真实的执行指令,是 JMM 定义出来跨平台的指令。因为不同的硬件实现内存屏障方式并不相同,JMM 为了屏蔽这种底层硬件平台的不同,抽象出了这些内存屏障指令,在运行的时候,有 JVM 来为不同的平台生成相对应的机器码。这些内存屏障指令,在不同的硬件平台上,可能会做一些优化,从而只支持部分 JMM 内存屏障指令,比如:在x86机器上,就只有 storeload
是有效的,其他都不支持,被替换成 nop,也就是空操作。
2.7、volatile 读写过程—8种原子方法
JMM定义8种原子操作,是为了确保在多线程环境下,对共享变量的操作是原子的,即一个线程正在进行这个操作的时候,其他线程不能同时进行这个操作,从而保证了数据的一致性。
这8种原子操作包括:
lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后可以被其他线程锁定。
read(读取):作用于主内存的变量,把一个变量的值从主内存传输到线程的工作内存中,以便随后的load操作使用。
load(载入):作用于线程的工作内存的变量,把read操作从主内存中得到的变量值放入工作内存中的变量副本。
use(使用):作用于线程的工作内存的变量,把工作内存中的一个变量的值传递给执行引擎。
assign(赋值):作用于线程的工作内存的变量,把一个从执行引擎接收到的值赋值给工作内存中的变量。
store(存储):作用于线程的工作内存的变量,把工作内存中的一个变量的值传递给主内存,以便随后的write操作使用。
write(写入):作用于主内存的变量,把store操作从工作内存中得到的变量的值写入到主内存的变量中。
通过这些原子操作的组合,可以保证共享变量的访问和修改都是原子的,从而避免了数据不一致的问题。
注意: 但是在 use 和 assign 中间这个缝隙(CPU执行引擎处) 会出现线程竞争,比如 i++ 这个操作底层其实是由三条指令集组成,在 use 和 assign 中间执行这三条语句,不能保证线程执行原子性,所以,还需要加锁才能够让线程保证原子性,数据不会出错。
JMM定义的8种原子操作,包括了对一个变量的读、写和读-改-写三种操作,以及对数组的读、写和读-改-写三种操作。这些操作确保了每个操作在执行时都具有原子性,但是只针对单个变量(boolean 这种)或者单个元素。
在实际开发中,复合操作(i++这种)通常涉及多个变量或者多个元素的修改,这种情况下,使用 JMM 提供的原子操作无法保证原子性,需要使用 synchronized 或者 Lock 等同步手段来保证线程安全。因此,JMM 定义的8种原子操作并不能完全保证原子性。
比如下面这个参考例子:
public class VolatileNotAtomicExample {
private volatile int count = 0;
public void increment() {
count++;
}
public int getCount() {
return count;
}
public static void main(String[] args) throws InterruptedException {
final int THREADS_COUNT = 1000;
final int INCREMENT_COUNT = 1000;
VolatileNotAtomicExample example = new VolatileNotAtomicExample();
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0; i < THREADS_COUNT; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < INCREMENT_COUNT; j++) {
example.increment();
}
});
threads[i].start();
}
for (int i = 0; i < THREADS_COUNT; i++) {
threads[i].join();
}
System.out.println("Count: " + example.getCount());
}
}
上述代码中,有 1000 个线程同时对同一个 volatile 变量进行了 1000 次累加操作,理论上 count 的值应该为 1000 * 1000 = 1000000,但是实际运行结果可能会小于这个值,因为 volatile 只能保证可见性,不能保证原子性。
count++ 底层指令如下:
0: iconst_0
1: istore_1
2: iinc 1, 1
5: return
在 use 和 assign 中间执行这三条语句,不能保证线程执行原子性。
2.8、volatile 用于 DCL
DCL(Double-checked locking)是一种单例模式的实现方式,通过对锁进行双重检查来实现线程安全和性能优化。使用 volatile 可以保证 DCL 实现的线程安全性。
以下是一个使用 volatile 实现 DCL 的示例代码:
public class Singleton {
private static volatile Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
在上面的示例中,instance 变量被声明为 volatile,这样可以保证 instance 对于所有线程的可见性。如果没有 volatile 修饰,一个线程可能会将 instance 缓存在本地内存中,而不是从主内存中读取,导致另一个线程无法看到该变量的最新值。同时,使用双重检查加锁的方式可以避免对整个 getInstance() 方法加锁,从而提高了性能。
如果不加上 volatile 修饰符,那么当一个线程首次访问 getInstance() 方法时,如果在 instance == null 的判断中出现指令重排,将会出现如下情况:
线程A创建了一个 Singleton 对象,并将该对象赋值给了 instance。
线程B调用 getInstance() 方法,发现 instance != null,于是直接返回 instance,但是这个 instance 实际上还没有完成初始化。
加上 volatile 修饰符,可以禁止指令重排,保证 instance 对于所有线程的可见性,并且在 instance 实例化完成之前,其它线程不能访问 instance 对象。这样就能够保证 DCL 实现的线程安全性。
3、原子性
在 Java 中,原子性是指一个操作是不可被中断的、不可被分割的,即要么这个操作已经全部执行完毕,要么就还没有执行。简单来说,原子性就是指一个操作要么全部执行成功,要么全部执行失败,不存在执行一半的情况。
在多线程编程中,原子性是非常重要的概念,因为多个线程可能同时访问和修改同一份数据,如果没有保证原子性,那么就可能会导致数据的不一致性或者出现其他异常情况。
在 Java 中,对于一些基本数据类型的操作,比如赋值、加减乘除等简单的操作,都是原子性的,也就是说,这些操作不会被其他线程打断。而对于一些复杂的操作,比如多个操作的组合,需要使用 synchronized
、Lock、
Atomic` 等同步机制来保证原子性,以避免出现线程安全问题。
假设有两个线程同时对一个共享的计数器进行加1操作,代码如下:
public class Counter {
private int count = 0;
public void increment() {
count++;
}
public int getCount() {
return count;
}
}
public class MyThread implements Runnable {
private Counter counter;
public MyThread(Counter counter) {
this.counter = counter;
}
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
counter.increment();
}
}
}
public class Main {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread thread1 = new Thread(new MyThread(counter));
Thread thread2 = new Thread(new MyThread(counter));
thread1.start();
thread2.start();
thread1.join();
thread2.join();
System.out.println("Count: " + counter.getCount());
}
}
上面的代码中,有一个 Counter 类表示一个计数器,它有一个 increment() 方法用于对计数器进行加1操作,另外还有一个 getCount() 方法用于获取计数器的当前值。
接下来定义一个 MyThread 类表示一个线程,它持有一个 Counter 对象,重复执行 10000 次对计数器进行加1操作。
最后在 Main 类中启动两个线程并等待它们执行完毕,最终输出计数器的值。
但是,由于两个线程同时对计数器进行修改,所以在不加任何同步机制的情况下,就会出现线程安全问题,导致计数器的值不一定是正确的。
因此,我们可以使用 Java 提供的原子类 AtomicInteger 来保证计数器的原子性:
public class Counter {
private AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.getAndIncrement();
}
public int getCount() {
return count.get();
}
}
这样就可以保证计数器的加1操作是原子性的,从而避免出现线程安全问题。