JVM运行时数据区概述

JVM运行时数据区概述

本节主要讲的是运行时数据区,也就是下图这部分,它是在类加载完成后的数据存储的一片空间,其中可以划分为线程共享的方法区、堆和线程私有的程序计数器、Java虚拟机栈、本地方法栈等。

image-20240218220954734

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域:

img

程序计数器

JVM中的程序计数寄存器(Program Counter Register)中,Register的命名源于CPU的寄存器,寄存器存储指令相关的现场信息。CPU只有把数据装载到寄存器才能够运行。这里,并非是广义上所指的物理寄存器,或许将其翻译为PC计数器(或指令计数器)会更加贴切,并且也不容易引起一些不必要的误会。JVM中的PC寄存器是对物理PC寄存器的一种抽象模拟

程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

由于Java虚拟机的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

补充一下,操作系统的线程切换时会保存线程当前执行的上下文信息如指令地址信息,之所以JVM为每个线程单独开辟一块程序计数器的内存空间,是因为JVM需要完成翻译、执行字节码指令的任务,操作系统只能识别机器指令,也就是说JVM需要知道每个线程的字节码指令执行位置,因此程序计数器由此诞生!

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。

img

1
2
3
4
5
6
7
public class HelloPCRegister {
public static void main(String[] args) {
int i = 10;
int j = 20;
System.out.println(i + j);
}
}

image-20240218230341343

Java虚拟机栈

虚拟机栈概述

虚拟机栈的出现背景:

  1. 由于跨平台性的设计,Java的指令都是根据栈来设计的。不同平台CPU架构不同,所以不能设计为基于寄存器的【如果设计成基于寄存器的,耦合度高,性能会有所提升,因为可以对具体的CPU架构进行优化,但是跨平台性大大降低】。
  2. 优点是跨平台,指令集小,编译器容易实现,缺点是性能下降,实现同样的功能需要更多的指令。

我们常说一句话,堆管存储栈管运行,Java虚拟机栈每个线程都会创建一份,随着Java方法的调用和结束,虚拟机栈中的栈帧也不断进行着入栈与出栈操作。与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stack)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class HelloStack {
public static void main(String[] args) {
int i = 10;
method1();
System.out.println("main");
}

public static void method1() {
int j = 20;
method2();
System.out.println("method1");
}

public static void method2() {
int k = 30;
System.out.println("method2");
}
}

image-20240218231951018

Java虚拟机栈的栈帧中的局部变量表保存了方法的参数列表和局部变量(八种基本数据类型、引用类型的地址)

image-20240218232123003

  • 栈是一种快速有效的分配存储方式,访问速度仅次于程序计数器。

  • 对于栈来说不存在垃圾回收问题,但是可能存在OOM。

  • 在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况:

    • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;
    • 如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。
1
2
3
4
5
6
7
8
public class HelloStackOOM {
private static int count = 0;
public static void main(String[] args) {
count++;
System.out.println(count);
main(args);
}
}

image-20240218232839084

使用-Xss选项设置栈的最大容量为256k,查看修改后虚拟机栈能够容纳的最大栈帧数即方法调用的深度

image-20240218233110117

image-20240218233126903

Java虚拟机栈的运行原理:

  1. JVM直接对Java栈的操作只有两个,就是对栈帧的压栈出栈,遵循先进后出(后进先出)原则
  2. 在一条活动线程中,一个时间点上,只会有一个活动的栈帧,即只有当前正在执行的方法的栈帧(栈顶栈帧)是有效的。这个栈帧被称为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method)
  3. 执行引擎运行的所有字节码指令只针对当前栈帧进行操作
  4. 如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前栈帧

第05章_栈桢内部结构

  1. 不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧
  2. 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧
  3. Java方法有两种返回函数的方式
    • 一种是正常的函数返回,使用return指令
    • 另一种是方法执行中出现未捕获处理的异常,以抛出异常的方式结束
    • 但不管使用哪种方式,都会导致栈帧被弹出

局部变量表

局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。

