技术探索系列 - 轻松带你掌握 JMM(1)
每日一句
鸟欲高飞先振翅,人求上进先读书 ——李苦禅
前提概要
背景介绍
谈到JVM的核心技术层面内,要说到最难理解同时也是最接近计算机底层的模型的,那一定是Java内存模型,同时它已经成为了面试中的必备问题,是非常具有原理性的一个知识点。但是,有不少人把它和 JVM的内存布局搞混了,以至于答非所问。这个现象在一些工作多年的程序员中非常普遍,主要是因为 JMM与多线程有关,算是一个比较有挑战性和难度的知识体系,接下来就和我一起学习吧。
在我之前的文章中,已经有相关JVM的内存布局方向的,你可以认为这是JVM的数据存储模型;但对于JVM的运行时模型,还有一个和多线程相关的,且非常容易搞混的概念——Java的内存模型(JMM,Java Memory Model)。
基本概念
JMM(Java的内存模型)主要是为了规定了线程和内存之间的一些关系,定义了程序中各个共享变量的访问规则,即在虚拟机中将变量存储到内存和从内存读取变量这样的底层细节。
设计原则
根据JMM的设计,系统存在一个主内存(
设计目的
屏蔽各种硬件和操作系统(服务厂商)的内存访问速度和方式差异,
为什么要理解JMM

JMM的涵盖范围
包括“
1. 并发
定义:并发(同时)发生。在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行,但任一个时刻点上只有一个程序在处理机上运行。并发需要处理两个关键问题:
通信 —— 是指线程之间如何交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递 。同步 —— 是指程序用于控制不同线程之间操作发生相对顺序的机制。在Java中通过volatile,synchronized, 锁等方式实现同步。
1.1 并发的三个概念
原子性: 原子是世界上的最小单位,具有不可分割性 。比如 :a=0; 这个操作是不可分割的,那么我们说这个操作时原子操作。
java的concurrent包下提供了一些原子类,比如:AtomicInteger、AtomicLong、AtomicReference等。
举个例子:
i = 0; //1
j = i ; //2
i++; //3
i = j + 1; //4
上面四个操作,有哪个几个是原子操作:
可见性:
是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的。也就是一个线程修改的结果。另一个线程马上就能看到。
举个例子:
//线程1执行的代码
int i = 0;
i = 10;
//线程2执行的代码
j = i;
假若执行线程1的是CPU1,执行线程2的是 CPU2。由上面的分析可知,当线程1执行 i = 10,这句时,会先把 i 的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得 j 的值为 0,而不是10。
这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。在上面已经分析了,在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。
对于可见性,Java提供了volatile关键字来保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
有序性:
即程序执行的顺序按照代码的先后顺序执行。
举个例子:
//线程1执行的代码
int i = 0;
boolean flag = false;
i = 1; //语句1
flag = true; //语句2
上面代码定义了一个int型变量,定义了一个
在Java里面,可以通过
另外,Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为
2. 主内存和本地内存
主内存:
即main memory。在java中,实例域、静态域和数组元素是线程之间共享的数据,它们存储在主内存中 。本地内存:
即local memory。 局部变量,方法定义参数 和 异常处理器参数是不会在线程之间共享的,它们存储在线程的本地内存中 。
2.1. 主内存与工作内存交互协议
2.1.1 内存间交互操作
我们接着再来关注下变量从主内存读取到工作内存,然后同步回工作内存的细节,这就是主内存与工作内存之间的交互协议。
lock(锁定)
作用于主内存中的变量,它将一个变量标志为一个线程独占的状态 。 unlock(解锁)
作用于主内存中变量,解除变量锁定状态,被解除锁定状态的变量才能被其他线程锁定。 read(读取)作用于主内存中的变量,它把一个变量的值从主内存中传递到工作内存,以便进行下一步的load操作。
load(载入)作用于工作内存中的变量,它把read操作传递来的变量值放到工作内存中的变量副本中。
use(使用)作用于工作内存中的变量,这个操作把变量副本中的值传递给执行引擎。当执行需要使用到变量值的字节码指令的时候就会执行这个操作。
assign(赋值)作用于工作内存中的变量,接收执行引擎传递过来的值,将其赋给工作内存中的变量。当执行赋值的字节码指令的时候就会执行这个操作。
store(存储)作用于工作内存中的变量,它把工作内存中的值传递到主内存中来,以便write操作。
write(写入)作用于主内存中的变量,它把store传递过来的值放到主内存的变量中。
2.1.2 JMM对交互指令的约束
JMM要求上述操作必须按顺序执行,而没有保证必须是连续执行。也就是read和load之间,store和write之间是可以插入其他指令的,如对主内存中的变量a、b进行访问时,可能的顺序是read a,read b,load b, load a。
不允许read和load、store和write操作之一单独出现
不允许线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。 不允许线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过assign和load操作。
一个变量在同一时刻只允许一条线程对其进行lock操作,lock和unlock必须成对出现
如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。
具体执行流程图:

3. 重排序
计算机在执行程序时,为了提高性能,编译器和处理器的常常会对指令做重排,一般分以下3种:
编译器优化的重排
(
编译器语义优化 )
编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
指令并行的重排
(
处理器级别指令级别优化 )
现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序
内存系统的重排
(
缓存级别以及优化 )
由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。

其中编译器优化的重排属于编译期重排,指令并行的重排和内存系统的重排属于处理器重排,在多线程环境中,这些重排优化可能会导致程序出现内存可见性问题。
不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。java内存中的变量都有指针引用,上下文引用成链,这个链是不会被打乱重排序的,只有没有数据依赖关系的代码,才会被冲排序,所以在单线程内部重排序不会改变程序运行结果。
4. as-if-serial语义
as-if-serial语义:所有动作都可以为优化而被重排序,但是必须保证它们重排序后的结果和程序代码本身的应有结果是一致的。
总结
4. happens-before规则
JDK5开始,JSR-133提出了happens-before的概念,通过这个概念来阐述操作之间的内存可见性。
两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见。
常见的满足happens- before原则的语法现象:
对象加锁:对一个监视器锁的解锁,happens-before 于随后对这个监视器锁的加锁。 volatile变量:对一个volatile域的写,happens-before 于任意后续对这个volatile域的读。