JVM 分为三个部分:类加载器,执行引擎,运行时数据区。
运行时数据区
运行时数据区由五个部分组成:程序计数器、栈、本地方法栈、堆、方法区。
程序计数器
线程私有,记录当前线程正在执行的虚拟机字节码指令的地址(如果正在执行的是本地方法则为空)。
栈
线程私有,线程执行 Java 方法的同时会创建一个栈帧,用于存储局部变量表、操作数栈、常量池引用等信息。从方法调用直至执行完成的过程,对应着一个栈帧在栈中入栈和出栈的过程。
本地方法栈
线程私有,与栈类似,它们之间的区别只不过是本地方法栈为本地方法服务。
本地方法一般是用其它语言(C、C++ 或汇编语言等)编写的,并且被编译为基于本机硬件和操作系统的程序,对待这些方法需要特别处理。
堆
线程共享,所有对象都在这里分配内存。
方法区/元空间
线程共享,用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据(从 JDK 1.8 开始,常量池和静态变量被移动到堆区)。
方法区是一个 JVM 规范,永久代与元空间都是其一种实现方式。从 JDK 1.8 开始,移除永久代,并把方法区移至元空间,它位于本地内存中,而不是虚拟机内存中。
运行时常量池
Class 文件中的常量(编译器生成的字面量和符号引用)会在类加载后被放入这个区域。
除了在编译期生成的常量,还允许动态生成,例如 String 类的 intern() 。
直接内存
直接内存并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。
在 JDK 1.4 中新引入了 NIO(New Input/Output) 类,它可以使用 Native 函数库直接分配堆外内存,然后通过 Java 堆里的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在堆内存和堆外内存来回拷贝数据。
垃圾收集
垃圾收集主要是针对堆和方法区进行。
程序计数器、虚拟机栈和本地方法栈这三个区域属于线程私有的,只存在于线程的生命周期内,线程结束之后就会消失,因此不需要对这三个区域进行垃圾回收。
判断一个对象是否可回收
引用计数法
为对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收。
缺点:无法回收循环引用的对象,因此 Java 虚拟机不使用引用计数法。
可达性分析法
以 GC Roots 为起始点进行搜索,可达的对象都是存活的,不可达的对象可被回收。
可达性分析法可以回收循环引用的对象,因此 Java 虚拟机采用的是该算法。
GC Roots 一般包含以下内容:
- 栈中局部变量表中引用的对象;
- 本地方法栈中 JNI 中引用的对象;
- 静态变量引用的对象;
- 常量引用的对象。
方法区的回收
在 JDK1.8 以前,方法区用于存放永久代对象,而永久代对象的回收率比新生代低很多,所以在方法区上进行回收性价比不高。
主要是对常量池的回收和对类的卸载,在大量使用反射和动态代理的场景都需要虚拟机具备类卸载功能。
类的卸载条件很多,需要满足以下三个条件,并且满足了条件也不一定会被卸载:
- 该类所有的实例都已经被回收,此时堆中不存在该类的任何实例;
- 加载该类的 ClassLoader 已经被回收;
- 该类对应的 Class 对象没有在任何地方被引用,也就无法在任何地方通过反射访问该类方法。
finalize()
一个对象被判定可回收时,若其重写了 finalize 方法,JVM 在回收这个对象前,就会调用 finalize 方法
如果在 finalize 方法中将自己引用到 GC ROOTS 链上,那么就有可能实现自救。
自救只能进行一次,因为 JVM 会对调用过 finalize 方法的对象做一个标记,当下次回收的时候不会再调用它的 finalize 方法,也就是 finalize 方法只会被调用一次。
并且,finalize 方法的调用具有不确定性,JVM 只保证 finalize 方法会被调用,不保证它会被执行完。如果对象在自救的过程中堵塞,就会被回收掉。
引用类型
无论是通过引用计数法还是可达性分析法,判定对象是否可被回收都与引用有关。
Java 提供了四种强度不同的引用类型。
强引用
强引用就是最常见的引用,被强引用关联的对象不会被回收。
软引用
被软引用关联的对象,只有在内存不够的情况下才会被回收。
弱引用
被弱引用关联的对象只要发生了垃圾回收就会被回收。
虚引用
又称为幽灵引用或者幻影引用,一个对象是否有虚引用的存在,不会对其生存时间造成影响,也无法通过虚引用得到一个对象。
为一个对象设置虚引用的唯一目的是能在这个对象被回收时收到一个系统通知。
垃圾收集算法
标记 - 清除算法
首先标记出所有可回收的对象,在标记完成后统一回收。
缺点:
- 标记和清除效率不高;
- 清除后会产生大量不连续的内存碎片,这会导致无法给大对象分配内存。
复制算法
将可用内存按容量划分为相等的两块,每次只使用其中的一块,当这一块内存用完了,就将还存活的对象复制到另外一块上,再把这块内存清理掉。
缺点是让可使用的内存空间减少了一半。
现在的商业虚拟机都采用这种收集算法回收新生代,但是并不是划分为大小相等的两块,而是一块较大的 Eden 区和两块较小的 Survivor 区,每次使用 Eden 和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象全部复制到另一块 Survivor 上,最后清理 Eden 和使用过的那一块 Survivor。
HotSpot 虚拟机的 Eden 和 Survivor 大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 就不够用了,此时需要依赖于老年代进行空间分配担保,也就是借用老年代的空间存储放不下的对象。
标记 - 整理算法
根据老年代的特点提出的一种算法。在标记-清除算法的基础之上,将存活的对象全部移动到一端,消除了标记-清理算法产生的内存碎片。
缺点是要移动对象,效率比较低。
分代收集算法
现在的商业虚拟机采用分代收集算法,它根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。
一般将堆分为新生代和老年代。
- 新生代使用:复制算法
- 老年代使用:标记 - 清除 或者 标记 - 整理 算法
垃圾收集器

