Understanding the JVM

Advanced Features and Best Practices, Third Edition.

Featured image

第1章 走进Java

1.1 概述

  1. 它摆脱了硬件平台的舒服,实现了“一次编写,到处运行”的理想。
  2. 它提供了一种相对安全的内存管理和访问机制,避免了绝大部分内存泄漏和指针越界问题。
  3. 它实现了热点代码检测和运行时编译及优化,这使得Java应用能随着运行时间的增长而获得更高的性能。
  4. 它有一套完善的应用程序接口。

1.2 Java技术体系

  1. Java程序设计语言
  2. 各种硬件平台上的Java虚拟机实现
  3. Class文件格式
  4. Java类库API
  5. 来自商业机构和开源社区的第三方Java类库

1.3 Java发展史

1.4 Java虚拟机家族

1.4.1 虚拟机始祖:Sun Classic/Exact VM

1.4.2 武林盟主:HotSpot VM


第2章 Java内存区域与内存溢出异常

2.1 概述

2.2 运行时区域

image

2.2.1 程序计数器

为了切换线程后能恢复到正确的执行位置,每条线程都有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,这类内存区域被称为“线程私有”的内存。

如果线程正在执行的是有一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应该为空(Undefined)。

2.2.2 Java虚拟机栈

  1. 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常。
  2. 如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。

2.2.3 本地方法栈

2.2.4 Java堆

如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。

2.2.5 方法区

如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常。

2.2.6 运行时常量池

2.2.7 直接内存

在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方法,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

2.3 HotSpot虚拟机对象探秘

2.3.1 对象的创建

在语言层面上,创建对象通常仅仅是一个new关键字而已;而在虚拟机中,对象的创建又是怎样一个过程呢?

  1. 当Java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那么必须先执行相应的类加载过程。
  2. 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务实际上等同于把一块确定大小的内存块从Java堆中划分出来。
  3. 接下来,java虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息,对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象对象头(Object Header)中。

2.3.2 对象的内存布局

  1. 第一类是用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特,官方称它为“Mark Word”。
  2. 第二类是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小。

这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义顺序的影响。HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers, OOPs),从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。

HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,也即任何对象的大小都必须是8字节的整数倍。

2.3.3 对象的访问定位

  1. 如果使用句柄访问的话,Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息。
  2. 如果使用直接指针凡哥维纳的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销。

image

2.4 实战:OutOfMemoryError异常

2.4.1 Java堆溢出

import java.util.ArrayList;
import java.util.List;

/**
 * VM Args: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
 *           堆初始值  堆最大值
 */
public class HeapOOM {
    static class OOMObject {

    }

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

        while (true) {
            list.add(new OOMObject());
        }
    }
}

java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid3500.hprof …
Heap dump file created [30039453 bytes in 0.120 secs]

解决这个内存区域的异常,常规的处理方法是首先通过内存映像分析工对Dump出来的堆转储快照进行分析。第一步首先应确认内存中导致OOM的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。

2.4.2 虚拟机栈和本地方法栈溢出

  1. 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出StackOverflowError异常。
  2. 如果虚拟机的栈内存允许扩展,当栈容量无法申请到足够的内存时,将抛出OutOfMemoryError异常。

2.4.2.1 StackOverflowError异常

1)使用-Xss参数减少栈内存容量

结果:抛出StackOverflowError异常,异常出现时输出的堆栈深度相应缩小。

/**
 * VM Args: -Xss180k
 */
public class JavaVMStackSOF1 {
    private int stackLength = 1;

    public void stackLeak() {
        stackLength++;
        stackLeak();
    }

    public static void main(String[] args) throws Throwable {
        JavaVMStackSOF1 oom = new JavaVMStackSOF1();
        try {
            oom.stackLeak();
        } catch (Throwable e) {
            System.out.println("stack length:" + oom.stackLength);
            throw e;
        }
    }
}

对于不同版本的Java虚拟机和不同的操作系统,栈容量最小值可能会有所限制,这主要取决于操作系统内存分页大小。譬如上述方法中的参数-Xss180k可以正常用于64位Windows系统下的JDK 11,而在Linux下这个值可能是228K。

stack length:1538
Exception in thread “main” java.lang.StackOverflowError
at oom.stack.JavaVMStackSOF1.stackLeak(JavaVMStackSOF1.java:10)
at oom.stack.JavaVMStackSOF1.stackLeak(JavaVMStackSOF1.java:11)
at oom.stack.JavaVMStackSOF1.stackLeak(JavaVMStackSOF1.java:11)

2)定义大量的本地变量

结果:抛出StackOverflowError异常,异常出现时输出的堆栈深度相应缩小。

public class JavaVMStackSOF2 {
    private static int stackLength = 0;

    public static void test() {
        long unused1, unused2, unused3, unused4, unused5,
             unused6, unused7, unused8, unused9, unused10,
             unused11, unused12, unused13, unused14, unused15,
             unused16, unused17, unused18, unused19, unused20,
             unused21, unused22, unused23, unused24, unused25,
             unused26, unused27, unused28, unused29, unused30,
             unused31, unused32, unused33, unused34, unused35,
             unused36, unused37, unused38, unused39, unused40,
             unused41, unused42, unused43, unused44, unused45,
             unused46, unused47, unused48, unused49, unused50,
             unused51, unused52, unused53, unused54, unused55,
             unused56, unused57, unused58, unused59, unused60,
             unused61, unused62, unused63, unused64, unused65,
             unused66, unused67, unused68, unused69, unused70,
             unused71, unused72, unused73, unused74, unused75,
             unused76, unused77, unused78, unused79, unused80,
             unused81, unused82, unused83, unused84, unused85,
             unused86, unused87, unused88, unused89, unused90,
             unused91, unused92, unused93, unused94, unused95,
             unused96, unused97, unused98, unused99, unused100;

        stackLength ++;

        test();

        unused1 = unused2 = unused3 = unused4 = unused5 =
        unused6 = unused7 = unused8 = unused9 = unused10 =
        unused11 = unused12 = unused13 = unused14 = unused15 =
        unused16 = unused17 = unused18 = unused19 = unused20 =
        unused21 = unused22 = unused23 = unused24 = unused25 =
        unused26 = unused27 = unused28 = unused29 = unused30 =
        unused31 = unused32 = unused33 = unused34 = unused35 =
        unused36 = unused37 = unused38 = unused39 = unused40 =
        unused41 = unused42 = unused43 = unused44 = unused45 =
        unused46 = unused47 = unused48 = unused49 = unused50 =
        unused51 = unused52 = unused53 = unused54 = unused55 =
        unused56 = unused57 = unused58 = unused59 = unused60 =
        unused61 = unused62 = unused63 = unused64 = unused65 =
        unused66 = unused67 = unused68 = unused69 = unused70 =
        unused71 = unused72 = unused73 = unused74 = unused75 =
        unused76 = unused77 = unused78 = unused79 = unused80 =
        unused81 = unused82 = unused83 = unused84 = unused85 =
        unused86 = unused87 = unused88 = unused89 = unused90 =
        unused91 = unused92 = unused93 = unused94 = unused95 =
        unused96 = unused97 = unused98 = unused99 = unused100 = 0;
    }

    public static void main(String[] args) {
        try {
            test();
        } catch (Error e) {
            System.out.println("stack length:" + stackLength);
            throw e;
        }
    }
}

