单例模式的奇幻漂流

1、单例模式实现
 
单例模式中类的构造函数是私有,外部类无法调用该构造函数,也就无法生成多个实例,该类自身必须定义一个静态私有实例,并向外提供一个静态的公有函数用于创建或者获取该静态私有实例。
单例模式这个题目可以关系到很多知识点。比如线程安全、类加载机制、synchronized的原理、volatile的原理、指令重排与内存屏障、枚举的实现、反射与单例模式、序列化如何破坏单例、CAS、CAS的ABA问题、Threadlocal等。
日常开发中开一般使用懒汉模式或者饿汉模式实现单例:
 
1.1、懒汉模式单例
 
懒汉式单例模式是指在类加载时候没有生成实例,只有第一次调用geInstance方法时才去创建这个单例。懒汉模式有两种写法,分别是线程安全的和非线程安全的。
 
非线程安全版本的懒汉模式:
public class Singleton {
    private static Singleton instance;
    private Singleton (){}
 
 
    public static Singleton getInstance() {
    if (instance == null) {
        instance = new Singleton();
    }
    return instance;
    }
}

线程安全版本的懒汉模式:

public class LazySingleton
{
    private static volatile LazySingleton instance=null;    //保证 instance 在所有线程中同步
    private LazySingleton(){}    //private 避免类在外部被实例化
    public static synchronized LazySingleton getInstance()
    {
        //getInstance 方法前加同步
        if(instance==null)
        {
            instance=new LazySingleton();
        }
        return instance;
    }
}

注意:如果编写的是多线程程序,则不能删除代码中的关键字 volatile 和 synchronized,否则将存在线程非安全的问题。如果不删除这两个关键字就能保证线程安全,但是每次访问时都要同步,会影响性能,且消耗更多的资源,这是懒汉式单例的缺点。 

 
懒汉模式可以在第一次真正用到的时候再实例化,避免了创建无效的对象。但是缺点是第一次使用的时候需要耗费时间进行对象的初始化。
 
 
1.2、饿汉模式单例
 
饿汉模式的单例中类一旦加载就创建一个单例,保证在调用getInstance方法之前单例已经存在了。饿汉式单例在类创建的同时就已经创建好一个静态的对象供系统使用,以后不再改变,所以是线程安全的,可以直接用于多线程而不会出现问题。 单例会在加载类后一开始就被初始化,即使客户端没有调用 getInstance()方法。
 
饿汉模式单例实现:
public class Singleton {  
    private static Singleton instance = new Singleton();  
    private Singleton (){}  
    public static Singleton getInstance() {  
        return instance;  
    }  
} 

饿汉模式单例的变种:

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

饿汉式的创建方式在一些场景中将无法使用:比如 Singleton 实例的创建是依赖参数或者配置文件的,在getInstance() 之前必须调用某个方法设置参数给它,那样这种单例写法就无法使用了。

饿汉模式单例如何保证线程安全?
通过定义静态的成员变量,以保证单例对象可以在类初始化的过程中被实例化。这其实是利用了ClassLoader的线程安全机制。ClassLoader的loadClass方法在加载类的时候使用了synchronized关键字。所以, 除非被重写,这个方法默认在整个装载过程中都是线程安全的。所以在类加载过程中对象的创建也是线程安全的。
 
1.3、双重检查锁单例模式
 
除了上述的懒汉模式和饿汉模式,其实单例的实现模式还有另外几种,分别是:
双重检查锁(实际上也称作双重锁懒汉模式): 通过同步代码块代替了懒汉模式中的同步方法,来减小锁的粒度,减少阻塞。但是避免并发,需要进行两次非空判断,所以叫做双重锁校验。
 
因为会有两次检查 instance == null,一次是在同步块外,一次是在同步块内。为什么在同步块内还要再检验一次?因为可能会有多个线程一起进入同步块外的 if,如果在同步块内不进行二次检验的话就会生成多个实例了。
 
在懒汉模式中,getInstance方法的前面加上关键字synchronized, 会导致很大的性能开销,并且加锁其实只需要在第一次初始化的时候用到,之后的调用都没必要再进行加锁。在双重检查锁中,代码会检查两次单例类是否有已存在的实例,一次加锁一次不加锁,一次确保不会有多个实例被创建。
public class DoubleCheckSingleton{
private  static volatile  DoubleCheckSingleton instance;//静止指令重排,就是对于一个 volatile 变量的写操作都先行发生于后面对这个变量的读操作
private DoubleCheckSingleton(){}
   public static DoubleCheckSingleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new DoubleCheckSingleton();
                }
            }
        }
        return instance;
    }
}

