Java 学习笔记:JVM

JVM 基本概念

  • 可运行 Java 代码的假想计算机,其包括字节码指令集、寄存器、栈、垃圾回收器、堆和存储方法域
  • 运行在操作系统之上,与硬件没有直接交互

在这里插入图片描述

  • Java 源文件 -> 编译器 -> 字节码文件
  • 字节码文件 -> JVM -> 机器码

线程

此处及下文所说的线程是指程序执行过程中的一个线程实体。JVM 允许一个应用并发执行多个线程。HotSpot JVM 中 Java 线程与原生操作系统线程有直接的映射关系。

当线程本地存储、缓冲区分配、同步对象、栈、程序计数器等准备好以后,就会创建一个操作系统原生线程。Java 线程结束,原生线程随之被回收,操作系统负责调度所有线程,并将其分配到任何可用的 CPU 上。当原生线程创建完毕,就会调用 Java 线程的 run() 方法。当线程结束时,会释放原生线程和 Java 线程的所有资源。

HotSpot JVM 后台运行的系统线程主要有以下几个:

线程特性
虚拟机线程 VM thread该线程等待 JVM 到达安全点操作出现。这些操作必须要在独立的线程里执行,因为当堆修改无法进行时,线程都需要 JVM 位于安全点。这些操作的类型有: stop-the-world 垃圾回收、线程栈 dump、线程暂停、线程偏向锁(biased locking)解除。
周期性任务线程该线程负责定时器事件(即中断),用于调度周期性操作的执行
GC 线程该线程支持 JVM 中不同的垃圾回收活动
编译器线程该线程在运行时将字节码动态编译成本地平台相关的机器码
信号分发线程该线程接收发送到 JVM 的信号并调用适当的 JVM 方法处理

运行时数据区域

在这里插入图片描述

除了被紫色线程框隔离的数据区外,堆与方法区由所有线程共享。

程序计数器

记录正在执行的虚拟机字节码指令的地址(如果正在执行的是本地方法则为空),可看作是当前线程所执行的字节码的行号指示器,字节码解释器工作时通过改变该计数器的值来选取下一条需要执行的字节码指令。

唯一没有规定任何 OutOfMemoryError 情况的区域。

Java 虚拟机栈

是描述 Java 方法执行的内存模型,每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用、动态链接、方法出口等信息。从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。

栈帧是用于存储数据和部分过程结果的数据结构,同时也被用于处理动态链接、方法返回值和异常分派。栈帧随着方法调用而创建,随着方法结束而销毁——无论方法是正常完成还是异常完成(抛出了在方法内未被捕获的异常)都算作方法结束。

在这里插入图片描述
可以通过 -Xss 这个虚拟机参数来指定每个线程的 Java 虚拟机栈内存大小:

java -Xss512M HackTheJava

所需内存空间在编译期间完成分配,大小在运行期间不会发生改变。

该区域可能抛出以下异常:

  • 当线程请求的栈深度超过虚拟机所允许的最大值,会抛出 StackOverflowError 异常
  • 栈进行动态扩展时如果无法申请到足够内存,会抛出 OutOfMemoryError 异常

特另:

  • 在单个线程情况下,无论是由于栈帧太大还是虚拟机栈容量太小,当内存无法分配时均抛出 StackOverflowError 异常。
  • 不断建立线程的情况下,为每个线程的栈分配的内存越大,越容易产生内存溢出异常。需要考虑减少最大堆和栈容量来换取更多线程的情况。

本地方法栈

本地方法栈与 Java 虚拟机栈类似,它们之间的区别只不过是本地方法栈为本地(Native)方法服务,虚拟机栈为虚拟机执行 Java 方法(即字节码)服务。

本地方法一般是用其它语言(C、C++ 或汇编语言等)编写的,并且被编译为基于本机硬件和操作系统的程序,对待这些方法需要特别处理。

在这里插入图片描述

用于存放对象实例与数组,几乎所有对象都在这里分配内存,是垃圾收集的主要区域(“GC 堆”)。

现代的垃圾收集器基本都是采用分代收集算法,其主要的思想是针对不同类型的对象采取不同的垃圾回收算法。可以将堆分成两块:

  • 新生代(Young Generation)

    用于存放新生的对象,一般占据堆的 1/3 空间。由于频繁创建对象,所以新生代会频繁出发 MinorGC 进行垃圾回收。

    又分为 Eden 区、From Survivor 区 和 To Survivor 区(空间划分为 8 :1 :1 )三个区域。

    • Eden 区:Java 新对象的出生地。当 Eden 区内存不够时会触发 MinorGC,对新生代区进行一次垃圾回收。
    • SurvivorFrom 区:上一次 GC 的幸存者,作为这一次 GC 的扫描者。
    • SurvivorTo 区:保留了一次 MinorGC 过程中的幸存者。
  • 老生代(Old Generation)

    主要存放应用程序中生命周期长的内存对象。

堆不需要连续物理内存,并且可以动态扩展其内存,无多余内存可完成实例分配且扩展失败时会抛出 OutOfMemoryError 异常。

可以通过 -Xms 和 -Xmx 这两个虚拟机参数来指定一个程序的堆内存大小,第一个参数设置初始值,第二个参数设置最大值。

java -Xms1M -Xmx2M HackTheJava

方法区

用于存放已被 JVM 加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

和堆一样不需要连续的内存,并且可以动态扩展,动态扩展失败一样会抛出 OutOfMemoryError 异常,可选择不实现垃圾收集。

对这块区域进行垃圾回收的主要目标是对常量池的回收和对类的卸载,但是对类的卸载一般比较难实现。

HotSpot 虚拟机把它当成永久代来进行垃圾回收。但 GC 不会在主程序运行期间对永久代区域进行清理,且很难确定永久代的大小,因为它受到很多因素影响,并且每次 Full GC 之后永久代的大小都会改变,所以经常会抛出 OutOfMemoryError 异常。

