概述

本文先阐述JVM内存模型、硬件(CPU、GPU)、OS(操作系统)内存区域架构、Java多线程原理以及Java内存模型(JMM)之间的关联后,再对Java内存模型进一步剖析

注意:一定不要混淆Java内存模型(JMM)和JVM内存模型


理解JVM内存模型和Java内存模型JMM的区别

JVM内存模型-JVM内存区域是如何划分的,分别有什么功能

Java通过JVM虚拟机实现跨平台运行的功能,只需要把源文件(.Java文件)通过?方式编译后成为字节码文件(.class文件)后就可以在不同的操作系统下运行字节码文件

JVM在运行期间,会把自己管理的内存划分为上图所示区域:运行时数据区,在Java程序运行的时候每个区域都对应着不同的功能,发挥着不同的作用。 JVM运行时数据区主要分为:线程共享区、线程私有区。


线程共享区

方法区Method Area

Java8之后修改为了源数据空间,该区域在线程共享区,该区域也叫非堆内存Non-Heap,因为在线程共享区还有一个堆内存,主要用于存储已经被虚拟机加载后的类信息、常量、静态变量、JIT编译后的代码缓存等数据。

注意 在方法区中还有一个运行时常量池Runtime Constant Pool的区域,主要用来存放编译器生成的各种字面量和符号引用,这些数据在虚拟机完成类加载之后就会载入到运行时常量池中,方便后续程序运行时使用

在方法区中很可能会导致OOM异常Out Of Memory

根据Java虚拟机规范的规范,如果方法区无法满足内存分配需求时,将会抛出Out Of Memory Error异常


堆空间-Java Heap-JVM

JVM堆是属于线程共享区域中的内存区域,只要虚拟机启动就会创建JVM堆,就是只要进行加载过程就会创建堆空间,给它分配内存空间。

JVM堆是JVM所管理的内存中最大的一块,主要用来存放对象实例,几乎所有新建(new)的对象都会在JVM堆中分配内存,但是还有少部分不是在JVM堆中分配内存


线程私有区

程序计数器Program Counter Register

程序计数器在线程私有区中,是一块较小的内存空间,主要作为线程执行的行号指示器。

JVM字节码解析器(就是对.class字节码文件进行解析)工作时,通过改变程序计数器的数值来决定.class文件下一条需要执行的字节码指令,是分支、循环还是跳转、还是异常处理、线程恢复等基础功能,都需要通过程序计数器来完成

其实就是因为CPU时间片在调度线程工作时,会中断某个线程,让另外一个线程开始工作,如果CPU想要重新调度这个被中断的线程区工作,就要通过程序计数器来得知上次执行到哪一行代码了,找到对应的位置


虚拟机栈Java Virtual Machine Stacks

虚拟机栈属于线程私有区,也叫做:线程栈,当操作系统OS在创建线程时就会创建线程栈,为其分配内存空间,虚拟机栈的总数和线程栈的总数是对应的,主要在执行Java方法时,作为临时内存区域来使用

当线程开始执行一个方法时,会先创建一个栈帧来存储这个方法对应的变量表、操作数栈、动态链接、返回值、返回地址等信息。那么直到这个方法执行结束/调用结束时,这个栈帧就会有一个出栈的过程,如下图:

在虚拟机栈中会有多个栈帧,因为不止一个线程,会同时执行多个方法

CPU调度线程去执行一个方法时,方法的调用开始直到调用结束表示栈帧入栈和出栈的过程


本地方法栈Native Method Stacks

本地方法栈也是属于私有线程区,这个区域跟C语言编写的Native方法相关,JVM会在本地方法栈中维护一张本地方法登记表,当有线程需要去调用本地Native方法时就会在本地方法栈维护的登记表栈去记录哪个线程调用了哪个本地方法/接口,是不会直接在本地方法栈中去直接调用方法/接口的,仅仅用于登记处理,真正是需要通过本地方法接口去调用本地方法库中C语言编写的函数

而一般情况下我们是不会关心这个区域的,因为热点HotSpot虚拟机中和虚拟栈已经合成一起了

还需要提醒一句:JVM内存模型和JMM内存模型是完全不一样的

  • JVM内存模型是Java程序在运行期间的数据区域,对于操作系统而言本质就是存在内存中

  • JMMJavaOS硬件架构层面的概念,JMM并不存在具体的代码,只是一种规范,不是技术实现,我感觉有点像HarnessHermes的区别,但是不知道这么理解是否正确,欢迎大家指正。


Java内存模型(JMM)

Java memory model(简称:JMM),是一个规范,不是真实存在的,也不是技术实现手段。规定Java程序中各个变量(实利字段、静态字段、构成数组对象的元素)的访问方式。

JVM运行程序的实体是线程,每个线程在创建的时候JVM都要给它分配工作内存,用来存储线程私有数据。在Java内存模型中规定所有的变量都要存储在主内存中,主内存是共享内存区域的,所有的线程都可以访问。当线程想对一个变量进行赋值/运算操作时就要把对应的数据拷贝到工作内存中

