JVM虚拟机面试知识点之言简意赅篇

       3年及以上Java老程序员面试的时候不可避免的会面对JVM虚拟机方面知识点的面试,但是深入了解JVM虚拟机内容太过于详细且深奥,下面就我对面试的认知将本书重要的知识点抽出来进行一次总结,总结比较简单,适合有基础的看,没基础的还是看书为好。

       面试主要考以下内容:

              (1)Java内存模型;

              (2)判断对象是否已死的方法?

             (3)垃圾收集算法;

             (4)垃圾收集器;

             (5)类加载机制;

============================================================================================

1.   Java内存模型

        Java的内存主要由以上5部分构成,其中,方法区内存和堆内存是所有线程共享的数据区;虚拟机栈和本地方法栈以及程序计数器为线程隔离数据区。

1.1   方法区

方法区为各个线程共享的区域,它用于存储一倍虚拟机加载的类信息,常量,静态变量和即时编译器编译后的代码等数据。

1.2   虚拟机栈

虚拟机栈为线程私有,它用于存储Java代码方法内的局部变量,包括基本数据类型的局部变量,和对象引用类型的局部变量。

1.3   本地方法栈

本地方法栈为虚拟机使用到的Native方法服务,如下所示:

1.4    堆

堆内存中主要存储对象以及对象的属性(包括成员变量)。

1.5   程序计数器

程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。

===========================================================================================

2   判断对象是否已死

       我们知道,当一个Java对象没有更多的引用去指向它的时候,该对象就会被默认为死亡,然后启动GC机制,对该对象进行回收。 这种说法比较笼统,那么有没有形象一点的呢?有,就是引用计数法可达性分析算法,通过这两者来具体判断对象的存活。

2.1   引用计数法

         书中是这样讲的:给对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1,任何时刻计数器值为0的对象就是不可能再被使用的,即开始回收。

         这到底对不对?我们不妨做个试验来验证一下:

package com.Jevin.demo1;

public class ReferenceCountingGC {

    public Object instance = null;

    public static void testGC() {

        ReferenceCountingGC refenceA = new ReferenceCountingGC();
        ReferenceCountingGC refenceB = new ReferenceCountingGC();

        //这个地方引用对象:
        refenceA.instance = refenceA;
        refenceB.instance = refenceB;

        //这个地方使引用失效
        refenceA = null;
        refenceB = null;

        //回收:
        System.gc();
    }

    public static void main(String[] args) {
        testGC();
    }
}

         如上述代码,创建了refereceA和refenceB两个引用,并且通过" .instance"引用了它,计数器为加一个为1,再通过将其设置成null,计数器减一个,为0,使引用失效,这时再触发内存回收System.gc(),按照我们的设想虚拟机应该会回收垃圾。在测试之前,需要配置一个参数:-XX:+PrintGC

 

 这个参数作用就是查看虚拟机运行参数的。

 运行结果,我们发现虚拟机内存使用量从3334K减到768K,说明虚拟机触发了一次内存回收。那说明我们的假设是对的。

 但是,但是。。。这种引用计数法是有局限性的,那就是它无法解决对象之间相互循环引用的问题

如上述代码中的引用对象换成如下:

       refenceA对象中的变量instance一直引用着refenceB,refenceB对象中的instance一直引用着refenceA,两者之间一直循环引用,即使将refenceA和refenceB置为null,两对象中的instance一直引用着对方。这样,引用计数器永远都不会为0,那么虚拟机应该不会回收这部分的垃圾才对,然而事实上:

虚拟机回收了,这也说明了引用计数法是有局限性的。

 

