我所理解的JDK系列·第6篇·并发编程基石——Thread类的工作原理

「这是我参与2022首次更文挑战的第2天,活动详情查看:2022首次更文挑战

1. 开篇词

说到并发编程,可能大家脑海中的第一印象会是 Thread、多线程、JUC、线程池、ThreadLocal 等等内容。确实,并发编程是 Java 编程中不可或缺的一部分,掌握并发编程的核心技术,在求职面试中会是摧城拔寨的利器。而今天将要跟大家一起聊聊的是:并发编程的基石——Thread 类的工作原理。

事实上,在笔者回忆关于 Thread 类的核心 API 以及对应的线程状态转换关系时,总觉得印象有一些模糊,故此才有这篇文章。本文的核心议题是 Thread 类,由此延伸出诸多议题,例如:进程与线程、线程状态及生命周期、Thread API 的用法等等。

2. 进程与线程

首先,有必要介绍一下进程与线程,以及它们之间的区别与关系。

进程是操作系统分配资源的基本单位,比如我们在启动一个 main 方法时就启动了一个 JVM 进程。

线程则是比进程纬度更小的单位,它是 CPU 分配的基本单位(因为真正占用运行的就是线程),比如启动一个 main 方法后它所在的线程就属于这个 JVM 进程的一个线程,它的名字叫主线程。一个进程可以有一个或多个线程,同一个进程中的各个线程之间共享进程的内存空间。

进程与线程之间的区别如下:

  • 进程是操作系统分配资源的最小单位,而线程是 CPU 分配(程序执行)的最小单位
  • 一个进程由一个或多个线程组成,线程是一个进程中代码的不同执行路线
  • 进程之间相互独立,但同一进程下的各个线程之间共享程序的内存空间
  • 调度和切换:线程上下文切换比进程上下文切换要快得多

3. Thread 常用 API

鉴于笔者此前对 Thread 类核心 API 知之甚少,对其原理不甚了解,于是本小节主要内容就是介绍 Thread 类核心 API 的用法、意义及对线程状态的影响等内容。

3.1 创建线程任务

在调用 Thread 类的 API 之前,需要先创建线程对象,这很简单,只需要 new Thread() 就可以了。但是事实上,如果只通过 new 创建线程并未做其他任何操作,那么这个线程将不会执行任何业务逻辑。

所以我们需要通过另外的手段为线程指定其执行的业务逻辑,那么问题来了:创建线程任务有几种形式?

一般来说,我们认为有三种形式:继承 Thread 类、实现 Runnable 接口以及实现 Callable 接口,下面一一进行详述。

3.1.1 继承 Thread 类

创建一个类,让它继承 Thread 类并覆盖 run 方法,run 方法中指定了线程执行的业务逻辑。这样在以后创建线程时可以直接实例化该类即可,在启动线程后程序会自动去执行覆盖的 run 方法逻辑。

public class CreateThreadByThread extends Thread {

    @Override
    public void run() {
        System.out.println("CreateThreadByRunnable#run, 自定义的业务逻辑");
    }

    public static void main(String[] args) {
        Thread thread = new CreateThreadByThread();
        thread.start();
    }
}

// 输出
CreateThreadByRunnable#run, 自定义的业务逻辑
复制代码

3.1.2 实现 Runnable 接口

创建一个类,实现 Runnable 接口并覆盖 run 方法,然后再去创建一个 Thread 类,并将实现 Runnable 接口的对象作为入参传入 Thread 类的构造器,在启动 Thread 对象后程序会去执行 Runnable 对象的 run 方法。

public class CreateThreadByRunnable implements Runnable {

    @Override
    public void run() {
        System.out.println("CreateThreadByRunnable#run, 自定义的业务逻辑");
    }

    public static void main(String[] args) {
        Runnable runnable = new CreateThreadByRunnable();
        // 将 runnable 对象作为入参传入 Thread 类构造器
        Thread thread = new Thread(runnable);
        thread.start();
    }
}
复制代码

