1 单例模式背景概念
场景: 代码中有些概念,不应该存在多个实例,此时便可以使用单例模式来解决,如DataSource这样的类,就应该是在一个程序中只有一个实例,不应该实例化多个DataSource对象.
单例模式是一种比较常见的"设计模式",以后在工作中会接触一些服务器,服务器里就需要依赖一些数据(可能是数据库中,也可能是在内存中),如果是在内存中的话,往往需要一个"数据管理器"实例来管理这些数据,像这种"数据管理器"对象也应该是单例的.
2 实现方式
2.1 饿汉模式
饿汉模式的代码实现如下:
public class ThreadDemo {
static class Singleton {
private Singleton() { }
//私有构造方法,要求外部无法new这个类的实例
private static Singleton instance = new Singleton();
public static Singleton getInstance() {
return instance;
}
}
public static void main(String[] args) {
//getInstance就是获取该类实例的唯一方式,不应该使用其他方式来创建实例
Singleton s1 = Singleton.getInstance();
Singleton s2 = Singleton.getInstance();
System.out.println(s1 == s2);
}
}
== 代码解读==:
- 先创建一个表示单例的类"Singleton",要求这个类只能有一个实例;
- 饿汉模式的"饿"指的是只要类被加载,实例就会立即创建,也就是说实例创建的时机比较早,后续无论怎么操作,只要严格使用getInstance,就不会出现其他实例;
- 把构造方法变成私有,此时在该类外部就无法new在这个类的实例;
- static成员表示Singleton类的唯一实例;
- static和类相关,和实例无关,类在内存中只有一份,static成员也就只有一份.
那么饿汉模式是否是线程安全的呢?
导致线程不安全的情况如下:
- 线程的调度是抢占式执行;
- 修改操作不是原子的;
- 多线程同时修改同一个变量;
- 内存可见性;
- 指令重排序.
对于饿汉模式来说,多线程同时调用getInstance,由于getInstance只做了一件事:读取 instance实例的地址,多个线程在同时读同一个变量,根据上述的情况,不符合多线程同时修改同一个变量,因此饿汉模式是线程安全的!
2.2 懒汉模式
类加载的时候,没有立刻实例化,第一次调用getInstance的时候,才真的实例化(“延时加载”);如果代码一整场都没有调用getInstance,此时实例化的过程就被省略了;一般认为,懒汉模式比饿汉模式效率更高,懒汉模式有很大的可能是"实例用不到",此时就节省了实例化的开销.饿汉模式和懒汉模式的区别可以用以下的例子来说明:
各种编译器,有两种主要的打开方式
1.记事本–>饿汉模式:此时会尝试把整个文件的内容都读取到内存中,然后再展示给用户;
2.vscode–>懒汉模式:只会把当前这一个屏幕内的内容(以及周围的一小点内容)加载到内存中,随着翻页,会继续加载内存.
2.2.1 单线程版本
代码如下:
public class ThreadDemo {
//Singleton 类被加载的时候,不会立即实例化,
//等到第一次使用这个实例的时候再实例化.
static class Singleton {
private Singleton() {}
private static Singleton instance = null;
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
}
代码解读:
对于懒汉模式来说,多线程同时调用getInstance,做了四件事:
- 读取instance的内容;
- 判断instance是否为null;
- 如果为空,就new一个实例;
- 返回实例的地址.
***当new一个实例的时候,就会修改instance的值,因此懒汉模式是线程不安全的,***只有在实例化之前调用,存在线程安全问题,如果要是已经把实例创建好了,后面再去并发调用getInstance就是线程安全的.具体流程如下所示:
2.2.2 多线程版本–1
以下有两种实现方式:
方式一:
public class ThreadDemo {
static class Singleton {
private Singleton() { }
private static Singleton instance = null;
public static Singleton getInstance() {
//这样加锁是可以保证原子性的
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
return instance;
}
}
}
方式二:
public class ThreadDemo {
static class Singleton {
private Singleton() { }
private static Singleton instance = null;
synchronized public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
}
代码解读:
- 方式一和方式二的区别是return是在锁内部完成的还是在锁外部完成的,因为return只是进行读操作,因此放在内部还是外部都不会影响结果;
- 虽然两种方式都可以,但是方式一的锁粒度(锁中包含的代码越多,就认为粒度越大)比较小,锁粒度越大,说明这段代码的并发能力越受限.
方式一的时间线如下:
2.2.3 多线程版本–2
对于懒汉模式来说,当实例被创建之前,存在线程不安全问题,但是一旦实例创建好之后,此时就不再涉及线程安全问题,在上面的代码中,哪怕是实例已经创建好了,每次调用getInstance还是会涉及加锁解锁,而这里的加锁解锁其实已经不必要了,只要代码中涉及到锁,基本上就和高性能无缘了!!!因此,只有在实例化之前调用的时候加锁,后面就不用加锁,实现方式如下:
public class ThreadDemo {
static class Singleton {
private Singleton() { }
private static Singleton instance = null;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
}
此时的时间线如图所示:
截止目前,此代码还有一个致命的问题:当线程二两次进行读instance的时候,或者进行多次读操作时,可能会被编译器优化,也就是说可能只有第一次读才是从内存中读,后续的读操作就直接从CPU中读取寄存器(和上次结果一致),这样的结果就是线程一修改后,线程二没有读到最新的值,毕竟多线程在调用getInstance时,先加锁的线程在修改,后加锁的线程在读取,这是内存可见性问题.
为了解决上述问题,可以稍作一下变动:
volatile的作用:
- 保持内存可见性;
- 相当于强行禁止编译器对读操作的优化,虽然牺牲了性能,但是保证了结果的正确性;
3 总结
为了保证线程安全,涉及到三个要点:
- 加锁->保证线程安全;
- 双重if->保证效率;
- volatile->避免内存可见性引来的问题.