简说设计模式——单例模式
一、什么是单例模式
单例模式(Singleton),保证一个类仅有一个实例,并提供一个访问它的全局访问点。UML结构图如下:
其中,Singleton类定义了一个getInstance操作,允许客户端访问它的唯一实例,getInstance是一个静态方法,主要负责创建自己的唯一实例。而对象的声明是private的,其他类无法访问到,只能通过getInstance()方法访问其唯一实例。
1 public class Singleton { 2 3 private static Singleton instance; 4 5 //限制产生多个对象 6 private Singleton() { 7 } 8 9 //通过方法获取实例对象 10 public static Singleton getInstance() { 11 if(instance == null) { 12 instance = new Singleton(); 13 } 14 15 return instance; 16 } 17 18 }
上述代码就是一个单例模式,首先声明了静态私有的一个对象,并通过getInstance方法返回该对象。如果该对象已经存在,则直接返回该对象,若不存在,则实例化后返回该对象。下面看一段代码:
public class Client { public static void main(String[] args) { Singleton instance1 = Singleton.getInstance(); Singleton instance2 = Singleton.getInstance(); if(instance1 == instance2) { System.out.println("两个对象是相同的实例"); } } }
二、单例模式的应用
1. 何时使用
- 当我们想控制实力数目,节省系统资源时,可以使用单例模式。
2. 优点
- 内存中只有一个实例,减少了内存开支,尤其一个对象需要频繁地创建销毁,而此时性能又无法优化时,单例模式的优势就非常明显。
- 避免对资源的多重占用(比如写文件操作,只有一个实例时,避免对同一个资源文件同时写操作),简单来说就是对唯一实例的受控访问。
3. 缺点
- 没有接口,不能继承,与单一职责冲突。
4. 使用场景
- 要求生成唯一序列号的环境。
- 在整个项目中有一个共享访问点或共享数据(如web页面上的计数器,不用每次刷新都在数据库里加一次,用单例先缓存起来即可)。
- 创建一个对象需要消耗的资源过多时(如访问I/O和数据库等资源)。
5. 应用实例
- 一个党只有一个主席/一个国家只有一个国王/一个皇朝只有一个皇帝。
- 计划生育。
- 多个进程或线程同时操作一个文件的现象。
三、高并发下的单例模式
需要注意的是,在高并发情况下,要注意单例模式线程同步的问题。单例模式有几种不同的实现方式,如上方的代码就需要考虑线程同步。
1. 懒汉式
所谓懒汉式单例,就是通过在上述代码中增加synchronized关键字来实现。
public class Singleton { private volatile static Singleton instance; private static Object syncRoot = new Object(); private Singleton() { } public static Singleton getInstance() { //双重锁定 if(instance == null) { synchronized (syncRoot) { if(instance == null) { instance = new Singleton(); } } } return instance; } }
这里使用了双重锁定(Double-Check Locking),这样可以不用让线程每次都加锁,而只是在实例未被创建的时候再枷锁处理,同时也能保证多线程的安全。
而这里判断了两次instance实例是否存在的原因是,当instance为null时,并且同时有两个线程调用getInstance()方法时,它们都可以通过第一重instance==null的判断,然后由于lock机制,这两个线程则只有一个进入,另一个在外排队等候,必须要其中一个进入并出来后,另一个才能进入,而此时如果没有了第二重排序,则第一个线程创建了实例,而第二个线程还是可以继续再创建新的实例,就没有达到单例的目的。
这里还需要注意一个问题,第三行中加入了volatile关键字,这里如果不加volatile可能会出现一个错误,即当代码读取到第11行的判断语句时,如果instance不为null时,instance引用的对象有可能还没有完成初始化,线程将访问到一个还未初始化的对象。究其原因是因为代码第14行,创建了一个对象,此代码可分解为三行伪代码,即分配对象的内存空间、初始化对象、设置instance指向刚分配的内存地址,分别记为1、2、3,在2和3之间,可能会被重排序,重排序后初始化就变为了最后一步。因此,线程A的intra-thread semantics(所有线程在执行Java程序时必须遵守intra-thread semantics,它保证重排序不会改变单线程内的程序执行结果)没有改变,但A2和A3的重排序将导致线程B判断出instance不为空,线程B接下来将访问instance引用的对象,此时,线程B将会访问到一个还未初始化的对象。而使用volatile就可以实现线程安全的延迟初始化,本质时通过禁止2和3之间的重排序,来保证线程安全的延迟初始化。
2. 饿汉式
饿汉式单例就不会出现产生多个单例的情况,但它是在自己被加载时就将自己实例化,所以要提前占用系统资源。
public class Singleton { private static final Singleton instance = new Singleton(); private Singleton() { } public static Singleton getInstance() { return instance; } //类中其他方法,尽量使static public static void dosomething() { } }
3. 静态内部类
这种方式与饿汉式一样,同样利用了类加载来保证只创建一个instance实例,因此不存在线程安全的问题,不一样的是,它是在内部类里面去创建对象实例。这样只要应用中不使用内部类,JVM就不会去加载这个单例类,也就不会创建单例对象,从而实现懒汉式延迟加载。
public class Singleton { //静态内部类 private static class SingletonHolder { public static Singleton instance = new Singleton(); } private Singleton() { } public static Singleton getInstance() { return SingletonHolder.instance; } }
4. 枚举
上面实现单例的方式都需要额外的工作来实现序列化,而且可以使用反射强行调用私有构造器。
而枚举很好的解决了这两个问题,使用枚举除了线程安全和防止反射调用构造器外,还提供了自动序列化机制,防止反序列化的时候创建新的对象。
public enum Singleton { instance; public static void dosomething() { } }
枚举的客户端写法如下:
public class Client { public static void main(String[] args) { //枚举 Singleton instance1 = Singleton.instance; Singleton instance2 = Singleton.instance; if(instance1 == instance2) { System.out.println("两个对象是相同的实例"); } } }
四、单例模式的实现
下面举一个完整的例子,就以一个皇朝只有一个皇帝为例,假设当今是唐朝小李的天下,来看看怎么用单例模式实现。UML图如下:
1. 皇帝类(Emperor类)
public class Emperor { private static final Emperor EMPEROR = new Emperor(); private Emperor() { } public static Emperor getEmperor() { return EMPEROR; } public static void say() { System.out.println("朕乃当今圣上小李"); } }
这里使用的是饿汉式单例,这样我们在加载类的时候就对对象进行了实例化操作,后续只需调用getEmperor()方法即可。
2. 客户端
public class Client { public static void main(String[] args) { //臣子朝拜 for(int day=0; day<3 ;day++) { Emperor emperor = Emperor.getEmperor(); emperor.say(); } } }
客户端部分写了一个每日早朝的情况,臣子每日都要朝拜皇帝,今天朝拜的皇帝应该和昨天、前天的一样,运行结果如下:
运行结果表示,连续三天上朝的皇帝都是小李,这就是一个简单的单例模式。