前言
其实关于并发、多线程这方面的文章,我已经写过了一些:
- 《当我们说起多线程与高并发时》
- 《Java多线程学习笔记(一) 初遇篇》
- 《Java多线程学习笔记(二) 相识篇》
- 《Java多线程学习笔记(三) 甚欢篇》
- 《Java多线程学习笔记(五) 长乐无极篇》
- 《Java多线程学习笔记(六) 长乐未央篇》
- 《Java多线程编程范式(一) 协作范式》
《当我们说起多线程与高并发时》是总领全篇,我们从操作系统的发展讲起,为什么要有线程这个概念出现。《Java多线程学习笔记(一) 初遇篇》讲Java平台下的线程,如何使用和创建,以及引入线程后所面临的问题,为了解决线程安全问题,Java引入的机制,这也是《Java多线程学习笔记(二) 相识篇》讨论的问题,《Java多线程学习笔记(三) 甚欢篇》是讲线程协作,即如何让线程之间协作去处理任务,《Java多线程学习笔记(五) 长乐无极篇》讲了CompletableFuture,这个强大的异步编排组件,《Java多线程学习笔记(六) 长乐未央篇》 讲ForkJoin模式,《Java多线程编程范式(一) 协作范式》 讲使用Java提供并发核心库来解决一些问题。但是在《Java多线程学习笔记(一) 初遇篇》我们的讨论相对还比较粗糙,当时我的想法是先基本搭建一个模型来快速的熟悉Java的并发编程,在实践中先用起来,我们没有直接讨论线程安全,什么是线程安全,这个问题在当时的我去看,没有找到一个很完美的定义,还有并发模型,并发是难以验证的,那我们该如何验证,我们将统一收拢,统一回答这些问题。
从指令集架构谈起
单看指令集架构来说,这是一个有些相对陌生的名词,让我们从生活中稍微常见的事物讲起,也就是苹果电脑Mac,很多程序员都喜欢Mac,Mac中现在比较热的一款是Mac m1、m2了,喜欢苹果的人,对m1和m2相当喜欢,这里说的m1和m2也就是CPU的代称,这两款CPU的指令集架构是ARM,那什么是指令集架构?在回答这个问题的时候,我们还是要请出《程序是如何运行的(一)》这篇文章的图:
从这幅图我们可以看到指令集架构是硬件系统和软件的桥梁,连接了硬件和软件。那他是什么呢?
An Instruction Set Architecture (ISA) is part of the abstract model of a computer that defines how the CPU is controlled by the software. The ISA acts as an interface between the hardware and the software, specifying both what the processor is capable of doing as well as how it gets done.
指令集架构是计算机抽象模型的一部分定义了CPU如何被软件控制。指令集充当硬件和软件之间的接合点,规定了处理器能够做什么,以及如何完成。
The ISA provides the only way through which a user is able to interact with the hardware. It can be viewed as a programmer’s manual because it’s the portion of the machine that’s visible to the assembly language programmer, the compiler writer, and the application programmer.
指令集是用户和计算机之间进行交互的唯一途径。它可以被看做是程序员的手册,因为它是程序员(汇编语言程序员,编译器开发者、应用程序程序员) 可以看到的机器部分。
The ISA defines the supported data types, the registers, how the hardware manages main memory, key features (such as virtual memory), which instructions a microprocessor can execute, and the input/output model of multiple ISA implementations. The ISA can be extended by adding instructions or other capabilities, or by adding support for larger addresses and data values.
指令集架构定义了数据类型、寄存器、硬件如何管理主存,关键特性(如虚拟内存),微处理器可以执行哪些指令,输入输出模型。指令集架构可以通过增加指令、其他功能、增加对更大地址和数据值的支持来进行扩展。
--ARM官网
这里提到了数据类型,我想起学Java的时候,老师谈到数据类型的时候说,你家附近开了一家水果店,卖西瓜几乎不要钱,大家都拿东西去买,有的人拿大袋子,有的人拿小袋子,你会拿什么去装,因为我非常爱吃西瓜,所以我说的是我会开辆车去装,几乎不要钱嘛。其实老师讲这个例子,只是想引出数据类型是数据的容器。我又想起小的时候吃饭,饭量小的用小碗,饭量大的用大碗。
那设计一个编译器来说,一般的思路就是先引入对应的数据类型,那数据类型看来还是来自于指令集架构的支持,已知Java是跨平台的,这个平台我们可以理解为指令集架构,从Github上OpenJDK的源码可以看出:
iKCHLN.jpeg
那么Java一共支持了七种指令集架构:aarch64、arm、ppc、riscv、s390、x86、zero。 这里简单的介绍一下指令系统,随着技术的进步,计算机的形态产生了巨大的变化,从巨型机到小型机到个人电脑,再到智能手机,其基础元件从电子管到晶体管再到超大规模的集成电路。虽然计算机的形态和应用组合千变万化,但从用户感知的应用软件到最底层的物理载体,计算机系统均呈现出层次的结构。下图展示了这些层次:
从上到下,计算机系统可分为四个层次,分别为应用软件、基础软件、硬件电路和物理载体。软件以指令形式运行在CPU硬件上,而指令系统介于软件和硬件之间,是软硬件交互的接口(这里引用的是《计算机体系结构基础》原文用的是界面,我想是用错了词,应当理解为接口更为得体),有着非常关键的作用。软硬件本身更迭速度很快,而指令系统则可以保持很长时间的稳定。有了稳定不变的指令系统接口,软件与硬件得到有效的隔离,并行发展。遵循同一指令系统的硬件可以运行为该指令系统设计的各种软件,比如X86计算机既可以运行最新软件,也可以运行30年前的软件(这一句话也引用自《计算机体系结构基础》,但是我的理解倒是与此不一致,X86计算机可以运行30年前的软件,得益于两方面,一方面是指令系统的向前兼容,另一部分则源于操作系统的向前兼容)。依据指令长度的不同,指令系统可分为复杂指令系统(Complex Instruction Set Computer,简称为CISC) ,精简指令系统(Reduced Instruction Set Computer, 简称RISC) 和 超长指令字(Very Long Instruction Word, 简称VLIW) 指令集三种。早期的CPU都采用CISC结构,这与当时的时代特点有关,早期的处理设备昂贵且处理速度缓慢,设计者不得不加入越来越多的复杂指令来提高执行效率,部分复杂指令甚至可与高级语言中的操作直接对应。这种设计简化了软件和编译器的设计,但也显著提高了硬件的复杂性。
随着硬件的发展,CISC结构出现了一系列问题。大量复杂指令在实际中很少用到,典型程序所使用的的百分之八十指令只占到指令集总指数的百分之二十,消耗大量精力的复杂涉及只有很少的回报。针对CISC结构的缺点,RISC遵循简化的核心思路,RISC简化了指令功能。上面的ARM 指令集架构就是RISC架构,ARMv8-A引入了64位架构,我们称之为Aarch64,在目前来看它们都指同一事物,也就是公版64位ARMv8以后的所有64位ARM架构。那X86是RISC还是CISC,以前我认为是CISC架构,但是我在《计算机体系结构基础》中看到这么一句话:
X86处理器中将CISC指令译码为类RISC的内部操作,然后对这些内部操作使用诸如超流水、乱序执行,多发射等高效实现手段。
所以X86指令集架构这么一看,也不能完全算作CISC架构,CISC和RISC有点走向融合的感觉。PPC 是Power PC的缩写,基于RISC。RISC-V从名字就可以看出基于RISC。S390没查到资料,Zero没有指令系统(没有指令系统引自维基百科,对这个我也缺乏认知,),资料太少,不知道是怎么运作的。
那么不同的指令集支持的指令就是不同的,而JVM是一个跨平台的虚拟机,Java是一个跨平台的语言,也就意味着Java要保证在适配的平台上,行为一致。
CPU 内存模型
浅谈模型
CPU这个词我们认识,内存这个词我们也认识,那模型呢,当我们说起模型这个词的时候,我们到底在说什么?当我说起模型这个词的时候,我想到的是预测,我想起的是预测,抽象出运行规律,根据运行规律来进行预测,这让我想起高中生物教材的K值曲线:
[ ]
这事实上是一种数学模型,那什么是数学模型?一般地说,数学模型可以描述为,对于现实世界的特定对象,为了一个特定目的,根据特有的内在规律,作出一些必要的简化建设,运用适当的数学工具,得到的一个数学结构。其实结构这个词,如果对我文章有些熟悉的话,其实这个词已经讨论过很多遍了, 这里再讨论一下:
the arrangement of and relations between the parts or elements of something complex.
复杂事物的各个部分或要素之间的安排和联系。
上面的种群增长数学模型,描述的就是在资源和空间有限,天敌的制约等(即存在环境阻力)的情况下,时间与种群数量之间的关系。数学模型最终是为了得到一个关系,那内存模型呢?数学模型和内存模型都是模型,那该怎么理解模型这个词呢?
模型是指对于某个实际问题或客观事物、规律进行抽象后的一种形式化表达方式。
那么CPU内存模型就是对某个实际的读写问题进行抽象后的一种形式化表达方式,那究竟是遇到了怎样的问题呢? 让我们从冯诺依曼计算机模型讲起,冯诺依曼计算机模型是一种将程序指令存储器和数据存储器并在一起的计算机设计概念结构。根据冯诺依曼结构设计出来的计算机我们称作冯诺依曼计算机,又称存储程序计算机。计算机在运行指令的时候,会从存储器中一条条将指令取出,通过译码(控制器),从存储器中取出数据,然后进行指定的运算和逻辑等操作,然后再按地址把运算结果返回内存中取。接下来,再取出下一条指令,在控制器模块中按照规定查找。依次进行下去。直至遇到停止指令。程序与数据一样存储,按照程序编排的顺序,一步一步地取出指令按规定操作,自动地完成指令规定的操作是计算机最基本的工作模型。下面这张图是Intel系统的硬件结构:
一般我们看CPU的性能,一般都是看主频,主频也被称之为时钟速度,那什么是时钟速度:
CPU 每秒要处理来自不同程序的众多指令(如算术等低级计算)。时钟速度则测量 CPU 每秒执行的周期数,以 GHz(千兆赫)为单位。从技术上讲,“周期”是由内部振荡器同步的脉冲,但就我们的目的而言,它们是帮助理解 CPU 速度的基本单位。在每个周期中,处理器内数十亿个晶体管会打开和关闭。时钟速度为 3.2 GHz 的 CPU 每秒执行 32 亿个周期。(较早的 CPU 的速度以兆赫计算,或每秒几百万个周期。有时,多个指令可在一个时钟周期内完成;而在其他情况下,一条指令可能需要多个时钟周期来处理。由于不同的 CPU 设计处理指令的方式不同,所以最好比较同一品牌和同一代 CPU 的时钟速度。
例如,5 年前时钟速度更高的 CPU,其性能可能还不如时钟速度更低的新 CPU,因为新架构可以更高效地处理指令。英特尔® X 系列处理器的性能可能优于时钟速度更高的 K 系列处理器,因为它可以在更多的内核之间分配任务,并具有更大的 CPU 缓存。但是,在同一代 CPU 中,在许多应用方面,时钟速度较高的处理器通常优于时钟速度较低的处理器。因此,请务必对同一品牌和同一代系的处理器进行比较。
---Intel官网, 参看参考文档[13]
我这里来补充介绍一下,时钟周期是计算机中最基本的、最小的时间单位,在一个时钟周期内,CPU仅完成一个最基本的操作。我笔记本的CPU主频为2.3GHZ,那么一秒之内,我的CPU就可以执行23亿基本操作。按照一般的推理来说,提高CPU的运算性能,我们在提升架构性能的同时,提升主频就可以了,也就是一边研究更高效的处理指令,一边研究怎么在高效的架构更加快速的提升时钟速度。但是遗憾的是,我们并不能主频不能无限制的被提升, 原因在于主频提高过了一个拐点之后,功耗会爆炸增长,提高主频这条路走不通,那就再加一个处理器,这也就是多核处理器。
多核处理器和并发任务的出现
引入了多核处理器之后,可以继续提升CPU的性能了,但是又引入了新的问题,这没办法,在计算机世界里面,没有银弹,能够应付一切情况。在多核CPU情况下,计算机的内存结构可以被下图表示:
iLLr1A.jpeg
数据从主内存一级一级的加载到CPU,完成指令之后,再将计算结果写入对应的内存地址,其实这里还漏了磁盘,我们姑且忽略。早期的计算机是独占式的,
像上面的图片一样,一个程序写好了放在纸带上,被读取执行,但是随着硬件的高度集成化发展,计算机变得可以同时执行多个进程,这某种程度是一种并发,比如我现在写文章用typora写,一边用浏览器打开b站听歌曲,浏览器和typora事实上是两个进程,但给我的感觉就是计算机同时在接收我敲击键盘的指令,一边在驱动我的音响播放音乐,这一切都源于CPU强大的计算速度,这对于用户来说是无感知的,想起我之前写的文章《当我们说起多线程与高并发时》:
计算机用户通常认为操作系统能够同时做很多事情是无比正常的事情,因为他们通常会在使用办公软件处理文字的时候,其他程序在下载文件,管理打印,处理音频。甚至是一个应用程序也是希望同时能够做不止一件事。例如,一个音频处理程序必须同时从网络上读取音频,然后解压缩,管理播放,更新进度(这是现在很稀松平常的事情,也就是在线听歌)。不管是文字处理软件有多忙,它也总是在时刻响应键盘和鼠标。能够同时做不止一件事情的软件,我们称之为并发软件。
上面的并发强调的是同时做不止一件事情,这是一种操作系统提供给程序的假象,事实上他们可能是交替执行的(并发),当然也可能是同时执行的(并行),这取决于当前计算机系统的基本配置和忙碌程度有关。假设当前计算机不是很忙碌,也就是说运行的程序并不多,又假设CPU很强大,进程的两个动作(现代操作系统来说一般是线程,现代操作系统调度的基本单位就是线程),就可能会被分配到这两个核心上同时执行。如果此时当前计算机系统相对来说处于一种比较忙碌的状态,那么他们就只能排队执行,交替执行。
我也想起我之前的某一位Java老师,认为没有多线程现在的操作系统只能顺序执行程序,计算机只能执行一个进程,这种看法是只站在一个Java这一种语言来考虑问题,存在一定的认知谬误,我想原因大概在于大概在于Java为人熟知最多的就是多线程API,这常常给人一种错觉。其实Java也提供了创建进程的API:
private static void createProcess() throws IOException { Runtime runTime = Runtime.getRuntime(); // 在单独的进程中执行传入的命令 runTime.exec(""); ProcessBuilder processBuilder = new ProcessBuilder(); // 开启一个进程 processBuilder.start(); }
进程和线程都是操作系统提供的概念,操作系统引入进程,是为了并发的执行程序,引入线程则是为了为了更好的共享资源、节省资源。那么多核碰见并发执行程序就擦出了火花,当多个处理器的运算任务涉及同一块主内存区域时,将可能导致各自的缓存数据不一致的情况,为了解决这个问题,就需要制定规则,这也就是缓存一致性协议,这类的协议有MSI、MESI、MOSI等等。
MESI协议简介
当CPU写数据时,如果发现操作的变量是共享变量,即在其他COU也存在该变量的副本,会发出信号通知其他CPU将变量的缓存行设置为无效状态,因为当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存中重新获取。缓存行的中具体的几种状态如下:
iLPvaZ.jpeg
我们现在举个例子来说明体会一下缓存一致性协议,为了讨论问题方便,我们现在的处理器只有两核心,也就是两个CPU, 现在主内存有一个变量x = 1,MESI的工作流程为:
- 假设CPU1需要读取x的值,此时CPU1从主内存中读取到缓存行后的状态为E,代表只有当前数据中独占数据,并利用CPU嗅探机制监听总线中是否有其他缓存读取x的操作。
- 此时如果CPU2也需要读取x的值到缓存行,则CPU2中缓存行的状态为S,表示多个缓存中共享,同时CPU1由于嗅探到CPU2也缓存了x,所以状态也变成了S。并且CPU1和CPU2会同时嗅探是否有令缓存失效获取独占的操作。
- 当CPU1有写入操作需要修改x的值时,CPU1中缓存行的状态就变成了M。
- CPU2由于嗅探到了CPU1的修改操作,则会将CPU2中缓存的状态变为I无效状态。
- 此时CPU1中缓存行的状态重新变回独占E的状态,CPU2要想读取x的值的话需要重新从主内存中读取。
状态变化图如下:
iLTWzw.jpeg
写缓冲器与无效化队列
MESI协议解决了缓存一致性问题,但是其自身也存在一个性能弱点-处理器执行写内存操作时,必须等待其他所有处理器将其高速中相应副本数据删除接收到这些处理器所回复的消息之后,才能将数据写入高速缓存。为了规避和减少这种等待造成的写操作的延迟,硬件设计者引入了写缓冲器和无效化队列。写缓冲器是处理器内部的一个容量比高速缓存还小的部件,每个处理器都有其写缓冲器,一个处理器无法读取另外一个处理器上的写缓冲器中的内容。
引入写缓冲器之后,处理器在执行写操作的时候会做这样的处理: 如果相应的缓存行状态为E或者M,那么处理器可能会直接将数据写入相应的缓存行而无需发送任何消息,具体的行为取决于具体处理器的实现。x86处理器不管相应的缓存条目状态如何,直接先将每一个写操作的结果都存入写缓冲器。如果相应的缓存条目状态为S,那么处理器会将写操作的相关数据存入写缓冲器的条目之中,并发送无效消息;如果相应缓存条目的状态为I,我们称相应的操作遇到了写未命中,那么此时处理器会将写操作相关的数据存入写缓冲器中,并向主内存发送请求读取消息,read消息可能导致内存读操作,注意是可能,不同的处理器可能有不同的操作,我们在这里不做赘述,相关的参考文档可以在对应处理的操作手册中可以看到,因此内存读操作的开销是比较大的。内存写操作的执行处理器在将写操作的相关数据写入写缓冲器之后便认为该写操作已经完成,即该处理器不会等其他处理器发送的无效消息和重新读取完成而是继续执行其他指令。
而其他处理器收到无效消息之后,并不删除消息中指定地址对应的副本数据,而是将消息存入无效化队列之后就回复Invalidate Acknowledge消息,从而减少了写操作执行处理器所需的等待时间。但并非所有处理器都会使用无效化队列。
乱序执行
从CPU的角度来看,如果两个指令之间存在明显的关联关系。例如,当从内存中读取一个值,然后将其作为指针访问另一个内存地址时,CPU可以推断出这两个操作是存在依赖关系的,不应该被重排序,那么潜台词就是如果两条指令之间不存在依赖关系时,处理器可能会对输入代码进行乱序执行,处理器会在计算之后将乱序执行的结果重组,保证该结果与执行顺序是一致的,但不保证各个语句执行的先后顺序和输入的顺序一致。大多数处理器不会依赖指令进行重排序,但也有例外,比如DEC Alpha处理器,它属于独立的领域。
猜测执行 Speculative execution
许多现代处理器会采用猜测执行,其中一种预测执行类型是,处理器可能会估计条件分支被执行的概率,并执行后面的指令。如果猜正确,处理器就更快地执行了指令,如果猜测错误,处理器会尝试撤销它预测执行指令的效果。这么讲可能有点抽象,我们类比生活中的例子,猜测执行技术就好比没有卫星导航时代在陌生地方开车遇到岔路口的情形: 虽然我们不确定其中哪条路能够通往目的地,但是我们可以凭借猜测走其中的一条路,万一猜错了可以掉头重新走另外一条路。猜测执行能够造成if语句的语句提前于其条件语句被执行的效果,既可能导致指令重排序。
存储子系统重排序
上面我们提到了指令重排序,指令重排序的对象是指令,它是实实在在的对指令顺序进行调整,而存储子系统重排序重排序是一种现象而不是一种动作,它并没有真正对指令执行顺序进行调整,而只是造成了一种指令的执行顺序像是被调整过一样的现象,其重排序的对象是内存操作的结果。习惯上为了便于讨论,在论及内存重排序问题的时候我们往往会采用指令重排序的方式来表述,即我们也会用内存操作X被重排序到内存操作Y之后这样的表述来称呼内存重排序。
从处理器的角度来说,读内存操作的实质是从指定的RAM地址加载数据,因此这种内存操作也被称为Load,写内存操作的实质是将数据存储到指定地址表示的RAM存储单元中,因此内存操作通常被称为Store。所以,内存重排序实际上只有以下四种可能:
X86处理器只存在最后一种问题读取会被重新排序到写入之前, X86因此被称为具有强内存模型的处理器, 其他平台的处理器四个问题都有可能出现,四个问题都可能出现的处理被称为弱内存模型处理器。这也就是CPU的内存模型。处理器支持哪种内存重排序就会提供能够禁止想要重排序的指令,这些指令就被称为内存屏障--LoadLoad屏障、LoadStore屏障、StoreStore屏障和StoreLoad屏障。
写缓冲器和无效化队列与内存重排序
写缓冲器和无效化队列都可能导致内存重排序,写缓冲器可能导致StoreLoad重排序,StoreLoad是大多数处理器都允许的一种内存重排序。假设处理器0和处理器1未使用任何同步措施而按照程序顺序并依照下标所示的线程交错顺序执行。其中X、Y为共享变量,其初始值为0,r1、r2位局部变量。
当处理器0执行到L2时,虽然在此之前S3已经被处理器1执行完毕,但是由于S3的执行结果可能仍然还停留在处理器1的写缓冲器中,而处理器无法读取另外一个处理器写缓冲器的内容,因此处理器0此刻读取到Y的值仍然是其高速缓存中存储的该变量的初始值0。同理,处理器1执行到L4时所读取到变量X的值也可能是该变量的初始值0。因此,从处理器1的角度来看,处理器1执行L4的那一刻处理器0已经执行了L2而S1却像是尚未执行,即处理器1对处理器0的两个操作的感知顺序是L2->S1, 也就是说此时写缓冲器导致了S1写操作被重排序到L2之后。StoreLoad可能导致某些算法失效,比如Peterson的互斥算法。
由内存模型到Java内存模型
我们可以看到,差异是客观存在的,跨平台不是天生的,要做到跨平台,就要弥合差异,保持一致。我们来总结一下我们目前遇到的问题,随着计算机技术的飞速发展,软件和硬件都在进化,硬件在进化的路上,发现提升主频的道路走不通,于是就选择了多核处理器来提升性能,而悬浮于硬件的软件则试图不让硬件闲着,引入进程和线程等概念。多核CPU必须面对的的一个问题就是,缓存不一致协议,目前使用最广泛的就是MESI协议,MESI协议如果没有无效化队列和写缓冲器的话,那么就会性能较差,于是人们引入了写缓冲器和无效化队列,但是引入写缓冲器和无效化队列解决了性能问题,又带来了不确定性,即一个处理器对共享变量所做的更新具体在什么时候能够被其他处理器读取到这一点,缓存一致性协议(带了写缓冲器和无效化队列的版本)是不保证的,写缓冲器和无效化队列都可能导致一个处理器在某一时刻读取到共享变量的旧值。JVM必须回答一个处理器对共享变量的更新在什么时候或者说什么情况才能够被其他处理器所读取,即可见性问题。可见性又衍生出一个新的问题,一个处理器先后更新多个共享变量的情况下,其他处理器是以何种顺序读取到这些更新的,即有序性问题。除此之外,Java作为一个跨平台的语言,必须屏蔽掉不同CPU内存模型的差异, 就必须定义自己的内存模型,这也就是JMM,Java memory model,在处理跨平台的时候遇到了一些问题,指令重排、存储子系统重排,这两个问题带来了可见性和有序性问题,这两个问题之外,还有原子性,也就是说我们希望这操作不能被打断。
我们姑且将存储子系统重排和指令重排都算做指令重排中,那么就可以得出这样的论断:
存储原子性 + 指令重排 = 内存模型
Java内存模型抽象了跨平台会遇到的问题,并给出规范,并未给出实现,这是Java一惯的风格,给出规范,不给出实现,因为JVM不止一种,要促进JVM世界的繁荣,就不要约束太紧,遵循一个规范,那么Java在不同的虚拟机上跑出来的结果,我们都可以由规范推导出来。JMM定义了final、volatile、synchronized关键字的行为并确保正确同步。从开发人员的角度来看,Java内存模型作为一个模型,它从什么的角度来回答以下几个线程安全问题:
- 原子性问题: 针对实例变量、静态变量(即共享变量而非局部变量)的读、写操作,哪些是具备原子性的,哪些可能不具备原子性。
- 可见性问题: 一个线程对实例变量、静态变量进行的更新在什么情况下能够被其他线程所读取。
- 有序性问题: 一个线程对多个实例变量、静态变量进行的更新在什么情况下在其他线程看来是可以乱序的。
在原子性方面 , Java内存模型规定对long/double型意外的基本数据类型以及引用类型的共享变量进行读、写都具有原子性。另外,Java内存模型还特别规定对volatile修饰的long/double型变量进行读、写操作也具有原子性。换而言之,对引用类型以及几乎所有基本数据类型进行的读、写操作,Java内存模型都保证它们具备原子性,而对long/double型变量的共享变量进行的读、写操作是否具有原子性则取决于具体的Java虚拟机实现。
JMM的一些行为难以测试,比如指令重排,原子性读取等等,那我们该如何验证我们的猜想呢,这个问题的答案叫JCStress。
JMM测试利器-JCStress
历史起源
Circa 2013 (≈ JDK 8) , we suddenly realized there are no regular concurrency tests that ask hard questions about JMM conformance
大约在2013年,也就是JDK8发布的时候,我们突然意识到没有常规的并发测试可以针对JMM的一致性进行深入探究。
Attempts to contribute JMM tests to JCK were futile: probabilistic tests
尝试将JMM测试贡献给JCK是徒劳的:这是概率性测试。
JVMs are notoriously awkward to test: many platforms, many compilers, many compilation and runtime modes, dependence on runtime profile
JVM是出了名的难以测试, 涉及多个平台,多个编译器、多种编译和运行时模式,以及依赖的配置文件,所以测试起来比较麻烦。
我对上面的话深表赞同,我在写多线程相关的测试,往往要加上可能在你的机器上跑不出来,建议多跑几轮,这让我常常对我的理解有一点不确定,我们将用这个框架验证JMM的行为。我们完全没有在一篇文章里面再介绍一下JCStress,想起了费马的名言:
关于此,我确信我发现一种美妙的证法,可惜这里的空白处太小,写不下。
写在最后
这篇文章大致前前后后构思了一个月,甚至更久了,最初是想从MESI协议讲起,再结合到JCStress,但是这条线一直顺不下来了,我的脑子跑入了很多概念,操作系统啊,计算机组成原理啊,编译啊,我有的时候会觉得大学的课程,像是我看过的一部武学小说《昆仑》,里面有一个高手将自己的武功拆成五个部分,传授给徒弟们,最后这五个部分融合在一起的时候,反而存在些排斥反应。我又想起看过的一部小说的一部修行功法《梵圣真魔功》,这部功法原本也是被拆分为三部,最后合三为一才见其功。这篇文章在构建的过程就是尝试在融合大学学习的过程,融合自己的理解,当你开始探索一些问题,会发现自己的一些理解存在一些偏差,但当你觉得你对一个问题有些清晰的时候,似乎又碰到了一些新的问题,想起杨万里的《过松源晨炊漆公店》:
莫言下岭便无难,赚得行人错喜欢。
政入万山围子里,一山放出一山拦。
在刚写这篇文章的时候,我开始读Java内存模型这个词,我发现我虽然认识模型这两个字,想起小学的认词, 其实有些词未必懂,但是会使用,许多时候面临困惑时,可以通过澄清语言和概念来寻找解决方案,我们是在用语言进行思考。想起了维特根斯坦,在他看来,哲学的核心目的在于澄清概念,因为许多生活/工作中的冲突,根本上来看,是每个人对讨论的概念都有一套自己的解释,而鲜少有人考察透彻概念到底是什么。而理解语言和事实的同构关系,除了实践之外,也需要语言本身的精确,否则会有模糊的关联。尽管我们能熟练使用某个词,但并代表他能解释这个词是什么。究其根本,在搞明白某个概念是什么之前,我们就先学会了怎么使用这个概念。
参考资料
[1]《计算机体系结构基础》 https://foxsen.github.io/archbase/sec-ISA.html#%E6%8C%87%E4%BB%A4%E7%B3%BB%E7%BB%9F%E7%AE%80%E4%BB%8B
[2] 计算机系统内的字长到底指的是什么?https://www.zhihu.com/question/20536161
[3] RISC VS CISC https://cs.stanford.edu/people/eroberts/courses/soco/projects/risc/risccisc/
[4] 为什么有的地方叫arm64,有的地方叫aarch64?https://www.zhihu.com/question/502737415
[5] 都2021年了,还把x86和ARM归为CISC和RISC?https://zhuanlan.zhihu.com/p/426773593
[6] Weak vs. Strong Memory Models https://preshing.com/20120930/weak-vs-strong-memory-models/
[7]《Java 多线程实战指南》 黄文海著
[8] CPU memory model https://bajamircea.github.io/coding/cpp/2019/10/25/cpu-memory-model.html
[10] 数学模型(第二版)http://www.tjsiam.org/ICTMA/books/%E6%95%B0%E5%AD%A6%E6%A8%A1%E5%9E%8B%EF%BC%88%E7%AC%AC%E4%BA%8C%E7%89%88%EF%BC%89.pdf
[11]【数学建模大赛常用模型】差分方程法建立种群增长模型,三节课彻底搞明白 https://www.bilibili.com/video/BV1Z34y1m75i/?spm_id_from=333.788.recommend_more_video.0&vd_source=aae3e5b34f3adaad6a7f651d9b6a7799
[12] Java 并发编程之 JMM & volatile 详解 https://juejin.cn/post/6916331359258542087
[13] 什么是时钟速度?https://www.intel.cn/content/www/cn/zh/gaming/resources/cpu-clock-speed.html
[14] CPU的主频为什么不能无限提升?其他条件不变的情况下,主频越高速度越快,为什么主频不能无限提高?https://www.zhihu.com/question/561577035
[15] 多核 CPU 和多个 CPU 有何区别?https://www.zhihu.com/question/20998226/answer/705020723
[16] Java并发编程:如何创建线程?https://www.cnblogs.com/dolphin0520/p/3913517.html
[17] CPU内存模型和Java内存模型以及Java内存区域 https://zhuanlan.zhihu.com/p/145902867
[18] CPU memory model https://bajamircea.github.io/coding/cpp/2019/10/25/cpu-memory-model.html
[20] Java 字节码技术详解 https://zhuanlan.zhihu.com/p/465426047
[21] Java Bytecode DUP https://stackoverflow.com/questions/12438567/java-bytecode-dup