【对线面试官】面试官:小伙子,谈谈单例模式

单例?

一个类只有一个对象实例,并对外提供一个获取实例的方法。一句话就能概括单例这个设计模式,真的只有这么简单吗?
单例模式分为两种方案,饿汉式懒汉式

一、饿汉式

  • 私有的构造方法
  • 只要当类加载的时候就初始化单例对象
public class Hungry {
 	private static Hungry hungry = new Hungry();
    private Hungry(){
    }
    
    public static Hungry newInstance(){
        return hungry;
    }
}

由于变量由static修饰,所以该对象由多个线程共享,并且在类加载阶段只初始化一次。

二、懒汉式

  • 私有的构造方法
  • 当需要使用实例对象时就创建
public class LazyMan {
   	private static volatile LazyMan lazyMan = null;
    private LazyMan() {
    }
 
    public static LazyMan newInstance() {
        if (lazyMan == null) {
        	lazyMan = new LazyMan(); 
        }
        return lazyMan;
    }
}

单线程情况下上面的代码不存在安全问题,但是放在多线程并发情况下呢?由于饿汉式是在类加载时初始化的对象,所以它在多线程情况下是线程安全的,但是懒汉式是对外提供方法创建对象,所以在并发情况下存在多线程同时操作共享资源的情景,下面我们假设一个场景:

  1. 线程A调用newInstance()初始化对象
  2. 线程A判空后进入if代码块,此时还没有完成实例化过程
  3. 线程B进来调用newInstance()方法,同时判空后进入if代码块
  4. 线程A执行new LazyMan()
  5. 线程B执行new LazyMan()

在这种情况下new了两次对象,破坏了单例

在多线程情况下如何保证线程安全,不用说,第一反应肯定是加锁,下面我们来加锁:

public class LazyMan {
   	private static volatile LazyMan lazyMan = null;
    private LazyMan() {
    }
 
    public static synchronized LazyMan newInstance() {
        if (lazyMan == null) {
        	lazyMan = new LazyMan(); 
        }
        return lazyMan;
    }
}

跑10个线程

site.kexing.lock.LazyMan@73c1f984
site.kexing.lock.LazyMan@73c1f984
site.kexing.lock.LazyMan@73c1f984
site.kexing.lock.LazyMan@73c1f984
site.kexing.lock.LazyMan@73c1f984
site.kexing.lock.LazyMan@73c1f984
site.kexing.lock.LazyMan@73c1f984
site.kexing.lock.LazyMan@73c1f984
site.kexing.lock.LazyMan@73c1f984
site.kexing.lock.LazyMan@73c1f984

很明显现在懒汉式是线程安全的,但是上文的synchronized 是直接锁的方法,这种方案锁的粒度太大,如果方法体中有大量的业务代码不需要同步,方法的性能,效率会非常低下,所以下面我们用同步代码块来降低锁的粒度

public class LazyMan {
   	private static volatile LazyMan lazyMan = null;
    private LazyMan() {
    }
 
    public static LazyMan newInstance() {
        if (lazyMan == null) {
            synchronized(LazyMan.class) {
                lazyMan = new LazyMan();
            }
        }
        return lazyMan;
    }
}

那么思考下这种方法在并发条件下是线程安全的吗?
答案不安全!

思考下面的场景:
1、线程A进入newInstance(),判断为空,拿到锁
2、线程B进入newInstance(),判断为空,发现锁被占有,等待
3、线程A new完对象后释放掉锁
4、线程B往下执行拿到锁,new对象

此时也是new了两个对象,也破坏了单例

测试:
同样跑10个线程

site.kexing.lock.LazyMan@37bafe8f
site.kexing.lock.LazyMan@1cd6fdf0
site.kexing.lock.LazyMan@27566eaf
site.kexing.lock.LazyMan@56b19245
site.kexing.lock.LazyMan@6b92174a
site.kexing.lock.LazyMan@5c98f75b
site.kexing.lock.LazyMan@73c1f984
site.kexing.lock.LazyMan@58ae7869
site.kexing.lock.LazyMan@4686939d
site.kexing.lock.LazyMan@1646a158

DCL懒汉式(双重检测锁)

public class LazyMan {
   	private static volatile LazyMan lazyMan = null;
    private LazyMan() {
    }
 
    public static LazyMan newInstance() {
        if (lazyMan == null) {
            synchronized(LazyMan.class) {
            	if(lazyMan == null){
                    lazyMan = new LazyMan();
                }
            }
        }
        return lazyMan;
    }
}

为什么对象要加volatile

首先聊聊volatile有哪些特性:

  • 可见性
  • 非原子性
  • 禁止指令重排

我们来看看new LazyMan()底层到底做了什么?

  1. lazyMan 分配内存空间
  2. 调用构造方法初始化lazyMan
  3. lazyMan指向分配的内存空间,此时的lazyMan才不为null

CPU为了提高程序编译后指令的效率,往往会将指令重排,达到CPU认为最优的方案,上面的123可能会被重排为132

