JVM学习笔记

学习时间:2021年11月12日至

1 什么是JVM

定义

Java Virtual Machine,JAVA程序的运行环境(JAVA二进制字节码的运行环境)

好处

  • 一次编写,到处运行
  • 自动内存管理,垃圾回收机制
  • 数组下标越界检查

比较

JVM JRE JDK的区别

image-20211113091501207

常见JVM

image-20211113091936463

2 内存结构

image-20211113092036796

2.1 程序计数器

2.1.1 作用

用于保存JVM中下一条所要执行的指令的地址。

2.1.2 特点

  • 线程私有
    • CPU会为每个线程分配时间片,当当前线程的时间片使用完以后,CPU就会去执行另一个线程中的代码
    • 程序计数器是每个线程私有的,当另一个线程的时间片用完,又返回来执行当前线程的代码时,通过程序计数器可以知道应该执行哪一句指令
  • 不会存在内存溢出(唯一一个不会存在内存溢出的区域)

2.2 虚拟机栈

2.2.1 定义

每个线程运行需要的内存空间,称为虚拟机栈。(私有)

  • 每个栈由多个栈帧组成,对应着每次调用方法时所占用的内存
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的方法,位于栈的顶部。

2.2.2 演示

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

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

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

image-20211113094514556

在控制台中可以看到,主类中的方法在进入虚拟机栈的时候,符合栈的特点。

2.2.3 问题辨析

  • 垃圾回收是否涉及栈内存?
    • 不需要。因为虚拟机栈中是由一个个栈帧组成的,在方法执行完毕后,对应的栈帧就会被弹出栈。所以无需通过垃圾回收机制去回收内存。
  • 栈内存的分配越大越好吗?
    • 不是。因为物理内存是一定的,栈内存越大,可以支持更多的递归调用,但是可执行的线程数就会越少。
  • 方法内的局部变量是否是线程安全的?
    • 如果方法内局部变量没有逃离方法的作用范围,则是线程安全
    • 如果如果局部变量引用了对象,并逃离了方法的作用范围,则需要考虑线程安全问题
1
2
3
4
5
6
7
8
9
// 多个线程同时执行该方法
static void m1(){
int x = 0;
for (int i = 0; i < 5000; i++) {
x++;
}
System.out.println(x);
}
// 结果:互不干扰

2.2.4 栈内存溢出

Java.lang.stackOverflowError 栈内存溢出

发生原因

  • 虚拟机栈中,栈帧过多(无限递归)

  • 每个栈帧所占用的内存过大

  • 第三方库使用错误导致栈内存溢出
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Test7 {
private static int count;
public static void main(String[] args) {
try {
method1();
} catch (Throwable e){
e.printStackTrace();
System.out.println(count);
}
}

private static void method1(){
count++;
method1();// 无限递归
}
}

image-20211113100450731

image-20211113100500194

2.3 本地方法栈

一些带有native关键字的方法就是需要JAVA去调用本地的C或者C++方法,因为JAVA有时候没法直接和操作系统底层交互,所以需要用到本地方法。

image-20211113101311352

2.4 堆

定义

通过new关键字创建的对象都会被放在堆内存

特点

  • 所有线程共享,堆内存中的对象都需要考虑线程安全问题
  • 有垃圾回收机制

堆内存溢出

java.lang.OutofMemoryError :java heap space. 堆内存溢出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Test1 {
public static void main(String[] args) {
int i = 0;
try {
List<String> list = new ArrayList<>();
String a = "hello";
while (true) {
list.add(a);
a = a + a;
i++;
}
} catch (Throwable e){
e.printStackTrace();
System.out.println(i);
}
}
}

image-20211114152504599

2.5 方法区

2.5.1 结构

image-20211114152756763

2.5.2 内存溢出

  • 1.8以前会导致永久代内存溢出(jvm内存)
  • 1.8以后会导致元空间内存溢出(操作系统内存)

2.5.3 常量池

二进制字节码的组成:类的基本信息、常量池、类的方法定义(包含了虚拟机指令)

通过反编译来查看类的信息

  • 获得对应类的.class文件
1
javac Test2.java
  • 获取反编译字节码文件的信息
