第十二章 Java 内存模型与线程

96 阅读19分钟

1 概述

多任务处理在现代计算机操作系统中基本时一项必备的功能。多任务处理除了是因为计算机的运算能力强大,另一个重要原因是计算机的运算速度与它的存储和通信子系统的速度差距太大,大量时间花费在磁盘IO、网络通信或者数据库访问上,多任务处理是希望处理器不把大部分时间花费在等待其他资源的空闲状态。

除了充分利用计算机处理器的能力外,一个服务端要同时服务多个客户端是一个更具体的并发应用场景。衡量一个服务性能高低,TPS 是重要的指标之一,而 TPS 值又与程序的并发能力密切相关。

2 硬件的效率与一致性

多任务并行处理还是很难消除 IO 与处理器运算速度的差距,为了尽可能加快读写速度计算机系统在内存与处理器之间加入了高速缓存。为了解决多个处理器的高速缓存和主存之间的缓存一致性问题,又引入了缓存一致性协议。从而计算机有了内存模型: image.png

Java 虚拟机也有自己的内存模型,并且与上面的内存模型据有高度的可类比性。

3 Java 内存模型

为了屏蔽硬件和操作系统的内存访问差异,以实现让 Java 程序在各种平台上都能达到一致的内存访问效果,Java 虚拟机定义了 Java 内存模型。

3.1 主内存与工作内存

Java 内存模型的主要目的是定义程序中各种变量的访问规则,此处的变量指的是实例字段、静态字段、构造数组对象的元素。

Java 内存模型规定了所有变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作都必须在工作内存中进行而不能直接读写主内存中的数据。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的值传递均需要通过主内存来完成。 image.png --- 图片

3.2 内存间交互操作

3.3 对于 volatile 型变量的特殊规则

volatile 关键字是有两个语义:第一个是保证变量的可见性。

volatile 关键字是 Java 虚拟机提供的最轻量级的同步机制。 它能保证变量对所有线程的可见性,即当变量发生变化时,所有线程能立即读到最新的值。但是它不能保证原子性,例如:当变量值参与运算时,如果值被加载到栈顶后,变量发生了改变,此时栈顶的值是过期的值且不会被更改。

由于 volatile 变量只能保证可见性,在不符合以下两条规则的运算场景下,我们仍然需要通过加锁来保证原子性:

  • 运算结果不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
  • 变量不需要其他的状态变量共同参与不变约束。

下面的场景就很适合使用 volatile 变量来控制并发:

volatile boolean shutdownRequested; 

public void shutdown() { 
    shutdownRequested = true; 
} 
public void doWork() { 
    while (!shutdownRequested) { 
    // 代码的业务逻辑 
    } 
}

volatile 关键字第二个语义是禁止指令重排优化。典型的场景是双重检查下的创建单例模式代码:

public class Singleton { 
    
    private volatile static Singleton instance; 

    public static Singleton getInstance() { 
        if (instance == null) { 
            synchronized (Singleton.class) { 
                if (instance == null) { 
                    instance = new Singleton();
                }
            }
        }
    return instance; 
    } 

    public static void main(String[] args) { 
        Singleton.getInstance(); 
    } 
}

3.4 针对 long 和 double 型变量的特殊规则

Java 内存模型要求 lock、unlock、read、load、assign、use、store、write 这八种操作都据有原子性,但是对于 64 位的数据类型 long、double 在模型中定义了条宽松的规定:允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作划分为两次 32 位的操作来进行。即允许虚拟机自行选择是否要保证 64 位数据类型的 load、store、read、write 这四个操作的原子性,这就是所谓的 long 和 double 的非原子性协定。

如果多个线程共享一个没有被申明位 volatile 的 long 或者 double 类型变量,并且同时对它们进行读取和修改操作,那么某些线程可能读取到一个既不是原值,也不是其他线程修改值的代表了半个变量的数值。但是这种情况非常罕见,除非明确知道该数据有线程竞争,否则没有必要把 long 和 double 申明为 volatile。

3.5 原子性、可见性与有序性

Java 内存模型是围绕着在并发过程中如何处理原子性、可见性、有序性这三个特征来建立的。

  • 原子性:由 Java 内存模型来直接保证原子性变量操作包括:read、load、assign、store、write 这六个。我们大致可以认为基本数据类型的访问、读写都是具备原子性的,(long 和 double 的非原子性协定属于例外情况)。

