JVM核心知识

01.JVM的运行机制

JVM(Java Virtual Machine)是用于运行Java字节码的虚拟机,JVM运行在操作系统之上,不与硬件设备直接交互。JVM包括下面的内容:

  • 一套字节码指令集
  • 一组程序寄存器
  • 一个虚拟机栈
  • 一个虚拟机堆
  • 一个方法区
  • 一个垃圾回收器

Java程序具体的运行过程如下:

(1)Java源文件被编译器编译成字节码文件。
(2)JVM将字节码文件编译成相应操作系统的机器码。
(3)机器码调用相应操作系统的本地方法库执行相应的方法。

Java虚拟机包含一下内容:

  • 类加载器子系统(Class Loader SubSystem)
  • 类加载器子系统(Class Loader SubSystem)
  • 执行引擎和本地接口库(Native Interface Library)

本地接口库通过调用本地方法库与操作系统交互,JAM框架图如下:

其中:

  • 类加载器子系统将编译好的.Class文件加载到JVM中;
  • 运行时数据区用于存储在JVM运行过程中产生的数据,包括程序计数器、方法区、本地方法区、虚拟机栈和虚拟机堆;
  • 执行引擎包括即时编译器和垃圾回收器,即时编译器用于将Java字节码编译成具体的机器码,垃圾回收器用于回收在运行过程中不再使用的对象;
  • 本地接口库用于调用操作系统的本地方法库完成具体的指令操作。

02.程序计数器

首先看下面这段程序

上面的代码中左边的是二进制字节码,也是JVM指令,所有系统平台的指令都是一样的,字节码不能直接执行,需要通过解释器将字节码翻译成机器码,然后才能被执行。

程序计数器的作用是记录下一个JVM指令执行的地址。

程序计数器的特点

  • 程序计数器是线程私有的

    每个线程都有自己的线程计数器

  • 不会存在内存溢出

    程序计数器是在Java虚拟机规范中唯一一个不存在内存溢出的区域

03.虚拟机栈

什么是虚拟机栈

​ 虚拟机栈是描述Java方法的执行过程的内存模型,它在当前栈帧(Stack Frame)中存储了局部变量表、操作数栈、动态链接、方法出口等信息。同时,栈帧用来存储部分运行时数据及其数据结构,处理动态链接(Dynamic Linking)方法的返回值和异常分派(DispatchException)。
​ 栈帧用来记录方法的执行过程,在方法被执行时虚拟机会为其创建一个与之对应的栈帧,也就是说,一个方法对应一个栈帧。方法的执行和返回对应栈帧在虚拟机栈中的入栈和出栈。无论方法是正常运行完成还是异常完成(抛出了在方法内未被捕获的异常),都视为方法运行结束。下图展示了线程运行及栈帧变化的过程。线程 1在CPU1上运行,线程 2在CPU2上运行,在CPU资源不够时其他线程将处于等待状态,等待获取CPU时间片。而在线程内部,每个方法的执行和返回都对应一个栈帧的入栈和出栈,每个运行中的线程当前只有一个栈帧处于活动状态。

总结来说就是:

  • 每个线程运行时所需要的内存,称为虚拟机栈
  • 每个栈由多个栈帧组成,每个栈帧对应一个方法
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法

使用IDEA调试下面的代码可以查看虚拟机栈的状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Main1 {
public static void main(String[] args) throws InterruptedException {
method1();
}

private static void method1(){
method2(1, 2);
}

private static int method2(int a, int b){
int c = a + b;
return c;
}
}

在代码的第三行打断点然后执行debug,可以看到当前栈中有一个栈帧main, 正在运行的栈对应的方法为main方法,继续往下执行几步,可以看到栈中有三个栈帧,分别对应代码中的三个方法,右边是栈帧中存在的变量:

垃圾回收会不会涉及栈内存?

不会。栈帧内存在每一次方法调用之后会弹出栈,然后内存空间会被自动回收,所以不需要进行垃圾回收。

栈内存的大小越大越好吗?

不是。栈内存变大会让线程内存变小,因为物理内存大小是固定的。可以使用-Xss命令分配栈内存,Linux中默认栈内存为1024KB,Windows中默认栈的大小跟机器的虚拟内存有关。

方法内的局部变量是否线程安全?

视情况而定:

  • 如果方法内部的变量没有逃离方法的作用范围,它是线程安全的。
  • 如果局部变量引用了变量,并逃离方法作用范围,它是线程不安全的。

判断一个变量是否为线程安全,就要看这个变量是线程私有的还是线程共享的。同一个方法在被不同的线程执行时,会分别创建相应的栈帧,然后分别执行,两个栈帧互不影响,但是如果方法是static类型的方法,那就大不一样了,就不再是线程私有的了,从而也就不是线程安全的。

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
/**
* 局部变量的线程安全问题
*/
public class Demo1_17 {
public static void main(String[] args) {
StringBuilder sb = new StringBuilder();
sb.append(4);
sb.append(5);
sb.append(6);
new Thread(()->{
m2(sb);
}).start();
}

public static void m1() {
StringBuilder sb = new StringBuilder();
sb.append(1);
sb.append(2);
sb.append(3);
System.out.println(sb.toString());
}

public static void m2(StringBuilder sb) {
sb.append(1);
sb.append(2);
sb.append(3);
System.out.println(sb.toString());
}

public static StringBuilder m3() {
StringBuilder sb = new StringBuilder();
sb.append(1);
sb.append(2);
sb.append(3);
return sb;
}
}

​ 上面的代码中,m1方法中的变量是线程安全的,因为它没有逃离作用范围。方法m2中的变量是线程不安全的,因为方法m2中的变量是一个参数,别的线程中的方法可能也会使用相同的参数,所以线程不安全。方法m3中的变量是线程不安全的,因为该变量是作为一个返回值,逃离了方法的作用范围,可能会被其它方法修改。

什么是栈内存溢出?

java.lang.StackOverflowError

通常栈内存溢出有两种情况:

  • 栈帧过多

    通常由于递归无限调用导致,栈帧的数量过多超过了内存限制

  • 栈帧过大

    这种情况不太容易出现

线程运行诊断

案例1:cpu占用过多

定位:

  • 用top命令定位哪个进程对cpu的占用过高
  • 用ps命令查看是哪个线程的问题:ps H -eo pid,tid,%cpu | grep 进程id
  • 用jstack命令找到有问题的线程,进一步定位到源代码的行数

案例2:运行很长时间却得不到结果

原因是线程发生了死锁,下面的代码中发生了死锁,就会一直得不到输出结果:

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
/**
* 演示线程死锁
*/
class A{};
class B{};
public class Demo1_3 {
static A a = new A();
static B b = new B();


public static void main(String[] args) throws InterruptedException {
new Thread(()->{
synchronized (a) {
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
synchronized (b) {
System.out.println("我获得了 a 和 b");
}
}
}).start();
Thread.sleep(1000);
new Thread(()->{
synchronized (b) {
synchronized (a) {
System.out.println("我获得了 a 和 b");
}
}
}).start();
}
}

04.本地方法栈

​ 本地方法栈又称之为本地方法区。

什么是本地方法?

​ 本地方法是指不是由Java代码编写的方法,有时候Java不能直接与操作系统底层打交道,这就需要一些用C或者C++编写的代码来调用操作系统底层的API,Java代码可以通过本地方法间接的调用操作系统底层API,这些本地方法运行时使用的方法就是本地方法栈。

​ Object类中使用了一些方法直接调用本地方法。

05.虚拟机堆

​ 在JVM运行过程中创建的对象和产生的数据都被存储在堆中(也就是使用new关键字创建的对象),堆是被线程共享的内存区域,也是垃圾收集器进行垃圾回收的最主要的内存区域。由于现代JVM采用分代收集法,因此Java堆从GC(GarbageCollection,垃圾回收)的角度还可以细分为:新生代、老年代和永久代。

​ 虚拟机堆的特点:

* 它是线程共享的,堆中的对象都要考虑线程共享的问题
* 有垃圾回收机制

堆内存溢出

java.lang.OutOfMemoryError

