0%

JVM拆解

JVM学习笔记

JVM 加载类

加载 -> 链接 -> 初始化

  • 加载
    是指查找字节流,并且据此创建类的过程。加载需要借助类加载器,在 Java 虚拟机中,类加载器使用了双亲委派模型,即接收到加载请求时,会先将请求转发给父类加载器。数组类是JVM虚拟机直接生成的,其他类需要借助类加载器完成查找字节流的过程的。在 Java 虚拟机中,类的唯一性是由类加载器实例以及类的全名一同确定的。即便是同一串字节流,经由不同的类加载器加载,也会得到两个不同的类。

  • 链接
    链接: 是指将创建成的类合并至 Java 虚拟机中,使之能够执行的过程。它可分为验证、准备以及解析三个阶段。
    验证: 在于确保被加载类能够满足 Java 虚拟机的约束条件
    准备: 为被加载类的静态字段分配内存
    解析:正是将这些符号引用解析成为实际引用。如果符号引用指向一个未被加载的类,或者未被加载类的字段或方法,那么解析将触发这个类的加载(但未必触发这个类的链接以及初始化。)

  • 初始化
    如果直接赋值的静态字段被 final 所修饰,并且它的类型是基本类型或字符串时,那么该字段便会被 Java 编译器标记成常量值(ConstantValue),其初始化直接由 Java 虚拟机完成。除此之外的直接赋值操作,以及所有静态代码块中的代码,则会被 Java 编译器置于同一方法中,并把它命名为 < clinit >。

  • JAVA中获取类的方式:

    1
    2
    3
    4
    5
    6
    7
    8
    // 四种
    Calss c1 = Class.forName("com.leezy.top.Coder");
    Class c2 = Coder.getClass();
    Class c3 = Coder.class;
    // 基本内置类型的包装类都有一个Type属性
    Class<Integer> type = Integer.TYPE;
    // 获取父类
    Class superClass = c1.getSuperClass();

JVM处理异常

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void main(String[] args) {
try {
// 异常监控的代码
} catch() {
// catch 代码块所捕获的异常类型不能覆盖后边的,否则编译器会报错
} catch() {

} catch() {

} finally {
// 必定会运行的代码
}
}

异常分为检查异常(checked exception)和非检查异常(unchecked exception), RuntimeException 和 Error是非检查异常,其他继承Throwable的Exception都是检查异常,需要程序显式的捕获或者在方法头用throws关键字声明;
需要注意的是异常的捕获是比较耗费性能的一件事,这是由于在构造异常实例时,Java 虚拟机便需要生成该异常的栈轨迹(stack trace);
在编译生成的字节码中,每个方法都附带一个异常表。异常表中的每一个条目代表一个异常处理器,并且由 from 指针、to 指针、target 指针以及所捕获的异常类型构成。这些指针的值是字节码索引,用以定位字节码。

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

private static int tryBlock;

private static int catchBlock;

private static int finallyBlock;

private static int methodExit;

public static void main(String[] args) {
try {
tryBlock = 0;
} catch (Exception e) {
catchBlock = 1;
} finally {
finallyBlock = 2;
}
methodExit = 3;
}
}

// 字节码
public class Test {
public Test();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return

public static void main(java.lang.String[]);
Code:
0: iconst_0
1: putstatic #2 // Field tryBlock:I
4: iconst_2
5: putstatic #3 // Field finallyBlock:I - 1
8: goto 30
11: astore_1
12: iconst_1
13: putstatic #5 // Field catchBlock:I
16: iconst_2
17: putstatic #3 // Field finallyBlock:I - 2
20: goto 30
23: astore_2
24: iconst_2
25: putstatic #3 // Field finallyBlock:I - 3
28: aload_2
29: athrow
30: iconst_3
31: putstatic #6 // Field methodExit:I
34: return
Exception table:
from to target type
0 4 11 Class java/lang/Exception
0 4 23 any // 指向复制的finally代码块
11 16 23 any // 指向复制的finally代码块
}

finally方法一定会执行,因为JVM虚拟机会复制finally代码块的内容,分别放在try-catch代码块所有正常执行路径以及异常执行路径的出口中。

JVM反射机制

反射是 Java 语言中一个相当重要的特性,它允许正在运行的 Java 程序观测,甚至是修改程序的动态行为。
在默认情况下,方法的反射调用为委派实现,委派给本地实现来进行方法调用。在调用超过 15 次之后,委派实现便会将委派对象切换至动态实现。这个动态实现的字节码是自动生成的,它将直接使用 invoke 指令来调用目标方法。
拿到类后如何使用反射

  1. 使用 newInstance() 来生成一个该类的实例。它要求该类中拥有一个无参数的构造器。P.S. 可以提高软件的可伸缩性、可扩展性。
  2. 使用 isInstance(Object) 来判断一个对象是否该类的实例,语法上等同于 instanceof 关键字。
  3. 使用 Array.newInstance(Class,int) 来构造该类型的数组。
  4. 使用 getFields()/getConstructors()/getMethods() 来访问该类的成员。方法名中带 Declared 的不会返回父类的成员,但是会返回私有成员;而不带 Declared 的则相反。
    拿到类后可以做:
  • 使用 Constructor/Field/Method.setAccessible(true) 来绕开 Java 语言的访问限制。
  • 使用 Constructor.newInstance(Object[]) 来生成该类的实例。
  • 使用 Field.get/set(Object) 来访问字段的值。
  • 使用 Method.invoke(Object, Object[]) 来调用方法。

JVM执行方法调用

  • 重载与重写,重载指的是方法名相同而参数类型不相同的方法之间的关系,重写指的是方法名相同并且参数类型也相同的方法之间的关系。
    如果子类定义了与父类中非私有方法同名的方法,而且这两个方法的参数类型相同,如果这两个方法都是静态的,那么子类中的方法隐藏了父类中的方法。如果这两个方法都不是静态的,且都不是私有的,那么子类的方法重写了父类中的方法。
  • JAVA编译器对重载方法的选取规则:
  1. 在不考虑对基本类型自动装拆箱,以及可变长参数的情况下选取重载方法;
  2. 如果在第 1 个阶段中没有找到适配的方法,那么在允许自动装拆箱,但不允许可变长参数的情况下选取重载方法;
  3. 如果在第 2 个阶段中没有找到适配的方法,那么在允许自动装拆箱以及可变长参数的情况下选取重载方法。
    Java识别方法只看方法名和参数类型,而JVM识别方法类名、方法名以及方法描述符。 在Java中,返回类型不一致而其他全部一致不算重载。Java 虚拟机中的静态绑定指的是在解析时便能够直接识别目标方法的情况,而动态绑定则指的是需要在运行过程中根据调用者的动态类型来识别目标方法的情况。
  • 静态绑定和动态绑定
    具体来说,Java 字节码中与调用相关的指令共有五种。
  1. invokestatic:用于调用静态方法。
  2. invokespecial:用于调用私有实例方法、构造器,以及使用 super 关键字调用父类的实例方法或构造器,和所实现接口的默认方法。
  3. invokevirtual:用于调用非私有实例方法。
  4. invokeinterface:用于调用接口方法。
  5. invokedynamic:用于调用动态方法。
    虚方法调用包括 invokevirtual 指令和 invokeinterface 指令。如果这两种指令所声明的目标方法被标记为 final,那么 Java 虚拟机会采用静态绑定。否则,Java 虚拟机将采用动态绑定,在运行过程中根据调用者的动态类型,来决定具体的目标方法。

JAVA对象的内存分布

JAVA程序中新建对象的方式有:

  • new方法
    1
    2
    3
    4
    5
    6
    7
    User user = new User();

    // Foo foo = new Foo(); 编译而成的字节码
    0 new Foo
    3 dup
    4 invokespecial Foo()
    7 astore_1
    当我们调用一个构造器时,它将优先调用父类的构造器,直至 Object 类。这些构造器的调用者皆为同一对象,也就是通过 new 指令新建而来的对象。通过 new 指令新建出来的对象,它的内存其实涵盖了所有父类中的实例字段。也就是说,虽然子类无法访问父类的私有实例字段,或者子类的实例字段隐藏了父类的同名实例字段,但是子类的实例还是会为这些父类实例字段分配内存的。
  • 反射机制
    1
    2
    User user = (User) Class.forName("com.leezy.top.User").newInstance(); 
    // 当需要调用类的带参数的构造函数时,应该采用 Constructor.newInstance(),
    newInstance创建对象实例的时候仅能调用无参的构造函数,所以必需确保类中有无参数的构造函数,否则将会抛出java.lang.InstantiationException异常,无法进行实例化。
  • Object.clone
    需要被克隆的对象需要实现Cloneable接口
    1
    2
    User u1 = new User();
    User u2 = (User)u1.clone();
    需要注意的是,基于克隆(原型模式)创建对象的方式是浅拷贝,如果对象的属性为引用类型,则仅复制地址。
  • 反序列化
    序列化需要实现Serializable接口,反序列化获取对象的方式如下:
    1
    2
    ObjectInputStream o1 = new ObjectInputStream(new FileInputStream("User.txt"));
    User u1 = (User)o1.readObject();
  • Unsafe.allocateInstance
    1
    2
    // 会绕过对象初始化阶段并绕过构造器的安全检查,慎用
    User instance = (User) UNSAFE.allocateInstance(User.class);
    在 Java 虚拟机中,每个 Java 对象都有一个对象头(object header),这个由标记字段和类型指针所构成。其中,标记字段用以存储 Java 虚拟机有关该对象的运行数据,如哈希码、GC 信息以及锁信息,而类型指针则指向该对象的类。在 64 位的 Java 虚拟机中,对象头的标记字段占 64 位,而类型指针又占了 64 位。也就是说,每一个 Java 对象在内存中的额外开销就是 16 个字节。为了节约空间,减少对象内存的使用量,64 位 Java 虚拟机引入了压缩指针的概念(对应虚拟机选项 -XX:+UseCompressedOops,默认开启),对象头中的类型指针也会被压缩成 32 位,使得对象头的大小从 16 字节降至 12 字节。同事默认情况下,Java 虚拟机堆中对象的起始地址需要对齐至 8 的倍数,(对应虚拟机选项 -XX:ObjectAlignmentInBytes,默认值为 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
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    class A {
    long l;
    int i;
    }

    class B extends A {
    long l;
    int i;
    }


    // 启用压缩指针时,B类的字段分布
    B object internals:
    OFFSET SIZE TYPE DESCRIPTION
    0 4 (object header)
    4 4 (object header)
    8 4 (object header)
    12 4 int A.i 0
    16 8 long A.l 0
    24 8 long B.l 0
    32 4 int B.i 0
    36 4 (loss due to the next object alignment)

    // 关闭压缩指针时,B类的字段分布
    B object internals:
    OFFSET SIZE TYPE DESCRIPTION
    0 4 (object header)
    4 4 (object header)
    8 4 (object header)
    12 4 (object header)
    16 8 long A.l
    24 4 int A.i
    28 4 (alignment/padding gap)
    32 8 long B.l
    40 4 int B.i
    44 4 (loss due to the next object alignment)

JVM垃圾回收机制

在JVM中,垃圾就是无引用对象所占用的堆内存空间, 比如对象a和对象b相互引用, 但是再没有其他引用指向a或者b, 此时a和b对象占用的内存空间就是垃圾。目前 Java 虚拟机的主流垃圾回收器采取的是可达性分析算法。这个算法的实质在于将一系列 GC Roots 作为初始的存活对象合集(live set),然后从该合集出发,探索所有能够被该集合引用到的对象,并将其加入到该集合中,这个过程我们也称之为标记(mark)。最终,未被探索到的对象便是死亡的,是可以回收的。传统的垃圾回收算法采用的是一种简单粗暴的方式,那便是 Stop-the-world,停止其他非垃圾回收线程的工作,直到完成垃圾回收。这也就造成了垃圾回收所谓的暂停时间(GC pause)。Java 虚拟机中的 Stop-the-world 是通过安全点(safepoint)机制来实现的。当 Java 虚拟机收到 Stop-the-world 请求,它便会等待所有的线程都到达安全点,才允许请求 Stop-the-world 的线程进行独占的工作。
具体垃圾回收的方式:

  • 清除: 即把死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表(free list)之中。当需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,并划分给新建的对象。一是会造成内存碎片。由于 Java 虚拟机的堆中对象必须是连续分布的,因此可能出现总空闲内存足够,但是无法分配的极端情况。二是分配效率较低。如果是一块连续的内存空间,那么我们可以通过指针加法(pointer bumping)来做分配。而对于空闲列表,Java 虚拟机则需要逐个访问列表中的项,来查找能够放入新建对象的空闲内存。
  • 压缩: 即把存活的对象聚集到内存区域的起始位置,从而留下一段连续的内存空间。这种做法能够解决内存碎片化的问题,但代价是压缩算法的性能开销。
  • 复制:把内存区域分为两等分,分别用两个指针 from 和 to 来维护,并且只是用 from 指针指向的内存区域来分配内存。当发生垃圾回收时,便把存活的对象复制到 to 指针指向的内存区域中,并且交换 from 指针和 to 指针的内容。但缺点是空间使用率低。

JVM堆内存的使用是符合二八原则的,JVM将堆内存划分为了新生代和老生代,新生代用来存储新建的对象。当对象存活时间够长时,则将其移动到老年代。其中,新生代又被划分为Eden区,以及两个大小相同的Survivor区, 非空的那个用form指针指向,空的那个用to指针指向。为了解决Eden区堆内存线程共享导致两个对象共同引用一段内存的问题,JVM使用了一种叫Thread Local Allocation Buffer,对应虚拟机参数 -XX:+UseTLAB,默认开启)。具体来说,每个线程可以向 Java 虚拟机申请一段连续的内存,比如 2048 字节,作为线程私有的 TLAB。这个操作需要加锁,线程需要维护两个指针(实际上可能更多,但重要也就两个),一个指向 TLAB 中空余内存的起始位置,一个则指向 TLAB 末尾。如果加法后空余内存指针的值仍小于或等于指向末尾的指针,则代表分配成功。否则,TLAB 已经没有足够的空间来满足本次新建操作。这个时候,便需要当前线程重新申请新的 TLAB。
默认情况下,Java 虚拟机采取的是一种动态分配的策略(对应 Java 虚拟机参数 -XX:+UsePSAdaptiveSurvivorSizePolicy),根据生成对象的速率,以及 Survivor 区的使用情况动态调整 Eden 区和 Survivor 区的比例。当Eden区空间耗尽时,Java 虚拟机便会触发一次 Minor GC,来收集新生代的垃圾。存活下来的对象,则会被送到 Survivor 区。MonitorGC其实就是上面的复制操作。
Java 虚拟机会记录 Survivor 区中的对象一共被来回复制了几次。如果一个对象被复制的次数为 15(对应虚拟机参数 -XX:+MaxTenuringThreshold),那么该对象将被晋升(promote)至老年代。另外,如果单个 Survivor 区已经被占用了 50%(对应虚拟机参数 -XX:TargetSurvivorRatio),那么较高复制次数的对象也会被晋升至老年代。
MonitorGC避免了垃圾回收中的全堆扫描问题,但是老年代的对象可能引用新生代的对象还是会导致全堆扫描,所以JVM引入了一个卡表的概念,即将整个堆划分为一个个大小为 512 字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。这个标识位代表对应的卡是否可能存有指向新生代对象的引用。如果可能存在,那么我们就认为这张卡是脏的。在进行 Minor GC 的时候,我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到 Minor GC 的 GC Roots 里。当完成所有脏卡的扫描之后,Java 虚拟机便会将所有脏卡的标识位清零。

JAVA常用工具

  • jps 打印所有正在运行的 Java 进程的相关信息
  • jstat 打印目标 Java 进程的性能数据
    1
    jstat -gcutil  17079 5000 10
  • jmap 允许用户统计目标 Java 进程的堆中存放的 Java 对象,并将它们导出成二进制文件。配合eclipse MAT使用比较好,可以图形展示。
    1
    2
    3
    # -clstats,该子命令将打印被加载类的信息。
    # -histo 该子命令将统计各个类的实例数目以及占用内存,并按照内存使用量从多至少的顺序排列。此外,-histo:live只统计堆中的存活对象。
    jmap -dump:live,format=b,file=filename.bin
  • jinfo 查看和修改目标 Java 进程的参数
  • jstack 用来打印目标 Java 进程中各个线程的栈轨迹,以及这些线程所持有的锁。
  • javap java->javac->javap 查阅 Java 字节码
  • OPENJDK工具集
    http://openjdk.java.net/projects/code-tools/
  • jcmd 可以替换除了jstat外的所有命令
    https://docs.oracle.com/en/java/javase/11/tools/jcmd.html
  • ASM
  • Java Mission Control
    1
    2
    # JFR 将在 Java 虚拟机启动之后持续收集数据,直至进程退出
    java -XX:StartFlightRecording=dumponexit=true,filename=myrecording.jfr MyApp

JVM启动参数说明

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# JVM启动时分配的内存
-Xms
# JVM运行过程中分配的最大内存
-Xmx
# JVM为每个线程分配的内存大小
-Xss
# 年轻代大小 JVM内存 = 年轻代 + 年老代 + 持久代(64M)
-Xmn
# 选择年轻代垃圾收集器为并行收集器
-XX:+UseParallelGC
# 配置年老代垃圾收集方式为并行收集
-XX:+UseParallelOldGC
# 查看参数
java -XX:+PrintFlagsFinal -version | grep {XXX}