深入理解Java虚拟机(2-9)学习总结

:本文参考学习周志明老师的《深入理解Java虚拟机(第3版)》

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

2.1 概述

  • 对于java程序员来说虚拟机的自动内存管理,不需要我们去给对象进行空间释放。

2.2 运行时数据区域

  • 程序运行的时候内存划分的数据区域。

image-20211206205218851

2.2.1 程序计数器

  • 程序计数器占据内存空间很小。它是当前线程执行字节码的行号指示器。
  • 而且能够保证线程切换的时候保存这次准备执行的指令行号。
  • 它没有OutOfMemoryError情况的区域。

2.2.2 Java虚拟机栈

  • Java虚拟机栈也是线程私有的。生命周期和线程一样。
  • 虚拟机栈描述的是线程的内存模型。
  • 每个方法调用的时候都会在栈中创建栈帧。
    • 局部变量表:存放了编译期可知的基本数据类型。对象引用。这些数据类型在局部变量表都是通过局部变量的槽表示的。通常进入一个方法的栈帧分配的局部变量槽大小都是确定的。运行期间不会修改。
    • 操作数栈
    • 动态连接
    • 方法出口。
  • 虚拟机栈的内存异常
    • StackOverflowError线程请求的栈深度大于虚拟机栈。
    • OutOfMemoryError栈无法申请到足够的内存空间。

2.2.3 本地方法栈

  • 本地方法栈,为本地方法服务。
  • 和虚拟机栈的内存异常是一样的。

2.2.4 Java堆

  • Java堆是虚拟机管理内存最大的一块。所有线程共享的一块。
  • 虚拟机启动的时候创建,堆的唯一目的就是存放对象实例。
  • Java堆是垃圾收集器管理的内存区域。
  • 所有线程共享的Java堆可以划分出多个线程私有的分配缓冲区(TLB),提升对象分配的速度。
  • Java堆处于物理上不连续的内存空间中。逻辑上是连续的。
  • 堆的异常
    • 堆无法扩展的时候会抛出OutOfMemoryError。

2.2.5 方法区

  • 方法区也是各个线程共享的内存区域。存储被虚拟机加载的类型信息,常量,静态变量,即时编译器编译后的代码缓存数据。别名是非堆。
  • 方法区和永久代不是等价的。只是永久代是方法区的一种实现方式。
  • 永久代的好处是HotSpot的垃圾收集器可以像管理Java堆一样管理部分内存,省去专门为方法区编写内存管理代码。
  • 但是永久代的问题是可能导致Java应用内存溢出的问题。因为永久代有-XX:MaxPermSize的上限。
  • 后来方法区的实现就是现在的本地内存中实现的元空间。
  • 方法区可以不实现垃圾回收,内存回收的主要目标是针对常量池的回收和类型的卸载。
  • 如果方法区不满足内存分配需求的时候会抛出OutOfMemoryError异常。

2.2.6 运行时常量池

  • 运行时常量池是方法区的一部分。
  • Class文件除了类的版本、字段、接口、方法的描述信息,还有一个信息是常量池。
  • 存放编译期生成的各种字面量与符号引用。这部分的内容在类加载的时候会存放到
  • 运行期间除了Class文件的常量池,还能够放入新的常量。
  • 如果常量池无法申请到内存抛出OutOfMemoryError异常。

2.2.7 直接内存

  • 直接内存不是虚拟机运行时数据的一部分。
  • NIO的加入,引入一种基于通道与缓冲区的IO方式。能够使用Native函数库直接分配堆外内存。然后通过一个存储在Java堆的DirectByteBuffer对象作为这块内存的引用操作。避免了Java堆和Native堆中复制数据。
  • 直接内存还是会受到本机的总内存限制,所以可能抛出异常OutMemoryError。

2.3 HotSpot虚拟机对象探秘

2.3.1 对象的创建

创建对象的过程

  • Java虚拟机遇到字节码new指令。首先检查指令的参数是否能够常量池中定位到类的符号引用。并且检查类引用符号的类是否加载,解析和初始化。没有就会进行类加载。
  • 类加载检查之后,虚拟机就会给新生的对象分配内存。对象内存所需的大小在类加载完成的时候可以确定。
  • 为对象分配内存的方式,相当于就是把一块内存拿出来,所有的空闲放到一边,不是空闲的放到另外一边,并且有一个分界的指针。分配内存就是指针移动的过程。这种分配方式就是指针碰撞。这种是java堆是规整的情况。
  • Java堆的内存不是规整的话,也就是使用过的内存和空闲的内存交错在一起。这个时候虚拟机就需要维护一个列表。这种分配就是空闲列表分配。
  • 对象分配内存是并发的过程,不是线程安全的。可能会导致两个线程要使用同一个空间。解决的方案要么就是CAS,要么就是线程划分不同的空间创建对象。就是每个线程都有自己的本地线程分配缓冲(TLAB)。是否使用这个可以通过指令-XX:+/-UseTLAB参数。
  • 内存分配结束之后,虚拟机就要把分配到的空间初始化为0值。
  • Java虚拟机还要对对象进行设置,对象是哪个类的实例。怎么找到类的源信息,对象的GC分代年龄信息。这些都存放到对象头。
  • 这个时候对象已经产生了。但是对于Java程序来说对象创建还需要构造函数。< init>还没有执行。init之后按照程序员的期望创建对象。

上面是详细的分析,现在做个简化

  1. 检测类是否已经加载,没有加载那么就加载
  2. 加载之后,需要给对象分配内存。确定好分配内存的方式,如果java堆是规整的,可以使用指针碰撞,否则就可以使用空闲列表、
  3. 对象的空闲分配并不是线程安全的,也就是可能会出现线程安全的问题,需要通过CAS重试或者是分配TLAB也就是本地线程分配缓冲来让线程单独有自己的创建对象的空间。
  4. 最后就是给分配的空间初始化零0值,并且给对象设置好对象头。
  5. 然后就是执行init初始化。

2.3.2 对象的内存布局

  • 对象的组成

    • 对象头
    • 实例数据
    • 对齐填充。
  • 对象头的信息

    • 存储对象自身的运行时数据。哈希码,GC分代年龄,锁状态标志,线程持有的锁,偏向线程ID,偏向时间戳。简称Mark Word

    image-20211206222720478

    • 另一部分是类型指针,对象指向它的类型元数据指针。Java虚拟机可以通过指针确定对象是哪个类的实例。
    • 如果对象是一个数组,对象头还有一个数组的长度。
  • 接下来就是对象存储的实例数据。

    • 这部分的存储顺序受到-XX:FieldsAllocationStyle参数和字段Java源码定义的顺序影响。
  • 对象的第三个部分是对齐填充。只是占位符的作用。要求对象的起始地址一定是8字节的整数倍。

2.3.3 对象的访问定位

  • Java程序通过栈上的reference数据来操作堆上的具体对象。他只是一个指向对象的引用。主流访问方式是句柄或者是直接指针

    • 句柄:需要句柄池,reference存储的是对象的句柄地址,句柄包含了对象的实例数据和类型数据的具体地址信息。

    image-20211206223257482

  • 直接指针,Java堆的对象内存布局需要考虑到访问类型数据的相关信息,reference直接就是对象的地址。如果只是访问对象那么就不需要再间接访问一次。

image-20211206223416280

  • 直接指针的好处是定位速度快,花销小。

2.4 实战:OutOfMemoryError异常

2.4.1 Java堆溢出

  • 设置好参数然后测试。这里是设置了堆大小是20MB。
  • 然后可以检查一下转储快照文件的分析信息。
