谈谈 Java 内存的管理

By Siu 2022/3/31

从 VM、GC 语言角度,JavaEr 很少会关注到内存的管理,但是所有程序的执行都避不开对内存使用的申请,以及回收;从现有主流的语言来看内存的管理大致会分为3类:

  • 使用和分配都由用户去决定;C 就是一个代表
  • 使用由用户来关注,回收交给 GC;典型如 Java,GO
  • 由编译系统来管理:Rust 的所有权系统就是这样的一个强大的内存管理系统

C 选择了“相信”用户,Java 选择了”包容“用户,Rust 选择了“教育”用户。

Java 用GC 给用户带来了友好,只需要关注定义、赋值、创建对象,其它交给 GC;孰优孰劣,不是今天的主题,还是回到 Java 内存的管理是怎样的?

栈和堆

栈和堆是编程语言中最基础的数据结构,栈和堆的的作用就是为程序提供运行时的内存空间。

栈(Stack)

栈是先入后出(FILO),可以类比为叠盘子,增加一个盘子只能从顶部(入栈),取下一个盘子只能从顶部(出栈)。

栈中的所有数据都必须占用已知且固定大小的内存空间。

堆(Heap)

与栈不同,对于大小未知或者可能变化的数据,我们需要将它存储在堆上。

当向堆上放入数据时,需要请求一定大小的内存空间。

性能

写入方面:入栈比在堆上分配内存要快,因为入栈时操作系统无需分配新的空间,只需要将新数据放入栈顶即可。相比之下,在堆上分配内存则需要更多的工作,这是因为操作系统必须首先找到一块足够存放数据的内存空间,接着做一些记录为下一次分配做准备。

读取方面:栈数据往往可以直接存储在 CPU 高速缓存中(高速缓存和内存的访问速度差异在 10 倍以上!),而堆数据只能存储在内存中。访问堆上的数据比访问栈上的数据慢,因为必须先访问栈再通过栈上的指针来访问内存。

因此,处理器处理和分配在栈上数据会比在堆上的数据更加高效。

JVM 规范

JVM 规范定义

JVM

注:

  • 类加载系统:负责从文件系统或是从网络中加载class信息,加载的信息存放在一个称之为“方法区”的内存空间

  • 执行引擎:是jvm非常核心的组件,它负责执行jvm的字节码,一般先会编译成机器码后执行。

  • 垃圾收集系统:GC垃圾回收,保证我们程序能够有足够的内存空间运行,回收掉内存中已经无效的数据。回收算法一般有标记清除算法,复制算法,标记整理算法等。

JVM 的内存结构(Runtime Data Area)

jvm-runtime-data-area

JVM 规范定义中的内存模型如上图中的运行时数据区中的描述,有以下主要定义:

  • 方法区(Method Area):存储已被类加载系统加载的类信息、常量、静态变量等;

    • JVM 规范中的定义:实现有“元空间”、“永久代”;

    • 方法区在JDK7及之前,是由堆中的”永久代(PermGen)“作为实现,逻辑上与堆是连续的内存空间;

    • JDK7 开始移除永久代,在 JDK8时正式被移除,方法区的定义由元空间在本地内存中实现;

    • 内存异常:OutOfMemoryError:Metaspace、OutOfMemoryError:PermGen space

  • VM 栈(Java Virtual Machine Stack):

    • 描述方法执行过程的内存模型;
    • 方法执行时,同时会创建一个栈镇(Stack Frame)用于存储:
      • 局部变量表:方法参数、方法体内局部变量、基本类型、对象引用(指针)、返回地址类型

方法区(Method Area)

方法区(Methed Area)用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。

知识点:方法区、永久代(PermGen space)、 Metaspace(元空间)的区别

方法区, 是 《JVM 规范》 定义的,所有虚拟机必须有的。 针对 HotSpot 虚拟机 :

  • JDK7及之前, PermGen space 就是 方法区。
  • JDK8及之后, PermGen space 被移除, 换成 Metaspace(元空间),也是对方法区的新的实现。

其实,移除永久代的工作从JDK1.7就开始了。JDK1.7中,存储在永久代的部分数据就已经转移到了Java Heap或者是 Native Heap。但永久代仍存在于JDK1.7中,并没完全移除,譬如符号引用(Symbols)转移到了native heap;字面量(interned strings)转移到了java heap;类的静态变量(class statics)转移到了java heap。

元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存

永久代 Java7及以前版本的Hotspot中方法区位于永久代中。同时,永久代和堆是相互隔离的,但它们使用的物理内存是连续的。也有将方法去归于堆的,但称之为非堆。

元空间

在Java8中,元空间(Metaspace)登上舞台,方法区存在于元空间(Metaspace)。同时,元空间不再与堆连续,而且是存在于本地内存(Native memory)。

