JVM 可以运行多种语言吗?
JVM 只识别字节码,所以 JVM 其实跟语言是解耦的,也就是没有直接关联,只要符合字节码规范,都可以由 JVM 运行。像 scala、Groovy、Kotlin 等语言都可以在 JVM 上运行。
谈谈 JMM
JMM是 JAVA 内存模型(JAVA Memory Model),目的是为了屏蔽各种硬件和操作系统之间的内存访问差异,从而让 JAVA 程序在各种平台对内存的访问一致。
Java 内存模型规定所有的变量都存储在主内存中(包括实例变量,静态变量)。每个线程都有自己的工作内存,线程的工作内存保存了该线程用到的变量(局部变量)和主内存的副本拷贝,线程对变量的操作都在工作内存中进行。线程不能直接读写主内存中的变量。 每个线程的工作内存都是独立的,线程操作数据只能在工作内存中进行,然后刷回到主存。这是 Java 内存模型定义的线程基本工作方式。不要把 JMM 与 JVM 的内存结构混淆了(堆、栈、程序计数器等)。一般问 JMM 是想问多线程、并发相关的问题。
追问1:JMM 是真实存在的嘛,和 JVM 内存模型(JAVA 虚拟机内存模型)是一样的嘛?
不是真实存在的,JMM 讲的也只是一种模型,真实的实现可能还是和模型会有差异的。JMM 和 JVM 是不一样的,它们并不是同一个层次的划分,基本上没啥关系。
JVM 内存模型
JVM 管理的内存包括 Java 运行时数据区,运行时数据区分为线程共享区域和线程私有区域,线程共享区域包括方法区和堆,线程私有区域包括程序计数器、虚拟机栈、本地方法栈。线程共享区域是非线程安全的,线程私有区域是线程安全的。
虚拟机栈:(生命周期与线程相同)Java 中每个方法执行的时候,Java 虚拟机都会同步创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
本地方法栈:本地方法栈是和虚拟机栈非常相似的一个区域,它服务的对象是 native 方法(使用 C/C++ 编写的方法)。
程序计数器:保存下一条需要执行的字节码指令,是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都是依赖程序计数器。
方法区:JDK 1.7 及之前叫作 ”永久代“,JDK 1.8 及以后叫作 ”元空间“,用来存储已经被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
堆:堆用于存放对象实例,是垃圾收集器管理的主要区域,因此也被称作 GC 堆。
通过 -Xms 设定程序启动时占用内存大小,通过 -Xmx 设定程序运行期间最大可占用的内存大小。如果程序运行需要占用更多的内存,超出了这个设置值,就会抛出 OutOfMemory 异常。
java -Xms1M -Xmx2M
追问1:程序计数器可以为空吗?
可以为空,当执行本地方法时。原因是因为,程序计数器存放当 Java 字节码的地址,而 native方法的方法体是非 Java 的,所以程序计数器的值才未定义,可以为空。
追问2:堆中又怎么细分的?
堆中可以细分为新生代和老年代,其中新生代又分为 Eden 区,From Survivor 和 To Survivor 区,比例是 8:1:1.
追问3:JVM 中哪些内存区域会内存溢出(Out Of Memory)?
除了程序计数器不会产生 OOM,其他区域都会产生 OOM.
说一下堆栈的区别?
- 堆的物理地址分配是不连续的,性能较慢;栈的物理地址分配是连续的,性能相对较快。
- 堆存放的是对象的实例和数组;栈存放的是局部变量,操作数栈,返回结果等。
- 堆是线程共享的;栈是线程私有的。
追问1:什么情况下会发生栈溢出?
- 当线程请求的栈深度超过了虚拟机允许的最大深度时,会抛出 StackOverFlowError 异常。这种情况通常是因为方法递归没终止条件。
- 新建线程的时候没有足够的内存去创建对应的虚拟机栈,虚拟机会抛出 OutOfMemoryError 异常。比如线程启动过多就会出现这种情况。
Java 中对象的创建过程是什么样的?
Java 中对象的创建过程为 5 步:
- 类加载检查:检查是否被加载过,没被加载过要进行类加载。首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
- 分配内存:把一块内存从堆里分配出来,大小在类加载完就可确定。
- 初始化零值:将分配到的内存空间都初始化为零值(不包括对象头),保证了对象不赋初值就可以直接用。
- 设置对象头:对象是哪个类的实例、如何才能找到类的元数据信息、对象的 GC 分代年龄等信息放在对象头中。
- 执行 init() 方法:初始化对象。
追问1:内存分配的策略有哪些?
Java 中的内存分配策略主要有两种,分别是指针碰撞和空闲列表:
- 指针碰撞:假设 Java 堆中的内存都是规整的,所有被使用过的放在一边,未使用过的放另一在边,中间有一个指针作为分界,分配内存仅仅需要把这个指针向空闲空间方向移动一段即可。
- 空闲列表:如果 Java 堆中的内存不是规整的,已使用过的和空闲的交错,虚拟机就需要维护一个列表,记录哪些内存是可用的,在分配的时候找到足够大的一块内存进行分配。
追问2:对象头包含哪些?
虚拟机中对象头包含两类信息,第一类是用于存储对象自身运行时数据、如哈希码、GC 分代年龄、线程持有的锁、偏向线程ID、偏向时间戳。 对象的另外一部分是类型指针,即对象指向它的类型元数据的指针。
追问3:对象的访问定位方法有几种,各有什么优缺点?
Java 虚拟机中对象的访问方式有 ①使用句柄和 ②直接指针两种。
- 句柄: 如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。
- 直接指针: 如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象的地址。
总结:使用句柄最大的好处就是 reference 中存储的是稳定句柄地址,在对象移动时只会改变句柄中的实例数据指针,而 reference 本身不需要被修改。使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。
JVM 在创建对象时采用了哪些并发安全机制?
1. CAS + 失败重试 线程 1 在读取一块内存空间的时候还没有分配给对象,然后比较一次以防止预处理过程中有线程(如线程 2)抢占了该块空间,如果读到值不为 null,即不相等,就再读取一次,如果这时候有值说明空间已被抢占了,就寻找下一块空间,否则,分配此块空间给线程创建的对象。
2. 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)
每个线程在 Java 堆中预先分配一小块内存,也就是本地线程分配缓冲,这样每个线程都单独拥有一个 Buffer,如果需要分配内存,就在自己的 Buffer 上分配,这样就不存在竞争的情况,可以大大提升分配效率。
为什么不要使用 finalize 方法?
一个对象要被回收,需要经过两次标记过程,一次是没有找到与 GC Roots 的引用链,它将被第一次标记。随后进行一次筛选(如果对象覆盖了 finalize),我们可以在 finalize 方法中去拯救(变为存活对象)。
- finalize 方法执行线程优先级很低。
- finalize 方法只能执行一次。
GC 如何判断对象可以被回收
垃圾回收的根本目的是利用一些算法进行内存的管理,从而有效的利用内存空间,在进行垃圾回收前,需要判断对象的存活情况,在 jvm 中有两种判断对象的存活算法,下面分别进行介绍。
1、引用计数法: 在对象中添加一个引用计数器,每当有一个地方引用它时计数器就加 1,当引用失效时计数器减 1。当计数器为0的时候,表示当前对象可以被回收。
这种方法的原理很简单,判断起来也很高效,但是存在两个问题:
- 堆中对象每一次被引用和引用清除时,都需要进行计数器的加减法操作,会带来性能损耗。
- 当两个对象相互引用时,计数器永远不会 0. 也就是说,即使这两个对象不再被程序使用,仍然没有办法被回收,通过下面的例子看一下循环引用时的计数问题:
public void reference(){
A a = new A();
B b = new B();
a.instance = b;
b.instance = a;
}
在方法执行完成后,栈中的引用被释放,但是留下了两个对象在堆内存中循环引用,导致了两个实例最后的引用计数都不为 0,最终这两个对象的内存将一直得不到释放,也正是因为这一缺陷,使引用计数算法并没有被实际应用在 GC 过程中。
2、可达性分析法
可达性分析算法是 jvm 默认使用的寻找垃圾的算法,需要注意的是,虽然说的是寻找垃圾,但实际上可达性分析算法寻找的是仍然存活的对象。至于这样设计的理由,是因为如果直接寻找没有被引用的垃圾对象,实现起来相对复杂、耗时也会比较长,反过来标记存活的对象会更加省时。
可达性分析算法的基本思路就是,以一系列被称为 GC Roots 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连时,证明该对象不再存活,可以作为垃圾被回收。
追问1:在 java 中,可作为 GC Roots 的对象有以下几种:
- 在虚拟机栈(栈帧的本地变量表)中引用的对象。
- 在方法区中静态属性引用的对象。
- 在方法区中常量引用的对象。
- 在本地方法栈中 JNI(native方法)引用的对象。
- jvm 内部的引用,如基本数据类型对应的 Class 对象、一些常驻异常对象等,及系统类加载器。
- 被同步锁 synchronized 持有的对象引用。
- 此外还有一些临时性的 GC Roots,这是因为垃圾收集大多采用分代收集和局部回收,考虑到跨代或跨区域引用的对象时,就需要将这部分关联的对象也添加到 GC Roots 中以确保准确性。
追问2:被标志为 GC 的对象一定会被 GC 掉吗?
不一定,还有逃脱的可能。真正宣告一个对象死亡至少经历两次标记的过程。如果对象进行可达性分析后没有与 GC Roots 相连,那么这是第一次标记,之后会在进行一次筛选,筛选的条件是是否有必要执行 finalize() 方法。
垃圾回收算法有哪些
1、标记-清除算法 标记清除算法是一种非常基础的垃圾收集算法,当堆中的有效内存空间耗尽时,会触发 STW(stop the world),然后分标记和清除两阶段来进行垃圾收集工作:
- 标记:从 GC Roots 的节点开始进行扫描,对所有存活的对象进行标记,将其记录为可达对象。
- 清除:对整个堆内存空间进行扫描,如果发现某个对象未被标记为可达对象,那么将其回收。
但是这种算法会带来几个问题:
- 在进行 GC 时会产生 STW,停止整个应用程序,造成用户体验较差。
- 标记和清除两个阶段的效率都比较低,标记阶段需要从根集合进行扫描,清除阶段需要对堆内所有的对象进行遍历。
- 仅对非存活的对象进行处理,清除之后会产生大量不连续的内存碎片。导致之后程序在运行时需要分配较大的对象时,无法找到足够的连续内存,会再触发一次新的垃圾收集动作。
此外,JVM 并不是真正的把垃圾对象进行了遍历,把内部的数据都删除了,而是把垃圾对象的首地址和尾地址进行了保存,等到再次分配内存时,直接去地址列表中分配,通过这一措施提高了一些标记-清除算法的效率。
2、复制算法
复制算法主要被应用于新生代,它将内存分为大小相同的两块,每次只使用其中的一块。在任意时间点,所有动态分配的对象都只能分配在其中一个内存空间,而另外一个内存空间则是空闲的。复制算法可以分为两步:
- 其中一块内存的有效内存空间耗尽后,JVM 会停止应用程序运行,开启复制算法的 GC 线程,将还存活的对象复制到另一块空闲的内存空间。复制后的对象会严格按照内存地址依次排列,同时 GC 线程会更新存活对象的内存引用地址,指向新的内存地址。
- 在复制完成后,再把使用过的空间一次性清理掉,这样就完成了使用的内存空间和空闲内存空间的对调,使每次的内存回收都是对内存空间的一半进行回收。
复制算法的的优点是弥补了标记清除算法中,会出现内存碎片的缺点,但是它也同样存在一些问题:
- 只使用了一半的内存,所以内存的利用率较低,造成了浪费。
- 如果对象的存活率很高,那么需要将很多对象复制一遍,并且更新它们的应用地址,这一过程花费的时间会非常的长。
从上面的缺点可以看出,如果需要使用复制算法,那么有一个前提就是要求对象的存活率要比较低才可以,因此,复制算法更多的被用于对象“朝生夕死”发生更多的新生代中。
3、标记-整理算法
标记整理算法和标记-清除算法非常的类似,主要被应用于老年代中。可分为以下两步:
- 标记:和标记-清除算法一样,先进行对象的标记,通过 GC Roots 节点扫描存活对象进行标记。
- 整理:将所有存活对象往一端空闲空间移动,按照内存地址依次排序,并更新对应引用的指针,然后清理末端内存地址以外的全部内存空间。
可以看到,标记整理算法对前面的两种算法进行了改进,一定程度上弥补了它们的缺点: - 相对于标记-清除算法,弥补了出现内存空间碎片的缺点。
- 相对于复制算法,弥补了浪费一半内存空间的缺点。
但是同样,标记整理算法也有它的缺点,一方面它要标记所有存活对象,另一方面还添加了对象的移动操作以及更新引用地址的操作,因此标记整理算法具有更高的使用成本。
4、分代收集算法 实际上,java 中的垃圾回收器并不是只使用的一种垃圾收集算法,当前大多采用的都是分代收集算法。JVM 一般根据对象存活周期的不同,将内存分为几块,一般是把堆内存分为新生代和老年代,再根据各个年代的特点选择最佳的垃圾收集算法。主要思想如下:
- 新生代中,每次收集都会有大量对象死去,所以可以选择复制算法,只需要复制少量对象以及更改引用,就可以完成垃圾收集。
- 老年代中,对象存活率比较高,使用复制算法不能很好的提高性能和效率。另外,没有额外的空间对它进行分配担保,因此选择标记-清除或标记整理算法进行垃圾收集。
至于为什么在某一区域选择某种算法,还是和三种算法的特点息息相关的,再从 3 个维度进行一下对比:
- 执行效率:从算法的时间复杂度来看,复制算法最优,标记-清除次之,标记整理最低。
- 内存利用率:标记整理算法和标记-清除算法较高,复制算法最差。
- 内存整齐程度:复制算法和标记-整理算法较整齐,标记-清除算法最差。
尽管具有很多差异,但是除了都需要进行标记外,还有一个相同点,就是在 GC 线程开始工作时,都需要 STW 暂停所有工作线程。
垃圾回收器有哪些?
垃圾回收器可以在新生代和老年代都有,在新生代有 Serial、Parallel New、Parallel Scavenge。老年代有 CMS、Serial Old、Parallel Old 还有不区分年代的 G1 算法。
追问1:CMS 垃圾回收器的过程是什么样的?会带来什么问题?
CMS 全称 Concurrent Mark Sweep,是一款并发的、使用标记-清除算法的垃圾回收器, 如果老年代使用 CMS 垃圾回收器,需要添加虚拟机参数 -“XX:+UseConcMarkSweepGC”.
CMS(Concurrent Mark Sweep) 回收过程可以分为 4 个步骤:
- 初始标记:标记出 GC Roots 能直接关联到的对象,速度很快,但需要暂停所有其他的工作线程。
- 并发标记: GC 和用户线程一起工作,执行 GC Roots 跟踪标记过程,不需要暂停工作线程。
- 重新标记:在并发标记过程中用户线程继续运作,导致在垃圾回收过程中部分对象的状态发生了变化,为了确保这部分对象的状态的正确性,需要对其重新标记并暂停工作线程。
- 并发清除:清理删除掉标记阶段判断的已经死亡的对象,这个过程用户线程和垃圾回收线程同时发生。
在整个过程中,耗时最长的是并发标记和并发清除阶段,这两个阶段垃圾收集线程都可以与用户线程一起工作,所以从总体上来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的
带来的问题:
- 产生浮动垃圾。 在并发清理阶段用户线程还在运行,会不断有新的垃圾产生,这一部分垃圾出现在标记过程之后,CMS 无法在当次收集中回收它们,只好等到下一次垃圾回收再处理。
- CMS 是基于标记-清除算法,会产生大量的空间碎片。
追问2:谈谈 G1 垃圾收集器?相比于 CMS 突出的地方是什么?
G1 垃圾收集器的目标是在不同应用场景中追求高吞吐量和低停顿之间的最佳平衡。
G1 将整个堆分成相同大小的分区(物理上不连续的),有四种不同类型的分区:Eden、Survivor、Old 和 Humongous. 分区的大小取值范围为 1M 到 32M,都是 2 的幂次方。分区大小可以通过-XX:G1HeapRegionSize 参数指定。Humongous 区域用于存储大对象。G1 规定只要大小超过了一个分区容量一半的对象就认为是大对象。
G1 收集器对各个分区回收所获得的空间大小和回收所需时间的经验值进行排序,得到一个优先级列表,每次根据用户设置的最大回收停顿时间,优先回收价值最大的分区。
特点:可以由用户指定期望的垃圾收集停顿时间。
G1 收集器的回收过程分为以下几个步骤:
- 初始标记。暂停所有其他线程,记录直接与 GC Roots 直接相连的对象,耗时较短 。
- 并发标记。从GC Roots 开始对堆中对象进行可达性分析,找出要回收的对象,耗时较长,不过可以和用户程序并发执行。
- 最终标记。需对其他线程做短暂的暂停,用于处理并发标记阶段对象引用出现变动的区域。
- 筛选回收。对各个分区的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,然后把决定回收的分区的存活对象复制到空的分区中,再清理掉整个旧的分区的全部空间。这里的操作涉及存活对象的移动,会暂停用户线程,由多条收集器线程并行完成。
相比于 CMS,G1 突出的地方:
- 基于标记-整理算法,不产生垃圾碎片。
- 可以精确的控制停顿时间,在不牺牲吞吐量的前提下实现短停顿垃圾回收。
优点:
- 并行性:G1 在回收期间,可以有多个 GC 线程同时工作,有效利用多核计算能力。此时减少用户线程 STW.
- 并发性:G1 拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况。
- 分代回收:可以同时回收新生代和老年代 。
追问3:jdk 默认使用的是哪种垃圾回收器?
jdk 1.7 默认垃圾收集器 Parallel Scavenge(新生代)+ Parallel Old(老年代)。 jdk 1.8 默认垃圾收集器 Parallel Scavenge(新生代)+Parallel Old(老年代)。 jdk 1.9 默认垃圾收集器 G1.
内存分配策略是什么样的?
- 对象优先在 Eden 分配:大多数情况下,对象在新生代 Eden 上分配,当 Eden 空间不够时,触发 Minor GC.
- 大对象直接进入老年代:大对象是指需要连续内存空间的对象,最典型的大对象有长字符串和大数组。可以设置 JVM 参数 -XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配。
- 长期存活的对象进入老年代:通过参数 -XX:MaxTenuringThreshold 可以设置对象进入老年代的年龄阈值。对象在 Survivor 区每经过一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度,就会被晋升到老年代中。
- 动态对象年龄判定:并非对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 中相同年龄所有对象大小的总和大于 Survivor 空间的一半,则年龄大于或等于该年龄的对象可以直接进入老年代,无需达到 MaxTenuringThreshold 年龄阈值。
- 空间分配担保:在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 是安全的。如果不成立的话虚拟机会查看 HandlePromotionFailure 的值是否允许担保失败。如果允许,那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 的值为不允许担保失败,那么就要进行一次 Full GC.
追问1:内存溢出与内存泄漏的区别?
内存溢出:实实在在的内存空间不足导致。
内存泄漏:该释放的对象没有释放,多见于自己使用容器保存元素的情况下。
Minor GC 和 Full GC的区别?
Minor GC:回收新生代,因为新生代对象存活时间很短,因此 Minor GC会频繁执行,执行的速度一般也会比较快。
Full GC:回收老年代和新生代,老年代的对象存活时间长,因此 Full GC 很少执行,执行速度会比 Minor GC 慢很多。
追问1:Full GC 的触发条件?
对于 Minor GC,其触发条件比较简单,当 Eden 空间满时,就将触发一次 Minor GC. 而 Full GC 触发条件相对复杂,有以下情况会发生 Full GC:
- 调用 System.gc():只是建议虚拟机执行 Full GC,但是虚拟机不一定真正去执行。不建议使用这种方式,而是让虚拟机管理内存。
- 老年代空间不足:老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等。 为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn 参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间。
- 空间分配担保失败:使用复制算法的 Minor GC 需要老年代的内存空间作担保,如果担保失败会执行一次 Full GC.
- JDK 1.7 及以前的永久代空间不足:在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 Class 的信息、常量、静态变量等数据。当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC. 如果经过 Full GC 仍然回收不了,那么虚拟机会抛出java.lang.OutOfMemoryError.
JVM 中类的加载机制是什么样的?
类的加载指的是将类的 class 文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个此类的对象,通过这个对象可以访问到方法区对应的类信息。类的加载分为 5 个阶段:
- 加载:读取 Class 文件,并根据 Class 文件描述创建对象的过程。
- 验证:确保 Class 文件符合当前虚拟机的要求。
- 准备:在方法区中为类变量分配内存空间并设置类中变量的初始值。
- 解析:JVM 会将常量池中的符合引用替换为直接引用。
- 初始化:执行类构造器方法为类进行初始化。
追问1:类加载器有哪些?
JVM 提供了三种类加载器,分别启动类加载器(Bootstrap ClassLoader)、扩展类加 载器(Extention ClassLoader)和应用类加载器(Application ClassLoader)。
Boostrap ClassLoader 是 Extention ClassLoader 的父类加载器,默认负责加载 %JAVA_HOME%/lib 下的 jar 包和 class 文件。
Exttion ClassLoader 是 AppClassLoader 的父类加载器,负责加载 %JAVA_HOME%/lib/ext 文件夹下的 jar 包和 class 类。
AppClassLoader 是自定义类加载器的父类,负载加载 classpath 下的类文件。系统类加载器,线程上下文加载器继承 AppClassLoader 实现自定义类加载器。
追问2:什么叫双亲委派机制?
双亲委派机制是指一个类在收到类加载请求后不会尝试自己加载这个类,而且把这该类加载请求委派给其父类去完成,父类在接收到该加载请求后又会将其委派给自己的父类,以此类推,这样所有的类加载请求都被向上委派到启动类加载器中。若父类加载器在接收到类加载请求后发现自己也无法加载该类,则父类会将该请求反馈给子类向下委派子类加载器加载该类,直到被加载成功,若找不到会曝出异常。
双亲委托机制的优点:
- 通过带有优先级的层级关可以避免类的重复加载。
- 保证 Java 程序安全稳定运行,Java 核心 API 定义类型不会随意替换。
追问3:如何打破双亲委派机制?
重写一个类继承 ClassLoader,并重写 loadClass 方法(Tomcat 是不支持双亲委派机 制的)。
|