stack length:592
Exception in thread “main” java.lang.StackOverflowError
at oom.stack.JavaVMStackSOF2.test(JavaVMStackSOF2.java:28)
at oom.stack.JavaVMStackSOF2.test(JavaVMStackSOF2.java:30)
at oom.stack.JavaVMStackSOF2.test(JavaVMStackSOF2.java:30)

2.4.2.1 OutOfMemoryError异常

package oom.stack;

/**
 * VM Args: -Xss2M (32位系统下运行)
 */
public class JavaVMStackOOM {
    private void dontStop() {
        while (true) {
        }
    }

    public void stackLeakByThread() {
        while (true) {
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    dontStop();
                }
            });
            thread.start();
        }
    }

    public static void main(String[] args) throws Throwable {
        JavaVMStackOOM oom = new JavaVMStackOOM();
        oom.stackLeakByThread();
    }
}

Exception in thread “main” java.lang.OutOfMemoryError: unable to create native thread

2.4.3 方法区和运行时常量池溢出

String::intern()是一个本地方法,它的作用是如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象的引用;否则,会将此String对象包含的字符串添加到常量池中,并且返回此String对象的引用。

    String a = "this is a test";
    String b = new String("this is a test");
    System.out.println( b.intern() == a); // true
    System.out.println( b.intern() == b); // false
public class RuntimeConstantPoolOOM {

    public static void main(String[] args) {
        String str1 = new StringBuilder("计算机").append("软件").toString();
        System.out.println(str1.intern() == str1);

        String str2 = new StringBuilder("ja").append("va").toString();
        System.out.println(str2.intern() == str2);
    }
}

上述代码在JDK 6中运行,会得到两个false,而在JDK 7及以上版本中运行,会得到一个true和false。产生差异的原因是因为字符串常量池移到了Java堆中。

加载sun.misc.Version这个类的时候java字符串已经进入常量池了。详情参考知乎https://www.zhihu.com/question/51102308/answer/124441115

2.4.4 本机直接内存溢出

import sun.misc.Unsafe;
import java.lang.reflect.Field;

/**
 * VM Args: -Xmx20M -XX:MaxDirectMemorySize=10M
 *                   指定直接内存大小
 */
public class DirectMemoryOOM {
    private static final int _1MB = 1024 * 1024;

    public static void main(String[] args) throws Exception {
        Field unsafeField = Unsafe.class.getDeclaredFields()[0];
        unsafeField.setAccessible(true);
        Unsafe unsafe = (Unsafe) unsafeField.get(null);
        while (true) {
            unsafe.allocateMemory(_1MB);
        }
    }
}

Exception in thread “main” java.lang.OutOfMemoryError
at java.base/jdk.internal.misc.Unsafe.allocateMemory(Unsafe.java:619)
at jdk.unsupported/sun.misc.Unsafe.allocateMemory(Unsafe.java:461)
at oom.directmemory.DirectMemoryOOM.main(DirectMemoryOOM.java:19)

第3章 垃圾收集器与分配策略

3.1 概述

  1. 哪些内存需要回收?
  2. 什么时候回收?
  3. 如何回收?

3.2 对象已死?

3.2.1 引用计数法

// objA和objB相互引用,引用计数器值都不为0。
public class ReferenceCountingGC {
    public Object instance = null;
    private static final int _1MB = 1024 * 1024;

    private byte[] bigSize = new byte[2 * _1MB];

    public static void testGC() {
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        objA.instance = objB;
        objB.instance = objA;
        
        objA = null;
        objA = null;
        
        System.gc();
    }
}

3.2.2 可达性分析法

image

  1. 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  2. 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  3. 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  4. 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  5. Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象等,还有系统类加载器。
  6. 所有被同步锁(synchronized关键字)持有的对象。
  7. 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

3.2.3 再谈引用

  1. 强引用是最传统的引用的定义,是指程序代码之中普遍存在的引用赋值,即类似“Object obj = new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾回收器就永远不会回收掉被引用的对象。
  2. 软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才回抛出内存溢出异常。JDK 1.2之后提供了SoftReference类来实现软引用。
  3. 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。JDK 1.2之后提供了WeakReference类来实现弱引用。
  4. 虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。JDK 1.2之后提供了PhantomReference类来实现虚引用。

3.2.4 回收方法区

  1. 该类的所有实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
  2. 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则是很难达成的。
  3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

3.3 垃圾收集算法

3.3.1 分代收集理论

  1. 弱分代假说:绝大多数对象都是朝生夕灭的。
  2. 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。
  1. 跨代引用假说:跨代引用相对于同代引用来说仅占极少数。

这个结构把老年代划分成若干小块,标记出老年代的哪一块内存会存在跨代引用。这就使得不必为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用。当发生Minor GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。

  1. 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
  2. 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单独收集老年代的行为。
  3. 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收集器会有这种行为。
  4. 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

3.3.2 标记 - 清除算法

  1. 执行效率不稳定。标记和清除两个过程的执行效率随着对象数量的增长而降低。
  2. 内存空间的碎片化问题。标记和清除之后会产生大量不连续的内存碎片,空间碎片太多不利于之后的内存分配。

image

3.3.3 标记 - 复制算法

  1. 当有较多对象存活的时候,复制将产生大量的开销。
  2. 将可用内存缩小为了原来的一半,空间浪费大。

HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局。

Appel式回收的具体做法是:把新生代分为一块较大的Eden空间和两块较小的Survivor空间(from和to),每次分配内存只使用Eden和其中一块Survivor。发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(大多数时候是老年代)进行分配担保(Handle Promotion)。

HotSpot虚拟机默认Eden和一块Survivor的大小比例是8:1,也即每次新生代中可用内存空间为整个新生代容量的90%。只有一块Survivor空间(10%的新生代容量)是会被“浪费”的。

image

3.3.4 标记 - 整理算法

根据所需确定所要使用的算法,譬如HotSpot虚拟机里面关注吞吐量的Parallel Scavenge收集器是基于标记 - 整理算法的,而关注延迟的CMS收集器则是基于标记 - 清除算法的。

二者兼顾的方法:让虚拟机多数时间都采用标记 - 清除算法,暂时容忍内存碎片的存在,知道内存空间的碎片化程度已经大到影响对象分配时,再采用标记 - 整理算法收集一次,以获得规整的内存空间。基于标记 - 清除算法的CMS收集器面临空间碎片化过多时采用的就是这种处理方法。

3.4 HotSpot的算法细节实现*

3.5 经典垃圾收集器

连线代表可以搭配使用。

image

3.5.1 Serial收集器

image

  1. 简单高效(与其他收集器的单线程相比),对于内存资源受限的环境,它是所有收集器里额外内存消耗最小的。
  2. 对于单核处理器或处理器核心较少的环境来说,Serial收集由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

3.5.2 ParNew收集器

image

并行和并发都是并发编程中的专业名词,在谈论垃圾收集器的上下文语境中,它们可以理解为:

并行(Parallel):并行描述的是多条垃圾收集器线程之间的关系,说明同一时间有多条这样的线程在协同工作,通常默认此时用户线程是处于等待状态。
并发(Concurrent):并发描述的是垃圾收集器线程与用户线程之间的关系,说明同一时间垃圾收集器线程与用户线程都在运行。

3.5.3 Parallel Scavenge收集器

停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序,良好的响应速度能提升用户体验;
高吞吐量则可以最高效率地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。

