MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

Java中join方法对线程执行的影响

2022-06-102.8k 阅读

Java线程基础回顾

在深入探讨 join 方法之前,我们先来回顾一下Java线程的基础知识。线程是程序执行的最小单位,Java通过 Thread 类来支持多线程编程。每个线程都有自己的执行路径,在多线程环境下,多个线程可以并发执行。

创建线程有两种常见方式:继承 Thread 类和实现 Runnable 接口。下面是两种方式的示例代码:

// 继承Thread类
class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("MyThread is running");
    }
}

// 实现Runnable接口
class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("MyRunnable is running");
    }
}

使用这两种方式创建线程并启动的代码如下:

public class ThreadCreationExample {
    public static void main(String[] args) {
        // 通过继承Thread类创建并启动线程
        MyThread myThread = new MyThread();
        myThread.start();

        // 通过实现Runnable接口创建并启动线程
        MyRunnable myRunnable = new MyRunnable();
        Thread thread = new Thread(myRunnable);
        thread.start();
    }
}

在多线程环境中,线程的执行顺序是不确定的,这是因为线程调度由操作系统的线程调度器决定。多个线程可能会竞争CPU资源,导致它们的执行顺序和我们预期的不一致。

join 方法的基本概念

join 方法是 Thread 类的一个实例方法。它的作用是使调用该方法的线程等待被调用 join 方法的线程执行完毕后,再继续执行。简单来说,就是让一个线程 “加入” 到另一个线程的执行过程中。

join 方法有三个重载版本:

  1. join():调用该方法的线程会等待被调用 join 方法的线程执行完毕。
  2. join(long millis):调用该方法的线程最多等待 millis 毫秒,如果在这期间被调用 join 方法的线程执行完毕,则调用线程继续执行;如果超过 millis 毫秒,即使被调用 join 方法的线程还未执行完毕,调用线程也会继续执行。
  3. join(long millis, int nanos):这个版本与第二个版本类似,只不过可以更加精确地指定等待时间,nanos 表示额外等待的纳秒数,取值范围是 0 - 999999。

简单示例说明 join 方法的作用

下面通过一个简单的示例来展示 join 方法如何影响线程的执行:

public class JoinExample1 {
    public static void main(String[] args) {
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                System.out.println("Thread1: " + i);
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        Thread thread2 = new Thread(() -> {
            try {
                thread1.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            for (int i = 0; i < 5; i++) {
                System.out.println("Thread2: " + i);
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        thread1.start();
        thread2.start();
    }
}

在上述代码中,thread2 调用了 thread1.join(),这意味着 thread2 会等待 thread1 执行完毕后才开始执行自己的 for 循环。运行这段代码,输出结果如下:

Thread1: 0
Thread1: 1
Thread1: 2
Thread1: 3
Thread1: 4
Thread2: 0
Thread2: 1
Thread2: 2
Thread2: 3
Thread2: 4

从输出结果可以清晰地看到,thread2 确实是在 thread1 执行完毕后才开始执行的。

join 方法原理剖析

要深入理解 join 方法的本质,我们需要查看 Thread 类的源代码。join 方法的实现依赖于 wait 方法,wait 方法是 Object 类的一个方法,它用于使当前线程等待,直到其他线程调用 notifynotifyAll 方法唤醒它。

下面是 join 方法的部分源代码(JDK 11 版本):

public final synchronized void join() throws InterruptedException {
    while (isAlive()) {
        wait(0);
    }
}

在这个实现中,while (isAlive()) 循环会一直检查被调用 join 方法的线程是否还存活。如果存活,调用 wait(0) 方法使当前线程进入等待状态,wait(0) 表示无限期等待,直到被唤醒。当被调用 join 方法的线程执行完毕,isAlive() 方法返回 false,循环结束,当前线程继续执行。

对于带有时间参数的 join 方法,其实现稍微复杂一些,但基本原理是类似的。例如 join(long millis) 方法的实现:

public final synchronized void join(long millis) throws InterruptedException {
    long base = System.currentTimeMillis();
    long now = 0;

    if (millis < 0) {
        throw new IllegalArgumentException("timeout value is negative");
    }

    if (millis == 0) {
        while (isAlive()) {
            wait(0);
        }
    } else {
        while (isAlive()) {
            long delay = millis - now;
            if (delay <= 0) {
                break;
            }
            wait(delay);
            now = System.currentTimeMillis() - base;
        }
    }
}

在这个版本中,如果 millis 为 0,就和无参数的 join 方法一样无限期等待。如果 millis 大于 0,会根据当前时间和设定的等待时间计算剩余等待时间 delay,每次调用 wait(delay) 等待 delay 时间。如果在等待过程中被唤醒或者等待时间结束,会重新计算 nowdelay,直到被调用 join 方法的线程执行完毕或者等待时间耗尽。

多线程环境下 join 方法的复杂应用

在实际的多线程编程中,join 方法经常用于更复杂的场景,例如线程之间的协作。假设有一个场景,需要多个线程完成一系列任务,并且这些任务之间有依赖关系。下面通过一个示例来展示如何使用 join 方法实现这种协作:

public class ComplexJoinExample {
    public static void main(String[] args) {
        Thread task1 = new Thread(() -> {
            System.out.println("Task1 is starting");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Task1 is completed");
        });

        Thread task2 = new Thread(() -> {
            try {
                task1.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Task2 is starting, depends on Task1");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Task2 is completed");
        });

        Thread task3 = new Thread(() -> {
            try {
                task2.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Task3 is starting, depends on Task2");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Task3 is completed");
        });

        task1.start();
        task2.start();
        task3.start();
    }
}

在这个示例中,task2 依赖于 task1 的完成,task3 依赖于 task2 的完成。通过 join 方法,我们确保了线程按照任务的依赖顺序执行。运行结果如下:

Task1 is starting
Task1 is completed
Task2 is starting, depends on Task1
Task2 is completed
Task3 is starting, depends on Task2
Task3 is completed

可以看到,任务按照预期的依赖关系依次执行。

join 方法与异常处理

在使用 join 方法时,需要注意 InterruptedException 异常。当一个线程在等待另一个线程执行完毕(即调用 join 方法)时,如果当前线程被中断,join 方法会抛出 InterruptedException 异常。

下面是一个处理 InterruptedException 异常的示例:

public class JoinWithExceptionExample {
    public static void main(String[] args) {
        Thread threadToJoin = new Thread(() -> {
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Thread to join is completed");
        });

        Thread mainThread = Thread.currentThread();
        Thread joiningThread = new Thread(() -> {
            try {
                threadToJoin.join();
            } catch (InterruptedException e) {
                System.out.println("Joining thread was interrupted");
                mainThread.interrupt();
            }
        });

        joiningThread.start();

        try {
            Thread.sleep(1000);
            joiningThread.interrupt();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

在上述代码中,joiningThread 调用了 threadToJoin.join()。主线程在 joiningThread 启动 1 秒后中断它。joiningThread 捕获到 InterruptedException 异常后,打印提示信息并中断主线程。运行这个程序,输出结果可能如下:

Joining thread was interrupted

处理 InterruptedException 异常是非常重要的,它可以确保线程在被中断时能够做出正确的响应,避免线程处于不一致的状态。

join 方法与线程状态转换

理解 join 方法对线程状态转换的影响有助于我们更深入地掌握多线程编程。在Java中,线程有六种状态:NEWRUNNABLEBLOCKEDWAITINGTIMED_WAITINGTERMINATED

当一个线程调用另一个线程的 join 方法时,调用线程的状态会发生变化。如果调用的是无参数的 join 方法,调用线程会进入 WAITING 状态,直到被调用 join 方法的线程执行完毕或者被中断。如果调用的是带有时间参数的 join 方法,调用线程会进入 TIMED_WAITING 状态,在等待时间结束或者被调用 join 方法的线程执行完毕、被中断时,调用线程会重新回到 RUNNABLE 状态。

下面通过一个示例来观察线程状态的变化:

import java.util.concurrent.TimeUnit;

public class JoinAndThreadStateExample {
    public static void main(String[] args) {
        Thread threadToJoin = new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(2);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("Thread to join is completed");
        });

        Thread joiningThread = new Thread(() -> {
            try {
                System.out.println("Joining thread state before join: " + Thread.currentThread().getState());
                threadToJoin.join(1000);
                System.out.println("Joining thread state after join: " + Thread.currentThread().getState());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });

        joiningThread.start();
        try {
            TimeUnit.MILLISECONDS.sleep(500);
            System.out.println("Joining thread state during join: " + joiningThread.getState());
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

运行这段代码,输出结果如下:

Joining thread state before join: RUNNABLE
Joining thread state during join: TIMED_WAITING
Thread to join is completed
Joining thread state after join: TERMINATED

从输出结果可以看到,在调用 join 方法之前,joiningThread 的状态是 RUNNABLE;在调用 join 方法并等待过程中,状态变为 TIMED_WAITING;当 join 方法返回后,状态变为 TERMINATED(因为 joiningThread 执行完毕)。

注意事项和常见问题

  1. 死锁风险:在使用 join 方法时,如果线程之间的依赖关系形成了环,就可能导致死锁。例如,线程A等待线程B,线程B等待线程C,而线程C又等待线程A,这样就形成了死锁。编写代码时要仔细检查线程之间的依赖关系,避免出现死锁情况。
  2. 性能问题:过度使用 join 方法可能会导致性能问题。如果一个线程频繁地等待其他线程,会降低系统的并发度,影响整体性能。在设计多线程程序时,要权衡使用 join 方法的必要性,尽量采用更高效的并发设计模式。
  3. 中断处理不当:如果在处理 InterruptedException 异常时没有正确处理,可能会导致线程状态不一致或者程序逻辑错误。在捕获到 InterruptedException 异常后,通常需要根据具体情况进行适当的处理,例如清理资源、重新设置中断状态等。

与其他线程同步机制的对比

除了 join 方法,Java 还提供了其他多种线程同步机制,如 synchronized 关键字、Lock 接口、CountDownLatchCyclicBarrier 等。与这些机制相比,join 方法有其独特的应用场景。

  1. synchronized 对比synchronized 主要用于控制对共享资源的访问,防止多个线程同时访问同一资源导致数据不一致。而 join 方法关注的是线程的执行顺序,使一个线程等待另一个线程执行完毕。例如,在一个多线程访问共享数据的场景中,synchronized 可以保证数据的一致性;而在需要按顺序执行任务的场景中,join 方法更为合适。
  2. Lock 对比Lock 提供了更灵活的锁控制,如可中断的锁获取、公平锁等。但它同样是用于资源同步,而不是线程执行顺序的控制。join 方法侧重于线程之间执行顺序的协调,两者的功能和应用场景有所不同。
  3. CountDownLatch 对比CountDownLatch 可以让一个或多个线程等待一组操作完成。它和 join 方法有些相似之处,但 CountDownLatch 更灵活,它可以允许多个线程等待多个其他线程的操作完成,并且可以通过 countDown 方法动态减少计数。而 join 方法通常是一个线程等待另一个线程执行完毕。例如,在一个任务需要等待多个子任务都完成才能继续的场景中,CountDownLatch 可能更合适;而在简单的一个线程依赖另一个线程完成的场景中,join 方法更简洁。
  4. CyclicBarrier 对比CyclicBarrier 用于让一组线程互相等待,直到所有线程都到达某个屏障点,然后再一起继续执行。它和 join 方法的区别在于,join 方法是一个线程等待另一个线程,而 CyclicBarrier 是多个线程互相等待。例如,在多个线程需要同时开始执行某个任务的场景中,CyclicBarrier 更为适用。

实际项目中的应用场景

  1. 任务依赖场景:在大型项目中,经常会有一些任务之间存在依赖关系。例如,在数据处理系统中,可能有一个线程负责从数据库读取数据,另一个线程负责对读取的数据进行清洗和预处理,第三个线程负责将处理后的数据写入文件或其他存储介质。这三个线程之间存在顺序依赖关系,通过 join 方法可以很方便地确保任务按顺序执行。
  2. 多阶段初始化:在一些应用程序的启动过程中,可能需要进行多个阶段的初始化工作,每个阶段由不同的线程负责。例如,一个Web应用程序可能需要初始化数据库连接、加载配置文件、启动一些后台服务等。这些初始化任务之间可能有依赖关系,使用 join 方法可以保证初始化工作按顺序完成,确保应用程序能够正常启动。
  3. 并发计算结果合并:在并行计算场景中,可能会将一个大的计算任务拆分成多个子任务,由不同的线程并行执行。当所有子任务执行完毕后,需要将各个子任务的计算结果合并。这时可以使用 join 方法等待所有子任务线程执行完毕,然后再进行结果合并操作。

通过深入理解 join 方法对线程执行的影响,我们能够更好地利用它来实现复杂的多线程协作,提高程序的并发性能和可靠性。在实际编程中,要根据具体的需求和场景,合理选择和使用 join 方法以及其他线程同步机制,以编写出高效、稳定的多线程程序。