第五章JMM内存模型

文章目录

  • 计算机硬件存储体系
    • 为什么要弄一个 CPU 高速缓存呢?
    • 为什么缓存能提高速度呢?
    • 缓存带来的问题
  • Java之JMM模型
    • JMM 是如何抽象线程和主内存之间的关系
      • 线程之间如何通信
      • Java 内存区域和 JMM 有何区别
    • JMM规范下,三大特性
      • 原子性
      • 可见性
      • 有序性
  • happens-before原则
    • x,y案例说明
    • happens-before总原则
    • happens-before8条
    • happens-before案例说明

计算机硬件存储体系

计算机存储结构,从本地磁盘到主存到CPU缓存,也就是从硬盘到内存,到CPU。一般对应的程序的操作就是从数据库查数据到内存然后到CPU进行计算

第五章JMM内存模型_第1张图片

为什么要弄一个 CPU 高速缓存呢?

  • 类比我们开发网站后台系统使用的缓存(比如 Redis)是为了解决程序处理速度和访问常规关系型数据库速度不对等的问题。 CPU 缓存则是为了解决 CPU 处理速度和内存处理速度不对等的问题。
  • CPU的运行并不是直接操作磁盘,而是将磁盘中的数据读入到内存,然后CPU进行运算,但是内存的读写速度远远小于我们的CPU,为了减缓这种差异,所以引入了缓存
  • 我们甚至可以把 内存看作外存的高速缓存,程序运行的时候我们把外存的数据复制到内存,由于内存的处理速度远远高于外存,这样提高了处理速度。

总结:CPU Cache 缓存的是内存数据用于解决 CPU 处理速度和内存不匹配的问题,内存缓存的是硬盘数据用于解决硬盘访问速度过慢的问题。

为什么缓存能提高速度呢?

  • 首先是物理上的原因,因为本身材料好所以运行速度很快,但是读写速度快意味着价格更贵,因为成本问题,不可能都用好的材料,所以才会出现这种分层的状况
  • 而我们的缓存的大小也很小,但是为什么能起到让速度不匹配的问题得到一定的解决的,其实是局部性原理
    • 时间局部性
      • 就比如我们Java中的循环,对于循环里的指令,因为要执行100次,所以这个指令在一段时间内会经常被访问,这就是时间局部性
    • 空间局部性
      • 比如我们在循环中,访问了数组a,对于数组我们知道,在内存中是连续存储的,我们第一个访问的a[0]。下一个是a[1],这就是我们的空间局部性,访问了一个存储单元,在很短的时间内,在其附近的存储单元也有可能被访问
  • 我们的缓存中就根据局部性去存储这些可能马上就会用到的东西,就能提高速度

缓存带来的问题

第五章JMM内存模型_第2张图片

  • 我们现代计算机都是多核CPU,所以会存在着多个CPU Cache

  • CPU Cache 的工作方式: 先复制一份数据到 CPU Cache 中,当 CPU 需要用到的时候就可以直接从 CPU Cache 中读取数据,当运算完成后,再将运算得到的数据写回 Main Memory 中。但是,这样存在 内存缓存不一致性的问题

    • 比如我执行一个 i++ 操作的话,如果两个线程同时执行的话,假设两个线程从 CPU Cache 中读取的 i=1,两个线程做了 1++ 运算完之后再写回 Main Memory 之后 i=2,而正确结果应该是 i=3。
  • 我们的程序运行在操作系统之上,操作系统屏蔽了底层硬件的操作细节,将各种硬件资源虚拟化。于是,操作系统也就同样需要解决内存缓存不一致性问题。

  • 操作系统通过 内存模型(Memory Model) 定义一系列规范来解决这个问题。无论是 Windows 系统,还是 Linux 系统,它们都有特定的内存模型。

Java之JMM模型

