线程在 Java
语法中可谓发挥着举足轻重的作用,提供了一种更高效的任务编排方式,更是由此衍生庞大的生态体系以及令人拍手叫绝的系统设计。
在 JDK 21
的虚拟线程之前,Java
中的线程与物理核心之间是相互绑定的,即当程序创建一个线程时,依赖于系统调度为其分配对应的一个核心。
本文将带你初步了解线程的创建运行方式以及生命周期。
一、基础概念
在开始具体的线程介绍之前,让我们先初步了解一下线程的一些相关概念。
1. 进程与线程
(1) 进程
进程是一个程序关于某个数据集合的一次运行活动,它是操作系统动态执行的基本单元,是并发执行的程序在执行过程中分配和管理资源的基本单位,竞争计算机系统资源的基本单位。
(2) 线程
线程是操作系统能够进行运算调度的最小单位,是进程的一个执行单元,是比进程更小的独立运行的基本单位。
2. 并行与并发
(1) 串行
串行 (serial)
指在从事某项工作时一个步骤一个步骤的去实施,与并行相对应。
(2) 并行(parallel)
并行 (parallel)
指同一时刻有多条指令在多个处理器上同时执行,无论从微观还是从宏观来看,二者都是一起执行的。
(3) 并发(concurrency)
并发 (concurrency)
指同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行。
对于绝大数编程语言来讲,涉及的通常都是并发而非真正意义上的并行,即多个线程之间快速交替运行。
二、线程创建
Java
针对单线程的创建一共提供 3
种方式,对应着三种不同线程类,下面将依次介绍创建方式。
1. Thread
通过继承 Thread
类并重写 run()
方法实现异步逻辑。
注意启动线程时调用的为 start()
而非 run()
,前者才为异步任务启动。
public void demo() {
Thread t1 = new MyThread();
t1.start();
}
// 继承 Thread 类重写 run() 方法
class MyThread extends Thread {
@Override
public void run() {
System.out.println("Thread is running!");
}
}
如果只是仅需要创建一个简单线程任务可以使用 lambda
函数。
public void demo() {
new Thread(() -> {
// do somethings
System.out.println("task demo!");
}).start();
}
Thread
与 Runnable
的线程区别是由于 Java
特性引起的,在 Java
中一个类可以实现多个接口,但只能继承一个父类,因此通过继承 Thread
实现的线程便无法继承其它父类。
// MyThread1 无法再继承其它类
class MyThread1 extends Thread {
@Override
public void run() {
System.out.println("Thread is running!");
}
}
2. Runnable
通过实现 Runnable
接口并重写 run()
方法用于定义线程业务逻辑。
注意直接执行 Runnable
的 run()
其仍为同步执行,通常将业务定义于 Runnable
并将任务通过 Thread
类或线程池进行提交,从而实现异步效果。
public void demo() {
Thread t1 = new Thread(new MyThread());
t1.start();
}
// 实现 Runnable 接口重写 run() 方法
class MyThread implements Runnable {
@Override
public void run() {
System.out.println("Thread is running!");
}
}
Runnable
创建的线程效果和 Thread
创建的并没有根本性区别,但因为 Runnable
是通过实现接口类实现线程因此其仍可以继承其它父类。
// MyThread2 可以同时继承其它父类
class MyThread2 extends Human implements Runnable {
@Override
public void run() {
System.out.println("Thread is running!");
}
}
3. Callable
无论是通过继承 Thread
类还是实现 Runnable
接口所创建的线程都没有返回值,当需要线程返回参数时,可通过实现 Callable
接口并重写 call()
方法。
Callable
与 Runnable
通过是通过 implements
接口类实现,但与后者不同的是 Callable
返回值,而 Runnable
没有返回值。
public void demo() {
TimeCall thread = new TimeCall();
try {
long l = thread.call();
System.out.println("Result: " + l);
} catch (Exception e) {
e.printStackTrace();
}
}
// 实现 Callable 接口重写 call() 方法
class TimeCall implements Callable<Long> {
@Override
public Long call() {
return System.currentTimeMillis();
}
}
三、性质特征
1. 生命周期
所谓生命周期即线程一个线程从创建到最后终结关闭所会经历的阶段,具体信息参考下表:
阶段 | 描述 |
---|---|
New | 新创建的线程,尚未执行。 |
Runnable | 运行中的线程,正在执行 run() 方法。 |
Blocked | 运行中的线程,因为某些操作被阻塞而挂起。 |
Waiting | 运行中的线程,因为某些操作在等待中。 |
Timed Waiting | 运行中的线程,因为执行 sleep() 方法正在计时等待。 |
Terminated | 线程已终止,因为 run() 方法执行完毕。 |
在代码中,我们可以通过 getState()
方法获取当前线程阶段状态。
public void LifeDemo() {
Thread thread = new Thread(() -> {
System.out.println("thread is running with " + Thread.currentThread().getName());
});
// 状态为 NEW
System.out.println("State:" + thread.getState());
thread.start();
// 状态为 RUNNABLE
System.out.println("State:" + thread.getState());
// 返回当前正在运行的线程
// 格式:[ name, priority, group.name ]
System.out.println("Info:" + Thread.currentThread());
}
2. 启动方式
在 Java
中对于创建的线程分别提供了 run()
与 start()
两种启用方式,下面分别介绍二者的区别。
(1) run()
启动线程则要等待 run
方法体执行完毕后才可继续执行下面的代码,程序中只有主线程这一个线程,程序执行路径还是只有一条,这样就没有达到写线程的目的。
(2) start()
启动线程无需等待 run
方法体代码执行完毕,可以直接继续执行下面的代码,从而实现异步的效果。
(3) 示例
下面通过一个简单案例进行说明,在线程 run
方法体中利用 sleep()
方法让线程睡眠 5
秒以模拟任务耗时。再分别通过 run()
和 start()
启动线程,并在启动线程后进行一个输出打印,根据输出的时间点即可观察二者的区别。
public void demo1() {
Thread thread1 = new MyThread();
thread1.run();
// 5 秒后才会打印
System.out.println("done.");
}
public void demo2() {
Thread thread1 = new MyThread();
thread1.start();
// 打印同步进行
System.out.println("done.");
}
class MyThread extends Thread {
@Override
public void run() {
try {
// 模拟任务耗时 5 秒
Thread.sleep(5 * 1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
3. 守护线程
守护线程即创建之后将一直处于运行状态,当所有非守护线程结束后,则其会自动结束退出。
如果直接通过 while(true)
开启线程若系统异常停止程序并无法立刻退出造成资源泄露浪费,而守护线程的创建只需通过 setDaemon()
设置即可,但需要注意不能在守护线程进行任务资源操作,因为其结束时不会释放资源。
public void DaemonDemo() {
Thread thread = new TimerThread();
// 设置为守护线程
thread.setDaemon(true);
thread.start();
try {
// 主线程休眠 10 秒
Thread.sleep(10000);
} catch (InterruptedException e) {
}
}
class TimerThread extends Thread {
@Override
public void run() {
while(true) {
System.out.println(LocalTime.now());
try {
// 每次输出后休眠 1 秒
Thread.sleep(1000);
} catch (InterruptedException e) {
break;
}
}
}
}
四、线程并发
当线程启动之后,不同线程任务间交替运行,我们无法清楚得知当前运行的为哪个任务,尤其当多个线程共享变量时,就会涉及到变量值不可知的情景。
如以下代码中当 main
方法中通过 start()
启动线程后,此时为线程 main
和 线程 thread
二者异步并发执行,在 main
方法执行自增前,并不能确定 thread
中的自增是否完成,导致最终输出的结果不确定性。
private int amount = 0;
public static void main(String[] arg) {
Thread thread = new MyThread();
thread.start();
System.out.println(amount);
amount++;
System.out.println(amount);
}
class MyThread extends Thread{
@Override
public void run() {
amount ++;
}
}
针对上述的情况,
Java
提供了isAlive()
和join()
以应对此类场景
1. isAlive()
isAlive()
方法用于判断当前线程是否处于活动状态,我们可以根据不同状态进行不同操作,从而避免共享变量冲突。
public void AliveDemo(){
Thread thread = new MyThread();
thread.start();
// 判断线程是否处于活动状态
while(thread.isAlive()) {
System.out.println("Waiting...");
}
// 线程结束才会继续向下执行
amount++;
System.out.println(amount);
}
2. Join()
join()
作用效果等同于 isAlive
,直到线程处于结束状态才会继续执行后续内容。
public void JoinDemo(){
Thread thread = new MyThread();
thread.start();
try {
// 等待线程结束
thread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
amount++;
System.out.println("After join() and do add:" + amount);
}
五、线程休眠
在使用线程时我们常用到 sleep()
, wait()
和 yeild()
等方法让线程暂时进入休眠,如上述提到的守护线程就是通过 sleep()
实现间隔时间打印。
下面就分别介绍一下两种休眠方式的使用方式和不同之处。
1. sleep()
sleep()
的使用方式很简单,通过 Thread.sleep()
即可让当前线程进入休眠。
当一个执行 sleep()
后将阻塞挂起,即线程内余下代码块将不会执行,待睡眠结束才会继续向下执行。
public static void main(String[] arg) {
// 睡眠 5 秒
Thread.sleep(5000);
// 5 秒后才会输出 done
System.out.println("done");
}
默认 sleep()
休眠时间单位是毫秒,通常使用 n * 1000
更直观的表示休眠时间,但如果需要更长时间休眠可使用 TimeUnit
替代,代码示例如下:
public static void main(String[] arg) {
// 传入时间单位为毫秒
Thread.sleep(1000);
// 休眠 5 分钟
Thread.sleep(5 * 60 * 1000);
// 休眠 5 分钟
TimeUnit.MINUTES.sleep(6);
System.out.println("线程休眠结束");
}
2. wait()
wait()
和 sleep()
不一样的地方在于前者除了可以指定休眠时间外,也可以不设置休眠时间从而进入无限期休眠,通过 notify()
方法退出休眠状态。
需要注意的是 wait
和 notify
同时执行者必须为同一对象,必须在同步代码块中使用,即在持有锁的情况下,否则将会抛出 IllegalMonitorStateException
异常。
如下述示例中 t1
在同步块中通过 wait()
进入无限期休眠,等待三秒后有 t2
执行 notify()
唤醒 t1
,此时控制台才会打印 Been wake.
信息。
public void demo() throws InterruptedException {
Object object = new Object();
new Thread(() -> {
synchronized (object) {
try {
// 休眠, 同步块中才能使用 wait()
object.wait();
System.out.println("Been wake.");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "t1").start();
Thread.sleep(3 * 1000);
new Thread(() -> {
synchronized (object) {
// 唤醒睡眠, 同步块中才能使用 notify()
object.notify();
}
}, "t2").run();
}
3. park()
JDK
中同时提供了 LockSupport
用于线程休眠,不同是其为锁无关对象,因此无需与 wait()
一样要在同步块中执行。其通过 park()
执行休眠,通过 parkNanos()
休眠执行时间,单位为纳秒。也正是因为锁无关的特性,在唤醒线程时执行 unpark(thread)
即可。
在下述示例中,线程 t1
启动后通过 park()
进行休眠,主线程等待 3
秒后执行 unpark()
唤醒线程此时将打印输出 Wake up.
信息。
public void packDemo() throws InterruptedException {
var t1 = new Thread(() -> {
System.out.println("Park.");
// park(): will block here
LockSupport.park();
System.out.println("Wake up.");
}, "t1");
t1.start();
TimeUnit.SECONDS.sleep(3);
LockSupport.unpark(t1);
}
4. yeild()
yeild()
顾名思义即退让,即放弃当前线程的 CPU
资源,与 LockSupport
类似它同样是锁无关。
在之前已经提到了 Java
中的线程并不是真正意义上的并行而是并发,由 CPU
按照一定算法进行时间切片轮询调度,从而实现宏观上的并行。因此,当线程执行 Thread.yield()
即表明降低当前自身优先级,将 CPU
资源优先提供给其它线程。
5. 区别介绍
既然上述几者都能实现线程休眠,那么二者到底有什么区别?
区别很简单:如果线程在持有锁的同时调用 sleep()
后其并不会释放锁,其它对象在此时是无法访问锁的,在这种情况下即容易造成死锁现象。而 wait()
在持有锁的情况下进入休眠将会释放锁,其它对象此时仍能访问锁。
下面通过两个例子详细介绍二者实际生效情况。
(1) sleep()
在下述示例中,当 t1
通过 synchronized
获取锁之后通过 sleep()
进入睡眠,注意此时 t1
仍然持有锁。而同时 t2
尝试获取锁时发现锁已被其它线程持有,将进入重试等待,等到 t1
休眠结束释放锁才能获取锁并输出信息。
最终程序运行的效果就是等待 3
秒后才输出 Get lock
信息,和我们预期的结果一致。
public void SleepDemo() throws InterruptedException {
new Thread(() -> {
synchronized (object) {
try {
// 进入休眠,但未释放锁
Thread.sleep(3 * 1000);
} catch (InterruptedException e) {
}
}
}, "t1").start();
Thread.sleep(500);
new Thread(() -> {
// 等待线程 1 释放锁
synchronized (object) {
System.out.println("Get lock");
}
}, "t2").start();
// 等待两线程结束
Thread.sleep(5 * 1000);
}
(2) wait()
稍微改造上面的例子,运行程序后可以发现在间隔 500ms
启动线程后几乎立即输出的 Get lock
,这正是因为 t1
在进入 wait()
休眠时释放锁的原因,此时 t2
可以直接取锁无需进入等待。
public void demo2() throws InterruptedException {
new Thread(() -> {
synchronized (object) {
try {
// 进入休眠,但释放锁
object.wait(3 * 1000);
} catch (InterruptedException e) {
}
}
}, "t1").start();
Thread.sleep(500);
new Thread(() -> {
// 立即获取锁
synchronized (object) {
System.out.println("Get lock");
}
}, "t2").start();
// 等待两线程结束
Thread.sleep(5 * 1000);
}
六、线程状态
1. 线程中断
当一个线程在运行期间若想停止任务即可通过 interrupt()
中断线程,但中断 interrupt()
只是向线程任务发送一个中断信号,至于线程相应需要根据其自身调度,并不是一发送中断即会中断线程。
线程中断状态第一次中断后将清除中断标识,后续再读全为 false
,同时当线程处于 sleep(), wait(), join()
等阻塞状态时若中断线程同样会清除中断状态。
public void demo1() throws InterruptedException {
Thread thread = new Thread(() -> {
int i = 0;
while (true) {
System.out.println(i++);
try {
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
});
thread.start();
TimeUnit.SECONDS.sleep(3);
// 中断线程,响应由调度决定
thread.interrupt();
}
2. 线程阻塞
(1) notify
在上面 wait()
示例中已经介绍了如何使用 notify()
,这里就不再过多介绍。
(2) Condition
在使用 synchronized
实现同步块时可以通过 wait()
实现动态唤醒的效果,而 Condition
则是针对 ReentrantLock
而言,可达到同样效果。
public void demo() throws InterruptedException {
Lock lock = new ReentrantLock();
// 绑定到锁对象
Condition condition = lock.newCondition();
// 等价于 "wait()"
condition.await();
// 休眠且指定时间
condition.await(1, TimeUnit.SECONDS);
// 等价于 "notify()"
condition.signal();
// 等价于 "notifyAll()"
condition.signalAll();
}