查看原文
其他

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

SpringForAll 2022-07-05
关注我,回复关键字“spring”
免费领取Spring学习资料

前言

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

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

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

1.1 为什么线程间需要通信?

线程是CPU执行的基本单位,为了提高CPU的使用率以及模拟多个应用程序同时运行的场景,便衍生出了多线程的概念。

在JVM架构下堆内存、方法区是可以被线程共享的,那为什么要这样设计呢?

举个例子简要描述下:

现要做一个网络请求,请求响应后渲染到手机界面。Android为了提升用户体验将main线程当作UI线程,只做界面渲染,耗时操作应交由到工作线程。如若在UI线程执行耗时操作可能会出现阻塞现象,最直观的感受就是界面卡死。网络请求属于IO操作会出现阻塞想象,前面提到UI线程不允许出现阻塞现象,所以网络请求必须扔到工作线程,但拿到数据包后怎么传递给UI线程呢?最常规的做法就是回调接口,将HTTP数据包解析成本地模型,再通过接口将本地模型对应的堆内存地址值传递到UI线程。

如果您正在学习Spring Boot,那么推荐一个连载多年还在继续更新的免费教程:http://blog.didispace.com/spring-boot-learning-2x/

工作线程将堆内存对象地址值交给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
  • 如果您正在学习Spring Boot,那么推荐一个连载多年还在继续更新的免费教程:http://blog.didispace.com/spring-boot-learning-2x/

工人:"生产到一半你销售员就拿去卖了,这锅我不背"

解决方案:

导致原因其实就是生产者对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

如果您正在学习Spring Boot,那么推荐一个连载多年还在继续更新的免费教程:http://blog.didispace.com/spring-boot-learning-2x/

所有生产者消费者线程都被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的局限性

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

来源:juejin.cn/post/6977173

836584353822



END



SpringMVC:如何保证Controller的并发安全细节打满,IO 操作必须手动关闭?关闭流方法是否有顺序?
Spring Boot框架零入侵式注解,优雅的实现重处理功能
8 种最坑的SQL错误用法,你有没有踩过?

关注后端面试那些事,回复【2022面经】

获取最新大厂Java面经

最后重要提示:高质量的技术交流群,限时免费开放,今年抱团最重要。想进群的,关注SpringForAll社区,回复关键词:加群,拉你进群。

点击“阅读原文”领取2022大厂面经
↓↓↓ 

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存