对于更大的范围的原子性保证,Java 内存模型提供了 lock、unlock 操作,这两个操作时通过字节码指令 monitorenter 和 monitorexit 来隐式操作的。这两个字节码指令反应到 Java 代码中就是 synchronized 关键字,因此 synchronized 块之间的操作也据有有原子性。

  • 可见性:可见性是指当一个线程修改了共享变量值时,其他线程能够立即得知这个修改。可见性是通过变量修改后把值同步回主内存,在变量读取前从主内存刷新变量值来实现的。普通变量和 volatile 变量都是相同的实现方式,只是 volatile 变量是通过特殊规则保证了新值能立即同步到主内存,已经每次使用前立即从主内存刷新。

除了 volatile 外,synchronized 和 final 也可以实现可见性。同步块能实现可见性是由于在进行 unlock 之前,必须把此变量同步回主内存中。而 final 修饰的字段在构造器中一旦被初始化完成,并且构造器没有把 this 的引用传递出去,那么其他线程中就能看见 final 字段的值了。如下面的代码,i、j无需同步就能被其他线程正确访问到:

public static final int i; 
public final int j; 
static { 
    i = 0; 
    // 省略后续动作 
} 
{ 
    // 也可以选择在构造函数中初始化 
    j = 0; 
    // 省略后续动作 
}

下面是一个构造器中 this 引用逃逸的例子,EventListener 会访问到没有初始化完成的 this 对象:

public class ThisEscape {

    public final int id;
    public final String name;

    public ThisEscape(EventSource<EventListener> source) {
        id = 1;
        source.registerListener(new EventListener() {
                public void onEvent(Object obj) {
                    System.out.println("id: "+ThisEscape.this.id);
                    System.out.println("name: "+ThisEscape.this.name);
                }
        });
        name = "flysqrlboy";
    }
  }
  • 有序性:Java 程序的有序性可以总结为:如果在线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。前半句是指:线程内表现为串行语义,后半句指:指令重排现象和工作内存与主内存同步延迟现象。

Java 语言提供 volatile 和 synchronized 来保证线程之间操作的有序性,volatile 语义包含禁止指令重排。而 synchronized 由一个变量同一时刻只允许一条线程对其进行 lock 操作来保证。

由于 synchronized 能保证前面三种特性,因此导致 synchronized 被滥用。

3.6 先行发生原则

Java 中的先行发生原则是判断数据是否存在竞争,线程是否安全非常游用的手段。通过这个规则的几条简单规则,我们就能解决并发环境下两个操作是否存在冲突的可能的所有问题。

下面是 Java 内存模型下的天然的先行发生关系,它们是不需要任何同步器就已经存在的,如果两个操作不在下面的关系中存在,并且无法从下列规则推到出来,则它们就没有顺序保障,虚拟机可以对它们随意的进行重排:

  • 程序次序规则:在一个线程内,按照控制流顺序,控制流书写在前面的操作先行发生于写在后面的操作,这里说的是控制流,而不是代码顺序。
  • 管程锁定规则:一个 unlock 操作先行发生于后面对同一个锁的 lock 操作,强调的是同一个锁,后面是指时间上的先后。
  • volatile 变量规则:对一个 volatile 变量的写操作先行发生于后面对这个变量的读操作,这里的后面指的是时间上的先后。
  • 线程启动规则:Thread 对象的 start() 方法先行发生于此线程的每一个动作。
  • 线程终止规则:线程中所有操作都先行发生于其他线程检查到此线程终止的点。也就是说 A 线程中的所有操作都先行发生于 B 线程检测到 A 线程已经终止之前。可以用 Thread::join()方法是否结束,Thread::isAlive() 的返回值来监测线程是否已经终止执行。
  • 线程中断规则:线程对 interrupt() 方法调用先行发生于被中断线程的代码监测到中断事件的发生,可以通过 Thread::interrupted() 的返回值检测到是否由中断发生。
  • 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。(对于对象,如果对象仅仅实例化完成,没有开始初始化,也可能发生对象终结)。
  • 传递性:如果操作 A 先行发生于操作 B,操作 B 先行发生于操作 C,那么就可以得出操作 A 先行发生于操作 C 的结论。

