Java 内存区域与 Java 内存模型

  • 一、前言
  • 二、Java 内存模型(JMM)
  • 1. CPU 和 内存的交互
  • 2. Java 内存模型中的主内存与工作内存
  • 3. volatile 关键字
  • 3.1 可见性
  • 3.2 禁止重排优化
  • 三、Java 内存区域
  • 1. 私有数据区域
  • 1.1 虚拟机栈
  • 1.2 程序计数器
  • 1.3 本地方法栈
  • 2. 共享数据区域
  • 2.1 方法区
  • 2.1.1 运行时常量池
  • 2.2 Java 堆


一、前言

在面试中经常会有面试官问起 Java 的内存模型,很多同学甚至连面试官都以为是方法区、堆、程序计数器等等那些,但实际上他们都搞混了 Java 内存模型和 Java 内存区域的概念。

如果面试官问你 Java 内存模型,你就要把正确的内存模型讲出来了。如果面试官说是那个方法区什么的,你先别着急反驳他,直接把内存区域也讲一遍。然后面完之后这家公司差不多就可以淘汰了,面试官都这么没水平,哈哈。

JVM 中的堆啊、栈啊、方法区什么的,是 Java 虚拟机的内存区域,Java 程序启动后,会初始化这些内存的数据。内存区域就是下图中运行时数据区这些东西,而 Java 内存模型,完全是另外一个东西。

JAVA 内存模式 根据条件检索对象 java内存区域和内存模型_JAVA 内存模式 根据条件检索对象

二、Java 内存模型(JMM)

