阅读 596

关于单例模式

单例模式无论是在实际项目开发还是面试中,都是经常会涉及到,今天总结一下什么样的单例模式才是正确的。

1. 存在问题的单例模式

1.1 线程不安全的懒汉式

/**
 * Created by zhoujunfu on 2016/8/24.
 * 线程不安全的懒汉式单例
 */
class SingletonLazyNonThreadSafe {

    private static SingletonLazyNonThreadSafe instance;

    private SingletonLazyNonThreadSafe() {
        System.out.println("初始化单例对象:" + this.hashCode());
    }

    public static SingletonLazyNonThreadSafe getInstance() {
        if (instance == null) {
            instance =  new SingletonLazyNonThreadSafe();
        }

        System.out.println("获取单例对象:" + instance.hashCode());
        return instance;
    }

}

class Runner implements Runnable {

    @Override
    public void run() {
        SingletonLazyNonThreadSafe.getInstance();
    }
}

public class SingletonDemo {

    public static void main(String[] args) throws InterruptedException {
        // 两个线程并发访问单例类创建实例
        Runner runnerOne = new Runner();
        Runner runnerTwo = new Runner();

        Thread threadOne = new Thread(runnerOne);
        Thread threadTwo = new Thread(runnerTwo);

        threadOne.start();
        threadTwo.start();
    }

}
复制代码

懒汉式,也是最想当然的单例方式,线程不安全,可以从以下运行结果看出,线程并发访问这种单例类时,会初始化多个实例,违反了单例类的原则,如果在两个线程start的代码中间加入线程休眠时间,这样后运行的线程才能拿到先运行线程创建的单例对象。

1.2 线程安全的懒汉式

/**
 * Created by zhoujunfu on 2016/8/24.
 * 懒汉式单例
 */
class SingletonLazyThreadSafe {

    private static SingletonLazyThreadSafe instance;

    private SingletonLazyThreadSafe() {
        System.out.println("初始化单例对象:" + this.hashCode());
    }

    public static synchronized SingletonLazyThreadSafe getInstance() {
        if (instance == null) {
            instance =  new SingletonLazyThreadSafe();
        }

        System.out.println("获取单例对象:" + instance.hashCode());
        return instance;
    }

}
class Runner implements Runnable {

    @Override
    public void run() {
        SingletonLazyThreadSafe.getInstance();
    }
}

public class TestSingleton {

    public static void main(String[] args) throws InterruptedException {
        // 两个线程并发访问单例类创建实例
         Runner runnerOne = new Runner();
        Runner runnerTwo = new Runner();

        Thread threadOne = new Thread(runnerOne);
        Thread threadTwo = new Thread(runnerTwo);

        threadOne.start();
        threadTwo.start();
    }

}
复制代码

通过将整个getInstance方法设为同步的,来保证每次只能有一个线程进入到创建/获取实例的方法内,虽然做到了线程安全,并且解决了多实例的问题,但是它并不高效。因为在任何时候只能有一个线程调用 getInstance() 方法。但是同步操作只需要在第一次调用时才被需要,即第一次创建单例实例对象时。

1.3 双重检验锁

/**
 * Created by zhoujunfu on 2016/8/24.
 * 懒汉式双重检查锁
 */
class SingletonDoubleCheck {
    private SingletonDoubleCheck() {
        System.out.println("初始化单例对象:" + this.hashCode());
    }

    private static SingletonDoubleCheck instance;

    public static SingletonDoubleCheck getInstance() {
        if (instance == null) {
            synchronized (SingletonDoubleCheck.class) {
                if (instance == null) {
                    instance = new SingletonDoubleCheck();
                }
            }
        }
        return instance;
    }
}
复制代码

双重检验锁模式(double checked locking pattern),是一种使用同步块加锁的方法。程序员称其为双重检查锁,因为会有两次检查 instance == null,一次是在同步块外,一次是在同步块内。为什么在同步块内还要再检验一次?因为可能会有多个线程一起进入同步块外的 if,如果在同步块内不进行二次检验的话就会生成多个实例了。
这段代码看起来很完美,很可惜,它是有问题。主要在于instance = new Singleton()这句,这并非是一个原子操作,事实上在 JVM 中这句话大概做了下面 3 件事情。
1.给 instance 分配内存
2.调用 Singleton 的构造函数来初始化成员变量
3.将instance对象指向分配的内存空间(执行完这步 instance 就为非 null 了)
但是在 JVM 的即时编译器中存在指令重排序的优化。也就是说上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是 1-2-3 也可能是 1-3-2。如果是后者,则在 3 执行完毕、2 未执行之前,被线程二抢占了,这时 instance 已经是非 null 了(但却没有初始化),所以线程二会直接返回 instance,然后使用,然后顺理成章地报错。
我们只需要将 instance 变量声明成 volatile 就可以了。有些人认为使用 volatile 的原因是可见性,也就是可以保证线程在本地不会存有 instance 的副本,每次都是去主内存中读取。但其实是不对的。使用 volatile 的主要原因是其另一个特性:禁止指令重排序优化。也就是说,在 volatile 变量的赋值操作后面会有一个内存屏障(生成的汇编代码上),读操作不会被重排序到内存屏障之前。比如上面的例子,取操作必须在执行完 1-2-3 之后或者 1-3-2 之后,不存在执行到 1-3 然后取到值的情况。从「先行发生原则」的角度理解的话,就是对于一个 volatile 变量的写操作都先行发生于后面对这个变量的读操作(这里的“后面”是时间上的先后顺序)。
但是特别注意在 Java 5 以前的版本使用了 volatile 的双检锁还是有问题的。其原因是 Java 5 以前的 JMM (Java 内存模型)是存在缺陷的,即时将变量声明成 volatile 也不能完全避免重排序,主要是 volatile 变量前后的代码仍然存在重排序问题。这个 volatile 屏蔽重排序的问题在 Java 5 中才得以修复,所以在这之后才可以放心使用 volatile。

