在《并发概述》讲了,线程只不过是更“轻量级”的进程,支持快速进行任务切换,从而并发执行更高效,提升 CPU 利用率。
无论是进程还是线程,都不是Java语言所独有的概念,而是操作系统提供的编程接口,被众多编程语言所支持。
提到操作系统核心API就不得不提POSIX标准了,POSIX 全称是Portable Operating System Interface,便携式操作系统接口的意思,旨在为类Unix操作系统提供一致的API接口。
Windows系统也有自己的系统API。
那POSIX标准就规范了进程、线程的接口定义,其中线程的接口定义叫Pthreads。C/C++就是使用POSIX线程(Pthreads)来创建和管理线程,Python中threading模块也是,JVM 就是使用 C/C++ 实现的,当然也是使用Pthreads 管理线程喽...。
所以,要了解Java线程,可以先对 Pthreads 建立一个基本的认识。
Pthreads
先看 Pthreads(POSIX Threads)关于线程创建的 API:
⨳ pthread_create
pthread_create 用于创建一个新线程,并立即开始执行。
// 成功返回 0,失败返回错误码。
int pthread_create(pthread_t *thread, // 指向线程标识符的指针,用于存储新创建线程的标识符。
const pthread_attr_t *attr, // 线程属性,传递 `NULL` 表示使用默认属性。
void *(*start_routine)(void *), //指向线程函数的指针,新线程会执行此函数。
void *arg); //传递给线程函数的参数。
线程创建后会有个ID,这个ID会存放在第一个参数*thread指向位置,这个ID在需要时(如线程同步、线程管理)可以和操作系统内核中的线程对象关联。
线程属性用于控制线程的行为,例如线程的栈大小、分离状态、调度策略(线程优先级)等,
线程主要的目的就是执行函数,第三个参数是函数,第四个参数是函数所需的参数,就不意外了。
⨳ pthread_exit
pthread_exit 可以终止调用该函数的线程,并可选择性地返回一个指针给 pthread_join。
void pthread_exit(void *retval);
⨳ pthread_join
pthread_join 用于等待指定线程结束,回收线程资源。
int pthread_join(pthread_t thread, // 要等待的线程的标识符。
void **retval); // 用于存储线程的返回值(由 `pthread_exit` 返回)
⨳ pthread_self
pthread_self 可以获取当前线程的标识符。
pthread_t pthread_self(void);
了解了线程的创建与退出,就可以使用这个API管理线程了:
//
// Created by CangoKing on 2024/8/31.
//
#include <pthread.h>
#include <stdio.h>
#define NUM_THREADS 5
// 线程执行的函数
void *print_message(void *threadid) {
long tid;
tid = (long)threadid;
printf("Hello from thread #%ld\n", tid);
pthread_exit(NULL);
}
int main() {
// 声明了一个线程数组 threads,用于存储线程标识符。
pthread_t threads[NUM_THREADS];
int rc;
long t;
for (t = 0; t < NUM_THREADS; t++) {
printf("In main: creating thread %ld\n", t);
// pthread_create() 创建线程
rc = pthread_create(&threads[t], NULL, print_message, (void *)t);
if (rc) {
printf("Error: unable to create thread, %d\n", rc);
exit(-1);
}
}
// 等待所有线程完成
for (t = 0; t < NUM_THREADS; t++) {
pthread_join(threads[t], NULL);
}
printf("All threads completed.\n");
pthread_exit(NULL);
}
输出结果如下:
D:\WorkSpace\Agit\c_demo\thread_demo\main.exe
In main: creating thread 0
In main: creating thread 1
In main: creating thread 2
In main: creating thread 3
In main: creating thread 4
Hello from thread #0
Hello from thread #1
Hello from thread #2
Hello from thread #3
Hello from thread #4
All threads completed.
回归正题,可以对比着,看一下Java的线程。
线程相关类
线程相关类很简单,一个代表线程任务的 Runnable 接口,一个线程基本类 Thread。
Runnable
Runnable 接口定义了线程的基本行为,对应 Pthreads 创建线程API的中的传入的函数,实现 Runnable 接口的任务,也可以有成员属性,对应 Pthreads 创建线程API的中的传入的函数参数。
package java.lang;
@FunctionalInterface
public interface Runnable {
/**
* Runs this operation.
*/
void run();
}
可以看到,Runnable 接口也是一个函数式接口,可以使用Lambda表达式或方法引用快速实现。
之所以 Runnable 的 run 方法没有参数,是因为 Java 强调面向对象的设计模式,鼓励将线程逻辑与参数封装在对象中。
那 run 方法没有参数还可以理解,没有返回值就说不过去了,毕竟 Pthreads 还是可以通过 pthread_exit 方法传递返回值给等待的主线程,有返回值的前提条件是主线程要等待它执行完成,这一部分放到后面线程同步的时候再来讲解。
Thread
Thread类定义了Java语言层面的线程。其核心成员属性如下:
⨳ long tid:线程的ID
⨳ ThreadGroup group:线程所属的线程组, 线程组是一组具有相似行为的线程集合。
⨳ String name:线程的名字
线程组啦,线程的名字啦,这些 Pthreads规范都没有,毕竟线程的ID作为线程的唯一标识就够了,至于Java在语言层面上想怎么建立线程ID与其他标识的关系都没问题。
⨳ Runnable target:继承Runnable的对象实例,即该线程要执行的任务。
⨳ boolean daemon:是否为守护进程,默认为 false,即默认为用户线程。
凡存在用户线程执行,程序就不会停止运行,main 方法是用户线程,我们自己创建的线程,只要设置 daemon 为 false,也是用户线程,当所有用户线程停止,进程会停掉所有守护线程,退出程序。
JVM 中的 GC 回收线程就是守护线程,当所有的用户线程都执行完了,它也就没必要守护了,也会跟着销毁。
Pthreads规范也没有守护线程的概念,所有线程在创建后会持续存在,直到它们完成任务或被显式终止。
Java 的 守护线程通常用于执行后台任务,如垃圾回收等,正因为Java 有守护线程,简化了线程开发,让用户不需要关注线线程的生命周期,也不需要在程执行完后进行资源回收,也就不需要设置Pthreads规范提到的分离状态了。
⨳ int priority:线程的优先级,最小优先级是1,最大优先级是10
理论上,线程的优先级越高,分配到CPU时间片的几率也就越高, 实际上,优先级只能作为一个参考数值,而且具体的线程优先级还和操作系统有关。
线程的优先级对应Pthreads规范的线程调度策略,但具体的优先级范围和行为取决于操作系统,可以设置但不一定有效。
线程生命周期
线程生命周期描述了线程从创建到终止过程中所经历的不同状态。
⨳ New(新建状态)
当线程对象被创建时,它处于新建状态。这时线程已经被创建,但尚未开始执行。对应 Thread 类的 new 方法,这只是 Java 语言层面的状态,在 Pthreads规范 找不到对应方法。
⨳ Runnable(可运行状态)
当线程的 start() 方法被调用时,线程进入可运行状态。这意味着线程已经准备好运行,并等待操作系统的线程调度器分配 CPU 时间片。
在可运行状态下,线程可能正在执行任务,也可能正在等待 CPU 时间片。对应 Pthreads规范的 pthread_create() 方法。
⨳ Blocked/Waiting/Timed Waiting(阻塞/等待/计时等待状态)
线程在某些情况下可能无法立即执行,会进入阻塞、等待或计时等待状态。这些状态下的线程无法获得 CPU 时间片,直到某个特定条件满足后才能继续运行。
-
Blocked(阻塞状态) : 当线程试图获取一个被其他线程持有的锁时,它会进入阻塞状态,直到该锁被释放。比如
synchronized块中的线程在等待锁时会进入阻塞状态。 -
Waiting(等待状态) : 线程在等待另一个线程执行特定操作(如通知或中断)时进入等待状态。比如线程调用
Object.wait()方法,进入等待状态,直到被其他线程notify()或notifyAll(),从而到切换到 Runnable状态。 -
Timed Waiting(计时等待状态) : 线程在指定时间内等待某个条件满足后继续执行。比如调用
Thread.sleep(long millis)或Object.wait(long timeout)后,线程进入计时等待状态。
在操作系统层面,这三种状态就是休眠状态,会让出CPU的使用权。
⨳ Terminated(终止状态)
当线程完成了它的任务或者因异常终止时,线程进入终止状态。此时,线程已经不再是活动的,不能再次启动。对应 Pthreads规范的 pthread_exit() 方法。
在实际开发中,我们更多关注的是运行状态和休眠状态,特别是二者的相互转化。涉及方法如下:
⨳ void sleep(long millis):让正在执行的线程睡millis毫秒,哪个线程执行这个方法,哪个线程就让出CPU使用权,进入休眠状态。
⨳ void join():用于让当前线程等待另一个线程执行完成。举例来说,如果线程 A 调用了线程 B 的 join() 方法,线程 A 会进入阻塞状态,直到线程 B 结束执行。
⨳ void join(long millis):和join方法类似,但最多等待millis指定的时间
⨳ :立即挂起目标线程,不会释放锁。void suspend()
sleep、join 和 suspend ,这三个方法类似,sleep 是要自己睡觉,会让出 CPU 使用权,join 是等待,等待某个线程先执行完成,也会让出 CPU 使用权,而 suspend 则是没有是一个没有终止时间的 sleep()。
三个进入休眠的方法,虽然都不会释放锁,但至少sleep和join都有个睡眠时间吧,而被 suspend 挂起的线程只能通过 resume() 方法唤醒,这如果永远不执行唤醒操作,那就是死锁了,风险很大,所以被废弃了。
Object 提供的
wait()方法,也会让出 CPU 使用权进入休眠状态,但他会释放锁。
还有一个 yield 方法,是礼貌的谦让,客气一下,并不会让出 CPU 使用权进入休眠状态。
⨳ void yield():当一个线程调用 yield() 时,它会提示线程调度器当前线程愿意让出 CPU 使用权,允许其他具有相同优先级的线程或更高优先级的线程得到执行机会。但这只是一个提示,线程调度器可以选择忽略此提示,当前线程可能会继续运行。
线程的基本使用
创建线程
Thread 类提供了多种构造方法来创建线程,使得线程的创建过程能够根据需要进行灵活配置:
⨳ Thread():创建一个新的线程对象,线程的名称由系统自动分配,形式为 Thread- 加上一个递增的编号。例如,Thread-0、Thread-1 等。
⨳ Thread(String name):指定 name 创建线程
⨳ Thread(Runnable target):指定 Runnable 的实例 target 创建线程
⨳ Thread(Runnable target, String name):指定 Runnable的实例 target 和 name 创建线程
⨳ Thread(ThreadGroup group, Runnable target, String name):指定线程组, Runnable的实例 和 name 创建线程
⨳ ...
这里使用最常规的方式,将任务封装成 Runnable 接口实现类,然后通过 Thread 调用。
- 数数任务
package com.cango.thread;
public class CountTask implements Runnable{
private int i;
public CountTask(int i){ // 线程所需参数,可由用户自定义传入
this.i = i;
}
@Override
public void run() {
int count = 50;
while (count>0) {
i++;
count--;
System.out.println(Thread.currentThread().getName() + " " + i);
try {
Thread.currentThread().sleep(1000);
} catch (InterruptedException e) {
}
}
}
}
这个数数任务就是给成员变量 i 累加 50 次。
- 多线程数数
public static void main(String[] args) {
CountTask countTask = new CountTask(10); // 初始值为 10
Thread t1 = new Thread(countTask, "t1");
Thread t2 = new Thread(countTask, "t2");
t1.start();
t2.start();
}
创建两个线程,分别在 10 的基础上,向上数 50 次。
输出结果如下:
t2 12
t1 11
t2 13
t1 13
t1 14
t2 15
...
t2 105
t1 106
t2 107
t1 108
t2 109
根据输出结果可以看出虽然每个线程都将 i 累加了 50 次(局部变量 count 没有并发问题),但结果是 109,并不是 110,说明多线程同时修改成员变量有并发问题。
线程异常
Thread 内部有一个匿名函数接口 UncaughtExceptionHandler,我们可以用于处理线程执行过程中未被捕获的异常,即 run 方法内发生异常且没有使用 try-catch 块处理时,该异常将被传递到 UncaughtExceptionHandler。
@FunctionalInterface
public interface UncaughtExceptionHandler {
void uncaughtException(Thread t, Throwable e);
}
这个异常处理器可以为所有线程设置,也可以为某个特定的线程设置,在多线程编程中,这是非常有用的功能,尤其是在编写长时间运行的服务器或后台任务时。
CountTask countTask = new CountTask(10);
Thread t1 = new Thread(countTask, "t1");
Thread t2 = new Thread(countTask, "t2");
t1.setUncaughtExceptionHandler((t, e) ->
System.out.println(t.getName() + " thread exception: " + e.getMessage()));
t1.start();
t2.start();
线程终止
在 Java 中,线程的终止有几种不同的方式,包括自然终止、异常终止和手动终止。
⨳ 自然终止:线程执行完它的 run() 方法后,正常结束执行并自动进入终止状态。这是线程最常见的终止方式。
⨳ 异常终止:线程在执行过程中由于未处理的异常而意外终止。当线程由于未捕获的异常而终止时,异常会传播到 run() 方法的边界,导致线程停止执行。此时,线程会进入 TERMINATED 状态,任何未完成的任务将被中断,且线程持有的任何资源都可能没有被正确释放。
⨳ 手动终止:指通过明确的指令或标志来请求一个线程停止运行。
自然终止是我们想要的,异常终止是程序写的有问题,那如果某个线程本身就是在不断循环执行任务,没有自然终止的可能,那该怎么手动终止这个线程呢?
Thread 类中确实存在一个 stop() 方法,该方法可以立即终止线程:
⨳ :立即终止线程的执行,并释放线程所持有的所有锁。void stop()
但不推荐,stop() 很粗鲁,当线程持有锁并正在修改共享数据时,如果使用 stop() 强制终止线程,锁将被释放,但数据可能处于不一致状态。这会导致其他线程获得锁后访问到损坏的数据,从而引发逻辑错误。
而且如果这个线程正在持有资源(例如文件句柄、数据库连接等)时被 stop() 强制终止,资源可能没有机会被正确释放。这会导致资源泄漏,随着时间的推移,应用程序的可用资源可能被耗尽。
因此,从 Java 1.2 开始,stop() 方法已被弃用,不建议使用。
下面介绍一下通过标志位来,让线程自然的终止。
- 带标志位的任务
package com.cango.thread;
public class CountTaskWithFlag implements Runnable{
private int i;
private volatile boolean running = true;
public CountTaskWithFlag(int i){
this.i = i;
}
@Override
public void run() {
while (running) {
i++;
System.out.println(Thread.currentThread().getName() + " " + i);
try {
Thread.currentThread().sleep(1000);
} catch (InterruptedException e) {
}
}
}
public void stop(){
this.running = false;
}
}
原本的线程任务示例是根据局部变量 count 来计数,循环 50 遍就自动终止线程,现在使用标志位(即 volatile 变量)来控制线程的终止。
CountTaskWithFlag countTask = new CountTaskWithFlag(10);
Thread t1 = new Thread(countTask, "t1");
t1.start();
Thread.sleep(3000);
countTask.stop();// 终止线程
t1.join();
外部线程可以通过修改 running 来请求线程停止。这种方法允许线程安全地检查和响应终止请求。
其实 Thread 还提供了一个interrupt() 方法用于通知线程应当停止。
⨳ void interrupt():不会直接终止线程,而是设置线程的中断状态。
- 如果线程处于正常活动状态,那么会将该线程的中断标志设置为true(可通过
interrupted()查看),被设置中断标志的线程将继续正常运行,不受影响。 - 如当前线程处于休眠状态,如线程调用了sleep,join,wait,condition.await,那么线程将立即退出被休眠状态,并抛出一个
InterruptedException异常。
interrupt() 也是修改中断标志,比起我们自定义的中断标志位,它还可以中断休眠,让线程可以及时的进行终止。
package com.cango.thread;
public class CountTaskWithInterrupt implements Runnable{
private int i;
public CountTaskWithInterrupt(int i){
this.i = i;
}
@Override
public void run() {
while (!Thread.currentThread().isInterrupted()) {
i++;
System.out.println(Thread.currentThread().getName() + " " + i);
try {
Thread.currentThread().sleep(1000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 重新设置中断状态
System.out.println("Thread was interrupted during sleep");
}
}
System.out.println("Thread is stopping");
}
}
外部线程可以调用 thread.interrupt() 向 thread 发送中断信号,线程会通过 isInterrupted() 方法检测到中断并停止运行。
总结
本篇文章先介绍了POSIX标准的C语言实现,其中创建线程的核心方法就是pthread_create,了解到线程的目的就是执行线程函数。
然后了解了Java对线程的实现,封装线程函数和函数参数的 Runnable 接口和 封装线程属性的 Thread类。
进而分析了线程的生命周期,及其相关的一些进入休眠状态的方法,最后由介绍一下线程异常的处理方式,和优雅结束线程的方法。
是不是感觉也不怎么复杂,不过就是学习API调用而已,下一篇将讲解 Object 类提供的几个线程相关方法,可以思考一下为啥线程相关的方法会封装到作为所有类的父类 Object 上。