单例模式无论是在实际项目开发还是面试中,都是经常会涉及到,今天总结一下什么样的单例模式才是正确的。
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;
}
}
复制代码