所以,当一个线程想要去操作变量的时候,首先要从主内存中把变量拷贝到该线程的工作内存中,然后再在工作内存中对该变量进行操作,在工作内存中操作完成后再将变更后的值刷回主内存中,即:线程不能直接在主内存中操作变量,避免造成数据污染问题,必须将主内存中的变量拷贝到工作内存中进行操作

注意

Java中线程在操作一个对象时,对象实例是存放在JVM的堆内存中的,而栈空间是存放该对象实例的引用地址的。

当一个线程去操作一个对象时,会先根据栈中的地址去主内存中的真实对象,然后将对象拷贝到自己对应的线程的工作内存中再进行操作,但是如果被操作的对象较大时,就不会完全拷贝整个对象,只会讲需要操作的部分成员拷贝过来

工作内存是每一个线程的私有数据区域,不同线程间的工作内存是无法访问的,线程间的通信就要靠主内存来完成的,如图:

JMM内存模型和JVM的唯一区别就是:都存在共享数据区域和私有数据区域


主内存

JMM中主内存属于共享内存区域,从某个角度上看可以认为主内存包含:方法区和堆

主内存主要存储共享数据,类的成员变量、方法的局部变量、共享的类信息、常量、静态变量等数据,还有所有线程创建的实例对象,栈内存中分配的对象除外

注意:因为主内存是线程共享区域,所以多个线程有可能对同一个数据同时进行操作,即非原子性操作,就会导致线程竞争,多线程安全问题的出现


工作内存

工作内存属于线程私有区,从某个角度看,可以认为包含:线程计数器、本地方法栈、虚拟机栈

工作内存主要存储的是当前方法所有的本地变量信息,每个线程的工作内存对其他线程是不可见的,例如线程T1的工作内存中,存储着主内存中拷贝回来的某个共享变量副本,线程T2是不可见的

即使这两个线程执行的是主内存中的同一段代码、同一个方法,都会在其工作内存中,创建属于当前线程的本地变量、字节码行号指示器、相关Native方法等信息,两者不会共用一块内存的数据

存储在工作内存中的数据没有线程安全问题 因为工作内存是每个线程的私有数据,线程间无法相互访问对方的工作内存,线程之间的通讯是需要依赖主内存来进行的


工作内存和主内存的关系

首先了解一下工作内存和主内存的数据存储类型和操作方式

根据虚拟机规范,对于一个实例对象的成员方法而言,如果方法中包含的本地变量(局部变量)是八大基本数据类型boolean、byte、char、int、long、float、double,就直接存储在工作内存的栈帧结构的局部变量表中

因为工作内存是线程私有区域,从某个角度看工作内存是包含虚拟机栈的,所以就直接存储在栈帧中

如果本地变量(局部变量)是引用数据类型:

  • 该对象在内存中的具体引用地址会被存储到工作内存的栈帧结构的局部变量表中

  • 具体的实例对象会被存储到主内存(堆中,跟上面工作内存的理解方式一样)

但对于实例对象的成员字段(全局变量)不管是基本数据类型、引用数据类型、包装类都会被存储到堆中(小部分在栈中分配的除外)

static静态变量以及类本身相关信息将会被存储到主内存中

注意: 主内存是线程共享区,主内存中的实例对象是可以被多条线程同时共享的,如果这两条线程同时调用同一个类的、同一个方法,那么这两条线程需要将操作的数据拷贝一份到自己的工作内存中,在工作内存操作完之后才刷新到主内存中

如图:

//假设此时主内存中有一个数据
//是一个全局变量而且是引用数据类型
Integer num = new Integer(100);
​
//有两个线程A和B都调用这个方法
public void add(){
  num++;
}

那如果这个变量是局部变量呢? 局部变量的话一个线程就会对应一个值

public void add(){
  Integer num = new Integer(200);
  num++;
}


硬件内存架构、OS操作系统、Java多线程实现原理及JMM

计算机硬件内存架构

先画一副简易的CPU和内存操作的简易图:

一般对于一台计算机而言,会有多个CPU,一个CPU会有多个核心,多核是指在一枚处理器中,集成了多个完整的核心(计算引擎),那么这样就可以支持多任务并发执行。从多线程的角度看,每个线程都会映射到各个CPU核心上并行执行

CPU内部有一组CPU寄存器,寄存器存储CPU直接访问和处理的数据,也就是一个临时存放数据的空间

一般情况下CPU都会先从内存中获取数据,然后把数据存放到寄存器中,然后对数据进行处理,但是内存的处理速度远低于CPU,导致CPU在处理指令时需要花很多时间在等待内存准备数据上。那么为了解决这个问题就在CPU和内存之间添加了CPU高速缓存,该缓存区域空间比较小,但是访问速度比内存快很多。高速缓存就可以把从内存提取的数据暂时保存起来,如果寄存器要取内存中同一位置的数据,可以直接从缓存中提取,不需要再去内存中获取,效率高很多,有点像MySQLRedis的关系,内存就是MySQL中获取数据,高速缓存就是从Redis中获取数据,寄存器就相当于是请求,例如查询语句,修改语句等等。

