Java中七种单利模式与原子性和指令重排

方式一

public class Single {
    private Single(){}

    private static Single single = null;

    public static Single getInstance(){
        if(single==null){
            single =new Single();
        }
        return single;
    }
}
  • 优点:简单明了
  • 缺点:线程不安全,在多线程中会重复创建对象
  • 适用场景 单线程情况下

既然方式一无法保证在多线程的单一性,我们可以通过加锁的方式经行改造一下

方式二

public class Single {
    private Single(){}

    private static Single single = null;

    public static Single getInstance(){
        synchronized (Single.class){
            if(single==null){
                single =new Single();
            }
        }
        return single;
    }
}

通过加一个同步锁,就保证了多线程的唯一性

  • 缺点:锁本身是一种非常耗时的操作,虽然保证了唯一性,但是在多个线程的情况下,无疑会浪费资源。
  • 适用场景:线程较少的地方比较适用

3、方式三 双重检查锁

public class Single {
    private Single(){}

    private static Single single = null;

    public static Single getInstance(){
        if(single == null){//1
            synchronized (Single.class){
                if(single==null){
                    single =new Single();//2
                }
            }
        }
        return single;
    }
}
  • 优点:只有实例为null的情况下,才执行枷锁操作,避免无意义的同步锁,相比于方式2 方式3 双重判空时间效率更高。
  • 缺点:两层if判断更容易出错,代码相对较多,同时虽然一定程度上解决了多线程的问题,但是还有一个重大的隐患,会出现重排序,导致程序获取到的实例为空。

这里需要具体说一下方式3
这里需要引入两个概念:

  • 原子操作

  • 指令重排

原子操作

原子操作就是不可分割的操作,不能被线程打断的操作就是原子操作
比如说,赋值

 private int a = 0;//非原子操作
 private void setA(){
    a = 10;//原子操作
 } 

第一行,int a =0; 这个不是原子操作,是因为他至少有两个操作:

  • 一是声明变量a,让a在内存中开辟一个空间。
  • 二是给a赋值
    正是因为有这两个操作,所以在多线程中,就会充满了不确定性,因为他有一个中间状态,变量a已经声明,但是没有赋值的状态,这样你无法确定这块内存空间的a 就是上面线程内存空间的a。
    而第三行,只有一个操作,给a赋值,对于这个操作,你并没有一个中间状态,要么成功变成10,要么不成功还是0,即使是在多线程并发的情况下也是如此。
    所以原子操作,就是指不能被线程打断的不可分割的操作。
指令重排

指令队列在CPU执行时不是串行的, 当某条指令执行时消耗较多时间时, 并不会一直等待, 而是开启下一个指令去执行,当然是有条件的, 即两条指令不存在相关性,如下例子。

int a =0;//指令1
int b =0;//指令2
int c = a +b;//指令3

这段代码在单线程情况下指令顺序可能是

  • 指令1—>指令2—>指令3
  • 指令2—>指令1—>指令3
    因为指令1和指令2并没有相关性,所以先执行指令1和指令2对代码的运行结果完全没区别,但是指令3则不是,必须放在指令1和指令2之后。
    在单线程情况下,指令重排不会对我们的代码逻辑造成影响,但是多线程情况下,就不一样了,代码如下
    //这里必须要添加volatile
    private volatile boolean initialized = false;
    private static String result;
        //线程A 往文件夹里写完内容 然后线程B去读取
        new Thread(new Runnable() {
            @Override
            public void run() {
                writeAsset("test.json");//指令1
                initialized =  true;//指令2
            }
        }).start();

        //线程B
        new Thread(new Runnable() {
            @Override
            public void run() {
                while (!initialized){
                    sleep();
                }
                //从文件夹里读取
               result =  readAsset("test.json");
            }
        }).start();

这里面的代码,就可能因为指令重排的问题,导致下面读取的result为null,A线程可能会出现指令2 initialized = true先于指令1 writeAsset执行。这就会导致线程B立即执行readAsset,所以需要把变量initialized 添加volatile关键字,阻止写操作指令重排,保证有序性。