为了更容易管理方法区,从 JDK 1.8 开始,移除永久代,并把方法区移至 “ 元数据区” (元空间),本质与永久代类似,与其最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,在默认情况下,元空间的大小仅受本地内存限制。类的元数据放入 native memory,字符串池和类的静态变量放入 Java 堆中。这样加载类的元数据就不再受 MaxPermSize 限制,而由系统的实际可用空间来控制

运行时常量池

运行时常量池是方法区的一部分。

Class 文件中的常量池(编译期生成的字面量和符号引用)会在类加载后被放入这个区域。

除了在编译期生成的常量,还允许存储运行期间动态生成的常量,例如 String 类的 intern ()。

  • 在 JDK 1.6 中, intern() 会把首次遇到的字符串实例复制到永久代中,返回的也是永久代中这个字符串实例的引用。
  • 在 JDK 1.7 中,intern() 不会再复制实例,而只是在常量池中记录首次出现的实例引用。

本机直接内存

在 JDK 1.4 中新引入了 NIO 类,提供了基于 Channel 与 Buffer 的 IO 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过 Java 堆里的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在堆内存和堆外内存来回拷贝数据。

虽然本机直接内存分配不会受到 Java 堆大小的限制,但仍旧受本机总内存(包括 RAM 以及 SWAP 区或分页文件)大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现 OutOfMemoryError 异常。

  • 直接内存异常特征:在 Heap Dump 文件中不会看到明显的异常
  • 常出现于:OOM 之后 Dump 文件很小且程序中直接或间接地使用了 NIO

潜在问题:

在垃圾收集进行时,虚拟机虽然会对直接内存进行回收,但是直接内存无法像新生代、老生代那样,发现空间不足就通知收集器进行垃圾回收,它只能等待老生代满了后 Full GC 顺便清理直接内存中的废弃对象。否则其只能等到抛出内存溢出异常时先捕获异常并在异常处理语句中调用 System.gc(),若仍旧无法解决(虚拟机打开了 了 -XX:+DisableExplicitGC 开关等情况),则在堆中还有空闲内存的情况下会抛出内存溢出异常。

内存占用

从实践经验的角度出发,除了 Java 堆和永久代之外,下面这些区域还会占用较多的内存,这里所有的内存总和受到操作系统进程最大内存的限制。

  • 本机直接内存 Direct Memory

    可通过 -XX:MaxDirectMemorySize 调整大小,内存不足时抛出 OutOfMemoryError 或者OutOfMemoryError:Direct buffer memory。

  • 线程堆栈

    可通过 -Xss 调整大小,内存不足时抛出 StackOverflowError(纵向无法分配,即无法分配新的栈帧)或者 OutOfMemoryError:unable to create new native thread(横向无法分配,即无法建立新的线程)。

  • Socket 缓存区

    每个 Socket 连接都 Receive 和 Send 两个缓存区,分别占大约 37KB 和 25KB 内存,连接多的话这块内存占用也比较可观。如果无法分配,则可能会抛出 IOException:Too many open files 异常。

  • JNI 代码

    如果代码中使用JNI调用本地库,那本地库使用的内存也不在堆中。

  • 虚拟机和 GC

    虚拟机、GC的代码执行也要消耗一定的内存。

垃圾收集

垃圾收集主要是针对堆和方法区进行,其内存分配与回收是动态的。

程序计数器、虚拟机栈和本地方法栈这三个区域属于线程私有的,只存在于线程的生命周期内,线程结束之后就会消失,内存分配在编译期基本确定,分配与回收均具有确定性,因此不需要对这三个区域进行垃圾回收。

判断对象是否可被回收

引用计数算法

为对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收。

在两个对象出现循环引用的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。正是因为循环引用的存在,因此 Java 虚拟机不使用引用计数算法。

public class Test {
    public Object instance = null;
    public static void main(String[] args) {
        Test a = new Test();
        Test b = new Test();
        a.instance = b;
        b.instance = a;
        a = null;
        b = null;
        doSomething();
    }
}

在上述代码中,a 与 b 引用的对象实例互相持有了对象的引用,因此当我们把对 a 对象与 b 对象的引用去除之后,由于两个对象还存在互相之间的引用,导致两个 Test 对象无法被回收。

可达性分析算法 / 根搜索算法

以 GC Roots 为起始点进行搜索,通过引用链(搜索所走过的路径)可到达的对象都是存活的,不可达的对象可被回收。

在这里插入图片描述

Java 虚拟机使用该算法来判断对象是否可被回收,GC Roots 一般包含以下内容:

  • 虚拟机栈中局部变量表中引用的对象
  • 本地方法栈中 JNI 中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中的常量引用的对象

方法区的回收

因为方法区主要存放永久代对象,而永久代对象的回收率比新生代低很多,所以在方法区上进行回收性价比不高。

主要是对常量池的回收和对类的卸载。

为了避免内存溢出,在大量使用反射和动态代理的场景都需要虚拟机具备类卸载功能。

类的卸载条件很多,需要满足以下三个条件,并且满足了条件也不一定会被卸载:

  • 该类所有的实例都已经被回收,此时堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。
  • 该类对应的 Class 对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法。

finalize()

类似 C++ 的析构函数,用于关闭外部资源。但是 try-finally 等方式可以做得更好,并且该方法运行代价很高,不确定性大,无法保证各个对象的调用顺序,因此最好不要使用。

当一个对象可被回收时,如果需要执行该对象的 finalize () 方法,那么就有可能在该方法中(等待队列中)让对象重新被引用,从而实现自救。自救只能进行一次,如果回收的对象之前调用了 finalize () 方法自救,后面回收时不会再调用该方法。