//-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
public class MyTest {
   
    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_pid11304.hprof ...
Heap dump file created [28269066 bytes in 0.086 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

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

  • 如果线程申请的栈深度太大就会造成StackOverflowError
  • 如果虚拟机栈允许动态拓展,并且申请内存不足的时候会爆出OutOfMemoryError
  • 可以通过-Xss来减少栈的内存容量。也就是减少栈的深度。
  • 定义大量的本地变量,增加方法帧的本地变量的表长度。也是会造成StackOverflowError。
  • 下面的这个实际上就是通过递归,不断叠加栈帧。让栈的深度不足导致的溢出。
public class JavaVMStackSOF {
    private int stackLength = 1;
    public void stackLeak() {
        stackLength++;
        stackLeak();
    }
    public static void main(String[] args) throws Throwable {
        JavaVMStackSOF oom = new JavaVMStackSOF();
        try {
            oom.stackLeak();
        } catch (Throwable e) {
            System.out.println("stack length:" + oom.stackLength);
            throw e;
        }
    }

}
  • 第二种情况的验证方式就是创建大量的局部变量导致栈帧的局部变量表空间不足溢出。
  • 也就是无论是栈帧太大还是说虚拟机的栈容量太小都会抛出StackOverflowError。
  • 不断创建线程的话就会导致内存溢出的状况。原因是操作系统给进程分配的空间有限。HotSpot减去Java堆的内存,减去方法区的内存,其它都是留给虚拟机栈和本地方法栈的。如果线程分配的栈内存越大,那么线程数量就会越少。

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

  • String.inern方法可以把字符串添加到常量池,并且返回String对象的引用。
  • 可以通过循环添加inern来把字符串加入到常量池导致的方法区的溢出。
public class RuntimeConstantPoolOOM {
    public static void main(String[] args) {
// 使用Set保持着常量池引用,避免Full GC回收常量池行为
        Set<String> set = new HashSet<String>();
// 在short范围内足以让6MB的PermSize产生OOM了
        short i = 0;
        while (true) {
            set.add(String.valueOf(i++).intern());
        }
    }
}

String.intern的讨论

  • 这里在jdk6是两个false但是jdk7是一个true一个false。
  • jdk6里面的intern方法,会把首次遇到的字符串实例复制到永久代的字符串常量池。返回的是永久代里面的字符串实例的引用。StringBuilder对象实例在Java堆上。
  • Jdk7的intern不需要拷贝实例到永久代。由于字符串常量池已经移动到了Java堆上,那么只需要在常量池记录首次出现的实例引用即可。intern返回引用与第一次创建的字符串实例是同一个。
  • 下面的java是因为已经出现过一次,所以str2创建的实例并不是第一个实例,所以引用不同。
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);
    }
}

2.4.4 本机直接内存溢出

  • 直接内存可以通过-XX:MaxDirectMemorySize参数决定大小。默认和java堆的大小-Xmx一致。

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

3.1 概述

  • 哪些内存需要回收?
  • 什么时候回收?
  • 如何回收?

3.2 对象已死?

  • 首先需要确定哪些对象已经死去,哪些对象活着。

3.2.1 引用计数算法

  • 引用计数算法,占用了一些额外空间来计数。每次有个地方引用对象,那么对象的计数器就会加1。
  • 但是主流的虚拟机没有采用引用计数法管理内存。缺点比如循环引用等问题。

3.2.2 可达性分析算法

  • 通常内存管理子系统都使用了可达性分析算法判断对象是否存活。
  • 通过GC Roots根对象起始点集。通过引用关系向下搜索。走过的路径就是引用链。

image-20211207222306036

GC Roots的对象

  • 虚拟机栈引用的对象,比如线程的栈帧里面的参数和局部变量。
  • 方法区里面的类静态属性的引用对象。比如Java类的引用类型静态变量。
  • 在方法区常量引用的对象,比如字符串常量池的引用。
  • 本地方法栈的JNI引用的对象。
  • 所有被同步锁持有的对象。synchronized(obj)
  • 反应java虚拟机内部情况的JM XBean,JVMTI中注册的回调,本地代码缓存。

3.2.3 再谈引用

  • 如果reference类型的数据存储的数值代表另一块内存的起始地址。那么这个reference数据代表的就是某块内存,某个对象的引用。
  • 但是我们需要描述更多的引用,比如一些内存空间足够,能够保存在内存的,但是内存空间不够,需要回收引用的对象。

引用的拓展

  • 强引用
    • 强引用存在的时候不会被垃圾回收
  • 软引用
    • 描述一些还有用,但是不是必须的对象。内存不足的时候会把这些对象列入回收范围,第二次回收才会回收软引用。
  • 弱引用
    • 非必须对象,第一次垃圾回收就会被回收掉。
  • 虚引用
    • 虚引用的作用是为了这个对象被回收的时候能够收到一个系统通知。无法通过虚引用得到一个对象实例。

3.2.4 生存还是死亡?

  • 可达性分析算法判断不可达的对象,不一定非要死。
  • 真正死的时候经历两次标记。
    • 发现对象没有与GC Roots相连接的引用链,第一次标记
    • 然后就会进行一次筛选,对象是否执行finalize。如果要执行那么就放到F-Queue队列中。并且通过Finalizer线程负责执行finalize。这里只是执行,但是不保证一定会执行完成。如果这个时候有对象finalize进入死循环就会导致后面的对象永久等待。
    • finalize是对象拯救自己的最后一次机会,意思就是是否能够在这个方法创建一个引用关联自己。
    • 那么第二次标记的时候就会把对象移出这个回收队列。如果没有移除,那么就要被回收了。
    • 任何一个对象的finalize只能被执行一次。

3.2.5 回收方法区

  • 方法区的回收性价比很低。
  • 方法区回收的内容
    • 废弃的常量。比如如果系统里面没有任何一个字符串对象是"java",也就是已经没有任何对象引用常量池的"java"那么就要进行回收。
    • 不再使用的类型
  • 判断类型是否需要回收
    • 类的所有实例被回收。
    • 加载类的类加载器被回收
    • 类的Class对象没有被引用。
  • -XX:TraceClass-Loading可以查看类的加载情况。

3.3 垃圾收集算法

  • 垃圾回收算法划分为
    • 引用计数式的垃圾收集
    • 追踪式的垃圾收集。
  • 下面的基本介绍的是追踪式的垃圾收集。

3.3.1 分代收集理论

  • 弱分代假说:大多数的对象都是朝生夕灭
  • 强分代假说:熬过多次垃圾回收的对象难以消除。
  • 所以垃圾收集器需要把Java堆分区。并且根据年龄分配到不同的区域。
    • 如果对象朝生夕灭那么就放到一个区域。关注如何保留少数存活而不是标记大量要被回收的对象
    • 如果是难以消灭的对象,可以较低的频率回收。
  • 垃圾收集器每次只能回收某一个或者是某些部分区域。
    • Minor Gc
    • Major Gc
    • Full Gc
  • 垃圾分区
    • 新生代
    • 老年代。
  • 如果要进行一次新生代垃圾收集(Minor Gc),新生代可能被老年代引用。为了找到区域存活对象还要去遍历一次Gc Roots之外的整个老年代,确保可达性分析结果的正确性。但是遍历老年代给内存回收带来性能负担。
  • 所以引入了跨代引用假说:跨代引用相对于同代引用只是占用少数。
    • 存在互相引用关系的对象,可能同时消灭和生存。
    • 如果新生代存在跨代引用,由于老年代难以消亡能够让新生代收集的时候存活,晋升到老年代的时候就取消了跨代引用。
    • 那么就不应该为了少数的跨代引用扫描整个老年代。也不需要去记录对象是否存在跨代。只需要在新生代这里创建一个记忆集,把老年代分成很多个小块,标识出老年代哪个内存会存在跨代引用。每次发生Minor Gc的时候,只有包含跨代引用的小块内存的对象才会被加入GC Roots里面去扫描。
  • 定义部分
    • 部分收集(Patial Gc):指目标不是完整收集Java堆的垃圾收集。
      • 新生代收集(Minor Gc/Young Gc):只在新生代收集
      • 老年代收集(Major GC、Old GC):老年代的垃圾收集,CMS收集器才有单独收集老年代的行为。
      • 混合收集(Mixed GC):目标收集整个新生代,以及部分老年代收集。只有G1才会有这样的行为。
    • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

3.3.2 标记-清除算法

  • 算法分为标记和清除。
    • 标记出所有要回收的对象。
    • 统一回收。
  • 也可以反过来,先标记出不需要回收的对象,把没有标记的回收。
  • 缺点
    • 执行效率不稳定。大部分需要回收的时候,标记就会耗费很多时间。
    • 内存碎片问题。

image-20211207232338987

3.3.3 标记-复制算法

  • 面对大量对象的标记的执行效率低的问题。提出了半区复制垃圾回收算法。
  • 可用的内存容量分为两块,每次只是使用一块,另一块内存用完就会把存活的对象复制到对面去。
  • 缺点
    • 复制开销。
    • 内存开销。
  • 但是对于少量对象存活的情况,复制开销很小。
  • 分配空间没有空间碎片。

image-20211207232633035

  • Appel式回收。把一块比较大的Eden区和两块比较小的Survivor区,每次分配内存只会使用Eden和两个Survivor区。内存不够的时候会把Eden和其中一个Survivor复制到另一个Survivor区中。
  • Eden和Survivor默认的比例是8:1。只要一个Survivor也就是10%的空间会被浪费。
  • 如果Survivor空间不足的时候就需要老年代作为分配担保。

3.3.4 标记-整理算法

  • 标记复制的问题是对象存活率高的时候,复制开销太大。
  • 针对老年代的存亡特征可以使用标记整理。标记-清理+整理内存。
  • 对于老年代来说整理开销很小,因为存活对象多。
  • 但是移动的时候,对象引用最好不要更新,所以需要Stop The World。停止用户应用。
  • HotSpot的关注吞吐量的Parallel Scavenge收集器就是基于标记整理。关注延迟的CMS收集器基于标记清理。
    • 移动对象会让内存回收变的复杂
    • 不移动对象,内存分配变得很复杂。
    • 吞吐量来说,移动对象更好。
    • 不移动的话,收集器的效率提升一些,但是内存分配和访问,比垃圾回收的频率高。总吞吐量下降。
  • 和稀泥式,可以不在内存分配和访问上增加负担,平时使用标记清除容忍碎片,碎片影响太大的时候可以通过一次标记整理收集一次。CMS的碎片多的处理方法。

