JVM 学习笔记(一)- Java 内存模型

173 阅读13分钟

1. 什么是内存模型

内存模型这个概念。我们可以理解为:在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。不同架构的物理计算机可以有不一样的内存模型,JVM 也有自己的内存模型。

我们知道,和C这类语言不一样,JVM是Java字节码运行的平台。“一次编写,到处运行”是Java语言所追求的核心。JVM在其背后功不可没,它为之承担了很大的管理和调度,其中最关键的一点就是:屏蔽各种操作系统的细节,定义自己的模型。让自己的字节码程序能够在自己的虚拟机中执行。

2. 主内存和工作内存

学习过《计算机组成原理》这门课的同学应该知道,计算机有几个特点:

  1. CPU不能直接读取硬盘中的值,只能通过加载到内存后,再访问内存。
  2. CPU不能再内存中计算值,而需要通过读写内存,将数据加载到寄存器,通过CPU内部的ALU等组件进行计算。
  3. 为了缓解CPU寄存器和内存之间的速度不匹配问题,我们引入了一个新的部件:高速缓存(Cahce),高速缓存通常集成在CPU中,我们读写内存时,会保存一个副本在高速缓存中,根据:局部性原理,如果下一次访问内存,如果内存地址仍然是副本对应的内存地址,那么会直接去访问速度更快的高速缓存。

其实以上几点,突出了计算机的整个存储体系:CPU寄存器->CPU高速缓存->内存->闪存/硬盘。这背后的逻辑是非常复杂的,但是操作系统或者是背后的硬件逻辑已经为我们进行了处理,我们在编写代码、使用计算机的时候是感受不到的。但是多副本的存储方式,也会带来读写一致性的问题。

回到正题,JVM的主内存的概念,就是我们电脑中的内存;而工作内存就是对应着CPU中的寄存器和缓存。由于有多核CPU的存在,也会有多个不同的工作内存的存在,核心和工作内存相对应。这样一来就会产生一个问题:

【问题1】 假设核心A操作主内存中的数据X时,修改了X的值,如果核心B也期望修改X的值,假设A先修改完了,那么B是否能够知道A已经修改了呢?这一问题称之为:可见性问题。 其实可见性问题在物理结构中也存在,例如CPU在修改高速缓存Cache值时是否要修改内存的值,是写时修改还是淘汰时修改,不同的实现方法有不同的处理方法。例如如果要换出时修改就需要加标记位,来标记是否修改过这部分的内存。

Java 的内存模型规定,所有的变量都存储在主内存中。,而工作内存中保留了该线程使用到的变量的主内存的副本工作内存并不是真实存在的,他只是一个抽象的概念,涵盖了:缓存、写缓冲区、寄存器以及其他地硬件和编译器优化。

Java内存模型规定,线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存中的变量。不同线程也无法直接访问对方工作内存中的变量。而线程之间,变量值的传递需要主内存来完成,由于【问题1】的存在,Java内存模型还通过了一系列的数据同步协议、规则来保障数据的一致性。

3. 指令重排

先将内存模型放一边,来看看指令重排。

指令重拍是指在程序的执行过程中,为了性能考虑,编译器和CPU可能会对指令进行重新排序。同样是在《计算机组成原理》这门课里面,我们知道,一条指令的执行,区分为很多个周期,常见的有:取指译码执行,不同的指令还有一些例如中断间址。还有一个很重要的概念,指令流水线。如果所有的 指令串行执行,假设所有的指令都是3个周期(取值、译码、执行),那么执行1000条指令就需要3*1000共3000个周期时间,但是,取指主要是访问存储器,译码主要是CPU内部的译码机构处理,而执行则主要是运算器ALU执行,这实际上是三者的工作,完全没必要串行执行,可以并行执行,指令1在译码的时候,指令2就可以进入取指了,形成指令的流水线。

如上图,指令在8个周期内的执行情况,采用指令流水线的执行情况已经完成了指令6的执行,而另一组才执行到指令3的译码阶段。执行1000条指令,只需要:2+1000个周期时间。相比不采用指令流水线的方案整整少了2/3。

