java设计模式————单例模式详解之解决指令重排序,解决反射攻击,美团面试题

单例模式

上一章的工厂模式已经详细的说明了设计模式在java各种的体现和好处,是不是意犹未尽呢,好,那么现在我们再来分析一下另一种设计模式,单例模式

单例模式的定义:

使用场景:确保任何情况下都绝对只有一个实例,那么在代码中有什么呢?

ServletContext、ServletConfig(单个配置文件)、ApplicationContext、DBPool等。

1.单例模式饿汉式(早期形式),随着项目初始化的时候就创建,但是缺点也显而易见,耗费内存。

/**
 * @Author Darker
 * @Descrption
 * 饿汉式单例,不管用不用,初始化的时候就创建
 * 缺点:耗费内存,以空间换时间,故不存在线程安全问题
 * @Date : Created in 11:31 2020-3-10
 */
public class HungryManSingleTon {

    //初始化就创建
    private static final HungryManSingleTon hungryManSingleTon = new HungryManSingleTon();

    /**
     * 还有一种写法
     *  private static final HungryManSingleTon hungryManSingleTon;
     *  static{
     *      hungryManSingleTon = new HungryManSingleTon();
     *  }
     */

    //构造方法私有
    private HungryManSingleTon(){}

    public static HungryManSingleTon getInstance(){
        return hungryManSingleTon;
    }

    //测试
    public static void main(String[] args) {
        HungryManSingleTon singleTon1 = HungryManSingleTon.getInstance();
        HungryManSingleTon singleTon2 = HungryManSingleTon.getInstance();
        System.out.println("对象1的hashcode"+singleTon1.hashCode()+""+"对象1的hashcode"+singleTon2.hashCode());
    }
}

2.单例模式懒汉式(饿汉式的升级),缺点,多线程时无法保证只创建一个实例。

/**
 * @Author Darker
 * @Descrption
 * 懒汉式单例,等被调用的时候再来创建
 * 优点:优化了内存应用,是饿汉的升级
 * 缺点:存在多线程中被创建多个,以时间换空间,在多线程环境下存在风险
 * @Date : Created in 11:40 2020-3-10
 */
public class SlackerSingleTon {

    //此时不能加final,不然无法改变值
    //加上volatile,禁止指令重排
    private static volatile SlackerSingleTon slackerSingleTon = null;

    //构造方法私有
    private SlackerSingleTon(){}

    public static SlackerSingleTon getInstance(){
        //避免多次重复被覆盖
        if(slackerSingleTon == null){
            //因为现在计算机性能太高,给1秒的停留时间
           try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            slackerSingleTon = new SlackerSingleTon();
        }
        return slackerSingleTon;
    }
    //测试
    public static void main(String[] args) {
        for (int i=0,len=3;i<len;i++){
            new Thread(() -> {
                System.out.println(Thread.currentThread().getName()+""+SlackerSingleTon.getInstance().hashCode());
            }).start();
        }
    }
}

3. 双检查锁单例(为了保证多线程中也是单例)。

那么既然发生了线城不安全好啊,那么我就给他锁一下不就是了,那么我们给上面的方法变成

public synchronized static SlackerSingleTon getInstance()

是不是就解决了这个不安全问题呢。

果然,真得解决了这个问题,但是有些人还是吹毛求疵不是,它觉得拿着synchronized这种重量级锁来锁整个方法是不是效率太低了,举个例子。

如果这个方法在规定位置多了400行代码,每次锁都得锁整个方法无疑降低了很大的效率,那么就想到我们可以只锁赋值这一块啊!

然后代码就变成了这个样子,老铁,没毛病啊,把上面的业务代码都忽略了,肯定效率ok了,但是运行你会发现,貌似并没有锁上啊。 

这是为什么呢?那是因为当我们判断完if不为null的时候,此时拿锁,很多线程已经通过if空的判断并且已经等待在了这个锁前面,而不是if外面,所以没锁上啊!

那既然如此,我们再给这个锁里面加个为null判断吧。

public static SlackerSingleTon getInstance(){
        //300行业务代码
        //避免多次重复被覆盖
        if(slackerSingleTon == null){
            //100行业务代码
            synchronized (SlackerSingleTon.class){
                //再做一次空判断
                if(slackerSingleTon == null){
                    //因为现在计算机性能太高,给1秒的停留时间
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    slackerSingleTon = new SlackerSingleTon();
                }
            }
        }
        return slackerSingleTon;
    }

ok了,这样即忽略了业务代码,也保证了效率和线程安全,nice,这就是双检锁单例了。

但是!但是!但是!!!!重要的事情说3遍。 

你以为这样就对了嘛,太天真了不是,你永远会发现学的越多,不懂的就越多,为什么呢!事情是这样的,一个小伙伴在美团面试,面试官让他说一下单例,并且讲到了双检锁,(很简单嘛,单例我已经滚瓜烂熟了,小伙伴心里想到),但是万万没想到,面试官问的是,这个成员变量是否需要用volatie呢?(纳尼,volatie,什么鬼,小伙伴心里凉了半截,只知道volatie是保证了原子可见性,这里修饰什么情况,所以一首凉凉送给我们的小伙伴)。

