写点什么

详解什么是 JMM!

用户头像
愚者
关注
发布于: 15 小时前

 1.JAVA 内存模型——JMM

1.1 现代计算机的内存模型

  早期计算机中 cpu 和内存的速度是差不多的,但在现代计算机中,cpu 的指令速度远超内存的存取速度,由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。需要注意的是,加入了高速缓存的机制,并不是始终都能从缓存中取到数据,如果不是同一内存地址的数据,处理器还必须绕过缓存,从主内存中获取数据,这种现象称之为“缓存命中率”,类似 application--redis--DB 架构。

  基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性(Cache Coherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致,例如:共享变量在多个处理器中被进行写操作,导致高速缓存中的数据不一致。如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有 MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly 及 Dragon Protocol 等。



1.2 JMM 模型与计算机内存模型的关系

  JVM 虚拟机是一种抽象化的计算机,同计算机一样,它有着自己的一套完善的硬体架构,如处理器、堆栈、寄存器、操作指令等,而在 JVM 篇中也讲到过虚拟机栈,虚拟机栈是用于描述 java 方法执行的内存模型,因此 JMM 也是属于 JVM 的一部分,只是 JMM 是一种抽象的概念,是一组规则,并不实际存在。所不同的是,JMM 模型定义的内存分为工作内存和主内存,工作内存是从主内存拷贝的副本,属于线程私有。当线程启动时,从主内存中拷贝副本到工作内存,执行相关指令操作,最后写回主内存。



1.3 JMM 三大特性

  JMM 三大特性,对于并发线程来说,也是常常容易出现问题的地方,因此 JMM 也可以说上主要是针对解决这三大核心问题的方法思路总结。

  • 原子性

  原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。比如对于一个静态变量 int i = 0,两条线程同时对他赋值,线程 A 操作为 i = 1,而线程 B 操作为 i = 2,不管线程如何运行,最终 i 的值要么是 1,要么是 2,线程 A 和线程 B 间的操作是没有干扰的,这就是原子性操作,不可被中断的特点。有点要注意的是,对于 32 位系统的来说,long 类型数据和 double 类型数据(对于基本数据类型,byte,short,int,float,boolean,char 读写是原子操作),它们的读写并非原子性的,也就是说如果存在两条线程同时对 long 类型或者 double 类型的数据进行读写是存在相互干扰的,因为对于 32 位虚拟机来说,每次原子读写是 32 位的,而 long 和 double 则是 64 位的存储单元,这样会导致一个线程在写时,操作完前 32 位的原子操作后,轮到 B 线程读取时,恰好只读取到了后 32 位的数据,这样可能会读取到一个既非原值又不是线程修改值的变量,它可能是“半个变量”的数值,即 64 位数据被两个线程分成了两次读取。但也不必太担心,因为读取到“半个变量”的情况比较少见,至少在目前的商用的虚拟机中,几乎都把 64 位的数据的读写操作作为原子操作来执行,因此对于这个问题不必太在意,知道这么回事即可。那么其实本质上原子性操作指的就是一组大操作要么就全部执行成功,要么就全部失败,举个例子:下单:{增加订单,减库存} 那么对于用户来说下单是一个操作,那么系统就必须保证下单操作的原子性,要么就增加订单和减库存全部成功,不存在增加订单成功,减库存失败,那么这个例子从宏观上来就就是一个原子性操作,非原子性操作反之,线程安全问题产生的根本原因也是由于多线程情况下对一个共享资源进行非原子性操作导致的。但是有个点在我们深入研究 Java 的并发编程以及在研究可见性之前时需要注意的,就是计算机在程序执行的时候对它的优化操作 -- 指令重排。计算机在执行程序时,为了提高性能,编译器和处理器的常常会对指令做重排,一般分以下 3 种:



编译器优化的重排: 编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。指令并行的重排: 现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。内存系统的重排: 由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差。其中编译器优化的重排属于编译期重排,指令并行的重排和内存系统的重排属于处理器重排,在多线程环境中,这些重排优化可能会导致程序出现内存可见性问题,下面分别阐明这两种重排优化可能带来的问题。

 编译器优化指令重排


int a = 0;int b = 0;
//线程A 线程B代码1:int x = a; 代码3:int y = b;代码2:b = 1; 代码4:a = 2;
复制代码


此时有 4 行代码 1、2、3、4,其中 1、2 属于线程 A,其中 3、4 属于线程 B,两个线程同时执行,从程序的执行上来看由于并行执行的原因最终的结果 x = 0;y=0; 本质上是不会出现 x = 2;y = 1; 这种结果,但是实际上来说这种情况是有概率出现的,因为编译器一般会对一些代码前后不影响、耦合度为 0 的代码行进行编译器优化的指令重排,假设此时编译器对这段代码指令重排优化之后,可能会出现如下情况:


//线程A                   线程B代码2:b = 1;         代码4:a = 2;代码1:int x = a;     代码3:int y = b;         
复制代码


这种情况下再结合之前的线程安全问题一起理解,那么就可能出现 x = 2;y = 1; 这种结果,这也就说明在多线程环境下,由于编译器会对代码做指令重排的优化的操作(因为一般代码都是由上往下执行,指令重排是 OS 对单线程运行的优化),最终导致在多线程环境下时多个线程使用变量能否保证一致性是无法确定的。

处理器指令重排先了解一下指令重排的概念,处理器指令重排是对 CPU 的性能优化,从指令的执行角度来说一条指令可以分为多个步骤完成,如下:

·取指:IF

译码和取寄存器操作数:ID

执行或者有效地址计算:EX

存储器访问:MEM

写回:WB

​ CPU 在工作时,需要将上述指令分为多个步骤依次执行(注意硬件不同有可能不一样),由于每一个步会使用到不同的硬件操作,比如取指时会只有 PC 寄存器和存储器,译码时会执行到指令寄存器组,执行时会执行 ALU(算术逻辑单元)、写回时使用到寄存器组。为了提高硬件利用率,CPU 指令是按流水线技术来执行的,如下:



流水线技术:类似于工厂中的生产流水线,工人们各司其职,做完自己的就往后面传,然后开始一个新的,做完了再往后面传递.....而指令执行也是一样的,如果等到一条指令执行完毕之后再开始下一条的执行,就好比工厂的生产流水线,先等到一个产品生产完毕之后再开始下一个,效率非常低下并且浪费人工,这样一条流水线上同时只会有一个工人在做事,其他的看着,只有当这个产品走了最后一个人手上了并且最后一个工人完成了组装之后第一个工人再开始第二个产品的工作)从图中可以看出当指令 1 还未执行完成时,第 2 条指令便利用空闲的硬件开始执行,这样做是有好处的,如果每个步骤花费 1ms,那么如果第 2 条指令需要等待第 1 条指令执行完成后再执行的话,则需要等待 5ms,但如果使用流水线技术的话,指令 2 只需等待 1ms 就可以开始执行了,这样就能大大提升 CPU 的执行性能。虽然流水线技术可以大大提升 CPU 的性能,但不幸的是一旦出现流水中​断,所有硬件设备将会进入一轮停顿期,当再次弥补中断点可能需要几个周期,这样性能损失也会很大,就好比工厂组装手机的流水线,一旦某个零件组装中断,那么该零件往后的工人都有可能进入一轮或者几轮等待组装零件的过程。因此我们需要尽量阻止指令中断的情况,指令重排就是其中一种优化中断的手段,我们通过一个例子来阐明指令重排是如何阻止流水线技术中断的,如下:


i = a + b;y = c - d;     
复制代码




LW R1,a             LW 指令表示 load,其中 LW R1,a 表示把 a 的值加载到寄存器 R1 中

LW R2,b             表示把 b 的值加载到寄存器 R2 中

ADD R3,R1,R2   ADD 指令表示加法,把 R1 、R2 的值相加,并存入 R3 寄存器中。

SW i,R3              SW 表示 store 即将 R3 寄存器的值保持到变量 i 中

LW R4,c             表示把 c 的值加载到寄存器 R4 中

LW R5,d             表示把 d 的值加载到寄存器 R5 中

SUB R6,R4,R5   SUB 指令表示减法,把 R4 、R5 的值相减,并存入 R6 寄存器中。

SW y,R6             表示将 R6 寄存器的值保持到变量 y 中

上述便是汇编指令的执行过程,在某些指令上存在 X 的标志,X 代表中断的含义,也就是只要有 X 的地方就会导致指令流水线技术停顿,同时也会影响后续指令的执行,可能需要经过 1 个或几个指令周期才可能恢复正常,那为什么停顿呢?这是因为部分数据还没准备好,如执行 ADD 指令时,需要使用到前面指令的数据 R1,R2,而此时 R2 的 MEM 操作没有完成,即未拷贝到存储器中,这样加法计算就无法进行,必须等到 MEM 操作完成后才能执行,也就因此而停顿了,其他指令也是类似的情况。前面讲过,停顿会造成 CPU 性能下降,因此我们应该想办法消除这些停顿,这时就需要使用到指令重排了,如下图,既然 ADD 指令需要等待,那我们就利用等待的时间做些别的事情,如把 LW R4,c 和 LW R5,d 移动到前面执行,毕竟 LW R4,c 和 LW R5,d 执行并没有数据依赖关系,对他们有数据依赖关系的 SUB R6,R5,R4 指令在 R4,R5 加载完成后才执行的,没有影响,过程如下:



正如上图所示,所有的停顿都完美消除了,指令流水线也无需中断了,这样 CPU 的性能也能带来很好的提升,这就是处理器指令重排的作用。关于编译器重排以及指令重排(这两种重排我们后面统一称为指令重排)相关内容已阐述清晰了,我们必须意识到对于单线程而已指令重排几乎不会带来任何影响,比竟重排的前提是保证串行语义执行的一致性,但对于多线程环境而已,指令重排就可能导致严重的程序轮序执行问题,如下:

​​


    int a = 0;    boolean f = false;    public void methodA(){        a = 1;        f = true;    }    public void methodB(){        if(f){            int i = a + 1;        }    }
复制代码


如上述代码,同时存在线程 A 和线程 B 对该实例对象进行操作,其中 A 线程调用 methodA 方法,而 B 线程调用 methodB 方法,由于指令重排等原因,可能导致程序执行顺序变为如下:


线程A                      线程B methodA:                methodB: 代码1:f= true;           代码1:f= true; 代码2:a = 1;             代码2: a = 0 ; //读取到了未更新的a                          代码3: i =  a + 1;
复制代码


由于指令重排的原因,线程 A 的 f 置为 true 被提前执行了,而线程 A 还在执行 a=1,此时因为 f=true 了,所以线程 B 正好读取 f 的值为 true,直接获取 a 的值,而此时线程 A 还在自己的工作内存中对当中拷贝过来的变量副本 a 进行赋值操作,结果还未刷写到主存,那么此时线程 B 读取到的 a 值还是为 0,那么拷贝到线程 B 工作内存的 a=0;然后并在自己的工作内存中执行了 i = a + 1 操作,而此时线程 B 因为处理器的指令重排原因读取 a 是为 0 的,导致最终 i 结果的值为 1,而不是预期的 2,这就是多线程环境下,指令重排导致的程序乱序执行的结果。因此,请记住,指令重排只会保证单线程中串行语义的执行的一致性,能够在单线程环境下通过指令重排优化程序,消除 CPU 停顿,但是并不会关心多线程间的语义一致性。

  • 可见性

经过前面的阐述,如果真正理解了指令重排现象之后的小伙伴再来理解可见性容易了,可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值。对于串行程序来说,可见性是不存在的,因为我们在任何一个操作中修改了某个变量的值,后续的操作中都能读取这个变量值,并且是修改过的新值。但在多线程环境中可就不一定了,前面我们分析过,由于线程对共享变量的操作都是线程拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程 A 修改了共享变量 i 的值,还未写回主内存时,另外一个线程 B 又对主内存中同一个共享变量 i 进行操作,但此时 A 线程工作内存中共享变量 i 对线程 B 来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题,另外指令重排以及编译器优化也可能导致可见性问题,通过前面的分析,我们知道无论是编译器优化还是处理器优化的重排现象,在多线程环境下,确实会导致程序轮序执行的问题,从而也就导致可见性问题。

  • 有序性

有序性是指对于单线程的执行代码,我们总是认为代码的执行是按顺序依次执行的,这样的理解如果是放在单线程环境下没有问题,毕竟对于单线程而言确实如此,代码由编码的顺序从上往下执行,但对于多线程环境,则可能出现乱序现象,因为程序编译成机器码指令后可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致,要明白的是,在 Java 程序中,倘若在本线程内,所有操作都视为有序行为,如果是多线程环境下,一个线程中观察另外一个线程,所有操作都是无序的,前半句指的是单线程内保证串行语义执行的一致性,后半句则指指令重排现象和工作内存与主内存同步延迟现象。

