JVM_12 JMM内存模型

Posted 兴趣使然の草帽路飞

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JVM_12 JMM内存模型相关的知识,希望对你有一定的参考价值。

在这里插入图片描述

学习视频链接:黑马程序员JVM完整教程

1. java 内存模型(JMM)

JMM 即 Java Memory Model,它定义了主存(共享内存)、工作内存(线程私有)抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、 CPU 指令优化等。JMM 体现在以下几个方面:

  • 原子性: 保证指令不会受到线程上下文切换的影响。
  • 可见性:保证指令不会受 cpu 缓存的影响。
  • 有序性:保证指令不会受 cpu 指令并行优化的影响。

简单的说,JMM 定义了一套在多线程读写共享数据时(成员变量、数组)时,对数据的可见性、有序 性、和原子性的规则和保障。

1.1 原子性

原子性在学习线程时讲过,下面来个例子简单回顾一下:

问题提出,两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?

问题分析

以上的结果可能是正数、负数、零。为什么呢?因为 Java 中对静态变量的自增,自减并不是原子操作。

例如对于 i++ 而言(i 为静态变量),实际会产生如下的 JVM 字节码指令:

getstatic i // 获取静态变量i的值
iconst_1 	// 准备常量1
iadd 		// 加法
putstatic i // 将修改后的值存入静态变量i

而对应i-- 也是类似:

getstatic i // 获取静态变量i的值
iconst_1 	// 准备常量1
isub 		// 减法
putstatic i // 将修改后的值存入静态变量i

如图:

在这里插入图片描述

如果是单线程以上 8 行代码是顺序执行(不会交错)没有问题:

// 假设i的初始值为0
getstatic i 	// 线程1-获取静态变量i的值 线程内i=0
iconst_1 		// 线程1-准备常量1
iadd 			// 线程1-自增 线程内i=1
putstatic i 	// 线程1-将修改后的值存入静态变量i 静态变量i=1
getstatic i 	// 线程1-获取静态变量i的值 线程内i=1
iconst_1 		// 线程1-准备常量1
isub 			// 线程1-自减 线程内i=0
putstatic i 	// 线程1-将修改后的值存入静态变量i 静态变量i=0

但多线程下这 8 行代码可能交错运行(为什么会交错?思考一下): 出现负数的情况:

// 假设i的初始值为0
getstatic i 	// 线程1-获取静态变量i的值 线程内i=0
getstatic i 	// 线程2-获取静态变量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

出现正数的情况:

// 假设i的初始值为0
getstatic i 	// 线程1-获取静态变量i的值 线程内i=0
getstatic i 	// 线程2-获取静态变量i的值 线程内i=0
iconst_1 		// 线程1-准备常量1
iadd 			// 线程1-自增 线程内i=1
iconst_1 		// 线程2-准备常量1
isub 			// 线程2-自减 线程内i=-1
putstatic i 	// 线程2-将修改后的值存入静态变量i 静态变量i=-1
putstatic i 	// 线程1-将修改后的值存入静态变量i 静态变量i=1

解决方法

synchronized(同步关键字):

synchronized( 对象 ) {
	要作为原子操作代码
}

synchronized 解决并发问题:

static int i = 0;
static Object obj = new Object();

public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int j = 0; j < 5000; j++) {
            synchronized (obj) {
            	i++;
            }
        }
    });
    Thread t2 = new Thread(() -> {
        for (int j = 0; j < 5000; j++) {
            synchronized (obj) {
            	i--;
            }
        }
    });
    
    t1.start();
    t2.start();
    
    t1.join();
    t2.join();

    System.out.println(i);
}

如何理解呢:你可以把 obj 想象成一个房间,线程 t1,t2 想象成两个人。

当线程 t1 执行到 synchronized(obj) 时就好比 t1 进入了这个房间,并反手锁住了门,在门内执行 count++ 代码。

这时候如果 t2 也运行到了 synchronized(obj) 时,它发现门被锁住了,只能在门外等待。

当 t1 执行完synchronized{} 块内的代码,这时候才会解开门上的锁,从 obj 房间出来。t2 线程这时才可以进入 obj 房间,反锁住门,执行它的 count-- 代码。

注意:上例中 t1 和 t2 线程必须用 synchronized 锁住同一个 obj 对象,如果 t1 锁住的是 m1 对 象,t2 锁住的是 m2 对象,就好比两个人分别进入了两个不同的房间,没法起到同步的效果。

1.2 可见性

退不出的循环

先来看一个现象,main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止:

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不会如预想的停下来
}