引用类型

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否可达,判定对象是否可被回收都与引用有关。

Java 提供了四种强度不同的引用类型。

强引用

把一个对象赋给一个引用变量,该引用变量就是一个强引用。被强引用关联的对象不会被回收。

使用 new 一个新对象的方式来创建强引用。

Object obj = new Object();

即使该对象永远不会被用到,JVM 也不会回收。因此强引用是造成 Java 内存泄漏的主要原因之一。

软引用

被软引用关联的对象只有在内存不够的情况下才会被回收,通常用在对内存敏感的程序中。

使用 SoftReference 类来创建软引用。

Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null;  // 使对象只被软引用关联

弱引用

被弱引用关联的对象一定会被回收,即它只能存活到下一次垃圾回收发生之前。

使用 WeakReference 类来创建弱引用。

Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;

虚引用

又称为幽灵引用或者幻影引用,一个对象是否有虚引用的存在,不会对其生存时间造成影响,也无法通过虚引用得到一个对象。它不能单独使用,必须和引用队列联合使用。

为一个对象设置虚引用的主要作用是跟踪对象被垃圾回收的状态,在这个对象被回收时收到一个系统通知。

使用 PhantomReference 来创建虚引用。

Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj, null);
obj = null;

垃圾收集算法

标记 - 清除

在这里插入图片描述

标记要回收的对象,在标记完成后统一回收所有被标记的对象。

不足:

  • 标记和清除过程效率都不高;
  • 会产生大量不连续的内存碎片,导致无法给大对象分配内存,不得不提前触发另一次垃圾收集动作。

复制

在这里插入图片描述

将内存划分为大小相等的两块,每次只使用其中一块,当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。

优点:

  • 使得内存分配时无需考虑内存碎片等复杂情况,只需移动堆顶指针,按顺序分配内存即可,简单、高效。

不足:

  • 只使用了内存的一半
  • 对象存活率较高时需要进行很多复制操作,效率降低

现在的商业虚拟机都采用这种收集算法回收新生代,因为新生代每次垃圾回收都要回收大部分对象,所需的复制操作较少。但是并不是划分为大小相等的两块,而是一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象全部复制到另一块 Survivor 上,最后清理 Eden 和使用过的那一块 Survivor。

HotSpot 虚拟机的 Eden 和 Survivor 大小比例默认为 8:1,每次新生代中可用内存空间为整个新生代容量的90%(80% + 10%),即保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 就不够用了,此时需要依赖于其他内存(老生代)进行空间分配担保,也就是另一块 Survivor 没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将之间通过分配担保机制进入老生代。

标记 - 整理

在这里插入图片描述
让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。

优点:

  • 不会产生内存碎片

不足:

  • 在标记 - 清除的基础上还需进行对象的移动,成本相对较高

分代收集

现在的商业虚拟机采用分代收集算法,它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。

一般将堆分为新生代和老生代。

  • 新生代

    每次垃圾收集都能发现大批对象已死,只有少量存活。因此选用 **“复制” **算法, 只需要付出少量存活对象的复制成本就可以完成收集。

  • 老生代

    因为对象存活率高、没有额外空间对它进行分配担保,则需要采用 “标记—清理”“标记—整理” 算法来进行回收,不必进行内存复制, 且直接腾出空闲内存。

分区收集

分区算法则将整个堆空间划分为连续的不同小区间,每个小区间独立使用,独立回收。这样做的好处是可以控制一次回收多少个小区间,根据目标停顿时间,每次合理地回收若干个小区间(而不是整个堆),从而减少一次GC所产生的停顿。

垃圾收集器

在这里插入图片描述

以上是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。

  • 单线程与多线程:单线程指的是垃圾收集器只使用一个线程,而多线程使用多个线程;
  • 串行:串行指的是垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序;
  • 并行:多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态;
  • 并发:用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行在另一个 CPU 上。
  • 除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行。

Serial 收集器

在这里插入图片描述

  • Serial 翻译为串行,也就是说它以串行的方式执行,使用复制算法。
  • 单线程的收集器,只会使用一个线程进行垃圾收集工作,在进行垃圾收集时,必须暂停其他所有的工作线程,直到收集完毕。

优点:简单高效,在单个 CPU 环境下,由于没有线程交互的开销,因此拥有最高的单线程收集效率

它是 Client 场景下的默认新生代收集器,因为在该场景下内存一般来说不会很大。它收集一两百兆新生代垃圾的停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿时间是可以接受的。

ParNew 收集器

在这里插入图片描述

  • Serial 收集器的多线程版本,使用多条线程进行垃圾收集,使用复制算法。
  • 默认开启线程数与 CPU 的数量相同

它是 Server 场景下默认的新生代收集器,除了性能原因外,主要是因为除了 Serial 收集器,只有它能与 CMS 收集器配合使用。

Parallel Scavenge 收集器

与 ParNew 一样是并行多线程收集器,使用复制算法,新生代使用。

其它收集器目标是尽可能缩短垃圾收集时用户线程的停顿时间,而它的目标是达到一个可控制的吞吐量,因此它被称为 “吞吐量优先” 收集器。这里的吞吐量指 CPU 用于运行用户程序的时间占总时间的比值。

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。而高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务,适合在后台运算而不需要太多交互的任务。

提供了精确控制吞吐量的参数:

-XX:MaxGCPauseMills		// 控制最大垃圾收集停顿时间
-XX:GCTimeRatio			// 垃圾收集时间占总时间的比率,即吞吐量的倒数
  • 缩短停顿时间是以牺牲吞吐量和新生代空间来换取的:新生代空间变小,垃圾回收变得频繁,导致吞吐量下降。

可以通过一个开关参数打开 GC 自适应的调节策略(GC Ergonomics),就不需要手工指定新生代的大小(-Xmn)、Eden 和 Survivor 区的比例、晋升老生代对象年龄等细节参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。