但是只有当寄存器要取的数据在缓存中有才能取得到,如果不是同一个内存地址的数据那么寄存器就得绕过CPU高速缓存从内存中去获取对应的数据,,这种想象在学习Redis的时候也讲过,就是缓存命中率,可以从缓存中获取就是命中,从内存中获取就是没有命中,那么命中率越高CPU执行性能越高

总结: 当CPU需要访问内存数据时,首先从内存中读取一部分数据到CPU高速缓存中存放,然后把读取缓存数据到寄存器中,当CPU需要写数据时会先刷寄存器中的数据到CPU缓存中,然后再把数据刷回内存中

Redis就是CPU高速缓存,CPU寄存器就是Java程序,内存就是MySQL


OSJVM线程关系、Java线程实现原理

Java中线程是基于一对一模型实现的,所谓的一对一模型,就是在语言级别的层面,去间接调用系统内核的线程模型,如我们在使用Java线程时:

new Thread(Runnable).start();

JVM最终会调用当前操作系统的内核线程,来执行当前的Runnable任务

内核线程Kernel-Level Thread, KLT,它是操作系统内核Kernel支持的线程,这种线程由操作系统进行控制,内核通过操作调度器完成对线程的执行调度,并将线程的任务映射到各个处理器上。每个线程可以视为内核的一个分身,那么多线程就有多个分身,所以操作系统就可以同时处理多个任务

但是我们编写的多线程程序是基于语言层面的多线程,一般是无法直接调用/创建内核线程,所以就出现了一种轻量级进程Light Weight Process,这也是通常意义上的线程。每个轻量级进程都会映射一个内核线程,所以可以通过轻量级进程来调用内核线程,进而由操作系统内核将内核线程的任务映射到各个处理器,这种轻量级进程与内核线程是一对一的关系,就称为Java线程和OS内核线程的一对一模型,如图:

Java中的每个线程都会经过操作系统内核,通过操作系统内核调用线程调度器把每一个线程对应的任务映射到CPU中进行处理,如果CPU是多核的,那么一个CPU可以同时并行调度执行多个线程,执行多个任务


JMM与硬件内存架构的关系

Java内存模型(JMM)和硬件内存架构是不一样的

对于硬件内存架构而言,只有:主内存、CPU高速缓存、寄存器,没有工作内存(线程私有区)和主内存(线程共享区)之分,JMM的内存划分对硬件内存架构是没有任何影响的,因为JMM只是一种理论,并非实际的技术,所以在JMM中的数据不管是在工作内存还是在主内存中,都有可能存储在计算机主内存、CPU高速缓存、寄存器中其中的一个区域中


为什么需要有JMM内存模型的存在?

由于线程是操作系统的最小调度单位,所以的程序运行时的实体都是一条线程,运行在操作系统上的Java程序也不例外。每条线程在创建时,JVM都会为其创建一个工作内存,可以理解为虚拟机栈,用于存储线程私有的数据,线程如果想要操作主内存中的某个变量,必须通过工作内存间接完成

主要过程: 将变量从主内存拷贝到线程自己的工作内存中,然后在工作内存中对变量进行操作,操作完成后再将修改后的变量刷回到主内存中,如果存在多个线程同时对主内存中的同一个实例对象/变量操作时可能会导致线程安全问题出现

举个例子:假设主内存中有一个共享变量:int i = 0,然后他们调用的方法是对i = i + 1

public void add(){
  i++;
}

有如下两种情况

第一种情况

有两个线程,分别是线程A和线程B,分别对变量i进行奥做,线程A和线程B各自分别把主内存中的变量i拷贝一份到自己的工作内存中,然后在工作内存中对变量i进行i=i+1的操作,然后AB进行完操作之后就刷回主内存中,i=1

Ai完成自增操作后变成了1,但是B是看不见的,还是基于i=0来进行操作的,所以最终在主内存中的值是1

但是理想情况下应该是i=2,所以这个时候就出现了线程安全问题


第二张情况

假设现在A线程想要把i的值修改为2,而B线程却想要读取i的值,那么B线程读取到的值,是A线程更新后的i=2这个值,还是更新前的i=1这个值呢?答案是不确定,即B线程有可能读取到A线程更新前的1,也有可能读取到A线程更新后的2

这是因为工作内存属于每个线程的私有数据区域,而线程A修改变量i时,首先是将变量从主内存拷贝到自己的工作内存中,然后对变量进行操作,操作完成后再将变量i写回主内存。对于B线程的也是类似的,这样就有可能造成主内存与工作内存间数据存在一致性问题。

假如A线程修改完后,正在将数据写回主内存,而B线程此时正在读取主内存的i,也就是将i=1拷贝到自己的工作内存中,这样B线程读取到的值就是i=1。但如果A线程已将i=2写回主内存后,B线程才开始读取的话,那么此时B线程读到的就是2,但到底是哪种情况先发生呢?这是不确定的。

所以如上两种情况,对于程序来说是不应该的,假设把这个变量i换成淘宝双十一的商品库存数,A、B线程换成参加双十一的用户,这时就会导致超卖、重复卖等问题的出现,这会由于技术问题造成业务经济受损,尤其是是在类似于淘宝双十一此类的大促活动中,此类问题如果不控制好,出现问题的风险会成倍增长,其实这也就是所谓的线程安全问题。