这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示,其中64位长度的longdouble类型的数据会占用两个变量槽,其余的数据类型只占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。请读者注意,这里说的“大小”是指变量槽的数量,虚拟机真正使用多大的内存空间(譬如按照1个变量槽占用32个比特、64个比特,或者更多)来实现一个变量槽,这是完全由具体的虚拟机实现自行决定的事情。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class HelloLocalVariable {
public static void main(String[] args) {
int i = 10;
int j = 20;
String str = "hhhh";
}

public void method() {
int a = 10;
long b = 20L;
double c = 30.0;
String str = "hhhh";
}
}

一般信息:

image-20240219002645232

字节码指令:

image-20240219003044327

行号对应表:

image-20240219003230201

局部变量表:

image-20240219003806262

杂项:

image-20240219003935435

关于Slot的理解

  1. 局部变量表,最基本的存储单元是Slot,局部变量表中存放编译期可知的各种基本数据类型(8种),引用类型(reference),returnAddress类型的变量
  2. 在局部变量表里,32位以内的类型只占用一个Slot(包括returnAddress类型),64位的类型占用两个Slot(long和double)
    • byte、short、char在储存前被转换为int,boolean也被转换为int,0表示false,非0表示true,long和double则占据两个Slot,如果需要访问局部变量表中一个64位的局部变量值时,只需要使用前一个索引即可
  3. JVM会为局部变量表中的每一个Slot都分配一个访问索引,通过这个索引即可成功访问到局部变量表中指定的局部变量值
  4. 当一个实例方法被调用的时候,它的方法参数和方法体内部定义的局部变量将会按照顺序被复制到局部变量表中的每一个Slot上
  5. 如果当前帧是由构造方法或者实例方法创建的,那么该对象引用this将会存放在index为0的Slot处,其余的参数按照参数表顺序继续排列(this也相当于一个变量)

image-20240219004726384

Slot的重复利用

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

1
2
3
4
5
6
7
8
public void function() {
int a = 10;
{
int b = 20;
b = a * 2;
}
int c = 30;
}

image-20240219005130499

操作数栈

操作数栈:Operand Stack,每一个独立的栈帧除了包含局部变量表以外,还包含一个后进先出(Last-In-First-Out)的操作数栈,也可以称之为表达式栈(Expression Stack)

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

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

img

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

每一个操作数栈都会拥有一个明确的栈深度用于存储数值,其所需的最大深度在编译期就定义好了,保存在方法的Code属性中,为maxstack的值。

栈中的任何一个元素都是可以任意的Java数据类型:

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

操作数栈并非采用访问索引的方式来进行数据访问的,而是只能通过标准的入栈和出栈操作来完成一次数据访问。操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,这由编译器在编译器期间进行验证,同时在类加载过程中的类检验阶段的数据流分析阶段要再次验证。

另外,我们说Java虚拟机的解释引擎是基于栈的执行引擎,其中的栈指的就是【操作数栈】。

1
2
3
4
5
6
7
public class HelloAddOperation {
public static void main(String[] args) {
int i = 10;
int j = 20;
int k = i + j;
}
}

image-20240219105041123

在这个操作数栈的执行过程中最大深度为2,局部变量表槽数为4(包含this引用)

动态链接

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

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

img

静态链接

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

动态链接

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

对应的方法的绑定机制为:早期绑定(Early Binding)和晚期绑定(Late Binding)。绑定是一个字段、方法或者类在符号引用被替换为直接引用的过程,这仅仅发生一次。

Java中任何一个普通的方法其实都具备虚函数的特征,它们相当于C++语言中的虚函数(C++中则需要使用关键字virtual来显式定义)。如果在Java程序中不希望某个方法拥有虚函数的特征时,则可以使用关键字final来标记这个方法。

  • 如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的,这样的方法称为非虚方法。
  • 静态方法、私有方法、final方法、实例构造器、父类方法都是非虚方法。

普通调用指令

  • invokestatic:调用静态方法,解析阶段确定唯一方法版本
  • invokespecial:调用方法、私有及父类方法,解析阶段确定唯一方法版本
  • invokevirtual:调用所有虚方法
  • invokeinterface:调用接口方法