回到刚刚的问题上来,为什么这个双重检查锁会出现问题?

single =new Single();//2

首先这句代码并不是一个原子操作,它的内部实现可以简化成如下代码

memory =allocate();       //1:分配对象的内存空间 
ctorInstance(memory);     //2:初始化对象 
instance =memory;         //3:设置instance指向刚分配的内存地址

上面的代码,2依赖于操作1,但是操作3并不依赖于操作2,所以可以经行指令重排,变成

memory =allocate();       //1:分配对象的内存空间 
instance =memory;         //3:设置instance指向刚分配的内存地址
ctorInstance(memory);     //2:初始化对象 

在单线程中,这样的重排肯定是没有任何问题的,但是在多线程中,这个时候一个线程在判断

if(single == null)//1

刚好碰到上一个线程走到,指令重排之后的instance =memory; 这个时候Single有了内存地址,引用不为null,但是却没有初始化对象,这个时候就出现问题了。

方式四 终极双重检查锁

public class Single {
    private Single(){}
    private volatile  static Single single = null;
    public static Single getInstance(){
        if(single == null){//1
            synchronized (Single.class){
                if(single==null){
                    single =new Single();//2
                }
            }
        }
        return single;
    }
}
  • 优点:解决了方式三中潜在的隐患,适用于各种环境
  • 缺点:代码少为有点多,双层if+synchronized +volatile 写起来麻烦

方式五 静态属性单利

 private Single(){}
    
    private static Single single = new Single();
    
    public static Single getInstance(){
        return single;
    }
  • 优点:线程安全,作为静态属性,在内存中只有一份(在Java中,一个类在一个ClassLoader中只会被初始化一次,这是JVM本身的特点,而静态资源会随着类的加载而加载)
  • 缺点:不能控制加载时机,静态属性会随着类的加载而加载,并不是我们调用 getInstance()方法才去创建对象,所以资源利用率不高。
    适用场景:内存消耗小的对象、程序一运行就需要加载到内存的对象。

当然方式五也可以改为静态代码块来实现

 private Single(){}
    static {
        Single single = new Single();
    }
    public static Single getInstance(){
        return single;
    }

方式六 静态内部类实现单利

方式五实现虽然优雅,但是有一些缺点,所以我们需要再次改进一下,一样是通过Java的特性来实现

加载外部类或者实例化外部类,都不会加载内部类 或静态内部类

 private Single(){}

   private static class SingleClass{
        private Single sInstance = new Single();
    }
    public static Single getInstance(){
        return SingleClass.sInstance ;
    }

既保证了唯一性,又能控制加载时机。
强烈推荐

方式七 枚举单利

public class EnumSingleton {
    public static EnumSingleton getInstance() {
        return Single.INSTANCE.getInstance();
    }

    private enum Single{
        INSTANCE;
        private EnumSingleton singleton;
        Single(){
            singleton = new EnumSingleton();
        }
        private EnumSingleton getInstance() {
            return singleton;
        }
    }
}

单例的枚举实现在《Effective Java》中有提到,因为其功能完整、使用简洁、无偿地提供了序列化机制、在面对复杂的序列化或者反射攻击时仍然可以绝对防止多次实例化等优点,单元素的枚举类型被作者认为是实现Singleton的最佳方法。

关于枚举序列化单利的问题

Java规范中规定,每一个枚举类型极其定义的枚举变量在JVM中都是唯一的,因此在枚举类型的序列化和反序列化上,Java做了特殊的规定。
在序列化的时候Java仅仅是将枚举对象的name属性输出到结果中,反序列化的时候则是通过 java.lang.Enum 的 valueOf() 方法来根据名字查找枚举对象。
也就是说,以下面枚举为例,序列化的时候只将 DATASOURCE 这个名称输出,反序列化的时候再通过这个名称,查找对于的枚举类型,因此反序列化后的实例也会和之前被序列化的对象实例相同。

private enum Single{
INSTANCE;
}

由此可知,枚举天生保证序列化单例。

你可能感兴趣的:(Java中七种单利模式与原子性和指令重排)