JVM(复习)java内存模型

JVM(复习)java内存模型


JVM(复习)java内存模型

java内存模型即JMM(java memory model)目的是为了解决java多线程环境下的对共享数据的读写一致性问题,通过happens-before语义定义了java程序对数据的访问规则,修正了由于读写冲突导致的cache数据不一致的问题,是一种逻辑抽象,并没有真正的内存实体

1,并发编程中两个关键问题

在多线程编程中解决的两个最常见的问题:

  • 多线程之间如何操作同一变量
  • 多线程中如何处理同步问题

Java的并发采用的是共享内存模型。线程之间通过共享程序的公共状态,通过写-读内存中的公共状态来进行隐式通信。

在java中,共享变量是存储在主内存中,即主内存是共享内存区域,所有线程都可以访问,但是线程对共享变量的操作(读取赋值。。。)都不能再主内存中直接操作,而是首先将共享变量拷贝一份回到每个线程私有的工作内存中,然后在进行相关操作,最后将操作完的变量值写入到主内存中。线程只能访问到主内存中的共享变量,不能访问到每个线程私有的工作内存中的共享变量

img

而JMM就是决定一个线程对共享变量的写入何时对另一个线程可见。

img

理解Java内存模型,是理解线程安全问题的基础。知道JMM有主内存和工作内存之分之后,我们就很容易的理解多个线程操作同个共享变量可能引发的数据不一致的问题。假设有如下代码:

    private static int a = 0;

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10000; i++) {
            new Thread(() -> {
                a = a+1;
            }).start();
        }
        Thread.sleep(3000);
        System.out.println(a);
    }

这里有一万个线程去操作共享数据a,如果不存在并发问题的话,“预期的结果应该是10000”在这里插入图片描述

在运行一次

在这里插入图片描述

实际结果是没法预测的,这跟主内存和工作内存有联系,假设a = 0,同时存在两个线程对a进行++操作,则此时两个线程的工作内存中都是1,在写入到主内存时,主内存由0变成1,但是正确结果应该是2才对

但是我对a变量加上volatile修饰后,结果就正确了:

    private static volatile int a = 0;

    public static void main(String[] args) throws InterruptedException {
        for (int i = 0; i < 10000; i++) {
            new Thread(() -> {
                a = a+1;
            }).start();
        }
        Thread.sleep(1000);
        System.out.println(a);
    }

在这里插入图片描述

  • 当写一个volatile变量时,JMM会把线程对应的工作内存中该共享变量值刷新回主内存,即在工作内存中操作完volatile修饰的共享变量后马上将新的值刷新回主内存
  • 而在读一个volatile变量时,JMM会把该线程对应的工作内存中的共享变量置为无效,要从主内存中读取该共享变量,所以对于一个volatile变量的读,总能看到任意线程对这个变量最后的写入

这就涉及到我们刚刚提到的JMM,JMM保证了一个线程对共享变量的写入何时对另一个线程可见,通过控制主内存与每个线程的本地内存之间的交互,来为提供内存可见性保证

2,可见性,原子性和有序性

2.1可见性

可见性指当一个线程修改了共享变量的值,其它线程能够立即得知这个修改。Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值来实现可见性的

JMM中主要有三种操作实现可见性:

  • volatile:在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值
  • synchronize:对一个变量执行 unlock 操作之前,必须把变量值同步回主内存
  • final:被 final 关键字修饰的字段在构造器中一旦初始化完成,并且没有发生 this 逃逸(其它线程通过 this 引用访问到初始化了一半的对象),那么其它线程就能看见 final 字段的值。

在JMM内部可见性的实现是通过:

  • 依赖于内存屏障
  • 禁止某些重排序的方式
  • happens-before规则

关于这三个概念可以看第3点,其实实现可见性就是通过内存屏障或者happens-before去禁止某些类型的重排序

2.2原子性

**原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。比如对于一个静态变量int x,两条线程同时对他赋值,线程A赋值为1,而线程B赋值为2,不管线程如何运行,最终x的值要么是1,要么是2,线程A和线程B间的操作是没有干扰的,这就是原子性操作,不可被中断的特点。**有点要注意的是,对于32位系统的来说,long类型数据和double类型数据(对于基本数据类型,byte,short,int,float,boolean,char读写是原子操作),它们的读写并非原子性的,也就是说如果存在两条线程同时对long类型或者double类型的数据进行读写是存在相互干扰的,因为对于32位虚拟机来说,每次原子读写是32位的,而long和double则是64位的存储单元,这样会导致一个线程在写时,操作完前32位的原子操作后,轮到B线程读取时,恰好只读取到了后32位的数据,这样可能会读取到一个既非原值又不是线程修改值的变量,它可能是“半个变量”的数值,即64位数据被两个线程分成了两次读取。但也不必太担心,因为读取到“半个变量”的情况比较少见,至少在目前的商用的虚拟机中,几乎都把64位的数据的读写操作作为原子操作来执行,因此对于这个问题不必太在意,知道这么回事即可。

2.3有序性

有序性是指对于单线程的执行代码,我们总是认为代码的执行是按顺序依次执行的,这样的理解并没有毛病,毕竟对于单线程而言确实如此,但对于多线程环境,则可能出现乱序现象,因为程序编译成机器码指令后可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致,要明白的是,在Java程序中,倘若在本线程内,所有操作都视为有序行为,如果是多线程环境下,一个线程中观察另外一个线程,所有操作都是无序的,前半句指的是单线程内保证串行语义执行的一致性,后半句则指指令重排现象和工作内存与主内存同步延迟现象。

