1、什么是单例以及为什么使用单例?
1.1、单例设计模式(Singleton Design Pattern)。一个类只允许创建一个对象(或者实例),那这个类就是一个单例类,这种设计模式就叫作单例设计模式,简称单例模式。
使用单例可以解决那些问题:
2、处理资源访问冲突:
譬如日志设计时,多对象在多线程环境下并发写日志时可能导致日志覆盖,进而导致日志丢失问题。举例代码如下:
/**
* 日志记录类
* @author PengMvc
*
*/
public class Logger {
private BufferedWriter out = null;
public Logger() {
try {
out = new BufferedWriter(new OutputStreamWriter(new FileOutputStream("src/resources/log.txt",true), "UTF-8"));
} catch (Exception e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
public void logger(String msg) {
try {
out.write(msg);
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
/**
* 订单接口
* @author PengMvc
*
*/
public class OrderController {
// 注入日志对象
private Logger log = new Logger();
public Boolean placeOrder() {
log.logger("用户下单");
return true;
}
}
/**
* 用户登陆接口
* @author PengMvc
*
*/
public class UserController {
// 注入日志对象
private Logger log = new Logger();
public Boolean userLogin() {
log.logger("用户登陆成功!");
return true;
}
}
在web容器servlet多线程的环境下,如果有两个线程同时持有log对象的实例,同时执行userLogin()和placeOrder()函数,那么就有可能出现日志被覆盖的问题。
2.1、那你肯定会想到加锁,加个互斥锁(对象级别的锁),代码如下(这个方案解决不了):
/**
* 日志记录类
* @author PengMvc
*
*/
public class LoggerLock {
private BufferedWriter out = null;
public LoggerLock() {
try {
out = new BufferedWriter(new OutputStreamWriter(new FileOutputStream("src/resources/log.txt",true), "UTF-8"));
} catch (Exception e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
public void logger(String msg) {
try {
synchronized (this) { //对象级别的锁
out.write(msg);
}
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
这种对象级别的锁并不能解决写日志时的覆盖问题,一个对象在多个线程同时调用logger函数会被强制要求按照顺序执行,但是,不同的对象之间并不同时共享一把锁。在不同的线程下,通过不同的对象调用logger函数,锁并不会起作用,所以仍然会出现日志互相覆盖的问题。
那么解决这个问题,可以考虑使用类级别的锁,代码如下:
/**
* 日志记录类
* @author PengMvc
*
*/
public class LoggerLock {
private BufferedWriter out = null;
public LoggerLock() {
try {
out = new BufferedWriter(new OutputStreamWriter(new FileOutputStream("src/resources/log.txt",true), "UTF-8"));
} catch (Exception e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
public void logger(String msg) {
try {
synchronized (LoggerLock.class) { //类级别的锁
out.write(msg);
}
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
实际上,解决资源竞争问题的办法还有很多,分布式锁是最常听到的一种解决方案。不过,实现一个安全可靠、无 bug、高性能的分布式锁,并不是件容易的事情。
2.2、衍生出来单例模式:
2.2.1、单例模式相对于之前类级别锁的好处是,不用创建那么多 Logger 对象,一方面节省内存空间,另一方面节省系统文件句柄(对于操作系统来说,文件句柄也是一种资源,不能随便浪费)代码实现。
/**
* 日志记录类
* @author PengMvc
*
*/
public class Logger {
private BufferedWriter out = null;
// 饿汉式
private static final Logger log = new Logger();
public Logger() {
try {
out = new BufferedWriter(new OutputStreamWriter(new FileOutputStream("src/resources/log.txt",true), "UTF-8"));
} catch (Exception e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
public static Logger getLogInstance() {
return log;
}
public void logger(String msg) {
try {
out.write(msg);
} catch (IOException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
3、表示全局唯一类:
从业务概念上,如果有些数据在系统中只应保存一份,那就比较适合设计为单例类。
比如,配置信息类。在系统中,我们只有一个配置文件,当配置文件被加载到内存之后,以对象的形式存在,也理所应当只有一份。
再比如,唯一递增 ID 号码生成器,如果程序中有两个对象,那就会存在生成重复 ID 的情况,所以,我们应该将 ID 生成器类设计为单例。
/**
* id生成器,一般而言应用启动后只允许存在一个实例;
* 避免id重复已经多线程环境下的问题
* @author PengMvc
*
*/
public class Idgenerator {
// 饿汉式
private static final Idgenerator idgenerator = new Idgenerator();
// AtomicLong是一个Java并发库中提供的一个原子变量类型,
// 它将一些线程不安全需要加锁的复合操作封装为了线程安全的原子操作,
// 比如下面会用到的incrementAndGet().
private AtomicInteger atomicInteger = new AtomicInteger(1000);
public static Idgenerator getIdgeneratorInstance() {
return idgenerator;
}
/**
* 获取自增id
* @return id
*/
public Integer getId() {
return atomicInteger.incrementAndGet();
}
}
4、如何实现一个单例:
4.1、要实现一个单例,我们需要关注的点无外乎下面几个:
4.1.1、构造函数需要是 private 访问权限的,这样才能避免外部通过 new 创建实例;
4.1.2、考虑对象创建时的线程安全问题;
4.1.3、考虑是否支持延迟加载;
4.1.4、考虑 getInstance() 性能是否高(是否加锁)。
4.2、饿汉式:
饿汉式的实现方式比较简单。在类加载的时候,instance 静态实例就已经创建并初始化好了,所以,instance 实例的创建过程是线程安全的。不过,这样的实现方式不支持延迟加载(在真正用到 IdGenerator 的时候,再创建实例),代码实现如下所示:
/**
* 饿汉式
* id生成器,一般而言应用启动后只允许存在一个实例;
* 避免id重复已经多线程环境下的问题
* @author PengMvc
*
*/
public class Idgenerator {
// 饿汉式
private static final Idgenerator idgenerator = new Idgenerator();
// AtomicLong是一个Java并发库中提供的一个原子变量类型,
// 它将一些线程不安全需要加锁的复合操作封装为了线程安全的原子操作,
// 比如下面会用到的incrementAndGet().
private AtomicInteger atomicInteger = new AtomicInteger(1000);
public static Idgenerator getIdgeneratorInstance() {
return idgenerator;
}
/**
* 获取自增id
* @return id
*/
public Integer getId() {
return atomicInteger.incrementAndGet();
}
}
有人觉得这种实现方式不好,因为不支持延迟加载,如果实例占用资源多(比如占用内存多)或初始化耗时长(比如需要加载各种配置文件),提前初始化实例是一种浪费资源的行为。最好的方法应该在用到的时候再去初始化。
不过,我不认同这样的观点。如果初始化耗时长,那我们最好不要等到真正要用它的时候,才去执行这个耗时长的初始化过程,这会影响到系统的性能(比如,在响应客户端接口请求的时候,做这个初始化操作,会导致此请求的响应时间变长,甚至超时)。采用饿汉式实现方式,将耗时的初始化操作,提前到程序启动的时候完成,这样就能避免在程序运行的时候,再去初始化导致的性能问题。
如果实例占用资源多,按照 fail-fast 的设计原则(有问题及早暴露),那我们也希望在程序启动时就将这个实例初始化好。如果资源不够,就会在程序启动的时候触发报错(比如 Java 中的 PermGen Space OOM),我们可以立即去修复。这样也能避免在程序运行一段时间后,突然因为初始化这个实例占用资源过多,导致系统崩溃,影响系统的可用性。
4.3、懒汉式:
懒汉式相对于饿汉式的优势是支持延迟加载。具体的代码实现如下所示:
/**
* 懒汉式
* ID生成器
* @author PengMvc
*
*/
public class LazyIdgenerator {
private static LazyIdgenerator LazyIdgenerator;
// 私有化构造方法
private LazyIdgenerator() {
}
public static synchronized LazyIdgenerator getIdgenerator() {
if(LazyIdgenerator == null) {
LazyIdgenerator = new LazyIdgenerator();
}
return LazyIdgenerator;
}
}
我们给 getInstance() 这个方法加了一把大锁(synchronzed),导致这个函数的并发度很低。量化一下的话,并发度是 1,也就相当于串行操作了。而这个函数是在单例使用期间,一直会被调用。如果这个单例类偶尔会被用到,那这种实现方式还可以接受。但是,如果频繁地用到,那频繁加锁、释放锁及并发度低等问题,会导致性能瓶颈,这种实现方式就不可取了。
4.4、双重检测机制,代码如下:
/**
* 双重检测机制
* @author PengMvc
*
*/
public class DoubleCheckIdGenerator {
// 加入volatile避免指令重排,jdk高版本其实已经没有了,故可以不加
private volatile static DoubleCheckIdGenerator generator;
// 私用化构造函数
private DoubleCheckIdGenerator() {}
public DoubleCheckIdGenerator getDoubleCheckIdGeneratorInstance() {
if(generator == null) {
synchronized (DoubleCheckIdGenerator.class) { // 类级别锁
if(generator == null) {
generator = new DoubleCheckIdGenerator();
}
}
}
return generator;
}
这种情况下,假如两个线程,线程A和线程B同时执行getDoubleCheckIdGeneratorInstance()函数,如果线程A、B均发现generator为null(一重检测),而我们用的是类级别锁,也就意味同时只有一个线程进去操作,譬如是线程A进去操作了,它发现此时generator为null(二重检测),那么它会创建一个对象,完了释放锁,那么线程B进来,发现generator不为为null了(二重检测),业务结束,直接返回线程A创建的对象。
网上有人说,这种实现方式有些问题。因为指令重排序,可能会导致 IdGenerator 对象被 new 出来,并且赋值给 instance 之后,还没来得及初始化(执行构造函数中的代码逻辑),就被另一个线程使用了。要解决这个问题,我们需要给 instance 成员变量加上 volatile 关键字,禁止指令重排序才行。实际上,只有很低版本的 Java 才会有这个问题。我们现在用的高版本的 Java 已经在 JDK 内部实现中解决了这个问题(解决的方法很简单,只要把对象 new 操作和初始化操作设计为原子操作,就自然能禁止重排序)。
4.5、静态内部类代码如下:
/**
* 静态内部类
* @author PengMvc
*
*/
public class Idgenerator2 {
private static class StaticIdgenerator {
private static final Idgenerator2 staticIdgenerator= new Idgenerator2();
}
public Idgenerator2 getInstance() {
return StaticIdgenerator.staticIdgenerator;
}
}
StaticIdgeneratorInstance 是一个静态内部类,当外部类 IdGenerator 被加载的时候,并不会创建 StaticIdgeneratorInstance 实例对象。只有当调用 getInstance() 方法时,SingletonHolder 才会被加载,这个时候才会创建 instance。instance 的唯一性、创建过程的线程安全性,都由 JVM 来保证。所以,这种实现方法既保证了线程安全,又能做到延迟加载。
4.6、枚举:
最后,我们介绍一种最简单的实现方式,基于枚举类型的单例实现。这种实现方式通过 Java 枚举类型本身的特性,保证了实例创建的线程安全性和实例的唯一性。具体的代码如下所示:
/**
* 枚举类实现了线程安全的单例
* @author PengMvc
*
*/
public enum IdgeneratorEnum {
INSTANCE;
private AtomicLong id = new AtomicLong(0);
public long getId() {
return id.incrementAndGet();
}
}
/**
* 测试类
* @author PengMvc
*
*/
public class IdgeneratorTest {
public static void main(String[] args) {
System.out.println(IdgeneratorEnum.INSTANCE.getId());
}
}
5、总结:
5.1、单例的定义:单例设计模式(Singleton Design Pattern)理解起来非常简单。一个类只允许创建一个对象(或者叫实例),那这个类就是一个单例类,这种设计模式就叫作单例设计模式,简称单例模式。
5.2、 单例的用处从业务概念上,有些数据在系统中只应该保存一份,就比较适合设计为单例类。比如,系统的配置信息类。除此之外,我们还可以使用单例解决资源访问冲突的问题。
5.3、单例的实现单例有下面几种经典的实现方式。
饿汉式:
饿汉式的实现方式,在类加载的期间,就已经将 instance 静态实例初始化好了,所以,instance 实例的创建是线程安全的。不过,这样的实现方式不支持延迟加载实例。
懒汉式:
懒汉式相对于饿汉式的优势是支持延迟加载。这种实现方式会导致频繁加锁、释放锁,以及并发度低等问题,频繁的调用会产生性能瓶颈。
双重检测:
双重检测实现方式既支持延迟加载、又支持高并发的单例实现方式。只要 instance 被创建之后,再调用 getInstance() 函数都不会进入到加锁逻辑中。所以,这种实现方式解决了懒汉式并发度低的问题。
静态内部类:
利用 Java 的静态内部类来实现单例。这种实现方式,既支持延迟加载,也支持高并发,实现起来也比双重检测简单。
枚举:
最简单的实现方式,基于枚举类型的单例实现。这种实现方式通过 Java 枚举类型本身的特性,保证了实例创建的线程安全性和实例的唯一性。