JMM和硬件知识小结

JMM和硬件知识小结

1.概述

这一部分知识的理解很重要,有助于更好的学习多线程并发知识,之前在一些并发问题的思考上觉得很不透明,完全是靠自己的主观臆测来说服自己,但是接触了MESI等概念之后就有了一种豁然开朗的感觉,接下来做一个简单的总结,可能不是很全面,所以我把他叫小结😂

2.JMM

首先就是对Java内存模型的理解,这个和虚拟机运行时数据区是不一样的,这个可以看作是一套规则,这套规则可以实现相应的线程安全,来保证原子性、可见性、有序性。我的理解是JMM是对底层操作系统的和CPU的一个封装,Java程序员在编码时不需要考虑每个平台的特性,只需要知道JMM结构即可,我们可以发现每一个底层的结构在JMM中都是有相应的规定的,例如,CPU对应字节码执行引擎,工作内存代表每一个CPU的寄存器和缓存,主内存就是物理内存,因为JVM为了保持良好的特性,也在JMM中实现了指令重排,在底层可以使用Lock#或者相应的MESI协议,而JMM需要使用synchronized关键字,volatile,或者是先行发生原则保证,你说巧了不是?
image.png

3.缓存一致性协议

我们知道每个处理器的缓存中都存有一份共享数据,那么如果其中一个处理器对数据进行更改,那么如何让其他处理器察觉并做出反应呢?

缓存结构:
image.png
我们可以发现缓存使用了类似于HashMap的结构,分为若干个桶,而每一个桶都有一条链表,链表的每个元素为缓存条目,缓存条目进一步可以分为以下三个结构:

  • Data Block(缓存行):存储从主内存中读取的数据以及准备写入主内存的数据,一个缓存行可存储多个变量
  • Tag:包含缓存行中数据的位置信息
  • Flag:缓存行的状态信息

CPU访问内存时,会通过内存地址解码的三个数据:index(桶编号)、tag(缓存条目的相对编号)、offset(变量在缓存条目中的位置偏移)来获取高速缓存中对应的数据。
如果找打相应的数据,并且缓存条目的Flag有效,那么缓存命中,否则缓存会从处理器中加载相应的数据,这个过程处理器处于停顿状态,不能处理其他的命令


MESI:

  • M:被修改(Modified)
    该缓存行的数据是被修改过的,与主存中的数据不一致。任一时刻,多个处理器的高速缓存中,Tag值相同的缓存条目,只有一个能处于该状态。
    在其它CPU读取同一Tag的缓存条目数据之前,该缓存行中的数据会写回主存,然后变为独享(exclusive)状态

  • E:独享的(Exclusive)
    该缓存行以独占的方式保留了相应内存地址的副本数据,其他所有CPU上的高速缓存都不能保留该数据的有效副本。该缓存条目的数据与主存中数据一致。
    在任何时刻当有其它CPU读取该内存时,该状态将变成共享状态(shared);当CPU修改该缓存行中内容时,该状态变成修改状态(Modified)

  • S:共享的(Shared)
    该缓存行的数据可能被多个CPU缓存,并且各个缓存中的数据与主存数据一致。
    当有一个CPU修改该缓存行数据时,其它CPU中该缓存行可以被作废,即变成无效状态(Invalid)。

  • I: 无效的(Invalid)
    该缓存行是无效的,不包含任何内存地址对应的有效副本数据。该状态是缓存条目的初始状态。

为了协调各个处理器进行干活,MESI规定了一组消息:
image.png
这个不需要记忆,只是作为一个参考,知道每个消息能干啥就行了,为了知识的完整性

上面的MESI就像锁一样,可以保证同一共享变量的读是并发的,写是独占的
我们在具体的读写实例中来看一下这个过程:
并发读
当处理器Processor 0要读取缓存中的数据S时,如果发现S所在的缓存条目状态为M、E或S,那么处理器可直接读取数据。