动态调用指令

  • invokedynamic:动态解析出需要调用的方法,然后执行

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
interface IUserService {
void login(String name, String password);
}

class Father {
public static void eat() {
System.out.println("eat");
}
public void sayHello() {
System.out.println("father");
}
}
public class HelloInvoke {
class Son extends Father implements IUserService {

@Override
public void login(String name, String password) {
System.out.println("login");
}

@Override
public void sayHello() {
// invokespecial
super.sayHello();
System.out.println("son");
}

public void method() {
// invokevirtual
sayHello();
// invokestatic
Father.eat();
// invokeinterface
IUserService userService = null;
userService.login("","");
}
}
}

这个例子里面还有一个有意思的点,就是内部类对象包含外层类对象的引用,即this$0:

image-20240219111534041

方法返回地址

存放调用该方法的PC寄存器的值。一个方法的结束,有两种方式:

  • 正常执行完成
  • 出现未捕获处理的异常,非正常退出

无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的PC计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。

执行引擎遇到任意一个方法返回的字节码指令(return),会有返回值传递给上层的方法调用者,简称正常完成出口;

  • 一个方法在正常调用完成之后,究竟需要使用哪一个返回指令,还需要根据方法返回值的实际数据类型而定
  • 在字节码指令中,返回指令包含ireturn(当返回值是boolean,byte,char,short和int类型时使用),lreturn(long类型),freturn(float类型),dreturn(double类型),areturn(引用类型),return(void类型或者类的构造器方法中)。

在方法执行过程中遇到异常(Exception),并且这个异常没有在方法内进行处理,也就是只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,简称异常完成出口。方法执行过程中的异常处理,存储在一个异常表,方便在发生异常的时候找到处理异常的代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class HelloReturnAddress {
// return
public void methodVoid() {
return;
}

// ireturn
public byte methodByte() {
return 1;
}

// ireturn
public int methodInt() {
return 10;
}

// lreturn
public long methodLong() {
return 10L;
}

// areturn
public String methodReference() {
return "";
}

public void method1() {
try {
method2();
} catch (Exception e) {

}
}

public void method2() {
throw new RuntimeException("测试异常");
}
}

image-20240219114220895

本地方法栈

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它,甚至有的Java虚拟机(譬如HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowErrorOutOfMemoryError异常。

JVN堆区

一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域。Java堆区在JVM启动的时候即被创建,是JVM管理的最大一块内存空间,堆内存的大小是可以调节的(-Xms设置堆初始容量,-Xmx设置堆最大容量)。

《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)。

第08章_TLAB

几乎所有的对象实例和数组都是在堆上分配的,也有一些特殊情况,例如随着逃逸分析技术的发展,栈上分配也是一种可能。

在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。

  • 也就是触发了GC的时候,才会进行回收
  • 如果堆中对象马上被回收,那么用户线程就会收到影响,因为有stop the word

堆,是GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。

img

堆内存细分

Java7及之前堆内存逻辑上分为三部分:新生代+老年代+永久代

  • Young Generation Space 新生代(又被划分为Eden区和Survivor区,设置参数-XX:SurvivorRatio)
  • Tenure Generation Space 老年代(设置参数-XX:NewRatio-Xms-Xmx)
  • Permanent Space 永久区(设置参数-XX:MaxPermSize)

Java8及之后堆内存逻辑上分为三部分:新生代+老年代+元空间

  • Young Generation Space 新生代(又被划分为Eden区和Survivor区,设置参数-XX:SurvivorRatio)
  • Tenure Generation Space 老年代(设置参数-XX:NewRatio-Xms-Xmx)
  • Meta Space 元空间(设置参数-XX:MetaspaceSize)
第08章_堆和方法区图

通常会将-Xms-Xmx两个参数配置相同的值,其目的是为了能够在Java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能

默认情况下

  • 初始内存大小:物理电脑内存大小/64
  • 最大内存大小:物理电脑内存大小/4
