深入理解JVM

317 阅读28分钟

Java虚拟机内存分区

  • 程序计数器 是一块较小的空间,存储当前线程所执行的字节码的行号指示器。字节码解释器根据它来选取下一条要执行的字节码指令。分支、循环、跳转、异常处理都依赖于这个计数器完成。每个线程有独立的计数器。 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。

  • 本地方法栈 和Java虚拟机栈类似,为虚拟机使用到的Native方法服务。是线程独有的。

  • Java虚拟机栈

    线程私有的,生命周期与线程相同。每个方法在执行的同时会创建一个栈帧,存储局部变量表,操作数栈,动态链接,方法返回地址等信息。方法的调用与执行结束,对应着该栈帧在虚拟机栈中的入栈和出栈栈上分配的局部变量表所需的内存空间在编译器就确定了,运行期不会改变局部变量表的大小。 分配内存时只要将栈顶指针进行移动就可以,效率比较高。 具体例子:

  • JAVA堆 所有线程共享的一块内存区域,也是JVM所管理的内存中最大的一块。在虚拟机启动时创建所有的对象实例和数组都要在堆上分配, 但随着JIT技术的发展,也有可能产生栈上分配和标量替换。 是垃圾收集器管理的主要区域,也称为GC堆,可分为新生代和老年代。再细一点还有EdenFrom SurvivorTo Survivor空间。 Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。 Java堆的大小既可以设置成固定大小的,也可以设置成可拓展的(-Xmx/-Xms控制)。

  • 方法区线程共享的,存储已经被虚拟机加载的类信息、常量、静态变量、JIT编译后的代码等数据。 其中常量存储在运行时常量池中。 是Java堆的一个逻辑部分。 并不是预置入Class文件中常量池的内容才会进入方法区运行时常量池,运行期间也有可能将新的常量放入池中,例如String类的intern()方法。 这部分的内存回收主要是针对常量池的回收和对类型的卸载。这部分的回收效果很差,尤其是对类型的卸载,条件相当苛刻。

  • 直接内存 直接内存是Java虚拟机之外的内存。 在NIO中引入了一种基于通道和缓冲的IO方式。它可以通过调用本地方法直接分配Java虚拟机之外的内存,然后通过一个存储在Java堆中的DirectByteBuffer对象直接操作该内存,这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

对象创建过程

当虚拟机遇到一条含有new的指令时,会进行一系列对象创建的操作:

  1. 检查这条指令的参数是否能够在常量池中定位到一个类的符号引用。
  2. 检查这个符号引用有没有被加载、解析和初始化,如果没有,就先执行类的加载过程
  3. 类加载检查通过后,虚拟机为该新生对象分配内存,大小在类加载完成后就可以完全确定。
  4. 分配内存时,根据Java堆是否规整决定分配方式,规整的Java堆采用指针碰撞。不规整就维护空闲链表。
  5. 分配空间时要考虑多线程操作。常用的同步策略是CAS配上失败重试保证更新操作原子性。或者把内存分配的动作按照线程划分在不同的空间进行,这样也就不会遇到同步更新同一块内存空间的问题。每块线程独立的这块分配缓冲称为Thread Local Allocation Buffer(TLAB)。只有TLAB用完并分配新的TLAB时,才需要同步锁定。
  6. 内存分配完成以后,或者在分配TLAB的时候,就对分配到的内存空间初始化为零值,不包括对象头。
  7. 对对象头进行必要的设置。对象是哪个类的实例、如何能找到类的元数据信息、对象的哈希码、对象的GC分代年龄、锁信息。
  8. 新的对象已经产生。但一般new指令后面还有<init>方法,把对象按照程序员的意愿进行初始化。

对象访问方式

JVM规范只规定了reference是一个指向对象的引用,并没有定义这个引用应该如何定位、访问堆中对象的具体位置,所以对象访问方式也是取决于虚拟机实现而定的。目前主流的访问方式有使用句柄和直接指针两种。

HotSpot采用直接指针方式访问对象,因为它只需一次寻址操作,从而性能比句柄访问方式快一倍。但是在垃圾收集时移动对象十分频繁,对象的reference本身也需要修改,而reference中的句柄地址是稳定的,只需要修改句柄中的实例数据指针

对象的内存模型

一个对象从逻辑角度看,它由成员变量和成员函数构成,从物理角度来看,对象是存储在堆中的一串二进制数,这串二进制数的组织结构如下。

对象在内存中分为三个部分:

  1. 对象头(header)
  2. 实例数据(instance data)
  3. 对齐补充(padding)

对象头

  1. 存储对象自身的运行时数据。也称为Mark Word。包括哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。

  2. 类型指针,即对象指向它的类元数据的指针。虚拟机通过这个指针确定这个对象是哪个类的实例。

  3. 如果对象是一个Java数组,那么对象头中还需要一块记录数组长度的信息。

实例数据

也就是在程序代码中所定义的各种类型的字段内容。包括从父类继承的字段。

对齐填充

因为HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,因此需要在末尾填充一些占位符。

如何判定哪些对象需要回收?

  1. 引用计数
/*相互循环引用*/
objA.instance = objB;
objB.instance = objA;

由于存在对象之间相互循环引用,导致它们的引用计数都不为0,然而它们已经不可能再被访问,却得不到有效得到清除。 因此主流的JVM都没有采用这种方法。

  1. 可达性分析 所有和GC Roots直接或间接关联的对象都是有效对象,和GC Roots没有关联的对象就是无效对象。

可以作为GC-root的对象

  1. Java栈和Native栈中局部变量表中引用类型的变量所引用的对象。
  2. 方法区中类静态属性引用的对象
  3. 方法区中常量所引用的对象 注意:GC Roots并不包括堆中对象所引用的对象!这样就不会出现循环引用。

引用类型

  1. 强引用:我们平时所使用的引用就是强引用。 A a = new A(); 也就是通过关键字new创建的对象所关联的引用就是强引用。 只要强引用存在,该对象永远也不会被回收。

  2. 软引用:软引用通过SoftReference类实现。 只有当堆即将发生OOM异常时,JVM才会回收软引用所指向的对象。 软引用的生命周期比强引用短一些。

  3. 弱引用: 弱引用通过WeakReference类实现。 只要垃圾收集器运行,弱引用所指向的对象就会被回收。

  4. 虚引用:通过PhantomReference类来实现。 它和没有引用没有区别,无法通过虚引用访问对象的任何属性或函数。 一个对象关联虚引用唯一的作用就是在该对象被垃圾收集器回收之前会受到一条系统通知。

演示程序:

public class ReferenceTest {
    public static void main(String[] args) {
        Hero h1 = new Hero("strong", 1);
        SoftReference<Hero> softReference = new SoftReference<Hero>(new Hero("soft", 2));
        WeakReference<Hero> weakReference = new WeakReference<Hero>(new Hero("weak", 3));

        System.out.println("before gc:");
        System.out.println(h1.name);
        System.out.println(softReference.get().name);
        System.out.println(weakReference.get().name);

        System.gc();

        System.out.println("after gc:");
        System.out.println(h1.name);
        System.out.println(softReference.get().name);
        System.out.println(weakReference.get().name);
    }
}

执行结果:

回收无效对象的过程

当JVM筛选出失效的对象之后,并不是立即清除,而是经历两次标记过程,具体过程如下:

  1. 判断该对象是否覆盖了finalize()方法 若已覆盖该方法,并该对象的finalize()方法还没有被执行过,那么就会将finalize()扔到F-Queue队列中; 若未覆盖该方法,则直接释放对象内存。

  2. 执行F-Queue队列中的finalize()方法 虚拟机会以较低的优先级执行这些finalize()方法们,也不会确保所有的finalize()方法都会执行结束。如果finalize()方法中出现耗时操作,虚拟机就直接停止执行,将该对象清除。

  3. 对象重生或死亡 如果在执行finalize()方法时,将this赋给了GC-root上的一个对象,那么该对象就重生了。如果没有,那么就会被垃圾收集器清除。

方法区(HotSpot中的永生代)的垃圾回收

回收废弃常量

假设常量池中有个"abc"的字符串,但是当前系统没有任何一个String对象是叫做"abc"的,也没有其他地方引用了这个字面量,如果这时发生内存回收,而且必要的话,这个"abc"就会被清理出常量池。常量池中的其他类(接口)、 方法、字段的符号引用也与此类似。