3.内存屏障,指令重排和happens-before

上文中我们提到,JMM实现可见性是通过内存屏障,禁止某些指令重排序,现在来具体看看这三个概念

3.1内存屏障

JMM定义了8个操作来完成主内存和工作内存的交互操作

img

  • read:把一个变量的值从主内存传输到工作内存中

  • load:在 read 之后执行,把 read 得到的值放入工作内存的变量副本中

  • use:把工作内存中一个变量的值传递给执行引擎

  • assign:把一个从执行引擎接收到的值赋给工作内存的变量

  • store:把工作内存的一个变量的值传送到主内存中

  • write:在 store 之后执行,把 store 得到的值放入主内存的变量中

  • lock:作用于主内存的变量

  • unlock

所谓内存屏障其实就是一组处理器指令(上面的8个指令),用于实现对内存操作的顺序限制,JMM 为了保证在不同的编译器和 CPU 上有相同的结果,通过插入特定类型的内存屏障来禁止特定类型的编译器重排序和处理器重排序,插入一条内存屏障会告诉编译器和 CPU:不管什么指令都不能和这条 Memory Barrier 指令重排序。

一共有四种内存屏障:

  • LoadLoad
  • LoadStore
  • StoreLoad
  • StoreStore

在java编译器生成指令序列的适当位置插入内存屏障指令来禁止特定类型的处理器重排序,让程序按我们预想的流程去执行

img

3.2指令重排序

指令重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段

as-if-serial:

不管怎么重排序,单线程程序执行结果不能被改变

  • 编译器优化的重排序。编译器在不改变单线程程序语义(as-if-serial )的前提下,可以重新安排语句的执行顺序。
  • 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对机器指令的执行顺序。
  • 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。 从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序

img

JMM对程序采取了不同的策略:

  • 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序
  • 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求(即允许这种重排序)
    • 在单线程程序中,对于存在控制依赖的操作重排序,不会改变执行结果
    • 但是在多线程程序中,对于存在控制依赖的操作重排序,可能会改变程序执行结果

所以:对于重排序可能会导致的多线程程序出现的内存可见性问题

  • 对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序
  • 对于处理器重排序,JMM的处理器重排序规则会要求java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序

3.3happens-before

JSR-133使用happens-before的概念来阐述操作之间的内存可见性,在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系(无论是否在一个线程之内)

  • **程序顺序规则:**一个线程中的每个操作,happens-before于该线程中的任意后续操作
  • **监视器锁规则:**对一个锁的解锁,happens-before于随后对这个锁的加锁
  • **volatile变量规则:**对一个volatile变量的写,happens-before于任意后续对这个volatile变量的读
  • **传递性:**如果A happens-before B,B happens-before C那么A happens-before C
  • **线程启动法则:**在一个线程里,对 Thread.start 的调用会 happens-before 于每一个启动线程中的动作。
  • **线程终止法则:**线程中的任何动作都 happens-before 于其他线程检测到这个线程已终结,或者从 Thread.join 方法调用中成功返回,或者 Thread.isAlive 方法返回false。
  • **中断法则法则:**一个线程调用另一个线程的 interrupt 方法 happens-before 于被中断线程发现中断(通过抛出InterruptedException, 或者调用 isInterrupted 方法和 interrupted 方法)。
  • **终结法则:**一个对象的构造函数的结束 happens-before 于这个对象 finalizer 开始。

当一个变量被多个线程读取且被至少一个线程写入时,如果读操作和写操作之前没有实现happens-before排序,则会产生数据竞争问题,产生错误的结果

happens-before和JMM关系图:

img

呈现给程序员看到的只有刚刚提到的规则,底层则是由JMM帮我们去实现了对于某种类型的重排序的禁止

4.volatile内存语义

  • 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存

  • 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,该线程需要从主内存中读取该共享变量的值

4.1 volatile特性

  1. 实现可见性:保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了共享变量的值能保证这个新值对其他线程是立马可见的
  2. **实现有序性:**禁止进行指令重排序
  3. volatile只能保证单条读或者写命令的原子性,复合操作(i++)不能保证原子性

4.2volatile如何禁止指令重排序

volatile变量的可见性是通过内存屏障实现的,在java编译器生成的指令序列的适当位置插入内存屏障指令来禁止特定类型的处理器重排序,让程序按我们预想的流程去执行

  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序,确保volatile写之前的操作不会被编译器重排序到volatile写之后
  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序,确保volatile读之后的操作不会被编译器重排序到读之前
  • 当第一个是volatile写,第二个是volatile读时,都不能进行重排序

所以,需要通过在指令序列中插入内存屏障来保证执行顺序

volatile插入内存屏障后生成的指令序列如图:

在这里插入图片描述

5.锁的内存语义

  • 当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中
  • 当线程获取锁时,JMM会把该线程对应的本地内存置为无效,从而使得被监视器保护的临界区代码必须从主内存读取共享变量
发布了254 篇原创文章 · 获赞 136 · 访问量 3万+

猜你喜欢

转载自blog.csdn.net/weixin_41922289/article/details/103412003