image-20211207233142004

3.4 HotSpot的算法细节实现

3.4.1 根节点枚举

  • 所有收集器在根节点(GC Roots)枚举的时候都是需要暂停用户线程。
  • 现在的可达性分析算法查找引用链能够和用户线程一起并发。但是必须在一致性快照中枚举。避免枚举过程的对象引用发生变化。所以这就是为什么要Stop The World。
  • 目前的Java虚拟机都是准确式垃圾收集。虚拟机应当有办法直接得到哪些地方存放着对象引用。HotSpot的解决方案,使用的是OopMap记录了引用的位置。这样就不需要还要去到方法区等GC Roots开始查找。
  • OopMap的位置基本指明了普通对象的指针的引用。也就是那些GC Roots。而且确定好了GC Roots是哪些。

3.4.2 安全点

  • 在OopMap的协助情况,HotSpot能够准确完成GC Roots的枚举。
  • 但是引用的变化导致OopMap内容变化的指令很多,如果每条指令都要生成对应的OopMap,那么就需要大量存储空间。
  • HotSpot只会在特定的位置记录这些消息,这些消息其实就是OopMap记录的GC Roots的消息。这些位置就是安全点。
  • 安全点解决了用户程序执行的时候,并且在任何指令流的位置随意停下来垃圾收集。而是强制必须要到达安全点才能够暂停。
  • 安全点不能太少,导致收集器等待很久。但是也不能太频繁导致增大了运行的内存负荷。
  • 安全点选取的位置是以是否具有让程序长时间执行的特征。安全点通常产生在指令序列的复用,比如方法调用,循环跳转。
  • 对于安全点,如何在垃圾收集的时候让所有的线程跑到最近的安全点,然后停顿下来。
    • 抢占式中断:不需要线程执行代码配合,系统直接把用户线程全部中断,如果发现有线程中断地方不在安全点上,那么恢复这条线程的执行。一会儿重新中断。直到跑到安全点。
    • 主动式中断:垃圾收集需要中断线程的时候,仅仅只是设置了标志位,线程执行会轮询标志位。如果发现中断标志为真的时候就在自己最近的安全点主动中断挂起。如果轮询标志的地方是和安全点重合的,那么还要加上创建对象和其它Java堆分配内存的地方。避免检查是否即将发生垃圾收集,避免没有足够内存分配新对象。
    • 轮询被精简为一条汇编指令。 test指令就会产生一个自陷异常信号。安全点轮询+触发线程中断。
  • 安全点就是垃圾收集+记录OopMap。统一一个时间,让所有线程停顿。

3.4.3 安全区域

  • 安全点解决如何停顿线程,虚拟机进入到垃圾回收状态。
  • 安全点机制保证了程序在执行的时候,在不长的时间就会遇到进入垃圾收集过程的安全点。
  • 但是程序不执行的时候呢?比如Sleep和Bolcked的线程。无法响应虚拟机的中断请求。
  • 所以这个时候都需要安全区域。确保某一段代码的引用关系不会发生变化。所以只要在这个区域执行垃圾收集都是安全的。
  • 线程执行安全区域的代码的时候,标识自己进入到安全区域。这段时间虚拟机垃圾收集就会不用去管这些已经声明自己在安全区域的线程。线程离开安全区域的时候,需要检查虚拟机是否完成根节点的枚举。如果完成那么线程就当做没事,否则就要一直等待。

3.4.4 记忆集与卡表

  • 为了解决对象跨代的问题,新生代建立了记忆表。不需要扫描所有的老年区。
  • 记忆集是一种记录从非收集区域向收集区域的指针集合的抽象数据结构。最简单的方式就是数组实现。包含所有含跨代引用对象的实现方案。空间占用和维护成本高昂。
  • 收集器只需要从记忆集判断哪个非收集区域指向收集区域即可,不需要了解跨代指针的的细节。所以可以使用使用粗犷的粒度记录,减少成本
    • 字长精度:每个记录精确到机器字长,字长里面包含跨代指针
    • 对象精度:每个记录精确到对象,对象包含跨代指针。
    • 卡精度:精准到一块内存区域,区域里面有对象含有跨代指针。
  • 卡精度使用的是卡表实现记忆集。卡表的实现可以是一个字节数组

CARD_TABLE [this address >> 9] = 0;

  • 字节数组CARD_TABLE每个元素都标识着一块内存区域的一块特定大小的内存块。这个内存块就是卡页。
  • 一般来说卡页是2n的字节数。HotSpot是29也就是512字节。
  • 也就是下面的卡表每1byte对应着512个字节。计算出在卡表的位置方式就是右移9位得到卡表的索引。
  • 一个卡页的内存不止一个对象,可能会有多个对象的字段存在跨代指针。那么卡表的元素就可以标记为1。这个时候就可以拿到GC Roots里面扫描。

image-20211208002110815

3.4.5 写屏障

  • 记忆集解决了GC Roots扫描范围的问题,但是没有解决卡表元素如何维护的问题。比如他们什么时候变脏,谁来把他们变脏。
  • 卡表元素变脏就是其它分代区域对象引用了本区域对象。那么对应的卡表元素就会变脏。引用字段赋值,卡表就要变脏。

如何在对象赋值的时候更新维护卡表。

  • 写屏障技术维护卡表状态。
  • 写屏障是虚拟机层面的引用类型字段赋值,赋值的时候会有一个环形通知。
  • 写前屏障或者是写后屏障完成一个卡表状态更新。
  • G1之前都是写后屏障。
void oop_field_store(oop* field, oop new_value) {
    // 引用字段赋值操作
    *field = new_value;
    // 写后屏障,在这里完成卡表状态更新
    post_write_barrier(field, new_value);
}
  • 写屏障的AOP插入操作造成一定的开销。写屏障的目的是解决记忆集的更新问题,但是这个开销还是会比扫描整个老年代来的好。
  • 卡表还有高并发问题的伪共享问题。这个问题就是比如64个卡表元素,每个缓存行是64个字节。一个卡表元素是1个字节。如果不同线程更新的数据刚好是32KB的内存位置。导致更新的还是同一个缓存行的数据就会出现伪共享的问题。
    • 解决方案,不采用无条件的写屏障,先检查卡表的标记。只有卡表的标记未标记那么才可以标记为脏。

3.4.6 并发的可达性分析

  • GC Roots遍历的速度相对比较快,线程停顿时间短。但是遍历下去的时候就与Java堆的容量有关系了。存储对象多,那么引用链的结构就会很复杂。
  • 为什么必须在一个保障一致性的快照才能进行对象图的遍历?引用三色标记来辅助推导。
    • 白色:对象未被垃圾收集器访问
    • 黑色:被垃圾收集器访问,而且对象的所有引用已经扫描过了。
    • 灰色:这个对象至少存在一个引用没有被扫描。
  • 如果用户线程在修改引用关系-对象图结构同时收集器在为对象图上颜色,导致原本消亡的对象错误标记是存活的。也可能会把原本存活的对象标记为消亡的。

image-20211208004326364

  • 产生对象消失的问题,黑色对象被误标记为白色。
    • 赋值器插入了一条或者是多条黑色对象对白色对象的引用。
    • 赋值器删除了全部灰色对象到白色对象的直接或者是间接引用。
  • 只有灰到白,白才有可能被标记为黑色。
  • 解决并发扫描的对象消失问题可以通过破坏这两个条件
    • 增量更新
      • 破坏了第一个条件。黑色插入的对白色对象的引用记录下来。并发扫描结束之后,就会把这些引用关系的黑色对象为根,重新扫描一次。也就是黑色插入的白色块会变灰色。
    • 原始快照。
      • 破坏第二个条件,灰色删除对白色对象的引用关系,把删除引用记录下来。把记录过的引用关系的灰色对象为根,重新扫描。无论删除与否都会按照一开始的图进行搜索。
  • CMS基于增量更新做并发标记
  • G1和Shenandoah通过原始快照实现。
  • 意思就是一个扫描插入,一个保存原来的样子。

总结

  • 介绍了虚拟机如何发起内存回收,通过枚举。
  • 然后介绍了优化枚举过程的OopMap,然后提出了安全点,不需要每次都更新OopMap。
  • 然后又提出安全区域,避免虚拟机等待那些Sleep或者阻塞的线程。
  • 然后提出了跨代引用的枚举解决方案可以通过记忆集。优化实现使用卡表减少空间浪费。
  • 又通过写屏障解决卡表的更新问题。
  • 最后就是可达性分析的三色标记的并发问题分析和两种解决方案。