回收无用的类

前面说过回收类的条件极其苛刻。回收一个类至少需要满足以下3个条件:

  1. 该类的所有实例都已经被回收,也就是Java堆中不存在该类的任何实例。
  2. 加载该类的ClassLoader已经被回收。
  3. 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射获取到该类的类型信息。

尽管满足了以上三个条件,类也不一定就必然回收。

在大量使用反射、动态代理、CGLib等ByteCode框架、动态生成JSP以及OSGI这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能,以保证永久代不会溢出。

垃圾收集算法

标记-清除算法

首先判断需要回收的对象,并给它们做上标记,然后清除被标记的数据。 这种算法标记和清除过程效率都很低,而且清除完后存在大量碎片空间,导致无法存储大对象,降低了空间利用率。

复制算法

因为新生代中的对象98%都是朝生暮死的,因此HotSpot中对新生代采用改进的复制算法来回收:

将内存分成一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中的一块Survivor空间。当回收时,将Eden和Survivor中还活着的对象一次性的复制到另一块Survivor区当中。最后清理掉刚才用过的Survivor空间和Eden空间。HotSpot默认Eden和Surivor的大小比例为8 : 1, 也就是新生代中有90%的可用空间。只有10%的空间被浪费。

当然98%的对象可回收只是一般的场景,当回收的效果很差,回收后的Eden+Survivor区仍然存放不了新的对象,会怎么办呢?

由于有内存分配担保机制,HotSpot会将这次的存活对象都转移到老年代,相当于为这个新对象重置了一次新生代的分区。

具体说一下分配担保的执行过程:当JVM准备为一个对象分配内存空间时,发现此时Eden+Survior中空闲的区域无法装下该对象,那么就会触发MinorGC,对该区域的废弃对象进行回收。但如果MinorGC过后只有少量对象被回收,仍然无法装下新对象,那么此时需要将Eden+Survior中的所有对象都转移到老年代中,然后再将新对象存入Eden区。这个过程就是“分配担保”。分配担保只针对复制算法。

标记-整理算法

在处理内存中大量对象都100%存活的情况下,上面的复制算法效率就很低,而且势必会大量用到内存担保。那对于老年代的回收,该向谁申请担保空间呢?

因此,针对老年代对象的特点,就有了标记-整理算法,标记过程与标记-清除算法一致。但后续步骤不是直接对对象进行回收,而是让所有存活对象向一侧移动,然后清理掉端边界以外的内存。该算法可以避免产生大量的内存碎片。

分代收集算法

将内存划分为老年代和新生代。老年代中存放寿命较长的对象,新生代中存放“朝生夕死”的对象。然后在不同的区域使用不同的垃圾收集算法。

GC停顿

由于需要枚举GC-root,因此可达性分析的这个过程必须在一个能确保一致性的快照中进行。这里的一致性是指在整个可达性分析的过程中,不可以出现对象引用关系还在不断变化的过程。这点导致了GC进行时必须停顿所有Java执行线程(Stop the world)。即使在号称几乎不会发生停顿的CMS收集器中,枚举根节点的过程也是必须要停顿的。

OopMap记录着对象内什么偏移量上是什么类型的数据,在GC扫描时就可以直接得知那些地方存在着对象引用。

安全点和安全区域:主动式中断和抢占式中断。前者到达安全点自动将自己中断挂起。

垃圾收集器的比较

新生代中的垃圾收集器

Serial垃圾收集器

  • 单线程

只开启一条GC线程进行垃圾回收,并且在垃圾回收过程中停止一切用户线程,从而用户的请求或图形化界面会出现卡顿。

  • 适合客户端应用

一般客户端应用所需内存较小,不会创建太多的对象,而且堆内存不大,因此垃圾回收时间比较短,即使在这段时间停止一切用户线程,用户也不会感受到明显的停顿,因此本垃圾收集器适合客户端应用。

  • 简单高效

由于Serial收集器只有一条GC线程,因此避免了线程切换的开销,从而简单高效。

  • 采用“复制”算法

ParNew垃圾收集器

它是Serial的多线程版本。

  • 多线程并行执行

