JVM_01_内存与垃圾回收_03_运行时数据区


前面讲解了类加载子系统,通过这一步,将class文件加载到运行时数据区。然后执行引擎通过对内存中的这些数据进行操作,从而运行程序。本文介绍运行时数据区的具体内容。

1. 概述

1.1 JVM运行时数据区

运行时数据区就是内存中的一块空间,这块空间由JVM负责管理维护。

而内存是非常重要的系统资源,是硬盘和CPU的中间仓库和桥梁,承载着操作系统和应用程序的实时运行。JVM内存布局规定了Java在运行过程中内存申请、分配、管理的策略,保证了JVM的高效稳定运行。不同的JVM对于内存的划分方式和管理机制存在着部分差异,不过总体上大差不差。本文针对默认虚拟机Hotspot讲解。

运行时数据区主要和类加载子系统、执行引擎以及本地方法接口(本地方法库,用于调用C/C++程序库)交互。分为以下5个部分:方法区、堆、程序计数器、本地方法栈、虚拟机栈。

image-20220731094616260

阿里官方手册提供的比较详细的结构图如下所示,其中元数据区以及JIT编译产物可认为是方法区:

image-20220731094858126

注意,Java虚拟机定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一一对应的,这些与线程对应的数据区域随着线程开始和结束而创建和销毁。

  • 方法区和堆区只有一份,多线程共享,所以要保证线程安全。
  • 程序计数器、本地方法栈、虚拟机栈是单线程共享,有多少个线程就有多少份这些内容。

因此,垃圾回收以及JVM优化主要针对堆区,因为其他三者是单线程的,而方法区则是加载一份。所以优化空间较大的则是堆区。

每个JVM只有一个Runtime实例,这个实例相当于运行时数据区,java.lang.Runtime。在程序中可通过该对象实例(或者静态方法)来获取JVM中运行时数据区的一些信息。

1.2 线程

  • 线程是一个程序里的运行单元。JVM允许一个应用有多个线程并行执行。
  • 在Hotspot JVM中,每个都与操作系统的本地线程直接映射。
    • 当一个Java线程准备好执行以后,此时一个操作系统的本地线程也会同时创建。Java线程执行终止后,本地线程也会回收。
    • 这里的线程准备好,指的就是程序计数器、栈结构、缓存分配等等。
  • 操作系统负责所有线程的安排调度到任何一个可用的CPU上。一旦本地线程初始化成功,它就会调用Java线程中的run()方法。

在Java程序运行过程中,除了main线程以及该线程创建的线程之外,还有以下几个重要的后台线程:

  1. 虚拟机线程

    这种线程的操作是需要JVM达到安全点才会出现。这些操作必须在不同的线程中发生的原因是他们都需要JVM达到安全点,这样堆才不会变化。这种线程的执行类型包括“stop-the-world”的垃圾收集,编程栈收集,线程挂起以及偏向锁撤销。(不懂)

  2. 周期任务线程

    这种线程是时间周期事件的体现(比如中断),他们一般用于周期性操作的调度执行。

  3. GC线程

    这种线程对在JVM里不同种类的垃圾收集行为提供了支持。

  4. 编译线程

    这种线程在运行时会将字节码编程成本地代码。

  5. 信号调度线程

    这种线程接收信号并发送给JVM,在它内部通过调用适当的方法进行处理。

2. 程序计数器(PC寄存器)

The Java Virtual Machine can support many threads of execution at once (JLS §17). Each Java Virtual Machine thread has its own pc (program counter) register. At any point, each Java Virtual Machine thread is executing the code of a single method, namely the current method (§2.6) for that thread. If that method is not native, the pc register contains the address of the Java Virtual Machine instruction currently being executed. If the method currently being executed by the thread is native, the value of the Java Virtual Machine’s pc register is undefined. The Java Virtual Machine’s pc register is wide enough to hold a returnAddress or a native pointer on the specific platform.

JVM中的程序计数寄存器(Program Counter Register)中,Register的命名源于CPU的寄存器,寄存器存储指令相关的现场信息。CPU只有把数据装载到寄存器才能够运行。

不过JVM中的寄存器并不是计算机中的物理寄存器,而是软件层面,是对物理PC寄存器的一种抽象模拟。本质上说,PC寄存器用来存储指向下一条指令的地址,也就是即将要执行的指令代码。由执行引擎读取下一跳指令

我们知道,后续讲到的栈,每个栈帧保存着要执行的代码,栈帧里面有若干行,因此寄存器会保存下一个要执行的指令的行号,执行引擎读取寄存器,然后到对应的行找到指令,去执行。

PC寄存器有以下说明:

  1. 它是一块很小的内存空间,几乎可以忽略不计。也是运行速度最快的存储区域。
  2. 在JVM规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。
  3. 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。而方法分为自定义方法和本地方法(C/C++库方法)。PC寄存器只会记录虚拟机栈中方法的指令行号,不会记录本地法方法栈中的内容,所以当执行的是本地方法栈时,PC寄存器保存的内容是undefined。
  4. 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
  5. 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
  6. 它是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

关于PC寄存器的两个常见问题:

  1. 使用PC寄存器存储字节码指令地址有什么用呢?为什么使用PC寄存器记录当前线程的执行地址呢?

    因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行。JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令。

  2. PC寄存器为什么会被设定为线程私有?

    因为线程是程序运行的基本单元,而多线程并发执行会频繁切换,如果不是线程私有,显然无法保存当前线程下一个要执行的指令,当切换回来之后,无法继续向下执行了。为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程分配一个PC寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。

既不存在垃圾回收(GC),也不存在内存溢出(OutOfMemory)问题。

3. 虚拟机栈

3.1 概述

Each Java Virtual Machine thread has a private Java Virtual Machine stack, created at the same time as the thread. A Java Virtual Machine stack stores frames (§2.6). A Java Virtual Machine stack is analogous to the stack of a conventional language such as C: it holds local variables and partial results, and plays a part in method invocation and return. Because the Java Virtual Machine stack is never manipulated directly except to push and pop frames, frames may be heap allocated. The memory for a Java Virtual Machine stack does not need to be contiguous.

In the First Edition of The Java® Virtual Machine Specification, the Java Virtual Machine stack was known as the Java stack.

