深入理解 Java 线程
线程创建方式
Java 中创建线程有两种方式。第一种是定义 Thread 类的一个子类,在子类中重写 run 方法。然后创建子类实例后调用子类的 start 方法。示例如下:
1 | class PrimeThread extends Thread { |
另一种创建线程的方式是定义 Runnable 接口的实现类,并实现 run 方法。然后将这个实现类的实例作为构造 Thread 对象的参数,然后调用 Thread 对象的 start 方法。示例如下:
1 | class PrimeRun implements Runnable { |
了解了上面的内容,可以说就已经掌握了 Java 中线程的使用方式。接下来介绍下线程的生命周期。
线程生命周期
五态模型
通用的线程生命周期可以用“五态模型”来描述。
- 初始状态
线程已经创建,但是还不允许分配 CPU 执行。这个状态是编程语言特特有的,这里的被创建,指的是在编程语言层面被创建,在操作系统层,真正的线程还没有创建 - 可运行状态
可以被分配 CPU 执行,这种状态下,真正的操作系统线程已经被创建 - 运行状态
被分配到 CPU 的线程状态转换为运行状态 - 休眠状态
运行状态的线程如果调用一个阻塞的 API(利用以阻塞的方式读取文件)或者等待某个事件(例如条件变量),那么线程转换为休眠状态并释放 CPU 的使用权,休眠状态的线程永远无法获取 CPU 使用权。
当等待的事件出现后,线程会从休眠状态转变为可运行状态。 - 终止状态
当线程执行完或者出现异常,线程进入终止状态。终止状态无法切换为任何其它状态,进入终止状态就意味着线程的生命周期结束了。
Java 中的线程的生命周期
Java 语言中线程共有六种状态。
- NEW(初始化状态)
- RUNNABLE(可运行 / 运行状态)
- BLOCKED(阻塞状态)
- WAITING(无时限等待)
- TIMED_WAITING(有时限等待)
- TERMINATED(终止状态)
相比于五态模型,Java 语言里把可运行状态和运行状态合并了,这两个状态在操作系统调度层面有用,而 JVM 层面不关心这两个状态,因为 JVM 把线程调度交给操作系统处理了。
除此以外,Java 语言中细化了休眠状态。Java 线程中的 BLOCKED、WAITING、TIMED_WAITING 都对应操作系统层面的休眠状态。
Java 中线程状态转换
1)RUNNABLE 和 BLOCKED 的转换
- 线程等待 synchronized 的隐式锁时
2)RUNNABLE 和 WAITING 的转换
- 获得 synchronized 隐式锁的线程,调用无参数的 Object.wait() 方法
- 调用无参数的 Thread.join() 方法
- 调用 LockSupport.park() 方法
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(Object blocker, long deadline) 方法
4)NEW 到 RUNNABLE 状态
- 调用线程对象的 start() 方法
5)RUNNABLE 到 TERMINATED 状态
- 执行完 run() 方法
- 执行 run() 方法的时候异常抛出
需要注意的是,当线程调用阻塞式 API 时,是否会转换到 BLOCKED 状态呢?
在操作系统层面,线程是会转换到休眠状态的,但是在 JVM 层面,Java 线程的状态不会发生变化,也就是说 Java 线程的状态会依然保持 RUNNABLE 状态。JVM 层面并不关心操作系统调度相关的状态,因为在 JVM 看来,等待 CPU 使用权(操作系统层面此时处于可执行状态)与等待 I/O(操作系统层面此时处于休眠状态)没有区别,都是在等待某个资源,所以都归入了 RUNNABLE 状态。
可见 Java 线程状态只是 JVM 的内部定义,和操作系统线程状态没有严格的对应关系,这里的 RUNNABLE 就对应了操作系统中的可运行状态、运行状态和部分休眠状态。
了解完 Java 线程状态,可能会有这样的疑问,为什么单单只有 synchronized 获取锁阻塞时,状态是 BLOCKED,而 JUC 中的锁阻塞时、或者阻塞在条件变量上时,状态是 WAITING 呢?
因为在最初的设计 synchronized 时,BLOCKED 表示锁阻塞,WAITING 表示条件变量阻塞,这样区分开是可以理解的,但 JUC 中提供阻塞线程的方法时,底层都是调用的 Unsafe 类的 park 方法,实现 park 方式时,就只能选择一种,JVM 就任选了其中之一。
接下来,我们结合 Thread 类源码,看下其中关键方法的实现。
构造方法
首先是构造方法,Thread 类中提供了多个重载的构造方法,可以设置线程的线程组、任务、名称、栈深度等,最终会调用到 init 方法初始化参数。
1 | private void init(ThreadGroup g, Runnable target, String name, |
- ThreadGroup g
线程组,用于批量管理线程。每个线程都属于一个线程组,如果没有指定,默认会将创建线程的线程的线程组设置为新建的这个线程的线程组
- Runnable target
线程执行的任务。前面介绍过两种创建线程的方式,其中之一就是将 Runnable 的实现类对象作为创建线程的参数。Thread 类本身实现了 Runnable 接口,线程启动后,会执行 Thread 类的 run 方法。run 方法的默认实现就是,如果 target 不为 null,则调用 target 的 run 方法
1 | public void run() { |
- String name
线程名称。如果没有指定,默认会使用如下形式。
1 | public Thread() { |
- long stackSize
线程栈深度。如果是 0,表示创建时没有指定;即使指定了,能发挥什么作用取决于虚拟机的具体实现,一些虚拟机会忽略它。
线程中断
1 | public void interrupt() { |
可以从以下几个方面来理解线程中断。
1)谁有权利中断一个线程
线程中断自身肯定是被允许的,否则会通过 checkAccess 方法来检查权限。
2)中断的效果
如果线程不是 alive 状态(isAlive() 方法返回 true,表示线程处于 alive 状态,也就是说线程已经开始,但还没有结束),调用这个方法没有任何影响
如果线程阻塞在 wait()、wait(long)、wait(long, int)、join()、join(long)、join(long, int)、sleep(long) 或者 sleep(long, int) 方法上,将会清除中断状态并收到 InterruptedException
如果线程阻塞在 InterruptibleChannel 的 I/O 操作上,那么这个通道将会被关闭,设置线程中断状态,并且线程会收到 java.nio.channels.ClosedByInterruptException
如果线程阻塞在 java.nio.channels.Selector 上,将会设置线程中断状态,然后立即从 selection 操作返回,返回值可能是一个非零值,就像调用wakeup 方法一样
如果不是以上这些情况,会设置线程的中断状态
3)检查线程中断
当线程发生中断时,除了上面提到的几种抛出异常的场景外,我们需要通过轮询中断状态来感知中断的发生,也就是在合适的时机(通常放在循环的开始处)去判断线程的中断状态,然后做对应的操作(抛出异常或直接返回)。
Thread 类中提供了以下两个检查中断状态的的方法,他们的区别在于前者是静态方法切会清除中断状态,后者是实例方法且不会清楚中断状态。
1 | public static boolean interrupted() { |
4)中断的作用
中断机制提供了一种线程通知的机制,可以用来唤醒阻塞的线程、终止线程任务等。中断只是给线程发出一个通知,其效果是依赖线程自身的中断检测逻辑的,也就是说中断后进行什么操作是由线程自身决定的。相比 suspend()、stop() 等方式更加的温和,不会出现死锁等问题。关于 suspend()、stop() 等方法的问题后文中有详细介绍。
Thread 类常用方法
- (static)yeild()
yeild() 提示 JVM 线程调度器,当前线程想要让出 CPU 的使用权,但是线程调度器可以忽略这一提示。
这是一个启发式的尝试,避免某个线程对 CPU 的过度使用。它的使用应与详细的分析和基准测试相结合,以确保它实际上具有预期的效果。
实际上很少有合适的场合会使用这个方法。它通常被用于调试或者测试,帮助复现多线程中的 bug。在实现并发工具时这个方法可能很有用, java.util.concurrent.locks 包中的很多类用到了它。
- (static)sleep(long)、sleep(long, int)
使正在运行的线程暂时停止执行一段时间(受系统定时器和调度器的精度和准确性影响)。sleep 的线程并不会释放它持有的锁。
- join()、join(long)、join(long, int)
等待线程执行结束。底层实现实际上是循环检查 isAlive 方法,不满足条件时,调用线程对象的 wait 方法阻塞调用方,线程终止时会调用 notifyAll 方法唤醒阻塞的线程。
1 | public final synchronized void join(long millis) |
Thread 类中的过时方法
- suspend() 和 resume()
suspend 用于挂起一个线程,与之对应的,resume 方法用于恢复挂起的线程。
之所以被弃用,因为这两个方法可能导致线程死锁。挂起的线程不会释放锁资源,如果被挂起的线程持有某个锁,而计划执行 resume 方法的线程需要首先获取这个锁资源,就发生了死锁。
- stop()
stop 方法使线程抛出 ThreadDeath 异常,导致线程直接终止。如果线程还没有开始就调用了 stop 方法,那么线程开始后会立刻终止。通常应用程序不应该捕获 ThreadDeath 异常,除非想要在线程终止前做一些额外的清理操作,如果捕获了 ThreadDeath,应该重新抛出这个异常,以确保线程能最终终止。
这个方法之所以被弃用,因为它是不安全的。调用 stop 方法后,线程会释放所有持有的锁,这些锁保护的资源可能正处于不一致的状态,这些损坏的对象将对其它线程可见,从而导致不可预料的行为。使用 stop 方法的场景,可以使用中断通知来完成,执行任务的线程检查到中断状态后,能够完成收尾工作,再停止运行。
异常处理
接下来,介绍下 Java 线程中的异常处理机制。
当线程因为未捕获异常终止时,虚拟机会通过线程的 getUncaughtExceptionHandler 方法获取线程的 uncaughtExceptionHandler,并调用其 uncaughtException 方法来处理异常;
如果没有显示设置 uncaughtExceptionHandler,getUncaughtExceptionHandler 方法会返回线程的 ThreadGroup 对象;
ThreadGroup 的 uncaughtException 方法的实现逻辑为:
如果存在父线程组,调用父线程组的 uncaughtException 方法
否则通过线程的 getDefaultUncaughtExceptionHandler 方法,获取线程的 defaultUncaughtExceptionHandler
如果显示设置 defaultUncaughtExceptionHandler,调用其 uncaughtException 方法来处理异常
否则判断异常是否是 ThreadDeath
是的话不做任何处理
否则通过标准错误输出打印异常信息
注意,通常我们需要在执行的 run 方法中主动捕获异常,因为线程默认的异常处理会将异常信息出处到标准错误,在生产环境中往往会被丢弃,导致我们无法定位异常原因。
创建多少线程合适?
最后,我们来看一个问题。我们使用多线程,是为更有效地利用系统资源,提高程序的响应速度和吞吐量,那么创建多少线程合适呢?是不是越多越好呢?
显然不是,多线程能够提升CPU 的利用率和 I/O 的利用率,但如果某个资源已经达到了瓶颈,再增加线程不但不会提升性能,还会使性能变得更差,原因是增加了线程切换的成本。所以创建多少线程合适,要看多线程具体的应用场景。
1)对于CPU密集型任务,设置为和 CPU 核心数相当或稍大,就可以充分利用CPU资源;
2)对于IO密集型任务,我们可以将线程池适当开大点,以便众多线程轮流使用CPU,提升CPU利用率,理论上来说可以遵循以下公式来设置线程数:
1 | pool_size = N * (cpu_time + io_time) / cpu_time |
应用上述公式有两个前提,一是没有瓶颈资源,任务执行依赖的资源能够满足所有的线程,二是没有瓶颈操作,任务的执行效率不会随着线程数增加而下降。
瓶颈资源很好理解,这个公式是为了让 CPU 的利用率达到 100%,但如果在这之前 IO 资源已经成了瓶颈,或者线程执行任务依赖的其它资源,比如数据库连接池,达到了瓶颈状态,这种情况下增加更多线程并不会提升性能。
瓶颈操作怎么理解呢?我们使用多线程本质上是一种并行优化,但如果线程执行的任务存在必须串行执行的部分,当代码执行到这部分逻辑时,即使在执行 IO 操作时把 CPU 资源让了出来,其他线程因为拿不到锁,也无法使用 CPU 资源。这种情况下增加更多线程也不会增加性能。
4)很多情况下,对于 io_time 和 cpu_time 的比例,以及是否有瓶颈操作和资源,不太容易得出准确的估计,通常我们会根据经验值进行设置,然后做好监控统计,随时调整优化线程数;
5)在一些比较重要的业务场景下,或者说线程数量设置不准确,可能导致非常大的影响时,我们需要通过压测来确定线程数,保证服务稳定性;
6)最后,业务流量、io_time和cpu_time的比例、任务执行的瓶颈操作和瓶颈资源也不是一成不变的,所以做好监控,按需调整是非常有必要的。