设计模式之单例模式

92 阅读5分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第 4 天,点击查看活动详情

设计模式中最简单、面试的时候问,大部分人也只会说的那一个就是:单例模式。

概念

单例模式(Singleton):保证一个类仅有一个实例,并提供一个访问它的全局访问点。

单例模式的类图可以说是所有模式的类图最简单的,事实上,它的类图上只有一个类。

案例场景

虽然上面说,单例模式是所有模式中最简单的,但是使用场景可不少。

  • 数据库的连接池不会反复创建
  • Spring 中一个单例模式 bean 的生成和使用
  • 平常代码中需要设置全局的一些属性保存

代码实战

简单版本(懒汉模式)

/**
* 简单版本(懒汉模式,线程不安全)
*/
public class SinglTonDemo {
    private static SinglTonDemo singlTonDemo;

    private SinglTonDemo() {
    }

    public static SinglTonDemo getSinglTonDemo() {
        if (singlTonDemo == null) {
            singlTonDemo = new SinglTonDemo();
        }
        return singlTonDemo;
    }
}

//测试方法
public class TestController {
    @Test
    public void test_SingleTon() {
        //简单版本(懒汉模式,线程不安全)
        SinglTonDemo s1 = SinglTonDemo.getSinglTonDemo();
        SinglTonDemo s2 = SinglTonDemo.getSinglTonDemo();
        System.out.println(s1 == s2);
    }
}

说明

  • private 修饰构造函数,可以防止客户端随意创建实例
  • 带有 static 关键字属性再每一个类中都是唯一的(类级别的,在类初始化的时候就会加载)
  • 已有有了 private 修饰构造函数,那必须有 static 方法去获取这个类的实例

缺陷

此方式满足在使用的时候才实例化对象,所以叫懒汉模式。如果仅在单线程环境,这是没问题。如果多线程就 GG 了。比如下面的代码,可能会输出多个实例

但是尽管在多线程环境下,仍然可能会返回同一个实例,但是代码需要的是确定性。所以需要改造。

@Test
    public void test_SingleTonMulThread() {
        //简单版本(懒汉模式,线程不安全)
        Set<String> set = new HashSet<String>();
        for (int i = 0; i < 100; i++) {
            new Thread() {
                @Override
                public void run() {
                    SinglTonDemo s = SinglTonDemo.getSinglTonDemo();
                    set.add(s.toString());
                }
            }.start();
        }

        for (String s : set) {
            System.out.println("实例化的对象:" + s);
        }
    }

简单版本改造V1

基于上面的懒汉模式,在多线程情况下会出现问题,那我们就针对多线程补救一下。一般想到的是:synchronized 关键字

/**
* 简单版本的改造,使得多线程环境下也能成功
*/
public class SinglTonDemoSuper {
    private static SinglTonDemoSuper singleTonDemo;

    private SinglTonDemoSuper() {
    }

    public static SinglTonDemoSuper getSingleTon() {
        synchronized (SinglTonDemoSuper.class) {
            if (singleTonDemo == null) {
                singleTonDemo = new SinglTonDemoSuper();
            }
        }
        return singleTonDemo;
    }
}

此时再使用上面的多线程代码对其生成对象,永远都只会返回一个。

缺陷

这样实现确实可以实现单例模式,但是没有考虑效率问题。因为我们创建一个实例后,其他操作都是读取而已。但是上面的代码还是强制串行操作。

简单版本改造V2(double check)

基于上面的代码,我们不希望每次获取实例的时候,都要加锁等待一次。所以继续改造

/**
* 双重检查
*/
public class SingleTonDoubleCheck {
    private static SingleTonDoubleCheck singleTonDemo;

    private SingleTonDoubleCheck() {
    }

    public static SingleTonDoubleCheck getSingleTon() {
        if (singleTonDemo == null) { //基于上面的代码在整个 synchronized 块外面加上一个判空
            synchronized (SingleTonDoubleCheck.class) {
                if (singleTonDemo == null) {
                    singleTonDemo = new SingleTonDoubleCheck();
                }
            }
        }
        return singleTonDemo;
    }
}

double check :顾名思义就是两次检查。

  • 第一个检查:如果实例已经存在,不需要同步了,就直接返回(防止每个线程获取实例的时候,都要等待)
  • 第二个检查:被同步的线程,有一个创建了实例,另一个就不用了(防止刚好两个线程同时第一个判空,造成两次 new)

double check 优化

参考:coolshell.cn/articles/26…

深究双重加锁,也是有缺陷的。从 JVM 层面上讲:创建一个新的对象并非原子性操作。 对于下面的语句,JVM 做了三件事:

singleton = new SingleTon();

  1. 给 singleton 分配内存
  2. 调用 Singleton 的构造函数来初始化成员变量,形成实例
  3. 将 singleton 对象指向分配的内存空间(执行完这步 singleton才是非 null 了)

但是 JVM 的即时编译器中存在指令重排序的优化。也就是说上面的第二步和第三步的顺序不能保证。如果先执行 3 ,再执行 2 ,此时刚好被线程二抢占了,这是 instance 已经是 非 null 了(但却没有初始化),所以线程二直接返回 instance,然后使用,然后顺利成章的报错。

 private volatile static SingleTonDemo singleTonDemo; //加上 volatile 关键字