Java内存模型(Java Memory Model , JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了 Java 程序在各种平台下对内存的访问都能保证效果一致的机制及规范。

光从定义看 JMM 是很抽象的,我们先了解一下为什么不同的硬件和操作系统之间会有访问差异。

1. CPU 和 内存的交互

在计算机中,CPU 和内存的交互最为频繁。相比内存,磁盘的读写太慢,内存相当于高速的缓冲区。但是随着 CPU 的发展,普通内存的读写速度也远远赶不上 CPU了,因此 CPU 厂商在每颗 CPU 上加上高速缓存,用于缓解这种情况。现在 CPU 和内存的交互大致如下。

JAVA 内存模式 根据条件检索对象 java内存区域和内存模型_Java_02

高速缓存的引进,让高速的 CPU 和相对低速的内存之间有个缓冲。但是这也引来了一个新的问题:缓存一致性。所以引入了缓存一致性协议来解决这一问题,但是不同物理硬件和操作系统用的缓存一致性协议可能不一样。

正是因为不同的物理硬件和操作系统的内存模型不太一致,所以 Java 为了实现一次编译到处运行的特性,就自己在物理硬件和操作系统层面之上搞了一套 Java 自己的内存模型。

2. Java 内存模型中的主内存与工作内存

Java 线程、主内存、工作内存三者的交互关系:

JAVA 内存模式 根据条件检索对象 java内存区域和内存模型_jmm_03

可以看到,这个交互关系跟硬件层面的交互关系很类似。关于工作内存跟主内存之间具体的交互协议,Java 内存模型定义了 8 种操作来完成,每一种操作都是原子的。

lock, unlock, read, load, use, assign, store, write.

这里就不展开讲这 8 种操作的含义和需要满足的规则。

3. volatile 关键字

当一个变量定义为 volatile 之后,它将具备两种特性:

  • 保证此变量对所有线程的可见性。
  • 禁止重排优化。

3.1 可见性

通过以下代码测试 volatile 的可见性。

/**
 * @author nick
 * @date 19.7.16
 */
public class TestVolatile {
    static int var = 1; // 这里加与不加 volatile 关键字会有不同的执行结果。

    public static void main(String[] args) {
        Write write = new Write();
        Read read = new Read();
        write.start();
        read.start();

    }

    static class Write extends Thread {
        @Override
        public void run() {
            for (int i = 0; i < 20; i++) {
                var = i;
                System.out.println("write:" + i);
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    static class Read extends Thread {
        @Override
        public void run() {
            while (true){
                if (var == 10){
                    System.out.println("Now, var in Thread Read is 10.");
                    break;
                }
            }
        }
    }
}

如果 var 变量加了 volatile 关键字的话,控制台打印完 write:10 之后会马上打印Now, var in Thread Read is 10. 。说明 Read 线程获取到了 Write 线程写的值。

如果没加 volatile 关键字,Write 线程循环打印完 20 次后就结束了,但是 Read 线程却一直在 while 循环中,因为它拿到的 var 变量的值一直是 1 。Write 线程对 var 变量的操作对 Read 线程并不可见。

3.2 禁止重排优化

volatile 关键字另一个作用就是禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象。先了解一个概念,内存屏障 (Memory Barrier)。 内存屏障,又称内存栅栏,是一个 CPU 指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现 volatile 的内存可见性)。由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条 Memory Barrier 则会告诉编译器和 CPU,不管什么指令都不能和这条 Memory Barrier 指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier 的另外一个作用是强制刷出各种 CPU 的缓存数据,因此任何 CPU 上的线程都能读取到这些数据的最新版本。总之,volatile 变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。下面看一个非常典型的禁止重排优化的例子,如下:

/**
 * @author nick
 * @date 19.7.16
 */
public class DoubleCheckLock {

    private static DoubleCheckLock instance;

    private DoubleCheckLock(){}

    public static DoubleCheckLock getInstance(){
        // 第一次检测
        if (instance == null){
            // 同步
            synchronized (DoubleCheckLock.class){
                if (instance == null){
                    // 多线程环境下可能会出现问题的地方
                    instance = new DoubleCheckLock();
                }
            }
        }
        return instance;
    }
}

上述代码一个经典的单例的双重检测的代码,这段代码在单线程环境下并没有什么问题,但如果在多线程环境下就可以出现线程安全问题。原因在于某一个线程执行到第一次检测,读取到的 instance 不为 null 时,instance 的引用对象可能没有完成初始化。因为 instance = new DoubleCheckLock(); 可以分为以下 3 步完成(伪代码)。

memory = allocate(); // 1.分配对象内存空间
instance(memory);    // 2.初始化对象
instance = memory;   // 3.设置 instance 指向刚分配的内存地址,此时 instance != null

由于步骤 1 和步骤 2 间可能会重排序,如下:

memory = allocate(); // 1.分配对象内存空间
instance = memory;   // 3.设置 instance 指向刚分配的内存地址,此时 instance != null ,但是对象还没有初始化完成!
instance(memory);    // 2.初始化对象

由于步骤 2 和步骤 3 不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。但是指令重排只会保证串行语义的执行的一致性(单线程),但并不会关心多线程间的语义一致性。所以当一条线程访问 instance 不为 null 时,由于 instance 实例未必已初始化完成,也就造成了线程安全问题。那么该如何解决呢,很简单,我们使用volatile 禁止 instance 变量被执行指令重排优化即可。

// 禁止指令重排优化
  private volatile static DoubleCheckLock instance;

三、Java 内存区域

JAVA 内存模式 根据条件检索对象 java内存区域和内存模型_Java_04

1. 私有数据区域

私有数据区域包含虚拟机栈、本地方法栈、程序计数器。

1.1 虚拟机栈

每个 Java 线程启动之后都会初始化一个虚拟机栈,是线程私有的。在虚拟机栈里面有很多栈帧,没调用一个方法就会往虚拟机栈里面压入一个栈帧,方法返回就会出栈。栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。

JAVA 内存模式 根据条件检索对象 java内存区域和内存模型_jvm_05

虚拟机栈有两种异常情况:StackOverflowError 和 OutOfMemoryError。我们知道,一个线程拥有一个自己的栈,这个栈的大小决定了方法调用的可达深度(递归多少层次,或嵌套调用多少层其他方法,-Xss 参数可以设置虚拟机栈大小),若线程请求的栈深度大于虚拟机允许的深度,则抛出 StackOverFlowError 异常。此外,栈的大小可以是固定的,也可以是动态扩展的,若虚拟机栈可以动态扩展(大多数虚拟机都可以),但扩展时无法申请到足够的内存(比如没有足够的内存为一个新创建的线程分配栈空间时),则抛出 OutofMemoryError 异常。

1.2 程序计数器

我们知道,线程是 CPU 调度的基本单位。在多线程情况下,当线程数超过 CPU 数量或 CPU 内核数量时,线程之间就要根据 时间片轮询抢夺 CPU 时间资源。也就是说,在任何一个确定的时刻,一个处理器都只会执行一条线程中的指令。因此,为了线程切换后能够恢复到正确的执行位置,每条线程都需要一个独立的程序计数器去记录其正在执行的字节码指令地址。

因此,程序计数器是线程私有的一块较小的内存空间,其可以看做是当前线程所执行的字节码的行号指示器。如果线程正在执行的是一个 Java 方法,计数器记录的是正在执行的字节码指令的地址;如果正在执行的是 Native 方法,则计数器的值为空。

程序计数器是唯一一个没有规定任何 OutOfMemoryError 的区域。

1.3 本地方法栈

本地方法栈与 Java 虚拟机栈非常相似,也是线程私有的,区别是虚拟机栈为虚拟机执行 Java 方法服务,而本地方法栈为虚拟机执行 Native 方法服务。与虚拟机栈一样,本地方法栈区域也会抛出 StackOverflowError 和 OutOfMemoryError 异常。

JAVA 内存模式 根据条件检索对象 java内存区域和内存模型_JAVA 内存模式 根据条件检索对象_06

java.lang.Thread 类里面的 yield 方法就是 native 方法。

2. 共享数据区域

2.1 方法区

方法区与 Java 堆一样,也是线程共享的并且不需要连续的内存,其用于存储已被虚拟机加载的 类信息、常量、静态变量、即时编译器编译后的代码等数据。方法区通常和永久区(Perm)关联在一起,但永久代与方法区不是一个概念,只是有的虚拟机用永久代来实现方法区,这样就可以用永久代 GC 来管理方法区,省去专门内存管理的工作。根据 Java 虚拟机规范的规定,当方法区无法满足内存分配的需求时,将抛出 OutOfMemoryError 异常。

2.1.1 运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分,用于存放编译期生成的各种 字面量 和 符号引用。其中,字面量比较接近 Java 语言层次的常量概念,如文本字符串、被声明为 final 的常量值等;而符号引用则属于编译原理方面的概念,包括以下三类常量:类和接口的全限定名、字段的名称和描述符和方法的名称和描述符。因为运行时常量池(Runtime Constant Pool)是方法区的一部分,那么当常量池无法再申请到内存时也会抛出 OutOfMemoryError 异常。

运行时常量池相对于 Class 文件常量池的一个重要特征是具备动态性。Java 语言并不要求常量一定只有编译期才能产生,运行期间也可能将新的常量放入池中,比如字符串的手动入池方法 intern()。

2.2 Java 堆

Java 堆的唯一目的就是存放对象实例,几乎所有的对象实例(和数组)都在这里分配内存。Java 堆是线程共享的,类的对象从中分配空间,这些对象通过 new、newarray、 anewarray 和 multianewarray 等指令建立,它们不需要程序代码来显式的释放。

由于 Java 堆唯一目的就是用来存放对象实例,因此其也是垃圾收集器管理的主要区域,故也称为称为 GC 堆。从内存回收的角度看,由于现在的垃圾收集器基本都采用分代收集算法,所以为了方便垃圾回收 Java 堆还可以分为新生代和老年代。新生代用于存放刚创建的对象以及年轻的对象,如果对象一直没有被回收,生存得足够长,对象就会被移入老年代。新生代又可进一步细分为 eden、s0 和 s1 。堆结构图如下:

JAVA 内存模式 根据条件检索对象 java内存区域和内存模型_java_07

**注意:Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。**而且,Java 堆在实现时,既可以是固定大小的,也可以是可拓展的,并且主流虚拟机都是按可扩展来实现的(通过 -Xmx(最大堆容量)和 -Xms(最小堆容量)控制)。如果在堆中没有内存完成实例分配,并且堆也无法再拓展时,将会抛出 OutOfMemoryError 异常。


参考资料: