当前位置: 首页 > 科技观察

从CPU开始,深入理解Java内存模型!_0

时间:2023-03-12 18:59:17 科技观察

Java内存模型,很多人会误理解为JVM的内存模型。但实际上,两者是完全不同的东西。Java内存模型定义了Java语言如何与内存交互,特别是Java语言运行时变量如何与我们的硬件内存交互。JVM内存模型是指JVM内存是如何划分的。Java内存模型是并发编程的基础。只有深入了解Java内存模型,才能避免一些误会。Java中的一些高级特性也是基于Java内存模型的,比如volatile关键字。为了让大家明白Java内存模型存在的意义,本文将从计算机硬件开始,一路写到操作系统和编程语言,引出Java内存存在的意义一一建模,让大家了解Java内存模型。有了更深刻的认识。看完之后,希望你能明白以下几个问题:为什么会有Java内存模型?Java内存模型解决了什么问题?什么是Java内存模型?从CPU说起,我们知道计算机有两个东西,CPU和内存。CPU负责计算,内存负责存储数据。CPU每次计算都需要从内存中获取数据。我们知道CPU的运行速度要比内存快很多,所以会出现CPU等待内存读取数据的情况。由于两者的速度差距太大,为了加快运算速度,计算机的设计者在CPU中加入了CPU缓存。这个CPU缓存的速度介于CPU和内存之间。每次需要读取数据时,都是先从内存中读取到CPU缓存中,然后CPU再从CPU缓存中读取。虽然还是有速度上的差距,但至少没有之前那么大了。增加CPU缓存随着技术的发展,出现了多核CPU,CPU的计算能力进一步提高。原来一次只能运行一个任务,现在可以同时运行多个任务。由于多核CPU的出现,虽然提高了CPU的处理速度,但也带来了一个新的问题:缓存一致性。在多CP??U系统中,每个处理器都有自己的缓存,它们都共享同一个主存,如下图所示。当多个CPU的计算任务都涉及同一个主存区域时,各自的缓存数据可能会不一致。如果发生这种情况,在同步回主内存时,哪个CPU缓存数据将占上风?多核CPU和缓存带来的问题我们举个例子,线程A执行了这样一段代码:i=i+10;线程B执行了这样一段代码:i=i+10;他们的i是存放在内存中共享的,初始值为0。按照我们的假设,最终输出的值应该是20。但实际上也有可能输出10的值。下面是一种可能的情况:线程A是分配给CPU0执行,此时读取i的值为0存入CPU0的缓存中。线程B分配给CPU1执行。此时读出i的值为0,存入CPU1的缓存中。CPU0执行运算,结果为10,运算完成后回写到内存中。此时内存i的值为10,CPU1进行计算,结果为10,计算完成后写回内存。此时内存i的值为10,可以看出导致错误结果的主要原因是两个CPU缓存中的数据相互独立,无法感知彼此的变化。此时,第一个问题就出现了:在硬件层面,由于多CPU的存在和CPU缓存的加入,导致了数据一致性问题。需要注意的是,这个问题是硬件层面的问题。每当使用多个CPU且CPU具有缓存时,就会出现此问题。对于CPU的厂商来说,这个问题是需要解决的,与具体的操作系统或编程语言无关。那么如何解决这个问题呢?答案是:缓存一致性协议。加入缓存一致性协议所谓缓存一致性协议是指CPU缓存与主存交互时遵循特定的规则,从而避免数据一致性问题。在不同的CPU中,使用不同的缓存一致性协议。比如奔腾系列CPU使用的是MESI协议,而AMD系列CPU使用的是MOSEI协议,Intel酷睿i7处理器使用的是MESIF协议。这里我们介绍最常见的一种:MESI数据一致性协议。在MESI协议中,每个缓存可能有4种状态,分别是:M(Modified):这行数据有效,数据已被修改,与内存中的数据不一致,数据只存在于本行缓存。E(Exclusive):这行数据有效,数据与内存中的数据一致,数据只存在于本Cache中。S(Shared):这行数据有效,数据与内存中的数据一致,数据存在于多个缓存中。I(Invalid):这行数据无效。那么在MESI协议的作用下,我们上面的线程执行过程就变成了:线程A被分配给CPU0执行,此时读到的i值为0,存入CPU0的缓存中。线程B分配给CPU1执行。此时读出i的值为0,存入CPU1的缓存中。CPU0执行运算,结果为10,运算完成后回写到内存中。此时内存i的值为10,同时通过消息的方式告诉其他持有i变量的CPU缓存,将这个缓存的状态值设置为Invalid。CPU1进行计算,从CPU缓存中取值,发现缓存值设置为Invalid。于是再次从内存中读取,将10的值放入CPU缓存中。CPU1进行计算,结果为20,计算完成后写回内存。此时内存i的值为20。从上面的例子我们可以知道,MESI缓存一致性协议本质上是定义了一些内存状态,然后通过消息通知其他CPU缓存,从而解决了数据一致性问题。从操作系统说起操作系统,它屏蔽了底层硬件的运行细节,虚拟化了各种硬件资源,方便了上层软件的开发。我们在开发应用软件的时候,不需要直接和硬件交互,只需要和操作系统交互即可。在这种情况下,操作系统需要对硬件进行封装,然后抽象出一些概念,以方便上层应用程序的使用。于是CPU时间片、内核态、用户态等概念也应运而生。前面我们提到CPU和内存之间会存在缓存一致性问题,操作系统抽象出来的CPU和内存也会面临这个问题。因此,操作系统层面也需要解决同样的问题。因此,对于任何一个系统,都需要解决这样一个问题。我们抽象出在特定的操作协议下对特定的内存或缓存进行读写访问的过程,得到的就是内存模型。无论是Windows系统还是Linux系统,都有特定的内存模型。Java语言是建立在操作系统上层的一种高级语言。它只能与操作系统交互,不能与硬件交互。类似于操作系统相对于硬件,操作系统需要对内存模型进行抽象,所以Java语言也需要对相对于操作系统的内存模型进行抽象。一般来说,编程语言也可以在操作系统层面直接复用内存模型,例如C++语言就是这样做的。但是由于不同操作系统的内存模型不同,有可能程序在一个平台上并发访问是完全正常的,而在另一个平台上并发访问却经常出错。因此,在某些场景下,需要针对不同平台编写程序。而我们都知道,Java最大的特点就是“WriteOnce,RunAnywhere”,即一次编译,就可以到处运行。为了达到这样的目标,Java语言必须在各个操作系统的基础上进一步抽象,建立一套对内存或缓存读写访问的抽象标准。这样,不管你在哪个操作系统,只要遵循这个规范,就可以保证并发访问正常。Java内存模型——不同层次的抽象和解决方案Java内存模型之前已经有铺垫。相信大家已经明白了为什么会有Java内存模型,Java内存模型是什么,有了感性的认识。这里我们给出Java内存模型更准确的定义。Java内存模型(JavaMemoryModel,JMM)用于屏蔽各种硬件和操作系统的内存访问差异,使Java程序在各种平台上都能达到一致的内存访问效果。Java内存模型定义了程序中每个变量的访问规则,即在虚拟机中将变量存入内存和从内存中取出变量的底层细节。这里所说的变量包括实例字段、静态字段和构成数组对象的元素,但不包括局部变量和方法参数。因为后者是线程私有的,不会被共享,自然不会出现竞争问题。内存模型的定义Java内存模型规定所有的变量都存储在主存中,每个线程都有自己的工作内存。线程使用的变量的主内存副本存储在线程的工作内存中。线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,不能直接读写。多变的。不同的线程不能直接访问彼此工作内存中的变量,线程间变量值的传递需要通过主内存来完成。主内存、工作内存和线程之间的关系如下图所示。Java内存模型图Java内存模型的主内存、工作内存和JVM的堆、栈、方法区不是同一个内存划分层次,两者没有关联。如果一定要对应,那么主内存主要对应Java堆中对象实例的数据部分,工作内存对应虚拟机栈中的一些区域。内存之间的交互??关于主内存和工作内存之间的具体交互协议,即变量如何从主内存复制到工作内存,如何从工作内存同步回主内存的细节,Java内存模型定义了8个操作来完成。虚拟机在实现时,必须保证下面提到的每一个操作都是原子的、不可分割的。锁:作用于主存的变量,标识一个变量为线程独占。解锁(unlock):作用于主存的变量,它释放一个处于锁定状态的变量,释放的变量可以被其他线程锁定。读(read):作用于主存的变量,它将变量的值从主存传送到线程的工作内存,以供后续的加载动作。加载(load):作用于工作内存的变量,它将通过读操作从主内存中获取的变量值放入工作内存的变量副本中。use(使用):作用于工作记忆的变量。它将工作内存中的变量值传递给执行引擎。每当虚拟机遇到需要使用变量值的字节码指令时,就会执行这个操作。Assign(赋值):作用于工作内存的变量,将从执行引擎接收到的一个值赋值给工作内存的变量,每当虚拟机遇到为该变量赋值的字节码指令时执行此操作.store(存储):作用于工作内存的变量,它将工作内存中的一个变量的值传送到主存中,以供后续的写操作。写入(write):作用于主存的变量,它将store操作从工作内存中得到的变量值放入主存的变量中。如果要将一个变量从主内存复制到工作内存,需要依次执行读取和加载操作。如果要将变量从工作内存同步回主内存,需要依次执行存储和写入操作。注意,Java内存模型只是要求上面两个操作必须按顺序执行,并不能保证一定会按顺序执行。换句话说,可以在读取和加载之间以及存储和写入之间插入其他指令。例如,当访问主存中的变量a和b时,一个可能的顺序是读取a,读取b,加载b,加载a。另外,Java内存模型还规定了以上8个基本操作必须满足以下规则:read和load、store和write操作之一不允许单独出现,即不允许读取一个变量来自主内存但不被工作内存接受。或者从工作内存发起写回,但主内存不接受它。不允许线程丢弃其最近的分配操作,即工作内存中已更改的变量必须将更改同步回主内存。不允许一个线程无缘无故(没有发生assign操作)将数据从线程的工作内存同步回主内存。一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未初始化(加载或赋值)的变量。也就是说,在对一个变量实现use和store操作之前,必须先进行assign和load操作。一个变量在同一时刻只能被一个线程加锁,但是加锁操作可以被同一个线程多次执行。多次加锁操作后,只有执行相同次数的解锁操作,变量才会被解锁。如果对一个变量进行了锁操作,工作内存中这个变量的值就会被清空。在执行引擎使用这个变量之前,需要重新执行加载或赋值操作来初始化变量的值。如果一个变量之前没有被锁操作锁定过,则不允许对其执行解锁操作,也不允许解锁被另一个线程锁定的变量。在对变量执行解锁操作之前,必须将该变量同步回主存(执行存储、写入操作)。这8种内存访问操作和上面提到的规则限制,连同后面介绍的一些关于volatile的特殊规定,已经完全决定了Java程序中哪些内存访问操作在并发下是安全的。总结一下这篇文章,我们从底层的CPU开始,一直到操作系统,最后讲到编程语言层面,让大家一一了解,最终理解Java内存模型诞生的原因(上层有数据一致性问题),最终要解决的问题(缓存一致性问题)。看到这里,我们已经大致解释了为什么会有Java内存模型,也知道了Java内存模型是什么。最后做一个总结:由于多核CPU和缓存的存在,导致了缓存一致性问题。这个问题属于硬件层面的问题,解决方案是各种缓存一致性协议。不同的CPU使用不同的协议,MESI是最经典的缓存一致性协议。操作系统作为底层硬件的抽象,自然需要解决CPU缓存与内存之间的缓存一致性问题。各个操作系统抽象出CPU缓存和高速缓存的读写访问过程,最终归结为“内存模型”。Java语言作为一种运行在操作系统层面的高级语言,为了解决多平台运行的问题,在操作系统的基础上进一步抽象,得到Java语言层面的内存模型。Java内存模型分为工作内存和主内存,每个线程都有自己的工作内存。每个线程不能直接与主内存交互,只能与工作内存交互。另外,为了保证并发编程下的数据准确性,Java内存模型还定义了8个基本原子操作和8个基本规则。如果一个Java程序能够遵守Java内存模型的规则,那么它写出的程序是并发安全的,这就是Java内存模型的最大价值所在。深入理解Java内存模型参考Java内存模型原理,你真的看懂了吗?Java并发编程实战-Gates等-微信阅读Java高并发编程详解:深入理解并发核心库-王文军-微信阅读操作系统对CPU的控制|王辉的博客操作系统:三篇轻松自CPU有了缓存一致性协议(MESI),为什么JMM需要volatile关键字?-罗一鑫的回答-知乎