以上是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。
- 单线程与多线程:单线程指的是垃圾收集器只使用一个线程,而多线程使用多个线程;
- 串行与并行:串行指的是垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序;并行指的是垃圾收集器和用户程序同时执行。
除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行。
Serial 收集器
它是单线程的收集器,以串行的方式进行新生代的垃圾收集工作。
它的优点是简单高效,在单个 CPU 环境下,由于没有线程交互的开销,因此拥有最高的单线程收集效率。
它是 Client 场景下的默认新生代收集器,因为在该场景下内存一般来说不会很大。它收集一两百兆垃圾的停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿时间是可以接受的。
ParNew 收集器
它是 Serial 收集器的多线程版本。
它是 Server 场景下默认的新生代收集器,除了性能原因外,主要是因为除了 Serial 收集器,只有它能与 CMS 收集器配合使用。
Parallel Scavenge 收集器
多线程新生代收集器,它的目标是达到一个可控制的吞吐量,因此也被称为“吞吐量优先”收集器(吞吐量指 CPU 用于运行用户程序的时间占总时间的比值)。
高吞吐量可以高效率地利用 CPU 时间,尽快完成程序的运算任务,适合在后台运算而不需要太多交互的任务。
缩短停顿时间是以牺牲吞吐量和新生代空间来换取的:新生代空间变小,垃圾回收变得频繁,导致吞吐量下降。
比如本来是每 1000ms 一次垃圾回收停 100ms,吞吐量 91%;为了降低停顿时间,变成每 500ms 一次垃圾回收停 80ms,吞吐量 86% 。停顿时间虽然降低了,但是吞吐量下降了。
Serial Old 收集器
是 Serial 收集器的老年代版本,也是给 Client 场景下的虚拟机使用
如果用在 Server 场景下,它有两大用途:
- 在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用。
- 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。
Parallel Old 收集器
是 Parallel Scavenge 收集器的老年代版本。
在注重吞吐量以及 CPU 资源敏感的场合,可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。
CMS 收集器
CMS(Concurrent Mark Sweep,并行标记清除)是一个多线程并行老年代收集器。分为以下四个流程:
- 初始标记:仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,需要停顿用户程序;
- 并发标记:进行 GC Roots Tracing 的过程,它在整个回收过程中耗时最长,不需要停顿用户程序;
- 重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿用户程序;
- 并发清除:不需要停顿。
在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。
CMS 收集器具有以下缺点:
- 吞吐量低:低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率不够高。
- 无法处理浮动垃圾,可能出现 Concurrent Mode Failure。浮动垃圾是指并发清除阶段由于用户线程继续运行而产生的垃圾,这部分垃圾只能到下一次 GC 时才能进行回收。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。
- 标记 - 清除算法导致的空间碎片,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。
G1 收集器
G1(Garbage-First)收集器是一款面向服务端应用的垃圾收集器,在多 CPU 和大内存的场景下有很好的性能,可以直接对新生代和老年代一起回收。
G1 把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离,每个 Region 可以单独进行垃圾回收。
这种划分方法带来了很大的灵活性,使得可预测的停顿时间模型成为可能。
通过记录每个 Region 的垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得)维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region。
每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描。
如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:
- 初始标记;
- 并发标记;
- 最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行(指开多个线程执行);
- 筛选回收:首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。
具备如下特点:
- 空间整合:整体来看是基于标记 - 整理算法实现的收集器,从局部上来看是基于复制算法实现的,这意味着运行期间不会产生内存空间碎片;
- 可预测的停顿:能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒。
内存分配与回收策略
Minor GC 和 Full GC
- Minor GC:回收新生代,因为新生代对象存活时间很短,因此 Minor GC 会频繁执行,执行的速度一般也会比较快。
- Full GC:回收老年代和新生代,老年代对象其存活时间长,因此 Full GC 很少执行,执行速度会比 Minor GC 慢很多。
内存分配策略
对象优先在 Eden 分配
大多数情况下,对象在新生代 Eden 上分配,当 Eden 空间不够时,发起 Minor GC。
大对象直接进入老年代
大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。
JVM 设置了一个阈值,让大于此阈值的大对象直接在老年代上分配,避免在 Eden 和 Survivor 之间的大量内存复制。
经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。
长期存活的对象进入老年代
为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄设定为 1 。每熬过一次 Minor GC ,年龄就增加 1。增加到一定年龄(默认 15)则移动到老年代中。
动态对象年龄判定
虚拟机并不是永远要求对象的年龄必须达到某个值才能晋升老年代。
如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需等到要求的年龄。
空间分配担保
在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的。
如果不成立的话虚拟机会查看 HandlePromotionFailure 的值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 的值不允许冒险,那么就要进行一次 Full GC。
Full GC 的触发条件
当 Eden 空间满时,就触发一次 Minor GC。
而 Full GC 则相对复杂,有以下条件:
调用 System.gc()
只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。
老年代空间不足
老年代空间不足的常见场景为:大对象直接进入老年代、长期存活的对象进入老年代等。
为了避免以上原因引起的 Full GC,应当
- 尽量不要创建过大的对象以及数组;
- 调大新生代的大小,让对象尽量在新生代被回收掉;
- 调大进入老年代的年龄,让对象在新生代多存活一段时间。
空间分配担保失败
Minor GC 在 survivor 区不足时需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC。
JDK 1.7 及以前的永久代空间不足
在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。
当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么虚拟机会抛出 java.lang.OutOfMemoryError。
为避免以上原因引起的 Full GC,可采用的方法为增大永久代空间。
Concurrent Mode Failure
执行 CMS GC 的过程中同时会有对象要放入老年代,而此时老年代空间不足(可能是 GC 过程中浮动垃圾过多导致暂时性的空间不足),便会报 Concurrent Mode Failure 错误,并触发 Full GC。
类加载机制
类是在运行期间第一次使用时动态加载的,而不是一次性加载所有类。因为如果一次性加载,那么会占用很多的内存。
类的生命周期