为了解决上述两类问题,JVM定义了一组规则,通过这组规则来决定:一个线程对共享变量的写入何时对另一个线程可见,这组规则也称为Java内存模型(JMM),JMM整体是围绕着程序执行的原子性、有序性、可见性展开的,下面我们看看这三个特性。


Java内存模型JMM围绕的三大特征

原子性

一个操作是不可中断的,即使在多线程的环境下,一个操作一旦开始了就不会被其他线程影响,即在主内存中不允许同时有多个线程去操作同一个变量

例如 :在主内存中有一个静态变量int i = 0,有两个线程执行方法同时对他进行赋值操作,线程A赋值i=1,线程B赋值i=2,不管什么情况,最终把这个变量刷回主内存中时,i的值要么是1要么是2,线程之间是互不干扰的

但是:long double这两个类型的数据他们的读写是非原子性的,不过不需要太重视

这个原子性其实跟MySQL事物是有点相似的:其实也是要么全部执行成功,要么全部执行失败,再举个例子:

下单操作:订单数量增加,库存删减

用户下单购物,系统就必须保证订单增加,库存删减这两个操作同时成功,否则就要同时失败,不能出现下单成功了,库存删减失败导致超卖的情况,这就是原子性的一个典型的例子

在我们研究Java并发编程以及在研究可见性时需要注意,计算机在执行程序时会通过指令重排去优化他的操作。那么计算机在执行程序的时候为了提高性能,编译器和处理器的常常会对指令进行重拍,一般分为一下3种:

  • 编译器优化的重排:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序

  • 指令并行的重排:处理器(CPU处理器)采用指令级并行技术来将多条指令重叠执行,如果不存在数据依赖性即后一条执行的语句不需要依赖前面语句的执行结果,处理器可以改变语句对应机器指令的执行顺序

  • 内存系统的重排:处理器使用缓存和读写缓冲区,使得加载load和存储store操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存和缓存的数据同步存在时间差

编译器重排是编译期重排,指令并行的重排和内存系统的重排属于处理器重排。在多线程的环境中这些重排优化可能会导致程序出现内存可见性问题,下面分别解释一下这两种重排优化可能带来的问题:

编译器优化指令重排
// 在Java内存模型中
// 主内存的共享变量
int a = 0;
int b = 0;
​
//在工作内存中
//线程A              线程B
代码1:int x = a;     代码3:int y = b;
代码2:b = 1;         代码4:a = 2;

两个线程是并行执行的,理论上来说x=0 y=0,不会出现x=2 y=1的可能性,因为代码一般是重上往下执行的,但是实际上两种情况都有可能出现,因为编译器是会对一些前后互不依赖,完全解耦合的代码进行指令重排优化,假设编译器对这段代码进行指令重排后,如下:

//线程A              线程B
代码2:b = 1;         代码4:a = 2;
代码1:int x = a;     代码3:int y = b;

那么编译器进行指令重排后,很有可能就会出现x=2 y=1的情况,所以在多线程环境下多个线程使用变量时无法保证一致性

注意:编译器重排的前提:代码不存在依赖性时才会发生

存在依赖性有两种情况:存在依赖性就无法进行重排

  • 数据依赖

  • 控制依赖

数据依赖

b的值就由a来决定,他们之间是有依赖性的

int a = 1;
int b = a;

控制依赖

if判断进行流程控制依赖f的值

boolean f = true;
if(f){
  System.out.println("123");
}

处理器指令重排

处理器指令重排是对CPU的性能进行优化,从执行角度来说一条指令可以分为多个步骤完成,如下:

取指:IF 译码和取寄存器操作数:ID

执行或者有效地址计算:EX

存储器访问:MEM

写回:WB

处理器CPU在工作的时候需要将一个指令分为上述多个步骤依次执行,不同处理器之间有微小差异,由于每一步就使用不同的硬件完成操作,比如取指时,会用到PC寄存器和存储器;译码时会用到指令寄存器组,执行时会用到ALU算法逻辑单元,写回时用到寄存器组,为了提高硬件利用率,CPU执行指令是按流水线技术来工作的,如下:

处理器指令流水线执行流程
  指令1: IF ID EX MEM WB
  指令2:    IF ID EX  MEM WB
  指令3:        IF ID  EX  MEM WB 

CPU处理器的执行流程和流水线技术流程类似,各司其职,就是上图所示的情况,即使指令1还没有执行完成,但是指令2会利用空闲的硬件去开始执行,例如一条指令的每一个步骤执行时间都要花费1ms,那么指令2只需要等待1ms的时间就能开始执行,不需要等待5ms的时间,极大提高了CPU的执行性能

但是如果流水线中断了,就会导致所有的硬件都进入一轮停顿期,就像工厂流水线组装手机一样,其中一个零件组装工序中断了,后续的工人就会进入一轮或者几轮的时间等待,损失也很大

所以我们需要尽可能避免指令执行中断的情况,那么指令重排就是一种优化中断的手段,举个例子:指令重排是如何组织流水线技术中断的:

1  i = a + b;
2  y = c - d;