那么这到底是一个什么样的问题呢?

首先本人去随便点开了几个人博客的单例模式讲解,发现一个现象。

 有的人单例变量前加了volatie,而有的人没有加上,这是为什么呢,然后基本写上的人都会注明解决指令重排序问题,好那么问题来了,什么是指令重排。

什么是指令重排序?

首先我们new一个对象来看看这个对象在翻译成字节码的时候是什么样子(不知道为什么翻译成字节码的同学,必须去补一补基础了),实际上很简单,它就是这5步来构成的。

现在我们来举个例子:

我有个源码

Class T{

 int m = 8;

}

T t = new T();

 那么我们的汇编码可以这样分成。

第一步:new #2  <T>

这里就相当于c++里面的new,其实就是分配内存的意思,就是直接在内存中new出来这个对象的内存布局。

第二步:dup(不谈,和我们说的问题没影响,有兴趣的同学自行了解)

第三步:invokespecial #3 <T.<init>>

调用类里面的构造方法。

第四步:astore_1

这里就是给栈上的t建立关联,也就是栈中的t指向堆中的这块内存地址。

第五步:return

返回你这个对象。

看完这你会发现,我们java只写了一个new,汇编码确是分五步来完成的,当它走在第一步的时候只是分配了一个内存地址,此时没有赋值,也就是说此时i=0,只是给了一个初始化的值,一直到第三步调用构造方法才把这个10赋值给这个成员变量i,也就意味着我们new一个对象的时候它会有一个中间态,我们把它叫做半初始化。

那么如果这里发生了重排序,对象的指针指向了一个半初始化状态的对象,也就是下图中的t指向内存中m为0,然后我们线程2进来发现你的对象已经有了指向关系了(虽然只是指向初始化值),但它依然会拿来直接使用,这样会出现什么现象呢?简单来说就是你本来付了100块钱,此时发生了指令重排序,我还没有执行构造方法,还没赋值,线程2过来就把这个引用拿走了,那么你就等于还是只付了0元,亏大了啊老铁!

 这和我们单例没半毛钱关系啊!和单线程中单例是没什么关系,但在多线程中,如果这里发生了指令重排序,某个线程过来直接拿到我们半初始化状态的对象来用(半初始化状态的对象是有地址的),此时不为null,直接就拿来用,那么是不是里面的值啥都没有,那么系统就出问题了不是?

所以这里必须加上volatie,禁止指令重排,这里可以参考一波 java并发编程 ||深入解析volatie,可见性与内存屏障

 

4. 静态内部类单例

到上面的双检锁你是不是觉得牛逼了,但后来为了提高效率,大家又想出了一种方法,通过利用内部类的特性,jvm的底层执行逻辑,完美的避免了线程安全问题。

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

首先看看代码

/**
 * @Author Darker
 * @Descrption
 * 静态内部类单例
 * 优点:
 * 全程没有用到sysnchronized
 * 性能最优的一种写法
 * 缺点:
 * 可以被反射攻击,虽然构造方法私有,但反射可以直接获得构造方法
 * @Date : Created in 15:02 2020-3-10
 */
public class ClassSingleTon {
    //私有化构造方法
    private ClassSingleTon(){}

    //可以理解为懒汉式单例
    //LazyHolder里面的逻辑需要等到外部的方法调用时才会执行
    //巧妙的利用了内部类的特性
    //jvm底层执行逻辑,完美避免了线程安全问题
    public static ClassSingleTon getInstance(){
        return LazyHolder.Lazy;
    }
    
    //静态内部类
    private static class LazyHolder{
        private static final ClassSingleTon Lazy = new ClassSingleTon();
    }
    
}

那么为什么这样做,不仅能确保线程安全,也能保证单例的唯一性,同时也延迟了单例的实例化。

首先我们来了解下类的加载机制,这是我在其它大佬博客中找到的一段话(如下图),告诉我们java机只有在5种场景下才会初始化一个类,那我们第一次进来调用这个类的静态方法getInstance()的时候,这个类ClassSingleTon才会开始初始化,那么和懒汉式不同的是这个getInstance()方法里面并不是new关键字,而是直接返回静态内部类的Lazy,所以不会再去初始化这个类,也就是说,初始化始终只会被调用一次,这样就达到了懒汉式的效果。

那又是怎么保证线程安全的呢,我又在大佬博客种找到了一段话(如下图),也就是说当一个类被初始化的时候只有一个线程会去执行,其它线程都处在阻塞状态,那么这样就确保了线程安全。

那么这样一看,完美啊,又是懒汉,又是线程安全 ,但还有一个问题,因为是静态内部类初始化,所以往往是无法传参数的,比如传context这种,所以到底用双检锁单例还是用静态内部类单例往往需要根据需求来考虑。