2. 不存在问题的单例模式

2.1 饿汉式(非懒加载)

class SingletonHungry {
    private SingletonHungry() {
        System.out.println("初始化单例对象:" + this.hashCode());
    }

    private static SingletonHungry instance = new SingletonHungry();

    public SingletonHungry getInstance() {
        return instance;
    }
}
复制代码

这种方法非常简单,因为单例的实例被声明成 static 和 final 变量了,在第一次加载类到内存中时就会初始化,所以创建实例本身是线程安全的。 这种写法如果完美的话,就没必要在啰嗦那么多双检锁的问题了。缺点是它不是一种懒加载模式(lazy initialization),单例会在加载类后一开始就被初始化,即使客户端没有调用 getInstance()方法。饿汉式的创建方式在一些场景中将无法使用:譬如 Singleton 实例的创建是依赖参数或者配置文件的,在 getInstance() 之前必须调用某个方法设置参数给它,那样这种单例写法就无法使用了。

2.2 饿汉式(懒加载)

class SingletonStaticNestedClass {
    private SingletonStaticNestedClass() {
    }

    private static class Holder {
        private static final SingletonStaticNestedClass instance = new SingletonStaticNestedClass();
    }

    public SingletonStaticNestedClass getInstance() {
        return Holder.instance;
    }
}
复制代码

这种写法仍然使用JVM本身机制保证了线程安全问题;由于 Holder 是私有的,除了 getInstance() 之外没有办法访问它,因此它是懒汉式的;同时读取实例的时候不会进行同步,没有性能缺陷;也不依赖 JDK 版本,但反序列化时会出现问题。

2.3 枚举式(终极方法)

enum SingletonByEnum {
    INSTANCE;
}
复制代码

我们可以通过EasySingleton.INSTANCE来访问实例,这比调用getInstance()方法简单多了。创建枚举默认就是线程安全的,所以不需要担心double checked locking,而且还能防止反序列化导致重新创建新的对象。但是还是很少看到有人这样写,可能是因为不太熟悉吧。 网络上很多关于单例类的文章都介绍了用枚举法实现单例,但仅仅靠上述的例子还无法知道具体的使用方法,下面以一个具体的例子来说明如何通过枚举实现单例类。

//Example 1
public enum MyDataBaseSource {
    DATASOURCE;
    private ComboPooledDataSource cpds = null;

