深入理解 Java 内存模型

用户头像
独钓寒江雪
关注
发布于: 2020 年 07 月 09 日
深入理解Java内存模型

多线程程序要比单线程程序复杂的多,单线程程序中,线程从内存中读取一个变量,如果这个变量的值本身就是1,那么线程读取到的值必然是1。但是在多线程程序中,如果多线程对变量的读写没有进行合理的控制,那么后续线程读取到的变量的值很可能是2,甚至是3等。所以有必要定义一种或多种规则,保证多线程下内存数据的一致性和准确性,Java内存模型(Java Memory Model,JMM)由此诞生。



在讨论Java内存模型之前,这里先一起聊聊CPU、高速缓存以及主内存,在了解这些知识后,对理解Java内存模型会有很大的帮助。

一、CPU、高速缓存以及主内存

中央处理器(Central Processing Unit,CPU)作为计算机系统的运算和控制核心,是信息处理、程序运行的最终执行单元,具有非常高的处理速度。CPU的执行速度相较于其他的操作如网络IO、内存数据读写等等,往往高出几个数量级,因此有必要解决CPU与其他物理硬件的速度差异性,否则在短时间内CPU将有可能进入到等待阶段,严重浪费了CPU资源。CPU高速缓存的出现就是为了解决CPU和内存之间的巨大速度差异性问题。CPU高速缓存与CPU以及主内存之间的关系图如下所示:

图1-1 CPU、高速缓存与主内存的关系

CPU主要是处理热点数据,如果热点数据总是需要从主存中去读取,这必将存在严重的性能损耗,CPU高速缓存的存在大大改善了这种不必要的损耗,高速缓存存储了热点数据,这种数据可以从两个方面来理解:

  • 时间局部性:CPU在某个时刻访问了主存中的某个数据,那么这个数据在往后的某个时刻很可能被再次访问。

  • 空间局部性:CPU在某个时刻访问了主存中的某个数据,那么这个数据的周边数据很可能在未来很短的时间内被访问。



CPU高速缓存是如何缓存热点数据的呢?这里需要简单介绍一下。CPU从硬件方面来说由密集晶体管组成,从功能考虑主要是由寄存器,控制器,运算器和时钟四部分构成,四个部分通过电流信号进行通信。这里主要来讲解一下寄存器,寄存器用来存储CPU核心需要处理的指令和数据等,可以理解为CPU中的内存区域。CPU核心对不直接对高速缓存和主存中的数据进行处理,而是处理寄存器中的数据,寄存器中的数据的直接来源是来自高速缓存,间接来源于主存。CPU需要处理某个数据的时候,首先将数据从主存中读取到高速缓存,再从高速缓存中读取到寄存器中,最后操作寄存器中的数据,操作完成之后,写回高速缓存再写回主存中。



在单核CPU中,这种设计是没有问题,只要保证高速缓存能被及时写回主存中,那么就可以保证寄存器、高速缓存、主存中的数据一致性问题,但是如果在多核CPU中,每个CPU核心都存在各自的高速缓存,这就可能存在缓存不一致问题。



在下面的CPU缓存结构图中,该CPU有四个核心,每个CPU核心具有一级缓存和二级缓存,这两种缓存属于核心的私有缓存,多个核心之间不能互相访问其私有缓存。三级缓存则是所有核心共享的缓存,是各个CPU核心二级缓存的直接来源。

图1-2 CPU缓存结构图

CPU在处理数据的时候,直接处理的是寄存器中的数据,寄存器中的数据来源于一级缓存,一级缓存的数据来源于二级缓存,二级缓存的数据来源于主存,各级缓存的速度由高到低排序依次为:寄存器、一级缓存、二级缓存、三级缓存、主存。CPU就是通过这种多级缓存的方式解决了速度差异性问题,但是在多核CPU中尚未解决多核CPU对共享数据的处理而存在的数据不一致性问题,为了解决这个问题,伊利诺斯州立大学提出了著名的MESI(Modified Exclusive Shared or Invalid)协议,是一种广泛应用于支持写回策略的缓存一致性协议。