3.5 经典垃圾收集器

  • 连线是可以搭配使用的意思。
  • 收集器所在的区域,表示的是属于新生代还是数据老年代收集器。

image-20211208010441380

3.5.1 Serial收集器

  • 单线程工作的收集器。
  • 强调它垃圾收集的时候需要暂停其它用户线程。
  • 简单高效。

image-20211208011803267

3.5.2 ParNew收集器

  • Serial收集器的多线程版本。
  • 垃圾收集的时候还是需要停止其它用户线程
  • CMS激活之后-XX:UserConcMarkSweepGC,新生代的默认收集器是parNew。
  • ParNew在单核心的状态未必比Serial的运行效率更高

image-20211208011936886

3.5.3 Parallel Scavenge收集器

  • 基于标记复制实现的垃圾收集器。
  • 能够并行收集的多线程收集器。
  • 它的特点是关注点是能够达到一个可以控制的吞吐量。吞吐量就是处理器用于运行用户代码的时间与处理器总耗时时间的比值。

image-20211208143530833

  • 适合后台不需要太多交互的分析任务。
  • 两个控制吞吐量的参数。
    • -XX:MaxGCPauseMills最大垃圾收集停顿时间。保证内存回收花费的时间不得超过用户设置的时间。垃圾停顿的时间缩短牺牲的是吞吐量和新生代空间的代价替换的。新生代小一点收集速度就快一点。
    • -XX:GCTimeRatio直接设置吞吐量的大小。
  • Parallel Scavenge收集器被称为吞吐量优先的收集器。
  • -XX:UseAdaptiveSizePolicy能够自己调整新生代的Eden和Survivor大小。这种调节方式就是自适应调节策略。
  • 新生代的大小(-Xmn)
  • Eden与Survivor(-XX:SurvivorRatio)
  • 晋升老年代对象的大小(-XX:PretenureSizeThreshold)。

3.5.4 Serial Old收集器

  • Serial的老年代版本。使用的是标记整理。
  • 能够与Parallel Scavenge收集器搭配使用。或者是作为CMS收集器失败之后的后备方案。在并发收集出现Concurrent Mode Failure时使用。

image-20211208144508409

3.5.5 Parallel Old收集器

  • 这个是Parallel Scavenge的老年代版本。支持多线程的并发收集。基于标记整理。
  • 如果新生代选择了Parallel Scavenge,老年代选择Serial Old,那么可能造成的情况是Serial Old是一个单线程导致的性能上面拖累。所以整体的吞吐量未必能够做的很好。
  • 在处理吞吐量和处理器资源比较稀缺的情况可以优先考虑Parallel Scavenge和Parallel Old的组合。

image-20211208144757552

3.5.6 CMS收集器

  • CMS(Concurrent Mark Sweep)收集器的目标是最短的垃圾回收停顿时间。关注点是应用的响应速度。
  • 收集步骤
    • 初始化标记
    • 并发标记
    • 重新标记
    • 并发清除。
  • 并发标记和重新标记仍然需要STW。
  • 初始标记只是标记一下GC Roots能够直接关联的对象。
  • 并发标记是从直接关联的对象遍历整个对象图的过程。但是不需要停顿用户线程。
  • 重新标记是为了修正并发标记期间,因为用户程序导致的标记产生变动的那一部分的对象的标记记录。这个阶段比初始化阶段长,但是比并发标记阶段短。这个地方使用的是增量更新。目的是破坏两个条件的第一个黑色增加白色引用的的关系。正常来说如果在并发条件下白色块被阻断的同时被黑色块引用就会被误删。那么解决方案要么就是增量更新重新标记插入,要么就是原始快照,按照之前的那样标记。
  • 最后就是并发清除。清除掉那些标记死亡的对象。

image-20211208145717424

  • 特点是
    • 并发收集
    • 低停顿。
  • 缺点
    • 对处理器的资源非常敏感。CMS默认是(处理器核心数量+3)/4的回收线程数量。如果处理器核心在是个以上,并发回收的垃圾回收线程只占用不到百分之25.但是核心比较少的时候,CMS的多线程就会影响应用的性能。因为这么少的核心还要分出一半的运算性能去执行收集线程。
    • 为了缓解上面的缺点可以通过增量并发收集器i-CMS,在并发标记,清理的时候与用户线程并发执行。减少垃圾收集的独占资源的时间。也就是不单独分配一个核心出去。
    • CMS无法处理浮动垃圾。可能会出现Concurrent Mode Failure。导致STW的Full GC。用户线程与垃圾收集线程一起执行,用户线程不断创建浮动垃圾,导致垃圾收集线程不能同时处理这些垃圾,只能下次处理。所以CMS是不能够把老年代填满了才取收集垃圾,避免爆满,需要预留一些老年代的空间。现在默认是老年代使用了百分之68的时候就会激活。后面上升到了百分之92,才会触发垃圾回收。但是如果CMS运行期间预留空间不足,就会出现一次并发失败。这个时候只能STW和临时启用Serial Old来进行老年代的收集。停顿时间就会非常长。-XX:CMSInitiatingOccupancyFraction太大可能就会频繁导致内存不足的问题。
    • CMS是一个标记清除的算法。空间碎片太多导致的对象分配难度上升。如果分配空间不足就会导致一次Full GC。为了解决问题提出了-XX:UseCMS-CompactAtFullCollection也就是随便合并的过程。只能移动存活对象,所以不能够并发与用户线程执行。空间分配解决但是停顿时间变长了。或者通过-XX:CMSFullGCBeforeCompation也就是多次Full GC之后,下一次Full GC先进行碎片的整理。

3.5.7 Garbage First收集器

  • Garbage First开创了收集器面向局部收集设计思路,基于Region的内存布局模式。
  • 面向服务器端应用的垃圾收集器。
  • 后来提出了统一垃圾收集器接口,内存回收的行为和实现分开。加入新的收集器变得更简单了。
  • 停顿时间模型的收集器,能够支持在M毫秒的时间片段内,消耗垃圾收集上的时间大概率不会超过N毫秒。
  • 怎么实现?
    • 以前实现的垃圾收集的目标要么就是新生代,要么就是老年代。要么是整个堆。
    • G1面向堆内存任何部分组成的回收集。衡量标准不再是它属于哪个分代。而是哪块内存存放的垃圾最多,回收的利益最大。这就是Mixed GC模式。
    • G1的Region堆内存布局是它能够实现目标的关键。虽然也是遵循分代的收集理论,但是内存分布的布局与其他收集器不同,G1不再坚定固定大小和固定数量的分代区域分配。而且把Java堆分成很多块大小想通的独立区域。每个Region根据需要可以扮演Eden,Survivor或者是老年代空间。
    • Region还有一个Humongous区域,专门存储大对象,G1认为只要对象超过Region的一半,那么就是大对象。每个Region的大小可以通过参数-XX:G1HeapRegionSize设定。取值的范围是1MB-32MB。是2的次幂。超过整个Region大小的超级大对象,存放到连续N个Humongous Region之中。G1通常把Homongous看作是老年代的一部分。
    • 新生代和老年代都是一系列动态集合。
    • G1收集器之所以可以创建可以预测的停顿时间模型是因为它把Region作为单次回收的最小单元。也就是每次收集的内存空间都是Region的整数倍。避免了全区域的垃圾回收。可以通过追踪区域的价值来进行回收。-XX:MaxGCPauseMills指定垃圾收集的时间。

image-20211208161745173

G1遇到的问题

  • Java堆分成多个独立Region之后,Region里面存在的跨代Region引用对象如何解决?

    • 通过记忆集避免对作为全堆GC Roots扫描。
    • G1的每个Region都需要维护自己的记忆集。记录下别的Region指向自己的指针。标记指针分别在哪些卡页的范围之内。本质就是一个哈希表。
    • Key是别的Region的起始地址,Value是一个集合。里面存储的是卡表的索引号。也就是一个双向的卡表结构。通过哈希表存储了各个区域的key,通过value存储卡表。所以G1的耗费的额外内存更多。
  • G1在并发标记过程如何与和用户线程互不干扰运行?

    • 第一个要解决的是用户线程改变对象的引用关系的时候,必须不能够打破对象图的结构。(通过增量更新或者是原始快照),G1使用的是原始快照(SATB)
    • G1为每个Region设置了两个名TAMS(Top at Mark Start)的指针。把Region的一部分空间划分出来,用于并发回收的过程的新对象分配。并发回收的时候,新对象分配一定要在这个区间。这个地址被标记过是存活的所以不会被回收。
  • 如何建立可靠的停顿预测模型?

    • G1的停顿预测模型是以衰减均值为理论基础来实现的。G1收集器记录每个Region收集的时间,每个Region的脏卡数量等的成本,分析出各个统计信息。Region的统计状态越新越能决定回收的价值。