This specification permits Java Virtual Machine stacks either to be of a fixed size or to dynamically expand and contract as required by the computation. If the Java Virtual Machine stacks are of a fixed size, the size of each Java Virtual Machine stack may be chosen independently when that stack is created.

A Java Virtual Machine implementation may provide the programmer or the user control over the initial size of Java Virtual Machine stacks, as well as, in the case of dynamically expanding or contracting Java Virtual Machine stacks, control over the maximum and minimum sizes.

The following exceptional conditions are associated with Java Virtual Machine stacks:

  • If the computation in a thread requires a larger Java Virtual Machine stack than is permitted, the Java Virtual Machine throws a StackOverflowError.
  • If Java Virtual Machine stacks can be dynamically expanded, and expansion is attempted but insufficient memory can be made available to effect the expansion, or if insufficient memory can be made available to create the initial Java Virtual Machine stack for a new thread, the Java Virtual Machine throws an OutOfMemoryError.

由于跨平台性的设计,Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的。虚拟机栈的优点是跨平台,指令集小,编译器容易实现;缺点是性能下降,实现同样的功能需要更多的指令。

另外,栈是运行时的单位,而堆是存储的单位。也就是说,栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。而堆解决的是数据存储的问题,即数据怎么放、放在哪儿。

Java虚拟机栈(Java Virtual Machine Stack),早期也叫Java栈。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的Java方法调用,即一次方法调用对应一个栈帧。栈的生命周期和线程是一致的。虚拟机栈的作用是主管程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回

栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。JVM直接对Java栈的操作只有两个:

  • 每个方法执行,伴随着进栈(入栈、压栈)
  • 执行结束后的出栈工作

对栈来说不存在垃圾回收问题,但是存在OOM问题。Java虚拟机栈可能出现的异常有两个:

  1. StackOverflowError

    Java虚拟机规范允许Java栈的大小是动态的,或者固定不变的。如果是固定不变的,那在分配的时候,线程请求分配的栈空间可能超过了固定值,那么此时就会抛出栈溢出异常。

  2. OutOfMemoryError

    如果是动态可扩展的,那么在尝试扩展的时候,如果内存已经不够了,此时就是抛出内存不足异常。

如何设置虚拟机栈的大小呢?

采用-Xss size来设置,在运行程序的时候,即java运行指令携带参数,-Xss来指定栈空间大小,单位为byte。

3.2 栈的存储单位

栈的基本存储单位是栈帧,对应一次方法调用。每调用一次就压栈,执行完,就弹栈。栈顶的栈帧被称为当前栈帧,当前栈帧对应的方法称为当前方法。

注意,不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另一个线程的栈帧。如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃的当前栈帧,使得前一个栈帧重新称为当前栈帧。也就是弹栈,次栈顶称为新栈顶。

Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令;另外一种,是抛出异常。不管使用哪种方式,都会栈帧被弹出。因此,只要是方法返回结果,那么将结果返回给前一个栈帧(也就是本方法的调用者)。

3.3 栈帧的内部结构

每个栈帧包含以下5部分:

  1. 局部变量表(Local Variables)
  2. 操作数栈(Operand Stack)(表达式栈)
  3. 动态链接(Dynamic Linking)(或指向运行时常量池的方法引用)
  4. 方法返回地址(Return Address)(或方法正常退出或者异常退出的定义)
  5. 一些附加信息

我们知道,栈的大小限制了栈帧的个数,那么栈帧的大小也决定了栈中存储栈帧的个数。栈帧的大小,则主要取决于局部变量表和操作数栈。

另外,动态链接、方法返回地址和一些附加信息可统称为帧数据区。

3.3.1 局部变量表(重点)

  • 局部变量表也被称为局部变量数组或本地变量表。
  • 定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量。这些数据类型包括各类基本数据类型、对象引用(reference),以及returnAddress类型。注意,这是一个数组,数组中每个元素是方法体内的局部变量的引用或者基本变量。
  • 由于局部变量表示建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题
  • 局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。可用javap命令解析class文件,查看每个方法的LocalVariableTable字段。

测试如下,即在编译阶段就确定了局部变量表的大小:

1
2
3
4
5
6
7
public class Jvm_test02 {

public static void main(String[] args) {
System.out.println();

}
}

image-20220731161842878

方法嵌套调用的次数由虚拟机栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。

局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁

经过测试,可以发现,除了main方法之外,所以的成员方法都有一个默认的参数this,也出现在局部变量表中。

局部变量表是数组,数组存放变量。但是可以注意到,这里显然可以存放不同类型的参数,因此这里数组的基本存储单元并不是传统的元素,而是Slot(变量槽)。

  • 参数值的存放总是在局部变量数组的index0开始,到数组长度-1的索引结束。

  • 局部变量表最基本的存储单元是Slot(变量槽)。

  • 局部变量表中存放编译期可知的各种基本数据类型(8种),引用数据类型(reference),returnAddress类型的变量。

  • 在局部变量表里,32位以内的类型只占用一个Slot(包括引用类型、returnAddress类型),64位的类型(long和double)占用两个slot。

    • byte、short、char、float在存储前转换为int,boolean也被转换为int,0表示false,非0表示true。
    • long和double则占据两个slot。
  • JVM会为局部变量表中的每一个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值。

  • 当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量都会按照顺序被复制到局部变量表中的每一个Slot中。

  • 如果需要访问局部变量表中一个64bit的局部变量值时,只需要使用第一个slot的索引即可。

  • 如果当前帧是由构造方法或实例方法创建的,那么该对象引用this变量将会存放在index为0的slot处,其余参数按照顺序继续排列。

  • 注意,栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后声明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public void m3(){
    int a = 0;

    {
    int b = 0;
    b = a + 1;
    }

    // 此时在局部变量表中,c就会复用局部变量b的slot。
    int c = a + 1;
    }

    测试如下所示,可以看到c的index序号和b的重复了,其实就是复用了b的slot位置了。

    image-20220731171915640

注意,前面提到过,静态变量在类加载中的链接阶段和初始化阶段,会进行赋值和初始化操作。而这里的局部变量,则不存在上述过程,因此,这也就意味着,一旦定义了局部变量,必须认为赋值,否则就会报错。