-XX:+UseAdaptiveSizePolicy  // 开关参数

Serial Old 收集器

在这里插入图片描述

Serial 收集器的老生代版本,同样是一个单线程收集器(无法充分利用服务器多 CPU 的处理能力),使用 ”标记 - 整理“ 算法。该收集器主要是给 Client 场景下的虚拟机使用。如果用在 Server 场景下,它有两大用途:

  • 在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用。
  • 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。

Parallel Old 收集器

在这里插入图片描述

JDK 1.6 中开始提供,是 Parallel Scavenge 收集器的老生代版本,使用多线程和 ”标记 - 整理“ 算法。

在JDK1.6之前,新生代使用 ParallelScavenge 收集器只能搭配老生代的 Serial Old 收集器,只能保证新生代的吞吐量优先,无法保证整体的吞吐量,Parallel Old 正是为了在老生代同样提供吞吐量优先的垃圾收集器,在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。

CMS 收集器

在这里插入图片描述

CMS(Concurrent Mark Sweep)是一种以获取最短回收停顿时间为目标的收集器,Mark Sweep 指的是标记 - 清除算法。

分为以下四个流程:

  • 初始标记:

    仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿。

  • 并发标记:

    进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿。

  • 重新标记:

    为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿,时间长于初始标记、短于并发标记。

  • 并发清除:

    不需要停顿。

在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。

缺点:

  • 吞吐量低:低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高。

  • 无法处理浮动垃圾,可能出现 Concurrent Mode Failure。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老生代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。

  • 标记 - 清除算法导致的空间碎片,往往出现老生代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。

    可使用 -XX:+UseCMSCompactAtFullCollection 参数(默认开启)在需要进行 Full GC 时开启内存碎片合并整理过程,该过程无法并发,导致停顿时间变长。

    可使用 -XX:CMSFullGCsBeforeCompaction 参数设置执行多少次不压缩的 Full GC 后进行带压缩的 Full GC(默认值为 0,表示每次进入 Full GC 时都进行碎片整理)。

G1 收集器

G1(Garbage-First),它是一款面向服务端应用的垃圾收集器,在多 CPU 和大内存的场景下有很好的性能。HotSpot 开发团队赋予它的使命是未来可以替换掉 CMS 收集器。

  • 适用于追求低停顿情况,对于追求吞吐量的情况不会有什么特别的优势。

堆被分为新生代和老生代,其它收集器进行收集的范围都是整个新生代或者老生代,而 G1 可以直接对新生代和老生代一起回收。

在这里插入图片描述

G1 把堆划分成多个大小相等的独立区域(Region),新生代和老生代不再物理隔离。

在这里插入图片描述

通过引入 Region 的概念,从而将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能。通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region,有计划地避免在整个 Java 堆中进行全区域的垃圾收集,确保在有限时间内获得最高的垃圾收集效率。

每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描。

在这里插入图片描述

如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:

  • 初始标记:

    标记 GC Roots 能直接关联到的对象并修改 TAMS(Next Top at Mark Set)的值,使下一阶段用户程序并发运行时能在正确可用的 Region 中创建新对象。需要停顿,但耗时很短。

  • 并发标记:

    从 GC Root 开始对堆中对象进行可达性分析,找出存活的对象。耗时较长,但可以与用户程序并发执行。

  • 最终标记:

    为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。

  • 筛选回收:

    首先,对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。

特点:

  • 并行与并发:利用多个 CPU 缩短停顿时间,部分其他收集器原本需要停顿 Java 线程执行的 GC 动作, G1 收集器仍然可以通过并发的方式使 Java 程序继续执行。
  • 分代收集:分代概念得以保留,采用不同方式去处理新创建的对象和已经存活一段时间、熬过多次 GC 的旧对象,以获得更好的收集效果。
  • 空间整合:整体来看是基于 “标记 - 整理” 算法实现的收集器,从局部(两个 Region 之间)上来看是基于 “复制” 算法实现的,这意味着运行期间不会产生内存空间碎片。
  • 可预测的停顿:能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒。

参数总结

在这里插入图片描述

内存分配与回收策略

下文以 Serial / Serial Old 收集器下情况为例描述。

Minor GC 和 Full GC

  • Minor GC:回收新生代,因为新生代对象存活时间很短,因此 Minor GC 会频繁执行,执行的速度一般也会比较快。
  • Full GC / Major GC:回收老生代和新生代,老生代对象其存活时间长,因此 Full GC 很少执行,执行速度会比 Minor GC 慢很多。出现了 Full GC,经常会伴随至少一次 Minor GC。

内存分配策略

对象优先在 Eden 分配

  • 大多数情况下,对象在新生代的 Eden 区上分配,当 Eden 空间不够时,发起 Minor GC
  • 若启动了本地线程分配缓冲,则将按线程优先在 TLAB (本地线程分配缓冲)上分配

实例:

  1. 内存大小设定
// 限制堆大小为 20M,不可扩展,10M 分配给新生代、10M 分配给老生代
-Xms20M -Xmx20M -Xmn10M
// 决定新生代中 Eden:Survivor = 8:1
-XX:SurvivorRatio=8
  1. 内存分配
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

​ 分配 allocation4 对象的语句时会发生一次 Minor GC,这次 GC 的结果是新生代 6651KB 变为 148KB,而总内存占用量则几乎没有减少(因为 allocation1、allocation2、allocation3 三个对象都是存活的,虚拟机几乎没有找到可回收的对象)。

​ 这次 GC 发生的原因是给 allocation4 分配内存的时候,发现 Eden 已经被占用了 6MB,剩余空间已不足以分配 allocation4 所需的 4MB 内存,因此发生 Minor GC。GC 期间虚拟机又发现已有的 3 个 2MB 大小的对象全部无法放入 Survivor 空间( Survivor 空间只有 1MB 大小),所以只好通过分配担保机制提前转移到老生代去。

  1. 结果