G1的收集器的运行过程

  • 初始标记:仅仅只是标记GC Roots能够直接关联的对象,并且修改TAMS指针的值。让下一个用户线程并发执行。能够正确在Region分配空间。需要停顿线程,但是耗时短,借助Minor GC的时候同步的。没有额外停顿

  • 并发标记:从GC Roots开始对堆对象进行可达性分析。耗时长,能与用户线程并发进行。还需要记录STAB记录下的并发引用对象。

  • 最终标记:对用户线程做一个暂停,处理STAB的重新标记。暂停用户线程

  • 筛选回收:负责更新Region的统计消息,对Region的回收价值和成本进行排序。根据用户的期望停顿时间制定回收计划。选择任意多个Region回收。把存活的对象复制到空的Region,清理掉旧Region的空间。必须暂停用户线程。

  • 也就是说G1除了并发阶段都是需要暂停线程的。在延迟可控的情况下必须保证高的吞吐量。兼顾延迟和吞吐量。

image-20211208163723519

  • G1的最大特点就是能够按照用户指定期望的停顿时间处理。
  • 后来的垃圾收集器都是导向应付应用的内存分配速率。不追求一次清理完。
  • 收集的速度只要能够跟上分配的速度即可。

G1的优点

  • G1看上去是基于标记整理,局部看来是标记复制。不会产生内存碎片。

G1的弱点

  • G1的卡表更为复杂。可能会占用多内存的百分20。

  • 内存占用很大。

  • CMS里面只需要给新生代创建卡表,老年代不需要,因为新生代的变化频繁,老年代创建卡表的问题就是新生代会不断地变化,维护卡表难度很大。但是G1就给每个Region创建了这样的卡表。

  • G1需要在指令执行的时候加上写后屏障解决卡表的维护,写前屏障解决实现原始快照的搜索算法的跟踪并发的指针变化。原始快照减少了并发标记和重新标记的消耗。避免了CMS的最终阶段停顿时间很长的问题。但是跟踪引用的变化也是需要负担的。G1对写屏障是一个异步操作,所以需要消息队列的结构把写前和写后屏障要做的事存放到队列,异步处理。

  • 小内存上CMS更好,大内存上G1的表现更好。

3.6 低延迟垃圾收集器

垃圾收集器的衡量标准

  • 内存占用
  • 吞吐量
  • 延迟
  • 延迟变得越来越重要,收集器可以容忍多占一点内存。硬件的性能越来越好,吞吐量也会上涨。但是延迟不一定能够减少。回收越大的内存,那么速度自然就会变慢。
  • 下面浅色是执行垃圾收集的时候挂起用户线程,深色是能够并行执行。CMS和G1通过增量更新和原始快照完成了并发执行。
  • 对于标记之后的处理,CMS仍然需要整理内存碎片导致停顿,G1虽然可以抑制部分的整理停顿,但是仍然需要停顿。
  • 对于Shenandoah和ZGC整个工作过程都是并发的。只要初始标记和最终标记的停顿。其它基本不需要停顿。

image-20211208165900435

3.6.1 Shenandoah收集器

  • Shenandoah可以并发进行垃圾标记,还能够并发进行对象清理之后的整理动作。
  • 改进
    • 管理内存方面
      • Shenandoah不支持分代收集。
      • 放弃了记忆集。使用连接矩阵记录Region的引用关系。降低了记忆集的维护消耗。降低了伪共享问题。
      • 连接矩阵就是如果有连接那么就会打上标记。如果N区域执行M区域,那么就在N行M列打上标记。

image-20211208173550254

  • 工作过程
    • 初始标记:标记与GC Roots关联的对象,产生STW
    • 并发标记:遍历对象图,标记处可达的对象。与用户线程并发的。
    • 最终标记:STAB的扫描,计算出高回收价值的Region。有STW
    • 并发清理:清理那些没有存活对象的区域。
    • 并发回收:复制存活对象到空的Region。通过读屏障和被称为Brooks Pointers的转发指针解决。并发回收的阶段时间长短取决于回收集的大小。
    • 初始引用更新:并发回收复制对象结束之后,还要把旧对象的引用修正到复制之后的新地址。这个就是引用更新。有STW。这里的初始并没有开始更新,而是建立多线程的集合点。确保并发回收阶段的收集器线程已经完成分配的移动对象的任务。
    • 并发引用更新:真正开始进行引用更新。和用户线程并发操作。
    • 最终引用更新:解决堆的引用更新之后,还要修正GC Roots的引用更新。STW,时间与GC Roots有关
    • 并发清理:并发回收+引用更新之后,再一次并发清理回收Region的内存空间,提供给新对象分配使用。
  • 总的来说就是标记,清理,并发回收、然后就是引用更新,最后就是回收Region的空间。也就是进行了两次的清理。一次是移动对象前的清理。一个是移动对象之后的清理。

image-20211208175102325

Brooks Pointer的概念

  • 首先是转发指针实现对象移动和用户程序并发的一种解决方案。移动对象的内存空间必须设置陷阱。一旦用户需要访问的时候就会自陷中段,进入到异常处理器,代码逻辑就会把访问转发到复制后的新对象上。这种的问题就是频繁的用户态转换到核心态。
  • Brooks新方案不需要陷阱。而是在原有的对象添加一个新的引用字段。正常的时候是指向自己。但是转发指针仍然需要成本,但是相对内存保护的陷阱转换已经好很多了。对象有副本的时候只需要修改指针的值,指向新的对象,那么就可以访问新的对象了。

image-20211208195915867

  • 但是如果出现并发写入的时候必须写到新的对象上面。
    1. 收集器复制新的对象副本
    2. 用户线程更新对象的某个字段。
    3. 收集器线程更新转发指针的引用地址是新的引用位置。
  • 上面如果不做保护的的话,事件2在事件1和事件3之前的话,那么那么就会导致处理的对象仍然是在旧的对象而不是新的对象上面。
  • 所以对于访问对象只能是收集器线程或者是用户线程。那么才可以避免这样的问题。Shenandoah通过的是CAS保证并发对象访问的正确性。

image-20211208201800403

转发指针的另外一个问题

  • 执行频率的问题。由于转发动作可以通过读屏障解决,但是问题是读的次数非常多。导致Shenandoah的开销很大。
  • 所以后来使用的是引用访问屏障。意思是只拦截数据类型是引用类型的读写操作,不去管原生数据类型等的非引用字段的读写。

Shenandoah的好处

  • 在停顿时间,由于并发回收的处理得到了很好的提升。

3.6.2 ZGC收集器

  • 低延迟的垃圾收集器。和Shenandoah高度相似。
  • 能够实现任意内存大小的情况下都可以把垃圾回收限制在10毫秒以内。
  • 但是它和Shenandoah的实现思路不同。
  • 使用了读屏障,染色指针,内存多重映射技术实现标记整理算法。

内存布局

  • 基于Region的内存布局。但是Region具有动态性。动态创建和销毁,还有区域的大小。
    • 小型Region:固定是2MB,存放小于256KB的小对象
    • 中型Region:32MB,放置>=256KB但是<4MB的对象
    • 大型Region:容量不固定。动态变化。必须是2MB的整数倍。放置大于等于4MB的对象。

ZGC的核心问题并发整理算法的实现

  • Shenandoah通过读屏障和转发指针实现并发整理。
  • ZGC虽然使用到读屏障。但是不同。
  • 染色指针技术:直接把消息记在了引用对象的指针上。现在这就直接是在遍历引用了。
  • 虚拟机能够通过指针的看到引用对象的三色标记状态。是否进入到重分配(被移动的意思)。但是也压缩了指针的可以访问的内存范围。

image-20211208204529684

染色指针的好处

  • 某个Region存活对象移动之后,Region就能够直接被释放或者是重用。而不需要等待整个堆的指向Region的引用修改之后才能够清理。比起Shenanduah更有优势。
  • 染色指针减少了垃圾收集过程的内存屏障使用数量。写屏障的目的是记录引用的变动情况。但是如果信息都保存到了指针,那么就可以省去一些记录的操作。
  • 染色指针能够作为拓展记录更多对象标记和重定位的相关数据。

ZGC的工作流程

  • 并发标记:同G1,但是标记是在指针上面进行的。
  • 并发预备分配:根据查询条件统计出收集的Region的信息,把Region组成重分配集。ZGC扫描的范围非常大,为了解决省去G1记忆集的维护成本。重分配集作用是决定哪些存活对象会被复制到新的Region,里面的Region会被释放。
  • 并发重分配:重分配是ZCG的核心阶段,把存活对象复制到新的Region,并且为重分配集的每个Region维护一个转发表。记录旧对象到新对象的转向关系,得益于染色指针,ZGC收集器只需要在引用就能够得知对象是否处于重分配集。如果用户并发访问重分配集的对象,就会被内存屏障截获,然后根据转发表转发到新复制的对象。同时可以修改引用的值,指向新的对象。这种就是指针的自愈能力。也就是只有第一次是陷入转发,下次就不需要了。
  • 并发重映射:修正重分配的旧对象的所有引用。

