性能文章>自省|探讨线程间入门级协作的19个问题>

自省|探讨线程间入门级协作的19个问题原创

1年前
337524
大家好,我是石页兄

一、前情概要

《并发之道:三大并发问题与 JMM 何干?》中,在讨论【编程语言如何解决并发问题】的话题时,有聊到编程语言面对并发的三大问题,它所做的是对多线程的通信、同步机制进行包装,为开发者提供使用轻便、功能丰富的多线程编程 API。而 API 只是上层工具,本质是要选择合适的多线程通信、同步机制:

  • 线程间通信:线程间交换信息的机制
  • 线程间同步:控制不同线程之间操作发生相对顺序的机制
图片

从上图可知,通过消息传递或者共享内存都可以实现线程间的通信、同步,不同的语言采用的方式可能不同,而 JAVA 采用的是共享内存的方式。

有个朋友看到这个之后有来探讨这个知识点,表示同步理解起来比较容易,比如加锁保持逻辑串行。可是这里所描述的【通信】则有点模糊,不太能 get,于是两人之间讨论一番,之后将讨论内容整理形成本篇。

二、入门级的线程间协作

  • 问题:线程之间通信是干嘛,日常工作中用的着他嘛?

    操作系统中线程间协作的核心是通信,而这个通信就是上边所描述的,线程之间交换、传递信息的机制。

  • 问题:怎么又冒出来个线程间协作,是什么的?

    在操作系统的概念定义中,线程协作是指多个线程之间相互协作,共同完成任务。比如生产者消费者场景下,一个线程负责生产数据,另一个线程负责消费数据。消费者线程在消费数据前,必须等待生产者线程把数据准备好。

  • 问题:这听起来跟线程间互同步的例子有点类似呢?这两者是一回事呢?

    Java 中,线程间的通信与同步是你中有我的关系,即线程间的通信,要在同步逻辑内(持锁后、释放锁之前)实现。

  • 问题:是不是说通信方法的调用要要在synchronized内?

    是的,Java中最早期提供的同步机制就是内置锁synchronized,在synchronized中通过Object对象提供的wait()方法以及notify()/notifyAll()方法实现线程间的通信。另外随着JDK不断地升级,这个内置锁的性能也越来越好。

  • 问题:Objectwait干嘛用?

    如果当前线程在等待满足某个条件后,才能继续执行,但是又必须依赖其它线程来触发条件;这种情况下需要考虑调用wait()方法,将当前线程挂起,等待条件满足后再继续执行。使用时的关键逻辑是:

    1. 先进入同步代码块,即拿到锁
    2. 调用 xxx.wait(); 把当前线程放入锁对象 xxx 的 wait set中挂起,之后释放锁( sleep()方法也能挂起线程,但挂起后不会释放锁)
    3. 等待别的线程调用 通知方法给信号后,刚执行 wait的线程 从锁对象 xxx 的 wait set 中移除,放入锁池队列中,被系统调度唤醒后重新持有锁,继续执行 wait之后的代码
    4. 通知方法 包括 notifynotifyAll,注意 interrupt也基于通知的能力
  • 问题:这里wait set和锁池队列都是什么呢?

    这里暂且先简单理解为两个不同状态标识的集合,JVM 将线程对象在这些集合之间迁移以辅助完成线程的调度执行以及状态的变更,具体的细节后续结合内部实现原理以及线程状态设计等内容再聊。

  • 问题:Objectnotify /notifyall怎么用?

    notify /notifyall用于通知执行wait后挂起等待的线程醒来,继续执行后续逻辑。其使用时的关键逻辑是:

    1. 先进入同步代码块,即拿到锁
    2. notify /notifyall给等待中的线程发信号,区别在于: notify只从 wait set中移动一个线程到锁池中, notifyAll是将 wait set中的全部线程都移到锁池中。
    3. 之后继续执行 notify/notifyAll 后的方法,直到退出同步代码块后才释放锁。这里很关键,容易踩坑的点是: 发送通知后,线程不会终止执行,而是继续执行,所以后续的代码可能还会修改竞态条件
  • 问题:什么是竞态条件?

    竞态条件是指在并发环境中,当有多个线程同时访问同一个临界资源时,由于多个线程的并发执行顺序的不确定,从而导致程序输出结果的不确定,这种情况我们称之为竞态条件 (Race Conditions)

    比如 if (a > 0),a > 0 就是竞态条件,只有当 a > 0 时才执行什么逻辑,那么 a 的值如果被修改成 < 0 ,就可以理解为上述的修改竞态条件。

  • 问题:wait的线程醒来后,就继续执行,它都不再确认一下条件是否真的满足嘛?

    朋友的这个问题,真的是关键中的关键,从注释中寻找线索

    As in the one argument version, interrupts and spurious wakeups are possible, and this method should always be used in a loop:
      synchronized (obj) {
          while (<condition does not hold>)
              obj.wait();
          ... // Perform action appropriate to condition
      }

    注释spurious wakeups是虚假唤醒的意思,什么是虚假唤醒呢?

    虚假唤醒是一种现象,它只会出现在多线程环境中,指的是在多线程环境下,多个线程等待在同一个条件上,等到条件满足时,所有等待的线程都被唤醒,但由于多个线程执行的顺序不同,后面竞争到锁的线程在获得时间片时条件已经不再满足,线程应该继续睡眠但是却继续往下运行的一种现象。

  • 问题:听起来挺干瘪的,能模拟个示例嘛?

    所以,这里强调的重点正是问题的关注点,当线程从wait()醒来后,要继续执行之前,一定要确认一下条件是否还满足,若不满足就再等,如此才是健壮的用法。

    1. 案例 1

      public class WaitNotifyDemo {

        private volatile  boolean isEmpty= false;

        /**
         * 假唤醒实例1 ,提供消费条件,唤醒消费线程,又把消费条件给取消了,这样消费线程醒来,缺不满足条件.
         */
        public synchronized void fakeProvider1(){
            System.out.println("冒牌provider 把isEmpty 设置为true,通知后,还把信号再修改为false");
            isEmpty = true;
            notify();//发送通知后,线程不会退出
            isEmpty = false; //说不清的原因 信号又被重置了.这种情况下,consumer线程醒来后,其实isEmpty条件是不满足的,所以应该应用用while循环来判断条件
        }
        public synchronized void consumer1() throws InterruptedException {
            //使用while循环来防止假唤醒.所谓的假唤醒本质是,唤醒后不满足继续执行的条件,所以继续判断下条件是否满足,不满足就继续wait.
            while (isEmpty){
                //条件不满足,等待
                this.wait();
            }
            System.out.println("成功消费一次,isEmpty 设置为true");
            isEmpty = true;
        }
      }
    2. 案例 2

      public class WaitNotifyDemo2 {

        private volatile  boolean isEmpty= false;
        /**
         * 一个provider
         * 多个线程执行消费
         * 正常的设置消费条件.发通知
         */
        public synchronized void provider(){
            System.out.println("provider 把isEmpty 设置为true,通知所有等待线程");
            isEmpty = true;
            notifyAll();
        }

        /**
         * 多个消费者都唤醒后,其中1个消费者拿到锁,执行消费后,把状态重置了.另外一个消费者的执行条件就不满足了,要继续等.
         * @throws InterruptedException
         */
        public synchronized void consumer() throws InterruptedException {
            //使用while循环来防止假唤醒.所谓的假唤醒本质是,唤醒后不满足继续执行的条件,所以继续判断下条件是否满足,不满足就继续wait.
            while (isEmpty){
                //条件不满足,等待
                this.wait();
            }
            System.out.println("成功消费一次,isEmpty 设置为true");
            isEmpty = true;
        }
      }
  • 问题:我记得线程里还有sleep()join()yield()方法,他们也是用于线程协作嘛?

    给咱们讨论的线程协作划个边界,咱们探讨的是持锁后的线程间的协作。yield()方法通常被称为线程让步,不会释放锁,线程执行了yield()方法后,就会从运行状态转换到就绪状态。sleep()会使线程进入阻塞状态,也不会释放锁。调用目标线程实例的join()方法后,会阻塞当前线程直到目标线程中run方法运行结束。

  • 问题:Java中的同步机制不还有 JUC 中显式的 xxxLock 嘛?

    是的,Java中提供的同步机制,大致分两类,一类是内置锁synchronized,另一类就是 JUC 中的显式锁,对于显式锁的场景,则是通过Condition对象的await()方法和signal()/signaAll()方法来实现线程间的通信,同时在其之上有封装了许多更高阶的 API,更方便复杂场景的使用,其内容挺多,都柔在一篇中不合适;所以后续咱们再聊。

    三、实战何时使用setDaemon、 interrupt 和 join ?

    此话题的初始示例如下:

public static void main(String[] args) {
    new Thread(()->{
        try {
            TimeUnit.SECONDS.sleep(10);
            for(int i =0;i<4;i++) {
                System.out.println(LocalDateTime.now() + " myThread exit");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }).start(); //调用start 来 启动子线程

    //主线程继续同时向下执行
    System.out.printf(LocalDateTime.now() + " main thread exit");
}

打印结果:

2022-12-11T13:17:47.939 main thread exit2022-12-11T13:17:57.877 myThread exit
2022-12-11T13:17:57.878 myThread exit
2022-12-11T13:17:57.878 myThread exit
2022-12-11T13:17:57.878 myThread exit
Process finished with exit code 0
    • 问题:朋友问为啥主线程没有立即退出?

      如果把子线程设置为 deamon Thread,则主线程退出后进程就退出了。

      public static void main(String[] args) {
          Thread thread = new Thread(() -> {
              try {
                  TimeUnit.SECONDS.sleep(10);
                  for (int i = 0; i < 4; i++) {
                      System.out.println(LocalDateTime.now() + " myThread exit");
                  }
              } catch (InterruptedException e) {
                  e.printStackTrace();
              }
          });
          thread.setDaemon(true);//设置为 deamon
          thread.start(); //调用start 来 启动子线程

          //主线程继续同时向下执行
          System.out.printf(LocalDateTime.now() + " main thread exit");
      }

      从结果看主线程是立即结束,进程快速退出了。

      2022-12-11T13:32:58.154 main thread exit
      Process finished with exit code 0
    • 问题:为何如此?

      setDaemon(boolean on)方法的注释可知,所有非 deamon 线程都退出时,进程才退出,也就是说运行中的 deamon 线程不能阻止进程的退出。

      Marks this thread as either a daemon thread or a user thread. The Java Virtual Machine exits when the only threads running are all daemon threads.

    • 问题:线程进入sleep 暂定执行状态,干嘛非要被 interrupt 啊?

      java.lang.Thread.sleep(xxx)方法(注意是类方法),作用是使当前正在执行的线程暂停指定的时间,上述代码中,当子线程调用TimeUnit.SECONDS.sleep(10)后,子线程会被暂停执行(放弃 CPU 时间片),等 10 秒后才醒来继续执行,如果希望线程快速醒来并退出,则可将这个子线程interrupt(中断),之后子线程会快速感知到中断状态并抛出 InterruptedException 异常。

    • 问题:线程中断是什么机制呢?

      java.lang.Thread#interrupt()方法是实例级方法。当线程正在执行wait()sleep()join()方法时线程是处于【waitting】状态,可理解为内部仍会不断地检查中断状态(interrupt status)的值;interrupt方法会改变目标线程的中断状态(interrupt status);所以当被interrupt()后,则抛出InterruptedException异常。还有一个重点:::异常抛出后,线程内的中断状态会被重置为false,所以如果捕获到异常,之后要安排好退出线程的逻辑,否则就可能是 bug。有两个方式判断中断状态:

      • Thread 实例级方法java.lang.Thread#interrupted() 

        返回线程的中断状态,需特别注意调用之后还将清除中断状态(置为 false)。
      • Thread 实例级方法java.lang.Thread#isInterrupted()

    用来检查指定线程的中断状态,true 为中断状态,false 为非中断状态。

 

    • 问题sleep方法会释放 CPU 资源,那会把synchronized锁释放掉嘛?

例如 synchronized 可使用 对象实例锁 和 类锁这两种,线程中的sleep只是放弃了 CPU 时间片的使用权,但并不会释放这种同步锁。

public class SynchronizedDemo {

    // 实例方法锁 即 this 实例锁
    public synchronized String 实例方法锁(){
        return "";
    }

    // 同 this 实例锁
    public String this锁(){
        synchronized (this){
            return "";
        }
    }

    // 静态方法锁 即类锁
    public static synchronized String 静态方法锁(){
        return "";
    }

    // 类锁,同上
    public String 类方法锁(){
        synchronized (SynchronizedDemo.class){
            return  "";
        }
    }
}
    • 问题:前面除了sleep(),还提到了join(),它是用来干嘛?

      java.lang.Thread.join()方法是用于辅助线程之间协作, 通过join()方法等待目标线程终止。这里有两个关键点:一个是会等待,另一个是目标线程的终止。

    • 问题:为什么要等待目标线程终止呢?

      一般使用子线程有两种情况:

      • 一种情况是把任务交给子线程去做,并不特别关心执行的结果
      • 另外一种则是采用多线程并行的策略来提升计算效率,需要拿到各子线程计算的结果。

对于第一种情况,只要给了线程终止信号(如设置退出循环的信号、通过interrupt打断sleep,快速结束线程),当线程内的任务逻辑结束后,线程后续自然终止了。

而第二种情况是子线程计算结束后线程才处于终止状态,也即当子线程终止之后才可以取结果,而join()方法则是等待直到子线程终止后才返回,而返回之后从子线程中取出的结果才是正确的。

我是石页兄,如果这篇文章对您有帮助或启发的话,欢迎关注笔者微信公众号【 架构染色 】进行交流和学习。

 

点赞收藏
石页兄
请先登录,查看2条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步

为你推荐

聊聊数据库连接池 Druid

聊聊数据库连接池 Druid

日常Bug排查-集群逐步失去响应

日常Bug排查-集群逐步失去响应

实现定时任务的六种策略

实现定时任务的六种策略

浅析AbstractQueuedSynchronizer

浅析AbstractQueuedSynchronizer

4
2