1
javap -v Test2.class
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
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
Classfile /E:/develop/study/project-study/juc_demo/src/test/java/com/hongyi/c5/Test2.class
Last modified 20211114日; size 430 bytes
SHA-256 checksum d9a3f77f1c9a71021aa858bbfb21099ab3599e28adf17d7013cab648bd749a32
Compiled from "Test2.java"
public class com.hongyi.c5.Test2
minor version: 0
major version: 59
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #21 // com/hongyi/c5/Test2
super_class: #2 // java/lang/Object
interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool: // 常量池
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Fieldref #8.#9 // java/lang/System.out:Ljava/io/PrintStream;
#8 = Class #10 // java/lang/System
#9 = NameAndType #11:#12 // out:Ljava/io/PrintStream;
#10 = Utf8 java/lang/System
#11 = Utf8 out
#12 = Utf8 Ljava/io/PrintStream;
#13 = String #14 // Hello World!
#14 = Utf8 Hello World!
#15 = Methodref #16.#17 // java/io/PrintStream.println:(Ljava/lang/String;)V
#16 = Class #18 // java/io/PrintStream
#17 = NameAndType #19:#20 // println:(Ljava/lang/String;)V
#18 = Utf8 java/io/PrintStream
#19 = Utf8 println
#20 = Utf8 (Ljava/lang/String;)V
#21 = Class #22 // com/hongyi/c5/Test2
#22 = Utf8 com/hongyi/c5/Test2
#23 = Utf8 Code
#24 = Utf8 LineNumberTable
#25 = Utf8 main
#26 = Utf8 ([Ljava/lang/String;)V
#27 = Utf8 SourceFile
#28 = Utf8 Test2.java
{
public com.hongyi.c5.Test2(); // 默认构造方法
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #13 // String Hello World!
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 5: 0
line 6: 8
}
SourceFile: "Test2.java"

虚拟机指令:#号的内容需要在常量池中查找

image-20211114155939375

2.5.4 运行时常量池

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

2.5.5 常量池和串池

串池StringTable

特征

  • 常量池中的字符串仅是符号,只有在被用到时才会转化为对象
  • 利用串池的机制,来避免重复创建字符串对象
  • 字符串变量拼接的原理是StringBuilder(1.8)
  • 字符串常量拼接的原理是编译器优化
  • 可以使用intern方法,主动将串池中还没有的字符串对象放入串池中
  • 注意:无论是串池还是堆里面的字符串,都是对象
1
2
3
4
5
6
7
8
9
10
11
12
13
// StringTable ["a", "b", "ab"] 实质上是hashtable结构
public class Test2 {
// 常量池中的信息,都会被加载到运行时常量池
// 这时a b ab都是常量池中的符号,还没有变为java字符串对象
// 0:ldc #7 这条语句会把a符号变为 "a" 字符串对象,并检查StringTable,若无则加入
// 3:ldc #9 这条语句会把b符号变为 "b" 字符串对象
// 6:ldc #11 这条语句会把ab符号变为 "ab" 字符串对象
public static void main(String[] args) {
String s1 = "a";// 懒汉式创建对象
String s2 = "b";
String s3 = "ab";
}
}

常量池中的信息,都会被加载到运行时常量池中,但这是a b ab 仅是常量池中的符号,还没有成为java字符串

1
2
3
4
5
6
7
0: ldc           #7                  // String a
2: astore_1
3: ldc #9 // String b
5: astore_2
6: ldc #11 // String ab
8: astore_3
9: return

当执行到 ldc #2 时,会把符号 a 变为 “a” 字符串对象,并放入串池中(hashtable结构 不可扩容)

当执行到 ldc #3 时,会把符号 b 变为 “b” 字符串对象,并放入串池中

当执行到 ldc #4 时,会把符号 ab 变为 “ab” 字符串对象,并放入串池中

最终StringTable [“a”, “b”, “ab”]

注意:字符串对象的创建都是懒惰的,只有当运行到那一行字符串且在串池中不存在的时候(如 ldc #2)时,该字符串才会被创建并放入串池中。

  1. 使用拼接字符串变量对象创建字符串的过程如下:
1
2
3
4
5
6
7
8
9
public class StringTableStudy {
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
//拼接字符串对象来创建新的字符串
String s4 = s1 + s2;
}
}

反编译后的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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."<init>":()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/Str
ing;
27: astore 4
29: return

通过拼接的方式来创建字符串的过程是:StringBuilder().append(“a”).append(“b”).toString()

最后的toString方法的返回值是一个新的字符串,但字符串的和拼接的字符串一致,但是两个不同的字符串,一个存在于串池之中,一个存在于堆内存(拼接)之中

1
2
3
4
5
6
7
String s1 = "a";
String s2 = "b";
String s3 = "ab";
//拼接字符串对象来创建新的字符串
String s4 = s1 + s2;
//结果为false,因为s3是存在于串池之中,s4是由StringBuffer的toString方法所返回的一个对象,存在于堆内存之中
System.out.println(s3 == s4); // false
  1. 使用拼接字符串常量对象的方法创建字符串
1
2
3
4
5
6
7
8
9
10
11
public class StringTableStudy {
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "ab";
//拼接字符串对象来创建新的字符串
String s4 = s1 + s2;
//使用拼接字符串的方法创建字符串
String s5 = "a" + "b";
}
}

反编译结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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."<init>":()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/Str
ing;
27: astore 4
//s5初始化时直接从串池中获取字符串ab
29: ldc #4 // String ab
31: astore 5
33: return
1
System.out.println(s3 == s5); // true
  • 使用拼接字符串常量的方法来创建新的字符串时,因为内容是常量,javac在编译期会进行优化,结果已在编译期确定为ab,而创建ab的时候已经在串池中放入了“ab”,所以s5直接从串池中获取值,所以进行的操作和 s5 = “ab” 一致。
  • 使用拼接字符串变量的方法来创建新的字符串时,因为内容是变量,只能在运行期确定它的值,所以需要使用StringBuilder来创建

