Java 线程安全

前言

关于线程安全问题是一块非常基础的知识,但基础不代表简单,一个人的基本功能往往能决定他是否可以写出高质量、高性能的代码。关于什么是synchronizedLockvolatile,相信大家都能道出一二,但概念都懂一用就懵,一不小心还能写出一个死锁出来。
这里基于生产者消费者模式加一个个具体案例,循序渐进的讲解线程安全问题的诞生背景以及解决方案,掌握synchronized的应用场景,以及与Lock的区别。

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

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

线程是CPU执行的基本单位,为了提高CPU的使用率以及模拟多个应用程序同时运行的场景,便衍生出了多线程的概念。
在JVM架构下堆内存、方法区是可以被线程共享的,那为什么要这样设计呢?
举个例子简要描述下:
现要做一个网络请求,请求响应后渲染到手机界面。Android为了提升用户体验将main线程当作UI线程,只做界面渲染,耗时操作应交由到工作线程。如若在UI线程执行耗时操作可能会出现阻塞现象,最直观的感受就是界面卡死。网络请求属于IO操作会出现阻塞想象,前面提到UI线程不允许出现阻塞现象,所以网络请求必须扔到工作线程,但拿到数据包后怎么传递给UI线程呢?最常规的做法就是回调接口,将HTTP数据包解析成本地模型,再通过接口将本地模型对应的堆内存地址值传递到UI线程。
工作线程将堆内存对象地址值交给UI线程这一过程,就是线程间通信,也是JVM将堆内存设置为线程共享的原因,关于线程间通信用一句通俗易懂的话描述就是:”多个线程操作同一资源”,这一资源位于堆内存或方法区

1.2 单生产单消费引发的安全问题

“多个线程操作同一资源”,听起来如此的简单,殊不知一不小心便可能引发致命问题。

案例

现有一个车辆公司,主要经营四轮小汽车和两轮自行车,工人负责生产,销售员负责售卖。
以上案例如何通过应用程序来实现?思路如下:
定义一个车辆资源类,可以设置为小汽车和自行车

  1. public class Resource {
  2. //一辆车对应一个id
  3. private int id;
  4. //车名
  5. private String name;
  6. //车的轮子数
  7. private int wheelNumber;
  8. //标记(后面会用到)
  9. private boolean flag = false;
  10. ...
  11. 忽略settergetter
  12. ...
  13. @Override
  14. public String toString() {
  15. return "id=" + id + "--- name=" + name + "--- wheelNumber=" + wheelNumber;
  16. }
  17. }

定义一个工人线程任务,专门用来生产四轮小汽车和俩轮自行车,为生产者

  1. public class Input implements Runnable{
  2. private Resource r;
  3. public Input(Resource r){
  4. this.r = r;
  5. }
  6. public void run() {
  7. //无限生产车辆
  8. for(int i =0;;i++){
  9. if(i%2==0){
  10. r.setId(i);//设置车的id
  11. r.setName("小汽车");//设置车类型
  12. r.setWheelNumber(4);//设置车的轮子数
  13. }else{
  14. r.setId(i);//设置车的id
  15. r.setName("电动车");//设置车类型
  16. r.setWheelNumber(2);//设置车的轮子数
  17. }
  18. }
  19. }
  20. }

定义一个销售员线程任务,专门用来销售车辆,为消费者

  1. public class Output implements Runnable{
  2. private Resource r;
  3. public Output(Resource r){
  4. this.r = r;
  5. }
  6. public void run() {
  7. //无限消费车辆
  8. for(;;){
  9. //消费车辆
  10. System.out.println(r.toString());
  11. }
  12. }
  13. }

开始生产、消费

  1. //资源对象,对应车辆
  2. Resource r = new Resource();
  3. //生产者runnable,对应工人
  4. Input in = new Input(r);
  5. //消费者runnable,对应销售员
  6. Output out = new Output(r);
  7. Thread t1 = new Thread(in);
  8. Thread t2 = new Thread(out);
  9. //开启生产者线程
  10. t1.start();
  11. //开启消费者线程
  12. t2.start();

打印结果:

  1. ...
  2. id=51--- name=电动车--- wheelNumber=2
  3. id=52--- name=小汽车--- wheelNumber=2
  4. ...

