Skip to main content

Java内存模型与线程

info

《深入理解Java虚拟机》第12章读书笔记

Java内存模型

Java内存模型(Java Memory Model, JMM)的定义是屏蔽下层操作系统和硬件的内存访问差异,让Java程序在各种平台下都能达到一致内存访问效果的一套机制,这套机制的工作内容是实现变量如何取值、赋值,注意这里的变量特指线程共享的元素,比如实例字段、静态字段和数组对象元素,因为线程私有变量取值、赋值不影响其他线程,不需要达到一致性内存访问效果,下文所称变量都是特指这些元素

JMM.svg

如图所示,JMM规定所有变量都存放在主内存中,而每条线程都有一个自己专属的工作内存,线程对变量的读取和赋值都必须在其工作内存中进行,而不能直接读写主内存中的变量,不同线程的工作内存互相隔离,通过主内存达到一致性状态。而对于工作内存和主内存之间的数据交互,JMM定义了如下8种操作来完成:

  • lock、unlock:锁定、解锁主内存中的某个变量
  • read、load:read操作从主内存读取变量值,load操作将该变量值放入到工作内存副本中
  • store、write:store操作将工作内存的变量值传送到主内存中,write操作将该变量值放入到主内存变量中
  • use、assign:use操作将工作内存中变量值传递给执行引擎(JVM执行取值字节码指令),assign操作将执行引擎变量值赋给工作内存中变量(JVM执行赋值字节码命令)
注意

JMM只要求read->loadstore->write这两种配套操作是按序的,但不要求两个操作之间是连续的!

特殊的volatile

volatile是JMM提供的一种最为轻量级的同步机制,为了达到同步效果,JMM专门为volatile定制了一些特殊的访问规则,由此实现了可见性和有序性

volatile保证可见性

可见性是指当线程a修改了volatile变量x的值,另一个线程b能够立即感知到x的新值。volatile的可见性让一些人误解volatile变量在并发下是线程安全的,如下代码便是个反例,其输出结果总是小于20000

volatile并不是线程安全的
public class TestVolatile {
public static volatile int race = 0;
private static final int THREADS_COUNT = 20;

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

public static void main(String[] args) throws InterruptedException {
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0; i < THREADS_COUNT; i++) {
threads[i] = new Thread(()->{
for (int j = 0; j < 1000; j++) {
increase();
}
});
threads[i].start();
}
for (Thread thread : threads) {
thread.join();
}
System.out.println(race); // 输出结果总是小于20000
}
}

问题来源于increase()代码,increase方法对应的字节码如下所示:

public static increase()V
L0
LINENUMBER 8 L0
GETSTATIC com/kayhaw/understandjvm/chap12/TestVolatile.race : I
ICONST_1
IADD
PUTSTATIC com/kayhaw/understandjvm/chap12/TestVolatile.race : I
L1
LINENUMBER 9 L1
RETURN
MAXSTACK = 2
MAXLOCALS = 0

由于执行一行++race代码时,实际执行的字节码指令有3条,假设现在race值为100,线程a执行执行ICONST_1IADD执行得到101,但还未执行PUTSTATIC指令,此时线程b一下子执行完3条语句得到101,此时线程a再执行PUTSTATIC覆盖了还是得到101,相当于少加了一次,最终导致结果值偏小。因此,volatile并不适合以下两种运算场景:

  • 运算结果并不依赖变量当前值,除非确定只有一条线程会修改变量值
  • 变量不需要和其他状态变量共同参与不变约束

volatile典型的使用场景是在多线程程序中修饰终止标记变量

volatile boolean shutdown;
public void shutdown() {
shutdown = true;
}
public void run() {
while(!shutdown) {
// do something
}
}

volatile保证有序性

使用volatile修饰的第二个语义时禁止指令重排优化,经典用例便是使用DLC创建单例:

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

