volatile字段的另一个特性是即时编译器无法将其分配到寄存器里。换句话说,volatile字段的每次访问均需要直接从内存中读写。
前文基础
极客时间《Java并发编程实战》—并发编程BUG的源头与Java如何解决可见性和有序性问题笔记
文章目录
首先我们大家都知道,OS会为我们做出很多优化,比如:高速Cache,编译优化,指令重排等。针对于高速Cache,我们现在的CPU架构是:每颗CPU都会有自己的高速Cache。如下图:
对于JAVA而言,它的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。此处的变量(Variables)与Java编程中所说的变量有所区 别,它包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。为了获得更好的执行效能,Java内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存进行交互,也没有限制即时编译器 是否要进行调整代码执行顺序这类优化措施。
Java内存模型规定了所有的变量都存储在主内存(Main Memory)中(此处的主内存与介绍物理 硬件时提到的主内存名字一样,两者也可以类比,但物理上它仅是虚拟机内存的一部分)。每条线程还有自己的工作内存(Working M emory ,可与前面讲的处理器高速缓存类比),线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变 量,线程间变量值的传递均需要通过主内存来完成
,线程、主内存、工作内存三者的交互关系如图所示
关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从 工作内存同步回主内存这一类的实现细节,Java内存模型中定义了以下8种操作来完成。Java虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的。
- lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
- unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量 才可以被其他线程锁定。
- read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以 便随后的load动作使用。
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的 变量副本中。
- use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚 拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量, 每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随 后的write操作使用。
- write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的 变量中。
针对上面的8种操作,虚拟机又制定了以下的8中访问规则,其实就是为了避免占着茅坑不拉屎的情况发生,比如:一个变量从主内存读取了但工作内 存不接受,或者工作内存发起回写了但主内存不接受。
- 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内 存不接受,或者工作内存发起回写了但主内存不接受的情况出现。
- 不允许一个线程丢弃它最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回 主内存。
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存 中。
- 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load或 a s s i gn ) 的 变 量 , 换 句 话 说 就 是 对 一 个 变 量 实 施 u s e 、 s t o r e 操 作 之 前 , 必 须 先 执 行 a s s i gn 和 l o a d 操 作 。
- 一个变量在同一个时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执 行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。
- 如果对一个变量执行lock操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量 前,需要重新执行load或assign操作以初始化变量的值。
- 如果一个变量事先没有被lock操作锁定,那就不允许对它执行unlock操作,也不允许去unlock一个 被其他线程锁定的变量。
- 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。
对于volatile型变量的理解
当一个变量被定义成volatile之后,它将具备两项特性:
-
第一项是保证此变量对所有线程的可见 性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量并不能做到这一点,普通变量的值在线程间传递时均需要通过主内存来完成。比如, 线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成了之后再对 主内存进行读取操作,新变量值才会对线程B可见。但是需要注意的是:基于volatile变量的运算在并发下是线程安全的,这句话是错误的!!! (其实就是一个顺序的问题,在并发环境下,两个线程同时读取到初始值1,然后各自执行++操作,但是由于++操作并不是原子的,所以线程A准备写入2的时候,可能已经切换到了线程B,B写入了2,然后再切换回线程A,A继续自己的进程,写入2,这就导致了并发问题)
原子性、可见性与有序性
不安全的发布
不安全的几种情况
当缺少 Happens-Before 关系时,就可能出现重排序问题
,这就解释了为什么在没有充分同步的情况下发布-一个对象会导致另-一个线程看到一一个只被部分构造的对象(请参见3.5节)。在初始化-一个新的对象时需要写入多个变量,即新对象中的各个域。- 同样,在发布一个引用时也需要写入一个变量,即新对象的引用。
如果无法确保发布共享引用的操作在另一个线程加载该共享引用之前执行
,那么对新对象引用的写入操作将与对象中各个域的写入操作重排序(从使用该对象的线程的角度来看)。在这种情况下,另一个线程可能看到对象引用的最新值,但同时也将看到对象的某些或全部状态中包含的是无效值,即一个被部分构造对象。
除了不可变对象以外,使用被另一个线程初始化的对象通常都是不安全的,除非对象的发布操作是在使用该对象的线程开始使用之前执行。
如下就是一个不安全的发布,因为另一个线程可能看到被部分构造的对象。
@NotThreadSafe
public class UnsafeLazyInitialization {
private static Resource resource;
public static Resource getInstance() {
if (resource == null)
resource = new Resource(); // 初始化操作何其漫长
return resource;
}
static class Resource {
}
}
安全的发布
TODO:第三章的安全发布常用模式
安全初始化模式
在初始器中采用了特殊的方式来处理静态域
(或者在静态初始化代码块中初始化的值),并提供了额外的线程安全性保证
。静态初始化器是由JVM在类的初始化阶段执行,即在类被加载后并且被线程使用之前。由于JVM将在初始化期间获得一个锁,并且每个线程都至少获取一次这个锁以确保这个类已经加载,因此在静态初始化期间,内存写人操作将自动对所有线程可见。因此无论是在被构造期间还是被引用时,静态初始化的对象都不需要显式的同步。
//1.提前初始化
@ThreadSafe
public class EagerInitialization {
private static Resource resource = new Resource();
public static Resource getResource() {
return resource;
}
static class Resource {
}
}
//2. 延迟初始化占位类模式
@ThreadSafe
public class ResourceFactory {
private static class ResourceHolder {
public static Resource resource = new Resource();
}
public static Resource getResource() {
return ResourceFactory.ResourceHolder.resource;
}
static class Resource {
}
}
双重检查加锁(DCL)
@NotThreadSafe
public class DoubleCheckedLocking {
private static Resource resource;
public static Resource getInstance() {
if (resource == null) {
//两个线程可能同时运行到这里
synchronized (DoubleCheckedLocking.class) {
//线程 A 进入并初始化,然后解锁
//线程 B 获得锁,再次判断是否已经初始化了
if (resource == null)
resource = new Resource();
}
}
return resource;
}
static class Resource {
}
}
它的工作原理是,首先检查是否在没有同步的情况下需要初始化,如果resource引用不为空,那么就直接使用它。否则,就进行同步并再次检查Resource是否被初始化(这是因为),从而保证只有一个线程对共享的Resource执行初始化。但是其致命的一个问题是:
当线程进行第一次检查的时候,代码读取到 instance 不为null时,instance 引用的对象有可能还没有完成初始化。这是为什么呐?因为指令重排序,因为在
new Resource()
时,可能会先设置引用不为 null ,然后再具体初始化各个字段。具体见:双重检查锁(DCL)问题
那么如何解决呐?
就是 禁止指令重排,即加 volatile 关键字。