内存模型与线程

并发处理的广泛应用是使得Amdahl定律代替摩尔定律的根本原因,成为了压榨计算机的新武器。本章介绍JVM如何实现并发


由于计算机的存储设备与处理器的运算速度有几个数量级的差距,因此现代计算机不得不加入高速缓存(Cache,二级缓存)来作为内存和处理器之间的缓冲

在多处理器系统中,每个处理器都有自己的高速缓存,这就引入新的问题:缓存一致性,因此处理器访问缓存时需要遵循一些协议。JVM内部的虚拟机缓存操作和硬件的模型具有很高的可比性内存模型

内存模型

Java虚拟机规范中定义的Java内存模型(Java Memory Model,JMM)来屏蔽各种硬件和操作系统的内存访问差异,而像C/C++直接使用物理硬件和操作系统的内存模型,这会因为不同平台上内存模型的差异而导致代码不能跨平台

在前面的关于JVM的内存结构的图中,我们可以看到,其中Java堆和方法区的区域是多个线程共享的数据区域。也就是说,多个线程可以操作保存在堆或者方法区中的同一个数据。这也就是我们常说的“Java的线程间通过共享内存进行通信”

其实JMM并不像JVM内存结构一样是真实存在的。他只是一个抽象的概念。JMM是和多线程相关的,他描述了一组规则或规范,可以理解为在特定的操作协议下,对特定的内存或者高速缓存进行读写访问的过程抽象。那么,简单总结下,Java的多线程之间是通过共享内存进行通信的,而由于采用共享内存进行通信,在通信过程中会存在一系列如可见性、原子性、顺序性等问题,而JMM就是围绕着多线程通信以及与其相关的一系列特性而建立的模型。JMM定义了一些语法集,这些语法集映射到Java语言中就是volatile、synchronized等关键字

JMM的目的是定义程序中各个变量(实例字段、静态字段等)的访问规则。JMM规定了所有变量都存储在主内存中,每条线程都有自己的工作内存(类似于上图中的Cache)

工作内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量所有操作都在工作内存完成,而不是直接读写主内存。线程之间变量的传递需要通过主内存完成

Java内存模型看上去和JVM内存结构差不多,很多人会误以为两者是一回事儿,这也就导致面试过程中经常答非所为。其实两者不是同一层次的内存划分。如果非要对应起来,主内存主要对应于Java堆的对象实例数据部分,工作内存对应于栈中的部分区域

为了实现主存和工作内存之间的交互协议,JMM定义了以下8种操作,JVM实现时必须保证每种操作都是原子的、不可分的

  • lock:把变量标志为一条线程独占的状态
  • unlock:释放处于锁定状态的变量
  • read:把一个变量的值从主内存传输到线程得到工作内存中,以便load使用,这一步目标主要是读取
  • load:把read得到的变量值放入工作内存的变量副本,这一步主要是载入
  • use:把工作内存中的变量值传递给执行引擎,每当JVM遇到需要变量的字节码指令时都会执行这个动作
  • assign:把执行引擎接受到的值赋给工作内存的变量,每当JVM遇到给变量赋值的字节码指令时都会执行这个动作
  • store:把工作内存的变量传递到主内存中,以便write使用
  • write:把store得到的变量的值放入主内存的变量中

除此之外8个操作还需满足以下规则,这8个操作和规则确定了Java程序中哪些内存访问操作在并发下是安全的

  • 不允许read和load、store和write操作单独出现,即不允许一个变量从主内存读取了但工作内存不接受,也不允许从工作内存写回但主内存不接受的情况
  • 一个线程如果有assign操作,则其后必须出现store和write操作,反之不能出现。改变变量必须回存
  • 新变量只能生灭于主内存,use和store变量前,必须有对应的load或assign该变量的操作
  • 一个变量同一时刻最多允许被一个线程对其lock,同一线程可对这个变量进行多次lock,执行同样次数的unlock才能完全解锁
  • lock变量时,会清空工作内存中变量副本的值,执行引擎使用前需重新load或assign
  • 没有lock的变量不允许unlock
  • 执行unlock前必须回存至主内存,即store和write

