JVM性能分析——JVM内存结构(运行时数据区)
文章目录
概述
每个 JVM 只对应一个 Runtime 实例,即运行时环境。
类的加载 –> 验证 –> 准备 –> 解析 –> 初始化,这几个阶段完成后,就可以被 Java 虚拟机所使用。Java 虚拟机会使用到它的执行引擎 (Execution Engine) 来执行类中的字节码指令。执行引擎会利用 JVM 运行时数据区 (Runtime Data Area) 中的各种区域来支持字节码的执行,如程序计数器、虚拟机栈、本地方法栈、堆、方法区等。下面着重介绍运行时数据区,即JVM内存结构。
内存的作用:
-
内存是非常重要的系统资源,是硬盘和 CPU 的中间仓库及桥梁,承载着操作系统和应用程序的实时运行。 JVM 内存布局规定了 Java 在运行过程中内存申请、分配、管理的策略,保证了 JVM 的高效稳定运行。不同的 JVM 对于内存的划分方式和管理机制存在着部分差异。结合 JVM 虚拟机规范,来探讨一下经典的 JVM 内存布局。
-
通过磁盘或者网络 IO 得到的数据,都需要先加载到内存中,然后 CPU 从内存中获取数据进行读取,也就是说内存充当了 CPU 和磁盘之间的桥梁。
JVM 内存管理概述
- JVM 内存布局定义了各个内存区域的用途和申请方式。比如程序计数器、虚拟机栈等线程私有区域,是随着线程的创建而创建;而堆内存、方法区等线程共享区域,则是由 JVM 统一管理和分配的。
- JVM 内存布局规定了堆内存的分代机制,将堆分为新生代、老年代等。不同区域使用不同的垃圾收集算法,提高内存回收的效率;方法区的内存分配和管理策略也由 JVM 内存布局决定。:
- JVM 内存布局定义了各个内存区域的生命周期和回收策略。比如程序计数器随线程创建或销毁,虚拟机栈随方法调用入栈出栈;堆内存和方法区的内存回收由 JVM 的垃圾收集器负责。
Java 虚拟机内存结构
Java 虚拟机定义了程序运行期间会使用到的运行时数据区:其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁(线程共享)。另外一些与线程的生命周期一致,这些与线程对应的数据区域会随着线程开始和结束而创建和销毁(线程私有)。
java 虚拟机将管理的内存分为五大区域:虚拟机栈或栈区(VM Stacks) 、堆(Heap)、方法区(Method Area)、程序计数器(Program Counter Register)和本地方法栈(Native Method Stack)五个部分。
- 虚拟机栈、程序计数器和本地方法栈是线程私有的;
- 堆和方法区是线程共享的。
下图来自阿里巴巴手册 JDK8:
运行时数据区
PC 程序计数器
寄存器用于存储指令相关的现场信息,CPU 只有把数据装载到寄存器才能够运行。JVM 中的程序计数寄存器(Program Counter Register),用于记录线程当前执行的字节码指令地址。这里并非是广义上所指的物理寄存器,并不是用于存储对象或数据的内存区域,而是一个用于线程执行指令的辅助数据结构。JVM 中的 PC 寄存器是对物理 PC 寄存器的一种抽象模拟。
PC 寄存器是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。既没有 GC 垃圾回收,也没有 OOM 内存溢出。
Java 虚拟机可以支持同时执行多个线程。每个 Java 虚拟机线程都有自己私有的PC(程序计数器)寄存器,线程切换时需要保存和恢复相关信息。程序计数器可以看作是字节码的行号指示器,它指示了当前线程执行到字节码的哪一行。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
由于 Java 虛拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储。
使用PC寄存器存储字节码指令地址有什么用呢?或者问为什么使用 PC 寄存器来记录当前线程的执行地址呢?
因为CPU需要不停的切换各个线程,这时候切换回来以后,就得知道接着从哪开始继续执行;
JVM的字节码解释器就需要通过改变PC寄存器的值来明确下一条应该执行什么样的字节码指令
PC寄存器为什么被设定为私有的?
我们都知道所谓的多线程在一个特定的时间段内只会执行其中某一个线程的方法,CPU 会不停地做任务切换,这样必然导致经常中断或恢复,如何保证分毫无差呢?为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个 PC 寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。
或:由于 CPU 时间片轮限制,众多线程在并发执行过程中,任何一个确定的时刻,一个处理器或者多核处理器中的一个内核,只会执行某个线程中的一条指令。这样必然导致经常中断或恢复,如何保证分毫无差呢?每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响。
虚拟机栈(栈区)
每个方法执行时,JVM 会在虚拟机栈中创建一个栈帧,它承载着方法执行所需的关键信息。
1. 简要概述
虚拟机栈出现的意义:
- 跨平台性:由于跨平台性的设计,Java 虚拟机使用基于栈的指令集架构,指令操作的对象是操作数栈和局部变量表。不同平台CPU架构不同,所以不能设计为基于寄存器的,栈架构的指令集不依赖于底层硬件平台的寄存器结构和指令集。【如果设计成基于寄存器的,耦合度高,可以对具体的CPU架构进行优化,性能会有所提升,但是跨平台性大大降低】。
- 相对于基于寄存器的指令集,基于栈架构的指令集较为简单,减少了编译器的复杂性和开发工作量。这使得编写 Java 虚拟机和 Java 编译器变得相对容易。
虚拟机栈的缺点:
- 指令数量增加:基于栈架构的指令集相对于基于寄存器的指令集,可能需要更多的指令来完成相同的任务。这是因为栈架构需要将操作数从内存加载到栈顶,执行操作后再存回内存,而基于寄存器的指令集可以直接在寄存器中进行操作,减少了内存访问次数。
- 性能下降:由于基于栈架构的指令集需要更多的指令来完成相同的任务,并且涉及到内存的频繁访问,可能导致性能下降。相比之下,基于寄存器的指令集可以直接在寄存器中进行操作,减少了内存访问的开销,可能具有更高的执行效率。
栈区是私有的,每个线程包含一个栈区,其他栈不能访问。栈中保存了基本数据类型和对象的引用(不是对象),对象都存放在堆区中。(栈是运行时的单位,而堆是存储的单位)
虚拟机栈描述的是 Java 方法执行的内存模型
:每个方法被执行的时候都会同时创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
Java 虚拟机规范允许 Java 栈的大小是动态的或者是固定不变的。
- 如果采用固定大小的 Java 虚拟机栈,那每一个线程的 Java 虚拟机栈容量可以在线程创建的时候独立选定。如果线程请求分配的栈容量超过Java虚拟机栈允许的最大容量,Java 虚拟机将会抛出一个 StackOverflowError 异常。
- 如果 Java 虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的虚拟机栈,那 Java 虚拟机将会抛出一个 OutofMemoryError 异常。
设置栈空间大小
- 可以使用参数 -Xss 选项来设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度。
- 如:-Xss 1m 或 -Xss 256k。
虚拟机栈的特点:
-
栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。
-
JVM 直接对 Java 栈的操作只有两个:
- 每个方法执行,伴随着进栈(入栈、压栈);
- 执行结束后的出栈工作;
-
对于栈来说不存在垃圾回收问题:
- 栈不需要 GC,但是可能出现 OOM。
栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据存储的问题,即数据怎么放,放哪里。
栈是主管 Java 程序的运行,它保存方法的局部变量(8 种基本数据类型、对象的引用地址)、部分结果,并参与方法的调用和返回。
2. 栈帧的内部结构
每个栈帧中存储着:
- 局部变量表(Local Variables);
- 操作数栈(Operand Stack)(或表达式栈);
- 动态链接(Dynamic Linking)(或指向运行时常量池的方法引用);
- 方法返回地址(Return Address)(方法正常退出或者异常退出的定义);
- 一些附加信息。
3. 局部变量表
基本概念
局部变量表也被称之为局部变量数组或本地变量表。通过定义为一个数字数组,存储方法参数和定义在方法体内的局部变量,这些数据类型包括8种基本数据类型、对象引用(reference)以及 returnAddress 类型(指向字节码指令地址)。
局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,写入方法的 Code 属性中,在方法运行期间不会改变局部变量表的大小。由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题;
方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。
- 对一个函数而言,它的参数和局部变量越多,局部变量表越大,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求;
- 进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。
局部变量表中的变量只在当前方法调用中有效。
- 当一个方法被调用时,参数值会通过局部变量表传递给方法内的参数变量。即虚拟机在创建栈帧时,会将实参的值复制到局部变量表中对应的位置。
public class ParameterPassing { public static void methodWithParameter(int param) { // param 存储在局部变量表中,值来自调用者传递的实参 System.out.println("方法内参数值: " + param); } public static void main(String[] args) { int argument = 10; methodWithParameter(argument); // argument 的值传递给 methodWithParameter 方法的 param } }
- 当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。
关于Slot的理解
局部变量表最基本的存储单元是 Slot(变量槽),Slot 是局部变量表的最小分配单位。当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个 slot 上。Slot 的基本存储规则:
- 32 位数据类型:int、float、reference、returnAddress占用 1 个 Slot。
- 64 位数据类型:long、double占用连续 2 个 Slot。
- 窄化类型自动转换:byte、short、char、boolean在存入 Slot 前会被转换为int类型存储。
JVM 会为局部变量表中的每一个 Slot 都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值。
- 单 Slot 类型:直接使用变量所在的 Slot 索引。
- 双 Slot 类型:通过第一个 Slot 的索引访问,例如long变量存储在索引i和i+1,只需通过索引i即可访问。
对应的字节码片段:public int add(long a, long b) { return (int) (a + b); }
0: lload_0 // 从Slot 0加载long型变量a(占用0和1两个Slot) 1: lload_2 // 从Slot 2加载long型变量b(占用2和3两个Slot) 2: ladd // 执行long加法 3: l2i // 转换为int 4: ireturn // 返回int
如果当前帧是由构造方法或者实例方法创建的,那么该对象引用 this 会固定存放在 index 为 0 的 slot 处,其余的参数按照参数表顺序继续排列。(this也相当于一个变量)。
Slot 的重用机制:
栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量超出其作用域后,其占用的Slot可以被后续新声明的局部变量重用,从而达到节省资源的目的。
- 例如在代码块中声明的变量,在块结束后其Slot可能被重用:
public void slotReuse() { { int a = 1; // 占用Slot 0 } int b = 2; // 可能重用Slot 0 }
局部变量表的 Slot 数量在编译期确定,并存储在方法的Code属性中。例如:
public class SlotAllocation {
public static void main(String[] args) {
int a = 1;
long b = 2L;
int c = 3;
}
}
编译后,main方法的局部变量表信息:
LocalVariableTable:
Start Length Slot Name Signature
0 11 0 args [Ljava/lang/String;
8 3 1 a I
0 11 0 args [Ljava/lang/String;
8 3 1 a I
13 3 4 c I
- args:Slot 0(静态方法无this)
- a:Slot 1
- b:Slot 2 和 3(long占用 2 个 Slot)
- c:Slot 4(复用b之后的 Slot)
4. 操作数栈
操作数栈也常被称为操作栈,它是一个后入先出(Last In, First Out, LIFO)的数据结构。在方法执行过程中,操作数栈用于暂存操作数和运算结果。每个方法在创建栈帧时,都会包含一个操作数栈,其大小在编译期就已经确定,并保存在方法的Code属性的max_stack数据项中。
操作数栈和局部变量表之间经常进行数据交互。局部变量表中的数据可以被加载到操作数栈中进行处理,操作数栈中的结果也可以存储回局部变量表。
操作数栈的作用
- 操作数栈,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
- 操作数栈就是 JVM 执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出来,这时方法的操作数栈是空的。
- 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的 Code 属性中,为 maxstack 的值。
- 栈中的任何一个元素都是可以任意的 Java 数据类型。
- 32bit 的类型占用一个栈单位深度;
- 64bit 的类型占用两个栈单位深度;
- 操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈(Push)和出栈(Pop)操作来完成一次数据访问。只不过操作数栈是用数组这个结构来实现的而已。
- 如果被调用的方法带有返回值的话,其返回值将会被压入当前栈帧的操作数栈中,并更新 PC 寄存器中下一条需要执行的字节码指令。
- 操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。
- 我们说 Java 虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是操作数栈。
5. 动态链接
动态链接(或指向运行时常量池的方法引用),它负责将符号引用转换为直接引用,使得方法调用能够正确执行。
在 Java 代码编译成字节码文件时,方法调用中的目标方法是以符号引用(Symbolic Reference)的形式存在的。符号引用是一种在编译期能够确定的、与目标方法相关的符号字符串,它包含了目标方法所在的类名、方法名以及方法的描述符等信息。而动态链接的过程就是在运行时,将这些符号引用转换为直接引用(Direct Reference),直接引用是指向目标方法在内存中实际位置的指针或者句柄。(在 Java 源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在 class 文件的常量池里,而动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用)
-
类加载阶段的解析:在类加载的解析阶段,Java 虚拟机会对类中的符号引用进行解析。对于invokestatic(静态方法调用)、invokespecial(实例构造器、私有方法、父类方法调用)指令,在类加载的解析阶段就会将符号引用转换为直接引用,这种解析是静态的,在编译期就能够确定目标方法的直接引用。
public class StaticMethodCall { public static void staticMethod() { System.out.println("This is a static method."); } public static void main(String[] args) { staticMethod(); // 在类加载的解析阶段,该方法调用的符号引用会转换为直接引用 } }
-
运行时的动态链接:对于invokevirtual(虚方法调用)和invokeinterface(接口方法调用)指令,符号引用的转换发生在运行时。当执行invokevirtual指令时,Java 虚拟机首先会根据对象的实际类型在方法表(Method Table)中查找对应的方法,然后将符号引用转换为直接引用。方法表是一个数组,它存储了对象所属类及其父类中所有虚方法的直接引用。
public class VirtualMethodCall { public void virtualMethod() { System.out.println("This is a virtual method."); } public static void main(String[] args) { VirtualMethodCall obj = new VirtualMethodCall(); obj.virtualMethod(); // 在运行时,该方法调用的符号引用会转换为直接引用 } }
-
动态链接与多态:动态链接是实现 Java 多态的关键机制。在多态调用中,根据对象的实际类型动态地将符号引用转换为直接引用,从而调用正确的方法。例如:
class Animal { public void speak() { System.out.println("Animal is making a sound."); } } class Dog extends Animal { @Override public void speak() { System.out.println("Dog is barking."); } } public class Polymorphism { public static void main(String[] args) { Animal animal1 = new Animal(); Animal animal2 = new Dog(); animal1.speak(); // 调用Animal的speak方法 animal2.speak(); // 在运行时根据animal2的实际类型(Dog)动态链接到Dog的speak方法 } }
-
每个栈帧内部都内置一个常量池引用指针,该指针指向方法所属类的运行时常量池。运行时常量池是 Class 文件常量池在 JVM 运行时的实例化产物,存储着方法调用、字段引用等符号信息。
6. 方法的调用
静态链接与动态链接
在 JVM 中,将符号引用转换为调用方法的直接引用与方法的绑定机制相关。
静态链接:
- 绑定时机:编译期或类加载阶段完成符号引用到直接引用的转换;
- 目标方法确定性:方法调用的目标在编译时已知,运行期不可变;
- 指令类型:主要用于invokestatic(静态方法)、invokespecial(构造器、私有方法、父类方法)指令
动态链接:
- 绑定时机:运行时完成符号引用到直接引用的转换;
- 目标方法动态性:方法调用的目标需在运行时根据对象实际类型确定
- 指令类型:主要用于invokevirtual(虚方法)、invokeinterface(接口方法)、invokedynamic(动态语言支持)指令
早期绑定与晚期绑定
静态链接与动态链接针对的是方法。早期绑定和晚期绑定范围更广。早期绑定涵盖了静态链接,晚期绑定涵盖了动态链接。
静态链接和动态链接对应的方法的绑定机制为:早期绑定(Early Binding)和晚期绑定(Late Binding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。
-
早期绑定
早期绑定发生在编译期或类加载阶段,目标方法在编译期就已确定,即可将这个方法与所属的类型进行绑定;绑定过程在编译期或类加载时完成,运行时无需再次解析。由于明确了被调用的目标方法究竟是哪一个,因此也就可以使用静态链接的方式将符号引用转换为直接引用。
适用场景:静态方法、私有方法、构造器、final方法;
-
晚期绑定
晚期绑定发生在运行时,会根据对象的实际类型在方法表中查找目标方法的直接引用。由于目标方法在编译期无法确定,因此绑定过程在运行时完成,需要动态解析。
适用场景:实例方法(默认虚函数);
多态与绑定
Java 是基于面向对象的编程语言,具有封装、继承和多态等面向对象特性。Java 具备多态特性,那么自然也就具备早期绑定和晚期绑定两种绑定方式。
多态是指同一方法在不同对象上可能表现出不同的行为。在 Java 中,多态主要通过继承和方法重写来实现。多态的实现依赖于绑定机制,
-
编译时多态(静态多态):通过方法重载(Overload)实现,编译器根据参数类型和数量选择具体方法。
public class OverloadExample { public void print(int num) { System.out.println("Integer: " + num); } public void print(String str) { System.out.println("String: " + str); } }
-
运行时多态(动态多态):通过方法重写(Override)和动态绑定实现,运行时根据对象实际类型调用方法。
public class Animal { public void speak() { System.out.println("Animal speaks"); } } public class Dog extends Animal { @Override public void speak() { System.out.println("Dog barks"); } } // 运行时多态演示 Animal animal = new Dog(); animal.speak(); // 输出: Dog barks
虚方法与非虚方法
虚方法(Virtual Method) 和 非虚方法(Non-Virtual Method) 是基于方法调用绑定机制的重要分类,它们直接影响方法调用的执行效率和多态实现。
虚方法是指在运行时根据对象的实际类型动态确定调用目标的方法。Java 中绝大多数实例方法都是虚方法:
- 所有非final、非static、非private的实例方法默认是虚方法;
- 通过 invokevirtual 和 invokeinterface 指令调用;
- 允许子类重写(Override)并在运行时动态选择实现;
非虚方法是指在编译期或类加载阶段就确定调用目标的方法:
- 静态方法、私有方法、final 方法、实例构造器、父类方法都是非虚方法。
- 通过 invokestatic、invokespecial 指令调用;
虚拟机中调用方法的指令
-
普通指令:
invokestatic:调用静态方法,解析阶段确定唯一方法版本。
invokespecial:调用<init>方法、私有及父类方法,解析阶段确定唯一方法版本。
invokevirtual:调用所有虚方法。
invokeinterface:调用接口方法。
-
动态调用指令
invokedynamic:动态解析出需要调用的方法,然后执行。
前四条指令固化在虚拟机内部,方法的调用执行不可人为干预。而 invokedynamic 指令则支持由用户确定方法版本。其中 invokestatic 指令和 invokespecial 指令调用的方法称为非虚方法,其余的(final 修饰的除外)称为虚方法。
关于 invokedynamic 指令
-
JVM 字节码指令集一直比较稳定,一直到 Java7 中才增加了一个 invokedynamic 指令,这是 Java 为了实现【动态类型语言】支持而做的一种改进。
-
但是在 Java7 中并没有提供直接生成 invokedynamic 指令的方法,需要借助 ASM 这种底层字节码工具来产生 invokedynamic 指令。直到 Java8 的 Lambda 表达式的出现,invokedynamic 指令的生成,在 Java 中才有了直接的生成方式。
-
Java7 中增加的动态语言类型支持的本质是对 Java 虚拟机规范的修改,而不是对 Java 语言规则的修改,这一块相对来讲比较复杂,增加了虚拟机中的方法调用,最直接的受益者就是运行在 Java 平台的动态语言的编译器。
动态语言和静态语言
动态类型语言和静态类型语言两者的区别就在于对类型的检查是在编译期还是在运行期,满足前者就是静态类型语言,反之是动态类型语言。静态类型语言在编译期就确定了变量的类型,编译器会检查变量的类型是否匹配,若不匹配则会报错。动态类型语言在运行时才确定变量的类型,变量的类型可以在运行时改变。
静态类型语言是判断变量自身的类型信息;动态类型语言是判断变量值的类型信息,变量没有类型信息,变量值才有类型信息,这是动态语言的一个重要特征。
如:
Java: String info = “mogu blog”; (Java 是静态类型语言的,会先编译就进行类型检查)。
JS: var name = “shkstart”; var name = 10; (运行时才进行检查)。
7. 方法返回地址
作用:方法返回地址用于记录方法执行完毕后程序应继续执行的位置。当一个方法执行结束时,无论是正常返回还是抛出异常,虚拟机都需要知道返回后应从哪里继续执行。例如,在方法 A 中调用方法 B,方法 B 执行完后,程序需要回到方法 A 中调用方法 B 的下一条指令继续执行,这个位置就是方法 B 的返回地址。
存储与获取:方法返回地址通常存储在虚拟机栈的栈帧中。每个栈帧都包含了方法返回地址等信息。当方法调用发生时,调用方的程序计数器(PC 寄存器)的值被压入栈帧中的方法返回地址区域。当方法返回时,从栈帧中取出方法返回地址,将其赋值给 PC 寄存器,从而使程序回到调用点继续执行。
方法正常返回与异常返回
- 正常返回:当方法正常执行完毕时,根据方法返回类型(如void、基本数据类型、对象引用等),将返回值压入操作数栈(若有返回值),然后从栈帧中获取方法返回地址,跳转至该地址继续执行。
- 异常返回:当方法执行过程中抛出异常且未被当前方法捕获时,返回地址由异常处理器表确定。JVM查找当前方法的异常处理表(每个方法都有一个异常表),如果找到匹配的异常处理器,PC寄存器跳转到处理器代码;如果未找到,当前方法栈帧被弹出,异常抛给调用者;
8. 一些附加信息
栈帧中还允许携带与 Java 虚拟机实现相关的一些附加信息。例如:对程序调试提供支持的信息。
方法区
方法区(method area)又叫静态区,是 Java 虚拟机规范中定义的一种内存区域,所有 Java 虚拟机线程之间共享方法区域。用于存储已加载的类信息、常量、静态变量等数据。运行时常量池作为方法区的一部分,主要用于存放编译期生成的各种字面量和符号引用,在类加载的过程中,这些常量会从 class 文件的常量池加载到运行时常量池中。运行时常量池具有动态性,不仅可以在类加载时初始化,在运行时也可以通过某些方式向其中添加常量。
方法区的内部结构
类信息中除了有类型信息、常量、字段(域)信息、方法信息、即时编译器编译后的代码缓存等描述信息外,还有一项信息是类型的常量池(class 常量池),用于存放编译器生成的各种字面值和符号引用,这部分内容将在类加载后放到方法区的运行时常量池中。
方法区域类似于C等传统语言编译后代码的存储区域,或类似于操作系统进程中的“文本”段。它用于存储已被虚拟机加载的类型信息、运行时常量池、静态变量、常量、Class 对象引用、方法信息和即时编译器(JIT Compiler)编译后的代码数据等。主要存储以下信息:
-
类型信息:类 / 接口的全限定名、父类信息、实现的接口列表、修饰符等;
-
运行时常量池:编译期生成的各种常量、字面量和符号引用;
-
字段信息:字段名称、类型、修饰符;
-
方法信息:方法名称、返回类型、参数列表、修饰符、字节码、异常表等;
-
静态变量:类变量(被static修饰的变量)
-
类引用:指向Class对象的引用;
-
方法表:存储了类中所有虚方法(包括从父类继承的虚方法)的直接引用,使得虚拟机能够快速定位和调用方法;
-
即时编译代码:JIT 编译后的本地机器码;
方法区的基本理解
方法区主要存放的是类元数据,而堆中主要存放的是对象实例。
-
方法区(Method Area)与 Java 堆一样,是各个线程共享的内存区域。多个线程同时加载统一个类时,只能有一个线程能加载该类,其他线程只能等等待该线程加载完毕,然后直接使用该类,即类只能加载一次。
-
方法区在 JVM 启动的时候被创建,并且它的实际的物理内存空间中和 Java 堆区一样都可以是不连续的。
-
方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。
- Java 7 及以前永久代配置
# 设置永久代大小(Java 7及以前) java -XX:PermSize=64m -XX:MaxPermSize=128m MainClass
- Java 8+ 元空间配置
# 设置元空间初始大小和最大限制 java -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=256m MainClass # 启用元空间使用情况监控 java -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=256m -XX:+PrintGCDetails -XX:+PrintClassHistogram MainClass
- Java 7 及以前永久代配置
-
方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误:java.lang.OutofMemoryError:PermGen space 或者java.lang.OutOfMemoryError:Metaspace。
- 加载大量的第三方的 jar 包。
- Tomcat 部署的工程过多(30~50个)。
- 大量动态的生成反射类。
-
关闭 JVM 就会释放这个区域的内存。
-
方法区的垃圾收集主要回收两部分内容:方法区的内存回收主要是针对废弃的类和常量池中的常量。
- 当一个类的所有实例都被回收,该类的java.lang.Class对象没有被引用,且类的加载器也被回收时,该类会被卸载,相关的内存会被释放。常量池中的常量如果不再被引用,也会被回收。
- 常量池回收:字符串常量池在 Java 7 + 移至堆中,遵循堆的 GC 规则;符号引用常量如类名、方法名、字段名等,当不再被引用时回收。
设置方法区内存大小
方法区的大小不必是固定的,jvm 可以根据应用的需要动态调整。
配置项 | JDK 7 及以前(永久代) | JDK 8+(元空间) |
---|---|---|
初始大小参数 | -XX:PermSize=64m |
-XX:MetaspaceSize=128m |
最大大小参数 | -XX:MaxPermSize=128m |
-XX:MaxMetaspaceSize=256m |
默认初始大小 | 20.75M | 平台依赖(Windows默认21M) |
默认最大大小 | 32位64M/64位82M | 无限制(-1) |
内存溢出错误 | OutOfMemoryError: PermGen space |
OutOfMemoryError: Metaspace |
内存区域 | 堆的一部分 | 本地内存(Native Memory) |
GC触发条件 | 达到MaxPermSize |
达到MetaspaceSize (触发扩容) |
运行时常量池(Run-Time Constant Pool)
运行时常量池用于存放编译期间生成的各种字面量和符号引用,(还有常量)。
每个运行时常量池都是从 Java 虚拟机的方法区域分配的,Java1.8 后的静态变量和字符串常量池移到了堆中。
类或接口的运行时常量池是在 Java 虚拟机创建类或接口时构建的。
运行时常量池与常量池
常量池是字节码文件(.class 文件)中的一部分,用于存储编译期生成的字面量(Literals)和符号引用(Symbolic References);运行时常量池是方法区(Method Area)的一部分,是类加载后常量池的运行时表示。
维度 | 常量池(ClassFile) | 运行时常量池(Runtime) |
---|---|---|
生命周期 | 编译期生成,存在于.class 文件中 |
类加载时创建,存在于JVM运行时内存 |
数据类型 | 存储符号引用和字面量(静态) | 存储直接引用和动态常量(动态) |
解析状态 | 未解析(符号引用未转换) | 部分解析(符号引用→直接引用) |
内存位置 | 磁盘(.class 文件) |
方法区(JVM内存,JDK 8+为元空间) |
修改性 | 不可修改(编译后固定) | 可动态添加(如intern() ) |
关联关系 | 是运行时常量池的数据来源 | 是常量池的运行时实例 |
为什么需要这两个概念?
-
编译期与运行期的解耦:
- 常量池在编译期存储符号化的静态数据,运行时常量池在类加载后动态解析,确保 JVM 能适应动态性(如多态、动态类加载)。
-
空间与效率的平衡:
- 常量池避免重复存储相同常量(如多个类引用同一个字符串);
- 运行时常量池通过直接引用和缓存(如方法表)加速方法调用。
-
支持 Java 的跨平台性:
- 字节码文件通过常量池实现 “平台无关性”,运行时常量池则在不同 JVM 实现中完成本地化解析(如不同操作系统的内存地址映射)。
Java中的常量池区分:
class 常量池中的数据在类加载后存放到运行时常量池。(class 常量池(编译器确定数据)、运行时常量池(类加载确定数据))。
字符串常量池是 class 常量池的一部分。
-
class 文件常量池(Class Constant Pool)
存在于编译后的.class文件中,是字节码的一部分。作为编译期的静态存储结构,为类加载器在运行时创建运行时常量池提供原始数据。用于存放字面量和符号引用:
-
字面量(Literals):
如字符串常量(“hello”)、基本数据类型的值(123、3.14)、声明为final的常量值。
-
符号引用(Symbolic References):
类和接口的全限定名(如java/lang/String)、
字段的名称和描述符(如name:Ljava/lang/String;)、
方法的名称和描述符(如toString:()Ljava/lang/String;)。
-
-
运行时常量池(The Run-Time Constant Pool)
是方法区(Method Area)的一部分。在 JDK 8 及之后,随着方法区被元空间(Metaspace)取代,运行时常量池也移至元空间。当类加载到内存中后,jvm 就会将 class 常量池中的内容解析后存放到运行时常量池中,由此可知,运行时常量池也是每个类都有一个。运行时常量池是 Class 文件常量池在运行时的表示,包含:
-
字面量(Literals):字符串常量(如"hello")、基本数据类型的值(如123、3.14)、final静态常量(如public static final int MAX = 100)。
-
符号引用(Symbolic References):类和接口的全限定名(如java/lang/String)、字段的名称和描述符(如name:Ljava/lang/String;)、方法的名称和描述符(如toString:()Ljava/lang/String;)。
-
直接引用(Direct References):类或接口的元数据指针(指向方法区中的Klass结构)、字段或方法的内存地址(在类加载解析阶段由符号引用转换而来)。
-
-
String 常量池
字符串常量池,即为了避免多次创建字符串对象,而将字符串在 jvm 中开辟一块空间,储存不重复的字符串。JDK 7 及之前位于运行时常量池内,属于方法区的一部分;JDK 7 及之后被移至堆(Heap)中,但仍属于运行时常量池的逻辑范畴。存储所有字符串字面量和显式intern的字符串。
在直接使用双引号" "声明字符串的时候, java 都会去常量池找有没有这个相同的字符串,如果有,则将常量池的引用返回给变量。如果没有,会在字符串常量池中创建一个对象,然后返回这个对象的引用。
使用 new 关键字创建,比如 String a = new String(“hello”);。这里可能创建两个对象:一个是用双引号括起来的 hello,按照上面的逻辑,如果常量池没有,创建一个对象;另一个是必须会创建的,new 关键字必然会在堆中创建一个新对象,最终返回的是 new 关键词创建的对象的地址。
(当使用 new 运算符创建 String 对象时,JVM 将首先在 SCP(字符串常量池)中检查,该对象是否可用。如果 SCP 内部没有该对象,JVM 将创建两个对象,一个在 SCP 内部,另一个在 SCP 外部。但是如果 JVM 在 SCP内部 中找到相同的对象,它只会在 SCP 外部创建一个对象。)
-
基本类型包装类常量池
在 Java 中,基本类型包装类常量池是一种对象缓存机制,用于复用常用的基本类型包装对象,以减少内存开销和提高性能。Java为以下包装类提供了常量池实现:Integer (-128~127)、Long (-128~127)、Short (-128~127)、Byte (全部值,因为byte范围本就是-128~127)、Character (0~127)、Boolean (只有TRUE和FALSE两个值),Float和Double没有常量池实现。
JVM在启动时就会预先创建并缓存这些常用范围的包装对象,例如Integer会缓存-128到127之间的256个Integer对象。
自动复用:
Integer a = 100; // 使用常量池 Integer b = 100; // 复用同一个对象 System.out.println(a == b); // true
超出边界情况:
Integer c = 128; // 超出缓存范围 Integer d = 128; // 创建新对象 System.out.println(c == d); // false
所以在包装类比较时,应使用equals()而不是==,==比较只在常量池范围内有效
字面值(字面量)
字面值是源代码中直接给出的常量值,是编译期已知的固定值。如字符串值(如"Hello World"),基本数据类型的数值(如123、true),以及它们的包装类的值,以及 final 修饰的变量值(编译期间就有确定的值),简单说就是在编译期间,就可以确定下来的值。(如1、’a’、”string” 等字面意义上的值)。
-
编译期:字面值存储在Class 文件常量池中。
-
运行期:
字符串字面量会转移到字符串常量池;
基本类型字面值:若被声明为final static,则存储在运行时常量池;局部变量:存储在栈帧中;实例变量:堆中对象的实例数据。
符号引用:
符号引用是编译期用于表示代码中引用的目标(类、方法、字段等)的符号化字符串。一个 java 类将会编译成一个class文件,由于编译时无法确定目标的实际内存地址,因此使用符号引用来占位。符号引用与虚拟机的内存布局无关,引用的目标并不一定加载到内存中。存储于 Class 文件常量池中,以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等结构存在。
- 类或接口的全限定名:例如:java/lang/String。
- 字段的名称和描述符:例如:name:Ljava/lang/String;(表示字段名name,类型为String)。
- 方法的名称和描述符:例如:toString:()Ljava/lang/String;(表示方法名toString,无参数,返回String)。
在类加载的解析阶段(Resolution),JVM 将符号引用转换为直接引用:
- 类解析:将类的符号引用(如java/lang/String)解析为方法区中该类的元数据指针;
- 字段解析:通过类的元数据找到字段的内存偏移量,即实际内存地址的指针。
- 方法解析:通过类的元数据和方法表(Method Table)找到方法的内存地址。
直接引用:
直接引用是运行时指向目标的真实内存地址或句柄,是符号引用解析后的最终形式。
- 内存地址:指向类元数据:指向方法区中类元数据的指针。指向对象实例:堆中对象的起始地址。
- 相对偏移量:字段偏移量:字段在对象中的偏移量(Person类的name字段可能位于对象起始地址 + 16 字节处)。方法表索引:虚方法在类的方法表(Method Table)中的索引位置;
- 句柄(Handle):一种间接指针,通过句柄表间接定位目标,用于支持对象移动,如 GC 后的对象重定位。
直接引用是和虚拟机的布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经被加载入内存中了。
符号引用是只包含语义信息,不涉及具体实现的;而解析(resolve)过后的直接引用则是与具体实现息息相关的。
符号引用——>经过解析——>直接引用:符号引用只是一个标记,开始不能代表具体的对象,只用经过解析后,才代表具体的对象(即地址);对于静态的符号引用,在准备阶段就代表具体的对象(即已解析成直接引用)。
堆区
基本概念
jvm 只有一个堆区(heap)被所有线程共享,提供所有类实例和数组对象存储区域,堆内存的大小是可以调整的。堆中不存放基本类型和对象引用,只存放对象本身。(目的是存放实例对象)。
-
一个 JVM 实例只存在一个堆内存,堆也是 Java 内存管理的核心区域。
-
Java 堆区在 JVM 启动的时候即被创建,其空间大小也就确定了,堆是 JVM 管理的最大一块内存空间,并且堆内存的大小是可以调节的。
-
《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。
-
所有的线程共享 Java 堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)。
-
《Java虚拟机规范》中对 Java 堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。(The heap is the run-time data area from which memory for all class instances and arrays is allocated)
-
从实际使用角度看:“几乎” 所有的对象实例都在堆分配内存,但并非全部。因为还有一些对象是在栈上分配的(逃逸分析,标量替换)。通过分析对象的作用域,JVM 可判断对象是否仅在方法内部使用(未逃逸)。对于未逃逸对象,JVM 可能:1)栈上分配:直接在栈帧中分配对象,方法结束后随栈帧销毁,无需 GC。2)标量替换(Scalar Replacement):将对象拆分为基本类型(如int、double),直接作为局部变量存储在栈上。
-
逃逸分析并非真正在栈上创建完整对象,而是将对象的存储转化为栈上的局部变量。从内存布局看,这些变量在逻辑上仍属于堆的逻辑范畴,但物理上可能暂存于栈帧中。
-
-
数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。
-
在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。
- 也就是触发了 GC 的时候,才会进行回收。
- 如果堆中对象马上被回收,那么用户线程就会受到影响,因为有 stop the word。
-
堆,是 GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。
- 堆需要 GC,存在 OOM。
设置堆内存
Java 堆区用于存储 Java 对象实例,那么堆的大小在 JVM 启动时就已经设定好了,大家可以通过选项 ”-Xms” 和 ”-Xmx” 来进行设置。
-
-Xms 用于表示堆区的起始内存,等价于 -XX:InitialHeapSize
-
-Xmx 用于表示堆区的最大内存,等价于 -XX:MaxHeapSize
一旦堆区中的内存大小超过 “-Xmx” 所指定的最大内存时,将会抛出 OutofMemoryError 异常。
通常会将 -Xms 和 -Xmx 两个参数配置相同的值
-
原因:假设两个不一样,初始内存小,最大内存大。在运行期间如果堆内存不够用了,会一直扩容直到最大内存。如果内存够用且多了,也会不断的缩容释放。频繁的扩容和释放造成不必要的压力,避免在 GC 之后调整堆内存给服务器带来压力。
如果两个设置一样的就少了频繁扩容和缩容的步骤。内存不够了就直接报 OOM。
默认情况下:
- 初始内存大小:物理电脑内存大小1/64。
- 最大内存大小:物理电脑内存大小1/4。
堆内存参数设置总结:
- -XX:+PrintFlagsInitial:查看所有的参数的默认初始值。
- -XX:+PrintFlagsFinal:查看所有的参数的最终值。(可能会存在修改,不再是初始值)。
-xms:初始堆空间内存。(默认为物理内存的1/64) - -Xmx:最大堆空间内存。(默认为物理内存的1/4)
- -Xmn:设置新生代的大小。(初始值及最大值)
- -XX:NewRatio:配置新生代与老年代在堆结构的占比。
- -XX:SurvivorRatio:设置新生代中 Eden 和 s0/s1 空间的比例。
- -XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄。
- -XX:+PrintccDetails:输出详细的GC处理日志。
- 打印gc简要信息:1)-xx:+Printcc;2)-verbose: gc
- -XX:HandlePromotionFailure:是否设置空间分配担保。
堆内存细分
现代 JVM(如 HotSpot)基于 分代假说(Generational Hypothesis)对堆内存进行分代管理:堆内存通常划分为新生代(Young Generation)、老年代(Old Generation) 和 永久代 / 元空间(Permanent Generation/Metaspace)
Java 7 及之前堆内存逻辑上分为三部分:新生代+老年代+永久代
-
Young Generation Space 新生代(Young/New)。
新生代又被划分为 Eden 区和 survivor 区。
-
Tenure generation space 老年代(old/Tenure)。
-
Permanent Space 永久代(Perm),存在于方法区。
Java 8 及之后堆内存逻辑上分为三部分:新生代+养老区+元空间
-
Young Generation Space 新生代(Young/New)。
- 新生代又被划分为 Eden 区和 Survivor 区。
-
Tenure generation space 养老区(old/Tenure)
-
Meta Space 元空间(Meta),存在于本地内存,属于非堆内存。
约定:新生区=新生代=年轻代;养老区=老年区=老年代;永久区=永久代。
新生代和老年代物理上是属于堆区,永久区物理上是在方法区中。
配置新生代与老年代在堆结构的占比:
- 默认-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3。
配置 Eden 和 两个 survivor 的占比:
- 默认 -XX:SurvivorRatio=8,Eden空间和另外两个survivor空间缺省所占的比例是 8:1:1。
新生代、老年代、元数据区(永久区)
java 中最常见的问题之一就是堆内存溢出(OutOfMemoryError: OOM),所以了解 jvm 堆工作及 GC 的原理非常重要。jvm 堆内存从 GC 的角度划分可分为:新生代(eden区、survivor form区和survivor to区)和老年代。
新生代
新生代是用来存放新生的对象,新生代通常占据着堆内存的 1/3 空间。新生代由 1 个 Eden 区 和 2 个 Survivor 区(S0 和 S1) 组成,默认比例为 8:1:1(可通过 -XX:SurvivorRatio 调整)。因为 java 对象频繁的创建,所以新生代会频繁的触发 Minor GC 进行垃圾回收。
- Eden 区(伊甸园区):java 新对象的出生地(当然如果新创建的对象占用的内存非常大是,则直接将其分配至老年代),当 Eden 区中的内存不足时,就会触发 Minor GC 对新生代区进行一次垃圾回收。
- Survivor To 区(幸存1区):用于保留 Minor GC 中的幸存者。
- Survivor From 区(幸存0区):用于存放上一次 Minor GC 中幸存者,并且作为本次 Minor GC 的被扫描者。
- 几乎所有的 Java 对象都是在 Eden 区被 new 出来的,当 Eden 区内存不足时,触发新生代 GC(Minor GC),标记并回收死亡对象。IBM 公司的专门研究表明,新生代中 80% 的对象都是 “朝生夕死” 的,绝大部分的 Java 对象的销毁都在新生代进行。
Minor GC 过程:Minor GC 通常采用复制算法。首先将 Eden 区和 Survivor From 区中存活的对象复制到 Survivor To 区之中(如果对象的年龄到达了老年代的标准时则赋值到老年代(通常年龄大于15 即可));然后清空 Eden 区和 Survivor From 区,最后将 Survivor To 区和 Survivor From 区互换,原来的 Survivor To 区变成下一次的 Survivor From 区。
一般情况下,新创建的对象都会被分配到 Eden 区(一些大对象特殊处理),这些对象经过第一次 Minor GC 后,如果仍然存活,将会被移到 Survivor 区。对象在 Survivor 区中每熬过一次 Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时,就会被移动到老年代中。
老年代
老年代主要存放在程序中生命周期长的对象。老年代通常占据着堆内存的 2/3 空间。老年代因为其中对象比较稳定,所以 Major GC 不会频繁的执行。在进行 Major GC 之前通常都会先执行一次 Minor GC,Minor GC 执行完后可能会有新生代的对象晋升到老年代之中,然后导致老年代的空间不足才触发 Major GC。当无法找到足够大的连续空间分配给新创建的较大的对象时也会提前触发一次 Major GC 进行垃圾回收来腾出空间。当触发 Major GC 时,GC 期间会停止一切线程等待至 GC 完成。
Major GC 过程:因为老年代每次只回收少量的对象,所以 Major GC 通常推荐采用标记整理算法。首先扫描一次所有的老年代,标记出所有存活的对象和需要回收的对象,将存活的对象移向内存的一端,然后清除边界外的对象。
永久代与元数据区及区别
永久代就是指永久保存的区域,主要存放类元数据信息,类元数据在被加载的时候放入永久代。他和存放实例的区域不同,理论上永久代区域数据“永久”存活,Full GC 时才会触发回收(如类卸载),关闭虚拟机就会释放这个区域的内存。应用动态加载大量类(如热部署、反射生成类)时,导致永久代空间会随着不断增加的类而占满,最终导致 OOM 异常。所以在 JAVA8 之后,移除了永久代,用一个叫元数据区的代替了永久代。元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现。元空间和永久代之间最大差异就是元空间使用的不是虚拟机中的内存,而是使用本地内存,这样的好处在于其元空间内存的大小仅仅受限于本地内存的大小(可通过 -XX:MaxMetaspaceSize 设置安全阈值),这样就可以避免永久代 OOM 的问题了。
什么情况下永久代会崩? 假设一个启动类,加载了大量的第三方jar包;Tomcat 部署了太多的应用;大量动态生成的反射类。若不断被加载,直到永久代空间满,就会出现 OutOfMemoryError。
- 在 JDK6 以前,永久代内存溢出抛出 "java.lang.OutOfMemoryError: PermGen space "这个异常。这里的 “PermGen space”其实指的就是方法区。不过方法区和“PermGen space”又有着本质的区别。前者是 JVM 的规范,而后者则是 JVM 规范的一种实现,并且只有 HotSpot 才有 “PermGen space”,而对于其他类型的虚拟机,如 JRockit(Oracle)、J9(IBM) 并没有“PermGen space”。由于方法区主要存储类的相关信息,所以对于动态生成类的情况比较容易出现永久代的内存溢出。最典型的场景就是,在 jsp 页面比较多的情况,容易出现永久代内存溢出。我们现在通过动态生成类来模拟 “PermGen space”的内存溢出:
在 JDK 1.8 中, HotSpot 已经没有 “PermGen space” 这个区间了,取而代之是一个叫做 Metaspace(元空间) 的东西。永久代与元数据区演变过程:
- JDK1.6 之前:为永久代,类型信息,字段,方法,常量、静态变量、常量池保存在方法区。
- JDK1.7:还有永久代,但是慢慢的退化了,是去永久代阶段,将静态变量、常量池中的字符串常量池保存在堆中,其他信息还是保存在方法区中。
- JDK1.8之后:无永久代 了,其他信息保存在元空间。但字符串常量池、静态变量仍然在堆中。
这里静态变量是指静态变量本身(即静态变量名),而其指向的对象一直是存放在堆中。
JDK 1.7及以后,内存溢出抛出 ”java.lang.OutOfMemoryError: Java heap space“ 异常。
从上述结果可以看出,JDK 1.6下,会出现 “PermGen Space” 的内存溢出,而在 JDK 1.7和 JDK 1.8 中,会出现堆内存溢出,并且 JDK 1.8中 PermSize 和 MaxPermGen 已经无效。因此,可以大致验证 JDK 1.7 和 1.8 将字符串常量由永久代转移到堆中,并且 JDK 1.8 中已经不存在永久代的结论。
永久代为什么要被元空间替代?
- 随着 Java8 的到来,HotSpot VM 中再也见不到永久代了。但是这并不意味着类的元数据信息也消失了。这些数据被移到了一个与堆不相连的本地内存区域,这个区域叫做元空间(Metaspace)。
- 由于类的元数据分配在本地内存中,元空间的最大可分配空间就是系统可用内存空间。
- 这项改动是很有必要的,原因有:
-
永久代的空间固定,为永久代设置空间大小是很难确定的。在某些场景下,如果动态加载类过多,容易产生Perm区的OOM。比如某个实际Web工程中,因为功能点比较多,在运行过程中,要不断动态加载很多类,经常出现致命错误。Exception in thread ‘dubbo client x.x connector’ java.lang.OutOfMemoryError:PermGen space。
而元空间和永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。 因此,默认情况下,元空间的大小仅受本地内存限制。
-
对永久代进行调优是很困难的。方法区的垃圾收集主要回收两部分内容:常量池中废弃的常量和不再用的类型,方法区的调优主要是为了降低 Full GC。(尽可能降低 Full GC)
- 有些人认为方法区(如HotSpot虚拟机中的元空间或者永久代)是没有垃圾收集行为的,其实不然。《Java虚拟机规范》对方法区的约束是非常宽松的,提到过可以不要求虚拟机在方法区中实现垃圾收集。事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在(如 JDK11 时期的 ZGC 收集器就不支持类卸载)。
- 一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻。但是这部分区域的回收有时又确实是必要的。以前Sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏。
-
字符串常量池 StringTable 为什么要调整位置?
详细看:https://imlql.cn/post/ee2ba71e.html
-
永久代内存限制:永久代的大小在 JVM 启动时固定(通过-XX:MaxPermSize设置,默认较小,如 64MB 或 82MB),若应用程序使用大量字符串常量(如字符串拼接、反射加载类等),容易导致永久代内存溢出(OOM)。将字符串常量池从永久代剥离,减少永久代的内存占用,降低因字符串常量过多导致的 OOM 风险。
-
垃圾回收效率:因为永久代的回收效率很低,在 Full GC 的时候才会执行永久代的垃圾回收,而 Full GC 是老年代的空间不足或永久代不足时才会触发。当有大量的字符串被创建,回收效率低,导致永久代内存不足。JDK7 中将 StringTable 放到了堆空间中,堆内存的垃圾回收(如分代回收)比永久代更高效,字符串常量池进入堆后,可通过 Minor GC 及时回收不再使用的短生命周期字符串,提升内存利用率。
-
这就导致 StringTable 回收效率不高,而我们开发中会有大量的字符串被创建,回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。
对象分配原则(对象提升规则)
-
优先分配到 Eden:开发中比较长的字符串或者数组,会直接存在老年代,但是因为新创建的对象都是朝生夕死的,所以这个大对象可能也很快被回收,但是因为老年代触发 Major GC 的次数比 Minor GC 要更少,因此可能回收起来就会比较慢。
-
大对象直接分配到老年代:尽量避免程序中出现过多的大对象。
-
长期存活的对象分配到老年代。当年龄达到 -XX:MaxTenuringThreshold(默认 15)时,晋升至老年代。
-
动态对象年龄判断:如果 Survivor 区中相同年龄的所有对象大小的总和大于 Survivor 空间的一半(由 -XX:TargetSurvivorRatio 控制)),年龄大于或等于该年龄的对象可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。
-
空间分配担保:在 Java 垃圾回收机制中,空间分配担保是为了确保新生代对象在晋升到老年代时的内存安全性而设计的机制,主要涉及Minor GC 过程中对象晋升的风险管控。其核心逻辑是:在进行 Minor GC 前,先检查老年代的可用内存是否足够容纳新生代中所有存活对象,以避免因老年代内存不足导致 Full GC 甚至 OOM(内存溢出)。
空间分配担保的本质:是一种风险控制策略,通过预判和提前触发 Full GC,避免 Minor GC 过程中因老年代内存不足导致的中断或 OOM。
核心逻辑与流程:
-
检查老年代可用内存
若老年代可用内存 ≥ 新生代所有对象总大小:直接进行 Minor GC,确保存活对象可安全晋升到老年代。
若老年代可用内存 < 新生代所有对象总大小:进入 “担保失败” 的风险预判流程。
-
判断是否允许担保失败
允许担保失败(默认情况):检查老年代的可用内存是否大于历次晋升到老年代对象的平均大小(JVM 会统计历史晋升的平均大小)。
- 若大于平均大小:尝试进行 Minor GC,尽管存在风险(如本次晋升对象大小超过老年代可用内存),但基于历史数据认为风险可控。
- 若小于或等于平均大小:直接触发 Full GC,先清理老年代以腾出足够空间,再进行 Minor GC。
频繁触发 Full GC 的警示:若应用程序频繁因空间分配担保而触发 Full GC,可能意味着:1)老年代内存过小;2)新生代对象存活率过高(如存在大量长生命周期对象误分配到新生代);3)大对象频繁创建,需调整大对象分配策略。
不允许担保失败(配置 -XX:-HandlePromotionFailure):只要老年代的可用内存 < 新生代中所有存活对象的总大小,就直接触发 Full GC(而非尝试冒险晋升)。
-
对象分配过程:
TLAB
TLAB 为对象分配内存(保证线程安全)。
为什么有 TLAB
-
堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据;
-
由于对象实例的创建在 JVM 中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的。
-
为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。
什么是 TLAB
TLAB(Thread Local Allocation Buffer)
- 从内存模型而不是垃圾收集的角度,对 Eden 区域继续进行划分,JVM 为每个线程分配了一个私有缓存区域,它包含在 Eden 空间内。
- 多线程同时分配内存时,使用 TLAB 可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略。
- 据我所知所有 OpenJDK 衍生出来的 JVM 都提供了 TLAB 的设计。
- 每个线程都有一个TLAB空间。
- 当一个线程的TLAB存满时,可以使用公共区域(蓝色)的。
TLAB 再说明
- 尽管不是所有的对象实例都能够在 TLAB 中成功分配内存,但 JVM 确实是将 TLAB 作为内存分配的首选。
- 在程序中,开发人员可以通过选项 “-XX:UseTLAB” 设置是否开启 TLAB 空间。
- 默认情况下,TLAB 空间的内存非常小,仅占有整个 Eden 空间的 1%,当然我们可以通过选项 “-XX:TLABWasteTargetPercent” 设置 TLAB 空间所占用 Eden 空间的百分比大小。
- 一旦对象在 TLAB 空间分配内存失败时,JVM 就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在 Eden 空间中分配内存。
- 分配过程
逃逸分析概述
-
如何将堆上的对象分配到栈,需要使用逃逸分析手段。
-
这是一种可以有效减少 Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。
-
通过逃逸分析,Java HotSpot 编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
-
逃逸分析的基本行为就是分析对象动态作用域:
-
当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
-
当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。
-
如下:
没有发生逃逸的对象,则可以分配到栈上,随着方法执行的结束,栈空间就被移除。
public void my_method() { v v = new V(); //use v //...... v=null; }
蓝色代码块发生逃逸的对象,黄色代码块没有发生逃逸的对象。
-
使用逃逸分析,编译器可以对代码做如下优化:
-
栈上分配。将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
-
同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
在动态编译同步块的时候,JIT 编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么 JIT 编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除。
-
分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在 CPU 寄存器中。
- 标量(scalar)是指一个无法再分解成更小的数据的数据。Java 中的原始数据类型就是标量。
- 相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java 中的对象就是聚合量,因为他可以分解成其他聚合量和标量。
- 在 JIT 阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过 JIT 优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。
本地方法栈
本地方法栈与虚拟机栈所发挥的作用是非常相似的,是线程私有的,其区别是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的 native 方法服务(本地方法指其他语言如C/C++写的外部程序,可以被 Java 调用)。
具体做法是 Native Method Stack 中登记 native 方法,在 Execution Engine 执行时加载本地方法库。
Java 虚拟机栈于管理 Java 方法的调用,而本地方法栈用于管理本地方法的调用。
允许被实现成固定或者是可动态扩展的内存大小(在内存溢出方面和虚拟机栈相同)
- 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java 虚拟机将会抛出一个 stackoverflowError 异常。
- 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么 Java 虚拟机将会抛出一个 OutofMemoryError 异常。
总结
总结三个存储空间存放的信息:
类变量(还包括静态的方法,块等,即 static 修饰)
符号引用,字面值,以及静态变量,静态方法等的具体空间在方法区(准备阶段确定在方法区);
(方法区存放与类相关的信息,一些永久信息)
实例对象
实例对象的具体空间在堆区(初始化阶段确定在堆区);
(堆区存放实例对象)
非静态方法(包括非静态方法(没有static修饰)中的局部变量)
非静态方法执行(以及其中的局部变量等)具体空间在栈区(方法调用执行时确定在栈区,其实方法在编译后就确定了具体空间大小)。
(栈区存放临时的量)
位置 | 是否有Error | 是否存在GC |
---|---|---|
PC计数器 | 无 | 不存在 |
虚拟机栈 | 有,SOF | 不存在 |
本地方法栈(在HotSpot的实现中和虚拟机栈一样) | 有,SOF | 不存在 |
堆 | 有,OOM | 存在 |
方法区 | 有 | 存在 |
栈、堆、方法区的交互关系
下面涉及了对象的访问定位
-
Person 类的 .class 信息存放在方法区中;
-
person 变量存放在 Java 栈的局部变量表中;
-
真正的 person 对象存放在 Java 堆中;
-
在 person 对象中,有个指针指向方法区中的 person 类型数据,表明这个 person 对象是用方法区中的 Person 类 new 出来的。
Native 关键字
使用 native 关键字,说明 Java 的作用范围不够了。native 关键字告诉 JVM 调用的该方法是在外部定义,由其他语言编写的(如调用底层 C 或 C++ 语言等的库)。因此在 JVM 内存区域中专门开辟了一块标记区域(本地方法栈),用来登记 native 方法。
在调用 native 方法的时候,会进入本地方法栈,然后调用本地方法接口(JNI),通过 JNI 加载本地方法库中对应的方法去执行。
native 是用做 java 和其他语言(如C/C++)进行协作时用的。一个 native 方法就是一个 Java 调用非 Java 代码的接口。一个 native 方法是指该方法的实现由非 Java 语言实现,比如用C或C++实现。
在定义一个 native 方法时,并不提供实现体(比较像定义一个Java Interface),因为其实现体是由非 Java 语言在外面实现的。
主要是因为 JAVA 无法对操作系统底层进行操作,但是可以通过 JNI(java native interface)调用其他语言来实现底层的访问。
用 Java 代码调用本地的 C/C++ 程序步骤如下:
- 编写带有 native 声明的方法的 java 类,生成 .java 文件;(注意这里出现了 native 声明的方法关键字)
- 使用 javac 命令编译所编写的 java 类,生成 .class 文件;
- 使用 javah -jni java类名,生成扩展名为 h 的头文件,也即生成 .h 文件;
- 使用 C/C++(或者其他编程想语言)实现本地方法,创建 .h 文件的实现,也就是创建.cpp 文件实现 .h 文件中的方法;
- 将 C/C++ 编写的文件生成动态连接库,生成 dll 文件。
- 在 Java 中用 System.loadLibrary() 方法加载第五步产生的动态链接库文件,这个 native() 方法就可以在 Java 中被访问了。
更多推荐
所有评论(0)