Quantcast
Channel: CSDN博客移动开发推荐文章
Viewing all articles
Browse latest Browse all 5930

由单例模式的优化,引出的java线程数据同步和类加载顺序知识点总结

$
0
0

由单例模式的优化,引出的java线程数据同步和类加载顺序知识点总结

摘要

几种单例模式的优缺点及其改进优化

DCL失效问题的原因以及解决

java中线程同步关键字final和volatile

java内存屏障

java类加载顺序总结

饿汉单例


    //片段1
    class Singleton1{
        private static Singleton1 instance = new Singleton1();

        private Singleton1(){}

        public static Singleton1 getInstance(){
            return instance;
        }
    }
  1. 线程安全的
  2. 在加载类时就已经对单例进行了初始化,不能做到使用时再进行资源初始化。需要考虑单例对象资源消耗方面的问题和资源加载的时机
  3. 不建议使用

懒汉单例


    //片段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;
        }
    }
  1. 在首次调用时才会进行资源的初始化,一定程度上节省了资源
  2. 片段2是非线程安全的,片段3是线程安全的,但是每次获取单例时都会进行同步,造成不必要的同步开销,效率不高
  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;
        }
    }
  1. 保证只在第一次调用时初始化单例资源,资源利用率高
  2. 线程安全
  3. 在jdk1.5之前,高并发情况下可能会遇到DCL失效的情况

“双重检查”,线程th1和线程th2同时调用getInstance()方法,此时Singleton4未被实例化,th1和th2进入到第一个if判断中,th1率先获取到了同步锁进入到同步代码块中,th2等待获取该类的同步锁,之后Singleton4正常实例化后,th1释放同步锁,获取到单例对象,th2此时获取到了同步锁进入同步代码块中,此时第二个if能够保证不会继续进行资源初始化,保证单例的唯一性

DCL失效问题

instance = new Singleton4();这句代码不是一个原子操作,会被编译为好几条汇编命令,主要做了三件事情:

  1. 给Singelton4的实例分配内存——实例化
  2. 为Singleton4的成员变量进行初始化和调用其构造函数——初始化
  3. 将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++;   
       }   
    } 

参考关于java自增操作的原子性

自增操作主要是以下几个步骤:
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 变量提供理想的线程安全,必须同时满足下面两个条件:

  1. 对变量的写操作不依赖于当前值
  2. 该变量没有包含在具有其他变量的不变式中

所以简单来说,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()

结论
  1. 加载父类
    1. 为静态属性分配存储空间并初始化赋值
    2. 执行静态初始化块和静态初始化语句(按照代码顺序执行)
  2. 加载子类
    1. 为静态属性分配存储空间并初始化赋值
    2. 执行静态初始化块和静态初始化语句(按照代码顺序执行)
  3. 加载父类构造器
    1. 为实例属性分配存储空间并赋初始化值
    2. 执行实例初始化块和实例初始化语句(按照代码顺序执行)
    3. 执行构造函数
  4. 加载子类构造器
    1. 为实例属性分配存储空间并赋初始化值
    2. 执行实例初始化块和实例初始化语句(按照代码顺序执行)
    3. 执行构造函数
  5. 回到main()函数
  6. 静态内部类在被调用时执行初始化
  7. 内部类的加载过程同上
作者:u012123160 发表于2016/11/18 22:57:03 原文链接
阅读:7 评论:0 查看评论

Viewing all articles
Browse latest Browse all 5930

Trending Articles



<script src="https://jsc.adskeeper.com/r/s/rssing.com.1596347.js" async> </script>