根据上面的铺垫,我们Java也可以直接复用操作系统层面的内存模型。不过,不同的操作系统内存模型不同。如果直接复用操作系统层面的内存模型,就可能会导致同样一套代码换了一个操作系统就无法执行了。

  • 因为我们的Java语言要实现跨平台,一次编译,到处执行,故Java虚拟机规范中试图定义一种Java内存模型(Java Memory Model,简称JMM) 来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。

  • 这只是 JMM 存在的其中一个原因。实际上,对于 Java 来说,你可以把 JMM 看作是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其还规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。

    • 为什么要遵守这些并发相关的原则和规范呢? 这是因为并发编程下,像 CPU 多级缓存和指令重排这类设计可能会导致程序运行出现一些问题。就比如说我们上面提到的指令重排序就可能会让多线程程序的执行出现问题,为此,JMM 抽象了 happens-before 原则(后文会详细介绍到)来解决这个指令重排序问题。

JMM 说白了就是定义了一些规范来解决这些问题,开发者可以利用这些规范更方便地开发多线程程序。对于 Java 开发者说,你不需要了解底层原理,直接使用并发相关的一些关键字和类(比如 volatilesynchronized、各种 Lock)即可开发出并发安全的程序。

  • 通过这组规范定义了程序中(尤其是多线程)各个变量的读写访问方式并决定一个线程对共享变量的写入何时以及如何变成对另一个线程可见
  • 关键技术点都是围绕多线程的原子性、可见性和有序性展开的。

JMM 是如何抽象线程和主内存之间的关系

第五章JMM内存模型_第3张图片

什么是主内存?什么是本地内存?

  • 主内存:所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量)
    • 共享变量(类中成员变量,静态变量,常量都属于共享变量,在堆和方法区中存储的变量)
    • 主内存从硬件角度来说就是内存条
  • 本地内存:每个线程都有一个私有的本地内存来存储共享变量的副本,并且,每个线程只能访问自己的本地内存,无法访问其他线程的本地内存。本地内存是 JMM 抽象出来的一个概念,存储了主内存中的共享变量副本。
    • 本地内存从硬件角度来说就是CPU的缓存,比如寄存器、L1、L2、L3缓存等

由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到的线程自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。

  • 关于修改共享变量,每次都是先从主内存将变量加载到自己的工作内存,之后关于此变量的所有操作都是在自己的工作内存中进行,然后写回主内存
  • 如果线程1已经将主内存的变量的写入工作内存,如果此时其他线程2已经将主内存的变量写入内存,那么对于线程2来说,线程1修改后的值就是不可见的,或者如果没有将主内存的变量写入内存,此时从主内存读的值是还没有更新的值(脏读)

线程之间如何通信

  • 通信是指线程之间以如何来交换信息。一般线程之间的通信机制有两种:共享内存和消息传递。Java的并发采用的是共享内存模型(JMM),Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。如果编写多线程程序的Java程序员不理解隐式进行的线程之间通信的工作机制,很可能会遇到各种奇怪的内存可见性问题。

Java 内存区域和 JMM 有何区别

这是一个比较常见的问题,很多初学者非常容易搞混。 Java 内存区域和内存模型是完全不一样的两个东西

  • JVM 内存结构和 Java 虚拟机的运行时区域相关,定义了 JVM 在运行时如何分区存储程序数据,就比如说堆主要用于存放对象实例。
  • Java 内存模型和 Java 的并发编程相关,抽象了线程和主内存之间的关系就比如说线程之间的共享变量必须存储在主内存中,规定了从 Java 源代码到 CPU 可执行指令的这个转化过程要遵守哪些和并发相关的原则和规范,其主要目的是为了简化多线程编程,增强程序可移植性的。

JMM规范下,三大特性

  • 我们只需要保证线程的原子性,防止指令重排,和可见就可以防止线程不安全的事情发生

原子性

  • 一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行。

  • 在 Java 中,可以借助synchronized、各种 Lock 以及各种原子类实现原子性。

    • synchronized 和各种 Lock 可以保证任一时刻只有一个线程访问该代码块,因此可以保障原子性。
    • 各种原子类是利用 CAS (compare and swap) 操作(可能也会用到 volatile或者final关键字)来保证原子操作。

