创建型设计模式 之 正确使用单例模式

1定义

单例模式(Singleton Pattern)属于创建型设计模式之一,它应该算上是我们日常开发里最常用到的设计模式之一。其使用上就是让在当前进程下指定的类只被初始化一次,并且一般会一直保存于内存中,保证了全局对象的唯一性。比如线程池、缓存、日志等等都常常被设计成单例模式来使用。

1.1 那单例和静态类的区别

常有人拿单例和静态类作比较和混淆他们的使用场景,因为它们都是用于全局的访问。其实它们的区分还是很简单的:

静态类:不具备面向对象的特性,不支持延时加载,一般用于工具类,静态的绑定是在编译期进行的,所以其效率也高。

单例:具备面向对象的特性,如继承父类、实现接口、多态等,支持延时加载和随时释放,可维护类对象状态信息。

2 实现方式

一般单例模式一般会将构造方法置为private,其实现方式会分为立即加载和延时加载两种方式,或者有人叫它们为“饿汉式单例”和“懒汉式单例”。

2.1 立即加载

立即加载的单例,一般是使用静态变量或静态代码块。

静态变量的单例

public class Singleton {
    private static Singleton singleton = new Singleton();
    private Singleton() {
        System.out.println("Singleton init");
    }
    public static Singleton getInstance() {
        return singleton;
    }
    public void doSomething() {
        System.out.println("do something");
    }
}

静态代码块的单例

public class Singleton {
    private static Singleton singleton;
    static {
        singleton = new Singleton();
    }
    private Singleton() {
        System.out.println("Singleton init");
    }
    public static Singleton getInstance() {
        return singleton;
    }
    public void doSomething() {
        System.out.println("do something");
    }
}

调用

Singleton.getInstance().doSomething();

静态变量和静态代码块的本质上是一样的,它们的构造方法都会在类的初始化阶段中执行,而且是线程安全的,所以如果你的代码不需要考虑内存问题且注重安全性,这种单例是最简单实际的。

是不是只要不调用getInstance方法,类就不会初始化,也就不会产生内存使用?

非也,如果一直不调用getInstance方法也有可能触发类的构造方法初始化类,而且类可能早就被执行了加载,类只要加载了就会产生内存使用。因为Java虚拟机的类加载机制   主要有七个阶段:Loading(加载)、verification(验证)、preparation(准备)、resolution(解析)、initialization(初始化)、using(使用)、unloading(卸载)。

载阶段中会进行类二进制字节流获取、创建运行的数据结构和创建类的实例。JVM规范允许类加载器在预料某个类将要被使用时就预先加载它,而且在调用一个类的.class和某个方法的返回类型是某类的话,那么该类一定会被加载。比如下方,MyClass1和MyClass2都会被加载。

public class Main {
    public static void main(String[] args){
        System.out.println("hello word: " + MyClass1.class);
    }
    public MyClass2 test() {
        return null;
    }
}

我们可以在运行配置中将虚拟机参数加上:-verbose:class ,便能看到有如下输出情况:

初始化阶段时会执行类中静态变量的赋值和静态代码块,而初始化的触发并非一定要调用getInstance方法主动创建类的实例,还有可以在外部访问该类的其它静态变量或静态方法,该类的子类被初始化等。

正因为上述描述中,我们在正常开发过程中或多或少都会存着这种间接性使类加载或初始化,所以如果你使用立即加载的方式使用单例模式的话,你就不要再纠结此类在什么时候开始产生内存。如果一定要让内存使用在刀刃上,那么请继续往下看延时加载的单例模式。

2.2 延时加载

2.2.1 线程不安全的单例

延时加载就是在真正使用时才对类进行初始化,在延时加载的单例模式中最需要考虑的就是线程安全。因为单例需要保证全局对象的唯一性,如果两个线程刚好同时进行初始化就会产生不可相象的异常结果。比如:

public class Singleton {
    private static Singleton singleton;
    private Singleton() {
        System.out.println("Singleton init");
    }
    public static Singleton getInstance() {
        if (singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }
    public void doSomething() {
        System.out.println("do something");
    }
}

上面的例子,它需要保证了到第一次调用getInstance方法时才初始化对象,但没有保证在多线程中其对象的唯一性。

2.2.2 加同步锁但影响性能的单例

有朋友可能会觉得,要保证线程同步不是很简单,只需要给getIntstance加上一个同步锁synchronized就可以了:

public class Singleton {
    private static Singleton singleton;
    private Singleton() {
        System.out.println("Singleton init");
    }
    public static synchronized Singleton getInstance() {
        if (singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }
    public void doSomething() {
        System.out.println("do something");
    }
}

没有错,加了synchronized关键字后,使getInstance方法加了同步锁,确定能解决多线程产生多个实例的情况,但是锁是有性能上的代价的,这样牺牲了运行效率是得不偿失的。

2.2.3 双重检查锁解决同步锁性能问题,但不实际

又有朋友提出,使用双重检查就可以避开每次访问getInstance方法时进行触发同步锁,就可以提高执行效率了:

public class Singleton {
    private static Singleton singleton;
    private Singleton() {
        System.out.println("Singleton init");
    }
    public static Singleton getInstance() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
    public void doSomething() {
        System.out.println("do something");
    }
}

这种进化版的加同步锁的单例一般叫做双检查锁(Double-Checked Lock, DCL),在实现思路上很好地解决同步和性能问题,它在创造对象的时刻能锁定初始化对象保证了多线程安全,在对象创建后期又通过判空避免了锁性能的消耗,但是这仅仅限于思路上,因为理论很完美,现实很残酷。如果你正在使用C++语法开发可能不会有问题,但是如果你使用的是Java语法的话那么依然会产生多对象的灾难。

原因是什么?这个就要从Java平台内存模型允许“无序写入”说起。我们看到的简单的一句类对象创建:singleton = new Singleton(); 代码,它底层的进行经历以下三步:

1. 分配内存空间

2. 初始化对象

3. 给静态变量singleton指向刚分配的内存地址

然而在虚拟机实际运行时,以上步骤可能会发生重排序,也就是说第2和第3步,可能会存在次序颠倒的情况,因为就是Java平台内存模型允许的。这样就会造成线程A在对象创建工作时,而线程B可能同时对一个尚未初始化的对象判断它为非空,从而获得了一个还未初始化完成的对象。

 

线程A

线程B

1

分配内存空间

 

2

给变量赋值(原第3步)

 

3

 

判断对象是否为null

5

 

不为null,返回错误的对象

5

初始化对象(原第2步)

 

所以使用双重检查的方式进行避免同步锁的性能消耗来达到单例的效果只不过是一次学术实践罢了,此方案行不通。

2.2.4 volatile解决双重检查锁重排序,但对类成员有要求

在JDK1.5后,可以通过volatile关键字来禁止对象创建时步骤被重排序。除此外volatile也是Java提供的一种轻量级的同步机制。因为线程本身会存在着一个本地内存,在一般情况下并非立即同步到主内存中去,当多线程同时操作一个共享变量时就会容易发生读写并发时其值更新不同步的情况,而使用volatile修饰的变量JVM会把线程对应的本地内存强制刷新到主内存中,从而保证其它线程立即得知新值更新。

public class Singleton {
    private static volatile Singleton singleton;
    private Singleton() {
        System.out.println("Singleton init");
    }
    public static Singleton getInstance() {
        if (singleton == null) {
            synchronized (Singleton.class) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
    public void doSomething() {
        System.out.println("do something");
    }
}

然而尽管可以使用volatile关键字对单例的类对象作修饰,从而可以解决双重检查锁的对象创建重排序的缺陷。但是它仍然允许类里非volatile声明变量在读写操作的重新排序。这意味着,除非类中所有字段都必须全部使用volatile修饰,否则线程B仍然有可能获得未完全初始化完成的对象。

2.2.5 使用仅有一个静态对象的辅助类

当一个类中仅有一个需要初始化的静态变量,而没有其它的方法和字段时,JVM会自动有效地执行延迟初始化,直到程序中首次调用该类的静态变量。

public class MySingleton {
    public static Singleton singleton = new Singleton();
}

上述示例代码需要先将Singleton类的构造方法置回public,而Singleton对象的加载和初始化会在第一次调用MySingleton. singleton时被执行。这样就可以解决延时加载且线程安全的情况。

2.2.6 使用静态内部类的单例

使用仅有一个静态对象的辅助类,虽然可以很好地解决延时加载且线程安全问题,但是每一个需要做成单例的类还必须附带一个辅助类这难免显得有点啰嗦,而且变量被外部直接引用在代码风格上也不太好看。那么可否将辅助类变成静态内部类?如:

public class Singleton {
    private static class InnerSingleton {
        private static Singleton singleton = new Singleton();
    }
    private Singleton() {
        System.out.println("Singleton init");
    }
    public static Singleton getInstance() {
        return InnerSingleton.singleton;
    }
    public void doSomething() {
        System.out.println("do something");
    }
}

没错,使用静态内部类可以塑造延时加载且线程安全的单例。静态内部类不会随着外部类的加载而加载、初始化而初始化。只有到了第一次调用Singleton. getInstance方法时,InnerSingleton类才会被加载和初始化,所以其静态变量singleton的初始化也是在InnerSingleton类的初始化阶段进行。

我们还可以通过下面代码尝试触发Singleton类的加载,而从输出结果看来,并未发现有InnerSingleton类的加载情况。

public class Main {
    public static void main(String[] args){
        System.out.println("hello word: " + Singleton.class);
    }

    public Singleton test() {
        return null;
    }
}

使用静态内部类的单例看似很完美,但是也有它的短板,那就是传参问题。因为通过静态方法getInstance传入参数时,无法直接传递到内部类中去,除非使用静态变量中转,这情况下像Android开如中,如Context这种参数如果使用静态变量中转就往往容易发生内存泄露问题以及代码规范的一些警告,所以在使用上还需要注意。

 

3 总结

一个看似简单的单例模式在使用上还是很有讲究的,既要考虑安全又要兼顾性能和内存。笔者建议,如果你的单例类对象在程序起动后便开始工作的,那么直接使用静态变量或静态代码块的立即加载的方式是最简单实际的。但如果在开始时就需要控制好内存的使用,仅需要在将来某个时刻才触发类初始化的,那就需要使用延时加载的单例,而在不考虑传参的情况下静态内部类形式延时加载单例是最安全又简洁的方现。具体开发过程中使用哪类单类还需要开发者自行斟酌,根据实际情况实际应用。

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章