image

3.5.4 Serial Old收集器

image

3.5.5 Parallel Old收集器

image

3.5.6 CMS收集器

  1. 初始标记(CMS initial mark)
  2. 并发标记(CMS concurrent mark)
  3. 重新标记(CMS remark)
  4. 并发清除(CMS concurrent sweep)
  1. 初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快;
  2. 并发标记就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行;
  3. 重新标记则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。
  4. 并发清除则是清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

image

  1. CMS收集器对处理器资源非常敏感。
  2. CMS收集器无法处理“浮动垃圾”(Floating Garbage),有可能出现“Concurrent Mode Failure”失败进而导致另一次完全“Stop The World”的Full GC的产生。
  3. CMS收集器是基于标记-清除算法的,所以收集结束时可能会产生大量的空间碎片。

3.5.7 Garbage First(G1)收集器

在G1收集器出现之前的所有其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老年代(Major GC)。而G1跳出了整个樊笼,它可以面向堆内存任何部分来组成回收集(Collection Set, 简称CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式。

G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每个Region都可以根据需要,扮演新生代的Eden空间,Survivor空间,或者是老年代空间。

每个Region的大小可以通过参数 -XX:G1HeapRegionSize设定,取值范围为1MB~32MB,且应为2的N次幂。而对于超过了整个Region容量的超级大对象,将会被存放在N个连续的Humongous Region之中,G1的大多数行为都把Humongous Region作为老年代的一部分来进行对待。

  1. 初始标记(Initial Marking)
  2. 并发标记(Concurrent Marking)
  3. 最终标记(Final Marking)
  4. 筛选回收(Live Data Counting and Evacuation)
  1. 初始标记仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
  2. 并发标记是从GC Roots开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以后,还有重新处理SATB(原始快照)记录下的在并发时有引用变动的对象。
  3. 最终标记是堆用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
  4. 筛选回收扶着更新Region的统计数据,对各个Region的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行完成的。

image

3.6 低延迟垃圾收集器

image

Shenandoah和ZGC收集器,几乎整个工作过程全部都是并发的,只有初始标记、最终标记这些阶段有短暂的停顿,并且这部分的停顿时间基本上是固定的,与堆的容量、堆中对象的数量没有正比例关系。这两款收集器目前仍处于实验状态,被官方命名为“低延迟垃圾收集器”(Low-Latency Garbage Collector或Low-Pause-Time Garbage Collector)。

3.6.1 Shenandoah收集器

  1. 支持并发的整理算法,G1的回收阶段是可以多线程并行的,但却不能与用户线程并发。
  2. Shenandoah是默认不使用分代收集的,不会有专门的新生代Region或者老年代Region的存在。
  3. Shenandoah摒弃了在G1中耗费大量内存和计算资源去维护的记忆集,改用名为“连接矩阵”(Connection Matrix)的全局数据结果来记录夸Region的引用关系降低了跨代维护的消耗。
  1. 初始标记:与G1一样,首先标记与GC Roots直接关联的对象,这个阶段仍是“Stop The World”的,但停顿时间与堆大小无关,只与GC Roots的数量相关。
  2. 并发标记:与G1一样,编辑对象图,标记出全部可达的对象,与用户线程一起并发,时间长短与堆中存活对象的数量以及对象图的结构复杂程度有关。
  3. 最终标记:与G1一样,处理剩余的SATB扫描,并在这个阶段统计出回收价值最高的Region,将这些Region构成一组回收集。此阶段也会有一小段短暂的停顿。
  4. 并发清理:这个阶段用于清理那些整个区域内连一个存活对象都没有找到的Region。
  5. 并发回收:这个阶段是Shenandoah与之前HotSpot中其他收集器的核心差异。在这个阶段,Shenandoah要把回收集里面的存活对象先复制一份到其他未被使用的Region中。但是有个难点是在移动对象的同时,用户线程仍然可能不停的对被移动的对象进行读写访问,移动对象之后整个内存中所有指向该对象的引用都还是旧对象的地址,这是很难一瞬间全部改变过来的。对于这个难点,Shenandoah将会通过读屏障和被称为“Brooks Pointers”的转发指针来解决。并发回收阶段运行时间的长短取决于回收集的大小。
  6. 初始引用更新:并发回收阶段复制对象结束后,还需要把堆中所有指向旧对象的引用修正蛋糕复制后的新地址,这个操作称为引用更新。这个阶段就是对这个操作进行初始化的,初始引用更新时间很短,会产生一个非常短暂的停顿。
  7. 并发引用更新:真正开始进行引用更新操作,这个阶段是与用户线程一起并发的,时间长短取决于内存中涉及的引用数量的多少。
  8. 最终引用更新:解决了堆中的引用更新后,还要修正存在于GC Roots 中的引用。这个阶段是Shenandoah的最后一次停顿,时间长短与GC Roots的数量有关。
  9. 并发清理:经过并发回收和引用更新之后,整个回收集中所有的Region已再无存活对象,最后再调用一次并发清理过程来回收这些Region 的内存空间,供以后新对象分配使用。

在原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己。

image

转发指针加入后带来的收益自然是当对象拥有了一份新的副本时,只需要修改一处指针的值,即旧对象上转发指针的引用位置,使其指向新对象,便可以将所有对该对象的访问转发到新的副本上。

image

3.6.2 ZGC收集器

  1. 小型Region(Small Region):容量固定为2MB,用于放置小于256KB的小对象。
  2. 中型Region(Medium Region):容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。
  3. 大型Region(Large Region):容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象。每个大型Region中只会存放一个大对象,这也预示着虽然名字叫做“大型Region”,但它的实际容量完全有可能小于中型Region,最小容量可低至4MB。

image

image

  1. 并发标记(Concurrent Mark)

    遍历对象图做可达性分析的阶段, 前后也要经过类似于G1, Shenandoah 的初始标记, 最终标记的短暂停顿。 与G1, Shenandoah不同的是, ZGC的标记是在指针上而不是在对象上进行的, 标记阶段会更新染色指针中的Marked0、Marked1标志位。

  2. 并发预备重分配(Concurrent Prepare for Relocate)

    此阶段需要根据特定的查询条件统计出本次收集过程要清理哪些Region, 将这些Region组成重分配集(Relocation Set)。

  3. 并发重分配(Concurrent Relocate)

    是ZGC执行过程中的核心阶段, 此过程要把重分配集中的存活对象复制到新的Region上, 并为重分配集中的每个Region维护一个转发表(Forward Table), 记录从旧对象到新对象的转向关系。
    由于染色指针的存在, ZGC能仅从引用上就明确得知一个对象是否处于重分配集之中。如果用户线程此时并发访问了位于重分配集中的对象, 这次访问将会被预置的内存屏障截获, 然后立即根据Region上的转发表记录将访问转发到新复制的对象上, 并同时修正该引用的值, 使其直接指向新对象, 此即为Self-Healing(自愈)[只有第一次访问旧对象会陷入转发]。

  4. 并发重映射(Concurrent Remap)

    修正整个堆中指向重分配集中旧对象的所有引用。
    重映射清理这些旧引用的主要目的是为了不变慢, 并不是很迫切。
    ZGC将并发重映射阶段要做的工作, 合并到了下一次垃圾收集循环中的并发标记阶段里去完成, 从而节省了一次遍历对象图的开销。

3.6.2.1 染色指针技术

在64位系统中, 理论可以访问的内存高达16EB。实际上基于需求, 性能, 和成本考虑, 在AMD64架构中只支持到52位(4PB)的地址总线和48位(256TB)的虚拟地址空间, 目前64位的硬件实际能够支持的最大内存只有256TB。此外操作系统还有自己的约束, 64Linux系统分别支持47位(128TB)的进程虚拟地址和46位(64TB)的物理地址空间, 64位的Windows系统只支持44位(16TB)的物理地址空间。

虽然Linux下64位指针的高18位不能用来寻址, 剩余的46位指针所能支持的64TB内存在今天仍能够充分满足大型服务器需要。而ZGC则利用了剩下的46位指针的高4位提取出来用于存储四个标志信息。

image

  1. 染色指针可以使得一旦某个Region的存活对象被移走之后, 此Region立即就能够被释放和重用掉, 而不必等待整个堆中所有指向该Region的引用都被修正后才能清理。
  2. 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量。
  3. 染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据, 以便日后进一步提高性能。

3.6.2.2 多重映射

处理器会使用分页管理机制把线性地址空间和物理地址空间分别划分为大小相同的块,这样的内存块被称为“页”(Page)。通过在线性虚拟空间的页与物理地址空间的页之间建立的映射表,分页管理机制会进行线性地址到物理地址空间的映射,完成线性地址到物理地址的转换。

任何的进程在进程自己看来自己的内存空间都是连续的, 但是计算机实际的物理内存并不是与该进程的内存是一一对应的。碎片化的物理内存可以映射成一个完整的虚拟内存, 同时应用可以申请比物理内存大的内存, 使得多个内存互不干扰, 使编译好的二进制文件的地址统一化……

3.7 选择合适的垃圾收集器

  1. 应用程序的主要关注点是什么?譬如吞吐量或则是停顿时间等等。
  2. 运行应用程序的基础设施如何?譬如硬件规格等等。
  3. 使用JDK的发行商是扫什么?版本号是多少?

3.8 实战:内存分配与回收策略

private static final int _1MB = 1024 * 1024;

3.8.1 对象有限在Eden分配

    /**
     * 对象优先在Eden分配
     * VM参数:-XX:+UseSerialGC -Xms20M -Xmx20M -Xmn10M -Xlog:gc* -XX:SurvivorRatio=8
     *         使用SerialGC     堆初始值  堆最大值  新生代   日志信息   新生代中Eden与一个Survivor区空间比例8:1
     */
    public static void testAllocation() {
        byte[] allocation1, allocation2, allocation3, allocation4;
        allocation1 = new byte[2 * _1MB];
        allocation2 = new byte[2 * _1MB];
        allocation3 = new byte[2 * _1MB];
        allocation4 = new byte[4 * _1MB]; // 出现一次Minor GC
    }
[0.067s][info][gc] Using Serial
[0.067s][info][gc,heap,coops] Heap address: 0x00000000fec00000, size: 20 MB, Compressed Oops mode: 32-bit
[0.474s][info][gc,start     ] GC(0) Pause Young (Allocation Failure)
[0.484s][info][gc,heap      ] GC(0) DefNew: 8167K->927K(9216K)
[0.484s][info][gc,heap      ] GC(0) Tenured: 0K->6144K(10240K)
[0.484s][info][gc,metaspace ] GC(0) Metaspace: 649K->649K(1056768K)
[0.484s][info][gc           ] GC(0) Pause Young (Allocation Failure) 7M->6M(19M) 9.935ms
[0.484s][info][gc,cpu       ] GC(0) User=0.00s Sys=0.02s Real=0.01s
[0.486s][info][gc,heap,exit ] Heap
[0.486s][info][gc,heap,exit ]  def new generation   total 9216K, used 5346K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
[0.486s][info][gc,heap,exit ]   eden space 8192K,  53% used [0x00000000fec00000, 0x00000000ff050cc0, 0x00000000ff400000)
[0.486s][info][gc,heap,exit ]   from space 1024K,  90% used [0x00000000ff500000, 0x00000000ff5e7d80, 0x00000000ff600000)
[0.486s][info][gc,heap,exit ]   to   space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
[0.486s][info][gc,heap,exit ]  tenured generation   total 10240K, used 6144K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
[0.486s][info][gc,heap,exit ]    the space 10240K,  60% used [0x00000000ff600000, 0x00000000ffc00030, 0x00000000ffc00200, 0x0000000100000000)
[0.486s][info][gc,heap,exit ]  Metaspace       used 660K, capacity 4538K, committed 4864K, reserved 1056768K
[0.486s][info][gc,heap,exit ]   class space    used 60K, capacity 403K, committed 512K, reserved 1048576K

3.8.2 大对象直接进入老年代

    /**
     * 大对象直接进入老年代
     * VM参数:-XX:+UseSerialGC -Xms20M -Xmx20M -Xmn10M -Xlog:gc* -XX:SurvivorRatio=8
     * -XX:PretenureSizeThreshold=3145728
     *  超过3145728(3MB)被定义为大对象
     */
    public static void testPretenureSizeThreshold() {
        byte[] allocation;
        allocation = new byte[4 * _1MB]; // 直接分配在老年代中
    }
[0.018s][info][gc] Using Serial
[0.018s][info][gc,heap,coops] Heap address: 0x00000000fec00000, size: 20 MB, Compressed Oops mode: 32-bit
[0.176s][info][gc,heap,exit ] Heap
[0.176s][info][gc,heap,exit ]  def new generation   total 9216K, used 2187K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
[0.176s][info][gc,heap,exit ]   eden space 8192K,  26% used [0x00000000fec00000, 0x00000000fee22cc0, 0x00000000ff400000)
[0.176s][info][gc,heap,exit ]   from space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400000, 0x00000000ff500000)
[0.176s][info][gc,heap,exit ]   to   space 1024K,   0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
[0.176s][info][gc,heap,exit ]  tenured generation   total 10240K, used 4096K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
[0.176s][info][gc,heap,exit ]    the space 10240K,  40% used [0x00000000ff600000, 0x00000000ffa00010, 0x00000000ffa00200, 0x0000000100000000)
[0.176s][info][gc,heap,exit ]  Metaspace       used 645K, capacity 4535K, committed 4864K, reserved 1056768K
[0.176s][info][gc,heap,exit ]   class space    used 59K, capacity 402K, committed 512K, reserved 1048576K

