写在前面

🚗本篇文章参考自以下学习资料:

Kyle’s Blog

JavaGuide

竹子爱熊猫

引言

  1. JVM,JRE,JDK之间的区别
  • JVM(Java Virtual Machine),Java虚拟机
  • JRE(Java Runtime Environment),Java运行环境,包含了JVM和Java的核心类库(Java API)
  • JDK(Java Development Kit)称为Java开发工具,包含了JRE和开发工具

image-20230827213038167


  1. 学习路线

image-20230827212904317

内存结构

程序计数器

程序计数器的作用就是记住下一条jvm指令的执行地址

  • 线程私有:每个线程都有独立的程序计数器,用于保存当前执行指令的地址 ,一旦指令执行,程序计数器将被下一条指令更新

  • 不会存在内存溢出

image-20230828100657668

虚拟机栈

定义

  1. 虚拟机栈:线程运行需要的内存空间

  2. 栈帧:每个方法运行时需要的内存

每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存

每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法

image-20230828101229662


例:方法一调用方法二,方法二调用方法三,方法三结束才能让方法二和方法一依次返回

image-20230828101315797


  1. 垃圾回收是否涉及栈内存?

不涉及,栈中的栈帧在调用完后会自动弹出栈,垃圾回收只用于堆。

  1. 栈内存分配越大越好吗?

不是越大越好,一般来说使用系统默认的就好,因为物理内存是一定的,栈内存分配多了反而线程数就少了,并发量也下降了,只是每个栈中能进行方法间的调用多了而已。

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

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

如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全

举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class MyThread extends Thread {
@Override
public void run() {
method();
}

static void method() {
int x = 0;
for (int i = 0; i < 5000; i++) {
x++;
}
System.out.println(x);
}

public static void main(String[] args) {
MyThread thread1 = new MyThread();
MyThread thread2 = new MyThread();
MyThread thread3 = new MyThread();

thread1.start();
thread2.start();
thread3.start();
}
}

这里的三个线程互不影响,x不受影响每个都是5000


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
public class Demo_01 {

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);
}

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

这里的m2和m3就是所谓的逃离方法的作用范围,因为其他线程都有可能通过方法参数或者返回值去得到方法内的局部变量,就要考虑线程安全问题了

栈内存溢出


内存溢出 (Memory Overflow)

定义: 内存溢出是指程序在运行过程中申请的内存超过了系统所能提供的最大内存限制,导致程序无法继续分配内存,最终程序崩溃的一种现象。

产生原因:

  1. 递归调用或循环调用: 如果程序中有无限递归调用或者没有正确退出条件的循环,会导致栈空间耗尽,从而引起栈溢出。
  2. 大量对象创建: 如果程序中频繁创建大量对象而没有及时释放,可能导致堆空间耗尽。
  3. 资源占用过多: 程序中如果大量使用了资源(如文件句柄、线程等),也可能导致内存溢出。
  4. 配置不当: 对于一些应用服务器(如Java虚拟机JVM),如果配置的最大堆内存过小,也会导致内存溢出。

内存泄漏 (Memory Leak)

定义: 内存泄漏是指程序在申请内存后未能释放已不再使用的内存区域,导致这部分内存无法被重复利用的现象。

产生原因:

  1. 未释放资源: 程序员忘记释放已经不再使用的内存空间。
  2. 对象引用循环: 在一些语言中(如Python),如果两个或多个对象互相引用对方,但又不再使用,如果没有正确处理,会导致垃圾回收机制无法回收这些对象。
  3. 静态集合持有引用: 如果一些静态集合保存了对象的引用,而这些对象本应被垃圾回收,这也会导致内存泄漏。
  4. 监听器或回调函数: 如果注册了一些监听器或回调函数但没有适当地取消注册,那么这些监听器或回调函数会继续占用内存。
  5. 缓存: 缓存机制如果没有适当的清理策略,也会导致内存泄漏。

简单总结一下程序运行中栈可能会出现两种错误:

  • StackOverFlowError 若栈的内存大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 错误。
  • OutOfMemoryError 如果栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。
  1. 栈帧过多导致栈内存溢出

image-20230828104644939


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Demo {
static int count = 0;

public static void main(String[] args) {
try {
method();
} catch (Throwable e) {
e.printStackTrace();
System.out.println(count);
}
}

private static void method() {
count++;
method();
}
}

出现StackOverFlowError的错误

  1. 栈帧过大导致栈内存溢出

image-20230828104711119


下面这个例子中,Emp中引入了Dept,而Dept中又引入了Emp,他们现在在循环引用,导致json解析时会出现StackOverFlow

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
public class Demo_03 {

public static void main(String[] args) throws JsonProcessingException {
Dept d = new Dept();
d.setName("Market");

Emp e1 = new Emp();
e1.setName("zhang");
e1.setDept(d);

Emp e2 = new Emp();
e2.setName("li");
e2.setDept(d);

d.setEmps(Arrays.asList(e1, e2));

// { name: 'Market', emps: [{ name:'zhang', dept:{ name:'', emps: [ {}]} },] }
ObjectMapper mapper = new ObjectMapper();
System.out.println(mapper.writeValueAsString(d));
}
}

class Emp {
private String name;
// @JsonIgnore
private Dept dept;

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public Dept getDept() {
return dept;
}

public void setDept(Dept dept) {
this.dept = dept;
}
}

class Dept {
private String name;
private List<Emp> emps;

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}

public List<Emp> getEmps() {
return emps;
}

public void setEmps(List<Emp> emps) {
this.emps = emps;
}
}

线程运行诊断⭐⭐⭐

案例1: cpu 占用过多

  1. 定位 用top定位哪个进程对cpu的占用过高

  2. ps H -eo pid,tid,%cpu | grep 进程id (用ps命令进一步定位是哪个线程引起的cpu占用过高)

  3. jstack 进程id 可以根据线程id 找到有问题的线程,进一步定位到问题代码的源码行号

案例2:程序运行很长时间没有结果(死锁)

查错能力,补充top命令到Linux中……⭐⭐⭐⭐⭐⭐⭐⭐

构成

局部变量表

一个栈帧中存放的有局部变量表和操作数栈

局部变量表(Local Variable Table)是Java虚拟机(JVM)中的一个数据结构,用于存储方法在执行过程中所使用的局部变量。每个方法在运行时都会创建一个局部变量表,用于存储该方法内部定义的局部变量,这些局部变量通常包括方法参数以及在方法内部声明的临时变量。

以下是局部变量表的主要特点和作用:

  1. 存储局部变量:局部变量表用于存储方法内部定义的局部变量,这些变量的生命周期仅限于方法的执行过程中。局部变量通常包括方法参数、临时变量和方法内部的其他局部变量。

  2. 助于方法执行:局部变量表中的局部变量存储了方法的输入和中间计算结果,这些值被用于方法的执行过程中。例如,在一个方法中,你可以声明一个局部变量来存储一个整数值,然后在方法中进行计算和操作。

  3. 类型检查:局部变量表会根据变量的声明类型进行类型检查,确保在方法中正确使用这些变量。这有助于Java编译器捕获类型错误。

  4. 提供方法调用信息:局部变量表还包含了方法调用时所需的信息,如方法的参数和返回值。这些信息有助于调用方法和返回结果。

  5. 运行时内存分配:局部变量表中的局部变量在方法的运行时被分配内存空间,以便存储数据。这些变量在方法执行结束后会被销毁,释放内存。

总之,局部变量表是Java虚拟机中用于存储方法内部局部变量的数据结构,它在方法的执行过程中起到重要作用,包括存储数据、进行类型检查、提供方法调用信息等。局部变量表的大小和内容是由编译器在编译时确定的,并在方法的执行过程中被动态使用。

操作数栈

操作数栈 主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果。另外,计算过程中产生的临时变量也会放在操作数栈中。

动态链接

动态链接 主要服务一个方法需要调用其他方法的场景。Class 文件的常量池里保存有大量的符号引用比如方法引用的符号引用。当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换为调用方法的直接引用,这个过程也被称为 动态连接

本地方法栈

  • 本地方法是指由非Java语言编写的代码,如C或C++,并被编译为本地二进制代码。
  • 本地方法栈就是本地方法的内存空间。

因为JAVA没法直接和操作系统底层交互,所以需要用到本地方法栈来调用本地的C或C++的方法

  • 例如Object类的源码中就有本地方法,用native关键字修饰本地方法(例如notifyAll等)
    • 本地方法只有函数声明,没有函数体,因为函数体是C或C++写的,通常是通过JNI(Java Native Interface)技术来实现的。

定义

  • Heap 堆

    • 通过 new 关键字,创建对象都会使用堆内存
  • 特点

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

堆内存溢出

错误:java.lang.OutOfMemoryError:Java heap space

垃圾回收会回收不用的对象,但是一直使用的就不会回收,所以还是会有堆内存溢出的问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import java.util.ArrayList;

/**
* 演示堆内存溢出:java.lang.OutOfMemoryError: Java heap space
*/
public class Demo_04 {
public static void main(String[] args) {
int i = 0;
try {
ArrayList<String> list = new ArrayList<>(); //Hello, HelloHello, HelloHelloHelloHello ···
String a = "Hello";
while (true) {
list.add(a);
a = a + a; // HelloHelloHelloHello
i++;
}
} catch (Throwable e) {
e.printStackTrace();
System.out.println(i);
}
}
}

堆内存诊断

  1. jps工具

    • 查看当前系统中有哪些Java进程
  2. jmap工具

    • 查看堆内存占用情况

      1
      jmap -heap 进程id # 进程id就是jps查出来的进程
  3. jconsole工具

    • 图形化界面的多功能监测工具,可以连续监测
  4. jvisualvm工具

补充

在堆内存的定义下,新生代和老年代是什么意思?

在Java等编程语言中,堆内存(Heap Memory)是用于存储对象实例的一块内存区域。在堆内存中,对象实例被动态地分配和回收,这使得堆内存成为垃圾回收的主要场所。

在堆内存中,一般会根据对象的生命周期将其分为不同的区域,其中最常见的划分是“新生代”(Young Generation)和“老年代”(Old Generation),这有助于进行更有效的垃圾回收。以下是它们的解释:

  1. 新生代(Young Generation):
    新生代是堆内存的一部分,用于存储新创建的对象。由于大部分对象在创建后很快就变得不再使用,因此将它们放在新生代中。新生代又分为三个区域:Eden区域和两个Survivor区域(通常称为From区和To区)。

    • Eden区域:这是对象最初被创建的地方。当Eden区域满了之后,将触发一次“Minor GC”(新生代垃圾回收),这时会把仍然存活的对象移到Survivor区域。
    • Survivor区域:这两个区域用来存放在Eden区域中存活下来的对象。在Minor GC后,存活的对象会从Eden区域移动到一个Survivor区域。在不断的Minor GC中,对象可能会在不同的Survivor区域之间来回移动,最终达到一定的年龄后,会被移动到老年代。
  2. 老年代(Old Generation):
    老年代用于存储长时间存活的对象,这些对象经过一定数量的Minor GC后仍然存活下来。老年代中的垃圾回收通常被称为“Major GC”或“Full GC”(全堆垃圾回收),因为它涉及整个堆内存的清理。Major GC发生的频率相对较低,因为老年代中的对象生命周期较长,所以它们不会频繁地触发垃圾回收。

通过将堆内存划分为新生代和老年代,可以针对不同生命周期的对象采用不同的垃圾回收策略,以提高系统性能和内存利用率。这种划分能够减少垃圾回收对整个应用程序性能的影响,使得垃圾回收变得更加高效。

方法区

定义

  1. 在JVM中,方法区是一块用于存储类信息、常量、静态变量、即时编译器编译后的代码(ClassLoader)等数据的内存区域,它是Java虚拟机规范中的一个概念。Java SE 7及之前版本中,方法区的实现被称为永久代,但在Java SE 8之后的版本中,永久代被废弃了,被元空间所替代。

  2. 与永久代不同的是,元空间使用的是本地内存(Native Memory),而不是虚拟机内存(堆内存),这样就避免了OutOfMemoryError错误,因为在使用本地内存时,可以动态地调整大小,而且可以使用操作系统的虚拟内存机制,使得Java应用程序不会被限制在固定的内存大小中。

