Java 采用 thread-per-task 的线程模型,即一个任务(一段代码)对应一个 Java 线程(thread),而一个 Java 线程对应一个操作系统线程,所以了解一些操作系统进程的管理知识可以更好的了解 Java 线程,下面以 Liunx 为例来分析 Java 线程
Liunx 的进程管理
Linux 中的进程(Process/Task)是正在执行的程序代码集合,是内核的最小资源调度单位,各个进程之间的资源相互隔离,Linux 通过虚拟处理器和内存,使得进程感觉自己在独享处理器和内存。而线程(Thread)是最小的计算单位,各个线程之间共享资源,理论上线程由进程产生,一个进程由若干线程组成。
Linux 中,内核不区分进程与线程,只不过使用不同的创建方式,进程由 fork()
或 exec()
系统调用创建,而线程由 clone()
系统调用创建,并且通过指定 clone 参数,与父进程共享内存空间,文件句柄,信号处理等(其本质还是进程)
POSIX thread (pthread) 的实现,在 2.6之前,Linux kernel 没有真正的 thread 支持,线程库都是通过 clone() 实现的。2.6 之后,由 NPTL(Native POSIX Thread Library) 实现,NPTL 除了使用 clone() 外,在内核中增加了 futex(fast userspace mutex) 用于支持处理线程之间的 sleep 与 wake。futex 是一种高效的对共享资源互斥访问的算法。NPTL是一个1×1 的线程模型,即一个用户线程对应一个内核进程。其他一些操作系统比如 Solaris 则是 MxN 的,M 对应创建的线程数,N 对应内核可以运行的实体。NPTL 创建线程的效率非常高。一些测试显示,基于 NPTL 的内核创建 10 万个线程只需要 2 秒,而没有 NPTL 支持的内核则需要长达 15 分钟
进程的数据结构
一个进程由结构 task_struct
表示,各进程通过双向列表联系起来,创建进程时由内核为 task_struct
分配内存(32bit 下为 1.7KB ),Linux 使用 PID 标识进程 ,其最大值决定了某一时刻系统最大的并发进程的数量
进程的状态迁移
进程的创建与销毁
fork() // or exec(), clone()
wait4()
exit()
无论是 fork()
还是 exec()
,都是通过调用 clone()
实现的,clone()
会调用 do_fork()
完成进程的创建,clone()
可以接受一系列参数来指明需要共享的资源
Linux 中的内核线程(ps
命令中中括号标识的进程)由内核直接创建,没有独立的地址空间,只在内核空间运行。 而用户进程(执行一个可执行文件)先由 fork()
通过复制当前进程创建一个子进程,调用 fork() 的进程称为父进程,新产生的进程称为子进程。当 fork() 调用结束,在返回点上父进程恢复执行,子进程开始执行,fork() 采用写时复制,初始时父进程与子进程只有 PID 不同,然后由 exec() 读取可执行文件并将其载入地址空间开始运行(替换子进程),父进程可以通过 wait4()
系统调用(wait/waitpid
)查询子进程是否终止。
如果父进程在子进程之前结束(没有 wait 其子进程),子进程就可能变为僵尸进程,导致进程描述符所占的空间无法释放,现在的内核(2.6)中如果子进程的父进程提前结束,内核会为该子进程查找一个养父进程,内核先在同组进程中找,如果找不到则由 init 进程充当,然后由其养父进程释放该进程,因为 init 进程一定会 wait 其子进程,所以可以认为该进程最终一定会被释放而避免产生僵尸进程
与进程相关的内核参数
ulimit -u # max_user_processes
/proc/sys/vm/max_map_count # max_map_count
/proc/sys/kernel/threads-max # max_threads
/proc/sys/kernel/pid_max # pid_max
查看系统的线程信息
/proc//status
/proc//sched
/proc//task
# ruser 用户 ID
# lwp (light weight process) 线程ID
# psr 为 CPU 的序号
ps -eo ruser,pid,ppid,lwp,psr -L
创建 Java 线程
当我们调用 new Thread()
时,JVM 并不会立即创建一个与其对应的系统线程,而是当调用了 start()
方法之后,JVM 才会通过系统调用 clone
来创建一个与其对应的系统线程(参考 pthread_create()
),因为 Java 线程最终被映射为系统线程,所以当我们需要创建线程时,尤其是需要大量线程时,我们需要注意:
- 操作系统对线程的数量的限制
- 创建、调度和终止线程的系统开销
- 线程本身对系统资源的消耗(尤其是内存,JVM 需要为每个线程维护一个独立的线程栈
-Xss
)
如果 JVM 无法创建线程,会抛出 java.lang.OutOfMemoryError: unable to create new native thread
异常
由于线程不可以无限制的使用,所以利用线程池(Executor Framework)对线程进行复用和管理是常见的使用线程的方式
go 语言则采取了另外一种方式构建线程:goroutine,goroutine 构建在系统线程的基础之上,与系统线程的关系是 m:n,即在 n 个系统线程(GOMAXPROCS)上多工的调度 m 个 goroutines,因此 goroutine 使用了非常小的调用栈(2KB)并且缩短了线程之间的调度(切换)时间
创建本地进程(native process)
Java 通过 ProcessBuilder.start()
(推荐)或 Runtime.exec
方法创建本地进程(即执行外部程序)并返回 Process
实例,该实例可用于控制进程并获取进程的相关信息,同时通过该实列还可以操作进程的输入输出、等待进程完成、检查进程的退出状态以及销毁(终止)进程。因为 Java 本地进程是和平台相关的,因此在使用时需要注意的地方包括:
- 即使没有任何对当前本地进程
Process
对象的引用,本地进程也不会被终止,而是继续异步的执行 - 在本地进程退出之前,无法获取进程的退出状态,需要通过
Process.waitFor()
来等待外部程序的退出 - 创建的本地进程(子进程)没有自己的终端或控制台(Java7 之后可以通过
ProcessBuilder.inheritIO()
将子进程的标准 I/O 和当前的 Java 进程设置成一样),本地进程的所有标准 I/O(stdin
,stdout
和stderr
)操作以流的方式被重定向到当前 Java 进程(父进程),在当前 Java 进程中可以通过Process.getOutputStream()
,Process.getInputStream()
和Process.getErrorStream()
来获得这些流,然后父进程使用这些流向子进程提供输入并从子进程获取输出。由于某些本地平台仅为标准输入和输出流提供有限的缓冲区大小,因此无法及时写入输入流或读取输出流,这有可能导致子进程阻塞甚至死锁,因此需要立即处理来自本地进程的输入和输出,即在ProcessBuilder.start()
之后,立即启动线程处理标准 I/O - 不能像命令行一样在本地进程中使用管道,如果需要使用管道,需要用 shell 对其包装,如:
new ProcessBuilder("/bin/sh", "-c", "ls -l| grep foo");
终止 Java 线程
Java 中,一个线程没有办法直接终止另一个线程,只能通过发送中断请求(或信号)来请求其它线程终止,而接收到请求的线程可能立即退出,也可能不做任何响应,所以一个线程只会在以下情况下退出
- 线程的 run 方法退出,包括正常退出和异常退出
- 线程响应了中断请求(或终止信号),退出 run 方法
- 线程所在的 JVM 关闭
对于异常退出的线程,记录异常信息对以后的调试和分析都非常重要,因此要尽可能的记录线程的异常信息
public void run() {
Throwable thrown = null;
try {
while (!isInterrupted())
runTask(getTaskFromWorkQueue());
} catch (Throwable e) { //cath unexpected exception
thrown = e;
} finally {
// To call uncaughtException method to record the exception
threadExited(this, thrown);
}
public void uncaughtException(Thread t, Throwable e) {
Logger logger = Logger.getAnonymousLogger();
logger.log(Level.SEVERE, t.getName(), e);
}
中断(Interrupt)机制
Java 的中断机制是一种通知机制,通过中断并不能直接终止另一个线程,而只是给另一线程发送了中断请求(改变线程的中断状态),该线程自己处理该中断请求,可能立即退出,也可能不做任何响应。
中断状态
Java 每个线程都维护着一个中断状态(interrupted status),该状态代表着是否有中断请求,该状态可以被任意线程设置,包括被中断的线程本身(除了线程本身正在中断中)
最佳实践:线程的中断状态应该由线程的创建者来设置
线程中与中断状态相关的方法:
-
public void interrupt()
,中断当前线程,即设置当前线程的中断状态 -
public static boolean interrupted()
,测试当前线程是否已经中断并 清除当前线程的中断状态,如果连续两次调用该方法,则第二次调用将返回false
(第一次调用已清除了其中断状态) -
public boolean isInterrupted()
,测试当前线程是否已经中断,线程的中断状态不受该方法的影响
中断使用的场景
- 某个操作超过了一定的执行时间限制需要中止时
- 多个线程做相同的事情,只要一个线程成功其它线程都可以取消时
- 一组线程中的一个或多个出现错误导致整组都无法继续时
- 当一个应用或服务需要停止时
中断的处理
想要处理中断,首先需要监测中断状态,可以不断的通过调用 interrupted
或 isInterrupted
来测试线程的中断的状态,但需要根据实际情况来决定测试频率,频繁的测试可能会使程序执行效率下降,相反,测试的较少又可能导致中断请求得不到及时响应。
当监测到中断状态后,需要选择在合适的时机处理中断,合适的时机并不一定是立即处理终端退出程序,该时机也需要根据实际情况来决定,主要是要避免线程所处理对象处于不一致状态。
当时机合适就可以处理中断,比如:回滚当前事务、清理资源、清理中断标志,抛出 InterruptedException
或立即退出。
InterruptedException
的处理
Java API 中的阻塞方法一般都会声明抛出
InterruptedException
异常(代表该方法是可中断的,即会响应中断请求),Java 的阻塞线程通过清除中断状态并抛出InterruptedException
来响应中断
如果应用程序捕获到了 InterruptedException
异常,则说明当前的线程调用的阻塞方法发生了中断,当前线程可以:
选择退出或结束
继续向方法调用栈的上层抛出该异常
-
捕获可中断方法的
InterruptedException
并设置中断状态,表明当前线程已经中断/*Non-cancelable Task that Restores Interruption Before Exit.*/ public Task getNextTask(BlockingQueue
关闭 JVM
JVM 的关闭方式包括:
- 通过调用
System.exit()
或键入 Ctrl-C(等同于kill -2
,向 JVM 发送SIGINT
信号)的标准关闭 - 通过
kill
命令的其它参数的关闭(abrupt shutdown)
在 JVM 关闭的时候可以通过 Shutdown Hooks (Runtime.addShutdownHook
)来执行一些清理工作,在使用 addShutdownHook
时需要注意:
- 如果有多个 hook,其执行是并发的,即无法确定 hook 的执行顺序
- 使用 abrupt shutdown 时,hook 不会执行
Java 中有两类线程:用户线程 (User Thread) 和守护线程 (Daemon Thread),守护线程是指在程序运行时在后台提供一种通用服务的线程,比如垃圾回收线程,这种线程并不属于程序中不可或缺的部分。因此,当所有的非守护线程结束时,程序也就终止了,同时会杀死进程中的所有守护线程。反之,只要任何非守护线程还在运行,程序就不会终止。用户线程和守护线程几乎没有区别,只是在 JVM 关闭的时候,JVM 不会为守护线程做更多的工作,如:守护线程剩下的代码不会执行
Java 线程的生命周期
线程的 runnable 状态仅代表线程在 JVM 中开始执行,但从系统来看,该线程可能真的在运行,也可能在等待别的资源
参考
- Thread doc
- ProcessBuilder doc
- 详细分析Java中断机制
- Interrupts Tutorial