使用volatile修饰后,字节码指令会多出一条lock addl $0x0, (%esp)空操作,由于lock指令的存在,相当于内存屏障,使得后面指令不能重排到内存屏障之前

volatile特殊规则

volatile之所以能够实现可见性和有序性,在于JMM对volatile变量定义的特殊规则:

  • 线程a对变量x的use操作和load、read操作是连续的,即每次使用x前都从主内存中获取最新值
  • 线程b对变量x的assgin操作和store、write操作是连续的,即每次修改x后都写会到主内存中
  • 线程a对变量x的use或assign操作先于线程b对变量x的use或assign操作,那么线程a对变量x的read或者write操作先于线程b的发生
特殊情况

对于64位的long/double类型数据,JMM允许将64位数据读写操作划分为两次32位数据读写操作,从理论上说可能会读取到一个既不是原值,又不是其他线程修改值的数据,但是这种特殊情况几乎不用考虑

原子性、可见性与有序性

  • 原子性:在JMM定义的6个数据读写操作都是具有原子性的,如果需要更大范围的原子性保证,使用lock/unlock操作,但是JVM并没有暴露这两个操作,而是提供了更高层次的monitorenter和monitorexit来隐式使用,反映到代码就是synchronized关键字
  • 可见性:在JMM中可见性通过每次读取、写入时立即刷新主内存和工作内存来实现,除了volatile外,synchronized和final也可以实现可见性
  • 有序性:volatile和synchronized都实现了有序性

先行发生原则

有序性不仅仅都靠volatile和synchronized来确保,先行发生是JMM中定义两项操作的偏序关系,以下先行发生规则无需其他同步器协助,如果两个操作的关系能从这些规则中推导出来,说明它们天然有序,否则JVM可以随意重排,无法保证有序性:

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

先行发生和时间先后顺序没有关系

Java线程

线程实现

实现线程的主要方式有三种:

实现方式简介优点缺点
内核线程使用内核线程的高级接口轻量级线程1:1实现轻量级线程被阻塞也不会影响整个进程继续工作线程创建、同步、析构都需要系统调用,而系统调用需要进行内核切换,代价高,线程数量有限
用户线程完全使用自定义线程库的用户线程进行1:N实现,线程的创建、销毁、调度完全在用户态中完成不需要系统调用所有操作需要用户程序自己处理
混合实现既有内核线程又有用户线程

对于Java语言,JDK1.2之前基于用户线程实现,自JDK1.3起主流Java虚拟机采用1:1线程模型,

线程优先级

Java线程优先级范围从1到10,优先级越高越容易,注意不是越先被执行,因为线程的最终调度还是有操作系统说了算

线程状态

线程生命周期中经历的状态转换如下图所示:

ThreadState.svg

  • 新建(New):线程创建但未调用start()方法启动
  • 运行(Runnable、Running):线程可运行、正在运行
  • 无限期等待(Waiting):需要其他线程显式唤醒,比如没有Timeout参数的Object::wait()、Thread::join()方法以及LockSupport::park()方法
  • 限期等待(Timed Waiting):由系统自动唤醒,比如Thread::sleep()方法,设置了Timeout参数的Object::wait()、Thread::join()方法
  • 阻塞(Blocked):阻塞状态等待获取锁,而等待状态在等一段时间或者其他线程唤醒它

Java与协程

由于采用内核线程1:1的线程实现,为了进行线程切换,需要保存及恢复线程的上下文环境,从操作系统角度看就是存储在内存、缓存、寄存器中数值的来回拷贝,由此导致切换线程成本高。那使用用户线程?由于早期用户线程被设计成协同式调度,因此又被称为“协程”,按照保存恢复上下文的方式不同,又可细分为有栈协程和无栈协程。

对于有栈协程,OpenJDK创建了Loom项目来为Java加入称之为纤程(Fiber)的用户线程。这种新的并发编程模型会和当前基于内核线程的线程实现并存,而不是取代它