用单例模式来说线程安全

上一篇博文讲了有关java和内存那些事情,今天来延申一下,结合设计模式的单例模式,来说说线程安全那些事情。

想要解锁更多新姿势?请访问https://blog.tengshe789.tech/

单例模式

单例模式大家应该都不陌生,为了保证系统中,应用的类一个类只有一个实例。传统课本上单例模式分两种,一种饿汉式,一种懒汉式。对应的代码如下:

懒汉式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 懒汉模式
* 单例实例在第一次使用时进行创建
*/
public class SingletonExample1 {

// 私有构造函数
private SingletonExample1() {

}

// 单例对象
private static SingletonExample1 instance = null;

// 静态的工厂方法
public static SingletonExample1 getInstance() {
if (instance == null) {
instance = new SingletonExample1();
}
return instance;
}
}

懒汉式的实例是在第一次使用时创建的,相应的静态工厂办法会先判断有没有实例,没有实例在进行创建。

然而这种创建方法时线程不安全的,如果有两个线程,同一时刻拿到单例对象,要去静态工厂办法访问,由于工厂办法没有锁,那么很有可能这两个线程最终会拿到两个实例。

饿汉式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* 饿汉模式
* 单例实例在类装载时进行创建
*/
public class SingletonExample2 {

// 私有构造函数
private SingletonExample2() {

}

// 单例对象
private static SingletonExample2 instance = new SingletonExample2();

// 静态的工厂方法
public static SingletonExample2 getInstance() {
return instance;
}
}

相对于上面那种懒汉式,饿汉式是线程安全的。直接将单例对象用static修饰,把实例对象放到堆内存中,保证了多个线程在访问时的可见性。但是缺点也是很大的,正是由于把实例对象放到堆内存中,这样应用一加载就会看到对应实例,极大浪费内存。

尝试用synchronized改造懒汉式

插一句嘴,synchronized底层原理,主要是两个指令实现的,分别是monitorentermonitorexit指令,下面是我从网络上找到的对应指令的解释:

monitorenter

每个对象有一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,过程如下:

1、如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者。

2、如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1.

3.如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。

monitorexit

执行monitorexit的线程必须是objectref所对应的monitor的所有者。

指令执行时,monitor的进入数减1,如果减1后进入数为0,那线程退出monitor,不再是这个monitor的所有者。其他被这个monitor阻塞的线程可以尝试去获取这个 monitor 的所有权。

  通过这两段描述,我们应该能很清楚的看出Synchronized的实现原理,Synchronized的语义底层是通过一个monitor的对象来完成,其实wait/notify等方法也依赖于monitor对象,这就是为什么只有在同步的块或者方法中才能调用wait/notify等方法,否则会抛出java.lang.IllegalMonitorStateException的异常的原因。

毫无疑问,懒汉式的性能是出色的,我们为什么不在懒汉式的基础上使用synchronized修饰呢?

下面是相关代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 懒汉模式
* 单例实例在第一次使用时进行创建
*/
public class SingletonExample3 {

// 私有构造函数
private SingletonExample3() {

}

// 单例对象
private static SingletonExample3 instance = null;

// 静态的工厂方法
public static synchronized SingletonExample3 getInstance() {
if (instance == null) {
instance = new SingletonExample3();
}
return instance;
}
}

加了synchronized修饰后的工厂方法,意味着在同一时间内只允许一个线程访问。这毫无疑问是线程安全的。但是这同时是不被推荐的,为什么呢?和上面使用static修饰的懒汉模式不同,这个工厂方法,在同一时间段内只允许一个线程访问,极大的限制cpu资源,性能极其差!

双重同步锁单例模式

那么我们吸取上面的教训,可不可以在使用synchronized修饰基础上在加以改进呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
/**
* 懒汉模式 -》 双重同步锁单例模式
* 单例实例在第一次使用时进行创建
*/
public class SingletonExample4 {

// 私有构造函数
private SingletonExample4() {

}
// 单例对象
private static SingletonExample4 instance = null;

// 静态的工厂方法
public static SingletonExample4 getInstance() {
if (instance == null) { // 双重检测机制 // B
synchronized (SingletonExample4.class) { // 同步锁
if (instance == null) {
instance = new SingletonExample4(); // A - 3
}
}
}
return instance;
}
}

很多人会认为这种办法是最佳的解决办法了,其实不是,这也是线程不安全的。怎么说呢?

当线程进入同步锁,走到instance = new SingletonExample4();时,JVM会进行如下操作:

  1. memory = allocate() 分配对象的内存空间
  2. ctorInstance() 初始化对象
  3. instance = memory 设置instance指向刚分配的内存

单线程情况下肯定没问题,但是在多线程情况下,JVM和CPU的优化中可能会执行指令重排。上面的第二步和第三步中,由于没有前后必然关系,cpu可能随时调换第二步和第三步的执行顺序。也就是会发生132这种顺序。

单例对象 volatile + 双重检测机制

吸取教训继续改进!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* 懒汉模式 -》 双重同步锁单例模式
* 单例实例在第一次使用时进行创建
*/
public class SingletonExample5 {

// 私有构造函数
private SingletonExample5() {

}

// 1、memory = allocate() 分配对象的内存空间
// 2、ctorInstance() 初始化对象
// 3、instance = memory 设置instance指向刚分配的内存

// 单例对象 volatile + 双重检测机制 -> 禁止指令重排
private volatile static SingletonExample5 instance = null;

// 静态的工厂方法
public static SingletonExample5 getInstance() {
if (instance == null) { // 双重检测机制 // B
synchronized (SingletonExample5.class) { // 同步锁
if (instance == null) {
instance = new SingletonExample5(); // A - 3
}
}
}
return instance;
}
}

volatile的相关功能请看我之前的博客。到这里,懒汉模式改进就完成了。

枚举模式-》最安全

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* 枚举模式:最安全
*/
public class SingletonExample6 {

// 私有构造函数
private SingletonExample6() {

}

public static SingletonExample6 getInstance() {
return Singleton.INSTANCE.getInstance();
}

private enum Singleton {
INSTANCE;

private SingletonExample6 singleton;

// JVM保证这个方法绝对只调用一次
Singleton() {
singleton = new SingletonExample6();
}

public SingletonExample6 getInstance() {
return singleton;
}
}
}

最推荐的是使用枚举类实现单例模式,这是线程安全的。JVM会保证枚举类中的构造方法只调用一次,因此使用枚举会保证只实例化一次。

参考资料

Java并发编程:Synchronized及其实现原理

全片结束,觉得我写的不错?想要了解更多精彩新姿势?赶快打开我的👉个人博客 👈吧!

谢谢你那么可爱,还一直关注着我~❤😝

-------------本稿が終わる感谢您的阅读-------------