一切有条不紊的进行,老板数着钞票那叫一个开心。吃水不忘挖井人,正当老板准备给员工发奖金时,出现了一个严重问题 编号为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关键字,使得生产者一次工作结束前消费者不得介入
更改后的代码如下:

  1. #Input
  2. public void run() {
  3. //无限生产车辆
  4. for(int i =0;;i++){
  5. synchronized(r){
  6. if(i%2==0){
  7. r.setId(i);//设置车的id
  8. r.setName("小汽车");//设置车类型
  9. r.setWheelNumber(4);//设置车的轮子数
  10. }else{
  11. r.setId(i);//设置车的id
  12. r.setName("电动车");//设置车类型
  13. r.setWheelNumber(2);//设置车的轮子数
  14. }
  15. }
  16. }
  17. }
  1. #Output
  2. public void run() {
  3. for(;;){
  4. synchronized(r){
  5. //消费车辆
  6. System.out.println(r.toString());
  7. }
  8. }
  9. }

生产者和消费者for循环中都加了一个synchronized,对应的锁是r,修改后重新执行

  1. ...
  2. id=79--- name=电动车--- wheelNumber=2
  3. id=80--- name=小汽车--- wheelNumber=4
  4. id=80--- name=小汽车--- wheelNumber=4
  5. ...

一切又恢复了正常。但又暴露出一个更严重的问题,编号为80的小汽车被消费(销售)了两次
也既销售员把一辆车卖给了两个客户,真乃商业奇才啊!!!

导致原因:
  • 生产者线程得到CPU执行权,将name和wheelNumber分别设置为小汽车和4,随后CPU执行权切换到了消费者线程。
  • 消费者线程得到CPU执行权,此时name和wheelNumber别为小汽车和4,随后打印name=小汽车—- wheelNumber=4,但消费后 CPU执行权并未切换到生产者线程,而是由消费者线程继续执行,于是就出现了编号为80的小汽车被打印(消费)了两次

    解决方案:

    产生问题的原因就是消费者把资源消费后未处于等待状态,而是继续消费。此时可以引入wait、notify机制,使得销售员售卖完一辆车后处于等待状态,当工人重新生产一辆新车后再通知销售员,销售员接收到工人消息后再进行售卖。
    更改后的代码如下:

    1. #Input
    2. public void run() {
    3. //无限生产车辆
    4. for(int i =0;;i++){
    5. synchronized(r){
    6. //flag为true的时候代表已经生产过,此时将当前线程wait,等待消费者消费
    7. if(r.isFlag()){
    8. try {
    9. r.wait();
    10. } catch (InterruptedException e) {
    11. e.printStackTrace();
    12. }
    13. }
    14. if(i%2==0){
    15. r.setId(i);//设置车的id
    16. r.setName("小汽车");//设置车的型号
    17. r.setWheel(4);//设置车的轮子数
    18. }else{
    19. r.setId(i);//设置车的id
    20. r.setName("电动车");//设置车的型号
    21. r.setWheel(2);//设置车的轮子数
    22. }
    23. r.setFlag(true);
    24. //将线程池中的线程唤醒
    25. r.notify();
    26. }
    27. }
    28. }
    1. #Output
    2. public void run() {
    3. //无限消费车辆
    4. for(;;){
    5. synchronized(r){
    6. //flag为false,代表当前生产的车已经被消费掉,
    7. //进入wait状态等待生产者生产
    8. if(!r.isFlag()){
    9. try {
    10. r.wait();
    11. } catch (InterruptedException e) {
    12. e.printStackTrace();
    13. }
    14. }
    15. //消费车辆
    16. System.out.println(r.toString());
    17. r.setFlag(false);
    18. //将线程池中的线程唤醒
    19. r.notify();
    20. }
    21. }
    22. }

    打印结果:

    1. ...
    2. id=129--- name=电动车--- wheelNumber=2
    3. id=130--- name=小汽车--- wheelNumber=4
    4. id=131--- name=电动车--- wheelNumber=2
    5. ...

    这次真的没问题了,工人和销售员都如愿以偿的拿到了老板发的奖金

    注意点1:

    synchronized括号内传入的是一把锁,可以是任意类型的对象,生产者消费者必须使用同一把锁才能实现同步操作。这样设计的目的是为了更灵活使用同步代码块,否则整个进程那么多synchronized,锁谁不锁谁根本不明确

    注意点2:

    waitnotify其实是object的方法,它们只能在synchronized代码块内由锁进行调用,否则就会抛异常。每一把锁对应线程池的一块区域,被wait的线程会被放入到锁对应的线程池区域,并且释放锁。notify会随机唤醒锁对应线程池区域的任意一个线程,线程被唤醒后会重新上锁,注意是随机唤醒任意一个线程

    2、由死锁问题看显示锁 Lock 的应用场景

    2.1 何为死锁?

    关于死锁,顾名思义应该是锁死了,它可以使线程处于假死状态但又没真死,卡在半道又无法被回收。
    举个例子:

    1. class Deadlock1 implements Runnable{
    2. private Object lock1;
    3. private Object lock2;
    4. public Deadlock1(Object obj1,Object obj2){
    5. this.lock1 = obj1;
    6. this.lock2 = obj2;
    7. }
    8. public void run() {
    9. while(true){
    10. synchronized(lock1){
    11. System.out.println("Deadlock1----lock1");
    12. synchronized(lock2){
    13. System.out.println("Deadlock1----lock2");
    14. }
    15. }
    16. }
    17. }
    18. }
    19. class Deadlock2 implements Runnable{
    20. private Object lock1;
    21. private Object lock2;
    22. public Deadlock2(Object obj1,Object obj2){
    23. this.lock1 = obj1;
    24. this.lock2 = obj2;
    25. }
    26. public void run() {
    27. while(true){
    28. synchronized(lock2){
    29. System.out.println("Deadlock2----lock2");
    30. synchronized(lock1){
    31. System.out.println("Deadlock2----lock1");
    32. }
    33. }
    34. }
    35. }
    36. }
    37. #运行
    38. private static final Object lock1 = new Object();
    39. private static final Object lock2 = new Object();
    40. public static void main(String[] args) {
    41. Deadlock1 d1 = new Deadlock1(lock1,lock2);
    42. Deadlock2 d2 = new Deadlock2(lock1,lock2);
    43. Thread t1 = new Thread(d1);
    44. Thread t2 = new Thread(d2);
    45. t1.start();
    46. t2.start();
    47. }

    运行后打印结果:

    1. Deadlock1----lock1
    2. 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 多生产多消费场景的死锁如何避免?