虽然以上两种形式一个是继承 Thread 类,一个是实现 Runnable 接口,但如果细看源码的话并没有本质上的区别。

首先看继承 Thread 类的形式,它需要覆盖 run 方法,我们来看看 Thread#run 的默认内容是什么:

// Thread#run
@Override
public void run() {
  if (target != null) {
    target.run();
  }
}

// Thread#target
/* What will be run. */
private Runnable target;
复制代码

事实上,Thread#run 方法是覆盖了 Runnable 接口的 run 方法,它的逻辑就是当 Runnable 类型私有成员变量不为空时,执行其 run 方法。

而实现 Runnable 接口的形式,我们在创建完 Runnable 类型的对象后,需要将它作为入参传入 Thread 类的构造器。

// Thread#Thread(java.lang.Runnable)
public Thread(Runnable target) {
  init(null, target, "Thread-" + nextThreadNum(), 0);
}

private void init(ThreadGroup g, Runnable target, String name,
                  long stackSize, AccessControlContext acc,
                  boolean inheritThreadLocals) {
  // 省略其他代码
  
  this.target = target;

  // 省略其他代码
}
复制代码

可以看到在 Thread 类的重载构造方法中,传入的 Runnable 类型的对象赋值给了 Thread 类的 target 私有成员变量。再联系我们刚刚提到的 Thread#run 方法:当 Runnable 类型私有成员变量不为空时,执行其 run 方法。

这不就是换了个皮吗?

所以实现 Runnable 接口的形式跟继承 Thread 类的形式并没有本质上的区别,它们都是基于覆盖 run 方法来实现改变线程需要执行的任务。

3.1.3 实现 Callable 接口

Callable 接口是 JDK1.5 才引入的类,它的功能比 Runnable 更强大,最大的特点是 Callable 允许有返回值,其次它支持泛型,同时它允许抛出异常被外层代码捕获,下面是实现 Callable 接口来创建线程的示例:

public class CreateThreadByCallable implements Callable<Integer> {

    public static void main(String[] args) {
        CreateThreadByCallable callable = new CreateThreadByCallable();
        FutureTask<Integer> future = new FutureTask<>(callable);
        // 创建线程并启动
        Thread thread = new Thread(future);
        thread.start();

        Integer integer = null;
        try {
            integer = future.get();
        } catch (InterruptedException | ExecutionException e) {
            e.printStackTrace();
        }
        System.out.println("FutureTask 返回内容: " + integer);

    }

    @Override
    public Integer call() throws Exception {
        System.out.println("CreateThreadByCallable#call, 自定义的业务逻辑,返回1");
        return 1;
    }
}
复制代码

值得注意的是,我们需要基于 FutureTask 类配合使用 Callale 接口,返回值、异常以及泛型都是 FutureTask 类提供的特性。

当深入 FutureTask 的构造器以及其内部方法时,笔者发现了一些新东西。

// FutureTask#FutureTask(java.util.concurrent.Callable<V>)
public FutureTask(Callable<V> callable) {
  if (callable == null)
    throw new NullPointerException();
  this.callable = callable;
  this.state = NEW;
}
复制代码

首先在 FutureTask 的构造器中,将 Callable 对象赋值给了 FutureTask 类的 Callable 类型私有成员变量。然后继续构造 Thread 对象,笔者发现咱们使用的 Thread 重载构造方法竟然与实现 Runnable 接口的场景是一致的,也就是说 FutureTask 实现了 Runnable 接口,打开源码一看,果然如此。

// FutureTask 类实现了 RunnableFuture 接口
public class FutureTask<V> implements RunnableFuture<V> {}

// RunnableFuture 接口继承于 Runnable 接口
public interface RunnableFuture<V> extends Runnable, Future<V> {}
复制代码

所以我们构造的 FutureTask 对象是作为 Runnable 类型传入 Thread 类中的,在线程启动时会去执行 FutureTask 内部的 run 方法,我们再来看看 FutureTask#run 方法。