​ Eden 占用 4 MB(被 allocation4 占用),Survivor 空闲,老生代被占用 6MB(被allocation1、 allocation2、allocation3 占用)。

大对象直接进入老生代

  • 大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组

  • 经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象

  • -XX:PretenureSizeThreshold,大于此值的对象直接在老生代分配,避免在 Eden 和 Survivor 之间的大量内存复制。

    该参数只对 Serial 和 ParNew 两款收集器有效,Parallel Scavenge收集器一般并不需要设置,若遇到必须使用该参数的场合,可以考虑 ParNew 加 CMS 的收集器组合。

长期存活的对象进入老生代

  • 为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄(默认 15)则移动到老生代中。
  • -XX:MaxTenuringThreshold 用来定义年龄的阈值。

动态对象年龄判定

虚拟机并不是永远要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老生代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老生代,无需等到 MaxTenuringThreshold 中要求的年龄。

空间分配担保

在发生 Minor GC 之前,虚拟机先检查老生代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。

如果不成立的话虚拟机会查看 HandlePromotionFailure 的值是否允许担保失败,如果允许那么就会继续检查老生代最大可用的连续空间是否大于历次晋升到老生代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 的值不允许冒险,那么就要进行一次 Full GC。

JDK 6 Update 24 之后不再使用 HandlePromotionFailure 参数,规则变为只要老生代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC。

Full GC 触发条件

对于 Minor GC,其触发条件非常简单,当 Eden 空间满时,就将触发一次 Minor GC。而 Full GC 则相对复杂,有以下条件:

调用 System.gc()

只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。

老生代空间不足

老生代空间不足的常见场景为前文所讲的大对象直接进入老生代、长期存活的对象进入老生代等。

为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老生代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老生代的年龄,让对象在新生代多存活一段时间。

空间分配担保失败

使用复制算法的 Minor GC 需要老生代的内存空间作担保,如果担保失败会执行一次 Full GC。

JDK 1.7 及以前的永久代空间不足

在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 信息、Meta 信息、常量、静态变量等数据。GC 不会在主程序运行期对永久代区域进行清理。

当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError。

为避免以上原因引起的 Full GC,可采用的方法为增大永久代空间或转为使用 CMS GC。

Concurrent Mode Failure

执行 CMS GC 的过程中同时有对象要放入老生代,而此时老生代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。

类加载机制

  • 虚拟机将描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 模型。
  • 类的加载、连接与初始化过程均是在程序运行期间完成的,稍微增加一些性能开销,但是提供了高度的灵活性,支持可动态扩展的语言特性。
  • 类是在运行期间第一次使用时动态加载的,而不是一次性加载所有类。因为如果一次性加载,那么会占用很多的内存。

类的生命周期

在这里插入图片描述

包括以下 7 个阶段:

  • 加载(Loading)
  • 验证(Verification)
  • 准备(Preparation)
  • 解析(Resolution)
  • 初始化(Initialization)
  • 使用(Using)
  • 卸载(Unloading)

其中,验证、准备、解析三个部分统称为连接

上述阶段通常是互相交叉地混合式进行的,通常会在一个阶段执行的过程中调用、激活另外一个阶段。

类加载过程

包含了加载、验证、准备、解析和初始化这 5 个阶段。

加载

加载是类加载的一个阶段,注意不要混淆。

加载过程完成以下三件事:

  • 通过类的完全限定名称获取定义该类的二进制字节流

    可控性最强,既可通过系统提供的引导类加载器完成,也可以通过自定义类加载器完成。

  • 将该字节流表示的静态存储结构转换为方法区的运行时存储结构

    存储格式由虚拟机自行定义

  • 在内存中生成一个代表该类的 java.lang.Class 对象,作为方法区中该类各种数据的外部访问接口

    没有明确规定在 Java 堆中存放,对于 HotSpot 虚拟机而言,Class 对象虽然是对象,但是存放在方法区中。

其中二进制字节流可以从以下方式中获取:

  • 从 ZIP 包读取,成为 JAR、EAR、WAR 格式的基础。
  • 从网络中获取,最典型的应用是 Applet。
  • 运行时计算生成,例如动态代理技术,在 java.lang.reflect.Proxy 使用 ProxyGenerator.generateProxyClass 的代理类的二进制字节流。
  • 由其他文件生成,例如由 JSP 文件生成对应的 Class 类。
  • 从数据库中读取,SAP Netweaver 等中间件服务器可选择将程序安装到数据库中来完成程序代码在集群间的分发。

数组类:由 Java 虚拟机直接创建,不通过类加载器,但是其中的元素类型最终需要依靠类加载器来创建。

  • 元素类型为引用类型:递归采用上述类加载过程来加载元素类型,数组类将在加载该元素类型的类加载器的类名称空间上被标识(确定唯一性)。
  • 元素类型为基本类型:虚拟机会将数组类标记为与引导类加载器相关联。

数组类可见性与元素类型可见性一致,若元素类型不是引用类型,那么数组类的可见性默认为 public。

