由单例模式的优化,引出的java线程数据同步和类加载顺序知识点总结
摘要
几种单例模式的优缺点及其改进优化
DCL失效问题的原因以及解决
java中线程同步关键字final和volatile
java内存屏障
java类加载顺序总结
饿汉单例
//片段1
class Singleton1{
private static Singleton1 instance = new Singleton1();
private Singleton1(){}
public static Singleton1 getInstance(){
return instance;
}
}
- 是线程安全的
- 在加载类时就已经对单例进行了初始化,不能做到使用时再进行资源初始化。需要考虑单例对象资源消耗方面的问题和资源加载的时机
- 不建议使用
懒汉单例
//片段2
class Singleton2{
private static Singleton2 instance;
private Singleton2(){}
public static Singleton2 getInstance(){
if(instance == null)
instance = new Singleton2();
return instance;
}
}
//片段3
class Singleton3{
private static Singleton3 instance;
private Singleton3(){}
private synchronized static Singleton3 getInstance(){
if(instance == null)
instance = new Singleton3();
return instance;
}
}
- 在首次调用时才会进行资源的初始化,一定程度上节省了资源
- 片段2是非线程安全的,片段3是线程安全的,但是每次获取单例时都会进行同步,造成不必要的同步开销,效率不高
- 不建议使用
DCL(double check lock) 单例
//片段4
class Singleton4{
private static Singleton4 instance;
private Singleton4(){}
private static Singleton4 getInstance(){
if(instance == null){
synchronized(Singleton4.class){
if(instance == null)
instance = new Singleton4();
}
}
return instance;
}
}
- 保证只在第一次调用时初始化单例资源,资源利用率高
- 是线程安全的
- 在jdk1.5之前,高并发情况下可能会遇到DCL失效的情况
“双重检查”,线程th1和线程th2同时调用getInstance()
方法,此时Singleton4
未被实例化,th1和th2进入到第一个if判断中,th1率先获取到了同步锁进入到同步代码块中,th2等待获取该类的同步锁,之后Singleton4
正常实例化后,th1释放同步锁,获取到单例对象,th2此时获取到了同步锁进入同步代码块中,此时第二个if能够保证不会继续进行资源初始化,保证单例的唯一性。
DCL失效问题
instance = new Singleton4();
这句代码不是一个原子操作,会被编译为好几条汇编命令,主要做了三件事情:
- 给Singelton4的实例分配内存——实例化
- 为Singleton4的成员变量进行初始化和调用其构造函数——初始化
- 将Singleton4的对象引用指向分配的内存空间(此时的instance就不是null了)
但是由于java编译器允许处理器乱序执行,以及jkd1.5之前的JMM(java memory model)中的cache、寄存器到主内存回写顺序的规定,上面第二条和第三条的执行顺序是无法保证的(正确执行顺序应该是1-2-3),即有可能instance不为null了但是还没有被初始化,存在第三条被执行,第二条未被执行的情况下(即1-3-2),被切换到线程B中,此时instance不为null,于是线程B得到了一个资源没有被初始化的单例,在使用时就会出错。
缺乏同步会导致无法实现可见性,这使得确定何时写入对象引用而不是原语值变得更加困难。在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在(更新值位于工作内存还没有同步到主存,而主存此时的值还是旧值)。(这就是造成著名的双重检查锁定(double-checked-locking)问题的根源,其中对象引用在没有同步的情况下进行读操作,产生的问题是您可能会看到一个更新的引用,但是仍然会通过该引用看到不完全构造的对象)。
于是再jdk1.5之后,具体化了volatile
关键字,保证每次instance对象每次取用都从主内存中读取,并且禁止编译器优化造成的对指令的重新排序,就可以使用DCL来完成单例模式。
volatile关键字
任何被volatile修饰的变量,会防止编译器“智能”优化代码,读写操作都不会调用工作内存而是直接取主存中重新加载此变量,即保证了内存可见性。因此对于Valatile修饰的变量的修改,所有线程马上就能看到,但是volatile不能保证对变量的修改是有序的。例如片段5代码
//片段5
public class VolatileTest
{
public volatile int a;
public void add(int count){
a++;
}
}
自增操作主要是以下几个步骤:
1. 加载局部变量表的变量
2. 将当前栈顶对象的引用赋值一份
3. 获取要进行操作的变量对应id的值,将其值压入栈顶
4. 将int型的值1压入栈顶
5. 将栈顶两个int类型的元素相加,并将其值压入栈顶
6. 将栈顶的值赋值给要进行操作的变量对应的id,即一个写回操作,也被称作“内存屏障”
内存屏障(memory barrier)
内存屏障(memory barrier)是一个CPU指令。基本上,它是这样一条指令: a) 确保一些特定操作执行的顺序; b) 影响一些数据的可见性(可能是某些指令执行后的结果)。编译器和CPU可以在保证输出结果一样的情况下对指令重排序,使性能得到优化。插入一个内存屏障,相当于告诉CPU和编译器先于这个命令的必须先执行,后于这个命令的必须后执行。内存屏障另一个作用是强制更新一次不同CPU的缓存。例如,一个写屏障会把这个屏障前写入的数据刷新到缓存,这样任何试图读取该数据的线程将得到最新值,而不用考虑到底是被哪个cpu核心或者哪颗CPU执行的。
内存屏障(memory barrier)和volatile什么关系?
上面的虚拟机指令里面有提到,如果你的字段是volatile,Java内存模型将在写操作后插入一个写屏障指令,在读操作前插入一个读屏障指令。这意味着如果你对一个volatile字段进行写操作,你必须知道:1、一旦你完成写入,任何访问这个字段的线程将会得到最新的值。2、在你写入前,会保证所有之前发生的事已经发生,并且任何更新过的数据值也是可见的,因为内存屏障会把之前的写入值都刷新到缓存。
同时也需要知道,java中主存和工作内存的区别java线程内存模型,线程、工作内存、主内存
cpu计算读取数据的顺序是:寄存器-高速缓存-内存,计算过程中考虑到数据读取的频繁性,一些数据就被拷贝到寄存器和高速缓存中,在计算完毕后,再将这些数据同步到内存中去。当多个线程同时计算某个内存的数据时,就会遇到多线程并发问题。每个线程都有自己的执行空间,这个执行空间即为工作内存,线程开辟时,工作内存是主存部分数据的拷贝,线程执行的时候用到某变量,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作:读取,修改,赋值等,这些均在工作内存完成,操作完成后再将变量写回主内存。
因此某个线程改变了某个变量的值,在完全计算结束并将数据同步到主存之前,修改的数据只是工作内存中该变量的拷贝,并不是真正的值。
volatile要求程序对变量的每次修改,都写回主内存,这样便对其它线程课件,解决了可见性的问题,但是不能保证数据的一致性;特别注意:原子操作:根据Java规范,对于基本类型的赋值或者返回值操作,是原子操作。但这里的基本数据类型不包括long和double, 因为对于32位的JVM来说,看到的基本存储单位是32位,而long 和double都要用64位来表示。所以无法在一个时钟周期内完成(实际步骤是先写前32位,后写后32位)。对于64位JVM,实现普通long和double的读写不要求是原子的,但是加了volatile关键字的long和double读写操作必须是原子的。知乎中对于64位jvm对long和double读写是否是原子操作的讨论
当一个VolatileTest对象被多个线程共享,a的值不一定是正确的,因为a=a+count包含了好几步操作,而此时多个线程的执行是无序的,因为没有任何机制来保证多个线程的执行有序性和原子性。volatile存在的意义是,任何线程对a的修改,都会马上被其他线程读取到,因为直接操作主存,没有线程对工作内存和主存的同步。所以,volatile的使用场景是有限的,在有限的一些情形下可以使用 volatile 变量替代锁。要使 volatile 变量提供理想的线程安全,必须同时满足下面两个条件:
- 对变量的写操作不依赖于当前值
- 该变量没有包含在具有其他变量的不变式中
所以简单来说,volatile适合这种场景:一个变量被多个线程共享,线程直接给这个变量赋值。
//片段6
class Singleton5{
private volatile static Singleton5 instance = null;
private Singleton5(){}
private static Singleton5 getInstance(){
if(instance == null){
synchronized(Singleton5.class){
if(instance == null)
instance = new Singleton5();
}
}
return instance;
}
}
进一步优化
直接上代码
//片段7
class Singleton6{
private volatile static Singleton6 instance;
private Singleton6(){}
private static Singleton6 getInstance(){
Singleton6 result = instance;
if(result == null){
synchronized(Singleton5.class){
result = instance;
if(result == null)
instance = result= new Singleton6();
}
}
return result;
}
}
优化原理
前面说过了,被关键字volatile
修饰的对象的读写操作之前都是要求从主存中重新加载的,而cpu从主存读取数据速度要比工作内存中读取数据速度慢,所以在new的过程中,new出的对象赋值给局部变量result再赋值给instance只需要对主存中的instance进行一次写操作,即读-读-写,而不是片段5中的读-读-读写操作。速度要快一点。
单例推荐写法
//片段8
class Singleton7{
private Singleton7(){}
public Singleton7 getInstance(){
return Singleton7Holder.instance;
}
private static class Singleton7Holder{
public static final Singleton7 instance = new Singleton7();
}
}
利用静态内部类的加载顺序,和关键字final
得出以上的结果
1. 在需要的时候进行单例的初始化和资源加载
2. 单例对象的唯一性,线程安全
这一块主要需要两个知识点,一是关键字final
的特性,二是java类加载顺序
final关键字
关键字final
可以视为 C++ 中const
机制的一种受限版本,用于构造不可变对象。final
类型的域是不能修改的(但如果 final
域所引用的对象时可变的,那么这些被引用的对象是可以修改的)。然而,在 Java 内存模型中,final
域还有着特殊的语义。final 域能确保初始化过程的安全性,从而可以不受限制的访问不可变对象,并在共享这些对象时无需同步。
在并发当中,原理是通过禁止cpu的指令集重排序,参考重排序详解1和重排序详解2,来提供线程的可见性,来保证对象的安全发布,防止对象引用被其他线程在对象被完全构造完成前拿到并使用。
与前面介绍的锁和volatile相比较,对final域的读和写更像是普通的变量访问。对于final域,编译器和处理器要遵守两个重排序规则:
1. 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
2. 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。
类加载顺序
测试代码1
public class ClassLoaderTest {
public static void main(String[] args){
new B();
new A.C();
}
}
class A{
private P p1 = new P("A--p1");
static P p3 = new P("A--p3");
public A(){
System.out.println("A()");
}
private P p2 =new P("A--p2");
static{
new P("A--static");
}
{
new P("A{...}");
}
public static class C {
private P p1 = new P("C--p1");
static P p3 = new P("C--p3");
public C(){
System.out.println("C()");
}
private P p2 =new P("C--p2");
static{
new P("C--static");
}
{
new P("C{...}");
}
}
}
class B extends A {
static {
new P("B -- static");
}
private P p1 = new P("B --p1");
static P p3 = new P("B -- p3");
public B() {
System.out.println("B()");
}
public P p2 = new P("B -- p2");
{new P("B{...}");}
}
class P {
public P(String s) {
System.out.println(s);
}
}
运行结果1
A–p3
A–static
B – static
B – p3
A–p1
A–p2
A{…}
A()
B –p1
B – p2
B{…}
B()
C–p3
C–static
C–p1
C–p2
C{…}
C()
测试代码2
public class ClassLoaderTest {
public static void main(String[] args){
new B();
new A.C();
}
}
class A{
//静态初始化块提前
static{
new P("A--static");
}
private P p1 = new P("A--p1");
//非静态初始化块提前
{
new P("A{...}");
}
static P p3 = new P("A--p3");
public A(){
System.out.println("A()");
}
private P p2 =new P("A--p2");
public static class C {
private P p1 = new P("C--p1");
static P p3 = new P("C--p3");
public C(){
System.out.println("C()");
}
private P p2 =new P("C--p2");
static{
new P("C--static");
}
{
new P("C{...}");
}
}
}
class B extends A {
private P p1 = new P("B --p1");
static P p3 = new P("B -- p3");
public B() {
System.out.println("B()");
}
public P p2 = new P("B -- p2");
//静态初始化块置后
static {
new P("B -- static");
}
{new P("B{...}");}
}
class P {
public P(String s) {
System.out.println(s);
}
}
运行结果2
A–static
A–p3
B – p3
B – static
A–p1
A{…}
A–p2
A()
B –p1
B – p2
B{…}
B()
C–p3
C–static
C–p1
C–p2
C{…}
C()
结论
- 加载父类
- 为静态属性分配存储空间并初始化赋值
- 执行静态初始化块和静态初始化语句(按照代码顺序执行)
- 加载子类
- 为静态属性分配存储空间并初始化赋值
- 执行静态初始化块和静态初始化语句(按照代码顺序执行)
- 加载父类构造器
- 为实例属性分配存储空间并赋初始化值
- 执行实例初始化块和实例初始化语句(按照代码顺序执行)
- 执行构造函数
- 加载子类构造器
- 为实例属性分配存储空间并赋初始化值
- 执行实例初始化块和实例初始化语句(按照代码顺序执行)
- 执行构造函数
- 回到
main()
函数 - 静态内部类在被调用时执行初始化
- 内部类的加载过程同上