深入理解Java虚拟机笔记(一)JVM内存模型和先行发生原则

一 概述

多任务处理在现代计算机操作系统中几乎已是项必备的功能了。 在许多情况下,让计算机同时去做几件事情,不仅是因为计算机的运算能力强大了,还有一个很重要的原因是计算机的运算速度与它的存储和通信子系统·速度的差距大大·,大量的时间都花费在磁盘IO、网络通信或者数据库访向上。

二 硬件的效率与一致性

物理计算机中的并发问题与虚拟机的情况很相似,具有相当大的参考意义。

绝大多数计算任务都不可能只靠处理器完成。处理器至少要和内存交互(如读取数据、存储结果等等),这个IO操作时很难消除的。现代计算机系统都不得不加入了一层读写接近处理器速度的高速缓存:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。

2.1 缓存一致性 (Cache Coherence)

基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性 (Cache Coherence)

在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同主内存 (Main Memory).当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致,如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?

为了了解决致性的问题, 需要各个处理器访问缓存时都遵循一些协议, 在读写时要根据协议来进行操作(协议类型很多)

2.2 硬件内存模型

可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问过程抽象.不同架构的物理机器可以拥有不一样的内存模型,而Java虚拟机也有自己的内存模型
在这里插入图片描述

2.3 乱序执行

为了使得处理器内部的运算单元能尽量被充分利用,处理器会对输入代码进行乱序执行(Out-Of-Order execution)优化,处理器会将乱序执行的结果重组,保证如何保证不是我们关心的点!)结果与顺序执行的结果时一样的;但并不保证程序中各个语句计算的先后顺序和代码中的顺序一致。

所以如果一个任务依赖另一个任务的中间结果,其顺序性不能靠代码的先后顺序来保证(应该可以靠其他方式).JVM也有类似的指令重排序优化

三 JVM内存模型

java内存模型(JMM)可以屏蔽掉硬件和操作系统的内存访问差异,保证了java的可移植性。本文的JMM基于jdk1.5的。

3.1 主内存和工作内存