元空间存在于本地内存,意味着只要本地内存足够,它不会出现像永久代中“java.lang.OutOfMemoryError: PermGen space”这种错误。

Metaspace 区域位于堆外,所以它的最大内存大小取决于系统内存,而不是堆大小。

默认情况下元空间是可以无限使用本地内存的,但为了不让它如此膨胀,JVM同样提供了参数来限制它使用的使用。

  • -XX:MetaspaceSize,metadata的初始空间配额,以bytes为单位,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当的降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize(如果设置了的话),适当的提高该值。
  • -XX:MaxMetaspaceSize,可以为metadata分配的最大空间。默认是没有限制的。

堆(Heap)

Java堆(Java Heap)是Java虚拟机中内存最大的一块,是被所有线程共享的,在虚拟机启动时候创建,Java堆唯一的目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。

这个区域被划分为年轻代和老年代的,我们经常接触的GC垃圾回收机制,就是主要回收堆空间的垃圾数据。 堆空间里的数据,是被所有线程所共享的,所以会存在线程安全问题,所以那些锁就是为了解决堆空间数线程安全问题而生的。

随着JIT编译器的发展和逃逸分析技术的逐渐成熟,栈上分配、标量替换优化的技术将会导致一些微妙的变化,所有的对象都分配在堆上渐渐变得不那么“绝对”了。

VM 栈

Java虚拟机栈(Java Virtual Machine Stacks)描述的是Java方法执行的内存模型。

每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储:

  • 局部变量表(Local Variable Array)

  • 操作数栈(Operand Stack)

  • 动态链接(Dynamic Linking)

  • 返回地址(Return Address)

  • 指向运行时常量池的引用

每个方法从调用直至执行完成的过程,都对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

栈空间是每个线程独有的,互相直接不能访问。

知识点:动态链接

每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用,包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接(Dynamic Linking)。比如:invokedynamic指令

在Java源文件被编译到字节码文件时,所有的变量和方法引用都作为符号引用(Symbilic Reference)保存在class文件的常量池里。

比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,动态链接的作用就是为了将这些符号引用转为调用方法的直接引用

压栈出栈过程

当方法运行过程中需要创建局部变量时,就将局部变量的值存入栈帧中的局部变量表中。

Java 虚拟机栈的栈顶的栈帧是当前正在执行的活动栈,也就是当前正在执行的方法,PC 寄存器也会指向这个地址。只有这个活动的栈帧的本地变量可以被操作数栈使用,当在这个栈帧中调用另一个方法,与之对应的栈帧又会被创建,新创建的栈帧压入栈顶,变为当前的活动栈帧。

方法结束后,当前栈帧被移出,栈帧的返回值变成新的活动栈帧中操作数栈的一个操作数。如果没有返回值,那么新的活动栈帧中操作数栈的操作数没有变化。

由于 Java 虚拟机栈是与线程对应的,数据不是线程共享的(也就是线程私有的),因此不用关心数据一致性问题,也不会存在同步锁的问题。

局部变量表

定义为一个数字数组,主要用于存储方法参数、定义在方法体内部的局部变量,数据类型包括各类基本数据类型,对象引用,以及 return address 类型

局部变量表容量大小是在编译期确定下来的。最基本的存储单元是 slot,32 位占用一个 slot,64 位类型(long 和 double)占用两个 slot。

对于 slot 的理解:

  • JVM 虚拟机会为局部变量表中的每个 slot 都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值。
  • 如果当前帧是由构造方法或者实例方法创建的,那么该对象引用 this,会存放在 index 为 0 的 slot 处,其余的参数表顺序继续排列。
  • 栈帧中的局部变量表中的槽位是可以重复的,如果一个局部变量过了其作用域,那么其作用域之后申明的新的局部变量就有可能会复用过期局部变量的槽位,从而达到节省资源的目的。

在栈帧中,与性能调优关系最密切的部分,就是局部变量表,方法执行时,虚拟机使用局部变量表完成方法的传递局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都不会被回收。

操作数栈
  • 栈顶缓存技术:由于操作数是存储在内存中,频繁的进行内存读写操作影响执行速度,将栈顶元素全部缓存到物理 CPU 的寄存器中,以此降低对内存的读写次数,提升执行引擎的执行效率。
  • 每一个操作数栈会拥有一个明确的栈深度,用于存储数值,最大深度在编译期就定义好。32bit 类型占用一个栈单位深度,64bit 类型占用两个栈单位深度操作数栈。
  • 并非采用访问索引方式进行数据访问,而是只能通过标准的入栈、出栈操作完成一次数据访问。
