JVM(Java Virtual Machine)即Java虚拟机,Java代码都是在JVM上运行的,所以了解JVM是成为Java高手的毕竟之路。
本系列内容将对JVM的知识进行介绍,是从头学习JVM知识的笔记。
本系列内容根据自己的学习和理解的基础上,并参考《深入理解Java虚拟机》一书介绍的知识所写。如果有写的不对的地方,请各位多多提点。
从头开始学习JVM(七)—— Java内存模型与线程
Java内存模型(JMM)
硬件效率与缓存一致性
在了解 Java内存模型(Java Memory Model,JMM)之前,必须要先了解一下硬件计算机中的并发问题,以及硬件和处理器之间运算速度差异而出现的缓存一致性(Cache Coherence)问题。
高速缓存
计算机并发地执行任务 和 处理器并发的执行任务 之间的关系没有想象中的那么简单,其中一个重要的复杂性来自于“计算”,大多数任务不可能只依靠处理器的计算就能完成,还需要与硬件和内存交互,比如读取运算数据、存储运算结果等,这些I/O操作很难消除,而计算机的存储设备的运算速度 与 处理器的运算速度 有几个量级的差距,为了尽可能的协调这种运算速度的差距,现代计算机操作系统中加入了一层运算速度接近处理器的高速缓存(Cache),作为内存和处理器之间的缓冲。
高速缓存:将运算需要使用到的数据复制到高速缓存中,让运算能提高速度,当运算结束之后再从缓存中同步会内存中,这样处理器就无需等待缓慢的内存读写了。
缓存一致性
在多处理器的系统中,每个处理器都有自己缓存,而它们又共享一个主内存(Main Memory),当多个处理器运算任务都涉及一块主内存时,可能会导致各自的缓存数据不一致,这就是缓存一致性(Cache Coherence)问题。
为了解决这个问题,各个处理器在访问缓存时都遵循一些协议,在读写数据时根据协议来进行操作,即缓存一致性协议。这类协议有MSI、MESI、MOSI、Synapse等。
- 乱序执行优化
为了使处理器内部运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Put Execution)优化。
处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中的每个语句计算的先后顺序与输入代码中的顺序一致。
这一优化与处理器中的乱序优化类似,JVM中的即时编译器(JIT)也有类似的指令重排序(Instruction Reorder)优化。
Java内存模型(JMM)
Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致性的内存访问效果。
JMM是一种规范性协议,即缓存一致性的协议,用于定义数据读写的规则。
主内存与工作内存
Java内存模型的主要目标是定义程序中各个变量的访问规则。
此处的变量(Variables)是指线程共享的元素,包括实例字段、静态字段 和 构成数组对象的元素。
不包括局部变量与方法参数,它们是线程私有的。
JMM规定了所有变量都存储在主内存(Main Memory)中,每个线程中还有自己的工作内存(Working Memory)。
此处主内存与物理硬件的主内存虽然同名但不是一个概念,JMM中的主内存是JVM内存的一部分。且此处的主内存、工作内存与JVM中的堆内存、栈内存等也是没有关系的,划分的层次不同。
线程可看做处理器,线程中的工作内存可看做高速缓存。
线程、工作内存、主内存的交互关系如图:
内存间交互操作
操作 | 作用对象 | 解释 |
---|---|---|
lock | 主内存 | 把一个变量标识为一条线程独占的状态 |
unlock | 主内存 | 把一个处于锁定状态的变量释放出来,释放后才可被其他线程锁定 |
read | 主内存 | 把一个变量的值从主内存传输到线程工作内存中,以便 load 操作使用 |
load | 工作内存 | 把 read 操作从主内存中得到的变量值放入工作内存中 |
use | 工作内存 | 把工作内存中一个变量的值传递给执行引擎, 每当虚拟机遇到一个需要使用到变量值的字节码指令时将会执行这个操作 |
assign | 工作内存 | 把一个从执行引擎接收到的值赋接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作 |
store | 工作内存 | 把工作内存中的一个变量的值传送到主内存中,以便 write 操作 |
write | 工作内存 | 把 store 操作从工作内存中得到的变量的值放入主内存的变量中 |
-
8个指令之间的规则:
-
read 和 load、store 和 write 必须成对使用。
-
不允许线程丢弃它最近的assign操作,即变量在工作内存中修改了之后必须同步回主存。
-
同步回主存的变量必须先经过assign操作。
-
一个新的变量只能在主存中诞生,工作内存中不能初始化变量,也不能直接使用未被初始化的变量。即在use 或 store操作之前,必须load 或 assign。
-
一个变量在同一时刻只允许一条线程对其进行lock操作,该条线程可以重复进行多次lock操作。但是lock和unlock次数必须相等,才能解锁。
-
如果对一个变量进行lock操作,则会情况工作内存中该变量的值,若要使用需重新执行load操作。
-
变量没有执行过lock就不能执行unlock操作,也不允许去unlock其他线程锁住的变量。
-
对一个变量进行unlock操作前,必须把它同步回主存中(执行store、wirte操作)。
volatile关键字
volatile
关键字是 Java 虚拟机提供的最轻量级的同步机制。
volatile
关键字的特性:
- 可见性。
volatile
关键字修饰的变量在每次使用前都会刷新,因此执行引擎总是能看到一致的数据。 - 禁止指令重排。在执行过程中多执行了“
lock addl $0x0,(%esp)
”操作,相当于一个内存屏障(Memory Barrier 或 Memory Fence),屏障后的指令不能被重排序到屏障之前。多个CPU下才需要指令屏障来保持代码顺序。 - 不保证原子性,不是线程安全的。可以使用synchronized关键字、java.util.concurrent下的lock锁、java.util.concurrent.atomic 下的原子类来保证原子性。
volatile底层实现原理
在说这个问题之前,我们先看看CPU是如何执行Java代码的。
首先Java代码会被编译成字节码.class文件,在运行时会被加载到JVM中,JVM会将.class转换为具体的CPU执行指令,CPU加载这些指令逐条执行。
有volatile关键字修饰的变量,我们通过工具获取JIT编译器生成的汇编指令,查看变量在进行写操作CPU里执行的指令:
+-----------+----------------------------------------------+
| Java代码: | instance = new Singleton(); |
| | //instance是volatile变量 |
+-----------+----------------------------------------------+
| 汇编代码: | 0x01a3de1d: movb $0x0,0x1104800(%esi); |
| | 0x01a3de24: lock addl $0x0,(%esp); |
+-----------+----------------------------------------------+
有volatile关键字修饰的变量进行写操作的时候会多第二行汇编代码,即lock前缀的汇编代码
。通过查阅IA-32架构软件开发者手册可知,lock前缀的指令在多核处理器下会引发了两件事情:
- 将当前处理器缓存行的数据会写回到系统内存。
- 这个写回内存的操作会引起在其他CPU里缓存了该内存地址的数据无效。
即volatile关键字修饰的变量在改变了变量后,在将新数据存入主存时必定会将缓存中的新数据更新到主存;然后为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议(MESI),每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,由于数据无效则会强制重新从系统内存里把数据读到处理器缓存里。
该实现原理具体内容可引自博文:volatile类型修饰符/内存屏障/处理器缓存
对于 long 和 double 型变量的特殊规则
Java 要求对于主内存和工作内存之间的八个操作都是原子性的,但是对于 64 位的数据类型,有一条宽松的规定:允许虚拟机将没有被 volatile 修饰的 64 位数据的读写操作划分为两次 32 位的操作来进行,即允许虚拟机实现选择可以不保证 64 位数据类型的 load、store、read 和 write 这 4 个操作的原子性。这就是 long 和 double 的非原子性协定。
原子性、可见性与有序性
回顾下并发下应该注意操作的特性,同时加深理解。
- 原子性(Atomicity)
由 Java 内存模型来直接保证的原子性变量操作包括 read、load、assign、use、store 和 write。大致可以认为基本数据类型的操作是原子性的。同时 lock 和 unlock 可以保证更大范围操作的原子性。而 synchronize 同步块操作的原子性是用更高层次的字节码指令 monitorenter 和 monitorexit 来隐式操作的。
- 可见性(Visibility)
是指当一个线程修改了共享变量的值,其他线程也能够立即得知这个通知。主要操作细节就是修改值后将值同步至主内存(volatile 值使用前都会从主内存刷新),除了 volatile 还有 synchronize 和 final 可以保证可见性。同步块的可见性是由“对一个变量执行 unlock 操作之前,必须先把此变量同步会主内存中( store、write 操作)”这条规则获得。而 final 可见性是指:被 final 修饰的字段在构造器中一旦完成,并且构造器没有把 “this” 的引用传递出去( this 引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那在其他线程中就能看见 final 字段的值。
- 有序性(Ordering)
如果在被线程内观察,所有操作都是有序的;如果在一个线程中观察另一个线程,所有操作都是无序的。前半句指“线程内表现为串行的语义”,后半句是指“指令重排”现象和“工作内存与主内存同步延迟”现象。Java 语言通过 volatile 和 synchronize 两个关键字来保证线程之间操作的有序性。volatile 自身就禁止指令重排,而 synchronize 则是由“一个变量在同一时刻指允许一条线程对其进行 lock 操作”这条规则获得,这条规则决定了持有同一个锁的两个同步块只能串行的进入。
先行发生原则
JMM中的的有序性都仅仅靠volatile 和 synchronize来完成,就太过繁琐了。Java语言有“先行发生”(happens-before)原则。这个原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据。
先行发生是 Java 内存模型中定义的两项操作之间的偏序关系。后发生的线程被先发生的线程影响就叫偏序关系,“影响”包括修改了内存中共享的变量值、发送了消息、调用了方法等。
如下伪代码解释偏序关系:
//在线程A中发生
i = 1;
//在线程B中发生
j = i;
//在线程C中发生
i = 2;
//此时线程B就受到A的影响,存在偏序关系。
//若C和B没有先行发生关系,那么B线程中的j就不知道等于1 还是2了。
下面是JMM中一些“天然”的先行发生关系,即无需任何同步器协助就已经存在了:
规则 | 解释 |
---|---|
程序次序规则 | 在一个线程内,代码按照书写的控制流顺序执行 |
管程锁定规则 | 一个 unlock 操作先行发生于后面对同一个锁的 lock 操作 |
volatile 变量规则 | volatile 变量的写操作先行发生于后面对这个变量的读操作 |
线程启动规则 | Thread 对象的 start() 方法先行发生于此线程的每一个动作 |
线程终止规则 | 线程中所有的操作都先行发生于对此线程的终止检测 (通过 Thread.join() 方法结束、 Thread.isAlive() 的返回值检测) |
线程中断规则 | 对线程 interrupt() 方法调用优先发生于被中断线程的代码检测到中断事件的发生 (通过 Thread.interrupted() 方法检测) |
对象终结规则 | 一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始 |
传递性 | 如果操作 A 先于 操作 B 发生,操作 B 先于 操作 C 发生,那么操作 A 先于 操作 C |
Java与线程
线程是比进程更轻量的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件I/O等),又可以独立调度(线程就是CPU调度的基本单位)。
线程的实现
线程的实现主要有3中方式:使用内核线程实现、使用用户线程实现、使用用户线程加轻量进程混合实现。
使用内核线程实现
内核线程(Kernel-Level Thread,KLT)是直接由操作系统内核(Kernel)支持的线程,这种线程由内核完成切换,通过操作调度器(Scheduler)对线程进行调度,并将线程任务映射到各个处理器上。
程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口 —— 轻量级进程(LWP),轻量级进程就是我们通常意义上所讲的线程,每个轻量级进程都有一个内核级线程支持,因此只有先支持内核线程,才能有轻量级进程。轻量级进程与内核线程之间的关系是一对一关系。
局限性:
- 由于是基于内核实现的,调用的代价比较高,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换。
- 每个系统支持的轻量级进程的数量是有限的。
使用用户线程实现
广义上来说,只要不是内核线程就可以认为是用户线程,因此可以认为轻量级进程也属于用户线程。狭义上说是完全建立在用户空间的线程库上的并且内核系统不可感知的。
用户线程的简历、同步、销毁和调度完全在用户态中完成,不需要内核的帮助,进程与用户线程之间是1:N的关系。
使用用户线程的优势在于不需要系统内核的支持,劣势也在于没有系统内核的支持。没有系统内核的支持下,所有线程操作都需要用户程序自己处理,且把处理器分配到进程上非常困难,如“阻塞如何处理”等需要内核支持的问题。目前用户线程方式使用甚少。
使用用户线程加轻量进程混合实现
混合实现方式中,用户线程还是在用户态中自行创建,使用轻量级进程作为用户线程与内核之间的桥梁。用户线程和轻量级进程的数量比是不定的,所以为N:M关系。
Java中线程的实现
对于Sun公司的HotSpot JDK来说,它的Windows和Linux版本都是使用的一对一线程模型实现的,一条Java线程映射到一条轻量级线程之中。因为Windows和Linux操作系统提供的就是一对一线程模型。
对于Solaris平台中,由于操作系统可以同时支持一对一和多对多的线程模型,则可以通过对应参数进行设置。
线程的调度
- 协同式线程调度
线程执行时间由线程自身控制,实现简单,切换线程自己可知,所以基本没有线程同步问题。坏处是执行时间不可控,容易阻塞。
- 抢占式线程调度
每个线程由系统来分配执行时间。缺点是只能主动让出执行时间(Thread.yield();),没有办法获取执行时间。Java使用这种调度方式。
其他关于线程的详细内容可以查看之前写过的文章:Java基础进阶——多线程与JUC(上).
和:Java基础进阶——多线程与JUC(下).