第一小节主要是在讲单生产单消费,为了进一步提升运行效率可以适当引入多生产多消费,既多个生产者多个消费者。继续引用第一小节案例,稍作改动:

  1. //生产者任务
  2. class Input implements Runnable{
  3. private Resource r;
  4. //将i写为成员变量而不是写在for循环中是为了方便讲解下面多生产多消费的内容,没必要纠结这点
  5. private int i = 0;
  6. public Input(Resource r){
  7. this.r = r;
  8. }
  9. public void run() {
  10. //无限生产车辆
  11. for(;;){
  12. synchronized(r){
  13. //flag为true的时候代表已经生产过,此时将当前线程wait,等待消费者消费
  14. if(r.isFlag()){
  15. try {
  16. r.wait();
  17. } catch (InterruptedException e) {
  18. e.printStackTrace();
  19. }
  20. }
  21. if(i%2==0){
  22. r.setId(i);//设置车的id
  23. r.setName("小汽车");//设置车的型号
  24. r.setWhell(4);//设置车的轮子数
  25. }else{
  26. r.setId(i);//设置车的id
  27. r.setName("电动车");//设置车的型号
  28. r.setWhell(2);//设置车的轮子数
  29. }
  30. i++;
  31. r.setFlag(true);
  32. //将线程池中的线程唤醒
  33. r.notify();
  34. }
  35. }
  36. }
  37. }
  38. public static void main(String[] args) {
  39. Resource r = new Resource();
  40. Input in = new Input(r);
  41. Output out = new Output(r);
  42. Thread in1= new Thread(in);
  43. Thread in2 = new Thread(in);
  44. Thread out1 = new Thread(out);
  45. Thread out2 = new Thread(out);
  46. in1.start();//开启生产者1线程
  47. in2 .start();//开启生产者2线程
  48. out1 .start();//开启消费者1线程
  49. out2 .start();//开启消费者2线程
  50. }