ParNew由多条GC线程并行地进行垃圾清理。但清理过程仍然需要停止一切用户线程。但由于有多条GC线程同时清理,清理速度比Serial有一定的提升。

  • 适合多CPU的服务器环境

由于使用了多线程,因此适合CPU较多的服务器环境。

  • 与Serial性能对比

ParNew和Serial唯一的区别就是使用了多线程进行垃圾回收,在多CPU的环境下性能比Serial会有一定程度的提升;但线程切换需要额外的开销,因此在单CPU环境中表现不如Serial。

  • 采用“复制”算法

  • 追求“降低停顿时间”

和Serial相比,ParNew使用多线程的目的就是缩短垃圾收集时间,从而减少用户线程被停顿的时间。

Parallel Scavenge垃圾收集器

并行的多线程垃圾收集器,关注点和其他尽量缩短停顿时间不同,它主要目标是达到一个可控制的吞吐量。

举个例子,系统如果把新生代调小一些,比如600MB--> 300MB,那它原来10s收集一次,现在可能5s收集一次。原来停顿100ms,现在停顿70ms。停顿时间下来了,但吞吐量也下来了。

Parallel Scavenge提供的参数

  • 设置“吞吐量”

通过参数-XX:GCTimeRadio设置垃圾回收时间占总CPU时间的百分比。

  • 设置“停顿时间”

通过参数-XX:MaxGCPauseMillis设置垃圾处理过程最久停顿时间。Parallel Scavenge会根据这个值的大小确定新生代的大小。如果这个值越小,新生代就会越小,从而收集器就能以较短的时间进行一次回收。但新生代变小后,回收的频率就会提高,因此要合理控制这个值。

  • 启用自适应调节策略

通过命令-XX:+UseAdaptiveSizePolicy就能开启自适应策略。我们只要设置好堆的大小和MaxGCPauseMillis或GCTimeRadio,收集器会自动调整新生代的大小、Eden和Survior的比例、对象进入老年代的年龄,以最大程度上接近我们设置的MaxGCPauseMillis或GCTimeRadio。

老年代的垃圾收集器

Serial Old垃圾收集器

Serial Old收集器是Serial的老年代版本,它们都是单线程收集器,也就是垃圾收集时只启动一条GC线程,因此都适合客户端应用。

它们唯一的区别就是Serial Old工作在老年代,使用“标记-整理”算法;而Serial工作在新生代,使用“复制”算法。

Parallel Old收集器

Parallel Old收集器是Parallel Scavenge的老年代版本,一般它们搭配使用,追求CPU吞吐量。 它们在垃圾收集时都是由多条GC线程并行执行,并停止一切用户线程。因此,由于在垃圾清理过程中没有使垃圾收集和用户线程并行执行,因此它们是追求吞吐量的垃圾收集器。

CMS收集器-Concurrent Mark Sweep

用户线程和垃圾收集线程可以并发执行。

***运作过程:***ne.png)

  1. 初始标记 - 仅仅只标记GC-root能直接关联到的对象,速度很快。需要GC停顿。
  2. 并发标记 - 进行GC-roots tracing
  3. 重新标记 - 修改并发标记中因为用户程序继续运行而导致标记的变动。GC停顿比初始标记稍长一点。
  4. 并发清除

缺点:

  1. 无法处理浮动垃圾。由于并发清理阶段用户线程还在运行,因此就还伴随着新的垃圾产生,这部分垃圾只能留待下一次GC时再清理。

  2. 可能发生Councurrent Mode Failure,不得不临时启用Serial Old收集器来临时对老年代进行重新收集。 因为用户线程和GC线程一起运行,因此CMS不能像其他收集器一样等到老年代几乎完全填满了再进行收集,需要预留一部分空间提供并发收集时程序正常运行时使用。如果CMS运行期间预留的内存无法满足程序要求,就会出现一次Councurrent Mode Failure。

  3. 由于采用标记清除算法,会产生内存碎片,导致在分配对象时由于找不到连续的空间存放对象提前触发一次FullGC。而内存整理/压缩,是无法并发执行的,停顿时间会变长。

通用垃圾收集器 - G1(Garbage First)

