【设计模式】单例(Singleton)

在众多的设计模式中单例应该是最常见的设计模式了,对于一名初级工程师来说,这个设计模式可能是自己唯一能够拿的出手的设计模式。那么什么是单例模式呢?顾名思义单例就是单个实例,也就是说在整个类的使用中只允许你创建一个实例。接下来我们就以一个栗子逐步深入了解这个设计模式。

在这里插入图片描述

一、案例引申

首先定义个炒鸡简单的类

/**
 * 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、优缺点

优点:

  • 保证只有一个实例,减少了内存的开销。尤其是频繁的创建和销毁实例。
  • 避免对资源的多重占用,只被一个实例使用。

缺点:

  • 没有接口,不能继承。扩展性难。
  • 与单一职责原则冲突,一个类应该只关心内部逻辑,而不关心外面怎么样来实例化

The end

  • 1
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值