从单例模式到HappensBefore
目录
- 双重检测锁的演变过程
- 利用HappensBefore分析并发问题
- 无volatile的双重检测锁
双重检测锁的演变过程
synchronized修饰方法的单例模式
双重检测锁的最初形态是通过在方法声明的部分加上synchronized进行同步,保证同一时间调用方法的线程只有一个,从而保证new Singlton()
的线程安全:
public class Singleton { private static Singleton instance; private Singleton() { } public static synchronized Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; } }
这样做的好处是代码简单、并且JVM保证new Singlton()
这行代码线程安全。但是付出的代价有点高昂: 所有的线程的每一次调用都是同步调用,性能开销很大,而且new Singlton()
只会执行一次,不需要每一次都进行同步。
既然只需要在new Singlton()
时进行同步,那么把synchronized
的同步范围缩小呢?
线程不安全的双重检测锁
public class Singleton { private static Singleton instance; private Singleton() { } public static Singleton getInstance() { if (instance == null) { synchronized (Singleton.class) { if (instance == null) { instance = new Singleton(); } } } return instance; } }
把synchronized
同步的范围缩小以后,貌似是解决了每次调用都需要进行同步而导致的性能开销的问题。但是有引入了新的问题:线程不安全,返回的对象可能还没有初始化。
深入到字节码的层面来看看下面这段代码:
instance = new Singleton() returen instance;
正常情况下JVM编译成成字节码,它是这样的:
step.1 new:开辟一块内存空间 step.2 invokespecial:执行初始化方法,对内存进行初始化 step.3 putstatic:将该内存空间的引用赋值给instance step.4 areturn:方法执行结束,返回instance
当然这里限定在正常情况下,在特殊情况下也可以编译成这样:
step.1 new:开辟一块内存空间 step.3 putstatic:将该内存空间的引用赋值给instance step.2 invokespecial:执行初始化方法,对内存进行初始化 step.4 areturn:方法执行结束,返回instance
步骤2和步骤3进行了调换:先执行步骤3再执行步骤2。
- 如果只有一个线程调用是没有问题的:因为不管步骤如何调换,JVM保证返回的对象是已经构造好了。
- 如果同时有多个线程调用,那么部分调用线程返回的对象有可能是没有构造好的对象。
这种特殊情况称之为:指令重排序
:CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理。当然不是乱排序,重排序保证CPU能够正确处理指令依赖情况以保障程序能够得出正确的执行结果。
利用HappensBefore分析并发问题
什么是HappensBefore
HappensBefore
:先行发生,是
- 判断数据是否存在竞争、线程是否安全的重要依据
- A happens-beforeB,那么A对B可见(A做的操作对B可见)
- 是一种偏序关系。hb(a,b),hb(b,c) => hb(a,c)
换句话说,可以通过HappensBefore推断代码在多线程下是否线程安全
举一个《深入理解Java虚拟机》上的例子:
//以下操作在线程A中执行 int i = 1; //以下操作在线程B中执行 j = i; //以下操作在线程C中执行 i = 2;
如果hb(i=1
,j=i
),那么可以确定变量j的值一定等于1。得出这个结论的依据有两个:
- 根据HappensBefore的规则,
i=1
的结果可以被j=i
观察到 - 线程C还没有登场
如果线程C的执行时间在线程A和线程B之间,那么j
的值是多少呢?答案是不确定!因为线程C和线程B之间没有HappensBefore的关系:线程C对变量的i
的更改可能被线程B观察到也可能不会!
HappensBefore关系
这些是“天然的”、JVM保证的HappensBefore关系:
- 程序次序规则
- 管程锁定规则
- volatile变量规则
- 线程启动规则
- 线程终止规则
- 线程中断规则
- 对象终结规则
- 传递性
重点介绍程序次序规则
,管程锁定规则
,volatile变量规则
,传递性
,后面分析需要用到这四个性质:
- 程序次序规则:在一个线程内,按照程序控制流顺序,书写在前面的操作HappensBefore书写在后面的操作
- 管程锁定规则:对于同一个锁来说,在时间顺序上,上一个unlock操作HappensBefore下一个lock操作
- volatile变量规则:对于一个volatile修饰的变量,在时间顺序上,写操作HappensBefore读操作
- 传递性:hb(a,b),hb(b,c) => hb(a,c)
分析之前线程不安全的双重检测锁
public class Singleton { private static Singleton instance; private Singleton() { } public static Singleton getInstance() { if (instance == null) { //1 synchronized (Singleton.class) { //2 if (instance == null) { //3 instance = new Singleton(); //4 new //4.1 invokespecial //4.2 pustatic //4.3 } } } return instance; //5 } }
经过上面的讨论,已经知道因为JVM重排序导致代码4.2
提前执行了,导致后面一个线程执行代码1
返回的值为false,进而直接返回了还没有构造好的instance对象: |线程1|线程2 | |--|--| | 1 | | | 2 | | | 3 | | | 4.1 | | | 4.3 | | | | 1 | | | 5 | | 4.2 | | | 5 | |
通过表格,可能清晰看到问题所在:线程1代码4.3 执行后,线程2执行代码1读到了脏数据。要想不读到脏数据,只要证明存在hb(T1-4.3,T2-1)(T1-4表示线程1代码4,T2-1表示线程2代码1,下同),那么是否存在呢?很遗憾,不存在:
- 程序次序规则:不在同一个线程
- 管程锁定规则:线程2没有尝试lock
- volatile变量规则:instance对象没有通过volatile关键字修饰
- 传递性:不存在
用HappensBefore分析,可以很清晰、明确看到没有volatile修饰的双重检测锁是线程不安全的。但,真的是这样的吗?
无volatile的双重检测锁
在第二部分,通过HappensBefore分析没有volatile修饰的双重检测锁是线程不安全,那只有用volatile修饰的双重检测锁才是线程安全的吗?答案是否定的。
用volatile关键字修饰的本质是想利用volatile变量规则
,使得写操作(T1-4)HappensBefore读操作(T2-1),那只要另找一条HappensBefore规则保证即可。答案是程序次序规则
和管程锁定规则
先看代码:
public class Singleton { private static Singleton instance; private Singleton() { } public static Singleton getInstance() { if (instance == null) { //1 synchronized (Singleton.class) { //2 if (instance == null) { //3 Singleton temp = new Singleton(); //4 temp.toString(); //5 instance = temp; //6 } } } return instance; //7 } }
在原有的基础上加了两行代码:
instance = new Singleton(); //4 Singleton temp = new Singleton(); //4 temp.toString(); //5 instance = temp; //6
为什么要这么做? 通过管程锁定规则保证执行到代码6
时,temp对象已经构造好了。想一想,为什么?
- 其他线程执行代码1时,如果能够观察到T1-6的写操作,那么直接返回instance对象
- 如果没有观察到T1-6的写操作,那么尝试获取锁,此时
管程锁定规则
开始生效:保证当前线程一定能够观察到T1-6操作
执行流程可能是这样的: |线程1|线程2 | 线程3| |--|--| -- | | 1 | | | | | | 1| | 2 | || | 3 | || | 4 | || | 5 | || | 6 | || | | | 2| | | | 3| | | 1 | 7 | | | 7 || | 7 | ||
无论怎样执行,其他线程都能够观察到T1-6的写操作
其他
volatile、synchronized为什么可以禁止JVM重排序
内存屏障。
JVM在凡是有volatile、synchronized出现的地方都加了一道内存屏障:重排序时,不可以把内存屏障后面的指令重排序到内存屏障前面执行,并且会及时的将线程工作内存中的数据及时更新到主内存中,进而使得其他的线程能够观察到最新的数据
参考资料
- 《深入理解Java虚拟机》

低调大师中文资讯倾力打造互联网数据资讯、行业资源、电子商务、移动互联网、网络营销平台。
持续更新报道IT业界、互联网、市场资讯、驱动更新,是最及时权威的产业资讯及硬件资讯报道平台。
转载内容版权归作者及来源网站所有,本站原创内容转载请注明来源。
- 上一篇
反射真的慢么?动态代理会创建很多临时class?
公众号:内核小王子 问题 1.反射真的慢么? 2.动态代理会创建很多临时class? 3.属性通过反射读取怎么实现的? 当我们在IDE中编写代码的时候,打一个点号,IDE会自动弹出对应的属性和方法名,当我们在debug的时候,IDE会将方法运行时方法内局部变量和外部实例上属性的值都展示出来,spring中的IOC和AOP,以及一个RPC框架中,我们反序列化,consumer的代理,以及provider的调用都会用到java的反射功能,有人说使用反射会慢,那么到底慢在哪里呢? 反射 反射使JAVA语言有了动态编译的功能,也就是在我们编码的时候不需要知道对象的具体类型,但是在运行期可以通过Class.forName()获取一个类的class对象,在通过newInstance获取实例。 先看下java.lang.reflect包下的几个主要类的关系图,当然动态代理的工具类也在该包下。 AnnotatedElement 作为顶级接口,这个接口提供了获取注解相关的功能,我们在方法,类,属性,构造方法上都可以加注解,所以下面的Field,Method,Constructor都有实现这个接口,以下是...
- 下一篇
我最喜欢的Mybatis 3.5新特性——Optional支持
Mybatis 3.5 发布有段时间了,终于支持了 Optional ,这么实用的特性,竟然还没人安利……于是本文出现了。 文章比较简单,但非常实用,因为能大量简化恶心的判空代码。 WARNING 由于本文非常简(low)单(比),我相信又会有类似如下的大佬出现(最近莫名其妙地被若干大佬喷,也不知道得罪谁了,必须高能预警一下,免得脏了大佬们的眼睛): 嫌低级喷:”这么简单文章也好意思写,没有源码分析好意思拿出来!”——我源码分析的文章也有小几十篇了,阅读量更差。而且我写文也不纯粹迎合观众,我觉得有用,有价值,就总结下,以后自己也好备忘,仅此而已。 秀优越感喷:”你的文章没有价值,看看我这篇”——真人真事,在某技术群讨论,吐槽了一圈后,贴出自己同类文章(带源码分析),对这种只能献上自己的膝盖,尊称100声大佬。 无脑喷:”你的文章就是一坨屎”——你才是一坨屎,没人逼着你看啊,自己找不开心啊咋地。 OK,预防针打过了,开始正文吧—— TIPS 简单起见—— 本文直接用Mybaits的注解式编程,不把SQL独立放在xml文件了 省略Service,直接Controller调用DAO Befo...
相关文章
文章评论
共有0条评论来说两句吧...
文章二维码
点击排行
推荐阅读
最新文章
- CentOS7安装Docker,走上虚拟化容器引擎之路
- SpringBoot2全家桶,快速入门学习开发网站教程
- Docker使用Oracle官方镜像安装(12C,18C,19C)
- MySQL8.0.19开启GTID主从同步CentOS8
- Springboot2将连接池hikari替换为druid,体验最强大的数据库连接池
- SpringBoot2配置默认Tomcat设置,开启更多高级功能
- CentOS8,CentOS7,CentOS6编译安装Redis5.0.7
- Eclipse初始化配置,告别卡顿、闪退、编译时间过长
- CentOS8安装Docker,最新的服务器搭配容器使用
- CentOS7编译安装Gcc9.2.0,解决mysql等软件编译问题