点击关注公众号,实用技术文章及时了解

来源:juejin.cn/post/6977173836584353822

前言

关于线程安全问题是一块非常基础的知识,但基础不代表简单,一个人的基本功能往往能决定他是否可以写出高质量、高性能的代码。关于什么是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 {//一辆车对应一个idprivate int id;//车名private String name;//车的轮子数private int wheelNumber;//标记(后面会用到)private boolean flag = false;...忽略setter、getter...@Overridepublic 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);//设置车的idr.setName("小汽车");//设置车类型r.setWheelNumber(4);//设置车的轮子数}else{r.setId(i);//设置车的idr.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);//设置车的idr.setName("小汽车");//设置车类型r.setWheelNumber(4);//设置车的轮子数}else{r.setId(i);//设置车的idr.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);//设置车的idr.setName("小汽车");//设置车的型号r.setWheel(4);//设置车的轮子数}else{r.setId(i);//设置车的idr.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);//设置车的idr.setName("小汽车");//设置车的型号r.setWhell(4);//设置车的轮子数}else{r.setId(i);//设置车的idr.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);//设置车的idr.setName("小汽车");//设置车的型号r.setWhell(4);//设置车的轮子数}else{r.setId(i);//设置车的idr.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的局限性

本想一文理清所有关于线程安全的问题,但到这发现篇幅已经很长啦,为了不影响阅读体验先到此为止吧~~

推荐

主流Java进阶技术(学习资料分享)

Java面试题宝典

加入Spring技术开发社区

PS:因为公众号平台更改了推送规则,如果不想错过内容,记得读完点一下“在看”,加个“星标”,这样每次新文章推送才会第一时间出现在你的订阅列表里。点“在看”支持我们吧!

一文彻底搞懂线程安全问题相关推荐

  1. 一文搞懂线程池原理——Executor框架详解

    文章目录 1 使用线程池的好处 2 Executor 框架 2.1 Executor 框架结构 2.2 Executor 框架使用示意图 2.3 Executor 框架成员 2.3.1 Executo ...

  2. layer output 激活函数_一文彻底搞懂BP算法:原理推导+数据演示+项目实战(下篇)...

    在"一文彻底搞懂BP算法:原理推导+数据演示+项目实战(上篇)"中我们详细介绍了BP算法的原理和推导过程,并且用实际的数据进行了计算演练.在下篇中,我们将自己实现BP算法(不使用第 ...

  3. 一文彻底搞懂前端监控 等推荐

    大家好,我是若川.话不多说,这一次花了几个小时精心为大家挑选了20余篇好文,供大家阅读学习.本文阅读技巧,先粗看标题,感兴趣可以都关注一波,一起共同进步. 前端点线面 前端点线面 百度前端研发工程师, ...

  4. opc服务器是硬件吗,opc是什么(一文彻底搞懂什么是OPC)

    原标题:(opc是什么(一文彻底搞懂什么是OPC)) opc是什么(一文完全搞懂什么是OPC)从2000年终以来,我们就一直在运用OPC软件互操纵性范例,而那些正准备踏入和想要踏入工业自动化范畴的人们 ...

  5. 一文彻底搞懂BP算法:原理推导+数据演示+项目实战(下篇)

    在"一文彻底搞懂BP算法:原理推导+数据演示+项目实战(上篇)"中我们详细介绍了BP算法的原理和推导过程,并且用实际的数据进行了计算演练.在下篇中,我们将自己实现BP算法(不使用第 ...

  6. 一文彻底搞懂Mybatis系列(十六)之MyBatis集成EhCache

    MyBatis集成EhCache 一.MyBatis集成EhCache 1.引入mybatis整合ehcache的依赖 2.类根路径下新建ehcache.xml,并配置 3.POJO类 Clazz 4 ...

  7. 一文彻底搞懂ROC曲线与AUC的概念

    一文彻底搞懂ROC曲线与AUC的概念 1. ROC曲线的初级含义 1.1 精确率和召回率 1.2 ROC曲线的含义 2. ROC曲线如何绘制 3. ROC曲线和排序有什么关联? 4. AUC和基尼系数 ...

  8. 一文快速搞懂Kudu到底是什么

    文章目录 引言 文章传送门: Kudu 介绍 背景介绍 新的硬件设备 Kudu 是什么 Kudu 应用场景 Kudu 架构 数据模型 分区策略 列式存储 整体架构 Kudu Client 交互 Kud ...

  9. 一文快速搞懂对95%置信区间的理解

    一文快速搞懂对95%置信区间的理解 综合知乎上各大神的解答和网络资料得到本文对95%置信区间的理解 先给出结论 最常出现的对置信区间的错误理解: 在95%置信区间内,有95%的概率包括真实参数  (错 ...

最新文章

  1. QIIME 2教程. 20实用程序Utilities(2021.2)
  2. 贝叶斯定理:AI 不只是个理科生 | 赠书
  3. [二分查找变形]弯曲的木杆(POJ 1905)
  4. Android中使用GridView实现标签效果源码
  5. 吴恩达 coursera ML 第六课总结+作业答案
  6. Python:windows程序打包
  7. YUV / RGB 格式及快速转换算法总结(转载)
  8. (四)Canvas API方法和属性汇总
  9. python开发范围_Python上的字母范围
  10. 用类,求三个数的最大数
  11. 数据恢复软件(绝对真实可用)
  12. Android -- Camera.ShutterCallback
  13. Guava 相关文章
  14. python Selenium启动chromedriver
  15. java get resttemplate 请求传递数组_Java面试中遇到的坑【4】
  16. phpstom可以配置php环境吗_环境配置 · PhpStorm · 看云
  17. MySQL导入sql文件的三种方法
  18. 摄影——相机的成像原理
  19. Java项目:基于SSM实现驾校预约管理系统
  20. 个性字体头像在线图片生成下载网址

热门文章

  1. 唏嘘!暴风影音官网、APP挂掉,办公地人去楼空,官方心酸回应...
  2. 中国制造特斯拉亮相 中文车尾标亮了!网友:好抠吗?
  3. 百度高级副总裁沈抖加入爱奇艺董事会 王路退出
  4. 为真全面屏探路?2019款新iPhone将采用超小前置摄像头
  5. IG击败TOP进入春季赛决赛 王思聪督战时吃玉米动作亮了
  6. 疯狂的折叠屏!不买折叠手机的5个理由
  7. 从开场白第一句到得分
  8. Linux多线程详解
  9. 产品经理之深度学习促进产品
  10. pojo类不能有默认值怎么办_打印机不能打印是什么原因 打印机不能打印处理方法介绍【详解】...