对比

  • G1通过写屏障维护记忆集才能处理跨代指针。实现Region的回收。记忆集占用大量的空间
  • ZGC完全没有使用记忆集,没有分代。但是ZGC的成本降低,因为不需要写屏障,也不需要记忆集占用大量的内存。但是承受的分配对象速率不高。可能会产生大量的浮动垃圾。
  • Shenanduah通过连接矩阵减小记忆集的内存占用成本。不采用分代。关键就是并发回收+引用更新。但是引用更新完才能够回收垃圾。这就比ZGC要慢一些。Shenanduah还有一个问题就是写对象要保证只能一个线程访问,防止出现修改了旧对象,所以这个地方需要内存屏障保证并发的顺序。还有一个转发指针的问题。访问太频繁这个转发也是一个很大的开销。
  • ZGC和Shenanduah虽然并发程度高,能够低延迟响应,同样也有缺点就是处理分配对象速度快的应用的能力比较差,因为并发的同时不能处理这些浮动垃圾。

3.7 选择合适的垃圾收集器

3.7.1 Epsilon收集器

  • Epsilon不能够进行垃圾收集的垃圾收集器。它除了垃圾收集还能够负责内存管理,对象分配,解释器的协作等。

3.7.2 收集器的权衡

  • 应用程序关注什么?
    • 尽快算出结果的->吞吐量
    • SLA应用->停顿时间,关注延迟
  • 运行的基础设施如何?
  • JDK的发行商是什么?版本号?

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

4.2 基础故障处理工具

4.2.1 jps:虚拟机进程状况工具

  • jps:列出正在运行的虚拟机程序。

4.2.2 jstat:虚拟机统计信息监视工具

  • jstat:监视虚拟机各种运行状态的信息的命令行工具。

4.2.3 jinfo:Java配置信息工具

  • jinfo:实时查看调整java虚拟机的参数。

4.2.4 jmap:Java内存映像工具

  • 生成堆转储快照。

4.2.5 jhat:虚拟机堆转储快照分析工具

  • jhat与jmap搭配使用,分析转储快照。

4.2.6 jstack:Java堆栈跟踪工具

  • jstack:用于生成虚拟机当时时刻的线程快照。

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

5.2 案例分析

5.2.1 大内存硬件上的程序部署策略

  • 网站原本的服务器是32位操作系统。后来转换为16GB物理内存,操作系统为64位。原本只给堆分配了1.5GB。后来分配了12GB。

  • 设置过大的内存给堆。但是内存仍然很快被用完。而且停顿时间很长

  • 因为过大的堆内存会导致长时间的停顿。16GB内存尝试提升程序的效能,但是反而出现停顿。所以只能吧堆内存设置回1.5GB。网站慢,但是不至于停顿。

  • 单体应用在较大的内存硬件上部署方式

    • 单独一个虚拟机管理Java堆内存。
    • 同时使用若干个虚拟机,建立逻辑集群利用硬件资源。
  • 单个虚拟机管理大内存的问题

    • 回收大块堆内存导致的长时间停顿
    • 大内存必须要有64位虚拟机支持。
    • 必须保证应用程序足够稳定。因为堆内存溢出就会无法生成转储快照了。
    • 64位的虚拟机消耗的内存一般比32位的虚拟机要大。指针膨胀还有数据型对齐导致的
  • 使用若干个虚拟机逻辑集群管理硬件,可以解决上述的问题

    • 一台物理机器上面启动多个应用服务器进程。每个进程分配端口,搭载负载均衡。
    • 问题
    • 节点竞争全局的资源
    • 很难高效利用某些资源池。
    • 32位的虚拟机作为集群节点不可避免受到32位内存的限制。

5.2.3 堆外内存导致的溢出错误

  • 这是一个学校的小型项目:基于B/S的电子考试系统,为了实现客户端能实时地从服务器端接收考 试数据,系统使用了逆向AJAX技术(也称为Comet或者Server Side Push),选用CometD 1.1.1作为服 务端推送框架,服务器是Jetty 7.1.4,硬件为一台很普通PC机,Core i5 CPU,4GB内存,运行32位 Windows操作系统。
  • 服务器不断发出内存溢出的异常。发现堆内存增大也没有作用。
  • 服务器的使用的内存限制是2GB。但是只是划分1.6GB给Java堆。DirectMemory耗费的内存是0.4GB。这就是导致内存溢出的问题。直接内存不能够经常垃圾收集,所以只能够不断发出内存溢出的异常。

第7章 虚拟机类加载机制

7.1 概述

  • 虚拟机如何加载Class文件,Class文件的信息进入到虚拟机会有什么变化。
  • Java虚拟机把描述类的数据从Class文件加载到内存,对数据进行校验,转换解析,初始化。最终形成可以被虚拟机直接使用的Java类。这个过程就是类加载机制。
  • 类型加载、连接和初始化都是在程序运行期间完成的。

7.2 类加载的时机

  • 一个类型从被加载到虚拟机内存开始到载出内存为止,它的生命周期经历加载,验证,准备,解析,初始化,使用,卸载七个阶段。
  • 准备,验证,解析三个部分统称为连接。

image-20211208221400472

  • 加载,验证,准备,初始化和卸载这5个阶段的顺序是确定的。但是解析阶段就不一定。某些情况可以在初始化之后再开始解析。为了支持Java语言的运行时绑定的特性。
  • 六种情况需要立即对类进行初始化
    • new,getstatic,putstatic还有invokestatic。都需要进行对类的初始化。
      • new对象
      • 读取类型的静态变量
      • 调用类型的静态方法
    • java.lang.reflect对类型反射的时候。必须先初始化
    • 初始化类的时候,需要先初始化父类。
    • 虚拟机启动的时候,用户指定要执行的主类。
    • java.lang.invoke.MethodHandle实例解析结果是REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial的方法句柄,对应的类要初始化。
    • 接口定义了默认方法,如果接口实现类初始化,那么接口也要初始化。
  • 只有直接定义的静态变量被调用才会初始化,如果只有父类有,那么子类是不会初始化的。
  • 数组创建的引用类不会初始化,但是触发了[Lorg.fenixsoft.classloading.SuperClass的初始化,这是虚拟机自动生成的。继承Object,通过newarray指令产生。表示了一个元素类型org.fenixsoft.classloading.SuperClass的一维数组。
  • 如果调用的是final static的常量。由于编译期已经进入了类的常量池,所以并没有直接引用定义常量的类。不会初始化。下面这里的hello world已经被存储在NotInitialization的常量池,而不是去访问ConstClass类。
package org.fenixsoft.classloading;
    /**
    * 被动使用类字段演示三:
    * 常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的
    类的初始化
    **/
    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);
    }
}

7.3 类加载的过程

7.3.1 加载

  • 加载阶段就是类加载过程的一个阶段。
    1. 通过一个类的全限名获取和定义这个类的二进制字节流
    2. 将这个字节流代表的静态存储结构转化为方法区运行时数据结构
    3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据访问入口。
  • 二进制字节流可以在很多地方获取
    • Zip压缩包
    • 网络。
    • 运行时计算生成。动态代理。java.lang.reflect.Proxy的ProxyGenerator.generateProxyClass()生成一个*$Proxy代理类的字节流。
    • 其它文件,比如JSP生成的Class文件。
    • 数据库中获取
    • 加密文件中获取。
  • 非数组类型,加载阶段是通过虚拟机内置的引导类加载器完成的。也可以用户自定义的加载器去完成。
  • 对于数组类,数组类不通过类加载器创建,而是Java虚拟机直接在内存中动态构造出来。但是数组的元素类型还是通过类加载器完成。
  • 数组类遵循的规则
    • 数组的组件类型是引用类型,那么就递归采用上面的定义的加载过程去加载组件的类型。
    • 数组组件不是引用类型,虚拟机把数组标记与引导类加载器关联。
    • 数组类的可访问性和组件的类型一样。
  • 加载阶段结束之后,Java虚拟机外部的二进制字节流就按照虚拟机的规定的格式存储到方法区。
  • 加载与连接是交替进行的。

7.3.2 验证

  • 验证是连接的第一步。确保Class文件的字节流的信息符合约束规范。
  • Class文件并不一定是Java源文件编译而来,还能够是二进制的敲出来的Class文件。
  • 验证方式
    • 文件格式验证:验证魔数,版本号,常量池的常量类型等
    • 元数据验证:验证类是否有父类,是否继承不允许继承的父类等。
    • 字节码验证。确定程序的语义是否正确。
    • 符号引用验证。这个是发生在符号引用转换为直接引用的时候。在第三阶段解析执行。

7.3.3 准备

  • 准备阶段为类定义的变量分配内存,并且设置初始值。变量所使用的内存是方法区中分配的。
  • 内存分配只包括类变量,不包括实例变量。实例变量随着对象实例化的时候存入java堆。

