JVM学习

引言

什么是JVM?

定义

​ Java Virtual Machine - java 程序的运行环境(java 二进制字节码的运行环境)
​ 将java的字节码加载到虚拟机里面,就可以运行了

好处

  • 一次编写,到处运行(正式jvm屏蔽了字节码和底层操作系统的差异,对外提供了统一 的运行环境)
  • 自动内存管理,垃圾回收功能 (不需要自己释放内存,减轻了内存的泄露)
  • 数组下标越界检查
  • 多态(可扩展性,虚方法表)

比较

jvm jre jdk的区别
在这里插入图片描述

  • jvm:屏蔽java代码与底层的差异 ,Java Virtual Machine
  • jre:jvm+基础类(集合类,日期类,线程类,IO类。。)Java Runtime Environment
  • jdk:jvm+基础类+编译工具(javac)Java Development Kit

学习JVM 有什么用

  • 面试
  • 理解底层的实现原理
  • 中高级程序员的必备技能(是否能用jvm相关的知识解决实际的问题 )

常见的 JVM

在这里插入图片描述

学习路线

在这里插入图片描述
在这里插入图片描述

内存结构

1、程序计数器

作用:字节码解释器⼯作时通过改变这个计数器的值来选取下⼀条需要执⾏的字节码指令,分⽀、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成 。因为JVM中多线程采用时间片轮转的方式实现,所以当一个线程时间片用完之后,要交给其它线程使用,为了下一次分配到该线程时,能够继续执行,所以需要程序计数器。

是记住下一条jvm指令的执行地址
特点:

  • 是线程私有的
  • 唯一一个内存结构中,不会存在内存溢出

Program Counter Register 程序计数器(寄存器)

在这里插入图片描述

在物理上 程序计数器是通过寄存器来实现的,因为寄存器是cpu组件里,读取速度最快的。因为程序计数器的读取指令是非常频繁的,

2、虚拟机栈

2.1 定义

描述的是 Java⽅法执⾏的内存模型,每次⽅法调⽤的数据都是通过栈传递的。

  • 每个线程运行时所需要的内存,称为虚拟机栈
  • 每个栈由多个栈帧(Frame)组成,对应着每次方法调用时所占用的内存
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法,也就是栈的顶部
  • 栈帧中都拥有:局部变量表、操作数栈、动态链接(指向常量池方法的引用)、⽅法出⼝信息 (方法返回地址和一些附加信息)
  • 每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态链接
  • 动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用
  • 在这里插入图片描述

Java ⽅法有两种返回⽅式:

  1. return 语句。
  2. 抛出异常。

不管哪种返回⽅式都会导致栈帧被弹出。

问题辨析

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

不涉及,栈内存就是一次一次的方法调用,所产生的栈帧内存,栈帧内存每次在方法调用之后,都会弹出栈,自动的回收,不需要垃圾回收,垃圾回收只回收堆内存中的无用对象

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

不,栈大,线程数变少,栈多,只会进行更多次的方法调用,不会影响速度,反而影响线程数目的减少
如果内存为500M,每个线程占用1M,可以运行500个线程,如果栈内存分配2M,则只能运行250个线程。

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

是线程安全的,每个线程的局部变量是线程私有的。

判断一个变量是否是线程安全的

不仅要看它是否是方法内的局部变量,还要看它是否逃离了方法的作用范围(例如返回值,其他线程可以操作,不安全)

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

2.2 栈内存溢出(java.lang.StackOverflowError

(1)栈帧过多导致栈内存溢出(方法没有终止条件下)
在这里插入图片描述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 演示栈内存溢出 java.lang.StackOverflowError
* -Xss256k
*/
public class Demo1_2 {
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();
}
}

(2)栈帧过大导致栈内存溢出

3、本地方法栈

调用不是java代码编写的方法,一般由C语言编写或者C++编写。

4、堆

4.1 定义

Heap 堆:通过 new 关键字,创建对象都会使用堆内存
特点:(1)它是线程共享的,堆中对象都需要考虑线程安全的问题(2)有垃圾回收机制

4.2 堆内存的溢出

1
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
/**
* 演示堆内存溢出 java.lang.OutOfMemoryError: Java heap space
* -Xmx8m
*/
public class Demo1_5 {

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

4.3 JVM指针碰撞和空闲列表

当类加载检查通过后,接下来虚拟机将为新生对象分配内存,堆主要就是用于存放对象的实例,在堆上为对象分配内存空间,将对象放进去,常用的方法有指针碰撞(java堆中内存是规整的)和空闲列表(java堆中内存是不规整的)两种方法。

指针碰撞

​ 适用于堆内存完整的情况,已分配的内存和空闲的内存分别在不同的一侧,指针指向分界点,当需要分配内存的时候,将指针向空闲区域的方向移动与对象大小相等的距离即可,用于串行回收器(Serial)和 并行收集器(ParNew)不会产生碎片的垃圾收集器。

image-20200512111153143

空闲列表

​ 适用于堆内存不完整的情况,已分配的内存和空闲的内存相互交错,JVM通过一张内存列表记录可用的内存信息,当分配内存时,从列表中找到一个足够大的内存块分配给对象实例,并更新列表上的数据,最常用此方案的是CMS垃圾回收器。

image-20200512111650404

5、方法区

5.1 定义

⽅法区与 Java 堆⼀样,是各个线程共享的内存区域,它⽤于存储已被虚拟机加载的类信息、常
量、静态变量、即时编译器编译后的代码等数据。

1.8的时候,方法区被彻底移除,取而代之的是元空间,元空间使用的是直接内存。

5.2 组成

在这里插入图片描述
在这里插入图片描述
方法区也被称为永久代。
⽅法区和永久代的关系:

⽅法区和永久代的关系很像Java 中接⼝和类的关系,类实现了接⼝,⽽永久代就是 HotSpot 虚拟机对虚拟机规范中⽅ 法区的⼀种实现⽅式。

5.3 常量池和运行时常量池

(1)常量池

程序在运行之前要编译成二进制字节码,

其中二进制字节码包含