第五章JMM内存模型_第4张图片

  • 客户端A先卖了一张票,然后去判断B的,由于A没将卖掉的结果写入主内存,所以客户端B的显示还有票,然后又卖了一次,那么执行A的将卖票的结果写入主内存,也执行B的卖票的结果,所以一张票就被卖了两次,称为超卖现象

可见性

  • 是指当一个线程修改了某一个共享变量的值,其他线程是否能够立即知道该变更 ,JMM规定了所有的变量都存储在主内存中。

  • Java中普通的共享变量不保证可见性,因为数据修改被写入内存的时机是不确定的,多线程并发下很可能出现"脏读",所以每个线程都有自己的工作内存,线程自己的工作内存中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取,赋值等 )都必需在线程自己的工作内存中进行,而不能够直接读写主内存中的变量。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成

导致-线程脏读

例子

第五章JMM内存模型_第5张图片

第五章JMM内存模型_第6张图片

第五章JMM内存模型_第7张图片

有序性

第五章JMM内存模型_第8张图片

  • 在单线程的上述的重排没有关系,但是在多线程下就会有很大的问题
  • 指令重排了解即可,因为很少是因为指令重排导致的线程安全问题

第五章JMM内存模型_第9张图片

  • 对于一个线程的执行代码而言,我们总是习惯性认为代码的执行总是从上到下,有序执行。为了提升执行速度/性能,计算机在执行程序代码的时候,会对指令进行重排序,编译器和处理器通常会对指令序列进行重新排
    • Java规范规定JVM线程内部维持顺序化语义,即只要程序的最终结果与它顺序化执行的结果相等,那么指令的执行顺序可以与代码顺序不一致,此过程叫指令的重排序。

优缺点

  • JVM能根据处理器特性(CPU多级缓存系统、多核处理器等)适当的机器对指令进行重排序,使得机器指令能更符合CPU的执行特性,最大限度的发挥机器性能。
  • 指令重排可以保证串行语义一致,但没有义务保证多线程间的语义也一致(即可能产生“脏读”),简单说,两行以上不相干的代码在执行的时候有可能先执行的不是第一条,不见得是从上到下顺序执行,执行顺序会被优化。

指令重排的三种表现(层面)

  • 编译器优化的重排
    • 编译器(包括 JVM、JIT 编译器等)在不改变单线程程序语义的前提下,重新安排语句的执行顺序。
  • 指令并行的重排
    • 现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  • 内存系统的重排
    • 内存系统也会有“重排序”,但又不是真正意义上的重排序。在 JMM 里表现为主存和本地内存的内容可能不一致,进而导致程序在多线程下执行可能出现问题。

image-20230612222338456

小总结

  • 单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致。
  • 处理器在进行重排序时必须要考虑指令之间的数据依赖性
  • 多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测

happens-before原则

  • 在JMM中,如果一个操作执行的结果需要对另一个操作可见性或者代码重新排序,那么这两个操作之间必须存在happens-before(先行发生)原则。逻辑上的先后关系。

为什么需要 happens-before 原则? happens-before 原则的诞生是为了程序员和编译器、处理器之间的平衡。程序员追求的是易于理解和编程的强内存模型,遵守既定规则编码即可。编译器和处理器追求的是较少约束的弱内存模型,让它们尽己所能地去优化性能,让性能最大化。happens-before 原则的设计思想其实非常简单:

  • 为了对编译器和处理器的约束尽可能少,只要不改变程序的执行结果(单线程程序和正确执行的多线程程序),编译器和处理器怎么进行重排序优化都行。
  • 对于会改变程序执行结果的重排序,JMM 要求编译器和处理器必须禁止这种重排序。

第五章JMM内存模型_第10张图片

x,y案例说明

  • x=5;线程A执行

  • y=x;线程B执行

  • 问:y一定等于5吗?

    • 答:不一定

如果线程A的操作(x= 5)happens-before(先行发生)线程B的操作(y = x),那么可以确定线程B执行后y = 5 一定成立;

