浅入理解JVM(三)—— 内存模型与线程

 

写在之前:一点碎碎念,反正没人看。对于知识点的总结,我应该尽量用“奥卡姆剃刀”去把大量内容去精简,然后言简意赅地复述一遍。对于这种总结性的语句,可以放在知识节点的开头,而大量进行阐述的内容则应该“傻瓜化”。努力让自己的语言浅显易懂,虽然可能达不到隔壁张奶奶听懂的水平,至少也要遵循费曼学习法的思路走吧。

1.Java 内存模型

Java 内存模型就是对内存或缓存进行读写访问的过程抽象。

cpu-to-cache

由于计算机的运算速度远远高于它的存储和通信的速度,大量的时间都被花在了磁盘 I/O、网络通信或者数据库访问上了,因此我们必须让计算机并发执行多个任务,减少电脑的“发呆”时间。但是绝大多数运算任务都不可能只靠处理器“计算”就能搞定的,任务处理过程必然还伴随有内存交互,比如读取运算数据、存储运算结果等,这个 I/O 操作就是很难消除的。因此,现代计算机系统都会配备一层或多层读写速度尽量接近处理器运算速度的高速缓存来缓冲。

但是,高速缓存的出现又带来了“缓存一致性问题”。如图,在共享内存多核系统下,每个处理器都有自己的高速缓存,但同时它们又共享同一个主内存,当它们的运算任务涉及到同一片内存区域时,就会导致各自的缓存数据不一致。而为了解决高速缓存数据冲突该以谁为准的问题,我们需要设立并遵循一些协议,在读写时要根据协议来,这就是缓存一致性协议

此外,我们还需要注意 Java 虚拟机中的指令重排序优化。为了让处理器内部的运算单元能够被充分“榨干”性能,我们不会将输入代码挨个送去运算,而是一股脑送很多个代码给多个运算单元,得到结果后再重组。因此,如果一个计算任务需要依赖另一个计算任务的中间结果,那么就要考虑先行发生原则( Happens-Before )了。

1.1 主内存和工作内存

Java 内存模型定义的目的是为了关注底层的内存存取细节。

thread-to-memory

Java 内存模型定义了程序中各种变量的访问规则,主要是变量值在内存中存取动作。这里的变量包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量和方法参数。 这个很好理解,后者是线程私有的,不会被共享,自然不会涉及缓存一致性问题。此外,如果局部变量是一个 reference 类型,那么它引用的对象在 Java 堆中可以被各个线程共享,但它自己本身仍然是线程私有的。

Java 内存模型规定所有变量都存放在主内存中,这里的主内存和前面说到的主内存不同,它在物理上仅仅只是虚拟机内存的一部分。每条线程也有自己的工作内存,里面保存了被线程使用的变量的主内存副本。线程也只能操作这些副本,而无法对主内存中的变量进行读写。当然,不同的线程之间也是隔离的,无法直接访问对方的工作内存。

1.2 内存间交互操作

Java 内存模型定义了 8 种操作用来描述变量在工作内存和主内存之间的具体交互,分别是 lockunlockreadloaduseassignstorewrite

这 8 种操作都是原子的、不可再分的,当然也有例外,在 1.4节 中会讲到 longdouble 变量带来的差异性。下面对 8 种操作进行具体描述:

(01)lock:把一个变量标识为一条线程独占的状态,用于主内存变量;

(02)unlock:把一个变量从线程独占状态中释放出来,用于主内存变量;

(03)read:把一个变量的值从主内存中传到工作内存;

(04)load:把 read 操作得到的值放入工作内存的变量副本中;

(05)use:把工作内存中一个变量的值传给执行引擎;

(06)assign:把执行引擎接收的值传给一个工作内存变量;

(07)store:把一个工作内存变量的值传给主内存;

(08)write:把 store 操作得到的值放入主内存的变量中。

如果想要将一个变量从主内存拷贝到工作内存,我们要按顺序执行 readload 操作。同理的,按顺序执行 storewrite 操作可以把一个变量从工作内存拷贝到主内存。当然,由于只要求按顺序执行而不是连续执行,虚拟机是允许中间插入多个其他操作的。