1
2
3
4
5
6
7
8
9
10
public class HelloHeapSize {
public static void main(String[] args) {
// 返回Java虚拟机中的初始堆内存总量
long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
// 返回Java虚拟机试图使用的最大堆内存
long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;
System.out.println("-Xms:" + initialMemory + "M");
System.out.println("-Xmx:" + maxMemory + "M");
}
}

image-20240219205323635

我的本机物理内存是32G,但是可用内存肯定是小于32G的,计算32*1024/4=8192M,接近于输出的7221M。

修改堆的内存设置-Xms60m -Xmx60m,查看此时JVM堆的情况:

image-20240219210120084

可以看出,默认新生代与老年代的比例是1:2(即-XX:NewRatio=2),默认新生代中Edan区与Survivor0区、Survivor1区的比例是6:1:1(即-XX:SurvivorRatio=6)。

修改堆的内存设置-Xms60m -Xmx60m -XX:+PrintGCDetails,查看此时JVM堆的情况:

image-20240219212006769

为什么设置的-Xms60m -Xmx60m但是可用堆内存只有57M呢,这是因为Survivor区只有一个是可用的(标记-复制算法),因此实际新生代内存为15360K+2560K=17920K,总的堆内存为17920K+40960K=58880K=57M。

对象分配过程

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

  • new的对象先放Edan区,此区有大小限制。
  • 当Edan区的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对Edan区进行垃圾回收(MinorGC),将Edan区中的不再被其他对象所引用的对象进行销毁,再加载新的对象放到Edan区。
  • 然后将Edan区中的剩余对象移动到Survivor0区。
  • 如果再次触发垃圾回收,此时上次幸存下来的放到Survivor0区的,如果没有回收,就会放到Survivor1区。
  • 如果再次经历垃圾回收,此时会重新放回Survivor0区,接着再去Survivor1区。
  • 啥时候能去老年代呢?可以设置晋升的分代次数,默认是15次。
  • 在老年代,相对悠闲。当老年代内存不足时,再次触发GC:Major GC,进行老年代的内存清理。
  • 若老年代执行了Major GC之后,发现依然无法进行对象的保存,就会产生OOM异常。

可以设置参数:-Xx:MaxTenuringThreshold=N进行设置

我们创建的对象,一般都是存放在Eden区的,当我们Eden区满了后,就会触发GC操作,一般被称为YoungGC/Minor GC操作。

img

当我们进行一次垃圾收集后,红色的将会被回收,而绿色的还会被占用着,会被存放在S0区(此时Edan区是空的)。同时我们给每个对象设置了一个年龄计数器,刚开始加入S0区时初始年龄是1

同时Eden区继续存放对象,当Eden区再次存满的时候,又会触发一个MinorGC操作,此时GC将会把Eden区和S0区中的对象进行一次垃圾收集,把存活的对象放到S1区,同时让年龄+1。

img

我们继续不断的进行对象生成和垃圾回收,当Survivor区中的对象的年龄达到15的时候,将会触发一次Promotion晋升的操作,也就是将年轻代中的对象晋升到老年代中。

img

对象分配过程中可能的特殊情况:

image-20240219214616334

1
2
3
4
5
6
7
8
9
10
11
public class HelloGC {
byte[] buffer = new byte[new Random().nextInt(1024 * 200)];

public static void main(String[] args) throws InterruptedException {
ArrayList<HelloGC> list = new ArrayList<>();
while (true) {
list.add(new HelloGC());
Thread.sleep(30);
}
}
}

image-20240219220628454

每次Edan区满了后发生Minor GC,此时Edan区中的所有HelloGC对象都不能被回收,因此需要放入Survivor区中,但是Survivor区中存放不下,此时会将Edan区的一部分对象直接转移到老年代中,一直持续这个过程,直到最后一次Minor GC时,由于老年代中也存放不下Edan区的对象了会发生Major GC,此时老年代中的所有HelloGC对象也都不能被回收,因此发生OOM。

三种GC分类

  • Minor GC:新生代的GC
  • Major GC:老年代的GC
  • Full GC:整堆收集,收集整个Java堆和方法区的垃圾收集