组成

1.6以前


1.8以后

方法区内存溢出

  1. 永久代内存溢出
1
java.lang.OutOfMemoryError: PermGen space
  1. 元空间内存溢出
1
java.lang.OutOfMemoryError: Metaspace

运行时常量池

常量池就是一行表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息

  • 常量池是 *.class 文件中的Constant pool中的内容,存在二进制字节码文件中
  • 而运行时常量池是当该类被加载时,将常量池信息放入运行时常量池,并把里面的符号地址(#2、#3)变为内存地址

符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。

·直接引用(Direct References)直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机
的内存中存在。

StringTable

JDK 1.7 为什么要将字符串常量池移动到堆中?

主要是因为永久代(方法区实现)的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。

StringTable类似于是一个HashTable

在下面这段Demo_08代码中,进行编译后的类的信息在常量池中,当该类被加载时,信息就被加载到了运行时常量池,此时的abab都没变为Java的字符串对象,当运行到该语句时才会转换。

而对于第7行代码而言,实质上是做了下面的操作

1
new StringBuilder().append("a").append("b").toSting()

而在StringBuilder中的toString方法中是创建了一个新的字符串对象,所以,s3是在常量池中的,而s4是在堆中的,所以这里打印为false

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

编译期优化

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

上面这段代码与最初的代码相比,就是用常量代替了变量去拼接,在编译期间,jvm认为常量不会再变化,所以在编译时就完成了拼接,这里的拼接是在StringTable串池中去寻找,找到了ab的字符串,就不会重新创建一个ab字符串了,所以第5行和第6行在底层存入的都是串池中的ab对象,故这里会打印True

字符串延迟加载

常量池中的字符串仅是符号,第一次用到时才变为对象

intern方法

可以使用intern方法,主动将串池中还没有的字符串对象放入串池

1.8中,将这个字符串对象尝试放入串池

  • 如果串池中已有,则不会放入
  • 如果串池中没有,则放入串池,并将串池中的结果返回
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
public class Demo_10 {

public static void main(String[] args) {
String s1 = "a"; // 常量池:["a"]
String s2 = "b"; // 常量池:["a", "b"]
String s3 = "a" + "b"; // 常量池:["a", "b", "ab"]
String s4 = s1 + s2; // 堆:new String("ab")
String s5 = "ab"; // s5引用常量池中已有的对象
String s6 = s4.intern(); // 常量池中已有"ab",将常量池中的"ab"的引用返回,s6引用常量池中已有的对象

System.out.println(s3 == s4); // s3在常量池,s4在堆,false
System.out.println(s3 == s5); // s3在常量池,s5在常量池,true
System.out.println(s3 == s6); // s3在常量池,s6在常量池,true

String str1 = "cd"; // 常量池:["cd"]
String str2 = new String("c") + new String("d"); // 堆:new String("cd")
str2.intern(); // 常量池中已有"cd",放入失败
System.out.println(str1 == str2); // str1在常量池,str2在堆,false

String str4 = new String("e") + new String("f"); // 堆:new String("ef")
str4.intern(); // 常量池中没有"ef",放入成功,并返回常量池"ef"的引用
String str3 = "ef"; // 常量池:["ef"]
System.out.println(str3 == str4); // str4是常量池的引用,str3也是常量池的引用,true
}
}

1.6 中 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有会把此对象复制一份,
放入串池,会把串池中的对象返回

也就是调用intern方法的对象并不是存入串池中的对象,而是复制出来的一个新的对象

例如s.intern(),在1.8中如果串池中没有该元素则放入,此时s也变成常量池中的对象,而在1.6中,s还是堆对象

StringTable 位置

JDK 1.6 中,字符串常量池(也就是 StringTable)是位于永久代中的。而在 JDK 1.8 中,永久代已经被移除,取而代之的是元空间(Metaspace),而字符串常量池也随之移动到了中。这意味着在 JDK 1.8 中,字符串常量池中的字符串也可以被垃圾回收器回收,而在 JDK 1.6 中则不行。

原因:因为字符串是很常用的,如果在永久代中保存是要等到老年代的时候才会去回收,不符合常用的特性。

  • 字符串常量池的大小是有限的,如果大量字符串被创建,永久代可能会出现内存溢出。
  • 字符串常量池中的字符串对象难以被垃圾回收,即使它们不再被引用,也不会被回收,容易导致永久代的内存泄漏。

StringTable垃圾回收

在 Java 8 及更高版本中,字符串常量池位于堆中,而堆是 JVM 中的一部分,因此字符串常量池中的字符串可以被垃圾回收器回收。具体来说,只有当字符串没有被任何对象引用时,它才能被垃圾回收。当字符串被回收时,它的存储空间将被释放并可以被重新利用。

StringTable 性能调优

  1. 调整 -XX:StringTableSize=桶个数

Stringtable类似于hashtable,这里的桶的个数指的就是存放链表的数组的个数,适当增大能有效避免哈希碰撞,能极大地提高效率

  1. 考虑将字符串对象是否入池

重复的字符串过多时考虑入池

直接内存

定义

  • 常见于 NIO 操作时,用于数据缓冲区
  • 分配回收成本较高,但读写性能高
  • 不受 JVM 内存回收管理
  • 直接内存也会导致内存溢出

传统IO,将文件读取到系统缓冲区中,但是Java代码不能直接读取系统缓冲区,所以需要在堆内存中分配一块Java缓冲区,将数据从系统缓冲区读取到Java缓冲区后,才能进行写操作

image-20230919223640091

直接内存的Direct Memory对Java堆内存和系统内存是共享的一块内存区,那么磁盘文件就可以直接读取到Direct Memory,而Java堆内存也可以直接访问Direct Memory

image-20230919223613085

减少了不必要的数据复制,从而提高了效率

分配和回收原理

对于直接内存需要使用Unsafe对象完成直接内存的分配回收,并且回收需要主动调用freeMemory方法

回收方法freeMemory

  • 其实释放的方法是在Deallocator()这个回调方法中
  • 而它是由Cleaner调用的, Cleaner(虚引用类型)是用来监测ByteBuffer对象的,一旦ByteBuffer对象被垃圾回收,那么就会由ReferenceHandler线程通过Cleanerclean方法调用freeMemory来释放直接内存
  • ByteBuffer 的实现类内部,使用了 Cleaner (虚引用)来监测 ByteBuffer 对象,一旦 ByteBuffer 对象被垃圾回收,那么就会由 ReferenceHandler 线程通过 Cleaner 的 clean 方法调 用 freeMemory 来释放直接内存

禁用垃圾回收对直接内存的影响

  • 由于垃圾回收是一个相对昂贵的操作,需要消耗CPU时间和系统资源。频繁调用System.gc()可能会导致性能下降,并且在某些情况下可能会造成应用程序的不稳定性。
  • 所以为了避免有些程序员老是手动调用垃圾回收,我们一般会进制显式手动垃圾回收,添加VM参数-XX:+DisableExplicitGC禁用显式的垃圾回收

🌈补充:对象

Java 对象的创建过程

文章取自JavaGuide

Step1:类加载检查

虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

Step2:分配内存

类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式“指针碰撞”“空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定

内存分配的两种方式 (补充内容,需要掌握):

  • 指针碰撞:
    • 适用场合:堆内存规整(即没有内存碎片)的情况下。
    • 原理:用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界指针,只需要向着没用过的内存方向将该指针移动对象内存大小位置即可。
    • 使用该分配方式的 GC 收集器:Serial, ParNew
  • 空闲列表:
    • 适用场合:堆内存不规整的情况下。
    • 原理:虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块儿足够大的内存块儿来划分给对象实例,最后更新列表记录。
    • 使用该分配方式的 GC 收集器:CMS

选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是”标记-清除”,还是”标记-整理”(也称作”标记-压缩”),值得注意的是,复制算法内存也是规整的。

Step3:初始化零值

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

Step4:设置对象头

初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。

Step5:执行 init 方法

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init> 方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

对象的访问定位的两种方式

文章取自JavaGuide

建立对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有:使用句柄直接指针

句柄

如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。

image-20231023095835562

直接指针

如果使用直接指针访问,reference 中存储的直接就是对象的地址。

image-20231023095911060

这两种对象访问方式各有优势。使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。

HotSpot 虚拟机主要使用的就是这种方式来进行对象访问。

垃圾回收✏️

文章参考自:https://cyborg2077.github.io/2023/04/01/JvmPart3/

  • 内存泄露:申请使用完的内存没有释放,导致虚拟机不能再次使用该内存,此时这段内存就泄露了,因为申请者不用了,而又不能被虚拟机分配给别人用。

  • 内存溢出:申请的内存超出了JVM能提供的内存大小,此时称之为溢出。

如何判断一个常量是废弃常量

假如在字符串常量池中存在字符串 “abc”,如果当前没有任何 String 对象引用该字符串常量的话,就说明常量 “abc” 就是废弃常量,如果这时发生内存回收的话而且有必要的话,”abc” 就会被系统清理出常量池了。

如何判断一个类是无用的类

方法区主要回收的是无用的类,那么如何判断一个类是无用的类的呢?

判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面 3 个条件才能算是 “无用的类”

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。

如何判断对象可以回收

引用计数法

当一个对象被引用是,就当引用对象的值+1,当引用对象的值为0时,则说明该对象没有被引用,那么就可以被垃圾回收器回收

这个引用计数法听起来很不错,而且实现起来也非常的简单,可是它有一个弊端,如下图所示,当两个对象循环引用时,两个对象的计数都未1,就导致这两个对象都无法被释放

引用计数法弊端

可达性分析算法

文章参考自:https://blog.csdn.net/qq_32099833/article/details/109253339

  • JVM垃圾回收机制的可达性分析算法,是一种基于引用的垃圾回收算法。其基本思想是通过一系列被称为”GC Roots”的根对象作为起点,寻找所有被根对象直接或间接引用的对象,将这些对象称为”可达对象”,而没有被找到的对象则被视为”不可达对象”,需要被回收。

image-20230920114845138

  • 可达性分析算法的主要优点是可以处理复杂的引用结构,例如循环引用、交叉引用等情况,能够识别出所有可达对象,从而准确地进行垃圾回收。但是,它也有一些缺点,例如需要耗费较多的时间进行垃圾回收、可能会出现漏标和误标等问题。为了解决这些问题,JVM中还采用了其他的垃圾回收算法,如标记-清除算法、复制算法、标记-整理算法等,以提高垃圾回收的效率和准确性。

  • 在JVM中,有几种类型的GC Roots对象:

    1. 虚拟机栈中引用的对象:虚拟机栈是用于存储方法调用和执行的栈空间。当一个方法被调用时,会在栈中创建一个栈帧,用于存储该方法的局部变量、参数和返回值等信息。如果栈帧中包含对某个对象的引用,那么这个对象就被视为GC Roots对象。
    2. 方法区中类静态属性引用的对象:方法区是用于存储类信息、常量池、静态变量等信息的内存区域。当一个类被加载到方法区时,其中的静态属性会被分配在方法区中,如果这些静态属性中包含对某个对象的引用,那么这个对象就被视为GC Roots对象。
    3. 方法区中常量引用的对象:常量池是方法区的一部分,用于存储常量。如果常量池中包含对某个对象的引用,那么这个对象就被视为GC Roots对象。
    4. 本地方法栈中JNI引用的对象:JNI是Java Native Interface的缩写,用于在Java程序中调用本地方法(即由C或C++等语言编写的方法)。当本地方法被调用时,会在本地方法栈中创建一个栈帧,如果该栈帧中包含对某个对象的引用,那么这个对象就被视为GC Roots对象。
    5. 被同步锁持有的对象:被synchronized锁住的对象也是绝对不能回收的,当前有线程持有对象锁呢,GC如果回收了对象,锁不就失效了嘛。

四种引用

参考文章自:https://blog.csdn.net/l540675759/article/details/73733763

强引用

1
Object obj =new Object();

上述Object这类对象就具有强引用,属于不可回收的资源,垃圾回收器绝不会回收它。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠回收具有强引用的对象,来解决内存不足的问题。

值得注意的是:如果想中断或者回收强引用对象,可以显式地将引用赋值为null,这样的话JVM就会在合适的时间,进行垃圾回收。

软引用(SoftReference)

如果一个对象只具有软引用,那么它的性质属于可有可无的那种。如果此时内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。

软引用可以和引用队列一起使用。

当不再有强引用引用该对象,此时只有软引用引用该对象时,该对象会在内存不足且垃圾回收的情况下被回收。

image-20230920121439505

而软引用本身也是个被强引用所引用的对象

image-20230920121310033


代码示例:

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
import java.io.IOException;
import java.lang.ref.SoftReference;
import java.util.ArrayList;
import java.util.List;

/**
* 演示软引用
* -Xmx20m -XX:+PrintGCDetails -verbose:gc
*/
public class Demo_18 {

private static final int _4MB = 4 * 1024 * 1024;


public static void main(String[] args) throws IOException {
/* List<byte[]> list = new ArrayList<>();
for (int i = 0; i < 15; i++) {
list.add(new byte[_4MB]);
}*/
soft();
}

public static void soft() {
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());
}
}
}

弱引用(WeakReference)

如果一个对象具有弱引用,那其的性质也是可有可无的状态。

而弱引用和软引用的区别在于:弱引用的对象拥有更短的生命周期,只要垃圾回收器扫描到它,不管内存空间充足与否,都会回收它的内存。

弱引用也可以和引用队列一起使用。

虚引用(PhantomReference)

虚引用和前面的软引用、弱引用不同,它并不影响对象的生命周期。如果一个对象与虚引用关联,则跟没有引用与之关联一样,在任何时候都可能被垃圾回收器回收。

注意:虚引用必须和引用队列关联使用,当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会把这个虚引用加入到与之关联的引用队列中。

程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。

引用总结

image-20230920113244283


image-20230920114442159

垃圾回收算法

标记清除(Mark Sweep)

  • 速度较快
  • 会产生内存碎片

标记清除

标记整理(Mark Compact)

  • 速度慢
  • 没有内存碎片

标记整理

复制(Copy)

  • 没有内存碎片
  • 需要占用双倍内存空间

复制

分代垃圾回收

分代垃圾回收

  • 对象首先分配在伊甸园区域
  • 新生代空间不足时,触发 minor gc,伊甸园和 from 存活的对象使用 copy 复制到 to 中,存活的对象年龄加 1并且交换 from to
  • minor gc 会引发 stop the world,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行
  • 当对象寿命超过阈值时,会晋升至老年代,最大寿命是15(4bit)
  • 当老年代空间不足,会先尝试触发 minor gc,如果之后空间仍不足,那么触发 full gc,STW( stop the world)的时间更长

补充

  • 当一次申请的内存大过伊甸园,大过幸存区时,且老年代有足够内存空间时,可以直接放入老年代

  • 一个线程内出现了内存溢出时不会影响别的进程的执行

GC分类

针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:

部分收集 (Partial GC):

  • 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
  • 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
  • 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。

整堆收集 (Full GC):收集整个 Java 堆和方法区。

垃圾回收器

文章参考自:https://cloud.tencent.com/developer/article/1592943

如果两个收集器之间存在连线,就说明它们可以搭配使用。

image-20230925224500405

新生代收集器

Serial

  • 单线程,简单高效
  • 堆内存较小,适合个人电脑
  • 在大型应用程序中可能会出现停顿时间过长的问题

Serial新生代:复制 SerialOld老年代:标记整理

image-20230921214603737

ParNew

ParNew收集器就是Serial收集器的多线程版本,它也是一个新生代收集器。除了使用多线程进行垃圾收集外,其余行为包括Serial收集器可用的所有控制参数、收集算法(复制算法)、Stop The World、对象分配规则、回收策略等与Serial收集器完全相同,两者共用了相当多的代码。

ParNew收集器的工作过程如下图(老年代采用Serial Old收集器):

image-20230925224749631

Parallel Scavenge

  • 多线程
  • 堆内存较大,多核 cpu
  • 让单位时间内,STW 的时间最短 0.2 0.2 = 0.4,垃圾回收时间占比最低,这样就称吞吐量高
  • JDK 1.8 默认采用的就是这种垃圾回收器
  • 不设置参数时,垃圾回收线程数默认就是cpu的个数

Parallel Scavenge收集器也是一个并行的多线程新生代收集器,它也使用复制算法。Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标是达到一个可控制的吞吐量(Throughput)。

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

image-20230921215041545

老年代收集器

Serial Old收集器

Serial Old 是 Serial收集器的老年代版本,它同样是一个单线程收集器,使用“标记-整理”(Mark-Compact)算法。

此收集器的主要意义也是在于给Client模式下的虚拟机使用。如果在Server模式下,它还有两大用途:

  • 在JDK1.5 以及之前版本(Parallel Old诞生以前)中与Parallel Scavenge收集器搭配使用。
  • 作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。

它的工作流程与Serial收集器相同

Parallel Old

Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。


Parallel新生代:复制 ParallelOld老年代:标记整理

image-20230921215041545

响应时间优先(CMS –> Concurrent Mark Sweep)⭐⭐⭐

  • 多线程
  • 堆内存较大,多核 cpu
  • 尽可能让单次 STW 的时间最短 0.1 0.1 0.1 0.1 0.1 = 0.5

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,它非常符合那些集中在互联网站或者B/S系统的服务端上的Java应用,这些应用都非常重视服务的响应速度。从名字上(“Mark Sweep”)就可以看出它是基于“标记-清除”算法实现的。

执行流程

CMS收集器工作的整个流程分为以下4个步骤:

  • 初始标记(CMS initial mark):仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要“Stop The World”。
  • 并发标记(CMS concurrent mark):进行GC Roots Tracing的过程,在整个过程中耗时最长。
  • 重新标记(CMS remark):为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。此阶段也需要“Stop The World”。
  • 并发清除(CMS concurrent sweep)

由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作。

所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。通过下图可以比较清楚地看到CMS收集器的运作步骤中并发和需要停顿的时间:

image-20230921220451317

优缺点
  1. 优点

CMS是一款优秀的收集器,它的主要优点在名字上已经体现出来了:并发收集、低停顿,因此CMS收集器也被称为并发低停顿收集器(Concurrent Low Pause Collector)。

  1. 缺点
  • 对CPU资源非常敏感 其实,面向并发设计的程序都对CPU资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。
  • CMS默认启动的回收线程数是(CPU数量+3)/4,也就是当CPU在4个以上时,并发回收时垃圾收集线程不少于25%的CPU资源,并且随着CPU数量的增加而下降。但是当CPU不足4个时(比如2个),CMS对用户程序的影响就可能变得很大,如果本来CPU负载就比较大,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然降低了50%,其实也让人无法接受。
  • 无法处理浮动垃圾(Floating Garbage) 可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。
  • 由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生。这一部分垃圾出现在标记过程之后,CMS无法再当次收集中处理掉它们,只好留待下一次GC时再清理掉。
  • 这一部分垃圾就被称为“浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。
  • 标记-清除算法导致的空间碎片 CMS是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。
  • 空间碎片过多时,将会给大对象分配带来很大麻烦,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象。

G1

G1特点

  • 并行与并发: G1 能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短“Stop The World”停顿时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。
  • 分代收集 :与其他收集器一样,分代概念在G1中依然得以保留。虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但它能够采用不同方式去处理新创建的对象和已存活一段时间、熬过多次GC的旧对象来获取更好的收集效果。
  • 空间整合 :G1从整体来看是基于“标记-整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的。这意味着G1运行期间不会产生内存空间碎片,收集后能提供规整的可用内存。此特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。
  • 可预测的停顿: 这是G1相对CMS的一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了降低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在GC上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。

横跨整个堆内存

在G1之前的其他收集器进行收集的范围都是整个新生代或者老生代,而G1不再是这样。

G1在使用时,Java堆的内存布局与其他收集器有很大区别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,而都是一部分Region(不需要连续)的集合。

建立可预测的时间模型

G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也就是Garbage-First名称的来由)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。

避免全堆扫描——Remembered Set

G1把Java堆分为多个Region,就是“化整为零”。但是Region不可能是孤立的,一个对象分配在某个Region中,可以与整个Java堆任意的对象发生引用关系。在做可达性分析确定对象是否存活的时候,需要扫描整个Java堆才能保证准确性,这显然是对GC效率的极大伤害。

为了避免全堆扫描的发生,虚拟机为G1中每个Region维护了一个与之对应的Remembered Set。虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作。

检查Reference引用的对象是否处于不同的Region之中(在分代的例子中就是检查是否老年代中的对象引用了新生代中的对象),如果是,便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set之中。当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描也不会有遗漏。

G1运行流程

  • 初始标记: 仅仅只是标记一下GC Roots 能直接关联到的对象,并且修改TAMS(Nest Top Mark Start)的值,让下一阶段用户程序并发运行时,能正确的在Region中创建对象,此阶段需要停顿线程,但耗时很短。
  • 并发标记: 从GC Root 开始对堆中对象进行可达性分析,找到存活对象,此阶段耗时较长,但可与用户程序并发执行
  • 最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并行执行
  • 筛选回收: 首先对各个Region中的回收价值和成本进行排序,根据用户所期望的GC 停顿是时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。

image-20230925225550414

G1和CMS比较

CMS(Concurrent Mark-Sweep)和G1(Garbage-First)是两种不同的Java垃圾回收器,它们在垃圾回收的工作流程和性能特点上有一些重要区别:

  1. 工作流程:
    • CMS:CMS回收器的工作流程包括初始标记、并发标记、重新标记和并发清除四个阶段。初始标记和重新标记需要”Stop The World”,而并发标记和并发清除可以与应用程序并行执行。它以尽量减少停顿时间为目标,但在初始标记和重新标记阶段仍然需要较长的停顿时间。
    • G1:G1回收器的工作流程包括初始标记、并发标记、最终标记和筛选回收四个阶段。初始标记和最终标记需要停顿线程,但耗时短暂。并发标记和筛选回收可以与应用程序并行执行。G1回收器以”Garbage-First”为目标,尝试在满足用户指定的停顿时间目标的情况下,尽量回收垃圾对象。
  2. 垃圾回收方式:
    • CMS:CMS回收器使用标记-清除算法。它首先标记存活对象,然后清除未标记的对象。这会导致内存碎片问题,可能需要更频繁的Full GC来解决。
    • G1:G1回收器使用分代垃圾回收,将堆内存划分为多个区域(Region),可以有选择性地回收这些区域。G1回收器会尽量避免内存碎片问题,因为它可以进行区域之间的内存拷贝来压缩碎片。
  3. 垃圾回收停顿时间:
    • CMS:CMS回收器的停顿时间相对较短,但在初始标记和重新标记阶段仍然需要较长的停顿时间,这可能在某些情况下对响应时间敏感的应用程序造成问题。
    • G1:G1回收器的停顿时间可控,用户可以指定目标停顿时间。这使得G1更适合需要可预测停顿时间的应用程序,可以更好地满足实时性要求。

总的来说,CMS和G1回收器都是为了减少垃圾回收引起的停顿时间而设计的,但它们的工作方式和性能特点有所不同。G1在内存碎片处理和停顿时间可控性方面提供了更好的解决方案,而CMS则在某些情况下可能更适合特定的应用程序。选择哪个回收器应根据具体应用程序的需求和性能特点来决定。

垃圾回收调优

看不懂,后续补充……….

类加载与字节码技术✏️

类文件结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ClassFile {
u4 magic; // 魔数,用于标识文件类型
u2 minor_version; // Java虚拟机的次版本号
u2 major_version; // Java虚拟机的主版本号
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]; // 类或接口的附加属性信息数组
}

一段java代码:

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