7.3.4 解析

  • 把符号引用替换为直接引用。
  • 符号引用:一组符号描述引用的目标。可以是任何字面量。但是和虚拟机的内存布局无关,引用目标的内容不一定是虚拟机内存的内容。
  • 直接引用:指向目标的指针,偏移量,或者是句柄。直接引用与虚拟机的内存布局直接相关。只要有了直接引用,那么目标一定就是在内存存在

7.3.5 初始化

  • 类初始化的最后一个步骤。初始化阶段,虚拟机才开始执行类的代码。主导权交给应用程序。
  • 初始化阶段主要是执行类构造器< cinit>()方法的过程。它是Javac编译器的自动生成物。
  • 执行接口cinit并不需要执行父类的。
  • 虚拟机必须保证cinit能够在多线程的环境下加锁同步。

7.4 类加载器

  • 类加载阶段的通过类的全限名来获取该类的二进制字节流这个动作放到Java虚拟机外部实现。实现这个动作的代码是类加载器。

7.4.1 类与类加载器

  • 类加载器只是实现类的加载动作。对于任意一个类,必须通过加载它的类加载器和类本身确定它在java虚拟机的唯一性。
  • 每一个类加载器都有一个独立的类名称空间。对比类一定是在同一个类加载器加载才有可比性。

7.4.2 双亲委派模型

  • 两种不同的类加载器
    • 启动类加载器:通过c++实现虚拟机自身的一部分。
    • 其它所有的类加载器,通过java语言实现。独立于虚拟机的外部。全部都是继承抽象了ClassLoader。
  • 启动类加载器:加载JAVA_HOME\lib目录或者是通过-Xbootclasspath来决定。虚拟机能够按照文件名识别(rt.jar、tools.jar)。无法被java程序直接引用。通常使用null替代。
  • 拓展类加载器:sun.misc.Launcher$ExtClassLoader通过java代码实现,负责加载\lib\ext目录。Java系统类库的拓展机制。由于是Java代码实现,所以开发者可以在程序使用这个类加载器来完成加载Class文件。
  • 应用程序类加载器:sun.misc.Launcher$AppClassLoader实现。也可以称为系统类加载器。负责加载ClassPath的类库。

image-20211208232450747

  • 双亲委派模型要求,类加载器有自己的父类加载器。通过组合的方式实现。
  • 但是双亲委派并不是一个强制性约束的模型。

双亲委派的工作过程

  • 类加载器收到类加载请求,先委派父类加载器加载。
  • 只有父类加载器无法加载才会由子类加载器来加载。

双亲委派的好处

  • Java的类与类加载器具备了优先级。
  • Object存放在rt.jar无论哪个类加载器要加载这个类都会委派给处于模型顶端的启动类加载器加载。所以保证了Object在各种类加载器的环境只有同一个。

7.4.3 破坏双亲委派模型

  • 双亲委派模型是Java设计者推荐给开发者的类加载实现方式。
  • jdk1.2之后为了引入双亲委派,而且能够兼容之前的用户自定义类加载器的代码。所以loadClass不能被覆盖,提供了findClass引导用户编写类加载逻辑去重写这个方法。这样能够保证符合双亲委派而且能够按照自己的意愿去加载类。
  • 第二次破坏由于模型的缺陷,双亲委派解决了各个类加载器协作的基础类一致性问题。但是基础类要调用回用户代码怎么办?
    • JDNI服务,启动类加载器加载它的代码,但是JDNI的目的是对资源进行查找和集中管理。所以会调用其它程序的ClassPath的JDNI服务提供者的实现的接口。启动类加载器不认识这些代码,那么如何加载?
    • 通过线程上下文类加载器加载SPI服务代码。父类加载器请求子类加载器去完成类加载的行为。打通逆向的使用类加载器。默认是应用程序类加载器。
    • 第三次破坏是用户对动态性的追求导致的。代码热替换,模块热部署。
    • OSGi实现模块热部署的方式是每个模块都有自己的类加载器。每次需要改的时候,连同类加载器一起换掉。实现热替换。

7.5 Java模块化系统

  • Java模块化系统实现模块化的关键可配置的封装隔离机制。
  • 模块的定义
    • 依赖其他模块的列表
    • 导出包列表,其他模块可使用的列表
    • 开放包列表,其他模块可以反射访问模块的列表。
    • 使用的服务列表。
    • 提供服务的实现列表。
  • 可配置的封装隔离机制解决的问题
    • 类路径缺失的运行时依赖的类型。通过显式依赖解决
    • 原来类路径的跨Jar文件的public类型的可访问性问题。模块提供更精密的可访问性。

7.5.1 模块的兼容性

  • 后面提出了类路径相对应的模块路径。某个类库是Jar还是模块。取决于放到哪个路径上。
  • 类路径上的Jar文件就是传统的Jar,模块路径的Jar会被当成一个模块。

第8章 虚拟机字节码执行引擎

8.1 概述

  • 执行引擎是Java虚拟机核心的组成成分之一。虚拟机是相对于物理机的概念。
  • 物理机的执行引擎是建立在处理器,缓存,指令集,操作系统层面上的。但是虚拟机的执行引擎由软件自行实现。
  • 在不同的虚拟机实现,执行引擎执行的字节码的时候,通常会解释执行和编译执行。

8.2 运行时栈帧结构

  • Java虚拟机以方法作为最基本的执行单元。栈帧是支持虚拟机进行方法调用和方法执行的背后数据结构。也是虚拟机运行时的虚拟机栈的栈元素。

8.2.1 局部变量表

  • 一组变量的存储空间,存放方法参数,方法内部定义的局部变量。在Code的max_locals中定义了局部变量表的容量。
  • 变量槽是最小的单位。
  • 变量槽可以存储不超过32位的存储空间的数据类型。
  • 对于引用类型,虚拟机可以保证间接或者是直接找到对象在Java堆的起始地址。并且能够根据引用找到对象所属的数据类型在方法区的存储类型信息。
  • 64位的数据类型会连续分配两个槽。
  • Java虚拟机如果通过索引的方式使用局部变量表,索引从0开始到最大的槽数量。
  • 当一个方法被调用的时候,虚拟机使用局部变量表完成参数值到参数变量的传递过程。如果是实例方法,局部变量表的第0位索引变量默认用于传递对象实例的引用。可以通过this关键词访问隐含的参数。
  • 为了节省栈帧的耗费的内存空间,局部变量表中的变量槽是可以重用的。

image-20211209124038933

  • 下面一个案例。只有第三个被回收了。placeholder能否被回收的关键是局部变量表是否存有placeholder的数据对象引用。
    • 第一次修改的时候代码也就是下面的2,虽然离开了placeholder的作用域,但是没有发生对局部变量表的修改,变量槽没被复用,所以GC Roots一部分的局部变量仍然保持对他的关联。所以可以在不使用的变量上设置为null让它在变量表中清除。被回收。
    • 第二次修改int a=0的时候复用了槽。所以可以被清除
//1
public static void main(String[] args)() {
    byte[] placeholder = new byte[64 * 1024 * 1024];
    System.gc();
}
//2
public static void main(String[] args)() {
	{
   		 byte[] placeholder = new byte[64 * 1024 * 1024];
    }
    System.gc();
}
//3
public static void main(String[] args)() {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
int a = 0;
System.gc();
}

  • 对于局部变量是一定需要设置初始值的,因为类可以通过准备阶段赋值,但是局部变量不可以,他没有准备阶段。

8.2.2 操作数栈

  • 操作数栈是一个后进先出的栈。
  • 最大深度是max_stacks
  • 方法执行的过程各种字节码指令往操作数栈写入和提取内容。进行各种算数运算。

8.2.3 动态连接

  • 每个栈帧都包含指向运行时常量池中该所属方法的引用。持有这个方法的引用是为了支持方法调用过程的动态连接。字节码的方法调用指令,就会以常量池的指向方法符号引用作为参数。符号引用会转变为直接引用。这个就是动态连接。

8.2.4 方法返回地址

  • 两种方式退出方法
    • 遇到返回的字节码指令。返回值返回给上层的调用者。
    • 遇到了异常。
  • 方法返回的时候需要在栈帧保存一些信息,帮助恢复它的上层调用方法的执行状态。方法正常退出的时候,主调方法的PC计数器的值就可以作为返回地址。栈帧需要保存这个值。
  • 恢复上层方法局部变量表和操作数栈。

8.3 方法调用

  • 方法调用不等于方法的代码被执行。方法调用阶段的目标是确认被调用方法的版本。