3.8.3 长期存活的对象将进入老年代

    /**
     * 长期存活的对象将进入老年代
     * VM参数:-XX:+UseSerialGC -Xms20M -Xmx20M -Xmn10M -Xlog:gc* -XX:SurvivorRatio=8
     * -XX:MaxTenuringThreshold=1 -Xlog:gc+age=trace
     *  长期存活对象定义为年龄超过1
     */
    public static void testTenuringThreshold() {
        byte[] allocation1, allocation2, allocation3;
        allocation1 = new byte[_1MB / 4]; // 什么时候进入老年代决定于-XX:MaxTenuringThreshold的设置
        allocation2 = new byte[4 * _1MB];
        allocation3 = new byte[4 * _1MB];
        allocation3 = null;
        allocation3 = new byte[4 * _1MB];
    }
[0.051s][info][gc] Using Serial
[0.053s][info][gc,heap,coops] Heap address: 0x00000000fec00000, size: 20 MB, Compressed Oops mode: 32-bit
[0.329s][info][gc,start     ] GC(0) Pause Young (Allocation Failure)
[0.334s][debug][gc,age       ] GC(0) Desired survivor size 524288 bytes, new threshold 1 (max threshold 1)
[0.334s][trace][gc,age       ] GC(0) Age table with threshold 1 (max threshold 1)
[0.334s][trace][gc,age       ] GC(0) - age   1:    1048576 bytes,    1048576 total
[0.334s][info ][gc,heap      ] GC(0) DefNew: 6375K->1024K(9216K)
[0.334s][info ][gc,heap      ] GC(0) Tenured: 0K->4251K(10240K)
[0.334s][info ][gc,metaspace ] GC(0) Metaspace: 629K->629K(1056768K)
[0.334s][info ][gc           ] GC(0) Pause Young (Allocation Failure) 6M->5M(19M) 4.293ms
[0.334s][info ][gc,cpu       ] GC(0) User=0.00s Sys=0.00s Real=0.00s
[0.335s][info ][gc,start     ] GC(1) Pause Young (Allocation Failure)
[0.341s][debug][gc,age       ] GC(1) Desired survivor size 524288 bytes, new threshold 1 (max threshold 1)
[0.341s][trace][gc,age       ] GC(1) Age table with threshold 1 (max threshold 1)
[0.341s][trace][gc,age       ] GC(1) - age   1:        824 bytes,        824 total
[0.341s][info ][gc,heap      ] GC(1) DefNew: 5201K->0K(9216K)
[0.341s][info ][gc,heap      ] GC(1) Tenured: 4251K->5275K(10240K)
[0.341s][info ][gc,metaspace ] GC(1) Metaspace: 630K->630K(1056768K)
[0.341s][info ][gc           ] GC(1) Pause Young (Allocation Failure) 9M->5M(19M) 6.012ms
[0.341s][info ][gc,cpu       ] GC(1) User=0.02s Sys=0.00s Real=0.01s
[0.342s][info ][gc,heap,exit ] Heap
[0.342s][info ][gc,heap,exit ]  def new generation   total 9216K, used 4390K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
[0.342s][info ][gc,heap,exit ]   eden space 8192K,  53% used [0x00000000fec00000, 0x00000000ff0495e0, 0x00000000ff400000)
[0.342s][info ][gc,heap,exit ]   from space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400338, 0x00000000ff500000)
[0.342s][info ][gc,heap,exit ]   to   space 1024K,   0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
[0.342s][info ][gc,heap,exit ]  tenured generation   total 10240K, used 5275K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
[0.342s][info ][gc,heap,exit ]    the space 10240K,  51% used [0x00000000ff600000, 0x00000000ffb26d40, 0x00000000ffb26e00, 0x0000000100000000)
[0.342s][info ][gc,heap,exit ]  Metaspace       used 650K, capacity 4535K, committed 4864K, reserved 1056768K
[0.342s][info ][gc,heap,exit ]   class space    used 59K, capacity 402K, committed 512K, reserved 1048576K