这两条指令的流水线执行过程,没有进行指令重排

指令 解析
LW R1,a LW指load,其中LW R1,a表示将a的值加载到寄存器R1中
LW R2,b LW指load,其中LW R2,b表示将b的值加载到寄存器R2中
ADD R3,R1,R2 ADD指令表示加法,把R1和R2的值相加并且把结果加载到寄存器R3中
SW i,R3 SW表示store,将寄存器R3的值保持到变量i中
LW R4,c LW指load,其中LW R4,c表示将c的值加载到寄存器R4中
LW R5,d LW指load,其中LW R5,d表示将d的值加载到寄存器R5中
SUB R6,R4,R5 SUB指令表示减法,把R4、R5的值想减并加载到寄存器R6中
SW y,R6 把R6寄存器的值保持到变量y中

X标志表示中断的含义,也就是说上图只要是有X的地方都会导致指令流水线出现停顿,那么就会影响后续指令的执行,可能需要经过1个或多个指令周期才能回复正常流水线指令流程

那么中断的原因是什么呢? 这是因为部分数据还没准备好,如执行ADD指令时,需要使用到前面指令的数据R1,R2,而此时R2MEM操作没有完成,即未拷贝到存储器中,这样加法计算就无法进行,必须等到MEM操作完成后才能执行,因此就产生了停顿,其他指令也是类似的情况。

前面讲过,停顿会造成CPU性能下降,因此我们应该想办法消除这些停顿,这时就需要使用到指令重排了,如下图,既然ADD指令需要等待,那我们就利用等待的时间做些别的事情,如把LW R4,cLW R5,d移动到前面执行,毕竟LW R4,cLW R5,d的执行,并没有数据依赖关系,对他们有数据依赖关系的SUB R6,R5,R4指令,在R4,R5加载完成后才执行的,没有影响,过程如下:

指令重排带来的好处:所有的停顿都消除了,流水线也无需中断了,这样CPU的性能就带来了很好的提升

那么上面已经解释清楚编译器重排和指令重排了,后续就统一称为指令重排

注意:对于单线程而言指令重排几乎不会带来任何影响,但是对于多线程而言指令重排可能会导致严重的程序乱序执行问题,如下:

int a = 0;
boolean f = false;
public void methodA(){
  a = 1;
  f = true;
}
public void methodB(){
  if(f){
    int i = a + 1;
  }
}

线程A和线程B分别执行方法A和方法B,那么在多线程的情况下,如果进行指令重排,就会导致执行顺序改变:

线程A                      线程B
 methodA:                methodB:
 代码1:f= true;           代码1:f= true;
 代码2:a = 1;             代码2: a = 0 ; //读取到了未更新的a
                          代码3: i =  a + 1;
​

而此时线程A还在自己的工作内存中,对拷贝过来的变量副本a(0)进行赋值操作,这时还未刷写到主存。此时线程B读取a变量,读到的a值还是为0,那么拷贝到线程B工作内存的a变量值会等于0,然后B线程在工作内存中执行i=a+1操作,因为处理器指令重排的原因,线程B读到a0,导致最终i的值为1,而不是预期的2,这就是多线程环境下,指令重排导致的程序乱序执行的结果。

因此,请记住,指令重排只会保证单线程中串行语义的执行一致性。单线程环境时,能够通过指令重排优化程序性能、消除CPU停顿;但是这种重排序,并不会关心多线程间的语义一致性。


可见性

当一个线程在其工作内存中修改了某个共享变量的值后,其他线程是否能够马上得知这个修改的值

对于串行程序来说,也就是对于单线程而言可见性是不存在的,因为在任何一步操作中修改了某个变量后续的操作都能读取到这个变量值,并且是修改完成后的变量值。

但是在多线程环境中就不一样了,因为所有的线程对主内存中的共享变量的操作都是拷贝到各自的工作内存中进行操作的,操作结束后才刷回主内存中

例如:线程A修改主内存中的共享变量i的值,但是还没有把修改后的值刷回主内存,另外一个线程B又从主内存中的i进行操作,此时A线程工作内存中i对线程B是不可见的,这种工作内存与主内存之间同步延迟的现象,就造成了可见性问题

指令重排和编译器优化都有可能导致可见性问题,因为无论是处理器优化还是编译器优化的重排现象在多线程环境下都会导致程序乱序执行的情况出现,从而导致可见性问题


有序性

在单线程环境下,对于单线程执行的代码而言,是按顺序依次执行的,执行会根据编码的顺序从上往下执行,即使发生指令重排,由于所有的硬件优化的前提都是必须遵守as-if-serial语义,所以不管怎么排序都不会影响单线程程序的执行结果,这称为有序执行

但是在多线程环境下可能会出现乱序现象,因为程序编译成机器码指令后,可能会出现指令重排现象,重排后的值了与原指令的顺序未必一致

Java程序中,倘若在本线程内,所有操作都视为有序行为,如果是多线程环境下,一个线程中观察另外一个线程,所有操作都是无序的。前半句指的是单线程内保证串行语义执行的一致性,后半句则指指令重排现象和工作内存与主内存同步延迟现象。


JMM是如何解决上述问题的?

