java实现单例模式的几种方式

设计模式

Posted by CHuiL on July 1, 2021

定义:使用该模式创建的类只有一个实例,且是在该类中实例化他自己并向系统提供该实例

使用场景要求只有一个对象的场景

饿汉模式

在加载该类的时候便实例化该类,换句话说,在声明的时候就实例化了。

饿汉模式由于一开始就创建了,所以并不存在并发安全问题。

public class Singleton{
    private static Singleton instance = new Singleton();
    private Singleton(){};
    public static Singleton getInstance(){
        return instance;
    }
}


懒汉模式

在调用该对象的时候才实例化。实际上就是第一次调用的时候实例化,再次调用是由于该对象已实例化了,所以就直接使用;

优点就是用到的时候在实例,缺点就是第一次调用时会比较慢,而且多线程环境下会有线程安全问题

线程不安全

public class Singleton {
    private static Singleton instance;
    public static Singleton getSingleton() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

如果有Singleton之前还没被调用过,这个时候突然来了一堆线程调用该单例,假设单例构造需要花很多时间,那么就会有很多线程判断instance==null,导致重复构造了该实例

线程安全,获取效率低

public class Singleton {
    private static Singleton instance;
    public static Singleton getSingleton() {
        synchronized (Singleton.class) {
            if (instance == null) {
                instance = new Singleton();
            }
        }
        return instance;
    }
}

这种能保证线程安全,但是在多线程环境下获取效率会很慢,因为每次获取都需要获得锁在进行判断,同一时刻线程很多的话,还会导致其他线程陷入无意义的阻塞

线程安全,效率更高 DCL写法

public class Singleton {
    private static Singleton instance;
    public static Singleton getSingleton() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

双重检查,介于上面的问题, 在外层多一层判断,只有真正判断为null时,才上锁进行构造。那这个时候如果不要锁里面的判断行不行?很明显是不可以的,因为这样锁就没意义了,又回到我们前面线程不安全的情况。同时间会有很多线程判断instance为null,从而进入构造的代码。

这种写法大部分情况下已经没问题了,但还是存在DCL失效的情况,因为指令重排。

防止指令重排

public class Singleton {
    private volatile static Singleton instance;
    public static Singleton getSingleton() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

instace加上了volatile关键字,只要是为了预防cpu指令重拍.这里的问题出在instance = new Singleton()这句赋值语句;该赋值语句可以被拆成多条指令

1.分配对象的内存空间
2.初始化对象
3.设置instance指向刚分配的内存地址,当instance指向分配地址时,instance不为空

这里1肯定要在23之前,但是23顺序可以调整,假设3被调整到2之前,那么instance这个引用变量便不再是null了,其他线程在进入到这个getSingleton()方法后,判断instance !=null,返回了该变量并且直接使用,但是该变量引用实际指向的对象并未初始化,从而导致了错误; 所以这里也需要加上volatile来保证23的指向顺序;

静态内部类单例模式

public class Singleton {

    private Singleton(){};
    
    public static Singleton getSingleton(){
        return SingletonHolder.sInstance;
    }
    
    public static class SingletonHolder{
        private static final Singleton sInstance = new Singleton();
    }
    
}

Singleton在初始被加载时并不是实例化sInstance,只有在第一次调用getSingleton时,才会加载SingletonHolder并初始化Singleton()。

这里有个疑问?为什么这种写法能保证线程安全呢?

首先要了解类加载过程中的最后一个阶段:即类的初始化,类的初始化阶本质就是执行类构造器的 <clinit>方法。

<clinit>方法:这不是由程序员写的程序,而是根据代码由javac编译器生成的。它是由类里面所有的类变量的赋值动作和静态代码块组成的。JVM内部会保证一个类的<clinit>方法在多线程环境下被正确的加锁同步,也就是说如果多个线程同时去进行“类的初始化”,那么只有一个线程会去执行类的<clinit>方法,其他的线程都要阻塞等待,直到这个线程执行完<clinit>方法。然后执行完<clinit>方法后,其他线程唤醒,但是不会再进入<clinit>()方法。也就是说同一个加载器下,一个类型只会初始化一次。

那么回到这个代码中,这里的静态变量的赋值操作进行编译之后实际上就是一个<clinit>代码,当我们执行getInstance方法的时候,会导致SingleTonHolder类的加载,类加载的最后会执行类的初始化,但是即使在多线程情况下,这个类的初始化的<clinit>代码也只会被执行一次,所以他只会有一个实例。

那么再增加一句,之所以这里变量定义的时候不需要volatile,因为只有一个线程会执行具体的类的初始化代码<clinit>,也就是即使有指令重排序,因为根本没有第二个线程给你去影响,所以无所谓。

此外,如果讲得再细致一点,可以参考下面的第二个网站,简单说一下:

    比如有一个类T,那么什么时候会进行类T的初始化?有以下5种情况:
    
    T 是一个类,而且一个 T 类型的实例被创建;
    T 是一个类,且 T 中声明的一个静态方法被调用;
    T 中声明的一个静态字段被赋值;
    T 中声明的一个静态字段被使用,而且这个字段不是一个常量字段;
    T 是一个顶级类(top level class,见 java 语言规范的§7.6),而且一个断言语句嵌套在 T 内部被执行。

而我们前面的代码则对应了第四种情况,一个静态字段被使用,因此此时则会进行静态内部类的初始化。又因为java是多线程语言,作者也考虑到了可能存在“多个线程在同一时间尝试去初始化同一个类”的情况,因此java规定,对于每一个类或接口 C,都有一个唯一的初始化锁 LC 与之对应。

JVM 在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来确保这个类已经被初始化过了。(但是只有第一个获得锁的线程,会执行初始化,执行完之后会设置一个标志位,表示已经初始化完成,后面其他的线程再次获得锁,检查标志位,发现已经初始化完了,直接释放锁,不会再次执行初始化。)

参考