2.2   可达性分析算法

        另一个判断对象是否存活的方法是可达性分析算法,它的基本思想是: 通过一系列称为"GC Roots"的对象作为起点,从起点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Root没有任何引用链相连(也就是说,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。

        如下图所示,对象object5,object6,object7虽然相互关联,但是他们到GC Roots是不可达的,所以他们被判定为是可回收的对象。

在Java语言中,可作为GC Roots的对象包括以下几种:

(1)   虚拟机栈中的引用对象;

(2)   方法区中类静态属性引用的对象;

(3)   方法区中常量引用的对象;

(4)   本地方法栈中JNI(即native方法)引用的对象; 

        在可达性分析算法中,对象即使不在GC Roots的引用链上,在面临回收之前,都有一次自救的机会:如果对象在进行可达性分析之后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况视为“没有必要执行”,这时,此对象才是真正的死亡。

         如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个F-Queue的队列之中。finalize()方法是对象逃脱死亡 命运的最后一次机会,但是任何对象的finalize()方法都只会被系统自动调用一次;稍后GC将对F-Queue中的对象进行第二次小规模的标记,只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this对象)赋值给某个变量或者对象的成员变量,此时自救成功。

        如下演示对象自救:

package com.Jevin.demo1;

public class FinalizeEscapeGC {

    public static FinalizeEscapeGC SAVE = null;

    public void isAlive(){
        System.out.println("yes,I am still alive!");
    }

    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("finalize() method execute!");
        FinalizeEscapeGC.SAVE = this;
    }

    public static void main(String[] args) throws Throwable {
        SAVE = new FinalizeEscapeGC();

        //对象第一次成功拯救自己:
        SAVE = null;
        System.gc();
        //因为finalize()方法优先级很低,所以暂停0.5秒等待它:
        Thread.sleep(500);
        if(SAVE != null){
            SAVE.isAlive();
        }else{
            System.out.println("no,I am dead!");
        }

        //下面代码与上述一样,却自救失败了。
        // 因为自救成功的关键是执行fianlize()方法,但是一个对象的finalize()方法只会被系统自动调用一次:
        SAVE = null;
        System.gc();
        //因为finalize()方法优先级很低,所以暂停0.5秒等待它:
        Thread.sleep(500);
        if(SAVE != null){
            SAVE.isAlive();
        }else{
            System.out.println("no,I am dead!");
        }
    }
}

结果是:

===============================================================================================

3   垃圾收集算法

3.1   标记 - 清除算法

        最基础的算法是标记-清除算法,如同他的名字一样,算法分为“标记”和“清除”两个阶段,首先标记所有需要回收的对象,标记完之后统一回收所有被标记的对象。如下图所示:

       这种垃圾收集算法不足之处有两个:

       一是效率问题,标记和清除两个过程的效率都不高;

       另一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

 

3.2   复制算法

        为了解决效率问题,一种称之为“复制”的搜集算法出现了,他将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,将还存活着的对象复制到另一块上面,然后将之前使用过的内存空间一次清理掉。如下所示:

       这样做不用考虑内存碎片等复杂空间问题,效率大大提高,但是将内存缩小为原来的一半,代价未免太高了一点。为了解决这个问题,并且考虑到新生代的对象98%都是“朝生夕死”的,所以不需要按照1:1的比例来划分内存空间,而是将内存划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性的复制到另外一块Survivor空间上,最后清理掉Eden和之前使用过的Survivor空间。Hotspot虚拟机默认Eden和Survivor的大小比例是8:1,也就是说,只有10%的内存会被浪费掉。但是我们没法保证每次回收都只有不多于10%的对象存活,当Survivor内存空间不够时,需要依赖其他内存(这里指老年代)进行分配担保。

 

3.3   标记 - 整理算法

        “标记 - 整理算法”类似于“标记 - 清除算法”,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向着内存空间一端移动,然后直接清理掉边界之外的内存。如下所示:

 老年代中一半都使用这种算法。

 

3.4   分代收集算法

       当前商业虚拟机的垃圾收集器都采用“分代收集算法”,根据对象存活周期的不同将内存划分为几块,一半把Java堆划分为新生代和老年代,这样就可以根据各个代的特点采用最适合的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高,没有额外空间对他进行分配担保,就必须使用“标记 - 清理”或者是“标记 - 整理”算法来进行垃圾回收。

=============================================================================================

4    垃圾收集器

下面是各个垃圾收集器的关系图:

        上图展示了7中作用于不同分代的收集器,如果两个收集器之间存在连线,就说明他们可以搭配使用。下面将逐一介绍这些收集器:

4.1   Serial收集器

       Serial收集器是一个单线程的,新生代的收集器。“单线程”的含义并不仅仅说明他只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在他进行垃圾收集时,必须暂停其他所有的工作线程,直到他收集结束。夸张点说,要是你的计算机每运行一个小时就会暂停响应5分钟,你会是什么样的心情?下图示意了Serial收集器的运行过程:

 

       由上图可以看出,当GC线程运行时,用户线程是暂停运行的(我们称之为“stop the world”),基于此,是不是说明Serial收集器一点用没有呢?假如"stop the world"没有那么夸张的话,那么Serial收集器也有着用武之地。例如:在用户的桌面应用场景中,分配给虚拟机管理的内存一半来说不会很大,收集几十兆甚至一两百兆的新生代,停顿时间完全可以控制在几十毫秒最多一百毫秒以内,只要不是频繁发生,这点停顿是可以接受的。所以,Serial收集器对于运行在Client模式下的虚拟机来说是一个很好地选择。尤其对于单个CPU的环境来说,简单而高效。

4.2   ParNew收集器

        ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数,收集算法,Stop the world,对象分配规则,回收策略等都与Serial收集器完全一样,工作过程如下,和Serial收集器一样:

 

        ParNew较于Serial除了多线程之外,几乎没有任何创新之处,但是他却是许多运行在Server模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关但很重要的原因是,除了Serial收集器外,目前只有它能与CMS收集器配合工作。ParNew收集器在单CPU的环境中绝对不会有比Serial收集器更好的效果,甚至由于出现线程交互的开销,该收集器在通过超线程技术实现的两个CPU的环境中都不能百分百的保证可以超越Serial收集器。当然,随着CPU的数量的增多,它对于GC时系统资源的有效利用还是很有好处的。它默认开启的收集线程数与CPU的数量相同。

4.3   Parallel Scavenge收集器

        Parallel Scavenge收集器是一个新生代的收集器,是使用复制算法的收集器,又是并行的多线程收集器。Parallel Scavenge收集器的关注点与其他的收集器不同,CMS等收集器的关注点都是尽量的缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目的则是达到一个可控制的吞吐量,为:吞吐量=运行用户时间 / (运行用户时间 + 垃圾收集时间 ),打个比方,假如原来10秒收集一次,每次停顿100毫秒,吞吐量为99.01%;现在变成每5秒收集一次,每次停顿70毫秒,吞度量为98.61%。停顿时间的确在下降,这也导致垃圾收集发生的更频繁一些,但是吞吐量却降下来了。

4.4   Serial Old收集器

        Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用“标记--整理”算法。这个收集器的主要意义也是在于给Client模式下的虚拟机使用。如果 在Server模式下,那么它主要还有两大用途:一是在jdk1.5及之前的版本中与Parallel Scavenge收集器搭配使用,另一种用途就是作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。Serial Old收集器工作过程图如下所示:

4.5   Parallel Old收集器

        Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记--整理”算法。这个收集器是在JDK1.6中才开始提供的,在此之前,新生代的Parallel Scavenge收集器一直处于比较尴尬的状态。原因是,如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old收集器外别无选择。由于老年代Serial Old收集器在服务端应用性能上的“拖累”,使用了Parallel Scavenge收集器也未必能在整体应用上获得吞吐量最大化的效果,由于单线程的老年代收集中无法充分利用服务器多CPU的处理能力,在老年代很大而且硬件比较高级的环境中,这种组合的吞吐量甚至还不一定有ParNew加CMS的组合给力。

        直到Parallel Old收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的应用组合,在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge和Parallel Old收集器,工作过程如下图:

 

4.6   CMS收集器(Concurrent Mark Sweep)

        CMS收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。

        CMS收集器是基于“标记--清除”算法实现的,整个过程分为4个步骤:

         1.初始标记

          2.并发标记

          3.重新标记

          4.并发标记

          其中,初始标记和重新标记这两个步骤仍然需要“stop the world”;初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但是远比并发标记的时间短。

         由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。通过下图可以比较清楚地看到CMS收集器的运作步骤中并发和需要停顿的时间。

       CMS是一款优秀的收集器,它的主要优点有两个:并发收集和低停顿。

       当然,缺点也是有的,主要有三个:

       第一:CMS收集器对CPU资源非常敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。

       第二:CMS收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理它们,只好留待下一次GC在清理掉,这一部分垃圾就从称为“浮动垃圾”。

      第三:CMS是一款基于“标记--清除”算法实现的收集器,这意味着收集结束时会产生大量的空间碎片。空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC。

4.7   G1收集器

       G1是一款面向服务端应用的垃圾收集器。它的使命就是替换掉CMS收集器。与其它收集器相比,G1具备如下特点:

       第一:并行与并发。G1能充分利用多CPU,多核环境下的硬件优势,使用多个CPU来缩短stop the world停顿的时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java线程继续执行。

       第二:分代收集。与其它收集器一样,分代概念在G1中依然得以保留。虽然G1可以不需要其它收集器配合就能独立管理整个GC堆,但他能过采用不同的方式去处理新创建的对象和已经存活了一段时间,熬过多次GC的旧对象以获取更好的收集效果。

       第三:空间整合。与CMS的“标记--清理”算法不同,G1从整体上来看是基于“标记--整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前出发下一次GC。

       第四:可预测的停顿。这是G1相对于CMS的另一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在垃圾收集器上的时间不得超过N毫秒,这几乎已经是实时Java的垃圾收集器的特征了。

4.7.1   G1收集器的原理

        使用G1收集器时,Java堆的内存布局就与其它收集器有很大差别,他将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但是新生代和老年代不再是物理隔离的了,它们是一部分Region(不需要连续)的集合。G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。这种使用Region划分内存空间以及优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。

4.7.2   G1收集器运作步骤:

(1) 初始标记

(2) 并发标记

(3) 最终标记

(4) 筛选回收

        G1的前几个步骤的运作过程和CMS有些相似。初始标记阶段仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建对象,这阶段需要停顿线程,单耗时很短。并发标记阶段是从GC Roots开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。而最终标记阶段则是为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这阶段对象变化记录在线程Remembered Set Logs里面,最终表阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并行执行。最后在筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。如下是G1收集器的运作步骤中并发阶段和需要停顿的阶段。

========================================================================================

 5   类加载机制

5.1   类加载时机

        虚拟机把描述类的数据从Class文件(这里所说的“Class文件”应当是一串二进制的字节流)加载到内存开始,到卸载出内存为止,他的整个生命周期包括:加载,验证,准备,解析,初始化,使用和卸载7个阶段。其中验证,准备,解析3个部分统称为连接。如下图:

下面举例说明类被加载的时机:

5.1.1:

package com.Jevin.demo1;


class SuperClass{

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

    public static int value = 123;
}

class SubClass extends SuperClass{

    static{
        System.out.println("SubClass init!");
    }

}

public class Notification {
    public static void main(String[] args) {
        System.out.println(SubClass.value);
    }
}

上述代码运行后:

只会输出"SuperClass init",而不会输出“SubClass init”。原因是:对于静态字段,只有直接定义这个字段的类才会被初始化

5.1.2

package com.Jevin.demo1;

class ConstClass{

    static{
        System.out.println("ConstClass init!");
    }

    public static final String HELLOWORLD = "hello world";

}

public class NotInitialize {
    public static void main(String[] args) {
        System.out.println(ConstClass.HELLOWORLD);
    }
}

输出结果:

没有输出“ConstClass init”,这是因为虽然在Java源码中引用了ConstClass类中常量HELLOWORLD,但是其实在编译阶段通过常量传播优化,已经将此常量的值“hello world”存储到了NotInitialize类的常量池中,以后NotInitialize对常量ConstClass.HELLOWORLD的引用实际都转化为NotInitialize类对自身常量池的引用。

 

5.2   类加载过程

      类加载过程包括:加载,验证,准备,解析和初始化这5个阶段。