  • 类基本信息

  • 常量池

  • 类方法定义(包含了虚拟机指令)

将class文件解析后可以看到这些二进制代码包含的内容:

1
2
3
4
5
public class test1 {
public static void main(String[] args) {
System.out.println("hello world");
}
}
1
javac test1.java
1
javap -v .class

类基本信息:

image-20220603190959878

常量池:俗称静态常量池,又称常量池表(Constant Pool Table),存在于*.class文件中,就是一张表,虚拟机指令根据这张表找到要执行的类名、类方法、参数类型、字面量等信息

image-20220603191047403

类方法定义:

image-20220603191109581

(2)运行时常量池

当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址 。(也就是将常量池中的内容放入到内存中运行称为运行时常量池。并把里面的#1,#2等等变为真实的地址

5.4 StringTable–字符串池(运行时常量池中的一部分)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// StringTable [ "a", "b" ,"ab" ]  hashtable 结构,不能扩容
public class Demo1_22 {
// 常量池中的信息,都会被加载到运行时常量池中, 当类被加载时 a b ab 都是常量池中的符号,还没有变为 java 字符串对象
// ldc #2 会把 a 符号变为 "a" 字符串对象
// ldc #3 会把 b 符号变为 "b" 字符串对象
// ldc #4 会把 ab 符号变为 "ab" 字符串对象

public static void main(String[] args) {
String s1 = "a"; // 懒惰的
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2; // new StringBuilder().append("a").append("b").toString() new String("ab")
String s5 = "a" + "b"; // javac 在编译期间的优化,结果已经在编译期确定为ab

System.out.println(s3 == s5);

}
}

特性:

  • StringTable数据结构为hash表(数组加链表),不可扩容,存字符串常量,唯一不重复。

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

  • 利用串池的机制,来避免重复创建字符串对象

  • 字符串变量拼接的原理是 StringBuilder (1.8),new一个String对象,创建在堆中

  • 字符串常量拼接的原理是编译期优化,因为常量在编译器就决定了值,不会改变,所以在串池中寻找和创建。

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

    (1)1.8 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池, 会把串
    池中的对象返回
    (2)1.6 将这个字符串对象尝试放入串池 ,如果有则并不会放入,如果没有会把此对象复制一份,
    放入串池, 会把串池中的对象返回 (也就是放入串池中的对象和当初创建的对象是两个对象

1
2
3
4
5
6
7
8
9
10
11
12
13
//举例一(1.8):
public class Demo1_23 {
// 串池 ["a", "b", "ab"] hashtable
public static void main(String[] args) {
String s = new String("a") + new String("b");

// 堆 new String("a") new String("b")
String s2 = s.intern(); // 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池, 会把串池中的对象返回

System.out.println( s2 == "ab"); //true
System.out.println( s == "ab" ); //true
}
}

分析如上代码:

当执行第一行时,创建了“a”“b”字符串,在串池中搜索,查找是否有该字符串,如果没有则创建,继续执行。

同时在堆中也创建了new String("a")new String("b")

执行s.intern()的时候,发现串池中没有“ab”字符串,则将s所引用的字符串放入串池,所以下面的执行结果都为true。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//举例二(1.8):
public class Demo1_23 {
// ["ab" ,"a", "b"]
public static void main(String[] args) {
String x = "ab";
String s = new String("a") + new String("b");

// 堆 new String("a") new String("b") new String("ab")
String s2 = s.intern(); // 将这个字符串对象尝试放入串池,如果有则并不会放入,如果没有则放入串池, 会把串池中的对象返回

System.out.println( s2 == x); //true
System.out.println( s == x ); //false
}
}

分析如上代码:

当执行第一行时,在字符串常量池中创建了“ab”字符串。

第二行时在字符串中创建了“a”“b” ,以及在堆中创建 new String("a") new String("b") new String("ab")

通过执行s.intern()想将s加入到常量池中,但是s的字符串常量池中已经存在不会放入。

所以 s不等于xs2是常量池中返回的对象“ab”,所以相等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//举例:(jdk1.6)中
public class Demo1_23 {
// ["a", "b", "ab"]
public static void main(String[] args) {

String s = new String("a") + new String("b");

// 堆 new String("a") new String("b") new String("ab")
String s2 = s.intern(); //s 拷贝一份,放入串池中,不是原来的s
// 将这个字符串对象尝试放入串池 ,如果有则并不会放入,如果没有会把此对象复制一份,放入串池, 会把串池中的对象返回
String x = "ab";
System.out.println( s2 == x); //true
System.out.println( s == x ); //false
}
}

分析如上代码:

当执行第一行的时候,在串池中创建两个常量 "a""b",在堆中创建 new String("a") new String("b") new String("ab")

执行第二行的时候,查看串池中是否有“ab”,如果有则不放入,和jdk1.8中的一样,如果没有,则拷贝一分,放入串池中,不是原先的变量s。

因此在执行s == x时为false,因为s中字符串对象还是堆中的。

5.5 StringTable存在的位置

在jdk1.6时,StringTable属于常量池中的一部分,存放于永久代中

image-20220603191146879

在jdk1.7之后存放于 堆内存中

image-20220603191157423

为什么要更改:

因为在永久代中,垃圾回收很慢。导致StringTable回收效率很低。而StringTable中存放的都是字符串常量,需要及时清理,如果不清理需要占用大量的内存。所以考虑移动到堆内存中,垃圾回收效率高,减轻了字符串对内存的占用。

5.6 StringTable垃圾回收

可以被垃圾回收

5.7 StringTable 性能调优

调整 -XX:StringTableSize=桶个数

什么是方法区

当虚拟机要使用一个类时,要读取并解析class文件获取相关的信息,再将信息存入到方法区域中,方法区会存储已经被虚拟机加载的类信息、字段信息、方法信息、常量、静态变量等。

方法区和永久代以及元空间有什么关系

永久代和元空间是方法区的两种实现方式,相当于java中的接口和类,类实现了接口===永久代和元空间实现了方法区。

并且,永久代是 JDK 1.8 之前的方法区实现,JDK 1.8 及以后方法区的实现便成为元空间。

img

为什么要将永久代替换为元空间

(1)因为永久代设置空间大小是很难确定的,在某些场景中,如果动态加载类过多,容易产生OOM,比如某个实际的web工程中,因为功能点比较多,在运行过程中,要不断的动态加载很多类,导致OOM,而元空间和永久代最大的区别在于:元空间并不在虚拟机中,而是使用本地的内存,因此,默认情况下,元空间的大小受本地内存的限制,所以出现OOM的情况变小,并且能加载更多的类

(2)永久代的对象是通过Full GC进行垃圾回收的,也就是和老年代一起进行垃圾回收的,替换元空间以后,简化了 FullGC
(3)在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 从来没有一个叫永久代的东西, 合并之后就没有必要额外的设置这么一个永久代的地方了

运行时常量池和字符串常量池

jdk1.7之前,运行时常量池所包含的字符串常量池和静态变量全部都存放在方法区中也就是永久代。

jdk1.7时,字符串常量池和静态变量移动到堆中,运行时常量池中所剩下的东西还在方法区中也就是永久代中,

jdk1.8时,方法区的实现从永久代变为了元空间。

运行时常量池中存放的是对象的引用,字符串常量池中存放的是对象,存放在堆中。

在声明一个字符串字面量时,如果字符串常量池中能够找到该字符串字面量,则直接返回该引用。如果找不到的话,则在常量池中创建该字符串字面量的对象并返回其引用。

jdk7中为什么把字符串常量池移动到堆中

因为永久代的GC回收率太低,只有在整个堆收集的时候(Full GC)才会发生GC,而JAVA程序通常有大量创建的字符串等待回收,将字符串常量放入到堆中,可以更高效的回收字符串。

img

6 直接内存

不是虚拟机的内存,是系统内存。Direct Memory

定义:

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

image-20220603191211665

java读取磁盘文件时,会从用户态切换到内核态,才能去操作系统内存。读取时,系统内存先开辟一块缓存空间,磁盘文件分块读取。然后java虚拟机内存再开辟缓存空间new Byte[]来读取系统内存的文件。由于有从系统内存读取到java虚拟机的内存,所以效率较低。

image-20220603191226097

读取磁盘文件时,会有一块直接内存,Java虚拟机和系统内存都能访问使用,所以效率更高。

它可以直接使⽤ Native 函数库直接分配堆外内存,然后通过⼀个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引⽤进⾏操作。这样就能在⼀些场景中显著提⾼性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据

7 总结

image-20220604115140591

垃圾回收

1 如何判断对象可以回收

1.2 引用计数法

给对象添加一个引用计数器,每当有引用它的地方,就将计数器加 1 ,当引用失效后,引用计数器减1,当计数器为 0 时,说明对象不会被引用了,可以进行回收。

弊端: 就是当对象A和B互相引用的时候,对象A和对象B的计数器都为1

image-20220603191323953

但这两个对象没有被其他对象引用,应该进行垃圾回收,但由于对象A和对象B的计数器都为1,回收失败。

1.2 可达性分析法

  • Java 虚拟机中的垃圾回收器采用可达性分析来探索所有存活的对象
  • 扫描堆中的对象,看是否能够沿着 GC Root对象 为起点的引用链找到该对象,找不到,表示可以回收
  • 哪些对象可以作为 GC Root ?

哪些对象可以作为 GC Root ?

  • 方法区静态属性引用的对象
    全局对象的一种,Class对象本身很难被回收,回收的条件非常苛刻,只要Class对象不被回收,静态成员就不能被回收。

  • 方法区常量池引用的对象
    也属于全局对象,例如字符串常量池,常量本身初始化后不会再改变,因此作为GC Roots也是合理的。

  • 方法栈中栈帧本地变量表引用的对象
    属于执行上下文中的对象,线程在执行方法时,会将方法打包成一个栈帧入栈执行,方法里用到的局部变量会存放到栈帧的本地变量表中。只要方法还在运行,还没出栈,就意味这本地变量表的对象还会被访问,GC就不应该回收,所以这一类对象也可作为GC Roots。

  • JNI本地方法栈中引用的对象
    和上一条本质相同,无非是一个是Java方法栈中的变量引用,一个是native方法(C、C++)方法栈中的变量引用。

  • 被同步锁持有的对象
    被synchronized锁住的对象也是绝对不能回收的,当前有线程持有对象锁呢,GC如果回收了对象,锁不就失效了嘛。

总结:

可达性分析就是JVM枚举根节点,找到程序能正常运行所必须存活的对象,然后以这些对象为根,根据引用关系开始向下搜寻。存在直接或间接引用链的对象就存活,不存在引用链的对象就回收。

1.3 四种引用

image-20220603191333535

(1)强引用

​ 以前我们使⽤的⼤部分引⽤实际上都是强引⽤,这是使⽤最普遍的引⽤。如果⼀个对象具有强引⽤,那就类似于必不可少的⽣活⽤品,垃圾回收器绝不会回收它。当内存空间不⾜, Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终⽌,也不会靠随意回收具有强引⽤的对象来解决内存不⾜问题。

​ 当强引用都断开时,才可以被回收。例如:String a = new String("hello world");

(2)软引用

​ 如果⼀个对象只具有软引⽤,那就类似于可有可⽆的⽣活⽤品。如果内存空间⾜够,垃圾回收器就不会回收它,如果内存空间不⾜了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使⽤。软引⽤可⽤来实现内存敏感的⾼速缓存。

​ 软引⽤可以和⼀个引⽤队列(ReferenceQueue)联合使⽤,如果软引⽤所引⽤的对象被垃圾回收, JAVA虚拟机就会把这个软引⽤加⼊到与之关联的引⽤队列中。

(3)弱引用

​ 如果⼀个对象只具有弱引⽤,那就类似于可有可⽆的⽣活⽤品。弱引⽤与软引⽤的区别在于:在垃圾回收器线程扫描它所管辖的内存区域的过程中,⼀旦发现了只具有弱引⽤的对象,不管当前内存空间⾜够与否,都会回收它的内存。不过,由于垃圾回收器是⼀个优先级很低的线程, 因此不⼀定会很快发现那些只具有弱引⽤的对象。

​ 弱引⽤可以和⼀个引⽤队列(ReferenceQueue)联合使⽤,如果弱引⽤所引⽤的对象被垃圾回收, Java虚拟机就会把这个弱引⽤加⼊到与之关联的引⽤队列中。

为什么软引用和弱引用要使用引用队列?

因为软引用和弱引用本身也是一个对象,也占有一定的内存,所以要释放它们两个时,就要扫描引用队列进行释放。

(4)虚引用

​ 就是形同虚设 .如果⼀个对象仅持有虚引⽤,那么它就和没有任何引⽤⼀样,在任何时候都可能被垃圾回收

虚引⽤主要⽤来跟踪对象被垃圾回收的活动。

​ 虚引⽤必须和引⽤队列(ReferenceQueue)联合使⽤。当垃 圾回收器准备回收⼀个对象时,如果发现它还有虚引⽤,就会在回收对象的内存之前,把这个虚引⽤加⼊到与之关联的引⽤队列中。程序可以通过判断引⽤队列中是 否已经加⼊了虚引⽤,来了解被引⽤的对象是否将要被垃圾回收。程序如果发现某个虚引⽤已经被加⼊到引⽤队列,那么就可以在所引⽤的对象的内存被回收之前采取必要的⾏动。

​ 虚引用引用的对象被垃圾回收时,把这个虚引⽤加⼊到与之关联的引⽤队列中。调用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
30
31
32
33
34
/**
* 演示软引用, 配合引用队列
*/
public class Demo2_4 {
private static final int _4MB = 4 * 1024 * 1024;

public static void main(String[] args) {
List<SoftReference<byte[]>> list = new ArrayList<>();

// 引用队列
ReferenceQueue<byte[]> queue = new ReferenceQueue<>();

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

// 从队列中获取无用的 软引用对象,并移除
Reference<? extends byte[]> poll = queue.poll();
while( poll != null) {
list.remove(poll);
poll = queue.poll();
}

System.out.println("===========================");
for (SoftReference<byte[]> reference : list) {
System.out.println(reference.get());
}

}
}

(5)终结器引用

  • 无需手动编码,但其内部配合引用队列使用,
  • 在垃圾回收时,终结器引用入队(被引用对象暂时没有被回收),
  • 再由 Finalizer 线程通过终结器引用找到被引用对象并调用它的 finalize方法,
  • 第二次 GC 时才能回收被引用对象

2 垃圾回收算法

2.1 标记清除

是最基础的收集算法,⾸先标记出所有不需要回收的对象,在标记完成后统⼀回收掉所有没有被标记的对象。

image-20220603191350783

优点:

速度较快

缺点:

标记清除后会产⽣⼤量不连续的碎⽚ ,

2.2 标记整理

对标记后,垃圾回收的空间进行整理。

image-20220603191400545

优点:

没有内存碎片的问题

缺点:

时间慢

2.3 复制算法

将内存区域划分成了大小相等的两个区域。To是空闲的,一个对象都没有

image-20220603191408841

标记内存中要回收的对象:

image-20220603191415683

将from中存活的对象复制到To中

image-20220603191422234

清空From

image-20220603191429522

交换From 和 To ,To总是空闲的一块

image-20220603191436488

优点:

不会产生内存碎片

缺点:

占用双倍的内存空间

2.4 分代回收算法

​ 当前虚拟机的垃圾收集都采⽤分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为⼏块。⼀般将 java 堆分为新⽣代和⽼年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。

image-20220603191445299

步骤:

  • 对象首先分配在伊甸园区域

  • 新生代空间不足时,触发 minor gc,伊甸园和 from 存活的对象使用 copy 复制到 to 中,存活的对象年龄加 1并且交换 from to

  • minor gc 会引发 stop the world,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行(因为垃圾回收时,会进行地址的交换,如果线程同时执行的话会找不到地址,出现混乱)

  • 当对象寿命超过阈值时,会晋升至老年代,最大寿命是15(4bit)

  • 当老年代空间不足,会先尝试触发 minor gc,如果之后空间仍不足,那么触发 full gc,STW(stop the world)的时间更长

    ⽐如在新⽣代中,每次收集都会有⼤量对象死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。⽽⽼年代的对象存活⼏率是⽐较⾼的,⽽且没有额外的空间对它进⾏分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进⾏垃圾收集。

3 垃圾回收器

3.1 串行(serial)

​ 看名字就知道是“单线程”收集器,它的 “单线程” 的意义不仅仅意味着它只会使⽤⼀条垃圾收集线程去完成垃圾收集⼯作,更重要的是它在进⾏垃圾收集⼯作的时候必须暂停其他所有的⼯作线程( “StopThe World” ),直到它收集结束。

新⽣代采⽤复制算法,⽼年代采⽤标记-整理算法。

缺点就是:STP时间太长,用户体验不佳

优点就是:简单而高效,没有线程之间交互的开销

image-20220603191456131

Serial Old 收集器
Serial 收集器的⽼年代版本,它同样是⼀个单线程收集器。它主要有两⼤⽤途:⼀种⽤途是在JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使⽤,另⼀种⽤途是作为 CMS 收集器的后备⽅案。

3.2 ParNew 收集器

ParNew 收集器其实就是 Serial 收集器的多线程版本,除了使⽤多线程进⾏垃圾收集外,其余⾏为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全⼀样。

新⽣代采⽤复制算法,⽼年代采⽤标记-整理算法。

它是许多运⾏在 Server 模式下的虚拟机的⾸要选择,除了 Serial 收集器外,只有它能与 CMS 收集器(真正意义上的并发收集器,后⾯会介绍到)配合⼯作。

3.2 吞吐量优先

​ Parallel Scavenge 收集器关注点是吞吐量(⾼效率的利⽤ CPU)。 所谓吞吐量就是 CPU 中⽤于运⾏⽤户代码的时间与 CPU 总消耗时间的⽐值。 垃圾回收器并行工作。Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量

新⽣代采⽤复制算法,⽼年代采⽤标记-整理算法。

image-20220603191505250

并发(Concurrent) :指⽤户线程与垃圾收集线程同时执⾏(但不⼀定是并⾏,可能会交替执⾏),⽤户程序在继续运⾏,⽽垃圾收集器运⾏在另⼀个 CPU 上

并行(Parallel) :指多条垃圾收集线程并⾏⼯作,但此时⽤户线程仍然处于等待状态。

3.3 CMS响应时间优先

CMS(Concurrent Mark Sweep)收集器是⼀种以获取最短回收停顿时间为⽬标的收集器。它⾮常符合在注重⽤户体验的应⽤上使⽤。

CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第⼀款真正意义上的并发收集器它第⼀次实现了让垃圾收集线程与⽤户线程(基本上)同时⼯作。

从名字中的Mark Sweep这两个词可以看出, CMS 收集器是⼀种 “标记-清除”算法实现的,它的运作过程相⽐于前⾯⼏种垃圾收集器来说更加复杂⼀些。整个过程分为四个步骤:

image-20220603191515127

  • 初始标记: 暂停所有的其他线程,并记录下直接与 root 相连的对象,速度很快 ;

  • 并发标记: 同时开启 GC 和⽤户线程,⽤⼀个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为⽤户线程可能会不断的更新引⽤域,所以 GC 线程⽆法保证可达性分析的实时性。所以这个算法⾥会跟踪记录这些发⽣引⽤更新的地⽅。

  • 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序运行导致标记记录改变的标记,这个阶段的停顿时间⼀般会⽐初始标记阶段的时间稍⻓,远远⽐并发标记阶段时间短。

  • 并发清除: 开启⽤户线程,同时 GC 线程开始对未标记的区域做清扫。

主要优点: 并发收集、低停顿。

三个明显缺点:

  • 对 CPU 资源敏感;
  • ⽆法处理浮动垃圾;
  • 它使⽤的回收算法-“标记-清除”算法会导致收集结束时会有⼤量空间碎⽚产⽣。

3.4 G1(Garbage-First )

适用场景

  • 同时注重吞吐量(Throughput)和低延迟(Low latency),默认的暂停目标是 200 ms
  • 超大堆内存,会将堆划分为多个大小相等的 Region
  • 整体上是 标记+整理 算法,两个区域之间是 复制 算法
  • G1 收集器在后台维护了⼀个优先列表,每次根据允许的收集时间,优先选择回收价值最⼤的Region(这也就是它的名字 Garbage-First 的由来)。这种使⽤ Region 划分内存空间以及有优先级的区域回收⽅式,保证了 G1 收集器在有限时间内可以尽可能⾼的收集效率(把内存化整为零)。

(1)G1垃圾回收的整个阶段

image-20220603191526911

(2)第一个阶段( Young Collection )

  • G1垃圾回收器将堆内存划分为多个大小相同的区域,每个区域都可以独立的作为新生代,幸存区或老年代

image-20220603191536263

其中:白色表示空闲区,E表示新生代的区域 ,S表示幸存区,O表示老年代的区域

  • 当内存逐渐占满,新生代将会进行一次垃圾回收,将幸存的对象以复制的算法放入幸存区

image-20220603191543901

  • 当幸存区对象的存活超过一定的时间,则幸存区中的一部分对象会晋升到老年代 ,不够年龄的拷贝到另一个幸存空间中去,新生代的幸存对象也会被拷贝到幸存空间中去

image-20220603191552734

(2)第二阶段(Young Collection + CM ):新生代垃圾回收+并发标记

  • 在 Young GC 时会进行 GC Root 的初始标记

  • 老年代占用堆空间比例达到阈值时,进行并发标记(不会 STW),由下面的 JVM 参数决定

    image-20220603191601802

同时并发标记过程中,会计算每个region存活对象的比例(G1垃圾回收的时候根据回收的价值高低来优先回收价值较高的region)

(3)第三个阶段(Mixed Collection )

会对 E、S、O 进行全面垃圾回收

  • 最终标记(Remark)会 STW
  • 拷贝存活(Evacuation)会 STW

拷贝存活时:对老年代来说,首先对各个Regin的回收价值和成本进行排序,优先回收垃圾最多的区域,主要为了达到暂停时间短的目标

image-20220603191612170

标记的一些概念:

image-20220603191617970

其中:黑色表示已经处理完成的,表示结束时会被存活下来的对象,灰色表示正在处理的,白色表示还没有处理的。

垃圾回收的时候通过颜色来判断是否存活还是回收,图中的灰色的有强引用引用着它,也会变成黑色。下一个白色也有引用应用引用着它,最终也会存活下来,而上面的白色没有引用,一直是白色,最后被垃圾回收掉。

例如:

image-20220603191624470

上图:

  • 情况一:当处理到B时,发现有强引用引用着它,所以它变成灰色,由于是并发标记,垃圾回收线程和用户线程同时执行,当用户取消了B引用C这条线,C则一直为白色。所以当并发标记结束后 ,C被当作垃圾被回收。

image-20220603191631341

  • 情况二:下图:当C和B 标记处理完成之后,并发标记还没有结束之前,用户线程又改变了引用地址,A引用了C,C的引用又发生了改变。但由与C和B已经处理完成,C标记为白色,A是黑色已经处理完成,不会再进行处理。则C就会被漏掉,因为我们仍然认为C是白色的,应该被垃圾回收,但是不正确,因为A引用了C,这样就发生了错误。
  • image-20220603191637135

根据以上的分析,为了防止并发标记阶段出现这种错误,所以要进行重新标记阶段。

JVM的解决方法:如果引用变化,则加入到队列中

当引用发生改变时,JVM就会给它加入一个写屏障,就会将C加入到队列当中并把C变成灰色表示还没有处理完,接下来进行重新标记的过程,Stop The World,重新标记的过程会将队列中的元素取出来,在进行一次检查,发现灰色,并且有强引用,。则变为黑色,不会进行垃圾回收。

image-20220603191644707

3.5 FullGC概念

  • SerialGC
    新生代内存不足发生的垃圾收集 - minor gc
    老年代内存不足发生的垃圾收集 - full gc
  • ParallelGC
    新生代内存不足发生的垃圾收集 - minor gc
    老年代内存不足发生的垃圾收集 - full gc
  • CMS
    新生代内存不足发生的垃圾收集 - minor gc
    老年代内存不足
  • G1
    新生代内存不足发生的垃圾收集 - minor gc
    老年代内存不足 (触发并发标记和混合收集,这个时候都进行的是并发收集,还没有到FullGC的程度),当垃圾回收的速度跟不上产生的垃圾,则并发收集失败,这个时候会触发串行收集(FullGC)

3.6 CMS和G1的区别

相对于 CMS 回收器来说,G1 回收器有下面几个不同的地方:

  • 采用化整为零的分区思想
  • 采用标记 - 整理的垃圾回收算法
  • 可预测的 GC 停顿时间

(1)采用化整为零的分区思想

因为G1垃圾回收器使用的是标记-整理算法,而CMS使用的是标记-清除算法,所以CMS会产生大量的内存碎片,而G1不会。

但为什么CMS不使用标记-整理算法呢?

因为CMS的老年代区域很大,使用标记-整理算法要花费很长的时间,导致接口响应时间变长

因为G1中采用分区的思想,将大块的内存化整为零成为region,此外还维护了一个带回收区域的列表,优先回收性价比高的区域。

(2)可预测的停顿时间

能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。

3.7 GC调优 没看,应该面试没有

类加载

1 类文件结构和字节码指令

从字节码的方式来解释程序的运行

这一部分先简单了解一下,面试好像不需要

image-20220622145501331

对于i++,++i

从字节码角度分析,下列代码运行的结果:

1
2
3
4
5
6
7
8
9
10
11
public class Demo3_6_1 {
public static void main(String[] args) {
int i = 0;
int x = 0;
while (i < 10) {
x = x++;
i++;
}
System.out.println(x); // 结果是 0
}
}

结果为:0

image-20220603191704808

因为x++操作:是先iload(将变量加载到操作数栈中)再进行iinc(在局部变量表中进行加1),

++x操作是:先iinc(在局部变量表中加1)再进行iload(将加1后的变量加载到操作数栈中)。

注意:iinc:是在局部变量表中加1的,而不是在操作数栈中加1

方法调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Demo3_9 {
public Demo3_9() { }

private void test1() { }

private final void test2() { }

public void test3() { }

public static void test4() { }

public static void main(String[] args) {
Demo3_9 d = new Demo3_9();
d.test1();
d.test2();
d.test3();
d.test4();
Demo3_9.test4();
}

}

字节码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
0: new #2 // class cn/itcast/jvm/t3/bytecode/Demo3_9
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 指令,把【对象引用】从操作数栈弹掉了😂
  • 还有一个执行 invokespecial 的情况是通过 super 调用父类方法

finally

1
2
3
4
5
6
7
8
9
10
11
12
public class Demo3_12_2 {
public static void main(String[] args) {
int result = test();
System.out.println(result);
}
public 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
public static int test();
descriptor: ()I
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=1, locals=2, args_size=0
0: bipush 10 // <- 10 放入栈顶
2: istore_0 // 10 -> slot 0 (从栈顶移除了)
3: bipush 20 // <- 20 放入栈顶
5: ireturn // 返回栈顶 int(20)
6: astore_1 // catch any -> slot 1
7: bipush 20 // <- 20 放入栈顶
9: ireturn // 返回栈顶 int(20)
Exception table:
from to target type
0 3 6 any
LineNumberTable: ...
StackMapTable: ...
  • 由于 finally 中的 ireturn 被插入了所有可能的流程,因此返回结果肯定以 finally 的为准
  • 至于字节码中第 2 行,似乎没啥用,且留个伏笔,看下个例子
  • 跟上例中的 finally 相比,发现没有 athrow 了,这告诉我们:如果在 finally 中出现了 return,会
    吞掉异常😱😱😱。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Demo3_12_2 {
public static void main(String[] args) {
int result = test();
System.out.println(result);
}
public static int test() {
int i = 10;
try {
return i;
} finally {
i = 20;
}
}
}

结果: 10

因为执行return i的时候进行的暂存,等到finally中的代码执行完成之后,再将暂存结果返回。

因此:如果在try中return了,则在finally做出的变化将不会影响返回值结果。

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 static int test();
descriptor: ()I
flags: ACC_PUBLIC, 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 // 10 -> slot 1,暂存至 slot 1,目的是为了固定返回值
5: bipush 20 // <- 20 放入栈顶
7: istore_0 // 20 -> i
8: iload_1 // <- slot 1(10) 载入 slot 1 暂存的值
9: ireturn // 返回栈顶的 int(10)
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: ...
LocalVariableTable:
Start Length Slot Name Signature
3 13 0 i I2.13 synchronized
注意
StackMapTable: ...

synchronized 。。。

2 编译期处理

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

2.1 默认构造器

1
2
public class Candy1 {
}

编译成class后的代码:

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

2.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();
}
}

2.3 泛型集合

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

2.4 可变参数

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

2.5 foreach循环

1
2
3
4
5
6
7
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
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);
}
}
}