intern方法 jdk1.8

调用字符串对象的intern方法,会将该字符串对象尝试放入到串池中

  • 如果串池中没有该字符串对象,则放入成功
  • 如果有该字符串对象,则放入失败

无论放入是否成功,都会返回串池中的字符串对象

注意:此时如果调用intern方法成功,堆内存与串池中的字符串对象是同一个对象;如果失败,则不是同一个对象。

  1. 例一:
1
2
3
4
5
6
7
8
9
10
11
12
13
public class Main {
public static void main(String[] args) {
//"a" "b" 被放入串池中,str则存在于堆内存之中
String str = new String("a") + new String("b");
//调用str的intern方法,这时串池中没有"ab",则会将该字符串对象放入到串池中,此时堆内存与串池中的"ab"是同一个对象
String str2 = str.intern();
//给str3赋值,因为此时串池中已有"ab",则直接将串池中的内容返回
String str3 = "ab";
//因为堆内存与串池中的"ab"是同一个对象,所以以下两条语句打印的都为true
System.out.println(str == str2);
System.out.println(str == str3);
}
}
  1. 例二
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Main {
public static void main(String[] args) {
//此处创建字符串对象"ab",因为串池中还没有"ab",所以将其放入串池中
String str3 = "ab";
//"a" "b" 被放入串池中,str则存在于堆内存之中
String str = new String("a") + new String("b");
//此时因为在创建str3时,"ab"已存在与串池中,所以放入失败,但是会返回串池中的"ab"
String str2 = str.intern();
//false
System.out.println(str == str2);
//false
System.out.println(str == str3);
//true
System.out.println(str2 == str3);
}
}

intern方法 jdk1.6

调用字符串对象的intern方法,会将该字符串对象尝试放入到串池中

  • 如果串池中没有该字符串对象,会将该字符串对象复制一份,再放入到串池中
  • 如果有该字符串对象,则放入失败

无论放入是否成功,都会返回串池中的字符串对象

注意:此时无论调用intern方法成功与否,串池中的字符串对象和堆内存中的字符串对象都不是同一个对象

面试题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class Test4 {
public static void main(String[] args) {
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);// false
System.out.println(s3 == s5);// true
System.out.println(s3 == s6);// true

String x2 = new String("c") + new String("d");
String x1 = "cd";
x2.intern();
System.out.println(x1 == x2);// false
}
}

串池垃圾回收

StringTable在内存紧张时,会发生垃圾回收

串池调优

  • 因为StringTable是由HashTable实现的,所以可以适当增加HashTable桶的个数,来减少字符串放入串池所需要的时间

    1
    -XX:StringTableSize=xxxxCopy
  • 考虑是否需要将字符串对象入池

    可以通过intern方法减少重复入池

2.6 直接内存

2.6.1 BIO和NIO读写文件

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

普通IO文件读写流程

image-20211115141608645

使用了DirectBuffer

直接内存是操作系统和Java代码都可以访问的一块区域,无需将代码从系统内存复制到Java堆内存,从而提高了效率。

image-20211115142403667

2.6.2 内存释放

内存溢出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Slf4j
public class Test8 {
static int _100Mb = 1024 * 1024 *100;
public static void main(String[] args) {
List<ByteBuffer> list = new ArrayList<>();
int i = 0;
try {
while (true) {
// 分配直接内存
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_100Mb);
list.add(byteBuffer);
i++;
}
} finally {
System.out.println(i);
}
}
}

执行结果:

image-20211115142822317

直接内存的回收不是通过JVM的垃圾回收来释放的,而是通过unsafe.freeMemory来手动释放:

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
public class Test9 {
static int _1Gb = 1024 * 1024 * 1024;
public static void main(String[] args) throws IOException {
Unsafe unsafe = getUnsafe();
// 分配内存
long base = unsafe.allocateMemory(_1Gb);
unsafe.setMemory(base, _1Gb, (byte) 0);
System.in.read();
// 释放内存
unsafe.freeMemory(base);

System.in.read();
}

/**
* 获取Unsafe对象的工具方法
*/
public static Unsafe getUnsafe(){
try {
Field f = sun.misc.Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
Unsafe unsafe = (Unsafe) f.get(null);
return unsafe;
} catch (NoSuchFieldException | IllegalAccessException e) {
e.printStackTrace();
}
return null;
}
}

allocateDirect方法的实现

1
2
3
public static ByteBuffer allocateDirect(int capacity) {
return new DirectByteBuffer(capacity);
}