这样写有好处: 检查变量是否被初始化(不去获得锁),如果已被初始化则立即返回, 如果没有获取锁,再次检查变量是否已经被初始化,如果还没被初始化就初始化一个对象。如果多个线程同时了通过了第一次检查,并且其中一个线程首先通过了第二次检查并实例化了对象,那么剩余通过了第一次检查的线程就不会再去实例化对象。 除了初始化的时候会出现加锁的情况,后续的所有调用都会避免加锁而直接返回,解决了性能消耗的问题,解决了上述的懒汉单例的缺点。

 
双重锁校验的单例中为什么要使用volatile?
因为编译器有可能进行指令重排优化,使得singleton对象再未完成初始化之前就对其进行了赋值,这样其他人拿到的对象就可能是个残缺的对象了。使用volatile的目的是避免指令重排。保证先进性初始化,然后进行赋值。保证 instance 在所有线程中同步。
 
2、不使用关键字synchronized和lock来实现单例模式
 
如果不使用Synchronized关键字或者lock来实现线程安全的单例模式呢?怎么解决?有什么方式?
 
2.1、静态内部类
静态内部类的优点是:外部类加载时并不需要立即加载内部类,内部类不被加载则不去初始化INSTANCE,故而不占内存。即当SingleTon第一次被加载时,并不需要去加载SingleTonHoler,只有当getInstance()方法第一次被调用时,才会去初始化INSTANCE,第一次调用getInstance()方法会导致虚拟机加载SingleTonHoler类,这种方法不仅能确保线程安全,也能保证单例的唯一性,同时也延迟了单例的实例化。
public class Singleton {  
    private static class SingletonHolder {  
        private static final Singleton INSTANCE = new Singleton();  
    }  
    private Singleton (){}  
    public static final Singleton getInstance() {  
        return SingletonHolder.INSTANCE;  
    }  
}

静态内部类的优点是:外部类加载时并不需要立即加载内部类,内部类不被加载则不去初始化INSTANCE,故而不占内存。即当SingleTon第一次被加载时,并不需要去加载SingleTonHoler,只有当getInstance()方法第一次被调用时,才会去初始化INSTANCE,第一次调用getInstance()方法会导致虚拟机加载SingleTonHoler类,这种方法不仅能确保线程安全,也能保证单例的唯一性,同时也延迟了单例的实例化。

类加载时机:JAVA虚拟机在有且仅有的5种场景下会对类进行初始化。
  1. 遇到new、getstatic、setstatic或者invokestatic这4个字节码指令时,对应的java代码场景为:new一个关键字或者一个实例化对象时、读取或设置一个静态字段时(final修饰、已在编译期把结果放入常量池的除外)、调用一个类的静态方法时。
  2. 使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没进行初始化,需要先调用其初始化方法进行初始化。
  3. 当初始化一个类时,如果其父类还未进行初始化,会先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的类),虚拟机会先初始化这个类。
  5. 当使用JDK 1.7等动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。
这5种情况被称为是类的主动引用,注意,这里《虚拟机规范》中使用的限定词是"有且仅有",那么,除此之外的所有引用类都不会对类进行初始化,称为被动引用。静态内部类就属于被动引用的行列。
 我们再回头看下getInstance()方法,调用的是SingleTonHoler.INSTANCE,取的是SingleTonHoler里的INSTANCE对象,跟上面那个DCL方法不同的是,getInstance()方法并没有多次去new对象,故不管多少个线程去调用getInstance()方法,取的都是同一个INSTANCE对象,而不用去重新创建。当getInstance()方法被调用时,SingleTonHoler才在SingleTon的运行时常量池里,把符号引用替换为直接引用,这时静态对象INSTANCE也真正被创建,然后再被getInstance()方法返回出去,这点同饿汉模式。那么INSTANCE在创建过程中又是如何保证线程安全的呢?在《深入理解JAVA虚拟机》中,有这么一句话:
2.2、饿汉模式
 
类似上述的饿汉变种实现单例模式,同样也是线程安全的。
 
Q:那是如何做到线程安全的呢?
A:因为以上几种虽然没有直接使用synchronized,但是也是间接用到了。
类加载过程的线程安全性保证:以上的静态内部类、饿汉等模式均是通过定义静态的成员变量,以保证单例对象可以在类初始化的过程中被实例化。这其实是利用了ClassLoader的线程安全机制。ClassLoader的loadClass方法在加载类的时候使用了synchronized关键字。所以, 除非被重写,这个方法默认在整个装载过程中都是线程安全的。所以在类加载过程中对象的创建也是线程安全的。
 
2.3、枚举实现单例
 
除了静态static,还能怎么不使用synchronized和lock实现一个线程安全的单例吗?答案是枚举!
public enum Singleton {  
    INSTANCE;  
    public void whateverMethod() {  
    }  
}

枚举其实底层是依赖Enum类实现的,这个类的成员变量都是static类型的,并且在静态代码块中实例化的,和饿汉有点像, 所以天然是线程安全的。

