前言
作为一个初级Java程序员,常常会将JVM内存模型和Java内存模型(JMM)弄混。实际上,这两者是完全不同的。今天我来介绍一下Java内存模型。
正文
Java内存模型基础
线程间通信的方式
通信是指线程之间是以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。
在共享内存的并发模型中,线程之间共享程序的公共状态,通过写 - 读内存中的公共状态进行隐式通信。在消息传递的并发模型中,线程之间没有公共状态,线程之间必须通过传送消息来显式进行通信。
线程之间同步方式
同步是指程序中用于控制不同线程间操作发生相对顺序的机制。在共享内存并发模型里。同步显式进行,编程时需要显式指定某个方法或某段代码需要在线程间互斥执行。在消息传递的并发模型里,由于消息的发送必须在消息的接受之前,因此同步是隐式进行的。
Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行。
Java内存模型的结构
在Java中,所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享。局部变量(Local Variables),方法定义参数(Java语言规范称之为Formal Method Parameters)和异常处理器参数(Exception Handler Parameters)不会在线程之间共享。
Java线程之间的通信由JMM控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。Java内存模型的抽象示意图如下:
如图,如果线程A和线程B要通信的话,必须要经历一下2个步骤:
1)线程A把本地内存A中更新过的共享变量刷新到主内存;
2)线程B到主内存中去读取线程A之前已更新过的共享变量。
这个很好理解,就是本地内存别的线程并不能读取,所以需要刷新到主内存中供其他线程读取。
Java内存模型的特征
Java内存模型是围绕着并发编程中原子性、可见性、有序性这三个特征来建立的。下面分别介绍一下这三个特征。
原子性(Atomicity)
一个操作不能被打断,要么全部执行完毕,要么不执行。在这点上有点类似于事务操作,要么全部执行成功,要么回退到执行该操作之前的状态。
Java中提供了两个字节码指令monitorenter
和monitorexit
来保证了原子性。它们对应的关键字便是synchronized,因此synchronized可以保证方法或代码块内的操作是原子性的。
可见性
Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存作为传递媒介的方式来实现的。
众所周知,Java中有一个关键字以内存可见性著称,他就是volatile。其实普通变量和volatile变量都由JMM管理,并且都有同步和刷新的操作。不同的是,volatile变量值修改后的新值立刻同步到主内存,每次使用前立即从主内存中刷新,因此volatile保证了多线程之间的操作变量的可见性,而普通变量则不能保证这一点。
除了volatile关键字能实现可见性之外,synchronized,Lock,final也是可以的。
使用synchronized关键字,在同步方法/同步块开始时(Monitor Enter),使用共享变量时会从主内存中读取最新值到线程私有的工作内存中,在同步方法/同步块结束时(Monitor Exit)将线程私有的工作内存中的值写入到主内存进行同步。关于monitorenter
和monitorexit
这两个字节码指令,我在前文synchronized的实现原理中也有说过。
使用Lock接口的最常用的实现ReentrantLock
(重入锁)来实现可见性:当我们在方法的开始位置执行lock.lock()
方法,这和synchronized开始位置(Monitor Enter)有相同的语义,一般在方法的finally块里执行lock.unlock()
方法,保证每一把锁都有对应的解锁,它和synchronized结束位置(Monitor Exit)有相同的语义。
final关键字的可见性是指:被final修饰的变量,在构造函数数一旦初始化完成,并且在构造函数中并没有把“this”的引用传递出去,那么其他线程就可以看到final变量的值。
有序性
对于一个单线程的代码而言,代码的执行顺序总是由前到后的,语义是串行化的;在多线程中由于指令重排,操作是无序的。
我们可以使用synchronized和volatile这两个关键字来实现有序性。具体而言,volatile关键字通过加入内存屏障来禁止指令的重排序,而synchronized关键字通过一个变量在同一时间只允许有一个线程对其进行操作的规则来实现。本质都是为了串行语义。
可以看到synchronized好像是万金油,哪儿都能用…事实上也的确很好使…
但是synchronized性能很低,虽然JDK在1.5之后对它进行了优化,也只能说差强人意,偶尔用用可以,不建议过度使用。(事实上,很多东西,无论好与不好,都不宜过度)
重排序
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种:
- 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行,如果不存在数据依赖性,处理器可以改变语句对应机器的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
以上第一种属于是编译器重排序,后两种属于处理器重排序。从Java源代码到最终执行的指令序列,需要经历很多重排序,这些重排序可能会造成多线程程序出现内存可见性问题。
什么叫内存可见性问题?
内存可见性问题就是有多个线程同时读取同一变量,当其中任意一个线程修改其变量的值时,其他线程都无法及时得到最新值。
happens-before简介
从JDK5开始,Java使用JSR-133模型概念,它使用happens-before 的概念来阐述操作之间的内存可见性。在JMM中,如果一个操需要对另一个操作可见,那么这两个操作必须要存在happens-before关系。这两个操作可以在一个线程之内,也可以是在不同线程之间。
需要注意的是,两个操作之间具有happens-before关系,并不意味着前一个操作一定要在后一个操作之前执行。happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。
一些与我们密切相关的happens-bofore规则有:
- 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作;
- 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁;
- volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读;
- 传递性:如果A happens-before B, 且B happens-before C,那么A happens-before C。
数据依赖性
如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。它分为下列3种类型:
名称 | 代码示例 | 说明 |
---|---|---|
写后读 | a=1; b=2; | 写一个变量之后,再读这个位置 |
写后写 | a=1; a=2; | 写一个变量之后,再写一个变量 |
读后写 | a=b; b=1; | 读一个变量之后,再写这个变量 |
上述3种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。
编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。但是不同处理器之间和不同线程之间的数据依赖性不会被编译器和处理器考虑。
as-if-serial语义
此语义的意思是:不管怎么重排序,(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。
为了遵守as-if-serial语义,编译器不会对存在数据依赖关系的操作作重排序,因为这种重排序会改变执行结果。但是如果操作之间不存在数据依赖关系,这些操作就可能会被编译器和处理器重排序。
总结
本篇介绍了Java内存模型及其特征,以及相应的Java关键字。相信在读完之后,大家会对JMM有一个比较清晰的认识。