如果S所在的缓存条目状态状态为 I,说明Processor 0的缓存中不包含S的有效数据。这时,Processor 0会往总线发送一条Read消息来读取S的有效数据,而缓存状态不为 I 的其他处理器(如Process 1)或主内存(其他处理器缓存条目状都为 I 时从主内存读)收到消息后需要回复Read Response,来将有效的S数据返回给发送者。

需要注意的是,返回有效数据的其他处理器(如Process 1),如果状态为M,则会先将数据写入主内存,此时状态为E,然后在返回Read Response后,再将状态更新为S。

这样,Processor 0读取的永远是最新的数据,即使其他处理器对这个数据做了更改,也会获取到其他处理器最新的修改信息。

互斥写
当处理器Processor 0要向地址A中写数据时,如果地址A所在的缓存条目状态为E、M,说明Processor 0已拥有该数据的独占权,Processor 0可直接将数据写入A,然后将缓存条目状态改为M

如果写的缓存条目状态为S,处理器Processor 0需要往总线发送Invalidate消息来获取该缓存条目的独占权,当接收到其他所有处理器返回的Invalidate Acknowledge消息后,Processor 0才会确定自己已获得独占权,然后再将数据更新到地址A中,并将对应的缓存条目状态改为M

如果写的缓存条目状态为I,处理器Processor 0需要往总线发送Read Invalidate消息来获取该缓存条目的独占权,其他步骤同S

需要注意的是,如果接收到Invalidate消息的其他其他处理器,缓存条目状态为M,则该处理器会先将数据写入主内存(以方便发送Read Invalidate指令的处理器读到最新值),然后再将状态改为I

这样,Processor 0与其他处理器写的时候,永远只有一个处理器能够获得独占权,即实现了互斥写。

4.写缓冲区和无效化队列

上面我们已经看到,MESI可以很好的协调各个处理器之间的读写安全问题,那么为什么还会出现一些线程不安全问题呢?

原因在于写缓冲器和无效化队列的引入。MESI协议虽然解决了缓存一致性问题,但其本身有一个性能缺陷:处理器每次写数据时,都得等待其他所有处理器将其高速缓存中对应的数据删除,并接收到它们返回的Read Response与Invalidate Acknowledge消息后才执行写操作。这个过程无疑是很消耗时间的。
无奈的硬件设计者,解决了缓存一致性问题后,为了解决新出现的性能问题,又引入了新的部件:写缓冲器和无效化队列。

写缓冲器:

写缓冲器是处理器内部一个容量比高速缓存还小的高速存储部件,每个处理器都有自身的写缓冲器,且一个处理器无法读取另一个处理器上的写缓冲器内容。写缓冲器的引入主要是为了解决上面提到的MESI的写延迟问题。

写操作过程
引入写缓冲器后,当处理器要写入数据时:
如果相应的缓存条目状态为 E、M,则直接写入,无需发送消息(照旧)
如果相应的缓存条目状态为 S, 处理器会将写操作相关信息存入写缓冲器,并发送Invalidate消息。(不再等待响应消息)
如果相应的缓存条目状态为 I,发生“写未命中”,将写操作相关信息存入写缓冲器,并发送Read Invalidate消息。(不再等待响应消息)

当处理器将写操作写入写缓冲器后,则认为写操作已经完成。而实际上,当处理器收到其他所有处理器回应的Read Response、Invalidate Acknowledge消息后,处理器才会将写缓冲器中对应的写操作写入相应的缓存行,这个时候,写操作才算真正完成。

写缓冲器让处理器在执行写操作时不需要再额外的等待,减少了写操作的延时,提高了处理器的指令执行效率。
当处理去读数据时,可能一些数据还在写缓冲器中并没有写入到高度缓存,所以首先要在写缓冲器中查找数据,如果没有再去高速缓存中找

无效化队列

处理器在接收到Invalidate消息后,并不马上删除消息中指定地址对应的副本数据,而是将消息存入无效化队列之后就回复Invalidate Acknowledge消息,从而减少了执行写操作的处理器的等待时间。
需要注意的是,有些处理器(如X86)可能并没有使用无效化队列

