JMM小记

关于JMM的思考

前言

看《Java并发编程的艺术》总在思考一个问题,JMM到底是个什么东西?我们又需要JMM来讨论什么问题?JMM中规定的happens-before规则到底决定了什么,有什么意义?

然而思考了很久,碍于水平有限并不能完全清楚的解答这一系列的问题,但还是决定将最近的一点思考记录下来。万一以后想明白了呢。

那么一个疑问就是关于JMM本身

为什么会有JMM

大致可以这么理解,并发问题的本质是应该串行的方法并行执行了,并操作了不该操作的数据导致了错误的结果。所以设计了悲观锁的机制,让对临界区资源操作的并发程序退化成串行执行。(理解意思即可)

所以引申出并发编程的两个关键问题

第一个问题

当我们评价一个多线程的程序时,第一个想起的总是线程是否安全,那么线程安全到底是指什么?

思考一个最常见的线程安全问题,方法A,B都会访问同样的资源。方法A先执行,方法B再执行,当A未执行完成B就读取了临界区的值,导致了不安全情况的发生。

那么精确的形容这个问题,其实就是不同线程因为都存在对临界区的操作而导致程序必须控制不同线程操作发生的相对顺序,也就是线程的同步问题

第二个问题

正常的多线程程序中,一般通过共享内存来实现线程之间的信息交换,而这实际就是在解决并发编程的通信问题


所以多线程技术讨论的核心都是这两个问题,但这一切可以实现的基础是要求:

  1. 先写的代码运行结果,之后的代码是一定可见的

  2. 代码的运行顺序是和我们所书写顺序一样的

但事与愿违,简单的认为之前写的代码结果一定可以被之后的代码感知是错误的,因为计算机底层的复杂实现,存在缓存。写入的代码不一定刷到了主存中,而读取的那一方也可能直接从缓存中读取而不经过主存。

同样的代码在处理器上的最终执行顺序也并不会和书写顺序一致。

总结上面两点,也就是:

  1. 先后运行的代码,多线程中不一定是内存可见的

  2. 代码执行的顺序一定和书写的顺序不同

而这一切其实都是底层的硬件实现所导致的。所以为了,程序员可以忽略底层细节而快速方便的讨论数据的内存状态(讨论上述两个问题),设计出了JMM这种抽象模型,它规定了Java程序运行时数据可能存在的内存状态,也定义了在内存级别下数据的原子性操作。同时可以看出JMM所讨论的问题正是多线程技术实现的基础

那么在此基础上来分析JMM中讨论的两个核心问题

JMM中为什么要讨论内存可见

JMM抽象示意图如下:

JMM.png

从图可以得知,如果线程A,B之间需要通信,那么必须要经历如下两个步骤:

  1. 线程A把本地内存A中更新过共享变量刷新到主内存中

  2. 线程B到主内存中读取线程A之前更新过的共享变量

这里也就说明了,如果希望程序正确的运行,共享变量内存可见性是十分重要的(如果A修改了某个值,B随后读取,但因为没有将本地内存中的值刷会主内存,而导致应该读到的值没有读到)。通常情况下,从A本地内存写道主内存再读到B的本地内存不是一个原子操作。

JMM中为什么要讨论重排序

开始为了处理多线程带来的问题,一般会想到让多线程退化成单线程,也就是悲观锁。当然对于悲观锁而言重排序没有任何的讨论意义,在保证内存可见的情况下,上一个获得锁对临界区的操作一定是对下一个锁可见的,无论上一个锁内的指令执行顺序如何,因为对当前获得锁的线程而言之前方法的操作都全部完成了顺序根本没有意义,而且JMM模型是允许在悲观锁内进行重排序的。

JMM讨论重排序是处于乐观锁的实现必要,因为悲观锁性能的性能问题,JUC包中的一切都是以CAS及乐观锁的思想进行实现的。这种实现本质是允许多个线程同时操作临界区的,只在关键步骤进行CAS操作进行检查,所以多个线程内各自指令的操作顺序就变得重要且有意义了,Java本地方法的CAS系列操作都通过内存屏障实现了volatile语义的读和写,保证了指令不被重排序乐观锁才有可能实现。

下面的例子也可以说明重排序的问题本质还是破坏了多线程的内存语义,导致了内存不可见。

另外在假设代码中每个读写操作都是原子的(也就是操作立即可见)情况下,重排序仍然会破坏内存的可见性

代码在实际运行时并不是按书写的顺序执行的。为了提高性能,编译器和处理器通常会对指令做重排序(编译器,指令级并行,内存系统三种),在单线程下系统可以自行检查代码之间的依赖关系,没有依赖关系的可以被重排序。在多线程下,线程之间代码的依赖关系显然已经不可能由系统完成,观察如下代码。

class ReorderExample{
    int a = 0;
    boolean flag = false;
    
    public void writer(){
        a = 1;          //1
        flag =true;     //2
    }
    
    public void  reader(){
        if(flag){       //3
            int i = a * a;//4
        }
    }
}

线程B在操作4时是不一定可以看到线程A对a的写入的。因为在线程A中1,2操作并没有依赖关系,被允许重排了,而B进入判断条件后a还没有被赋值,并无法感知到1随后对a的修改。所以即使这里保证了内存被即使刷会主存且强制读取,重排序还是破坏了多线程的语义

一些同样重要的概念

Volatile 在JMM中有多重要

volatile规定了变量如何保证可见性,同时对一个变量的读或写保证为原子性。也就是JMM讨论的核心问题之一,内存的可见性就是以volatile为代表,因为volatile是对于内存可见性的最小实现,所以讨论其他操作的内存语义时都以volatile进行比较。

而volatile的语义又是通过内存屏障来保证的,JMM中讨论的内存 屏障也经过了简化,它对编译器和处理器发出内存屏障的指令,但具体实现取决于不同的硬件设备。

Happens-before

学习JMM难免会困惑happens-before到底是个啥?举例中总会说到volatile的写/读实现了happens-before关系,还是十分令人疑惑。

但可以明确的是Happens-before是一套形容操作间内存可见关系的规则,是JMM为了屏蔽底层的硬件细节(如重排序)而通过volatile, lock等方法和工具为程序员提供的一种便于理解内存可见性的手段。

Happens-before定义了8种规则,这些规则都是具有已有的具体实现上总结出的内存可见规则,但说XXX建立了Happens-before规则就可以简单快速的了解操作间的内存可见情况

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

友情链接更多精彩内容