单例模式详细整理分享——玩转单例模式

目录

什么是单例

代码实现要点

单例使用场景

饿汉式

饿汉式优点:

饿汉式缺点:

解决反射和序列化不安全:

完整实现

登记式

完整实现:

懒汉式和双检锁

枚举式

ThreadLocal实现方式

CAS实现方式

总结



什么是单例

单例类在整个程序中只能有一个实例,这个类负责创建自己的对象,并确保只有一个对象被创建。

代码实现要点

  1. 私有构造器,避免通过new创建实例对象。
  2. 持有该类的属性
  3. 对外提供获取实例的静态方法

单例使用场景

 全局使用的类,要写成单例;要消耗系统资源的类要写成单例,比如数据库连接池、工厂类、数据源等。这些创建和销毁要消耗很多系统资源的对象都会把它写成单例。

注:在使用Spring框架的时候,Spring的bean它的默认作用域也是单例的。

饿汉式

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

  它是典型的单例模式,它是通过Java ClassLoader类加载机制实现的,默认是线程安全的,当类加载成功的时候,静态属性就被初始化。属性的初始化是没有延迟加载,它没有加任何的锁,所以它的执行效率很高。

饿汉式优点:

  1. 在类加载的时候静态属性对象就被初始化,加载是没有被延迟的。

  2. 线程安全的,没有加锁,执行效率高。

饿汉式缺点:

    如果当前的对象在创建的时候要消耗很多资源的话,那么这个类加载的时候会浪费掉很多的内存,并且触发类的加载的机制并不一定是getInstance方法,可能是当前类的其他静态方法,并且在其他静态方法中没有使用到单例对象,造成内存的浪费,产生垃圾对象。并且对反射和反序列化,它是不安全的,我们在反射的情况下可以获取到多个对象。

解决反射和序列化不安全:

1、解决反射安全问题:

    在私有构造函数中判断instance是否为空,如果不为空则抛出异常。

2、解决序列化安全问题:

     通过重写“readResolve”方法,即反序列的对象由该方法提供。静态的对象在序列化的过程中是不会被保存的,所以在反序列的时候它会重新生成实例,这样就破坏了单例,通过readResolve方法,在它反序列的时候返回的是同一个静态实例对象,那么就解决了它反序列获取多个实例不相等的问题。

完整实现