volatile定义的变量,对所有线程都是可见的。这意味着当一条线程修改了一个变量的值,新值对于其他线程来说是可以立即得知的,而普通变量的值需要通过主内存才能在线程之间传递。但其实volatile变量其实在工作内存也存在不一致的问题,只是因为在每次使用前都会先刷新,执行引擎看不到不一致的情况

volatile变量只能保证可见性,不能保证在并发下是安全的,当不满足下面两个条件时,要使用加锁来保证原子性

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

其次,volatile变量禁止指令重新排序优化volatile通过内存屏障实现,内存屏障是硬件层的概念,不同的硬件平台实现内存屏障的手段并不是一样,java通过屏蔽这些差异,统一由JVM来生成内存屏障的指令(如插入Lock addl $Ox0,(%esp)),通过强制处理器顺序执行待定的内存操作来避免这个问题

内存屏障有两个作用:

  • 阻止屏障两侧的指令重排序。指令把修改同步到内存中,意味着所有之前的操作都已经执行完成,便形成了无法越过内存屏障的效果
  • 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效,这相当于volatile变量的修改对其他CPU可见
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// code run by first thread   		
intentFirst = true;

while (intentSecond)
if (turn != 0) {
intentFirst = false;
while (turn != 0) {}
intentFirst = true;
}

criticalSection();

turn = 1;
intentFirst = false;

上述的intentFirst变量由于没有volatile,存在被乱序执行的可能,所以第13、14行可能先执行,这就对导致程序就会错误执行

JMM对volatile变量定义了特殊规则

  • 线程T对变量V执行use之前必须执行load,相反执行load之前必须先执行use。这保证在使用V之前都从主内存刷新最新的值
  • 线程T对变量V执行store之前必须执行assign,相反执行assign之前必须先执行store。这保证修改V之后都能立即同步到主内存

JMM允许将没有volatile修饰的64位数据的读写操作分为两次32位的操作进行,这就可能读到半个变量的值。不过JVM几乎都会选择把64位读写作为原子操作,因此不需特意把64位数据申明为volatile

总结

Java内存模型是围绕在并发过程中如何处理原子性可见性有序性而建立的

  • 原子性:read、load、assign、use、store、write操作保证了原子性变量操作,因此大致可认为基本数据类型都是具备原子性的,当遇到比基本数据类型更大的原子性保证时,还有lock、unlock保证,这反映到更高层次就是字节码指令monitorentermonitorexit,进而反映到Java代码就是synchronize,因此在该关键字块之间的操作也具备原子性
  • 可见性:上面讲过volatile实现了可见性,普通变量和volatile变量的区别在于,volatile的特殊规则保证了新值立即同步到主内存中,以及每次使用前立即从主内存刷新,普通变量则不能保证这一点;除此之外,synchronize也能保证可见性,因为规则:对一个变量执行unlock之前,必须把变量同步回主内存中;final变量保证可见性的规则:final 常量无需同步,就能被其它线程正确访问
  • 有序性:Java有序性:如果在本线程内观察,所有操作都是有序的;但是如果从一个线程观察另一个线程,所有操作都是无序的。造成后半句的原因就是指令重排序工作内存与主内存同步延迟volatile本身包含禁止指令重排序的含义,synchronize则是:一个变量再同一时刻只允许一条线程对齐进行lock操作

先行发生原则

该规则是判断数据是否存在竞争、线程是否安全的主要依据,依靠该原则和几条规则,就能解决并发环境下两个操作之间是否可能存在冲突的所有问题

先行发生是JMM定义的两项操作之间的偏序关系,如果说操作A先行发生于操作B,就是说操作B发生之前,操作A产生的影响(修改内存中共享变量的值、发送了信息、调用了方法等)能被操作B观察到

