有部分同学反馈说Volatile
修饰的共享变量不具有原子性,从程序角度去理解,volatile
变量确实不具有原子性,而是在可见性。
而文中,我也特意强调是对单个volatile
变量读写具有原子性,这是从内存语义角度出发的。对单个volatile
变量的读写与一个普通变量的读写操作都是使用一个锁来同步,他么之间的执行效果是相同的。
最典型的例子,就是64位的long
和double
类型变量,可能被拆成32位的两次读写,如果进行并发,执行效果不一定符合预期。尽管JDK5开始后的JSR-133内存模型增强了,只允许64位的long
和double
类型变量的写可以分两次32位写,读只能一次性到位,具有原子性。而通过volatile
修饰的变量,其读写都是具有原子性。
对于这种i++
,是属于复合操作,就是其他同学所说不具有原子性的出发点了。
前言
对Android
开发者来说,相信对并发编程知识的掌握是非常薄弱的,一直是个人进阶的软肋之一。对于并发实践经验缺乏的开发者来说,文绉绉的技术书籍和博客,会比较羞涩难懂。从本文开始,尝试着逐个攻破并发编程的基础知识点。
由于无知与惰性,让我们感觉摸到了技术的天花板!
面试10问
本文结合个人实际面试经验和最近学习归纳总结而出,欢迎各位大佬点赞支持。
通过面试10问,让大家掌握单例模式的双重检查模式和静态内部类单例模式,并了解其中原理。从原理进而引出本文的重点:volatile
和synchronized
。
第1问:平常在Android开发中,有用到哪么设计模式么?
当时回答:平常用的比较多的是单例模式、构造者模式、工厂模式。尤其是单例模式中双重检查模式和静态类单例模式;能够保证多线程对象唯一,不会创建多个实例导致程序执行错误或影响性能。
解读:虽然设计模式有很多种,个人来说,经常用也就单例模式了。虽然面试前突击浏览复习了,然面试一紧张,没啥卵用。所以回答一定要往自己了解的说,并引导面试官往自己会的问。
第2问:在纸上写一下双重检查模式和静态类单例模式代码?
心理活动:还好面试前自己已经默写过很多遍了,问题不大,哗啦啦的写出来:
双重检查模式:
public class Singleton {
private volatile static Singleton singleton;
private Singleton (){}
public static Singleton getSingleton() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
复制代码
静态内部类模式:
public class Singleton {
private Singleton(){
}
public static Singleton getSingleton(){
return Inner.instance;
}
private static class Inner {
private static final Singleton instance = new Singleton();
}
}
复制代码
写好递给面试官:双重检查模式和单例模式都能够有效保证线程安全,又都是延时初始化,能够减少不必要的性能开销。
第3问:双重检查模式有什么需要注意地方?
public class Singleton {
private volatile static Singleton singleton;
private Singleton() { } //1
public static Singleton getSingleton() { //2
if (singleton == null) { // 3.1
synchronized (Singleton.class) { //3.2
if (singleton == null) { //3.3
singleton = new Singleton(); //4
}
}
}
return singleton;
}
}
复制代码
答:双重检查模式需要注意以下几点:
- 构造函数得私有,禁止其他对象直接创建实例;
- 对外提供一个静态方法,可以获取唯一的实例;
- 即然是双重检查模式,就意味着创建实例过程会有两层检查。第一层就是最外层的判空语句:
代码3.1处的if (singleton == null)
,该判断没有加锁处理,避免第一次检查singleton
对象非null
时,多线程加锁和初始化操作;当前对象未创建时,通过synchronized
关键字同步代码块,持有当前Singleton.class
的锁,保证线程安全,然后进行第二次检查。 -
Singleton
类持有的singleton
实例引用需要volatile
关键字修饰,因为在最后一步singleton = new Singleton();
创建实例的时候可能会重排序,导致singleton
对象逸出,导致其他线程获取到一个未初始化完毕的对象。
第4问:刚刚讲到的第四点,为什么会有重排序,volatile
关键字如何禁止重排序?
答:重排序是指编辑器和处理器为了优化程序性能而对指令序列进行重排序的一种手段。只要遵守as -if-serial
语义(无论怎么重排序,单线程程序的执行结果不会改变)。所以编译器为了优化性能,可能会对下图中2和3步骤进行重排序,这种重排序时允许的,因为不会改变单线程(目前只有该线程独占该代码块)内程序的执行结果。
在单线程环境是没有问题,如果在多线程环境下,程序的执行结果就会被破坏。如下图所示,线程B在第一步判空时,singleton实例的引用已经非null,所以它不进入申请锁阶段,而直接访问对象,但此对象还没初始化完成,那么对象在实际使用就会出各种问题。
volatile
修饰的变量本身具有可见性和原子性,所谓的可见性是指对一个volatile变量的读值,读到的值是所有线程中最新修改的值;而原子性是指对单个变量的读写具有原子性。之所以会有这两个特性,是因为会在该共享变量的汇编指令之前增加Lock
指令,该Lock
前缀指令会在多核处理器做两件事:
1、将当前处理器缓存行的数据写回到系统内存;
2、这个写回内存的操作会使其他处理器里缓存了该内存地址的数据无效。
ps:单核处理器一时刻只能有一条线程执行,多线程是指单核CPU对不同线程进行上下文切换和调度;多核处理器同一个时刻可能多条线程(每个核一条线程)并发执行, 这时同步非常重要,现代CPU基本都是多核了。
由于volatie变量的可见性这个特性使其 写-读 建立起了happens-before
关系,从内存语义的角度上说,线程A写一个volatile
变量,实质上是线程A向接下来将要读这个volatiel
变量的某个线程发出了通知。原理上讲的话,在写一个volatile
变量是,JAVA内存模型(JMM)会把该线程对应的本地内存中的共享变量刷新到主内存;而在读volatile
变量时,会把该线程对应的本地内存置为无效,从主内存中读取该变量。线程之间通过共享程序的volatile
变量(共享状态),通过写读操作共享状态进行隐式通信。
JMM为了实现这种volatile
内存语义,会限制编译器和处理器的部分重排序。
为编译器优化制定以下三条规则 :
- 第一个操作是对volatile变量的读,无论第二个操作是什么,都禁止重排序;
- 第一个操作是对volatile变量的写,第二个操作是对volatile的读,禁止重排序;
- 第二个操作是对volatile变量的写,无论第一个操作是什么,都禁止重排序;
从第2条规则就可以理解通过添加volatile
关键字修饰单例的引用,可以禁止重排序。
根据这三条规则,编译器会在生成字节码时,在指令序列插入适当的,保守策略的内存屏障(一组CPU指令,实现对内存操作的顺序限制)。
- volatile写操作前插入StoreStore屏障;
- volatile写操作后插入StoreLoad屏障;
- volatile读操作后插入LoadLoad屏障;
- volatile读操作后插入LoadStore屏障;
以上内存屏障时非常保守,编译器在生成字节码时,也会进行部分优化,减少一些不必要的内存屏障,以提高性能。不同的处理器会根据自身的内存模型继续优化。
ps:JMM是为了屏蔽底层硬件内存模型不一致,为顶层开发提供一套标准的内存模型,让开发这专注要业务开发。
第5问:刚刚提到的happens-before规则,具体怎么说来的?
答:从JDK5开始,使用了新的JSR-133内存模型,该模型定义了happens-before
规则:
- 程序顺序规则:一个线程中的每个操作,happens-before于该线程的任意后续操作;
- 监视器原则:对一个锁的解锁,happens-before于随后对该锁的加锁;
- volatile规则:对一个volatile变量的写,happens-before 于任意后续对这个volatile域的读;
- 传递性:如果A happes-before B,B happens-before C,那么A happens-before C;
- start()原则:线程A执行ThreadB.start()操作,start() happens-before 线程B内所有操作;
- jion()原则:如果线程A执行 ThreadB.jion()并成功返回,那线程B的所有操作都happens-before 于A从jion()操作成功返回。
第6问:规则第2点讲到了锁,那锁在双重检查单例模式起了什么作用?
答:在代码3.2处
,用到了synchronized
关键字,对Singletion.Class
对象进行了同步,确保了在多线程环境下只有一个线程对Singletion
类的Class对象进行实例化。在Java中,每一个对象都可以作为锁:
- 对于普通同步方法,锁是当前实例对象;
- 对于静态同步方法,锁是当前类的Class对象;
- 对于同步方法块,锁是Synchoized括号的Class对象。
第7问:静态内部类单例模式有没有用到锁?
答:有的,JVM在类的初始化阶段(在Class被加载后,且在线程使用之前),会执行类的初始化,JVM会去获取一个锁,这个锁能同步多个线程对同一个类的初始化。
当一个线程A获取到这个初始化锁时,其他线程想要获取初始化锁只能等待;线程A执行类静态初始化和初始化静态字段的过程,就算发生类似双重检查模式的重排序,对结果也没有影响,因为此时没有其他线程可以捕获到初始化锁。线程A初始化完毕,释放锁并通知等待获取初始化锁的线程。根据happens-befroe
关系中的监视器规则,当其他线程获取到初始锁时,已经能看到线程A的初始化所有操作,此时静态对象已经初始化完毕,其他线程无需再初始化。
第8问:了解过锁的原理,知道锁存储在哪么?
答:JVM(Java虚拟机)是基于进入和退出Monitor
对象来实现方法同步和代码块同步的。同步代码块使用monitorenter
指令在编译后插入到同步代码块的开始位置,使用monitorexit
插入到同步代码块的结束处或异常处,monitorenter
必须有对应monitorexit
指令与之配对。任何对象都有一个monitor
与之相关联,当且一个monitor
被持有后,将处于锁定状态。线程执行到monitorenter
指令时,将会尝试获取对象所对应的monitor
的所有权,即获得对象的锁。方法则是在方法的指令前增加ACC_SYNCHRONIZED
修饰符。
Synchronized
用的锁是存放在Java的对象头;如果对象是数组,用3字宽存储对象头,其中一字宽用于存储数组长度;非数组,则2字宽存储对象头。在32位虚拟机,1字宽=4字节=32位。
第9问:即然了解过Java的对象头,那应该清楚锁升级的几种状态吧,说一下?
答:在Java SE6,为了减少获得锁和释放锁带来的性能消耗,引入了偏向锁和轻量级锁。意味着此时锁从低到高共有四种状态:无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。锁的状态是根据线程对锁的竞争情况来定义的。32位JVM运行状态下,Mark Work的存储结构:
偏向锁: 线程在大多数情况下并不存在竞争条件,使用同步会消耗性能,而偏向锁是对锁的优化,可以消除同步,提升性能。当一个线程获得锁,会将对象头的锁标志位设为01,进入偏向模式.偏向锁可以在让一个线程一直持有锁,在其他线程需要竞争锁的时候,再释放锁。==》只有一个线程进入临界区。
轻量级锁: 当线程A获得偏向锁后,线程B进入竞争状态,需要获得线程A持有的锁,那么线程A撤销偏向锁,进入无锁状态。线程A和线程B交替进入临界区,偏向锁无法满足,膨胀到轻量级锁,锁标志位设为00。==》多个线程交替进入临界区。
重量级锁: 当多线程交替进入临界区,轻量级锁hold得住。但如果多个线程同时进入临界区,hold不住了,膨胀到重量级锁==》多个线程同时进入临界区。
第10问:为什么Synchronized够用,还要增加Volatile?
Volatile
相对Synchronized
来说在同步上比较轻量级,能够有效降低CPU频繁的线程上下文切换和调度。同时,Volatile
的原子操作是针对单个volatile
变量的写读操作,无法和Sychronized
对整个方法或代码块起的作用相比较。
总结
基本每一问都会涉及到一些知识点,面试官也会从不同方向去提问,引出不同知识点。例如后面几个问题可以引出Java的内存模型,这些都是面试的高频问题。
通过本文,需要掌握双重检查模式和静态内部类模式这单例模式
的两种写法,还需掌握volatile
和synchronized
的知识点。
更多Android技术分享可以关注@我,也可以加入QQ群号:1078469822,学习交流Android开发技能。
作者:新小梦
链接:https://juejin.cn/post/6856964867811721229
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
网友评论