在介绍MESI协议之前,先来探讨一下缓存写回。缓存写回是指CPU处理后的数据通过缓存写回到主存中,写回的方式通常包括两种:Write Through和Write Back。



  • Write Through是指CPU将更新后的数据从寄存器写回到缓存后立马同步更新到主存中,这种方式在在多核CPU环境中,依赖总线事务来保证强一致性,因此在效率上会有一定的折扣。

  • Write Back是指CPU将更新后的数据从寄存器写回到缓存后,并不会立即更新到主存中,而是在等到某个合适的时机后才写回到主存中。这种方式效率高,但是缺点也很明显,一旦出现系统掉电,缓存中的数据将会丢失。



两种缓存写回方式,无论是哪一种,都需要处理多线程环境下缓存不一致问题。为了保证缓存的一致性,处理器提供写失效(write invalidate)和写更新(write update)两种策略来保证缓存一致。



  • 写失效是指CPU核心处理其私有缓存的某个数据之后,会通知所有其它CPU核心的缓存中的这一数据在它们中的副本失效。这样就可以避免其它“过时”的副本被使用而造成数据异常。

  • 写更新是指某个CPU核心更新其私有缓存中的某个数据时,它把所更新的数据发送给所有的其它CPU核心的缓存,此举用来更新这一数据在其它CPU核心缓存中的副本。



比较两种策略,一般来说,使用写更新策略,需要传输更新后的数据,而写失效只需传输写失效信息,因此写更新传输的数据量比写失效要大,而且,被更新的数据的某些副本以后也不一定再被使用,综合考虑来说,基于写失效MESI协议是保持缓存一致性比较好的途径。



CPU核心操作缓存都是基于缓存行的,一个缓存行可以存储多个变量(存满当前缓存行的字节数,目前主流CPU缓存的缓存行大小都是64字节),而CPU对缓存的修改又是以缓存行为最小单位的,缓存行的基本结构图如下所示:

MESI协议描述了CPU缓存中缓存行的四种状态,且这四种状态之间可以进行相互转换。对四种状态的详细描述如下:



  • Modified:被修改的

表示被修改过的缓存行,CPU缓存行中的数据已经被修改过,即与主内存中的数据存在差异,该修改过的缓存行中的内存数据将在未来的某个合适的时间点写回到主内存中,在写回之前,主内存中的数据是允许被其他CPU直接读取或者缓存到对应的缓存行中。一旦写回到主内存,那么该CPU对应的缓存行的状态就变更为独享(Exclusive)状态。



  • Exclusive:独享的

该缓存行只被缓存在该CPU的缓存中,它是未被修改过的,与主存中数据一致。该状态可以在任何时刻当有其它CPU读取该内存时变成共享(Shared)状态。同样地,当CPU修改该缓存行中内容时,该状态可以变成Modified状态。



  • Shared:共享的

该状态意味着该缓存行可能被多个CPU缓存,并且各个缓存中的数据与主存数据一致,当有一个CPU修改该缓存行中数据,其它CPU中该缓存行可以被作废,变成无效状态,也就是变成Invalid状态。



  • Invalid:无效的

CPU缓存的缓存行是无效的,可能是因为其他CPU修改了共享缓存行的数据。



缓存行中的数据状态有以上四种,引起缓存行中数据的变化的操作也有四种,分别是:

在一个典型系统中,可能会有几个缓存(在多核系统中,每个核心都会有自己的缓存)共享主存总线,每个相应的CPU会发出读写请求,而缓存的目的是为了减少CPU读写共享主存的次数。



一个缓存除在Invalid状态外都可以满足CPU的读请求,一个Invalid的缓存行必须从主存中读取(变成S或者E状态)来满足该CPU的读请求。