1.4 如何解决 JMM 中上面的问题

  对于原子性引起的安全问题,除了 jvm 提供的原子类型数据外,方法级或代码块级的,可以用 synchronized 关键字或者 Lock 锁接口的方法,来进行保证原子性;对于工作内存与主内存同步延迟现象导致的可见性问题,可以使用加锁或者 Volatile 关键字解决,它们都可以使一个线程修改后的变量立即对其他线程可见。对于指令重排导致的可见性问题和有序性问题,则可以利用 volatile 关键字解决,因为 volatile 的另外一个作用就是禁止重排序优化,关于 volatile 稍后会进一步分析。除了靠 sychronized 和 volatile 关键字(volatile 关键字不能保证原子性,只能保证的是禁止指令重排与可见性问题)来保证原子性、可见性以及有序性外,JMM 内部还定义一套 happens-before 原则来保证多线程环境下两个操作间的原子性、可见性以及有序性。

1.5 as-if-serial

  定义:不管怎么重排序,单线程程序的执行结果不能被改变。编译器、runtime 和处理器都必须遵守 as-if-serial 语义。所以编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

double pi =3.14; // A

double r = 1.0; // B

double area = pi * r * r; // C




 A 和 C、B 和 C 都不存在数据的依赖,因此 A 和 C、B 和 C 不会进行重排,而 A 和 B 之间不存在数据的依赖,因此,以上执行顺序存在两种情形:




 as-if-serial 语义把单线程程序保护了起来,遵守 as-if-serial 语义的编译器、runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。

1.6 Happens-Before

  从 jdk5 开始,java 使用新的 JSR-133 内存模型,基于 Happens-Before 的概念来阐述操作之间的内存可见性。

定义:

  • 如果一个操作 Happens-Before 另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。

  • 两个操作之间存在 Happens-Before 关系,并不意味着一定要按照 Happens-Before 原则制定的顺序来执行。如果重排序之后的执行结果与按照 Happens-Before 关系来执行的结果一致,那么这种重排序并不非法。

Happens-Before 规则:

  Happens-Before 的八个规则(摘自《深入理解 Java 虚拟机》12.3.6 章节):

  1. 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;

  2. 管程锁定规则:一个 unLock 操作先行发生于后面对同一个锁的 lock 操作;(此处后面指时间的先后)

  3. volatile 变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;(此处后面指时间的先后)

  4. 线程启动规则:Thread 对象的 start()方法先行发生于此线程的每个一个动作;

  5. 线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过 Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;

  6. 线程中断规则:对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;

  7. 对象终结规则:一个对象的初始化完成先行发生于他的 finalize()方法的开始;

  8. 传递性:如果操作 A 先行发生于操作 B,而操作 B 又先行发生于操作 C,则可以得出操作 A 先行发生于操作 C;

2. volatile VS synchronized


2.1 volatile


2.1.1 定义:volatile 用来修饰成员变量(静态变量和实例变量),被修饰的变量在被修改时能够保证每个线程获取该变量的最新值,从而避免出现数据脏读的现象,也就是我们说的保证数据的可见性。


2.1.2 实现原理:

  在生成汇编代码时会在 volatile 修饰的共享变量进行写操作的时候会多出 Lock 前缀的指令。如果对声明了 volatile 的变量进行写操作,JVM 就会向处理器发送这一条 Lock 前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。因此,volatile 修饰的变量具有以下的特点:

  1. Lock 前缀的指令会引起处理器缓存写回内存;

  2. 一个处理器的缓存回写到内存会导致其他处理器的缓存失效;

  3. 当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。


 volatile 可见性测试代码:

/** * volatitle可见性测试 */public class VisibilityTest implements Runnable {
volatile int i = 1;
@Override public void run() { System.out.println("Thread " + Thread.currentThread().getName() + " start....."); while (true) { if (i == 3) { break; } } System.out.println("Thread " + Thread.currentThread().getName() + " loop end...."); }
public static void main(String[] args) { VisibilityTest test = new VisibilityTest(); Thread t = new Thread(test); t.setName("t"); t.start(); try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } test.i = 3; System.out.println("i = " + test.i); }}
复制代码


如果对 i 变量不加 volatile 修饰,会发现这段代码可能会出现一直死循环,永不退出的情况。