public class Singleton_EH implements Serializable {
    private static Singleton_EH instance = new Singleton_EH();
    //解决反射问题:
    private Singleton_EH(){
        if(instance!=null)
          throw new IllegalArgumentException("单例不能实例化!");
    }
    public static Singleton_EH getInstance(){
        return instance;
    }
    //解决序列化问题:
    private Object readResolve(){
        return instance;
    }
    public static void main(String[] args) throws Exception {
        //演示反射问题是否被解决:
        Singleton_EH s1 = Singleton_EH.getInstance();
        Constructor constructor = Singleton_EH.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        Singleton_EH s2 = (Singleton_EH)constructor.newInstance();
        System.out.println(s1==s2);
    }
}
public class SerializeUtil {
    public static void serialize(Object obj){
        ObjectOutputStream objectOutputStream = null;
        try{
            File file = new File("obj");
            objectOutputStream = new ObjectOutputStream(new FileOutputStream(file));
            objectOutputStream.writeObject(obj);
            objectOutputStream.flush();
        }catch (Exception ex){
            ex.printStackTrace();
        }finally {
            try {
                objectOutputStream.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
    public static Object unserialize(){
        ObjectInputStream objectInputStream = null;
        try{
            File file = new File("obj");
            objectInputStream = new ObjectInputStream(new FileInputStream(file));
            return objectInputStream.readObject();
        }catch (Exception ex){
            ex.printStackTrace();
        }finally {
            try {
                objectInputStream.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
        return null;
    }
    public static void main(String[] args) {
        //演示序列化问题是否被解决:
        Singleton_EH s = Singleton_EH.getInstance();
        SerializeUtil.serialize(s);
        Object obj1 = SerializeUtil.unserialize();
        Object obj2 = SerializeUtil.unserialize();
        System.out.println(obj1==obj2);//输出true
    }
}

登记式

    静态内部类的形式。登记式与饿汉式的区别在于,它们初始化对象的时机是不一样的,在登记式当我们调用geteInstance方法的时候才会去触发静态内部类的加载,即当我们在登记式中调用其他静态方法时,如果在这些方法没有使用单例对象,它是不会去触发静态内部类的加载。 这样不会消耗系统的资料,我们什么时候调用getInstance方法就代表我们一定是要使用单例对象。

如果在singleton2的私有构造方法中输出“singlon2 loaded”,则在调用getInstance方法时则会输出该句话。

 测试一下:在加载Singleton2类的时候,这个单例对象是否会被创建(即是否输出私有构造中的语句)

只去加载当前的类,是不会触发单例对象的创建的,只有调用getInstance方法才会去创建单例对象,即实现延迟加载。

登记式默认对反射是不安全的,改造(使用私有构造器中判断不符合条件则抛出异常的方式):

在创建Singleton2对象的时候,我们进行一些判断,即内部类中的属性instance是不是null如果不为null的话,代表它已经被初始化了,就不应该去调用构造方法了,可以扔一个异常出去。

同样对于序列化问题,同样可以使用在单例类中重写readResolve方法:

 public Object readResolve(){
        return SingletonHolder.instance;
    }

完整实现:

public class Singleton implements Serializable {
    private static class SingletonHolder{
        private static Singleton instance = new Singleton();
    }
    private Singleton(){
        if(SingletonHolder.instance!=null){
            throw new IllegalArgumentException("单例不可反射实例化!");
        }
    }
    public Object readResolve(){
        return SingletonHolder.instance;
    }
    public static Singleton getInstance(){
        return SingletonHolder.instance;
    }
}

总结:

对于反射的安全性而言,饿汉式和登记式都可以通过在私有构造函数中设置判断,如果install不为null,则抛出异常,解决反射的安全性,即不能通过反射创建对象。

对序列化和反序列化,饿汉式和登记式都可以通过readResolve方法实现序列化的安全性,即序列化得到的对象为单例对象。

懒汉式和双检锁

 懒汉式,第一次使用单例对象的时候才去加载它,而饿汉式是类一旦被加载就创建对象(静态实例对象)。

但是对于getInstance创建Instance方法,它是线程不安全的。(饿汉式由jvm保证只有一个实例,jvm保证安全性)。

  线程不安全: 当线程A进入getInstance方法,if判断,此时instance为null,线程A进入if分支中,此时线程A时间片用完,轮到线程B进入getInstance方法,此时因为线程A进入if分支但未创建对象,所以线程B同样进入if分支,创建对象。 当轮到线程A执行时,从上次的地方继续执行又创建对象。导致两个对象不相同,即出现不安全性(导致系统中有多个单例对象)。

验证:

由上图可知创建的单例对象存在不同的情况。

修改懒汉式为线程安全:

方式一:直接将getInstance方法变为同步方法,加上Synchronized关键词。但是问题是:它每次调用方法都会进行锁住,而当对象创建好后并不用锁住它!效率很低。

改进: 使用双检锁的形式:

解读: 在创建对象之初,instance为null,线程A和线程B都进入第一个if判断,执行同步块时,只有一个线程能执行,假如线程A进入第二个if判断,创建对象,执行同步块释放锁,此时线程B进入第二个if判断,由于已经创建对象,所以需要再次验证是否为null,如果不验证则会出现再次重复创建对象的操作。

这种方式减少同步的次数,同步只发生在刚开始创建对象时,在对象创建后无需进入同步代码块。

面试官可能问的更深入一些,比如:双检锁的方式就是百分百线程安全吗?

此时,应该思考,是否不是百分百线程安全的,为什么呢?

这里可能涉及到虚拟机中的指令重排。

回到代码:

当我们运行到 instance = new Singleton7()时,在虚拟机中会进行如下三步操作:

正常情况下,步骤是: 先分配对象内存空间,接着进行对象初始化,对象初始化后再将instance指向分配的内存空间。

在某些编译器上可能会出现指令重排,将第二个步骤和第三个步骤进行调换。

在程序运行中指令重排并不会影响最终的结果, 是先进行初始化对象还是先instance指向分配的空间,最终的结果都是 instance 指向初始化对象后分配的内存空间。

 但在多线程情况下,会在极小的概率下出现一个问题,看下图:

 Instance一开始的时候是null,当指令重排后,在线程A中,instance指向了一个内存空间,但当前内存空间存在的对象还没有进行初始化,此时线程B来访问的时候,它发现instance指向了一个地址,不是null,所以线程B就会把instance指向的地址拿走,但此时地址中的对象还没有被初始化,也就是说线程B会拿到一个空的对象,虽然这种情况概率很低,但是也是不排除的。

解决办法:

在变量instance上加上关键词 volatile,它可以保证对于instance对象所有的操作不会进行指令重排,就避免了上面的问题。

记住,对于双检锁创建单例对象,在instance前面加上关键词volatile。

枚举式

枚举式目前是推荐使用单例的最佳方式。

首先它是线程安全的,它不是延迟初始化,它是立即初始化的。并且枚举式自动支持序列化的,也能自己防止反序列化创建一个新的对象, 并且枚举式能直接防止反射攻击

相比于饿汉式和登记式,它简单又高效。

测试:

测试是否反射安全:

测试结果:通过反射创建对象会报错,即不支持反射创建对象,即反射安全!

对于序列化,枚举是不需要实现Serialized接口的,默认它就支持序列化的。

 也不是说枚举式就是很完美的,如果单例模式需要继承,则枚举式就不恰当。

ThreadLocal实现方式

 ThreadLocal和双检锁模式不同,ThreadLocal本身不加锁,它为会每个线程提供一个独立的变量副本,也就是说它是一个空间换时间的操作。它可以保证在每一个线程之间类的对象都是单例的,但是在不同的线程之间,它是不能保证单例的。代码如下:

public class Singleton_TL {
    private static Singleton_TL instance = null;
    private Singleton_TL(){}
    private static final ThreadLocal<Singleton_TL> threadLocalSingleton = new ThreadLocal<Singleton_TL>(){
        @Override
        protected Singleton_TL initialValue() {
            return new Singleton_TL();
        }
    };
    public static Singleton_TL getInstance(){
        return threadLocalSingleton.get();
    }
}

测试:

单线程测试,结果返回true,即单例对象唯一。

//在单线程下,获得的单例对象是唯一的。
Singleton_TL singleObj = Singleton_TL.getInstance();
Singleton_TL singleObj2 = Singleton_TL.getInstance();
System.out.println(singleObj==singleObj2);  //输出true

多线程测试:

 for(int i=0;i<20;i++){
            new Thread(()->{
                Singleton_TL s1 = Singleton_TL.getInstance();
                Singleton_TL s2 = Singleton_TL.getInstance();
                System.out.println(Thread.currentThread().getName()+",线程内单例对象比较:"+(s1==s2)+",打印当前线程单例对象引用:"+s1);
            }).start();
        }

结果:

通过ThreadLocal创建的单例对象,在线程内是相同的,但线程间均不相同。

CAS实现方式

 Compare and Space,是一种无锁的乐观策略,它假设当前线程在访问资源的时候不会冲突,如果出现冲突的话就重试当前的操作,直到没有冲突为止。

public class SingletonCAS {
    private static final AtomicReference<SingletonCAS> instance = new AtomicReference<>();
    private SingletonCAS(){}
    public static final SingletonCAS getInstance(){
        for(;;){
            SingletonCAS current = instance.get();
            if(current!=null)
                return current;
            current = new SingletonCAS();
            if(instance.compareAndSet(null,current)){
                return current;
            }
        }
    }
}

首先,它是个延迟加载的过程,

current要替换instance里面的对象,为了在多线程之间保证线程安全,在此通过调用原子类下面的compareAndSet方法,compareAndSet对第一个参数进行判断,如果instance里面的对象等于第一个参数,则用第二个参数替换。

  如果至少有两个线程进行访问的话,进行getInstance方法,没有加锁,刚开始通过instance.get获取的对象都是null对象,此时两个线程都new了一个对象,但是对instance的操作它是一个原子性的,因为instance是AtomicReference,是一个原子类的操作,所以只会有一个线程对它进行替换的操作(compareAndSet),比如说A线程,它先进行替换,将instance指向的null替换成A线程创建的current对象,当B线程来的时候,因为A线程已经把instance指向的对象替换了,所以B线程的替换是一个失败的,此时B线程没有进入if判断,并没有获取到对象,那怎么办?  这里通过for( ; ;) 进行重试的机制或者用while(true)。  那下一次B线程进行循环则获取A线程创建的对象。

 问题: 我们可以发现,当刚开始多个线程进入时,会new多个对象,而实际真正能对instance指向的对象赋值的只有一个,会造成垃圾对象。

也就是能保证你拿到的instance是一个,即同一个单例对象,但是你不能保证系统里面只初始化了一个对象。并且这种形式,有重试机制(死循环) ,如果当多个线程刚开始竞争时,假设线程A竞争到了instance.compareAndSet的原子性操作,但是线程A卡住了的话,此时会对CPU压力比较大。

总结

线程安全: 除了懒汉式和ThreadLocal(单个线程安全)不保证线程间安全。

双检锁是自己通过synchronize和volatile实现线程间安全。

饿汉式、登记式(默认反射是不安全的,通过改造私有构造函数)、枚举式是jvm自己保证的线程安全。

饿汉和登记式可通过改造实现反射和反序列化的安全性。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值