如果他们不存在happens-before原则,那么y = 5 不一定成立。

  • 是happens-before原则的威力。------------------->包含可见性和有序性的约束

先行发生原则(happens-before)被定义在了JMM之中

  • 如果Java内存模型中所有的有序性都仅靠volatile和synchronized来完成,那么有很多操作都将会变得非常啰嗦,但是我们在编写Java并发代码的时候并没有察觉到这一点。
  • 我们没有时时、处处、次次,添加volatile和synchronized来完成程序,这是因为Java语言中JMM原则下有一个**“先行发生”(Happens-Before)的原则限制和规矩**

这个原则非常重要:

它是判断数据是否存在竞争,线程是否安全的非常有用的手段。依赖这个原则,我们可以通过几条简单规则一揽子解决并发环境下两个操作之间是否可能存在冲突的所有问题,而不需要陷入Java内存模型苦涩难懂的底层编译原理之中。

happens-before总原则

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

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

  • 面试问就说这个规则,如果记得具体的就说具体的

happens-before8条

JMM存在的天然存在的happens-before 关系,8条

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

  • 锁定规则:锁的获取的先后顺序,一个unLock操作先行发生于对同一个锁的lock操作之后(这里的后面是指时间上的先后),就是先加锁才能解锁,先解锁才能加锁

    public class HappenBeforeDemo{
       static Object objectLock = new Object();
       public static void main(String[] args) throws InterruptedException{
        //对于同一把锁objectLock,threadA一定先unlock同一把锁后B才能获得该锁,   A 先行发生于B
        synchronized (objectLock){
    
         }
       }
    }
    
  • volatile变量规则:对一个volatile变量的写操作先行发生于对这个变量的读操作,前面的写对后面的读是可见的,这里的后面同样是指时间上的先后。

  • 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C

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

  • Thread t1 = new Thread(()->{
     System.out.println("----hello thread")//后执行
        },"t1");
    t1.start();//-------------------先执行
    
  • 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;

    • 可以通过Thread.interrupted()检测到是否发生中断。也就是说你要先调用了interrupt()方法设置过中断标志位,我才能检测到中断发送。
  • 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过isAlive()等手段检测线程是否已经终止执行。

  • 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始,finalize的通常目的是在对象被不可撤销的丢弃之前执行清理操作。

happens-before案例说明

public class TestDemo{
  private int value = 0;
  public int getValue(){
      return value; 
  }
  public  int setValue(){
      return ++value;
  }
}

问:假设存在线程A和B,线程A先(时间上的先后)调用了setValue(),然后线程B调用了同一个对象的getValue(),那么线程B收到的返回值是什么?是0还是1?

答:真不一定

我们就这段简单的代码一次分析happens-before的规则(规则5、6、7、8 可以忽略,因为他们和这段代码毫无关系):

  • 1 由于两个方法是由不同的线程调用,不在同一个线程中,所以肯定不满足程序次序规则;
  • 2 两个方法都没有使用锁,所以不满足锁定规则;
  • 3 变量不是用volatile修饰的,所以volatile变量规则不满足;
  • 4 传递规则肯定不满足;

所以我们无法通过happens-before原则推导出线程A happens-before线程B,虽然可以确认在时间上线程A优先于线程B指定,但就是无法确认线程B获得的结果是什么,所以这段代码不是线程安全的。那么怎么修复这段代码呢?

修复

//1
public class TestDemo
{
  private int value = 0;
  public synchronized int getValue(){
      return value; 
  }
  public synchronized int setValue(){
      return ++value;
  }
}
//synchronized太猛了,降低太多的效率
//2
public class TestDemo
{
  private volatile int value = 0;
  public int getValue(){
      return value; 
  }
  public synchronized int setValue(){
      return ++value;
  }
}
  • 把value定义为volatile变量,由于setter方法对value的修改不依赖value的值,满足volatile关键字使用场景
  • 理由:利用volatile保证读取操作的可见性;利用synchronized保证复合操作的原子性结合使用锁和volatile变量来减少同步的开销。

你可能感兴趣的:(JUC学习,java,redis,缓存)