4 Java 与线程

并发不一定要依赖多线程(php中很常见多进程并发)。但是 Java 里的并发基本上是依赖线程。

4.1 线程的实现

线程是比进程更轻量级的调度执行单位,线程可以把进程的资源分配和执行调度分开,各个线程既可以共享资源(内存地址、文件IO),又可以独立调度。目前线程是 Java里面进行处理器资源调度的最基本单元。

主流的操作系统都提供了线程实现,但是 Java 语言提供了在不同硬件和操作系统平台下对线程操作的统一处理,每个已经调用过 start() 方法,且还没有结束的实例就代表一个线程。

线程主要又三种实现方式:使用内核线程实现(1:1实现),使用用户线程实现(1:N 实现),使用用户线程加轻量进程混合实现(N:M 实现)。

4.1.1 内核线程实现

使用内核线程实现的方式也被称为 1:1 实现。内核线程就是由操作系统内核支持的线程,它的切换由内核来完成,调用由内核操作调度器实现。每个内核线程可以视为内核的一个分身。

程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口:轻量级进程(LWP),轻量级进程就是我们通常意义上讲的线程,每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程 1:1 的关系称为一对一的线程模型: image.png

由于每个轻量级进程都称为一个独立的调度单元,即使其中某一个轻量级进程在系统调度中阻塞了,也不会影响整个进程继续工作。但是由于轻量级进程是基于内核实现的,所以各种线程操作都需要系统调用,系统调用需要在用户态和内核态来回切换,代价较高。并且每个轻量级进程需要一个内核线程支持,因此一个系统支持轻量级进程的数量是有限的。

4.1.2 用户线程实现

使用用户线程实现的方式被称为 1:N 实现。广义上讲,一个线程只要不是内核线程就可以被认为是用户线程(UT),从这个定义上看轻量级进程也是属于用户线程,但是它建立在内核线程之上,不具备通常意义上用户线程的优点。

进程与用户线程之间的 1:N 关系图: image.png

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

用户线程的优势在于不需要系统内核支持,劣势也在于没有系统内核支持所有线程操作都需要用户自己去处理。Java、Ruby 等语言都曾经使用过用户线程,最终又放弃使用了它。但最近以高并发为卖点的编程语言又普遍支持了用户线程,比如 Golang、Erlang 等。

4.1.3 混合实现

除了依赖内核线程实现和完全由用户程序自己实现之外,还有一种将内核线程与用户线程一起使用的实现方式,被称为 N:M 实现。在这种混合实现下,既存在用户线程,也存在轻量级进程。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换、析构操作依然廉价,并且可以支持大规模用户线程并发。而操作系统支持的轻量级进程则作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用要通过轻量级进程来完成,这大大降低了整个进程被完全阻塞的风险。下面是多对多线程模型: image.png

4.1.4 Java 线程的实现

Java 线程如何实现不受 Java 虚拟机规范约束,它是根据具体的虚拟机实现而不同。Java 最早期的 Classic 虚拟机上使用的是用户线程实现,但是从 JDK1.3 开始主流平台普遍使用 1:1 的线程模型。但是也有部分支持多种线程模型的虚拟机实现。

4.2 Java 线程调度

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

协同式线程调度:线程的执行时间由线程本身控制,线程执行完自己的工作后,要主动通知系统切换到另一个线程上去,协同式的线程调度的好处是实现简单,由于线程要执行完成后才会切换,所以一般没有线程同步问题。缺点是执行时间不可控,如果线程一直阻塞,有可能导致整个系统崩溃,Lua 语言中的协同例程就是这类实现。

抢占式线程调度:每个线程由系统来分配执行时间,线程切换不由线程自己决定。比如 Java 可以用 Thread::yield() 方法主动让出执行时间,但是如果想要主动获取执行时间,线程本身没有什么办法。这种调度方式线程的执行时间是可控的,也不会有一个线程导致整个进程甚至整个系统阻塞。

虽然 Java 线程调度是系统自动完成,但是我们仍然可以建议操作系统给某些线程多分配一点执行时间,Java 语言提供了 10 个级别的线程优先级,优先级越高越容易被系统选择执行。但是由于优先级是映射到系统原生线程上的,并且每个平台的优先级别设置不一样,所以不可以过度依赖线程优先级。