如果这个操作底层的指令被重排为132,思考下面场景:

  1. 线程A进入双重检测锁,执行到指令重排后的指令3(注意此时lazyMan已经指向了内存空间,不为null
  2. 线程B进来,发现lazyMan不为null,直接return
  3. 此时return的对象还没有经过指令2构造初始化,也就是一块没有填充值的内存空间

volatile关键字可以避免指令重排,始终按序执行

两层检测并加锁可有效避免线程安全问题,第一层判断主要是为了减少线程争夺资源,如果不为空后则不会去抢夺锁,降低CPU压力

真的安全吗?

别忘了Java中有一种技术叫做反射

通过反射破坏单例

public class LazyMan {
   	private static volatile LazyMan lazyMan = null;
    private LazyMan() {
    }
 
    public static LazyMan newInstance() {
        if (lazyMan == null) {
            synchronized(LazyMan.class) {
            	if(lazyMan == null){
                    lazyMan = new LazyMan();
                }
            }
        }
        return lazyMan;
    }
 	public static void main(String[] args) throws Exception {
        //正常调用方法创建
        LazyMan lazyMan1 = LazyMan.newInstance();
        //拿到class
        Class<LazyMan> lazyManClass = LazyMan.class;
        //拿到构造器
        Constructor<LazyMan> declaredConstructor = lazyManClass.getDeclaredConstructor(null);
        //反射构造器创建
        LazyMan lazyMan = declaredConstructor.newInstance();
        System.out.println(lazyMan);
        System.out.println(lazyMan1);
    }
}

编译运行:

site.kexing.single.LazyMan@b4c966a
site.kexing.single.LazyMan@2f4d3709

通过反射拿到构造器创建了两个实例对象

解决方案,信号量法:

这种方法不论是通过反射还是调用提供的方法只能构造出一个实例!

public class LazyMan {
	private static Boolean kexing = false;
   	private static volatile LazyMan lazyMan = null;
    
    private LazyMan() {
    	synchronized (LazyMan.class){
            if(kexing == false){
                kexing = true;
            }else {
                throw new RuntimeException("请不要试图使用反射破坏单例");
            }
        }
    }
 
 	//双重检测锁
    public static LazyMan newInstance() {
        if (lazyMan == null) {
            synchronized(LazyMan.class) {
            	if(lazyMan == null){
                    lazyMan = new LazyMan();
                }
            }
        }
        return lazyMan;
    }
}

只要是第一次创建实例,信号量kexing 置为true,随后进来的都会走else抛出异常

同样的,执行上面同样的main方法进行测试:

Exception in thread "main" java.lang.reflect.InvocationTargetException
	at java.base/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance0(Native Method)
	at java.base/jdk.internal.reflect.NativeConstructorAccessorImpl.newInstance(NativeConstructorAccessorImpl.java:62)
	at java.base/jdk.internal.reflect.DelegatingConstructorAccessorImpl.newInstance(DelegatingConstructorAccessorImpl.java:45)
	at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:500)
	at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:481)
	at site.kexing.single.LazyMan.main(LazyMan.java:54)
Caused by: java.lang.RuntimeException: 请不要试图使用反射破坏单例
	at site.kexing.single.LazyMan.<init>(LazyMan.java:17)
	... 6 more

nice,有效的阻止了重复创建实例

真的安全吗??

如果狂徒张三通过不正当手段知道了程序是通过这个方案保证单例,那么这个方案也会变得不堪一击,要知道,一个类在反射面前是光着身子的!

如何破解上文的信号量法?
很简单,拿到字节码class对象后通过getDeclaredFields()拿到类的变量域对象数组,遍历一下,field.getName()拿到成员变量名,field.get(field.getName())拿到成员变量的值,通过set(Object obj, Object value)修改,始终保证信号量为true,狂徒张三:就这?

枚举单例(终极方案)

public enum  EnumLazyMan{
    INSTANCE;

    public static EnumLazyMan newInstance() {
        return INSTANCE;
    }
}

狂徒张三:反射试试?

Exception in thread "main" java.lang.IllegalArgumentException: Cannot reflectively create enum objects
	at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:493)
	at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:481)
	at site.kexing.single.EnumLazyMan.main(EnumLazyMan.java:29)

Cannot reflectively create enum objects

张三:…

我们点进源码看看为什么会这样

public T newInstance(Object ... initargs)
        throws InstantiationException, IllegalAccessException,
               IllegalArgumentException, InvocationTargetException
    {
        Class<?> caller = override ? null : Reflection.getCallerClass();
        return newInstanceWithCaller(initargs, !override, caller);
    }

    /* package-private */
    T newInstanceWithCaller(Object[] args, boolean checkAccess, Class<?> caller)
        throws InstantiationException, IllegalAccessException,
               InvocationTargetException
    {
        if (checkAccess)
            checkAccess(caller, clazz, clazz, modifiers);

        if ((clazz.getModifiers() & Modifier.ENUM) != 0)
            throw new IllegalArgumentException("Cannot reflectively create enum objects");

        ConstructorAccessor ca = constructorAccessor;   // read volatile
        if (ca == null) {
            ca = acquireConstructorAccessor();
        }
        @SuppressWarnings("unchecked")
        T inst = (T) ca.newInstance(args);
        return inst;
    }

抽出重点:

if ((clazz.getModifiers() & Modifier.ENUM) != 0)
            throw new IllegalArgumentException("Cannot reflectively create enum objects");

在Java圣经《Effective Java》中,Joshua Bloch这么说:

A single-element enum type is often the best way to implement a singleton.
枚举是一般情况下最好的Java实现单例的方法

It is more concise, provides the serialization machinery for free, and provides an ironclad guarantee against multiple instantiation, even in the face of sophisticated serialization or reflection attacks.
枚举单例可以有效防御两种破坏单例(使单例产生多个实例)的行为:反射攻击与序列化攻击

上一篇:网站的site:domain:inurl:分别是什么意思,如何更好的运用它们


下一篇:maven-5/10