写缓冲器和无效化队列是可见性问题产生的根本来源
这里简单说一下自己的理解,已经将可见性问题刨祖坟了,可见性问题的产生有两个原因,1.如果一个处理器写缓冲器中的内容并没有写到高速缓存中。那么其他的处理器是读不到这个值的,2.还没有根据无效化队列对高速缓存进行删除,这个处理器读取数据时读到的还是过时的数据,具体的解法方式是底层使用了两个叫做内存屏障的指令,分别为存储屏障和加载屏障,存储屏障可以将写缓冲器中的数据写到高速缓存中,加载屏障可以立马将无效队列标记的高速缓存设置为I状态,这也是可见性方案解决的底层原理

5.先行发生原则

这一块是JMM提供给我们的一个非常核心的功能,这一点目前我在实际的场景中感触不深,可能将来用到的时候回来再看就好了吧,我的理解是先行发生原则减少了程序员的工作量,如果仅仅使用synchronized和volatile来保证线程安全,那么是很麻烦的,幸运的是JMM自带了happen-before,这是JVM默认的实现

下面是happens-before原则规则:

  • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
  • 锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作;
  • volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
  • 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
  • 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
  • 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
  • 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
  • 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;

我们来详细看看上面每条规则(摘自《深入理解Java虚拟机第12章》):

  • 程序次序规则:一段代码在单线程中执行的结果是有序的。注意是执行结果,因为虚拟机、处理器会对指令进行重排序(重排序后面会详细介绍)。虽然重排序了,但是并不会影响程序的执行结果,所以程序最终执行的结果与顺序执行的结果是一致的。故而这个规则只对单线程有效,在多线程环境下无法保证正确性。

  • 锁定规则:这个规则比较好理解,无论是在单线程环境还是多线程环境,一个锁处于被锁定状态,那么必须先执行unlock操作后面才能进行lock操作。

  • volatile变量规则:这是一条比较重要的规则,它标志着volatile保证了线程可见性。通俗点讲就是如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作一定是happens-before读操作的。

  • 传递规则:提现了happens-before原则具有传递性,即A happens-before B , B happens-before C,那么A happens-before C

  • 线程启动规则:假定线程A在执行过程中,通过执行ThreadB.start()来启动线程B,那么线程A对共享变量的修改在接下来线程B开始执行后确保对线程B可见。

  • 线程终结规则:假定线程A在执行的过程中,通过制定ThreadB.join()等待线程B终止,那么线程B在终止之前对共享变量的修改在线程A等待返回后可见。

上面八条是原生Java满足Happens-before关系的规则,但是我们可以对他们进行推导出其他满足happens-before的规则:

  • 将一个元素放入一个线程安全的队列的操作Happens-Before从队列中取出这个元素的操作
  • 将一个元素放入一个线程安全容器的操作Happens-Before从容器中取出这个元素的操作
  • 在CountDownLatch上的倒数操作Happens-Before CountDownLatch#await()操作
  • 释放Semaphore许可的操作Happens-Before获得许可操作
  • Future表示的任务的所有操作Happens-Before Future#get()操作
  • 向Executor提交一个Runnable或Callable的操作Happens-Before任务开始执行操作

这里再说一遍happens-before的概念:如果两个操作不存在上述(前面8条 + 后面6条)任一一个happens-before规则,那么这两个操作就没有顺序的保障,JVM可以对这两个操作进行重排序。如果操作A happens-before操作B,那么操作A在内存上所做的操作对操作B都是可见的。

参考文章:
https://www.jianshu.com/p/7d3150fc0277?tdsourcetag=s_pctim_aiomsg
《深入理解JVM虚拟机》

Copyright: 采用 知识共享署名4.0 国际许可协议进行许可

Links: https://hadoo666.top/archives/jmm和硬件知识小结