补充:

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

3.3.2 操作数栈(重点)

每一个独立的栈帧中除了包含局部变量表之外,还包含一个后进先出的操作数栈,也可称之为表达式栈。

操作数栈,在方法执行过程中,根据字节码指令,往栈中写入数据或提取数据,即入栈/出栈。

  • 某些字节码指令将值压入操作数栈,其余的字节码指令将操作数取出栈。使用它们后再把结果压入栈。
  • 比如:执行复制、交换、求和等操作。

操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间操作数栈就是JVM执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这个方法的操作数栈是空的。

每一个操作数栈都会拥有一个明确的栈深度用于存储数值,和局部变量表一样,其所需的最大深度在编译期就定义好了,保存在方法的Code属性中,为max_stack的值【也就是图中stack的值】。栈中的任何一个元素都是可以任意的Java数据类型。

  • 32bit的类型占用一个栈单位深度
  • 64bit的类型占用两个栈单位深度

操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈(push)和出栈(pop)操作来完成一次数据访问

如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新PC寄存器中下一条需要执行的字节码指令

操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段再次验证。

另外,我们通常所说的:Java虚拟机的解释引擎是基于栈的执行引擎,这里的栈指的就是操作数栈。

image-20220731190055508

3.3.3 动态链接

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

在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用

如图所示,其实黄色的部分就是动态链接,其实就是保存的是方法区内存中的运行时常量池中的某个对象地址。

image-20220731204424309

注意,上面说的符号引用,指定就是当前的值不是真正的引用内容,而是引用内容在常量池中的地址。类似引用和对象内存地址的关系。下图中的#7、#21等等这些都是符号引用,而后面的//注释才是真正在常量池中的实际内容。那么为什么需要设置常量池,多一层符号引用呢?显然这是为了“代码复用”,其实就是内存复用,以#13为例,该符号引用表示输出流的输出方法,后续其他方法也有可能用到,这样只需要在常量池中加载一份即可。

image-20220731205030074

3.3.4 方法返回地址

  • 存放的是该方法的PC寄存器的值。【其实就是为了连接相邻栈帧,即调用栈帧结束之后,回到调用处继续执行】
  • 一个方法结束后,有两种方式:
    • 正常执行完成
    • 出现未处理的异常,非正常退出
  • 无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的PC计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出时,返回地址是通过异常表来确定,栈帧中一般不会保存这部分信息,而异常表则是由程序中的try{}catch(){}语句生成,指明了发生异常之后,下一条指令是哪里。【换句话说,通过异常退出的栈帧,不会给他的上层调用者产生任何的返回值】

总体上说,方法返回地址就是存储下一条要执行的指令。方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。

3.3.5 一些附加信息

栈帧中还允许携带与Java虚拟机实现相关的一些附加信息。例如,对程序调试提供支持的信息。

3.3.6 总结

虚拟机栈的基本结构是栈帧,对应着一次方法调用,也可认为是一组数据操作。首先方法中存在局部变量,因此需要用局部变量表存储这些数据;而对数据操作,存储这些中间结果,则需要用到操作数栈。解析后的字节码指令push、store等操作指的就是对操作数栈的操作,store、load等操作指的就是对局部变量表的操作。

3.3.7 补充:栈顶缓存技术

前面提过,基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着需要更多的指令分派(instruction dispatch)次数和内存读/写次数。

由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,Hotspot JVM的设计者们提出了栈顶缓存(Tos,Top-of-Stack Caching)技术,将栈顶元素全部缓存在物理CPU的寄存器中,以此降低对内存的读/写次数,提升执行引擎的执行效率

3.3.8 补充:方法调用(重点)

在JVM中,将符号引用转换为调用方法的直接引用与方法的绑定机制有关。换句话说,程序执行,最终都是直接引用的,无论有没有符号引用,如果有则将其转换为直接引用。那么符号引用其实在写代码的时候就是符号引用,关键是符号引用是在编译期间还是运行期间转换成直接引用的。

  • 静态链接:

    当一个字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期保持不变时。这种情况将调用方法的符号引用转换为直接引用的过程,称之为静态链接。

  • 动态链接:

    如果被调用的方法在编译期无法被确定下来,也就是说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此也就被称之为动态链接。

上述两个链接对应的绑定机制为:早期绑定和晚期绑定。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程。这仅仅发生一次,就是将符号引用转换为直接引用,就是将直接引用绑定到具体的变量上面

  • 早期绑定:

    早期绑定就是指被调用的目标方法如果在编译期可知,且运行期保持不变时,即可将这个方法与所属的类型进行绑定,这样一来,由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。

  • 晚期绑定:

    如果被调用的方法在编译期无法被确定下来,只能够在程序运行期根据实际的类型绑定相关的方法,这种绑定方式也就被称之为晚期绑定

其实从Java语法的角度上来看,这其实很容易理解。显然,参数是接口以及抽象类,这种肯定是晚期绑定,因为接口和抽象类无法实例化,即在编译阶段无法知道具体的引用,所以只能在运行阶段根据参数来确定。而如果参数是最终的子类,显然时候早期绑定,因为只能是这个,没有该类的子类了;或者调用父类的构造方法,这显然是能够唯一确定的。【这其实就是多态的体现】

在字节码指令中,如果是晚期绑定,那么就会是关键字invokeVirtual(虚方法,包括final修饰,但是final修饰的是早期绑定)、invokeInterface(接口方法);如果是早期绑定,那么就是invokeSpecial(非虚方法)、invokeStatic(静态方法)。在Java中,任何一个普通的方法其实都具备虚函数的特征,相当于C++中的虚函数。如果在Java程序中不希望某个方法拥有虚函数的特征时,则可以使用关键字final来标记这个方法。

这里就引入了虚方法和非虚方法。

  1. 非虚方法
    1. 如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的。这样的方法称为非虚方法。
    2. 静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法。
    3. 非虚方法其实就是能够确定了调用的具体方法,即不会重写等,不会使得编译器迷惑,到底是父类的方法还是子类重写的方法。
  2. 虚方法
    1. 除了非虚方法,剩余的方法都是虚方法。

