JMM三大特性理解以及volatile简单使用

JMM概念

JMM是什么?
JMM全称为 Java Memory Model,即Java内存模型,是一个逻辑上的抽象概念,仅仅是一组约定或规范。
思考:JMM存在的意义是什么?或者说它的作用是什么?
它规定了在任何操作系统,Java线程间读取和操作数据是就要遵循这个流程,展示了线程与主内存的抽象关系,使得Java程序在各个平台都能达到一致的访问效果。

各个线程是从主内存中拿出数据拷贝到自己的工作内存中(缓存)进行操作的,如果是写操作,操作后再将自己工作内存中修改的数据写入到内存。这也就是说,数据在各个线程的工作内存中的一切操作,在没有写入主内存之间,一切都是不可见的。
在这里插入图片描述
整体的流程如下:
代码中定义的所有多个线程可共享的变量都是存储在主内存中的,每个线程都有自己的独立内存空间(在缓存中),线程想要操作某个数据时,要先从主内存拷贝一个此数据的副本到自己的工作区间,原型数据仍在内存中。在自己的空间内的缓存数据经过CPU处理过后,再从缓存写入内存中。注意:CPU不能直接从内存中读写数据(不能越级)。每个线程缓存的工作空间是不可见的。

思考:为什么不直接操作内存?
因为CPU的速度与内存的速度存在鸿沟,如果数据都从内存中读取,那么CPU的处理时间大部分都浪费在了读取数据上。这时,计算机的组成中引入了缓存,缓存相较于内存快了不少,在操作数据时,为了提升速度,先将数据从内存读取到缓存中,在将缓存中的数据读取到CPU中,这样就能减少CPU与内存之间的速度差。内存中的数据是各个线程线程可见的,当前线程从内存中读取到缓存中的数据只有当前线程可见。

在这里插入图片描述
思考:既然线程中各个工作区的内容彼此不可见,那线程间怎样进行通信。例如:A线程将变量num由0修改为1,而B线程读取的仍是0这个副本,B怎样知道A已经将num修改为了1呢?

通过各个线程都能见到的共享内存。A线程将num修改后,立即写入共享内存中,而B线程在操作num这个变量时,也不在从缓存中读取,而是再从内存中读取最新数据到缓存。那这样不是使得B线程在读变量num时不使用缓存内容而是再次从内存读取不使得速度变慢了吗?
要想达到线程间的对某些数据操作能够立即通信,就要针对这些数据进行这样操作,如果对通信及时性不敏感的,则还可以使用缓存提升速度。

思考:逻辑上的共享内存与线程的独立工作空间在物理上对应的硬件是什么?
共享内存就是计算机上以G为单位的内存条,而CPU缓存多数指的是速度更快,但容量很小,以M或K为单位的寄存器

JMM的三大特性