我们都知道,JVM的调优的一个环节,也就是垃圾收集,我们需要尽量的避免垃圾回收,因为在垃圾回收的过程中,容易出现STW的问题,而 Major GC和Full GC出现STW的时间,是Minor GC的10倍以上。

JVM在进行GC时,并非每次都对上面三个内存区域一起回收的,大部分时候回收的都是指新生代。针对Hotspot VM的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(Full GC)

部分收集:不是完整收集Java堆的垃圾收集,其中又分为:

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

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

Minor GC

当年轻代空间不足时,就会触发Minor GC,这里的年轻代满指的是Eden区满,Survivor区满不会引发GC(每次Minor GC会清理Edan区的内存)。因为年轻代的Java对象大多都具备 朝生夕灭 的特性,所以Minor GC非常频繁,一般回收速度也比较快。

Minor GC会引发STW,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行。

img

Major GC

出现了Major GC,经常会伴随至少一次的Minor GC(但非绝对的,在Paralle1 Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。

Full GC

触发Full GC执行的情况有如下五种:

  • 调用System.gc()时,系统建议执行Full GC,但是不必然执行
  • 老年代空间不足
  • 方法区空间不足
  • 老年代的可用内存小于历次通过Minor GC后进入老年代的平均大小

在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次Minor GC可以确保是安全的。如果不成立,则虚拟机会先查看-XX:HandlePromotionFailure参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者-XX:HandlePromotionFailure设置不允许冒险,那这时就要改为进行一次Full GC。说明:Full GC是开发或调优中尽量要避免的,这样暂时时间会短一些。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// -Xms10m -Xmx10m -XX:+PrintGCDetails
public class HelloGCDetail {
public static void main(String[] args) {
int i = 0;
try {
List<String> list = new ArrayList<>();
String a = "huling";
while(true) {
list.add(a);
a = a + a;
i++;
}
}catch (Exception e) {
e.getStackTrace();
}
}
}

image-20240220135252032

内存分配策略

如果对象在Eden区出生并经过第一次Minor GC后仍然存活,并且能被Survivor区容纳的话,将被移动到Survivor区中,并将对象年龄设为1。对象在Survivor区中每熬过一次MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,其实每个JVM、每个GC都有所不同)时,就会被晋升到老年代(对象晋升老年代的年龄阀值,可以通过选项-XX:MaxTenuringThreshold来设置)。

针对不同年龄段的对象分配原则如下所示:

  • 优先分配到Eden区
    • 开发中比较长的字符串或者数组,会直接存在老年代,但是因为新创建的对象都是朝生夕死的,所以这个大对象可能也很快被回收,但是因为老年代触发Major GC的次数比Minor GC要更少,因此可能回收起来就会比较慢
  • 大对象直接分配到老年代
    • 尽量避免程序中出现过多的大对象
  • 长期存活的对象分配到老年代
  • 动态对象年龄判断
    • 如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄

空间分配担保:-XX:HandlePromotionFailure

  • 也就是经过Minor GC后,所有的对象都存活,因为Survivor区比较小,所以就需要将Survivor区无法容纳的对象,存放到老年代中
1
2
3
4
5
6
// -Xms60m -Xmx60m -XX:+PrintGCDetails
public class HelloBigObject {
public static void main(String[] args) {
byte[] array = new byte[1024 * 1024 * 20];
}
}

image-20240220140049117

对象分配TLAB

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

从内存模型而不是垃圾收集的角度,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,它包含在Eden空间内。多线程同时分配内存时,使用TLAB可以避免一系列的线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略

img

尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选。在程序中,开发人员可以通过选项-XX:+UseTLAB设置是否开启TLAB空间。

默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1%,当然我们可以通过选项-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小。

一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过CAS配合失败重试确保数据操作的原子性,从而直接在Eden空间中分配内存。

img

