1. 什么是单例模式?
创建单例类的方法叫单例模式. 单例类, 就是只能产生一个对象的类.
2. 为什么使用单例模型
场景一: 一个写日志的类 (资源访问冲突)
- 首先, 假设如下方法 FileWriter 的 write 方法本身没有锁. 此假设下设计一个Log类. 在多线程下写日志会冲突, 导致日志覆盖问题.
首先想到加锁, 尝试方法上加 synchronized, 发现不管用, 因为这个加在对象上的锁, 对不同对象, 没有锁控制. 于是想到在类上加锁. synchronized(Log.class)
public class Logger {
private FileWriter writer;
public Logger() {
File file = new File("/Users/wangzheng/log.txt");
writer = new FileWriter(file, true); //true表示追加写入
}
public void log(String message) {
// synchronized(this) { // 加锁加载对象上 (1)
// synchronized (Log.class){ // 加锁加在类上
writer.write(mesasge);
}
}
}
-
在类上加锁是一种很通用的方法, 除此之外, 解决资源竞争的方法还有
- 将日志发送到一个 BlockingQueue, 用一个线程 EventLoop 负责将队列中的内容写到文件 (可参考 org.apache.spark.util.EventLoop)
-
如果用单例模式呢?
上面的解决办法中, 虽然在类上加了锁, 但因为能创建多个 Log 对象, 导致空间浪费. 如果只能产生一个对象, 就可以节省内存. 当然即使只创建一个对象, 仍要保证线程安全问题, 单例模式和线程安全无关, 因为同一个对象可以被多个线程使用
3. 单例模式的实现方式
实现单例模式, 有几个问题需要考虑在内:
- 构造函数需要是 private 访问权限的,这样才能避免外部通过 new 创建实例;
- 考虑对象创建时的线程安全问题;
- 考虑是否支持延迟加载;
- 考虑 getInstance() 性能是否高(是否加锁)。
1. 饿汉式
- 饿汉式的单例, 在类加载时, instance 静态实例就已经创建并初始化好了.
- 实例初始化是和类加载绑定的
- 用类的静态属性的方式保证只有一个实例
// 一个单例的 ID 递增生成器 public class IdGenerator { private static final IdGenerator instance = new IdGenerator(); private AtomicLong id = new AtomicLong(0); private IdGenerator() {} public static IdGenerator getInstance() { return instance; } public long getId() { return id.incrementAndGet(); } }
- 争议点: 不能延迟加载, 对象随类初始化
因为饿汉式的单利对象是在类加载时初始化的, 不能懒加载, 导致提前初始化. 所以其报表不已, 有人认为提前初始化是一种资源浪费, 应该真正使用时再去初始化; 而另一些人认为, 提前初始化满足 fail-fast 的设计原则(有问题及早暴露), 而且如果资源不够,就会在程序启动的时候触发报错
2. 懒汉式
- 懒汉式相当于延迟加载版的饿汉式, 单例实例也是静态属性, 但实例是在 getInstance() 获取时创建, 也因此需要一把类级别的锁防止对象重复初始化.
public class IdGenerator { private static IdGenerator instance; private AtomicLong id = new AtomicLong(0); private IdGenerator() {} public static synchronized IdGenerator getInstance() { // 一把类级别的大锁 if (instance == null) { instance = new IdGenerator(); } return instance; } public long getId() { return id.incrementAndGet(); } }
- 缺点: 无法面对高并发场景
懒汉式的缺点十分明显: 由于给 getInstance() 方法加了一把类级别的大锁(synchronzed), 导致函数的并发度为1, 相当于串行操作. 如果这个单例类偶尔被用到,那这种实现方式还可以接受。但是,如果频繁地用到,那频繁加锁、释放锁及并发度低等问题,会导致性能瓶颈,
3. 双重检测
饿汉式不支持延迟加载, 懒汉式不支持高并发. 因此出现第三种方式, 双重检测: 既能延迟加载, 又支持高并发.
-
基于懒汉式的改造点
- 如果实例已存在, 就不要先获得锁才能获取对象
因此, 加锁操作的锁竞争放在判断 instance 为空后进行, 还是类级别的大锁 (因为确保静态方法的锁)
public class IdGenerator { private AtomicLong id = new AtomicLong(0); private volatile static IdGenerator instance; private IdGenerator() {} public static IdGenerator getInstance() { if (instance == null) { synchronized(IdGenerator.class) { // 此处为类级别的锁 if (instance == null) { instance = new IdGenerator(); } } } return instance; } public long getId() { return id.incrementAndGet(); } }
- 如果实例已存在, 就不要先获得锁才能获取对象
-
为什么是双重检测? 只检测一遍
instance == null
不行吗
因为为了支持 getInstance() 的高并发, 锁没有加载方法上, 而是加在if (instance == null)
这个条件的判断后. 即判断条件本身没有加锁, 所以在进入 synchronized 代码块后, 判断条件可能已经不成立, 需要再次判断. 第二次判断因为加了锁, 所以是安全的 -
为什么 instance 实例加 volatile?
在低版本的 jvm 中, 对象初始化instance = new IdGenerator()
这句其实是2个动做, 分为new IdGenerator()
创建动作 和instance=
赋值操作. CPU 的指令重排, 导致赋值语句和不依赖此变量的计算语句重排.(参考volatile), 即在释放锁指令可能先于赋值语句执行. 即同步块退出后, 可能其它线程看到的 instance 仍然是 null, 导致对象重复创建.
高版本的 jvm 已不存在此问题, 解决办法很简单, 让对象的new和赋值成为原子操作即可.
4. 静态内部类
静态内部类的方式, 是饿汉式的改造, 将饿汉式单例类作为一个整体放在普通类内部, getInstance()
方法返回内部静态类的静态属性
public class IdGenerator {
private AtomicLong id = new AtomicLong(0);
private IdGenerator() {
}
public static IdGenerator getInstance() {
return SingletonHolder.instance;
}
public long getId() {
return id.incrementAndGet();
}
private static class SingletonHolder {
private static final IdGenerator instance = new IdGenerator();
}
}
当外部类 IdGenerator 被加载的时候,并不会创建 SingletonHolder 实例对象。只有当调用 getInstance() 方法时,SingletonHolder 才会被加载,这个时候才会创建 instance。instance 的唯一性、创建过程的线程安全性,都由 JVM 的类加载来保证。所以,这种实现方法既保证了线程安全,又能做到延迟加载。
5. 枚举
-
上面4种方法的潜在问题?
上面4中方法的问题在于, 我们是如何满足不让用户自己创建对象这一前提的? 是通过私有化构造函数, 避免用户访问构造函数. 可是即使不访问构造方法, 还有两种创建对象的方式:- 反序列化创建对象化:
只要把单例对象序列化成字节流, 然后读取成新的对象, 就会创造出第二个对象. 因为反序列化是靠字节流和类模板实现, 不用通过构造函数 - 反射:
反射会通过 api 调用私有方法Constructor constructor = singleton.getClass().getDeclaredConstructor(new Class[0]); constructor.setAccessible(true);
- 反序列化创建对象化:
-
用枚举实现单例, 解决上述所有问题
-
jvm 如何实现枚举对象
- 所有枚举编译后都是
Enum
的子类 . -
Enum
类不支持序列化和反序列化. 对应方法直接抛异常
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException { throw new InvalidObjectException("can't deserialize enum"); } private void readObjectNoData() throws ObjectStreamException { throw new InvalidObjectException("can't deserialize enum"); }
- enum 可以反射获取 value, 但不能反射调用构造函数
- enum 的第一行, 是所有可能的, 不可变的枚举对象列表
public enum Season { // enum 有一组不可变的常量集合 (常量不可变, 集合不可变) WINTER(5), SPRING(10), SUMMER(15), FALL(20); private int value; // compiler 限制 enum 的构造函数必须是 private private Season(int value) { this.value = value; } }
枚举 Season 编译后生成的枚举类:
final class Season extends Enum { public static Season[] values() { return (Season[]) $VALUES.clone(); } public static Season valueOf(String s) { return (Season) Enum.valueOf(Season, s); } private Season(String s, int i, int j) { super(s, i); value = j; } public static final Season WINTER; public static final Season SUMMER; private int value; private static final Season $VALUES[]; static { WINTER = new Season("WINTER", 0, 10); SUMMER = new Season("SUMMER", 1, 20); $VALUES = (new Season[]{ WINTER, SUMMER }); } }
可见, 枚举第一行列出的所有可能的值(Enum类的name属性), 在编译后会变成静态属性, 初始化放到了静态代码块中, 与饿汉模式写法相同, 且其构造函数不能通过反射调用, 又不能序列化反序列化, 因此是实现单例的最佳模式.
- 所有枚举编译后都是
-
基于枚举类型的单例实现。这种实现方式通过 Java 枚举类型本身的特性,保证了实例创建的线程安全性和实例的唯一性。具体的代码如下所示:
public enum IdGenerator { INSTANCE; private AtomicLong id = new AtomicLong(0); public long getId() { return id.incrementAndGet(); } }
-
网友评论