线程是比进程更轻量级的调度执行单位,线程的引入可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源,又可以独立调度。比如线程有自己的栈(Stack)、寄存器(Register)和本地存储(Thread Local)等,但会和进程内其他线程共享文件描述符、虚拟地址空间等。目前线程是 Java 里进行处理器资源调度的最基本单位。
目前 Java 使用的线程调度方式是 抢占式调度。抢占式调度的多线程系统中每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定。在这种实现线程调度的方式下,线程的执行时间是系统可控的,也不会有一个线程导致整个进程甚至整个系统阻塞的问题。
线程属性
Thread 类的 start() 方法用于启动相应的线程,实质是请求 Java 虚拟机运行相应的线程,但具体何时能够运行由线程调度器决定。因此 start() 调用结束并不意味着相应线程已经开始运行。并且线程的 start() 方法只能被调用一次,我们不能通过重新调用一个已经运行结束的线程的 start() 方法来使其重新运行。
守护线程是一种特殊的线程,专门在后台默默完成一些系统性的服务,比如垃圾回收线程、JIT 线程,如果 JVM 发现只有守护线程存在时,将结束进程。与之相对应的是用户线程,用户线程是系统的工作线程,它会完成这个程序应该要完成的业务操作。
Thread daemonThread = new Thread();
daemonThread.setDaemon(true);
daemonThread.start();
线程的 run 方法不能抛出任何检查型异常,但是,非检查型异常可能会导致线程终止。在这种情况下,线程会死亡。我们可以使用 setUncaughtExceptionHandler 方法为任何线程实例安装一个异常处理器,也可以使用 Thread 类的静态方法 setDefaultUncaughtExceptionHandler 为所有线程安装一个默认的异常处理器。该处理器可以在 run 方法发生未捕获异常时自定义异常逻辑,而避免线程直接终止。
线程状态转换
Java 语言中线程共有六种状态,分别是:
- NEW:创建后尚未启动的线程处于这种状态。
- RUNNABLE:该状态是一个复合状态,包括操作系统线程状态中的 READY 和 RUNNING,处于此状态的线程有可能正在执行,也有可能正在等待着操作系统为它分配执行时间。
- BLOCKED:线程被阻塞了,【阻塞状态】与【等待状态】的区别是阻塞状态在等待着获取到一个排它锁,这个事件将在另外一个线程放弃这个锁时发生;而等待状态则是在等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域时,线程将进入这种状态。
- WAITING:处于这种状态的线程不会被分配处理器执行时间,它们要等待被其他线程显式唤醒。
- TIMED_WAITING:处于这种状态的线程也不会被分配处理器执行时间,不过无须等待被其他线程显式唤醒,在一定时间后它们会由系统自动唤醒。
- TERMINATED:已终止线程的线程状态,线程已经结束执行。
在操作系统层面,Java 线程中的 BLOCKED、WAITING、TIMED_WAITING 状态都被看作是休眠状态。因此只要 Java 线程处于这三种状态之一,那么这个线程就永远没有 CPU 的使用权。
1. RUNNABLE 与 BLOCKED 的状态转换
synchronized 修饰的方法、代码块同一时刻只允许一个线程执行,其他线程只能等待,这种情况下,等待的线程就会从 RUNNABLE 转换到 BLOCKED 状态。而当等待的线程获得 synchronized 隐式锁时,就又会从 BLOCKED 转换到 RUNNABLE 状态。
2. RUNNABLE 与 WAITING 的状态转换
总体来说,有三种场景会触发这种转换。
第一种场景,获得 synchronized 隐式锁的线程,调用无参数的 Object.wait() 方法。
第二种场景,调用无参数的 Thread.join() 方法。例如有一个线程对象 thread A,当调用 A.join() 的时候,执行这条语句的线程会等待 thread A 执行完,而等待中的这个线程,其状态会从 RUNNABLE 转换到 WAITING。当线程 thread A 执行完,原来等待它的线程又会从 WAITING 状态转换到 RUNNABLE。
第三种场景,调用 LockSupport.park() 方法,当前线程会阻塞,线程的状态会从 RUNNABLE 转换到 WAITING。调用 LockSupport.unpark(Thread thread) 可唤醒目标线程,目标线程的状态又会从 WAITING 状态转换到 RUNNABLE。
3. RUNNABLE 与 TIMED_WAITING 的状态转换
有五种场景会触发这种转换:
- 调用带超时参数的 Thread.sleep(long millis) 方法;
- 获得 synchronized 隐式锁的线程,调用带超时参数的 Object.wait(long timeout) 方法;
- 调用带超时参数的 Thread.join(long millis) 方法;
- 调用带超时参数的 LockSupport.parkNanos(Object blocker, long deadline) 方法;
- 调用带超时参数的 LockSupport.parkUntil(long deadline) 方法。
4. 从 RUNNABLE 到 TERMINATED 状态
线程执行完 run() 方法后,会自动转换到 TERMINATED 状态,当然如果执行 run() 方法时抛出异常,也会导致线程终止。
线程协作机制
1. 中断
有时候我们需要强制中断 run() 方法的执行,例如 run() 方法访问一个很慢的网络,我们等不下去了,想终止怎么办呢?正确的姿势其实是调用 interrupt() 方法。interrupt() 方法并不会使线程退出,而是仅仅给线程发送一个退出通知,告知线程退出,至于目标线程接到通知后如何处理,则完全由目标线程自行决定。
被 interrupt 的线程,是怎么收到通知的呢?一种是异常,另一种是主动检测。
1)异常
当线程 A 处于 WAITING、TIMED_WAITING 状态时,如果其他线程调用线程 A 的 interrupt() 方法,会使线程 A 返回到 RUNNABLE 状态,同时线程 A 会触发 InterruptedException 异常,并且中断标记会被清除。类似 wait()、join()、sleep() 这样的方法,在方法签名上都会 throws InterruptedException 这个异常。这个异常的触发条件就是:其他线程调用了该线程的 interrupt() 方法。
public static void main(String[] args) throws InterruptedException{
Thread threadA = new Thread(() -> {
try {
Thread.sleep(10_000_000);
} catch (InterruptedException e) {
// 触发InterruptedException异常后,中断标志位会被清除,所以这里条件不成立
if (Thread.currentThread().isInterrupted()) {
System.out.println("Interrupted flag is true");
}
System.out.println("触发 InterruptedException 异常, 程序退出");
}
});
threadA.start();
Thread.sleep(3000);
// 触发threadA的InterruptedException异常
threadA.interrupt();
}
2)主动检测
还有一种是主动检测,如果线程处于 RUNNABLE 状态,并且没有阻塞在某个 I/O 操作上,例如中断计算圆周率的线程 A,这时就得依赖线程 A 主动检测中断状态了。如果其他线程调用线程 A 的 interrupt() 方法,那么线程 A 可以通过 isInterrupted() 方法,检查中断标志位,来判断是不是自己被中断了。
public static void main(String[] args) throws InterruptedException{
Thread thread = new Thread(() -> {
while (true) {
// 响应中断、自行退出线程
if (Thread.currentThread().isInterrupted()) {
System.out.println("Thread is interrupted");
break;
}
}
});
thread.start();
Thread.sleep(1000);
thread.interrupt();
}
还有个非常相似的方法:interrupted() 方法。它是一个静态方法,用来检查当前线程是否被中断,并且调用该方法会清除该线程的中断标记,即将当前线程的中断状态重置为 false。
2. 等待-通知
一个完整的等待-通知机制:线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当要求的条件满足时,通知等待的线程,重新获取互斥锁。Java 语言内置的 synchronized 配合 wait()、notify()、notifyAll() 这三个方法就能轻松实现等待-通知机制。
如下图所示,左边有一个等待队列,同一时刻只允许一个线程进入 synchronized 保护的临界区,当有一个线程进入临界区后,其他线程就只能进入图中左边的等待队列里等待。这个等待队列和互斥锁是一对一的关系,每个互斥锁都有自己独立的等待队列。
在并发程序中,当一个线程进入临界区后,由于某些条件不满足,需要进入等待状态。如上图所示,当调用 wait() 方法后,当前线程就会被阻塞,并且进入到右边的等待队列中,这个等待队列也是互斥锁的等待队列。 线程在进入等待队列的同时,会释放持有的互斥锁,线程释放锁后,其他线程就有机会获得锁并进入临界区了。
当等待线程要求的条件满足时,可以调用 notify() 和 notifyAll() 方法来唤醒等待线程。如下图所示,当条件满足时调用 notify(),会通知等待队列(互斥锁的等待队列)中的线程,告诉它条件曾经满足过。
为什么说是曾经满足过呢?因为 notify() 只能保证在通知时间点,条件是满足的。而被通知线程的执行时间点和通知的时间点基本上不会重合,所以当线程执行时可能条件已经不满足了,所以要重新检验条件是否满足。所以一般采用下面这种写法:
// 推荐
while(条件不满足) {
wait();
}
// 不推荐,可能引入bug
if(条件不满足) {
wait();
}
注意事项:
被通知的线程要想重新执行,仍然需要获取到互斥锁(因为曾经获取的锁在调用 wait() 时已经释放了)。由于 wait()、notify()、notifyAll() 方法操作的等待队列是互斥锁的等待队列,所以这三个方法能够被调用的前提是已经获取了相应的互斥锁,所以我们会发现 wait()、notify()、notifyAll() 都是在 synchronized{} 内部被调用的。否则,JVM 会抛出一个 IllegalMonitorStateException 异常。
3. join
join() 方法本质上是让调用线程 wait 在当前线程对象实例上。线程 A 调用线程 B 的 join() 方法后会被阻塞,直到线程 B 执行完成。
// 无限等待,会一直阻塞当前线程,直到目标线程执行完毕,等待期间如果被中断会抛出异常
public final void join() throws InterruptedException
// 限定最大等待时间,超时不再等待,等待期间如果被中断会抛出异常
public final void join(long millis) throws InterruptedException
实现原理:
public final synchronized void join(long millis)
......
if (millis == 0) {
while (isAlive()) {
wait(0);
}
} else {
while (isAlive()) {
long delay = millis - now;
if (delay <= 0) {
break;
}
wait(delay);
now = System.currentTimeMillis() - base;
}
}
}
从代码中可以看到,join() 方法让调用线程在当前线程对象上进行等待。因此,不要在这个 Thread 对象实例上使用类似 wait() 或 notify() 等方法,因为这很有可能会影响系统 API 的工作。
4. yield
Thread.yield() 是一个静态方法,它会使当前线程主动让出 CPU,并非被阻塞挂起,而是处于就绪状态。让出 CPU 不代表线程不再执行,当前线程在让出 CPU 时还会进行 CPU 资源的争夺,但是否能够再次被分配到就不一定了。
5. sleep
Thread.sleep() 方法会让当前线程休眠若干时间,休眠期间线程会让出执行权,不参与 CPU 调度,但该线程拥有的监视器资源,比如锁是不让出的。如果在睡眠期间其他线程调用了该线程的 interrupt() 方法,该线程会在调用 sleep() 的地方抛出 InterruptedException 异常返回。
6. 过时方法
suspend()、resume()
不推荐使用 suspend() 方法去挂起线程是因为 suspend() 方法在导致线程暂停的同时,并不会释放任何锁资源,直到对应线程执行 resume() 方法。
stop()
Thread.stop() 方法在结束线程时,会直接终止线程,并立即释放这个线程所持有的锁。如果线程持有了 ReentrantLock 锁,被 stop() 的线程并不会自动调用 ReentrantLock 的 unlock() 去释放锁,那其他线程就再也没机会获得 ReentrantLock 锁。
线程实现
主流的操作系统都提供了线程的实现,Java 语言则提供了在不同硬件和操作系统平台下对线程操作的统一封装处理, 每个已经调用过 start() 方法且还未结束的 java.lang.Thread 类的实例就代表着一个线程,实现线程主要有以下三种方式:
- 使用内核线程实现(1:1 实现)
- 使用用户线程实现(1:N 实现)
- 使用用户线程加轻量级进程混合实现(N:M 实现)
基本上在 Java 1.2 后,JDK 已经抛弃了所谓的 Green Thread,即用户调度的线程,主流的商用 Java 虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现,即采用一比一映射到操作系统的内核线程。因此何时冻结或唤醒线程、该给线程分配多少处理器执行时间等都是由操作系统来全权决定的。
这种实现有利有弊,总体来说,Java 语言得益于精细粒度的线程和相关的并发操作,其构建高扩展性的大型应用的能力已经毋庸置疑,但其复杂性也提高了并发编程的门槛。近几年的 Go 语言等提供了协程大大提高了构建并发应用的效率。于此同时 Java 也在 Loom 项目中,孕育新的类似轻量级用户线程(Fiber)等机制,也许在不久的将来就可以在新版 JDK 中使用到它。
1. 内核线程实现
使用内核线程实现的方式也被称为 1:1 实现。内核线程(KLT)就是直接由操作系统内核支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。
程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(LWP),就是通常意义上讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程才能有轻量级进程。
由于内核线程的支持,每个轻量级进程都成为一个独立的调度单元,即使其中某一个轻量级进程在系统调用中被阻塞了,也不会影响整个进程继续工作。
缺点是由于是基于内核线程实现的,所以各种线程操作都需要进行系统调用。而系统调用的代价相对较高,需要在用户态和内核态中来回切换。其次,每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源,因此一个系统支持轻量级进程的数量是有限的。
2. 用户线程实现
使用用户线程实现的方式被称为 1:N 实现。用户线程(UT)指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在及如何实现的。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也能够支持规模更大的线程数量。
用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要由用户程序自己去处理。线程的创建、销毁、切换和调度都是用户必须考虑的问题。除非有明确的需求,一般的应用程序都不倾向使用用户线程,但 golang 使用的是用户线程。
3. 混合实现线程
混合实现是将内核线程与用户线程一起使用的实现方式,被称为 N:M 实现。在这种混合实现下,既存在用户线程,也存在轻量级进程。 用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。
而操作系统支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级进程来完成,这大大降低了整个进程被完全阻塞的风险。在这种混合模式中,用户线程与轻量级进程的数量比是不定的,是 N:M 的关系。