介绍
从Java代码到CPU指令:
- 最开始,程序员编写的java代码,是.java文件
- 在编译命令之后,从刚才的 .java文件,会生成一个新的java字节码文件 .class
- JVM会执行刚才生成的字节码文件(.class),并把字节码文件转化成机器指令
- JVM的不同实现会带来不同的翻译,不同CPU平台的机器指令又千差万别,无法保证并发安全的效果一致
- 机器指令可以直接在CPU上执行,也就是最终的程序运行
由于这样的问题存在,开发重点开始向下转移,需要研究Java底层原理。
JVM内存结构 VS Java内存模型 VS Java对象模型
- 容易混淆的三个概念,其实他们截然不同
- JVM内存结构:与java虚拟机的运行时区域有关
- 方法区,堆
- 堆:主要存放着对象实例,new出来的对象实例
- 方法区:存储着已经加载的static变量,类信息,常量信息,永久引用(static变量的引用)
- Java栈、本地方法栈、程序计数器
- java栈:保存了各个基本的数据类型,以及对于对象的引用,不是对象本身,对象本身保存在堆中,对象的引用保存在栈中
- 本地方法栈:保存本地方法相关的栈
- 程序计数器:保存当前线程所执行到的字节码的行号数,下一条需要执行的指令等
- 方法区,堆
- Java内存模型:和java的并发编程相关(重点)
- Java对象模型:和Java对象在虚拟机中的表现形式有关
- 每个对象在JVM中存储有一定结构,这个结构就是Java对象模型
- JVM会给这个类创建一个instanceClass,保存在方法区,用来在JVM层表示该java类
- 当我们在Java代码中,使用new创建一个对象的时候,JVM会创建一个instanceOopDesc对象,这个对象中包含了对象头以及实例数据
- 每个对象在JVM中存储有一定结构,这个结构就是Java对象模型
Java内存模型
JMM(Java Memory Model)
- C语言不存在内存模型
- 依赖于处理器,不同处理器的运行结果不一样
- 无法保证并发安全
- 需要一个标准,让多线程运行的结果可预期
- JMM是一组规范,需要各个JVM的实现来遵守JMM规范,以便于开发者可以利用这些规范,更方便地开发多线程程序
- 如果没有这样的一个JMM内存模型来规范,那么可能经过不同的JVM的不同规则重排序后,导致不同的虚拟机的运行结果不一致,就是很大的问题
- volatile、synchronized、Lock等的原理都是JMM
- 如果没有JMM,那就需要我们自己指定什么时候用內存栅栏等,那是相当麻烦的,幸好有了JMM,让我们只需要用同步工具和关键字就可以开发并发程序
- JMM最重要的3点内容:重排序、可见性、原子性
重排序
什么是重排序:
- 在线程内部的多行代码的实际执行顺序与代码在Java文件中的顺序不一致,代码指令并不是严格按照代码语句顺序执行的,它们的顺序被改变了,这就是重排序。
- 编译器优化:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序,在不改变程序语义的前提下,尽可能减少寄存器的读取、存储次数,充分复用寄存器的存储值。
- CPU指令重排:就算编译器不发生重排,CPU也可能对指令进行重排
- CPU采用流水线技术提升效率
- 可能一个指令执行了很久,它后面的指令在另外的流水线中已经执行结束了,这就导致了CPU指令重排
- 内存的重排序:线程A的对某个对象进行修改,其他线程没看到这个修改,就引出来了可见性问题。
重排序的好处:提高处理速度
- 比如一个程序中有三个变量需要先后去寄存器获取数据,优化之后,就可以一次读取寄存器,直接把三个变量的数据都读出来,避免多次读写寄存器。
重排序需要满足以下两个条件:
- 在单线程环境下不能改变程序运行的结果:as-if-serial
- 所有的操作均可以为了优化而被重排序,但是你必须要保证重排序后执行的结果不能被改变
- 编译器,runtime,处理器都必须遵守 as-if-serial
- as-if-serial只保证单线程环境,多线程环境下无效
- 存在数据依赖关系的不允许重排序
- 写后读
- 写后写
- 读后写
示例1:
1 | int a = 1 ; //A |
A、B、C三个操作之间关系为:A、B之间不存在数据依赖关系,A和C、B和C存在数据依赖关系,所以在进行重排序的时候,A、B可以随意排序,但是必须位于C的前面,执行顺序可以是 A->B->C;也可以是 B->A->C; 但是不管是何种执行顺序,最终C的结果总是3
示例2:
1 | public class RecordExample1 { |
按照重排序的规则,操作A与操作B有可能会进行重排序,如果重排序了,B会抛出异常( / by zero),此时A语句一定会执行不到,那么a还会等于3么?如果按照as-if-serial原则它就改变了程序的结果。其实JVM对异常做了一种特殊的处理,为了保证as-if-serial语义,Java异常处理机制对重排序做了一种特殊的处理:JIT(即时编译器)在重排序时会在catch语句中插入错误代偿代码(a = 3),这样做虽然会导致cathc里面的逻辑变得复杂,但是JIT优化原则是:尽可能地优化程序正常运行下的逻辑,哪怕以catch块逻辑变得复杂为代价。
可见性
在多线程环境下,我们无法就所有场景来规定某个线程修改的变量何时对其他线程可见,但是我们可以指定某些规则,这规则就是happens-before,通过happens-before规定线程之间的可见性
为什么会有可见性问题:
- CPU并不直接从内存获取数据,内存到CPU之间存在多级缓存,每一级缓存只会缓存前一级缓存的部分内容,但是缓存的读写速度会越来越快,直到寄存器缓存最高级,CPU直接从寄存器中拿数据
- 当并行的多级缓存中,某一方的缓存执行的程序很快,另一方就可能不知道其他线程的数据已经更新了,而是还在用之前的数据,就导致了读的数据过期了
- 如果所有CPU核心都只用一个缓存,就不存在内存可见性问题了
- 每个核心都会将自己需要的数据读到独占缓存中,数据修改后也是写入到缓存中,然后等待刷入到主存中,所以会导致有些核心读取的值是一个过期的值。
每个线程独占的内存抽象成本地内存:registers、L1 chache
所有线程共享的内存抽象成主存:L2 cache、L3cache、RAM
主内存与本地内存的关系
JMM有以下规定:
- 所有的变量都存储在主内存中,同时每个线程也有自己独立的工作内存,工作内存中的变量内容是主内存中的拷贝
- 线程不能直接读写主内存中的变量,而是只能操作自己工作内存中的变量,然后再同步到主内存中
- 主内存是多个线程共享的,但线程间不共享工作内存,如果线程间需要通信,必须借助主内存中转来完成
happens-before原则
- 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
- 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作;
- 线程A执行完操作后,解锁了
- 线程B拿到了这把锁,一定能看到线程A在解锁之前的所有操作,能看到对什么变量进行了改变
- volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
- 核心思想:当某个CPU在写数据时,如果发现操作的变量是共享变量,则会通知其他CPU告知该变量的缓存是无效的,因此其他CPU在读取该变量时,发现其无效会重新从主存中加载数据。
- 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作;
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
- 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
- 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;
volatile关键字
- volatile是一种同步机制,比synchronized或者Lock相关类更轻量,因为使用volatile并不会发生上下文切换等开销很大的行为,当值发生改变后,只是将其刷回内存。
- 什么是上下文
- 上下文一般是和寄存器相关的状态
- 切换线程,需要先保存当前寄存器的状态,这个状态就是上下文
- 保存状态到内存后,才会从内存重新载入新的信息
- 什么是上下文
- 如果一个变量被修饰成 volatile,那么JVM就知道了这个变量可能会被并发修改。
- 但是开销小,相应的能力也小,虽然说 volatile是用来同步的保证线程安全的,但是 volatile做不到 synchronized那样的原子保护, volatile仅在很有限的场景下才能发挥作用。
不适用场景: a++;因为a++是一个组合操作,是多个操作的组合
适用场合:
- boolean flag,如果一个共享变量自始至终只被各个线程赋值,而没有其他的操作(赋值不依赖于原来的值,如对比,取反等),那么就可以用volatile来代替syunchronized或者代替原子变量,因为对基本变量赋值自身是有原子性的,而volatile又保证了可见性,所以就足以保证线程安全
- 作为刷新之前变量的触发器,能够让触发器之前的内容都变得可见
1 | //Thread A |
volatile的作用:
- 可见性:读一个volatile变量之前,需要先让相应的本地缓存失效,这样就必须到主内存读取新的值,写一个volatile属性会立即刷入到主内存
- 禁止指令重排序优化:解决单例双重锁乱序问题
- 当程序执行到 volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
- 在进行指令优化时,不能将在对volatile变量访问的语句之前的语句放在volatile变量访问语句的后面执行,也不能把 volatile变量后面的语句放到其前面执行。
示例:
1 | //x、y为非volatile变量 |
由于 flag 变量为 volatile 变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会将语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。
并且 volatile 关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。
volatile小结:
- volatile修饰符适用于以下场景:某个属性被多个线程共享,其中一个线程修改了此属性,其他线程可以立即得到修改后的值,比如boolean flag;或者作为触发器,实现轻量级同步
- volatile属性的读写都是无锁的,它不能替代synchronized,因为它没有提供原子性和互斥性。因为无锁,不需要花费时间在获取锁和释放锁上,所以说它是低成本的
- voaltile只能作用于属性,我们用volatile修饰属性,这样compilers就不会对这个属性做指令重排序
原子性
原子性:一系列的操作,要么全部执行成功,要么全部不执行,不会出现执行一般的情况,是不可分割的。
Java中的原子操作:
- 除了long和double之外的基本类型(byte、short、int、boolean、float、char)的赋值操作
- 32位JVM虚拟机,对于long和double值得单个写入被视为两个单独得写入:每个32位写一个。这可能导致一次写入64位值得前32位,而从另一次写入看到第二次32位得情况。
- 在32位上的JVM上,long和double的操作不是原子的,但是在64位的JVM上是原子的
- 所有引用reference的赋值操作,不管是32位的及其还是64位的机器。
- java.concurrent.Atomic.*包中所有类的原子操作。
注意:
- 原子操作+原子操作!= 原子操作
- 比如两次取钱都是两次独立的原子操作,但是两次原子操作中间可能被其他线程所打断。
参考
- 本文作者: xczll
- 本文链接: https://xczllgit.github.io/2020/03/17/concurrent/2020-03-17-javaMemoryModel/
- 版权声明: 本博客所有文章除特别声明外,均采用 MIT 许可协议。转载请注明出处!