JMM的主要目标就是定义程序中各个变量的访问规则:即jvm将变量(不包括局部变量和方法参数,这些在栈中,线程私有)存储到内存和从内存中取出的底层细节。JVM没有限制硬件和操作系统层面的优化。

  1. JMM的主内存就是jvm内存中的一部分
  2. 下图可以类比前面硬件的内存模型
  3. 线程对变量的所有操作都在工作内存中进行
  4. 工作内存会拷贝主内存的数据(对象不会全部拷贝,可能拷贝某个字段或者引用
    在这里插入图片描述

3.2 内存间的交互操作

JMM定义了如下8种操作区完成变量从主内存拷贝到工作内存、以及从工作内存同步回主内存之类的实现细节。JVM实现时保证下面的操作都是原子性、不可分割的(在某些平台,如32位系统上,double和long类型的变量会有例外)

  • lock(锁定):作用于主内存的变量,一个变量在同一时间只能一个线程锁定,该操作表示这条线成独占这个变量
  • unlock(解锁):作用于主内存的变量,表示这个变量的状态由处于锁定状态被释放,这样其他线程才能对该变量进行锁定
  • read(读取):作用于主内存变量,表示把一个主内存变量的值传输到线程的工作内存,以便随后的load操作使用
  • load(载入):作用于线程的工作内存的变量,表示把read操作从主内存中读取的变量的值放到工作内存的变量副本中(副本是相对于主内存的变量而言的)
  • use(使用):作用于线程的工作内存中的变量,表示把工作内存中的一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时就会执行该操作
  • assign(赋值):作用于线程的工作内存的变量,表示把执行引擎返回的结果赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时就会执行该操作
  • store(存储):作用于线程的工作内存中的变量,把工作内存中的一个变量的值传递给主内存,以便随后write操作使用
  • write(写入):作用于主内存的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中

如果要把一个变量从主内存传输到工作内存,那就要顺序的执行read和load操作,如果要把一个变量从工作内存回写到主内存,就要顺序的执行store和write操作(都是两步,不是一步!)。
虚拟机只是要求顺序的执行,并没有要求连续的执行,所以如下也是正确的。对于两个线程,分别从主内存中读取变量a和b的值,并不一样要read a; load a; read b; load b.

3.3 8种操作必须满足的规则

关键主要是知道jvm会如何运作怎么实现可以先放放!!!
针对volatile修饰的变量,会有一些特殊规定
在这里插入图片描述

3.4 对于volatile型变量的特殊规则

volatile可以说是java虚拟机中提供的最轻量级的同步机制。java内存模型对volatile专门定义了一些特殊的访问规则。当一个变量被定义位volatille之后,它将具备两种特性

可见性

保证此变量对所有的线程的可见性,即一个线程修改了该变量的值,其他线程可以立即得知。需要注意:可见性并不保证线程安全。多线程同时操作一个线程还是会出现覆盖、资源竞争等问题。

严谨的说:工作内存在某一个时刻变量可能会出现不一致,但执行引擎不会出现这种情况(因为它每次都会去主内存中读取最新值)

禁止指令重排序

普通的变量仅仅会保证在该方法执行的过程中,所有依赖赋值结果的地方都能获取到正确的结果,但不能保证变量赋值的操作顺序和程序代码的顺序一致。(在单线程环境下没有问题)
在这里插入图片描述

注意:指令重排序是指机器级别的优化操作,也就是java代码对应的汇编代码被提前执行(代码顺序是对的,执行顺序不一定)

如何实现的呢?内存屏障
在这里插入图片描述

内存屏障总结(以前一直没想通!)

  1. 很多代码,在cpu看来都可以重排序来提高效率。单线程时肯定没问题
  2. 内存屏障保证后面的指令不会重排序到前面的指令前面(前面的照样重排序,即:前面的必定先于后面的
  3. 我对volatile解决DCL缺陷的理解:原来a= new A()代码中。这一行实质有3步(汇编指令不止3条):1.分配内存,2.初始化,3.对象复制给引用a。在没用volatile修饰a时,由于指令重排序。可能第三步会先执行(因为2、3步没有依赖关系).如果第三步先执行,并且恰好工作内存中的a同步回了主内存。这时另一个线程读取到了a的状态,然后拿着a进行操作。这是a还没进行初始化呢?肯定不安全了!如果用了volatile,那么a保证行java代码所生成的汇编码前面/后面都加了内存屏障。也就是代码顺序在它前面的必然在它前面执行,在它后面的必然在他后面执行。即第3步必然在第2步后面
  4. 书上只提到了一个内存屏幕,但我反推:一个后面内存屏障只能保证后面的代码不会重排序到volatile变量前面。但这样无法保证这个内存屏障前面的volatile变量的操作不会被重排序(比如第三点在第3步后面加了内存屏障,它是无法保证第2步重排序到第3步后面/内存屏障前面)。所以必然前面也有一个内存屏障,只有这样。第3步才能保证在第2步后面执行。我从其他博客找到的资料也验证了我的想法,如下图:
  5. 在这里插入图片描述

性能问题

跟其他保证并发安全的工具相比。在某些情况下,volatile的同步机制性能要优于锁(使用synchronized关键字或者java.util.concurrent包中的锁)。但是现在由于虚拟机对锁的不断优化和实行的许多消除动作,很难有一个量化的说快多少。
与自己相比,就可以确定一个原则:volatile变量的读操作和普通变量的读操作几乎没有差异,但是写操作会慢一些,因为要在本地代码中插入许多内存屏障指令来禁止指令重排序,保证处理器不发生代码乱序执行行为。

3.5 long和double变量的特殊规则

在这里插入图片描述

  1. jvm规范不要求实现。但现在的商用虚拟机都将这两个变量实现成原子操作。所以不需要太关心
  2. 可能会出现的情况:某些线程可能会读到某个变量一般是旧值,一般是新值

四 原子性、可见性、有序性

JMM就是围绕着这三个特性来实现的

4.1 原子性

由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store 和write,我们大致可以认为基本数据类型的访问读写是具备原子性

如果应用场景需要个更大范围的原子性保证 (经常会遇到), Java内存模型还提供了lock和unlock操作来满足这种需求,尽管JVM未把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作,这两个字节码指令反映到Java代码中就是同步块一synchronized 关键字,因此在synchronized块之间的操作也具备原子性。

4.2 可见性

可见性是指一个线程修改了一个变量的值后,其他线程立即可以感知到这个值的修改。正如前面所说,volatile类型的变量在修改后会立即同步给主内存,在使用的时候会从主内存重新读取,是依赖主内存为中介来保证多线程下变量对其他线程的可见性的。
除了volatile,synchronized和final也可以实现可见性。synchronized关键字是通过unlock之前必须把变量同步回主内存来实现的,final则是在初始化后就不会更改,所以只要在初始化过程中没有把this指针传递出去(this引用逃逸很危险,其他线程可能看到初始化了一半的对象!!!)也能保证对其他线程的可见性。

4.3 有序性

在单线程中观察,所有的操作都是有序的;在一个线程中观察另一个线程,所有的操作都是无序的。
java本身提供了volatile和synchronized来保证有序性。其中synchronized的原理是因为被包围的代码同时只能被一个线程执行

4.4 万能的synchronized

这三种特性synchronized都能满足,但是也造成了被滥用的局面,可能会对性能照成较大的影响

五 先行发生原则

如果java内存模型中所有的有序性仅仅靠volatile和synchronized来完成,那么有一些操作将会变得很烦琐,但是我们编写代码时并没有这一感觉。这是因为java语言有一个先行发生(happends-before)的原则.

这个原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据(这个依据也是我们程序员编程时需要考虑的因素,即代码是否是安全的).

5.1 先行可以被后面的操作观察到

先行发生原则是JMM中定义的两项操作之间的偏序关系。如果说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到

5.2 八条规则

  1. 程序次序规则同一个线程内,按照代码出现的顺序,前面的代码先行于后面的代码,准确的说是控制流顺序,因为要考虑到分支和循环结构。
  2. 管程锁定规则:一个unlock操作先行发生于后面(时间上)对同一个锁的lock操作。
  3. volatile变量规则:对一个volatile变量的写操作先行发生于后面(时间上)对这个变量的读操作。
  4. 线程启动规则:Thread的start( )方法先行发生于这个线程的每一个操作。
  5. 线程终止规则:线程的所有操作都先行于此线程的终止检测。可以通过Thread.join( )方法结束、Thread.isAlive( )的返回值等手段检测线程的终止。
  6. 线程中断规则:对线程interrupt( )方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupt( )方法检测线程是否中断
  7. 对象终结规则:一个对象的初始化完成先行于发生它的finalize()方法的开始。
  8. 传递性:如果操作A先行于操作B,操作B先行于操作C,那么操作A先行于操作C。

5.3 结论

  • 一个操作时间上的先发生不代表这个操作会是先行发生(即时间线上后面的操作不一定可见影响)
  • 一个操作先行发生也不能推导出这个操作必定是时间上的先发生(比如指令重排序,解释一下:对操作int i =2;int j = 3;而言;根据第一条规则,在同一线程内int i = 2先行于int j= 3,但这时可能会有指令重排序,因为这个不影响最后的结果,我理解就是观察到没有影响也是一种成功的观察;但在时间上先行的不一定先执行了)
  • 时间先后顺序于先行发生原则之间基本没有太大的关系(因为先行不代表先执行,否则不就是时间线上的比较了吗?先行表示的是一种关系。A如果先行于B,则jvm会保证B能观察到A的影响)
  • 我觉得这个原则主要可以用来帮助我们分析代码是否是安全的!!!暂时不用过于深究更底层的细节,否则会陷入牛角尖

参考

  1. 深入理解Java内存模型
发布了107 篇原创文章 · 获赞 1 · 访问量 3947

猜你喜欢

转载自blog.csdn.net/m0_38060977/article/details/104578699