物理计算机的效率和一致性

物理计算机和虚拟机有一定的相似性,所以虚拟机和物理机在处理并发问题的方案也有相似的地方。

由于存储设备和处理器存在巨大的运算速度差距,所以物理机加入了读写速度尽可能接近处理器运算速度高速缓存。运算时将运算所需要的数据先复制到缓存中,当运算结束后再从缓存中同步到内存,如下图所示:

image-20221104143811642

缓存一致性

每个处理器都有自己的高速缓存,但是只共享同一个主内存,所以当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。为了解决缓存一致性问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作。

乱序执行优化

物理计算机中,处理器可以针对输入打乱后再执行,执行结束后 再将结果重组,保证该结果与顺序执行的结果是一致的。乱序执行不能保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,所以如果一个任务依赖另外一个任务的中间计算结果,那么其顺序性并不能靠代码的先后顺序来保证。和处理器的乱序执行优化类似,Java 虚拟机的即使编译器也有指令重排序优化。

Java 内存模型

不同计算机的硬件和不同操作系统的内存模型存在一定的差异,所以 JVM 试图规范定义一种内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,让 Java 程序在各个平台下都能达到一致的内存访问效果。

主内存和工作内存

主内存中存储了所有的变量,而工作内存是每条线程私有的,其中保存了主内存中线程需要的变量的副本。

线程对于变量的所有操作都在工作内存中进行,线程之间无法直接访问对方工作内存中的变量,变量值的传递必须通过主内存。

image-20221104144307177

一般来说,主内存主要对应于 Java 堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。从更低层次上说,主内存就直接对应于物理硬件的内存,工作内存对应于寄存器和高速缓存,这是因为程序运行时主要访问读写的是工作内存,可以获得更好的运行速度。

内存间交互

关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java 内存模型中定义了 8 种操作,虚拟机必须保证每一种操作都是原子的、不可再分的。8 种操作中前 4 种作用于主内存,后 4 种作用于工作内存:

  • lock(锁定):把一个变量标识为一条线程独占的状态。
  • unlock(解锁):把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
  • write(写入):把 store 操作从工作内存中得到的变量的值放入主内存的变量中。
  • load(载入):把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。

部分操作的流程为:

image-20221106021916459

在 Java 内存模型中,如果要把一个变量从主内存复制到工作内存,就必须顺序地执行 read 和 load 操作,如果要把变量从工作内存同步回主内存,就必须顺序地执行 store 和 write 操作。

运行时数据区域

Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域。其中方法区和堆为线程共享,程序计数器、虚拟机栈和本地方法栈是线程私有的。

image-20221104161711312

程序计数器

Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间实现的。一个处理器在任何时刻只会执行一条线程中的指令,所以为了线程切换后可以回到正确的执行位置,每个线程就需要有一个独立的程序计数器记录当前线程执行的字节码的行号。

如果线程正在执行的是一个 Java 方法,程序计数器记录的就是正在执行的虚拟机字节码指令的地址,如果正在执行的是 Native 方法,程序计数器值则为空。

程序计数器是唯一一个在 Java 虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

Java 虚拟机栈

常说的栈内存(stack)就是虚拟机栈,或者说是虚拟机栈中局部变量表部分。

虚拟机栈描述的是 Java 方法执行时的内存模型:每个方法在执行时都会创建一个栈帧,方法从调用直至执行完成的过程,就对应着栈帧在虚拟机栈中入栈到出栈的过程。

栈帧中存储了局部变量表、操作数栈、动态链接、方法出口等信息:

  • 局部变量表:存放了编译期可知的各种基本数据类型、对象引用和 returnAddress 类型。局部变量表需要的内存空间在编译期间分配完成,进入一个方法时帧中的局部变量表大小是完全确定的,在方法运行期间也不会改变。
  • 动态链接:动态链接是在运行时将符号引用解析为直接引用的过程。
  • 操作数:参与运算的常量或者变量称为操作数。

Java 虚拟机栈中可能抛出以下异常:

  • 当线程请求的栈深度超过最大值,会抛出StackOverflowError异常。
  • 栈进行动态扩展时如果无法申请到足够内存,会抛出OutOfMemoryError异常。

本地方法栈

本地方法栈与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行 Java 方法服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowErrorOutOfMemoryError异常。

Java 堆

Java 堆在虚拟机启动时创建,并且被所有线程共享,是 Java 虚拟机管理的内存中最大的一块内存区域。它的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。Java 虚拟机规范对 Java 堆的描述是:所有的对象实例以及数组都要在堆上分配。

由于 Java 堆占据了大片的内存区域并且存放了很多对象实例,所以是垃圾收集器管理的主要区域。使用分代收集算法时,Java 堆可以细分为新生代和老年代:

image-20221105144855123

其中 Eden 区、两个 Survivor 区 S0 和 S1 都属于新生代,中间一层属于老年代。大部分情况,新创建的对象都会先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 S0 或者 S1,并且对象的年龄还会加 1,当它的年龄增加到一定程度,就会被晋升到老年代中。

Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

方法区

方法区是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。方法区和堆一样不需要连续的内存,并且可以动态扩展,失败同样会抛出OutOfMemoryError异常。

JDK1.8 之前使用永久代实现方法区,但是永久代的回收效率低且大小在每次 Full GC 之后都会改变,所以经常抛出OutOfMemoryError异常。JDK1.8 之后彻底废除永久代,转而使用元空间实现方法区。元空间位于本地内存,所以大小仅受本地内存的约束。之前永久代中的字符串常量池、静态变量被移动到堆中,类信息被移动到本地内存中的元空间。

为什么要用元空间替换永久代?

整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,可以加载的类更多。虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。

控制参数

可以通过如下参数来控制各区域的内存大小:

  • -Xms:设置堆的初始大小。

  • -Xmx:设置堆的最大值。

  • -Xss:设置单个线程的栈大小。

  • -XX:NewRatio:老年代和新生代的比例。

  • -XX:NewSize:新生代对象生成时占用内存的默认大小。

  • -XX:MaxNewSize:新生代对象占用内存的最大值。

参考

  1. 深入理解Java虚拟机:JVM高级特性与最佳实践(第2版),周志明
  2. 浅谈Java内存模型以及交互
  3. Java 启动参数,知乎
  4. Java 启动参数