8.3.1 解析

  • 类加载的解析阶段会把符号引用转换为直接引用。前提是已经有了可以调用版本。
  • 方法调用的指令。
    • invokestatic:调用静态方法
    • invokespecial:调用实例构造器的< init>方法,私有方法和父类中的方法。
    • invokevirtual:调用虚方法
    • invokeinterface:调用接口方法。
    • invokedynamic:先运行时动态解析出调用点的限定符所引用的方法。然后执行这个方法。前面四条调用指令,分派逻辑都固化在Java虚拟机的内部,而invokedynamic指令的分派逻辑是由用户设定的引导方法决定的。
  • 只要是能够被invokestatic或者是invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本。Java语言符合这个条件的静态方法,私有方法,实例构造器,父类方法,再加上final修饰的方法。直接可以把方法的符号引用解析为直接引用。这个解析调用是一个静态的过程,编译器就能够完成。

8.3.2 分派

  • 分派调用揭示了多态特征的最基本表现。
1.静态分派
  • 静态分派与重载。
public class StaticDispatch {
    static abstract class Human {
    }
    static class Man extends Human {
    }
    static class Woman extends Human {
    }
    public void sayHello(Human guy) {
        System.out.println("hello,guy!");
    }
    public void sayHello(Man guy) {
        System.out.println("hello,gentleman!");
    }
    public void sayHello(Woman guy) {
        System.out.println("hello,lady!");
    }
    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        StaticDispatch sr = new StaticDispatch();
        sr.sayHello(man);
        sr.sayHello(woman);
    }
}

//结果
hello,guy!
hello,guy!
  • 上面的代码Human man = new Man();为什么选择了执行Human的重载版本?
  • Human是变量的静态类型,Man是实际类型。静态类型编译期可以决定好,但是实际类型必须要在运行期才知道。
  • 虚拟机重载方法的时候取决于参数的静态类型。所以选择了sayHello(Human)作为调用的目标。
2.动态分派
  • 它是多态的重要体现。
public class DynamicDispatch {
    static abstract class Human {
        protected abstract void sayHello();
    }
    static class Man extends Human {
        @Override
        protected void sayHello() {
            System.out.println("man say hello");
        }
    }
    static class Woman extends Human {
        @Override
        protected void sayHello() {
            System.out.println("woman say hello");
        }
    }
    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        man.sayHello();
        woman.sayHello();
        man = new Woman();
        man.sayHello();
    }
}
//结果
man say hello
woman say hello
woman say hello
  • 这里调用的方法已经不能再使用静态分派了。因为静态类型都是Human。但是他们产生了不同的行为。
  • 原因就是他们的实际类型不同。
  • 虚拟机是如何根据实际类型来指定分派的方法的?
    • javap命令输出这段代码可以发现都是调用了invokevirtual。
    • invokevirtual的执行步骤
      • 找到操作数栈的第一个元素所指向的实际类型C。
      • 类型C找到与常量描述符和简单名称的都相符的方法。进行访问权限校验。并且返回方法的引用。
      • 否则按照继承关系从下到上依次对C的各个父类进行搜索
      • 如果没有就返回java.lang.AbstractMethodError
    • invokevirtual的特性决定了只有运行期的时候才能够决定实际类型和调用的方法。这种就是动态分派。
    • 但是类的字段是不会参与多态的。子类的同名字段直接覆盖父类的。
public static void main(java.lang.String[]);
    Code:
        Stack=2, Locals=3, Args_size=1
        0: new #16; //class org/fenixsoft/polymorphic/DynamicDispatch$Man
        3: dup
        4: invokespecial #18; //Method org/fenixsoft/polymorphic/Dynamic Dispatch$Man."<init>":()V
        7: astore_1
        8: new #19; //class org/fenixsoft/polymorphic/DynamicDispatch$Woman
        11: dup
        12: invokespecial #21; //Method org/fenixsoft/polymorphic/DynamicDispatch$Woman."<init>":()V
        15: astore_2
        16: aload_1
        17: invokevirtual #22; //Method org/fenixsoft/polymorphic/Dynamic Dispatch$Human.sayHello:()V
        20: aload_2
        21: invokevirtual #22; //Method org/fenixsoft/polymorphic/Dynamic Dispatch$Human.sayHello:()V
        24: new #19; //class org/fenixsoft/polymorphic/DynamicDispatch$Woman
        27: dup
        28: invokespecial #21; //Method org/fenixsoft/polymorphic/DynamicDispatch$Woman."<init>":()V
        31: astore_1
        32: aload_1
        33: invokevirtual #22; //Method org/fenixsoft/polymorphic/Dynamic Dispatch$Human.sayHello:()V
        36: return
3.单分派与多分派
  • 方法的接收者与方法的参数统称为方法的宗量。根据分派基于多少宗量划分单分派和多分派。
  • 单分派根据一个宗量对目标方法选择。
  • 多分派根据多于一个宗量对目标方法进行选择。
  • 下面的案例
    • main方法调用了两次hardChoice()方法,两次输出都是不同的。
    • 首先是静态分配的选择过程,选择Father还是Son。第二个是参数选择方法参数QQ还是360。最终产生了两条invokevirtual指令。两条指令的参数分别为常量池指向Father::hardChoice(360)及Father::hardChoice(QQ)方法的符号引用。
    • 这是根据两个宗量来决定的。这个静态分派就是一个多分派。
    • 再看看虚拟机的选择,就是动态分派。son.hardChoice(new QQ())执行这个的时候对应的是invokevirtual编译器决定好了hardChoice(QQ)方法名,所以不会再管QQ这个参数。所以这个动态分派只有一个宗量是单分派
public class Dispatch {
    static class QQ {}
    static class _360 {}
    public static class Father {
        public void hardChoice(QQ arg) {
            System.out.println("father choose qq");
        }
        public void hardChoice(_360 arg) {
            System.out.println("father choose 360");
        }
    }
    public static class Son extends Father {
        public void hardChoice(QQ arg) {
            System.out.println("son choose qq");
        }
        public void hardChoice(_360 arg) {
            System.out.println("son choose 360");
        }
    }
    public static void main(String[] args) {
        Father father = new Father();
        Father son = new Son();
        father.hardChoice(new _360());
        son.hardChoice(new QQ());
    }
}

//结果
father choose 360
son choose qq
  • 也就是说Java语言是一门静态多分派,动态单分派的语言。
4.虚拟机动态分派的实现
  • 对于动态分派的优化。可以建立一个虚方法表。用索引代替元数据的查找。
  • 虚方法表里面存放着各个方法的实际入口地址。如果某个方法没有重写,那么指向的方法入口就是父类的。如果重写就是指向子类的。

image-20211209165450347

8.4 动态类型语言支持

  • invokedynamic指令实现动态类型语言。

8.4.1 动态类型语言

什么是动态类型语言?它与java语言和java虚拟机有什么关系?

  • 动态语言关键特征是它的类型检查的主体过程是在运行期而不是在编译期。
  • 下面代码是否能够正常编译和运行。
    • 能够正常编译,但是运行的时候出现NegativeArraySizeException异常。这是一个运行时的异常。
    • 连接时异常是把代码放到了根本无法执行的路径上面。
public static void main(String[] args) {
    int[][][] array = new int[1][0][-1];
}
  • 什么是类型检查?
    • 比如obj.println(“hello world”);
    • 这一行是无法执行的,因为它没有具体的上下文。obj是什么类型,是什么程序语言。
    • java语言会在编译期间把方法的完整符号引用生成,并且作为方法调用指令存储到Class文件。
    • 符号引用决定了方法在哪个类型,方法的名字,参数等。
    • 但是对于ECMAScript(JavaScript)无论obj是什么类型,只要这种类型的方法定义里面包含了方法也就是找到相同签名的方法就可以运行。
  • 上面解释了静态与动态语言的区别。类型检测非常重要。

第9章 类加载及执行子系统的案例与实战

9.2 案例分析

9.2.1 Tomcat:正统的类加载器架构

  • 实现一个健全的Web服务器需要解决的问题
    • 部署在同一个服务器的两个Web应用所使用的Java类库可以实现相互隔离。两个不同的程序应用可能依赖同一个第三方类库的不同版本。不能要求每个类库在服务器只能有一份。
    • 部署在同一个服务器的两个Web应用所使用的Java类库可以共享。避免资源的浪费
    • 服务器需要尽可能保证自身的安全不受部署的Web应用程序影响。服务器自己也有依赖的类库。他们必须相互独立。
    • 支持JSP应用的Web服务器,需要支持HotSwap功能。JSP需要编译成Java的Class文件。但是JSP的修改次数非常多。如果重新替换Class文件。
  • 上述的问题,也就是一个类路径无法满足需求。所以需要好几个类路径存放第三方类库。
  • Tomcat的目录结构。有三组/common/*、/server/*和/shared/*还要加上应用的WEB-INF目录。
    • common目录是被Tomcat和所有Web应用共同使用
    • server目录只能被Tomcat使用,对Web应用不可见
    • shared目录被Web应用共同使用,对Tomcat不可见。
    • /WebApp/WEB-INF仅仅只能Web应用自己看见。

image-20211209173109681

  • 上面的目录都对应了自己的类加载器。并且上层还是原来的三个类加载器。
  • 25
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 51
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 51
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值