// java.util.concurrent.FutureTask#run
public void run() {
  if (state != NEW || !UNSAFE.compareAndSwapObject(this, runnerOffset, null, Thread.currentThread()))
    return;
  try {
    Callable<V> c = callable;
    if (c != null && state == NEW) {
      V result;
      boolean ran;
      try {
        // 执行 callable 属性的 call 方法获取返回值
        result = c.call();
        ran = true;
      } catch (Throwable ex) {
        result = null;
        ran = false;
        setException(ex);
      }
      if (ran)
        // 若执行完毕,将返回值赋值给 outcome 属性(在 FutureTask#get 方法中返回)
        set(result);
    }
  } finally {
    runner = null;
    int s = state;
    if (s >= INTERRUPTING)
      handlePossibleCancellationInterrupt(s);
  }
}

// java.util.concurrent.FutureTask#set
protected void set(V v) {
  if (UNSAFE.compareAndSwapInt(this, stateOffset, NEW, COMPLETING)) {
    outcome = v;
    UNSAFE.putOrderedInt(this, stateOffset, NORMAL); // final state
    finishCompletion();
  }
}
复制代码

可以看到,在 FutureTask#run 方法中实际上执行的是 Callable#call 方法。所以说,实现 Callable 接口的形式,最终 Thread 执行的内容还是 Thread#run 方法,只不过这个 run 方法是被 FutureTask 类的 run 方法覆盖了,而调用 Callable#call 方法是 FutureTask#run 的内部预定义逻辑。

3.1.4 创建线程的方法

通过以上三种创建线程任务的形式以及对它们源码的探究,我们可以知道,无论是哪种形式最终还是以覆盖 Thread#run 的形式来实现的。

这里有一个题外话:创建线程的方法有几种?

这个问题在网络上经常被误解读,大部分观点都认为可以通过继承 Thread 类、实现 Runnable 接口以及实现 Callable 接口来创建线程。事实上我在上文中描述的是”创建线程任务的形式“,想要突出的并非创建线程的方法而是创建线程执行任务的方法。

在上文的示例代码中我们可以知道,即便是通过后两种形式(即实现 Runnable 接口、实现 Callable 接口),我们最终还是需要 new Thread 来创建一个线程,只不过是通过传入 Runnable 对象来改变了线程的行为。

所以说,创建线程的方法只有一种:new Thread()

3.2 start

Thread#start 可以说是 Thread 类最常用的方法了,这个方法的作用是让线程开始执行。

调用刚创建好的线程的 start 方法后,该线程将会从 NEW 状态转化成 RUNNABLE 状态,CPU 会在合适的时间分配给该线程时间片,真正执行线程的业务方法。

需要注意的是,一个线程只能调用一次 start 方法,否则就会抛出 IllegalThreadStateException 异常,原因是在 Thread#start 方法中,首先会判断线程状态。

// java.lang.Thread#start
public synchronized void start() {
	// 若线程状态不为 NEW,抛出异常
  // A zero status value corresponds to state "NEW".(0值对应的状态是NEW)
  if (threadStatus != 0)
    throw new IllegalThreadStateException();

  // 省略其他逻辑...
}
复制代码

可以看到,当线程的状态不是 NEW 状态时,再次调用它的 start 方法,将会抛出 IllegalThreadStateException 异常。也就是说,线程一旦完成执行,就不能重新启动了。

3.3 join

Thread#join 方法的作用是等待线程执行完毕,在 JDK1.8 中该方法有三个重载方法,另外两个带参数的重载方法是设置了超时时间:

jdk-1-6

Thread#join 方法的意义可以这样描述:在A线程内调用B线程的 join 方法,A线程会等待B线程执行完毕再执行。在这个过程中,线程A的状态将由 RUNNABLE 转变为 WAITING,B线程执行完毕后,A线程状态将转变为 RUNNABLE,该结论可以通过下面的示例来验证:

public static void main(String[] args) throws InterruptedException {
  Thread thread1 = new Thread(() -> {
    System.out.println("thread1 is running");
    try {
      // 为观察效果明显,将睡眠时间设置的长一点
      Thread.sleep(50000);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
    System.out.println("thread1 is over");
  });

  thread1.start();
  thread1.join();
}
复制代码

运行该程序,首先将输出 thread1 is running,然后 thread1 线程进入 sleep 方法,sleep 结束后输出 thread1 is over。而在 thread1 线程 sleep 的过程中,打开 jconsole 工具,可以观察到调用了 thread1.join 方法的 main 线程状态是 WAITING。

jdk-6-2

上面说的是 Thread#join 方法,如果是设置了超时时间的重载方法,调用某个线程对象 join 方法的线程状态将转变为 TIMED_WAITING。

除此之外,还有一个问题值得思考:在当前线程调用其他线程的 join 方法后,若其他线程尝试获取当前线程已持有的锁,是否会成功?我们来做个实验。

private static String str = "123";

private static void testJoinLock() throws InterruptedException {
  // main 线程先占用 str 资源
  synchronized (str) {
    Thread thread1 = new Thread(() -> {
      System.out.println("thread1 is running");
      // 子线程尝试占用 str 资源
      synchronized (str) {
        System.out.println("thread1 is get str lock");
      }
      System.out.println("thread1 is over");
    });

    thread1.start();
    thread1.join();
  }
}
复制代码

先声明一个共享资源 str 变量,main 线程首先对该变量加上同步锁,然后实例化一个子线程,子线程中也尝试去给 str 加上同步锁。运行该程序,观察到最终输出的内容是:thread1 is running,且程序一直未终止。

猜测可能子线程时被阻塞了,打开 jconsole,如下图:

jdk-6-3

果然,观察到 Thread-0 线程的状态是 BLOCKED,且资源拥有者是 main,即该线程被 main 线程阻塞了。

所以,当线程A因调用线程B线程的 join 方法而进入 WAITING 状态时,并不会释放本身已持有的锁资源。

3.4 yield

Thread#yield 方法的作用是释放时间片,让 CPU 再次选择线程执行。这句话潜在的意思是说 CPU 可能选中之前放弃时间片的线程来执行。

值得注意的是,Thread#yield 方法不会释放已经持有的锁资源。

3.5 interrupt

Thread#interrupt 方法的作用是中断线程,调用线程的该方法会请求终止当前线程,需要注意的是该方法仅仅是给当前线程发送了一个终止的信息,并设置中断标志位,最终是否终止是线程自己处理的。

还有两个比较类似的方法:Thread#interrupted, Thread#isInterrupted

Thread#interrupt 方法的作用是检查该线程是否被中断,同时清除中断标志位

Thread#isInterrupted 方法的作用是检查该线程是否被中断,但不清除中断标志位

值得注意的是,当调用线程的 Thread#interrupt 方法时,若当前线程处于 TIMED_WAITING 或 WAITING 状态时(如调用过 Object#wait, Thread#join, Thread#sleep 或对应重载方法的线程),将会抛出 InterruptedException 异常,使得线程直接进入 TERMINATED 状态。

public static void main(String[] args) {
  Thread thread1 = new Thread(() -> {
    System.out.println("thread1 is running");
    try {
      // 为观察效果明显,将睡眠时间设置的长一点
      Thread.sleep(50000);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
    System.out.println("thread1 is over");
  });

  thread1.start();
  // 中断线程
  thread1.interrupt();
}
复制代码

执行该方法,输出内容为:

thread1 is running
thread1 is over
java.lang.InterruptedException: sleep interrupted
	at java.lang.Thread.sleep(Native Method)
	at io.walkers.planes.pandora.jdk.thread.usage.InterruptMethod.lambda$main$0(InterruptMethod.java:16)
	at java.lang.Thread.run(Thread.java:748)
复制代码

可以看到,程序抛出了 InterruptedException 异常。

4. 线程状态

在 Java 语言中,线程被抽象成 Thread 类,而在 Thread 类中有一个 State 枚举类,它描述了线程的各种状态。

// java.lang.Thread.State
public enum State {
  NEW,
  RUNNABLE,
  BLOCKED,
  WAITING,
  TIMED_WAITING,
  TERMINATED;
}
复制代码

JDK 源码的注释对于线程状态的描述如下:

  • NEW:未启动的线程处于这种状态
  • RUNNABLE:在 JVM 中执行的线程处于这种状态
  • BLOCKED:等待锁而被阻塞的线程处于这种状态
  • WAITING:一个线程正在无限期地等待另一个线程执行某个特定的操作,它就处于这种状态
  • TIMED_WAITING:在指定的等待时间内等待另一个线程执行某个操作的线程处于这种状态
  • TERMINATED:已退出的线程处于这种状态

下面我就用代码模拟处于各个状态的线程,同时会例举线程进入该状态的方法。

4.1 NEW

未启动的线程处于 NEW 状态,这个状态十分容易模拟,当我 new 出一个 Thread 对象后,该 Thread 线程就处于 NEW 状态,模拟代码如下:

public static void main(String[] args) {
  Thread thread = new Thread();
  System.out.println("Thread state is: " + thread.getState());
}

// 程序输出内容
Thread state is: NEW
复制代码

所以创建一个线程对象后,该线程的状态就是 NEW 状态。

4.2 RUNNABLE

在 JVM 中执行的线程处于 RUNNABLE 状态,即当调用一个线程的 start 方法后,等待 CPU 分配给该线程时间片,该线程正式执行时,他就处于 RUNNABLE 状态,模拟代码如下:

public static void main(String[] args) {
  Thread thread = new Thread(() -> System.out.println("Thread state is: " + Thread.currentThread().getState()));
  thread.start();
}

// 程序输出内容
Thread state is: RUNNABLE
复制代码

所以调用一个线程的 start 方法后,若未抛出异常,该线程就会进入 RUNNABLE 状态。

这里需要注意的是,若对于非 NEW 状态的线程调用它的 start 方法,将会抛出 IllegalThreadStateException 异常,原因源码如下:

// java.lang.Thread#start
public synchronized void start() {
	// 若线程状态不为 NEW,抛出异常
  // A zero status value corresponds to state "NEW".(0值对应的状态是NEW)
  if (threadStatus != 0)
    throw new IllegalThreadStateException();

  // 省略其他逻辑...
}
复制代码

除此之外,还有几种情况也会进入 RUNNABLE 状态:

  • BLOCKED 状态下的线程因获取锁成功而进入 RUNNABLE 状态
  • 因调用 sleep, join 方法而进入 WAITING/TIMED_WAITING 状态的线程,超过超时时间、正常等待结束或调用 Object#notify, Object#notifyAll 方法,会进入 RUNNABLE 状态
  • RUNNABLE 状态的线程因调用 yield 方法而重新进入 RUNNABLE 状态

4.3 BLOCKED

线程由于等待锁而被阻塞将处于 BLOCKED 状态,想要模拟该状态下的线程就需要引入共享资源以及第二个线程了,线程1先启动并占有锁资源,然后再启动线程2,当线程2尝试获取锁资源时,发现共享资源已被线程1占用,于是进入阻塞状态。模拟代码如下:

public class StateBlocked {
		// 共享资源
    private static String str = "lock";

    public static void main(String[] args) throws InterruptedException {
        Thread thread1 = new Thread(() -> {
            synchronized (str) {
                System.out.println("Thread1 get lock");
                // 防止线程 thread1 释放 str 锁资源
                try {
                    Thread.sleep(10000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });
        thread1.start();
        // 保证 thread1 先拿到锁资源
        Thread.sleep(1000);

        Thread thread2 = new Thread(() -> {
            synchronized (str) {
                System.out.println("Thread1 get lock");
            }
        });
        thread2.start();
        // 保证 thread2 进入 synchronized 代码块
        Thread.sleep(1000);
        System.out.println("Thread2 state is: " + thread2.getState());
    }
}
复制代码

上述模拟代码的输出结果如下:

Thread1 get lock
Thread2 state is: BLOCKED
Thread1 get lock
复制代码

所以线程由于进入同步块尝试获取锁失败被阻塞时,其状态就是 BLOCKED 状态。

4.4 WAITING

一个线程正在无限期地等待另一个线程执行某个特定的操作,它就处于 WAITING 状态。启动一个线程A,在另一个线程B中调用 Thread#join 方法,线程B会等待线程A执行完毕,这时线程B就是 WAITING 状态,模拟代码如下:

public static void main(String[] args) throws InterruptedException {
  // 锁资源
  String str = "lock";
  Thread thread = new Thread(() -> {
    // sleep 100s 是为了有足够的时间查看线程状态
    try {
      Thread.sleep(100000);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  });
  thread.start();
  // 主线程等待 thread 线程执行完毕
  thread.join();
}
复制代码

执行该方法后,我们需要打开 jconsole,找到对应进程,查看其线程状态,如下图:

jdk-6-4

可以看到,此时 main 线程的状态是 WAITING。

所以,调用一个线程的 join 方法(不指定超时时间),调用方将进入 WAITING 状态

除此之外,调用一个线程的 Object#wait (不指定超时时间),调用方也将进入 WAITING 状态。

4.5 TIMED_WAITING

在指定的等待时间内等待另一个线程执行某个操作的线程处于 TIMED_WAITING 状态。TIMED_WAITING 状态与 WAITING 状态唯一的不同就是前者指定了超时时间,在上一步代码基础上略作改动,我们就可以模拟出 TIMED_WAITING 状态,模拟代码如下:

public static void main(String[] args) throws InterruptedException {
  // 锁资源
  String str = "lock";
  Thread thread = new Thread(() -> {
    // sleep 100s 是为了有足够的时间查看线程状态
    try {
      Thread.sleep(100000);
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
  });
  thread.start();
  // 主线程等待 thread 线程执行完毕,指定超时时间为 10s
  thread.join(10000);
}
复制代码

执行该方法后,打开 jconsole,找到对应进程,查看其线程状态,如下图:

jdk-6-5.png)

可以看到,此时 main 线程的状态是 TIMED_WAITING。

所以,调用一个线程的 join 方法(指定超时时间),调用方将进入 TIMED_WAITING 状态

除此之外,调用一个线程的 Object#wait (指定超时时间),调用方也将进入 WAITING 状态。

4.5 TERMINATED

已退出的线程处于 TERMINATED 状态,这个状态就是线程自然结束的状态,十分容易模拟,代码如下:

public static void main(String[] args) throws InterruptedException {
  Thread thread = new Thread();
  thread.start();
  // 等待线程 thread 执行完毕
  thread.join();
  System.out.println("Thread state is: " + thread.getState());
}
复制代码

所以,当一个线程正常结束时,它将进入 TERMINATED 状态

除此之外,当一个线程抛异常退出时,也会进入 TERMINATED状态,例如在 WAITING/TIMED_WAITING 状态下的线程调用 Thread#interrupt 方法而退出。

5. 线程状态转换图

将上述的线程状态转换关系总结为如下图:

jdk-6-6-线程状态转换图

6. 小结

本文首先介绍了进程与线程的联系与区别,随后描述了 Thread 类常用的 API 以及创建线程执行任务的形式,然后详细说明了线程的状态并作出示例,最后总结了线程状态转换图。

总结了几个小问题:

  • 进程与线程的联系与区别
  • 创建线程任务的形式有哪几种?
  • 创建线程有几种方式?
  • 线程状态有哪几种?
  • 描述一下线程状态的转换规则

7. 参考资料

  • Java 并发编程之美

最后,本文收录于个人语雀知识库: 我所理解的后端技术,欢迎来访。

Guess you like

Origin juejin.im/post/7054693530001735711