如果不断地产生对象而且有人在使用这个对象,则会产生堆内存溢出。使用-Xmx参数可以给堆分配空间,使用下面的代码可以造成堆内存溢出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
/**
* 演示堆内存溢出 java.lang.OutOfMemoryError: Java heap space
* -Xmx8m
*/
public class Demo1_5 {

public static void main(String[] args) {
int i = 0;
try {
List<String> list = new ArrayList<>();
String a = "hello";
while (true) {
list.add(a); // hello, hellohello, hellohellohellohello ...
a = a + a; // hellohellohellohello
i++;
}
} catch (Throwable e) {
e.printStackTrace();
System.out.println(i);
}
}
}

堆内存的诊断

诊断工具:

  • jps工具:查看当前系统中有哪些Java进程

    命令:jps

  • jmap工具:查看堆内存占用情况

    命令:jmap -heap 类名称ID

  • jconsole工具:可视化界面,多功能检测工具。可以连续监测

    命令:jconsole

  • jvirsualvm工具:查看多种信息

    命令:jvisualvm

06.方法区

什么是方法区?

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.

Java 虚拟机有一个在所有 Java 虚拟机线程之间共享的方法区。方法区类似于传统语言的编译代码的存储区或者类似于操作系统进程中的“文本”段。它存储每个类的结构,例如运行时常量池、字段和方法数据,以及方法和构造函数的代码,包括类和实例初始化以及接口初始化中使用的特殊方法。

方法区是在虚拟机启动时创建的。尽管方法区在逻辑上是堆的一部分,但简单的实现可以选择不进行垃圾收集或压缩它。本规范不强制要求方法区的位置或用于管理编译代码的策略。方法区可以是固定大小的,或者可以根据计算的需要来扩展,并且如果不需要更大的方法区,则可以收缩。方法区的内存不需要是连续的。

Java虚拟机实现可以为程序员或用户提供对方法区的初始大小的控制,以及在方法区大小变化的情况下,对最大和最小方法区大小的控制。

以下异常情况与方法区相关:

  • 如果方法区中的内存无法满足分配请求,Java 虚拟机将抛出一个OutOfMemoryError.

Java1.6和Java1.8中方法区的实现不同,在Java1.6中方法区在JVM的内存结构中,但是从Java1.8开始,方法区的实现为元空间,这个空间在操作系统的内存中。

方法区内存溢出问题

在Java1.8版本以前叫永久代内存溢出PermGen Space

在Java1.8版本以后叫元空间内存溢出Meta Space

看一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/**
* 演示元空间内存溢出 java.lang.OutOfMemoryError: Metaspace
* -XX:MaxMetaspaceSize=8m
*/
public class Demo1_8 extends ClassLoader { // 可以用来加载类的二进制字节码
public static void main(String[] args) {
int j = 0;
try {
Demo1_8 test = new Demo1_8();
for (int i = 0; i < 10000; i++, j++) {
// ClassWriter 作用是生成类的二进制字节码
ClassWriter cw = new ClassWriter(0);
// 版本号, public, 类名, 包名, 父类, 接口
cw.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
// 返回 byte[]
byte[] code = cw.toByteArray();
// 执行了类的加载
test.defineClass("Class" + i, code, 0, code.length); // Class 对象
}
} finally {
System.out.println(j);
}
}
}

在上面的代码中把原空间大小设置为8M,会产生元空间溢出-XX:MaxMetaspaceSize=8m

运行时常量池

  • 常量池,就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息。
  • 运行时常量池,常量池是 *.class 文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址

下面看一段代码:

1
2
3
4
5
public class HelloWorld {
public static void main(String[] args) {
System.out.println("hello world");
}
}

首先创建一个hello.java文件,然后使用javac命令编译这个Java文件生成hello.class字节码文件,然后使用javap命令反编译这个字节码文件:

javap -v hello.class

以下是输出信息:

Classfile /C:/Users/86155/IdeaProjects/test/src/Main2.class
Last modified 2024-1-4; size 416 bytes
MD5 checksum 0cfc53e7c23f5fe2b56927d96f1f6a92
Compiled from “main2.java”
public class Main2
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #6.#15 // java/lang/Object.”“:()V
#2 = Fieldref #16.#17 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #18 // hello, world
#4 = Methodref #19.#20 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Class #21 // Main2
#6 = Class #22 // java/lang/Object
#7 = Utf8
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 main
#12 = Utf8 ([Ljava/lang/String;)V
#13 = Utf8 SourceFile
#14 = Utf8 main2.java
#15 = NameAndType #7:#8 // ““:()V
#16 = Class #23 // java/lang/System
#17 = NameAndType #24:#25 // out:Ljava/io/PrintStream;
#18 = Utf8 hello, world
#19 = Class #26 // java/io/PrintStream
#20 = NameAndType #27:#28 // println:(Ljava/lang/String;)V
#21 = Utf8 Main2
#22 = Utf8 java/lang/Object
#23 = Utf8 java/lang/System
#24 = Utf8 out
#25 = Utf8 Ljava/io/PrintStream;
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String hello, world
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 3: 0
line 4: 8
}
SourceFile: “main2.java”

二进制字节码中包含类的基本信息,常量池,类方法定义,虚拟机指令等。在上面的输出结果中可以找到这些信息,在常量池中#1、#2等是一个地址,在运行常量池中,这些地址会变成内存中的真实地址。

07.StringTable串池

什么是串池?

在Java中,字符串池(String Pool)是一种字符串常量池,用于存储字符串常量,以便在程序中重复使用相同的字符串对象,以节省内存和提高性能。

1.常量池中字符串仅是符号,第一次用到时才变为对象
2.利用串池机制,来避免重复创建字符串对象
3.字符串变量拼接的原理是StringBuilder(1.8)
4.字符串常量拼接的原理是编译器优化
5.可以使用intern方法,主动将串池中还没有的字符串对象放入串池
6.jdk1.8 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池,会把串池中的对象返回
7.jdk1.6 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则把对象复制一份(又创建了一个新的对象,这个对象才会放入串池,调用intern方法的对象和放入串池中的对象是两个对象)放入串池,会把串池中的对象返回。

串池面试题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
String s1 = "a";
String s2 = "b";
String s3 = "a" + "b";
String s4 = s1 + s2;
String s5 = "ab";
String s6 = s4.intern();
// 问
System.out.println(s3 == s4);
System.out.println(s3 == s5);
System.out.println(s3 == s6);
String x2 = new String("c") + new String("d");
String x1 = "cd";
x2.intern();
// 问,如果调换了【最后两行代码】的位置呢,如果是jdk1.6呢
System.out.println(x1 == x2);

要想回答上面的问题就要从串池和常量池中的角度分析问题

常量池与串池的关系

常量池最初存在于字节码文件中,当运行时会变成运行时常量池存在于堆(1.8)中。

看下面一段代码:

1
2
3
4
5
6
7
public class Main3 {
public static void main(String[] args){
String s1 = "a";
String s2 = "b";
String s3 = "ab";
}
}

先将代码编译成字节码文件,然后反编译字节码文件,查看反编译后的内容:

javac -g Main3.java # 如果不加-g参数则反编译的时候不会有LocalVariableTable:的信息

javap -v Main3.class