3 类的加载过程**

image-20220603191722403

image-20220603191728110

3.1 加载

指 JVM 读取 Class 文件,并且根据 Class 文件描述创建 java.lang.Class 对象的过程。

类的加载过程主要是将类的字节码文件读取到运行时区域的方法区(内部使用C++完成),在堆中创建java.lang.Class 对象, 并封装类在方法区的数据结构的过程 。

3.2 验证

主要用于确保 Class 文件符合当前虚拟机的要求, 保障虚拟机自身的安全,只有通过验证的 Class 文件才能被 JVM 加载。

3.3 准备

为 static 变量分配空间,设置默认值

  • static 变量在 JDK 7 之前存储于 instanceKlass 末尾,从 JDK 7 开始,存储于 _java_mirror 末尾
  • static 变量分配空间和赋值是两个步骤,分配空间在准备阶段完成,赋值在初始化阶段完成
  • 如果 static 变量是 final 的基本类型,以及字符串常量,那么编译阶段值就确定了,赋值在准备阶
    段完成
  • 如果 static 变量是 final 的,但属于引用类型(new),那么赋值也会在初始化阶段完成

3.4 解析

将常量池中的符号引用(仅仅是符号,不知道类、方法或属性到底在哪个位置)解析为直接引用 (能够确切的知道类、方法或者属性在内存中的具体位置了)

