循序渐进 帮你看穿线程安全问题
前言
关于线程安全问题是一块非常基础的知识,但基础不代表简单,一个人的基本功能往往能决定他是否可以写出高质量、高性能的代码。关于什么是synchronized
、Lock
、volatile
,相信大家都能道出一二,但概念都懂一用就懵,一不小心还能写出一个死锁出来。本文将基于生产者消费者
模式加一个个具体案例,循序渐进的讲解线程安全问题
的诞生背景以及解决方案,一文帮你抓住synchronized
的应用场景,以及与Lock
的区别。
1. 线程安全问题的诞生背景以及解决方式
1.1 为什么线程间需要通信?
线程是CPU
执行的基本单位,为了提高CPU
的使用率以及模拟多个应用程序同时运行
的场景,便衍生出了多线程的概念。
在JVM
架构下堆内存、方法区
是可以被线程共享的,那为什么要这样设计呢?
举个例子简要描述下:
现要做一个网络请求,请求响应后渲染到手机界面。
Android
为了提升用户体验将main线程
当作UI线程
,只做界面渲染,耗时操作应交由到工作线程
。如若在UI线程
执行耗时操作可能会出现阻塞现象,最直观的感受就是界面卡死
。网络请求
属于IO操作
会出现阻塞想象,前面提到UI线程
不允许出现阻塞
现象,所以网络请求必须扔到工作线程
,但拿到数据包后怎么传递给UI线程
呢?最常规的做法就是回调接口
,将HTTP数据包
解析成本地模型,再通过接口
将本地模型
对应的堆内存地址值
传递到UI线程
。工作线程
将堆内存对象地址值
交给UI线程
这一过程,就是线程间通信
,也是JVM
将堆内存
设置为线程共享的原因,关于线程间通信
用一句通俗易懂的话描述就是:"多个线程操作同一资源",这一资源位于堆内存
或方法区
1.2 单生产单消费引发的安全问题
"多个线程操作同一资源"
,听起来如此的简单,殊不知一不小心便可能引发致命问题。哟,此话怎讲呢?
,不急,容我娓娓道来...
案例
现有一个车辆公司,主要经营四轮小汽车
和两轮自行车
,工人负责生产,销售员负责售卖。
以上案例如何通过应用程序来实现?思路如下:
定义一个车辆资源类,可以设置为小汽车
和自行车
public class Resource {
//一辆车对应一个id
private int id;
//车名
private String name;
//车的轮子数
private int wheelNumber;
//标记(后面会用到)
private boolean flag = false;
...
忽略setter、getter
...
@Override
public String toString() {
return "id=" + id + "--- name=" + name + "--- wheelNumber=" + wheelNumber;
}
}
定义一个工人
线程任务,专门用来生产四轮小汽车和俩轮自行车,为生产者
public class Input implements Runnable{
private Resource r;
public Input(Resource r){
this.r = r;
}
public void run() {
//无限生产车辆
for(int i =0;;i++){
if(i%2==0){
r.setId(i);//设置车的id
r.setName("小汽车");//设置车类型
r.setWheelNumber(4);//设置车的轮子数
}else{
r.setId(i);//设置车的id
r.setName("电动车");//设置车类型
r.setWheelNumber(2);//设置车的轮子数
}
}
}
}
定义一个销售员
线程任务,专门用来销售车辆,为消费者
public class Output implements Runnable{
private Resource r;
public Output(Resource r){
this.r = r;
}
public void run() {
//无限消费车辆
for(;;){
//消费车辆
System.out.println(r.toString());
}
}
}
开始生产、消费
//资源对象,对应车辆
Resource r = new Resource();
//生产者runnable,对应工人
Input in = new Input(r);
//消费者runnable,对应销售员
Output out = new Output(r);
Thread t1 = new Thread(in);
Thread t2 = new Thread(out);
//开启生产者线程
t1.start();
//开启消费者线程
t2.start();
打印结果:
...
id=51--- name=电动车--- wheelNumber=2
id=52--- name=小汽车--- wheelNumber=2
...
一切有条不紊的进行,老板数着钞票那叫一个开心。吃水不忘挖井人,正当老板准备给员工发奖金时,出现了一个严重问题
编号为52
的小汽车少装了俩轮子!!!得,奖金不仅没了,还得连夜排查问题
导致原因:
tips:流程对应上面打印结果。下同
- 生产者线程得到CPU执行权,将
name
和wheelNumber
分别设置为电动车和2,随后CPU切换到了消费者线程。 - 消费者线程得到CPU执行权,此时
name
和wheelNumber
别为电动车
和2
,随后打印name=电动车--- wheelNumber=2
,CPU切换到了生产者线程。 - 生产者线程再次得到CPU执行权,将name设置为小汽车(未对wheelNumber进行设置),此时name和wheelNumber分别为小汽车和2,CPU切换到了消费者线程。
- 消费者线程得到CPU执行权,此时name和wheelNumber别为小汽车和2,随后打印
name=小汽车--- wheelNumber=2
工人:"生产到一半你销售员就拿去卖了,这锅我不背"
解决方案:
导致原因其实就是生产者
对Resource
的一次操作还未结束,消费者
强行介入了。此时可以引入synchronized
关键字,使得生产者
一次工作结束前消费者
不得介入
更改后的代码如下:
#Input
public void run() {
//无限生产车辆
for(int i =0;;i++){
synchronized(r){
if(i%2==0){
r.setId(i);//设置车的id
r.setName("小汽车");//设置车类型
r.setWheelNumber(4);//设置车的轮子数
}else{
r.setId(i);//设置车的id
r.setName("电动车");//设置车类型
r.setWheelNumber(2);//设置车的轮子数
}
}
}
}
#Output
public void run() {
for(;;){
synchronized(r){
//消费车辆
System.out.println(r.toString());
}
}
}
生产者和消费者for循环中都加了一个synchronized
,对应的锁是r
,修改后重新执行
...
id=79--- name=电动车--- wheelNumber=2
id=80--- name=小汽车--- wheelNumber=4
id=80--- name=小汽车--- wheelNumber=4
...
一切又恢复了正常。但又暴露出一个更严重的问题,编号为80
的小汽车被消费(销售)
了两次
也既销售员把一辆车卖给了两个客户,真乃商业奇才啊!!!
导致原因:
- 生产者线程得到CPU执行权,将
name
和wheelNumber
分别设置为小汽车
和4
,随后CPU执行权切换到了消费者线程。 - 消费者线程得到CPU执行权,此时
name
和wheelNumber
别为小汽车
和4
,随后打印name=小汽车--- wheelNumber=4
,但消费后 CPU执行权并未切换到生产者线程,而是由消费者线程继续执行,于是就出现了编号为80
的小汽车
被打印(消费)
了两次
解决方案:
产生问题的原因就是消费者把资源消费后未处于等待状态,而是继续消费。此时可以引入wait、notify
机制,使得销售员售卖完一辆车后处于等待状态,当工人重新生产一辆新车后再通知销售员,销售员接收到工人消息后再进行售卖。
更改后的代码如下:
#Input
public void run() {
//无限生产车辆
for(int i =0;;i++){
synchronized(r){
//flag为true的时候代表已经生产过,此时将当前线程wait,等待消费者消费
if(r.isFlag()){
try {
r.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
if(i%2==0){
r.setId(i);//设置车的id
r.setName("小汽车");//设置车的型号
r.setWheel(4);//设置车的轮子数
}else{
r.setId(i);//设置车的id
r.setName("电动车");//设置车的型号
r.setWheel(2);//设置车的轮子数
}
r.setFlag(true);
//将线程池中的线程唤醒
r.notify();
}
}
}
#Output
public void run() {
//无限消费车辆
for(;;){
synchronized(r){
//flag为false,代表当前生产的车已经被消费掉,
//进入wait状态等待生产者生产
if(!r.isFlag()){
try {
r.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
//消费车辆
System.out.println(r.toString());
r.setFlag(false);
//将线程池中的线程唤醒
r.notify();
}
}
}
打印结果:
...
id=129--- name=电动车--- wheelNumber=2
id=130--- name=小汽车--- wheelNumber=4
id=131--- name=电动车--- wheelNumber=2
...
这次真的没问题了,工人和销售员都如愿以偿的拿到了老板发的奖金
注意点1:
synchronized
括号内传入的是一把锁
,可以是任意类型的对象,生产者消费者必须使用同一把锁才能实现同步操作。这样设计的目的是为了更灵活使用同步代码块,否则整个进程那么多synchronized
,锁谁不锁谁根本不明确注意点2:
wait、notify
其实是object
的方法,它们只能在synchronized
代码块内由锁进行调用,否则就会抛异常。每一把锁对应线程池的一块区域,被wait
的线程会被放入到锁对应的线程池区域,并且释放锁。notify
会随机唤醒锁对应线程池区域的任意一个线程,线程被唤醒后会重新上锁,注意是随机唤醒任意一个线程
2. 由死锁问题看显示锁 Lock 的应用场景
2.1 何为死锁?
关于死锁
,顾名思义应该是锁死了
,它可以使线程处于假死状态但又没真死,卡在半道又无法被回收。
举个例子:
class Deadlock1 implements Runnable{
private Object lock1;
private Object lock2;
public Deadlock1(Object obj1,Object obj2){
this.lock1 = obj1;
this.lock2 = obj2;
}
public void run() {
while(true){
synchronized(lock1){
System.out.println("Deadlock1----lock1");
synchronized(lock2){
System.out.println("Deadlock1----lock2");
}
}
}
}
}
class Deadlock2 implements Runnable{
private Object lock1;
private Object lock2;
public Deadlock2(Object obj1,Object obj2){
this.lock1 = obj1;
this.lock2 = obj2;
}
public void run() {
while(true){
synchronized(lock2){
System.out.println("Deadlock2----lock2");
synchronized(lock1){
System.out.println("Deadlock2----lock1");
}
}
}
}
}
#运行
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public static void main(String[] args) {
Deadlock1 d1 = new Deadlock1(lock1,lock2);
Deadlock2 d2 = new Deadlock2(lock1,lock2);
Thread t1 = new Thread(d1);
Thread t2 = new Thread(d2);
t1.start();
t2.start();
}
运行后打印结果:
Deadlock1----lock1
Deadlock2----lock2
run()
方法中写的是无限循环,按理来说应该是无限打印。但程序运行后,在我没有终止控制台的情况下只打印了这两行数据。实际上这一过程引发了死锁
,具体缘由如下:
- 线程
t1
执行,判断了第一个同步代码块,此时锁lock1
可用,于是持着锁lock1
进入了第一个同步代码块,打印了:Deadlock1----lock1
,随后线程切换到了线程t2
- 线程
t2
执行,判断第一个同步代码块,此时锁lock2
可用,于是持着锁lock2
进入了第一个同步代码块,打印了:Deadlock2----lock2
,接着向下执行,判断锁lock1
不可用(因为锁lock1
已经被线程t1
所占用),于是线程t1
进行等待.随后再次切换到线程t1
- 线程
t1
执行,判断第二个同步代码块,此时锁lock2
不可用(因为所lock2
已经被线程t2
所占用),线程t1
也进入了等待状态 通过以上描述可知:线程t1
持有线程t2
需要的锁进行等待,线程t2
持有线程t1
所需要的锁进行等待,两个线程各自拿着对方需要的锁处于一种僵持现象,导致线程假死即死锁
以上案例只是死锁的一种,死锁的标准就是判断线程是否处于假死
状态
2.2 多生产多消费场景的死锁如何避免?
第一小节主要是在讲单生产单消费
,为了进一步提升运行效率可以适当引入多生产多消费
,既多个生产者多个消费者
。
继续引用第一小节案例,稍作改动:
//生产者任务
class Input implements Runnable{
private Resource r;
//将i写为成员变量而不是写在for循环中是为了方便讲解下面多生产多消费的内容,没必要纠结这点
private int i = 0;
public Input(Resource r){
this.r = r;
}
public void run() {
//无限生产车辆
for(;;){
synchronized(r){
//flag为true的时候代表已经生产过,此时将当前线程wait,等待消费者消费
if(r.isFlag()){
try {
r.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
if(i%2==0){
r.setId(i);//设置车的id
r.setName("小汽车");//设置车的型号
r.setWhell(4);//设置车的轮子数
}else{
r.setId(i);//设置车的id
r.setName("电动车");//设置车的型号
r.setWhell(2);//设置车的轮子数
}
i++;
r.setFlag(true);
//将线程池中的线程唤醒
r.notify();
}
}
}
}
public static void main(String[] args) {
Resource r = new Resource();
Input in = new Input(r);
Output out = new Output(r);
Thread in1= new Thread(in);
Thread in2 = new Thread(in);
Thread out1 = new Thread(out);
Thread out2 = new Thread(out);
in1.start();//开启生产者1线程
in2 .start();//开启生产者2线程
out1 .start();//开启消费者1线程
out2 .start();//开启消费者2线程
}
运行结果:
id=211--- name=自行车--- wheelNumber=2
id=220--- name=小汽车--- wheelNumber=4
id=220--- name=小汽车--- wheelNumber=4
id=220--- name=小汽车--- wheelNumber=4
...
安全问题又产生了,编号为211-220
的车辆未被打印,也即生产了未被消费。同时编号为220
的车辆被打印了三次。先别着急,我接着给大家分析:
生产者线程in1
得到执行权,生产了id
为211
的车辆,将flag
置为true
,循环回来再判断标记为true
,此时执wait()
方法进入等待状态生产者线程in2
得到执行权,判断标记为true
,执行wait()
方法进入等待状态。消费者线程out1
得到执行权,判断标记为true
,不进行等待而是选择了消费id
为211
的车辆,消费完毕后将标记置为false
并执行notify()
将线程池中的任意一个线程给唤醒,假设唤醒的是in1
生产者线程in1
再次得到执行权,此时生产者线程in1
被唤醒后不会判断标记而是选择生产一辆id
为1
的车辆,随后将标记置为true
并执行notify()
将线程池中任意一个线程给唤醒,假设唤醒的是in2
生产者线程in2
再次得到执行权,此时生产者线程in2
被唤醒后不会判断标记而是直接生产了一辆id
为212
的车辆,随后唤醒in1
生产id
为213
的车辆,再唤醒in2
..... 以上即为编号211-220
的车辆未被打印的原因,编号为220
车辆重复打印同理。 如何解决?其实很简单,将生产者和消费者判断flag
地方的if更改成while
,被唤醒后重新再判断标记即可。代码就不重复贴了,运行结果如下:
id=0--- name=小汽车--- wheelNumber=4
id=1--- name=电动车--- wheelNumber=2
id=2--- name=小汽车--- wheelNumber=4
id=3--- name=电动车--- wheelNumber=2
id=4--- name=小汽车--- wheelNumber=4
看起来很正常,但在我没有关控制台的情况下打印到编号为4
的车辆时停了,没错,死锁出现了,具体原因如下:
线程in1
开始执行,生产了一辆车将flag
置为true
,循环回来判断flag
进入wait()
状态,此时线程池中进行等待的线程有:in1
线程in2
开始执行,判断flag
为true
进入wait()
状态,此时线程池中进行等待的线程有:in1,in2
线程out1
开始执行,判断flag
为true
,消费了一辆汽车将flag
置为false
并唤醒一个线程,我们假定唤醒的为in1
(这里需要注意,被唤醒并不意味着会立刻执行,只是当前具备着执行资格但并不具备执行权),线程out1
循环回来判读flag
进入wait
状态,此时线程池中的线程有in2,out1
,随后out2
得到执行权线程out2
开始执行,判断标记为false
,进入等待状态,此时线程池中的线程有in2,out1,out2
线程in1
开始执行,判断标记为false
,生产了一辆汽车必将flag
置为true
并唤醒线程池中的一个线程,我们假定唤醒的是in2
,随后in1
循环判断flag
进入wait()
状态,此时线程池中的线程有in1,out1,out2
线程int2
得到执行权,判断标记为false
,进入wait()
状态,此时线程池中的线程有in1,in2,out1,out2
所有生产者消费者线程都被wait
掉了,导致了死锁现象的产生。根本原因在于生产者wait
后理应唤醒消费者,而不是唤醒生产者
,object
还有一个方法notifyAll()
,它可以唤醒锁对应线程池区域的所有线程,所以将notify
替换成notifyAll
即可解决以上死锁问题
2.3 通过 Lock 优雅的解决死锁问题
2.2
提到的notifyAll
是可以解决死锁问题,但不够优雅,因为notifyAll()
会唤醒对应线程池所有线程,单其实只需要唤醒一个即可,多了就会造成线程反复被wait
,进而会造成性能问题。所以后来Java
在1.5
版本引入了显示锁Lock
的概念,它可以灵活的指定wait、notify
的作用域,专门用来解决此类问题。
通过显示锁Lock
对2.2
死锁问题改进后代码如下:
#生产者
class Input implements Runnable{
private Resource r;
private int i = 0;
private Lock lock;
private Condition in_con;//生产者监视器
private Condition out_con;//消费者监视器
public Input(Resource r,Lock lock,Condition in_con,Condition out_con){
this.r = r;
this.lock = lock;
this.in_con = in_con;
this.out_con = out_con;
}
public void run() {
//无限生产车辆
for(;;){
lock.lock();//获取锁
//flag为true的时候代表已经生产过,此时将当前线程wait,等待消费者消费
while(r.isFlag()){
try {
in_con.await();//跟wait作用相同
} catch (InterruptedException e) {
e.printStackTrace();
}
}
if(i%2==0){
r.setId(i);//设置车的id
r.setName("小汽车");//设置车的型号
r.setWhell(4);//设置车的轮子数
}else{
r.setId(i);//设置车的id
r.setName("电动车");//设置车的型号
r.setWhell(2);//设置车的轮子数
}
i++;
r.setFlag(true);
//将线程池中的消费者线程唤醒
out_con.signal();
lock.unlock();//释放锁
}
}
}
//消费者
class Output implements Runnable{
private Resource r;
private Lock lock;
private Condition in_con;//生产者监视器
private Condition out_con;//消费者监视器
public Output(Resource r,Lock lock,Condition in_con,Condition out_con){
this.r = r;
this.lock = lock;
this.in_con = in_con;
this.out_con = out_con;
}
public void run() {
//无限消费车辆
for(;;){
lock.lock();//获取锁
while(!r.isFlag()){
try {
out_con.await();//将消费者线程wait
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(r.toString());
r.setFlag(false);
in_con.signal();//唤醒生产者线程
lock.unlock();//释放锁
}
}
}
public static void main(String[] args) {
Resource r = new Resource();
Lock lock = new ReentrantLock();
//生产者监视器
Condition in_con = lock.newCondition();
//消费者监视器
Condition out_con = lock.newCondition();
Input in = new Input(r,lock,in_con,out_con);
Output out = new Output(r,lock,in_con,out_con);
Thread t1 = new Thread(in);
Thread t2 = new Thread(in);
Thread t3 = new Thread(out);
Thread t4 = new Thread(out);
t1.start();//开启生产者线程
t2.start();//开启生产者线程
t3.start();//开启消费者线程
t4.start();//开启消费者线程
}
这次就真的没问题了。其中Lock
对应synchronized
,Condition
为Lock
下的监视器,每一个监视器对应一个wait、notify
作用域,注释写的很清楚就不再赘述
综上所述
- 多线程是用来提升CUP使用率的
- 多个线程访问同一资源可能会引发安全问题
- synchronized配合wait、notify可以解决线程安全问题
- Lock可以解决synchronized下wait、notify的局限性
已经深夜12点了...本想一文理清所有关于线程安全的问题,但到这发现篇幅已经很长啦,为了不影响阅读体验先到此为止吧~~
如果大家感兴趣欢迎评论区留言,我下篇文章再详细讲解volatile
的设计背景,以及它与synchronized
的区别
转载自:https://juejin.cn/post/6977173836584353822