并发编程之ThreadLocal与单例的推荐写法

× 文章目录
  1. 一.ThreadLocal的概念
    1. 1.1 ThreadLocal概念
    2. 1.2 ThreadLocal代码示例
  2. 二.单例与多线程
    1. 2.1 饿汉模式
    2. 2.2 枚举写法
    3. 2.3 懒汉式,线程不安全
    4. 2.4 懒汉式,线程安全
    5. 2.5 单例模式的两种推荐写法
      1. 2.5.1 静态内部类(static inner class)的方式
      2. 2.5.2 Dubble Check的方式
        1. 2.5.2.1 只做一次check的写法
        2. 2.5.2.2 做两次check的写法
  3. 三.参考文章

摘要:本文结合最近网关项目代码重构,总结介绍了ThreadLocal是一种多线程间并发访问变量的解决方案,用空间换时间,并用代码示例说明,还介绍了什么是单例以及单例的推荐两种写法分别是静态内部类写法和dubbl check instance的写法,扩展介绍了其它懒汉,枚举,饿汉的写法等。

一.ThreadLocal的概念

1.1 ThreadLocal概念

ThreadLocal概念:线程局部变量,是一种多线程间并发访问变量的解决方案。与其synchronized等加锁的方式不同,ThreadLocal完全不提供锁,而使用以空间换时间的手段,为每个线程提供变量的独立副本,以保障线程安全。

从性能上说,ThreadLocal不具有绝对的优势,在并发不是很高的时候,加锁的性能会更好,但作为一套与锁完全无关的线程安全解决方案,在高并发量或者竞争激烈的场景,使用ThreadLocal可以在一定程度上减少锁竞争。

ThreadLocal多线程间并发访问变量的解决方案,为每个线程提供变量的副本,用空间换时间。
因为ThreadLocal在每个线程中对该变量会创建一个副本,即每个线程内部都会有一个该变量,且在线程内部任何地方都可以使用,线程之间互不影响,这样一来就不存在线程安全问题,也不会严重影响程序执行性能。


虽然ThreadLocal能够解决上面说的问题,但是由于在每个线程中都创建了副本,所以要考虑它对资源的消耗,比如内存的占用会比不使用ThreadLocal要大。

1.2 ThreadLocal代码示例

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
31
32
33
34
35
36
37
38
39
40
41
42
43
package org.xujin.bf;
public class ConnThreadLocal {
//用ThreadLocal去存储多线程下访问的变量
public static ThreadLocal<String> th = new ThreadLocal<String>();
public void setTh(String value){
th.set(value);
}
public void getTh(){
System.out.println(Thread.currentThread().getName() + ":" + this.th.get());
}
public static void main(String[] args) throws InterruptedException {
final ConnThreadLocal ct = new ConnThreadLocal();
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
ct.setTh("张三");
ct.getTh();
}
}, "t1");
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(1000);
ct.setTh("李四");
ct.getTh();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "t2");
t1.start();
t2.start();
}
}

执行结果如下:

1
2
t1:张三
t2:李四

如上述代码所示,如果把ct.setTh(“李四”)注释,执行结果如下:

1
2
t1:张三
t2:null

PS:两个线程之前都使用了ThreadLocal包装的变量th,但是Threadlocal两个线程之间数据独立,因此t1,t2两个线程之间数据访问隔离了。

二.单例与多线程

什么是单例? 单例是应用或者系统中保证一个类仅有一个实例,并提供一个访问它的全局访问点。

单例模式,最常见的就是饥饿模式和懒汉模式,一个直接实例化对象,一个是在调用方法时进行实例化对象。在多线程模式中,考虑到性能和线程安全,我们一般选择下面两种比较经典的单例模式,在性能提高的同时,又保证线程安全。

单例推荐的写法有dubble check instancestatic inner class(静态内部类的模式),因为简单安全,源生就支持多线程,对多线程比较友好,因此推荐静态内部的写法。

2.1 饿汉模式

