共计 11234 个字符,预计需要花费 29 分钟才能阅读完成。
1 Java 内存模型
JMM(Java Memory Model),是一种基于 计算机内存模型
(定义了共享内存零碎中多线程程序读写操作行为的标准)并屏蔽了各种硬件和操作系统的拜访差别,保障了 Java 程序在各种平台下对内存的拜访都能保障成果统一的机制及标准。
Java 内存模型形容了 Java 程序中各种变量(线程共享变量)的拜访规定,以及在 JVM 中将变量存储到内存和从内存中读取出变量这样的底层细节。
在 Java 中,所有实例域、动态域和数组元素都存储在堆内存中,堆内存在线程之间共享。因为线程的工作内存是线程公有内存,线程间无奈相互拜访对方的工作内存。所以线程 0、线程 1 和线程 2 须要读写主内存的 共享变量
时,就都先将该共享变量拷贝(load)到本人的工作内存,而后在本人的工作内存中对该变量进行所有操作,线程工作内存对 变量正本实现操作之后再将后果同步(save)至主内存。
JMM 存在三大个性:原子性 、 可见性 、 有序性。
原子性:
保障指令不会受到线程上下文切换的影响。对共享内存的操作必须是要么全副执行直到执行完结,且两头过程不能被任何内部因素打断,要么就不执行。
可见性:
保障指令不会受 CPU 缓存的影响。多线程操作共享内存时,执行后果可能及时的同步到共享内存,确保其余线程对此后果可见。
有序性:
保障指令不会受 CPU 指令并行优化的影响。程序的执行程序依照代码程序执行,在单线程环境下,程序的执行都是有序的;然而在多线程环境下,JMM 为了性能优化,编译器和处理器会对指令进行重排,程序的执行会变成无序。
1.1 可见性
在上面案例中,main 线程中 run
变量的批改对于子线程不可见,导致子线程无奈进行:
public class Test {
static boolean run = true;
public static void main(String[] args) throws InterruptedException {Thread t = new Thread(() -> {while (run) {}});
System.out.println(t.isAlive());
t.start();
System.out.println(t.isAlive());
Thread.sleep(1000);
run = false; // 线程 t 不会如料想的停下来
}
}
运行后果:
false
true
不难剖析:
- 初始状态,t 线程刚开始从主内存读取了
run
的值到工作内存。 - 因为 t 线程频繁地从主存中读取 run 的值,JIT 即时编译器会将 run 的值缓存至本人工作内存中的高速缓存中,缩小对主存中 run 的拜访以提高效率。
- 1 秒之后,main 线程批改了 run 的值,并同步至主内存,而 t 线程仍是从本人工作内存中的高速缓存中读取这个变量。
的值,后果永远是旧值。
对于这个问题,咱们能够应用 sychronized 关键字解决。
public class Test2 {
static boolean run = true;
final static Object lock = new Object();
public static void main(String[] args) throws InterruptedException {Thread thread = new Thread(() -> {while (true) {synchronized (lock) {if (!run) {break;}
}
}
});
thread.start();
Thread.sleep(1);
synchronized (lock) {run = false;}
}
}
synchronized 语句块既能够保障代码块的原子性,也同时保障代码块内变量的可见性。但毛病是 synchronized 是重量级锁,性能绝对更低。
1.2 初识 volatile
下面问题也能够用 volatile 关键字解决。
volatile(示意易变关键字的意思),它能够用来润饰成员变量和动态成员变量,它要求线程必须从主内存中获取变量的值。线程操作 volatile 变量都是间接操作主内存。
public class Test {
volatile static boolean run = true;
public static void main(String[] args) throws InterruptedException {Thread t = new Thread(() -> {while (run) {}});
t.start();
Thread.sleep(1000);
run = false; // 线程 t 会停下来
}
}
volatile 体现的就是 JMM 的可见性,volatile 保障的是在多个线程之间,一个线程对 volatile 变量的批改对另一个线程可
见,但不能保障多线程的原子性,仅用在一个写线程,多个读线程的状况。上例从字节码了解是这样的:
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
getstatic run // 线程 t 获取 run true
putstatic run // 线程 main 批改 run 为 false,仅此一次
getstatic run // 线程 t 获取 run false。
1.3 有序性
1.3.1 重排序
JVM 会在不影响正确性的前提下,能够调整语句的执行程序,例如上面代码:
static int i;
static int j;
// 在某个线程内执行如下赋值操作
i = ...;
j = ...;
能够看到,至于是先执行 i 还是 先执行 j,对最终的后果不会产生影响。所以,下面代码真正执行时,既能够是
i = ...;
j = ...;
// 或者
j = ...;
i = ...;
这种个性称之为 重排序,重排序次要分 3 种类型。
(1)编译器优化的重排序。编译器在不扭转单线程程序语义的前提下,能够重新安排语句的执行程序。
(2)指令级并行的重排序。古代处理器采纳了指令级并行技术(ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器能够扭转语句对应机器指令的执行程序。
(3)内存零碎的重排序。因为处理器应用缓存和读 / 写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
上述的 1 属于编译器重排序,2 和 3 属于处理器重排序。这些 重排序可能会导致多线程程序呈现内存可见性问题。
对于编译器,JMM 的编译器重排序规定会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。
对于处理器重排序,JMM 的处理器重排序规定会要求 Java 编译器在生成指令序列时,插入特定类型的 内存屏障(Memory Barriers,Intel 称之为 Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。
指令重排序优化
事实上,古代处理器会设计为一个时钟周期实现一条执行工夫最长的 CPU 指令。为什么这么做呢?能够想到指令还能够再划分成一个个更小的阶段,例如,每条指令都能够分为:取指令 - 指令译码 - 执行指令 - 内存拜访 - 数据写回
这 5 个阶段。
术语参考:
instruction fetch (IF)
instruction decode (ID)
execute (EX)
memory access (MEM)
register write back (WB)
在不改变程序后果的前提下,这些指令的各个阶段能够通过重排序和组合来实现指令级并行,分阶段、分工正是晋升效率的要害!
反对流水线的处理器
古代 CPU 反对多级指令流水线,例如反对同时执行 取指令 - 指令译码 - 执行指令 - 内存拜访 - 数据写回
的处理器,就能够称之为五级指令流水线。这时 CPU 能够在一个时钟周期内,同时运行五条指令的不同阶段(相当于一 条执行工夫最长的简单指令),IPC = 1,实质上,流水线技术并不能缩短单条指令的执行工夫,但它变相地进步了 指令地吞吐率。
1.3.2 案例剖析
int num = 0;
// volatile 润饰的变量,能够禁用指令重排 volatile boolean ready = false; 能够避免变量之前的代码被重排序
boolean ready = false;
// 线程 1 执行此办法
public void actor1(I_Result r) {if(ready) {r.r1 = num + num;}
else {r.r1 = 1;}
}
// 线程 2 执行此办法
public void actor2(I_Result r) {
num = 2;
ready = true;
}
I_Result 是一个对象,有一个属性 r1 用来保留后果,可能的后果有几种?
状况 1:线程 1 先执行,这时 ready = false,所以进入 else 分支后果为 1。
状况 2:线程 2 先执行 num = 2,但没来得及执行 ready = true,线程 1 执行,还是进入 else 分支,后果为 1。
状况 3:线程 2 执行到 ready = true,线程 1 执行,这回进入 if 分支,后果为 4。
状况 4:线程 2 执行 ready = true,切换到线程 1,进入 if 分支,相加为 0,再切回线程 2 执行 num = 2。
状况 4 呈现的在于呈现了指令重排,指令重排是 JIT 编译器在运行时的一些优化,这个景象须要通过大量测试能力复现,能够应用 jcstress 工具进行测试。下面仅是从代码层面体现出了有序性问题,上面在讲到 double-checked locking 问题时还会从 java 字节码的层面理解有序性的问题。
重排序也须要恪守肯定规定:
- 重排序操作不会对存在数据依赖关系的操作进行重排序。比方:
a=1;b=a;
这个指令序列,因为第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序。 - 重排序是为了优化性能,然而不管怎么重排序,单线程下程序的执行后果不能被扭转。比方:
a=1;b=2;c=a+b
这三个操作,第一步(a=1)和第二步 (b=2) 因为不存在数据依赖关系,所以可能会产生重排序,然而 c =a+ b 这个操作是不会被重排序的,因为须要保障最终的后果肯定是 c =a+b=3。
重排序在单线程模式下是肯定会保障最终后果的正确性,然而在多线程环境下,问题就进去了。解决办法:volatile 润饰的变量,能够禁用指令重排。
Tips:应用 synchronized 并不能解决所有有序性问题,然而变量齐全在 synchronized 代码块的爱护范畴内,那么变量就不会被多个线程同时操作,也不必思考有序性问题!
## 2 volatile 原理
从上文可知,一旦一个共享变量(类的成员变量、类的动态成员变量)被 volatile 润饰之后,那么就具备了两层语义:
(1)保障了不同线程对这个变量进行操作时的可见性,即一个线程批改了某个变量的值,这新值对其余线程来说是立刻可见的。
(2)禁止进行指令重排序。
Tips:对任意单个 volatile 变量的读 / 写具备原子性,但相似于 volatile++ 这种复合操作不具备原子性。
JVM 到底如何禁止重排序的呢?由此引出 Java 中的 happen-before 规定。
2.1 happens-before
JMM 能够通过 happens-before 关系JMM 能够通过 happens-before 关系向程序员提供跨线程的内存可见性保障。
《JSR-133:Java Memory Model and Thread Specif ication》对 happens-before 关系的定义如下。
(1)如果一个操作 happens-before 另一个操作,那么第一个操作的所有 执行后果 将对第二个操作可见,而且第一个操作的执行程序个别排在第二个操作之前。
(2)两个操作之间存在 happens-before 关系,并不 意味着 Java 平台的具体实现必须要依照 happens-before 关系指定的程序来执行。如果重排序之后的执行后果,与按 happens-before 关系来执行的后果统一,JMM 容许这种重排序。
happens-before 具体规定:
(1)程序程序规定:一个线程内,依照代码程序,书写在后面的操作,happens-before 于书写在前面的操作。
(2)监视器锁规定:对一个锁的解锁,happens-before 于随后对这个锁的加锁。
// 线程解锁 lock 之前对变量的写,对于接下来对 lock 加锁的其它线程对该变量的读可见。static int x;
static Object lock = new Object();
new Thread(()->{synchronized(lock) {x = 10;}
},"t1").start();
new Thread(()->{synchronized(lock) {System.out.println(x);
}
},"t2").start();
(3)volatile 变量规定 :对一个 volatile 域的写,happens-before 于任意 后续 对这个 volatile 域的读。
volatile static int x;
public static void main(String[] args) {new Thread(()->{x = 10;},"t1").start();
new Thread(()->{System.out.println(x);
},"t2").start();}
(4)传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
// 具备传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z,配合 volatile 的防指令重排
volatile static int x;
static int y;
public static void main(String[] args) {new Thread(() -> {
y = 10;
x = 20;
}, "t1").start();
new Thread(() -> {
// x=20 对 t2 可见, 同时 y=10 也对 t2 可见
System.out.println(x);
}, "t2").start();}
(5)线程启动规定:如果线程 A 执行操作 ThreadB.start()(启动线程 B),那么 A 线程的 ThreadB.start()操作 happens-before 于线程 B 中的任意操作。
// 线程 start 前对变量的写,对该线程开始后对该变量的读可见
static int x;
x = 10;
new Thread(()->{System.out.println(x);
},"t2").start();
(6)线程中断规定:对线程 interrupt 办法的调用,happens-before 被中断线程的代码检测到中断事件的产生。
// 线程 t1 打断 t2(interrupt)前对变量的写,对于其余线程得悉 t2 被打断后对变量的读可见(通过
// t2.interrupted 或 t2.isInterrupted)static int x;
public static void main(String[] args) {Thread t2 = new Thread(()->{while(true) {if(Thread.currentThread().isInterrupted()) {System.out.println(x);
break;
}
}
},"t2");
t2.start();
new Thread(()->{
try {Thread.sleep(1);
} catch (InterruptedException e) {e.printStackTrace();
}
x = 10;
t2.interrupt();},"t1").start();
while(!t2.isInterrupted()) {Thread.yield();
}
System.out.println(x);
}
(7)线程终结规定:如果线程 A 执行操作 ThreadB. join()并胜利返回,那么线程 B 中的任意操作 happens-before 于线程 A 从 ThreadB. join()操作胜利返回。
// 线程完结前对变量的写,对其它线程得悉它完结后的读可见(比方其它线程调用 t1.isAlive() 或 t1.join()期待它完结)static int x;
Thread t1 = new Thread(()->{x = 10;},"t1");
t1.start();
t1.join();
System.out.println(x);
(8)对象终结规定:一个对象的初始化实现,happens-before 它的 finalize() 办法的开始。
咱们着重看第三点 Volatile 规定:对 volatile 变量的写操作,happen-before 后续的读操作。
为了实现 volatile 内存语义,JMM 会重排序,其规定如下:
是否能重排序 | 第二个操作 | 第二个操作 | 第二个操作 |
---|---|---|---|
第一个操作 | 一般读 / 写 | Volatile 读 | Volatile 写 |
一般读 / 写 | No | ||
Volatile 读 | No | No | No |
Volatile 写 | No | No |
当第二个操作是 volatile 写操作时,不论第一个操作是什么,都不能重排序。
2.2 内存屏障
volatile 的底层实现原理是内存屏障(Memory Barrier/Memory Fence),上面这段话摘自《深刻了解 Java 虚拟机》:
“察看退出 volatile 关键字和没有退出 volatile 关键字时所生成的汇编代码发现,退出 volatile 关键字时,会多出一个 lock 前缀指令”。
lock 前缀指令实际上相当于一个 内存屏障(也成内存栅栏),内存屏障会提供 3 个性能:
(1)它确保指令重排序时不会把其前面的指令排到内存屏障之前的地位,也不会把后面的指令排到内存屏障的前面;即在执行到内存屏障这句指令时,在它后面的操作曾经全副实现。
(2)它会强制将对缓存的批改操作立刻写入主存。
(3)如果是写操作,它会导致其余 CPU 中对应的缓存行有效。
下图是实现 happens-before 规定所须要的内存屏障:
是否能重排序 | 第二个操作 | 第二个操作 | 第二个操作 | 第二个操作 |
---|---|---|---|---|
第一个操作 | 一般读 | 一般写 | Volatile 读 | Volatile 写 |
一般读 | LoadStore | |||
一般写 | StoreStore | |||
Volatile 读 | LoadLoad | LoadStore | LoadLoad | LoadStore |
Volatile 写 | StoreLoad | StoreStore |
(1)LoadLoad 屏障
执行程序:Load1—>Loadload—>Load2
确保 Load2 及后续 Load 指令加载数据之前能拜访到 Load1 加载的数据。
(2)StoreStore 屏障
执行程序:Store1—>StoreStore—>Store2
确保 Store2 以及后续 Store 指令执行前,Store1 操作的数据对其它处理器可见。
(3)LoadStore 屏障
执行程序:Load1—>LoadStore—>Store2
确保 Store2 和后续 Store 指令执行前,能够拜访到 Load1 加载的数据。
(4)StoreLoad 屏障
执行程序: Store1—> StoreLoad—>Load2
案例剖析
还是以之前代码为例:
int num = 0; // 共享变量 num
// 依据程序程序规定,num happens-before ready
volatile boolean ready = false; // volatile 变量 ready
// 线程 1 执行此办法
public void actor1(I_Result r) {
// LoadLoad 屏障
if(ready) { // ready 是被 volatile 润饰的,读取值带 LoadLoad 屏障
r.r1 = num + num;
}
else {r.r1 = 1;}
}
// 线程 2 执行此办法
public void actor2(I_Result r) {
num = 2;
ready = true; // ready 是被 volatile 润饰的,赋值带 LoadStore 屏障
// LoadStore 屏障
}
2.3 double-checked locking
上面以驰名的 double-checked locking 单例模式为例,这是 volatile 最常应用的中央。
实现单例模式时,如果未思考多线程的状况,就容易写出上面的代码:
public final class Singleton {private Singleton() { }
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
// 首次拜访会同步,而之后的应用不必进入 synchronized
synchronized (Singleton.class) {if (INSTANCE == null) {INSTANCE = new Singleton();
}
}
return INSTANCE;
}
}
下面代码的问题在于,即便曾经产生了单实例之后,之后调用了 getInstance()办法之后还是会加锁,这会重大影响性能!
双重查看锁(double checked locking)是对上述问题的一种优化。
public final class Singleton {private Singleton() { }
private static Singleton INSTANCE = null;
public static Singleton getInstance() {if(INSTANCE == null) {
// 首次拜访会同步,而之后的应用没有 synchronized
synchronized(Singleton.class) {if (INSTANCE == null) {INSTANCE = new Singleton();// error
}
}
}
return INSTANCE;
}
}
如果这样写,运行程序就成了:
(1)查看变量是否被初始化(不去取得锁),如果已被初始化则立刻返回。
(2)获取锁。
(3)再次查看变量是否曾经被初始化,如果还没被初始化就初始化一个对象。
这样,除了初始化的时候会呈现加锁的状况,后续的所有调用都会防止加锁而间接返回,解决了性能耗费的问题。
上述写法看似解决了问题,但在多线程环境下,是有很大的 隐患 的。if(INSTANCE == null
代码没有在同步代码块 synchronized 中,不能享有 synchronized 保障的原子性、可见性。
查看 getInstance 办法对应的字节码为:
public static com.kai.demo.memory.Singleton getInstance();
descriptor: ()Lcom/kai/demo/memory/Singleton;
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=0
0: getstatic #2 // 获取到 INSTANCE 动态变量
3: ifnonnull 37
6: ldc #3 // 取得 Singleton.class 类对象
8: dup // 将类对象的援用地址复制了一份 -> 长期类对象援用
9: astore_0 // 长期类对象援用 -> 存入局部变量表 slot 1 中
10: monitorenter // 将类对象的 Mark Word 置为指向 Monitor 指针
11: getstatic #2 // 再次获取到 INSTANCE 动态变量
14: ifnonnull 27
17: new #3 // 新建一个 Singleton 实例,实例对象援用入栈
20: dup // 复制 Singleton 实例的援用 -> 长期援用
21: invokespecial #4 // 长期实例援用调用构造方法 <init>
24: putstatic #2 // 实例的赋值操作
27: aload_0 // 获取到长期类对象援用
28: monitorexit // 将 lock 对象的 Mark Word 重置,唤醒 EntryList
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // return INSTANCE;
40: areturn
--- omit ---
次要来看 17-24 步:
- 17:创立 Singleton 实例,将实例对象援用入栈
- 20:复制一份对象援用(长期援用)
- 21:利用长期援用,调用构造方法
- 24:利用对象援用,赋值给动态 INSTANCE
编译器为了性能优化,可能会将 21 和 24 进行 重排序。如果两个线程 t1、t2 按工夫序列执行:
因为 0: getstatic
这行代码( if(INSTANCE == null)
)在 monitor 管制之外,t2 能够越过 monitor 读取 INSTANCE 变量的值。这时 t1 还未齐全将构造方法执行结束,t2 拿到的是将是一个未初始化结束的单例。
double-checked locking 解决办法
对 INSTANCE 应用 volatile 润饰即可。
public final class Singleton {private Singleton() { }
private static volatile Singleton INSTANCE = null;
public static Singleton getInstance() {
// 实例没创立,才会进入外部的 synchronized 代码块
if (INSTANCE == null) {synchronized (Singleton.class) { // t2
// 兴许有其它线程曾经创立实例,所以再判断一次
if (INSTANCE == null) { // t1
INSTANCE = new Singleton();}
}
}
return INSTANCE;
}
}
字节码上看不出来 volatile 指令的成果:
// -------------------------------------> 退出对 INSTANCE 变量的 LoadLoad 屏障
0 getstatic #2 <com/kai/demo/memory/Singleton.INSTANCE>
3 ifnonnull 37 (+34)
6 ldc #3 <com/kai/demo/memory/Singleton>
8 dup
9 astore_0
10 monitorenter-----------------------> 保障原子性、可见性
11 getstatic #2 <com/kai/demo/memory/Singleton.INSTANCE>
14 ifnonnull 27 (+13)
17 new #3 <com/kai/demo/memory/Singleton>
20 dup
21 invokespecial #4 <com/kai/demo/memory/Singleton.<init>>
24 putstatic #2 <com/kai/demo/memory/Singleton.INSTANCE>
// -------------------------------------> 退出对 INSTANCE 变量的 LoadStore 屏障
27 aload_0
28 monitorexit-----------------------> 保障原子性、可见性
29 goto 37 (+8)
32 astore_1
33 aload_0
34 monitorexit
35 aload_1
36 athrow
37 getstatic #2 <com/kai/demo/memory/Singleton.INSTANCE>
40 areturn
如下面的正文内容所示,读写 volatile 变量操作(即 getstatic 操作和 putstatic 操作)时会退出内存屏障(Memory Barrier(Memory Fence)),保障上面两点:
- 可见性
(1)写屏障(sfence)保障在该屏障之前的 t1 对共享变量的改变,都同步到主存当中
(1)而读屏障(lfence)保障在该屏障之后 t2 对共享变量的读取,加载的是主存中最新数据
- 有序性
(1)写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
(2)读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
- 更底层是读写变量时应用 lock 指令来多核 CPU 之间的可见性与有序性
参考资料
【JAVA 学习笔记】多线程
JAVA 并发编程的艺术
volatile 原理解析
volatile 关键字的作用、原理
Java 并发编程:volatile 的应用及其原理