但是,有一个问题,就是指令间结果依赖的问题,例如:指令1通过计算得到了结果X,而指令2依赖于这个X的结果,那么指令2在取指译码后就不得不等待指令1将指令存储回内存,详细可见参考来源3

这种产生的等待周期,将会消耗掉一定的时间,我们可以在不违背数据依赖关系和整体逻辑的情况下,将后面的指令合理地提前,一定程度上可以减少运行的时钟周期。这就是指令重排

指令重排在单线程环境下没有问题,但是在多线程环境下,由于线程与线程间CPU是彼此不可见的,如果(线程)彼此之间的逻辑存在依赖的情况下,可能会带来一些难以预见的后果。详见[4.3]

4. 内存交互

Java内存交互 有三大特性:原子性、可见性、有序性。归根结底,这三大特性是为了多线程数据的一致性,使得程序在多线程并发,指令重排优化的情况下能够如期运行。

4.1 原子性

原子性:即一个操作或者多个操作,要么全部执行(执行的过程不会被任何因素打断),要么就都不执行。

即使在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程所干扰。 在 Java 中,为了保证原子性,提供了两个高级的字节码指令 monitorenter 和 monitorexit。这两个字节码,在 Java 中对应的关键字就是 synchronized。

因此,在 Java 中可以使用 synchronized 来保证方法和代码块内的操作是原子性的。

4.2 可见性

可见性:是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

Java内存模型是通过**"变量修改后将新值同步回主内存, 变量读取前从主内存刷新变量值"**,依赖主内存作为传递媒介的方式来实现的。

Java 实现多线程可见性的方式有:

  • volatile
  • synchronized
  • final

4.3 有序性

有序性区分两个场景:线程内线程间

  • 线程内:单个线程内部来说,指令会按照一种“串行”(as-if-serial)的方式执行,这种方式已经应用于顺序编程语言。
  • 线程间:这个线程“观察”到其他线程并发地执行非同步的代码时,由于指令重排序优化,任何代码都有可能交叉执行。

唯一起作用的约束是:对于同步方法,同步块(synchronized 关键字修饰)以及 volatile 字段的操作仍维持相对有序。

指令重排是怎样影响多线程间的有序性的?

当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。 例如:线程A将变量isAccept的值初始化为false,需要计算结果,才能判断isAccept的值究竟是什么。线程B去读取这个isAccept的值,按这个值来做处理。但是线程B读取这个值的时候,因为猜测,线程B认为将取值的过程提前可以节省时间,导致出错。所以我们应该保障共享变量的可见性。

5. 内存屏障

Java 中如何保证底层操作的有序性和可见性?可以通过内存屏障。内存屏障是被插入两个 CPU 指令之间的一种指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障有序性的。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将主内存的值写入高速缓存,清空无效队列,从而保障可见性。详细可见参考来源1

6. Volatile

Volatile是JVM提供的轻量级的同步机制。可以保证变量在多线程中的可见性。

Volatile变量具有两种特性:

  1. 保证变量对所有的线程可见性:当一条线程修改了 volatile 变量的值,新值对于其他线程来说是可以立即得知的。而普通变量不能做到这一点,普通变量的值在线程间传递均需要通过主内存来完成。
  2. 禁止指令重排

为什么Volatile可能保证可见性 如果字段被Volatile修饰,Java内存模型将在写操作后插入一个写屏障指令,在读操作前插入一个读屏障指令。 这意味着,你如果对一个volatile进行写操作,你必须知道:

  1. 一旦你完成写入,任何访问这个字段的线程将会得到最新的值。
  2. 在你写入之前,会保证所有之前发生的事情已经发生 ,并且任何更新过的值都是可见的,因为内存屏障会把之前的写入值都刷新到缓存。

