【Java 多线程并发】 join()方法

Metadata

title: 【Java 多线程并发】 join()方法
date: 2023-07-04 23:28
tags:
  - 行动阶段/完成
  - 主题场景/程序
  - 笔记空间/KnowladgeSpace/ProgramSpace/BasicsSpace
  - 细化主题/Java
categories:
  - Java
keywords:
  - Java
description: 【Java 多线程并发】 join()方法

概述

join()方法是Thread类中的一个方法,可以由线程对象调用。
在当前线程调用join()方法时并不需要提前自己写同步代码块来获取对象锁,调用了join()方法也不会释放当前线程所持有的对象锁。

适用场景

需要等待某几件事情完成后才能继续往下执行

作用

  • 当线程任务量大时,保证 main 线程在这些线程运行结束后再结束、
  • 可控制子线程间执行顺序

注意点

  • join阻塞的是当前线程,并不是join方法的线程对象对应的线程
  • 唤醒当前线程的操作是在JVM底层实现的,并没有显式调用notifyAll()方法
  • 当前线程A在进入到线程对象B的join方法中使获取了线程对象B的锁,在join内部调用wait()方法时又会释放掉线程对象B的锁,在线程B执行完后当前线程才会再次获取线程对象B的锁
  • 线程A调用线程对象B的join方法时,只有当此时线程对象已经被启动并且还没有执行完时才会起作用

底层

通过wait和notifyall来实现线程的通信达到线程阻塞的目的;

join和wait方法的区别

  • wait方法会让当前线程释放对象锁,并进入等待状态,直到被其他线程唤醒或者超时时间到达。
  • join方法不会让当前线程释放对象锁,而是让当前线程进入到等待状态等待目标线程执行完毕或者超时时间到达。

简介

join()方法是Thread类中的一个方法,可以由线程对象调用。

在线程操作中,可以使用join()方法让一个线程强制运行,线程强制运行期间,其他线程无法运行,必须等待此线程完成之后才可以继续执行,即等待线程执行终止。在当前线程调用join()方法时并不需要提前自己写同步代码块来获取对象锁,调用了join()方法也不会释放当前线程所持有的对象锁。因为join方法在源码层面就是一个被synchronized修饰的同步方法,所以会进入到方法时会获取到对象锁的,具体在后面的源码中会讲解。

适用场景:

需要等待某几件事情完成后才能继续往下执行,比如多个线程加载资源,需要等待多个线程全部加载完毕再汇总处理。

作用:

  • 当线程任务量大时,保证 main 线程在这些线程运行结束后再结束、
  • 可控制子线程间执行顺序

Java中如何让多线程按照自己指定的顺序执行?

这个问题最简单的回答是通过Thread.join来实现,Thread.join的作用之一是用来保证线程的顺序性的。下面这段代码演示了Thread.join的作用:

public class JoinDemo extends Thread{
    int i;
    Thread previousThread; //上一个线程
    public JoinDemo(Thread previousThread,int i){
        this.previousThread=previousThread;
        this.i=i;
    }
    @Override
    public void run() {
        try {
          //调用上一个线程的join方法,大家可以自己演示的时候可以把这行代码注释掉
            previousThread.join(); 
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("num:"+i);
    }
    public static void main(String[] args) {
        Thread previousThread=Thread.currentThread();
        for(int i=0;i<10;i++){
            JoinDemo joinDemo=new JoinDemo(previousThread,i);
            joinDemo.start();
            previousThread=joinDemo;
        }
    }
}

上面的代码,注意 previousThread.join部分,大家可以把这行代码注释以后看看运行效果,在没有加join的时候运行的结果是不确定的。加了join以后,运行结果按照递增的顺序展示出来。

thread.join的含义是当前线程需要等待previousThread线程终止之后才从thread.join返回。简单来说,就是线程没有执行完之前,会一直阻塞在join方法处。

join()方法和join(long timeout)方法的使用

例子:

public static void main(String[] args){
    ...
    thread1.join();
    thread2.join();
    System.out.println("all child thread over!");
}

主线程首先会在调用thread1.join() 后被阻塞,等待thread1执行完毕后,调用thread2.join(),等待thread2 执行完毕(有可能),以此类推,最终会等所有子线程都结束后main函数才会返回。如果其他线程调用了被阻塞线程的 interrupt() 方法,被阻塞线程会抛出 InterruptedException 异常而返回。

通过上面的例子,我们就能知道join()方法是线程对象调用的,哪个线程对象调用这个方法,就会让哪个线程强制执行,也就是除了这个调用join方法的线程对象对应的线程以外,其他线程都被阻塞,等待这个被强制执行的线程执行完毕后,其他线程才能继续向下执行。

join()方法还提供可以传入时间参数的方法,即指定的等待时间内被join的线程还没执行完,就不再等待,继续向下执行。带参和不带参数方法的区别在于等待方式的不同:

  • 当调用join()方法时,当前线程会被阻塞,进入到WAITING状态(join方法就是在当前线程的环境下被另一个线程对象调用的),直到调用join方法的线程对象对应的线程执行完毕后才会继续执行。如果调用join方法的线程对象对应的线程已经执行完毕,那么当前线程会立即继续执行。
  • 当调用join(long timeout)方法时,当前线程也会被阻塞,进入到TIMED_WAITING状态,但是最多只会等待指定的时间(以毫秒为单位),如果等待的时间超过了指定的时间或者调用join方法的线程对象对应的线程已经执行完毕,那么当前线程会立即继续执行。

这里要注意,虽然是通过一个线程对象调用的join方法,但是这个join方法还是在当前线程的环境下调用的,所以其实调用join方法的线程还是当前线程,并不是那个线程对象的线程调用的join方法。

给出一个实例帮助理解:

public class JoinExample {
    private static final int TIMES = 100;
    private class JoinThread extends Thread {
        JoinThread(String name){
           super(name);
        }
        @Override
        public void run() {
            for (int i = 0; i < TIMES; i++) {
                System.out.println(getName() + " " + i);
            }
        }
    }
 
    public static void main(String[] args) {
        JoinExample example = new JoinExample();
        example.test();
    }
    private void test() {
       for (int i = 0; i < TIMES; i++) {
           if (i == 20) {
               Thread jt1 = new JoinThread("子线程1");
               Thread jt2 = new JoinThread("子线程2");
               jt1.start();
               jt2.start();
               // main 线程调用了jt1线程和jt2线程的join()方法
               // main 线程必须等到 jt1和jt2线程 执行完之后才会向下执行
               try {
                   // 需要等到jt1执行完成之后,才向下执行。在jt1没有执行完期间,其他线程无法运行。
                   jt1.join();
                   // 需要等到jt2执行完成之后,才向下执行。在jt2没有执行完期间,其他线程无法运行。
                   jt2.join();
                   // join还存在可以传入时间参数的方法:join(long mills) - 等待时间内被join的线程还没执行,就不再等待,继续向下执行
               } catch (InterruptedException e) {
                   e.printStackTrace();
               }
           }
           System.out.println(Thread.currentThread().getName() + "  " + i);
        }
    }
}

方法源码

上面的例子中主线程是如何被阻塞的?又是通过什么方法唤醒的呢?下面我们就通过源码来看看Thread.join方法做了什么事情。

在底层源码不管是无参数,还是有参数的join都是使用的同一个带参源码方法。

无参数方法

// 无参数join()方法
public final void join() throws InterruptedException {
    // 不指定等待时间,就传入0,就会一直等待调用的线程执行完后才会继续向下执行
    join(0);
}

有参数方法

join源码底层本质是利用当前线程的线程对象的isAlive()方法和wait()方法实现的。源码如下:

// join方法向外抛出了异常,所以使用join方法需要在外层处理异常或者继续向上抛出异常
// 我们可以看到这个方法是被synchronized修饰的方法,假设当前线程是A,线程A执行了线程对象B的join方法,
// 那么线程A进入到join方法中,就会获取到对象B的对象锁,之所以要用synchronized修饰join方法,就是为了获取B的对象锁,
// 因为join方法底层是利用wait方法实现的,而调用某个对象的wait方法需要持有该对象的锁才行
// 下面的所有讲解就是按照假设当前线程是A,线程A执行了线程对象B的join方法的前提下讲的,此时join方法就是在线程对象B中
public final synchronized void join(long millis)
    throws InterruptedException {
    // 下面整个代码环境都是在对象B中的,所以里面直接调用的方法按照Java语法规则,只要没有指定方法所在的对象,那么就都是调用的当前所在对象中的方法,也就是线程对象B中的方法
    // 获取当前时间
    long base = System.currentTimeMillis();
    long now = 0;
    // 传参小于0,非法参数,抛出异常
    if (millis < 0) {
        throw new IllegalArgumentException("timeout value is negative");
    }
    // 传入0超时时间意味着永远等待,直到B线程执行完毕
    if (millis == 0) {
        // 这个isAlive()是线程对象B中的方法
        // isAlive()方法检查的是调用该方法的线程对象对应的线程是否还在运行,也就是检查线程B(被等待线程)是否在还在运行
        while (isAlive()) {
            // 如果B线程还在执行,则当前线程A会调用wait()方法使自己阻塞(等待),直到被等待的B线程执行结束当前线程才会继续执行。
            // 下面这个wait放大,是线程对象B的wait方法,当前线程A执行了对象B的wait方法,使得当前线程阻塞等待。因为join方法被synchronized修饰,所以当前线程已经持有了对象B的锁了,才能调用B对象的wait方法
            // 传入0也表示没有等待期限,只有当某个线程调用了B对象的notify()/notifyAll()方法,才有可能会使在对象B上的等待队列中等待的线程A唤醒
            wait(0);
        }
    // 传入的超时时间不为0,意味着如果当前线程A等待了超过millis毫秒了,线程对象B对应的线程还没有执行完,那么也会自动被唤醒继续向下执行,不会一直等待了  
    } else {
        // 最多等待 millis 毫秒
        // isAlive()判断线程B是否执行完成
        while (isAlive()) {
            // 每一轮循环都会重新计算还剩下多少等待时间,用最多的等待时间减去当前的时间
            long delay = millis - now;
            // 如果delay小于等于0了,说明已经到了等待时间了,这个时候不管线程B是否执行完了,都直接跳出循环,后续会去唤醒当前线程A
            if (delay <= 0) {
                break;
            }
            // 如果此时还没有超时并且线程B没有执行完,那么就当前线程A就继续调用线程对象B的wait方法,并且传入最多还要等待的时间,来使当前线程阻塞指定的时间
            wait(delay);
            // 每一轮循环都会获取一次当前的时间
            now = System.currentTimeMillis() - base;
        }
    }
    // join方法执行到最后,JVM底层会去执行让当前线程A唤醒的操作,源码中并没有显式调用notify()/notifyAll()方法,整个唤醒操作是在JVM底层实现的
}

join()方法是用于让一个线程等待另一个线程执行完毕的方法。当一个线程A执行了另一个线程B的join()方法后,线程A将会被挂起,直到线程B执行完毕。

join()方法的底层实现原理是基于对象的wait()和notify()方法来实现的。当一个线程A执行另一个线程B的join()方法时,线程A会进入等待状态(WAITING),线程B会运行直到执行完毕。当线程B执行完毕后,JVM底层会自动调用对象的notifyAll()方法来通知所有等待在该对象上的线程,包括线程A。此时,线程A会重新进入就绪状态(在Java线程中其实就是进入到RUNNABLE状态),等待获取CPU资源继续向下执行。

注意点一:join阻塞的是当前线程,并不是join方法的线程对象对应的线程

有很多人不理解join为什么阻塞的是当前线程,而不是调用join方法的线程对象对应的线程呢?不理解的原因是阻塞当前线程A的wait()方法是放在线程对象B这个实例中被调用的,让大家误以为应该阻塞B线程。但实际上当前线程会持有线程对象B的对象的锁(因为join方法使用synchronized修饰的,所以当前线程也就获取了线程对象B的锁),在线程对象B调用的wait()方法时,而这个wait()方法的调用者线程对象B是在当前线程环境中的。所以造成当前线程阻塞。

注意点二:唤醒当前线程的操作是在JVM底层实现的,并没有显式调用notifyAll()方法

为什么线程B执行完毕就能够唤醒当前线程呢?或者说是在什么时候唤醒的?

这里我们要注意一点,被等待的线程并不会真正地调用notifyAll()方法来唤醒其他等待线程,而是由底层的JVM代码实现自动唤醒等待线程的功能。这个功能在底层被称为“monitor enter”和“monitor exit”,是由JVM来负责管理的。具体实现细节比较复杂,但是对于Java开发者来说,只需要知道在使用join()方法等待线程执行完毕时,等待的线程会被自动唤醒,不需要手动调用notify()或notifyAll()方法。在Thread类的join()方法的源码中,没有直接调用notify()或notifyAll()方法的代码。但是,join()方法的底层实现确实是基于对象的wait()和notify()方法来实现的。

如果想要知道实现唤醒的具体细节,我们就得翻jdk的源码,但是如果大家对线程有一定的基本了解的话,通过wait方法阻塞的线程,需要通过notify或者notifyall来唤醒。所以在线程执行完毕以后会有一个唤醒的操作,只不过并不是显式调用,而是在JVM底层代码实现的。

接下来在hotspot的源码中找到 thread.cpp,看看被等待线程执行结束以后有没有做相关的事情来证明我们的猜想

void JavaThread::exit(bool destroy_vm, ExitType exit_type) {
    assert(this == JavaThread::current(),  "thread consistency check");
    ...
    // Notify waiters on thread object. This has to be done after exit() is called
    // on the thread (if the thread is the last thread in a daemon ThreadGroup the
    // group should have the destroyed bit set before waiters are notified).
    ensure_join(this); 
    assert(!this->has_pending_exception(), "ensure_join should have cleared");
    ...
}

注意点三:当前线程A在进入到线程对象B的join方法中使获取了线程对象B的锁,在join内部调用wait()方法时又会释放掉线程对象B的锁,在线程B执行完后当前线程才会再次获取线程对象B的锁

在Java中,每个对象都有一个相关联的锁,也称为监视器锁。当一个线程需要访问被该锁保护的对象时,它必须先获得该锁的所有权。只有获得锁的线程才能调用wait()、notify()和notifyAll()方法。因此,在join()方法中,使用了synchronized关键字来使当前线程获取到Thread对象的锁,确保只有一个线程可以进入join()方法,避免出现竞态条件。

当前线程A调用线程对象B的join方法时,当前线程会尝试获取线程对象B的锁,获取对象B的锁成功后,就会在join方法内部会调用对象B的wait方法,此时当前线程A就会释放线程对象B的锁,使得线程A进入等待状态。此时线程B就会又获取到线程对象B的锁,当线程对象B执行完毕后,JVM底层会调用notifyAll方法唤醒所有等待线程,包括线程A,底层调用了唤醒方法后,线程B又会释放掉线程对象B的锁,此时线程A会重新获取线程对象B的锁(因为是线程A调用的线程对象B的join方法,join方法又是被synchronized修饰的方法,如果线程A唤醒后不再次持有对象B的锁,就没有办法继续在join方法内部继续向下执行了),然后继续执行后面的代码。

注意点四:线程A调用线程对象B的join方法时,只有当此时线程对象已经被启动并且还没有执行完时才会起作用

当一个线程对象被创建后,调用该线程对象的start()方法可以启动该线程,线程开始执行。而当一个线程在执行过程中调用另一个线程对象的join()方法时,会让该线程执行完毕后再继续执行当前线程。

start()方法会创建一个新的线程,并且让这个新线程执行run()方法中的代码。如果没有调用start()方法,那么线程对象B只是一个普通的对象,并没有对应的线程。如果线程对象还没有执行start()方法,那么在当前线程中调用该线程对象的join()方法不会有任何效果,因为线程还没有开始执行。这种情况下当前线程调用另一个线程对象的join()方法会使当前线程进入等待状态,直到被等待的线程结束或者超时,但是被等待的线程并没有开始执行,甚至都不存在这个线程,所以当前线程会一直处于等待状态,直到超时或者被中断。

还有一点需要注意的是,当线程A执行join()方法等待线程B执行完毕时,如果线程B已经执行完毕了,那么线程A并不会阻塞,而是直接退出join()方法,继续执行下面的代码。

所以,在使用join()方法之前,一定要确保调用了start()方法来启动相应的线程。

以上join()方法的简单实现原理。实际上,Java虚拟机在实现中还考虑了许多细节,以确保线程的正确协作和优化执行效率。

总结:

当一个线程对象调用了join方法后,它就会让当前线程(即调用join方法的线程)进入等待状态,直到这个线程对象对应的线程执行完毕为止。
join方法的底层实现原理是基于对象的wait和notify方法来实现的。在当前线程的环境下当线程对象调用了join方法时,当前线程会获取这个对象的锁,并且调用这个对象的wait方法来使当前线程阻塞等待。当这个对象对应的线程执行完毕后,JVM底层会调用notifyAll方法来唤醒所有等待在这个对象上的线程,包括当前线程。这个唤醒过程是在JVM底层实现的,作为用户的我们看不到显式的调用唤醒方法的代码。
源码中有相关的体现,可以参考Thread类中join和isAlive方法以及JVM中ensure_join和lock.notify_all方法(就是JVM中的这两个方法实现了join方法中唤醒当前线程的操作)。

总结

Thread.join其实底层是通过wait和notifyall来实现线程的通信达到线程阻塞的目的;

join和wait方法的区别是:

  • wait方法会让当前线程释放对象锁,并进入等待状态,直到被其他线程唤醒或者超时时间到达。
  • join方法不会让当前线程释放对象锁,而是让当前线程进入到等待状态等待目标线程执行完毕或者超时时间到达。