一文彻底搞懂线程安全问题

开发 后端
本文将基于生产者消费者模式加一个个具体案例,循序渐进的讲解线程安全问题的诞生背景以及解决方案,一文帮你抓住synchronized的应用场景,以及与Lock的区别。

前言

关于线程安全问题是一块非常基础的知识,但基础不代表简单,一个人的基本功能往往能决定他是否可以写出高质量、高性能的代码。关于什么是synchronized、Lock、volatile,相信大家都能道出一二,但概念都懂一用就懵,一不小心还能写出一个死锁出来。

本文将基于生产者消费者模式加一个个具体案例,循序渐进的讲解线程安全问题的诞生背景以及解决方案,一文帮你抓住synchronized的应用场景,以及与Lock的区别。

一. 线程安全问题的诞生背景以及解决方式

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会随机唤醒锁对应线程池区域的任意一个线程,线程被唤醒后会重新上锁,注意是随机唤醒任意一个线程

三. 由死锁问题看显示锁 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的局限性

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

责任编辑:庞桂玉 来源: Java知音
相关推荐

2020-12-07 06:19:50

监控前端用户

2021-07-08 10:08:03

DvaJS前端Dva

2022-06-07 10:13:22

前端沙箱对象

2021-06-30 08:45:02

内存管理面试

2020-03-18 14:00:47

MySQL分区数据库

2019-11-06 17:30:57

cookiesessionWeb

2024-08-08 14:57:32

2023-12-15 15:55:24

Linux线程同步

2020-12-18 09:36:01

JSONP跨域面试官

2023-11-23 06:50:08

括号

2021-08-05 06:54:05

观察者订阅设计

2023-04-12 08:38:44

函数参数Context

2023-12-12 07:31:51

Executors工具开发者

2019-12-06 09:44:27

HTTP数据安全

2022-03-24 08:51:48

Redis互联网NoSQL

2021-10-20 08:49:30

Vuexvue.js状态管理模式

2024-04-12 12:19:08

语言模型AI

2021-01-06 13:52:19

zookeeper开源分布式

2023-11-03 12:29:48

Java虚拟线程

2020-05-11 14:35:11

微服务架构代码
点赞
收藏

51CTO技术栈公众号