进行编译后得到的二进制字节码文件:

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
0000000 ca fe ba be 00 00 00 34 00 23 0a 00 06 00 15 09
0000020 00 16 00 17 08 00 18 0a 00 19 00 1a 07 00 1b 07
0000040 00 1c 01 00 06 3c 69 6e 69 74 3e 01 00 03 28 29
0000060 56 01 00 04 43 6f 64 65 01 00 0f 4c 69 6e 65 4e
0000100 75 6d 62 65 72 54 61 62 6c 65 01 00 12 4c 6f 63
0000120 61 6c 56 61 72 69 61 62 6c 65 54 61 62 6c 65 01
0000140 00 04 74 68 69 73 01 00 1d 4c 63 6e 2f 69 74 63
0000160 61 73 74 2f 6a 76 6d 2f 74 35 2f 48 65 6c 6c 6f
0000200 57 6f 72 6c 64 3b 01 00 04 6d 61 69 6e 01 00 16
0000220 28 5b 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72
0000240 69 6e 67 3b 29 56 01 00 04 61 72 67 73 01 00 13
0000260 5b 4c 6a 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69
0000300 6e 67 3b 01 00 10 4d 65 74 68 6f 64 50 61 72 61
0000320 6d 65 74 65 72 73 01 00 0a 53 6f 75 72 63 65 46
0000340 69 6c 65 01 00 0f 48 65 6c 6c 6f 57 6f 72 6c 64
0000360 2e 6a 61 76 61 0c 00 07 00 08 07 00 1d 0c 00 1e
0000400 00 1f 01 00 0b 68 65 6c 6c 6f 20 77 6f 72 6c 64
0000420 07 00 20 0c 00 21 00 22 01 00 1b 63 6e 2f 69 74
0000440 63 61 73 74 2f 6a 76 6d 2f 74 35 2f 48 65 6c 6c
0000460 6f 57 6f 72 6c 64 01 00 10 6a 61 76 61 2f 6c 61
0000500 6e 67 2f 4f 62 6a 65 63 74 01 00 10 6a 61 76 61
0000520 2f 6c 61 6e 67 2f 53 79 73 74 65 6d 01 00 03 6f
0000540 75 74 01 00 15 4c 6a 61 76 61 2f 69 6f 2f 50 72
0000560 69 6e 74 53 74 72 65 61 6d 3b 01 00 13 6a 61 76
0000600 61 2f 69 6f 2f 50 72 69 6e 74 53 74 72 65 61 6d
0000620 01 00 07 70 72 69 6e 74 6c 6e 01 00 15 28 4c 6a
0000640 61 76 61 2f 6c 61 6e 67 2f 53 74 72 69 6e 67 3b
0000660 29 56 00 21 00 05 00 06 00 00 00 00 00 02 00 01
0000700 00 07 00 08 00 01 00 09 00 00 00 2f 00 01 00 01
0000720 00 00 00 05 2a b7 00 01 b1 00 00 00 02 00 0a 00
0000740 00 00 06 00 01 00 00 00 04 00 0b 00 00 00 0c 00
0000760 01 00 00 00 05 00 0c 00 0d 00 00 00 09 00 0e 00
0001000 0f 00 02 00 09 00 00 00 37 00 02 00 01 00 00 00
0001020 09 b2 00 02 12 03 b6 00 04 b1 00 00 00 02 00 0a
0001040 00 00 00 0a 00 02 00 00 00 06 00 08 00 07 00 0b
0001060 00 00 00 0c 00 01 00 00 00 09 00 10 00 11 00 00
0001100 00 12 00 00 00 05 01 00 10 00 00 00 01 00 13 00
0001120 00 00 02 00 14

字节码指令

入门

在上面这个helloworld中有两个方法,对应着两个字节码指令

  • 一个是public cn.itcast.jvm.t5.HelloWorld();构造方法的字节码指令

    1
    2a b7 00 01 b1
    1. 2a => aload_0 加载 slot 0 的局部变量,即 this,做为下面的 invokespecial 构造方法调用的参数
    2. b7 => invokespecial 预备调用构造方法,哪个方法呢?
    3. 00 01 引用常量池中 #1 项,即Method java/lang/Object."<init>":()V
    4. b1 表示返回
  • 另一个是public static void main(java.lang.String[]);主方法的字节码指令

    1
    b2 00 02 12 03 b6 00 04 b1
    1. b2 => getstatic 用来加载静态变量,哪个静态变量呢?
    2. 00 02 引用常量池中 #2 项,即Field java/lang/System.out:Ljava/io/PrintStream;
    3. 12 => ldc 加载参数,哪个参数呢?
    4. 03 引用常量池中 #3 项,即 String hello world
    5. b6 => invokevirtual 预备调用成员方法,哪个方法呢?
    6. 00 04 引用常量池中 #4 项,即Method java/io/PrintStream.println:(Ljava/lang/String;)V
    7. b1 表示返回

javap工具