2.1.3 volatile 的有序性

  volatile 的有序性是通过禁止指令重排来实现的。为了性能,在 JMM 中,在不影响正确语义的情况下,允许编译器和处理器对指令序列进行重排序。而禁止指令重排底层是通过设置内存屏障来实现。

  JMM 内存屏障分为四类:




java 编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。为了实现 volatile 的内存语义,JMM 会限制特定类型的编译器和处理器重排序,JMM 会针对编译器制定 volatile 重排序规则表:



"NO"表示禁止重排序。为了实现 volatile 内存语义时,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,为此,JMM 采取了保守策略:

  1. 在每个 volatile 写操作的前面插入一个 StoreStore 屏障;

  2. 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障;

  3. 在每个 volatile 读操作的前面插入一个 LoadLoad 屏障;

  4. 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。

需要注意的是:volatile 写是在前面和后面分别插入内存屏障,而 volatile 读操作是在后面插入两个内存屏障

StoreStore 屏障:禁止上面的普通写和下面的 volatile 写重排序;

StoreLoad 屏障:防止上面的 volatile 写与下面可能有的 volatile 读/写重排序

LoadLoad 屏障:禁止下面所有的普通读操作和上面的 volatile 读重排序

LoadStore 屏障:禁止下面所有的普通写操作和上面的 volatile 读重排序






 2.2 synchronized

 synchronized 作为 java 关键字,可以用来修饰方法和代码块,修饰常规方法和代码块中 this 属于对象锁,修饰静态方法和代码块中 Object.class 属于类锁。

2.2.1 synchronized 的可见性

JMM 关于 synchronized 的两条规定:

  1)线程解锁前,必须把共享变量的最新值刷新到主内存中

  2)线程加锁时,将清空工作内存中共享变量的值,从而使用共享变量时需要从主内存中重新获取最新的值

   (注意:加锁与解锁需要是同一把锁)

 线程 A 和 B 竞争锁资源,线程 A 先拿到锁进入方法修改共享变量,在解锁前会将当前工作内存的变量写会主内存,然后释放锁资源;线程 B 在获取锁后,会清空当前工作内存,重新从主内存中拷贝变量副本,从而实现可见性。

2.2.2 synchronized 的原子性

原子操作是指不会被线程调度机制打断的操作;这种操作一旦开始,就一直运行到结束。synchronized 底层由于采用了字节码指令 monitorenter 和 monitorexit 来隐式地使用这 lock 和 unlock 两个操作,使得其操作具有原子性。


2.2.3 synchronized 的有序性

 根据前面也知道,volatile 的有序性表现在禁止指令重排。而 synchronized 有序性表现在 as-if-serial 语义,但 as-if-serial 语义不能确保多线程情况下的禁止指令重排。如单例中的双重检验锁写法:

/** * 双重校验锁 */public class DoubleCheckSingleton {
private volatile static DoubleCheckSingleton singleton = null; private DoubleCheckSingleton(){
} public static DoubleCheckSingleton getSingleton(){ if(singleton==null){// 第一重判断,实例为空,才允许进入获取锁资源,避免进入锁,减少性能消耗 synchronized (DoubleCheckSingleton.class){ //利用锁的互斥性,每次只允许单线程进入创建实例 if(singleton==null){//第二重判断,防止被实例化多次 singleton = new DoubleCheckSingleton(); } } } return singleton; }}
复制代码


先看看这个 singleton = new DoubleCheckSingleton()实际上是分三步:1、在堆中分配内存;2、调用构造器创建实例;3、将当前引用指向该实例的内存,以上三步完成,这个实例就创建完毕,但由于编译器和处理器的指令重排,导致在多线程情况下,2和3会调换位置,从而产生性能问题。所以为禁止指令重排,在实例变量中引入volatile进行修饰。
我们知道,synchronized能使得线程像单线程的as-if-serial语义一样,而步骤2和3之间是不存在依赖关系的,所以虽然遵循了as-if-serial语义,2和3仍然存在指令重排现象。


本篇文章参阅了《深入理解JVM虚拟机》、《Java并发编程之美》以及借鉴了很多优秀博主的文章,就不一一列举,同时在此也表示感谢。
复制代码



用户头像

愚者

关注

还未添加个人签名 2021.07.22 加入

还未添加个人简介

评论

发布
暂无评论
详解什么是JMM!