在众多的设计模式中单例应该是最常见的设计模式了,对于一名初级工程师来说,这个设计模式可能是自己唯一能够拿的出手的设计模式。那么什么是单例模式呢?顾名思义单例就是单个实例,也就是说在整个类的使用中只允许你创建一个实例。接下来我们就以一个栗子逐步深入了解这个设计模式。
一、案例引申
首先定义个炒鸡简单的类
/**
* Create by SunnyDayA on 2019/04/08
*/
public class Person {}
然后定义一个测试类,在测试类中打印其内存地址
/**
* Create by SunnyDayB on 2019/04/08
*/
public class Test {
public static void main(String[] args) {
Person p1 = new Person();
Person p2 = new Person();
Person p3 = new Person();
System.out.println("p1的内存地址:" + p1);
System.out.println("p2的内存地址:" + p2);
System.out.println("p3的内存地址:" + p3);
/*
log:
p1的内存地址:pattern_singleton.Person@1b6d3586
p2的内存地址:pattern_singleton.Person@4554617c
p3的内存地址:pattern_singleton.Person@74a14482
*/
}
}
观察log发现:3个对象的内存地址各不相同,这说明默认情况下每次new操作都会产生新的对象,那么如何能保证整个类的使用过程中只产生一个实例呢?这个我们需要好好思考下了~
如何设计单例呢?
首先有一个小细节需要留意下,如上若是仔细观看会发现Person类与Test类是两个不同开发人员来开发完成的。这个在实际开发中也是常见的事情。这里根据上述情况定义SunnyDayA为Person类的定义者,SunnyDayB为Person类的调用者。
好了,回归正题,上述栗子中,每次Person类的对象创建工作都是由类的调用者来完成的,这显然是不行的,要想实现单一实例我们必须做到如下几点:
(1)私有构造方法:使类的对象创建工作交付给类的创建者来完成。
(2)创建本类的私有静态成员对象:静态可保证对象在内存中只有一份,私有可保证对象只能被类的内部成员使用。
(3)暴露方法,提供类的对象:类的调用者只能调用这个公有方法来获取类的对象。
二、单例
其实上述的3步就是单例的书写思路了,接下来我们就来具体实现下、分析写法的利弊、探究下各种单例写法。
1、饿汉式
/**
* Create by SunnyDay on 2019/04/08
*/
public class Person {
// 提供私有静态对象
private static Person person = new Person();
// 私有构造函数
private Person() {
}
// 暴露方法 提供对象
public static Person getInstance() {
return person;
}
}
这种单例叫饿汉式。这时你会发现类的调用者使用这个类时不能再随意的new了,只能通过类的创建者暴露的方法来创建对象。
接下来分析下这种写法的优缺点:
优点:线程安全。
缺点:不具备延迟载机制,在类首次被装载、使用时,静态成员对象就会被创建。
2、懒汉式
既然饿汉式不具备延迟加载机制,我们又想在使用时再创建对象这时可修改代码如下:
/**
* Create by SunnyDay on 2019/04/08
*/
public class Person {
private static Person person;
private Person() {}
public static Person getInstance() {
if (person==null) {
person = new Person();
}
return person;
}
}
如上简单稍作修改就实现了延迟加载的效果,但是多线程情况下是不安全的。所以懒汉式的优缺点如下:
优点:具备延迟加载特性
缺点:多线程下不安全,单例可能会失效。
3、DCL双检查锁机制(DCL:double checked locking)
/**
* Create by SunnyDay on 2019/04/08
*/
public class Person {
private volatile static Person person;
private Person() {
}
public static Person getInstance() {
if (null == person) {
synchronized (Person.class) {
if (null == person) {
person = new Person();
}
}
}
return person;
}
}
上述写法注意点:
1、volatile 关键字添加。
2、if判断再加锁,而非直接加在方法上,这样可以避免资源浪费。因为synchronized 锁住的资源被一个线程访问时,其他线程只能处于等待状态。
写法分析:
1、第一次判空为了避免非必要加锁。第二次判空是为了在person为空的状况下才创建实例。
2、当类第一次加载时才对实例进行加锁再实例化。这样既可以节约内存空间,又可以保证线程安全。
为啥要加volatile 关键字
假设线程A执行到 person = new Person()这句代码时,这里看似是一句代码,实际不是一个原子操作,JVM最终会把上述代码编译成多条汇编指令。jvm大致做了3件事:
1、给Person类的实例分配内存空间
2、调用Person类的构造,给Person类的成员进行初始化。
3、将person引用指向分配的内存空间。
由于java编译器允许处理器乱序执行,以及jdk1.5之前JMM中Cache、寄存器到主内存回写顺序的规定,上述2,3的执行顺序是无法保证的所以执行顺序可能为1-2-3,也可能为1-3-2。假如为1-3-2时,并且在3执行完毕2未执行之前被切换到线程B上,这时因为person在3已经进行赋值,静态值线程可见,B访问的值不为空,直接使用这时就会出错了。 jdk1.5之后官方调整了JVM,优化了volatile 关键字,保证了对象从主存中读取。虽然使用这个关键字牺牲了点性能,但是还是值得的。
疑问?
既然person = new Person()这句代码不是一个原子操作,为啥饿汉式是线程安全的?
其实很简单,在饿汉式中person引用是直接进行赋值操作的,这个赋值是在类的初始化阶段完成的:
虚拟机会保证一个类的 < clinit >() 方法在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的 < clinit >() 方法,其它线程都需要阻塞等待,直到活动线程执行 < clinit >() 方法完毕。
因此懒汉式则是依赖了volatile+锁两步实现的线程安全。饿汉式是通过虚拟机的自身实现机制。二者归根到底都是JMM定义的几种原子性操作的结果。
DCL优缺点
优点:
1、资源利用率高,第一次执行时才会被创建实例,效率高。
2、使用最多的单利实现方式,使用时才被实例化,绝大多数场景下都能保证单利唯一,除非你的代码在及其复杂的高并发或者jdk1.6场景下。否则这种方式一般能够满足需求。
缺点:
1、第一次加载反应稍慢,由于java内存模型的原因,偶尔会失败。
2、在高并发环境下有一定的缺陷,虽然发生概率很小。
4、静态内部类方式的单例
/**
* Create by SunnyDay on 2019/04/08
*/
public class Person {
private Person() {
}
private static class Holder {
private static Person INSTANCE = new Person();
}
public static Person getInstance() {
return Holder.INSTANCE;
}
}
优点:
1、外部类加载时并不需要立即加载内部类,内部类不被加载则不去初始化person,节省内存。
2、线程安全(参考jvm初始化类的过程)且具备延迟加载机制。
3、高并发实战推荐
5、 枚举单例
/**
* Create by SunnyDay on 2020/09/16
*/
public enum SingleTon {
INSTANCE;
public void dosth(){
// todo
}
}
1、写法简单
2、任何情况下都是单例(包括序列化,反射情况下)
6、容器单例
/**
* Create by SunnyDay on 2020/09/17
*/
public class SingletonManager {
private static Map<String, Object> objectMap = new HashMap<>();
private SingletonManager() {
}
public static void registerService(String key, Object instance) {
if (!objectMap.containsKey(key)) {
objectMap.put(key, instance);
}
}
public Object getService(String key) {
return objectMap.get(key);
}
}
1、将多种对象单例放到一个类中统一管理。
2、使用时使用统一的方法进行获取,隐藏了实现细节,较低了耦合度。
三、单例破坏的避免
经过一系列的优化我们已经会写3种线程安全的单利了,但是在序列化情况下,上述的DCL和静态内部类方式的单例还会出现问题的。接下来我们便来个栗子,验证下以及分析下如何避免。
1、序列化时单例失效栗子
/**
* Create by SunnyDay on 2019/04/08
*/
public class Person implements Serializable {
private Person() {
}
private static class Holder {
private static Person INSTANCE = new Person();
}
public static Person getInstance() {
return Holder.INSTANCE;
}
}
/**
* Create by SunnyDay on 2019/04/08
*/
public class Test {
public static void main(String[] args) {
try {
ObjectOutputStream
oos = new ObjectOutputStream(new FileOutputStream("test.txt"));//输出到当前根目录下,无文件自动创建。
oos.writeObject(Person.getInstance());
File file = new File("test.txt");
ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
Person person = (Person) ois.readObject();
System.out.println(person == Person.getInstance());
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
}
}
}
-----------------------------
log:
false
Process finished with exit code 0
2、简析
1、序列化的大致过程:序列化时吧对象转换为输出流写入磁盘,反序列化时吧磁盘内的文件转化为输出了流,生成对象。
2、反序列化过程中对象生成原理:利用反射,反射无参构造,创建对象。其实反序列化操作提供了一个特殊的钩子函数,类中存在一个私有的、被实例化的readResove方法。这个方法可以让开发人员控制对象的反序列化。
3、解决
解决思路:
1、设计类时,不实现序列化接口:不现实,当需要序列化时。
2、设计类时,构造里面抛异常。不让调用:不现实,当需要序列化时。
3、提供readResove方法:生成相同对象。代码如下。
/**
* Create by SunnyDay on 2019/04/08
*/
public class Person implements Serializable {
private Person() {
}
private static class Holder {
private static Person INSTANCE = new Person();
}
public static Person getInstance() {
return Holder.INSTANCE;
}
// copy 过来 返回自己的实例即可。
private Object readResolve() throws ObjectStreamException {
return Holder.INSTANCE;
}
}
序列化对象的生成主要就在反序列化操作上。有兴趣的同学可以研究下ObjectInputStream#readObject源码,看下具体反列化如何生成对象的。
单例UML类图
小结
1、单例使用场景
1、创建一个对象需要使用过多的资源(如访问IO、数据库等),且对象需要频繁使用。
2、需要频繁的创建对象,然后销毁对象。
3、优缺点
优点:
- 保证只有一个实例,减少了内存的开销。尤其是频繁的创建和销毁实例。
- 避免对资源的多重占用,只被一个实例使用。
缺点:
- 没有接口,不能继承。扩展性难。
- 与单一职责原则冲突,一个类应该只关心内部逻辑,而不关心外面怎么样来实例化