运行结果:

  1. id=211--- name=自行车--- wheelNumber=2
  2. id=220--- name=小汽车--- wheelNumber=4
  3. id=220--- name=小汽车--- wheelNumber=4
  4. id=220--- name=小汽车--- wheelNumber=4
  5. ...

安全问题又产生了,编号为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,被唤醒后重新再判断标记即可。代码就不重复贴了,运行结果如下:

  1. id=0--- name=小汽车--- wheelNumber=4
  2. id=1--- name=电动车--- wheelNumber=2
  3. id=2--- name=小汽车--- wheelNumber=4
  4. id=3--- name=电动车--- wheelNumber=2
  5. 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的概念,它可以灵活的指定waitnotify的作用域,专门用来解决此类问题。
通过显示锁Lock对2.2死锁问题改进后代码如下:

  1. #生产者
  2. class Input implements Runnable{
  3. private Resource r;
  4. private int i = 0;
  5. private Lock lock;
  6. private Condition in_con;//生产者监视器
  7. private Condition out_con;//消费者监视器
  8. public Input(Resource r,Lock lock,Condition in_con,Condition out_con){
  9. this.r = r;
  10. this.lock = lock;
  11. this.in_con = in_con;
  12. this.out_con = out_con;
  13. }
  14. public void run() {
  15. //无限生产车辆
  16. for(;;){
  17. lock.lock();//获取锁
  18. //flag为true的时候代表已经生产过,此时将当前线程wait,等待消费者消费
  19. while(r.isFlag()){
  20. try {
  21. in_con.await();//跟wait作用相同
  22. } catch (InterruptedException e) {
  23. e.printStackTrace();
  24. }
  25. }
  26. if(i%2==0){
  27. r.setId(i);//设置车的id
  28. r.setName("小汽车");//设置车的型号
  29. r.setWhell(4);//设置车的轮子数
  30. }else{
  31. r.setId(i);//设置车的id
  32. r.setName("电动车");//设置车的型号
  33. r.setWhell(2);//设置车的轮子数
  34. }
  35. i++;
  36. r.setFlag(true);
  37. //将线程池中的消费者线程唤醒
  38. out_con.signal();
  39. lock.unlock();//释放锁
  40. }
  41. }
  42. }
  43. //消费者
  44. class Output implements Runnable{
  45. private Resource r;
  46. private Lock lock;
  47. private Condition in_con;//生产者监视器
  48. private Condition out_con;//消费者监视器
  49. public Output(Resource r,Lock lock,Condition in_con,Condition out_con){
  50. this.r = r;
  51. this.lock = lock;
  52. this.in_con = in_con;
  53. this.out_con = out_con;
  54. }
  55. public void run() {
  56. //无限消费车辆
  57. for(;;){
  58. lock.lock();//获取锁
  59. while(!r.isFlag()){
  60. try {
  61. out_con.await();//将消费者线程wait
  62. } catch (InterruptedException e) {
  63. e.printStackTrace();
  64. }
  65. }
  66. System.out.println(r.toString());
  67. r.setFlag(false);
  68. in_con.signal();//唤醒生产者线程
  69. lock.unlock();//释放锁
  70. }
  71. }
  72. }
  73. public static void main(String[] args) {
  74. Resource r = new Resource();
  75. Lock lock = new ReentrantLock();
  76. //生产者监视器
  77. Condition in_con = lock.newCondition();
  78. //消费者监视器
  79. Condition out_con = lock.newCondition();
  80. Input in = new Input(r,lock,in_con,out_con);
  81. Output out = new Output(r,lock,in_con,out_con);
  82. Thread t1 = new Thread(in);
  83. Thread t2 = new Thread(in);
  84. Thread t3 = new Thread(out);
  85. Thread t4 = new Thread(out);
  86. t1.start();//开启生产者线程
  87. t2.start();//开启生产者线程
  88. t3.start();//开启消费者线程
  89. t4.start();//开启消费者线程
  90. }

这次就真的没问题了。其中Lock对应synchronizedConditionLock下的监视器,每一个监视器对应一个waitnotify作用域,注释写的很清楚就不再赘述

综上所述

  • 多线程是用来提升CPU使用率的
  • 多个线程访问同一资源可能会引发安全问题
  • synchronized配合waitnotify可以解决线程安全问题
  • Lock可以解决synchronizedwaitnotify的局限性