枚举在java中与普通类一样,都能拥有字段与方法,而且枚举实例创建是线程安全的,在任何情况下,它都是一个单例。可直接以 SingleTon.INSTANCE的方式调用。
 
枚举实现单例的好处除了写法简单,线程安全以外,枚举还有一个好处,那就是"枚举可以解决反序列化会破坏单例的问题"
在枚举序列化的时候,Java仅仅是将枚举对象的name属性输出到结果中,反序列化的时候则是通过java.lang.Enum的valueOf方法来根据名字查找枚举对象。同时,编译器是不允许任何对这种序列化机制的定制的,因此禁用了writeObject、readObject、readObjectNoData、writeReplace和readResolve等方法。
 
普通的Java类的反序列化过程中,会通过反射调用类的默认构造函数来初始化对象。所以,即使单例中构造函数是私有的,也会被反射给破坏掉。由于反序列化后的对象是重新new出来的,所以这就破坏了单例
 
但是,枚举的反序列化并不是通过反射实现的。所以,也就不会发生由于反序列化导致的单例破坏问题。
 
2.4、CAS和ThreadLoacl
枚举其实也是借助了synchronized的,那你知道哪种方式可以完全不使用synchronized的吗?
 
非锁的方式实现:CAS(AtomicReference)实现单例模式,和ThreadLoacl实现
 
CAS实现版本:
public class Singleton {
    private static final AtomicReference<Singleton> INSTANCE = new AtomicReference<Singleton>();
    private Singleton() {}
    public static Singleton getInstance() {
        for (;;) {
            Singleton singleton = INSTANCE.get();
            if (null != singleton) {
                return singleton;
            }
            singleton = new Singleton();//大量的对象被创建,很容易造成OOM
            if (INSTANCE.compareAndSet(null, singleton)) {
                return singleton;
            }
        }
    }
}
 
使用CAS实现的单例优缺点:
 
用CAS的优点在于不需要使用传统的锁机制来保证线程安全,CAS是一种基于忙等待的算法,依赖底层硬件的实现,相对于锁它没有线程切换和阻塞的额外消耗,可以支持较大的并行度
CAS的一个重要缺点在于如果忙等待一直执行不成功(一直在死循环中),会对CPU造成较大的执行开销。另外,代码中,如果N个线程同时执行到 singleton = new Singleton();的时候,会有大量对象被创建,可能导致内存溢出。
 
ThreadLocal,看看能不能实现?其实上述将的单例模式是指ClassLoader内的单例,如何要实现线程内单例呢?
 
所谓线程内单例,指的是线程内唯一,线程间可以不唯一。我们通过一个 HashMap 来存储对象,其中 key 是线程 ID,value 是对象。这样我们就可以做到,不同的线程对应不同的对象,同一个线程只能对应一个对象
 
A:ThreadLocal?这也可以吗?可以使用ThreadLocal来实现单例呢?
ThreadLocal的理解:
ThreadLocal会为每一个线程提供一个独立的变量副本,从而隔离了多个线程对数据的访问冲突。对于多线程资源共享的问题,同步机制(synchronized)采用了“以时间换空间”的方式,而ThreadLocal采用了“以空间换时间”的方式。同步机制仅提供一份变量,让不同的线程排队访问,而ThreadLocal为每一个线程都提供了一份变量,因此可以同时访问而互不影响。
 
ThreadLocal实现单例模式实现版本
public class Singleton {
     private static final ThreadLocal<Singleton> singleton =
     new ThreadLocal<Singleton>() {
         @Override
         protected Singleton initialValue() {
            return new Singleton();
         }
     };
     public static Singleton getInstance() {
        return singleton.get();
     }
 
     private Singleton() {}
}
 
2.5、集群单例
集群相当于多个进程构成的一个集合,“集群唯一”就相当于是进程内唯一、进程间也唯一。也就是说,不同的进程间共享同一个对象,不能创建同一个类的多个对象。
实现集群内单例的核心思路,其实跟利用分布式锁控制访问共享资源是一个道理,只是将创建/销毁单例对象的过程用分布式锁加以控制,保证每次只有一个节点能做创建/销毁的操作:
public class IdGenerator {
  private AtomicLong id = new AtomicLong(0);
  private static IdGenerator instance;
  private static SharedObjectStorage storage = FileSharedObjectStorage();
  private static DistributedLock lock = new DistributedLock();
 
  private IdGenerator() {}
 
  public synchronized static IdGenerator getInstance()
    if (instance == null) {
      lock.lock();
      instance = storage.load(IdGenerator.class);
    }
    return instance;
  }
 
  public synchroinzed void freeInstance() {
    storage.save(this, IdGeneator.class);
    instance = null; //释放对象
    lock.unlock();
  }
 
  public long getId() {
    return id.incrementAndGet();
  }
}
 