JMM的三大特性是在并发编程中应该考虑的三个问题。

  • 原子性
    和MySQL事务中的原子性侧重点不同。MySQL中是将一段看成一个整体,要么全部成功,要么全部失败。在JMM中侧重于在一段原子操作中,不能有其他线程进入操作。更像是隔离性
    我们在代码中一行代码可能会被JVM编译成多个汇编指令再去执行。例如:有A,B两个线程在各自线程空间将共享内存中的 num=0修改为1,假设 流程1: 从共享内存中将num拷贝成一个副本到内存空间 流程2:在内存空间中将num的值修改为1 ,流程3:将修改后的num放入主内存中。
    假设,A线程先启动,执行了1,2,这时如果没有原子性,A线程被操作系统挂起或B线程闯入(挂起由操作系统决定,时机自己无法干预),B线程一口气执行了1,2,3所有流程,此时共享内存num的值已经为1,这时A被唤醒,执行它的流程3,相当于把num=1,再次重写一遍。由于线程在为变量赋值时没有原子性(我认为更像是隔离性),在读取数据,操作数据,写回数据这一段有其他线程闯入,最终导致两个线程一起操作num,最终使得num=1,而不是2。因此,在多线程环境下要考虑JMM的原子性问题。

  • 可见性
    多个线程在自己的内存空间中有同一变量,任何一个线程将此变量修改,其他所有线程都会立即感知到。
    由于JMM,每个线程是先将一个变量拷贝成副本放入自己的内存空间,但每个线程的内存空间是独立的,不存在共享。若其中有线程将此变量修改,在此线程的内存空间没有刷新到内存到之前,其他线程是不会感知到的,他们看到仍是最初从主内存中拷贝过来的数据。

  • 有序性
    有序性是由于JVM将我们自己的代码进行了优化,将一些代码顺序做了一些调整,使得在运行时能够达到更快的效果。就好比考试时,试卷中有按序排好的题,而我们通常选择会做的做,这样效率更高。试卷的题顺序就好比我们写代码的顺序,实际做题的顺序就好比代码编译后的顺序。即使是重排也不是任意重排的,它一定是有依赖关系的排在前面。例如:x=1,y=x;y要依赖x,则x=1即使重排后也一定会排在y=x的前面。
    对顺序进行优化又能够保证结果最终一致性是一件好事,但在并发编程中就可能存在一些问题
    以下是如果出现指令重排就会导致不同结果的例子:

public class Application {
    
    
    public static void main(String[] args) throws CloneNotSupportedException, InterruptedException {
    
    
        ReSortDemo reSortDemo = new ReSortDemo();
        new Thread(()->{
    
    reSortDemo.t1();},"A").start();
        new Thread(()->{
    
    reSortDemo.t2();},"B").start();
    }
}
class ReSortDemo{
    
    
    int num=0;
    boolean flag=false;
    void t1(){
    
    
        num=999; //语句1
        Thread.sleep(100);
        flag=false;  // 语句2
    }
    void t2(){
    
    
        while (!flag){
    
    
        }
        num=100;
        System.out.println(num);
    }

}

如果A线程先启动,按照语句1、语句2的执行顺序,执行到语句2时,B线程方法进入,此时最终计算结果num=100;但注意:语句1和语句2没有依赖关系,可能发生指令重排,使得语句2在语句1的前面,那么最终的计算结果为num=999;但如果是在单线程环境下,就不会发生此问题。

Volatile的使用

Volatile是一个关键字,能够提供轻量级的同步机制,他加在变量前面可以确保变量在多线程计算时的可见性(任意线程在自己的工作空间修改都会使得其他线程立即可见)与禁止指令重排(标记部分不会被编译器优化)。但Volatile不保证原子性,这是相较于synchronized、lock这种重型锁不同的地方。

Volatile的可见性

验证在JMM中,多线程间的不可见性:
思路:准备两个线程(一个是A线程,一个是main线程)和一个资源类。在A线程启动后,从资源类中拷贝数据,并修改数据,但要确保在修改数据前main线程已经将原数据拷贝到自己的工作空间(否则A线程可能极快的将数据写入主内存,会使得main线程拷贝出的就是已经修改后的数据)。当A修改完数据后,在判断mian线程取出的数据是否发生改变。在默认的情况下,JMM会表现出不可见性,以下是代码的验证:
在这里插入图片描述

思考:为什么A线程要sleep一会再去操作数据?
为了确保main线程在拿到数据时,一定是A没有修改过的。这样才能验证JMM模型,如果A线程修改后放入主内存,mian线程再去内存拷贝,这样线程已经通过主内存完成了数据拷贝,无法验证数据不可见性。