方法的调用
  • 静态链接:当一个字节码文件被装载进 JVM 内部时,如果被调用的目标方法在编译期可知,且运行时期间保持不变,这种情况下降调用方的符号引用转为直接引用的过程称为静态链接。
  • 动态链接:如果被调用的方法无法再编译期被确定下来,只能在运行期将调用的方法的符号引用转为直接引用,这种引用转换过程具备动态性,因此被称为动态链接。
  • 方法绑定
    • 早期绑定:被调用的目标方法如果再编译期可知,且运行期保持不变。
    • 晚期绑定:被调用的方法在编译期无法被确定,只能够在程序运行期根据实际的类型绑定相关的方法。
  • 非虚方法:如果方法在编译期就确定了具体的调用版本,则这个版本在运行时是不可变的。这样的方法称为非虚方法静态方法,私有方法,final 方法,实例构造器,父类方法都是非虚方法,除了这些以外都是虚方法。
  • 虚方法表:面向对象的编程中,会很频繁的使用动态分配,如果每次动态分配的过程都要重新在类的方法元数据中搜索合适的目标的话,就可能影响到执行效率,因此为了提高性能,JVM 采用在类的方法区建立一个虚方法表,使用索引表来代替查找。
    • 每个类都有一个虚方法表,表中存放着各个方法的实际入口。
    • 虚方法表会在类加载的链接阶段被创建,并开始初始化,类的变量初始值准备完成之后,JVM 会把该类的方法也初始化完毕。
  • 方法重写的本质
    • 找到操作数栈顶的第一个元素所执行的对象的实际类型,记做 C。如果在类型 C 中找到与常量池中描述符和简单名称都相符的方法,则进行访问权限校验。
    • 如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回 java.lang.IllegalAccessError 异常。
    • 否则,按照继承关系从下往上依次对 C 的各个父类进行上一步的搜索和验证过程。
    • 如果始终没有找到合适的方法,则抛出 java.lang.AbstractMethodError 异常。

Java 中任何一个普通方法都具备虚函数的特征(运行期确认,具备晚期绑定的特点),C++ 中则使用关键字 virtual 来显式定义。如果在 Java 程序中,不希望某个方法拥有虚函数的特征,则可以使用关键字 final 来标记这个方法。

Java 虚拟机栈的特点
  • 运行速度特别快,仅仅次于 PC 寄存器。
  • 局部变量表随着栈帧的创建而创建,它的大小在编译时确定,创建时只需分配事先规定的大小即可。在方法运行过程中,局部变量表的大小不会发生改变。
  • Java 虚拟机栈会出现两种异常:StackOverFlowError 和 OutOfMemoryError。
    • StackOverFlowError 若 Java 虚拟机栈的大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度时,抛出 StackOverFlowError 异常。
    • OutOfMemoryError 若允许动态扩展,那么当线程请求栈时内存用完了,无法再动态扩展时,抛出 OutOfMemoryError 异常。
  • Java 虚拟机栈也是线程私有,随着线程创建而创建,随着线程的结束而销毁。
  • 出现 StackOverFlowError 时,内存空间可能还有很多。

本地方法栈

本地方法栈(Native Method Stack)与虚拟机栈的作用是一样的,只不过 VM栈是服务Java方法的,而本地方法栈是为调用Native方法服务的(即JDK中用native修饰的方法)。

在Java虚拟机规范中对于本地方法栈没有特殊的要求,虚拟机可以自由的实现它,因此在Sun HotSpot虚拟机直接把本地方法栈和虚拟机栈合二为一了。

程序计数器/寄存器

程序计数器是一块较小的内存空间,是当前线程正在执行的那条字节码指令的地址。若当前线程正在执行的是一个本地方法,那么此时程序计数器为Undefined

作用
  • 字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制。
  • 在多线程情况下,程序计数器记录的是当前线程执行的位置,从而当线程切换回来时,就知道上次线程执行到哪了。
特点
  • 是一块较小的内存空间。
  • 线程私有,每条线程都有自己的程序计数器。
  • 生命周期:随着线程的创建而创建,随着线程的结束而销毁。
  • 是唯一一个不会出现 OutOfMemoryError 的内存区域。

直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但这部分也是被频繁的读写使用,也可能会导致OutOfMemoryError异常的出现。

Java的 NIO中的allocateDirect方法是可以直接使用直接内存的,能显著的提高读写的速度。

从线程共享角度看内存区域

再看 Java 中的栈内存和堆内存

引用

字符串

GC

OOM

逃逸分析

另一种实现:Netty 中的内存管理

ref

Java 虚拟机规范(英文)

方法区、永久代、元空间辨析

JVM 的组成

堆内存:年轻代、年老代、永久代

Java 8 内存模型:永久区、元空间实例代码测试

元空间和直接内存

jverson.com/thinking-in-java/

栈帧中的动态链接作用是什么?

深入理解虚拟机笔记

直接内存溢出

JVM参数设置-jdk8

Java 字节码子令集概述

字节码和字节码分析

深入理解 JVM 中的 returnAddress