性能文章>Java的线程>

Java的线程原创

1年前
306422

介绍线程

线程是系统调度的最小单元,一个进程可以包含多个线程,线程是负责执行二进制指令的。

每个线程有自己的程序计数器、栈(Stack)、寄存器(Register)、本地存储(Thread Local)等,但是会和进程内其他线程共享文件描述符、虚拟地址空间等。

对于任何一个进程来讲,即便我们没有主动去创建线程,进程也是默认有一个主线程的。


守护线程(Daemon Thread)

有的时候应用中需要一个长期驻留的服务程序,但是不希望这个服务程序影响应用退出,那么我们就可以将这个服务程序设置为守护线程,如果 Java 虚拟机发现只有守护线程存在时,将结束进程。

在 Java 中将线程设置为守护线程,具体的实现代码如下所示:

public static void main(String[] args) {
    Thread daemonThread = new Thread();
    // 必须在线程启动之前设置
    daemonThread.setDaemon(true);
    daemonThread.start();
}

通用的线程生命周期

在操作系统层面,线程有生命周期。

对于有生命周期的事物,要学好它,只要能搞懂生命周期中各个节点的状态转换机制就可以了。

通用的线程生命周期基本上可以用下图这个 “五态模型” 来描述。这五态分别是:初始状态、可运行状态、运行状态、休眠状态和终止状态。

这“五态模型”的详细情况如下所示。


初始状态

初始状态,指的是线程已经被创建,但是还不允许被 CPU 调度。

初始状态属于编程语言特有的,这里所谓的被创建,仅仅是在编程语言层面被创建,而在操作系统层面,真正的线程还没有被创建。

在 Java 中,初始状态相当于是创建了 Thread 类的对象,但是还没有调用 Thread#start() 方法。


可运行状态

可运行状态,指的是线程可以**作系统调度,但是线程还没有开始执行。

在可运行状态下,真正的操作系统线程已经被创建。多个线程处于可运行状态时,操作系统会根据调度算法选择一个线程运行。

在 Java 中,可运行状态相当于是调用了 Thread#start() 方法,但是线程还没有被分配 CPU 执行。


运行状态

当有空闲的 CPU 时,操作系统会将空闲的 CPU 分配给一个处于可运行状态的线程,被分配到 CPU 的线程的状态就从可运行状态转换成了运行状态。

在 Java 中,运行状态相当于是调用了 Thread#start() 方法,并且线程被分配 CPU 执行。


休眠状态

如果运行状态的线程调用了一个阻塞的 API(例如以阻塞的方式读取文件)或者等待某个事件(例如条件变量),那么线程的状态就会从运行状态转换到休眠状态,同时释放 CPU 的使用权,休眠状态的线程永远没有机会获得 CPU 的使用权。

当等待的资源或条件满足后,线程就会从休眠状态转换到可运行状态,并等待 CPU 调度。


终止状态

线程执行完毕或者出现异常,线程就会进入终止状态,即线程的生命周期终止。


这五种状态在不同编程语言里会有简化合并。例如:

  • C 语言的 POSIX Threads 规范,就把初始状态和可运行状态合并了;
  • Java 程序设计语言把可运行状态和运行状态合并了,这两个状态在操作系统调度层面有用,而 Java 虚拟机层面不关心这两个状态,因为 Java 虚拟机把线程调度交给操作系统处理了。

除了简化合并,这五种状态也有可能被细化,比如,Java 语言里就细化了休眠状态(这个下面我们会详细讲解)。

Java 的线程生命周期

不同的程序设计语言对于操作系统线程进行了不同的封装,下面我们学习一下 Java 的线程生命周期。

Java 程序设计语言中,线程共有六种状态,分别是:

  1. NEW(初始状态)
  2. RUNNABLE(可运行 / 运行状态)
  3. BLOCKED(阻塞状态)
  4. WAITING(无时限等待)
  5. TIMED_WAITING(有时限等待)
  6. TERMINATED(终止状态)

NEW(初始状态)、TERMINATED(终止状态)和通用的线程生命周期中的语义相同。

在操作系统层面,Java 线程中的 BLOCKED、WAITING、TIMED_WAITING 是一种状态,即通用的线程生命周期中的休眠状态。也就是说只要 Java 线程处于这三种状态之一,那么这个线程就永远没有机会获得 CPU 的使用权。

所以 Java 中的线程生命周期可以简化为下图:

1651248522988-a3b8cd59-986b-49ee-8743-c262c7a1c180.png


其中,可以将 BLOCKED、WAITING、TIMED_WAITING 理解为导致线程处于休眠状态的三种原因。

  • 那具体是哪些情形会导致线程从 RUNNABLE 状态转换到这三种状态呢?
  • 而这三种状态又是何时转换回 RUNNABLE 的呢?
  • 以及 NEW、TERMINATED 和 RUNNABLE 状态是如何转换的?

下面我们详细讲解。

Java 的线程状态切换

从 NEW 到 RUNNABLE 状态

刚创建 Thread 类的对象时,线程处于 NEW 状态。

NEW 状态的线程,不会**作系统调度,因此不会执行。Java 线程要执行,就必须转换到 RUNNABLE 状态。

从 NEW 状态转换到 RUNNABLE 状态只要调用线程对象的 start() 方法就可以了,具体的实现代码如下所示:

public static void main(String[] args) {
    Thread thread = new Thread(new Runnable() {
        @Override
        public void run() {
            System.out.println("hello");
        }
    });
    thread.start();
}

从 RUNNABLE 到 TERMINATED 状态

线程执行完 Thrad#run() 方法后,会自动从 RUNNABLE 状态转换到 TERMINATED 状态。

如果执行 run() 方法的时候异常了抛出,也会导致线程终止,进入 TERMINATED 状态 。

1. RUNNABLE 与 BLOCKED 的状态转换

只有一种场景会触发 RUNNABLE 与 BLOCKED 的状态转换,就是线程等待 synchronized 的隐式锁。

  • 当使用 synchronized 申请加锁失败时,该线程的状态就会从 RUNNABLE 转换到 BLOCKED 状态。
  • 当等待的线程获得锁时,该线程的状态就会从 BLOCKED 状态转换到 RUNNABLE 状态。

如果你熟悉操作系统线程的生命周期的话,可能会有个疑问:线程调用阻塞式 API 时,是否会转换到 BLOCKED 状态呢?在操作系统层面,线程是会转换到休眠状态的,但是在 Java 虚拟机层面,Java 线程的状态不会发生变化,也就是说 Java 线程的状态会依然保持 RUNNABLE 状态。

Java 虚拟机层面并不关心操作系统调度相关的状态,因为在 Java 虚拟机看来,等待 CPU 的使用权(操作系统层面此时处于可执行状态)与等待 I/O(操作系统层面此时处于休眠状态)没有区别,都是在等待某个资源,所以都归入了 RUNNABLE 状态。

而我们说的 Java 线程在调用阻塞式 API 时,线程会阻塞,指的是操作系统线程的状态,并不是 Java 线程的状态。

2. RUNNABLE 与 WAITING 的状态转换

总体来说,有三种场景会触发 RUNNABLE 与 WAITING 的状态转换。


第一种场景,获得 synchronized 隐式锁的线程,调用无参数的 Object#wait() 方法。

这里应该调用的是锁对象的 wait() 方法,具体的实现代码如下所示:

public void method() throws InterruptedException {
    synchronized (this) {
        this.wait();
    }
}
  • 当调用 wait() 方法时,调用方法的线程的状态从 RUNNABLE 状态转换到 WAITING 状态
  • 当调用 notify() 方法时,被唤醒的线程的状态从 WAITING 状态转换到 RUNNABLE 状态

第二种场景,调用无参数的 Thread#join() 方法。

join() 是一种线程同步方法,例如有一个线程对象 thread A:

  • 当调用 A.join() 方法时,执行这条语句的线程会等待 thread A 执行完,而等待中的这个线程,其状态会从 RUNNABLE 转换到 WAITING。
  • 当线程 thread A 执行完,原来等待它的线程又会从 WAITING 状态转换到 RUNNABLE。

Thread#join() 方法的实现基于 Object#wait()。


第三种场景,调用 LockSupport#park() 方法。

LockSupport 类,也许你有点陌生,其实 Java 并发包中锁的实现都用到了 LockSupport#park() / unpark()。

  • 当调用 LockSupport.park() 方法时,调用方法的线程的状态从 RUNNABLE 转换到 WAITING。
  • 当调用 LockSupport.unpark(Thread thread) 方法时,被唤醒的线程的状态从 WAITING 状态转换到 RUNNABLE 状态

总结来说:Object#wait() 和 LockSupport#park() 方法使线程的状态转换到 WAITING。

3. RUNNABLE 与 TIMED_WAITING 的状态转换

总体来说,有五种场景会触发 RUNNABLE 与 TIMED_WAITING 的状态转换:

  1. 获得 synchronized 隐式锁的线程,调用带超时参数的 Object#wait(long timeout) 方法;
  2. 调用带超时参数的 Thread#join(long millis) 方法;(底层调用 Object#wait(long timeout) )
  3. 调用带超时参数的 LockSupport.parkNanos(Object blocker, long deadline) 方法;
  4. 调用带超时参数的 LockSupport.parkUntil(long deadline) 方法。
  5. 调用带超时参数的 Thread.sleep(long millis) 方法;

这里你会发现:

  • TIMED_WAITING 和 WAITING 状态的区别,仅仅是触发条件多了超时参数。
  • 与 RUNNABLE 与 WAITING 的状态转换 相比,多了一个 Thread.sleep() 场景。

Java 线程 API 的使用

线程的创建

创建线程的几种方式:

  1. 继承 Thread 类,重写 run() 方法。
  2. 实现 Runnable 接口,实现其中的 run() 方法。将该实现类的对象作为参数传递到 Thread 类的构造器中,创建 Thread 类的对象。
  3. 实现 Callable 接口,实现其中的 call() 方法。将该实现类的对象作为参数传递到 FutureTask 类的构造器中,创建FutureTask 类的对象。将 FutureTask 类的对象作为参数传递到 Thread 类的构造器中,创建 Thread 类的对象。Callable 它解决了 Runnable 无法返回结果的困扰。

「实现 Runnable 接口」VS「继承 Thread 类」

  • 通过实现(implements)的方式没有类的单继承性的局限性
  • 实现的方式更适合处理多个线程有共享数据的情况

「实现 Callable 接口」VS「实现 Runnable 接口」

  • call() 可以有返回值
  • call() 可以抛出异常被外面的操作捕获,获取异常的信息
  • 「实现 Callable 接口」支持泛型

// 自定义线程对象
class MyThread extends Thread {
    public void run() {
        // 线程需要执行的代码
        ......
    }
}

// 创建线程对象
MyThread myThread = new MyThread();
// 实现Runnable接口
class Runner implements Runnable {
    @Override
    public void run() {
        // 线程需要执行的代码
        ......
    }
}

// 创建线程对象
Thread thread = new Thread(new Runner());
public static void main(String[] args) throws ExecutionException, InterruptedException {
    MyTask task = new MyTask();
    // FutureTask 用于接收运算结果
    FutureTask futureTask = new FutureTask<>(task);
    Thread thread = new Thread(futureTask);

    thread.start();
	// FutureTask 可用于线程间同步 (当前线程等待其他线程执行完成之后,当前线程才继续执行)
    // get() 返回值即为 FutureTask 构造器参数 Callable 实现类实现的 call() 的返回值
    System.out.println(futureTask.get());
}

public class MyTask implements Callable {
    @Override
    public String call() {
        // 若不需要返回值,可 return null;
        return "ok";
    }
}

线程的执行

创建好 Thread 类的对象后,通过调用 Thread#start() 方法创建线程执行任务。

线程执行要调用 start() 而不是直接调用 run(),直接调用 run() 方法只会在当前线程上同步执行 run() 方法的内容,而不会启动新线程。调用 start() 方法的作用:

  1. 启动一个新的线程
  2. 新的线程调用 run() 方法

线程的停止

有时候我们需要强制中断 run() 方法的执行,例如 run() 方法访问一个很慢的网络,我们等不下去了,想终止怎么办呢?Java 的 Thread 类里面倒是有个 stop() 方法,不过已经标记为 @Deprecated,所以不建议使用了。正确的方式是调用 interrupt() 方法。Thread#interrupt() 配合合适的代码,即可优雅的实现线程的终止。

stop() 和 interrupt() 方法的区别。

  • stop() 方法会真的杀死线程,不给线程喘息的机会,如果线程持有 ReentrantLock 锁,被 stop() 的线程并不会自动调用 ReentrantLock 的 unlock() 去释放锁,那其他线程就再也没机会获得 ReentrantLock 锁,这实在是太危险了。所以该方法就不建议使用了,类似的方法还有 suspend() 和 resume() 方法,这两个方法同样也都不建议使用了。
  • interrupt() 方法仅仅是通知线程,线程有机会执行一些后续操作,线程也可以无视这个通知。被 interrupt 的线程,是怎么收到通知的呢?一种是异常,另一种是主动检测。

异常

当线程 A 处于 WAITING、TIMED_WAITING 状态时,如果其他的线程调用线程 A 的 interrupt() 方法,会使线程 A 返回到 RUNNABLE 状态,同时线程 A 的代码会触发 InterruptedException 异常。

上面我们提到转换到 WAITING、TIMED_WAITING 状态的触发条件,都是调用了类似 wait()、join()、sleep() 这样的方法,我们看这些方法的签名,发现都会 throws InterruptedException 这个异常。这个异常的触发条件就是:其他的线程调用了该线程的 interrupt() 方法。

当线程 A 处于 RUNNABLE 状态时:

  • 当线程 A 处于 RUNNABLE 状态,并且阻塞在 java.nio.channels.InterruptibleChannel 上时,如果其他的线程调用线程 A 的 interrupt() 方法,线程 A 会触发 java.nio.channels.ClosedByInterruptException 这个异常;
  • 当线程 A 处于 RUNNABLE 状态,并且阻塞在 java.nio.channels.Selector 上时,如果其他的线程调用线程 A 的 interrupt() 方法,线程 A 的 java.nio.channels.Selector 会立即返回。

上面这两种情况属于被中断的线程通过异常的方式获得了通知。


主动检测

还有一种是主动检测,如果线程处于 RUNNABLE 状态,并且没有阻塞在某个 I/O 操作上,例如中断计算圆周率的线程 A,这时就得依赖线程 A 主动检测中断状态了。如果其他的线程调用线程 A 的 interrupt() 方法,那么线程 A 可以通过 isInterrupted() 方法,检测是不是自己被中断了。

参考资料

第17讲 | 一个线程两次调用start()方法会出现什么情况?-极客时间 (geekbang.org)

09 | Java线程(上):Java线程的生命周期 (geekbang.org)

06 | 线程池基础:如何用线程池设计出更“优美”的代码? (geekbang.org)

11 | 线程:如何让复杂的项目并行执行?-极客时间 (geekbang.org)

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

为你推荐

随机一门技术分享之Netty

随机一门技术分享之Netty

MappedByteBuffer VS FileChannel:从内核层面对比两者的性能差异

MappedByteBuffer VS FileChannel:从内核层面对比两者的性能差异

2
2