加载阶段与连接阶段的部分内容(如部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但是夹在加载阶段之中进行的动作仍然属于连接阶段的内容,即这两个阶段的开始时间仍然保持着固定的先后顺序

验证(非必要)

**目的:**确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

**原因:**Java 本身是相对安全的语言,纯粹的 Java 代码若做出违法行为则无法通过编译。但是在字节码语言层面上,可以做到 Java 代码无法做到的事情,引入有害字节流导致系统崩溃。验证阶段直接决定了 Java 虚拟机能否承受恶意代码的攻击。

特殊情况:若所运行的所有代码都已经被反复使用和验证过,那么在实施阶段可以考虑使用 -Xverify:none 参数来关闭大部分的类验证措施,缩短虚拟机类加载的时间。

检验动作:

  • 文件格式验证(基于二进制字节流)

    验证字节流是否符合 Class 文件格式规范并且能够被当前版本的虚拟机处理

    保证输入的字节流能够正确地解析并存储于方法区之内,格式上符合描述一个 Java 类型信息的要求

  • 元数据验证(基于类的元数据信息中的数据类型)

    对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范要求

  • 字节码验证(基于类的元数据信息中的方法体)

    • 类型推导:通过数据流和控制流分析

      ( JDK 1.6 中提供选项参数,使得在类型检查校验失败时回退到类型推导方式进行校验,也可选择完全使用类型推导)

    • 类型检查:通过检查 StackMapTable 属性中的记录

      ( JDK 1.7 之后对于主版本号大于 50 的 Class 文件的唯一选择)

      属性描述了方法体中所有的基本块开始时本地变量表与操作栈应有的状态,在类型检查时仅需检查记录状态是否合法,减少性能消耗。

    确定程序语义是合法的、符合逻辑的。

  • 符号引用验证

    发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段——解析阶段中发生

    可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验,如类的全限定名是否存在、描述符与字段是否存在、方法访问权限是否合法等。

    目的:确保解析动作能正常执行

准备

类变量是被 static 修饰的变量,准备阶段是正式为类变量分配内存并设置初始值的阶段,使用的是方法区的内存。

实例变量不会在这阶段分配内存,它会在对象实例化时随着对象一起被分配在堆中。应该注意到,实例化不是类加载的一个过程,类加载发生在所有实例化操作之前,并且类加载只进行一次,实例化可以进行多次。

初始值一般为 0 值,例如下面的类变量 value 被初始化为 0 而不是 123,因此此时尚未执行任何 Java 方法,赋值语句存放在类构造器 <clinit> 中。

public static int value = 123;

如果类变量是常量,即类字段的字段属性中存在 ConstantValue 属性,那么该常量将初始化为表达式所定义的值而不是 0。例如下面的常量 value 被初始化为 123 而不是 0。

public static final int value = 123;

解析

解析阶段是虚拟机将常量池的符号引用替换为直接引用的过程。

其中,解析过程在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 的动态绑定。

  • 符号引用

    以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可,其形式在 Java 虚拟机规范的 Class 文件格式中被明确定义,因此各虚拟机能接受的符号引用必须一致。

    符号引用与虚拟机实现的内存布局无关,引用的目标不一定已经加载到内存中。同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。

  • 直接引用

    可以是指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。

    直接引用与虚拟机实现的内存布局相关,如果存在直接引用,那么引用的目标必定已经在内存中存在。

发生时间

解析阶段发生的具体时间并未规定,可根据需要来判断是在类被加载时就进行解析还是等到符号引用将要被使用前才去解析。

  • 对于 invokedynamic 指令,其所对应的引用称为 “动态调用点限定符” ,此处动态的含义即为必须等到程序实际运行到这条指令时解析才能进行。
  • 除了动态绑定的指令 invokedynamic 之外,其余可触发解析的指令都是静态的,可在刚刚完成加载阶段还没有开始执行代码时进行。

重复解析

除了动态绑定的指令 invokedynamic 之外,当对同一个符号引用进行多次解析请求时,虚拟机实现可以对第一次解析的结果进行缓存,在运行时常量池中记录直接引用并把常量标识为已解析状态。

初始化

初始化阶段才真正开始执行类中定义的 Java 程序代码(字节码)。初始化阶段是虚拟机执行类构造器 <clinit>() 方法的过程,且仅执行一次。(实例初始化方法为 < init >())在准备阶段,类变量已经赋过一次系统要求的初始值,而在初始化阶段,根据程序员通过程序制定的主观计划去初始化类变量和其它资源。

<clinit>() 是由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序由语句在源文件中出现的顺序决定。如果一个类中没有对静态变量赋值也没有静态语句块,那么编译器可以不为这个类生成 < clinit >() 方法。

特别注意的是,静态语句块只能访问到定义在它之前的类变量,定义在它之后的类变量只能赋值,不能访问。例如以下代码:

public class Test {
    static int b;			  // 自动初始化为 0
    static {
        i = 0;                // 给变量赋值可以正常编译通过
        System.out.print(i);  // 这句编译器会提示“非法向前引用”
        
        int a = 1;			  // 需要初始化
        System.out.print(a);  // 正常访问
        System.out.print(b);  // 正常访问
    }
    static int i = 1;
}

虚拟机会保证在子类的 <clinit>() 方法执行之前,父类的 <clinit>() 方法已经执行完毕,也就意味着父类中定义的静态语句块的执行要优先于子类。

在虚拟机中第一个被执行的 <clinit>() 方法的类肯定是 java.lang.Object。

例如以下代码:

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
}

接口中不可以使用静态语句块,但仍然有类变量初始化的赋值操作,因此接口与类一样都会生成 <clinit>() 方法。但接口与类不同的是,执行接口的 <clinit>() 方法不需要先执行父接口的 <clinit>() 方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的 <clinit>() 方法。

虚拟机会保证一个类的 <clinit>() 方法在多线程环境下被正确的加锁和同步,如果多个线程同时初始化一个类,只会有一个线程执行这个类的 <clinit>() 方法,其它线程都会阻塞等待,直到活动线程执行 <clinit>() 方法完毕。如果在一个类的 <clinit>() 方法中有耗时的操作,就可能造成多个线程阻塞,在实际过程中此种阻塞很隐蔽。

类初始化时机

主动引用