验证Volatile的可见性:
在这里插入图片描述
思考:底层的流程是怎样的呢?
由于num变量加上了volatile关键字,在A线程从主内存中拷贝数据到自己的工作空间并修改数据后会立即写入主内存中。当main线程获取此变量时,不在从自己的工作空间读取旧数据,而是从主内存读取最新数据到自己的空间区间。虽然线程间的工作区间彼此不可见,但线程间可以通过主内存实现即使通信。
思考:性能相较于不加Volatile是否下降?
加了Volatile后使得线程间需要频繁的从主内存获取最新数据,针对此变量的工作区间缓存无效。而使用工作区间就是为了提升速度,所以会使得性能下降。

思考:如果不使用Volatile,能否通过synchronized解决?
使用synchronized解决变量可见性的思路:如果是多个线程操作同一个变量,在修改数据的方法上和获取数据的方法上都加上synchronized对象锁。这样当A线程操作num时抢到此资源类的对象锁,main线程这是没有资源类的对象锁,无法调用getNum()方法。只能等A线程修改完毕释放锁(在释放锁时已经将变量从工作空间写入了主内存),main线程才会获取锁调用getNum()方法,由于已经存在先后关系,使得num表现出了可见性。
在这里插入图片描述
但使用synchronized在方法中加锁这种方式,使得线程再次串行执行过于笨重。

验证volatile并不能够实现原子性

  1. 不加Volatile
    思路:设定多个线程操作同一个变量,若不保证原子性则有可能出现值覆盖的情况。
    在这里插入图片描述

思考:是什么原因造成的?
是由于num在取值,赋值再将值放回的过程中没有没有原子性,在取值和写回值之间有其他线程进程进入。例如:A线程刚取完值,B线程进入,一口气执行完了,A在接着执行,导致同样的值同样的操作得出同样的结果。没有实现相加的效果,而是出现了相同值的值覆盖。
思考:countDownLatch在这里的作用是什么?
因为最终的结果是在main线程中输出的,可能发生的情况就是,各个线程还没有计算完就main线程就将结果打印出来,使得结果会偏小。加上countDownLatch后,初始化时指定线程个数,每个线程结束会减1,如果没有减为0,则main线程等待。直到所有新城执行完毕再去输出结果。
2. 加上Volatile
在这里插入图片描述

思考:如何理解这里的原子性?
因为变量赋值虽然只有一行代码,但在编译时可能有多行代码,这就使得JVM在执行时也有多个步骤。如果这几个步骤不能一气呵成,有其他线程来闯入,就破坏了此变量内部的原子性。原子性就是要将一段看做一个整体不可分割。

思考:volatile的可见性在这里发挥怎样的作用?
能够确保一个线程将num修改后,其他线程在读取时能够立即获取最新修改的值。其他但在写操作时,是从之前内存中的数据拷贝到自己的工作空间的数据,与读不通,写的时候可能不是最新的数据。因此,volatile的可见性对原子性没有任何影响。
思考:那如果数据在写的时候,再次进行读取并与预期值比较,比较符合预期值后再进行修改(判断与修改保持原子性)这样是否就能达到整体的原子性?
这就是CAS的底部原理。还是上面的例子,A线程将num的值修改为1后未来得及写入就挂起,此时num的值仍为0。B线程闯入,读取num为0的值,将num修改为1,然后再次写入。此时num=1,这时A线程获得执行权,A线程此时仍认为num应该为0,但此时为1。如果A线程再进行一次原子性判断,这时就会读取到num已经为1,与预期不符。这时再进行其他处理。类似于一个乐观锁的形式,在多线程下降解决原子性问题。
思考:如何将上面代码改装成具有原子性的?

  1. 可以使用锁,将在写num时,改为串行执行。原子性问题就是在多线程写时才有的,如果直接在写时改为串行,将直接的解决问题
    使用synchronized锁的方式:
    在这里插入图片描述
    使用lock的方式:
    在这里插入图片描述
  2. 使用CAS
    在这里插入图片描述
    CAS更像是乐观锁,而synchronized更像是悲观锁的方式。相比之下,使用CAS的效率更高。

猜你喜欢

转载自blog.csdn.net/m0_52889702/article/details/128868906