字节码指令关键字:

  1. invokeStatic:调用静态方法,解析阶段确定唯一方法版本
  2. invokeSpecial:调用init方法、私有以及父类方法,解析阶段确定唯一方法版本
  3. invokeVirtual:调用所有虚方法
  4. invokeInterface:调用接口方法
  5. invokeDynamic:动态解析出需要调用的方法,然后执行

前四条指令固化在虚拟机内部,方法的调用执行不可认为干预,而invokeDynamic指令则支持用户确定方法版本。其中invokeStatic指令和invokeSpecial指令调用的方法称为非虚方法,其余的(final修饰除外)称为虚方法。

JVM字节码指令集一直比较稳定,一直到Java7才增加了invokeDynamic指令。Java8的Lambda表达式的出现,invokeDynamic指令的生成,在Java中才有了直接的生成方式。

针对虚方法,重写的时候,因为多层继承,不知道重写的是哪层父类的方法,因此会逐级访问父类,判断是否是该方法。因此,这样来说,每调用一次该虚方法,显然就需要逐级访问一次,这是不合理的,因此设置了虚方法表,表中直接注明了引用关系,后期直接访问该表即可。

4. 本地方法栈

阅读本节前,请先阅读JVM_01_内存与垃圾回收_03_运行时数据区

  • 上面的虚拟机栈用于管理Java方法的调用,而本地方法栈则是用于管理本地方法的调用。
  • 本地方法栈也是线程私有的。
  • 大小允许被固定或者可扩展内存。同样会出现StackOverflowError、OutOfMemoryError。
  • 当某个线程调用一个本地方法时,它就进入了一个全新的并且不再受虚拟机限制的世界。它和虚拟机有着同样的权限
    • 本地方法可以通过本地方法接口来访问虚拟机内部的运行时数据区
    • 使用操作系统自己的寄存器和本地内存
  • 并不是所有的JVM都支持本地方法。因为Java虚拟机规范并没有明确要求本地方法栈的使用语言、具体实现方式、数据结构等。
  • 在Hotspot JVM中,直接将本地方法栈和虚拟机栈合二为一

5. 堆

5.1 概述

堆空间是多线程共享的,是进程唯一的,需要考虑线程安全问题。

  • 一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域。
  • Java堆区在JVM启动的时候即被创建【引导类加载器】,其空间大小也就确定了。是JVM管理的最大一块内存空间。
    • 堆内存的大小是可以调节的。
  • 堆可以处在物理上不连续的内存空间中,但是在逻辑上应该被视为连续的
  • 所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区。【所以,完整的堆空间,并不是所有的线程完全共享】
  • 几乎所有的对象实例以及数组都分配在堆中。【逃逸分析、栈上分配、标量替换】
  • 数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。
  • 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。【需要垃圾回收器后续统一回收的时候,判断是否是垃圾,并不是马上回收的。】
  • 堆,是垃圾收集器执行垃圾回收的重点区域。

image-20220801111452701

5.2 堆内存细分

现代垃圾收集器大部分基于分代收集理论设计,堆空间逻辑上细分为:

  • Java7及之前分为:新生区+养老区+永久区
  • Java8及之后分为:新生区+养老区+元空间

但是元空间(永久区)是方法区的具体落地实现,因此在之后的讲解中,堆分为新生区和养老区。另外,在java命令设置堆空间大小的时候,实际上通过可视化工具来看,确实设置堆空间大小后,新生区和养老区内存之和就是设置的大小。

5.3 设置堆空间大小以及OOM

除了前面-Xss设置虚拟机栈的大小,JVM也提供了参数来设置堆空间大小。

  • -Xms用于表示堆区的起始内存,等价于-XX:InitialHeapSize,后面加具体的数字以及单位即可。
  • -Xmx用于表示堆区的最大内存,等价于-XX:MaxHeapSize,后面加具体的数字以及单位即可。

具体的命令细节,可官网查看java (oracle.com)

默认的堆内存

  • 初始大小是:物理电脑内存的 1/64。
  • 最大是:物理电脑内存的 1/4。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Jvm_test03 {

public static void main(String[] args) {
System.out.println();

long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;

// 注意,获取到的数值和设置的数值有点区别【这是因为新生代中的两个幸存者区只能有一个存储对象,所以这里获取到的就是实际使用的用于存储对象的堆区】
System.out.println("-Xms:" + initialMemory + "M");
System.out.println("-Xmx:" + maxMemory + "M");

System.out.println("系统内存大小为:" + initialMemory * 64.0 / 1024 + "G");
System.out.println("系统内存大小为:" + maxMemory * 4.0 / 1024 + "G");
}
}

5.4 新生区和老年区参数设置

存储在JVM中的Java对象可以被划分为两类:

  • 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速。【因此这些对象可及时回收,可放在新生区】
  • 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致。【这些对象无需及时回收,可挡在老年区】
  • 一般情况下,普通对象都是在伊甸园区创建,然后随着存活时间逐渐被放入幸存者0、幸存者1,最终放入到老年代。

新生区分为伊甸园区、幸存者1区、幸存者2区。

image-20220801134348564

既然存在多个区,此时可设置年轻代、老年代在堆区的占比【一般情况下,该参数无需修改】:

  • 默认是:-XX:NewRatio=2,表示新生代占1份,老年代占2份,即一共3份。
  • 比如修改为:-XX:NewRatio=4,表示新生代占1份,老年代占4份,即一共5份。

新生代又分为伊甸园、幸存者0、幸存者1区,三者的比例默认为8:1:1。可通过-XX:SurvivorRatio=8来设置【虽然是默认,但是也需要显式设置】。

  • 几乎所有的Java对象都是在Eden区被new出来的。
  • 绝大部分的Java对象的销毁都在新生代进行了。
    • IBM公司的专门研究表明,新生代中80%的对象都是“朝生夕死”的。

5.5 新生代和老年代对象分配过程

