Java线程与协程

1. 线程的实现方式

1.1. 内核线程实现

使用内核线程实现的方式也被称为1:1实现。

内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核(Kernel,下称内核)支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就称为多线程内核(Multi-Threads Kernel)。程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(Light Weight Process,LWP),轻量级进程就是我们通常意义上所讲的线程,由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间1:1的关系称为一对一的线程模型:

Untitled

内核线程实现的局限性:

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

1.2. 用户线程实现

使用用户线程实现的方式被称为1:N实现。

用户线程(User Thread,UT)指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在及如何实现的用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也能够支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。这种进程与用户线程之间1:N的关系称为一对多的线程模型

Untitled

用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要由用户程序自己去处理。线程的创建、销毁、切换和调度都是用户必须考虑的问题,而且由于操作系统只把处理器资源分配到进程,那诸如“阻塞如何处理”“多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难

1.3. 混合实现

混合实现是将内核线程与用户线程一起使用的实现方式,被称为N:M实现。

在这种混合实现下,既存在用户线程,也存在轻量级进程。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构等操作依然廉价,并且可以支持大规模的用户线程并发。而操作系统支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级进程来完成,这大大降低了整个进程被完全阻塞的风险。混合模式中,用户线程与轻量级进程的数量比是不定的,是N:M的关系

Untitled

2. Java线程

2.1. Java线程实现

Java虚拟机规范没有规定Java的线程实现方式。

以HotSpot虚拟机为例,使用的是内核线程的实现方式。

它是每一个Java线程都是直接映射到一个操作系统原生线程上的,而且中间没有额外的间接结构,所以HotSpot自己是不会去干涉线程调度的(可以设置线程优先级给操作系统提供调度建议),全权交给底下的操作系统去处理,所以何时冻结或唤醒线程、该给线程分配多少处理器执行时间、由哪个处理器核心去执行等问题都是操作系统全权决定。

2.2. Java线程调度

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

  • 协同式(Cooperative Threads-Scheduling)线程调度

    线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,主动通知系统切换到另一个线程上去。

    协同式多线程实现简单,切换操作对线程可知。

  • 抢占式(Preemptive Threads-Scheduling)线程调度

    Java采用的是抢占式线程调度。 抢占式调度的多线程系统,由操作系统分配每个线程的执行时间,线程的切换不由线程本身决定。比如Java中可以使用Thread#yield方法让出执行时间,但还是由操作系统决策。

    抢占式线程调度不会出现一个线程执行时间过长,导致整个系统阻塞的问题,操作系统会为每个线程都分配部分时间。

2.3. 线程状态

Java定义了六种线程状态:

Untitled

  • 新建(New):创建后尚未启动的线程的状态

  • 运行(Runnable):包括操作系统线程状态中的Running与Ready,也就是说,此时线程可能正在执行,也可能正在等待操作系统为它分配执行时间

  • 无限期等待(Waiting):这种状态下,线程不会被分配处理器执行时间,需要等待其他线程显式唤醒。

    Java中下列方法会导致线程陷入该状态:

    • 没有设置Timeout参数的Object#wait方法
    • 没有设置Timeout参数的Thread#join方法
    • LockSupport#park方法
  • 限期等待(Timed Waiting):这种状态下,线程不会被分配处理器执行时间,在一定时间之后会被系统自动唤醒。

    Java中以下方法会让线程进入限期等待状态:

    • Thread#sleep方法
    • 设置了Timeout参数的Object#wait方法
    • 设置了Timeout参数的Thread#join方法
    • LockSupport#parkNanos方法
    • LockSupport#partUntil方法
  • 阻塞(Blocked):线程进入同步区域时,会进入被阻塞的状态。“阻塞”与“等待”的区别在于“阻塞”是在等待获取一个排它锁,当另一个线程放弃这个锁时,线程会离开阻塞状态。

  • 结束(Terminated):已终止线程的线程状态,线程已结束执行。

3. Java协程

这一块目前应该还没有正式发布,只是一个技术展望。

传统的内核线程,在线程切换时会带来很高的成本,主要来自“用户态与核心态”之间的状态转换,会带来响应中断、保护和恢复执行现场的成本

  • 系统中断发生,线程A切换到线程B执行,操作系统需要先挂起线程A,保存好线程A的上下文。
  • 切换到线程B,恢复线程B挂起时多上下文(寄存器,内存分页等信息)

如果使用用户线程,转移到程序员手中负责线程的切换,尽管这些过程依然存在,可是程序员可以在程序角度缩减这些开销。由于最初很多用户线程实现基于协同式调度,因此有了协程(Coroutine):

  • 有栈协程(Stackfull Coroutine):协程会完整地做调用栈的保护、恢复工作。
  • 无栈协程(Stackless Coroutine):有限状态机,状态保存在闭包中(比如各个语言的await、async、yield)。

微软有一个概念叫做纤程(Fiber),OpenJDK创建了Loom项目使用了纤程的名字,这是一个Java关于有栈协程的实现,尚未明确正式发布。

4. 线程安全

4.1. 线程安全的实现方法

4.1.1. 互斥同步

互斥同步(Mutual Exclusion & Synchronization)是一个阻塞同步(Blocking Synchronization)方案,互斥是方法,同步是目的。

Java中的synchronized关键字就是一个互斥同步的方案,除此以外还有ReentrantLock类。

除了ReentrantLock是JDK实现的,synchronized是一个关键字外,ReentrantLock还多了一些特性:

  • 等待可中断。当持有锁的线程长期不释放锁的时候,正在等待的线程可以选择放弃等待,改为处理其他事情。
  • 公平锁。多个线程公平的按顺序获得锁。
  • 锁绑定多个条件。一个ReentrantLock可以绑定多个Condition对象。

4.1.2. 非阻塞同步

非阻塞同步无需阻塞线程,也被称为无锁(Lock-Fre)编程。

Java中的CAS操作就是一个典型的非阻塞同步方案。

4.1.3. 无同步方案

  • 可重入代码(Pure Code)。代码执行任何时刻中断它,执行其他线程,都不会影响它。
  • 线程本地存储(Thread Local Storage)。比如ThreadLocal。