此外,Java 还对这 8 种操作进行了各种规定:

  • 不允许刚刚讲过的 readload 操作以及 storewrite 操作单独出现
  • 不允许一个线程丢弃它最近的 assign 操作
  • 不允许一个线程不经过 assign 操作就把一个值传回主内存
  • 一个新的变量只能在主内存中“诞生”
  • lock 操作是一个可重入锁
  • 对一个变量执行 lock 操作会清空工作内存中此变量的值(以保证在执行引擎使用这个值前,需要重新执行 loadassign 操作
  • 不允许对一个未被锁住的变量执行 unlock 操作
  • 对一个变量执行 unlock 操作前必须先把这个变量同步回主内存

虽然看着很繁琐,但这里面的规定其实都在围绕线程安全这一主题进行展开的。

1.3 volatile 型变量

当一个变量被定义为 volatile 之后,它将具备两个特性:保证此变量对所有线程可见以及禁止指令重排序优化。

这里的“可见”是指当一个线程对该变量的值进行修改后,新值对于其他线程是立即可知的。但是要注意,这种变量的一致性其实仍然无法保证其参与运算时的线程安全。比较典型的是自增操作:

public static volatile int race = 0;

public static void increase() {
    race++;
}

当有多个线程并发执行上面的 increase 操作的时候,其执行次数会大于最后得到的 race 变量值,这显然是线程不安全的。其原因在于自增操作不是原子操作,它是由多个机器码指令组合成的。具体而言,当线程 A 取到变量 race,这时它是正确的,但是如果在执行加 1 操作的时候,它的值失效了,这时候线程 A 依然会继续将一个错误的结果赋值给变量 race,导致隐患产生。

正是由于 volatile 变量只保证可见性,在不符合下面两条规则的场景中,我们要想保证线程安全,仍然要通过加锁方式实现:

(01)运算结果不依赖变量的当前值,或者确保只有单一的线程修改变量的值;

(02)变量不需要与其他的状态变量共同参与不变约束。

在讲到指令重排序之前,我们先看一下单例模式的双重检测锁实现:

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

当然,代码比较简单,我们主要关注给 instance 变量赋值那句。由于 Java 虚拟机存在指令重排序的优化,导致其顺序从“1 分配内存 - 2 初始化对象实例 - 3 让 instance 指向分配的内存空间”可能变成“1 - 3 - 2”,而第 3 步结束后 instance 变量不再为空,从而解开第一个 if 语句的拦路关卡。如果这时候刚好另一个线程也在调用该方法,它会直接进入到 if 语句中并返回一个错误的半成品 instance 对象。

此外,我们还需要要知道 volatile 是如何实现上述两个特性的。在 Java 内存模型中对 volatile 变量定义了特殊规则:

(01)每次使用 volatile 变量前都必须从主内存刷新最新的值,用于保证其可见性;具体做法是保证对该变量的 loaduse 操作是连续执行的(不是顺序哦),而前面也提到了 readload 操作是顺序执行的,所以实际上就相当于将这三个操作进行了有序组合;

(02)每次修改 volatile 变量后都必须立刻同步回主内存中,用于保证其可见性;类似的,具体做法是将 assignstorewrite 三个操作进行了有序组合;

(03)被 volatile 修饰的变量不会被指令重排序优化;具体做法是如果 A 先于 B,那么 P 先于 Q;

T -> V: P-F-A
T -> W: Q-G-B

1.4 long 和 double 型变量

Java 内存模型规定了 8 种内存交互操作都具有原子性,但是对于 64 位的数据类型( longdouble)却特别定义了一条特别宽松的规定:允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作划分为两次 32 位的操作来进行。这又叫做“ longdouble 的非原子性协定”,它允许虚拟机自行选择是否要保证 64 位数据类型的 loadstorereadwrite 操作。

在多线程环境下,如果使用了 32 位的 Java 虚拟机,去共享一个未声明为 volatile 的变量,可能会读到“半个”变量的奇怪现象。当然,现在都是 64 位机器了,我们自然也没必要去专门针对这个问题去刻意声明变量为 volatile 变量。

1.5 原子性、可见性和有序性

1.原子性

Java 的原子性大部分都是直接源于其内存交互操作中的 6 个,也即 readloaduseassignstorewrite 操作,例外就是“ longdouble 的非原子性协定”,了解即可。

如果我们需要一个更大范围的原子性保证,比如线程同步块这种,我们就要用到 lockunlock 操作。当然,虚拟机并没有直接提供这两个操作,而是提供了更高层次的字节码指令 moniterentermoniterexit 来隐式地使用这两个操作,它们俩反映到代码中就是 synchronized 关键字。

2.可见性

可见性的实现其实有三种方式,除了我们前面提到的 volatile 外,还有两个关键字也能实现—— synchronizedfinal 关键字。synchronized 的可见性是借助于“对一个变量执行 unlock 操作前必须先把这个变量同步回主内存”这条规则获得的。而 final 的可见性是指类中的 final 字段一旦在构造器中初始化完成,并且构造器并没有把“ this ”的引用传出去,那么它对于其他线程就是可见的。

public static final int i;

public final int j;

static{
    i = 0;
}
{
    j = 0;
}

在上面的代码中,ij 都具备可见性,无须同步。

3.有序性

如果在本线程中观察,所有的操作都是有序的;但如果在另一个线程中观察此线程,所有的操作都是无序的。

Java 语言提供了 volatilesynchronized 两个关键字来保证线程之间操作的有序性,前者不再赘述,synchronized 关键字主要是通过“一个变量在同一时刻只允许一条线程对其进行 lock 操作”获得的有序性,这个规则限制了持有同一个锁的两个同步块只能串行地进入。

1.6 先行发生原则

先行发生原则是 Java 内存模型中定义的关于操作之间偏序关系的规定。

如果说操作 A 先行发生于操作 B,其实就是指操作 A 执行后产生的影响能够被 B “看到”,比如 B 需要操作 A 的结果帮助运算。下面是先行发生关系的全部规则

  • 程序次序规则:在一个线程内,按照控制流顺序运行操作(是控制流顺序而非代码顺序,因为还要考虑分支、循环等结构);
  • 管程锁定规则:一个 unlock 操作先行发生于后面对同一个锁的 lock 操作;
  • volatile 变量规则:对于一个 volatile 变量的写操作先行发生于对该变量的读操作;
  • 线程启动规则Thread 对象的 start() 操作先行发生于此线程的每一个动作;
  • 线程终止规则:类似的,所有动作都先行发生于此线程的终止检测(终止检测有 Thread::join()Thread::isAlive() 等);
  • 线程中断规则:对线程 interrupt() 方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
  • 对象终结规则:一个对象的初始化完成先于它的 finalize() 方法的开始;
  • 传递性:操作 A 先行发生于操作 B,而操作 B 先行发生于操作 C,那么操作 A 先行发生于操作 C。

2.Java 与线程

2.1 线程的实现

线程有内核线程实现、用户线程实现和混合实现三种实现方式。

1 内核线程实现

使用内核线程实现的方式又叫 1:1 实现,内核线程 KLT 就是直接由操作系统内核支持的线程,而内核也会负责线程切换和调度工作。不过,程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口——轻量级进程 LWP,轻量级进程其实就是我们通常意义上的线程。而这种一个轻量级进程对应一个内核线程的关系又叫一对一的线程模型。

cpu-to-cache

不过,正是由于轻量级进程是基于内核线程实现的,所以各种现场操作如创建、析构及同步都需要进行系统调用,代价很高。另外,由于这种一比一的关系,加上内核线程的栈空间限制,一个系统支持的轻量级进程数量也是有限的。

2 用户线程实现

广义上来讲,不是内核线程的线程都可以叫用户线程,但由于轻量级进程究其本质仍不具备通常意义上用户线程的优点,固然要将它排除在外了。

简单的说,用户线程的存在和运作是无法被系统内核所感知的。如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速而且低消耗的,同时还支持规模更大的线程数。而这种进程和用户线程之间的 1:N 关系又被称为一对多线程模型。

cpu-to-cache

但正是由于所有线程操作都必须要用户程序自行处理的特点,用户线程的复杂性也大大增加了。早期,Java 和 Ruby 都曾使用过它,但后来都放弃了。近年来许多以高并发为特点的编程语言又开始支持它,比如 GolangErlang 等。

3 混合实现

混合实现是将前两种实现方式进行整合的一种折衷处理,许多 UNIX 操作系统在使用这种 M:N 的线程模型。

cpu-to-cache

2.2 线程调度

线程调度有两种方式,协同式调度和抢占式调度,前者的好处是简单,不过一旦一个线程出现问题,整个程序就会一直阻塞在那里,而 Java 的线程调度方式是后者——抢占式调度。

在 Java 中,线程的切换本身是不能由程序决定的,比如我们可以通过 Thread::yield() 方法主动让出执行时间,但是如果想要获取执行时间却是无能为力的。不过,我们仍然可以通过设置线程的优先级去“建议”操作系统给某些线程多分配一点时间。

在设置优先级的时候,我们又有问题出现了:Java 线程的优先级有十个对应数字 1~10,但是有些系统并没有那么多优先级,比如 Windows 系统只划分了 7 个优先级(除了 5 和 10 以外都是两两分级的),当然了解即可。

2.3 线程状态

线程有六种状态,分别是新建、运行、无限期等待、限期等待、阻塞以及结束。

其中,进入无限期等待的方法有:

(01)没有设置 Timeout 参数的 Object::wait() 方法;

(02)没有设置 Timeout 参数的 Thread::join() 方法;

(03)LockSupport::park() 方法。

进入限期等待的方法有:

(01)设置 Timeout 参数的 Object::wait() 方法;

(02)设置 Timeout 参数的 Thread::join() 方法;

(03)Thread::sleep() 方法;

(04)LockSupport::parkNanos() 方法;

(05)LockSupport::parkUntil() 方法。