为新对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间中产生内存碎片。

  1. new的对象先放在伊甸园区,此区有大小限制。

  2. 当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区

  3. 然后将伊甸园区中的剩余对象移动到幸存者0区。【此时Eden为空了,因为要么是垃圾被回收,要么不是垃圾被放到幸存者区】

  4. 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者0区的,如果没有回收,就会放到幸存者1区。

  5. 如果再次经历垃圾回收,此时会重新放回到幸存者0区,接着再去幸存者1区。

  6. 啥时候能去养老区呢?可以设置次数(年龄计数器),默认是15次。

    -XX:MaxTenuringThreshold=<N>进行设置

  7. 在养老区,相对悠闲。当养老区不足时,触发GC:Major GC,进行养老区的内存清理。

  8. 若养老区执行了Major GC之后发现依然无法进行对象的保存,就会发生OOM异常:java.lang.OutOfMemoryError: Java heap space

注意:

伊甸园区满了之后,触发的垃圾回收器为YGC或者Minor GC。

幸存者0区和1区,最多只能有一个区有对象。即如果当前是0区有,那么下次再次触发垃圾回收的时候,就是将0区的非垃圾对象以及Eden的非垃圾对象移动到1区。每触发一次垃圾回收(对象每移动一次,年龄计数器age就加1)。当到15时,则会进入到老年期。

只有伊甸园区满了才会触发YGC,但是回收垃圾则是回收的是Eden和S0或S1三个区的内容。

如果S0或S1满了,此时Eden的对象就不再到S0、S1了,而是直接到老年区。

另外,如果存在超大对象,Eden在空的时候,放不下,此时就会直接放在养老区。老年代如果放不下,可进行垃圾回收(Full GC、Major GC),如果垃圾回收之后,仍然放不下,直接OOM了。

常用的调优工具有如下几种:

  1. JDK命令行
  2. Jconsole
  3. JVisualVM
  4. Jprofiler
  5. Java Flight Recoder
  6. GCViewer
  7. GC Easy

5.6 Minor GC、Major GC、Full GC

垃圾回收器线程在工作的时候,会暂停用户线程,导致吞吐量降低。因此调优其实就是使得GC次数变少,使得吞吐量不降低。而Major GC、Full GC则会使用户线程延时时间更长。

注意,Hotspot VM中有个概念:部分收集和整堆收集。部分收集指的是不是完整地收集整个Java堆的垃圾,只是收集部分区域,比如新生代等等;而整堆收集,则是对整个Java堆进行垃圾回收。

JVM在进行GC时,并非每次都对上面三个内存(新生代、老年代;方法区)区域进行回收的,大部分回收的都是指新生代。

部分收集分为:

  1. 新生代收集(Minor GC / Young GC):只是新生代(Eden、S0、S1)的垃圾收集。
  2. 老年代收集(Major GC / Old GC):只是老年代的垃圾收集。
    • 目前,只有CMS GC会有单独收集老年代的行为。
    • 注意,很多时候Major GC和Full GC混淆使用,需要具体分辨是老年代回收还是整堆回收。
  3. 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集。
    • 目前只有G1 GC会有这种行为。

整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集

5.6.1 Minor GC

当Eden空间不足时,会触发Minor GC,清理整个年轻代的垃圾。

由于大部分对象都是朝生夕死,所以Minor GC非常频繁,一般回收速度也比较快。注意,Minor GC会引发STW,暂停其他用户的线程,等垃圾回收结束,用户线程才恢复运行

5.6.2 Major GC

当老年区空间不足时,会触发Major GC。Major GC的速度一般会比Minor GC慢10倍以上,STW的时间更长。如果Major GC后,内存还不够,就直接报OOM了。

5.6.3 Full GC(了解,后续详细说)

触发Full GC有以下几种情况:

  1. 调用System.gc()时,系统建议执行Full GC,但是不必然执行。
  2. 老年代空间不足
  3. 方法区空间不足
  4. 通过Minor GC后导致老年代的平均大小大于老年代的可用内存。

开发中要避免Full GC。

5.7 堆空间划分思想

堆空间为什么要划分为新生代、老年代、永久代呢?

  • 经研究,不同对象的生命周期不同,70%~99%的对象是临时对象。
  • 因此垃圾对象则往往都是上面的临时对象。那么划分其实就是为了缩小GC的操作范围,如果不划分空间,显然垃圾回收的时候就需要将检查所有的对象,效率较低。而划分空间后,可直接在某个子区域内检查对象即可。因此可将容易成为垃圾的对象放到Eden区中。

不同生存年龄段(age)的对象的内存分配策略如下:

  1. 优先分配到Eden【刚new的对象直接分配到Eden】

  2. 大对象直接分配到老年代【因为Eden不够存储大对象】(尽量避免程序中出现过多的大对象)

  3. 长期存活的对象分配到老年代

  4. 动态对象年龄判断

    如果幸存者区中相同年龄的所有对象的总和大于幸存者区空间的一半,那么年龄大于等于该年龄的对象可直接进入老年区,无需等到阈值的年龄。

  5. 空间分配担保:-XX:HandlePromotionFaliure【就是幸存者区满了之后,无需等待阈值,直接进入老年区】

5.8 为对象分配内存:TLAB

TLAB是Thread Local Allocation Buffer的简称,为什么会出现这个结构呢?

  1. 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
  2. 由于对象实例的创建在JVM中非常频繁,因此在并发环境中从堆区中划分内存空间是线程不安全的
  3. 为了避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度

TLAB是从内存模型的角度出发,而不是垃圾回收的角度出发,对JVM区域继续进行划分的。JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内。如果在TLAB分配失败了,才会在Eden中加锁分配内存。

多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称为快速分配策略。

目前所有OpenJDK衍生出来的JVM都提供了TLAB的设计。

因此说,堆空间不一定是多线程共享的,因为TLAB的存在。

对象分配流程如下所示:

image-20220906225914464

参考:JVM 内存结构_大家好我是Boger的博客-CSDN博客Java内存模型JMM简单分析 - 白日梦想家12138 - 博客园 (cnblogs.com)

5.9 堆空间的参数设置

针对堆空间的常用参数设置如下所示:

参数名 描述
-XX:+PrintFlagsInitial 查看所有的参数的默认初始值
-XX:+PrintFlagsFinal 查看所有的参数的最终值(可能会存在修改,不是默认值)
-Xms 设置初始堆空间大小(默认为物理内存的1/64)
-Xmx 设置最大堆空间大小(默认为物理内存的1/4)
-Xmn 设置新生代的大小(初始值以及最大值)
-XX:NewRatio 配置新生代与老年代在堆结构的占比
-XX:SurvivorRatio 设置新生代中Eden和S0、S1空间的比例
-XX:MaxTenuringThreshold 设置新生代对象的最大年龄,超过这个阈值则存储到老年区
-XX:+PrintGCDetails 输出详细的GC处理日志
-XX:HandlerPromotionFailuer 是否设置空间分配担保
-XX:+DoEscapeAnalysis 开启逃逸分析
-XX:+EliminateAllocations 开启标量替换(默认打开),允许将对象打散分配在栈上。

5.10 补充:堆内分配对象存储的唯一选择吗?

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

在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无需进行垃圾回收了。这就是常见的堆外存储技术。

5.10.1 逃逸分析

如何将堆上的对象分配到栈,需要使用逃逸分析手段。这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。

通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围,从而决定是否要将这个对象分配到堆上

逃逸分析的基本行为就是分析对象动态作用域

  • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
  • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。
  • 注意,上面的是对象实体,并不是对象引用。

因此,在开发中,能够使用局部变量的,就不要在方法外定义。基于逃逸分析,可对代码进行优化。

  1. 栈上分配
  2. 同步省略
  3. 分离对象或标量替换

5.10.2 代码优化之栈上分配

JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收,这样就无需进行垃圾回收了

5.10.3 代码优化之同步省略

如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。

线程同步的代价是相当高的,同步的后果是降低并发性和性能。在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能被一个线程访问而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫做同步省略,也叫锁消除

5.10.4 代码优化之标量替换

有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。

