详解什么是JMM!

2年前 (2022) 程序员胖胖胖虎阿
196 0 0

1.JAVA内存模型——JMM

1.1 现代计算机的内存模型

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

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

详解什么是JMM!

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

  JVM虚拟机是一种抽象化的计算机,同计算机一样,它有着自己的一套完善的硬体架构,如处理器、堆栈、寄存器、操作指令等,而在JVM篇中也讲到过虚拟机栈,虚拟机栈是用于描述java方法执行的内存模型,因此JMM也是属于JVM的一部分,只是JMM是一种抽象的概念,是一组规则,并不实际存在。所不同的是,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种:

详解什么是JMM!

编译器优化的重排: 编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
指令并行的重排: 现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性(即后一个执行的语句无需依赖前面执行的语句的结果),处理器可以改变语句对应的机器指令的执行顺序。
内存系统的重排: 由于处理器使用缓存和读写缓存冲区,这使得加载(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指令是按流水线技术来执行的,如下:

详解什么是JMM!

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

i = a + b;
y = c - d;     

详解什么是JMM!

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加载完成后才执行的,没有影响,过程如下:

详解什么是JMM!

详解什么是JMM!

正如上图所示,所有的停顿都完美消除了,指令流水线也无需中断了,这样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

详解什么是JMM!

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

详解什么是JMM!

 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内存屏障分为四类:

详解什么是JMM!

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

详解什么是JMM!

"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读重排序

详解什么是JMM!

 详解什么是JMM!

 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并发编程之美》以及借鉴了很多优秀博主的文章,就不一一列举,同时在此也表示感谢。

版权声明:程序员胖胖胖虎阿 发表于 2022年11月3日 下午4:24。
转载请注明:详解什么是JMM! | 胖虎的工具箱-编程导航

相关文章

暂无评论

暂无评论...