JAVA系列之内存模型(JMM)

什么是CPU多级缓存?
什么是缓存一致性?
volatile关键字的作用?
synchronized关键字和volatile 关键字的区别?

一、简介

Java内存模型是在硬件内存模型上的更高层的抽象,它屏蔽了各种硬件和操作系统访问的差异性,保证了Java程序在各种平台下对内存的访问都能达到一致的效果。
Java内存模型是不可见的,它并不是一个真实的东西,它只是一个概念、一个规范。

二、硬件内存模型

1、高速缓存和缓存一致性

早期计算机中CPU和内存的速度是差不多的,随着CPU高速发展,CPU的运算速度已经远远高于它从存储介质读取数据的速度,这里的存储介质有很多,比如磁盘、光盘、网卡、内存等。所以,在程序运行的过程中,CPU大部分时间都浪费在了磁盘IO、网络通讯、数据库访问上,如果不想让CPU在那里白白等待,我们就必须想办法去把CPU的运算能力压榨出来,让CPU同时去处理多项任务,这也就是我们常说的“并发执行”。

但是,所有的运算都不可能只依靠CPU的计算就能完成,往往还需要跟内存进行交互,如读取运算数据、存储运算结果等。CPU与内存的交互往往是很慢的,所以现代计算机系统加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:

将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。

基于高速缓存机制很好地解决了处理器与内存的速度矛盾,但是引入了一个新的问题:缓存一致性(Cache Coherence)。

在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致,
例如:共享变量在多个处理器中被进行写操作,导致高速缓存中的数据不一致。如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。

2、CPU三级缓存

随着CPU能力的不断提升,一层缓存就无法满足要求了,就逐渐衍生出了三级缓存L1、L2、L3)。

  • 离CPU距离:L1最近,L2其次,L3再次。
  • 运行速度:L1最快、L2次快、L3最慢;
  • 容量大小:L1最小、L2较大、L3最大;
  • 制作成本:L1最高、L2其次、L3最小;

CPU会先在最快的L1中寻找需要的数据,找不到再去找次快的L2,还找不到再去找L3,L3都没有那就只能去内存找了。

其中一级缓存还分为一级数据缓存(Data Cache,D-Cache,L1d)和一级指令缓存(Instruction Cache,I-Cache,L1i),分别用于存放数据及执行数据的指令解码,两者可同时被CPU访问,减少了CPU多核心、多线程争用缓存造成的冲突,提高了处理器的效能。一般CPU的L1i和L1d具备相同的容量。

3、缓存一致性

单核CPU,多线程。进程中的多个线程会同时访问进程中的共享数据,CPU将某块内存加载到缓存后,不同线程在访问相同的物理地址的时候,都会映射到相同的缓存位置,这样即使发生线程的切换,缓存仍然不会失效。但由于任何时刻只能有一个线程在执行,因此不会出现缓存访问冲突。
多核CPU,多线程。每个核都至少有一个L1 缓存。多个线程访问进程中的某个共享内存,且这多个线程分别在不同的核心上执行,则每个核心都会在各自的caehe中保留一份共享内存的缓冲。由于多核是可以并行的,可能会出现多个线程同时写各自的缓存的情况,而各自的cache之间的数据就有可能不同。

解决这个缓存一致性问题的方法有很多,比如:
1.总线加锁(此方法性能较低,现在已经不会再使用)。
2.MESI协议: 这是Intel提出的,MESI协议也是相当复杂,在这里我就简单的说下:当一个CPU修改了Cache中的数据,会通知其他缓存了这个数据的CPU,其他CPU会把Cache中这份数据的Cache Line置为无效,要读取数据的话,直接去内存中获取,不会再从Cache中获取了。

关于缓存一致性详细介绍:https://www.cnblogs.com/chanmufeng/p/16523365.html

4、Java线程与CPU

我们在Java中开启一个线程,最终也是交给CPU去执行。 具体的流程是:我们在使用Java线程,内部会调用操作系统(OS)的内核线程(Kernel-Level Thread),这种线程是操作系统内核(Kernel)直接支持的,内核通过调度器,对线程进行调度,并将线程交给各个CPU内核去处理。 如下图所示:

5、指令重排序

除此之外,为了使CPU中的运算单元能够充分地被利用,CPU可能会对输入的代码进行乱序执行优化,然后在计算之后再将乱序执行的结果进行重组,保证该结果与顺序执行的结果一致,但并不保证程序中各个语句计算的先后顺序与代码的输入顺序一致,因此,如果一个计算任务依赖于另一个计算任务的结果,那么其顺序性并不能靠代码的先后顺序来保证。

总的来说,硬件内存模型定义了共享内存系统中多线程读写操作行为的规范。为了解决上面提到的多个缓存读写一致性以及乱序排序优化的问题。

三、Java内存模型

1、Java内存模型概念

本地内存:我们知道,Java里面每个线程都有一个自己的本地内存(上图绿色区域),存放的是私有变量和主内存数据的副本。如果私有变量是基本数据类型,则直接存放在本地内存,如果是引用类型变量,存放的是引用(指针),实际的数据存放在主内存。本地内存是不共享的,只有属于它的线程可以访问。也称之为线程栈工作空间

主内存:存放的是共享的数据,所有线程都可以访问。也有不少其他称呼,比如堆内存共享内存

Java内存模型规定了所有对共享变量的读写操作都必须在本地内存中进行,需要先从主内存中拿到数据,复制到本地内存,然后在本地内存中对数据进行修改,再刷新回主内存。