DirectByteBuffer类

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
DirectByteBuffer(int cap) {   // package-private

super(-1, 0, cap, cap);
boolean pa = VM.isDirectMemoryPageAligned();
int ps = Bits.pageSize();
long size = Math.max(1L, (long)cap + (pa ? ps : 0));
Bits.reserveMemory(size, cap);

long base = 0;
try {
base = unsafe.allocateMemory(size); // 申请内存
} catch (OutOfMemoryError x) {
Bits.unreserveMemory(size, cap);
throw x;
}
unsafe.setMemory(base, size, (byte) 0);
if (pa && (base % ps != 0)) {
// Round up to page boundary
address = base + ps - (base & (ps - 1));
} else {
address = base;
}
cleaner = Cleaner.create(this, new Deallocator(base, size, cap)); //通过虚引用,来实现直接内存的释放,this为虚引用的实际对象
att = null;
}

这里调用了一个Cleaner的create方法,且后台线程还会对虚引用的对象监测,如果虚引用的实际对象(这里是DirectByteBuffer)被回收以后,就会调用Cleaner的clean方法,来清除直接内存中占用的内存。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public void clean() {
if (remove(this)) {
try {
this.thunk.run(); //调用run方法
} catch (final Throwable var2) {
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
if (System.err != null) {
(new Error("Cleaner terminated abnormally", var2)).printStackTrace();
}

System.exit(1);
return null;
}
});
}
}
}

对应对象的run方法:

1
2
3
4
5
6
7
8
9
public void run() {
if (address == 0) {
// Paranoia
return;
}
unsafe.freeMemory(address); //释放直接内存中占用的内存
address = 0;
Bits.unreserveMemory(size, capacity);
}

直接内存的回收机制总结

  • 使用了Unsafe类来完成直接内存的分配回收,回收需要主动调用freeMemory方法
  • ByteBuffer的实现内部使用了Cleaner(虚引用)来检测ByteBuffer。一旦ByteBuffer被垃圾回收,那么会由ReferenceHandler来调用Cleaner的clean方法调用freeMemory来释放内存

3 垃圾回收

3.1 如何判断对象可以回收

3.1.1 引用计数法

弊端:循环引用时,两个对象的计数都为1,导致两个对象都无法被释放。

image-20211119100819572

3.1.2 可达性分析算法

  • JVM中的垃圾回收器通过可达性分析来探索所有存活的对象
  • 扫描堆中的对象,看能否沿着GC Root对象为起点的引用链找到该对象,如果找不到,则表示可以回收
  • 可以作为GC Root的对象
    • 虚拟机栈(栈帧中的本地变量表)中引用的对象。 
    • 方法区中类静态属性引用的对象
    • 方法区中常量引用的对象
    • 本地方法栈中JNI(即一般说的Native方法)引用的对象

3.1.3 五种引用

image-20211119101741784

强引用

只有GC Root都不引用该对象时,才会回收强引用对象

  • 如上图B、C对象都不引用A1对象时,A1对象才会被回收

软引用

当GC Root指向软引用对象时,在内存不足时,会回收软引用所引用的对象

  • 如上图如果B对象不再引用A2对象且内存不足时,软引用所引用的A2对象就会被回收

弱引用

只有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用所引用的对象

  • 如上图如果B对象不再引用A3对象,则A3对象会被回收

弱引用的使用和软引用类似,只是将 SoftReference 换为了 WeakReference

虚引用

当虚引用对象所引用的对象被回收以后,虚引用对象就会被放入引用队列中,调用虚引用的方法

  • 虚引用的一个体现是释放直接内存所分配的内存,当引用的对象ByteBuffer被垃圾回收以后,虚引用对象Cleaner就会被放入引用队列中,然后调用Cleaner的clean方法来释放直接内存
  • 如上图,B对象不再引用ByteBuffer对象,ByteBuffer就会被回收。但是直接内存中的内存还未被回收。这时需要将虚引用对象Cleaner放入引用队列中,然后调用它的clean方法来释放直接内存

终结器引用

所有的类都继承自Object类,Object类有一个finalize方法。当某个对象不再被其他的对象所引用时,会先将终结器引用对象放入引用队列中,然后根据终结器引用对象找到它所引用的对象,然后调用该对象的finalize方法。调用以后,该对象就可以被垃圾回收了。

  • 如上图,B对象不再引用A4对象。这时终结器对象就会被放入引用队列中,引用队列会根据它,找到它所引用的对象。然后调用被引用对象的finalize方法。调用以后,该对象就可以被垃圾回收了

引用队列

  • 软引用和弱引用可以配合引用队列
    • 弱引用虚引用所引用的对象被回收以后,会将这些引用本身放入引用队列中,方便一起回收这些软/弱引用对象(本身)
  • 虚引用和终结器引用必须配合引用队列
    • 虚引用和终结器引用在使用时会关联一个引用队列

3.1.4 软引用使用

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
/**
* -Xmx20m 设定堆内存为20m
*/
public class Test4 {
private static final int _4MB = 4 * 1024 * 1024;

public static void main(String[] args){
// 强引用,会内存泄漏
/*List<byte[]> list = new ArrayList<>();
for (int i = 0; i < 5; i++) {
list.add(new byte[_4MB]);
}
System.in.read();*/
soft();
}

/**
* 软引用
*/
public static void soft() {
// list --> SoftReference --> byte[]
List<SoftReference<byte[]>> list = new ArrayList<>();
for (int i = 0; i < 5; i++) {
SoftReference<byte[]> ref = new SoftReference<>(new byte[_4MB]);
System.out.println(ref.get());
list.add(ref);
System.out.println(list.size());
}
System.out.println("循环结束:" + list.size());
for (SoftReference<byte[]> ref :
list) {
System.out.println(ref.get());
}
}
}

