JVM

回顾《深入理解 Java 虚拟机》之 Java 和线程

第六篇

Posted by ChenJY on February 16, 2019 | Viewed times

首先,并发不一定要依赖多线程,例如 PHP 中可以存在多进程并发。但是当我们在 Java 里面谈论并发时,一般都与线程脱不了干系,因此我们先来谈谈什么是线程,它跟进程有什么区别。

再谈进程与线程

如果一个服务器同时只能为一个客户端连接服务,其他都需要阻塞,那么效率定然会很感人,为了让服务器能同时服务更多的客户端连接,会经常应用并发编程。而实现并发的手段有多进程多线程IO 多路复用等。

例如 Linux 的系统函数 fork 可以在父进程中 fork 出一个子进程,然后父进程来监听请求的到达,子进程处理具体的 IO 操作,达到并发处理的目的。还有,Tomcat 本身是一个进程,但是它内部处理请求是多线程的方式进行的。

进程是计算机概念中很重要的抽象,它使得程序之间隔离开。在这里有个问题?就是究竟什么是计算机资源?我认为可以分为计算资源存储资源两种,计算资源能具体来讲就是 CPU 的时间片,存储资源即为内存加磁盘。进程就是计算机中分配资源的最小单位,可以为一个进程分配可执行的时间片,还有这个进程可以用多大的存储空间(实际上每个进程都有独立的虚拟地址空间)。这种资源分配就造成了一种假象,即该进程自认为自己独占所有的计算机资源在运行

线程它是进程的一个执行流,是 CPU 任务调度的基本单位,一个进程可以由单个线程组成也可以由多个线程组成,线程间共享进程的所有资源,但每个线程也有自己的堆栈和局部变量。线程由 CPU 独立调度执行,在多 CPU 环境下就允许多个线程同时运行。

但是在最开始的时候,没有线程,那时进程既是资源分配也是调度的最小单位,多核 CPU 的出现让同时执行多个任务成为可能,因此为了提高效率才将资源分配和调度分开,诞生了线程。

进程线程的优劣势

  1. 进程有自己的独立地址空间,每启动一个进程,系统就会为它分配地址空间,建立数据表来维护代码段、堆栈段和数据段,这种操作非常昂贵。而线程是共享进程中的数据的,使用相同的地址空间,因此 CPU 切换一个线程的花费远比进程要小很多,同时创建一个线程的开销也比进程要小很多。
  2. 线程之间的通信更方便,同一进程下的线程共享全局变量、静态变量等数据,而进程之间的通信需要以通信的方式(IPC)进行。不过如何处理好同步与互斥是编写多线程程序的难点。
  3. 但是多进程程序更健壮,多线程程序只要有一个线程死掉,整个进程也死掉了,而一个进程死掉并不会对另外一个进程造成影响,因为进程有自己独立的地址空间。

线程的实现

线程分为两种:

名称 内容
用户级线程(User-Level Thread, ULT) 由应用程序所支持的线程实现, 对内核不可见
内核级线程(Kernel-Level Thread, KLT) 内核级线程又称为内核支持的线程

实现线程主要有三种方式:使用内核线程实现、使用用户线程实现、使用用户线程加轻量级进程混合实现

使用内核线程实现

