提示:这里为每天自己的学习内容心情总结;
Learn By Doing,Now or Never,Writing is organized thinking.
目前的想法是,根据 Java Guide 和 JavaLearning 和 小林coding进行第一轮复习,之后根据 Tiger 和 CS-Notes 进行最后的重点复习。
先多,后少。
提示:以下是本篇文章正文内容
JVM的四个组成部分、运行时内存区域划分、对象内存布局。
类加载器、运行时数据区、执行引擎、本地库接口。
对于Java程序员来说,在虚拟机自动内存管理机制的帮助下,不需要进行手动去释放对象所占用的内存,因为Java程序员把控制内存的权利交给了Java虚拟机。
一旦出现内存泄漏和内存溢出的问题,如果不了解Java虚拟机是怎样使用内存的,那么排查错误、修正问题将会成为一项异常艰难的工作。
Java虚拟机主要有四部分组成:
工作原理: 类加载器(classLoader
)将字节码文件加载到内存中,然后将其放在运行时数据区的方法区内,由于字节码文件只是JVM的一套指令集规范,不能直接交给底层系统去执行,需要使用执行引擎将字节码指令翻译成底层系统指令,交给CPU去执行,在执行的过程中可能需要调用其它语言的本地库接口来实现整个程序的功能。
程序计数器、虚拟机栈、本地方法栈、堆、方法区,直接内存。
Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域。
JVM的运行时数据区域,主要五部分有:程序计数器、虚拟机栈、本地方法栈、堆、方法区组成,其中程序计数器、虚拟机栈和本地方法栈是线程私有,而堆和方法区是所有线程共享的;
程私有的:
线程共享的:
**程序计数器:**记住当前字节码文件执行中下一条 JMV 指令的执行地址。
OOM
的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。cpu register
)实现的。**虚拟机栈:**指的是每个线程运行时所需要的内存空间。
「栈帧」,对应的是方法调用和方法执行背后的数据结构,每个栈帧中都存储了:方法的局部变量表、操作数栈、动态连接、方法返回地址等信息。
每一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机栈中从入栈到出栈的过程,无论方法正常完成还是异常完成都算作方法结束。
栈可能发生两种错误(error):
和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。
方法执行完毕后相应的栈帧也会出栈并释放内存空间,也会出现 StackOverFlowError
和 OutOfMemoryError
两种错误。
Java 虚拟机所管理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
为什么说是几乎所有对象实例都存在于堆中呢?
这是因为 HotSpot 虚拟机引入了 JIT 编译器优化之后,会对对象进行逃逸分析,如果发现方法中的对象引用没有被返回或者被外部使用(未逃逸),那么对象可以直接在栈上分配内存。
「方法区」属于是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域,在不同的虚拟机实现上,方法区的实现是不同的。
当虚拟机要使用一个类时,需要加载字节码(class)文件获取类相关信息,将类相关信息存储到方法区中。
方法区存储已经被 JVM 加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
在 HotSpot 虚拟机中,方法区的具体实现和 JDK 的版本息息相关:
为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?
因为永久代会受限于 JVM 本身设置的空间大小,有上限而无法调整,而元空间使用的是本地内存,虽然受本机可用内存的限制,但是溢出概率更小,可以加载的类更多了。
常量池表会在类加载后存放到方法区中的**「运行时常量池」**中,常量池表主要存放两个信息:
当运行时常量池无法再申请到内存时,会抛出 OutOfMemoryError
错误。
**「字符串常量池」**是 JVM 为了提升性能和减少内存消耗,针对字符串(String)专门开辟的一块区域,目的是避免字符串的重复创建。
Q:JDK 1.7 为什么要将字符串常量池移动到堆中?
主要是因为永久代(方法区实现)的 GC 回收效率太低,只有在整堆收集 (Full GC)的时候才会被执行 GC。Java 程序中通常会有大量的被创建的字符串等待回收,将字符串常量池放到堆中,能够更高效及时地回收字符串内存。
**「直接内存」**是一种特殊的内存缓冲区,并不在 Java 堆或方法区中分配的,而是通过 JNI 的方式在本地内存上分配的。
直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError
错误出现。
「直接内存」通过DirectByteBuffer 对象的 allocateDirect() 方法进行分配内存,基于通道(Channel) 与 缓存区(Buffer)的 I/O 方式,直接操作本地内存,无需将数据拷贝到 JVM 堆内存中,从而避免频繁的内存拷贝,提高程序的性能。
注意事项:
对象的创建流程(五步)、内存分配方式和线程安全实现、对象访问方式。
Java 对象(仅限于普通 Java 对象,不包括数组和Class对象等)的创建过程;
方法,把对象按照程序员的意愿进行初始化(赋值),这样一个真正可用的对象才算完全产生出来类加载完成后,对新建对象在 Java 堆中分配内存空间,分配方式有两种,区别是 Java 堆是否规整,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定:
「指针碰撞」,是假设 Java 堆中的内存是一个绝对规整的空间,所有被使用过的内存被放在一边,空闲的内存被放在另外一边,在使用过的内存和空闲内存之间放着一个指针,作为已使用和未使用空间的分界点。
在进行内存分配时,只需将指针朝着未使用的方向移动一段和对象大小相等的距离即可。
使用该分配方式的 GC 收集器:Serial, ParNew。
「空闲列表」,虚拟机维护一个列表,记录哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并且更新列表上的记录。
使用该分配方式的 GC 收集器:CMS。
在实际开发过程中,创建对象是很频繁的事情,在进行内存分配时,需要保证线程安全,通常来讲,虚拟机采用两种方式来保证线程安全:
在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:
Java 程序通过操作栈上栈帧中的 reference 数据来操作堆上的具体对象, reference 是指向对象的引用 ,目前主流的访问方式有:句柄、直接指针。
HotSpot 虚拟机主要使用的是直接指针来进行对象访问。
使用句柄是在 Java 堆中划分出一块内存作为句柄池,reference 中存储的句柄池中的句柄地址,句柄包含了对象实例数据和对象类型数据的具体地址信息:
使用句柄的优点是,在发生对象移动时只会修改句柄池中句柄对实例数据的引用地址,reference中存储的地址不会修改;
使用直接指针是在堆中直接找到对象的内存地址,通过对象可以访问到对象类型数据的信息:
使用直接引用的优点是,访问速度快,节省了一次指针定位的时间开销;
对象引用算法、垃圾回收算法、垃圾收集器。
如果没有特殊说明,都是针对的是 HotSpot 虚拟机。
常见面试题:
Java 的**「自动内存管理」**主要是针对 Java 堆中的对象内存的分配、回收, Java 堆被划分为了几个不同的区域,这样我们就可以根据各个区域的特点选择合适的垃圾收集算法。
在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:
下图所示的 Eden 区、两个 Survivor 区 S0 和 S1 都属于新生代,中间一层属于老年代,最下面一层属于永久代。
新生代:老年代 = 1
: 2
.
新生代中的,Eden : From-Survivor 1 : To-Survivor 1 = 8
: 1
: 1
.
针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:
部分收集 (Partial GC):
新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。
整堆收集 (Full GC):收集整个 Java 堆和方法区。
当对象在 Java 堆中进行内存分配时,遵循的规则如下:
存活的对象年龄必须达到阈值才能晋升到老年代中吗?
不是,如果 survivor 区域空间中,小于或等于某个年龄的所有对象总和的内存大小超过了 survivor 内存空间的一半,那么年龄大于或等于这个年龄的对象可以直接进入老年代中。
空间分配担保是什么?为什么需要有空间分配担保机制呢?
在进行 Minor GC 之前,JVM 首先会检查老年代中的最大可用连续空间是否大于新生代中所有对象的内存空间之和。
什么时候会触发 YGC 和 FGC?对象什么时候会进入老年代?
堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)。
在对象头中添加一个引用计数器:
这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间循环引用的问题。
所谓「对象之间的相互引用」问题,是除了对象 obj1
和 obj2
相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为 0,于是引用计数算法无法通知 GC 回收器回收他们。
「可达性分析算法」是指,以 GC ROOTS 对象作为起点,根据引用关系向下搜索,搜索过程走过的路径称之为「引用链」。当一个对象和 GC ROOTS 之间没有引用链相连时,则证明此对象是可被回收的。
哪些对象可以作为 GC Roots 呢?
对象可以被回收,就代表一定会被回收吗?
即使在可达性分析法中被判定为不可达的对象,也不一定会被回收。
一个对象真正被回收之前,至少需要经历两次标记过程:
为什么不推荐使用 finalize() 方法呢?
任何一个对象的 finalize() 方法都只会被系统自动调用一次,如果对象面临下一次回收,对象的 finalize() 方法就不会被再执行。并且,它的运行代价高昂、不确定性大,无法保证各个对象的调用顺序。
判定对象的存活都与**「引用」**有关,引用分为:强引用、软引用、弱引用、虚引用,四种(引用强度逐渐减弱、强软弱虚)。
方法区中的垃圾回收成果很低,主要回收两部分内容:废弃的常量、废弃的类型。
**「标记-清除」**算法(Mark and Sweep),分为两个阶段:标记(mark)、清除(sweep)。
首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收没有被标记的对象,标记就是判断对象是否属于垃圾的判定过程。
会有两个明显的问题:
为了解决「标记-清除」算法的效率和内存碎片问题,提出了**「标记-复制」**算法。
**「标记-复制」**算法(Mark and Copy),是指将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把这次使用的空间一次清理掉,每次的内存回收都是对内存区间的一半进行回收。
仍然有两个问题:
「标记-复制」算法在对象存活率较高时,就需要进行较多的复制操作,效率会很低,提出了「标记-整理」算法。
**「标记-整理」**算法(Mark and Compact),是根据老年代的存活特点。首先,标记完存活的对象后,不直接对可回收的对象进行清理,而是将所有存活的对象都向一端移动,然后直接清理掉其他地方的内存。
由于多了整理这一步,因此效率也不高,适合老年代这种垃圾回收频率不是很高的场景。
「分代收集」将 Java 堆分为新生代、老年代,可以根据各个年代的特点选择合适的垃圾收集算法。
为什么需要分代收集算法?
分代的垃圾回收策略,是基于不同对象的生命周期是不一样的,对于不同生命周期的对象可以采取不同的收集方式,以提高回收效率。
分代收集下的新生代和老年代应该采用什么样的垃圾回收算法?
**「新生代」**的内存按照 8:1:1
的比例分为一个 Eden 区和两个 survivor(survivor0、 survivor1)区。
大部分对象在 Eden 区中生成,回收时先将 Eden 区存活对象复制到一个 survivor0 区,然后清空 Eden 区,当这个 survivor0 区也存放满的时候,则将 Eden 区和 survivor0 区存活对象复制到另一个 survivor1 区,然后清空 Eden 区 和这个 survivor0 区,此时 survivor0 区是空的,然后将 survivor0 区和 survivor1 区交换,即保持 survivor1 区为空, 如此往复。
**「老年代」**中存放的都是一些生命周期较长的对象,老年代的存储空间也比新生代也大很多(大概比例是2 : 1),当老年代内存满时触发 Major GC,Major GC 发生频率比较低,因为老年代对象存活时间比较长,存活率标记高。
所有的垃圾收集器在进行**「根节点枚举」时,都必须暂停用户线程( STW ),并且,根节点枚举是在一个能保障一致性的快照**中进行,这个快照保证在执行枚举根节点过程中,根节点集合的对象引用关系不会出现不断变化的情况。
JVM 是通过一组称为「OopMap」的数据结构,来快速完成根节点枚举操作。
OopMap (Object Pointer Map),用于记录方法中栈帧的对象引用和堆中的对象之间的映射关系,通常是由编译期完成的,在编译阶段就能确定一个方法中包含哪些对象引用。在进行垃圾回收时,垃圾回收器会使用 OopMap 来快速判断对象是否可以被回收,提高垃圾回收的效率和准确性。
在 OopMap 的协助下,可以快速完成根节点枚举,但是如果为每一条指令都生成对应的 OopMap ,需要大量额外的存储空间,所以引入了「安全点」。
只有在**「安全点」**才会生成 OopMap ,保证了在用户程序执行时,并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是只有当指令到达安全点后才能暂停用户程序。
使用安全点的设计,似乎完美解决了停顿用户线程。但是,可能出现刚过安全点,CPU 时间片用完,导致无法继续响应 JVM 的请求,于是引入「安全区域」。
当进入到安全点后,用户线程停顿后,「安全区域」保证停顿时间内引用关系不会发生变化。在这个安全区域中,任意地方开始垃圾收集都是安全的,可以将安全区域理解成拉伸了的安全点。
为了解决对象的跨域(跨代)引用所带来的问题,设计了一个名为**「记忆集」**(Remembered Set)的数据结构,用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。
「卡表」(card table),就是记忆集中的一种具体实现。
在发生垃圾收集时,只要筛选出卡表变脏的元素,就能轻易得到哪些卡页内存块中包含跨代指针,把它们加入 GC ROOTS 中一并扫描。
垃圾收集算法是内存回收的方法论,垃圾收集器是内存回收的具体实现。
「Serial」(串行)收集器,是一个单线程的垃圾收集器,只会使用一个线程(GC 线程)去完成垃圾回收的工作,并且在进行垃圾收集时必须暂停其它所有的工作线程(Stop the World!)。
「ParNew」是 Serial 的多线程版本,除了在进行垃圾收集时候可以使用多线程并行进行垃圾回收,其余和 Serial 完全一样;
「ParallelScavenge」也是一个多线程的收集器,注重吞吐量优先的垃圾收集器。
「吞吐量」(ThroughPut)是指,处理器用于运行用户代码的时间与处理器总消耗时间的比值,高吞吐量可以最高效率的利用处理器资源,尽快的完成程序的运算任务,主要适用于在后台运算而不需要太多和用户交互的场景。
**「Serial Old」**是 Serial 的老年代版本,也是一个单线程垃圾收集器。
**「Parallel Old」**是 Parallel Scavenge 的老年代版本,支持多线程并行垃圾收集。
在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器
「CMS」(Concurrent Mark Sweep)收集器是基于标记-清除 算法 ,一种以获取最短回收停顿时间为目标的收集器,非常符合在注重用户体验的应用上使用。
是 HotSpot 虚拟机第一款真正意义上的并发收集器,第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。CMS 的工作流程分为四步:
初始标记和重新标记两个阶段需要 STW,耗时最长的并发标记和并发清除,都不需要停顿,可以和用户线程一起工作。
「G1」 (Garbage-First) 是,一款面向服务器的垃圾收集器,能满足回收停顿时间的要求同时还能具备高吞吐量。
G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的。
G1 收集器的运作大致分为以下几个步骤:初始标记、并发标记、最终标记、筛选回收。
「G1」 是面向局部收集的设计思路和基于 Region 的内存布局形式,G1 垃圾收集器不再区分新生代和老年代,是面向堆内存中的所有区域进行垃圾回收。
G1 将 Java 堆分为多个大小相等的独立区域(Region),每一个 Region 都可以根据需要,扮演新生代(Eden 、 Survivor)或者老年代空间。以 Region 作为每次垃圾回收的最小单元,跟踪每个 Region 区域里所能释放的空间以及回收时间,在后台维护一个优先级列表,优先处理回收价值最大的 Region 区域。
从 JDK9 开始,G1 垃圾收集器成为了默认的垃圾收集器。
加载、连接(验证、准备、解析)、初始化。
字节码文件加载到 JVM 中,需要三个步骤:
「加载」是类加载过程的第一步,主要完成以下三件事:
为什么 JVM 允许还没有进行验证、准备和解析的类信息放入方法区呢?
是因为加载阶段和连接阶段的部分动作(比如一部分字节码文件格式验证动作)是交叉进行的,也就是说加载阶段还没完成,链接阶段可能已经开始了。但这些夹杂在加载阶段的动作(验证文件格式等)仍然属于连接操作。
「连接」(Linking)阶段,又可细分为三个:验证、准备、解析。
「验证」是指,确保当前字节码文件中的各种数据信息符合虚拟机的规范,不会危害虚拟机自身的安全。
验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用-Xverifynone
参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
验证阶段大致会完成 4
个阶段的检验动作:
**「准备」**是指,为类的静态变量(被 static 修饰的变量)分配内存,并将其初始化为默认值,这些内存都将在方法区中分配。
0
、0L
、null
、false
等),而不是被在 Java 代码中被显式地赋予的值;final
修饰的,并且只有是基本数据类型或者是字符串常量,才会被初始化为设定的值;static
变量是被 final
修饰的,但是属于引用类型,赋值动作也会在初始化阶段完成。「解析」是指,JVM 将常量池内的符号引用替换为直接引用的过程。
**「初始化」**是指,执行
方法的过程,也是真正开始执行类中定义的 Java 程序代码。
方法 是编译期将这个类的所有静态变量和静态代码块合并到一起并产生的,编译期收集的顺序是由程序代码在文件中出现的顺序决定的。静态语句块中只能访问到定义在静态语句块之前的变量,定义在静态代码块后的变量,静态代码块只能对其赋值,不能访问。
,也不是必须的,如果一个类中没有静态代码块和静态变量,编译期就可以不为这个类生成
方法。
在多个线程同时去对一个类执行
方法的时候,只有一个线程能够去执行,其余的线程都会被阻塞,确保一个类加载只会执行一次。
启动类加载器(BootstrapClassLoader)、扩展类加载器(ExtentionClassLoader)、应用程序类加载器(ApplicationClassLoader)。
「类加载器」是负责加载 Java 类到 JVM 中并执行,每一个 Java 类都有一个指针指向加载它的类加载器(ClassLoader)。
「类加载器」的主要作用就是加载 Java 类的字节码( .class
文件)到 JVM 中(在内存中生成一个代表该类的 Class
对象)。
其实除了加载类之外,类加载器还可以加载 Java 应用所需的资源如文本、图像、配置文件、视频等等文件资源。
JVM 启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载,大部分类在具体用到的时候才会去加载,这样对内存更加友好。
对于已经加载的类会被放在 ClassLoader 中。
在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载,对于一个类加载器来说,相同二进制名称的类只会被加载一次。
在 JVM 中内置了三个重要的 ClassLoader :
名称 | 加载哪儿的类 | 说明 |
---|---|---|
BootstrapClassLoader | %JAVA_HOME%/lib | 启动类加载器,最顶层的加载类,由 C++实现,通常表示为 null,并且没有父级,主要用来加载 JDK 内部的核心类库以及被 -Xbootclasspath 参数指定的路径下的所有类。 |
ExtensionClassLoader | %JRE_HOME%/lib/ext | 扩展类加载器,主要负责加载 目录下的 jar 包和类以及被 java.ext.dirs 系统变量所指定的路径下的所有类。 |
AppClassLoader | classpath | 应用程序类加载器,面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。 |
除了这三种类加载器之外,用户还可以加入自定义的类加载器来进行拓展,以满足自己的特殊需求。
除了 BootstrapClassLoader 是 JVM 自身的一部分之外,其他所有的类加载器都是在 JVM 外部实现的,并且全都继承自 ClassLoader 抽象类,这样做的好处是用户可以自定义类加载器,以便让应用程序自己决定如何去获取所需的类。
每个 ClassLoader 可以通过 getParent() 方法获取其父类加载器,如果获取父类加载器为 null
的话,那么该类是通过 BootstrapClassLoader
加载的。
public abstract class ClassLoader {
...
// 父加载器
private final ClassLoader parent;
@CallerSensitive
public final ClassLoader getParent() {
//...
}
...
}
为什么 获取到 ClassLoader 为null
就是 BootstrapClassLoader 加载的呢?
这是因为 BootstrapClassLoader 是由 C++ 实现,由于这个 C++ 实现的类加载器在 Java 中是没有与之对应的类的,所以拿到的结果是 null。
如果需要自定义实现自己的类加载器,只需要继承 ClassLoader 抽象类,重写里面的方法。
「双亲委派模型」是指 JVM 中各种类加载器之间的层次关系。
类加载器之间的父子关系一般不是以继承的关系来实现的,而是通常使用组合关系来复用父加载器的代码。
双亲委派模型,保证了 Java 程序的稳定运行,可以避免类的重复加载,也保证了 Java 的核心 API 不被篡改。
双亲委派模型的执行流程:
loadClass()
方法来加载类),这样的话,所有的请求最终都会传送到顶层的启动类加载器 BootstrapClassLoader
中。findClass()
方法来加载类)。protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 1、首先,检查该类是否已经加载过
Class c = findLoadedClass(name);
if (c == null) {
// 2、如果 c 为 null,则说明该类没有被加载过
long t0 = System.nanoTime();
try {
if (parent != null) {
// 3、当父类的加载器不为空,则通过父类的loadClass来加载该类
c = parent.loadClass(name, false);
} else {
// 4、当父类的加载器为空,则调用启动类加载器来加载该类
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
//非空父类的类加载器无法找到相应的类,则抛出异常
}
if (c == null) {
//当父类加载器无法加载时,则调用findClass方法来加载该类
//用户可通过覆写该方法,来自定义类加载器
long t1 = System.nanoTime();
c = findClass(name);
//用于统计类加载器相关的信息
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
//对类进行link操作
resolveClass(c);
}
return c;
}
}
JVM 判定两个 Java 类是否相同的具体规则:JVM 不仅要看类的全名是否相同,还要看加载此类的类加载器是否一样。
只有两者都相同的情况,才认为两个类是相同的,即使两个类来源于同一个 Class
文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相同。
如果不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。
如果想打破双亲委派模型则需要重写 loadClass() 方法。
为什么是重写 loadClass() 方法打破双亲委派模型呢?
因为类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器 *loadClass()*方法来加载类)。
Tomcat 服务器为了能够优先加载 Web 应用目录下的类,然后再加载其他目录下的类,就自定义了类加载器 WebAppClassLoader
来打破双亲委托机制。
提示:这里对文章进行总结: