发展历程

  1. Classic VM

    世界上第一款商用Java虚拟机,现已被淘汰。只提供解释器,要想使用JIT即时编译器需外挂。一旦使用了JIT,JIT会接管执行系统,解释器不再工作。

  2. Exact VM

    此虚拟机可以知道内存中某个位置的数据具体是什么类型,具备现代高性能虚拟机雏形(热点探测、编译器与解释器混合工作)。气短,最终被Hotspot虚拟机替代。

  3. HotSpot VM

    目前,HotSpot占有绝对的地位,在服务器、桌面、移动端、嵌入式都有应用。名称中的HotSpot指的就是热点探测技术(通过计数器查找热点代码,触发JIT即时编译),同时编译器与解释器协同工作,在响应时间和执行性能中取得平衡。是JDK8默认使用的虚拟机,也是本文章所学习的虚拟机。

    image-20240112195148320

  4. JRockit VM

    专注服务器端应用。它不太关注启动速度,因此不包含解释器,全靠编译器执行代码。

  5. J9 VM

    市场定位与HotSpot接近,服务器、桌面、嵌入式等多用途VM。

  6. KVM

    活跃于诺基亚时代,你是否玩过带咖啡图标的游戏。

  7. Azul VM/Liquid VM

    高性能虚拟机中的战斗机。

  8. Graal VM

    口号”Run Programs Faster Anywhere”,野心勃勃。HotSpot基础上增强的跨语言全栈虚拟机,可以作为”任何语言”的运行平台使用,包括:Java、C/C++、JavaScript、Ruby、Python、R等。如果有一天HotSpot被取代,Graal希望最大。

  9. etc …

整体结构

JVM(Java虚拟机)是Java程序运行的核心组件,它负责将Java字节码翻译成可以在特定硬件和操作系统上执行的机器指令。

image-20240112213236979

  1. 字节码文件(Class File):Java源代码经编译器(如javac)编译后得到的中间表示形式,它包含了被编译后的Java程序的指令集。字节码文件以.class为扩展名,它并不是机器语言指令,而是一种与特定平台无关的中间代码。

  2. 类加载器(Class Loader):负责将Java字节码文件加载到内存中,并生成对应的Java类对象。类加载器根据类的全限定名来查找和加载类。

  3. 运行时数据区(Runtime Data Area):JVM在运行时使用的内存区域,分为以下几个部分:

    • 方法区(Method Area):存储类信息、常量池、代码缓存等。
    • 堆(Heap):存储对象实例。
    • 栈(Stack):存储局部变量和方法调用的现场。
    • 程序计数器(Program Counter):存储当前线程将要执行的字节码指令地址。
    • 本地方法栈(Native Method Stack):存储调用本地方法的信息。
  4. 执行引擎(Execution Engine):负责执行Java字节码指令的核心组件。执行引擎通常使用解释器或即时编译器(JIT)将字节码翻译成机器指令。

  5. 垃圾收集器(Garbage Collector):自动管理内存的组件。它负责检测不再使用的对象,并释放它们所占用的内存。

  6. 本地方法接口(Native Method Interface):简称JNI,JNI提供了一系列的接口和函数,用于在Java代码中调用本地(Native)方法。本地方法是使用其他语言(如C、C++)编写的方法。

  7. 本地方法库(Native Method Libraries):包含了与操作系统相关的库,JVM可以通过本地方法库调用操作系统提供的底层功能。

指令结构

JVM(Java虚拟机)使用基于的指令集结构。用于存储操作数和中间结果。

与之相对应的是基于寄存器的指令集结构,以下是两种结构之间的对比:

  1. 数据操作方式
    • 基于栈:操作数通常存储在操作数栈中,指令从栈顶取出操作数进行操作,并将结果放回栈顶。
    • 基于寄存器:操作数通常存储在寄存器中,指令直接读取和操作寄存器中的数据。
  2. 指令长度
    • 基于栈:由于操作数通常存储在栈中,故使用零地址指令,指令相对较短,但是完成同一操作所需指令数比基于寄存器的更多。
    • 基于寄存器:操作码更复杂且指令需要指定寄存器的编号或名称,导致指令长度较长。
  3. 访问速度
    • 基于栈:需要访问内存的读取栈中数据,因此速度较慢。
    • 基于寄存器:无需频繁访问内存,执行起来更高效。
  4. 移植性
    • 基于栈:不需要硬件支持,可移植性好,更好实现跨平台。
    • 基于寄存器:依赖硬件(寄存器),可移植性差。

测试:

image-20240112171023922

image-20240201193353414

类加载器

类加载过程

image-20240113133721951

  1. 加载(Loading):类加载器子系统负责从不同的来源(例如本地文件系统、网络)加载字节码文件,并将其转换为内存中方法区的Class对象。

  2. 验证(Verification):一旦类被加载到内存中,类加载器会对其进行验证,以确保字节码的合法性和安全性。验证过程包括静态语法检查、字节码验证和符号引用验证等,以防止潜在的安全风险和类文件结构问题。

  3. 准备(Preparation):类加载器会将类的静态变量(类变量)分配到堆区jdk8),并设置默认的初始值。final 修饰的类变量在准备阶段就会显示赋值(最终值)。

    1
    2
    3
    4
    public class App{
    // 准备阶段将a赋值为0,初始化阶段赋值为1
    private static int a = 1;
    }
  4. 解析(Resolution):解析阶段是将符号引用替换为直接引用的过程。

  5. 初始化(Initialization):包括对静态变量的赋值、执行静态代码块和调用静态方法(指在静态代码块中被调用)等。类的初始化是类加载过程中的最后一步,也是真正执行类的字节码逻辑的阶段。

类加载器分类

JVM中不同的类加载器主要有三种:引导类加载器(Bootstrap ClassLoader)扩展类加载器(ExtClassLoader)系统类加载器(AppClassLoader)。它们负责加载不同位置的类文件。

除了以上三种类加载器,用户也可以自定义类加载器

  1. 引导类加载器(启动类加载器)

    JVM的内置类加载器,负责加载Java核心库(如rt.jar)和JVM相关的类。由C++实现,并不是Java类。它是类加载器中的顶级加载器,无法被Java代码直接引用。由于是JVM的一部分,其加载路径与具体实现相关,无法通过Java代码直接指定。

  2. 扩展类加载器

    负责加载JVM扩展目录(如jre/lib/ext)中的类库,它是由Java实现的。

  3. 系统类加载器(应用程序类加载器)

    负责加载应用程序classpath下的类库,也是由Java实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class App
{
public static void main(String[] args) {
// 获取系统类加载器
ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
System.out.println(systemClassLoader); // sun.misc.Launcher$AppClassLoader@18b4aac2
// 获取扩展类加载器
ClassLoader extClassLoader = systemClassLoader.getParent();
System.out.println(extClassLoader); // sun.misc.Launcher$ExtClassLoader@28d93b30
// 获取不到引导类加载器
ClassLoader bootstrapClassLoader = extClassLoader.getParent();
System.out.println(bootstrapClassLoader); // null
// 默认使用系统类加载器加载用户自定义的类
ClassLoader classLoader = App.class.getClassLoader();
System.out.println(classLoader); // sun.misc.Launcher$AppClassLoader@18b4aac2
// 输出结果间接证明String类由引导类加载器加载 ===》Java核心类库都是由引导类加载器进行加载
ClassLoader classLoader1 = String.class.getClassLoader();
System.out.println(classLoader1); // null
}
}
1
2
3
4
5
// 获取BootstrapClassLoader能够加载的路径
URL[] urLs = sun.misc.Launcher.getBootstrapClassPath().getURLs();
for (URL urL : urLs) {
System.out.println(urL.toExternalForm());
}

image-20240113203539527

Java虚拟机对字节码文件采用按需加载的方式,当需要用到某一类时才会将它的class文件加载到内存中生成class对象。

1
2
3
4
5
6
7
8
9
10
11
12
public class App
{
public static void main(String[] args) {
System.out.println("hello");
}
}
class Test{
static {
System.out.println("执行静态代码块");
}
}
// 程序执行时jvm不会加载Test类,即不会执行静态代码块

双亲委派机制

加载某类时,采用双亲委派机制。

image-20240114093037972

  1. 如果一个类加载器收到了类加载请求,它会先将请求委托给父类加载器去执行;
  2. 如果父类加载器还存在其父类加载器,则进一步向上委托,最终将到达顶层启动类加载器;
  3. 如果父类加载器可以完成类加载任务,成功返回,倘若无法完成加载任务,子加载器才会尝试自己去加载。

示例:

image-20240114094057192

如上图所示,程序没有执行静态代码块,自定义String类没有被加载。当需要加载java.lang.String类时,并不会由应用程序类加载器去加载,而是委托给启动类加载器去加载。

image-20240114095504096

上图同样可以说明问题。

双亲委派机制优势:

  • 避免类的重复加载
  • 保护程序安全,防止核心API被随意篡改

运行时数据区

image-20240114140101888

jvm定义了若干程序运行期间使用到的运行时数据区,其中有些会随着虚拟机的启动而创建,随着虚拟机的退出而销毁。另外一些则是与线程一一对应,这些与线程对应的数据区会随着线程的开始和结束创建和销毁。

图中蓝色为单个线程所私有紫色为多个线程所共享。而图中所有部分均属于一个进程,一个进程对应着一个jvm实例。

程序计数器

存储将要执行指令的地址,由执行引擎根据地址读取指令。

image-20240114151606889

问:为什么要使用程序计数器记录当前线程所要执行指令的地址?

答:因为CPU需要在各个线程间不停切换,CPU得知道当前线程从哪里开始执行。

虚拟机栈

即栈。每个线程各有一个。

栈帧

栈以栈帧(Stack Frame)作为存储单位。

栈帧的创建和销毁与方法的调用和返回密切相关。当一个方法被调用时,JVM会创建一个新的栈帧并将其推入栈顶;而当方法返回时,栈帧会被从栈中弹出并销毁,恢复到调用该方法时的状态。

栈帧由以下几个主要的部分组成:

  1. 局部变量表:用于存放方法中的局部变量。

  2. 操作数栈:用于存放方法执行过程中的操作数。

  3. 动态链接:用于指向运行时常量池中该栈帧所属方法的引用。

  4. 方法返回地址:用于保存方法的返回地址,即方法执行完后的下一条指令的地址。

除了上述主要部分外,栈帧还可能包含一些辅助信息,如异常处理机制相关的信息。

当前正在执行的方法叫当前方法;与该方法对应的栈帧叫当前栈帧;定义这个方法的类叫当前类。执行引擎运行的字节码指令只针对当前栈帧进行操作。

常见异常

在虚拟机栈的处理过程中,常见的异常包括:

  1. StackOverflowError:当一个线程的栈深度超过了最大限制,会抛出此异常。这通常是由于方法递归调用导致的,递归调用没有结束条件或者递归层数过多时会发生。

  2. OutOfMemoryError:栈也是分配在内存中的,如果栈的大小超过了内存限制,会抛出此异常。通常是由于创建过多的线程或者每个线程的栈设定值过大导致的。

  3. StackUnderflowError:当栈中操作数栈中没有足够的操作数执行某个指令时,会抛出此异常。这可能是由于异常或错误的指令执行引发的,导致操作数栈中的数据不匹配。

设置大小

栈的大小可以是动态或者固定

在使用命令行启动应用程序时,可以使用参数-Xss来指定栈的大小,其后接具体的大小数值。例如,-Xss1m表示将栈的大小设置为1MB

示例命令行:

1
java -Xss1m ClassName

栈的大小是有一定限制的,不能无限制地设置。栈的大小限制可能因操作系统和硬件的不同而有所不同。超过限制的设置可能会导致OutOfMemoryError异常。

也可在VM options填入-Xss1m进行测试:

image-20240114164512701

1
2
3
4
5
private static int count = 0;
public static void main(String[] args) {
System.out.println(count++); // 查看最后输出大小差异
main(args);
}

局部变量表

  1. 空间大小:局部变量在编译期已经确定,其内存空间在方法的字节码中就已经分配好,并且固定不变,与运行时动态分配的对象不同。
  2. 存储空间:局部变量以数组或类似数组的形式组织,每个槽可以存储一个基本类型值或一个对象引用。数组大小在编译时确定,具体取决于方法中声明的局部变量和方法参数的数量。
  3. 访问方式:根据变量类型的不同,局部变量表提供了不同的操作指令来访问和操作这些变量。如果是一个基本类型的局部变量,可以直接通过其索引进行读取和写入操作;如果是一个引用类型的局部变量,则需要先加载该引用到操作数栈上才能进行操作。
  4. 方法参数:方法的参数也存储在局部变量表中,它们按照顺序排列在局部变量表的前几个槽位上。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class App {
// 构造方法
public App(){

}
public static void main(String[] args) {
int i = 1;
float f = 1.2f;
double d = 2.2;
char c = 'A';
String s = "123";
}
// 实例方法
public void method(){

}
}

反编译:

image-20240114200919186

image-20240114223234634

  • 局部变量表最基本的存储单元是槽(Slot32位
  • 4字节以内的类型占一个槽,8字节的类型(long、double)占用两个槽。如上图double类型占槽3,4
  • boolean/byte/short/char在存储前被转换成int,对于boolean0表示false非0表示true
  • float直接存储,不需要转换。
  • 引用类型占一个槽。如上图String[]String类型引用分别占槽06
  • 如果当前帧是由构造方法实例方法创建的,那么方法所属实例对象的引用(this)会存储在槽0中。
1
2
3
4
5
6
7
8
public void method(){
int a = 1;
{
int b = 2;
a = b + 1;
}
int c = 3;
}

image-20240114225205123

  • 槽位复用,变量bc共用槽2

操作数栈

由数组实现。操作数栈的大小在编译时确定,当一个方法开始执行,栈帧中的操作数栈会被创建,但此时是一个空栈。

  • 操作数栈基本的存储单元是槽(Slot32位
  • 4字节以内的类型占一个槽,8字节的类型(long、double)占用两个槽。
  • boolean/byte/short/char在存储前被转换成int,对于boolean0表示false非0表示true
  • float直接存储,不需要转换。
  • 引用类型占一个槽。

数据类型的存放与局部变量表一致,但是操作数栈不能通过索引,只能以出/入栈的方式对数据进行存取。

栈顶缓存技术:是一种优化技术,用于提高计算机系统的性能。它通过将操作数栈的顶部元素缓存在寄存器上,减少对内存的访问次数,从而加快程序的执行速度。

本地方法栈

实质与虚拟机栈基本一致,只不过栈帧所对应的是本地方法。

不管是虚拟机栈还是本地方法栈,它们都不是GC回收的对象。

前言

image-20240115192535726

内存划分

image-20240117130129059

image-20240116134646337

  1. 新生代(Young Generation):
    • Eden区:作为对象的初始分配区域,大部分对象在Eden区被创建。
    • Survivor区(通常称为FromTo区):用于存放在Eden区中存活下来的对象。
    • 对象在新生代经历多次垃圾回收后,如果仍然存活,会被移到老年代。
  2. 老年代(Old Generation):
    • 存放长时间存活的对象,包括从新生代经历多次垃圾回收后存活下来的对象。
    • 垃圾回收对老年代的操作相对较少,并且通常会触发更耗时的Full GC(Full Garbage Collection)。

堆的大小可以通过设置JVM的启动参数来调节,例如通过-Xmx-Xms参数可以限制堆的最大和初始大小。默认堆空间大小:初始内存大小为物理内存/64最大内存大小为物理内存/4

1
2
3
4
// 获取初始堆内存
System.out.println(Runtime.getRuntime().totalMemory() / 1024 / 1024 + "M"); // 245M
// 获取最大堆内存
System.out.println(Runtime.getRuntime().maxMemory() / 1024 / 1024 / 1024 + "G"); // 3G

实际开发中建议初始堆内存和最大堆内存设置成一样,避免频繁扩容缩容造成系统压力。

image-20240116145838487

配置新生代与老年代在堆空间的占比:

-XX:NewRatio=4,表示新生代/老年代=4,新生代占整个堆空间的1/5。默认占比为2

配置伊田园与两个幸存者区占比:

-XX:SurvivorRatio=8,表示8:1:1。默认占比为8

对象分配过程

image-20240116184906301

  1. 创建的对象先放伊甸园,(绝大多数情况下)。

  2. 当伊甸园的空间填满时,程序又需要创建对象,垃圾回收器将对新生代进行垃圾回收(MinorGC),对不再被引用的对象进行销毁,其中幸存的对象移动到幸存者1区。再将新对象放到伊甸园区。

  3. 如果之后有对象创建再次触发垃圾回收,新生代中的幸存者移动到幸存者2区,新对象放到伊甸园区。

  4. 如若新生代垃圾回收后伊田园还是放不下新对象,直接晋升到老年代。
  5. 如此往复,每次新生代垃圾回收的幸存者在幸存者1区和幸存者2区之间移动,始终有一个是空的。同时每次新生代垃圾回收后的幸存者年龄会增加,当年龄超过15后,将被移动到老年代。
  6. 当有效幸存者区不能容纳所有幸存者,而且幸存者的年龄还未达到阈值时,JVM会将存活时间较长的对象晋升到老年代。
  7. 若老年代空间不够,对老年代进行垃圾回收(MajorGC),如果回收后还是没有足够空间,则OOM

修改对象年龄阈值:-XX:MaxTenuringThreshold=<N>,默认15

对堆进行分代的唯一理由就是优化GC性能

相关GC

  • Minor GC/Young GC:新生代垃圾回收
  • Major GC/Old GC:老年代垃圾回收
  • Mixed GC:整个新生代和部分老年代垃圾回收
  • Full GC:整个堆区和方法区的垃圾回收。

通常情况下垃圾回收会触发STW,暂停用户线程,等垃圾回收结束,用户线程才恢复运行。也存在一些并发算法,允许用户线程与垃圾回收并发执行。

Major GC一般比Minor GC10倍以上,导致STW时间更长。

TLAB

TLAB是Thread-Local Allocation Buffer(线程本地分配缓冲区)的缩写,是为了提高多线程并发分配对象的效率而引入的一种内存分配技术。

image-20240116193404854

image-20240116193437223

TLAB是虚拟机在堆内存的Eden划分出来的一块专用空间,每个线程都有自己的TLAB,用于存放线程所分配的对象。当一个线程需要分配对象时,它会在自己的TLAB中进行分配,避免了与其他线程的竞争,提高分配对象的效率。只有TLAB占满才会将对象分配到共享Eden中。

可通过-XX:UseTLAB设置是否开启TLAB。默认情况下,TLAB是开启的,且空间占整个Eden空间的1%。可以通过-XX:TLABWasteTargetPercent设置占用Eden空间的百分比。

常用参数

已经提到的参数略。

  • -XX:+PrintFlagsInitial:查看所有参数默认初始值。
  • -XX:+PrintFlagsFinal:查看所有参数最终值。
  • -XX:+PrintGCDetails:程序结束后输出GC处理日志。

逃逸分析

堆是对象分配的唯一选择吗?

在jvm中,对象在堆中分配内存的,这是一个普遍常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。待方法结束,对象随着栈帧一起被回收,这样对象就无须等待堆中的垃圾回收,提高系统效率。

  • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
  • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他方法中或者作为返回值传递回调用方法。

逃逸分析默认开启,使用-XX:+DoEscapeAnalysis显示开启;-XX:+PrintEscapeAnalysis查看逃逸分析筛选结果。

标量替换

  • 标量:是指一个无法再分解数据。
  • 聚合量:相对的,那些还可以分解的数据叫做聚合量,对象就是聚合量,因为它可以分解成其他聚合量或标量。

标量替换是一种优化技术,用于将对象的字段分解为独立的标量,从而避免对整个对象的分配和访问。它可以减少对象的内存占用和提高代码执行效率。

在Java中,对象一般以连续的内存块来表示。但是并不是每个对象都需要被当作整体来处理,某些对象只包含字段并且字段间可以被视为相互独立的标量。在JIT阶段,如果经过逃逸分析,发现一个对象不会被外部方法访问,那么经过JIT优化,就会把这个对象拆解成若干个标量来替代,存储在栈上的局部变量表中。

-XX:+EliminateAllocations:开启标量替换(默认打开),允许将对象打散分配在栈上。

方法区

前言

各个线程共享的内存区域。在JVM启动的时候创建,实际物理内存空间可以是不连续的。所占内存大小可以是固定或者动态的。这些都与堆一致。

方法区决定了系统可以保存多少个类(包括一些隐含的类),如果系统定义了太多的类,将抛出OOM

-XX:MetaspaceSize-XX:MaxMetaspaceSize指定初始元空间大小和最大元空间大小。一旦触及水位线,Full GC将被触发并卸载没用的类,之后根据GC后释放的内存大小决定提高还是降低水位线。为了避免频繁GC以及水位线的调整,建议将初始水位线设置为相对较高的值。

永久代、元空间广义上都是方法区。元空间(jdk1.8及之后)和永久代(jdk1.7及之前)最大的区别:元空间并不占用虚拟机内存,而是使用本地内存,默认情况下,元空间的大小仅受本地内存限制。

内部结构

  1. 类型信息

    对每个加载的类型(类、接口、枚举、注解),jvm必须在方法区中存储以下类型信息:

    类型全名(包名+类名)、类型修饰符(public、abstract等)、直接父类全名(extends ClassName)、直接接口的有序列表(implements Interface1, interface2)。

  2. 域信息(成员变量)

    必须在方法区中保存类型的所有域相关信息以及域的声明顺序。相关信息包括:域名称、域类型、域修饰符。

  3. 方法信息

    方法名称、返回类型、参数数量和类型、方法修饰符、方法字节码指令、操作数栈大小、局部变量表、异常表等。

  4. 运行时常量池

    字节码中的常量池加载到方法区后就成为了运行时常量池,此时符号引用转换成直接引用(真实地址)。常量池保存编译期间已经确定的各种数据及它们的引用。相同内容的常量在常量池中只会保存一份

    注:jdk1.8及之后字符串常量和静态变量保存在堆中,其他常量则保存在方法区。

    将字符串常量放到堆中的原因:字符串常量占比较大,放在方法区不容易被垃圾回收,相反放在堆中能够更及时回收内存。

可以用以下命令将字节码文件反编译到txt中查看:

1
javap -v -p App.class > app.txt

垃圾回收

方法区主要回收两部分内容:常量池中废弃的常量和不再使用的类型。

对于常量,只要其没有被其他地方引用,就可以被回收。与堆中对象回收类似。

判定一个类型是否不再被使用,条件相对苛刻,需同时满足以下三个条件才可能被回收:

  1. 该类型所有的实例以及派生子类的实例都已经被回收;
  2. 加载该类型的类加载器已经被回收;
  3. 该类型的Class对象没有在任何地方被引用,例如反射。

对象实例化

创建方式

  1. new
  2. ClassnewInstance():反射,只能调用空参构造器,权限必须public。
  3. ConstructornewInstance():反射,可以调用空参、带参构造器,权限没有要求。
  4. clone():不调用构造器,当前类需实现Cloneable接口,实现clone()方法。
  5. 反序列化:将从文件或网络中获取的二进制流反序列化成对象。
  6. 第三方库Objenesis

创建步骤

仅讨论普通Java对象。

  1. 判断对象所属类是否已加载到内存,如果未加载,则先使用类加载器将类加载到内存中。

  2. 为对象分配内存,对象所需的内存的大小在类加载完成后便可完全确定。分配方式有指针碰撞(Bump the Pointer)和空闲列表(Free List)两种方式:

    指针碰撞:堆空间规整存放,指针指示边界点,对象分配在边界点处,之后指针挪动对象大小距离。

    空闲列表:堆空间不规整,虚拟机会维护一个列表,记录哪些内存块是可用的,将对象分配在空闲块中并更新列表。

    选择哪种分配方式由堆空间是否规整决定,而堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

  3. 初始化分配到的空间:为实例变量设置默认值

  4. 设置对象头:将对象的所属类信息、HashCode、GC信息、锁信息等数据存储在对象头中。

  5. 执行init方法进行初始化:实例变量显式赋值、执行实例化代码块、调用构造方法。显示赋值以及执行完代码块后才会调用构造方法,其余按照定义顺序执行。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    public class App {
    public static void main(String[] args) {
    Customer customer = new Customer(); // 创建对象
    }
    }
    class Customer{
    int id = 66; // 显式赋值66
    String name;
    Account acct;
    // 实例化代码块
    {
    name = "围巾";
    }
    // 构造器方法
    public Customer(){
    acct = new Account();
    }
    }
    class Account{

    }

内存布局

具体细节已在JUC中提到。

  1. 对象头:包括元数据和类型指针,如果是数组还需记录数组长度。
  2. 实例数据:存储各种类型的字段,包括从父类继承下来的字段。
  3. 对齐填充

访问定位

对象访问方式主要有两种:句柄访问和直接指针(hotspot采用)。

句柄访问:可以看作是对实际数据的间接引用。它允许程序访问内存中的对象而不直接操作指针,隐藏了数据的具体位置。这种方式可以提高程序的稳定性和安全性,因为它不直接暴露指针。

image-20240119224619853

直接指针:是直接访问内存地址的方法。提供了更直接的控制和灵活性,但也更容易导致错误,如空指针引用和内存泄漏等问题。

image-20240119225704399

总结图示

以上方临近代码为例,其内存示意图如下所示:

image-20240119222507804

执行引擎

前言

执行引擎需要执行的字节码指令依赖于当前线程的程序计数器。

  • 解释器:将字节码指令逐行解释成机器指令并立即执行。
  • JIT编译器:将热点字节码指令编译成机器指令保存在方法区的代码缓存区域中。

默认情况下HotSpot VM采用解释器与即时编译器并存的架构,也可以根据具体的应用场景,通过命令显式地为虚拟机指定程序的执行模式:

  • -Xint:完全采用解释器执行程序。
  • -Xcomp:完全采用即时编译器执行程序。如果即时编译出现问题,解释器会介入。
  • -Xmixed:采用解释器+即时编译器混合模式共同执行程序。

各类编译器:

  • 前端编译器:将源代码编译成字节码。Javac以及ECJ
  • 后端编译器:将字节码编译成机器码。hotspot vm的JIT编译器。
  • 静态提前编译器:直接将源代码编译成机器码。AOT编译器。

JIT

根据代码被调用执行的频率,JIT编译器在运行时会将那些频繁被调用的代码直接编译为机器指令缓存起来,以此提升程序的执行性能。一个被多次调用的方法或者是一个方法体内部循环次数较多的循环体都可以被称之为热点代码。目前HotSpot VM所采用的热点探测方式是基于计数器的热点探测。HotSpot VM会为每一个方法都建立2个不同类型的计数器,分别为方法调用计数器回边计数器

  • 方法调用计数器

    用于统计方法的调用次数。

  • 回边计数器

    用于统计方法中循环体的循环次数。

默认阈值(指的是两计数器值之和)在Client模式下是1500次,在Server模式下是10000次。超过这个阈值,就会触发JIT编译(编译的是整个方法)。这个阈值可以通过虚拟机参数-XX:CompileThreshold设定。当一个方法或者循环体被调用时,会先检查是否存在被JIT编译后的机器代码,如果存在,直接使用编译后的机器代码来执行。如果不存在,则使用解释器执行并且将对应的计数器值加1,然后判断方法调用计数器与回边计数器值之和是否超过阈值。如果超过阈值,那么将会向JIT编译器提交该方法的代码编译请求。

如果不做任何设置,方法调用计数器统计的并不是被调用的绝对次数,而是一段时间之内被调用的次数。当超过一定的时间限度,如果总调用次数仍然不足以将方法提交给JIT编译器编译,那这个方法调用计数器值就会被减少一半(通常不将回边计数器值减半),这个过程称为热度衰减,而这段时间就称为半衰周期

可以使用虚拟机参数-XX:-UseCounterDecay来关闭热度衰减,让方法调用计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被JIT编译成机器代码进行缓存。另外,可使用-XX:CounterHalfLiferime参数设置半衰周期的时间。

C1/C2编译器

C1和C2编译器是指两种不同的JIT编译器。

  1. C1编译器(Client Compiler)又称为Client模式的编译器或者轻量级编译器。它是JVM中的一种快速编译器,主要针对客户端应用场景进行优化。C1编译器的目标是快速地将字节码编译为本地机器码,并进行简单的优化。它的编译速度快,但生成的机器码优化程度较低。

  2. C2编译器(Server Compiler)又称为Server模式的编译器或者高性能编译器。它是JVM中的一种高级优化编译器,主要针对服务器端应用场景进行优化。C2编译器会进行更加复杂和深入的优化,以生成高性能的本地机器码。它的编译速度相对较慢,但生成的机器码优化程度较高。

在JVM运行时,通常会根据程序的执行情况和环境的要求,在C1和C2编译器之间进行动态切换。通常在应用程序启动初期使用C1编译器进行快速编译,后续随着代码热点的识别和性能分析,逐渐切换到C2编译器进行更深层次的优化。

  • -client:指定虚拟机运行在Client模式下,使用C1编译器。
  • -server:指定虚拟机运行在Server模式下,使用C2编译器。

64位操作系统只能使用Server模式,就算设置了-client参数也会被忽略。但是Server模式下也可能使用到C1编译器,只不过C2编译器被视为主要的编译器。

Graal/AOT编译器

自JDK10起,Hotspot又加入一个全新的即时编译器,Graal编译器。编译效果追平了C2编译器。未来可期。

自JDK9引入了AOT编译器。在程序运行之前,便将字节码转换为机器码的过程。

String

基本特性

image-20240120194535026

  • String类被final修饰,不可被继承。

  • String类实现了Serializable接口,表示支持序列化;实现了Comparable接口,表示可以比较大小。

  • String类在JDK8及以前内部定义了final char[] value用于存储字符串数据。JDK9时改为byte[]

  • 通过字面量的方式(区别于new)给一个String变量赋值,此时字面量存储在字符串常量池中。

    1
    2
    String s1 = "abc"; // abc为字面量,存储在字符串常量池
    String s2 = new String("abc");

字符串常量池

字符串常量池是一个Hashtable(数组加链表)。如果放进常量池的字符串非常多,就会造成Hash冲突严重,从而导致链表过长,而链表长了后将使性能大幅下降。使用-XX:StringTableSize可设置StringTable的长度。

  • JDK6中,StringTable长度默认是1009,如果常量池中的字符串过多就会导致效率下降很快。
  • JDK7中,StringTable长度默认是60013
  • JDK8中,StringTable长度默认是600131009是可设置的最小值。

案例

前言:

==:对于基本数据类型比较的是它们之间的值是否相等。对于引用类型,比较的是内存地址是否相等。

equals:该方法是Object类的一个方法,所有的类都继承于Object这个超类。JDK1.8 Object类equals方法源码如下,即返回结果取决于两个对象的使用==判断结果。

image-20240121210629318

在实际使用中,其它类一般会重写equals方法,如JDK1.8 String类的equals源码如下:

image-20240121210909188

即两个字符串使用==相等或者所有组成字符都相等返回true。

总结:

==:基本类型,比较值是否相等;引用类型,比较内存地址值是否相等。

equals:就算两个字符串内存地址不一致,值相同就会返回 true。

案例:

1
2
3
String s1 = "a";
String s2 = "b";
String s3 = "a";

"a"存储在字符串常量池中且只有一份。s1s3均指向常量池中的"a"。以下图示证明:

image-20240121200025020

image-20240121200152769

image-20240121200331888

1
2
3
4
5
6
7
8
9
10
11
12
public class App {
public static void main(String[] args) {
int i = 1;
Object obj = new Object();
App app = new App();
app.foo(obj);
}
private void foo(Object param){
String str = param.toString().intern();
System.out.println(str);
}
}

内存布局如下图,其中省略了一些局部变量(如 this、args):

image-20240121203734614

1
2
3
String s1 = "ab";
String s2 = "a"+"b";
System.out.println(s1 == s2); // true

常量与常量的拼接结果保存在常量池中,原理是编译期优化

1
2
3
4
String s1 = "a";
String s2 = "ab";
String s3 = s1 + "b";
System.out.println(s2 == s3); // false
1
2
3
4
5
6
7
final String s1 = "a";
String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
String s5 = s1 + "b";
System.out.println(s3 == s4); // false
System.out.println(s3 == s5); // true

只要有一个是变量,结果保存在堆中新创建的String对象中。原理是StringBuilder。注意 final 修饰的变量就是常量。

image-20240121222434779

1
2
3
4
5
final String s1 = "a";
final String s2 = "b";
String s3 = "ab";
String s4 = s1 + s2;
System.out.println(s3 == s4); // true

没有使用StringBuilder,结果直接指向字符串常量池中的ab,原理是编译期优化

image-20240121223432005

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
String s1 = "JavaEE";
String s2 = "Hadoop";
String s3 = "JavaEEHadoop";
String s4 = "JavaEE" + "Hadoop";
String s5 = s1 + "Hadoop";
String s6 = "JavaEE" + s2;
String s7 = s1 + s2;
String s8 = s7.intern();
System.out.println(s3 == s4); // ture
System.out.println(s3 == s5); // false
System.out.println(s3 == s6); // false
System.out.println(s3 == s7); // false
System.out.println(s5 == s6); // false
System.out.println(s5 == s7); // false
System.out.println(s6 == s7); // false
System.out.println(s3 == s8); // true
1
2
3
4
5
6
7
8
9
10
11
12
13
static final int LENGTH = 10000;

public static void main(String[] args) {
String s1 = "";
for (int i = 0; i < LENGTH; i++) {
s1 = s1 + "a";
}

StringBuilder s2 = new StringBuilder();
for (int i = 0; i < LENGTH; i++) {
s2.append("a");
}
}
  • 第一个循环体

    每次循环都会创建一个StringBuilder以及通过toString新建一个String对象,共新建20000个对象。而且之后进行 GC 还会增加额外的开销。

  • 第二个循环体

    只需新建一个StringBuilder对象。不过StringBuilder采用了扩容技术,存满后会再新建一个字符数组再将原来的数据复制过去,这期间会增加一些性能损耗。可以通过new StringBuilder(10000)提高初始长度,减少扩容次数。默认长度 16

  • 总结

    明显第二个循环体执行效率更高效。

new String

String str = new String("ab")创建了几个对象?

答案是2个,一个是中新建的 String 对象,另一个为字符串常量池中的 ab,当然前提是常量池中原本不存在 ab。此时 str 指向的是堆中的 String 对象而不是指向常量池的 ab

注意:常量池String对象中都保存了 ab

image-20240122094548839

String str = new String("a") + new String("b")呢?

答案是 6 个:

  1. new StringBuilder
  2. new String 存放 a
  3. 常量池中的 a
  4. new String 存放 b
  5. 常量池中的 b
  6. toString 生成 String 存放 ab

同理,str 指向的是 toString 生成的 String 对象。注意常量池中并没有 ab

image-20240122095654923

intern

String.intern():如果当前字符串存在于字符串常量池,那直接返回此字符串在常量池的引用;如果当前字符串不在字符串常量池中,那么在常量池创建该字符串,然后返回常量池中的引用。

1
2
3
4
5
6
7
8
9
String s1 = new String("1"); // s1 指向堆中 String 对象
s1.intern(); // 此时 1 已在常量池中,该语句无意义
String s2 = "1"; // s2 指向常量池中的 1
System.out.println(s1 == s2); // false

String s3 = new String("1") + new String("1"); // s3 指向堆中 String 对象
s3.intern(); // 向常量池存入 11
String s4 = "11"; // s4 指向常量池中的 11
System.out.println(s3 == s4); // jdk6 false; jdk7/8 true

造成差异的原因就是 s3.intern(),执行该语句确实会向常量池中存入 11,但是在 jdk6 中是直接在常量池中创建一个 11 并返回新地址。而在 jdk7/8 中是在常量池中创建一个原本在堆中已经存在值为 11String 对象引用,所以实际上常量池中的 11 与堆中 String 对象中的 11 地址相同。

如果堆中原本不存在所需字符串对象,那么也是直接在常量池中进行创建。

如果堆中存在所需字符串对象并且常量池中也已存在,那么 intern 返回的是常量池中的引用。

1
2
3
4
String s3 = new String("1") + new String("1"); // s3 指向堆中 String 对象
String s4 = "11"; // s4 指向常量池中的 11
s3.intern(); // 常量池已存在 11,该语句无意义
System.out.println(s3 == s4); // false

对换 23 行,不管是哪个 jdk 版本结果都为 false

1
2
3
4
String s1 = new String("a") + new String("b");
String s2 = s1.intern();
System.out.println(s1 == "ab"); // jdk6 false jdk7/8 true
System.out.println(s2 == "ab"); // jdk6 true jdk7/8 true
1
2
3
4
5
String x = "ab";
String s1 = new String("a") + new String("b");
String s2 = s1.intern();
System.out.println(s1 == "ab"); // false
System.out.println(s2 == "ab"); // true
1
2
3
4
String s1 = new String("ab"); // 执行完这条指令,常量池中已经存在 ab
s1.intern();
String s2 = "ab";
System.out.println(s1 == s2); // false

空间效率测试:

1
2
3
4
5
6
7
8
9
10
static final int MAX_NUM = 1000 * 10000;
static final String[] arr = new String[MAX_NUM];

public static void main(String[] args) {
Integer[] data = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
for (int i = 0; i < MAX_NUM; i++) {
// arr[i] = String.valueOf(data[i % data.length]); // ①
// arr[i] = String.valueOf(data[i % data.length]).intern(); // ②
}
}

如果循环体中①,执行过程中会在堆中创建一千万个 String 对象,这些对象被数组 arr 引用,不会被回收。

如果循环体为②,执行过程中同样会在堆中创建一千万个 String 对象并且会往常量池中存储110的字符串常量。但是此时被 arr 引用的是常量池中的字符串并非 String 对象,所以经过 GC 后堆中的一千万个 String 对象基本都会被回收,只留下最初10String 对象(jdk7/8),因为被常量池引用着。

结论:使用 intern 空间效率更高。

注意:String.valueOf().intern() 既会在堆中创建 String 对象也会将字符串存入常量池。其它如newString().intern()toString().intern()等同理。

垃圾回收

前言

对于C/C++,垃圾回收基本上是手工进行的。开发人员可以使用new关键字进行内存申请,并使用delete关键字进行内存释放。这种方式可以灵活控制内存释放的时间,但是会给开发人员带来管理负担。倘若存在有内存区忘记被回收,就会产生内存泄漏,甚至导致内存溢出。

现在,除了Java以外,C#、Python、Ruby等语言都使用了自动垃圾回收的思想,也是未来发展趋势。可以说,这种自动化内存分配和垃圾回收的方式己经成为现代开发语言必备的标准。

标记算法

垃圾标记阶段:在GC之前,首先需要区分出内存中哪些是存活对象,哪些是己经死亡的对象。只有被标记为己经死亡的对象,才会在垃圾回收时,释放掉其所占用的内存空间。判断对象是否存活一般有两种方式:引用计数算法可达性分析算法

1)引用计数算法

该算法比较简单,每个对象保存一个整型的引用计数器属性。用于记录对象被引用的情况。当对象被引用或者引用失效,引用计数器则相应的加 1 减 1。计数器值为 0 时,表示对象不再被使用,可进行回收。

优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。

缺点:

  • 需要单独的字段存储计数器,增加了空间开销

  • 每次引用变更都需要更新计数器,增加了时间开销

  • 无法处理循环引用的情况。是一个致命缺陷,导致了Java的垃圾回收器中没有使用这类算法。

    image-20240122210646789

2)可达性分析算法

根对象集合(GC Roots):一组活跃的引用,可以理解为由堆外指向堆内的引用(非绝对)。GC Roots 包括以下几类:

  1. 虚拟机栈中的引用:包括引用类型的参数和局部变量。
  2. 类静态属性引用:类中引用类型静态变量。
  3. 常量引用:指向常量的引用,如String s = "a"中的引用 s,它所指向的常量不会被垃圾回收。
  4. 本地方法栈中JNI(Java Native Interface)引用:本地方法中对Java对象的引用。
  5. 锁引用:如 synchronized(obj) 中的引用 obj,它指向的对象不会被垃圾回收,否则线程将获取不到锁。
  6. etc ..

可达性分析算法以GC Roots为起始点,搜索目标对象是否可达。使用该算法后,内存中的存活对象都会被GC Roots直接或间接引用着,GC Roots到目标对象的路径称为引用链(Reference Chain)。如果目标对象不可达,意味着该对象己经死亡。

相对于引用计数算法而言,可达性分析算法不仅同样具备实现简单和执行高效等特点,更重要的是该算法可以有效地解决循环引用的问题,防止此类内存泄漏的发生。该算法是 Java 虚拟机使用的标记算法。

image-20240122214412072

清除算法

目前在 JVM 中比较常见的三种垃圾清除算法分别是标记-清除算法(Mark-Sweep)复制算法(Copying)标记-压缩算法(Mark-Compact)

1)标记-清除算法

原理:

当堆中的有效内存空间被耗尽的时候,就会停止整个程序,然后进行两项工作,第一项是标记, 第二项是清除。

  1. 标记:从 GC Roots 开始遍历,标记所有引用链上的对象。一般是在对象头中记录为可达对象。
  2. 清除:对所有对象进行遍历,如果发现某个对象的对象头中没有标记为可达,则将其回收。

缺点:

  • 效率不高。

  • 清理出来的空闲内存是不连续的,容易产生内存碎片。并且需要维护一个空闲列表。

注意:这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲列表里。

2)复制算法

原理:

将可用内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存块中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象。新生代的垃圾回收使用的就是复制算法,幸存者 1/2 区只有一块会被使用。

优点:

  • 实现简单,运行高效。

  • 保证了空间的连续性,不会出现内存碎片。

缺点:

  • 需要增加额外的内存空间。
  • 因为改变了存活对象的地址,所以需要更新对象引用,使其指向新的位置。

由于大部分对象在新生代就会死亡,所以实际上需要复制的存活对象以及维护的引用不会很多,这也是为什么新生代使用该算法的原因。

3)标记-压缩算法

原理:

  1. 标记:和标记-清除算法一样, 从 GC Roots 开始标记所有存活对象。

  2. 压缩:将所有存活对象移动到内存的一端,紧凑排列。

  3. 清除:将其余空间释放。

优点:

  • 消除了标记-清除算法当中空闲内存分散的缺点,当需要给新对象分配内存时,只需持有内存边界地址即可,这比维护一个空闲列表少了许多开销。

  • 消除了复制算法当中额外内存的代价。

缺点:

  • 算法效率较低。

  • 需要更新引用。

是用于老年代的垃圾回收算法。

总结

标记-清除算法 标记-压缩算法 复制算法
时间开销 中等 最慢 最快
空间开销 少(存在碎片) 少(没有碎片)
移动对象

这些算法在垃圾回收过程中,程序将处于Stop The World(STW)状态。在该状态下,程序将暂停执行,等待垃圾回收的完成。如果垃圾回收时间过长或者次数过多,将严重影响程序执行效率。

finalization

对象终止(finalization)机制,允许开发人员提供对象被销毁之前的自定义处理逻辑。垃圾回收某个对象之前,会自动调用这个对象的finalize()方法。是 Object 定义的方法,允许子类重写,通常在这个方法中进行一些资源释放和清理的工作,如关闭文件、数据库连接等。

虚拟机中对象可能的三种状态:

  • 可触及的:从根节点开始,可以到达这个对象。
  • 可复活的:虽然某个对象不可达,但是该对象有可能在finalize()中复活。
  • 不可触及的:对象的finalize()已被调用,并且没有复活,那么就会进入不可触及状态。

对象的finalize()只会被调用一次,如果在 finalize() 方法中对象被复活(即重新回到引用链上),那么在下次垃圾回收此对象之前,finalize() 方法将不会再次被调用。当然如果对象没有重写 finalize() 方法,那就一次也不会被调用。

如果垃圾回收前还未执行过对象的finalize()方法,对象会被插入到一个队列中,由Finalizer线程执行其finalize()方法。该线程是一个低优先级线程,所以不能确保finalize()方法会被立刻执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class MyObject {
public static void main(String[] args) {
MyObject obj = new MyObject();
obj = null; // 使对象变为不可达
System.gc(); // 触发垃圾回收
}

@Override
protected void finalize() throws Throwable {
try {
// 执行资源释放或清理操作
System.out.println("Finalize method called");
} finally {
super.finalize();
}
}
}

其他思想

  1. 分代收集算法

    不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式。例如把堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。

  2. 增量收集算法

    它让垃圾收集线程和应用程序线程交替执行。每次垃圾收集线程只完成收集任务的一部分,接着切换到应用程序线程。依次反复,直到垃圾收集完成。

  3. 分区算法

    将内存空间划分为多个不同的区域,每个区域独立管理。每次清理若干区域。

手动回收

通过调用System.gc()或者Runtime.getRuntime().gc(),会触发Full GC,同时对堆和方法区进行垃圾回收。但是不能保证能够立即触发回收。

image-20240123204123724

如上图所示:System.gc()底层实际上直接调用了Runtime.getRuntime().gc()

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class App {
public static void main(String[] args) {
{
App app = new App();
}
System.gc();
}

@Override
protected void finalize() {
System.out.println("执行回收前操作");
}
}

image-20240123210036350

执行后 App 对象并没有被回收,因为还被局部变量表中的 app 引用着。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class App {
public static void main(String[] args) {
{
App app = new App();
}
int num = 1; // 添加这行代码
System.gc();
}

@Override
protected void finalize() {
System.out.println("执行回收前操作");
}
}

image-20240123210402388

执行后 App 对象被回收了。因为代码块执行结束后局部变量表中的 app 实际上已经没用了,之后的 num 复用了原本 app 所占用的位置,导致 App 对象失去引用而被回收。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class App {

public static void main(String[] args) {
m1();
System.gc();
}

private static void m1(){
App app = new App();
}

@Override
protected void finalize() {
System.out.println("执行回收前操作");
}
}

执行后 App 对象被回收,原因是 m1 方法执行结束后所属栈帧会随之退栈,局部变量表中的引用也随之被销毁。

工作方式

  • 串行:单线程执行垃圾收集任务,同样用户线程暂停。
  • 并行:多条垃圾收集线程同时工作,此时用户线程暂停。
  • 并发:用户线程与垃圾收集线程交替执行。

image-20240124223212668

安全点/区域

安全点(Safe Point):

用户线程并不是在任何一个时间点上都可以被挂起,只有在特定的位置上才能暂停用户线程从而执行垃圾收集线程,这些位置成为安全点。

在 GC 时,如何确保所有线程都处于安全点?

  • 抢先式中断(没有采用):

    首先中断所有线程,如果有线程不在安全点上,就恢复线程,让线程跑到安全点。

  • 主动式中断

    设置一个中断标志,各个线程运行到安全点的时候主动检查这个标志, 如果中断标志为真,则自行挂起。

安全区域(Safe Region):

安全区域中的任何位置都可以是安全点。用户线程在安全区域中的任何位置都可以挂起。

引用

  1. 强引用(StrongReference):只要引用关系还在,对象就不会被回收。通常创建的引用就是强引用。

  2. 软引用(SoftReference):内存不足时才会对软引用指向的对象进行回收。

    1
    2
    3
    4
    5
    Object obj1 = new Object();
    SoftReference<Object> obj2 = new SoftReference<>(obj1);
    obj1 = null; // 销毁强引用
    // 或
    SoftReference<Object> obj = new SoftReference<>(new Object());
  3. 弱引用(WeakReference):只要进行了 GC,弱引用指向的对象就会被回收。

    1
    2
    3
    4
    WeakReference<String> str = new WeakReference<>(new String("abc"));
    System.out.println(str.get()); // abc
    System.gc();
    System.out.println(str.get()); // null
  4. 虚引用(PhantomReference):无法通过虚引用来获得对象的实例,也不会对对象的生命周期构成影响,使用虚引用的唯一目的就是能在指向对象被回收时收到通知。虚引用在创建时必须提供一个引用队列作为参数。当虚引用指向的对象被回收时,会将这个虚引用加入引用队列以进行通知。

    1
    2
    ReferenceQueue<Object> queue = new ReferenceQueue<>();
    PhantomReference<Object> phantomRef = new PhantomReference<>(new Object(), queue);

image-20240124143551150

垃圾回收器

种类

  • 串行回收器

    Serial:第一款垃圾回收器,分为 Serial 和 Serial Old。

  • 并行回收器

    ParNew:是 Serial GC 的多线程版本;

    Parallel:JDK6 后成为默认回收器。分为 Parallel Scavenge 和 Parallel Old。

  • 并发回收器

    CMS:是一种以最短停顿时间为目标的回收器;

    G1:用于替代 CMS,JDK9 后成为默认回收器。

etc ..

已列举回收器作用域及组合图示:

image-20240124220823699

  1. Serial Old 作为 CMS 后备预案;
  2. JDK8 将 Serial+CMS、ParNew+Serial Old这两个组合声明废弃,并在 JDK9 中完全取消对这些组合的支持;
  3. JDK14 废弃Parallel Scavenge 和 Serial Old 组合;
  4. JDK14 删除了 CMS 垃圾回收器。

查看默认使用的垃圾回收器:

1
java -XX:+PrintCommandLineFlags -version

image-20240124222550039

Serial

  • Serial GC 采用串行回收、复制算法、STW 方式进行垃圾回收。
  • Serial Old GC 采用串行回收、标记-压缩算法、STW 方式进行垃圾回收。

使用 -XX:+UseSerialGC 指定年轻代使用 Serial GC 以及老年代使用 Serial Old GC。

ParNew

ParNew GC 除了采用并行回收的方式回收内存外,与 Serial GC 之间几乎没有任何区别。ParNew GC 作用于年轻代中,采用复制算法、STW 方式进行垃圾回收。

  • -XX:+UseParNewGC 指定新生代使用 ParNew GC,不影响老年代。
  • -XX:ParallelGCThreads 设置并行线程数量,默认开启和 CPU 相同数量的线程。

Parallel

  • Parallel Scavenge GC 采用复制算法、并行回收、STW 方式进行垃圾回收。
  • Parallel Old GC 采用标记-压缩算法、并行回收、STW 方式进行垃圾回收。

-XX:+UseParallelGC:指定新生代使用 Parallel GC;-XX:+UseParallelOldGC:指定老年代使用 Parallel Old GC。这两个参数只要开启一个,另一个会被自动开启。

-XX:ParallelGCThreads:设置新生代并行线程数。在默认情况下,当 CPU 数小于或等于 8 个,线程数等于 CPU 数量。当 CPU 数大于 8 个时,线程数等于$(3+\lfloor5*CpuCount/8\rfloor)$。

-XX:MaxGCPauseMillis:设置最大 STW 时间。为了尽可能地把停顿时间控制在 MaxGCPauseMillis 以内,回收器在工作时会调整堆大小或者其他一些参数。

-XX:GCTimeRatio:垃圾回收时间占总时间的比例 = 1/(N+1)。取值范围 [0,100]。默认值 99,也就是垃圾回收时间不超过总时间的 1%。用于衡量吞吐量(用户线程运行时间占总运行时间的比例)的大小。

-XX:+UseAdaptiveSizePolicy:开启 Parallel Scavenge GC 自适应调节功能。在这种模式下,新生代的大小、Eden 和 Survivor 的比例、晋升老年代的对象年龄等参数会被自动调整。以达到堆大小、吞吐量和停顿时间之间的平衡点。

CMS

采用标记-清除算法,存在 STW,不过停顿时间很短。

image-20240125152905326

  1. 初始标记 (Initial-Mark):在这个阶段中,程序中所有的工作线程都将会因为 STW 而出现短暂的暂停,这个阶段的主要任务是标记出 GC Roots 直接引用的对象。
  2. 并发标记(Concurrent-Mark):从 GC Roots 的直接引用对象开始遍历整个对象图,这个过程耗时较长但是不需要停顿用户线程。
  3. 重新标记(Remark):由于在并发标记阶段中,程序的工作线程在此期间可能修改了对象的引用关系,因此必须对并发标记期间因引用关系修改而导致标记产生变动的那一部分对象重新标记。
  4. 并发清除(Concurrent-Sweep):清除死亡的对象,释放内存空间。由于不需要移动存活对象,所以这个阶段可以与用户线程并发。

由于最耗费时的并发标记与并发清除阶段都不需要暂停其他线程工作,所以整体的回收是低停顿的。

另外,由于在垃圾回收时用户线程还需执行,所以在 CMS 回收过程中,还应该确保用户线程有足够的内存可用。因此,CMS GC 不能像其他回收器那样等到老年代完全被填满了再进行 GC,而是当内存使用率达到某一阀值时,便开始进行回收,以确保用户线程在 CMS 工作过程中依然有足够的空间支持程序运行。要是 CMS 运行期间预留的内存无法满足程序需要,这时虚拟机将启动后备预案,临时启用 Serial Old GC 来对老年代进行垃圾回收。

  • -XX:+UseConcMarkSweepGC:指定使用 CMS GC 执行内存回收任务。开启参数后会自动将 ParNew GC 设置为新生代 GC。即:ParNew (Young Gen)+ CMS(Old Gen)+ Serial Old(Old Gen)。
  • -XX:CMSInitiatingOccupanyFraction:设置老年代内存使用率的阈值,一旦达到该國值,便开始进行回收。
  • -XX:+UseCMSCompactAtFullCollection: Full GC 后启动压缩整理阶段。
  • -XX:CMSFullGCsBeforeCompaction:设置在执行多少次 Full GC 后对内存空间进行压缩整理。需要设置前一个参数。
  • -XX:ParallelCMSThreads:设置 CMS 线程数量。

G1

G1 把内存划分为多个大小相等的区域(Region),能够针对每个区域来进行垃圾回收。在选择进行垃圾回收的区域时,它会优先回收死亡对象较多的区域。

G1 增加了一种新的内存区域 Humongous。主要用于存储大对象。如果一个 H 区装不下,那么会寻找连续的 H 区来存储。G1 的大多行为都把 H 区当成老年代的一部分看待。

image-20240125165213020

G1 提供了三种垃圾回收模式:Young GC、Mixed GC 和 Full GC,在不同的条件下被触发:

在新生代,Eden 不足时触发 Young GC,采用的仍然是并行的复制算法,所以同样会发生 Stop-The-World 的暂停。回收时会从年轻代移动存活对象到幸存者区或者老年代。当堆占用率达到一定值时(默认45%),开始对老年代进行标记(与 CMS 基本一致)。标记完成后触发 Mixed GC。GC 期间,G1 从 Old Region 移动存活对象到空闲区域。注意 Mixed GC 只会回收部分 Old Region,在回收老年代的同时也会捎带对部分新生代进行回收。整体上对于老年代采用标记-压缩算法。对于一些解决不了的情况才会触发 Full GC,一次性回收整个堆区和方法区。

-XX:+UseG1GC:指定使用 G1 GC 执行垃圾回收任务。

-XX:G1HeapRegionSize:设置 Region 的大小。值必须是 2 的幂次方,范围是 1MB 到 32MB 之间。

-XX:MaxGCPauseMillis:设置最大 GC 停顿时间。

-XX:ParallelGCThread:设置并行 GC 线程数。最多设置为 8。

-XX:ConcGCThreads:设置并发标记的线程数。

-XX:InitiatingHeapOccupancyPercent:设置触发 Mixed GC 的堆占用率阈值。超过此值,就触发GC。默认值是 45。

字节码文件

image-20240126193805848

image-20240126231134594

字节码文件的总体结构如下:

  1. 魔数

    在 Class 文件开头,唯一作用是确定这个文件是否为一个能被虚拟机接受的有效合法的 Class 文件。魔数值固定为0xCAFEBABE

  2. Class 文件版本

    紧接着魔数的 4 个字节存储的是 Class 文件的版本号。前两个字节表示副版本号 minor_version,后两个字节表示主版本号 major_version。表示 Class 文件是由哪个版本的 JDK 编译而成。

    注意:高版本的 JVM 可以执行由低版本编译器生成的 Class 文件,但是低版本的 JVM 不能执行由高版本编译器生成的 Class 文件。

  3. 常量池

    在版本号之后,紧跟着的是常量池的计数以及若干个常量池表项。在常量池的入口占用 2 字节来表示表项个数。常量池存放编译时期生成的各种字面量和符号引用(包括方法名、类名等,在运行时这些符号引用将替换成内存中对应类和方法的地址,也就是直接引用)。

  4. 访问标志:用 2 个字节表示。

  5. 本类索引,父类索引,接口索引

    存储指向常量池的索引,都使用 2 个字节表示。对于接口,还包括接口计数。

  6. 字段表

    用于描述类或接口中定义的字段(访问符、数据类型、字段名等信息)。字段表中不会列出从父类或者所实现的接口中继承而来的字段,但有可能列出一些其他字段。

  7. 方法表

    与字段表同理,会存储访问符、返回类型、方法参数、方法的字节码指令等信息。

  8. 属性表

    存储一些辅助信息。

指令集

前言

描述符 含义
B byte
C char
D double
F float
I int
J long
S short
Z boolean
V void
L 引用类型
[ 数组类型

首先需注意有些指令的操作码第一个符号和与之对应描述符的区别:

image-20240127135152560

另外此前已经提到过,大部分指令都没有支持 boolean、byte、char 和 short。在处理这些数据类型时,会先将它们符号扩展为 int 类型再进行处理:

image-20240127141631176

加载与存储

1)局部变量压栈指令:

将局部变量表中的数据压入操作数栈。

load

  • xload_n:x 可以是任意数据类型(i、l、f、d、a),n 表示索引,范围 [0, 3]。
  • xload n:当 n 超过 3 时使用。

例如 aload_1 表示将局部变量表中槽索引为 1 的对象引用入栈。

2)常量入栈指令:

const

  • iconst_n:范围 [-1, 5];
  • lconst_n:范围 [0, 1];
  • fconst_n:范围 [0, 2];
  • dconst_n:范围 [0, 1];
  • aconst_null

例如 icons_m1:将 -1 入栈; icons_2:将 2 入栈;aconst_null:将 null 入栈。

push

只能表示 boolean、byte、char、short、int 类型数据。

  • bipush n:范围 [-128, 127];
  • sipush n:范围 [-32768, 32767]。

ldc

用于完成以上指令不能满足的情况,它保存指向常量池的索引。其中 ldc2_w 支持的索引范围更广。

image-20240127152245949

3)出栈装入局部变量表指令:

将操作数栈中栈顶元素弹出,装入局部变量表指定位置。

store

  • xstore_n:范围 [0, 3];
  • xstore n:n > 3。

算术

通常将操作数栈中的数据弹出进行运算,结果再入栈。

  1. 加法:iadd、ladd、fadd、dadd;

  2. 减法:isub、lsub、fsub、dsub;

  3. 乘法:imul、lmul、fmul、dmul;

  4. 除法:idiv、ldiv、fdiv、ddiv;

  5. 取余:irem、lrem、frem、drem;

  6. 取负:ineg、lneg、fneg、dneg;

  7. 自增:iinc;

    例如 iinc 2 by 1 表示将局部变量表索引为 2 的数加 1。不涉及操作数栈。

  8. 位运算

    • 位移:ishl、ishr、iushr、lshl、lshr、lushr;
    • 或:ior、lor;
    • 与:iand、land;
    • 异或:ixor、lxor;
  9. 比较:dcmpg、dcmpl、fcmpg、fcmpl、lcmp。

    比较栈顶两个元素大小后将结果(-1、0、1)入栈。

类型转换

1)宽化类型转换

  • 把 int 类型转换成 long、 float、double 类型。对应的指令:i2l、i2f、 i2d。
  • 把 long 类型转换成 float、double 类型。对应的指令:l2f、l2d。
  • 把 float 类型转换成 double 类型。对应的指令:f2d。

int、long 类型转换成 float 类型,或者 long 类型转换成 double 类型时,可能发生精度丢失。这种损失并不会抛出异常。

image-20240127191650096

2)窄化类型转换

  • 把 int 类型转换成 byte、char、short 类型。对应的指令:i2b、i2c、i2s。
  • 把 long 类型转换成 int 类型。对应的指令:l2i。
  • 把 float 类型转换成 int、long 类型。对应的指令:f2i、f2l。
  • 把 double 类型转换成 int、long、float 类型。对应的指令:d2i、d2l、d2f。

窄化类型转化可能发生溢出以及精度丢失。

image-20240127194858351

如果想将 float、double 转换成 byte、char、short 类型,会先转成 int 类型再转换成对应类型。

1
2
3
double d = 2.3;
short s = (short) d;
// 先执行 d2i 再执行 i2s

对象创建与访问

1)创建指令

  • new:用于创建类实例。
  • newarray:创建基本数据类型数组。
  • anewarray:创建引用类型数组。
  • multianewarray:创建多维数组。

执行时会取出操作数栈中的数用于确定数组大小,对象创建完成后会自动将对象引用入操作数栈。

image-20240127203718029

2)字段访问指令

  • 访问类字段:getstatic、putstatic。
  • 访问实例字段:getfield、putfield。

get 指令会将获取到的字段值入栈。而 put 指令则是弹出栈顶的数值保存到指定字段当中。

3)数组操作指令

  • xastore:弹出栈顶元素存入数组当中。
  • xaload:取出数组元素保存到操作数栈。

4)类型检查指令

  • checkcast:取出栈顶元素检查类型强制转换是否可以进行。如果可以进行,会将原本栈顶元素再入栈;若不能进行将抛出异常。
  • instanceof:取出栈顶元素,判断是否是某个类的实例,会将判断结果入栈。

image-20240127214848915

方法调用与返回

1)方法调用指令

  • invokevirtual(调用虚方法):实际调用的方法在运行时基于对象的类型确定。
  • invokeinterface(调用接口方法):专门用于调用接口上的方法。会调用由特定对象所实现的接口方法。
  • invokespecial(调用特殊方法):用于调用私有方法、实例初始化方法(构造方法)、父类方法等。
  • invokestatic(调用静态方法):用于调用类的静态方法。
  • invokedynamic(调用动态方法):Java7 引入了该指令,支持在运行时动态选择方法。

image-20240127222928310

2)方法返回指令

  • return:返回值为 void 的方法、实例初始化方法。
  • xreturn:x 代表返回类型。将当前操作数栈顶层元素弹出,压入调用者的操作数栈中。

操作数栈管理

  • pop、pop2:将一个或两个槽位从栈顶弹出,并且直接废弃。
  • dup:复制栈顶槽位并将其副本推送到栈顶。
  • dup2:复制栈顶的两个槽位并将复制品推送到栈顶。注意 long、double 本身就占据了两个槽位。
  • dup_x1:复制栈顶槽位并插入栈顶第 2(1+1) 个槽下面。
  • dup_x2:复制栈顶槽位并插入栈顶第 3(1+2)个槽下面。
  • dup2_x1:复制栈顶两个槽位并插入栈顶第 3(2+1) 个槽下面。
  • dup2_x2:复制栈顶两个槽位并插入栈顶第 4(2+2) 个槽下面。
  • swap:将栈顶两个槽位交换位置。
  • nop:在字节码中并不执行任何操作,只是占用一个字节的空间。

控制转移

1)条件跳转指令

条件跳转指令通常和比较指令结合使用。

  • ifeq:栈顶元素为 0 时跳转;
  • ifne:栈顶元素不等于 0 时跳转;
  • iflt:栈顶元素小于 0 时跳转;
  • ifle:栈顶元素小于等于 0 时跳转;
  • ifgt:栈顶元素大于 0 时跳转;
  • ifge:栈顶元素大于等于 0 时跳转;
  • ifnull:栈顶元素为 null 时跳转;
  • ifnonnull:栈顶元素不为 null 时跳转。

2)比较条件跳转指令

  • if_icmpeq:比较栈顶两个 int 类型元素大小,相等时跳转;
  • if_icmpne:比较栈顶两个 int 类型元素大小,不相等时跳转;
  • if_icmplt:比较栈顶两个 int 类型元素大小,前者小于后者时跳转;
  • if_icmple:比较栈顶两个 int 类型元素大小,前者小于等于后者时跳转;
  • if_icmpgt:比较栈顶两个 int 类型元素大小,前者大于后者时跳转;
  • if_icmpge:比较栈顶两个 int 类型元素大小,前者大于等于后者时跳转;
  • if_acmpeq:比较栈顶两个引用类型数值,相等时跳转;
  • if_acmpne:比较栈顶两个引用类型数值,不相等时跳转。

3)多条件分支跳转指令

多条件分支跳转指令是专为 switch-case 语句设计的。

  • tableswitch:case 值连续,效率比较高。
  • lookupswitch:case 值不连续。

image-20240128162724824

4)无条件跳转指令

  • goto:跳转到指定位置;
  • goto_w:支持的地址范围更广;

异常

1)异常抛出指令

athrow:当程序运行时出现异常时会调用该指令。会将异常对象引用从操作数栈的栈顶弹出,然后将控制流转移到异常处理程序。如果当前方法没有捕获异常,会强制结束当前方法,将异常抛给调用者方法。

注意对于隐式异常情况,不会在字节码显示调用 athrow。例如除数为 0。

2)异常处理

异常处理并不是由字节码指令来实现的(早期使用 jsr、ret 指令),而是采用异常表完成。

如果方法定义了 try-catch 或者 try-finally 等异常处理逻辑,就会创建一个异常表,它保存了每个异常的处理信息。当 try 块出现异常,将去异常表中匹配并调转到指定地址中执行异常处理逻辑。

image-20240128201025209

同步控制

JVM 支持两种同步结构:方法级的同步和方法内部一段指令序列的同步,这两种同步都是使用 monitor 来支持。

1)同步方法

是隐式的, 无须通过字节码指令来控制。当调用方法时,调用指令将自动检查方法的访问标志(访问标志存储在方法表中)是否设置了同步锁。因此对于同步方法而言,monitorentermonitorexit 指令是隐式存在的,并未直接出现在字节码指令中。

image-20240128202348478

2)同步代码块

当一个线程进入同步代码块时,会使用 monitorenter 指令请求进入。如果当前对象的监视器计数器为 0,则它被准许进入,若为 1,则判断持有当前监视器的线程是否为自己,如果是,则进入。否则进行等待,直到对象的监视器计数器为 0,才会被允许进入同步块。当线程退出同步块时,需要使用 monitorexit 声明退出。

指令 monitorenter 和 monitorexit 在执行时,都需要在操作数栈压入锁对象,锁定和释放都是针对锁对象的监视器进行的。

image-20240128204055761

类生命周期

加载

将类的字节码文件(二进制流)加载到机器内存中,并在内存中构建出类原型一一类模板对象。类模板对象,其实就是 JVM 从字节码文件中解析出的常量池、类字段、类方法等信息存储到类模板中,JVM 在运行期便能通过类模板而获取类中的任意信息,例如对类中方法的调用。

二进制流获取方式,JVM 可以通过多种途径获得:

  1. 通过文件系统读入一个 class 后缀的文件(最常见);
  2. 读入 jar、zip 等归档数据包,提取类文件;
  3. 事先存放在数据库中的类二进制数据;

  4. 使用类似于 HTTP 之类的协议通过网络进行加载;

  5. 在运行时动态生成一段 Class 的二进制数据等。

类加载进内存后,其内存示意图如下:

image-20240129135125638

当类加载进内存时,会在堆中创建一个 Class 对象,通过该对象访问方法区中具体的类数据结构。

1
Class<?> clazz = Class.forName("com.scarf.User")

该语句执行后 clazz 就会保存着堆中 User 类的 Class 对象引用。

注意:类加载进内存后在堆中创建的 Class 对象和通过 new 关键字创建的对象是不同的概念。

链接

1)验证阶段

当类加载到内存后,就开始链接操作,验证是链接操作的第一步。它的目的是检查加载的字节码是否符合规范。

验证阶段大体需要做以下检查:

  1. 格式验证:确保类文件的字节码符合规定的文件格式,包括魔数、版本号等;
  2. 语义验证:验证语法是否符合规范。例如验证类的继承关系或字段、方法的定义是否正确;
  3. 字节码验证:对字节码进行数据流分析,检查是否有不合法的操作码序列。例如跳转指令是否指向正确或方法的调用是否传递正确的数据类型;
  4. 符号引用验证:检查符号引用的直接引用是否存在。

2)准备阶段

为类的静态变量分配内存,并将其初始化为默认值。

类型 默认初始值
byte 0
short 0
int 0
long 0
float 0.0f
double 0.0d
char \u0000(0)
boolean false
引用类型 null

3)解析阶段

将类、接口、字段和方法等符号引用转为直接引用。

初始化

初始化阶段的重要工作是执行类的初始化方法:<clinit> 。它是由类静态成员的赋值语句以及静态代码块合并产生的。如果类中没有定义静态变量或者没有对静态变量进行赋值以及不包含静态代码块,将不会生成该方法。注意对 final 修饰的类变量的赋值操作可能发生在准备阶段也可能在初始化阶段,具体取决于是否需要创建对象。

在加载一个类之前,虚拟机总是会先试图加载该类的父类,因此父类的 <clinit> 总是在子类的 <clinit> 之前被调用。 也就是说,父类的静态成员赋值以及静态代码块的执行优先级高于子类。

<clinit> 方法是线程安全的,因为该方法只有类被加载时会被调用且只会被调用一次。如果当前线程成功执行了该方法,其余等待线程之后就没有机会执行该方法了。

image-20240129160119593

类的主动使用与被动使用:它们的区别就是是否会调用 <clinit>

主动使用会导致类的初始化,而被动使用只会触发类的加载和链接,但不会导致类的初始化。这是 Java 虚拟机的一种优化策略,以提高性能。

主动使用包括以下情况:

  1. 创建类实例对象

    1
    new User();
  2. 调用类的静态方法

    1
    User.staticMethod();
  3. 使用类静态字段(final 修饰特殊考虑)

    1
    int a = User.a;
    1
    2
    3
    4
    // User 类
    public static int a = 1; // 需进行类初始化
    public static final String b = "abc"; // 准备阶段已显示赋值,无需再进行类初始化
    public static final String c = new String("abc"); // 需进行类初始化
  4. 反射

    1
    Class<?> clazz = Class.forName("com.scarf.User");
  5. 子类使用前,需对所有父类进行初始化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    public class App {
    public static void main(String[] args) {
    new Scarf();
    }
    }
    class User{
    static {
    System.out.println("User类初始化");
    }
    }
    class Admin extends User{
    static {
    System.out.println("Admin类初始化");
    }
    }
    class Scarf extends Admin{ }
  6. 虚拟机启动自动对主类进行初始化(main 方法所属类)

    1
    2
    3
    4
    5
    6
    public class App {
    static {
    System.out.println("APP类初始化");
    }
    public static void main(String[] args) { }
    }

被动使用包括以下情况:

  1. 调用子类从父类继承而来的静态变量,不会导致子类初始化

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    public class App {
    public static void main(String[] args) {
    int i = Child.i; // 只对 Parent 进行初始化,而不会对 Child 进行初始化
    }
    }
    class Parent{
    static {
    System.out.println("Parent类初始化");
    }
    public static int i = 1;
    }

    class Child extends Parent{
    static {
    System.out.println("Child类初始化");
    }
    }
  2. 定义类引用型数组,不会触发此类的初始化

    1
    Parent[] parents = new Parent[10];
  3. 调用 ClassLoader 的 loadClass() 方法加载一个类,不会导致类的初始化

    1
    ClassLoader.getSystemClassLoader().loadClass("com.scarf.Parent");

卸载

类、类加载器、类实例三者之间的引用关系:

image-20240129200846148

  1. 类加载器对象会指向它所加载的所有类的 Class 对象。
  2. Class 对象也指向加载自身的 ClassLoader 对象。
  3. Class 对象被该类的所有实例对象指向着。

要想方法区中的类型数据被回收,堆中的 Class 对象必须先被回收。但是 Class 对象被回收是很难的,因为被类加载器对象以及许多类实例引用着,同时类加载器对象通常不会被回收,固程序运行期间类通常不会被卸载。

类加载器

前言

所有的类都是由类加载器进行加载的,类加载器负责通过各种方式将类的二进制数据流读入内存,转换为一个与目标类对应的 Class 对象实例,之后交给 JVM 进行链接、初始化等操作。因此, 类加载器只能影响到类的加载,无法影响类的其他行为。

命名空间:每个类加载器都有自己的命名空间,这意味着同一个类可以被不同的类加载器加载,而它们在 JVM 中被视为不同的类。可以借助这一特性,来运行同一个类的不同版本。

可见性:子类加载器可以访问父类加载器加载的类型,但是反过来是不允许的。这是因为子类加载器中,包含着父类加载器的引用。注意这里的父子并不是继承关系,而是上下层关系。

单一性:由于父类加载器加载的类型对于子类加载器是可见的,所以父类加载器中加载过的类型,就不会在子类加载器中重复加载。这里指的是在同一位置下的类型,不同位置的相同类型可以破坏单一性。

ClassLoader

除了引导类加载器,其他类加载器都继承于ClassLoader抽象类。

image-20240130185751010

ClassLoader 中主要方法:

  • getParent()

    返回父类加载器。

  • loadClass(String name)

    加载名称为 name 的类,返回结果为 Class 实例。如果找不到类,则抛出 ClassNotFoundException 异常;如果类此前已被加载过,直接返回 Class 实例。方法中的内部逻辑使用了双亲委派机制,会先调用父类加载器的 loadClass 方法,如果父类加载器无法加载,再调用当前类加载器的 findClass 方法。

  • findClass(String name)

    判断当前类加载器是否可以加载指定类,如果可以则取得要加载类的字节码,将其转换成流,然后调用 defineClass 方法生成类的 Class 实例。在自定义类加载器时,会重写此方法,根据需要从指定位置获取类的流数据。

  • defineClass(String name, byte[] b, int off, int len)

    根据给定的字节数组将之转换为 Class 实例,off 和 len 参数表示实际 Class 信息在数组中的位置和长度。执行此方法才会真正将类加载到内存

所有的类都是通过调用 loadClass 方法进行加载,最后都会间接调用 defineClass 方法加载到内存。

自定义类加载器

1
2
3
4
5
6
7
8
9
10
11
12
13
public class App {

public static void main(String[] args) throws ClassNotFoundException {
MyClassLoader myClassLoader = new MyClassLoader("/Users/suweijin/Desktop/");
Class<?> demoClazz = myClassLoader.loadClass("Demo");
try {
Method m1 = demoClazz.getMethod("m1");
m1.invoke(null);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
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
public class MyClassLoader extends ClassLoader {

private final String path;

public MyClassLoader(String path) {
this.path = path;
}

@Override
protected Class<?> findClass(String name) {

BufferedInputStream bis = null;
ByteArrayOutputStream baos = null;
try {
// 获取字节码文件完整路径
String filePath = path + name + ".class";
// 获取输入流
bis = new BufferedInputStream(Files.newInputStream(Paths.get(filePath)));
// 获取输出流
baos = new ByteArrayOutputStream();
// 读入数据并写出数据
int len;
byte[] data = new byte[1024];
while ((len = bis.read(data)) != -1) {
baos.write(data, 0, len);
}
// 获取内存中数据
byte[] byteCode = baos.toByteArray();
// 创建类实例
return defineClass(null, byteCode, 0, byteCode.length);
} catch (IOException e) {
throw new RuntimeException(e);
} finally {
try {
if (bis != null) {
bis.close();
}
if (baos != null) {
baos.close();
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
}
1
2
3
4
5
6
// Demo 类源代码,可将编译后的 class 文件保存在任意地方
public class Demo {
public static void m1(){
System.out.println("Demo 方法");
}
}

注意Class<?> demoClazz = myClassLoader.loadClass("Demo"); 语句执行时一样会先尝试委托给父类加载器进行加载,但是 Demo.class 是不会被父类加载器识别到的,因为只有写成完整类名才可能被识别到,例如 com.scarf.Demo。也就是说只要将原本归属于某类加载器的 class 文件保存到其他地方再通过自定义类加载器加载(当然也可以保存到其他类加载器能识别到的地方),就可以实现在一个程序中加载进两个同名类,例如将 Demo.class 改成 String.class。又由于命名空间的存在,内存中的两个类实例是不会冲突的。

新特性

JDK 9 后对类加载器做了一些修改:

  1. 扩展机制被栘除,扩展类加载器由于向后兼容性的原因被保留,不过被重命名为平台类加载器(PlatformClassLoader)。可以通过 ClassLoader.getPlatformClassLoader() 来获取。
  2. 平台类加载器和应用程序类加载器都不再继承 URLClassLoader。启动类加载器、平台类加载器、应用程序类加载器全都继承于 BuiltinClassLoader,而 BuiltinClassLoader 则继承于 SecureclassLoader
  3. 启动类加载器更名为 BootClassLoader

性能监控

命令行

1)jps

查看正在运行的 Java 进程。

2)jstat

1
jstat <option> [-t] [-h<lines>] <vmid> [<interval> [<count>]]
  • option:具体功能参数;
  • -t:显示程序执行总时间,单位 s;
  • -h:在周期性数据输出时,输出多少行数据后输出一个表头信息;
  • vmid:进程号,用于输出指定进程的统计信息;
  • interval:指定统计信息输出间隔,单位 ms,即查询间隔;
  • count:指定查询总次数,与 interval 合用。

image-20240131110035742

option 主要参数:

  • -class:显示类加载器的相关信息,如类的加载/卸载数量等;

  • -printcompilation:输出已经被 JIT 编译的方法;

  • -gc:显示与 GC 相关的信息。包括各区的容量、已用空间、GC 时间等;

    image-20240131111932049

  • -gcutil:显示内容与 -gc 基本相同,但空间占用使用百分比表示;

  • -gccause:与 -gcutil 基本相同,额外会显示 GC 产生的原因;

  • -gccapacity:主要关注各区域的最大、最小空间;

  • -gcnew:显示新生代 GC 状况;

  • -gcnewcapacity:与 -gcnew 基本相同;

  • -gcold:显示老年代 GC 状况。

3)jinfo

1
jinfo [option] <pid>
  • -sysprops:查看系统属性,是一组键值对,用于提供关于运行时环境的信息。如操作系统类型、Java版本等;
  • -flags:查看 JVM 配置的参数;
  • -flag 具体参数:查看当前程序是否使用了指定参数;
  • -flag [+|-]具体参数:使用或禁用指定参数,针对 boolean 类型;
  • -flag 具体参数=参数值:使用指定参数,针对非 boolean 类型。

4)jmap

  • -dump:生成堆快照(dump文件),通常在写 dump 文件前会触发一次 Full GC。dump 文件是一个二进制文件,可使用 VisualVM 或 jhat 命令进行查看。

    1
    2
    jmap -dump:format=b,file=<filename.hprof> <pid>
    eg: jmap -dump:format=b,file=/Users/suweijin/Desktop/dump.hprof 95981

    还有一种通过配置参数的方式自动获取 dump 文件:

    • -XX:+HeapDumpOnOutOfMemoryError:在程序发生 OOM 时,自动导出当前堆快照。
    • -XX:HeapDumpPath=<filename.hprof>:指定堆快照的存储位置。
  • -heap:输出堆空间详细信息,包括 GC、堆配置以及内存使用情况等;

  • -histo:输出堆中对象的统计信息。

5)jhat

内罝了一个微型 HTTP/HTML 服务器。生成 dump 文件的分析结果后,可在浏览器中查看分析结果。该命令在JDK9 后己经被删除,官方建议用 VisualVM 代替。

1
jhat filename.hprof

6)jstack

导出线程快照。线程快照就是当前虚拟机内各个线程的详细情况。

1
jstack pid

7)jcmd

它是一个多功能的命令,用来实现前面除了 jstat 之外所有命令的功能。

  • jcmd -l:列出所有 Java 进程;
  • jcmd pid help:针对指定进程,列出所有支持的命令;
  • jcmd pid 具体命令:通过上个指令所得到的各种命令,指定执行其中的某个命令。

Arthas

已经有了 VisualVM 等图形化监控工具,为什么还需要 Arthas?

图形化工具优点是可以在图形界面上看到各维度的性能监控数据,但若是想要远程监控服务器上的进程就需要在进程中配置相关监控参数,并且线上环境的网络是隔离的,开放网络终归不安全。而 Arthas 克服了这些缺点,不需要进行远程连接,也无需配置监控参数。

  1. 下载启动包

    1
    curl -O https://alibaba.github.io/arthas/arthas-boot.jar
  2. 运行

    1
    java -jar arthas-boot.jar
  3. 选择监控进程

    image-20240131160010033

  4. 选择后自动下载 Arthas(仅第一次启动时需要)

    image-20240131160208676

  5. 命令行操作

    [arthas@98060]$ 表示进入了监控客户端,在这里可以直接执行相关命令。 使用 quit(退出当前客户端)、stop(关闭 arthas 服务端,并退出所有客户端)。

  6. 网页界面操作

    1
    http://127.0.0.1:3658

    image-20240131160634236

  7. 具体命令查看官方文档

    1
    https://arthas.aliyun.com/