个人成长博客

纸上得来终觉浅,绝知此事要躬行

0%

Java内存模型

概述

Java虚拟机规范中定义了Java内存模型(Java Memory Model,JMM),用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果,JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。

Java内存模型

主内存与工作内存

Java内存模型中涉及到的概念有:

  • 主内存:java虚拟机规定所有的变量(不是程序中的变量)都必须在主内存中产生,为了方便理解,可以认为是堆区。和物理机的主内存相似,只不过物理机的主内存是整个机器的内存,而虚拟机的主内存是虚拟机内存中的一部分。存储Java实例对象,包括成员变量、类信息、常量、静态变量等。属于数据共享的区域,多线程并发操作时会引发线程安全问题。
  • 工作内存:java虚拟机中每个线程都有自己的工作内存,该内存是线程私有的为了方便理解,可以认为是虚拟机栈。线程的工作内存保存了线程需要的变量在主内存中的副本。虚拟机规定,线程对主内存变量的修改必须在线程的工作内存中进行,不能直接读写主内存中的变量。不同的线程之间也不能相互访问对方的工作内存。如果线程之间需要传递变量的值,必须通过主内存来作为中介进行传递。存储当前方法的所有本地变量信息,本地变量堆其他线程不可见。字节码行号提示器、Native方法信息。属于线程私有数据区域,不存在线程安全问题。

工作内存与主内存交互

物理机高速缓存和主内存之间的交互有协议,同样的,java内存中线程的工作内存和主内存的交互是由java虚拟机定义了如下的8种操作来完成的,每种操作必须是原子性的(double和long类型在某些平台有例外)。java虚拟机中主内存和工作内存交互,就是一个变量如何从主内存传输到工作内存中,如何把修改后的变量从工作内存同步回主内存。

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

如果要把一个变量从主内存传输到工作内存,那就要顺序的执行read和load操作,如果要把一个变量从工作内存回写到主内存,就要顺序的执行store和write操作。对于普通变量,虚拟机只是要求顺序的执行,并没有要求连续的执行,所以如下也是正确的。对于两个线程,分别从主内存中读取变量a和b的值,并不一样要read a; load a; read b; load b; 也会出现如下执行顺序:read a; read b; load b; load a; (对于volatile修饰的变量会有一些其他规则,后边会详细列出),对于这8中操作,虚拟机也规定了一系列规则,在执行这8中操作的时候必须遵循如下的规则:

  1. 不允许read和load、store和write操作之一单独出现,也就是不允许从主内存读取了变量的值但是工作内存不接收的情况,或者不允许从工作内存将变量的值回写到主内存但是主内存不接收的情况
  2. 不允许一个线程丢弃最近的assign操作,也就是不允许线程在自己的工作线程中修改了变量的值却不同步/回写到主内存
  3. 不允许一个线程回写没有修改的变量到主内存,也就是如果线程工作内存中变量没有发生过任何assign操作,是不允许将该变量的值回写到主内存
  4. 变量只能在主内存中产生,不允许在工作内存中直接使用一个未被初始化的变量,也就是没有执行load或者assign操作。也就是说在执行use、store之前必须对相同的变量执行了load、assign操作
  5. 一个变量在同一时刻只能被一个线程对其进行lock操作,也就是说一个线程一旦对一个变量加锁后,在该线程没有释放掉锁之前,其他线程是不能对其加锁的,但是同一个线程对一个变量加锁后,可以继续加锁,同时在释放锁的时候释放锁次数必须和加锁次数相同
  6. 对变量执行lock操作,就会清空工作空间该变量的值,执行引擎使用这个变量之前,需要重新load或者assign操作初始化变量的值
  7. 不允许对没有lock的变量执行unlock操作,如果一个变量没有被lock操作,那也不能对其执行unlock操作,当然一个线程也不能对被其他线程lock的变量执行unlock操作
  8. 对一个变量执行unlock之前,必须先把变量同步回主内存中,也就是执行store和write操作

这8个动作必须是原子的,不可分割的。针对volatile修饰的变量,会有一些特殊规定。

volatile修饰的变量的特殊规则

关键字volatile可以说是java虚拟机中提供的最轻量级的同步机制。java内存模型对volatile专门定义了一些特殊的访问规则。假定T表示一个线程,V和W分别表示两个volatile修饰的变量,那么在进行read、load、use、assign、store和write操作的时候需要满足如下规则:

  1. 只有当线程T对变量V执行的前一个动作是load,线程T对变量V才能执行use动作;同时只有当线程T对变量V执行的后一个动作是use的时候线程T对变量V才能执行load操作
  2. 只有当线程T对变量V执行的前一个动作是assign的时候,线程T对变量V才能执行store动作;同时只有当线程T对变量V执行的后一个动作是store的时候,线程T对变量V才能执行assign动作
  3. 假定动作A是线程T对变量V实施的use或assign动作,动作F是和动作A相关联的load或store动作,动作P是和动作F相对应的对变量V的read或write动作;类似的,假定动作B是线程T对变量W实施的use或assign动作,动作G是和动作B相关联的load或store动作,动作Q是和动作G相对应的对变量W的read或write动作。如果动作A先于B,那么P先于Q

