高并发编程从入门到精通(五)

1,650 阅读12分钟

面试中最常被虐的地方一定有并发编程这块知识点,无论你是刚刚入门的大四萌新还是2-3年经验的CRUD怪,也就是说这类问题你最起码会被问3年,何不花时间死磕到底。消除恐惧最好的办法就是面对他,奥利给!(这一系列是本人学习过程中的笔记和总结,并提供调试代码供大家玩耍

上章回顾

1.interrupt、isInterrupted和interrupted三个关键字的用法和区别是什么?

2.begin和end分别做了哪些操作?

3.private volatile Interruptible blocker这个参数是在哪一步设值的?

请自行回顾以上问题,如果还有疑问的自行回顾上一章哦~

本章提要

本章学习完成,你将系统地掌握synchronized关键字,并能很好的应用好这个关键字来解决一些线程安全相关的问题。(老规矩,熟悉这块的同学可以选择直接关注点赞👍完成本章学习哦!)

本章代码下载

一、什么是线程安全问题

线程安全问题就是指多线程之间由于存在共享的资源,这些共享资源在多线程之间同时被调用的时候可能会产生的数据不同步的问题。为了更清晰地了解线程安全问题,我们用一个存在线程安全问题的代码来看几个线程不安全的现象。

直接上代码

//统计当前已经有多少人点赞👍,并学习完成本章的学习工作
public class UnSafeThreadExample implements Runnable {

  private int sum = 1;
  private final static int MAX = 500;

  @Override
  public void run() {
    while (sum <= MAX) {
      System.out.println("当前已经有:" + (sum++) + "人点赞");
      try {
        Thread.sleep(100);
      } catch (InterruptedException e) {
        e.printStackTrace();
      }
    }
  }
  
  public static void main(String[] args) {
    final UnSafeThreadExample unSafeThreadExample = new UnSafeThreadExample();
    for (int i = 0; i < 5; i++) {
      new Thread(unSafeThreadExample).start();
    }
  }
  
}

多次运行我们可以归纳出以下几类问题:

1.点赞人数重复出现

如果真出现这样的情况,脑壳怕是要敲烂咯。明明有两位同学点赞了本文,可是后台却只统计了一次,这可不行。

我们先不急着去想如何解决问题,解决问题的前提是要弄明白问题是如何产生的,以免治标不治本的塑料工程

1.主存中sum=486,首先线程A发现自己线程本地工作空间中没有sum相关信息,从主存中读取sum=486

2.线程A在自己工作空间做累加操作sum+1,但是此时由于某种原因CPU执行权到了线程B手里

3.线程B发现自己本地工作空间没有sum相关值,也是从主存中读取的sum=486

4.线程B在自己的工作空间做累加操作sum+1,并完成赋值操作sum=487(这里同学们要注意⚠️累加操作并不是原子性操作,一般来说他包括累加和赋值两步

5.线程B将sum刷新到主存中,此时主存中sum=487,同时线程B完成控制台输出,整个线程生命周期结束。

6.线程B结束释放CPU执行权,此时CPU执行权又被A获取到,A继续完成之前的任务

7.线程A进行累加赋值,sum=487,同时线程B完成控制台输出。

上面就是针对重复输出同一数字过程的描述,其本质原因是因为线程之间的不可见性,由于线程A不能感知线程B已经修改了主存的sum值,导致线程A仍旧使用旧数据486来进行累加赋值和输出操作。

2.点赞人数超出上限

1.首先线程A,线程B同时读取到sum=499,并通过了while验证

2.此时线程B由于某种原因(线程切换,线程阻塞,线程休眠等)暂停了

3.线程A完成累加操作将sum=500刷新到主存并输出到控制台

4.此时线程B又获得了CPU执行权重新开始执行,此时线程由于sleep发现线程中到sum=400已经过期,需要重新从主存中获取,获取到sum=500

5.线程B执行完剩下到累加逻辑,最终刷新sum=501到主存,同时输出到控制台sum=501


以上列举了两种线程不安全到场景,一般都是由于多线程之间存在共享变量到不可见性和操作到非原子性导致的。下面我们介绍一下解决这些问题的一种方法synchronized关键字。

二、Synchronized关键字

针对这个关键字的解析我们的老方法显然已经不适用了,这里再教大家一个绝招官方文档JDK 8官方文档

这份文档每次JDK升级的时候都会修订一次,这边我们参照的还是广泛应用的JDK 8版本。

1.如何理解这个关键字

官方文档对synchronized的描述已经比较详尽了,建议同学们可以仔细阅读一下17.1章节。这里由于篇幅缘故这里就不带大家一句一句地品了,我给大家总结一下:

1.synchronized提供一种锁机制,主要针对的是监视锁,是一种排他锁的机制,保障多线程编程过程中共享变量的互斥,从而防止数据不一致的情况出现。

2.synchronized可以作用于变量、方法和类,但是不能作用于null,否则会报空指针异常。

  public static void main(String[] args) {
    UnSafeThreadExample t = null;
    synchronized (t) {
      System.out.println("made it!");
    }
  }

3.synchronized对于同一个监视锁可以获取多次。官方文档中也提供了这么一个多次调用的例子。

Example 14.19-1. The synchronized Statement
  class Test {
  
    public static void main(String[] args) {
      Test t = new Test();
      synchronized (t) {
        synchronized (t) {
          System.out.println("made it!");
        }
      }
    }
  }
This program produces the output:
made it!

当然虽然synchronized提供给我们一种比较稳妥的解决方案,但是从来没有十全十美的方案,有利必有弊,在之后的使用过程中我们会慢慢地给大家展示其弊端

2.Synchronized实战

1.改造之前的点赞程序

public class SafeThreadExample implements Runnable {

  private int sum = 1;
  private final static int MAX = 500;
  private final static Object MUTEX = new Object();

  @Override
  public void run() {
    synchronized (MUTEX) {
      while (sum <= MAX) {
        System.out.println("当前已经有:" + (sum++) + "人点赞");
        try {
          Thread.sleep(10);
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
      }
    }
  }

  public static void main(String[] args) {
    final SafeThreadExample safeThreadExample = new SafeThreadExample();
    for (int i = 0; i < 5; i++) {
      new Thread(safeThreadExample).start();
    }
  }
}

这里我们创建一个MUTEX共享对象,在线程内部通过synchronized来加锁,这样多线程中有且只有一个线程能获取到这个对象的监视锁,这样就不会出现线程不安全的情况了。但是这样同样又一个问题,我们运行程序可以发现运行速度大大下降。这就是一个使用synchronized关键字的一个弊端就是会导致我们的程序运行速度降低。

2.使用jconsole查看Synchronized运行期间各个线程的状态

还是使用点赞程序来开刀,我们先把休眠时间修改为5分钟方便我们观察

紧接着我们在控制台输入jconsole命令,打开jconsole界面。

打开我们刚刚启动的程序,同时找到我们创建的5个线程,查看对应状态。可以发现除了第一个调用了sleep处于TIMED_WAITING状态之外,其他线程全部都是状态: java.lang.Object@10eb0916上的BLOCKED, 拥有者: Thread-0由此验证了Synchronized的排他性

3.使用javap指令反汇编查看底层指令

javap不知道大家有没有用过这个指令,我这里也简单讲一下过程。

1.首先使用javac把需要反汇编的java文件生成对应的class文件

javac /Users/doudou/workspace/6m/ThreadStudy/src/com/lyf/page4/SafeThreadExample.java

2.进入生成class文件的对应目录

cd /Users/doudou/workspace/6m/ThreadStudy/src/com/lyf/page4

3.使用javap指令来对class文件进行反汇编

javap -c SafeThreadExample

反汇编结果我们截取run()部分的结果如下:

 public void run();
    Code:
       0: getstatic     #4                  // Field MUTEX:Ljava/lang/Object;
       3: dup
       4: astore_1
       5: monitorenter
       6: aload_0
       7: getfield      #3                  // Field sum:Ljava/lang/Integer;
      10: invokevirtual #5                  // Method java/lang/Integer.intValue:()I
      13: sipush        500
      16: if_icmpgt     92
      19: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
      22: new           #8                  // class java/lang/StringBuilder
      25: dup
      26: invokespecial #9                  // Method java/lang/StringBuilder."<init>":()V
      29: ldc           #10                 // String 当前已经有:
      31: invokevirtual #11                 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      34: aload_0
      35: getfield      #3                  // Field sum:Ljava/lang/Integer;
      38: astore_2
      39: aload_0
      40: aload_0
      41: getfield      #3                  // Field sum:Ljava/lang/Integer;
      44: invokevirtual #5                  // Method java/lang/Integer.intValue:()I
      47: iconst_1
      48: iadd
      49: invokestatic  #2                  // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
      52: dup_x1
      53: putfield      #3                  // Field sum:Ljava/lang/Integer;
      56: astore_3
      57: aload_2
      58: invokevirtual #12                 // Method java/lang/StringBuilder.append:(Ljava/lang/Object;)Ljava/lang/StringBuilder;
      61: ldc           #13                 // String 人点赞
      63: invokevirtual #11                 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      66: invokevirtual #14                 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      69: invokevirtual #15                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      72: getstatic     #16                 // Field java/util/concurrent/TimeUnit.MINUTES:Ljava/util/concurrent/TimeUnit;
      75: ldc2_w        #17                 // long 5l
      78: invokevirtual #19                 // Method java/util/concurrent/TimeUnit.sleep:(J)V
      81: goto          6
      84: astore_2
      85: aload_2
      86: invokevirtual #21                 // Method java/lang/InterruptedException.printStackTrace:()V
      89: goto          6
      92: aload_1
      93: monitorexit
      94: goto          104
      97: astore        4
      99: aload_1
     100: monitorexit
     101: aload         4
     103: athrow
     104: return

同学们看到这个莫慌,我们一起来品一下这段到底讲了啥。

  • 0: getstatic #4

获取MUTEX对象

  • 5: monitorenter

执行monitorenter JVM指令,获取到MUTEX监视锁

中间一大段内容讲述的是读取sum和MAX,然后进行判断累加的过程不是我们的重点,一笔带过

  • 93: monitorexit

执行monitorexit JVM指令释放MUTEX对象的监视锁

这里仔细的同学会发现有两个moniterexit指令,第100行可看到也是一个moniterexit指令,这两个指令目的是啥呢?

  • 第一个monitorexit是在程序正常退出释放锁
  • 第二个monitorexit是在发生异步退出释放锁

monitorenter

每个对象都有自己的一个监视锁,并且每个对象的监视锁在同一时间只能被一个线程获取,每个monitor都有自己的一个计数器,这里归纳一下monitorenter相关的点

1.只有在monitor计数器为0的时候表示该对象的监视锁没有被获取,此时线程才能尝试获取对应的监视锁,一旦成功就会对计数器进行累加。

2.由于Synchronized的可重入性,在线程内部已经占有的情况下再次进入会导致monitor计数器加1

monitorexit

根据happen-before原则,每次执行monitorexitJVM指令之前都必须获取到该对象到监视锁也就是每一个monitorexit之前都需要又一个monitorentermonitorext会对monitor计数器进行减1到操作。

4.死锁

用代码来体验一次死锁场景

废话不多说,线上一段代码

public class DeathLockExample {

  //点赞
  private final static Object GIVE_THE_THUMBS_UP = new Object();
  //取消点赞
  private final static Object GIVE_THE_THUMBS_OFF = new Object();

  public void up() {
    synchronized (GIVE_THE_THUMBS_UP) {
      synchronized (GIVE_THE_THUMBS_OFF) {
        System.out.println("我要给你点赞");
      }
      System.out.println("系统提示:点赞成功");
    }
  }


  public void off() {
    synchronized (GIVE_THE_THUMBS_OFF) {
      synchronized (GIVE_THE_THUMBS_UP) {
        System.out.println("我要取消给你点赞");
      }
      System.out.println("系统提示:取消点赞成功");
    }
  }

  public static void main(String[] args) {
    DeathLockExample deathLockExample = new DeathLockExample();

    new Thread(() -> {
      while (true) {
        deathLockExample.up();
      }
    }, "点赞线程").start();

    new Thread(() -> {
      while (true) {
        deathLockExample.off();
      }
    }, "取消点赞线程").start();

  }
}

这段代码的目的是开启两个线程,一个线程负责点赞,一个线程负责取消点赞,哈哈哈这里就是举一个栗子,大家可千万不要取消点赞~~😁

运行起来之后输出情况是这样的

我们发现我们的取消点赞程序根本起不来,当然同学们也可能会看到个别的点赞和取消点赞出现,但是不影响我们的结果,结果就是当前线程根本不会自己结束。这是一个比较头痛的事情,也即是我们所说的死锁的情况了。同样我们使用jconsole来查看下当前线程的运行情况。

点击检测死锁可以看到这样的结果

点赞线程当前获取了java.lang.Object@54e53d48上的监视锁,在获取java.lang.Object@46b2e8cb对象监视锁的时候发现该对象监视锁已经被占用,所以进入了阻塞队列,等待java.lang.Object@46b2e8cb监视锁被释放。取消点赞线程则反之。

阴差阳错恰巧形成了一种互相等待的状态,这就是Synchronized关键字的另一个缺陷了,可能会导致死锁。这样的死锁情况就是经典的哲学家吃面问题

扩展阅读

死锁产生的原因

1.交叉锁导致死锁

我们上面写的那个程序就是交叉锁,相互等待互相释放自己持有的锁而产生的死锁的情况。

2.内存不足

并发请求的时候系统内存不足也会导致死锁,这就是我们常见的电脑卡死情况,通过杀进程释放内存可以解决一些问题。

3.程序意外退出导致锁未能得到释放

例如你读写文件的时候,某个线程获取到了文件的write锁,由于某种原因意外崩溃退出了,但是此时write锁得不到释放,这时候也会导致死锁。

4.死循环导致死锁

原理上死循环开始是不会导致死锁的,但是由于死循环一直发生,CPU资源内吃完,其他线程得不到应该有的CPU资源,此时也会导致程序死锁。但是这种死锁是比较难以排查,他的一个特点就是CPU居高不下。


三、synchronized使用小结

1.synchronized不能作用于null对象,之前需要对对象进行初始化。

2.由于synchronized会导致线程进入阻塞状态,影响多线程下程序的运行状态。所以synchronized作用范围不能太广,需要理性使用。

3.避免交叉锁的产生,避免死锁。


好啦~本章学习已经完成,虽然是synchronized相对来说重量级比较大,但是他的排他性和可重入性在日常开发中应用十分广发哦,jdk很多源码也是应用到了这一关键字,同学们日常开发中也可以尝试着用一用,增强记忆。

最后学习完成的同学麻烦动动手指头,点点赞👍哦,祝大家身体健康,周末快乐呀~😄