3.5 初始化

主要通过执行类构造器的方法为类进行初始化。 方法是在编译阶段由编译器自动收集类中静态语句块和变量的赋值操作组成的。

发生的时机
概括得说,类初始化是【懒惰的】

  • main 方法所在的类,总会被首先初始化
  • 首次访问这个类的静态变量或静态方法时
  • 子类初始化,如果父类还没初始化,会引发
  • 子类访问父类的静态变量,只会触发父类的初始化
  • Class.forName
  • new 会导致初始化

不会导致类初始化的情况

  • 访问类的 static final 静态常量(基本类型和字符串)不会触发初始化
  • 类对象.class 不会触发初始化(在加载阶段已经生成了,)
  • 创建该类的数组不会触发初始化
  • 类加载器的 loadClass 方法
  • Class.forName 的参数 2 为 false 时

4 类加载器

什么是类加载器?常见的类加载器有哪些?

类加载器:通过一个类的全限定性类名(类名全程,带包路径的用点隔开 eg:com.zlw.test)获取该类的二进制字节流,叫做类加载器。

常见的类加载器有四种:

名称 加载哪的类 说明
Bootstrap ClassLoader JAVA_HOME/jre/lib (加载java核心类库)–启动类加载器 无法直接访问
Extension ClassLoader JAVA_HOME/jre/lib/ext (加载java的扩展库)–扩展类加载 上级为 Bootstrap,显示为 null
Application ClassLoader classpath(通过java类路径来加载类,一般来说,java应用的类都是用它来加载)–应用程序加载器 上级为 Extension
自定义类加载器 自定义 (由java语言实现,继承自ClassLoader) 上级为 Application

