单例模式(Java实践)

在计算机软件中,单例的定义是在整个程序生命周期里,一个单例类的实例只能存在一个

Java 应用里使用单例的例子

最佳实践(推荐)

在Joshua Bloch, Effective Java 2nd Edition p.18中给出了单例的最佳实践,使用枚举!

// best practice
public enum Singleton {
  INSTANCE;
}

这种方式对于从C转Java的同学来说估计很难接受,我自己刚才始也难以接受,但是Java中一切都是对象,枚举也是对象!所以枚举对象里面是可以有成员变量与方法的!枚举单例的好处是:无法通过反射与序列化创建多个实例。项目中正真使用这种方式做单例的很少,原因有两点

  • 枚举单例早期的代码里面没人使用,Effective Java 2nd Edition出版之前几乎没有人知道枚举单例,在老代码中无法找到枚举单例的参照
  • 目前Java Web开发对象管理都交给DI容器了,DI容器可以保证项目中对象是单例,大家直接手撸单例的机会也变少了

单例模式非常简单,如果要学院派一点,深究单例的注意点的话,需要注意的是如下几点:

  • 获取实例是否线程安全
  • 是否需要延迟加载
  • 是否反射安全(enum单例由JVM保证,其他单例可以通过在构造函数中添加检测代码保证)
  • 是否序列化与反序列化安全 (enum单例由JVM保证)

我们先从一个反例开始,一起看一下单例模式在Java中是如何演进的。

教学版(错误示例)

@NotThreadSafe
public final class Singleton {
    private static Singleton singleton = null; // static 变量保证类内唯一
    private Singleton() { // private 构造函数防止外部调用
        System.out.println("create a Singleton instance!");
    }
    public static Singleton getInstance() {
        if (singleton== null) { // 存在多线程问题
            singleton= new Singleton();
        }
        return singleton;
    }
}

假定程序的运行环境是单线程的,这段代码如果用做单例模式的教学,单例的主要思想还是描述清楚了。但是真实的软件运行环境是很恶劣的,生产环境还是不可以使用这样的代码的,一旦多个线程在同一时间点调用getInstance()方法,将创建多个Singleton实例!

测试代码如下

public class SingletonTest {

    @Test
    public void test() {
        int threadsNum = 4;
        long currentTime = System.currentTimeMillis();
        ThreadPoolExecutor executor = buildFixedThreadPool(threadsNum);
        for (int i = 0; i < threadsNum; i++) {
            executor.execute(() -> {
                keepCheckingUntil(currentTime + 100);
                Singleton.getInstance();
            });
        }
        executor.shutdown();
        waitExecutorTermination(executor);
    }

    private void waitExecutorTermination(ThreadPoolExecutor executor) {
        try {
            executor.awaitTermination(1, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            System.out.println(e.getStackTrace());
        }
    }

    private ThreadPoolExecutor buildFixedThreadPool(int poolSize) {
        return new ThreadPoolExecutor(poolSize, poolSize, 0L, TimeUnit.MILLISECONDS,
            new ArrayBlockingQueue(poolSize));
    }

    private void keepCheckingUntil(long timestamp) {
        while(System.currentTimeMillis() < timestamp);
    }
}

运行测试得到如下运行结果

create a Singleton instance!
create a Singleton instance!
create a Singleton instance!
create a Singleton instance!

在同一时间点创建了4个实例

线程安全版(不推荐)

public final class Singleton {
    private static Singleton singleton = null; // static 变量保证类内唯一
    private Singleton() { // private 构造函数防止外部调用
        System.out.println("create a Singleton instance!");
    }
    public static synchronized Singleton getInstance() { //存在性能问题
        if (singleton== null) {
            singleton= new Singleton();
        }
        return singleton;
    }
}

这个版本直接在方法上添加了synchronized关键字,再运行上面的单元测试能够看到正确的日志输出,只有一个实例被创建。但是直接在方法上加synchronized关键字实在太粗暴了,这样在程序整个运行期间内,都要通过获得锁的方式获取Singleton实例,在高并发的场景下调用getInstance()会使得线程串行执行,并且频繁的获得锁与释放锁操作也很多余。在Singleton实例已经被创建之后完全没有必要加锁了,直接返回创建好的实例就好了,于是又衍生出了只在创建Singleton实例的时候才进行加锁的方式

双重检测版本(不推荐)

public final class Singleton {

    private static Singleton singleton = null;

    private Singleton() {
        System.out.println("create a Singleton instance!");
    }