执行结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
[B@3feba861
1
[B@5b480cf9
2
[B@6f496d9f
3
[B@723279cf
4
[B@10f87f48
5
循环结束:5
null
null
null
[B@723279cf
[B@10f87f48

可见有三个对象已经被回收。如果在垃圾回收时发现内存不足,在回收软引用所指向的对象时,软引用本身不会被清理

如果想要清理软引用,需要使用引用队列

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
39
40
41
42
/**
* -Xmx20m 设定堆内存为20m
*/
public class Test4 {
private static final int _4MB = 4 * 1024 * 1024;

public static void main(String[] args){
soft();
}

/**
* 软引用
*/
public static void soft() {
// list --> SoftReference --> byte[]
List<SoftReference<byte[]>> list = new ArrayList<>();
// 引用队列
ReferenceQueue<byte[]> queue = new ReferenceQueue<>();

for (int i = 0; i < 5; i++) {
// 关联了软引用对象和软引用队列
// 当软引用引用的对象被回收时,软引用对象自己就会被加入到软引用队列中
SoftReference<byte[]> ref = new SoftReference<>(new byte[_4MB], queue);
System.out.println(ref.get());
list.add(ref);
System.out.println(list.size());
}
System.out.println("循环结束:" + list.size());

// 从list中移除软引用对象
Reference<? extends byte[]> poll = queue.poll();
while (poll != null) {
list.remove(poll);
poll = queue.poll();
}
System.out.println("================================");
for (SoftReference<byte[]> ref :
list) {
System.out.println(ref.get());
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
[B@3feba861
1
[B@5b480cf9
2
[B@6f496d9f
3
[B@723279cf
4
[B@10f87f48
5
循环结束:5
================================
[B@723279cf
[B@10f87f48

大概思路为:查看引用队列中有无软引用,如果有,则将该软引用从存放它的集合中移除(这里为一个list集合)

3.1.5 弱引用使用

弱引用的使用和软引用类似,只是将 SoftReference 换为了 WeakReference

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* -Xmx20m
*/
public class Test5 {
private static final int _4MB = 4 * 1024 * 1024;

public static void main(String[] args) {
// list --> WeakReference --> byte[]
List<WeakReference<byte[]>> list = new ArrayList<>();
for (int i = 0; i < 6; i++) {
WeakReference<byte[]> ref = new WeakReference<>(new byte[_4MB]);
list.add(ref);
for (WeakReference<byte[]> w :
list) {
System.out.print(w.get() + " ");
}
System.out.println();
}
System.out.println("循环结束:" + list.size());
}
}

执行结果:

1
2
3
4
5
6
7
[B@10f87f48 
[B@10f87f48 [B@b4c966a
[B@10f87f48 [B@b4c966a [B@2f4d3709
null null null [B@4e50df2e
null null null [B@4e50df2e [B@1d81eb93
null null null [B@4e50df2e [B@1d81eb93 [B@7291c18f
循环结束:6

3.2 垃圾回收算法

3.2.1 标记清除 Mark Sweep

image-20211121141114959

定义:标记清除算法顾名思义,是指在虚拟机执行垃圾回收的过程中,先采用标记算法确定可回收对象,然后垃圾收集器根据标识清除相应的内容,给堆内存腾出相应的空间

  • 这里的腾出内存空间并不是将内存空间的字节清0,而是记录下这段内存的起始结束地址,下次分配内存的时候,会直接覆盖这段内存

缺点容易产生大量的内存碎片,可能无法满足大对象的内存分配,一旦导致无法分配对象,那就会导致jvm启动gc,一旦启动gc,我们的应用程序就会暂停,这就导致应用的响应速度变慢

3.2.2 标记整理 Mark Compact

image-20211121141600746

标记-整理 会将不被GC Root引用的对象回收,清楚其占用的内存空间。然后整理剩余的对象,可以有效避免因内存碎片而导致的问题,但是因为整体需要消耗一定的时间,所以效率较低。

3.2.3 复制 Copy

image-20211121141850513

image-20211121142035730

image-20211121142045109

将内存分为等大小的两个区域,FROM和TO(TO中为空)。先将被GC Root引用的对象从FROM放入TO中,再回收不被GC Root引用的对象。然后交换FROM和TO。这样也可以避免内存碎片的问题,但是会占用双倍的内存空间

3.3 分代回收

image-20211121142232132

3.3.1 回收流程

新创建的对象都被放在了新生代的伊甸园Eden

image-20211121142707061

当伊甸园中的内存不足时,就会进行一次垃圾回收,这时的回收叫做 Minor GC

Minor GC 会将伊甸园和幸存区FROM存活的对象复制到 幸存区 TO中(复制算法), 并让其寿命加1,再交换两个幸存区

image-20211121142752734

image-20211121142808651

image-20211121142825141

再次创建对象,若新生代的伊甸园又满了,则会再次触发 Minor GC(会触发 stop the world, 暂停其他用户线程,只让垃圾回收线程工作),这时不仅会回收伊甸园中的垃圾,还会回收幸存区中的垃圾,再将活跃对象复制到幸存区TO中。回收以后会交换两个幸存区,并让幸存区中的对象寿命加1

image-20211121143047194

如果幸存区中的对象的寿命超过某个阈值(最大为15,4bit),就会被放入老年代

image-20211121143106391

如果新生代老年代中的内存都满了,就会先触发Minor GC,再触发Full GC,扫描新生代和老年代中所有不再使用的对象并回收。

3.3.2 GC分析

相关VM参数

image-20211121144023027

代码示例

1
2
3
4
5
6
7
8
9
10
public class Test1 {
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;
public static void main(String[] args) {
// -Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc
}
}

打印信息:

image-20211121145147845

  1. 512KB
1
2
3
4
5
6
7
8
9
10
11
12
13
public class Test1 {
private static final int _512KB = 512 * 1024;
private static final int _1MB = 1024 * 1024;
private static final int _5MB = 5 * 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[_512KB]);
}
}

image-20211121151006177

  1. 5MB

image-20211121151244495

  1. 6MB

image-20211121151311155

  1. 8MB:大对象直接晋升至老年代

image-20211121151358463

  • 大对象处理策略

当遇到一个较大的对象时,就算新生代的伊甸园为空,也无法容纳该对象时,会将该对象直接晋升为老年代

  • 线程内存溢出

某个线程的内存溢出了而抛异常(out of memory),不会让其他的线程结束运行。

这是因为当一个线程抛出OOM异常后它所占据的内存资源会全部被释放掉,从而不会影响其他线程的运行,进程依然正常

3.4 垃圾回收器

3.4.1 相关概念

并行收集:指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态

并发收集:指用户线程与垃圾收集线程同时工作(不一定是并行的可能会交替执行)。用户程序在继续运行,而垃圾收集程序运行在另一个CPU上

吞吐量:即CPU用于运行用户代码的时间与CPU总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 )),也就是。例如:虚拟机共运行100分钟,垃圾收集器花掉1分钟,那么吞吐量就是99%

3.4.2 串行

  • 单线程
  • 内存较小,个人电脑(CPU核数较少)

image-20211122093725197

1
-XX:+UseSerialGC = Serial + SerialOld

安全点:让其他线程都在这个点停下来,以免垃圾回收时移动对象地址,使得其他线程找不到被移动的对象。

因为是串行的,所以只有一个垃圾回收线程。且在该线程执行回收工作时,其他线程进入阻塞状态。

Serial 收集器

Serial收集器是最基本的、发展历史最悠久的收集器

特点:单线程、简单高效(与其他收集器的单线程相比),采用复制算法。对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程手机效率。收集器进行垃圾回收时,必须暂停其他所有的工作线程,直到它结束(Stop The World)

ParNew 收集器

ParNew收集器其实就是Serial收集器的多线程版本

特点:多线程、ParNew收集器默认开启的收集线程数与CPU的数量相同,在CPU非常多的环境中,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。和Serial收集器一样存在Stop The World问题

Serial Old 收集器

Serial Old是Serial收集器的老年代版本

特点:同样是单线程收集器,采用标记-整理算法

3.4.3 吞吐量优先

  • 多线程
  • 堆内存较大,多核CPU
  • 单位时间内,STW(stop the world,停掉其他所有工作线程)时间最短
  • JDK1.8默认使用的垃圾回收器

image-20211122094110381

Parallel Scavenge 收集器——并行收集器

与吞吐量关系密切,故也称为吞吐量优先收集器

特点:属于新生代收集器也是采用复制算法的收集器(用到了新生代的幸存区),又是并行的多线程收集器(与ParNew收集器类似)

该收集器的目标是达到一个可控制的吞吐量。还有一个值得关注的点是:GC自适应调节策略(与ParNew收集器最重要的一个区别)

GC自适应调节策略:Parallel Scavenge收集器可设置-XX:+UseAdptiveSizePolicy参数。当开关打开时不需要手动指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRation)、晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等,虚拟机会根据系统的运行状况收集性能监控信息,动态设置这些参数以提供最优的停顿时间和最高的吞吐量,这种调节方式称为GC的自适应调节策略。

Parallel Scavenge收集器使用两个参数控制吞吐量:

  • XX:MaxGCPauseMillis 控制最大的垃圾收集停顿时间
  • XX:GCRatio 直接设置吞吐量的大小

Parallel Old 收集器

是Parallel Scavenge收集器的老年代版本

特点:多线程,采用标记-整理算法(老年代没有幸存区)

3.4.4 响应时间优先

  • 多线程
  • 堆内存较大,多核CPU
  • 尽可能让单次STW时间变短(尽量不影响其他线程运行)

image-20211122094759111

CMS 收集器——并发收集器

Concurrent Mark Sweep,一种以获取最短回收停顿时间为目标的老年代收集器

特点:基于标记-清除算法实现。并发收集、低停顿,但是会产生内存碎片

应用场景:适用于注重服务的响应速度,希望系统停顿时间最短,给用户带来更好的体验等场景下。如web程序、b/s服务

CMS收集器的运行过程分为下列4步:

初始标记:标记GC Roots能直接到的对象。速度很快但是仍存在Stop The World问题

并发标记:进行GC Roots Tracing 的过程,找出存活对象且用户线程可并发执行。

重新标记:为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。仍然存在Stop The World问题

并发清除:对标记的对象进行清除回收。

CMS收集器的内存回收过程是与用户线程一起并发执行的。

3.5 G1

3.5.1 定义

全称:Garbage First

JDK 9以后默认使用,而且替代了CMS 收集器。

image-20211122102109941

适用场景

  • 同时注重吞吐量和低延迟(响应时间)
  • 超大堆内存(内存大的),会将堆内存划分为多个大小相等的区域
  • 整体上是标记-整理算法,两个区域之间是复制算法

相关参数:JDK8 并不是默认开启的,所需要参数开启

image-20211122095650464

3.5.2 垃圾回收阶段

image-20211122100018892

新生代伊甸园垃圾回收—–>内存不足,新生代回收+并发标记—–>回收新生代伊甸园、幸存区、老年代内存——>新生代伊甸园垃圾回收(重新开始)

3.5.3 Young Collection

分区算法region

分代是按对象的生命周期划分,分区则是将堆空间划分连续几个不同小区间,每一个小区间独立回收,可以控制一次回收多少个小区间,方便控制 GC 产生的停顿时间

E:伊甸园 S:幸存区 O:老年代

  • STW

image-20211122100341476

当Eden区内存不够时,会将对象内存复制到幸存区:

image-20211122100423310

S区对象年龄超过一定阈值时,会晋升至老年区,没有超过的重新复制到一块新的S区:

image-20211122100645430

3.5.4 Young Collection + CM

CM:并发标记 Concurrent Mark

  • 在 Young GC 时会对 GC Root 进行初始标记
  • 在老年代占用堆内存的比例达到阈值时,对进行并发标记(不会STW,因为是并行的),阈值可以根据用户来进行设定

image-20211122100922134

image-20211122100834689

3.5.5 Mixed Collection

会对E S O 进行全面的回收

  • 最终标记(Remark) 会stw
  • 拷贝存活(Evacuation) 会stw
1
-XX:MaxGCPauseMills:xxx  //用于指定最长的停顿时间

:为什么有的老年代被拷贝了,有的没拷贝?

因为指定了最大停顿时间,如果对所有老年代都进行回收,耗时可能过高。为了保证时间不超过设定的停顿时间,会回收最有价值的老年代(回收后,能够得到更多内存)

image-20211122101101477

3.5.6 Full GC

G1在老年代内存不足时(老年代所占内存超过阈值)

  • 如果垃圾产生速度慢于垃圾回收速度,不会触发Full GC,还是并发地进行清理
  • 如果垃圾产生速度快于垃圾回收速度,便会触发Full GC

3.5.7 Young Collection 跨代引用

  • 新生代回收的跨代引用(老年代引用新生代)问题

image-20211122101749966

  • 卡表与Remembered Set
    • Remembered Set 存在于E中,用于保存新生代对象对应的脏卡
      • 脏卡:O被划分为多个区域(一个区域512K),称为卡片(card),如果该区域引用了新生代对象,则该区域被称为脏卡,下图中红色区域为脏卡。
  • 在引用变更时通过post-write barried + dirty card queue
  • concurrent refinement threads 更新 Remembered Set

image-20211122101915080

3.5.8 Remark

重新标记阶段

在垃圾回收时,收集器处理对象的过程中

黑色:已被处理,需要保留的 灰色:正在处理中的 白色:还未处理的

image-20211122102229564

但是在并发标记过程中,有可能A被处理了以后未引用C,但该处理过程还未结束,在处理过程结束之前A引用了C,这时就会用到remark

过程如下

  • 之前C未被引用,这时A引用了C,就会给C加一个写屏障,写屏障的指令会被执行,将C放入一个队列当中,并将C变为 处理中 状态
  • 并发标记阶段结束以后,重新标记阶段会STW,然后将放在该队列中的对象重新处理,发现有强引用引用它,就会处理它

image-20211122102412228

3.5.9 JDK 8u20 字符串去重

1
2
String s1 = new String("Hello");// char[]{'H', 'e', 'l', 'l', 'o'}
String s2 = new String("Hello");// char[]{'H', 'e', 'l', 'l', 'o'}
1
-XX:+UseStringDeduplication

过程

  • 将所有新分配的字符串(底层是char[])放入一个队列
  • 当新生代回收时,G1并发检查是否有重复的字符串
  • 如果字符串的值一样,就让他们引用同一个字符串对象
  • 注意,其与String.intern的区别
    • intern关注的是字符串对象
    • 字符串去重关注的是char[]
    • 在JVM内部,使用了不同的字符串标

优点与缺点

  • 节省了大量内存
  • 新生代回收时间略微增加,导致略微多占用CPU

3.5.10 JDK 8u60 回收巨型对象

  • 一个对象大于region的一半时,就称为巨型对象
  • G1不会对巨型对象进行拷贝
  • 回收时被优先考虑
  • G1会跟踪老年代所有incoming引用,如果老年代incoming引用为0的巨型对象就可以在新生代垃圾回收时处理掉

image-20211122102950714

image-20211122103049979

3.6 GC调优

暂略

4 类加载和字节码技术

image-20211123160337132

4.1 类文件结构

1
2
3
4
5
public class Test1 {
public static void main(String[] args) {
System.out.println("Hello World!");
}
}

首先获得.class字节码文件:

1
javac -parameters -d . Test1.java

可以使用hexdump工具查看该字节码文件:

1
hexdump Test1.class

得到的Test1.class字节码文件内容如下:

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
000000  ca fe ba be 00 00 00 3b 00 1f 0a 00 02 00 03 07
000010 00 04 0c 00 05 00 06 01 00 10 6a 61 76 61 2f 6c
000020 61 6e 67 2f 4f 62 6a 65 63 74 01 00 06 3c 69 6e
000030 69 74 3e 01 00 03 28 29 56 09 00 08 00 09 07 00
000040 0a 0c 00 0b 00 0c 01 00 10 6a 61 76 61 2f 6c 61
000050 6e 67 2f 53 79 73 74 65 6d 01 00 03 6f 75 74 01
000060 00 15 4c 6a 61 76 61 2f 69 6f 2f 50 72 69 6e 74
000070 53 74 72 65 61 6d 3b 08 00 0e 01 00 0c 48 65 6c
000080 6c 6f 20 57 6f 72 6c 64 21 0a 00 10 00 11 07 00
000090 12 0c 00 13 00 14 01 00 13 6a 61 76 61 2f 69 6f
0000a0 2f 50 72 69 6e 74 53 74 72 65 61 6d 01 00 07 70
0000b0 72 69 6e 74 6c 6e 01 00 15 28 4c 6a 61 76 61 2f
0000c0 6c 61 6e 67 2f 53 74 72 69 6e 67 3b 29 56 07 00
0000d0 16 01 00 14 63 6f 6d 2f 68 6f 6e 67 79 69 2f 6a
0000e0 76 6d 2f 54 65 73 74 31 01 00 04 43 6f 64 65 01
0000f0 00 0f 4c 69 6e 65 4e 75 6d 62 65 72 54 61 62 6c
000100 65 01 00 04 6d 61 69 6e 01 00 16 28 5b 4c 6a 61
000110 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 3b 29
000120 56 01 00 10 4d 65 74 68 6f 64 50 61 72 61 6d 65
000130 74 65 72 73 01 00 04 61 72 67 73 01 00 0a 53 6f
000140 75 72 63 65 46 69 6c 65 01 00 0a 54 65 73 74 31
000150 2e 6a 61 76 61 00 21 00 15 00 02 00 00 00 00 00
000160 02 00 01 00 05 00 06 00 01 00 17 00 00 00 1d 00
000170 01 00 01 00 00 00 05 2a b7 00 01 b1 00 00 00 01
000180 00 18 00 00 00 06 00 01 00 00 00 08 00 09 00 19
000190 00 1a 00 02 00 17 00 00 00 25 00 02 00 01 00 00
0001a0 00 09 b2 00 07 12 0d b6 00 0f b1 00 00 00 01 00
0001b0 18 00 00 00 0a 00 02 00 00 00 0a 00 08 00 0b 00
0001c0 1b 00 00 00 05 01 00 1c 00 00 00 01 00 1d 00 00
0001d0 00 02 00 1e

根据 JVM 规范,类文件结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ClassFile { // u4表示占用4个字节,以此类推
u4 magic; // 魔数
u2 minor_version;
u2 major_version;
u2 constant_pool_count;
cp_info constant_pool[constant_pool_count-1];
u2 access_flags; // 访问修饰
u2 this_class;
u2 super_class;
u2 interfaces_count;
u2 interfaces[interfaces_count];
u2 fields_count;
field_info fields[fields_count];
u2 methods_count;
method_info methods[methods_count];
u2 attributes_count;
attribute_info attributes[attributes_count];
}

魔数

  • u4 magic

对应字节码文件的0~3个字节,表示它是否是【class】类型的文件

000000 ca fe ba be 00 00 00 3b 00 1d 0a 00 02 00 03 07

版本

  • u2 minor_version; 小版本

  • u2 major_version; 大版本(主版本)

表示类的版本

000000 ca fe ba be 00 00 00 3b 00 1d 0a 00 02 00 03 07

常量池

image-20211123162750617

4.2 字节码指令