学习之前我们首先得知道JMM内存模型跟Java内存区域的划分是不同的概念层次,所以在正式学习JMM之前我们先来回忆一下Java内存区域(没学过Java内存区域的也不打紧)
Java内存区域概述
Java虚拟机在运行程序时会把其自动管理的内存划分为几个区域,每个区域都有的用途以及创建销毁的时机,其中蓝色部分代表的是所有线程共享的数据区域,而绿色部分代表的是每个线程的私有数据区域。
方法区(Method Area):
方法区属于线程共享的内存区域,又称Non-Heap(非堆),主要用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,根据Java 虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError 异常。值得注意的是在方法区中存在一个叫运行时常量池(Runtime Constant Pool)的区域,它主要用于存放编译器生成的各种字面量和符号引用,这些内容将在类加载后存放到运行时常量池中,以便后续使用。
JVM堆(Java Heap):
Java 堆也是属于线程共享的内存区域,它在虚拟机启动时创建,是Java 虚拟机所管理的内存中最大的一块,主要用于存放对象实例,几乎所有的对象实例都在这里分配内存,注意Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称做GC 堆,如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError 异常。
程序计数器(Program Counter Register):
属于线程私有的数据区域,是一小块内存空间,主要代表当前线程所执行的字节码行号指示器。字节码解释器工作时,通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
虚拟机栈(Java Virtual Machine Stacks):
属于线程私有的数据区域,与线程同时创建,总数与线程关联,代表Java方法执行的内存模型。每个方法执行时都会创建一个栈桢来存储方法的的变量表、操作数栈、动态链接方法、返回值、返回地址等信息。每个方法从调用直结束就对于一个栈桢在虚拟机栈中的入栈和出栈过程。
本地方法栈(Native Method Stacks):
本地方法栈属于线程私有的数据区域,这部分主要与虚拟机用到的 Native 方法相关,一般情况下,我们无需关心此区域。
这里之所以简要说明这部分内容,注意是为了区别Java内存模型与Java内存区域的划分,毕竟这两种划分是属于不同层次的概念。
Java内存模型概述
多核CPU并发缓存架构
在学习JMM之前我们先来了解一下多核CPU并发缓存架构;
早期电脑的数据一般是写在磁盘上的,当CPU需要从磁盘上读取数据的时候需要先将数据写入主内存RAM中(内存条) ,然后CPU再从主内存中读取。 随着技术的发展CPU的运行速度逐渐超越主内存,如果继续让CPU跟主内存直接进行交互,会影响数据的存储效率,也就是说CPU的效率会受制于主内存;
所以为了弥补主内存跟CPU的运行速度的差距,在主内存跟CPU之间加入了一层CPU高速缓存 ;这个时候读取数据就变成了:磁盘读取到主内存,然后将主内存中的数据读取到CPU高速缓存,CPU再从高速缓存中读取数据;由于CPU高速缓存的速度比主内存快很多,几乎等于CPU的速度,所以性能会提升不少;
高速缓存出现不久,系统变得越来越复杂,高速缓存与主存之间的速度差异被拉大,直到加入了另一级缓存,新加入的这级缓存比第一缓存更大,而且更慢,而且经济上不合适,所以有了二级缓存,甚至是三级缓存。
这个可以在自己电脑的任务管理器中验证:
JMM多线程内存模型就跟CPU的多级缓存模型类似,且是基于CPU缓存模型来建立的;
Java内存模型 JMM 即 Java Memory Model, 本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。
由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在每个线程自己的工作内存中进行;
首先要将数据、变量从主内存拷贝到线程自己的工作内存空间,然后才能对变量进行操作,操作完成后再将工作内存中的变量写回主内存;而不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝,前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图
这里再提一嘴,JMM与Java内存区域的划分是不同的概念层次,更恰当说JMM描述的是一组规则,通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式;
JMM是围绕并发的三大特性:原子性,有序性、可见性以及JMM八大原子操作展开的(稍后会分析)。JMM与Java内存区域唯一相似点,就是都存在共享数据区域和私有数据区域,在JMM中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区,而工作内存数据线程私有数据区域,从某个程度上讲则应该包括程序计数器、虚拟机栈以及本地方法栈。
或许在某些地方,我们可能会看见主内存被描述为堆内存,工作内存被称为线程栈,实际上他们表达的都是同一个含义。
关于JMM中的主内存和工作内存详细说明如下:
主内存
主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发生线程安全问题。
工作内存
主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。
弄清楚主内存和工作内存后,再来了解一下主内存与工作内存的数据存储类型以及操作方式,根据虚拟机规范,对于一个实例对象中的成员方法而言,如果方法中包含本地变量是基本数据类型。(boolean,byte,short,char,int,long,float,double),将直接存储在工作内存的帧栈结构中,但倘若本地变量是引用类型,那么该变量的引用会存储在功能内存的帧栈中,而对象实例将存储在主内存(共享数据区域,堆)中。
但对于实例对象的成员变量,不管它是基本数据类型或者包装类型(Integer、Double等)还是引用类型,都会被存储到堆区。至于static变量以及类本身相关信息将会存储在主内存中。需要注意的是,在主内存中的实例对象可以被多线程共享,倘若两个线程同时调用了同一个对象的同一个方法,那么两条线程会将要操作的数据拷贝一份到自己的工作内存中,执行完成操作后才刷新到主内存。
工作内存和主内存的简单演示:
private static boolean initFlag=false;
public static void main(String[] args) throws InterruptedException {
new Thread(()->{
System.out.println("waiting data ...");
while (!initFlag){}
System.out.println("======success=====");
}).start();
Thread.sleep(2000);
new Thread(()->prepareData()).start();
}
private static void prepareData() {
System.out.println("prepare data...");
initFlag=true;
System.out.println("data ready ...");
}
上述代码的意图很简单,一个线程死循环检测initFlag是否为真,另一个线程直接调用方法将initFlag设置为真,按道理来说,在第二个线程启动并将initFlag置为真后,第一个线程就应该检测到initFlag的变化,然后退出死循环,然而执行的结果却并非如此;在第一个线程执行完后,程序并为停止,第一个线程还在死循环,仿佛initFlag从来没有变化……
这样就验证了线程会将要操作的数据先从主内存中拷贝一份到自己的工作内存中,才能进行后续操作。而这也是我们接下来要说的可见性问题产生的原因;
JMM 体现在以下几个方面
- 原子性 - 保证指令不会受 线程上下文切换的影响
- 可见性 - 保证指令不会受 cpu 缓存的影响 (JIT对热点代码的缓存优化)
- 有序性 - 保证指令不会受 cpu 指令并行优化的影响
原子性
原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。比如对于一个静态变量int x,两条线程同时对他赋值,线程A赋值为1,而线程B赋值为2,不管线程如何运行,最终x的值要么是1,要么是2,线程A和线程B间的操作是没有干扰的,这就是原子性操作,不可被中断的特点。
有点要注意的是,对于32位系统的来说,long类型数据和double类型数据(对于基本数据类型,byte,short,int,float,boolean,char读写是原子操作),它们的读写并非原子性的,也就是说如果存在两条线程同时对long类型或者double类型的数据进行读写是存在相互干扰的,因为对于32位虚拟机来说,每次原子读写是32位的,而long和double则是64位的存储单元,这样会导致一个线程在写时,操作完前32位的原子操作后,轮到B线程读取时,恰好只读取到了后32位的数据,这样可能会读取到一个既非原值又不是线程修改值的变量,它可能是“半个变量”的数值,即64位数据被两个线程分成了两次读取。但也不必太担心,因为读取到“半个变量”的情况比较少见,至少在目前的商用的虚拟机中,几乎都把64位的数据的读写操作作为原子操作来执行,因此对于这个问题不必太在意,知道这么回事即可。
可见性 (重点
)
概述
可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值。对于串行程序来说,可见性是不存在的,因为我们在任何一个操作中修改了某个变量的值,后续的操作中都能读取这个变量值,并且是修改过的新值。
但在多线程环境中可就不一定了,前面我们分析过,由于线程对共享变量的操作都是线程拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程A修改了共享变量x的值,还未写回主内存时,另外一个线程B又对主内存中同一个共享变量x进行操作,但此时A线程工作内存中共享变量x对线程B来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题,另外指令重排以及编译器优化也可能导致可见性问题,通过前面的分析,我们知道无论是编译器优化还是处理器优化的重排现象,在多线程环境下,确实会导致程序轮序执行的问题,从而也就导致可见性问题。
可见性问题:退不出的循环
引出问题
- 先来看一个现象,
main线程
对run变量
的修改
对于t线程不可见
,导致了 t 线程无法停止
如下面的代码所示,我们开启了t1线程,并希望在主线程睡眠一秒后将run的值变为false,以此来停止t1线程;但是线程并不会如我们所想的一般停下来。
public class Test1 {
static boolean run = true;
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
while (run) {
}
});
t1.start();
Sleeper.sleep(1);
run = false;
System.out.println(run);
}
}
为什么会出现这种情况呢?我们从JMM的角度来详细分析一下。
首先JMM将我们整个Java内存划分为主内存、工作内存;主内存是所有线程共享信息存放的位置,工作内存是所有线程私有信息存放的地方。
而且JMM底层有八大原子操作:
①read(读取)︰从主内存读取数据
②load(载入)︰将主内存读取到的数据写入工作内存
③use(使用):从工作内存读取数据来计算
④assign(赋值)︰将计算好的值重新赋值到工作内存中
⑤store(存储)︰将工作内存数据写入主内存
⑥write(写入):将store过去的变量值赋值给主内存中的变量
⑦lock(锁定):将主内存变量加锁,标识为线程独占状态
⑧unlock(解锁):将主内存变量解锁,解锁后其他线程可以锁定该变量
然后我们开始分析程序:
初始状态
,t线程
刚开始会从主内存(成员变量)
读取run的值到工作内存。
因为主线程sleep(1)秒, 这时候t1线程循环了好多次run的值, 超过了一定的阈值, JIT就会将主存中的run值读取到工作内存 (相当于缓存了一份, 不会去主存中读run的值了)。
那它是怎么读取的呢?
t1线程执行了read原子操作从主内存(成员变量)
将run的值读取然后又执行load原子操作将数据加载到自己的工作内存。然后执行use原子操作使用run变量进入死循环;
- 因为t1线程是循环判断run的值,所以要频繁地从主存中读取run的值,这样效率比较低,所以JIT即时编译器会将run的值缓存至自己工作内存中的高速缓存中,下次读取的时候直接在自己的工作内存中的高速缓存取,减少对主存中run的访问以提高效率
- 虽然JIT即是编译器的优化本意是好处,但这就带来了一个问题, 1 秒之后,main线程修改了run的值, 并同步至主存。
这里main主线程修改run的值涉及到的原子操作过程是这样的:main线程同样会像t线程一样将数据读取到工作内存中,然后使用use原子操作使用工作内存中的run变量,使用完了run变量后又执行assign原子操作将修改后的run进行赋值到工作内存中;
然后又执行store原子操作将run存储到主内存中,最后执行write操作将run写回主内存中的run变量中;这样主内存中的数据就得到了同步;
- 而 t线程仍然是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值
所以才会导致永远退不出循环。
问题解决
那怎么解决这个问题呢?给run变量添加一个volatile(窝力跳)关键字修饰。加了这个关键字修饰就是告诉线程,每次都要去主内存中去获取该值,不要去自己的工作内存中获取。
**所以我们可以为主存(成员变量)进行**`**volatile**`**修饰, 增加变量的可见性, 当主线程修改run为false, t1线程对run的值可见, 这样就可以退出循环 。**
**虽然效率上有所损失但是保证了被**`**volatile**`**修饰的变量的对其他线程的可见性。**<br /><br />现在的问题是为什么加了这个volatile关键字就可以让其他线程可见呢?其他线程是如何感知到有线程将run变量进行修改的呢?<br />这里就涉及到了一个缓存一致性协议(在英特尔环境下该协议的简称是MESI,不同硬件可能有不同叫法)。<br />这个协议的大概意思是多个cpu(线程)从主内存读取同一个数据到各自的高速缓存,当其中某个cpu修改了缓存里的数据,①该数据会**马上(没使用volatile修饰前是随机同步的)**同步回主内存,②其它cpu通过总线嗅探机制可以感知到数据的变化从而将自己缓存里的数据失效,然后再重新读取主内存数据(CPU跟硬件一般是通过总线进行打交道的,总线是数据的传输通道,也就是说其他线程修改数据时会经过总线传输,那么其他线程也可以通过总线嗅探机制感知到数据的变化)。<br />volatile底层的实现原理大概也是这么来做的<br />1)会将当前处理器缓存行的数据**立即**写回到系统内存。<br />2)这个写回内存的操作会引起在其他CPU里缓存了该内存地址的**数据无效**(MESI协议)<br />3)提供内存屏障功能,使lock前后指令不能重排序<br />volatile关键字是进不去查看源码的,要想进一步了解volatile到底是怎么实现的,就得去查看对应的汇编语言了(底层的东西都得去研究硬件级别的东西);<br />要想知道程序对应的汇编语言,可以将下面两个文件放到你的jre运行环境的bin目录下(可以不做这步)<br /><br />然后在执行代码的时候给虚拟机加上如下参数,再执行<br /><br />然后选择jre环境运行<br /><br />这个时候再来运行代码就能显示对应的汇编语言了;下面将到volatile原理的时候再详细分析分析;<br />我们的代码加了volatile关键字进行修饰后,其代码语句中涉及到赋值对应的汇编指令就会有一个lock前缀修饰<br /><br />其中这个指令就对应着八大原子操作中的assign操作。<br />因为汇编语言已经很底层了,再往下就得去研究01二进制了;所以要想看lock前缀修饰的时候有什么作用,就得去查看IA-32架构软件开发者手册对lock指令的解释了:<br />1)当CPU看到汇编语言的lock前缀,就会将当前cpu处理器缓存行的数据立即写回到系统主内存(也就是马上执执行store跟write两个原子操作)。<br />2)同样是因为这个lock前缀指令,这个写回内存的操作对应的数据经过总线时触发总线嗅探机制引起在其他CPU里缓存了该内存地址的数据无效(MESI协议)<br />3)volatile底层实现主要是通过汇编lock前缀指令,它会锁定这块内存区域的缓存(缓存行锁定)并回写到主内存
volatile的底层是在store原子操作将数据同步回主内存之前进行lock操作,在同步完成之后进行unlock操作。为什么要执行这两个操作呢?好像没有这两个操作也可以保证线程间数据的一致性啊?
这是因为当两个线程同时修改数据时,两个线程同时将数据同步到主内存,这个时候就会存在并发问题;(此前已经感知到lock失效然后在write操作之前有线程再次读取)
又因为这里面的运算速度极高(锁的粒度非常小),所以这里加锁的话对性能的影响极低;
另一个解法
除了用volatile保证可见性之外,还有一种解决方案,那就是我们之前学到的synchronized关键字。为什么这样也可以?之前没有加synchronized关键字之前产生可见性问题是因为主内存跟工作内存之间存在差异,因为主内存中的值发生了变化而线程读取的仍是工作内存中的值;这里使用synchronized关键字是如何避免这个问题的?
public class Test1 {
static boolean run = true;
final static Object obj = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
// 1s内,一直都在无限循环获取锁. 1s后主线程抢到锁,修改为false, 此时t1线程抢到锁对象,while循环也退出
while (run) {
synchronized (obj) {
}
}
});
t1.start();
Sleeper.sleep(1);
// 当主线程获取到锁的时候, 就修改为false了
synchronized (obj) {
run = false;
System.out.println("false");
}
}
}
- volatile 可以认为是一个轻量级的锁,被 volatile 修饰的变量,汇编指令会存在于一个”lock”的前缀。在CPU层面与主内存层面,通过缓存一致性协议,加锁后能够保证写的值同步到主内存,使其他线程能够获得最新的值。
使用synchronized关键字也有相同的效果, 在Java内存模型中,synchronized规定,线程在加锁时, 先清空工作内存 → 在主内存中拷贝最新变量的副本到工作内存 → 执行完代码 → 将更改后的共享变量的值刷新到主内存中 → 释放互斥锁。
可见性 vs 原子性
可见性
,它保证的是在多个线程之间一个线程对 volatile 变量的修改对另一个线程可见, **而不能保证原子性。volatile用在一个写线程,多个读线程**的情况, 比较合适。- 之前我们讲线程安全时举的例子:两个线程一个 i++ ,一个 i— ,这就不能用volatile了,它只能保证看到共享变量最新值(可见性),**不能解决指令交错(原子性)**
注意 :synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是 synchronized 是属于重量级操作,性能相对更低。 如果在前面示例的死循环中加入 System.out.println() 会发现即使不加 volatile 修饰符,线程 t 也能正确看到 对 run 变量的修改了,想一想为什么?因为println方法里面有synchronized修饰。
volatile比较适合一个线程写多个线程读的情况。它不能保证原子性但可以保证可见性。
模式之两阶段终止
- 当我们在执行线程一时,想要终止线程二,这是就需要使用
interrupt方法
来优雅的停止线程二。这是我们之前的做法,现在我们来学习跟使用volatile关键字来实现两阶段终止模式。
Balking犹豫模式
Balking (犹豫)模式用在一个线程发现另一个线程或本线程已经做了某一件相同的事,那么本线程就无需再做了,直接结束返回
有序性 (重点)
概述
JVM会在不影响正确性的前提下,会调整语句的执行顺序, 是一种优化 。假如说CPU要执行两条指令:指令1需要去内存中读取数据,需要等待内存返回数据,因为CPU的运算速度比内存要快很多(CPU在1ms内发起读指令,剩下99ms都在等内存返回数据); 另外一条指令跟指令1没有任何依赖关系,这个时候就会进行优化。
比如说,
这种特性就被称之为『指令重排』, 单线程下指令重排肯定没什么,但是在多线程下『指令重排』就可能会影响正确性。
既然在多线程情况下会受到影响,为什么还要优化呢? 因为CPU 支持多级指令流水线,例如一条指令又细分为取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回,支持同时执行取指令 - 指令译码 - 执行指令 - 内存访问 - 数据写回 的处理器。这时CPU可以在一个时钟周期内,同时运行五条指令的不同阶段(相当于一条执行时间最长的复杂指令),IPC = 1,本质上,流水线技术并不能缩短单条指令的执行时间,但它变相地提高了指令地吞吐率。
有序性问题引出
int num = 0;
boolean ready = false;
// 线程1 执行此方法
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
}
else {
r.r1 = 1;
}
}
// 线程2 执行此方法
public void actor2(I_Result r) {
num = 2;
ready = true;
}
I_Result 是一个对象,有一个属性 r1 用来保存结果,r1可能的结果有几种?
- 情况1:线程1 先执行,这时 ready = false,所以进入 else 分支结果为 1
- 情况2:线程2 执行到 ready = true,线程1 执行,这回进入 if 分支,结果为4(因为 num 已经执行过了)
- 情况3:线程2 先执行 num = 2,但没来得及执行 ready = true,线程1 执行,还是进入 else 分支,结果为1
但是结果还有可能是 0 ,这种情况下是:
**线程2 执行 ready = true,切换到线程1,进入 if 分支,两个为0的num相加为 0,再切回线程2 执行 num = 2。**
这种现象叫做指令重排,是JIT 编译器在运行时的一些优化,这个现象需要通过大量测试才能复现,可以使用jcstress工具进行测试。
解决的办法是添加上一个volatile关键字,禁止重排序
public class ConcurrencyTest {
int num = 0;
//boolean ready = false;
volatile boolean ready = false; // 不会发生指令重排,也就不会出现结果为0的情况
public void actor1(I_Result r) {
if(ready) {
r.r1 = num + num;
} else {
r.r1 = 1;
}
}
public void actor2(I_Result r) {
num = 2;
ready = true;
}
}
上面仅是从代码层面体现出了有序性问题,下面在讲到 double-checked locking (双重检查锁)问题时还会从java字节码的层面了解有序性的问题。
**重排序也需要遵守一定规则:
重排序会遵循as-if-serial与happens-before原则。(涉及到编译原理的语义分析来识别存在数据依赖)
- 其中happens-before原则第二条,违反的话可能变成重入锁(这里我们没有重入锁的语义)
- 指令重排序操作不会对存在数据依赖关系的操作进行重排序。比如:a=1;b=a; 这个指令序列,由于第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序。
- 重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变。 比如:a=1;b=2;c=a+b这三个操作,第一步(a=1)和第二步(b=2)由于不存在数据依赖关系,所以可能会发生重排序,但是c=a+b这个操作是不会被重排序的,因为需要保证最终的结果一定是c=a+b=3。
- 指令重排序 在 单线程模式下是一定会保证最终结果的正确性, 但是在多线程环境下,问题就出来了。解决方法:volatile 修饰的变量,可以禁用指令重排
- 注意:
- 使用synchronized并不能解决有序性问题,但是如果是该变量整个都在synchronized代码块的保护范围内,那么变量就不会被多个线程同时操作,也不用考虑有序性问题!在这种情况下相当于解决了重排序问题!
volatile 原理 (重点)
- volatile 的底层实现原理是内存屏障,Memory Barrier(Memory Fence)
- 在 volatile 变量的写指令后会加入写屏障。(保证写屏障之前的写操作, 都能同步到主存中)
- 在volatile 变量的读指令前会加入读屏障。(保证读屏障之后的读操作, 都能读到主存的数据)
JVM是如何实现读写屏障?要看底层,先将open jdk下载
其实底层实现主要是通过汇编lock前缀指令,它会锁定这块内存区域的缓存(缓存行锁定)并回写到主内存
volatile是如何保证可见性 (重点
)
读写屏障是如何保证可见性的?
**写屏障(sfence)是保证在该屏障之前的,对共享变量的改动(写操作),都同步到主存当中**
ready变量是被volatile变量修饰的,而写屏障是在写操作,也就是赋值操作之后加的;且写屏障的效果是在写屏障之前的所有写操作都同步到主内存中,而不是将其暂时保存在工作内存中。
- 读屏障(lfence)保证在该屏障之后,对共享变量的读取,加载的是主存中最新数据,有了对写屏障的了解,那么读屏障就好理解了,读屏障是加在对volatile 修饰变量的读取之前的,它的效果则是保证读屏障之后的读操作, 都能读到主存中的最新数据。
volatile是如何保证有序性
- 写屏障会确保指令重排序时,不会将写屏障之前的代码 排 在写屏障之后
- 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
volatile不能解决指令交错 (不能解决原子性)
- 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证其它线程的读, 跑到它将指令写入主存的前面去
- 有序性的保证也只是保证了==本线程==内相关代码不被重排序,多个线程之间谁先谁后这可不一定;
比如下图t1线程准备将i=1写入主存,但是t2线程, 早就先读取了i=0, 所以此时还是会出现指令交错的现象, 这个时候其实可以使用synchronized来解决原子性问题。
无法保证原子性的一个实例
num是volatile修饰的变量
为什么有volatile修饰,这个程序的结果还是小于等于一万
下图是对应的JMM模型图
有这样一种情况,线程1执行原子操作use的时候,另一个线程2并不会等待线程1执行完use操作,而有可能同时执行use原子操作;也就是说两个线程都可以同时进行use跟assign操作;这个时候我们假设线程1先执行store、write操作将数据同步到主存;而且在线程1将数据同步的过程中会执行lock锁住主内存,这样线程2就写不了了;因为store操作前会执行lock指令,这样就让其他线程通过总线嗅探机制探测到并使它工作内存中相应数据失效,这就意味着之前线程2所做的++操作被丢失了(正常来讲两个线程进行++操作,主内存中的num应该是2,由于线程2的++操作被丢失了,所以此时主内存中的num其实是1)。
正是因为在并发执行的过程中,有很多的++操作被丢失掉了,所以才会导致num最终的数据小于或者等于10000;
进一步证明了volatile不能保证原子性;若要保证原子性,需要在increase方法添加一个synchronized修饰。
double-checked locking (双重检查锁) 问题 (重点
)
- 首先synchronized是可以保证它的临界区的资源的原子性、可见性、有序性的, 其中有序性的前提是, 在synchronized代码块中的共享变量, 不会在代码块外使用到, 否则有序性不能被保证, 只能使用volatile来保证有序性。
下面代码的第二个双重检查方式的单例, 就出现了这个问题(在synchronized外使用到了INSTANCE), 此时synchronized就不能防止指令重排, 确保不了指令的有序性。
- 以著名的
double-checked locking(双重检查锁) 单例模式
为例,这是volatile最常使用的地方。
我们之前都学习过单例模式,有一种懒汉式的单例,即实例不是一开始就创建出来,而是在第一次用到的时候才创建该单例对象
多线程同时调用getInstance(), 如果不加synchronized锁, 此时两个线程同时 判断INSTANCE为空, 此时都会new Singleton(), 此时就破坏单例了.所以要加锁, 防止多线程操作共享资源,造成的安全问题 。
但是上面代码的效率是有问题的, 因为当我们创建了一个单例对象后, 又来一个线程获取到锁了,还是会加锁, 严重影响性能,再次判断INSTANCE==null, 此时肯定不为null, 然后就返回刚才创建的INSTANCE; 这样导致了很多不必要的判断; 所以要双重检查, 在第一次线程调用getInstance(), 直接在synchronized外,判断instance对象是否存在了,如果不存在, 才会去获取锁,然后创建单例对象,并返回; 第二个线程调用getInstance(), 会进行if(instance ==null)的判断, 如果已经有单例对象, 此时就不会再去同步块中获取锁了,提高效率。
public final class Singleton {
private Singleton() { }
private static Singleton INSTANCE = null;
public static Singleton getInstance() {
if(INSTANCE == null) { // t2
// 首次访问会同步,而之后的使用没有 synchronized同步
synchronized(Singleton.class) {
if (INSTANCE == null) { // t1
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
但是上面的if(INSTANCE == null)判断代码没有在同步代码块synchronized中, 不能享有synchronized保证的原子性、可见性、以及有序性。所以可能会导致 指令重排 。
也就是说 在多线程环境下创建单例对象,上面的代码还是有问题的,如下图所示是创建对象的底层过程
getInstance 方法对应的字节码为
0: getstatic #2 // 获取INSTANCE 静态变量
3: ifnonnull 37 // 判断INSTANCE是否不为空,不为空则跳转到37行
// ldc是获得类对象
6: ldc #3 // class cn/itcast/n5/Singleton
// dup是复制操作数栈栈顶的值放入栈顶, 将类对象的引用地址复制了一份
8: dup
// 操作数栈栈顶的值弹出,即将对象的引用地址存到局部变量表中
// 即astore_0是将类对象的引用地址临时存储了一份,是为了将来解锁用
9: astore_0
10: monitorenter //进入同步代码块,底层相关与创建一个Monitor对象
11: getstatic #2 // 能进入同步代码块的话又去获取静态变量
14: ifnonnull 27 //又判断是否非空,不为空到27行
//
17: new #3 //如果上条指令为空的话就往下走,这里是新建一个Singleton实例
// 复制了一个实例的引用
20: dup
// 通过这个复制的引用用来调用它的构造方法
21: invokespecial #4 // Method "<init>":()V
// 最开始的这个引用用来进行赋值操作
24: putstatic #2 // Field INSTANCE:Lcn/itcast/n5/Singleton;
27: aload_0 //将刚才的类对象拿出来
28: monitorexit //解锁
29: goto 37 //来到37行
32: astore_1
33: aload_0
34: monitorexit
35: aload_1
36: athrow
37: getstatic #2 // 又去获取INSTANCE静态变量
40: areturn //将INSTANCE返回
其中我们重点关注的代码是17—24行
其中
- 17 表示创建对象,将对象引用入栈(分配内存) // new Singleton
- 20 表示复制一份对象引用 // 复制了引用地址, 解锁使用
- 21 表示利用一个对象引用,调用构造方法 // 根据复制的引用地址调用构造方法
- 24 表示利用一个对象引用,赋值给 static INSTANCE
这几行代码就可能被jvm 重排序优化为:先执行 24(赋值),再执行 21(构造方法)。如果两个线程 t1,t2 按如下时间序列执行:
- 通过上面的字节码发现, 这一步INSTANCE = new Singleton();操作不是一个原子操作, 它包括21, 24两个指令, 此时可能就会发生指令重排的问题(不违背重排序的两个原则)
如果是这种情况下,
- 关键在于 0: getstatic 这行代码在 monitor 控制之外(也就是说外层的判断中INSTANCE 遍量不在synchronized同步代码块之内),它就像之前举例中不守规则的人,可以越过 monitor 读取 INSTANCE 变量的值()。
- 这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例 。(使用对象的时候构造方法还没有执行!!!)
对 INSTANCE 使用 volatile 修饰即可禁用指令重排。
注意在 JDK 5 以上的版本的 volatile 才会真正有效所以,只有共享变量完全处于同步代码块内才能保证原子性、有序性、可见性;
问题解决
给单例对象添加volatile关键字修饰
public final class Singleton {
private Singleton() { }
private static volatile Singleton INSTANCE = null;
public static Singleton getInstance() {
// 实例没创建,才会进入内部的 synchronized代码块
if (INSTANCE == null) {
synchronized (Singleton.class) { // t2
// 也许有其它线程已经创建实例,所以再判断一次
if (INSTANCE == null) { // t1
INSTANCE = new Singleton();
}
}
}
return INSTANCE;
}
}
为什么加了这个关键字就可以解决呢?我们可以从读写屏障上解释一下。
**读写 volatile 变量操作(即getstatic操作和putstatic操作)时会加入内存屏障(Memory Barrier(Memory Fence))**
,保证下面两点:
- 可见性
- 写屏障(sfence)保证在该屏障之前的 t1 对共享变量的改动,都同步到主存当中
- 读屏障(lfence)保证在该屏障之后 t2 对共享变量的读取,加载的是主存中最新数据
- 有序性
- 写屏障 会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
- 读屏障 会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
加上volatile
之后, 保证了指令的有序性
, 不会发生指令重排 ,那么之前构造方法被重排的问题就不会发生了;
- synchronized 既能保证原子性、可见性、有序性,其中有序性是在该共享变量完全被synchronized 所接管(包括共享变量的读写操作),上面的例子中synchronized 外面的 if (INSTANCE == null) 中的INSTANCE读操作没有被synchronized 接管,因此无法保证INSTANCE共享变量的有序性(即不能防止指令重排)。
- 对共享变量加volatile关键字可以保证可见性和有序性,但是不能保证原子性(即不能防止指令交错)
happens-before (对共享变量的写操作,对其它线程的读操作可见
)
happens-before规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下happens-before规则,JMM并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见
方式1
线程解锁m之前对变量(
_成员变量或静态成员变量_
)的写,对于接下来对m加锁的其它线程对该变量的读可见synchronized锁, 保证了
可见性
static int x;
static Object m = new Object();
new Thread(()->{
synchronized(m) {
x = 10;
}
},"t1").start();
new Thread(()->{
synchronized(m) {
System.out.println(x);
}
},"t2").start();
// 10 t1线程释放锁之后t2线程才能获取锁。
方式2
- 线程对volatile 变量的写,对接下来其它线程对该变量的读可见
- volatile修饰的变量, 通过
**写屏障**
, 共享到主存中, 其他线程通过**读屏障**
, 读取主存的数据
- volatile修饰的变量, 通过
volatile static int x;
new Thread(()->{
x = 10;
},"t1").start();
new Thread(()->{
System.out.println(x);
},"t2").start();
方式3
- 线程 start() 前对变量的写,对该线程开始后对该变量的读可见
- 线程还没启动时, 修改变量的值, 在启动线程后, 获取的变量值, 肯定是修改过的
static int x;
x = 10;
new Thread(()->{
System.out.println(x);
},"t2").start();
方式四 :
- 线程结束前 对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive() 或 t1.join()等待它结束)
- 主线程获取的x值, 是线程执行完对x的写操作之后的值。
static int x;
Thread t1 = new Thread(()->{
x = 10;
},"t1");
t1.start();
t1.join();
System.out.println(x);
方式五 :
- 线程 t1 打断 t2(interrupt)前对变量的写,对于其他线程得知 t2 被打断后, 对变量的读可见(通过 t2.interrupted 或 t2.isInterrupted)
static int x;
public static void main(String[] args) {
Thread t2 = new Thread(()->{
while(true) {
if(Thread.currentThread().isInterrupted()) {
System.out.println(x); // 10, 打断了, 读取的也是打断前修改的值
break;
}
}
},"t2");
t2.start();
new Thread(()->{
sleep(1);
x = 10;
t2.interrupt();
},"t1").start();
while(!t2.isInterrupted()) {
Thread.yield();
}
System.out.println(x); // 10
}
方式六:
- 对变量默认值(0,false,null)的写,对其它线程对该变量的 读可见 (最基本)
具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z ,配合 volatile 的防指令重排,有下面的例子 - 因为x加了volatile, 所以在volatile static int x 代码的上面添加了读屏障, 保证读到的x和y的变化是可见的(包括y, 只要是读屏障下面都OK); 通过传递性, t2线程对x,y的写操作, 都是可见的
练习
1、balking 模式习题
2、[线程安全]单例模式 (重点
)
- 单例模式有很多实现方法,饿汉、懒汉、静态内部类、枚举类,试分析每种实现下创建、获取单例对象(即调用 getInstance)时的线程安全,并思考注释中的问题(分析的是创建获取单例对象时可能存在的线程安全问题,而不是分析单例对象自身是否有共享变量需要一些关键字来进行安全保护)
- 清楚两个概念
饿汉式
:类加载就会导致该单实例对象被创建懒汉式
:类加载不会导致该单实例对象被创建,而是**首次使用该对象时才会创建**
实现1: 饿汉式
// 问题1:单例对象为什么要加 final修饰?这是防止子类继承后不适当的更改破坏单例
// 问题2:如果单例实现了序列化接口, 还要做什么来防止反序列化破坏单例?
/*如果实现了序列化接口,将来进行反序列化的时候会生成新的对象,这样跟单例模式生成的对象是不同的。要解决直接加上readResolve()方法直接返回你的单例对象就行了,如下所示。这是因为在反序列化的过程中一旦发现了你的readResolve方法中返回了一个对象,它就会使用你返回的对象作为反序列化的对象。*/
public final class Singleton implements Serializable {
// 问题3:为什么无参构造器设置为private私有? 这是为了防止在其它类中使用new生成新的实例。设置为private私有是否能防止反射创建新的实例?不能。反射能够获取你的构造器对象访问私有的构造方法创建对象实例。
private Singleton() {}
// 问题4:这样初始化是否能保证单例对象创建时的线程安全? 没有,这是类变量,是jvm在类加载阶段就进行了初始化,jvm保证了此操作的线程安全性。因为类加载阶段对静态成员变量进行赋值都是线程安全的。
private static final Singleton INSTANCE = new Singleton();
// 问题5:为什么提供静态方法而不是直接将 INSTANCE 设置为 public, 说出你知道的理由。
//1.提供更好的封装性;2.提供范型的支持
public static Singleton getInstance() {
return INSTANCE;
}
public Object readResolve() {
return INSTANCE;
}
}
实现2: 使用枚举实现单例(饿汉式)
因为枚举的变量, 底层是通过public static final来修饰的, 类加载就创建了,所以是饿汉式
// 问题1:枚举单例是如何限制实例个数的:创建枚举类的时候就已经定义好了,每个枚举常量其实就是枚举类的一个静态成员变量。
// 问题2:枚举单例在创建时是否有并发问题:没有,这是静态成员变量,jvm会保证它的线程安全
// 问题3:枚举单例能否被反射破坏单例:不能
// 问题4:枚举单例能否被反序列化破坏单例:枚举类默认就是实现了序列化接口,枚举类已经考虑到此问题,无需担心破坏单例
// 问题5:枚举单例属于懒汉式还是饿汉式:饿汉式
// 问题6:枚举单例如果希望加入一些单例创建时的初始化逻辑该如何做:加构造方法就行了。枚举类也可以写构造方法,成员方法。
enum Singleton {
INSTANCE;
}
实现3:懒汉式
public final class Singleton {
private Singleton() { }
//注意不能使用INSTANCE单例对象作为锁对象,因为它需要进行赋值,并可能为null,你为null的话就不能跟monitor锁进行关联。
private static Singleton INSTANCE = null;
// 分析这里的线程安全, 并说明有什么缺点:synchronized加在静态方法上,可以保证线程安全。缺点就是锁的范围过大,性能较低。
public static synchronized Singleton getInstance() {
if( INSTANCE != null ){
return INSTANCE;
}
INSTANCE = new Singleton();
return INSTANCE;
}
}
上面是一个懒汉式的单例, 代码存在性能问题: 当单例对象已经创建好了, 多个线程访问getInstance()
方法, 仍然会获取锁, 同步操作, 性能很低, 此时出现重复判断
, 因此要使用双重检查
实现4:DCL 懒汉式
双重检查
public final class Singleton {
private Singleton() { }
// 问题1:解释为什么要加 volatile ?为了防止重排序问题,即防止构造方法的执行放在了赋值后面。
private static volatile Singleton INSTANCE = null;
// 问题2:对比实现3, 说出这样做的意义:双重检查,缩小synchronized范围提高性能。
public static Singleton getInstance() {
if (INSTANCE != null) {
return INSTANCE;
}
synchronized (Singleton.class) {
// 问题3:为什么还要在这里加为空判断, 之前不是判断过了吗?这是解决为了第一次创建单例对象时的并发问题。
if (INSTANCE != null) { // t2
return INSTANCE;
}
INSTANCE = new Singleton();
return INSTANCE;
}
}
}
实现5:静态内部类实现懒汉式
public final class Singleton {
private Singleton() { }
// 问题1:属于懒汉式还是饿汉式:懒汉式,这是一个静态内部类。类加载本身就是懒惰的(使用到类的时候才会去加载类),在没有调用getInstance方法时是没有执行LazyHolder内部类的类加载操作的。(静态内部类不会随着外部类的加载而加载, 这是静态内部类和静态变量的区别)
private static class LazyHolder {
static final Singleton INSTANCE = new Singleton();
}
// 问题2:在创建时是否有并发问题,这是线程安全的,因为是通过类加载创建的单例, JVM保证不会出现线程安全。
public static Singleton getInstance() {
return LazyHolder.INSTANCE;
}
}