这种方法非常简单,因为单例的实例被声明成 static 和 final 变量了,在第一次加载类到内存中时就会初始化,所以创建实例本身是线程安全的。

1
2
3
4
5
6
7
8
9
public class Singleton{
//类加载时就初始化
private static final Singleton instance = new Singleton();
private Singleton(){}
public static Singleton getInstance(){
return instance;
}
}

这种写法如果完美的话,就没必要在啰嗦那么多双检锁的问题了。缺点是它不是一种懒加载模式(lazy initialization),单例会在加载类后一开始就被初始化,即使客户端没有调用 getInstance()方法。饿汉式的创建方式在一些场景中将无法使用:譬如 Singleton 实例的创建是依赖参数或者配置文件的,在 getInstance() 之前必须调用某个方法设置参数给它,那样这种单例写法就无法使用了。

2.2 枚举写法

用枚举写单例实在太简单了!这也是它最大的优点。下面这段代码就是声明枚举实例的通常做法。

1
2
3
public enum EasySingleton{
INSTANCE;
}

可以通过EasySingleton.INSTANCE来访问实例,这比调用getInstance()方法简单多了。创建枚举默认就是线程安全的,所以不需要担心double checked locking,而且还能防止反序列化导致重新创建新的对象。

2.3 懒汉式,线程不安全

1
2
3
4
5
6
7
8
9
10
public class Singleton {
private static Singleton instance;
private Singleton (){}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}

这段代码简单明了,而且使用了懒加载模式,但是却存在致命的问题。当有多个线程并行调用 getInstance() 的时候,就会创建多个实例。也就是说在多线程下不能正常工作。

2.4 懒汉式,线程安全

为了解决上面的问题,最简单的方法是将整个 getInstance() 方法设为同步(synchronized)。

1
2
3
4
5
6
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}

虽然做到了线程安全,并且解决了多实例的问题,但是它并不高效。因为在任何时候只能有一个线程调用 getInstance() 方法。但是同步操作只需要在第一次调用时才被需要,即第一次创建单例实例对象时。这就引出了双重检验锁。

2.5 单例模式的两种推荐写法

2.5.1 静态内部类(static inner class)的方式

个人推荐使用静态内部类的方法,这种方法也是《Effective Java》上所推荐的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package org.xujin.bf;
/**
* @author xujin
*/
public class InnerSingleton {
//使用静态内部类,构造单例
private static class Singletion {
private static Singletion single = new Singletion();
}
//用过暴露getInstance方法,return静态内部类的方式
public static Singletion getInstance(){
return Singletion.single;
}
}

这种写法仍然使用JVM本身机制保证了线程安全问题;由于 Singletion 是私有的,除了 getInstance() 之外没有办法访问它,因此它是懒汉式的;同时读取实例的时候不会进行同步,没有性能缺陷;也不依赖 JDK 版本。

2.5.2 Dubble Check的方式

理解成Dubble Check的写法之前,大家先看一下,我在Janus网关项目的中的写法,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class ConfigManager extends Observable {
private static ConfigManager instance;
private ConfigManager() {}
public static ConfigManager getInstance() {
if (instance == null) {
synchronized (ConfigManager.class) {
if (instance == null) {
instance = new ConfigManager();
}
}
}
return instance;
}
}

双重检验锁模式(double checked locking pattern),是一种使用同步块加锁的方法。程序员称其为双重检查锁,因为会有两次检查 instance == null,一次是在同步块外,一次是在同步块内。为什么在同步块内还要再检验一次?因为可能会有多个线程一起进入同步块外的 if,如果在同步块内不进行二次检验的话就会生成多个实例了。

2.5.2.1 只做一次check的写法
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class DubbleSingleton {
private static DubbleSingleton ds;
public static DubbleSingleton getDs(){
//第一次check
if(ds == null){
try {
//模拟初始化对象的准备时间...
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (DubbleSingleton.class) {
ds = new DubbleSingleton();
}
}
return ds;
}
public static void main(String[] args) {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println(DubbleSingleton.getDs().hashCode());
}
},"t1");
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println(DubbleSingleton.getDs().hashCode());
}
},"t2");
Thread t3 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println(DubbleSingleton.getDs().hashCode());
}
},"t3");
t1.start();
t2.start();
t3.start();
}
}

