面试题八:如何写出线程安全的程序?
如何写出线程安全的程序?
一、面试官视角:这道题想考察什么?
- 是否对线程安全有初步了解
- 是否对线程安全的产生原因有思考
- 是否知道final,volatile关键字的作用
- 是否清楚JDK 1.5之前Java DCL为什么有缺陷
- 是否清楚的知道如何编写线程安全的程序
- 是否对ThreadLocal的使用注意事项有认识
二、题目剖析:
1、什么是线程安全问题?
场景:
假如有三个线程在访问CPU和内存,它们分别为:Thread1、Thread2和Thread3,每一个线程都有一个自己的内存副本(工作内存1、工作内存2和工作内存3),内存副本其实就是Java的内存模型,此时这三个线程同时触发 ++ 运算,比如Thread1先去它自己对应的工作内存1中进行 ++ 运算,比如从5++到了6,此时主内存的数值还是5,其它两个线程对应的内存副本的数值也都还是5,那么问题来了,数值还没来得及同步呢,这个时候Thread2也对自己对应的工作内存2进行 ++ 运算,从5++到了6,这个时候可能是时间片的原因或其它原因,Thread1才把它的工作内存1的数据同步到主内存中,此时主内存的数值变成了6,接下来主内存发了一个指令,要把它自己的数值刷新到其它的内存副本中,这个时候Thread1、Thread2和Thread3对应的工作内存3的数值都变成了6,那么问题又来了,Thread2它自己已经完成了一次 ++ 运算,它本身已经从5++到了6了,按理来说Thread1和Thread2都已经进行 ++ 运算了,主内存的数值应该是7,但现在的结果却还是6,这是不是就出现了不一致的情况,再比如Thread3也进行了 ++ 运算,从6++到了7,这结果也不对,应该是8,因为这三个线程都进行了 ++ 运算,应该是从5+3=8,这就是线程安全问题。
结论:线程安全问题本质上就是可变资源(内存)线程间共享的问题;不可变、不共享是没有线程安全问题的。
扩展:进程有安全问题吗?
没有;因为进程之间只共享CPU时间片(抢时间片),进程之间的内存是相互独立的,一旦进程被kill了,其所拥有的内存都会还给物理内存,但线程是存在于进程当中的,同一个进程中的线程是可以共享内存资源的。
2、如何实现线程安全?
不共享资源
可重入函数
1
2
3
4
5// 可重入函数
// 什么是可重入函数:传入一个参数进来,经过一系列的运算,再返回一个值出去,中间不会涉及到任何的外部内存的访问、修改,这就叫可重入函数,它没有副作用。
public static int addTwo(int num) {
return num + 2;
}使用ThreadLocal
上面的 ++ 运算的例子可以看出来,虽然在每一个线程当中都会去访问这个值(数值5),但最终访问的是自己的线程当中的内存副本里面的数值5。
比如下面的例子:
这个token的使用场景就是,一个服务器提供很多服务,每一个用户请求进来,都会开一个线程为他提供服务,所以每一个用户属于不同的线程,那么每一个线程去访问这个token,都是不一样的,都有一个属于自己的String类型的副本token,这样的话就不会互相干扰,这样的好处就是,因为不共享资源,所以没有线程安全的问题。
1
2
3
4
5
6
7
8
9
10final static ThreadLocal<String> token = new ThreadLocal<>();
public static void threadLocal() {
Runnable runnable = () -> {
token.set(UUID.randomUUID().toString());
...
System.out.println("e." + Thread.currentThread().getName()+":"+token.get());
};
startThread(5, runnable);
}深入:ThreadLocal剖析(ThreadLocal.java):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if(map != null) {
// key:this,就是ThreadLocal
// value:你想要传入的值
map.set(this, value);
}else {
createMap(t, value);
}
}
// ThreadLocalMap存在线程上的
ThreadLocalMap getMap(Thread t) {
return t.threadLocal;
}
// 结论:ThreadLocal本质上是一个绑定在了线程上的一个ThreadLocalMap;如果我和你的线程不一样,那么值一定是不一样的。ThreadLoalMap 与 WeakHashMap:
ThreadLocalMap WeakHashMap 对象持有 弱引用 弱引用 对象GC回收 不影响 不影响 引用清除策略 1、主动移除
2、线程退出时移除1、主动移除
2、GC 后移除(ReferenceQueue)Hash冲突 开放定址法 单链表法 Hash计算 神奇数字的倍数 对象hashCode再散列 适用场景 对象较少 通用 ThreadLocal的使用建议:
声明为全局静态final成员
ThreadLocal在一个线程当中有一个实例就够了,没必要每次创建的时候都去弄一个出来,我们知道使用ThreadLocalMap对象set()的时候,它是以ThreadLocal为key的,所以如果不断的去变化ThreadLocal的对象引用的话,那些对象set()进去就永远找不到了;如果不声明为全局静态final成员的话,还有另外一个问题就是,可见性问题。
避免存储大量对象
这是由底层的数据结构决定的
用完后及时移除对象
因为ThreadLocal本身没有监听机制
共享不可变资源
1
2
3
4
5
6
7
8
9class FinalFieldExample {
final int x;
int y;
public FinalFieldExample() {
x = 3;
y = 4;
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16static FinalFieldExample f;
static void writer() {
f = new FinalFieldExample();
}
static void reader() {
if(f != null) {
int i = f.x;
int j = f.y;
}
}
// writer 和 reader是两个线程
// 我们所期望的i 和 j的值是:i=3,j=4
// 但实际情况,有可能是i=3,j=0,这个取决于虚拟机的实现或者cpu的架构的特征,从而来决定指令会不会重排序,如果重排序的话,首先会将非final的成员,重排序到构造方法之外,如上面的示例,因为 x 是final的,所以 x 一定会在构造方法之内被赋值,但 y 是非final的,有可能构造方法执行完了,y的赋值操作还没有弄完,但在reader线程中,发现 f 已经不为null了,可以读值了,所以有可能 i=3,j=0的情况存在。
// 结论:被final修饰的变量、方法以及类,不仅不能被修改、不能被覆写、不能被继承,而且还禁止重排序。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21class Singleton {
private volatile static Singleton singleton;
private Singleton() {}
public static Singleton getInstance() {
// 判断是否为null
if(singleton == null) {
// 是null,加锁
synchronized(Singleton.class) {
// 再判断是否为null
if(singleton == null) {
// 还为null,开始初始化,先调用构造方法,再赋值给singleton
singleton = new Singleton();
}
}
}
return singleton;
}
}
// jdk1.5之后,volatile语义被增强了,除了保证线程间可见性,还有禁止重排序功能,所以写DCL(单例)的时候,把volatile加上。
// 上面的示例如果不加volatile的话,有可能出现类似于重排序的问题,可能在锁里面就把singleton给赋值了,但是构造方法还没执行完,那其它线程有可能就会拿到一个没有被初始化完的singleton的引用,这样就会引发问题。共享可变资源
保证可见性(你改了,我看得到)
使用final关键字
使用volatile关键字
加锁,锁释放时会强制将缓存刷新到主内存
加锁只是对另外跟你同样争用同一个锁的那些线程才能保证可见性,而且加锁的话,会在锁释放时强制将缓存刷新到主内存当中,这就是为什么其它线程加锁了才能看到我刷新到主内存的这个值,原因就在于,它只有加锁了,它才会去从主内存当中刷新,不然的话,它有可能就拿着自己的内存副本去读了(上面的 ++ 运算示例就是因为没有加锁、没有保证可见性也没有保证操作原子性)
保证操作原子性(你在改,我就不能改,我在改,你就不能改;反例:上面的 ++ 运算,你改我也改)
加锁,保证操作的互斥性
使用CAS指令(如Unsafe.compareAndSwapInt,Unsafe不是公开的,需要用反射才能获取到)
使用原子数值类型(如AtomicInteger)
使用原子属性更新器(AtomicRerenceFieldUpdater)
禁止重排序(加不加final,那执行顺序就是不一样)
- 使用final关键字
- 使用volatile关键字
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!