了解Java内存模型(JMM)
1、什么是JMM?
Java内存模型(Java Memory Model,简称JMM) 是一种抽象的概念,并不真实存在,它描述的是一组规则或规范!
通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式!
JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(即线程栈,也叫虚拟机栈,总之这个空间线程独有),用于存储线程私有数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作 (读取赋值等) 必须在工作内存中进行!
首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存。不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本!前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成!
2、JMM 与 JVM 的区别
很多人分不清 JMM 与 JVM,甚至以为它们是同一个概念!(有次面试我把JMM当成JVM,一顿输出后,然后就回家等通知了😆)
JMM 与 JVM 内存区域的划分是不同的概念层次,更恰当的说JMM描述的是一组规则,通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式,JMM是围绕原子性,有序性、可见性展开。
JMM与JVM内存区域唯一相似点,就是,都存在共享数据区域和私有数据区域!
在JMM中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区,而工作内存数据线程私有数据区域,从某个程度上讲则应该包括程序计数器、虚拟机栈以及本地方法栈。
3、主内存、工作内存
主内存、工作内存工作时的交互图(基于JMM规范):
主内存
主要存储的是Java实例对象!
所有线程创建的实例对象都存放在主内存中,不管该 实例对象是成员变量还是方法中的本地变量(也称局部变量)!
当然也包括了共享的类信息、常量、静态变量。
由于是共享数据区域,多条线程对同一个变量进行访问可能会发生线程安全问题。
工作内存
主要存储当前方法的所有本地变量信息(存储着主内存中的变量副本),当然也包括了字节码行号指示器、相关Native方法的信息。
每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的!
注意,由于工作内存是每个线程的私有数据空间,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题!
需要注意的是, 在主内存中的实例对象可以被多线程共享,倘若两个线程同时调用了同一个对象的同一个方法,那么两条线程会将要操作的数据拷贝一份到自己的工作内存中,执行完成操作后才刷新到主内存。数据存储模型图:
3、JMM存在的必要性
在明白了Java多线程的实现原理与Java内存模型的具体关系后,接着来谈谈Java内存模型存在的必要性。
而每个线程创建时JVM都会为其创建一个工作内存(即线程栈,也叫虚拟机栈,总之这个空间线程独有),用于存储线程私有数据,线程与主内存中的变量操作必须通过工作内存间接完成,主要过程是将变量从主内存拷贝的每个线程各自的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,如果存在两个线程同时对一个主内存中的实例对象的变量进行操作就有可能诱发线程安全问题。
假设主内存中存在一个共享变量x,现在有A和B两条线程分别对该变量x=1进行操作,A/B线程各自的工作内存中存在共享变量副本x。
假设现在A线程想要修改x的值为2,而B线程却想要读取x的值,那么B线程读取到的值是A线程更新后的值2还是更新前的值1呢?
答案是,不确定,即B线程有可能读取到A线程更新前的值1,也有可能读取到A线程更新后的值2!
假如A线程修改完后正在将数据写回主内存,而B线程此时正在读取主内存,即将x=1拷贝到自己的工作内存中,这样B线程读取到的值就是x=1,但如果A线程已将x=2写回主内存后,B线程才开始读取的话,那么此时B线程读取到的就是x=2,但到底是哪种情况先发生呢?
如以下示例图所示案例:
以上关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成。
数据同步八大原子操作:
(1)lock(锁定):作用于主内存的变量,把一个变量标记为一条线程独占状态
(2)unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
(3)read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
(4)load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
(5)use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎
(6)assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量
(7)store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作
(8)write(写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送到主内存的变量中
如果要把一个变量从主内存中复制到工作内存中,就需要按顺序地执行read和load操作;
如果把变量从工作内存中同步到主内存中,就需要按顺序地执行store和write操作;
但Java内存模型只要求上述操作必须按顺序执行,因此,就有可能导致上述的A、B线程问题!
4、JMM同步规则
1)不允许一个线程无故地(未发生过assign操作)把数据从工作内存同步回主内存中
2)一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(未发生过load或assign)的变量,即对一个变量实施use和store操作之前,必须先自行load和assign操作。
3)一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现!
4)如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量之前需要重新执行load或assign操作初始化变量的值。
5)如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
6)对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)
5、并发编程3大问题:可见性、原子性、有序性
原子性
原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响!
在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作有点要注意的是:
对于32位系统的来说,long类型数据和double类型数据 (对于基本数据类型,byte,short,int,float,boolean,char读写是原子操作),它们的读写并非原子性的!
也就是说如果存在两条线程同时对long类型或者double类型的数据进行读写是存在相互干扰的!
因为对于32位虚拟机来说,每次原子读写是32位的,而long和double则是64位的存储单元,这样会导致一个线程在写时,操作完前32位的原子操作后,轮到B线程读取时,恰好只读取到了后32位的数据,这样可能会读取到一个既非原值又不是线程修改值的变量,它可能是**“半个变量”**的数值,即64位数据被两个线程分成了两次读取。
但也不必太担心,因为读取到**“半个变量”**的情况比较少见,至少在目前的商用的虚拟机中,几乎都把64位的数据的读写操作作为原子操作来执行,因此对于这个问题不必太在意,知道这么回事即可。
X = 10; //原子性(简单的读取、将数字赋值给变量)
Y = x; //变量之间的相互赋值,不是原子操作
X++; //对变量进行计算操作
X = x+1;
可见性
可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值!
对于串行程序来说,可见性是不存在的,因为我们在任何一个操作中修改了某个变量的值,后续的操作中都能读取这个变量值,并且是修改过的新值。
但在多线程环境中可就不一定了,前面我们分析过,由于线程对共享变量的操作都是线程拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程A修改了共享变量x的值,还未写回主内存时,另外一个线程B又对主内存中同一个共享变量x进行操作,但此时A线程工作内存中共享变量x对线程B来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题!
另外指令重排以及编译器优化也可能导致可见性问题,通过前面的分析,我们知道无论是编译器优化还是处理器优化的重排现象,在多线程环境下,确实会导致程序乱序执行的问题,从而也就导致可见性问题。
有序性
有序性是指程序执行的顺序按照代码的先后顺序执行(处理器可能会对指令进行重排序)!
有序性对于单线程的执行代码,我们总是认为代码的执行是按顺序依次执行的,这样的理解并没有毛病,毕竟对于单线程而言确实如此。
但对于多线程环境,则可能出现乱序现象,因为程序编译成机器码指令后可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致!
要明白的是,在Java程序中,倘若在本线程内,所有操作都视为有序行为,但是如果是多线程环境下,一个线程中观察另外一个线程,所有操作都是无序的!
6、JMM如何解 决原子性&可见性&有序性 问题
原子性问题
除了JVM自身提供的对基本数据类型读写操作的原子性外,可以通过 synchronized和Lock实现原子性。因为synchronized和Lock能够保证任一时刻只有一个线程访问该代码块。
可见性问题
volatile关键字保证可见性。当一个共享变量被volatile修饰时,它会保证修改的值立即被其他的线程看到,即修改的值立即更新到主存中,当其他线程需要读取时,它会去内存中读取新值。synchronized和Lock也可以保证可见性,因为它们可以保证任一时刻只有一个线程能访问共享资源,并在其释放锁之前将修改的变量刷新到内存中。
有序性问题
在Java里面,可以通过volatile关键字来保证一定的“有序性”(具体原理将在下一篇中讲述volatile关键字)。另外可以通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
可见,synchronized和Lock YYDS!
7、指令重排
Java语言规范规定JVM线程内部维持顺序化语义。
即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。
指令重排序的意义是什么?
JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的对机器指令进行重排序,使机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。
下图为从源码到最终执行的指令序列示意图:
8、as-if-serial语义
as-if-serial语义的意思是:不管怎么指令重排(编译器和处理器为了提高并行度),程序**(单线程程序)**的执行结果不能被改变!
编译器、runtime和处理器都必须遵守as-if-serial语义!
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。
但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序!
9、happens-before 原则
只靠sychronized和volatile关键字来保证原子性、可见性以及有序性,那么编写并发程序可能会显得十分麻烦!
一方面,程序员需要JMM提供一个强的内存模型来编写代码;另一方面,编译器和处理器希望JMM对它们的束缚越少越好,这样它们就可以尽可能多的做优化来提高性能,希望的是一个弱的内存模型。
JMM考虑了这两种需求,并且找到了平衡点,对编译器和处理器来说,只要不改变程序的执行结果(单线程程序和正确同步了的多线程程序),编译器和处理器怎么优化都行!
而对于程序员,JMM提供了happens-before规则(JSR-133规范),满足了程序员的需求——简单易懂,并且提供了足够强的内存可见性保证。
换言之,程序员只要遵循 happens-before 规则,那他写的程序就能保证在JMM中具有强的内存可见性!
JMM使用happens-before的概念来定制两个操作之间的执行顺序。这两个操作可以在一个线程以内,也可以是不同的线程之间。因此,JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证。
happens-before关系的定义:
- 如果 操作A happens-before 操作B,那么 操作A 的执行结果将对 操作B 可见,而且 操作A 的执行顺序排在 操作B 之前。
- 两个操作之间存在happens-before关系,并不意味着JVM必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么JMM也允许这样的重排序。
as-if-serial语义保证单线程内重排序后的执行结果和程序代码本身应有的结果是一致!
而happens-before关系保证正确同步的多线程程序的执行结果不被重排序改变。
总之,如果操作A happens-before 操作B,那么 操作A 在内存上所做的操作对 操作B 都是可见的,不管它们是否在一个线程中!
Java中,有以下几种天然的happens-before关系:
- 程序顺序规则:一个线程中的每一个操作,happens-before于该线程中的任意后续操作。
- 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁,即必须先解锁,才能再加锁。
- volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
- 传递性:如果A happens-before B,且 B happens-before C,那么 A happens-before C。
- start规则:如果线程A执行操作ThreadB.start()启动线程B,那么A线程的ThreadB.start()操作 happens-before于线程B中的任意操作。
- join规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
重排序有两类,JMM对这两类重排序有不同的策略:
会改变程序执行结果的重排序:比如 A -> C,JMM要求编译器和处理器都禁止这种重排序。
不会改变程序执行结果的重排序:比如 A -> B,JMM对编译器和处理器不做要求,允许这种重排序。
举例:
int a = 1; // A操作
int b = 2; // B操作
int sum = a + b;// C 操作
System.out.println(sum);
根据以上介绍的happens-before规则,假如只有一个线程,那么不难得出:
A happens-before B
B happens-before C
A happens-before C
注意,真正在执行指令的时候,其实JVM有可能对操作A & B进行重排序,因为无论先执行A还是B,他们都对对方是可见的,并且不影响执行结果。
如果这里发生了重排序,这在视觉上违背了happens-before原则,但是JMM是允许这样的重排序的。
所以,我们只关心happens-before规则,不用关心JVM到底是怎样执行的。只要确定操作A happens-before操作B就行了。