总结上面三条规则,可以概括为:

  1. volatile类型的变量保证对所有线程的可见性:主要根据前两条得来,可见性是指当一个线程修改了这个变量的值,新值(修改后的值)对于其他线程来说是立即可以得知的。正如上面的前两条规则规定,当写一个volatile类型的变量时,每次值被修改了就立即同步回主内存;当读取一个volatile类型的变量,JMM会把该线程对应的工作内存置为无效,从主内存重新读取值。返回到前面对普通变量的规则中,并没有要求这一点,所以普通变量的值是不会立即对所有线程可见的。需要注意的是,基于volatile变量的运算在并发下是并不是线程安全的。当对于volatile变量的修改操作不是原子性的时候,会存在多个线程同时从主内存拿到的值是最新的,但是经过多步运算后回写到主内存的值是有可能存在覆盖情况发生的。
  2. volatile类型的变量禁止指令重排序优化:根据第三条得来,主要通过内存屏障来实现。通过插入内存屏障指令禁止在内存屏障前后的指令重排序优化。强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。

long和double变量的特殊规则

Java内存模型要求对主内存和工作内存交换的八个动作是原子的,正如章节开头所讲,对long和double有一些特殊规则。八个动作中lock、unlock、read、load、use、assign、store、write对待32位的基本数据类型都是原子操作,对待long和double这两个64位的数据,java虚拟机规范对java内存模型的规定中特别定义了一条相对宽松的规则:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,也就是允许虚拟机不保证对64位数据的read、load、store和write这4个动作的操作是原子的。这也就是我们常说的long和double的非原子性协定(Nonautomic Treatment of double and long Variables)。

并发内存模型的实质

Java内存模型围绕着并发过程中如何处理原子性、可见性和顺序性这三个特征来设计的。

原子性(Automicity)

由Java内存模型来直接保证原子性的变量操作包括read、load、use、assign、store、write这6个动作,虽然存在long和double的特例,但基本可以忽律不计,目前虚拟机基本都对其实现了原子性。如果需要更大范围的控制,lock和unlock也可以满足需求。lock和unlock虽然没有被虚拟机直接开给用户使用,但是提供了字节码层次的指令monitorenter和monitorexit对应这两个操作,对应到java代码就是synchronized关键字,因此在synchronized块之间的代码都具有原子性。

可见性

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

有序性

有序性从不同的角度来看是不同的。单纯单线程来看都是有序的,但到了多线程就会跟我们预想的不一样。可以这么说:如果在本线程内部观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句说的就是“线程内表现为串行的语义”,后半句值得是“指令重排序”现象和主内存与工作内存之间同步存在延迟的现象。保证有序性的关键字有volatile和synchronized,volatile禁止了指令重排序,而synchronized则由“一个变量在同一时刻只能被一个线程对其进行lock操作”来保证。

volatile和synchronized的区别

总体来看,synchronized对三种特性都有支持,虽然简单,但是如果无控制的滥用对性能就会产生较大影响。volatile和synchronized的区别:

  1. volatile本质是告诉JVM当前变量在寄存器(工作内存)中的值是不确定的,需要从主内存中读取;synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住,知道该线程完成变量操作为止
  2. volatile仅能使用在变量级别;synchronized则可以使用在变量、方法和类级别
  3. volatile仅能实现变量的修改可见性,不能保证原子性;而synchronized则可以保证变量修改的可见性和原子性
  4. volatile不会造成线程的阻塞;synchronized可能会造成线程的阻塞
  5. volatile标记的变量不会被编译器优化;synchronized标记的变量可以被编译器优化

先行发生原则

如果Java内存模型中所有的有序性都要依靠volatile和synchronized来实现,那是不是非常繁琐。Java语言中有一个“先行发生原则”,是判断数据是否存在竞争、线程是否安全的主要依据。先行发生原则是Java内存模型中定义的两个操作之间的偏序关系。先行发生原则对应的八大原则为:

  1. 程序次序原则: 在一个线程内部,按照代码的顺序,书写在前面的先行发生与后边的。或者更准确的说是在控制流顺序前面的先行发生与控制流后面的,而不是代码顺序,因为会有分支、跳转、循环等
  2. 管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须注意的是对同一个锁,后面是指时间上的后面
  3. volatile变量规则:对一个volatile变量的写操作先行发生与后面对这个变量的读操作,这里的后面是指时间上的先后顺序
  4. 线程启动规则:Thread对象的start()方法先行发生与该线程的每个动作。当然如果你错误的使用了线程,创建线程后没有执行start方法,而是执行run方法,那此句话是不成立的,但是如果这样其实也不是线程了
  5. 线程终止规则:线程中的所有操作都先行发生与对此线程的终止检测,可以通过Thread.join()和Thread.isAlive()的返回值等手段检测线程是否已经终止执行
  6. 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
  7. 对象终结规则: 一个对象的初始化完成先行发生于他的finalize方法的执行,也就是初始化方法先行发生于finalize方法
  8. 传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那么操作A先行发生于操作C。

如果两个操作不满足上述任一原则,那么这两个操作就没有顺序保障,JVM可以对这两个操作进行重排序。如果A先行发生原则于B,那么操作A在内存上所做的操作对操作B都是可见的。这里对内存所做的操作值,修改了内存中的共享变量、发送了消息、调用了方法等。