JVM内存模型与线程

定义内存模型(Java Memory Model,JMM)来屏蔽各种硬件和操作系统的内存访问差异,使Java程序在各种平台下都能达到一致的并发效果。JMM规定:

  • 所有变量都存储在主内存(Main Memory)
  • 每个线程有自己的工作内存(Working Memory),工作内存中保存被该线程使用到的变量的主内存副本拷贝,线程对变量所有操作(读取、赋值等)必须在工作内存中进行,不能直接读写主内存中的变量
  • 不同线程无法直接访问对方工作内存中的变量,线程间变量值的传递需要通过主内存完成

主内存和工作内存交互协议

JMM定义了以下八种操作,保证每一种操作都是原子的、不可再分的(double、long类型变量除外)

  • lock(锁定):作用于主内存变量,把一个变量标识为一个线程独占状态
  • unlock(解锁):作用于内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
  • read(读取):作用于主内存变量,把一个变量的值从主内存传输到线程的工作内存中,以便随后load操作使用
  • load(载入):作用于工作内存变量,把read操作从主内存得到的变量值放入工作内存的变量副本中
  • use(使用):作用于工作内存变量,把工作内存中的一个变量值传递给执行引擎,当JVM遇到一个需要使用到变量的值的字节码指令时会执行这个操作
  • assign(赋值):作用于工作内存变量,把一个从执行引擎接收到的值赋值给工作内存的变量,当JVM遇到一给变量赋值的字节码指令时执行这个操作
  • store(存储):作用于工作内存变量,把工作内存中的一个变量值传递到主内存中,以便随后write操作使用
  • write(写入):作用于主内存变量,把store操作从工作内存中得到的值放入主内存变量中

JMM规定在执行上述八种基本操作时必须满足以下规则:

  • 不允许read和load、store和write操作之一单独出现
  • 不允许一个线程丢弃最近的assign操作,即变量在工作内存中改变之后必须将变化同步回主内存
  • 不允许一个线程无原因(没有发生过assign操作)把数据从工作内存同步回主内存
  • 一个新变量只能在主内存“诞生”,不允许在工作内存直接使用一个未初始化(load或者assign)的变量,也就是说对一个变量实施use和store操作之前,必须先执行load和assign操作
  • 一个变量在同一时刻只允许一个线程进行lock操作(保证synchronized块有序性),但lock操作可以被同一个线程重复执行多次,多次lock之后,只有执行相同次数的unlock操作,变量才会被解锁
  • 对一个变量执行lock操作,将清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或者assign操作初始化变量的值
  • 如果一个变量是前没有被lock操作锁定,不允许对它进行unlock操作,也不允许去unlock一个被其他线程锁定的变量
  • 对一个变量执行unlock操作前,必须先把此变量同步回主内存中(执行store和write操作),此规则保证了同步块synchronized的可见性

volatile型变量

一个变量定义成volatile,具备两种特性:

  1. 保证此变量对所有线程可见性。当一个线程修改了这个变量值,新值对其他线程是可以立即得知的,因此,规定:

    • 每次使用volatile变量都必须先从主内存刷新最新值,即use操作之前必须是load操作

    • 每次修改volatile变量都必须立即同步回主内存,即assign操作之后马上进行store操作

    普通变量与volatile变量的区别是volatile保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。由于volatile变量只能保证可见性,而Java的运算并非都是原子操作,在不符合以下规则的运算场景中,仍然需要加锁来保证原子性。

    • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量值。因此多个线程并发递增加修改volatile变量值(依赖原值)需要加锁
    • 变量不需要与其他状态变量共同参与不变约束,如多个线程使用volatile变量控制共同退出
  2. 禁止指令重排序优化。如两个线程使用非volatile变量标识状态,线程A将状态预设为false,等完成某些步骤之后,再设置为true,线程B等待状态为true继续,当该变量为普通变量时,会由于指令重排序优化,导致A中未完成操作,先将状态设置为true步骤提前执行,导致线程B提早执行。而volatile关键字用于避免此类情况的发生,规定:线程A的use/assign操作如果先于线程B,则其对应的load/store和read/write操作都要优先于线程B执行。

volatile变量读操作性能消耗与普通变量几乎没有差别,写操作由于需要在本地代码插入许多内存屏障指令来保证处理器不发生乱序执行,因此比普通变量慢一些。但是都比锁要低,在volatile与锁选择的唯一判断依据是volatile能否满足需求,如果满足,优先选择volatile

JMM允许JVM将没有被volatile修饰的64位long和double的读写操作分为两次32位操作进行,即允许long/double的load、store、read和write这四个操作非原子性。这仅仅是模型允许,但是目前各种平台下的JVM几乎都会作为原子操作对待,因此编写代码时一般不需要将long/double专门声明为volatile。