image-20220604100611851

5 双亲委派模式

什么是双亲委派模式?

当一个类加载器收到一个类加载请求时,首先不会尝试自己去加载,而是将这个类委派给上级加载器进行加载,只有上级加载器在自己的搜索范围查找不到该类时,子加载器才会去尝试自己去加载该类。

好处:

(1)如果没有双亲委派模式,用户会自己自定义一个java.lang.String,无法保证类的唯一性。

(2)同时也避免了类的重复加载,因为 JVM中区分不同类,不仅仅是根据类名,相同的 class文件被不同的 ClassLoader加载就是不同的两个类。

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
protected Class<?> loadClass(String name, boolean resolve)throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 1. 检查该类是否已经加载
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
// 2. 有上级的话,委派上级 loadClass
c = parent.loadClass(name, false);
} else {
// 3. 如果没有上级了(ExtClassLoader),则委派
BootstrapClassLoader c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
}
if (c == null) {
long t1 = System.nanoTime();
// 4. 每一层找不到,调用 findClass 方法(每个类加载器自己扩展)来加载
c = findClass(name);
// 5. 记录耗时
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}

执行流程为:
(1)sun.misc.Launcher$AppClassLoader //1 处, 开始查看已加载的类,结果没有
(2) sun.misc.Launcher$AppClassLoader // 2 处,委派上级sun.misc.Launcher$ExtClassLoader.loadClass()
(3) sun.misc.Launcher$ExtClassLoader // 1 处,查看已加载的类,结果没有
(4) sun.misc.Launcher$ExtClassLoader // 3 处,没有上级了,则委派 BootstrapClassLoader查找
(5) BootstrapClassLoader 是在 JAVA_HOME/jre/lib 下找 H 这个类,显然没有
(6)sun.misc.Launcher$ExtClassLoader // 4 处,调用自己的 findClass 方法,是在JAVA_HOME/jre/lib/ext 下找 H 这个类,显然没有,回到 sun.misc.Launcher$AppClassLoader的 // 2 处
(7)继续执行到 sun.misc.Launcher$AppClassLoader // 4 处,调用它自己的 findClass 方法,在
classpath 下查找,找到了