// IdGenerator使用举例
IdGenerator idGeneator = IdGenerator.getInstance();
long id = idGenerator.getId();
IdGenerator.freeInstance();

上面是一段伪代码,我们把单例对象序列化并存储到外部共享存储区(比如文件)。进程在使用这个单例对象的时候,需要先从外部共享存储区中将它读取到内存,并反序列化成对象,然后再使用,使用完成之后还需要再存储回外部共享存储区。多个不同进程之间的访问通过分布式锁来控制。

 
3、破坏单例模式
 
单例模式(Singleton Pattern)是 Java 中最简单的设计模式之一。是一种创建型设计模式。单例模式保证一个类仅有一个实例,并提供一个访问它的全局访问点。
单例模式一般体现在类声明中,单例的类负责创建自己的对象,同时确保只有单个对象被创建。这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。
 
如何破坏单例,一般有两种方式:反射+序列化。最常用的就是反射
 
3.1、反射
/**
* 使用双重校验锁方式实现单例
*/
public class Singleton implements Serializable{
    private volatile static Singleton singleton;
    private Singleton (){}
    public static Singleton getSingleton() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
} 
上述单例模式提供了一个private类型的构造函数,正常情况下,我们无法直接调用对象的私有方法。但是反射技术给我们提供了一个后门。
 
如下代码,我们通过反射的方式获取到Singleton的构造函数,设置其访问权限,然后通过该方法创建一个新的对象:
public class SingletonTest {
    public static void main(String[] args) {
        Singleton singleton = Singleton.getSingleton();
        try {
            Class<Singleton> singleClass = (Class<Singleton>)Class.forName("com.dev.interview.Singleton");
            Constructor<Singleton> constructor = singleClass.getDeclaredConstructor(null);
            constructor.setAccessible(true);
            Singleton singletonByReflect = constructor.newInstance();
 
            System.out.println("singleton : " + singleton);
            System.out.println("singletonByReflect : " + singletonByReflect);
            System.out.println("singleton == singletonByReflect : " + (singleton == singletonByReflect));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
输出为:
singleton : com.dev.interview.Singleton@55d56113
singletonByReflect : com.dev.interview.Singleton@148080bb
singleton == singletonByReflect : false

如上,通过发射的方式即可获取到一个新的单例对象,这就破坏了单例。

怎么避免反射带来的破坏性:在单例的构造函数中增加判断就可以了
我们在Singleton的构造函数中增加如下代码:
private Singleton() {
    if (singleton != null) {
        throw new RuntimeException("Singleton constructor is called... ");
    }
}

这样,在通过反射调用构造方法的时候,就会抛出异常:

Caused by: java.lang.RuntimeException: Singleton constructor is called...
 
3.2、序列化破坏单例
如以下代码,我们通过先将单例对象序列化后保存到临时文件中,然后再从临时文件中反序列化出来:
public class SingletonTest {
    public static void main(String[] args) {
        Singleton singleton = Singleton.getSingleton();
        //Write Obj to file
        ObjectOutputStream oos = null;
        try {
            oos = new ObjectOutputStream(new FileOutputStream("tempFile"));
            oos.writeObject(singleton);
            //Read Obj from file
            File file = new File("tempFile");
 
            ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
            Singleton singletonBySerialize = (Singleton)ois.readObject();
            //判断是否是同一个对象
 
            System.out.println("singleton : " + singleton);
            System.out.println("singletonBySerialize : " + singletonBySerialize);
            System.out.println("singleton == singletonBySerialize : " + (singleton == singletonBySerialize));
 
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
输出结果如下:
singleton : com.dev.interview.Singleton@617faa95
singletonBySerialize : com.dev.interview.Singleton@5d76b067
singleton == singletonBySerialize : false

如上,通过先序列化再反序列化的方式,可获取到一个新的单例对象,这就破坏了单例。

因为在对象反序列化的过程中,序列化会通过反射调用无参数的构造方法创建一个新的对象,所以,通过反序列化也能破坏单例。
 
如何避免序列化引起的单例破坏?
答案是只要修改下反序列化策略就好了。
只需要在Sinleton中增加readResolve方法,并在该方法中指定要返回的对象的生成策略就可以了。即序列化在Singleton类中增加以下代码即可:
private Object readResolve() {
    return getSingleton();
}

为什么增加readResolve就可以解决序列化破坏单例的问题了呢?

因为反序列化过程中,在反序列化执行过程中会执行到ObjectInputStream#readOrdinaryObject方法,这个方法会判断对象是否包含readResolve方法,如果包含的话会直接调用这个方法获得对象实例。
 
 
posted @ 2021-01-31 20:59  jrliu  阅读(130)  评论(0编辑  收藏  举报