除了JVM自身保障读写基本数据类型的原子性外,对于方法级别或代码块级别的原子性操作可以使用synchronized关键字,或者Lock锁接口的实现类来保证程序执行的原子性,关于synchronized的详解下篇文章讲解

工作内存和主内存同步延迟现象导致的可见性问题,可以使用加锁或Volatile关键字来解决,因为Volatile 的另外一个作用就是禁止重排序优化,关于Volatile稍后在下面就会进行分析

除了靠sychronizedvolatile关键字(volatile关键字不能保证原子性,只能保证的是禁止指令重排与可见性问题)来保证原子性、可见性以及有序性外,JMM内部还定义一套happens-before原则来保证多线程环境下两个操作间的原子性、可见性以及有序性


JMM中的happens-before原则

线程在执行过程中与内存的交互

在了解这个原则前必须对线程执行过程中与内存的交互操作有个简单的认知,Java程序在执行过程中实际上是OS在调度JVM的"线程"执行,执行的过程就是与内存的交互操作,而内存交互操作有8种(虚拟机实现必须保证每一个操作都是原子性的,不再可分的,对于double long类型的变量来说,load store read write这些操作在某些平台上允许例外)

  • lock(锁定):作用于主内存的变量,把一个变量标识为线程独占状态;

  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定;

  • read(读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用;

  • load(载入):作用于工作内存的变量,它把read操作从主存中变量放入工作内存中;

  • use(使用):作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值,就会使用到这个指令;

  • assign(赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中;

  • store(存储):作用于主内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用;

  • write(写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。

JMM对这八种指令的使用,制定了如下规则:

  • 不允许readloadstorewrite操作之一单独出现。即:使用了read必须load,使用了store必须write

  • 不允许线程丢弃他最近的assign操作,即工作变量的数据改变了之后,必须告知主存;

  • 不允许一个线程将没有assign的数据从工作内存同步回主内存;

  • 一个新的变量必须在主内存中诞生,不允许工作内存直接使用一个未被初始化的变量。就是对变量实施use、store操作之前,必须经过assignload操作;

  • 一个变量同一时间只有一个线程能对其进行lock。多次lock后,必须执行相同次数的unlock才能解锁;

  • 如果对一个变量进行lock操作,会清空所有工作内存中此变量的值,在执行引擎使用这个变量前,必须重新loadassign操作初始化变量的值;

  • 如果一个变量没有被lock,就不能对其进行unlock操作。也不能unlock一个被其他线程锁住的变量;

  • 对一个变量进行unlock操作之前,必须把此变量同步回主内存

JMM对这八种操作规则和对volatile的一些特殊规则,就能确定哪里操作是线程安全,哪些操作是线程不安全的了。但是这些规则实在复杂,很难在实践中直接分析,所以一般我们也不会通过上述规则进行分析。更多的时候,会使用JMM中的happens-before规则来进行分析


JMM中的happens-before原则

假如在多线程开发过程中,我们需要通过加锁或volatile来解决这些问题的话,那么编写程序的时候会非常麻烦,而且加锁本质上是让多线程的并行执行变为了串行执行,这样会大大的影响程序的性能,那么其实真的需要嘛?不需要,因为在JMM中还为我们提供happens-before原则来辅助保证程序执行的原子性、可见性以及有序性的问题,它是判断数据是否存在竞争、线程是否安全的依据,happens-before原则内容如下。

  • 一、程序顺序原则:即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行;

  • 二、锁规则:解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁);

  • 三、volatile规则volatile变量的写,先发生于读,这保证了volatile变量的可见性。简单的理解就是:volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值;

  • 四、线程启动规则:线程的start()方法先于它的每一个动作,即如果线程A,在执行线程Bstart方法前修改了共享变量的值,那么当线程B执行start方法时,线程A变更过的共享变量,对线程B可见;

  • 五、传递性优先级规则A先于BB先于C,那么A必然先于C

  • 六、线程终止规则:线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程Bjoin方法成功返回后,线程B对共享变量的修改将对线程A可见;

  • 七、线程中断规则:对线程interrupt()方法的调用,先发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断;

  • 八、对象终结规则:对象的构造函数执行,结束先于finalize()方法。

happens-before原则无需添加任何手段来保证,这是由JMM规定的,Java程序默认遵守如上八条原则,下面我们再通过之前的案例,重新认识这八条原则是如何判断线程是否会出现安全问题:

java
​
 体验AI代码助手
 代码解读
复制代码int a = 0;
boolean f = false;
public void methodA(){
    a = 1;
    f = true;
}
public void methodB(){
    if(f){
        int i = a + 1;
    }
}

同样的道理,目前存在A、B两条线程,线程A调用实例对象的methodA()方法,线程B调用methodB()方法,线程A先启动、线程B后启动,那么线程B读取到的i值是多少呢?

现在依据happens-before的八条原则进行判断:

  • 由于存在两条线程同时调用,因此程序顺序原则不合适。

  • methodA()方法和methodB()方法都没有使用同步手段,锁规则也不合适。

  • 没有使用volatile关键字,volatile变量原则不适应。

  • 线程启动规则、线程终止规则、线程中断规则、对象终结规则、传递性在本次案例也不合适。

  • 线程A、B的启动时间虽然有先后,但线程B执行结果却不确定。

也就是说,上述代码没有适合8条原则中的任意一条,也没有使用任何同步手段,所以上述的操作是线程不安全的,因此线程B读取的值自然也是不确定的。

修复这个问题的方式很简单,要么给methodA()方法和methodB()方法添加同步手段(加锁),或者给共享变量添加volatile关键字修饰,保证该变量在被一个线程修改后总对其他线程可见。


Volatile关键字

3.1、Volatile关键字保证的可见性

VolatileJava提供的轻量级同步工具,它能保证可见性和做到禁止指令重排做到有序性,但是它不能保证原子性,如果你的程序必须保障原子性的话,那么可以考虑使用JUC原子包下的原子类(后续篇章会讲到),或者靠加锁的方式来保证。

但是我们假设使用volatile来修饰共享变量,那它能够保证的是:一个线程对volatile所修饰的变量进行更改操作后,总是能对其他线程可见,如下:

java
​
 体验AI代码助手
 代码解读
复制代码volatile int i = 0;
public void add(){
      i++;
}

对于上述代码,任何线程调用add()方法对i进行i++操作后,对其他线程都是可见的,但这段代码不存在线程安全问题吗?存在,为什么?因为i++并不是原子性操作,i++实际上是三个操作的组成:

从主存读取值、工作内存中+1操作、将运算结果刷写回主存。

任何一条线程在执行任何一步时,都有可能被其他线程打断,所以还是会出现线程安全问题(具体参考之前描述线程安全问题第一种情况)。

为此,我们要清楚,此时如果有多条线程调用add()方法,依旧会出现线程安全问题,如果想要解决这个问题,还是需要使用synchronized、lock或者原子类来保证,volatile关键字只能禁止指令重排以及可见性。

现在再来看一个案例,此类场景可以使用volatile关键字修饰变量,从而达到线程安全的目的,如下:

java
​
 体验AI代码助手
 代码解读
复制代码volatile boolean flag;
​
public void toTrue(){
      flag = true;
}
​
public void methodA(){
      while(!flag){
          System.out.println("我是false....false.....false.......");
      }
}

由于对boolean变量flag值的修改,属于原子性操作,因此可以通过使用volatile修饰flag变量,使用该变量对其他线程立即可见,从而达到线程安全的目的。

那么JMM是如何实现让volatile变量对其他线程立即可见的呢?实际上,当写一个volatile变量时,JMM会把该线程工作内存中的共享变量值刷新到主内存中;当读取一个volatile变量时,JMM会把该线程对应的工作内存置为无效,那么该线程只能从主内存中重新读取共享变量。

volatile变量正是通过这种写-读方式实现对其他线程可见(其内存语义实现则是通过内存屏障,稍后会说明)。

3.2、Volatile如何禁止指令重排序的?

volatile关键字另一个作用,就是禁止编译器或者处理器对指令进行重排优化,从而避免多线程环境下程序出现乱序执行的现象,那volatile是如何做到禁止指令重排优化的呢?先了解一个概念,内存屏障(Memory Barrier)。

内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。

由于编译器和处理器都能执行指令重排优化,如果在指令间插入一条MemoryBarrier,则会告诉编译器和CPU,不管什么指令都不能和这条MemoryBarrier指令重排序,也就是通过插入内存屏障,禁止在内存屏障前后的指令执行重排序优化。

Memory Barrier的另外一个作用:强制刷出各种CPU缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。

屏障类型 指令示例 说明
LoadLoad Barriers Load1; LoadLoad; Load2; 确保Load1指令数据的装载,发生于Load2及后续所有装载指令的数据装载之前。
StoreStore Barriers Store1; StoreStore; Store2; 确保Store1数据的存储对其他处理器可见(刷新到内存中),并发生于Store2及后续所有存储指令的数据写入之前。
LoadStore Barriers Load1; LoadStore; Store2; 确保Load1指令数据的装载,发生于Store2及后续所有存储指令的数据写入之前。
StoreLoad Barriers Store1; StoreLoad; Load2; 确保Store1数据的存储对其他处理器可见(刷新到内存中),并发生于Load2及后续所有装载指令的数据装载之前。StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载)完成之后,才执行该屏障之后的内存访问指令。

Java编译器在生成指令序列的适当位置,会插入内存屏障指令来禁止特定类型的处理器重排序,从而让程序按我们预想的流程去执行。

JMM把内存屏障指令分为4类,StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。

总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。案例如下:

java
​
 体验AI代码助手
 代码解读
复制代码public class Singleton{
  private static Singleton singleton;
  
  private Singleton(){}
  
  public static Singleton getInstance(){
     if(singleton == null){
          synchronized(Singleton.class){
                if(singleton == null){
                      singleton = new Singleton();
               }
          }
      }
  }
}

上述代码是一个经典双重检测的单例模式,这段代码在单线程环境下并没有什么问题,可是如果在多线程环境下,就可能出现线程安全问题。原因在于某一个线程执行到第一次检测,读取到的singleton不为null时,singleton的引用对象可能没有完成初始化。

因为singleton = new Singleton();可以分为以下3步完成(伪代码):

java
​
 体验AI代码助手
 代码解读
复制代码// 1.分配对象内存空间
memory = allocate();
​
// 2.初始化对象
singleton(memory); 
​
// 3.设置singleton指向刚分配的内存地址,此时singleton != null
singleton = memory;

由于步骤1和步骤2间可能会重排序,如下:

java
​
 体验AI代码助手
 代码解读
复制代码// 1.分配对象内存空间
memory = allocate(); 
​
// 3.设置singleton指向刚分配的内存地址,此时singleton != null
singleton = memory; 
​
// 2.初始化对象
singleton(memory);

由于步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后,程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。

但是指令重排只会保证串行语义的执行的一致性(单线程),并不会关心多线程间的语义一致性。所以当一条线程访问singleton不为null时,由于singleton实例未必已初始化完成,也就造成了线程安全问题。

那么该如何解决呢,很简单,我们使用volatile禁止new singleton()时发生指令重排即可:

java
​
 体验AI代码助手
 代码解读
复制代码private volatile static Singleton singleton;

下面简单说明一下双重锁单例的流程:

双重锁单例解释

3.3、volatile关键字的真正实现

java
​
 体验AI代码助手
 代码解读
复制代码private volatile static Singleton singleton;

在这个双重锁单例的例子中,为singleton变量加上volatile后,会禁止new这个操作,被其他线程打断,而我们提到过:volatile具备禁止指令重排序的作用,但是这种禁止重排序,并不是禁止了所有指令重排,下面展开聊聊。

java
​
 体验AI代码助手
 代码解读
复制代码A操作
B操作
C操作
D操作
E操作

现在这里有五个指令操作,假设它们之间不存在依赖性,因此可以被任意重排序,可以是ACDEB、ABEDC……,现在对B、C、D操作加上内存屏障:

java
​
 体验AI代码助手
 代码解读
复制代码A操作
​
内存屏障
B操作
C操作
D操作
内存屏障
​
E操作

这会禁止A、B、C、D、E五个操作重排序嘛?其实并不会,依旧会发生重排,比如重排成E、(D、B、C)、A,但由于我对B、C、D加了内存屏障,尽管可以重排序,但(B、C、D)三个操作变成了一个整体,内部就算重排,也不会有任何影响。

这相当于把(B、C、D)当成了一个指令(原子操作),不可以被其他线程打断,所以volatile可不可以禁止指令重排呢?答案是可以的,说禁止也对,说不禁止也对,就是相对视角的问题,许多资料讲述:“volatile可以禁止指令重排序”,其实更具体的说法应该是:“volatile可以禁止屏障内的指令,和屏障外的指令发生重排序”。

同时还有个误区要纠正一下:volatile并没有直接使用OS的内存屏障指令,而是使用JVM内存屏障字节码指令,JVM的内存屏障字节码指令会间接使用OS的内存屏障指令。这句话有点绕,简单来说就是:JVMOS原生的内存屏障指令有层封装,volatile使用的是JVM封装后的内存屏障。

JVM内存屏障字节码指令的定义,位于HotSpot源码的bytecodeInterpreter.cpp文件中:

字节码内存屏障

许多资料讲述volatile可见性时,会直接跳过JVM这层封装,直接去聊操作系统级别的MESI等一致性协议,其实这是有点不太妥当的,因为OS的内存屏障指令,保证了cpu寄存器、高速缓冲区、机器内存的数据一致性,这是硬件层面的数据一致性。

JVM的内存屏障指令(字节码指令),保证了JVM线程工作内存(线程栈),和JVM程序主内存中的数据一致性,这是软件层面的数据一致性。

JVM的指令最终会依赖OS的指令,但有些资料会跳过了JVM内存屏障这层封装,直接跟你去聊了OS内存屏障,这就导致了许多人,压根不清楚JVM还有一层封装,以为volatile直接用了OS的原语指令。搞清这个误区后,接着再来说说volatile如何实现的可见性。

保证内存数据的可见性,原理还是内存屏障,但用的是读+写屏障,当多个线程读共享变量时,会触发读屏障,读屏障中会记录哪些线程读了这个变量,然后当一条线程写回数据时,就会触发写屏障,此时写屏障里面,就会根据前面“读屏障”记录下来的线程,去通知所有还未刷回的线程,重新再来读取一次最新值,以此实现了内存中共享数据的可见性。

在硬件层面,将volatile修改的高速缓存数据,写回到机器内存时,这个写回内存操作会将把其他处理器(寄存器)中,缓存了该地址的数据置为无效。多核处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据,来检查自己寄存器中的值是不是过期了,当处理器发现自己寄存器中对应内存地址的数据被修改时,就会将当前处理器的缓存行设置成无效状态,当处理器对这个无效状态的数据进行修改时,就会重新从机器内存中读取数据到CPU寄存器。

PS:上述两段话中,前者是JVM字节码指令保障的软件层面数据一致性,后者是OS原语指令保障的硬件层面数据一致性,两者相结合,从而实现了volatile关键字的可见性。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