6 自定义加载器

6.1 为什么要自定义类加载器

(1)加密:Java代码可以轻易的被反编译,如果你需要把自己的代码进行加密防止反编译,可以先将编译后的代码用某种加密算法加密,类加密后就不能再用Java的ClassLoader去加载类了,这时就需要自定义ClassLoader在加载类的时候先解密类,然后再加载。

(2)想加载非 classpath 随意路径中的类文件

(3)从非标准的来源加载代码:如果你的字节码是放在数据库、甚至是在云端,就可以自定义类加载器,从指定的来源加载类。

(4)以上两种情况在实际中的综合运用:比如你的应用需要通过网络来传输 Java 类的字节码,为了安全性,这些字节码经过了加密处理。这个时候你就需要自定义类加载器来从某个网络地址上读取加密后的字节代码,接着进行解密和验证,最后定义出在Java虚拟机中运行的类。

6.2 自定义加载器步骤

(1)从上面源码看出,调用loadClass时会先根据委派模型在父加载器中加载,如果加载失败,则会调用当前加载器的findClass来完成加载。

(2)因此我们自定义的类加载器只需要继承ClassLoader,并覆盖findClass方法,下面是一个实际例子,在该例中我们用自定义的类加载器去加载我们事先准备好的class文件。

步骤:

  • 继承 ClassLoader 父类
  • 要遵从双亲委派机制,重写 findClass 方法(因为只有重新了findClass方法才会委托上级的加载器优先加载,只有上级没有找到class时,才会在本身的类加载器进行加载)
    • 注意不是重写 loadClass 方法,否则不会走双亲委派机制
  • 读取类文件的字节码
  • 调用父类的 defineClass 方法来加载类
  • 使用者调用该类加载器的 loadClass 方法

