文章目录
1、Java 内存模型
JMM 即 Java Memory Model ,它从Java层面定义了 主存、工作内存 抽象概念,底层对应着CPU 寄存器、缓存、硬件内存、CPU 指令优化等。JMM 体现在以下几个方面:
- 原子性 – 保证指令不会受 线程上下文切换的影响
- 可见性 – 保证指令不会受 cpu 缓存的影响 (JIT对热点代码的缓存优化)
- 有序性 – 保证指令不会受 cpu 指令并行优化的影响
2、可见性
2.1、退不出的循环
main线程对run变量的修改对于t线程不可见,导致了 t 线程无法停止
public class Test1 {
volatile static boolean run = true;
public static void main(String[] args) throws InterruptedException {
Thread t1 = new Thread(() -> {
while (run) {
// 如果打印一句话
// 此时就可以结束, 因为println方法中, 使用到了synchronized
// synchronized可以保证原子性、可见性、有序性
// System.out.println("123");
}
});
t1.start();
Thread.sleep(1000);
run = false;
System.out.println(run);
}
}
- 一开始一直不结束, 是因为无限循环, run都是true, JIT及时编译器, 会对t1线程所执行的run变量,进行缓存, 缓存到本地工作内存. 不去访问主存中的run. 这样可以提高性能
- 也可以说是JVM打到一定阈值之后, while(true)变成了一个热点代码, 所以一直访问的都是缓存到本地工作内存(局部)中的run.
- 当主线程修改主存中的run变量的时候,t1线程一直访问的是自己缓存的, 所以不认为run已经改为false了. 所以一直运行.
- 我们为主存(成员变量)进行volatile修饰, 增加变量的可见性, 当主线程修改run为false, t1线程对run的值可见. 这样就可以退出循环
使用synchronized解决
public class Test1 {
static boolean run = true;
final static Object obj = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
// 1s内,一直都在无限循环获取锁. 1s后主线程抢到锁,修改为false, 此时t1线程抢到锁对象,while循环也退出
while (run) {
synchronized (obj) {
}
}
});
t1.start();
Sleeper.sleep(1);
// 当主线程获取到锁的时候, 就修改为false了
synchronized (obj) {
run = false;
System.out.println("false");
}
}
}
分析run变量的不可见性原因?
- 初始状态, t线程刚开始从主内存(成员变量), 因为主线程sleep(1)秒, 这时候t1线程循环了好多次run的值, 超过了一定的阈值, JIT就会将主存中的run值读取到工作内存 (相当于缓存了一份, 不会去主存中读run的值了)
- 因为t1线程频繁地从主存中读取run的值,JIT即时编译器会将run的值缓存至自己工作内存中的高速缓存中,减少对主存中run的访问以提高效率
- 1 秒之后,main线程修改了run的值, 并同步至主存。而 t线程是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值
2.2、实现可见性方法
-
使用volatile(表示易变关键字的意思),它可以用来修饰成员变量和静态成员变量,它可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存
-
volatile 可以认为是一个轻量级的锁,被 volatile 修饰的变量,汇编指令会存在于一个”lock”的前缀。在CPU层面与主内存层面,通过缓存一致性协议,加锁后能够保证写的值同步到主内存,使其他线程能够获得最新的值
-
使用synchronized关键字也有相同的效果, 在Java内存模型中,synchronized规定,线程在加锁时, 先清空工作内存 → 在主内存中拷贝最新变量的副本到工作内存 → 执行完代码 → 将更改后的共享变量的值刷新到主内存中 → 释放互斥锁
2.3、可见性 vs 原子性
两个线程一个 i++ 一个 i– ,只能保证看到最新值(可见性),不能解决指令交错(原子性)
// 假设i的初始值为0
getstatic i // 线程2-获取静态变量i的值 线程内i=0
getstatic i // 线程1-获取静态变量i的值 线程内i=0
iconst_1 // 线程1-准备常量1
iadd // 线程1-自增 线程内i=1
putstatic i // 线程1-将修改后的值存入静态变量i 静态变量i=1
iconst_1 // 线程2-准备常量1
isub // 线程2-自减 线程内i=-1
putstatic i // 线程2-将修改后的值存入静态变量i 静态变量i=-1
- synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性
- 但缺点是 synchronized 是属于重量级操作,性能相对更低
- 如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也能正确看到 对 run 变量的修改了,想一想为什么?
- 因为println方法里面有synchronized修饰。还有那个等烟的示例, 为啥没有出现可见性问题?和synchrozized是一个道理
3、 有序性
- 是JIT即时编译器的优化, 可能会导致指令重排
- 为什么要优化? 因为CPU 支持多级指令流水线
- 例如支持同时执行 取指令 – 指令译码 – 执行指令 – 内存访问 – 数据写回 的处理器,效率快
- JVM会在不影响正确性的前提下,可以调整语句的执行顺序, 是一种优化
3.1、支持流水线的处理器
- 现代 CPU 支持多级指令流水线,例如支持同时执行 取指令 – 指令译码 – 执行指令 – 内存访问 – 数据写回 的处理器,就可以称之为五级指令流水线
- 流水线技术并不是说让多个指令并行执行,可能还是需要等他其他指令执行完才可以执行,那么这个时候等待就有一个停顿,我们可以让和这个指令后面不相干的指令继续执行,这就是指令重排
3.2、重排序要求
- 指令重排序操作不会对存在数据依赖关系的操作进行重排序
- 比如:a=1;b=a; 这个指令序列,由于第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序
- 重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变
- 比如:a=1;b=2;c=a+b这三个操作,第一步(a=1)和第二步(b=2)由于不存在数据依赖关系,所以可能会发生重排序,但是c=a+b这个操作是不会被重排序的,因为需要保证最终的结果一定是c=a+b=3
- 指令重排序 在 单线程模式下是一定会保证最终结果的正确性, 但是在多线程环境下,问题就出来了
- volatile 修饰的变量,可以禁用指令重排
4、volatile 原理
- volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)
- 对 volatile 变量的写指令后会加入写屏障。(保证写屏障之前的写操作, 都能同步到主存中)
- 对 volatile 变量的读指令前会加入读屏障。(保证读屏障之后的读操作, 都能读到主存的数据)
4.1、volatile是如何保证可见性
- 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
public void actor2(I_Result r) {
num = 2;
ready = true; // ready是被volatile修饰的 ,赋值带写屏障
// 写屏障.(在ready=true写指令之后加的,
//在该屏障之前对共享变量的改动, 都同步到主存中. 包括num)
}
- 读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据
public void actor1(I_Result r) {
// 读屏障
// ready是被volatile修饰的 ,读取值带读屏障
if(ready) { // ready, 读取的就是主存中的新值
r.r1 = num + num; // num, 读取的也是主存中的新值
} else {
r.r1 = 1;
}
}
4.2、volatile是如何保证有序性
- 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
public void actor2(I_Result r) {
num = 2;
ready = true; // ready是被volatile修饰的 , 赋值带写屏障
// 写屏障
}
- 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
public void actor1(I_Result r) {
// 读屏障
// ready是被volatile修饰的 ,读取值带读屏障
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
4.3、volatile不能解决指令交错 (不能解决原子性)
- 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证其它线程的读, 跑到它前面去
- 有序性的保证也只是保证了本线程内相关代码不被重排序
- 下图t2线程, 就先读取了i=0, 此时还是会出现指令交错的现象, 可以使用synchronized来解决原子性
5、double-checked locking (双重检查锁) 问题
以著名的double-checked locking(双重检查锁) 单例模式为例,这是volatile最常使用的地方
// 最开始的单例模式是这样的
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
/*
多线程同时调用getInstance(), 如果不加synchronized锁, 此时两个线程同时
判断INSTANCE为空, 此时都会new Singleton(), 此时就破坏单例了.所以要加锁,
防止多线程操作共享资源,造成的安全问题
*/
synchronized(Singleton.class) {
if (INSTANCE == null) { // t1
INSTANCE = new Singleton();
}
}
return INSTANCE;
}
}
/*
首先上面代码的效率是有问题的, 因为当我们创建了一个单例对象后, 又来一个线程获取到锁了,还是会加锁,
严重影响性能,再次判断INSTANCE==null吗, 此时肯定不为null, 然后就返回刚才创建的INSTANCE;
这样导致了很多不必要的判断;
所以要双重检查, 在第一次线程调用getInstance(), 直接在synchronized外,判断instance对象是否存在了,
如果不存在, 才会去获取锁,然后创建单例对象,并返回; 第二个线程调用getInstance(), 会进行
if(instance==null)的判断, 如果已经有单例对象, 此时就不会再去同步块中获取锁了. 提高效率
*/
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
if(INSTANCE == null) { // t2
// 首次访问会同步,而之后的使用没有 synchronized
synchronized(Singleton.class) {
if (INSTANCE == null) { // t1
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
//但是上面的if(INSTANCE == null)判断代码没有在同步代码块synchronized中,
// 不能享有synchronized保证的原子性、可见性、以及有序性。所以可能会导致 指令重排
注意: 但在多线程环境下,上面的代码是有问题的,getInstance 方法对应的字节码为
0: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
3: ifnonnull 37 // 判断是否为空
// ldc是获得类对象
6: ldc #3 // class cn/itcast/n5/Singleton
// 复制操作数栈栈顶的值放入栈顶, 将类对象的引用地址复制了一份
8: dup
// 操作数栈栈顶的值弹出,即将对象的引用地址存到局部变量表中
// 将类对象的引用地址存储了一份,是为了将来解锁用
9: astore_0
10: monitorenter
11: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
14: ifnonnull 27
// 新建一个实例
17: new #3 // class cn/itcast/n5/Singleton
// 复制了一个实例的引用
20: dup
// 通过这个复制的引用调用它的构造方法
21: invokespecial #4 // Method "<init>":()V
// 最开始的这个引用用来进行赋值操作
24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
27: aload_0
28: monitorexit
29: goto 37
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
40: areturn
- 17 表示创建对象,将对象引用入栈 // new Singleton
- 20 表示复制一份对象引用 // 复制了引用地址, 解锁使用
- 21 表示利用一个对象引用,调用构造方法 // 根据复制的引用地址调用构造方法
- 24 表示利用一个对象引用,赋值给 static INSTANCE
可能jvm 会优化为:先执行 24(赋值),再执行 21(构造方法)
- t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例
- 对 INSTANCE 使用 volatile 修饰即可,可以禁用指令重排
6、happens-before (对共享变量的写操作,对其它线程的读操作可见)
happens-before 规定了对共享变量的写操作,对其它线程的读操作可见,它是可见性与有序性的一套规则总结。抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见
下面说的变量都是指 成员变量或静态成员变量
方式一 :
- 线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见
- synchronized锁, 保证了可见性
static int x;
static Object m = new Object();
new Thread(()->{
synchronized(m) {
x = 10;
}
},"t1").start();
new Thread(()->{
synchronized(m) {
System.out.println(x);
}
},"t2").start();
// 10
方式二 :
- 线程对 volatile 变量的写,对接下来其它线程对该变量的读可见
- volatile修饰的变量, 通过写屏障, 共享到主存中, 其他线程通过读屏障, 读取主存的数据
volatile static int x;
new Thread(()->{
x = 10;
},"t1").start();
new Thread(()->{
System.out.println(x);
},"t2").start();
方式三:
- 线程 start() 前对变量的写,对该线程开始后对该变量的读可见
- 线程还没启动时, 修改变量的值, 在启动线程后, 获取的变量值, 肯定是修改过的
static int x;
x = 10;
new Thread(()->{
System.out.println(x);
},"t2").start();
方式四 :
- 线程结束前 对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或 t1.join()等待它结束)
- 主线程获取的x值, 是线程执行完对x的写操作之后的值
static int x;
Thread t1 = new Thread(()->{
x = 10;
},"t1");
t1.start();
t1.join();
System.out.println(x);
方式五 :
- 线程 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); // 10, 打断了, 读取的也是打断前修改的值
break;
}
}
},"t2");
t2.start();
new Thread(()->{
sleep(1);
x = 10;
t2.interrupt();
},"t1").start();
while(!t2.isInterrupted()) {
Thread.yield();
}
System.out.println(x); // 10
}
方式六 :
- 对变量默认值(0,false,null)的写,对其它线程对该变量的 读可见 (最基本)
- 具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z ,配合 volatile 的防指令重排,有下面的例子
- 因为x加了volatile, 所以在volatile static int x 代码的上面添加了读屏障, 保证读到的x和y的变化是可见的(包括y, 只要是读屏障下面都OK); 通过传递性, t2线程对x,y的写操作, 都是可见的
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。
文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/148632.html