内核线程(KTL)就是直接由操作系统内核(Kernel)支持的线程,这种线程由内核完成线程切换,内核通过操纵调度器(scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。

程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口——轻量级进程(Light Weight Process,LWP)Linux 内核中其实没有线程的概念,它所谓的线程就是轻量级进程。

由于内核线程的支持,每个 LWP 都会成为一个独立的调度单元,即使一个轻量级进程在系统调用中阻塞了,也不会影响整个进程继续工作,这种进程与用户线程之间是 1:1 的关系,但是 LWP 也有局限性:

  1. 由于是基于内核线程实现的,所以各种线程操作,如创建、析构以及同步都需要进行系统调用,代价较高,需要在用户态和内核态中来回切换
  2. 每个轻量级进程都需要一个内核线程的支持,因此轻量级进程要消耗一定的内核资源,因此一个系统支持的轻量级进程是有限的。

使用用户线程实现

广义上,只要一个线程不是内核线程(KLT)就可以认为是用户线程(ULT)。狭义上用户线程指的是完全建立在用户空间的线程库上,系统内核也不能感知线程存在的实现,用户线程的建立、同步销毁和调度完全在用户态中完成,不需要内核的帮助。

如果实现得当这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也可以支持更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。

这种进程与用户线程之间是 1:N 的关系。其优势在于不需要系统内核的支援,缺点是所有线程操作都需要用户程序自己处理,诸如 “阻塞处理”、“多处理器系统如何将线程映射到其他处理器上” 这类问题解决起来很麻烦,因而使用用户线程实现的程序一般都比较复杂

使用用户线程加轻量级进程混合实现

在这种实现方式下,用户线程还是完全建立在用户态中,可支持大规模用户线程并发;而操作系统提供支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能以及处理器映射,并且用户线程的系统调用要通过轻量级线程来完成,大大降低了整个进程被完全阻塞的风险。这种模式下用户线程与轻量级进程的数量比是不确定的,即为 N:M 的关系。

Java 线程的实现

java.lang.Thread 类的一个实例就代表一个线程,注意这个 Java 类与大部分 Java API 由显著区别,在于它的关键方法都声明为 Native,意味着这个方法没有或者说无法使用平台无关的手段实现。

对于 Sun JDK 来说,它的 Windows 版与 Linux 版都是使用 1:1 线程模型实现的。

Java 线程调度

线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种,分别是协同式线程调度抢占式线程调度

协同式调度

协同式中线程的执行时间由线程本身控制,当线程把自己的工作执行完了之后,主动通知系统切换到另一个线程上,好处是简单,切换操作对于线程自己是可知的,没什么线程同步问题。坏处是线程执行时间不可控,可能会一直阻塞然后系统崩溃。

抢占式调度

抢占式的话,每个线程由系统分配执行时间,不由线程本身决定。线程的执行时间是系统可控的,不会有一直阻塞的问题,Java 使用抢占式调度Java 中可以通过设置优先级来达到给一些线程多一点时间,给一些线程少一点时间的目的。Java 语言一共有 10 个级别的线程优先级,优先级高的越容易被系统选择执行。但是 Java 的线程是通过映射到系统原生的线程上来实现的,所以线程调度最终还是取决于系统。

状态转换

Java 语言定义了五种线程状态(注意这里说的是 Java 中定义的线程状态,和 Linux 中不尽相同),任何一个时间点,一个线程只能有其中一个状态,分别是:

  1. 新建(New):创建后尚未启动
  2. 运行(Runnable):包括了操作系统线程状态中的 Running 和 Ready,意味着这种状态下的线程可能正在执行,也可能在等待分配时间片
  3. 无限期等待(Waiting):不会被分配 CPU 执行时间,等待被其他线程显式地唤醒,调用 Object.wait 或者 Thread.join 都会使得线程进入无限期等待状态
  4. 限期等待(Timed Waiting):不会被分配 CPU 执行时间,但是无需被其他线程显式唤醒,而是一定时间后由系统唤醒,调用 Thread.sleep 方法,或者是有超时参数的 Object.waitThread.join 都会使得线程进入限期等待状态
  5. 阻塞(Blocked):阻塞状态等待获取一个排它锁(这是跟等待状态最大的区别),这个事件将在另一个线程放弃这个锁的时候发生;而等待状态是等待一段时间或者唤醒动作的发生。
  6. 结束(Terminated):已终止的线程

参考资料

  • 《深入理解 Java 虚拟机》 周志明著

许可协议


这是一个不定时更新的、披着程序员外衣的文青小号。

在这里,既分享极客技术,也记录人间烟火,欢迎关注。

0

Comment