虚拟机规范中并没有强制约束何时进行加载,但是规范严格规定了有且只有下列五种情况必须对类进行初始化(加载、验证、准备需要在此之前开始):

  • 遇到 new、getstatic、putstatic、invokestatic 这四条字节码指令时,如果类没有进行过初始化,则必须先触发其初始化。最常见的生成这 4 条指令的场景是:使用 new 关键字实例化对象的时候;读取或设置一个类的静态字段(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)的时候;以及调用一个类的静态方法的时候。

  • 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行初始化,则需要先触发其初始化。

  • 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

    接口初始化时不要求其父接口全部完成初始化。仅在真正使用到父接口(引用父接口中定义的常量)时才会对父接口进行初始化。

  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main () 方法的那个类),虚拟机会先初始化这个主类。

  • 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic, REF_putStatic, REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

被动引用

以上 5 种场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。被动引用的常见例子包括:

  • 通过子类引用父类的静态字段,不会导致子类初始化。
System.out.println(SubClass.value);  // value 字段在 SuperClass 中定义
  • 通过数组定义来引用类,不会触发此类的初始化。该过程会对数组类进行初始化,数组类是一个由虚拟机自动生成的、直接继承自 Object 的子类,其中包含了数组的属性和方法(安全性在于该类封装了数组元素的访问方法,越界等情况会抛出异常,而 C++ 直接翻译为对数组指针的移动)。
SuperClass[] sca = new SuperClass[10];

  • 常量在编译阶段会存入调用类的常量池中(编译阶段常量传播优化),本质上并没有直接引用到定义常量的被调用类,而是被转化为调用类对自身常量池的引用,实际调用类的 Class 文件中并没有对被调用类(ConstClass)的符号引用,因此不会触发定义常量的类的初始化。
System.out.println(ConstClass.HELLOWORLD);

其他:

  • 通过类名获取 Class 对象,不会触发类的初始化
  • 通过 Class.forName 加载指定类时,如果指定参数 initialize 为 false 时,也不会触发类初始化
  • 通过 ClassLoader 默认的 loadClass 方法,也不会触发初始化动作

类加载器

定义:实现 “通过一个类的全限定名来获取描述此类的二进制字节流” 动作的代码模块。

类与类加载器

两个类相等,需要类本身相等,并且使用同一个类加载器进行加载。这是因为每一个类加载器都拥有一个独立的类名称空间。

这里的相等,包括类的 Class 对象的 equals () 方法、isAssignableFrom () 方法、isInstance () 方法的返回结果为 true,也包括使用 instanceof 关键字做对象所属关系判定结果为 true。

public class ClassLoaderTest{
	public static void main(String[]args)throws Exception{
		ClassLoader myLoader=new ClassLoader(){
		@Override
		public Class<?loadClass(String name)throws ClassNotFoundException{
			try {
				String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
				InputStream is = getClass().getResourceAsStream(fileName);
				if(is == null){
					return super.loadClass(name);
				} byte[]b = new byte[is.available()];
				is.read(b);
				return defineClass(name,b,0,b.length);
				} catch(IOException e) {
					throw new ClassNotFoundException(name);
				}
	        } 
        };
		Object obj = myLoader.loadClass("ClassLoaderTest").newInstance();
		System.out.println(obj.getClass());
		// class ClassLoaderTest
		System.out.println(obj instanceof ClassLoaderTest);
		// false 
    }
}

虚拟机中存在了两个 ClassLoaderTest 类,一个是由系统应用程序类加载器加载的,另外一个是由自定义的类加载器加载的,虽然都来自同一个 Class 文件,但依然是两个独立的类,做对象所属类型检查时结果自然为 false 。

类加载器分类

从 Java 虚拟机的角度来讲,只存在以下两种不同的类加载器:

  • 启动类加载器(Bootstrap ClassLoader):使用 C++ 实现,是虚拟机自身的一部分;
  • 所有其它类的加载器:使用 Java 实现,独立于虚拟机,继承自抽象类 java.lang.ClassLoader。

从 Java 开发人员的角度看,类加载器可以划分得更细致一些:

  • 启动类加载器(Bootstrap ClassLoader):

    此类加载器负责将存放在 <JRE_HOME>\lib 目录中的,或者被 -Xbootclasspath 参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机内存中。启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,直接使用 null 代替即可。

  • 扩展类加载器(Extension ClassLoader)

    这个类加载器是由 ExtClassLoader(sun.misc.Launcher$ExtClassLoader)实现的。它负责将 <JAVA_HOME>/lib/ext 或者被 java.ext.dir 系统变量所指定路径中的所有类库加载到内存中,开发者可以直接使用扩展类加载器。

  • 应用程序类加载器(Application ClassLoader)

    这个类加载器是由 AppClassLoader(sun.misc.Launcher$AppClassLoader)实现的。由于这个类加载器是 ClassLoader 中的 getSystemClassLoader () 方法的返回值,因此一般称为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

双亲委派模型

应用程序是由三种类加载器互相配合从而实现类加载,除此之外还可以加入自己定义的类加载器。

下图展示了类加载器之间的层次关系,称为双亲委派模型(Parents Delegation Model)。该模型要求除了顶层的启动类加载器外,其它的类加载器都要有自己的父类加载器。这里的父子关系一般通过组合关系(Composition)来实现,而不是继承关系(Inheritance)。
在这里插入图片描述

工作过程

一个类加载器首先将类加载请求转发到父类加载器,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中。只有当父类加载器无法完成(它的搜索范围中没有找到所需的类)时,子加载器才尝试自己加载。
在这里插入图片描述

优点

使得 Java 类随着它的类加载器一起具有一种带有优先级的层次关系,从而使得基础类得到统一。