首先 t 线程运行,然后过一秒,主线程设置 run 的值为 false,想让 t 线程停止下来,但是 t 线程并没有停!

为什么呢?来图解分析一下:

  1. 初始状态, t 线程刚开始从主内存读取了 run 的值到工作内存。

    在这里插入图片描述

  2. .因为 t 线程要频繁从主内存中读取 run 的值,JIT 编译器会将 run 的值缓存至自己工作内存中的高 速缓存中,减少对主存中 run 的访问,提高效率

    在这里插入图片描述

  3. 1 秒之后,main 线程修改了 run 的值,并同步至主存,而 t 是从自己工作内存中的高速缓存中读 取这个变量的值,结果永远是旧值

在这里插入图片描述

解决方法

volatile(易变关键字):

它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到 主存中获取它的值,线程操作 volatile 变量都是直接操作主存。

 public static volatile boolean run = true; // 保证内存的可见性

可见性

前面例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对 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

比较一下之前我们将线程安全时举的例子:两个线程一个i++ 一个 i-- ,只能保证看到最新值,不能解 决指令交错

// 假设i的初始值为0
getstatic i 	// 线程1-获取静态变量i的值 线程内i=0
getstatic i 	// 线程2-获取静态变量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

注意

synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是synchronized是属于重量级操作,性能相对更低

如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也 能正确看到对 run 变量的修改了,想一想为什么?(因为println()中有synchronized关键字加锁,可以保证原子性与可见性,它是 PrintStream 类的方法 )

1.3 有序性

诡异的结果(指令重排)

首先看一个例子:

// 可以重排的例子 
int a = 10; 
int b = 20; 
System.out.println( a + b );

// 不能重排的例子 
int a = 10;
int b = a - 5;

指令重排简单来说可以,在程序结果不受影响的前提下,可以调整指令语句执行顺序。多线程下指令重排会影响正确性。

多线程下指令重排问题

再分析下面的代码:

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 用来保存结果,问,可能的结果有几种?

在多线程环境下,以上的代码 r1 的值有三种情况:

  • 情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1

  • 情况2:线程2 先执行 num = 2,但没来得及执行ready = true,线程1 执行,还是进入 else 分支,结 果为1

  • 情况3:线程 2 先执行,但是发送了指令重排,num = 2ready = true 这两行代码语序发生装换,

    ready = true; // 前
    num = 2; // 后
    

    然后执行 ready = true 后,线程 1 运行了,那么 r1 的结果是为 0。

  • 情况4:结果还有可能是 0

    • 这种现象叫做指令重排,是 JIT 编译器在运行时的一些优化,这个现象需要通过大量测试才能偶尔遇见!

解决方法

volatile 修饰的变量,可以禁用指令重排,禁止的是加volatile 关键字变量之前的代码重排序

volatile 原理

  • volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)
  • 对 volatile 变量的写指令后会加入写屏障
  • 对 volatile 变量的读指令前会加入读屏障

如何保证可见性?

  • 写屏障(sfence)保证在该屏障之前的,对共享变量的改动,都同步到主存当中
public void actor2(I_Result r) {
     num = 2;
     ready = true; // ready 是被 volatile 修饰的,赋值带写屏障
     // 写屏障
}
  • 而读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据:
public void actor1(I_Result r) {
     // 读屏障
     // ready是被 volatile 修饰的,读取值带读屏障
     if(ready) {
        r.r1 = num + num;
     } else {
        r.r1 = 1;
     }
}

如图所示:

在这里插入图片描述

如何保证有序性?

  • 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
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;
     }
}

注意

  • volatile 不能解决指令交错

  • 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证其它线程的读跑到它前面去

  • 而有序性的保证也只是保证了本线程内相关代码不被重排序

2. CAS与原子类

这里需要总结一下,CAS就是Java中的乐观锁,synchronized关键字就是Java中的悲观锁!

  • CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系, 我吃亏点再重试呗。
  • synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁 你们都别想改,我改完了解开锁,你们才有机会。
  • 原子操作类:juc(java.util.concurrent)中提供了原子操作类,可以提供线程安全的操作,例如:AtomicInteger、 AtomicBoolean等,它们底层就是采用 CAS 技术 + volatile 来实现的。

如果对大家有帮助,请三连支持一下!

在这里插入图片描述

以上是关于JVM_12 JMM内存模型的主要内容,如果未能解决你的问题,请参考以下文章

JMM java内存模型浅读

JMM内存模型JVM内存模型

JMM内存模型JVM内存模型

java内存结构JVM——java内存模型JMM——java对象模型JOM

JVM细谈Java内存模型JMM

详解Jvm内存结构