原子性、可见性和有序性

  • 原子性:由JMM直接保证的原子性操作包括:read、load、assign、use、store和write这六个,大致可以认为基本数据类型的访问读写是具备原子性的。如果需要更大范围的原子性要求,JMM提供了lock和unlock,尽管JVM未直接开放这两个操作,但提供了更高层次的字节码指令monitorenter和monitorexit来隐式使用着两个操作,着两个字节码指令反映到Java就是同步块synchronized,因此synchronized块之间的操作也具备原子性。
  • 可见性:指当一个线程修改了变量的值,其他线程能够立即得知这个修改。JMM都是通过主内存将变量更新的值更新到其他线程,除了volatile之外,synchronized和final也能实现可见性,synchronized可见性由“对一个变量执行unlock操作前,必须先把此变量同步回主内存中(执行store和write操作)”规则获得,final可见性指:被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把this引用传递到其他线程,那么在其他线程就能看到final字段的值。
  • 有序性:如果在本线程内观察,所有操作都是有序的(Within-Thread As-If-Serial Semantics),如果在一个线程中观察另一个线程,所有操作都是无序的(指令重排序和工作内存与主内存同步延迟)。volatile关键字本身就包含禁止指令重排序的语义,synchronized块的有序性由“一个变量在同一时刻只允许一个线程进行lock操作”规则获得,因为同一个锁的两个同步块只能串行进入。

先行发生原则(happens-before)

先行发生原则是JMM定义的两个操作之间的偏序关系,如果操作A先行发生于操作B,那么在发生操作B之前,操作A产生的影响(包括修改了内存中共享变量值、发送消息、调用方法等)能被操作B观察到。先行发生原则是判断数据是否存在竞争,线程是否安全的主要依据。JMM包括以下先行发生关系,如果两个操作之间的关系不在此列,并且无法从下列规则推导,则没有顺序性保障,JVM可以对它们进行随意重排序:

  • 程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,前面的操作先行发生于后面的操作。准确地说应该是控制流顺序,而不是代码顺序,因为考虑分支、循环结构
  • 管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于时间后面对同一个锁的lock操作
  • volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于时间后面对这个变量的读操作
  • 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每个动作
  • 线程终止规则(Thread Termination Rule):线程中所有操作都先行发生于对此线程的终止检测,可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测线程已经终止执行
  • 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,通过Thread.interrupted()方法检测是否有中断发生
  • 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始
  • 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,则操作A先行发生于操作C

一个操作“时间上先发生”不代表这个操作“先行发生”,如:一个线程先对某个非volatile变量设置值,另一个线程获取到的可能是设置前的值也可能是设置后的值,因为两者不存在先行发生关系;另外,一个操作“先行发生”不代表这个操作“时间上先发生”,如:同一个线程内的指令重排序,对于两个单独变量的赋值,代码顺序后面的赋值时间上可以先被JVM执行。因此,时间的先后顺序与并行发生原则之间没有太大关系,衡量并发安全问题的时候不要受到时间顺序干扰,一切以先行发生原则为准

Java线程

线程是比进程轻量的调度执行单位,线程可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件I/O等),由可以独立调度(线程是CPU调度的最基本单位)。实现线程主要有三种方式:

  1. 使用内核线程(Kernerl Thread,KLT)实现,直接由操作系统内核执行的线程,由内核完成线程切换,内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器。程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口-轻量式进程(Light Weight Process,LWP)。轻量式进程与内核线程之间1:1的关系称为一对一线程模型。基于内核线程实现,都需要进行系统调用,代价相对较高,需要在用户态和内核态来回切换,而且,一个系统支持轻量级进程的数量有限。
  2. 使用用户线程(User Thread,UT)实现,用户线程完全在用户态完成,不需要内核帮助,所有线程操作都需要用户程序自己处理。由于是实现复杂,现在已经被Java、Ruby等语言弃用。
  3. 将内核线程和用户线程一起使用的实现方式。用户线程还是完全建立在用户空间,因此创建、切换、析构等操作依然廉价,可以支持大规模用户线程并发。而轻量级进程作为用户线程和内核线程之间的桥梁,这样可以使用内核提供的线程调度功能及处理器映射,并且用户线程的系统调用通过轻量级线程完成,大大降低进程被阻塞的风险。用户线程与轻量级进程M:N的关系称为多对多的线程模型。

Sun JDK,操作系统支持怎样的线程模型,很大程度就决定了JVM的线程怎样映射。Windows版和Linux版使用一对一的线程模型来实现,一条线程映射到一条轻量级进程中。Solaris平台由于可以同时支持一对一及多对多线程模型,因此Solaris版JDK也支持两种线程模型,可以通过参数进行设置。

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

  • 协同式(Cooperative Thread-Scheduling)线程调度:执行时间由线程本身控制,执行完之后主动通知系统切换到另外一个线程。好处简单,坏处是执行时间不可控,可能会导致程序一直堵塞。
  • 抢占式(Preemptive Thread-Scheduling)线程调度:每个线程由系统分配执行时间,线程切换不由线程本身决定(Thread.yield()可以让出执行时间,但线程本身无法直接获取执行时间)。Java使用的就是这种方式。线程调度由系统自动完成,但是可以通过设置线程优先级“建议”系统多分配执行时间。但是优先级仅仅作为建议,不能进行准确判断。

5种线程状态,任意一个时间点,只能有其中一种状态,状态间可以如下进行转换,其中阻塞与等待状态的区别:阻塞是等待获取一个排他锁,这个事件在另一线程释放这个锁时发生;而等待则是等待一段时间(Thread.sleep())或者唤醒动作(Object.notify())的发生。