例如 java.lang.Object 存放在 rt.jar 中,如果编写另外一个 java.lang.Object 并放到 ClassPath 中,程序可以编译通过。由于双亲委派模型的存在,所以在 rt.jar 中的 Object 比在 ClassPath 中的 Object 优先级更高,这是因为 rt.jar 中的 Object 使用的是启动类加载器,而 ClassPath 中的 Object 使用的是应用程序类加载器。rt.jar 中的 Object 优先级更高,那么程序中所有的 Object 都是这个 Object。

实现

以下是抽象类 java.lang.ClassLoader 的代码片段,其中的 loadClass () 方法运行过程如下:先检查类是否已经加载过,如果没有则让父类加载器去加载。若父加载器为空则默认使用启动类加载器作为父加载器。当父类加载器加载失败时抛出 ClassNotFoundException,此时尝试自己去加载。

public abstract class ClassLoader {
    // 用于委派的父加载器
    private final ClassLoader parent;

    public Class<?> loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false);
    }

    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        synchronized (getClassLoadingLock(name)) {
            // 首先检查请求的类是否已经被加载过了
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // 如果父加载器抛出 ClassNotFoundException 
                    // 说明父加载器无法完成加载请求
                }

                if (c == null) {
                    // 在父加载器无法完成加载的时候
                    // 调用本身的 findClass 方法来进行类加载
                    c = findClass(name);
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

    protected Class<?> findClass(String name) throws ClassNotFoundException {
        throw new ClassNotFoundException(name);
    }
}

问题

1. 向前兼容

**实例:**双亲委派模型在 JDK 1.2 之后才被引入,而类加载器和抽象类 java.lang.ClassLoader 则在 JDK 1.0 时代就已经存在。

**解决:**JDK 1.2 之后已不提倡用户再去覆盖 loadClass() 方法,而应当把自己的类加载逻辑写到 findClass() 方法中,在 loadClass() 方法的逻辑里如果父类加载失败,则会调用自己的 findClass() 方法来完成加载,这样就可以保证新写出来的类加载器是符合双亲委派规则的。

2. 基础类调用回用户代码的情况

**实例:**JNDI 服务。JNDI 服务的代码由启动类加载器去加载(在 JDK 1.3 时放进去的 rt.jar ),但 JNDI 的目的
就是对资源进行集中管理和查找,它需要调用由独立厂商实现并部署在应用程序的 ClassPath 下的 JNDI 接口提供者( SPI, Service Provider Interface )的代码,但是启动类加载器无法识别这些代码。

解决:引入线程上下文类加载器,其可通过 java.lang.Thread 类的 setContextClassLoader() 方法进行设置。如果创建线程时还为设置,其会从父线程中继承一个。如果应用程序全局范围内均未设置,则默认是应用程序类加载器。JNDI 服务使用这个线程上下文类加载器去加载所需要的 SPI 代码,也就是父类加载器请求子类加载器去完成类加载的动作,这种行为实际上打通了双亲委派模型的层次结构来逆向使用类加载器。

Java 中所有涉及 SPI 的加载动作基本上都采用这种方式,例如 JNDI、JDBC、JCE、JAXB 和 JBI 等。

3. 程序动态性

此处动态性指:代码热替换、模块热部署等,即无需重启应用程序即可进行替换、升级代码或模块等操作。

解决: OSGi 模块化标准。OSGi 实现模块化热部署的关键则是它自定义的类加载器机制的实现。每一个程序模块( OSGi 中称为 Bundle )都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。会带来额外的高复杂度。

在 OSGi 环境下,类加载器不再是双亲委派模型中的树状结构,而是进一步发展为更加复杂的网状结构。查找顺序如下:

  1. 将以 java.* 开头的类委派给父类加载器加载
  2. 否则,将委派列表名单内的类委派给父类加载器加载
  3. 否则,将 Import 列表中的类委派给 Export 这个类的 Bundle 的类加载器加载
  4. 否则,查找当前 Bundle 的 ClassPath,使用自己的类加载器加载
  5. 否则,查找类是否在自己的 Fragment Bundle 中,如果在,则委派给 Fragment Bundle 的类加载器加载
  6. 否则,查找 Dynamic Import 列表的 Bundle,委派给对应 Bundle 的类加载器加载
  7. 否则,类查找失败

上面的查找顺序中只有开头两点仍然符合双亲委派规则,其余的类查找都是在平级的类加载器中进行的。

自定义类加载器实现

以下代码中的 FileSystemClassLoader 是自定义类加载器,继承自 java.lang.ClassLoader,用于加载文件系统上的类。它首先根据类的全名在文件系统上查找类的字节代码文件(.class 文件),然后读取该文件内容,最后通过 defineClass () 方法来把这些字节代码转换成 java.lang.Class 类的实例。

java.lang.ClassLoader 的 loadClass () 实现了双亲委派模型的逻辑,自定义类加载器一般不去重写它,但是需要重写 findClass () 方法。

public class FileSystemClassLoader extends ClassLoader {

    private String rootDir;

    public FileSystemClassLoader(String rootDir) {
        this.rootDir = rootDir;
    }

    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] classData = getClassData(name);
        if (classData == null) {
            throw new ClassNotFoundException();
        } else {
            return defineClass(name, classData, 0, classData.length);
        }
    }

    private byte[] getClassData(String className) {
        String path = classNameToPath(className);
        try {
            InputStream ins = new FileInputStream(path);
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            int bufferSize = 4096;
            byte[] buffer = new byte[bufferSize];
            int bytesNumRead;
            while ((bytesNumRead = ins.read(buffer)) != -1) {
                baos.write(buffer, 0, bytesNumRead);
            }
            return baos.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

    private String classNameToPath(String className) {
        return rootDir + File.separatorChar
                + className.replace('.', File.separatorChar) + ".class";
    }
}


参考:

https://cyc2018.github.io/CS-Notes/#/notes/Java 虚拟机

《深入理解 Java 虚拟机:JVM 高级特性与最佳实践》

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值