3.8.4 动态对象年龄判定

    /**
     * 动态对象年龄判定
     * VM参数:-XX:+UseSerialGC -Xms20M -Xmx20M -Xmn10M -Xlog:gc* -XX:SurvivorRatio=8
     * -XX:MaxTenuringThreshold=15 -Xlog:gc+age=trace
     */
    @SuppressWarnings("unused")
    public static void testTenuringThreshold2() {
        byte[] allocation1, allocation2, allocation3, allocation4;
        allocation1 = new byte[_1MB / 4]; // allocation1+allocation2大于survivor空间一半
        allocation2 = new byte[_1MB / 4];
        allocation3 = new byte[4 * _1MB];
        allocation4 = new byte[4 * _1MB];
        allocation4 = null;
        allocation4 = new byte[4 * _1MB];
    }
[0.028s][info][gc] Using Serial
[0.028s][info][gc,heap,coops] Heap address: 0x00000000fec00000, size: 20 MB, Compressed Oops mode: 32-bit
[0.209s][info][gc,start     ] GC(0) Pause Young (Allocation Failure)
[0.215s][debug][gc,age       ] GC(0) Desired survivor size 524288 bytes, new threshold 1 (max threshold 15)
[0.215s][trace][gc,age       ] GC(0) Age table with threshold 1 (max threshold 15)
[0.215s][trace][gc,age       ] GC(0) - age   1:    1048576 bytes,    1048576 total
[0.216s][info ][gc,heap      ] GC(0) DefNew: 6630K->1024K(9216K)
[0.216s][info ][gc,heap      ] GC(0) Tenured: 0K->4507K(10240K)
[0.216s][info ][gc,metaspace ] GC(0) Metaspace: 631K->631K(1056768K)
[0.216s][info ][gc           ] GC(0) Pause Young (Allocation Failure) 6M->5M(19M) 6.732ms
[0.216s][info ][gc,cpu       ] GC(0) User=0.00s Sys=0.00s Real=0.01s
[0.216s][info ][gc,start     ] GC(1) Pause Young (Allocation Failure)
[0.218s][debug][gc,age       ] GC(1) Desired survivor size 524288 bytes, new threshold 15 (max threshold 15)
[0.218s][trace][gc,age       ] GC(1) Age table with threshold 15 (max threshold 15)
[0.218s][trace][gc,age       ] GC(1) - age   1:       2368 bytes,       2368 total
[0.218s][info ][gc,heap      ] GC(1) DefNew: 5201K->2K(9216K)
[0.218s][info ][gc,heap      ] GC(1) Tenured: 4507K->5531K(10240K)
[0.218s][info ][gc,metaspace ] GC(1) Metaspace: 631K->631K(1056768K)
[0.218s][info ][gc           ] GC(1) Pause Young (Allocation Failure) 9M->5M(19M) 2.129ms
[0.218s][info ][gc,cpu       ] GC(1) User=0.02s Sys=0.00s Real=0.00s
[0.221s][info ][gc,heap,exit ] Heap
[0.221s][info ][gc,heap,exit ]  def new generation   total 9216K, used 4392K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
[0.221s][info ][gc,heap,exit ]   eden space 8192K,  53% used [0x00000000fec00000, 0x00000000ff0496d8, 0x00000000ff400000)
[0.221s][info ][gc,heap,exit ]   from space 1024K,   0% used [0x00000000ff400000, 0x00000000ff400940, 0x00000000ff500000)
[0.221s][info ][gc,heap,exit ]   to   space 1024K,   0% used [0x00000000ff500000, 0x00000000ff500000, 0x00000000ff600000)
[0.221s][info ][gc,heap,exit ]  tenured generation   total 10240K, used 5531K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
[0.221s][info ][gc,heap,exit ]    the space 10240K,  54% used [0x00000000ff600000, 0x00000000ffb66d58, 0x00000000ffb66e00, 0x0000000100000000)
[0.222s][info ][gc,heap,exit ]  Metaspace       used 651K, capacity 4535K, committed 4864K, reserved 1056768K
[0.222s][info ][gc,heap,exit ]   class space    used 59K, capacity 402K, committed 512K, reserved 1048576K

3.8.5 空间分配担保

  1. 如果这个条件成立,那这一次Minor GC可以确保是安全的。
  2. 否则,虚拟机会查看-XX:-HandlePromotionFailure参数的设置值是否允许担保失败。

    如果允许,那会继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小。如果大于,那将尝试进行一次Minor GC,尽管这次Minor GC是有风险的;如果小于,那么就要进行一次Full GC。
    如果不允许,那么就要进行一次Full GC。

第4章 虚拟机性能监控、故障处理工具*

第5章 调优案例分析与实战*

第6章 类文件结构

6.1 概述

6.2 无关性的基石

image

6.3 Class类文件结构

  1. 无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。
  2. 表是由多个无符号数或者其他表作为数据项构成的复合数据类型,为了便于区分,所有表的命名都习惯性地以“_info”结尾。表用于描述由层关系的符合结构的数据,整个Class文件本质上也可以视作是一张表。

6.3.1 魔数与Class文件版本

// TestClass.java
package org.fenixsoft.clazz;

public class 
{

	private int m;
	
	public int inc() {
		return m + 5;
	}
}

编译上述TestClass.java文件生成TestClass.class,并使用十六进制编辑器WinHex打开Class文件。

image

如图,开头4个字节的十六进制表示是0xCAFEBABE,代表次版本号的第5个和第6个字节值为0x0000,而主版本号的值为0x0039,也即是十进制的57,代表该JDK版本号是13。

6.3.2 常量池

image

如图,常量池容量为0x0013,即十进制的19,这就代表常量池中有18项常量,索引值范围为1~18。

  1. 被模块到处或者开放的包
  2. 类和接口的全限定名
  3. 字段的名称和描述符
  4. 方法的名称和描述符
  5. 方法句柄和方法类型
  6. 动态调用点和动态常量
类型 标志 描述
CONSTANT_Utf8_info 1 UTF-8编码的字符串
CONSTANT_Integer_info 3 整型字面量
CONSTANT_Float_info 4 浮点型字面量
CONSTANT_Long_info 5 长整型字面量
CONSTANT_Double_info 6 双精度浮点型字面量
CONSTANT_Class_info 7 类或接口的符号引用
CONSTANT_String_info 8 字符串类型字面量
CONSTANT_Filedref_info 9 字段的符号引用
CONSTANT_Methodref_info 10 类中方法的符号引用
CONSTANT_InterfaceMethodref_info 11 接口中方法的符号引用
CONSTANT_NameAndType_info 12 字段或方法的部分符号引用
CONSTANT_MethodHandle_info 15 表示方法句柄
CONSTANT_MethodType_info 16 表示方法类型
CONSTANT_Dynamic_info 17 表示一个动态计算常量
CONSTANT_InvokeDynamic_info 18 表示一个动态方法调用点
CONSTANT_Module_info 19 表示一个模块
CONSTANT_Package_info 20 表示一个模块中开放或者导出的包
javap -verbose TestClass  
Compiled from "TestClass.java"  
public class org.fenixsoft.clazz.TestClass  
  minor version: 0  
  major version: 57  
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER  
  this_class: #8                          // org/fenixsoft/clazz/TestClass  
  super_class: #2                         // java/lang/Object  
  interfaces: 0, fields: 1, methods: 2, attributes: 1  
Constant pool:  
   #1 = Methodref          #2.#3          // java/lang/Object."<init>":()V  
   #2 = Class              #4             // java/lang/Object  
   #3 = NameAndType        #5:#6          // "<init>":()V  
   #4 = Utf8               java/lang/Object  
   #5 = Utf8               <init>  
   #6 = Utf8               ()V  
   #7 = Fieldref           #8.#9          // org/fenixsoft/clazz/TestClass.m:I  
   #8 = Class              #10            // org/fenixsoft/clazz/TestClass  
   #9 = NameAndType        #11:#12        // m:I  
  #10 = Utf8               org/fenixsoft/clazz/TestClass  
  #11 = Utf8               m  
  #12 = Utf8               I  
  #13 = Utf8               Code  
  #14 = Utf8               LineNumberTable  
  #15 = Utf8               inc  
  #16 = Utf8               ()I  
  #17 = Utf8               SourceFile  
  #18 = Utf8               TestClass.java  

6.3.3 访问标志

标志名称 标志值 含义
ACC_PUBLIC 0x0001 是否为public类型
ACC_FINAL 0x0010 是否被声明为fianl,只有类可设置
ACC_SUPER 0x0020 是否允许使用invokespecial字节码指令的新语义,
JDK 1.0.2之后都为真
ACC_INTERFACE 0x0200 标识这是一个接口
ACC_ABSTRACT 0x0400 是否为abstract类型,
接口和抽象类为真,其他为假
ACC_SYNTHETIC 0x1000 标识这个类并非由用户代码生成
ACC_ANNOTATION 0x2000 标识这是一个注解
ACC_ENUM 0x4000 标识这是一个枚举
ACC_MODULE 0x8000 标识这是一个模块

image

如图,前例中,TestClass是一个普通的Java类,它的ACC_PUBLIC、ACC_SUPER标识位为真,而其他7个标识位为假,因此它的access_flags的值为:0x0001|0x0020 = 0x0021。即图中0x0021。

6.3.4 类索引、父类索引与接口索引集合

除了java.lang.Object之外,所有的Java类都有父类,因此除了java.lang.Object外,所有Java类的父类索引都不为0。

image

如图,0x0008、0x0002、0x0000分别表示类索引为8,父类索引为2,接口索引集合大小为0。对照前面javap命令计算出来的常量池,可以找到对应的类和父类的常量。

  #2 = Class              #4             // java/lang/Object
  #8 = Class              #10            // org/fenixsoft/clazz/TestClass

6.3.5 字段表集合

类型 名称 数量
u2 access_flags 1
u2 name_index 1
u2 descriptor_index 1
u2 attributes_count 1
attribute_info attributes attributes_count
标志名称 标志值 含义
ACC_PUBLIC 0x0001 字段是否public
ACC_PRIVATE 0x0002 字段是否private
ACC_PROTECTED 0x0004 字段是否protected
ACC_STATIC 0x0008 字段是否static
ACC_FINAL 0x0010 字段是否final
ACC_VOLATILE 0x0040 字段是否volatile
ACC_TRANSIENT 0x0080 字段是否transient
ACC_SYNTHETIC 0x1000 字段是否由编译器自动产生
ACC_ENUM 0x4000 字段是否enum
标识字符 含义
B 基本类型byte
C 基本类型char
D 基本类型double
F 基本类型float
I 基本类型int
J 基本类型long
S 基本类型short
Z 基本类型boolean
V 特殊类型void
L 对象类型,如Ljava/lang/Object;

如一个定义为“java.lang.String[][]”类型的二维数组将被记录城“[[Ljava/lang/String;”,一个整型数组“int[]”将被记录成“[I”。

如方法void inc()的描述符为“()V”,
方法java.lang.String toString()的描述符为“()Ljava/lang/String;”,
方法int indexOf(char[] source, int sourceOffset, int sourceCount, char[] target, int targetOffset, int targetCount, int fromIndex)的描述符为“([CII[CII)I”。

image

如图,第一个u2类型的数据为容量计数器fields_count,其值为0x0001,说明这个类只有一个字段表数据。接下来是access_flags标志,值为0x0002,代表private修饰符的ACC_PRIVATE标志位为真。接下来是代表字段名称的name_index,值为0x000B(十进制的11)对应m,接下来是代表字段描述的descriptor_index,值为0x000C(十进制的12)对应I。根据以上信息,可以推断出原代码定义的字段为“private int m”。

  #11 = Utf8               m
  #12 = Utf8               I

6.3.6 方法表集合

类型 名称 数量
u2 access_flags 1
u2 name_index 1
u2 descriptor_index 1
u2 attributes_count 1
attribute_info attributes attributes_count
标志名称 标志值 含义
ACC_PUBLIC 0x0001 方法是否为public
ACC_PRIVATE 0x0002 方法是否为private
ACC_PROTECTED 0x0004 方法是否为protected
ACC_STATIC 0x0008 方法是否为static
ACC_FINAL 0x0010 方法是否为final
ACC_SYNCHRONIZED 0x0020 方法是否为synchronized
ACC_BRIDGE 0x0040 方法是不是由编译器产生的桥接方法
ACC_VARARGS 0x0080 方法是否接受不定参数
ACC_NATIVE 0x0100 方法是否为native
ACC_ABSTRACT 0x0400 方法是否为abstract
ACC_STRICT 0x0800 方法是否为strictfp
ACC_SYNTHETIC 0x1000 方法是否由编译器自动产生

image

如图,第一个u2类型的数据(即计数器容量)的值为0x0002,代表集合中有两个方法,这两个方法为编译器添加的实例构造器<init>和源码中定义的方法inc()。第一个方法的访问标志值为0x0001,也就是只有ACC_PUBLIC标志为真,名称索引值为0x0005,对应常量池中“<init>”,描述索引值为0x0006,对应常量池中“()V”,属性表计数器attributes_count的值为0x0001,表示此方法的属性表集合有1项属性,属性名称的索引值为0x000D(十进制的13),对应常量池中“Code”,说明此属性是方法的字节码描述。

6.3.7 属性表集合

类型 名称 数量
u2 attribute_name_index 1
u4 attribute_length 1
u1 info attribute_length

6.4 字节码指令简介

6.4.1 字节码与数据类型

举个例子,iload指令用于从局部变量表中加载int型的数据到操作数栈中,而fload指令加载的则是float类型的数据。

6.4.2 加载和存储指令

  1. 将一个局部变量加载到操作栈:iload、iload_<n>、lload、lload_<n>、fload、fload_<n>、dload、dload_<n>、aload、aload_<n>
  2. 将一个数值从操作数栈存储到局部变量表:istore、istore_<n>…
  3. 将一个常量加载到操作数栈:bipush、sipush、ldc、ldc_w、ldc2w、aconst_null、iconst_ml、iconst__<i>、lconst__<l>、fconst__<f>、dconst<d>
  4. 扩充局部变量表的访问索引的指令:wide

    尖括号结尾的指令代表一组指令(例如iload_<n>,代表iload_0、iload_1、iload_2和iload_3这几条指令)。

6.4.3 运算指令

  1. 加法指令:iadd、ladd、fadd、dadd
  2. 减法指令:isub…
  3. 乘法指令:imul…
  4. 除法指令:idiv…
  5. 求余指令:irem…
  6. 取反指令:ineg…
  7. 位移指令:ishl、ishr、iushr、lshl、lshr、lushr
  8. 按位或指令:ior、lor
  9. 按位与指令:iand、land
  10. 按位异或指令:ixor、lxor
  11. 局部变量自增指令:iinc
  12. 比较指令:dcmpg、dcmpl、fcmpg、fcmpl、lcmp

6.4.4 类型转换指令

  1. int类型到long、float或者double类型
  2. long类型到float、double类型
  3. float类型到double类型

6.4.5 对象创建与访问指令

  1. 创建类实例的指令:new
  2. 创建数组的指令:nwearray、anewarray、multianewarray
  3. 访问类字段(static字段,或者称为类变量)和实例字段(非static字段,或者称为实例变量)的指令:getfield、putfield、getstatic、putstatic
  4. 把一个数组元素加载到操作数栈的指令:baload、caload、saload、iaload、laload、faload、daload、aaload
  5. 将一个操作数栈的值储存到数组元素中的指令:bastore、castore、sastore、iastore、fastore、dastore、aastore
  6. 取数组长度的指令:arraylength
  7. 检查类实例类型的指令:instanceof、checkcast

6.4.6 操作数栈管理指令

  1. 将操作数栈的栈顶一个或两个元素出栈:pop、pop2
  2. 复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup、dup2、dup_x1、dup2_x1、dup_x2、dup2_x2
  3. 将栈最顶端的两个数值互换:swap

6.4.7 控制转移指令

  1. 条件分支:ifeq、iflt、ifle、ifne、ifgt、ifge、ifnull、ifnonnull、if_icmpeq、if_icmpne、if_icmplt、if_icmpgt、if_icmple、if_icmpge、if_acmpeq和if_acmpne
  2. 复合条件分支:tableswith、lookupswitch
  3. 无条件分支:goto、goto_w、jsr、jsr_w、ret

6.4.8 方法调用和返回指令

  1. invokevirtual指令:用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是Java语言中最常见的方法分派方式。
  2. invokeinterface指令:用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用。
  3. invokespecial指令:用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。
  4. invokestatic指令:用于调用类方法(static方法)。
  5. invokedynamic指令:用于在运行时动态解析出调用点限定符所引用的方法。并执行该方法。

    前4条指令的分派逻辑都固化在Java虚拟机内部,用户无法改变,而第5条指令的分派逻辑是由用户所设定的引导方法决定的。

方法返回指令是根据返回值的类型区分的,包括ireturn(当返回值是boolean、byte、char、short和int类型时使用)、lreturn、freturn、dreturn和areturn,另外还有一条return指令供声明为void的方法、实例初始化方法、类和接口的类初始化方法使用。

6.4.9 异常处理指令

6.4.10 同步指令

第7章 虚拟机类加载机制

7.1 概述

7.2 类加载的时机

image

其中加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类型的加载过程必须按照这种顺序按部就班地开始,而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java序言的运行时绑定特性(也称为动态绑定或晚期绑定)。上述按部就班地“开始”,并不是等待上一个阶段结束后,再开始,而是相互交叉地混合进行。

  1. 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:

    1)使用new关键字实例化对象的时候。
    2)读取或设置一个类型的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候。
    3)调用一个类型的静态方法的时候。

  2. 调用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
  3. 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  4. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
  5. 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF__putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
  6. 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

    以上6种场景的行为称为对一个类型进行“主动引用”。除此6种之外,所有引用类型的方式都不会触发初始化,称为“被动引用”。

public class SuperClass {
    static {
        System.out.println("SuperClass init!");
    }

    public static int value = 123;
}
public class SubClass extends SuperClass {
    static {
        System.out.println("SubClass init!");
    }
}
/**
 * 被动使用类字段演示一:
 * 通过子类引用父类的静态字段,不会导致子类初始化
 **/
public class NotInitialization {
    public static void main(String[] args) {
        System.out.println(SubClass.value); 
        // 只会输出“SuperClass init!”,不会输出“SubClass init!”
    }
}

/**
 * 被动使用类字段演示二:
 * 通过数组定义来引用类,不会触发此类的初始化
 **/
public class NotInitialization {
    public static void main(String[] args) {
        SuperClass[] sca = new SuperClass[10];
        // 无输出
    }
}
/** 被动使用类字段演示三:
 * 常量在编译阶段会存入调用类的常量池中,
 * 本质上没有直接引用到定义常量的类,
 * 因此不会触发定义常量的类的初始化
 **/
public class ConstClass {
    static {
        System.out.println("ConstClass init!");
    }

    public static final String HELLOWORLD = "hello world";
}
public class NotInitialization {
    public static void main(String[] args) {
        System.out.println(ConstClass.HELLOWORLD);
        // 不会输出“ConstClass init!”
    }
}

7.3 类加载的过程

7.3.1 加载

  1. 通过一个类的全限定名来获取定义此类的二进制字节流。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

7.3.2 验证

验证阶段是非常重要的,这个阶段是否严谨,直接决定了Java虚拟机是否能承受恶意代码的攻击,从代码量和耗费的执行性能的角度上讲,验证阶段的工作量在虚拟机的类加载过程中占了相当大的比重。

  1. 文件格式验证。第一阶段要检验字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。这一阶段可能包括的验证点:

    是否以魔术0xCAFEBABE开头。
    主、次版本号是否在当前Java虚拟机的接受范围之内。

  2. 元数据验证。第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java虚拟机规范》的要求。这一阶段可能包括的验证点:

    这个类是否有父类。
    这个类的父类是否继承了不允许被继承的类(被final修饰的类)。

  3. 字节码验证。第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。这阶段对类的方法体(Class文件中的Code属性)进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为,可能包括的验证点:

    保证任何跳转指令都不会跳转到方法体以外的字节码指令上。

  4. 符号引用验证。第四阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生。这一阶段可能包括的验证点:

    符号引用中通过字符串描述的全限定名是否能找到对应的类。
    在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。

7.3.3 准备

public static int value1 = 123;
public static final int value2 = 123;

变量value1在准备阶段过后的初始值为0而不是123,将value1赋值为123的动作要到类的初始化阶段才会被执行。而value2在准备阶段过后值即为123。

7.3.4 解析

符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须都是一致的,因为符号引用的字面量形式明确定义在《Java虚拟机规范》的Class文件格式中。
直接引用(Direct References):直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在虚拟机的内存中存在。

  1. 类或接口的解析。假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,那虚拟机完成整个解析的过程需要以下3个步骤:

    1)如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口。一旦这个加载过程出现了任何异常,解析过程就宣告失败。
    2)如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类似“[Ljava/lang/Integer”的形式,那将会按照第1点的规则加载数组元素类型。如果N的描述符如前面所假设的形式,需要加载的元素类型就是“java.lang.Integer”,接着由虚拟机生成一个代表此数组维度和元素的数组对象。
    3)如果上面的步骤没有出现任何异常,那么C在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成之前还要进行符号引用验证,确认D是否具备对C的访问权限。如果发现不具备访问权限,将抛出java.lang.IllegalAccessError异常。

  2. 字段解析。要解析一个未被解析过的字段符号引用,首先将会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类或接口的符号引用。如果在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段符号引用解析的失败。如果解析成功完成,那将这个字段所属的类或接口用C表示,虚拟机规范要求按照如下步骤对C进行后续字段的搜索:

    1)如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
    2)否则,如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
    3)否则,如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
    4)否则,查找失败,抛出java.lang.NoSuchFieldError异常。
    5)如果查找过程成功返回了引用,将会对这个字段进行权限验证,如果发现不具备对字段的访问权限,将抛出java.lang.Ille-galAccessError异常。

  3. 方法解析。类方法解析的第一个步骤与字段解析一样,也需要先解析出类方法表的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,我们依然用C表示这个类,接下来虚拟机将会按照如下步骤进行后续的类方法搜索:

    1)类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现class_index中索引的C是个接口,那就直接抛出java.lang.IncompatibleClassChangeError异常。
    2)如果通过了第1步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
    3)否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
    4)否则,在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类C是一个抽象类,这时查找结束,抛出java.lang.AbstractMethodError异常。
    5)否则,宣告方法查找失败,抛出java.lang.NoSuchMeth-odError。

  4. 接口方法解析。接口方法也需要先解析出接口方法表的class_index项中索引的方法所属的类或接口的符号引用,如果解析成功,依然用C表示这个接口,接下来虚拟机将会按照如下步骤进行后续的接口方法搜索:

    1)与类方法解析不同,如果在接口方法表中发现class_index中的索引C是个类而不是接口,那就直接抛出java.lang.Incom-patibleClassChangeError异常。
    2)否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
    3)否则,在接口C的父接口中递归查找,直到java.lang.Object类(查找范围会包括Object类)为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束。
    4)对于规则3,由于Java的接口允许多重继承,如果C的不同父接口中存在多个简单名称和描述符都与目标相匹配的方法,那么将会从这多个方法中返回其中一个并结束查找。 5)否则,宣告方法查找失败,抛出java.lang.NoSuchMeth-odError异常。

7.3.5 初始化

<clinit>()并不是程序员在Java代码中直接编写的方法,它是Javac编译器的自动生产物。

  1. <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量;定义在它之后的变量,在前面的静态语句块中可以赋值,但是不能访问。
    public class Test {
     static {
         i = 0; // 可以赋值
         System.out.println(i); // 无法访问
     }
     static int i =1;
    }
    
  2. <clinit>()方法与类的构造函数(即在虚拟机视角中的实例构造器<init>()方法)不同,它不需要显示地调用父类构造器,Java虚拟机会保证在子类的<clinit>()方法执行前,父类的<clinit>()方法已经执行完毕。因此在Java虚拟机中第一个被执行的<clinit>()方法的类型肯定是java.lang.Object。
  3. 由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。
    public class Test {
     static class Parent {
         public static int A = 1;
         static {
             A = 2;
         }
     }
     static class Sub extends Parent {
         public static int B = A;
     }
     public static void main(String[] args) {
         System.out.println(Sub.B);
         // 输出 2
     }
    }
    
  4. <clinit>()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
  5. 接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,因为只有当父接口中定义的变量被使用时,父接口才会被初始化。此外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。
  6. Java虚拟机必须保证一个类的<clinit>()方法在多线程环境中被正确地加锁同步,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行完毕<clinit>()方法。如果在一个类的<clinit>()方法中有耗时很长的操作,那就可能造成多个进程阻塞,在实际应用中这种阻塞往往是很隐蔽的。
    public class Test {
     static class DeadLoopClass {
         static {
             if (true) {
                 System.out.println(Thread.currentThread() + "init DeadLoopClass");
                 while (true) {
                 }
             }
         }
     }
     public static void main(String[] args) {
         Runnable script = new Runnable() {
             @Override
             public void run() {
                 System.out.println(Thread.currentThread() + "start");
                 DeadLoopClass dlc = new DeadLoopClass();
                 System.out.println(Thread.currentThread() + "run over");
             }
         };
         Thread thread1 = new Thread(script);
         Thread thread2 = new Thread(script);
         thread1.start();
         thread2.start();
     }
    }
    /** 输出结果
     * Thread[Thread-1,5,main]start
     * Thread[Thread-0,5,main]start
     * Thread[Thread-1,5,main]init DeadLoopClass
     * 一条线程死循环模拟长时间操作,另外一条线程阻塞等待
     **/
    

7.4 类加载器

7.4.1 类与类加载器

更通俗的表达:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个Java虚拟机加载,只要加载它们的的类加载器不同,那这两个类就必定不相等。

7.4.2 双亲委派模型

image

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去完成加载。