特点

  • 追求停顿时间
  • 多线程GC
  • 面向服务端应用
  • 标记-整理和复制算法合并
  • 不会产生碎片内存。
  • 可对整个堆进行垃圾回收
  • 可预测停顿时间

G1的内存模型

G1垃圾收集器没有新生代和老年代的概念了,而是将堆划分为一块块独立的Region。当要进行垃圾收集时,首先估计每个Region中的垃圾数量,每次都从垃圾回收价值最大的Region开始回收,因此可以获得最大的回收效率。

Remembered Set 一个对象和它内部所引用的对象可能不在同一个Region中,那么当垃圾回收时,是否需要扫描整个堆内存才能完整地进行一次可达性分析?

当然不是,每个Region都有一个Remembered Set,用于记录本区域中所有对象引用的对象所在的区域,从而在进行可达性分析时,只要在GC ROOTs中再加上Remembered Set即可防止对所有堆内存的遍历。

虚拟机发现程序在对reference对象写操作时,会产生一个Write Barrier暂时中断写操作,检查reference引用的对象是否处于不同的Region中,如果是便记录到本Region对应的Remembered Set中。

G1垃圾收集过程

  1. 初始标记
    标记与GC ROOTS直接关联的对象,停止所有用户线程,只启动一条初始标记线程,这个过程很快。
  2. 并发标记 进行全面的可达性分析,开启一条并发标记线程与用户线程并行执行。这个过程比较长。
  3. 最终标记 标记出并发标记过程中用户线程新产生的垃圾。停止所有用户线程,并使用多条最终标记线程并行执行。
  4. 筛选回收 回收废弃的对象。此时也需要停止一切用户线程,并使用多条筛选回收线程并行执行。

筛选回收阶段首先对各个Region的回收价值和成本排序,然后以用户所期望的GC停顿时间执行回收计划。这个阶段可以做到和用户线程并发执行,但是由于只回收一部分Region,时间是用户控制的,而且停顿用户线程可以大幅度提高效率。

Minor GC / Full GC

Minor GC

新生代的GC,MinorGC非常频繁,回收速度也很快。发生在为新生对象分配Eden空间时不够的情况。

Full GC

老年代的GC,指发生在老年代的GC,出现Full GC一般伴随着至少一次Minor GC。Full GC的速度比MinorGC慢十倍。 在为大对象分配老年代空间时不够会直接触发FullGC。 在为新生代提供分配担保时,如果空间不够也会触发FullGC。

对象内存分配策略

主要是在堆上分配,主要分配在新生代的Eden区,如果启动了TLAB,那将按线程优先在TLAB上分配。少数情况下也直接分配在老年代上,分配的规则不是百分百确定的。主要取决于当前使用的哪一种垃圾收集器组合,以及JVM中与内存相关的参数的配置。

对象优先在Eden区中分配

每次创建对象时,首先会在Eden区中分配。 当Eden区没有足够的空间分配时会触发一次MinorGC。 若Eden区+Survior1区剩余内存太少,对象仍然无法放入该区域时,就会启用“分配担保”,将当前Eden区+Survior1区中的对象提前转移到老年代中,然后再将新对象存入Eden区。

大对象直接进入老年代

所谓“大对象”就是指一个占用大量连续存储空间的对象,如数组。也可以通过JVM的参数-XX:PretrnureSizeThreshold设置大对象的评判范围。

老对象转移到老年代

新生代中的每个对象都有一个年龄计数器,当新生代发生一次MinorGC后,存活下来的对象的年龄就加一,当年龄超过一定值(默认15)时,就将超过该值的所有对象转移到老年代中去。

动态对象年龄判定

当Survivor中相同年龄的所有对象大小的总和超过Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,而不需要等到15岁。

内存分配担保的冒险

当垃圾收集器准备要在新生代发起一次MinorGC时,首先会检查“老年代中最大的连续空闲区域的大小 是否大于 新生代中所有对象的大小?”,也就是老年代中目前能够将新生代中所有对象全部装下?

若老年代能够装下新生代中所有的对象,那么此时进行MinorGC没有任何风险,然后就进行MinorGC。