    public static Singleton getInstance() {
        if (singleton == null) { // 第一次检测,实例创建好之后直接返回
            synchronized (Singleton.class) { //最小力度加锁
                if (singleton == null) { // 第二次检测,防止多线程环境多次创建
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

双重检测版本的思想就是将锁的范围缩小,只在第一次创建实例的时候进行加锁,来获得更好的性能。有没有觉得上面的代码太繁琐了,我只想要一个单例但是却要写这么多代码,有没有简单一点的方式?

静态常量(推荐)

public final class Singleton {

    private Singleton() {
        System.out.println("create a Singleton instance!");
    }

    private static final Singleton INSTANCE = new Singleton();

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

这种方式因为使用了静态常量,这个类一旦被使用,这个类就会被JVM载入内存,并且在特定条件下会对这个类进行初始化,即使不调用getInstance方法,INSTANCE实例也已经被初始化了,是一个比较推荐的方式,通过提前初始化的方式,保证了getInstance方法是线程安全的,在项目代码中可能会找到在大量的这种单例。这里引出了一个问题,JVM什么时候会对对象进行初始化?这里引用一下另外一片博客的内容,详细内容也可以参考《深入理解Java虚拟机》

什么情况下需要开始类加载过程的第一个阶段:"加载"。虚拟机规范中并没强行约束,这点可以交给虚拟机的的具体实现自由把握,但是对于初始化阶段虚拟机规范是严格规定了如下几种情况,如果类未初始化会对类进行初始化。
1、创建类的实例
2、访问类的静态变量(除常量【被final修辞的静态变量】原因:常量一种特殊的变量,因为编译器把他们当作值(value)而不是域(field)来对待。如果你的代码中用到了常变量(constant variable),编译器并不会生成字节码来从对象中载入域的值,而是直接把这个值插入到字节码中。这是一种很有用的优化,但是如果你需要改变final域的值那么每一块用到那个域的代码都需要重新编译。
3、访问类的静态方法
4、反射如(Class.forName("my.xyz.Test"))
5、当初始化一个类时,发现其父类还未初始化,则先出发父类的初始化
6、虚拟机启动时,定义了main()方法的那个类先初始化
以上情况称为称对一个类进行“主动引用”,除此种情况之外,均不会触发类的初始化,称为“被动引用”
接口的加载过程与类的加载过程稍有不同。接口中不能使用static{}块。当一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有真正在使用到父接口时(例如引用接口中定义的常量)才会初始化。
被动引用例子
1、子类调用父类的静态变量,子类不会被初始化。只有父类被初始化。。对于静态字段,只有直接定义这个字段的类才会被初始化.
2、通过数组定义来引用类,不会触发类的初始化
3、 访问类的常量,不会初始化类

那么有没有只有在调用getInstance方法的时候才初始化我们的单例实例的简单方法呢?答案就是内部静态类和枚举单例

内部静态类(推荐)

public final class Singleton {

    private Singleton() {
        System.out.println("create a Singleton instance!");
    }

    static public Singleton getInstance() {
        return HelperHolder.INSTANCE;
    }

    private static class HelperHolder {
        private static final Singleton INSTANCE = new Singleton();
    }
}

如果非要做到懒加载,在只在调用getInstance方法的时候才创建单例实例,内部静态类方式可以满足。因为内部静态类其实也是一个类,只不过这个类的访问权限受限。JVM加载Singleton类也不会导致HelperHolder类被加载,HelperHolder类只有在getInstance方法被调用时才会被载入内存。

关于懒加载

懒加载将对象初始化工作推迟到程序运行期间,好处是缩短了程序的启动时间。弊端也很明显,一旦对象无法初始化,这种异常状态怎么处理?此时程序已经处于运行状态了,难以人工干预,一旦没有正确处理异常,生产环境将会产生大量的错误。如果不使用懒加载,程序启动时就出现异常,阻止程序启动,防止生产环境出现问题,早发现早治疗!

结束语

关于Java单例的最佳选择当然是枚举了,JVM的机制就已经保证了枚举单例的安全性。静态常量与内部静态类也是可以使用的,因为在绝大多数日常开发中,对于单例安全要求没有那么苛刻,并且老代码里面应该随处可见静态常量与内部静态类的单例设计。

但是枚举单例简直找不出什么弊端,感觉大家可以在你今后项目中尝试使用枚举作为单例,简单高效,又不容易出错。

参考文档

  1. CoolShell.深入浅出单实例SINGLETON设计模式
  2. Java类加载机制
  3. 枚举类实现原理
  4. Java-Design-Pattern
posted @ 2020-04-05 15:57  migoo  阅读(340)  评论(0编辑  收藏  举报