自定义加载器的代码为:

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
package cn.example.myclassloader;

import java.io.*;
import java.nio.file.Files;
import java.nio.file.Paths;

public class MyClassLoader extends ClassLoader {
@Override
//name:类的名称
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
String path = name.replace(".","\\");
path = "D:\\class\\" + path + ".class";
ByteArrayOutputStream baos = new ByteArrayOutputStream();
//根据路径进行拷贝,将拷贝的结果放入到 baos的输出流中
Files.copy(Paths.get(path), baos);
//得到字节数组
byte[] bytes = baos.toByteArray();
//将byte[] -> *.class
return super.defineClass(name,bytes,0,bytes.length);
} catch (IOException e) {
e.printStackTrace();
throw new ClassNotFoundException("没找到相应的类文件" + name);
}
}
}

测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
package cn.example.myclassloader;

import org.junit.Test;

public class MyClassLoaderTest{
@Test
public void test1() throws ClassNotFoundException {
//创建类加载器对象
MyClassLoader myClassLoader = new MyClassLoader();
//调用loadClass方法 实现类的加载 class文件名为:G
Class<?> g = myClassLoader.loadClass("G");
System.out.println(g.getClassLoader());//cn.example.myclassloader.MyClassLoader@1e965684
Class<?> aClass = myClassLoader.loadClass("cn.example.cat.G");
//两次加载是一样的,因为第一次加载时,已经放入自定义加载器的缓存中了,下次再执行时,再缓存中已经能找到,就不进行重复的加载了
//当是两个不同的类加载器,则结果是不一样的。
System.out.println(aClass.getClassLoader());//cn.example.myclassloader.MyClassLoader@1e965684
}
}


JVM学习
http://example.com/2022/05/30/JVM学习/
作者
zlw
发布于
2022年5月30日
许可协议