堆空间参数设置

  • -XX:+PrintFlagsInitial:查看所有的参数的默认初始值

  • -XX:+PrintFlagsFinal:查看所有的参数的最终值(可能会存在修改,不再是初始值)

  • -Xms:初始堆空间内存(默认为物理内存的1/64)

  • -Xmx:最大堆空间内存(默认为物理内存的1/4)

  • -Xmn:设置新生代的大小(初始值及最大值)

  • -XX:NewRatio:配置新生代与老年代在堆结构的占比

  • -XX:SurvivorRatio:设置新生代中Eden和S0/S1空间的比例

  • -XX:MaxTenuringThreshold:设置新生代垃圾晋升到老年代的最大存活年龄

  • -XX:+PrintGCDetails:输出详细的GC处理日志

  • -XX:HandlePromotionFalilure:是否设置空间分配担保

1
2
3
4
5
6
// -Xms60m -Xmx60m -XX:NewRatio=2 -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=15 -XX:+PrintFlagsFinal -XX:+PrintGCDetails
public class HelloHeapArgs {
public static void main(String[] args) throws InterruptedException {
Thread.sleep(100000);
}
}

image-20240220141412282

MaxTenuringThreshold只能在[0,15]的范围值,这是因为Java对象的Monitor对象头中的MarkWord中关于GC年龄只有四位,因此最大取值只能是15。

image-20240220141718917

在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间

  • 如果大于,则此次Minor GC是安全的。
  • 如果小于,则虚拟机会查看-XX:HandlePromotionFailure设置值是否允担保失败。
    • 如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小
    • 如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;
    • 如果小于,则改为进行一次Full GC。
    • 如果HandlePromotionFailure=false,则改为进行一次Full GC。

在JDK6 Update24之后,HandlePromotionFailure参数不会再影响到虚拟机的空间分配担保策略,观察OpenJDK中的源码变化,虽然源码中还定义了HandlePromotionFailure参数,但是在代码中已经不会再使用它。JDK6 Update 24之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升到老年代的对象的平均大小就会进行Minor GC,否则将进行Full GC

逃逸分析

随着JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么绝对了。在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。

这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象引用的使用范围从而决定是否要将这个对象分配到堆上。逃逸分析的基本行为就是分析对象动态作用域

  • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
  • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸,例如作为调用参数传递到其他地方中。
1
2
3
4
5
6
public static StringBuffer createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}

如果想要StringBuffer对象不发生逃逸,可以这样写:

1
2
3
4
5
6
public static String createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}

在JDK 1.7版本之后,HotSpot中默认就已经开启了逃逸分析,如果使用的是较早的版本,开发人员则可以通过:

  • 选项-XX:+DoEscapeAnalysis显式开启逃逸分析
  • 通过选项-XX:+PrintEscapeAnalysis查看逃逸分析的筛选结果

栈上分配

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// -Xms1G -Xmx1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
public class HelloStackAllocate {
public static void main(String[] args) throws InterruptedException {
long start = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println("花费的时间为:" + (end - start) + " ms");
// 为了方便查看堆内存中对象个数
Thread.sleep(10000000);
}

private static void alloc() {
User user = new User();
}

static class User {}
}

明显可以看出,没有开启逃逸分析时所有User对象都是分配在堆上的,因此会发生GC,而且花费的时间是231ms:

image-20240220151033050

如果使用-XX:DoEscapeAnalysis开启逃逸分析后,发现User对象不会逃逸到方法外,因此会进行栈上分配(通过标量替换实现的),不会操作堆空间,花费的时间仅有3ms,也不会发生任何GC。

image-20240220151305592

同步省略

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

1
2
3
4
5
6
public void method() {
Object obj = new Object();
synchronized(obj) {
System.out.println(obj);
}
}

代码中对obj这个对象加锁,但是obj对象的生命周期只在method方法中,并不会被其他线程所访问到,所以在JIT编译阶段就会被优化掉,优化成:

1
2
3
4
public void method() {
Object obj = new Object();
System.out.println(obj);
}

标量替换

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

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

1
2
3
4
5
6
7
8
9
10
11
public static void main(String args[]) {
alloc();
}
class Point {
private int x;
private int y;
}
private static void alloc() {
Point point = new Point(1,2);
System.out.println("point.x" + point.x + ";point.y" + point.y);
}

以上代码,经过标量替换后,就会变成:

1
2
3
4
5
private static void alloc() {
int x = 1;
int y = 2;
System.out.println("point.x = " + x + "; point.y=" + y);
}

