【线程】线程安全问题及解决措施
- 前言
- 一、由“随机调度”引起的线程安全问题
- 1.1现象
- 1.2 原因
- 1.3 解决办法
- 1.4 不当加锁造成的死锁问题
- 二、由“系统优化”引起的线程安全问题
- 2.1 内存可见性问题 / 指令重排序问题
- 2.2 解决方案
前言
何为线程安全,即某段代码无论在单线程下执行、还是多线程下执行,都不会产生错误,此为线程安全;如果这个代码,在单线程下能够正确运行,在多线程下就不能正常运行,此为线程不安全或存在线程安全问题。
一、由“随机调度”引起的线程安全问题
1.1现象
首先来看一个实例:
public class ThreadDemo1 {
private static int count=0;
public static void main(String[] args) throws InterruptedException {
Thread t1 =new Thread(()->{
for(int i=0;i<50000;i++){
count++;
}
});
Thread t2=new Thread(()->{
for(int i=0;i<50000;i++){
count++;
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count++十万次后:"+count);
}
}
输出结果:
我们期望的结果是10万;
但是可以发现,不仅输出的结果不对,而且每次输出的还不一样。
1.2 原因
我们前面做过很多铺垫:
- 一个加法操作在系统中是由多个指令组成的:读取数据,加法运算,将结果写回内存;
- 多个线程在系统的执行是随机调度,抢占式执行的。
造成出现错误的直观原因呢就是一个线程进行加法操作后,还没有保存,就被另一个线程读走了。
结合两种铺垫,在系统中就可能出现各个指令的多种执行顺序,(列出四种,实际无数种),而只有前两种情况是对的。
但是这样的执行顺序又好像是在串行,跟我们要说的多线程似乎又无关了。其实不然,
Thread t1=new Thread(()->{
for(int i=0;i<50000;i++){
synchronized (locker) {
count++;
}
}
});
这样一段代码中,我们只对count++
加锁,而实际上for循环里的比较和自增我们并没有加锁,实际上实现的逻辑比较复杂的时候,我们也只需要对必要的步骤加锁,而不是对整个线程加锁,这样一来,整体的效率还是要比较高的。
那我们反过来思考一个问题,如果我这个操作在系统中只是由一个指令组成,那还会出现这样的问题吗?
显然是不会的,如果操作在系统中只有一个指令组成,就不涉及到这么多错误的执行顺序,什么顺序都是正确的。
- 所以,造成这个问题的直接原因是上述
count++
这个操作不是原子的,即这个操作是由多个指令组成的; - 那造成这个问题的根本原因还是在于系统自身随机调度 / 线程抢占式执行这样的设定。
- 其次就是代码的写法问题,如果不针对同一个变量进行运算,也不会出现这样的问题。
1.3 解决办法
充分考虑出现上述问题的原因后,我们就可以很好的解决问题:
- 针对
count++
是非原子性的操作,我们可以对这个操作(三个指令)打包到一起,称为加锁操作; - 针对随机调度这样的设定,我们没有办法更改;
- 针对代码结构这个问题,不一定能够修改。
综上,最好的解决办法就是对非原子的、可能造成问题的操作加锁。
Java中,使用synchronized(Object object){ }
对操作加锁。
public class ThreadDemo1 {
private static int count=0;
public static void main(String[] args) throws InterruptedException {
Object locker=new Object();
Thread t1 =new Thread(()->{
for(int i=0;i<50000;i++){
//注意:此处的加锁,是针对某一个对象加锁
//具体是什么对象,无所谓
synchronized (locker) {
count++;
}
}
});
Thread t2=new Thread(()->{
for(int i=0;i<50000;i++){
synchronized (locker) {
count++;
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count++十万次后:"+count);
}
}
加锁之后,运行结果就正确了。
但是,此处的加锁也是很有讲究的,必须要产生锁竞争才可以。理解了锁竞争,以下问题就很容易解决了:
- 一个线程加锁,一个线程不加锁,可以吗?不可以,不能产生锁竞争;
- 两个线程,针对不同的对象加锁,可以吗?不可以,不能产生锁竞争。
这种明显的很容易区别出来,但也有一些不容易区分是否是对同一个对象加锁的操作,比如:
- 创建一个Test对象,在Test类内对
this
加锁:
class Test {
public int count = 0;
//对this加锁,也可以写作:对非静态方法加锁
//synchronized public void add()
public void add() {
synchronized (this) {
count++;
}
}
}
public class ThreadDemo1 {
public static void main(String[] args) throws InterruptedException {
Test t = new Test();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
t.add();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
t.add();
}
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("count++十万次后:"+t.count);
}
}
此时this
都指向对象t
,是同一个对象,存在锁竞争。
- 对类对象
Test.class
加锁:
class Test {
public int count = 0;
//对类对象加锁,也可以写作:对静态方法加锁
synchronized public static void func()()
public void add() {
synchronized (Test.class) {
count++;
}
}
}
一个类的类对象只有一个,对类对象加锁,也是同一个对象,存在锁竞争。
所以,加锁的核心一定是产生锁竞争。
1.4 不当加锁造成的死锁问题
首先,理解一个概念:可重入锁。通过synchronized创建的锁就是可重入锁。
Object locker=new Object();
Thread t=new Thread(()->{
synchronized (locker){
synchronized (locker){
System.out.println("hello");
}
}
});
此代码,能正常打印出“hello”吗? 可以。
所谓可重入锁,就是在同一线程中,可以对同一对象多次加锁,不会产生阻塞。如果是不可重入锁,就会在第二次加锁时产生阻塞,卡死,出现死锁。
可重入锁的实现机制:
- 首先会记录持有锁的线程;相同的线程重复加锁就只是改动计数器,不同的线程则要阻塞等待。
- 其次在简单的不可重入锁的基础上加了一层计数器,每加一次锁,计数加一;每释放一次锁,计数减一,计数器变为0时,就将锁完全释放。
产生死锁的几种典型场景:
- 一个线程,一把锁
这种主要是针对不可重入锁,反复加锁出现的死锁; - 两个线程,两把锁
相当于A房间内锁了B的钥匙,B房间内锁了A的钥匙,产生的死锁;
public class ThreadDemo1 {
public static void main(String[] args) throws InterruptedException {
Object A=new Object();
Object B=new Object();
Thread t1=new Thread(()->{
synchronized (A){
try {
//在拿到锁A后,休眠,确保t2线程能拿到B
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (B){
System.out.println("线程t1");
}
}
});
Thread t2=new Thread(()->{
synchronized (B) {
try {
//在拿到锁B后,休眠,确保t1线程能拿到A
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (A) {
System.out.println("线程t2");
}
}
});
t1.start();
t2.start();
}
}
运行,程序卡死:
我们使用jconsole来观察线程状态,两个线程产生死锁:
简单的说就是t1拿到了A锁,t2拿到了B锁。t1此时想要在拿到B锁的条件是,t2能够释放B锁;t2想要拿到A锁的条件是,t1能够释放A锁,条件互斥,故产生死锁。
- N个线程M把锁。
经典问题:哲学家就餐问题,本质上和两个线程互相加锁一致。
此处可以将哲学家理解为线程,将筷子理解为锁。
此处是5个哲学家5根筷子(注意不是5双),每个人要想吃到食物需要拿到就近的两根筷子。(两把锁)每个哲学家除了吃食物外,还要放下筷子,思考人生。但是每个哲学家什么时候吃饭,什么时候思考人生是不确定的。(随机调度)
大多数情况下,这个模型是可以正常运行的。但是有些时候,则不行,比如所有哲学家都同时拿起左手的筷子,那么每个人都手执一根筷子,那就所有人都吃不到饭,也不会放下筷子思考人生。此时就出现了死锁。
解决这个问题,我们要先思考此处产生死锁的必要条件:
(1)互斥使用,锁的基本特征,同一把锁不能被多个线程同时持有;
(2)不可抢占,除非拿到锁的线程主动释放,别的线程不能得到这把锁;
(3)请求保持,一个线程拿到锁A之后,尝试获取锁B;
(4)循环等待,此处五个哲学家都在等下一个人放下筷子产生了死循环
所谓必要条件,就是只有四个条件都满足,才会产生死锁。前两个条件是锁的基本特征,不能够破坏;第三个条件依赖于代码结构和真实需求,不一定能破坏。那解决问题的核心就在于如何打破循环。
其实解决问题的方案有很多种:
(1)引入额外的筷子(锁)/ 去掉一个哲学家(线程)
(2)引入计数器,限制同时吃饭的人数(线程)
(3)引入加锁顺序的规则。
前两种方案虽能解决问题,且实现也较容易,但是普适性较差。
第三种方案普适性较高,此处我们可以给筷子和锁都编号,并规定:5号哲学家要先拿起右手的筷子(5号筷子),别的哲学家先拿左手的筷子。此时循环被打破,问题得以解决。
哲学家思考问题参考:哲学家就餐问题
二、由“系统优化”引起的线程安全问题
2.1 内存可见性问题 / 指令重排序问题
先看示例:
public class ThreadDemo1 {
private static int flag=0;
public static void main(String[] args) throws InterruptedException {
Thread t1=new Thread(()->{
while(flag==0){
//此处不写任何逻辑
}
});
Thread t2=new Thread(()->{
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
flag=1;
});
t1.start();
t2.start();
}
}
此处在t2线程种,将flag
设置为1后,按理说进程就应该结束了。
但实际上并没有:
此处就是JVM虚拟机/编译器对代码做出了优化,
while(flag==0){
//此处不写任何逻辑
}
这个过程中,涉及的核心指令有两条:
- 将内存中的flag读取到cpu寄存器中(load)
- 拿着寄存器的值和0比较
由于CPU的执行速度非常快,因为每次flag的值都不变、且load操作开销较大。此处JVM/编译器就将从内存中读取flag优化为从寄存器/缓存中读取flag。此时,即使在线程 t2 中修改了flag的值,也无法读取到,故称为内存可见性问题。
由于JVM虚拟机/编译器触发优化的条件我们并不可知,例如:
Thread t1=new Thread(()->{
while(flag==0){
try {
Thread.sleep(1);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
我们将 t1 线程的逻辑改为每轮循环等待1ms,此时优化就不会出现。
2.2 解决方案
我们可以利用volatile
关键字强制设置某个变量必须从内存中读取,同时禁止指令重排序来解决这个问题:
private volatile static int flag = 0;