• 彻底搞懂线程安全问题


    前言

    关于线程安全问题是一块非常基础的知识,但基础不代表简单,一个人的基本功能往往能决定他是否可以写出高质量、高性能的代码。关于什么是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的局限性
  • 相关阅读:
    混合模式程序集是针对“v2.0.50727”版的运行时生成的,在没有配置其他信息的情况下,无法在 4.0 运行时中加载该程序集。
    SQL中获取自增长的最大ID
    (inline)内联函数在IOS开发中的使用
    MS SQL SERVER 2005 高可用性之日志传送
    19_toast通知和notify通知 onTouch事件响应
    20 按比例设置 子控件的宽度和高度
    18_SurfaceView 其他线程绘图
    使用Microsoft Media Service实现网络影音多媒体应用系列第三篇技术要点
    使用Microsoft Media Service实现网络影音多媒体应用系列第二篇开发须知
    MVC3WIN7下的IIS7.5部署MVC3应用程序
  • 原文地址:https://www.cnblogs.com/fengyun2050/p/16211889.html
Copyright © 2020-2023  润新知