2、Java内存模型与硬件内存模型之间的关系

通过前面的铺垫,我们认识到Java的执行最终还是会交给CPU去处理,但是Java的内存模型和硬件架构又不完全一致。对于硬件来说,只有CPU,Cache和主内存,并没有Java内存模型中本地内存(线程栈、工作空间)或者主内存(共享内存,堆内存)的概念。

所以,不管是Java内存模型中的本地内存,还是主内存的数据,最终都会存储在CPU(更准确的来说是寄存器、Cache、内存)上。

3、Java内存模型作用

Java内存模型就是为了解决多线程下共享数据的读写一致性问题

4、Java内存模型的同步操作与规则

关于主内存与工作内存之间具体的交互协议,Java内存模型定义了以下8种具体的操作来完成:

  • lock(锁定):作用于主内存的变量,它把主内存中的变量标识为一条线程独占状态;
  • unlock(解锁):作用于主内存的变量,它把锁定的变量释放出来,释放出来的变量才可以被其它线程锁定;
  • read(读取):作用于主内存的变量,它把一个变量从主内存传输到工作内存中,以便后续的load操作使用;
  • load(载入):作用于工作内存的变量,它把read操作从主内存得到的变量放入工作内存的变量副本中;
  • use(使用):作用于工作内存的变量,它把工作内存中的一个变量传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作;
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的变量赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时使用这个操作;
  • store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传递到主内存中,以便后续的write操作使用;
  • write(写入):作用于主内存的变量,它把store操作从工作内存得到的变量的值放入到主内存的变量中;

Java内存模型还定义了执行上述8种操作的基本规则:

(1)不允许read和load、store和write操作之一单独出现,即不允许出现从主内存读取了而工作内存不接受,或者从工作内存回写了但主内存不接受的情况出现;
(2)不允许一个线程丢弃它最近的assign操作,即变量在工作内存变化了必须把该变化同步回主内存;
(3)不允许一个线程无原因地(即未发生过assign操作)把一个变量从工作内存同步回主内存;
(4)一个新的变量必须在主内存中诞生,不允许工作内存中直接使用一个未被初始化(load或assign)过的变量,换句话说就是对一个变量的use和store操作之前必须执行过load和assign操作;
(5)一个变量同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一个线程执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才能被解锁。
(6)如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load或assign操作初始化变量的值;
(7)如果一个变量没有被lock操作锁定,则不允许对其执行unlock操作,也不允许unlock一个其它线程锁定的变量;
(8)对一个变量执行unlock操作之前,必须先把此变量同步回主内存中,即执行store和write操作;
注意,这里的lock和unlock是实现synchronized的基础,Java并没有把lock和unlock操作直接开放给用户使用,但是却提供了两个更高层次的指令来隐式地使用这两个操作,即moniterenter和moniterexit。

5、Java内存模型三大特性

上面介绍的内存模型同步操作与规则就是围绕原子性、可见性、顺序性三个特性来设计的。

5.1 原子性

原子性是指一个操作是不可中断的。即使是多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程干扰。

i = 0;       //1
j = i ;      //2
i++;         //3
i = j + 1;   //4

上面四个操作,只有1才是原子操作,其余均不是

1在Java中,对基本数据类型的变量和赋值操作都是原子性操作;
2中包含了两个操作:读取i,将i值赋值给j
3中包含了三个操作:读取i值、i + 1 、将+1结果赋值给i;
4中同三一样
注:在32位的JDK环境下,对64位数据的读取不是原子性操作,如long、double

要想在多线程环境下保证原子性,则可以通过锁、synchronized来确保

5.2 可见性

一个线程在工作内存修改了共享内存中变量值,对其他持有该数据的线程不可见。
主要原因:

  • 共享变量更新后,工作内存与主内存未及时更新
  • 重排序结合线程交叉执行
    针对可见性,可通过在变量上增加volatiles、ynchronized和final关键字解决
  • volatile变量在修改之后立即同步回主内存,以及在每次读取前立即从主内存刷新
  • synchronized的可见性,JMM关于synchronized的两条规定:
      1)线程解锁前,必须把共享变量的最新值刷新到主内存中
      2)线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新获取最新的值
  • final的可见性是指被final修饰的字段在构造器中一旦被初始化完成,那么其它线程中就能看见这个final字段了。

5.3 有序性

代码在运行的时候,执行顺序可能并不是严格从上到下执行的,会进行指令重排。根据CPU流水线作业,一般来说简单的操作会先执行,复杂的操作后执行。
指令重排两个规则:

  • as-if-serial:不管怎么重排序,单线程的执行结果不能发生改变。正是由于这个特性,在单线程中,程序员一般无需理会重排序带来的问题。
  • happens-before:先行发生原则,如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序

Java中提供了volatilesynchronized两个关键字来保证有序性

  • volatile天然就具有有序性,通过插入内存屏障禁止重排序。
  • synchronized的有序性是由“一个变量同一时刻只允许一条线程对其进行lock操作”这条规则获取的。

四、总结

1、硬件内存架构使得必须建立内存模型来保证多线程环境下对共享内存访问的正确性;

2、Java内存模型定义了保证多线程环境下共享变量一致性的规则;

3、Java内存模型提供了工作内存与主内存交互的八大同步操作和八大规则;

4、Java内存模型三大特性原子性、可见性、有序性介绍,以及实现方式;

posted @ 2022-11-02 14:16  夕阳醉了  阅读(166)  评论(0编辑  收藏  举报