一个写请求只有在该缓存行是M或者E状态时才能被执行,如果缓存行处于S状态,必须先将其它缓存中该缓存行变成Invalid状态(也就是不允许不同CPU同时修改同一缓存行,即使修改该缓存行中不同位置的数据也不允许)。该操作经常作用广播的方式来完成,例如:RequestFor Ownership(RFO)。



缓存可以随时将一个非M状态的缓存行作废,或者变成Invalid状态,而一个M状态的缓存行必须先被写回主存。



一个处于M状态的缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S状态之前被延迟执行。



一个处于S状态的缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。



一个处于E状态的缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S状态。



对于M和E状态而言总是精确的,他们在和该缓存行的真正状态是一致的。而S状态可能是非一致的,如果一个缓存将处于S状态的缓存行作废了,而另一个缓存实际上可能已经独享了该缓存行,但是该缓存却不会将该缓存行升迁为E状态,这是因为其它缓存不会广播他们作废掉该缓存行的通知,同样由于缓存并没有保存该缓存行的copy的数量,因此(即使有这种通知)也没有办法确定自己是否已经独享了该缓存行。



从上面的意义看来E状态是一种投机性的优化:如果一个CPU想修改一个处于S状态的缓存行,总线事务需要将所有该缓存行的copy变成Invalid状态,而修改E状态的缓存不需要使用总线事务。



遵循MESI协议的CPU高级缓存与CPU以及主内存之间的关系图如下所示:

图1-3 CPU、高速缓存(MESI)与主内存的关系

二、Java内存模型

2.1 定义

Java内存模型(Java Memory Model,JMM)是为Java虚拟机定义的一套规范,旨在屏蔽在不同平台中对物理内存访问的差异性,使得Java程序可以在不同平台上都能达到内存访问的一致性,这也是Java程序跨平台的重要特点。



Java内存模型主要定义的是程序中对共享变量的访问规则,即Java虚拟机对主存上的共享变量的访问规则。需要注意的是这里的变量跟我们写Java程序中的变量不是完全等同的。这里的变量是指实例字段,静态字段,构成数组对象的元素,但是不包括局部变量和方法参数(因为这是线程私有的)。这里可以简单的认为主内存是Java虚拟机内存区域中的堆,局部变量和方法参数是在虚拟机栈中定义的。但是在堆中的变量如果在多线程中都使用,就涉及到了堆和不同虚拟机栈中变量的值的一致性问题了。

图2-1 Java内存分配栈、堆模型

上图中,Java内存分配仅仅展示了线程栈和堆内存。堆内存是运行时动态分配对象的存储空间,用于存放对象的成员变量等,对象的成员方法存放在方法区中(这里对方法区不进行讨论),由于堆内存是在运行时进行动态内存划分,所以堆内存的访问效率没有栈高。而线程栈中存储的是基本数据类型以及复杂数据类型的句柄(引用),由于线程栈在整个运行时的生命周期是完全确定的,所以它是缺乏一定的灵活性,但是其拥有较高的访问效率,仅次于计算机的寄存器。通常通过Java代码new语句创建出来的对象都是存储在堆内存中的,当线程栈通过对象的句柄来访问对象的成员变量的时候,都会对对象的成员变量进行私有拷贝,然后对私有拷贝数据进行读写。 由于堆内存在多线程环境是共享的,如果多个线程同时访问同一个对象的成员变量且没有对对象的变量进行任何的控制,那么就很有可能出现数据不一致的现象。

2.2 Java内存模型的抽象

Java内存模型其实就是模仿了真实物理CPU与主内存交互模型,是对物理CPU与主内存的交互进行软件层面的模拟与抽象,Java内存模型的抽象结构图如下所示:

图2-2 Java内存模型抽象图

这里对Java内存模型的抽象图进行说明:



线程间的共享变量存储在主内存中,每个线程还拥有一个本地内存(工作内存),这个本地内存是一个抽象概念,并不是真实存在的内存,它是对CPU高速缓存,寄存器,以及其他的物理硬件的一个优化,它存储了线程读取主内存中的共享变量的副本,线程对共享变量的操作是基于这个副本的。这里提一个重要的概念,线程间的通信不是通过本地内存,而是通过主内存的,例如,线程1将处理完的数据从本地内存中写回到主内存中,线程2在读取共享变量的时候是将线程1写回到主内存的共享变量读取到自己的本地内存中,这就完成了线程间的通信。在并发场景中,假如有一个计数的需求,那么如果主内存中的共享变量是1,线程1和线程2同时对共享变量进行读取操作,那么两个线程中本地内存中的共享变量副本的值均为1,由于线程间的通信不是通过本地内存的,所以线程间的共享变量的副本是不可见的,在经过各自的加1操作后,线程1和线程2都将本地内存中的数据写回到主内存,那么就出现了计数不准确的问题。在多线程环境中,如果不对共享变量的访问进行合理的控制,那么有很大可能性会引发数据的异常。



要深入理解JMM,那么首先必须掌握几个与内存模型相关的概念,JMM的主要技术点也是围绕了这几个概念来展开的,这些基本概念分别是:多线程的原子性、可见性和有序性。理解了这三个基本概念,那么就可以通过八种同步操作来保证多线程环境下共享变量访问的一致性。

2.3 原子性、可见性和有序性

从上面的内容就可以了解到,JMM就是为了控制多线程环境下共享变量访问一致性问题而提出的一种规范。JMM存在的目的就是为了解决由于多线程通过共享内存操作共享数据时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的一系列并发问题,而它最终目的是保证并发场景中各种操作的原子性、可见性和有序性。



原子性是指一个操作是不可中断的,即使多个线程同时执行时,一个操作一旦开始,就不会受到其他线程的干扰,直到操作完成。举个例子,有一个全局变量i的初始值是0,线程A对其赋值为1,线程B对其赋值为-1,那么第三个线程C在赋值结束后读取i的值要么是1要么是-1。线程A和B之间是没有任何干扰的,没有因为赋值不同而产生异常,且不可中断,这是原子性的一个特点。原子性是通用的,但是也有例外,在32位系统中,long类型的数据的多线程读写不是原子性的,这是因为long型数据有64位,多线程之间的读写是有干扰的。对于这个特殊的案例,资料很多,有兴趣的读者可以自行了解。



在Java中,是通过synchronized关键字来保证原子性的,它的基本实现原理是在字节码层面提供了两个字节码指令monitorenter和monitorexit,在JVM中解释执行字节码的时候,遇到这两个关键字,只允许一个线程去操作两者之间的字节码,其他的线程要进行等待,也就是说需要等待当前线程释放对象锁后其他线程才可以继续执行。因此,为了保证原子性,可以使用Java中的synchronized关键字,这是一个比较方便的方式来保证方法和代码块内的操作是原子性的。



可见性是指某个线程对共享变量的任何操作,其他线程是可见的,也就是说其他线程读取到的数据一直都是最新的。主要的实现原理是通过在变量修改后将新值同步写回主内存,其他线程在操作变量前去主存读取刷新变量值的方式来实现的。Java中提供了volatile关键字来实现这一功能,被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新。因此,可以使用volatile来保证多线程操作时变量的可见性。当然除了volatile,Java中的synchronized和final两个关键字也可以实现可见性。



有序性是指程序执行的顺序按照代码的先后顺序执行,在Java中,可以使用synchronized和volatile来保证多线程之间操作的有序性。实现方式有所区别:volatile关键字会禁止指令重排。synchronized关键字保证同一时刻只允许一条线程操作。



了解更多干货,欢迎关注我的微信公众号:爪哇论剑(微信号:itlemon)



发布于: 2020 年 07 月 09 日 阅读数: 78
用户头像

独钓寒江雪

关注

还未添加个人签名 2018.09.30 加入

Java码农一枚。

评论

发布
暂无评论
深入理解Java内存模型