可以看到,Point这个聚合量经过逃逸分析后,发现他并没有逃逸,就被替换成两个标量了。那么标量替换有什么好处呢?就是可以大大减少堆内存的占用。因为一旦不需要创建对象了,那么就不再需要分配堆内存了。标量替换为栈上分配提供了很好的基础。

逃逸分析的不足

关于逃逸分析的论文在1999年就已经发表了,但直到JDK1.6才有实现,而且这项技术到如今也并不是十分成熟的。

其根本原因就是无法保证逃逸分析的性能消耗一定能高于其节省的性能消耗。虽然经过逃逸分析可以做标量替换、栈上分配和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。一个极端的例子,就是经过逃逸分析之后,发现所有对象都是逃逸的,那这个逃逸分析的过程就白白浪费掉了。

虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段。注意到有一些观点,认为通过逃逸分析,JVM会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是取决于JVM设计者的选择。据我所知,Oracle Hotspot JVM中并未这么做,这一点在逃逸分析相关的文档里已经说明,所以可以明确所有的对象实例都是创建在堆上

目前很多书籍还是基于JDK7以前的版本,JDK已经发生了很大变化,intern字符串的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元数据区取代。但是,intern字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,所以这一点同样符合前面一点的结论:对象实例都是分配在堆上

JVM方法区

方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息常量静态变量即时编译器编译后的代码缓存等数据。虽然《Java虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫作“非堆”(Non-Heap),目的是与Java堆区分开来。

说到方法区,不得不提一下“永久代”这个概念,尤其是在JDK 8以前,许多Java程序员都习惯在HotSpot虚拟机上开发、部署程序,很多人都更愿意把方法区称呼为“永久代”(Permanent Generation),或将两者混为一谈。本质上这两者并不是等价的,因为仅仅是当时的HotSpot虚拟机设计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已,这样使得HotSpot的垃圾收集器能够像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。但是对于其他虚拟机实现,譬如BEA JRockit、IBM J9等来说,是不存在永久代的概念的。原则上如何实现方法区属于虚拟机实现细节,不受《Java虚拟机规范》管束,并不要求统一。但现在回头来看,当年使用永久代来实现方法区的决定并不是一个好主意,这种设计导致了Java应用更容易遇到内存溢出的问题(永久代有-XX:MaxPermSize的上限,即使不设置也有默认大小,而J9和JRockit只要没有触碰到进程可用内存的上限,例如32位系统中的4GB限制,就不会出问题),而且有极少数方法(例如String::intern())会因永久代的原因而导致不同虚拟机下有不同的表现。当Oracle收购BEA获得了JRockit的所有权后,准备把JRockit中的优秀功能,譬如Java Mission Control管理工具,移植到HotSpot虚拟机时,但因为两者对方法区实现的差异而面临诸多困难。考虑到HotSpot未来的发展,在JDK 6的时候HotSpot开发团队就有放弃永久代,逐步改为采用本地内存(Native Memory)来实现方法区的计划了,到了JDK 7的HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出,而到了JDK 8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Metaspace)来代替,把JDK 7中永久代还剩余的内容(主要是类型信息)全部移到元空间中。

《Java虚拟机规范》对方法区的约束是非常宽松的,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域的确是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收有时又确实是必要的。以前Sun公司的Bug列表中,曾出现过的若干个严重的Bug就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏。

在Java堆中,尤其是在新生代中,对常规应用进行一次垃圾收集通常可以回收70%至99%的内存空间,相比之下,方法区回收囿于苛刻的判定条件,其区域垃圾收集的回收成果往往远低于此。

方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。回收废弃常量与回收Java堆中的对象非常类似。举个常量池中字面量回收的例子,假如一个字符串“java”曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“java”,换句话说,已经没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。

判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:

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

Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。

在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。

直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现,所以我们放到这里一起讲解。

在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据

显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括物理内存、SWAP分区或者分页文件)大小以及处理器寻址空间的限制,一般服务器管理员配置虚拟机参数时,会根据实际内存去设置-Xmx等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。