JVM让这被修改的值刷新到所有的线程的缓存当中,这样所有线程拿到的都是新值,但是如果其他CPU核心在这之前修改了值,那么会导致这个修改的值丢失,被覆盖。从Load到store到内存屏障,一共4步,只有最后一步是保证让这个变量全部线程可见,所以,Volatile关键字不能保证对变量操作的原子性。

Volatile禁止指令重排,自然而然保证有序性,但是,Volatile变量不能保证并发操作的安全性。如果你认为Volatile关键字和Sychronized有一模一样的作用,只不过是作用在变量上,那么就理解错了,如果我们多线程并发地对一个Volatile变量X增加操作,例如每个线程执行for循环,对X++执行5000次,6个线程执行。按这种理解,那么最后的结果应该是:5000 * 6 = 30000,但是实际的执行结果却可能是:小于30000。

原因很简单,某两个线程同时获取了X的值为1000,然后两个线程都修改X的值为1001,然后写入主内存,再刷新工作内存。但是线程1在执行内存屏障的操作时,线程2和线程1执行的是一样的操作,都是将X从1000-1001,最终的结果自然而然就会少。

所以,Volatile关键字只能保证可见性、有序性,但是不能保证对变量操作的原子性。

七. 第三种线程安全的单例模式

使用sychronized加锁和使用静态内部类实现的线程安全的单例模式比较常见,但是根据volatile的特性,我们可以实现另一种线程安全的单例:基于volatile和sychonrized块锁的单例模式。

对于sychronized加锁这种方式,有一个比较明显的缺点,锁的粒度太大,锁会将整个sychronized修饰的方法锁住,其他线程就无法得到锁,无法进入方法体:

//单例实现:普通加锁
class Singleton {
  private static Singleton instance = null;
  //普通单例中,在进入方法时,就会由于sychronized的加锁,导致线程间争用getInstance()方法效率降低。
  public synchronized static Singleton getInstance() {
      if (instance == null) {
          instance = new Singleton();
      }
      return instance;
  }
}

但是,实际上我们加锁的最终目的是为了不反复重建instance实例,我们只有在第一次getInstance时,去判断是否需要重建实例。在第二次、第三次是完全没必要的,所以,实际上我们在第二次、第三次访问时,是不需要去互斥访问的,我们对方法进行一点修改:

class Singleton2 {
    private static Singleton2 instance = null;
    public static Singleton2 getInstance() {
        if(instance == null){
            synchronized (Singleton2.class){
                if (instance == null) {
                    instance = new Singleton2();
                }
            }
        }
        return instance;
    }
}

如果在第一次已经创建之后,进入方法,只需要在if处判断是否创建过即可,如果创建过直接返回实例,这样一来就不会浪费时间卡在方法处的sychronized上了。能够加快访问效率。但是,由于上文,我们知道,instance实例在内存中,并不是只有一份的,在主内存有一份instance实例,但是在不同的线程间,工作内存中也有主内存实例的副本。Java内存模型通过一系列的数据同步协议、规则来保障数据的一致性。那么这样一来,假设线程A修改了instance的值,将他初始化,线程B不能立刻知道这一行为,导致也进入了创建的流程,这样一来,由于对象更新的滞后性导致单例失效。所以,我们需要一种手段,在线程A更新数值的时候,立刻让B知道。 结合上文所述的volatile的特性:“一旦你完成写入,任何访问这个字段的线程将会得到最新的值。”如果我们把instance声明成volatile就解决了这个不可见的问题:

//单例实现:双重检查模式(DCL)
class SingletonDCL {
  private volatile static SingletonDCL instance = null;
  
  public static SingletonDCL getInstance() {
      if (instance == null) {
          synchronized (SingletonDCL.class) {
              if (instance == null) {
                  instance = new SingletonDCL();
              }
          }
      }
      return instance;
  }
}

这就是第三种线程安全的懒汉单例模式:双重检查锁(DCL)。

参考来源
  1. Java 内存模型
  2. 主内存和工作内存怎么理解?
  3. Java内存模型与指令重排
  4. 为什么volatile能保证可见性? (内存屏障)