若老年代无法装下新生代中所有的对象,那么此时进行MinorGC是有风险的,垃圾收集器会进行一次预测:根据以往MinorGC过后存活对象的平均数来预测这次MinorGC后存活对象的平均数。

如果以往存活对象的平均数小于当前老年代最大的连续空闲空间,那么就进行MinorGC,虽然此次MinorGC是有风险的。

如果以往存活对象的平均数大于当前老年代最大的连续空闲空间,那么就对老年代进行一次Full GC,通过清除老年代中废弃数据来扩大老年代空闲空间,以便给新生代作担保。

如果冒险失败,也就是这次老年代并不能放下所有新生代的存活对象,那么就只好在失败后再发起一次Full GC。

Class文件结构

  • 魔数
  • 本文件的版本信息
  • 常量池
  • 访问标志
  • 类索引
  • 父类索引
  • 接口索引集合
  • 字段表集合
  • 方法表集合

类的生命周期

加载——>验证——>准备——>解析——>初始化——>使用——>卸载

类加载过程

1. 加载

  1. 通过一个类的全限定名来获取这个类的二进制字节流,即class文件: 在程序运行过程中,当要访问一个类时,若发现这个类尚未被加载,并满足类初始化时机的条件时,就根据要被初始化的这的全限定名找到该类的二进制字节流,开始加载过程。
  2. 将二进制字节流的存储结构转化为特定的数据结构,存储在方法区中;
  3. 在方法区中创建一个java.lang.Class类型的对象。接下来程序在运行过程中所有对该类的访问都通过这个类对象,也就是这个Class类型的类对象是提供给外界访问该类的接口。

2. 验证

验证是为了保证二进制字节流中的信息符合虚拟机规范,并没有安全问题。

  1. 文件格式验证 这个阶段主要验证输入的二进制字节流是否符合class文件结构的规范。二进制字节流只有通过了本阶段的验证,才会被允许存入到方法区中。因此验证和加载其实是交互进行的。

  2. 元数据验证 本阶段对方法区中的字节码描述信息进行语义分析,确保其符合Java语法规范。

  3. 字节码验证 本阶段是验证过程的最复杂的一个阶段。 本阶段对方法体进行语义分析,保证方法在运行时不会出现危害虚拟机的事件。

  4. 符号引用验证 本阶段验证发生在解析阶段,确保解析能正常执行。

3. 准备

  • 为已经在方法区中的类中的静态成员变量分配内存,类的静态成员变量也存储在方法区中。
  • 为静态成员变量设置初始值,初始值为0、false、null等。

4. 解析

解析阶段是虚拟机将常量池中的符号引用替换为直接引用的过程。 符号引用:以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要能够无歧义的定位到目标即可。符号引用与虚拟机的内存布局无关,引用的目标不一定已经加载到内存中。

直接引用:直接引用可以是直接指向目标的指针、相对偏移量、或是一个能够间接定位到目标的句柄。和虚拟机实现的内存布局相关,如果有了直接引用,那引用的目标一定已经在内存中存在。

5. 初始化

初始化阶段就是执行类构造器clinit()的过程。 clinit()方法由编译器自动产生,收集类中static{}代码块中的类变量赋值语句和类中静态成员变量的赋值语句。在准备阶段,类中静态成员变量已经完成了默认初始化,而在初始化阶段,clinit()方法对静态成员变量进行显示初始化。

类加载的时机

  1. 在运行过程中遇到如下字节码指令时,如果类尚未初始化,那就要进行初始化:new、getstatic、putstatic、invokestatic。这四个指令对应的Java代码场景是:
    1. 通过new创建对象;
    2. 读取、设置一个类的静态成员变量(不包括final修饰的静态变量);
    3. 调用一个类的静态成员函数。
  2. 使用java.lang.reflect对类进行反射调用的时候,如果类没有初始化,那就需要初始化;
  3. 当初始化一个类的时候,若其父类尚未初始化,那就先要让其父类初始化,然后再初始化本类;
  4. 当虚拟机启动时,虚拟机会首先初始化带有main方法的类,即主类;

类与类加载器

类加载器的作用

将class文件加载进JVM的方法区,并在方法区中创建一个java.lang.Class对象作为外界访问这个类的接口。 主要实现了“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作。