三个线程访问执行结果,打印出来的饿HashCode

1
2
3
189210536
1513896901
1785098644

如上述所示,只做一次check的写法,导致三个线程访问,hashCode不一致,原因没有做两次check。

2.5.2.2 做两次check的写法
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
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
package org.xujin.bf;
/**
* @author xujin
*/
public class DubbleSingleton {
private static DubbleSingleton ds;
public static DubbleSingleton getDs() {
//???check
if (ds == null) {
try {
//????????????...
Thread.sleep(3000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (DubbleSingleton.class) {
//???check,??????check?
if (ds == null) {
ds = new DubbleSingleton();
}
}
}
return ds;
}
public static void main(String[] args) {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println(DubbleSingleton.getDs().hashCode());
}
}, "t1");
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println(DubbleSingleton.getDs().hashCode());
}
}, "t2");
Thread t3 = new Thread(new Runnable() {
@Override
public void run() {
System.out.println(DubbleSingleton.getDs().hashCode());
}
}, "t3");
t1.start();
t2.start();
t3.start();
}
}

三个线程访问执行结果,打印出来的饿HashCode

1
2
3
786514993
786514993
786514993

这段代码看起来很完美,很可惜,它是有问题。主要在于ds = new DubbleSingleton();这句,这并非是一个原子操作,事实上在 JVM 中这句话大概做了下面 3 件事情。

  • 1.给 instance 分配内存
  • 2.调用 Singleton 的构造函数来初始化成员变量
  • 3.将instance对象指向分配的内存空间(执行完这步instance就为非null了)

但是在 JVM 的即时编译器中存在指令重排序的优化。也就是说上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是 1-2-3 也可能是 1-3-2。如果是后者,则在 3 执行完毕、2 未执行之前,被线程二抢占了,这时 instance 已经是非 null 了(但却没有初始化),所以线程二会直接返回 instance,然后使用,然后顺理成章地报错

但是我们只需要将 instance 变量声明成 volatile如下所示,示例代码就OK。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Singleton {
private volatile static Singleton instance; //声明成 volatile
private Singleton (){}
public static Singleton getSingleton() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}

有些人认为使用 volatile 的原因是可见性,也就是可以保证线程在本地不会存有 instance 的副本,每次都是去主内存中读取。但其实是不对的。使用 volatile 的主要原因是其另一个特性:禁止指令重排序优化。也就是说,在 volatile 变量的赋值操作后面会有一个内存屏障(生成的汇编代码上),读操作不会被重排序到内存屏障之前。比如上面的例子,取操作必须在执行完 1-2-3 之后或者 1-3-2 之后,不存在执行到 1-3 然后取到值的情况。从「先行发生原则」的角度理解的话,就是对于一个 volatile 变量的写操作都先行发生于后面对这个变量的读操作(这里的“后面”是时间上的先后顺序)。

但是特别注意在 jdk 1.5 以前的版本使用了 volatile 的双检锁还是有问题的。其原因是 Java 5 以前的 JMM (Java 内存模型)是存在缺陷的,即时将变量声明成 volatile 也不能完全避免重排序,主要是 volatile 变量前后的代码仍然存在重排序问题。这个 volatile 屏蔽重排序的问题在 jdk 1.5 中才得以修复,所以在这之后才可以放心使用 volatile

在JDK1.5及其后续版本中,扩充了volatile语义,系统将不允许对 写入一个volatile变量的操作与其之前的任何读写操作 重新排序,也不允许将 读取一个volatile变量的操作与其之后的任何读写操作 重新排序。

三.参考文章

使用单例模式需要注意的几个问题

双重检查锁定失败可能性

如果您觉得文章不错,可以打赏我喝一杯咖啡!