Classfile /C:/Users/86155/IdeaProjects/test/src/Main3.class
Last modified 2024-1-4; size 298 bytes
MD5 checksum cde5a2709f88bc15b845ef8ed895661f
Compiled from “Main3.java”
public class Main3
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #6.#15 // java/lang/Object.”“:()V
#2 = String #16 // a
#3 = String #17 // b
#4 = String #18 // ab
#5 = Class #19 // Main3
#6 = Class #20 // java/lang/Object
#7 = Utf8
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 main
#12 = Utf8 ([Ljava/lang/String;)V
#13 = Utf8 SourceFile
#14 = Utf8 Main3.java
#15 = NameAndType #7:#8 // ““:()V
#16 = Utf8 a
#17 = Utf8 b
#18 = Utf8 ab
#19 = Utf8 Main3
#20 = Utf8 java/lang/Object
{
public Main3();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object.”“:()V
4: return
LineNumberTable:
line 1: 0

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=4, args_size=1
0: ldc #2 // String a
2: astore_1
3: ldc #3 // String b
5: astore_2
6: ldc #4 // String ab
8: astore_3
9: return
LineNumberTable:
line 3: 0
line 4: 3
line 5: 6
line 6: 9

LocalVariableTable:
Start Length Slot Name Signature
0 10 0 args [Ljava/lang/String;
3 7 1 s1 Ljava/lang/String;
6 4 2 s2 Ljava/lang/String;
9 1 3 s3 Ljava/lang/String;

}
SourceFile: “Main3.java”

JVM虚拟机指令为:

1
2
3
4
5
6
7
0: ldc           #2                  // String a
2: astore_1
3: ldc #3 // String b
5: astore_2
6: ldc #4 // String ab
8: astore_3
9: return
  • 指令0:从常量池#2中找到字符串a
  • 指令2:将字符串a赋值给变量1
  • 指令3:从常量池#3中找到字符串b
  • 指令5:将字符串b赋值给变量2
  • 指令6:从常量池#4中找到字符串ab
  • 指令8:将字符串ab赋值给变量3
  • 指令9:返回

LocalVariableTable的信息如下:

LocalVariableTable:
Start Length Slot Name Signature
0 10 0 args [Ljava/lang/String;
3 7 1 s1 Ljava/lang/String;
6 4 2 s2 Ljava/lang/String;
9 1 3 s3 Ljava/lang/String;

当把字符串加载到运行时常量池中的时候,这些字符串还不是对象,这时a、b、ab都是常量池中的符号,还没有变为Java中的字符串对象,当执行到具体使用这些符号的代码的时候,就把字符转成对象,例如上面的指令ldc #2就把符号a变为”a”的字符串对象,然后准备好一块空间,这个空间就是StringTable的空间,刚开始时StringTable为空,Java虚拟机会去串池中找”a”这个对象,如果没有找到,就把”a”添加到串池中,然后返回这个串池中的对象。串池是一个哈希表,这个哈希表长度是固定的而且是不能扩容的。

字符串变量拼接

看下面这段代码:

1
2
3
4
5
6
7
8
public class Main3 {
public static void main(String[] args){
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
}
}

同样先执行编译,然后执行反编译,查看反编译输出内容:

javac -g Main3.java # 编译

javap -v Main3.class # 反编译

Classfile /C:/Users/86155/IdeaProjects/test/src/Main3.class
Last modified 2024-1-4; size 651 bytes
MD5 checksum 59c31d968f5f57b1a052857929699b53
Compiled from “Main3.java”
public class Main3
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #10.#29 // java/lang/Object.”“:()V
#2 = String #30 // a
#3 = String #31 // b
#4 = String #32 // ab
#5 = Class #33 // java/lang/StringBuilder
#6 = Methodref #5.#29 // java/lang/StringBuilder.”“:()V
#7 = Methodref #5.#34 // java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
#8 = Methodref #5.#35 // java/lang/StringBuilder.toString:()Ljava/lang/String;
#9 = Class #36 // Main3
#10 = Class #37 // java/lang/Object
#11 = Utf8
#12 = Utf8 ()V
#13 = Utf8 Code
#14 = Utf8 LineNumberTable
#15 = Utf8 LocalVariableTable
#16 = Utf8 this
#17 = Utf8 LMain3;
#18 = Utf8 main
#19 = Utf8 ([Ljava/lang/String;)V
#20 = Utf8 args
#21 = Utf8 [Ljava/lang/String;
#22 = Utf8 s1
#23 = Utf8 Ljava/lang/String;
#24 = Utf8 s2
#25 = Utf8 s3
#26 = Utf8 s4
#27 = Utf8 SourceFile
#28 = Utf8 Main3.java
#29 = NameAndType #11:#12 // ““:()V
#30 = Utf8 a
#31 = Utf8 b
#32 = Utf8 ab
#33 = Utf8 java/lang/StringBuilder
#34 = NameAndType #38:#39 // append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
#35 = NameAndType #40:#41 // toString:()Ljava/lang/String;
#36 = Utf8 Main3
#37 = Utf8 java/lang/Object
#38 = Utf8 append
#39 = Utf8 (Ljava/lang/String;)Ljava/lang/StringBuilder;
#40 = Utf8 toString
#41 = Utf8 ()Ljava/lang/String;
{
public Main3();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object.”“:()V
4: return
LineNumberTable:
line 1: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this LMain3;

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=5, args_size=1
0: ldc #2 // String a
2: astore_1
3: ldc #3 // String b
5: astore_2
6: ldc #4 // String ab
8: astore_3
9: new #5 // class java/lang/StringBuilder
12: dup
13: invokespecial #6 // Method java/lang/StringBuilder.”“:()V
16: aload_1
17: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
20: aload_2
21: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
24: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
27: astore 4
29: return
LineNumberTable:
line 3: 0
line 4: 3
line 5: 6
line 6: 9
line 7: 29
LocalVariableTable:
Start Length Slot Name Signature
0 30 0 args [Ljava/lang/String;
3 27 1 s1 Ljava/lang/String;
6 24 2 s2 Ljava/lang/String;
9 21 3 s3 Ljava/lang/String;
29 1 4 s4 Ljava/lang/String;
}
SourceFile: “Main3.java”

指令说明:

  • 指令9:创建一个StringBuilder的对象
  • 指令13:执行StringBuilder中的init方法,这是无参的构造方法
  • 指令16:加载变量1中的内容,和astore_1是相反的步骤
  • 指令17:调用StringBuilder的append方法,append的内容就是上一条指令中加载的方法1
  • 指令24:调用StringBuilder的toString方法
  • 指令27:将数据存储到变量4中

我们来看一下StringBuilder中的toString()方法:

1
2
3
4
5
@Override
public String toString() {
// Create a copy, don't share the array
return new String(value, 0, count);
}

这个方法返回一个新建的String对象。这个新建的String对象是没有被添加到串池中的,所以下面的代码会输出false:

1
2
3
4
5
6
7
8
9
public class Main3 {
public static void main(String[] args){
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
System.out.println( s3 == s4 );
}
}

输出:false

接下来看下面一段代码:

1
2
3
4
5
6
7
8
9
10
public class Main3 {
public static void main(String[] args){
String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
String s5 = "a" + "b";
System.out.println( s3 == s5 );
}
}

输出:true

那么定义的s5和s4有什么区别呢?首先同样将源文件进行编译和反编译查看反编译后的信息:

javac -g Main3.java

javap -v Main3.java

9: new #5 // class java/lang/StringBuilder
12: dup
13: invokespecial #6 // Method java/lang/StringBuilder.”“:()V
16: aload_1
17: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
20: aload_2
21: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
24: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
27: astore 4
29: ldc #4 // String ab
31: astore 5
33: getstatic #9 // Field java/lang/System.out:Ljava/io/PrintStream;
36: aload_3
37: aload 5
39: if_acmpne 46
42: iconst_1
43: goto 47
46: iconst_0

在上面的虚拟机指令中可以看出:指令29直接从常量池中读取字符串ab,然后将字符串ab存到变量5中,为什么使用String s5 = "a" + "b";这条语句可以直接从常量池中获取字符串ab呢?这是因为字符串”a”和”b”都是常量,在编译的时候就把ab看作是一个常量,这是编译器的优化,而在String s4 = s1 + s2;这条语句中,s1和s2都是变量,编译器不能把s1+s2的结果直接定义为常量。那么既然s5是常量池中的常量,将”ab”赋值给变量s5之后,要去串池中查看有没有字符串”ab”,此时有”ab”,则把串池中的”ab”对象返回给s5,所以s3和s5是相等的。

intern方法(1.8)

intern方法用于将还没有放入串池中的字符串放入串池。

但是要注意,使用intern()方法将字符串放入串池的时候,如果串池中已经有了这个字符串,则不会被放入进去,当前的这个在堆中的字符串变量也不会指向串池中的字符串,仍然会指向在堆中的这个字符串,当串池中没有这个字符串的时候才会将这个字符串放入串池,同时将这个字符串变量指向串池中的字符串。

下面看一段代码:

1
2
3
4
5
6
7
8
public class Main {
public static void main(String[] args){
String s = new String("a") + new String("b");
String s2 = s.intern();
System.out.println(s2 == "ab");
System.out.println(s == "ab");
}
}

分析:

使用+拼接两个字符串的操作实际上是调用StringBuilder中的append和toString方法。这种方法创建的字符串变量只存在于堆中,不会把字符串添加到串池,而调用intern()方法之后,串池中则会添加’ab’, 目前串池中有[‘a’, ‘b’, ‘ab’],(‘a’和’b’是常量,在编译的时候由于优化编译而放到串池中),s对象会指向串池中的’ab’,创建字符串s2时会首先查看串池中是否有字符串’ab’,如果有,则直接将字符串变量指向串池中的字符串,所以创建的字符串对象s2也会指向串池中的’ab’,所以s2==s,

下面再看一段代码:

1
2
3
4
5
6
7
8
9
10
11
public class Main {
public static void main(String[] args){
String x = "ab";
String s = new String("a") + new String("b");
String s2 = s.intern(); //将字符串尝试放入串池,
// 如果串池中有,则不会把字符串添加到串池,但是返回的是串池中的对象,
// 如果串池中没有,则把字符串添加到串池,同样返回串池中的对象。
System.out.println(s2 == x); //true
System.out.println(s == x); //false
}
}

分析:

首先,创建字符串x=’ab’,’ab’会被放入串池中,变量x指向串池中的’ab’,然后创建字符串变量s, 会把字符串’a’,’b’,放入串池,s=’ab’,此时s在堆中,然后s调用intern()方法,尝试将字符串’ab’放入串池,但是这个时候串池中已经有字符串’ab’了,则变量s不会指向串池中的字符串’ab’,但是,intern()方法会返回串池中的对象,那么变量s2也指向串池中的字符串。所以会有如此输出结果。

另外,串池中的字符串对象同样存在于堆中。

串池垃圾回收

从Java1.7之后串池被移到了堆中,所以串池也是会受到垃圾回收的管理的,当内存空间不足时,串池中的字符串常量所占用的内存空间同样会被回收。

看下面一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
* 演示 StringTable 垃圾回收
* -Xmx10m -XX:+PrintStringTableStatistics -XX:+PrintGCDetails -verbose:gc
*/
public class Demo1_7 {
public static void main(String[] args) throws InterruptedException {
int i = 0;
try {
// for (int j = 0; j < 100000; j++) { // j=100, j=10000
// String.valueOf(j).intern();
// i++;
// }
} catch (Throwable e) {
e.printStackTrace();
} finally {
System.out.println(i);
}

}
}

首先在jvm参数中添加下面的参数:

  • -Xmx10m
  • -XX:+PrintStringTableStatistics
  • -XX:+PrintGCDetails
  • -verbose:gc

这些参数的含义如下:

  • 设置虚拟机堆内存的最大值
  • 打印串表的统计信息
  • 打印垃圾回收日志
  • 启用垃圾收集(GC)日志的基本记录功能

运行这段代码会看到下面的信息:

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
0
Heap
PSYoungGen total 2560K, used 1716K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
eden space 2048K, 83% used [0x00000000ffd00000,0x00000000ffead340,0x00000000fff00000)
from space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
to space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
ParOldGen total 7168K, used 0K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
object space 7168K, 0% used [0x00000000ff600000,0x00000000ff600000,0x00000000ffd00000)
Metaspace used 3314K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 361K, capacity 388K, committed 512K, reserved 1048576K
SymbolTable statistics:
Number of buckets : 20011 = 160088 bytes, avg 8.000
Number of entries : 13621 = 326904 bytes, avg 24.000
Number of literals : 13621 = 579944 bytes, avg 42.577
Total footprint : = 1066936 bytes
Average bucket size : 0.681
Variance of bucket size : 0.684
Std. dev. of bucket size: 0.827
Maximum bucket size : 6
StringTable statistics:
Number of buckets : 60013 = 480104 bytes, avg 8.000
Number of entries : 1775 = 42600 bytes, avg 24.000
Number of literals : 1775 = 158848 bytes, avg 89.492
Total footprint : = 681552 bytes
Average bucket size : 0.030
Variance of bucket size : 0.030
Std. dev. of bucket size: 0.172
Maximum bucket size : 3

Process finished with exit code 0

从上面的代码中可以看出,串池中一共有1775个实体,也就是有1775个字符串,现在如果把上面的代码中的for循环打开,理论上会在串池中添加100000个字符串,但是因为有了垃圾回收机制,实际上在串池中不会存在这么多字符串。

下面显示往串池中放100000个字符串后的串池相关信息

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
[GC (Allocation Failure) [PSYoungGen: 2048K->488K(2560K)] 2048K->696K(9728K), 0.0037207 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 2536K->480K(2560K)] 2744K->760K(9728K), 0.0017601 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 2528K->456K(2560K)] 2808K->736K(9728K), 0.0035008 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
100000
Heap
PSYoungGen total 2560K, used 1449K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
eden space 2048K, 48% used [0x00000000ffd00000,0x00000000ffdf8468,0x00000000fff00000)
from space 512K, 89% used [0x00000000fff00000,0x00000000fff72040,0x00000000fff80000)
to space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
ParOldGen total 7168K, used 280K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
object space 7168K, 3% used [0x00000000ff600000,0x00000000ff646000,0x00000000ffd00000)
Metaspace used 3330K, capacity 4500K, committed 4864K, reserved 1056768K
class space used 363K, capacity 388K, committed 512K, reserved 1048576K
SymbolTable statistics:
Number of buckets : 20011 = 160088 bytes, avg 8.000
Number of entries : 13622 = 326928 bytes, avg 24.000
Number of literals : 13622 = 579960 bytes, avg 42.575
Total footprint : = 1066976 bytes
Average bucket size : 0.681
Variance of bucket size : 0.684
Std. dev. of bucket size: 0.827
Maximum bucket size : 6
StringTable statistics:
Number of buckets : 60013 = 480104 bytes, avg 8.000
Number of entries : 18670 = 448080 bytes, avg 24.000
Number of literals : 18670 = 1105072 bytes, avg 59.190
Total footprint : = 2033256 bytes
Average bucket size : 0.311
Variance of bucket size : 0.327
Std. dev. of bucket size: 0.572
Maximum bucket size : 4

Process finished with exit code 0

可以看到,串池中只有18670个字符串,说明串池中有的的字符串常量被回收了。

串池调优

  • 调整 -XX:StringTableSize=桶个数,桶个数越大,查找字符串所耗费的时间越少,因为减少了hash冲突的处理时间。
  • 将大量的重复的字符串及时地放入串池中。

08.直接内存

  • 常见于NIO操作时,用于数据缓冲区
  • 分配回收成本较高,但读写性能高
  • 不受JVM内存回收管理,因为涉及到系统内存

什么是NIO

​ NIO(Non-blocking I/O,非阻塞输入/输出)是一种用于处理输入和输出的编程模型,在Java中尤为常见。它与传统的阻塞I/O模型不同,传统模型在等待数据的过程中会阻塞线程。NIO提供了一种方法,允许I/O操作在没有立即可用数据时不会阻塞程序的执行。这是通过所谓的“选择器”和“通道”来实现的。主要特点包括:

  1. 通道(Channel):类似于流,但有所不同。通道可以同时进行读写操作,而流通常是单向的(只能读或只能写)。通道还可以异步地读写。
  2. 缓冲区(Buffer):数据的容器。在NIO中,所有数据都是使用缓冲区处理的。缓冲区本质上是一个数组,通常是ByteBuffer,但也有其他类型如CharBuffer, IntBuffer等。
  3. 选择器(Selector):一种特殊的对象,用于监视一个或多个通道的状态(比如,是否可读、可写)。这允许单个线程管理多个通道,从而有效地处理多个网络连接。

NIO适合处理需要高并发、高性能的场景,尤其是在网络编程中非常有用。它使得一个单独的线程可以管理多个活跃的连接,而不是传统的一个连接对应一个线程的模型。这种模型可以显著减少资源开销,提高程序的性能和可伸缩性。

什么是直接内存

Java代码在进行磁盘I/O地时候,不会直接读写磁盘文件,而是通过调用本地方法实现磁盘I/O,实现的流程如下图所示:

Java程序从用户态转为内核态,然后调用本地方法读取磁盘文件,然后将文件内容写入系统缓冲区,系统缓冲区中的数据会复制一份到Java缓冲区,然后系统从内核态转为用户态,继续执行代码。这个过程中有个问题就是数据在系统缓冲区和Java缓冲区同时存在,造成资源浪费。

如果上述过程使用的是直接内存则会是下面的这张图:

​ 直接内存就是在系统内存中存在这样一块区域,系统内存可以直接访问它,Java内存也可以直接访问它,在读写文件时,磁盘文件会被调到直接内存中,而Java虚拟机也可以直接访问直接内存中的数据。

​ 直接内存同样也有内存溢出的问题。

直接内存的分配和回收

​ JVM的垃圾回收不会回收直接内存的空间,但是可以使用Unsafe对象完成直接内存的分配和回收,回收需要主动调用freeMenory方法。

​ ByteBuffer的实现类内部,使用了Cleaner(虚引用)来监测ByteBuffer对象,一旦ByteBuffer对象被垃圾回收,那么就会由ReferenceHandler线程通过Cleaner的clean方法调用freeMeory来释放直接内存。

09.垃圾回收

  • 如何判断对象可以回收
  • 垃圾回收算法
  • 分代垃圾回收
  • 垃圾回收器
  • 垃圾回收调优

垃圾回收算法

  1. 引用计数法:

    垃圾回收算法中的引用计数法是一种用于自动内存管理的技术。其基本原理如下:

    • 引用计数: 在这种方法中,系统为每个对象维护一个引用计数器,以追踪有多少引用指向该对象。每当有一个新的引用指向该对象时,其引用计数增加;当一个引用被移除或更改指向其他对象时,引用计数减少。

    • 回收判断: 当一个对象的引用计数变为零时,意味着没有任何引用指向该对象,表明该对象不再被需要,因此可以被垃圾回收机制安全地回收。

    • 优点: 引用计数法的一个主要优点是它可以立即回收不再使用的对象,减少程序运行时的内存占用。这种方法简单且易于实现。

    • 缺点: 然而,引用计数法有几个缺点。最主要的是它无法处理循环引用问题。如果两个或多个对象相互引用,即使它们已经不再被程序使用,它们的引用计数也不会降至零,导致这些对象不能被回收。此外,维护引用计数器本身也需要时间和资源,可能会对性能造成影响。

    由于这些限制,许多现代垃圾回收系统,如Java虚拟机中的垃圾回收器,采用更复杂的算法,如标记-清除(Mark-and-Sweep)或分代收集(Generational Collection)算法,以解决循环引用问题并提高垃圾收集的效率。引用计数法仍然在某些场景中使用,尤其是在资源有限的环境中或作为更复杂垃圾回收算法的一部分。

  2. 可达性分析算法:

    ​ 可达性分析(Reachability Analysis)算法是一种在垃圾回收(GC)中常用的方法,用于确定哪些对象是“可达的”(即仍然被程序使用的)以及哪些对象可以被安全地回收。这种方法主要用于解决引用计数算法无法处理的循环引用问题。它的工作原理如下:

    • 根集合(Root Set):可达性分析算法首先定义一个根集合。这个集合包含了一些基础引用,如全局静态变量、活动线程的堆栈中的局部变量和参数、CPU寄存器中的对象引用等。这些引用被认为是活跃的,并且可以直接访问的。根对象就是肯定不能被垃圾回收的对象。

    • 从根集合出发的搜索:算法从这些根引用开始,遍历所有可从这些根通过任意引用链可达的对象。这个遍历的过程称为图的遍历,在这个过程中,每遇到一个对象,就将其标记为“可达的”。

    • 标记过程:通过这种方式,所有从根集合直接或间接可达的对象都会被标记。这些对象是活跃的,即它们还在程序中被使用,因此不应该被回收。

    • 回收未标记的对象:在标记过程完成后,未被标记的对象就被认为是“不可达的”,因为它们无法从根集合出发通过任何引用链被访问到。这些不可达的对象即被视为垃圾,并可以被垃圾回收器回收。

    • 优点与应用:可达性分析算法能有效处理循环引用的问题,使得垃圾回收更加精确。这种方法是现代垃圾回收器,如Java虚拟机(JVM)中使用的算法的基础。

    ​ 可达性分析算法通过减少内存泄漏的风险,提高了内存管理的效率和程序的性能。然而,与引用计数法相比,它可能更占用资源,因为需要定期进行全面的内存扫描。

    疑问:

    根集合中的对象可以被回收吗?

    答:可以

    在垃圾回收中,根集合(Root Set)中的对象通常不会被回收。根集合是可达性分析算法的起点,它包含了一系列被认为是活动的、可直接访问的引用。这些引用可能来自于各种来源,如:

    1. 全局静态变量:在程序的全局作用域中声明的变量。
    2. 活动线程的栈帧:当前活动线程的方法调用栈中的局部变量和参数。
    3. CPU 寄存器中的引用:直接存储在硬件寄存器中的对象引用。

    由于这些引用被视为是“活动的”或“正在使用中”的,因此在垃圾回收的过程中,从根集合出发可达的对象被认为是必需的,不应被回收。垃圾回收器的任务是确定哪些对象不再可从根集合通过任何引用链访问,从而确定哪些对象是“不可达的”并可以安全地回收。

    然而,需要注意的是,根集合中的对象状态可能会随着程序的执行而改变。例如,当一个局部变量超出作用域或者一个线程结束时,原本在根集合中的引用可能被移除,这样相关的对象就不再是根集合的一部分,因此在之后的垃圾回收过程中就可能被回收。

四种引用

  • 强引用
    • 这是最常见的引用类型。当我们在代码中创建一个对象并将其赋值给一个引用变量时,就创建了一个强引用。
    • 例如:Object obj = new Object();
    • 只要强引用存在,垃圾回收器就永远不会回收被引用的对象。即使内存不足,JVM宁愿抛出OutOfMemoryError也不会回收这种对象。
  • 软引用
    • 软引用是一种对内存敏感的引用,如果没有强引用指向对象,并且系统即将发生内存溢出之前,这种类型的引用指向的对象可能会被回收。
    • 软引用通常用于实现内存敏感的缓存。
    • 例如:使用java.lang.ref.SoftReference类创建软引用。
    • 可以配合引用队列来释放软引用本身。
  • 弱引用
    • 弱引用是比软引用更弱的一种引用类型。无论内存是否足够,只要垃圾回收器发现了弱引用,就会回收其指向的对象。
    • 弱引用主要用于实现规范化映射(Canonicalizing Mappings)。
    • 例如:使用java.lang.ref.WeakReference类创建弱引用。
    • 可以配合引用队列来释放软引用本身。
  • 虚引用
    • 虚引用是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会影响其生命周期。换句话说,即使拥有虚引用,也无法阻止对象被垃圾回收器回收。
    • 虚引用主要用于在对象被回收时收到一个系统通知。
    • 例如:使用java.lang.ref.PhantomReference类创建虚引用。
    • 必须配合引用队列使用。

什么是引用队列?

引用队列(Reference Queue)在Java中是与特殊引用类型(如软引用、弱引用和虚引用)一起使用的一种机制。它用于跟踪垃圾收集器何时回收了这些特殊引用所指向的对象。引用队列的特点和用途如下:

  1. 作用:当一个软引用、弱引用或虚引用指向的对象被垃圾回收器回收时,该引用本身可以被加入到一个引用队列中。程序可以检查这个队列,了解哪些对象已经被回收,从而采取相应的行动,如清理资源或触发其他处理逻辑。

  2. 使用方法

  • 在创建软引用、弱引用或虚引用时,可以指定一个引用队列。
  • 当引用指向的对象被回收时,引用对象本身(而不是它所指向的对象)会被加入到这个队列中。
  1. 示例代码
1
2
ReferenceQueue<Object> refQueue = new ReferenceQueue<>();
WeakReference<Object> weakRef = new WeakReference<>(new Object(), refQueue);

在这个示例中,创建了一个弱引用weakRef,当其指向的对象被回收时,weakRef会被加入到refQueue

  1. 应用场景:引用队列常用于那些需要在对象被回收后执行一些后续处理的场景。例如,你可能需要在一个对象不再被使用时释放它所占用的额外资源。

  2. 处理引用队列:程序可以轮询或等待引用队列中的元素,以便知道何时有引用被加入到队列中。这允许程序在对象被垃圾回收后执行一些清理工作。

总的来说,引用队列是Java内存管理的一个高级特性,它为处理对象生命周期的不同阶段提供了额外的控制机制,使得开发者能够更精细地管理资源和内存。

回收算法

垃圾回收算法是通过某种算法确定哪些变量应该被回收,而回收算法是指已经知道了哪些变量应该被回收,通过算法如何回收的变量所占用的变量空间的过程。

  • 标记清除法:标记准备清除的变量的内存地址,然后释放被标记的内存空间,这样做的好处是快,坏处是会产生内存碎片。
  • 标记整理法:相比于标记清除法,标记整理法,多了一个整理内存空间的步骤,将没有被释放掉的变量所占用的空间紧凑地排列到一起。优点是解决了内存碎片的问题,缺点是耗时。
  • 复制法:首先将堆分成两个半区,分别是“from-space”和“to-space”。当进行垃圾回收时,垃圾回收器会识别出from-space中所有还活着的对象。然后,垃圾回收器会将所有活动的对象复制到to-space中。在复制的过程中,同时会进行内存的压缩,即将所有活动对象紧凑地排列在一起,从而消除碎片。复制完成后,from-space和to-space的角色会互换。原来的from-space(现在是空的)成为新的to-space,原来的to-space(包含所有活动对象)成为新的from-space。复制算法的优点包括:效率高,能消除内存碎片。缺点包括:空间开销大,不适合长期存活的对象。

在实际中,不会单单使用一种算法,往往是几种算法的融合。

10.分代回收

​ Java虚拟机(JVM)中的分代垃圾回收(Garbage Collection,GC)是一种优化垃圾回收性能的技术。在这种方法中,堆内存被分为几个不同的区域或“代”,主要包括年轻代(Young Generation)、老年代(Old Generation)和永久代(PermGen,但在Java 8中被元空间Metaspace所替代)。每个代针对不同生命周期的对象进行优化处理。

年轻代(Young Generation):

  • Eden空间: 新创建的对象首先被分配到Eden空间(Eden:伊甸园)。
  • 两个幸存者空间(Survivor Spaces): 当Eden空间满了之后,会进行一次Minor GC(也称为Young GC)。在这个过程中,存活的对象会被移动到一个幸存者空间(S0或S1)。不存活的对象则会被清除。可以把这两个幸存者空间称为form空间和to空间
  • 对象的年龄: 每次从一个幸存者空间移动到另一个时,对象的年龄就会增加。当对象达到一定年龄阈值时,它们就会被移动到老年代,这个过程被称为晋升(Promotion)。

老年代(Old Generation):

  • 老年代用于存储长期存活的对象。
  • 当对象在年轻代中达到一定年龄后,它们就会被移动到老年代。
  • 老年代的空间通常比年轻代大得多,所以Full GC(涉及老年代的垃圾回收)的频率通常低于Minor GC。
  • Full GC通常比Minor GC更耗时,因为它需要检查整个老年代。

永久代/元空间(PermGen/Metaspace):

  • 这部分空间主要用于存储类的元数据、常量以及一些编译后的代码。
  • 在Java 8之前,这部分被称为永久代,但在Java 8中被替换为元空间。元空间使用本地内存,因此其大小不再受JVM内存的限制。

​ 整个分代垃圾回收的过程是为了提高垃圾回收的效率。由于大多数新创建的对象很快就变得不可达(例如临时变量),通过频繁地在年轻代进行Minor GC,可以快速回收这些短命对象,同时减少了对老年代的影响。老年代的Full GC频率较低,但每次运行时会更耗时。通过这种方式,JVM能够更有效地管理不同生命周期的对象,提高垃圾回收的效率。

下面的过程展示了分代回收的过程:

  1. 首先在堆内存中有两个区域分别代表新生代(年轻代)和老年代

  2. 将新创建的两个对象分配到Eden区域

  3. Eden中的对象越来越多触发了一次Minor GC,Minor GC会引发stop the world, 暂停其它用户的线程,运行垃圾回收的线程。

  4. 使用垃圾回算法确定哪些对象可以被放入幸存区(from和to区域),假设第二个和第四个对象被选中放入幸存区,则把第二个和第四个对象放入幸存区to中

  5. 将幸存区中的对象的年龄+1,并且交换form和to区域的指针

  6. 删除Eden区中的所有对象

  7. 当Eden区域中的对象再次把空间占满的时候,会再触发一次Minor GC,此时会同时扫描Eden区和幸存区的所有对象,看哪些对象可以留下来,假设下图中红色的对象和幸存区中的对象可以留下来

  8. 将可以留下来的对象转移到幸存区to中,同时删除其余对象,包括幸存区中不能留下来的对象也要删除,存留下来的对象年龄加一,然后交换from和to所指向的区域

    问题:当Survivor区域中的空间满了之后怎么办?

    当Survivor区(例如其中一个Survivor区,通常称为S0或S1)在JVM的Minor GC过程中满了,JVM会采取几种策略来处理这种情况。这主要取决于对象的大小和年龄,以及JVM的具体垃圾回收算法。以下是一些常见的处理方式:

    1. 对象晋升(Promotion):如果Survivor区域无法容纳所有存活的对象,这些对象可能会被直接晋升到老年代(Old Generation)。这通常发生在对象已经在新生代中存活了足够多的垃圾回收周期,达到了一定的年龄阈值。
    2. 调整对象年龄阈值:在某些情况下,JVM可能会动态调整对象从新生代晋升到老年代的年龄阈值。如果Survivor区快满时,JVM可能会降低这个阈值,以便更早地将对象移至老年代。
    3. Full GC触发:如果老年代也接近满载,并且无法容纳更多从新生代晋升的对象,这可能会触发一次Full GC。Full GC是一种更为全面的垃圾回收过程,它涉及整个堆内存,包括新生代和老年代。Full GC通常比Minor GC更耗时,会暂停应用程序的执行。
    4. 内存分配调整:在某些垃圾回收器和JVM实现中,可能会调整Eden区和Survivor区的大小比例,或者调整整个堆的大小,以适应不同的内存使用模式。
    5. 异常抛出:在极端情况下,如果老年代也已满,并且无法为新对象或晋升的对象分配更多空间,JVM可能会抛出OutOfMemoryError异常。

    具体采取哪种策略取决于JVM的垃圾回收器类型(如Serial, Parallel, G1, CMS等)和其配置。不同的垃圾回收器有不同的行为和优化策略,以处理内存分配和回收的问题。

  9. 当新生代中对象的年龄到达一定的阈值的时候,会把对象从新生代移动到老年代中,一般来说最大的阈值是15

  10. 如果老年代中的空间也被占满的时候,会促发一次Full GC,Full GC(全面垃圾回收)是Java虚拟机(JVM)中的一种垃圾回收(GC)过程,它与Minor GC相比,更加全面和彻底。在Full GC过程中,整个堆内存(包括新生代、老年代和永久代/元空间,后者取决于JVM版本)都会被清理。以下是在Full GC过程中发生的一些关键事项:

    1. 暂停应用线程:Full GC通常是一个“Stop-The-World”事件,意味着它会暂停应用程序中的所有线程,直到垃圾回收过程完成。这可能导致应用程序出现明显的停顿。
    2. 新生代清理:Full GC会先尝试清理新生代(包括Eden区和两个Survivor区),移除不再被引用的对象,并将存活的对象移动到老年代或者Survivor区。
    3. 老年代清理:Full GC的主要部分是清理老年代,移除其中不再被引用的对象。由于老年代通常存储着长时间存活的对象,因此这个过程可能相当耗时。
    4. 永久代/元空间清理:在JVM的早期版本中(Java 8之前),Full GC还会涉及对永久代的清理。在Java 8及之后的版本中,永久代被元空间(Metaspace)所替代,元空间的垃圾回收也可能在Full GC中进行。
    5. 压缩堆:在某些情况下,Full GC还会包括压缩堆的步骤,即重新排列存活的对象,减少内存碎片,以便为新对象分配连续的内存空间。
    6. 异常抛出:在极端情况下,如果老年代也已满,并且无法为新对象或晋升的对象分配更多空间,JVM会抛出OutOfMemoryError异常。
    7. 调整堆大小:在Full GC之后,JVM可能会调整堆的大小,以适应应用程序的内存使用模式。

    Full GC的频繁发生通常是内存管理问题的一个指标,可能是由于内存泄露、堆大小设置不当或者垃圾回收器选择不恰当等原因引起的。由于Full GC会导致较长时间的应用暂停,因此在性能敏感的应用中,通常会尽量避免频繁的Full GC事件。通过调整JVM参数和优化应用程序的内存使用,可以减少Full GC的发生频率和影响。

11.GC分析

GC分析相关参数

在进行垃圾回收(GC)分析时,Java虚拟机(JVM)的各种参数可以帮助你更好地了解和控制GC的行为。以下是一些常用于GC分析的JVM参数:

  1. 设置堆大小
    • -Xms<size>:设置堆的初始大小。
    • -Xmx<size>:设置堆的最大大小。
  2. 设置新生代大小
    • -Xmn<size>:设置新生代的大小。
  3. 选择垃圾回收器
    • -XX:+UseSerialGC:使用串行垃圾回收器。
    • -XX:+UseParallelGC:使用并行垃圾回收器。
    • -XX:+UseConcMarkSweepGC:使用CMS垃圾回收器。
    • -XX:+UseG1GC:使用G1垃圾回收器。
  4. 垃圾回收日志
    • -verbose:gc:启用垃圾回收日志。
    • -XX:+PrintGCDetails:打印详细的GC日志。
    • -XX:+PrintGCTimeStamps:在GC日志中添加时间戳。
    • -XX:+PrintGCDateStamps:在GC日志中添加日期和时间戳。
    • -Xloggc:<file>:将GC日志输出到指定的文件。
  5. 调整GC参数
    • -XX:SurvivorRatio=<ratio>:设置Eden区与一个Survivor区的大小比例。
    • -XX:NewRatio=<ratio>:设置新生代与老年代的大小比例。
    • -XX:MaxTenuringThreshold=<threshold>:设置对象从新生代晋升到老年代的年龄阈值。
  6. 调优和监控
    • -XX:+UseAdaptiveSizePolicy:允许垃圾回收器调整堆大小参数,如Eden区的大小。
    • -XX:+PrintTenuringDistribution:打印晋升阈值和对象年龄的分布情况。
    • -XX:+HeapDumpOnOutOfMemoryError:在发生内存溢出时生成堆转储文件。
    • -XX:HeapDumpPath=<path>:指定堆转储文件的路径。
  7. 其他调试参数
    • -XX:+PrintCommandLineFlags:打印出实际使用的JVM参数。

使用这些参数可以帮助你监控和调整GC行为,以提高应用程序的性能。不过,需要注意的是,并不是所有参数在所有版本的JVM中都可用。在使用这些参数之前,最好查阅你所使用的JVM版本的文档。

GC案例分析

先看下面一段代码:

1
2
3
4
5
6
7
8
9
10
11
12
public class Main5 {
private static final int _512KB = 512 * 1024;
private static final int _1MB = 1024 * 1024;
private static final int _6MB = 6 * 1024 * 1024;
private static final int _7MB = 7 * 1024 * 1024;
private static final int _8MB = 8 * 1024 * 1024;

// -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc
public static void main(String[] args) {

}
}

在添加VM参数之后运行代码,会输出以下内容:

Heap
def new generation total 9216K, used 2065K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
eden space 8192K, 25% used [0x00000000fec00000, 0x00000000fee04590, 0x00000000ff400000)
from space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
to space 1024K, 0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
tenured generation total 10240K, used 0K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
the space 10240K, 0% used [0x00000000ff600000, 0x00000000ff600000, 0x00000000ff600200, 0x0000000100000000)
Metaspace used 3288K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 357K, capacity 388K, committed 512K, reserved 1048576K

对于上面输出信息的解释:

  1. 新生代(New Generation)
  • 总大小:9216K。这是新生代的总内存大小。
  • 使用情况:使用了2065K,表示当前新生代已使用的内存量。

新生代分为三个部分:

  • Eden区:
    • 总大小为8192K。
    • 使用率为25%。这意味着Eden区目前大约有1/4的空间被使用。
  • From Survivor区:
    • 总大小为1024K。
    • 使用率为0%。这意味着From区目前没有被使用。
  • To Survivor区:
    • 总大小为1024K。
    • 使用率同样为0%。
  1. 老年代(Tenured Generation)
  • 总大小:10240K。这是老年代的总内存大小。
  • 使用情况:使用了0K。这表示老年代目前没有被使用。
  1. 元空间(Metaspace)
  • 使用情况:3288K。这是元空间当前的使用量。
  • 容量:4496K。这是元空间目前的容量。
  • 提交大小:4864K。这是为元空间提交的内存大小。
  • 保留大小:1056768K。这是元空间的最大保留大小。
  1. 类空间(Class Space,Metaspace的一部分)
  • 使用情况:357K。
  • 容量:388K。
  • 提交大小:512K。
  • 保留大小:1048576K。

然后运行下面的代码,查看输出的信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Main5 {
private static final int _512KB = 512 * 1024;
private static final int _1MB = 1024 * 1024;
private static final int _6MB = 6 * 1024 * 1024;
private static final int _7MB = 7 * 1024 * 1024;
private static final int _8MB = 8 * 1024 * 1024;

// -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc
public static void main(String[] args) {
ArrayList<byte[]> list = new ArrayList<>();
list.add(new byte[_7MB]);
}
}

[GC (Allocation Failure) [DefNew: 1901K->625K(9216K), 0.0010157 secs] 1901K->625K(19456K), 0.0010587 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
Heap
def new generation total 9216K, used 8039K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
eden space 8192K, 90% used [0x00000000fec00000, 0x00000000ff33d8c0, 0x00000000ff400000)
from space 1024K, 61% used [0x00000000ff500000, 0x00000000ff59c638, 0x00000000ff600000)
to space 1024K, 0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
tenured generation total 10240K, used 0K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
the space 10240K, 0% used [0x00000000ff600000, 0x00000000ff600000, 0x00000000ff600200, 0x0000000100000000)
Metaspace used 3325K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 363K, capacity 388K, committed 512K, reserved 1048576K

Process finished with exit code 0

由上面的信息可以看出,代码运行时触发了一次垃圾回收事件:

垃圾回收(GC)事件

  • 类型:GC (Allocation Failure)。这表示这次GC是由于新生代没有足够空间来分配新对象而触发的。
  • 新生代(DefNew)变化:1901K->625K(9216K)。在GC之前,新生代使用了1901K内存;GC之后,减少到625K。新生代的总大小是9216K。
  • 时间:此次GC耗时0.0010157秒。
  • 整个堆的变化:1901K->625K(19456K)。GC之前,整个堆(新生代加老年代)使用了1901K,GC之后,减少到625K。整个堆的大小是19456K。
  • 时间细节:用户时间(user)0.00秒,系统时间(sys)0.00秒,实际时间(real)0.00秒。

12.垃圾回收器

垃圾回收器(Garbage Collector, GC)是用于自动管理内存的系统,主要用于编程语言如Java中。它们可以从不同的角度进行分类,其中三个重要的角度是串行性、吞吐量优先和响应时间优先。以下是这三个类型的简要解释:

  1. 串行垃圾回收器(Serial Garbage Collectors):

    • 这种类型的垃圾回收器在执行垃圾回收时只使用单个线程。适用于堆内存比较小的个人电脑。
    • 串行回收器在工作时会暂停所有应用程序线程(称为”Stop-The-World”),直到垃圾回收完成。
    • 优点在于实现简单,资源需求较低,适合于小型应用或有限的资源环境,如单核处理器或小内存系统。
    • 缺点是在回收过程中,应用程序的响应可能会受到影响,不适合需要高响应性或多线程执行的应用。
  2. 吞吐量优先垃圾回收器(Throughput-First Garbage Collectors):

    • 这类回收器注重于最大化应用程序的运行时间和性能,即优先保证应用程序的吞吐量。堆内存需求较大,需要多核CPU。
    • 它们通常会使用多个线程来进行垃圾回收,减少了垃圾回收对应用性能的影响。
    • 吞吐量优先的回收器适合于计算密集型应用,如大型服务器或长时间运行的后台处理系统。
    • 不过,这种类型的回收器可能会牺牲响应时间,因为虽然垃圾回收的总时间减少了,但”Stop-The-World”的暂停时长可能仍然较长。
    • 可以理解为这种回收器是让总的垃圾回收时间最短。
  3. 响应时间优先垃圾回收器(Response-Time-First Garbage Collectors):

    • 这类回收器的设计重点是最小化对应用响应时间的影响,即减少垃圾回收操作对用户体验的干扰。堆内存需求较大,需要多核CPU。。
    • 它们通常采用更复杂的算法,以及增量或并发的回收方式,以减少单次垃圾回收造成的暂停时间。
    • 响应时间优先的回收器非常适用于交互式应用,如图形界面应用、实时系统等,其中用户体验和快速响应至关重要。
    • 但是,这种类型的垃圾回收器可能会牺牲一些吞吐量,因为它需要更多的计算资源来管理更复杂的垃圾回收过程。
    • 可以理解为这种垃圾回收器是让每一次STW的时间最短。

每种类型的垃圾回收器都有其适用场景和权衡。在选择垃圾回收器时,需要根据应用程序的具体需求和运行环境来决定最合适的类型。

串行垃圾回收器
使用-XX:+UseSerialGC=Serial + SerialOld开启串行垃圾回收器,其中Serial指的是新生代的垃圾回收,采用复制算法;而SerialOld指的是老年代的垃圾回收,使用的是标记整理算法。

串行垃圾回收器(Serial Garbage Collector)是一种简单而有效的垃圾回收机制,主要用于小型应用或资源受限的环境。它的工作过程可以分为几个主要阶段:

  1. 标记阶段(Marking Phase):
    • 在这个阶段,串行垃圾回收器遍历所有的对象,标记那些仍然被应用程序所引用的对象。
    • 这个过程是”Stop-The-World”的,意味着在标记过程中,应用程序的所有线程都会被暂停。
    • 回收器从一组称为“根”的对象开始(通常是活动线程的局部变量和输入参数),递归地遍历所有可达的对象。
  2. 清除阶段(Sweeping Phase):
    • 在标记完成后,清除阶段开始。
    • 在这个阶段,垃圾回收器遍历堆内存,回收那些没有被标记的对象,释放其占用的内存空间。
    • 这个过程同样是”Stop-The-World”的,应用程序线程在此阶段仍然被暂停。
  3. 压缩阶段(Compacting Phase,可选):
    • 有些串行垃圾回收器还包括一个压缩阶段,这在内存碎片化成问题时尤其重要。
    • 在压缩阶段,回收器会重新排列存活的对象,将它们移动到堆的一端,从而使剩余的堆空间成为连续的大块,便于未来的内存分配。
    • 这一阶段也是”Stop-The-World”的。

串行垃圾回收器的优点在于其简单性和较低的资源需求,它不需要像并发或并行回收器那样管理多个线程之间的复杂交互。但它的主要缺点是在垃圾回收过程中,应用程序的所有线程都必须暂停,这可能会导致明显的应用程序响应延迟,尤其是在处理大量数据或在内存分配频繁的应用中。因此,串行垃圾回收器通常最适用于小型或资源受限的应用程序。

吞吐量优先的垃圾回收器
开启吞吐量优先的垃圾回收器所用到的参数:

-XX:+UseParallelGC新生代的吞吐量优先的垃圾回收器

-XX:+UseParallelOldGC老年代的吞吐量优先的垃圾回收器

-XX:+UseAdaptiveSizePolicy自适应的新生代大小调整策略

-XX:GCTimeRatio=ratio调整垃圾回收时间占运行时间的比例,如果垃圾回收次数过多则通常会增大堆空间

-XX:MaxGCPauseMillis=msGC时最大暂停毫秒数

-XX:ParallelGCThreads=n指定线程数量

吞吐量优先的垃圾回收器(Throughput-First Garbage Collectors)主要关注于最大化应用程序执行代码的时间与垃圾回收时间的比例,即优先考虑应用的吞吐量。它们适用于那些对停顿时间不太敏感、更关注总体执行效率和性能的应用场景,如后台处理和批处理系统。下面是吞吐量优先垃圾回收器的工作方式:

  1. 工作原理:
    • 吞吐量优先的回收器通常采用并行垃圾回收机制,在多个处理器或核心上同时执行垃圾回收操作,以减少垃圾回收所需的总时间。
    • 与串行垃圾回收器相比,它们在执行垃圾回收时仍然会暂停应用线程(Stop-The-World),但由于并行处理,暂停时间通常比串行回收器短。
  2. 标记-清除(Mark-Sweep)或标记-整理(Mark-Compact)算法:
    • 吞吐量优先的回收器通常使用标记-清除或标记-整理算法。
    • 在标记阶段,回收器标记出所有活动的对象。
    • 在清除阶段,未标记的对象被认为是垃圾并被回收。在标记-整理算法中,存活的对象还会被移动以减少内存碎片。
  3. 多个回收区域:
    • 这类回收器经常将堆内存分为多个区域,如年轻代(Young Generation)和老年代(Old Generation)。
    • 年轻代中的对象频繁进行垃圾回收,因为许多对象生命周期短暂。
    • 老年代中的对象较少进行垃圾回收,因为这些对象通常存活时间更长。
  4. 优化停顿时间:
    • 尽管吞吐量优先,这类回收器也会尝试优化停顿时间,以避免长时间的应用暂停。
    • 例如,可以通过调整年轻代和老年代的大小或采用增量收集策略来减少单次垃圾回收的影响。
  5. 配置和调整:
    • 吞吐量优先的垃圾回收器通常提供多种配置选项,允许开发人员或系统管理员根据应用需求调整其行为。
    • 例如,可以调整堆大小、年轻代和老年代的比例、并行收集线程的数量等,以优化应用的性能。

在Java中,Parallel GC是一种典型的吞吐量优先垃圾回收器。它在年轻代使用并行的标记-复制(Mark-Copy)算法,在老年代使用并行的标记-清除或标记-整理算法。Parallel GC通过并行处理和优化的算法来提高吞吐量,同时保持合理的停顿时间。总之,吞吐量优先的垃圾回收器通过并行处理和智能内存管理策略,在保持应用性能的同时,尽量减少垃圾回收对应用停顿的影响。它们适合于那些对停顿时间要求不严格、更注重总体运行效率的应用。

响应时间优先的垃圾回收器

CMS(Concurrent Mark-Sweep)垃圾回收器是Java虚拟机中用于管理堆内存的一种回收器。它的设计目的是获取更短的垃圾回收停顿时间,特别适用于那些对应用响应时间有严格要求的场景。以下是CMS垃圾回收器的工作原理:

  1. 并发标记(Concurrent Mark):

    • 初始标记(Initial Mark):

      这是唯一一个需要”Stop-The-World”的标记阶段,但这个阶段通常很快。在这个阶段,CMS标记所有直接与GC Roots相连的对象。

    • 并发标记(Concurrent Marking):

      在这个阶段,CMS回收器并发地标记所有从GC Roots可达的对象。这意味着它在应用程序线程运行的同时执行,从而减少了停顿时间。

  2. 预清理(Pre-Cleaning):

    • 在并发标记阶段和最终标记阶段之间,可能会有一些变更。预清理阶段用于处理这些变更,它通常也是并发执行的。
  3. 最终标记(Final Mark):

    • **最终标记阶段通常需要短暂的”Stop-The-World”**。在这个阶段,CMS处理剩下的标记工作,尤其是在并发阶段因为程序运行而产生的变化。
  4. 并发清除(Concurrent Sweep):

    • 在完成所有标记工作后,CMS并发地清除所有未被标记的对象,释放它们占用的内存空间。这个过程不需要停止应用程序线程。
  5. 重置(Reset):

    • 最后,CMS回收器重置内部数据结构,准备下一次垃圾回收。

CMS的特点和局限性:

  • 优点:
    • 减少停顿时间:由于大部分工作是并发进行的,CMS能显著减少垃圾回收造成的停顿时间。
    • 适合交互式应用:对于需要快速响应的应用(如Web服务器),CMS提供了更好的用户体验。
  • 局限性:
    • 处理器资源占用:并发执行的垃圾回收占用了额外的CPU资源,可能会影响应用程序的吞吐量。
    • 内存碎片:CMS是一种不压缩的回收器,可能会导致较多的内存碎片,从而影响大对象的分配。
    • “并发模式失败”风险:如果堆内存不足以满足应用需求,CMS可能会触发”并发模式失败”,此时会退回到传统的”Stop-The-World”全面垃圾回收。

CMS垃圾回收器适用于对停顿时间敏感的应用,但需要仔细管理和调优,以确保在降低停顿时间的同时,不会过度消耗资源或引起内存碎片问题。随着Java虚拟机的发展,一些新的垃圾回收器(如G1和ZGC)被引入,它们旨在解决CMS的一些局限性,并提供更好的性能和停顿时间控制。