5.2.1   加载

在加载阶段,虚拟机需要完成一下3件事:

1)通过一个类的全限定名来获取定义此类的二进制字节流。

2)将这个字节流所代表的静态存储结构转化为此方法区的运行时数据结构。

3)在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

 

5.2.2   验证

       这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求。验证阶段大致上会完成下面4个阶段的校验动作:

1)   文件格式校验

2)   元数据校验

3)   字节码校验

4)   符号引用校验

 

5.2.3   准备

       准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这个阶段中有两个容易产生混淆的概念,首先,这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在实例化时随对象一起分配在Java堆中。假设一个类变量的定义为:

 public static int value = 123;

       那变量value在准备阶段过后的初始值为0而不是123,因为这时候尚未开始执行任何Java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器<clinit>()方法之中,所以把value赋值为123的动作将在初始化阶段才会执行。

 

5.2.4   解析

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

1)类或接口的解析

2)字段解析

3)类方法解析

4)接口方法解析

 

5.2.5   初始化

        初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的Java程序代码(或者说是字节码)。

       在准备阶段,变量已经赋过一次系统要求的初始值,而在初始阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另一个角度来表达,初始化阶段是执行类构造器<clinit>()方法的过程。<clinit>()方法是有编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但是不能访问,如下:

package com.Jevin.demo1;

public class StaticTest {

    static{
        i = 0;                        //给变量赋值可以正常编译通过
        System.out.println(i);        //这句编译会提示“非法向前引用”
    }

    static int i = 1;

}

       由于父类中的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作,字段B的值是2而不是1,如下:

package com.Jevin.demo1;

public class Give {

    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);
    }
}

 

5.3   类加载器

       虚拟机设计团队把类加载阶段中的“通过一个类的全限定名来获取描述此类的二进制字节流”这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类,实现这个动作的代码模块称为“类加载器”。

5.3.1   类和类加载器

        类加载器虽然只用于实现类的加载动作,但它在Java程序中起到的作用却远远不限于类加载阶段。对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达的更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就不相等。

        这里所指的“相等”,包括代表类的Class对象的equals()方法,isAssignableFrom()方法,isInstance()方法返回的结果,也包括使用instanceof关键字作对象的所属关系判定等情况,如下:

package com.Jevin.demo1;

import java.io.InputStream;

public class ClassLoadTest {
    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 (Exception e) {
                    throw new ClassNotFoundException("name");
                }
            }
        };

        Object obj = myLoader.loadClass("com.Jevin.demo1.ClassLoadTest").newInstance();
        System.out.println(obj.getClass());
        System.out.println(obj instanceof com.Jevin.demo1.ClassLoadTest);
    }
}

运行结果如下:

以上两行输出结果中,从第一句可以看出,这个对象确实是类com.Jevin.demo1.ClassLoadTest实例化出来的对象,但从第二句可以出现,这个对象与类com.Jevin.demo1.ClassLoadTest做所属类型检查的时候却返回了false,这是因为虚拟机中存在了两个ClassLoaderTest类,一个是由系统应用程序类加载器所加载的,另一个是由我们自定义的类加载器所加载的,虽然都来自于同一个Class文件,但依然是两个独立的类,做对象所属类型检查时结果自然为false。

 

5.3.2   双亲委派模型

从Java开发人员的角度来看,绝大多数Java程序都会使用到以下3种系统提供的类加载器:

1)启动类加载器(Bootstrap ClassLoader):这个类加载器负责将存在<JAVA_HOME>\lib目录下的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机是别的类库加载到虚拟机内存中。启动类加载器无法被Java程序直接饮用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器,那直接使用null代替即可。

2)扩展类加载器(Extension ClassLoader):这个加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载<JAVA_HOMNE>\lib/ext目录下的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。

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

   上图中所展示的类加载器之间的这种层次关系,称为类加载器的双亲委派模型。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承的关系来实现,而是都使用组合关系来复用父加载器的代码。

  双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己区尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需要的类)时,子加载器才会尝试自己去加载。

That's all!

 

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值