    private MyDataBaseSource() {
        try {

            /*--------获取properties文件内容------------*/
            // 方法一:
            /*
             * InputStream is =
             * MyDBSource.class.getClassLoader().getResourceAsStream("jdbc.properties");
             * Properties p = new Properties(); p.load(is);
             * System.out.println(p.getProperty("driverClass") );
             */

            // 方法二:(不需要properties的后缀)
            /*
             * ResourceBundle rb = PropertyResourceBundle.getBundle("jdbc") ;
             * System.out.println(rb.getString("driverClass"));
             */

            // 方法三:(不需要properties的后缀)
            ResourceBundle rs = ResourceBundle.getBundle("jdbc");
            cpds = new ComboPooledDataSource();
            cpds = new ComboPooledDataSource();
            cpds.setDriverClass(rs.getString("driverClass"));
            cpds.setJdbcUrl(rs.getString("jdbcUrl"));
            cpds.setUser(rs.getString("user"));
            cpds.setPassword(rs.getString("password"));
            cpds.setMaxPoolSize(Integer.parseInt(rs.getString("maxPoolSize")));
            cpds.setMinPoolSize(Integer.parseInt(rs.getString("minPoolSize")));
            System.out.println("-----调用了构造方法------");
            ;
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public Connection getConnection() {
        try {
            return cpds.getConnection();
        } catch (SQLException e) {
            return null;
        }
    }

}
public class Test {
    public static void main(String[] args) {
        MyDataBaseSource.DATASOURCE.getConnection() ;
        MyDataBaseSource.DATASOURCE.getConnection() ;
        MyDataBaseSource.DATASOURCE.getConnection() ;
    }
}
//Example 2
public enum UserActivity {
    INSTANCE;

    private DataSource _dataSource;
    private JdbcTemplate _jdbcTemplate;

    private UserActivity() {
        this._dataSource = MysqlDb.getInstance().getDataSource();
        this._jdbcTemplate = new JdbcTemplate(this._dataSource);
    }

    public void dostuff() {
     ...
    }
}

// use it as ...
UserActivity.INSTANCE.doStuff();
复制代码

Tips: 关于枚举

先看一下枚举类型的实质: 我们定义一个代表不同颜色的枚举类型Color,

public enum Color {
    RED, BLUE, GREEN;
}
复制代码

除了以上的定义方式,我们还可以如下定义,

public enum Color {
    RED(), BLUE(), GREEN();
}
复制代码

到这里你就会觉得迷茫(如果你是初学者的话),为什么这样子也可以?其实,枚举的成员就是枚举对象,只不过他们是静态常量而已。使用 javap 命令(javap 文件名<没有后缀.class>)可以反编译 class 文件,如下

我们可以使用普通类来模拟枚举,下面定义一个 Color 类。

public class Color {
    private static final Color RED = new Color();
    private static final Color GREEN = new Color();
    private static final Color BLUE = new Color();
}
复制代码

对比一下,你就明白了。如果按照这个逻辑,是否还可以为其添加另外的构造方法?答案是肯定的!

public enum Color {
    RED("red color", 0), BLUE("blue color", 1), GREEN("green color", 2);

    Color(String desc, int value) {
        this.desc = desc;
        this.value = value;
    }

    String desc;
    int value;
}
复制代码

为 Color 声明了两个成员变量,并为其构造带参数的构造器。如果你这样创建一个枚举

public enum Color {
    RED("red color", 0), BLUE("blue color", 1), GREEN("green color", 2);
}
复制代码

编译器就会报错,因为没有对应的构造函数。 对于类来讲,最好将其成员变量私有化,然后,为成员变量提供 get、set 方法。按照这个原则,可以进一步写好 enum Color.

public enum Color {
    RED("red color", 0), BLUE("blue color", 1), GREEN("green color", 2);

    Color(String desc, int value) {
        this.desc = desc;
        this.value = value;
    }

    private String desc;
    private int value;

    public String getDesc() {
        return desc;
    }

    public void setDesc(String desc) {
        this.desc = desc;
    }

    public int getValue() {
        return value;
    }

    public void setValue(int value) {
        this.value = value;
    }
}
复制代码

但是,java 设计 enum 的目的是提供一组常量,方便用户设计。如果我们冒然的提供 set 方法(外界可以改变其成员属性),好像是有点违背了设计的初衷。那么,我们应该舍弃 set 方法,保留 get 方法。

public enum Color {
    RED("red color", 0), BLUE("blue color", 1), GREEN("green color", 2);

    Color(String desc, int value) {
        this.desc = desc;
        this.value = value;
    }

    private String desc;
    private int value;

    public int getValue() {
        return value;
    }

    public void setValue(int value) {
        this.value = value;
    }
}
复制代码

普通类,我们可以将其实例化,那么,能否实例化枚举呢?在回答这个问题之前,先来看看,反编译之后的 Color.class 文件

public enum Color {
    RED("red color", 0), BLUE("blue color", 1), GREEN("green color", 2);

    private Color(String desc, int value) {
        this.desc = desc;
        this.value = value;
    }

    private String desc;
    private int value;

    public int getValue() {
        return value;
    }

    public void setValue(int value) {
        this.value = value;
    }
}
复制代码

可以看出,编译器淘气的为其构造方法加上了 private,那么也就是说,我们无法实例化枚举。所有枚举类都继承了 Enum 类的方法,包括 toString 、equals、hashcode 等方法。因为 equals、hashcode 方法是 final 的,所以不可以被枚举重写(只可以继承)。但是,可以重写 toString 方法。 那么,使用 Java 的不同类来模拟一下枚举,大概是这个样子

public class Color {
    private static final Color RED = new Color("red color", 0);
    private static final Color GREEN = new Color("green color", 1);
    private static final Color BLUE = new Color("blue color", 2);
    private static final Color YELLOW = new Color("yellow color", 3);

    private final String _name;
    private final int _id;

    private Color(String name, int id) {
        _name = name;
        _id = id;
    }

    public String getName() {
        return _name;
    }

    public int getId() {
        return _id;
    }

    public static List<Color> values() {
        List<Color> list = new ArrayList<Color>();
        list.add(RED);
        list.add(GREEN);
        list.add(BLUE);
        list.add(YELLOW);
        return list;
    }

    @Override
    public String toString() {
        return "the color _name=" + _name + ", _id=" + _id;
    }

}
复制代码