标量(Scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为它可以分解成其他聚合量和标量。

在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。

5.10.5 总结

现在逃逸分析的技术并不是很成熟,虽然1999年就已经提出了该技术。这是因为逃逸分析在分析的过程中开销就比较大。另外,单纯的栈上分配,其实是依据标量替换的(标量替换默认打开),如果标量替换关闭了,显然栈上分配是没效果的。从某种角度上说,对象实例仍然都是分配在堆上的。注意,这里是对象实例,通过标量替换后,就不在是对象实例了。【只是在代码层面上看,是对象,但是底层,已经是标量替换了。】

6. 方法区(元空间)

接下来讲解方法区。方法区就是下图中的元空间,是多线程共享的。

image-20220801200824415

6.1 栈、堆、方法区的交互关系

在运行时数据区,最重要的就是方法区、虚拟机栈和堆了。下图是三者的简单交互关系。方法区存储的是具体类class,堆则是存储了具体的实例,虚拟机栈中则存储了本地变量引用。总体上说,本地变量保存了对象在堆内存中具体的内存地址。可以找到这个具体实例对象。而这个实例对象中保存了该对象所属的具体类型class。

image-20220801201111471

6.2 方法区理解

The Java Virtual Machine has a method area that is shared among all Java Virtual Machine threads. The method area is analogous to the storage area for compiled code of a conventional language or analogous to the “text” segment in an operating system process. It stores per-class structures such as the run-time constant pool, field and method data, and the code for methods and constructors, including the special methods (§2.9) used in class and instance initialization and interface initialization.

The method area is created on virtual machine start-up. Although the method area is logically part of the heap, simple implementations may choose not to either garbage collect or compact it. This specification does not mandate the location of the method area or the policies used to manage compiled code. The method area may be of a fixed size or may be expanded as required by the computation and may be contracted if a larger method area becomes unnecessary. The memory for the method area does not need to be contiguous.

A Java Virtual Machine implementation may provide the programmer or the user control over the initial size of the method area, as well as, in the case of a varying-size method area, control over the maximum and minimum method area size.

The following exceptional condition is associated with the method area:

  • If memory in the method area cannot be made available to satisfy an allocation request, the Java Virtual Machine throws an OutOfMemoryError.

虽然在规范中方法区(元空间)是堆的一部分(逻辑上),但在实际实现中,方法区实际上可以看成是一块独立于Java堆的内存空间。

  • 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。
  • 方法区在JVM启动的时候被创建,并且它的实际的物理内存空间和Java堆区一样都可以是不连续的
  • 方法区的大小,根堆空间一样,可以选择固定大小或者可扩展。
  • 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,JVM同样会抛出内存溢出错误:【比如加载大量的jar包,以及动态生成过多的反射类】
    • Java7及之前:java.lang.OutOfMemoryError: PermGen space(永久代)
    • Java8及之后:java.lang.OutOfMemoryError: Metaspace(元空间)
  • 关闭JVM就会释放这个区域的内存。

永久代和元空间都是属于方法区的落地实现。永久代是Java7及其之前的称呼,元空间则是Java8之后的称呼。元空间和永久代的本质是类似的,都是对JVM规范方法区的实现。元空间和永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用本地内存,而且内部结构也进行了调整

6.3 设置方法区大小与OOM

和堆一样,方法区的大小也是可以设置的。

JDK7以前:

  1. -XX:PermSize设置永久代初始分配空间,默认值是20.75。
  2. -XX:MaxPermSize设置永久代最大可分配空间,32位机器默认是64M,64位机器默认是82M。
  3. 当JVM加载的类信息容量超过了这个值,会报异常OutOfMemoryError:PermGen space。

JDK8之后:

  1. -XX:MetaspaceSize设置元空间初始分配空间,默认值是21M。(JVM在运行过程中会动态变化)
  2. -XX:MaxMetaspaceSize设置元空间最大可分配空间,默认值是-1,表示本地空间最大值。(一般不设置)
  3. 当JVM加载的类信息容量超过了这个值,会报异常OutOfMemoryError:Meta space。

6.4 内存结构

方法区主要用于存储已被虚拟机加载的类型信息域信息方法信息运行时常量池静态变量即时编译器编译后的代码缓存等。

6.4.1 类型信息

对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM必须在方法区中存储以下类型信息:

  1. 这个类型的完整有效名称(全名=包名.类名)
  2. 这个类型的直接父类的完整有效名(对于interface、Object,则不需要)
  3. 这个类型的修饰符(public、abstract、final的某个子集)
  4. 这个类型实现的直接接口的一个有序列表

6.4.2 域信息(Field、属性、成员变量)

  1. JVM必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。
  2. 域的相关信息包括:域名称、域类型、域修饰符等。

6.4.3 方法信息(Method)

JVM必须保存所有方法的以下信息:

  1. 方法名称
  2. 方法的返回类型(或void)
  3. 方法参数的数量和类型(按顺序)
  4. 方法的修饰符
  5. 方法的字节码指令、操作数栈、局部变量表以及参数数量(abstract和native方法除外)【方法体】
  6. 异常表(abstract和native方法除外)
    1. 每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引。

6.4.4 non-final类变量

  • 静态变量和类关联在一起,随着类的加载而加载,他们称为类数据在逻辑上的一部分。
  • 类变量被类的所有实例共享,即使没有类实例也可以访问他。

注意常量,在编译的时候就已经赋值了。

6.4.5 运行时常量池

编译后的字节码文件中,有常量池表(Constant Pool Table),这个常量池表,把字节码文件加载到运行时数据区后,运行时常量池中的内容就是常量池表中的内容。

一个有效的字节码文件中除了包含类的版本信息、字段、方法以及接口等描述信息外,还包含一项信息那就是常量池表(Constant Pool Table),包括各种字面量和对类型、域和方法的符号引用。而真实的字节码指令,如果涉及到字面量和符号引用,那么该内容写的就是在常量池表中的索引,编译的时候并不是真正加载过来。本质上说,常量池表存储的是真实的内容,而字节码中的是符号。

而常量池中存储的内容才是真正的数据对象。那么为什么需要常量池呢?

  • 因为既然在整个类中各个方法都使用了这个对象(以及字面值),显然不能在堆中定义多份。而且也不是局部变量,甚至说,这就不是变量,是常量,因此,划分出一块区域,存储常量,即常量池。
  1. 运行时常量池是方法区的一部分。
  2. 常量池表是Class文件的一部分,用于存放编译期生成的各种字面量与“符号引用(运行期常量池替换为真实地址,比如类,就会找到方法区中的这个类的位置)”,这部分内容将类加载后存放到方法区的运行时常量池中。
  3. 运行时常量池,在加载类和接口到虚拟机后,就会创建对应的运行时常量池。
  4. JVM为每个已加载的类型(类或接口)都维护一个常量池。池中的数据项像数组一样,是通过索引访问的。
  5. 运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能够获得的方法或字段引用。此时不再是常量池中的符号地址了,这里换为真实地址了。
    1. 运行时常量池,相对于class文件常量池表的另一重要特征是:具备动态性
  6. 当创建类或接口的运行时常量池时,如果构造运行时常量池所需的内存空间超过了方法区所能提供的最大值,则JVM会跑OOM异常。

下图是两种方式解析class文件,可以看到常量池(表)中的常量,有些内容仍然是符号引用

image-20220802100331073

image-20220802100349586

6.5 Hotspot的方法区演变过程

只有Hotspot才有永久代。变化如下:

版本 描述
jdk1.6及之前 有永久代(permanent generation),字符串常量池、静态变量存放在永久代。
jdk1.7 有永久代,但已经逐步“去永久代”,字符串常量池、静态变量移除,保存在堆中。
jdk1.8及之后 无永久代,类型信息、字段、方法、常量保存在本地内存的元空间,字符串常量池、静态变量仍在堆中

注意,上面说的静态变量指的是引用名,实际的对象,无论哪个版本,都是存放在堆中。

那么为什么元空间会替换永久代呢?(为什么要使用本地内存,而不再把元空间放在JVM的虚拟内存中呢?)

  1. 为永久代设置空间大小是很难确定的。

    在某些场合,如果动态加载类过多,容易产生GC以及OOM。

  2. 对永久代进行调优是很困难的

    由操作系统自己管理本地内存比较合适,而且元空间存储的内容本来就不容易发生变化,所以无需JVM管理。

元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用计算机本地内存。因此默认情况下,元空间的大小仅受本地内存限制。

字符串常量池(字符串字面量)、静态变量(引用名)为什么会从永久代移除,逐渐移动到堆中呢?

  1. jdk7将StringTable(字符串常量池)放到了堆空间。因为永久代的回收效率较低,在Full GC的时候才会触发。而Full GC是老年代的空间不足、永久代不足时才会触发。

    这就导致StringTable回收效率不高。而我们开发过程中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。

6.6 垃圾回收

《JVM虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(比如JDK 11 时期的ZGC收集器就不支持类卸载)。

一般来说,方法区的垃圾回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。以前Sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的Hotspot虚拟机对此区域未完全回收而导致内存泄露。

注意,内存溢出,指的是内存不够了。内存泄露指的是该空间已经不被使用了,但是没有释放掉【可能引用还在,可能循环引用】,导致不会被垃圾回收,即这块空间不会被重复利用了。

方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再使用的类型信息

6.6.1 废弃的常量

方法区内常量池中主要存放的两大类常量:字面量和符号引用。

字面量其实就是文本字符串、被声明为final的常量值等。而符号引用则是编译原理方面的概念,包括下面三大类常量:

  1. 类和接口的全限定名
  2. 字段的名称和描述符
  3. 方法的名称和描述符

Hotspot虚拟机对常量池的回收策略是很明确的,只要常量池中的常量没有被任何地方引用,就可以被回收。

6.6.2 不再使用的类型信息

判断一个类型是否属于“不再被使用的类”的条件就比较苛刻了,需要同时满足以下三个条件:

  1. 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例
  2. 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGI、JSP的重加载等,否则通常是很难达成的。
  3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

7. 对象的实例化、内存布局与访问定位

上面其实已经将运行时数据区的内容介绍完了,这里再补充一点内容。

7.1 对象的实例化

对象的实例化主要有两部分,分为语法语法显式创建和底层实际创建。

image-20220802160411503

7.1.1 创建对象的方式

  1. new

    调用构造方法、单例模式隐式调用构造方法、工厂模式隐式调用构造方法

  2. 反射:Class.newInstance()、Constructor的newInstance(Xxx)

  3. clone(),当前类实现Cloneable接口,实现clone()方法

  4. 反序列化

  5. 第三方库Objenesis

7.1.2 创建对象的步骤

  1. 判断对象对应的类是否加载、链接、初始化

    虚拟机遇到一条new指令,首先去检查这个指令的参数能否在Metaspace的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化(即判断类元信息是否存在)。如果没有,那么在双亲委派模式下,使用当前类加载器以ClassLoader+包名+类名为Key进行查找对应的.class文件。如果没有找到这个文件,则抛出ClassNotFoundException异常。如果找到,则进行类加载,并生成对应的Class类对象。

  2. 为对象分配内存

    在保证类信息加载之后,首先计算对象占用空间大小,接着在堆中划分一块内存给新对象。如果实例成员变量是引用变量,仅分配引用变量空间即可,即4个字节大小【即保存的是引用】。这里对内存空间情况说明:

    1. 如果内存规整,采用指针碰撞法(Bump The Pointer)为对象分配内存

      就是在内存中,所有使用的内存放在一边,空闲的内存在另一侧,中间存放着一个指针作为分界点的指示器,分配内存就仅仅是把分界点指针向另一边移动指定大小的位置就行。如果垃圾收集器选择的是Serial、ParNew这种基于压缩算法的,虚拟机采用这种分配方式。一般情况下,如果使用代用Compact过程的收集器时,就会使用指针碰撞这种方式分配内存。

    2. 如果内存不规整,采用空闲列表(Free List)

      如果内存不规整(即不是采用上面的垃圾回收算法),已使用的内存和未使用的内存相互交错,那么虚拟机将采用空闲列表法来为对象分配内存。意思是虚拟机维护了一个列表,记录哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。这种方式称为“空闲列表”。

    注意,选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾回收器是否带有压缩整理功能决定。

  3. 处理并发安全问题

    因为多线程并发的原因,需要保证堆内存线程安全。有两种方式:

    1. 采用CAS失败重试、区域加锁保证更新的原子性
    2. 每个线程预先分配一块TLAB
  4. 初始化分配的空间

    对属性赋默认初始化值。【注意,这里是赋初始化值,不是显式初始化】。这样就保证了对象实例字段在不赋值时可以直接使用。

  5. 设置对象的对象头

    将对象的所属类(即类的元数据信息)、对象的HashCode和对象的GC信息、锁信息等数据存储在对象的对象头中。这个过程的具体设置方式取决于JVM实现。

  6. 执行init方法进行初始化(即显式初始化、构造方法和实例代码块)

    这个步骤就是显式初始化。从Java程序的视角来看,初始化才正式开始。初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。

    因此一般来说(由字节码中是否跟随有invokespecial指令所决定),new指令之后会接着就是执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全创建出来。

总结,加载类信息、分配内存(注意保证线程安全)、零值初始化、设置对象头、构造方法。经过这几个步骤,才算是完整的把对象创建出来。如果只是new,其实这仅仅分配了空间,只有调用构造方法,才相当于赋予了一个独立的对象。

7.2 对象的内存布局

上面说到了对象头,这里详细讲解一下对象的内存布局。如下所示:

image-20220803094618363

对象完整包含三部分:对象头、实例数据、对齐填充。

7.2.1 对象头(Header)

对象头包含运行时元数据和类型指针两部分。

  1. 运行时元数据

    1. 哈希值

      对象的哈希值,输出对象地址时的那串哈希值。

    2. GC分代年龄(年龄计数器Age)

      即该对象经历了几次垃圾回收,只有超过阈值才会被放到老年区。

    3. 锁状态标志

    4. 线程持有的锁

    5. 偏向线程ID

    6. 偏向时间戳

  2. 类型指针

    指向类元数据InstanceKlass,确定该对象所属的类型。即保存本对象的所属类(getClass方法)。

  3. 注意,如果是数组,还需记录数组的长度。

7.2.2 实例数据(Instance Data)

它是对象真正存储的有效信息,包括程序代码中定义的各种类型的字段(包括从父类继承下来和本身拥有的字段)

注意:

  1. 相同宽度的字段总量总是被分配在一起
  2. 父类中定义的变量会出现在子类之前
  3. 如果CompactFields参数为true(默认为true):子类的窄变量可能插入到父类变量的空隙

7.2.3 对齐填充(Padding)

了解。没有实际作用,仅仅起到占位符的作用

总体来看,对象的内存布局如下所示:

image-20220803100025510

7.3 对象的访问定位

JVM是如何通过栈帧中的对象引用访问到其堆空间内部的对象实例的呢?以及如果通过类型指针找到方法区中的类元信息呢?

通过栈帧中的reference局部变量所存储的内存地址。

image-20220803100905537

对象访问方式主要有两种:

因为JVM没有具体的明确,所以事先方式有以下两种。

  1. 句柄访问

    即对象类型数据和对象实例数据是分开的。即局部变量保存的是句柄池的地址。

    缺点就是效率低(需要先找句柄池再找数据),并且需要开辟句柄池空间。

    优点是如果对象地址发生了改变,只需要在句柄池中修改即可,而局部变量则无需修改。

    image-20220803102456640

  2. 直接指针(Hotspot采用)

    即对象类型数据是包含在对象实例数据中的。

    image-20220803102615480

8. 直接内存(Direct Memory)

在Java8及之后,元空间(方法区的具体落地实现)采用本地直接内存,而不再采用JVM的内存。参考Java中的NIO。

  1. 不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。
  2. 直接内存是在Java堆外的、直接向系统申请的内存空间。
  3. 通常,访问直接内存的速度会优于Java堆,即读写性能高。
    1. 因此处于性能考虑,读写频繁的场合可能会考虑使用直接内存。
    2. Java的NIO库允许Java程序直接使用内存,用于数据缓冲区。
  4. 也可能导致OutOfMemoryError: Direct buffer memory异常。
  5. 由于直接内存在Java堆外,因此他的大小不会直接受限于-Xmx指定的最大堆大小,但是系统内存是有限的,Java堆和直接内存的总和依然首先于操作系统能给出的最大内存。
  6. 缺点:
    1. 分配回收成本较高
    2. 不受JVM内存回收管理
  7. 直接内存大小可以通过MaxDirectMemorySize设置。如果不指定,默认与堆的最大值-Xmx参数值一致。

9. 备注

参考B站《尚硅谷》。


文章作者: 浮云
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 浮云 !
  目录