JVM中的三种类加载器:

  1. 启动类加载器 负责加载Java_Home\lib中的class文件。

  2. 扩展类加载器 负责加载Java_Home\lib\ext目录下的class文件。

  3. 应用程序类加载器 负责加载用户classpath下的class文件。

双亲委派模型

工作过程

如果一个类加载器收到了加载类的请求,它首先将请求交由父类加载器加载;若父类加载器加载失败,当前类加载器才会自己加载类。

作用

像java.lang.Object这些存放在rt.jar中的类,无论使用哪个类加载器加载,最终都会委派给最顶端的启动类加载器加载,从而使得不同加载器加载的Object类都是同一个。主要解决了各个类加载器的基础类的统一

原理

双亲委派模型的代码在java.lang.ClassLoader类中的loadClass函数中实现,其逻辑如下:

  • 首先检查类是否被加载;
  • 若未加载,则调用父类加载器的loadClass方法;
  • 若该方法抛出ClassNotFoundException异常,则表示父类加载器无法加载,则当前类加载器调用findClass加载类;
  • 若父类加载器可以加载,则直接返回Class对象;
protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
                    c = findClass(name);

                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

自定义类加载器

自定义类加载器可以实现从指定的目录、网络、甚至数据库读取class文件的二进制流加载类的需求。可以摆脱sun.misc.Launcher$AppClassLoader只能加载classpath下的.class文件的限制。还可以在此基础上实现对class文件的加密解密、压缩与解压。

自定义的类加载器,为了不违反双亲委派模型,可以不重写ClassLoader里的loadClass()方法,而只重写最后一步当父类也就是JVM内置的那三个类加载器找不到该类时调用的findClass()方法。

自定义类加载器代码

public class MyClassLoader1 extends ClassLoader{
    private String classpath;
    public MyClassLoader1(String classpath) {
        this.classpath = classpath;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        try {
            //根据类的class文件位置得到字节流
            byte [] classData = Util.getData(classpath, name);
            if(classData != null){
                //将字节流转化为类
                return defineClass(name, classData,0,classData.length);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
        return super.findClass(name);
    }
}

测试自定义类加载器代码

先自定义了一个Printer类,编译后将class文件放在桌面上。因此classpath就设置成桌面的位置。

public class Printer{
    private static String name = "rick";
    public static void printHello(){
        System.out.println("hello! printed by " + name);
    }
}

通过反射拿到该类的静态函数Printer$printHello。进行反射调用。

public static void testMyClassLoader(){
    MyClassLoader1 myClassLoader1 = new MyClassLoader1("C:\\Users\\M\\Desktop");
    System.out.println(myClassLoader1);
    try {
        Class c = myClassLoader1.loadClass("Printer");
        Method print = c.getMethod("printHello");
        //静态方法,调用时会忽视invoke()的参数
        //因此参数设为null也能正常调用
        print.invoke(null);
    } catch (ClassNotFoundException | IllegalAccessException e) {
        e.printStackTrace();
    } catch (NoSuchMethodException e) {
        e.printStackTrace();
    } catch (InvocationTargetException e) {
        e.printStackTrace();
    }
}

输出结果:

test.MyClassLoader1@3764951d
hello! printed by rick

可以看到这个外部的class文件被转换成了类加载到了方法区,通过反射可以像普通类一样使用。

判断是否是同一个类对象的前提--当这两个类由同一个加载器加载

比较两个类是否相等,只有当这两个类由同一个加载器加载才有意义;否则,即使同一个class文件被不同的类加载器加载,那这两个类必定不同,即通过类的Class对象的equals执行的结果必为false。就连同一个加载器的不同实例,加载同一个class文件,得到的两个类都被认为是两个不同的类。

例如:

MyClassLoader1 myClassLoader1 = new MyClassLoader1();
MyClassLoader1 myClassLoader2 = new MyClassLoader1();
Class c1 = myClassLoader1.loadClass("Printer");
Class c2 = myClassLoader2.loadClass("Printer");
System.out.println("c1 == c2 ? : " +  (c1 == c2));
System.out.println("c1.equals(c2) = " + c1.equals(c2));

输出的结果:

c1 == c2 ? : false
c1.equals(c2) = false