4.3 状态转换

Java 定义了线程的 6 种状态,任意时刻线程只能有其中一种状态,并且状态之间可以通过特定的方法进行流转:

  • 新建(New):创建后尚未启动的线程处于这个状态。
  • 运行(Runnable):此状态包含操作系统的 Running 和 Ready 状态,此时线程可能正在执行,也可能正在等待操作系统为它分配时间。
  • 无限期等待(Waiting):处于这种状态的线程不会被分配处理器执行时间,它们需要等待被其他线程显示的唤醒,以下方法会使线程进入无限期等待:
    • 没有设置 Timeout 参数的 Object::wait() 方法;
    • 没有设置 Timeout 参数的 Thread::join() 方法;
    • LockSupport::park()方法;
  • 有限期等待(Timed Waiting):处于这种状态的线程也不会被处理器分配时间,不过无需显示的被唤醒,一定时间后它们会由系统自动唤醒。以下方法会是线程进入有限期等待:
    • Thread::sleep()方法;
    • 设置了 Timeout 参数的 Object::wait()方法;
    • 设置了 Timeout 参数的 Thread::join()方法;
    • LockSupport::parkNanos()方法;
    • LockSupport::parkUntil()方法;
  • 阻塞(Blocked):阻塞状态与等待状态的区别是,阻塞状态在等待获取一个排他锁。在程序进入同步区时线程会进入这种状态。当当前线程获取到锁后,流转出阻塞状态,而等待状态则是等待一段时间,或者被唤醒后流转出等待状态。
  • 结束(Terminated):已终止线程的线程状态,线程已经结束执行。 image.png

5 Java 与协程

在 Java 语言早期,抽象出了统一的线程接口,这为程序员屏蔽了线程的平台复杂性。并且在统一的线程接口上涌现出了大批优秀的多线程框架,比如处理 HTTP 请求的 Servlet API,但是目前这种并发编程方式和同步机制已经出现疲态。

5.1 内核线程的局限

由于如今 Web 服务的请求量级增加和微服务化,导致每个服务需要处理更大量的请求,以及微服务化后需要每个服务之间更短的响应时间。但是 Java 1:1 的内核线程模型就尽显疲态。因为用户线程映射到内核线程的天然缺陷是切换、调度成本高昂,且系统能容纳的线程数量也有限。当请求数量级变大,且微服务化后,用户线程切换的开销甚至可能接近用于计算本身的开销。

传统的 Java Web 服务器的线程池的容量通常在几十到两百之间,当数百万请求来到线程池时,即使系统能处理得过来,但是它得切换损耗也是相当可观得。

5.2 协程得复苏

前面提到的内核线程切换的成本高,主要高在切换时首先需要把线程挂起,然后把执行在当前的所以上下文妥善保管,然后把新的线程上下文恢复。这个过程免不了一系列数据在各种寄存器、缓存中来回拷贝,这不可能是一个轻量级的操作。

当然在用户态进行线程切换也避免不了线程中断和恢复,只是在用户态有很多手段进行轻量级的手段来实现上下文的备份和恢复。比如用栈来实现线程的保护和恢复工作。由于最初大多数用户线程是被设计成协同式调度的,所以它又有了一个别名:协程。又由于这时候的协程会完整的做调用栈的保护、恢复工作,所以今天也被称为:有栈协程。无栈协程式本质式一种有限状态机,状态保存在闭包里,它比有栈协程恢复调用轻量很多,但是功能也相对有限。

协程的主要优势式轻量。如果不显示的设置线程栈的大小(-Xss 或-XX:ThreadStackSize),在 64 位 Linux 上 HotSpot 的线程栈容量默认是 1MB,此外内核数据结构还会额外消耗 16KB 内存。而一个协程的栈通常在几百个字节到几 KB 之间,所以 Java 虚拟机线程池容量达到 200 就已经不算小了,而很多支持协程的应用,同时并存的协程数量可以数十万万计。

协程的主要缺点在于调用栈、调度器等这些需要在应用层面实现,应用层的复杂度会很高。

5.3 Java 的解决方案

Java 计划推出名为纤程的有栈协程。