下面的规则是JMM天然存在的先行发生关系,如果两个操作之前的关系不在下面规则中,就没有顺序保障,虚拟机可以随意重排序

  • 程序次序规则:在一个线程内,按照程序控制流顺序,书写在前面的操作先行发生在书写后的操作
  • 管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作
  • volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作
  • 线程启动规则:Thread对象的start()方法先行发生于线程的每个动作
  • 线程终止规则:线程所有操作都先行发生于此线程的终止检测
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
  • 对象终结规则:一个对象初始化完成先行发生于它的finalize()方法的开始
  • 传递性:操作之间的先行关系存在传递关系

时间先后顺序和先行发生原则之间没有关系,衡量并发安全问题的时候一切以先行发生原则为准

线程

线程是比进程更轻量级调度执行单位,是CPU调度的基本单位。每个已经执行start()且未结束的java.lang.Thread类都是一个线程实例,该类的关键方法都是Native,这意味着这个方法没有使用或无法使用平台无关的手段实现

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

内核线程

Kernel-Level Thread(KLT)就是直接由Kernel支持的线程,由其完成线程切换,Kernel通过Scheduler对线程进行调度,并负责将线程的任务映射到各个处理器上

一般应用程序不会直接使用内核线程,而是使用内核线程的一个高级接口:轻量级进程(Light Weight Process,LWP,即线程),这种轻量级进行和内核线程是一对一的线程模型内核线程一对一

这种实现方式由于是基于内核线程实现,各种线程操作都需要进行系统调用,而系统调用代价较高,需要在用户态和内核态切换,其次每个轻量级进程都要一个内核线程支持,因此就需要消耗内核资源,导致支持的进程数量有限

用户线程

狭义上的用户线程完全建立在用户空间的线程库上,系统内核不能感知其存在。用户线程的建立、同步、销毁和调度完全在用户态上完成,不需要切换到内核态,因此消耗低,还支持规模更大的线程数量,这种模型成为一对多线程模型内核线程一对一
这种模型的弊端就是所有线程操作都要用户程序自己处理,而且不能发挥多处理器的用处

用户+进程

在这种混合模式下,既存在用户线程,也存在轻量级进程,在这种模式下,用户线程和轻量级进程的数量比不一定,成为多对多线程模型。Unix系列的OS都提供N:M线程模型的实现

Java线程

在JDK 1.2中,线程模型替换为基于OS原生线程模型来实现,因此OS支持怎样的线程模型,就决定了JVM的线程是怎么映射的

对于Sun JDK来说,Windows版和Linux版都是使用一对一线程模型实现的

线程调度是指系统为线程分配处理器使用权的过程,分为协同式线程调度抢占式线程调度。协同式调度就是由线程本身控制,抢占式调度每个线程由系统来分配执行时间。具体可以参考OS文章

我们可以通过设置线程优先级来建议系统给某些线程多分配一些时间。Java中就设置了10个优先级。但是Java的线程是通过映射到系统的原生线程上来实现的,最终调度依旧取决于OS,因此不见得从OS的优先级和Java优先级存在一一对应的关系

Java定义了5种线程状态

  • 新建:创建后尚未启动的线程
  • 运行:此状态的线程有可能正在执行,有可能正在等待CPU分配时间
  • 无限期等待:不会被分配CPU执行时间,要等待被其他线程显示唤醒,没有设置Timeout的Object.wait()方法和Thread.join()方法、LockSupport.park()方法都会使线程入此状态
  • 限期等待:不会被分配CPU执行时间,但在一定时间后会被系统自动唤醒,如Thread.sleep()、设置Timeout的Object.wait()方法和Thread.join()方法、LockSupport.parkNanos()等
  • 阻塞:阻塞和等待状态的区别在于,阻塞状态在等待获取一个排他锁,这个事件将在另一个线程放弃这个锁的时候发生

线程状态转换