可以使用javap工具来反编译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
$ javap -v HelloWorld.class
Classfile /D:/Workspace/JVM/demo/target/classes/com/demo/HelloWorld.class
Last modified 2023-4-5; size 551 bytes
MD5 checksum 1389d939c65ba536eb81d1a5c61d99be
Compiled from "HelloWorld.java"
public class com.demo.HelloWorld
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #6.#20 // java/lang/Object."<init>":()V
#2 = Fieldref #21.#22 // java/lang/System.out:Ljava/io/PrintStream;
#3 = String #23 // hello world
#4 = Methodref #24.#25 // java/io/PrintStream.println:(Ljava/lang/String;)V
#5 = Class #26 // com/demo/HelloWorld
#6 = Class #27 // java/lang/Object
#7 = Utf8 <init>
#8 = Utf8 ()V
#9 = Utf8 Code
#10 = Utf8 LineNumberTable
#11 = Utf8 LocalVariableTable
#12 = Utf8 this
#13 = Utf8 Lcom/demo/HelloWorld;
#14 = Utf8 main
#15 = Utf8 ([Ljava/lang/String;)V
#16 = Utf8 args
#17 = Utf8 [Ljava/lang/String;
#18 = Utf8 SourceFile
#19 = Utf8 HelloWorld.java
#20 = NameAndType #7:#8 // "<init>":()V
#21 = Class #28 // java/lang/System
#22 = NameAndType #29:#30 // out:Ljava/io/PrintStream;
#23 = Utf8 hello world
#24 = Class #31 // java/io/PrintStream
#25 = NameAndType #32:#33 // println:(Ljava/lang/String;)V
#26 = Utf8 com/demo/HelloWorld
#27 = Utf8 java/lang/Object
#28 = Utf8 java/lang/System
#29 = Utf8 out
#30 = Utf8 Ljava/io/PrintStream;
#31 = Utf8 java/io/PrintStream
#32 = Utf8 println
#33 = Utf8 (Ljava/lang/String;)V
{
public com.demo.HelloWorld();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
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 5: 0
line 6: 8
LocalVariableTable:
Start Length Slot Name Signature
0 9 0 args [Ljava/lang/String;
}
SourceFile: "HelloWorld.java"

图解方法执行流程

  1. 原始java代码
1
2
3
4
5
6
7
8
public class Demo3_1 {
   public static void main(String[] args) {
       int a = 10;
       int b = Short.MAX_VALUE + 1;
       int c = a + b;
       System.out.println(c);
 }
}
  1. 编译后的字节码文件
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
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
$ javap -v Demo_20.class
Classfile /D:/Workspace/JVM/demo/target/classes/com/demo/Demo_20.class
Last modified 2023-4-7; size 601 bytes
MD5 checksum 0f9e41fb2a7334a69c89d2661540f4f1
Compiled from "Demo_20.java"
public class com.demo.Demo_20
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #7.#25 // java/lang/Object."<init>":()V
#2 = Class #26 // java/lang/Short
#3 = Integer 32768
#4 = Fieldref #27.#28 // java/lang/System.out:Ljava/io/PrintStream;
#5 = Methodref #29.#30 // java/io/PrintStream.println:(I)V
#6 = Class #31 // com/demo/Demo_20
#7 = Class #32 // java/lang/Object
#8 = Utf8 <init>
#9 = Utf8 ()V
#10 = Utf8 Code
#11 = Utf8 LineNumberTable
#12 = Utf8 LocalVariableTable
#13 = Utf8 this
#14 = Utf8 Lcom/demo/Demo_20;
#15 = Utf8 main
#16 = Utf8 ([Ljava/lang/String;)V
#17 = Utf8 args
#18 = Utf8 [Ljava/lang/String;
#19 = Utf8 a
#20 = Utf8 I
#21 = Utf8 b
#22 = Utf8 c
#23 = Utf8 SourceFile
#24 = Utf8 Demo_20.java
#25 = NameAndType #8:#9 // "<init>":()V
#26 = Utf8 java/lang/Short
#27 = Class #33 // java/lang/System
#28 = NameAndType #34:#35 // out:Ljava/io/PrintStream;
#29 = Class #36 // java/io/PrintStream
#30 = NameAndType #37:#38 // println:(I)V
#31 = Utf8 com/demo/Demo_20
#32 = Utf8 java/lang/Object
#33 = Utf8 java/lang/System
#34 = Utf8 out
#35 = Utf8 Ljava/io/PrintStream;
#36 = Utf8 java/io/PrintStream
#37 = Utf8 println
#38 = Utf8 (I)V
{
public com.demo.Demo_20();
descriptor: ()V
flags: 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 6: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/demo/Demo_20;

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0: bipush 10
2: istore_1
3: ldc #3 // int 32768
5: istore_2
6: iload_1
7: iload_2
8: iadd
9: istore_3
10: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
13: iload_3
14: invokevirtual #5 // Method java/io/PrintStream.println:(I)V
17: return
LineNumberTable:
line 8: 0
line 9: 3
line 10: 6
line 11: 10
line 12: 17
LocalVariableTable:
Start Length Slot Name Signature
0 18 0 args [Ljava/lang/String;
3 15 1 a I
6 12 2 b I
10 8 3 c I
}
SourceFile: "Demo_20.java"
  1. 常量池载入运行时常量池

image-20231010162136501

  1. 方法字节码载入方法区

image-20231010162213690

  1. main 线程开始运行,分配栈帧内存

(stack=2,locals=4):操作数栈深度为2,局部变量表数量为4

image-20231010162352755

  1. 执行引擎开始执行字节码
  • bipush 10
    • 将一个 byte 压入操作数栈(其长度会补齐 4 个字节),类似的指令还有
    • sipush 将一个 short 压入操作数栈(其长度会补齐 4 个字节)
    • ldc 将一个 int 压入操作数栈
    • ldc2_w 将一个 long 压入操作数栈(分两次压入,因为 long 是 8 个字节)
    • 这里小的数字都是和字节码指令存在一起,超过 short 范围的数字存入了常量池

image-20231010163015751

  • istore_1
    • 将操作数栈顶数据弹出,存入局部变量表的 slot 1

image-20231010163105787

  • ldc #3
    • 从常量池加载 #3 数据到操作数栈
    • 注意 Short.MAX_VALUE 是 32767,所以 32768 = Short.MAX_VALUE + 1 实际是在编译期间计算
      好的

image-20231010163201925

  • istore_2

image-20231010163243864

  • iload_1

image-20231010163355144

  • iload_2

image-20231010163433368

  • iadd

image-20231010163623499

  • istore_3

image-20231010164657704

  • getstatic #4

image-20231010165829176

image-20231010165852971

  • iload_3

image-20231010170007711

  • invokevirtual #5
    • 找到常量池 #5 项
    • 定位到方法区 java/io/PrintStream.println:(I)V 方法
    • 生成新的栈帧(分配 locals、stack等)
    • 传递参数,执行新栈帧中的字节码

image-20231010170131429

  • 执行完毕,弹出栈帧

  • 清除 main 操作数栈内容

image-20231010170243175

  • return
    • 完成 main 方法调用,弹出 main 栈帧
    • 程序结束

例子:从字节码角度分析 a++

  • 源码
1
2
3
4
5
6
7
8
public class Demo3_2 {
   public static void main(String[] args) {
       int a = 10;
       int b = a++ + ++a + a--;
       System.out.println(a); //11
       System.out.println(b); //34
 }
}
  • 字节码
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
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
$ javap -v Demo_21.class
Classfile /D:/Workspace/JVM/demo/target/classes/com/demo/Demo_21.class
Last modified 2023-4-7; size 576 bytes
MD5 checksum 5bc962752b10ca4b57350ca9814ec5b0
Compiled from "Demo_21.java"
public class com.demo.Demo_21
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #5.#22 // java/lang/Object."<init>":()V
#2 = Fieldref #23.#24 // java/lang/System.out:Ljava/io/PrintStream;
#3 = Methodref #25.#26 // java/io/PrintStream.println:(I)V
#4 = Class #27 // com/demo/Demo_21
#5 = Class #28 // java/lang/Object
#6 = Utf8 <init>
#7 = Utf8 ()V
#8 = Utf8 Code
#9 = Utf8 LineNumberTable
#10 = Utf8 LocalVariableTable
#11 = Utf8 this
#12 = Utf8 Lcom/demo/Demo_21;
#13 = Utf8 main
#14 = Utf8 ([Ljava/lang/String;)V
#15 = Utf8 args
#16 = Utf8 [Ljava/lang/String;
#17 = Utf8 a
#18 = Utf8 I
#19 = Utf8 b
#20 = Utf8 SourceFile
#21 = Utf8 Demo_21.java
#22 = NameAndType #6:#7 // "<init>":()V
#23 = Class #29 // java/lang/System
#24 = NameAndType #30:#31 // out:Ljava/io/PrintStream;
#25 = Class #32 // java/io/PrintStream
#26 = NameAndType #33:#34 // println:(I)V
#27 = Utf8 com/demo/Demo_21
#28 = Utf8 java/lang/Object
#29 = Utf8 java/lang/System
#30 = Utf8 out
#31 = Utf8 Ljava/io/PrintStream;
#32 = Utf8 java/io/PrintStream
#33 = Utf8 println
#34 = Utf8 (I)V
{
public com.demo.Demo_21();
descriptor: ()V
flags: 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
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/demo/Demo_21;

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: bipush 10
2: istore_1
3: iload_1
4: iinc 1, 1
7: iinc 1, 1
10: iload_1
11: iadd
12: iload_1
13: iinc 1, -1
16: iadd
17: istore_2
18: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
21: iload_1
22: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
25: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
28: iload_2
29: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
32: return
LineNumberTable:
line 5: 0
line 6: 3
line 7: 18
line 8: 25
line 9: 32
LocalVariableTable:
Start Length Slot Name Signature
0 33 0 args [Ljava/lang/String;
3 30 1 a I
18 15 2 b I
}
SourceFile: "Demo_21.java"
  • 分析⭐⭐⭐:
    • 注意 iinc 指令是直接在局部变量 slot 上进行运算
    • a++ 和 ++a 的区别是先执行 iload 还是 先执行 iinc

条件判断指令

指令 助记符 含义
0x99 ifeq 判断是否 == 0
0x9a ifne 判断是否 != 0
0x9b iflt 判断是否 < 0
0x9c ifge 判断是否 >= 0
0x9d ifgt 判断是否 > 0
0x9e ifle 判断是否 <= 0
0x9f if_icmpeq 两个int是否 ==
0xa0 if_icmpne 两个int是否 !=
0xa1 if_icmplt 两个int是否 <
0xa2 if_icmpge 两个int是否 >=
0xa3 if_icmpgt 两个int是否 >
0xa4 if_icmple 两个int是否 <=
0xa5 if_acmpeq 两个引用是否 ==
0xa6 if_acmpne 两个引用是否 !=
0xc6 ifnull 判断是否 == null
0xc7 ifnonnull 判断是否 != null
  • 原始Java代码
1
2
3
4
5
6
7
8
9
10
public class Demo3_3 {
   public static void main(String[] args) {
       int a = 0;
       if(a == 0) {
           a = 10;
      } else {
           a = 20;
    }
 }
}
  • 字节码
1
2
3
4
5
6
7
8
9
10
0: iconst_0
1: istore_1
2: iload_1      
3: ifne         12
6: bipush       10
8: istore_1
9: goto         15
12: bipush       20
14: istore_1
15: return

循环控制指令

  • 源代码
1
2
3
4
5
6
7
8
public class Demo_23 {
public static void main(String[] args) {
int a = 0;
while (a < 10) {
a++;
}
}
}
  • 字节码
1
2
3
4
5
6
7
8
 0: iconst_0                // 将整数常量值0(int类型)压入操作数栈中。
1: istore_1 // 将栈顶数据存入局部变量表 slot 1
2: iload_1 // 将局部变量表slot 1的值压入操作数栈
3: bipush 10 // 将10压入操作数栈
5: if_icmpge 14 // 判断 i >= 10 ,成立则跳转到14行,不成立则执行下一行
8: iinc 1, 1 // i自增
11: goto 2 // 跳转到第2行
14: return

练习-判断结果

  • 源代码
1
2
3
4
5
6
7
8
9
10
11
public class Demo_26 {
public static void main(String[] args) {
int i = 0;
int x = 0;
while (i < 10) {
x = x++;
i++;
}
System.out.println(x);
}
}
  • 最终x的结果是0
    • 执行x++时,先执行iload_x,将0加载到操作数栈中
    • 然后执行iinc,将局部变量表中的x自增,此时局部变量表中的x = 1
    • 此时又执行了一个赋值操作,istore_x,将操作数栈中的0,重新赋给了局部变量表中的x,导致x为0

构造方法

<cinit>()V

  • 源代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Demo_27 {
static int i = 10;

static {
i = 20;
}

static {
i = 30;
}

public static void main(String[] args) {

}
}
  • 字节码
1
2
3
4
5
6
7
 0: bipush        10
2: putstatic #2 // Field i:I
5: bipush 20
7: putstatic #2 // Field i:I
10: bipush 30
12: putstatic #2 // Field i:I
15: return
  • 编译器会按照从上至下的顺序,收集所有的static静态代码块和静态成员赋值的代码,合并成一个特殊的方法<cinit>()V
  • <cinit>()V方法会在类加载的初始化阶段被调用

<init>()V

  • 源代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class Demo_28 {
private String a = "s1";

{
b = 20;
}

private int b = 10;

{
a = "s2";
}

public Demo_28(String a, int b) {
this.a = a;
this.b = b;
}

public static void main(String[] args) {
Demo_28 demo = new Demo_28("s3", 30);
System.out.println(demo.a);
System.out.println(demo.b);
}
}
  • 字节码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
 0: aload_0
1: invokespecial #1 // super.<init>()V
4: aload_0
5: ldc #2 // <- "s1"
7: putfield #3 // -> this.a
10: aload_0
11: bipush 20 // <- 20
13: putfield #4 // -> this.b
16: aload_0
17: bipush 10 // <- 10
19: putfield #4 // -> this.b
22: aload_0
23: ldc #5 // <- "s2"
25: putfield #3 // -> this.a
28: aload_0 // ------------------------------
29: aload_1 // <- slot 1(a) "s3" |
30: putfield #3 // -> this.a |
33: aload_0 |
34: iload_2 // <- slot 2(b) 30 |
35: putfield #4 // -> this.b --------------------
38: return
  • 编译器会按照从上至下的顺序,收集所有代码块和所有成员变量赋值的代码,形成新的构造方法,但原始构造方法内的代码总是会在最后

方法调用

  • 源代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Demo_29 {
public Demo_29(){}
private void test1(){}
private final void test2(){}
public void test3(){}
public static void test4(){}

public static void main(String[] args) {
Demo_29 demo = new Demo_29();
demo.test1();
demo.test2();
demo.test3();
demo.test4();
Demo_29.test4();
}
}
  • 字节码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
 0: new           #2                  // class com/demo/Demo_29
3: dup
4: invokespecial #3 // Method "<init>":()V
7: astore_1
8: aload_1
9: invokespecial #4 // Method test1:()V
12: aload_1
13: invokespecial #5 // Method test2:()V
16: aload_1
17: invokevirtual #6 // Method test3:()V
20: aload_1
21: pop
22: invokestatic #7 // Method test4:()V
25: invokestatic #7 // Method test4:()V
28: return
  • new 是创建【对象】,给对象分配堆内存,执行成功会将【对象引用】压入操作数栈
  • dup 是赋值操作数栈栈顶的内容,本例即为【对象引用】,为什么需要两份引用呢,一个是要配
    合 invokespecial 调用该对象的构造方法 ““:()V (会消耗掉栈顶一个引用),另一个要配合 astore_1 赋值给局部变量
  • 最终方法(final),私有方法(private),构造方法都是由 invokespecial 指令来调用,属于静
    态绑定
  • 普通成员方法是由 invokevirtual 调用,属于动态绑定,即支持多态
  • 成员方法与静态方法调用的另一个区别是,执行方法前是否需要【对象引用】
  • 比较有意思的是 d.test4();是通过【对象引用】调用一个静态方法,可以看到在调用invokestatic 之前执行了 pop 指令,把【对象引用】从操作数栈弹掉了

所以在调用静态方法的时候就不要用对象去调用了,否则会多出来20,21这两行的多余的代码

多态的原理

当执行 invokevirtual 指令时,

  1. 通过栈帧中的对象引用找到对象
    • 在多态情况下,对象引用可以指向子类对象,即父类引用指向子类实例。这意味着你可以通过父类引用调用子类对象的方法。
  2. 分析对象头,找到对象的实际 Class
    • JVM需要确定实际对象的类型,这是多态的关键。通过对象的头部信息,JVM可以确定它属于哪个类。
  3. Class 结构中有 vtable,它在类加载的链接阶段就已经根据方法的重写规则生成好了
    • vtable(虚方法表)是每个类的一部分,它包含了该类及其父类中的虚拟方法的引用。在类加载的链接阶段,JVM会构建vtable,确保正确的方法引用与类的层次结构一致。
  4. 查表得到方法的具体地址
    • 当你调用一个虚拟方法时,JVM会根据对象的实际类型,查找与方法名匹配的虚方法表中的方法引用。这确保了调用的是实际对象的版本,而不是引用类型的版本。
  5. 执行方法的字节码
    • 一旦找到了正确的方法引用,JVM将执行该方法的字节码。这是多态的最终体现,因为不同对象的不同实现将根据其实际类型而执行不同的行为。

异常处理

try-catch

  • 源代码
1
2
3
4
5
6
7
8
9
10
public class Demo3_11_1 {
   public static void main(String[] args) {
       int i = 0;
       try {
           i = 10;
      } catch (Exception e) {
           i = 20;
    }
 }
}
  • 部分关键字节码
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
public static void main(java.lang.String[]); 
   descriptor: ([Ljava/lang/String;)V
   flags: ACC_PUBLIC, ACC_STATIC
   Code:
     stack=1, locals=3, args_size=1
        0: iconst_0
        1: istore_1
        2: bipush        10
        4: istore_1
        5: goto          12
        8: astore_2
        9: bipush        20
       11: istore_1
       12: return
     Exception table:
        from    to  target type
            2     5     8   Class java/lang/Exception
     LineNumberTable: ...        
     LocalVariableTable:
       Start  Length  Slot  Name   Signature
           9       3     2     e   Ljava/lang/Exception;
           0      13     0  args   [Ljava/lang/String;
           2      11     1     i   I
     StackMapTable: ...
   MethodParameters: ...
}
  • 可以看到多出来一个 Exception table 的结构,[from, to) 是前闭后开的检测范围,一旦这个范围
    内的字节码执行出现异常,则通过 type 匹配异常类型,如果一致,进入 target 所指示行号
  • 8 行的字节码指令 astore_2 是将异常对象引用存入局部变量表的 slot 2 位置

多个 single-catch 块的情况

  • 源代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Demo3_11_2 {
   public static void main(String[] args) {
       int i = 0;
       try
因为异常出现时,只能进入 Exception table 中一个分支,所以局部变量表 slot 2 位置被共用
multi-catch 的情况
           i = 10;
      } catch (ArithmeticException e) {
           i = 30;
      } catch (NullPointerException e) {
           i = 40;
      } catch (Exception e) {
           i = 50;
    }
 }
}
  • 部分关键字节码
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 static void main(java.lang.String[]); 
   descriptor: ([Ljava/lang/String;)V
   flags: ACC_PUBLIC, ACC_STATIC
   Code:
     stack=1, locals=3, args_size=1
        0: iconst_0
        1: istore_1
        2: bipush        10
        4: istore_1
        5: goto          26
        8: astore_2
        9: bipush        30
       11: istore_1
       12: goto          26
       15: astore_2
       16: bipush        40
       18: istore_1
       19: goto          26
       22: astore_2
       23: bipush        50
       25: istore_1
       26: return
     Exception table:
        from    to  target type
            2     5     8   Class java/lang/ArithmeticException
            2     5    15   Class java/lang/NullPointerException
            2     5    22   Class java/lang/Exception
     LineNumberTable: ...
     LocalVariableTable:
       Start  Length  Slot  Name   Signature
           9       3     2     e   Ljava/lang/ArithmeticException;
          16       3     2     e   Ljava/lang/NullPointerException;
          23       3     2     e   Ljava/lang/Exception;
           0      27     0  args   [Ljava/lang/String;
           2      25     1     i   I
     StackMapTable: ...
   MethodParameters: ...

因为异常出现时,只能进入 Exception table 中一个分支,所以局部变量表 slot 2 位置被共用

multi-catch 的情况

  • 源代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Demo3_11_3 {finally
   public static void main(String[] args) {
       try {
           Method test = Demo3_11_3.class.getMethod("test");
           test.invoke(null);
      } catch (NoSuchMethodException | IllegalAccessException |
InvocationTargetException e) {
           e.printStackTrace();
    }
 }
   public static void test() {
       System.out.println("ok");
 }
}
  • 部分关键字节码
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
public static void main(java.lang.String[]); 
   descriptor: ([Ljava/lang/String;)V
   flags: ACC_PUBLIC, ACC_STATIC
   Code:
     stack=3, locals=2, args_size=1
        0: ldc           #2                  
        2: ldc           #3                  
        4: iconst_0
        5: anewarray     #4                  
        8: invokevirtual #5                  
       11: astore_1
       12: aload_1
       13: aconst_null
       14: iconst_0
       15: anewarray     #6                  
       18: invokevirtual #7                  
       21: pop
       22: goto          30
       25: astore_1
       26: aload_1
       27: invokevirtual #11 // e.printStackTrace:()V
       30: return
     Exception table:
        from    to  target type
            0    22    25   Class java/lang/NoSuchMethodException
            0    22    25   Class java/lang/IllegalAccessException
            0    22    25   Class java/lang/reflect/InvocationTargetException
     LineNumberTable: ...
     LocalVariableTable:
       Start  Length  Slot  Name   Signature
          12      10     1  test   Ljava/lang/reflect/Method;
          26       4     1     e   Ljava/lang/ReflectiveOperationException;
           0      31     0  args   [Ljava/lang/String;
     StackMapTable: ...
   MethodParameters: ...

Exception table中的target都指向一个地方

finally

  • 源代码
1
2
3
4
5
6
7
8
9
10
11
12
public class Demo3_11_4 {
   public static void main(String[] args) {
       int i = 0;
       try {
           i = 10;
      } catch (Exception e) {
           i = 20;
      } finally {
           i = 30;
    }
 }
}
  • 部分关键字节码
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 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: iconst_0
        1: istore_1     // 0 -> i
        2: bipush        10    // try --------------------------------------
        4: istore_1            // 10 -> i                                 |
        5: bipush        30    // finally                                 |
        7: istore_1            // 30 -> i                                 |
        8: goto          27    // return -----------------------------------
       11: astore_2            // catch Exceptin -> e ----------------------
       12: bipush        20    //                                         |
       14: istore_1            // 20 -> i                                 |
       15: bipush        30    // finally                                 |
       17: istore_1            // 30 -> i                                 |
       18: goto          27    // return -----------------------------------
       21: astore_3            // catch any -> slot 3 ----------------------
       22: bipush        30    // finally                                 |
       24: istore_1            // 30 -> i                                 |
       25: aload_3             // <- slot 3                               |
       26: athrow              // throw ------------------------------------
       27: return
     Exception table:
        from    to  target type
            2     5    11   Class java/lang/Exception
            2     5    21   any    // 剩余的异常类型,比如 Error
           11    15    21   any    // 剩余的异常类型,比如 Error
     LineNumberTable: ...
     LocalVariableTable:
       Start  Length  Slot  Name   Signature
          12       3     2     e   Ljava/lang/Exception;
           0      28     0  args   [Ljava/lang/String;
           2      26     1     i   I
     StackMapTable: ...
   MethodParameters: ...
  • 可以看到有3个[from, to)
    • 第一个[2, 5)是检测try块中是否有Exception异常,如果有则跳转至11行执行catch块
    • 第二个[2, 5)是检测try块中是否有其他异常(非Exception异常),如果有则跳转至21行执行finally块
    • 第三个[11, 15)是检测catch快中是否有其他异常,如果有则跳转至21行执行finally块
  • 结论:finally中的代码被复制了三分,分别放进try流程、catch流程以及catch剩余的异常类型流程

finally面试题

  • 源代码,结果为20
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Demo_35 {
public static void main(String[] args) {
int result = test();
System.out.println(result);
}

private static int test() {
try {
return 10;
} finally {
return 20;
}
}
}
  • 字节码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
private static int test();
descriptor: ()I
flags: ACC_PRIVATE, ACC_STATIC
Code:
stack=1, locals=2, args_size=0
0: bipush 10 // 将 int 10 压入栈顶
2: istore_0 // 将栈顶的 int 10 存入到局部变量 slot 0 中,并从栈顶弹出
3: bipush 20 // 将 int 20 压入栈顶
5: ireturn // 返回栈顶的 int 20
6: astore_1 // 捕获任何异常
7: bipush 20 // 将 int 20 压入栈顶
9: ireturn
Exception table:
from to target type
0 3 6 any
LineNumberTable:
line 11: 0
line 13: 3
StackMapTable: number_of_entries = 1
frame_type = 70 /* same_locals_1_stack_item */
stack = [ class java/lang/Throwable ]
  • 由于 finally 中的 ireturn 被插入了所有可能的流程,因此返回结果肯定以 finally 的为准

  • 至于字节码中第 2 行,似乎没啥用,且留个伏笔,看下个例子

  • 跟上例中的 finally 相比,发现没有 athrow 了,这告诉我们:如果在 finally 中出现了 return,会
    吞掉异常

  • 源代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Demo_37 {
public static void main(String[] args) {
int result = test();
System.out.println(result);
}

private static int test() {
int i = 10;
try {
return i;
} finally {
i = 20;
}
}
}
  • 字节码
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
private static int test();
descriptor: ()I
flags: ACC_PRIVATE, ACC_STATIC
Code:
stack=1, locals=3, args_size=0
0: bipush 10 // 将 10 放入栈顶
2: istore_0 // 10 -> i
3: iload_0 // <- i(10)
4: istore_1 // 将 i(10) 暂存至 slot 1,目的是为了固定返回值
5: bipush 20 // 将 20 放入栈顶
7: istore_0 // 20 -> i
8: iload_1 // 载入 slot 1 暂存的值 (10)
9: ireturn // 返回栈顶的值
10: astore_2
11: bipush 20
13: istore_0
14: aload_2
15: athrow
Exception table:
from to target type
3 5 10 any
LineNumberTable:
line 10: 0
line 12: 3
line 14: 5
line 12: 8
line 14: 10
line 15: 14
LocalVariableTable:
Start Length Slot Name Signature
3 13 0 i I
StackMapTable: number_of_entries = 1
frame_type = 255 /* full_frame */
offset_delta = 10
locals = [ int ]
stack = [ class java/lang/Throwable ]

在上面的例子中,因为只在try块中有一个return语句,而在finally块中没有return语句,所以ireturn会加载try块中的局部变量i,而不是finally块中的局部变量i。如果在finally块中也有一个return语句,那么ireturn将加载finally块中的局部变量i 并返回它的值。

synchronized

  • 源代码
1
2
3
4
5
6
7
8
public class Demo_38 {
public static void main(String[] args) {
Object lock = new Object();
synchronized (lock) {
System.out.println("ok");
}
}
}
  • 字节码
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
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0: new #2 // class java/lang/Object
3: dup
4: invokespecial #1 // Method java/lang/Object."<init>":()V
7: astore_1 // lock引用 -> lock
8: aload_1 // <- lock (synchronized开始)
9: dup
10: astore_2 // lock引用 -> slot 2
11: monitorenter // monitorenter(lock引用)
12: getstatic #3 // Field java/lang/System.out:Ljava/io/PrintStream;
15: ldc #4 // String ok
17: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
20: aload_2 // <- slot 2(lock引用)
21: monitorexit // monitorexit(lock引用)
22: goto 30
25: astore_3 // any -> slot 3
26: aload_2 // <- slot 2(lock引用)
27: monitorexit // monitorexit(lock引用)
28: aload_3
29: athrow
30: return
Exception table:
from to target type
12 22 25 any
25 28 25 any
LineNumberTable:
line 5: 0
line 6: 8
line 7: 12
line 8: 20
line 9: 30
LocalVariableTable:
Start Length Slot Name Signature
0 31 0 args [Ljava/lang/String;
8 23 1 lock Ljava/lang/Object;

编译期处理(语法糖)

所谓的语法糖,其实就是指 java 编译器把 java 源码编译为 class 字节码的过程中,自动生成和转换的一些代码,主要是为了减轻程序员的负担,算是 java 编译器给我们的一个额外福利

默认构造器

  • 如果一个类没有声明任何构造函数,Java 编译器会自动为该类生成一个无参构造函数。
1
2
3
public class Candy01 {

}
  • 编译成class后的代码
1
2
3
4
5
6
public class Candy1 {
// 这个无参构造是编译器帮助我们加上的
public Candy1() {
super(); // 即调用父类 Object 的无参构造方法,即调用 java/lang/Object."<init>":()V
}
}

自动拆装箱

这个特性是JDK5开始加入的,代码片段1:

1
2
3
4
5
6
public class Candy2 {
   public static void main(String[] args) {
       Integer x = 1;
       int y = x;
 }
}

这段代码在JDK5之前是无法编译通过的,必须改写为代码片段2:

1
2
3
4
5
6
public class Candy2 {
   public static void main(String[] args) {
       Integer x = Integer.valueOf(1);
       int y = x.intValue();
 }
}

显然之前版本的代码太麻烦了,需要在基本类型和包装类型之间来回转换(尤其是集合类中操作的都是包装类型),因此这些转换的事情在 JDK5以后都由编译器在编译阶段完成。即代码片段1 都会在编译阶段被转换为代码片段2。

泛型集合取值

泛型也是在JDK 5 开始加入的特性,但 java 在编译泛型代码后会执行 型除的动作,即泛型信息在编译为字节码之后就丢失了,实际的类型都当做了 object 类型来处理:

1
2
3
4
5
6
7
public class Candy3 {
   public static void main(String[] args) {
       List<Integer> list = new ArrayList<>();
       list.add(10); // 实际调用的是 List.add(Object e)
       Integer x = list.get(0); // 实际调用的是 Object obj = List.get(int index);
 }
}

以下是泛型擦除的一些重要原因和意义

  1. 向后兼容性:泛型在Java 5及以后的版本引入,但为了保持与之前的版本的兼容性,Java编译器采用了泛型擦除。这意味着你可以编写使用泛型的代码,但在编译后,泛型信息将被擦除,以便在旧版本的Java中运行。
  2. 减少冗余代码:泛型擦除可以减少编译后的字节码文件的大小。因为泛型信息在运行时被擦除,所以编译器不会为每个不同的泛型类型生成新的类文件,从而减小了生成的字节码的体积。
  3. 避免类型相关的错误:泛型擦除确保在运行时不会引入类型相关的错误。这是因为在泛型擦除之后,编译器会插入必要的强制类型转换,以确保类型安全性。这有助于减少在运行时出现ClassCastException等类型错误的可能性。
  4. 可以实现通用性:泛型擦除使得可以编写通用的泛型代码,这些代码不依赖于具体的泛型类型。这有助于编写更通用、可重用的代码库,例如集合框架。

可变参数

可变参数也是JDK5加入的新特性:

1
2
3
4
5
6
7
8
9
public class Candy4 {
   public static void foo(String... args) {
       String[] array = args; // 直接赋值
       System.out.println(array);
 }
   public static void main(String[] args) {
       foo("hello", "world");
 }
}

可变参数 String… args 其实是一个 String[] args ,从代码中的赋值语句中就可以看出来。
同样 java 编译器会在编译期间将上述代码变换为:

1
2
3
4
5
6
7
8
9
public class Candy4 {
   public static void foo(String[] args) {
       String[] array = args; // 直接赋值
       System.out.println(array);
 }
   public static void main(String[] args) {
       foo(new String[]{"hello", "world"});
 }
}

注意:如果调用foo()时没有提供任何参数,那么则等价为foo(new String),创建了一个空的数组,而不是传一个null进去

foreach 循环

  1. 数组的循环
1
2
3
4
5
6
7
8
public class Candy5_1 {
   public static void main(String[] args) {
       int[] array = {1, 2, 3, 4, 5}; // 数组赋初值的简化写法也是语法糖哦
       for (int e : array) {
           System.out.println(e);
    }
 }
}

会被编译器转换为:

1
2
3
4
5
6
7
8
9
10
11
public class Candy5_1 { 
   public Candy5_1() {
 }
   public static void main(String[] args) {
       int[] array = new int[]{1, 2, 3, 4, 5};
       for(int i = 0; i < array.length; ++i) {
           int e = array[i];
           System.out.println(e);
    }
 }
}

  1. 集合的循环
1
2
3
4
5
6
7
8
public class Candy5_2 {
   public static void main(String[] args) {
       List<Integer> list = Arrays.asList(1,2,3,4,5);
       for (Integer i : list) {
           System.out.println(i);
    }
 }
}

实际被编译器转换为对迭代器的调用:

1
2
3
4
5
6
7
8
9
10
11
12
public class Candy5_2 { 
   public Candy5_2() {
 }
   public static void main(String[] args) {
       List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
       Iterator iter = list.iterator();
       while(iter.hasNext()) {
           Integer e = (Integer)iter.next();
           System.out.println(e);
    }
 }
}

switch 字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Candy6_1 {
   public static void choose(String str) {
       switch (str) {
           case "hello": {
               System.out.println("h");
               break;
      }
           case "world": {
               System.out.println("w");
               break;
      }
    }
 }
}

会被编译器转换为:

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
public class Candy07 {
public Candy07() {

}

public static void choose(String str) {
byte x = -1;
switch (str.hashCode()) {
case 99162322: // hello 的 hashCode
if (str.equals("hello")) {
x = 0;
}
break;
case 113318802: // world 的 hashCode
if (str.equals("world")) {
x = 1;
}
}
switch (x) {
case 0:
System.out.println("h");
break;
case 1:
System.out.println("w");
}
}
}
  • 可以看到,执行了两遍 switch,第一遍是根据字符串的 hashCode 和 equals 将字符串的转换为相应
    byte 类型,第二遍才是利用 byte 执行进行比较。
  • 为什么第一遍时必须既比较 hashCode,又利用 equals 比较呢?hashCode 是为了提高效率,减少可
    能的比较;而 equals 是为了防止 hashCode 冲突,例如 BM 和 C. 这两个字符串的hashCode值都是
    2123 ,如果有如下代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Candy6_2 {
   public static void choose(String str) {
       switch (str) {
           case "BM": {
               System.out.println("h");
               break;
      }
           case "C.": {
               System.out.println("w");
               break;
      }
    }
 }
}

会被编译器转换为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static void choose(String str) {
byte x = -1;
switch (str.hashCode()) {
case 2123: // hashCode 值可能相同,需要进一步用 equals 比较
if (str.equals("C.")) {
x = 1;
} else if (str.equals("BM")) {
x = 0;
}
default:
switch (x) {
case 0:
System.out.println("h");
break;
case 1:
System.out.println("w");
}
}
}

switch 枚举

switch 枚举的例子,原始代码:

1
2
3
enum Sex {
   MALE, FEMALE
}
1
2
3
4
5
6
7
8
9
10
public class Candy7 {
   public static void foo(Sex sex) {
       switch (sex) {
           case MALE:
               System.out.println("男"); break;
           case FEMALE:
               System.out.println("女"); break;
    }
 }
}

转换后代码:

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 Candy08 {
/**
* 定义一个合成类(仅 jvm 使用,对我们不可见)
* 用来映射枚举的 ordinal 与数组元素的关系
* 枚举的 ordinal 表示枚举对象的序号,从 0 开始
* 即 MALE 的 ordinal()=0,FEMALE 的 ordinal()=1
*/
static class $MAP {
// 数组大小即为枚举元素个数,里面存储case用来对比的数字
static int[] map = new int[2];

static {
map[Sex.MALE.ordinal()] = 1;
map[Sex.FEMALE.ordinal()] = 2;
}
}

public static void foo(Sex sex) {
int x = $MAP.map[sex.ordinal()];
switch (x) {
case 1:
System.out.println("男");
break;
case 2:
System.out.println("女");
break;
}
}
}

ordinal()是获取枚举编号的方法

枚举类

  • 源代码
1
2
3
enum Sex {
MALE, FEMALE
}
  • 字节码
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
public final class Sex extends Enum<Sex> {
public static final Sex MALE;
public static final Sex FEMALE;
private static final Sex[] $VALUES;
static {
MALE = new Sex("MALE", 0);
FEMALE = new Sex("FEMALE", 1);
$VALUES = new Sex[]{MALE, FEMALE};
}

/**
* Sole constructor. Programmers cannot invoke this constructor.
* It is for use by code emitted by the compiler in response to
* enum type declarations.
*
* @param name - The name of this enum constant, which is the identifier
* used to declare it.
* @param ordinal - The ordinal of this enumeration constant (its position
* in the enum declaration, where the initial constant is
* assigned
*/
private Sex(String name, int ordinal) {
super(name, ordinal);
}

public static Sex[] values() {
return $VALUES.clone();
}

public static Sex valueOf(String name) {
return Enum.valueOf(Sex.class, name);
}
}
  • Sex被声明为一个final类,它继承了Enum<Sex>类,Enum是Java中定义枚举的抽象类。MALE和FEMALE是Sex类的两个枚举值,它们被定义为静态常量。
  • 除此之外,还有一个私有的、finalSex类型数组$VALUES,它用于存储Sex类的所有枚举值。在类的静态块中,$VALUES数组被初始化为一个包含MALEFEMALE的数组。
  • 构造函数Sex(String name, int ordinal)是私有的,这意味着无法在类的外部使用这个构造函数来创建Sex的实例。只有Java编译器生成的代码才能调用这个构造函数来创建Sex的实例。
  • values()valueOf(String name)是从Enum类继承的两个静态方法。values()方法返回一个包含Sex类所有枚举值的数组,valueOf(String name)方法返回指定名称的枚举值。
  • 当我们使用MALE或者FEMALE时,其实底层调用的是Enum.valueOf(Sex.class, "MALE")Enum.valueOf(Sex.class, "FEMALE")

try-with-resources

]DK 7 开始新增了对需要关闭的资源处理的特殊语法 try-with-resources:

1
2
3
4
5
try(资源变量    = 创建资源对象){ 

} catch( ) {

}

其中资源对象需要实现 Autocloseable 接口,例如 Inputstream、outputstream、Connection、statement、Resultset 等接口都实现了 Autocloseable,使用 try-with-resources 可以不用写 finally 语句块,编译器会帮助生成关闭资源代码,例如:

1
2
3
4
5
6
7
8
9
public class Candy9 {
   public static void main(String[] args) {
       try(InputStream is = new FileInputStream("d:\\1.txt")) {
           System.out.println(is);
      } catch (IOException e) {
           e.printStackTrace();
    }
 }
}

会被转换为:

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
public class Candy09 {
public Candy09() {
}

public static void main(String[] args) {
try {
InputStream is = new FileInputStream("d:\\tmp.txt");
Throwable t = null;
try {
System.out.println(is);
} catch (Throwable e1) {
// t 是我们代码出现的异常
t = e1;
throw e1;
} finally {
// 判断了资源不为空
if (is != null) {
// 如果我们代码有异常
if (t != null) {
try {
is.close();
} catch (Throwable e2) {
// 如果 close 出现异常,作为被压制异常添加
t.addSuppressed(e2);
}
} else {
// 如果我们代码没有异常,close 出现的异常就是最后 catch 块中的 e
is.close();
}
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}

为什么要设计一个 addsuppressed(Throwable e) (添加被压制异常) 的方法呢?

是为了防止异常信息的丢失(想想 try-with-resources 生成的 fianlly 中如果抛出了异常) :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Test {
public static void main(String[] args) {
try (MyResource resource = new MyResource()) {
int i = 1/0;
} catch (Exception e) {
e.printStackTrace();
}
}
}
class MyResource implements AutoCloseable {
public void close() throws Exception {
throw new Exception("close 异常");
}
}

输出如下,两个异常信息都不会丢失:

1
2
3
4
5
java.lang.ArithmeticException: / by zero
at com.demo.Test.main(Test.java:6)
Suppressed: java.lang.Exception: close 异常
at com.demo.MyResource.close(Test.java:14)
at com.demo.Test.main(Test.java:7)

方法重写时的桥接方法

方法重写时,对返回值分两种情况

  1. 父类与子类的返回值完全一致
  2. 子类返回值可以是父类返回值的子类(比较绕口,直接看下面的例子来理解)
1
2
3
4
5
6
7
8
9
10
11
12
class A {
public Number m() {
return 1;
}
}
class B extends A {
@Override
// 父类A方法的返回值是Number类型,子类B方法的返回值是Integer类型,Integer是Number的子类
public Integer m() {
return 2;
}
}

那么对于子类,编译器会做如下处理:

1
2
3
4
5
6
7
8
9
10
11
class B extends A {
public Integer m() {
return 2;
}

// 此方法才是真正重写了父类 public Number m() 方法
public synthetic bridge Number m() {
// 调用 public Integer m()
return m();
}
}

其中的桥接方法比较特殊,仅对Java虚拟机可见,并且与原来的public Integer m()没有命名冲突

匿名内部类

  • 原始Java代码
1
2
3
4
5
6
7
8
9
10
public class Candy10 {
public static void main(String[] args) {
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("ok");
}
};
}
}
  • 转换后代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 额外生成的类
final class Candy10$1 implements Runnable {
Candy10$1() {
}
public void run() {
System.out.println("ok");
}
}

public class Candy10 {
public static void main(String[] args) {
Runnable runnable = new Candy10$1();
}
}
  • 对于匿名内部类,它的底层实现是类似于普通内部类的,只不过没有命名而已。在生成匿名内部类的class文件时,Java编译器会自动为该类生成一个类名,在原始类名上加后缀$1,如果有多个匿名内部类,则$2$3以此类推
  • 引用局部变量的匿名内部类,原始Java代码
1
2
3
4
5
6
7
8
9
10
public class Candy11 {
public static void test(final int x){
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("ok" + x);
}
};
}
}
  • 转换后代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 额外生成的类
final class Candy11$1 implements Runnable {
int val$x;
Candy11$1(int x) {
this.val$x = x;
}
public void run() {
System.out.println("ok:" + this.val$x);
}
}
public class Candy11 {
public static void test(final int x) {
Runnable runnable = new Candy11$1(x);
}
}
  • 注意:这也解释了为什么匿名内部类引用局部变量时,局部变量必须为final的
    • 因为在创建Candy$11对象时,将x的值赋给了val$x属性,所以x不应该再发生变化了
    • 如果变化,那么val$x属性没有机会再跟着一起变化

⭐⭐⭐类加载阶段

文章参考自:https://javaguide.cn/java/jvm/class-loading-process.html

一个类的完整生命周期

加载

将类的字节码载入方法区中,内部采用C++的instanceKlass描述Java类,它的重要field有

  1. _java_mirror:Java的类镜像,例如对String来说,就是String.class,作用是把klass暴露给Java使用
  2. _super:父类
  3. _fields:成员变量
  4. _methods:方法
  5. _constants:常量池
  6. _class_loader:类加载器
  7. _vtable:需方发表
  8. _itable:接口方法表
  • 如果这个类还有父类没有加载,先加载父类
  • 加载和链接可能是交替运行的

instanceKlass这样的元数据是存储在方法区(1.8后是在元空间内),但_java_mirror是存储在堆中

image-20231018144014687

链接

验证

  • 验证类是否符合JVM规范,安全性检查

准备

为static变量分配空间,设置默认值,即类变量,其内存空间是在方法区中的

  • static变量分配空间和赋值是两个步骤,分配空间在准备阶段完成,赋值在初始化阶段完成
  • 如果static遍历是final的基本类型以及字符串常量,那么编译阶段值就确定了,赋值在准备阶段完成
  • 如果static遍历是final的,但属于引用类型,那么赋值也会在初始化阶段完成

这里所设置的初始值”通常情况”下是数据类型默认的零值(如 0、0L、null、false 等),比如我们定义了public static int value=111 ,那么 value 变量在准备阶段的初始值就是 0 而不是 111(初始化阶段才会赋值)。特殊情况:比如给 value 变量加上了 final 关键字public static final int value=111 ,那么准备阶段 value 的值就被赋值为 111。

解析

将常量池中的符号引用解析为直接引用

符号引用和直接引用

初始化

在初始化阶段,主要完成以下工作:

  1. 执行静态初始化器和静态变量初始化
    • 执行类中的所有静态初始化块(static blocks),这些初始化块用于执行一些一次性的初始化操作。
    • 初始化所有的静态变量(static fields)到其定义时的初始值或默认值,如果静态变量有显式初始化语句,则执行这些初始化语句。
  2. 执行类的构造函数调用准备
    • 虽然类的构造函数不会在这个阶段直接被调用,但是会为之后通过该类实例化对象做准备。这意味着类的构造函数和方法区域已经准备好供调用。
  3. 触发父类初始化
    • 如果一个类继承了父类,在初始化子类之前,会先初始化其父类。这意味着父类的静态初始化块和静态变量初始化也会被执行。
  4. 执行类的初始化方法
    • 如果类中有任何初始化方法(如<clinit>()方法),它们将在初始化阶段执行。<clinit>()是一个特殊的静态初始化方法,由编译器自动生成,用于包含所有的静态初始化代码。
  5. 反射引用解析
    • 在初始化阶段,对于类中的反射引用,JVM会确保所有引用的类已经被加载、链接和初始化。
  6. 线程安全
    • 类的初始化过程是线程安全的。这意味着即使多个线程同时请求初始化同一个类,初始化只会发生一次,并且在初始化过程中其他线程会被阻塞直到初始化完成。

初始化即调用<cinit>()V 方法,虚拟机ui保证这个类的构造方法的线程安全

  • 发生的时机:总的来说,类的初始化是懒惰的

    1. main方法所在的类,总会被首先初始化

    2. 首次访问这个类的静态变量或静态方法时,会进行初始化

    3. 子类初始化,如果父类还没未初始化,则父类也会进行初始化

    4. 默认的Class.forName会导致初始化

    5. new对象会导致初始化


  • 不会导致类初始化的情况

    1. 访问类的 static final 静态常量(基本类型和字符串) 不会触发初始化
    2. 调用类对象.class不会触发初始化
    3. 类加载器的loadClass方法不会触发初始化
    4. Class.forName的参数2为false时(initalize = false),不会触发初始化

卸载

卸载类即该类的 Class 对象被 GC。

卸载类需要满足 3 个要求:

  1. 该类的所有的实例对象都已被 GC,也就是说堆不存在该类的实例对象。
  2. 该类没有在其他任何地方被引用
  3. 该类的类加载器的实例已被 GC

所以,在 JVM 生命周期内,由 jvm 自带的类加载器加载的类是不会被卸载的。但是由我们自定义的类加载器加载的类是可能被卸载的。

JDK 自带的 BootstrapClassLoader, ExtClassLoader, AppClassLoader 负责加载 JDK 提供的类,所以它们(类加载器的实例)肯定不会被回收。而我们自定义的类加载器的实例是可以被回收的,所以使用我们自定义加载器加载的类是可以被卸载掉的。

⭐⭐⭐类加载器

介绍

赋予了 Java 类可以被动态加载到 JVM 中并执行的能力

简单来说,类加载器的主要作用就是加载 Java 类的字节码( .class 文件)到 JVM 中(在内存中生成一个代表该类的 Class 对象)。

类加载器加载规则

JVM 启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载。也就是说,大部分类在具体用到的时候才会去加载,这样对内存更加友好。

对于已经加载的类会被放在 ClassLoader 中。在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。也就是说,对于一个类加载器来说,相同二进制名称的类只会被加载一次。

类型

  • JDK 8为例
名称 中文名 加载哪的类 说明
Bootstrap ClassLoader 启动类加载器 JAVA_HOME/jre/lib 无法直接访问
Extension ClassLoader 扩展类加载器 JAVA_HOME/jre/lib/ext 上级为 Bootstrap,显示为 null
Application ClassLoader 应用程序类加载器 classpath 上级为 Extension
自定义类加载器 自定义 上级为 Application
  • 当JVM需要加载一个类时,它会首先委托父类加载器去加载这个类,如果父类加载器无法加载这个类,就会由当前类加载器来加载。如果所有的父类加载器都无法加载这个类,那么就会抛出ClassNotFoundException异常。

启动类加载器

  • Bootstrap ClassLoader是所有类加载器中最早的一个,负责加载JRE/lib下的核心类库,如java.lang.Object、java.lang.String等。

  • 输出的结果是null,因为引导类加载器是由JVM的实现者用C/C++等语言编写的,而不是由Java编写的。在Java虚拟机的实现中,引导类加载器不是Java对象,也没有对应的Java类,因此它的ClassLoader属性为null。

双亲委派机制

执行流程

  • 所谓双亲委派机制,就是指调用类加载器的loadClass方法时,查找类的规则
  • 从下往上询问,从上往下加载
  • ClassLoader 类使用委托模型来搜索类和资源。
  • 双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。
  • ClassLoader 实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器。

image-20231018154518908

双亲委派源码精简化后的主要逻辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 检查类是否已经被加载
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
// 如果类没有被加载,则委托给父ClassLoader加载
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 如果父ClassLoader加载失败,则在自身查找类
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}

🔴🟡🟢简单总结一下双亲委派模型的执行流程:

  • 在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载(每个父类加载器都会走一遍这个流程)。
  • 类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器 loadClass()方法来加载类)。这样的话,所有的请求最终都会传送到顶层的启动类加载器 BootstrapClassLoader 中。
  • 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(调用自己的 findClass() 方法来加载类)。
  • 如果子类加载器也无法加载这个类,那么它会抛出一个 ClassNotFoundException 异常。

🌈 拓展一下:

JVM 判定两个 Java 类是否相同的具体规则:JVM 不仅要看类的全名是否相同,还要看加载此类的类加载器是否一样。只有两者都相同的情况,才认为两个类是相同的。即使两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相同。

⭐⭐⭐双亲委派模型的好处

这种机制有助于保证类的唯一性和安全性

双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。

如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现两个不同的 Object 类。双亲委派模型可以保证加载的是 JRE 里的那个 Object 类,而不是你写的 Object 类。这是因为 AppClassLoader 在加载你的 Object 类时,会委托给 ExtClassLoader 去加载,而 ExtClassLoader 又会委托给 BootstrapClassLoaderBootstrapClassLoader 发现自己已经加载过了 Object 类,会直接返回,不会去加载你写的 Object 类。

打破双亲委派

https://www.bilibili.com/video/BV1ww411U79d/

为什么要打破双亲委派机制?

就拿Tomcat来说,Tomcat就打破了双亲委派的机制,才使得其能运行webapp的Java程序。

Tomcat大部分情况其实是遵循双亲委派的,其只是在双亲委派的基础上做了一些调整,比如他新加入了四个类加载器:

  1. common class loader:这个类加载器负责加载tomcat和各个webapp所共享的jar包
  2. Catalina class loader:这个类加载器负责加载tomcat私有的jar包
  3. shared class loader:这个类加载器负责加载各个webapp所共享但是对tomcat不共享的jar包
  4. webapp class loader:这个类加载器负责加载各个独立的webapp私有的jar包

如何打破双亲委派模型?

  1. 使用自定义类加载器: 自定义类加载器可以重写默认的类加载行为,以实现自定义的加载逻辑。通过在自定义类加载器中重写loadClass方法,可以指定在加载特定类时不按照双亲委派模型的方式进行,而是根据自定义的逻辑加载类。这样可以实现特定版本的类加载,而不受父类加载器的限制。

    1
    2
    3
    4
    5
    6
    7
    public class CustomClassLoader extends ClassLoader {
    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
    // Implement custom class loading logic here
    return super.loadClass(name);
    }
    }

双亲委派模型的源码其实就是通过parent这个变量来实现的,只需要重写classLoader方法,不让其继续向上传递即可。

  1. 使用线程上下文类加载器: Java中的线程上下文类加载器(Thread Context Class Loader)允许在加载线程中切换类加载器。这在某些框架和应用中经常用到,例如在基于SPI的框架中,可以通过设置线程上下文类加载器来加载特定的实现类,而不受双亲委派模型的限制。
1
Thread.currentThread().setContextClassLoader(customClassLoader);
  1. 修改系统属性或环境变量: 在某些情况下,可以通过修改Java虚拟机的系统属性或环境变量来影响类加载行为。例如,可以通过设置java.system.class.loader系统属性来指定一个特定的类加载器,从而影响类的加载行为。
1
java -Djava.system.class.loader=com.example.CustomClassLoader MainClass

线程上下文类加载器

线程上下文类加载器的作用

线程上下文类加载器是每个线程独有的,它可以通过Thread.currentThread().getContextClassLoader()方法获取,用于在运行时刻切换类加载器的上下文。这一机制特别适合以下情况:

  1. 解耦模块的类加载关系: 在某些框架或容器中,不同模块可能使用不同的类加载器加载类。如果一个模块需要调用另一个模块的类,而这两个模块的类加载器不一样,直接使用双亲委派模型可能导致类找不到或版本冲突。通过设置线程上下文类加载器,可以让调用线程的类加载器来加载目标类,避免了这些问题。
  2. 动态加载实现类: 在一些基于插件或SPI(Service Provider Interface)的框架中,需要动态加载实现类。SPI机制通过在META-INF/services目录下的配置文件中列出接口的实现类,但这些实现类可能由不同的模块提供,使用线程上下文类加载器可以确保正确加载这些实现类。

使用线程上下文类加载器的步骤

使用线程上下文类加载器通常需要以下步骤:

  1. 保存当前的上下文类加载器(可选): 在修改线程上下文类加载器之前,可以通过Thread.currentThread().getContextClassLoader()保存当前的类加载器,以便稍后恢复。

    1
    ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader();
  2. 设置新的上下文类加载器: 使用Thread.currentThread().setContextClassLoader(newClassLoader)来设置新的类加载器。通常,newClassLoader是你希望在接下来的代码中使用的特定类加载器,可以是自定义的类加载器或框架提供的特定实现。

    1
    Thread.currentThread().setContextClassLoader(customClassLoader);
  3. 执行需要的操作: 在设置了新的上下文类加载器后,接下来的类加载操作(如加载类、资源文件等)会使用设置的类加载器来进行。

  4. 恢复原始的上下文类加载器(可选): 如果修改上下文类加载器的操作完成后,可以恢复原始的上下文类加载器,以保持程序的一致性和避免潜在的影响。

    1
    Thread.currentThread().setContextClassLoader(originalClassLoader);

注意事项

  • 线程上下文类加载器的传递性: 线程上下文类加载器是由父线程传递给子线程的,默认情况下子线程会继承父线程的上下文类加载器。这意味着在多线程环境下,需要注意线程上下文类加载器的设置和维护,避免不必要的混淆和冲突。
  • 安全性考虑: 修改线程上下文类加载器是一种高级技术手段,需要谨慎使用。不当的使用可能导致类加载冲突、内存泄漏或安全问题,特别是在大型应用或框架中需要特别注意。

自定义类加载器

https://javaguide.cn/java/jvm/classloader.html

运行期优化

没听懂………….(后续待补充了)