包括以下 7 个阶段:
- 加载
- 验证
- 准备
- 解析
- 初始化
- 使用
- 卸载
类加载过程
包含了加载、验证、准备、解析和初始化这 5 个阶段。
加载
加载是类加载的一个阶段,注意不要混淆。
加载过程完成以下三件事:
- 通过类的完全限定名称获取定义该类的二进制字节流;
- 将该字节流表示的静态存储结构转换为方法区的运行时存储结构;
- 在内存中生成一个代表该类的 Class 对象,作为方法区中该类各种数据的访问入口。
其中二进制字节流可以从以下方式中获取:
- 从 ZIP 包读取,成为 JAR、EAR、WAR 格式的基础。
- 从网络中获取,最典型的应用是 Applet。
- 运行时计算生成,例如动态代理技术,在 java.lang.reflect.Proxy 使用 ProxyGenerator.generateProxyClass 的代理类的二进制字节流。
- 由其他文件生成,例如由 JSP 文件生成对应的 Class 类。
验证
确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
准备
为静态变量分配内存并设置初始值。
初始值一般为 0 值,例如下面的类变量 value 被初始化为 0 而不是 123。
1 | public static int value = 123; |
如果类变量是常量,那么它将初始化为表达式所定义的值而不是 0。例如下面的常量 value 被初始化为 123 而不是 0。
1 | public static final int value = 123; |
解析
将常量池的符号引用替换为直接引用。
解析过程在某些情况下可以在初始化阶段之后再开始,这是为了支持 Java 的动态绑定。
初始化
初始化阶段才真正开始执行类中定义的 Java 程序代码。
初始化阶段是虚拟机执行类构造器 <clinit>() 方法的过程。
在准备阶段,静态变量已经赋过一次系统要求的初始值,而在初始化阶段,会根据程序制定的计划去初始化静态变量和其它资源。
类构造器 <clinit>() 方法是由编译器自动收集类中所有 静态变量的赋值动作 和 静态语句块中的语句 合并产生的,编译器收集的顺序由语句在源文件中出现的顺序决定。
需要注意的是,静态语句块只能访问到定义在它之前的类变量,定义在它之后的类变量只能赋值,不能访问
1 | public class Test { |
由于父类的 <clinit>()
方法先执行,也就意味着父类中定义的静态语句块的执行要优先于子类:
1 | static class Parent { |
接口中不可以使用静态语句块,但仍然有静态变量初始化的赋值操作,因此接口与类一样都会生成 <clinit>() 方法。
但接口与类不同的是,执行接口的 <clinit>() 方法不需要先执行父接口的 <clinit>() 方法。只有当父接口中定义的变量使用时,父接口才会初始化。
另外,接口的实现类在初始化时也一样不会执行接口的 <clinit>() 方法。
虚拟机会保证一个类的 <clinit>() 方法在多线程环境下被正确的加锁和同步。
如果多个线程同时初始化一个类,只会有一个线程执行这个类的 <clinit>() 方法,其它线程都会阻塞等待,直到活动线程执行 <clinit>() 方法完毕。
如果在一个类的 <clinit>() 方法中有耗时的操作,就可能造成多个线程阻塞,在实际过程中此种阻塞很隐蔽。
类初始化时机
主动引用
虚拟机规范中并没有强制约束何时进行加载,但是规范严格规定了有且只有下列五种情况必须对类进行初始化(加载、验证、准备都会随之发生):
- 遇到 new、getstatic、putstatic、invokestatic 这四条字节码指令时,如果类没有进行过初始化,则必须先触发其初始化。最常见的生成这 4 条指令的场景是:使用 new 关键字实例化对象的时候;读取或设置一个类的静态变量(被 final 修饰、已在编译期把结果放入常量池的静态字段除外)的时候;以及调用一个类的静态方法的时候;
- 对类进行反射调用的时候,如果类没有进行初始化,则需要先触发其初始化;
- 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化;
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类;
- 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic, REF_putStatic, REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化;
被动引用
以上 5 种场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。被动引用的常见例子包括:
- 通过子类引用父类的静态字段,不会导致子类初始化;
- 通过数组定义来引用类,不会触发此类的初始化。该过程会对数组类进行初始化,数组类是一个由虚拟机自动生成的、直接继承自 Object 的子类,其中包含了数组的属性和方法;
- 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
类加载器
虚拟机设计团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到 JVM 外部去实现,以便让应用程序自己决定如何去获得想要的类,实现这个动作的代码模块称为“类加载器”。
类与类加载器
类加载器虽然只是实现类的加载动作,但它在 Java 中起到的作用却远远不限于类加载阶段。对于任意一个类,都需要加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。
通俗地说,比较两个类是否相等,只有在两个类是由同一个类加载器加载的前提下才有意义。否则即使这两个类来源于同一个 class 文件,只要加载它们的类加载器不同,那么这两个类就一定不相等。
这里的相等,包括类的 Class 对象的 equals() 方法、isAssignableFrom() 方法、isInstance() 方法的返回结果为 true,也包括使用 instanceof 关键字做对象所属关系判定结果为 true。
类加载器分类
从 Java 虚拟机的角度来讲,只存在两种不同的类加载器:
- 启动类加载器,使用 C++ 实现,是虚拟机自身的一部分;
- 所有其它类的加载器,使用 Java 实现,独立于虚拟机,继承自抽象类 java.lang.ClassLoader。
从 Java 开发人员的角度看,类加载器可以划分得更细致一些:
- 启动类加载器(Bootstrap ClassLoader):这个类加载器负责将放在<JAVA_HOME>\lib 目录中的,或者被 -Xbootclasspath 参数所指定的路径中的,并且是虚拟机识别的类库加载到虚拟机内存中;
- 扩展类加载器(Extension ClassLoader):这个类加载器是由 ExtClassLoader 实现的。它负责将 <JAVA_HOME>\lib\ext 或者被 java.ext.dir 系统变量所指定路径中的所有类库加载到内存中,开发者可以直接使用扩展类加载器;
- 应用程序类加载器(Application ClassLoader):这个类加载器是由 AppClassLoader 实现的。由于这个类加载器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,所以一般也它称为系统类加载器。它负责加载用户类路径(ClassPath)上所指的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。
双亲委派模型
应用程序是由三种类加载器互相配合从而实现类加载,除此之外还可以加入自己定义的类加载器。
下图展示了类加载器之间的层次关系,称为双亲委派模型(Parents Delegation Model)。该模型要求除了顶层的启动类加载器外,所有的类加载器都要有自己的父类加载器。这里的父子关系一般通过组合关系来实现而不是继承关系。

工作过程
一个类加载器首先将类加载请求转发到父类加载器,只有当父类加载器无法完成时才尝试自己加载。
好处
使得 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,此时再调用 findClass 方法尝试自己去加载。
1 | public abstract class ClassLoader { |
破坏双亲委派模型
双亲委派模型并不是一个强制性的约束模型,只是 Java 设计者推荐给开发者的类加载器实现方式。到目前为止,双亲委派模型主要出现过 3 次较大规模的“破坏”。
重写 loadClass 方法
第一次发生在双亲委派模型出现之前 —— 即 JDK1.2 发布之前。由于在该模型引入之前,java.lang.ClassLoader 就已经存在,面对已经存在的用户自定义类加载器的实现代码,Java 设计者引入双亲委派模型时不得不做出一些妥协。为了向前兼容,JDK1.2 以后才添加了 findClass 方法。然而在此之前用户不得不去重写 loadClass 方法。
使用线程上下文类加载器
第二次是由于模型的本身缺陷所导致的。双亲委派模型很好的解决了基础类的统一问题,基础类之所以称为基础,是因为它们总是被当作用户调用的 API ,但是它并没有考虑若基础类要调用户的代码该怎么办。
一个典型的例子是 JNDI 服务,它的代码由启动类加载器去加载,但 JNDI 的目的就是对资源进行集中管理和查找,它需要调用部署在 CLASSPATH 下的 JNDI 接口提供者,但启动类加载器不可能“认识”这些代码。为了解决这个问题,Java 设计团队只好采用了一个不太优雅的设计:线程上下文类加载(Thread Context ClassLoader)。这个类加载器可以通过 Thread 类的 setContextClassLoader() 方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序全局都未设置过,默认就是应用类加载器。
有了线程上下文类加载器,JNDI 就可以去使用它去加载所需要的 SPI(service provider interface) 代码,也就是父类加载器请求子类加载器去加载。它破坏了双亲委派模型的一般性原则,但这也是无可奈何的事。
OSGI
第三次是用户对程序动态性的追求所导致的,如代码热替换、模块热部署等。简单点说就是希望程序也像计算机的鼠标键盘等外设一样,不用重启计算机也能使用。
目前,OSGI 已经称为了业界“事实上”的 Java 模块化标准,而 OSGI 实现模块化热部署的关键则是自己的类加载机制的实现。
OSGI 中,每一个程序模块(Bundle)都有一个自己的类加载器,当需要更换一个 Bundle 时,就把 Bundle 连同自己的类加载器一起换掉以实现代码的热替换。在此环境下,类加载器不再时双亲委派模型的树状结构,而是发展成了更复杂网状结构。当收到类加载请求时,OSGI 按以下的顺序进行搜索
- 以 java.util.* 开头的类委派给父类加载器加载;
- 否则,将委派列表名单内的类委派给父类加载器加载;
- 否则,将 import 列表中的类委派给 Export 这个类的 Bundle 的类加载器加载;
- 否则,查找当前 Bundle 的 classpath,使用自己的类加载器加载;
- 否则,查找类是否在自己的 Fragment Bundle 中,如果在则委派给 Fragment Bundle 的类加载器加载;
- 否则,查找 Dynamic Import 列表中的 Bundle,委派给对应的 Bundle 的类加载器加载;
- 否则,类查找失败。
上面的查找顺序除了开头两点符合双亲委派模型,其余的类查找都是在平级的类加载器中进行的。