只需要在变量前面加一个 volatile 关键字即可。(防止指令重排)

  • 这个变量不会在多个线程中存在副本,直接从内存中读取
  • 禁止指令重排优化。 也就是说,在 volatile 变量的赋值操作后面会有一个内存屏障(生成的汇编代码上),读操作不会被重排序到内存屏障之前。

推荐实现的方式

简化版本(内部类)

public class SingleTonDemo {
    private SingleTonDemo(){}

    private static class SingletonHolder{
        private static final SingleTonDemo INSTANCE = new SingleTonDemo();
    }

    public static final SingleTonDemo getInstance() {
        return SingletonHolder.INSTANCE;
    }
}
  • 一个类的静态属性只会在第一次加载类时初始化,这是 JVM 帮我们保证的,所以无需担心并发问题。静态变量只初始化一次,所以 singleton 仍然是单例的。
  • 由于 SingletonHolder 是私有的,除了 getInstance() 之外没有办法访问它,因此它只有在getInstance() 被调用时才会真正创建;同时读取实例的时候不会进行同步,没有性能缺陷;也不依赖 JDK 版本。

推荐实现的方式

小结

到这里,单例模式算是差不多了。以上优化后的形式保证了几点:

1.Singleton最多只有一个实例,在不考虑反射强行突破访问限制的情况下。

2.保证了并发访问的情况下,不会发生由于并发而产生多个实例。

3.保证了并发访问的情况下,不会由于初始化动作未完全完成而造成使用了尚未正确初始化的实例。


额外学习-反射

上面提到了反射情况。

暴力反射下:上面的都不存在单例。因为可以通过反射暴力调用私有构造函数。而私有构造函数是实现单例的一个强有力保证

// 反射获取的 Class 是上面简化版本的类
public class SingleTonTest {

    public static void main(String[] args) {
        Set<String> set = new HashSet<String>();
        for (int i = 0; i < 10; i++) {
            try {
                //你的类路径
                Class c = Class.forName("xxxx");
                Constructor constructor = c.getDeclaredConstructor();
                constructor.setAccessible(true);
                Object obj = constructor.newInstance();
                set.add(obj.toString());
            } catch (Exception e) {
            }
        }

        for (String s : set) {
            System.out.println("打印:" + s);
        }
    }
}

上面的反射案例,是通过 Constructor 去调用单例类的私有构造函数。(相当于 new XXX();所以每次调用都会生成不同的对象)。

tip:优雅版本+防反射

最新版的《Effctive Java》推荐的方式:

默认枚举实例的创建是线程安全的,所以不需要担心线程安全的问题。但是在枚举中的其他任何方法的线程安全由程序员自己负责。还有防止上面的通过反射机制调用私用构造器。

//定义一个单元素的枚举类
public enum InstanceDemo {
   INSTANCE;
}

//暴力反射调用枚举类
public class SingleTonTest {
    public static void main(String[] args) throws Exception {
        InstanceDemo s = InstanceDemo.INSTANCE;
        Constructor<InstanceDemo> constructor = InstanceDemo.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        InstanceDemo obj = constructor.newInstance();
        System.out.println(obj == s);
    }
}


//运行结果:没有这个方法(空的构造函数)
Exception in thread "main" java.lang.NoSuchMethodException: com.practice.test.gooddemo.InstanceDemo.<init>()
	at java.lang.Class.getConstructor0(Class.java:3082)
	at java.lang.Class.getDeclaredConstructor(Class.java:2178)
	at com.practice.test.gooddemo.SingleTonTest.main(SingleTonTest.java:13)
//通过反射调用方法,只能指定调用,比如获取构造函数,也只能完全匹配方法、参数。

通过 Enum 类,查看到底是什么构造函数,在IDEA中找到 Enum 类,然后 ctrl+f12 :展示此类所有属性方法

public class SingleTonTest {

    public static void main(String[] args) throws Exception {
        InstanceDemo s = InstanceDemo.INSTANCE;
        Constructor<InstanceDemo> constructor = InstanceDemo.class.getDeclaredConstructor(String.class,int.class);
        constructor.setAccessible(true);
        //上面会获取到构造方法,但是下面实例化会报错
        InstanceDemo obj = constructor.newInstance();
        System.out.println(obj == s);
    }
}


//结果:
Exception in thread "main" java.lang.IllegalArgumentException: Cannot reflectively create enum objects
	at java.lang.reflect.Constructor.newInstance(Constructor.java:417)
	at com.practice.test.gooddemo.SingleTonTest.main(SingleTonTest.java:16)
    
//debug 进去就一目了然了:是放射类型就抛异常
     if ((clazz.getModifiers() & Modifier.ENUM) != 0)
            throw new IllegalArgumentException("Cannot reflectively create enum objects");

因此枚举是防反射的。对于序列化与反序列化,也可以简单测试:

public class SingleTonTest {
	//序列化-反序列化也没问题的
    public static void main(String[] args) {
        InstanceDemo s = InstanceDemo.INSTANCE;
        byte[] serialize = SerializationUtils.serialize(s);
        Object deserialize = SerializationUtils.deserialize(serialize);
        System.out.println(s == deserialize); //true
    }
}

so~,枚举是实现单例的首选。

总结

  • 双重检验
  • 静态内部类
  • 枚举

这三个都能实现单例,但是首推枚举,不仅实现简介,还可以防反射。

参考

coolshell.cn/articles/26…