深入理解JVM学习笔记-Java内存模型与线程

硬件效率与一致性:让计算机并发执行若干任务与更充分利用计算机处理器的效能之间的因果关系看起来顺利成章,实际上它们之间的关系并没有想象中的那么简单,由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存与处理器之间的缓冲,基于高速缓存的存储交互很好的解决 了处理器与内存的速度矛盾,但是又引入一个新的问题:缓存一致性。在多处理器系统中,每个处理器都有自己的高速缓存,而他们又共享同一主内存,为了解决缓存一致性问题,需要各个处理器访问缓存时都遵循一些协议,在读写时根据协议来进行操作。处理器、高速缓存、主内存之间的交互关系如下图所示:


处理器、高速缓存、主内存间的交互关系.png

Java内存模型

Java虚拟机规范试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽掉各种硬件和操作系统的内存访问差异。这个模型必须足够严谨,才能让Java并发内存访问 不会产生歧义,但是又必须足够宽松,使得虚拟机的实现有足够的自由空间去利用硬件的各种特性。

主内存与工作内存

Java内存模型的主要目的是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。此处的变量与java编程中的所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但不包括局部变量和方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。
java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程工作内存保存了被该线程使用的变量的主内存副本拷贝,线程对变量所有操作都必须在工作内存中进行,不能直接读写主内存中的变量,不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需通过主内存来完成,线程、主内存、工作内存三者交互关系如图所示:

image.png

主内存工作内存与java内存区域中的java堆、栈、方法区不是同一个层次的内存划分,两者基本没有关系,从变量、主内存、工作内存的定义来看,主内存主要对应于java堆中的对象实例数据部分、而工作内存则对应于虚拟机栈中的部分区域。
在单线程中不会出现线程安全问题,而在多线程编程中,有可能会出现同时访问同一个资源的情况,这种资源可以是各种类型的的资源:一个变量、一个对象、一个文件、一个数据库表等,而当多个线程同时访问同一个资源的时候,就会存在一个问题:所以主内存可以是一个变量、一个对象、一个文件、一个数据库表。
主内存与工作内存交互操作:主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存,如果从工作内存同步回主内存的实现细节。java内存模型定义了八种操作来完成内存交互操作,虚拟机实现时必须保证每一种操作都是原子的,不可再分的,
lock:锁定把变量标示为一条线程独占;
unlock:解锁把一个处于锁定状态的变量释放出俩,其它线程才可以锁定;
read:读取把变量从主内存传输到工作内存中。
load:载入把read操作从主内存中得到的变量值放入到工作内存的变量副本中。
use:使用作用工作内存变量;
assign:赋值作用于工作内存变量,把执行引擎接收到的值赋值工作内存变量。
store:存储作用工作内存变量,把一个变量值传送到主内容中以便write操作使用。
write:写入作用于主内存变量,把store操作从工作内存得到的变量存放到主内存变量中。
除了以上八种操作来完成内存交互操作,还规定了8种基本操作必须满足一些规则。

volatile关键字

当变量被定位volatile之后,它将具备两种特性:
可见性:保证此变量对所有线程的可见性,可见性指当一条线程修改了这个变量的值之后,新值对于其他线程来说是可以立即得知的。而普通变量的值在线程之间传递需要通过主内存来完成。
指令重排序:第二个语义就是禁止指令重排序。volatile关键字禁止指令重排序有两层意思:
1)当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
2)在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
问题一:volatile如何保证可见性?
即一个线程修改了值,另外一个线程可以立马可见,java内存模型对volatile变量的操作指定如何规则来保证可见性:
(1)每次使用变量前都必须先从主内存刷新最新的值,用于保证能看见其他线程对变量v所做的修改;(这个写回内存的操作会导致在其它CPU里缓存了该内存地址的数据无效)
(2)在工作内存中,每次修改变量都必须立即同步回主内存中,用于保证其他线程可以看到自己对变量的修改;
问题二:volatile如何禁止指令重排序?
观察加入volatile关键字和没有加入volatile关键字时所生成的字节码发现,加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障,在volatile修饰的变量进行赋值后,会添加lock操作(内存屏障),内存屏障会提供3个功能:
1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
2)它会强制将对缓存的修改操作立即写入主存;
3)如果是写操作,它会导致其他CPU中对应的缓存行无效。
简单例子:

//x、y为非volatile变量
//flag为volatile变量
x = 2;        //语句1
y = 0;        //语句2
flag = true;  //语句3
x = 4;         //语句4
y = -1;       //语句5

由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。
新例子:

//线程1:
context = loadContext();   //语句1
inited = true;             //语句2
 
//线程2:
while(!inited ){
  sleep()
}
doSomethingwithconfig(context);

指令重排序会导致语句2会在语句1之前执行,那么久可能导致context还没被初始化,而线程2中就使用未初始化的context去进行操作,导致程序出错。这里如果用volatile关键字对inited变量进行修饰,就不会出现这种问题了,因为当执行到语句2时,必定能保证context已经初始化完毕。

原子性、可见性于有序性

Java内存模型相关操作和规则主要围绕并发过程中如何处理原子性、可见性、有序性三个特征来建立的。
(1)原子性:在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。
x = 10;是原子操作;
y = x;它先要去读取x的值,再将x的值写入工作内存。
x++和x = x+1:读取x的值,进行加1操作,写入新的值。
Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。synchronized同步代码块之间的操作具备原子性;并发包中的原子操作类(Atomic系列)java.util.concurrent.atomic包,在该包中提供了许多基于CAS实现的原子操作类。
(2)可见性:
Java提供了volatile关键字来保证可见性。一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
(3)有序性
在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。在本线程内观察,所有操作都是有序的,在一个线程横纵观察另外一个线程,所有的操作都是无序的,前半句指线程内表现为串行的语义,后半句指指令重排序的现象。volatile和synchronized关键字可以保证线程间操作的有序性,volatile关键字包含禁止指令重排序;synchronized由一个变量在用一个时刻只允许一个线程对其进行lock操作获得,这条规则决定了持有同一个锁的两个同步块执行串行进入。synchronized在需要原子性、可见性、有序性时都可以作为一种解决方案。

先行发生原则

先行发生原则(happens-before)与有序性有关系:java内存模型所有有序性仅靠volatile和synchronized来完成会很繁琐,先行发生原则是判断数据是否存在竞争、线程是否安全的主要依据。在衡量并发安全问题时不要受到时间顺讯的干扰,一些以先行发生原则为准。

Java内存模型总结

Jvm内存模型内存模型与并发有关,定义程序中各个变量的访问规则来保证并发安全问题,主要围绕原子性、可见性、有序性来建立的。
原子性:volatile保证不了原子性,通过synchronized、Lock、原子操作类(AtomicInteger,基于CAS实现)这三种方式可以保证原子性。
可见性:volatile、synchronized、final、Lock保证可见性。
有序性:volatile(指令重排序)、synchronized可以保证有序性,和先行发生原则也是来解决有序性问题。

Java与线程

实现线程的三种方式:使用内核线程实现、使用用户线程实现、使用用户线程加轻量级进程实现。
Java线程实现:Java线程模型是基于操作系统原生线程模型来实现,操作系统支持什么样的线程模型,在很大程度上决定了Java虚拟机的线程是如何映射的。
Java线程调度:线程调度是指系统为线程分配处理器使用权的过程,调度方式有两种:
(1)协同式调度:线程的执行时间由线程本身来控制,线程把自己执行完之后,要主动通知系统切换到另外一个线程。缺点:线程执行时间不可控,如果一个线程有问题,一直不停止,那么程序就会阻塞在哪里。就是自己不执行完一直执行。
(2)抢占式调度: 每个线程由系统来分配执行时间,线程的切换不是由线程本身决定(可以通过Thread.yield()让出执行时间),在这种调度方式下,线程执行时间是系统可控的,也就不会有一个线程导致整个进程阻塞的问题,Android系统的线程调度使用抢占式调度。JVM 采用的是抢占式调度模型,也就是先让优先级高的线程占用 CPU,如果线程的优先级都一样,那就随机选择一个线程,并让该线程占用 CPU。
虽然java线程调度是系统自动完成的,但是我们还是建议系统给某些线程多分配一点执行时间,这个可以通过线程优先级来完成。如果我们的程序想干预线程的调度过程,最简单的办法就是给每个线程设定一个优先级。线程优先级并不是太靠谱,原因是java线程是通过映射到系统的原生线程上来实现的,所以线程调度最重还是取决于操作系统,虽然很多操作系统都提供线程优先级概念,但是并不见得能与java线程优先级一一对应。

推荐阅读更多精彩内容