但是!但是!但是!!!我非要用反射来获取你的单例你怎么办呢?

/**
 * @Author Darker
 * @Descrption
 * @Date : Created in 15:59 2020-3-10
 */
public class LazyInnerClassSingleTonTest {
    public static void main(String[] args) {
        Class<?> clazz = ClassSingleTon.class;
        try {
            Constructor<?> clazzDeclaredMethod = clazz.getDeclaredConstructor(null);
            clazzDeclaredMethod.setAccessible(true);//强行授权
            Object o1 = clazzDeclaredMethod.newInstance();
            ClassSingleTon o2 = ClassSingleTon.getInstance();
            System.out.println(o1+""+o2 );

        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

想不到吧,小样,我就不用你的方法,我反射来获取你的单例,你能咋办,这样单例又不是单例了吧。

同样的我们也有解决办法不是。

public class ClassSingleTon {
    //私有化构造方法
    private ClassSingleTon(){
        if(LazyHolder.Lazy != null){
            throw new RuntimeException("不允许创建多个实例,兄弟老老实实按我的规则来搞");
        }
    }

    //可以理解为懒汉式单例
    //LazyHolder里面的逻辑需要等到外部的方法调用时才会执行
    //巧妙的利用了内部类的特性
    //jvm底层执行逻辑,完美避免了线程安全问题
    public static ClassSingleTon getInstance(){
        return LazyHolder.Lazy;
    }

    private static class LazyHolder{
        private static final ClassSingleTon Lazy = new ClassSingleTon();
    }

}

 

ok,搞定了,这个就是最后的静态内部类单例了。

当然还有人说可以反序列化,先把这个单例对象序列化到磁盘,然后又从磁盘读取回来这个对象,最后的地址不相等不是,对,确实可以这样破坏,但这样就过分了吧,解决方法当然也有,就是重写里面的readResolve方法,直接返回你的单例(但实际上还是覆盖一遍),有兴趣的朋友可以自己去研究一下,这里我就不搞了(手动滑稽)。

5.注测式单例--枚举单例,容器单例

枚举单例

就因为有这么多奇奇怪怪的家伙又是反射又是序列化来破坏单例,那么我们自然要想办法解决不是,那么就想到了枚举单例。枚举在java中与普通类一样,都能拥有字段与方法,而且枚举实例创建是线程安全的,在任何情况下,它都是一个单例。

/**
 * @Author Darker
 * @Descrption
 * @Date : Created in 16:30 2020-3-10
 */
public enum EnumSingleTon {
    INSTANCE;

    private Object data;

    public Object getData() {
        return data;
    }

    public void method(){
        //TODO
    }

    public static EnumSingleTon getInstance(){return INSTANCE;}
}

容器单例(重点,spring用的)

首先上代码

/**
 * @Author Darker
 * @Descrption
 * @Date : Created in 16:51 2020-3-10
 */
public class iocBean {
}

/**
 * @Author Darker
 * @Descrption
 * @Date : Created in 16:46 2020-3-10
 */
public class ContainerSingleTon {
    private ContainerSingleTon(){}

    private static Map<String,Object> ioc = new ConcurrentHashMap<>();

    public static Object getBean(String className){
        
            if(!ioc.containsKey(className)){
                Object obj = null;
                try {
                    Thread.sleep(1000);
                    obj = Class.forName(className).newInstance();
                    ioc.put(className,obj);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                return obj;
            }
       
        return ioc.get(className);
    }
}

看看这个代码你会发现,这个容器单例模式其实就是多了一个hashMap用来存放实体对象,然后通过一个简单工厂模式来给你提供对象,当然也利用了hashMap可以的唯一性等,所形成的一个懒汉式单例,测试一下。

好,单例成功,那我们再来看看是不是线程安全呢?

显然,结果是不安全的,那么问题来了,spring明明是线程安全的不是,所以我们马上意识到肯定加锁了,于是。

public static Object getBean(String className){
        synchronized (ioc){
            if(!ioc.containsKey(className)){
                Object obj = null;
                try {
                    Thread.sleep(1000);
                    obj = Class.forName(className).newInstance();
                    ioc.put(className,obj);
                } catch (Exception e) {
                    e.printStackTrace();
                }
                return obj;
            }
        }
        return ioc.get(className);
    }

 

成功了,这就是spring种用到的容器式单例。 当然,spring里面还有很多其它的东西,有兴趣的可以自己去看看,但其中的思想大概就类似这样了。
      

单例模式的优点:

1.在内存中只有一个实例,减少了内存的开销。

2.可以避免对资源的多重占用。

3.设置全局访问点,严格控制访问。

单例模式的缺点:

1.没有接口,扩展困难。

2.如果要扩展单例对象,只有修改代码,没有其它途径,不符合开闭原则。

 

 

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值