一、java
1.1 什么是虚拟机/h2>
Java 虚拟机,是一个可以执行 Java 字节码的虚拟机进程。Java 源文件被编译成能被 Java 虚拟机执行的字节码文件( )。
Java 被设计成允许应用程序可以运行在任意的平台,而不需要程序员为每一个平台单独重写或者是重新编译。Java 虚拟机让这个变为可能,因为它知道底层硬件平台的指令长度和其他特性。
但是,跨平台的是 Java 程序(包括字节码文件),而不是 JVM。JVM 是用 C/C++ 开发的,是编译后的机器码,不能跨平台,不同平台下需要安装不同版本的 JVM 。
- 也就是说,JVM 能够跨计算机体系结构来执行 Java 字节码,主要是由于 JVM 屏蔽了与各个计算机平台相关的软件或者硬件之间的差异,使得与平台相关的耦合统一由 JVM 提供者来实现。
1.2 怎样通过 Java 程序来判断 JVM 是 32 位 还是 64 位/h2>
Sun 有一个 Java System 属性来确定JVM的位数:32 或 64。
我可以使用以下语句来确定 JVM 是 32 位还是 64 位:
32 位 JVM 和 64 位 JVM 的最大堆内存分别是多数/strong>
理论上说上 32 位的 JVM 堆内存可以到达 2^32,即 4GB ,但实际上会比这个小很多。不同操作系统之间不同,如 Windows 系统大约 1.5 GB,Solaris 大约 3GB。
64 位 JVM 允许指定最大的堆内存,理论上可以达到 2^64 ,这是一个非常大的数字,实际上你可以指定堆内存大小到 100GB 。甚至有的 JVM,如 Azul ,堆内存到 1000G 都是可能的。
64 位 JVM 中,int 的长度是多数/strong>
Java 中,int 类型变量的长度是一个固定值,,都是 32 位或者 4 个字节。意思就是说,在 32 位 和 64 位 的Java 虚拟机中,int 类型的长度是相同的。
二、Java 内存区域与内存溢出异常
2.1 JVM 运行内存的分类/h2>
JVM 运行内存的分类如下图所示:
2)为对象分配内存
类加载完成以后,虚拟机就开始,此时所需内存的大小就已经确定了。只需要在堆上分配所需要的内存即可。
具体的分配内存有两种情况:第一种情况是内存空间绝对规整,第二种情况是内存空间是不连续的。
- 对于内存绝对规整的情况相对简单一些,虚拟机只需要在被占用的内存和可用空间之间移动指针即可,这种方式被称为“”。
- 对于内存不规整的情况稍微复杂一点,这时候虚拟机需要维护一个列表,来记录哪些内存是可用的。分配内存的时候需要找到一个可用的内存空间,然后在列表上记录下已被分配,这种方式成为“”。
多线程并发时会出现正在给对象 A 分配内存,还没来得及修改指针,对象 B 又用这个指针分配内存,这样就出现问题了。解决这种问题有两种方案:
第一种,是采用同步的办法,使用 CAS 来保证操作的原子性。
另一种,是每个线程分配内存都在自己的空间内进行,即是每个线程都在堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local
Allocation Buffer, TLAB),分配内存的时候再TLAB上分配,互不干扰。可以通过 -XX:+/-UseTLAB
参数决定。
3)为分配的内存空间初始化零值
对象的内存分配完成后,还需要将对象的内存空间都,这样能保证对象。
4)对对象进行其他设置
分配完内存空间,初始化零值之后,虚拟机还需要对对象进行其他必要的设置,设置的地方都在,包括这个对象所属的类,类的元数据信息,对象的 hashcode ,GC 分代年龄等信息。
5)执行 init 方法
执行完上面的步骤之后,在虚拟机里这个对象就算创建成功了,但是对于 Java 程序来说还需要,因为这个时候对象只是被初始化零值了,还没有真正的去根据程序中的代码,调用了 init 方法之后,这个对象才真正能使用。
2.4 对象的内存布局是怎样的/h2>
对象头:对象头包括两部分信息。
- 第一部分,是存储对象自身的运行时数据,如哈希码,GC 分代年龄,锁状态标志,线程持有的锁等等。
- 第二部分,是类型指针,即对象指向类元数据的指针。
实例数据:就是数据。
对齐填充:不是必然的存在,就是为了对齐。
2.5 对象是如何定位访问的/h2>
对象的访问定位有两种:
- 句柄定位:Java 堆会画出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。
- 直接指针访问:Java 堆对象的不居中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象地址。
- 使用句柄来访问的最大好处,就是 reference 中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而 reference 本身不需要修改。
- 使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在 Java 中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。
2.6 有哪些 OutOfMemoryError 异常/h2>
在 Java 虚拟机中规范的描述中,除了程序计数器外,虚拟机内存的其它几个运行时区域都有发生的 OutOfMemoryError(简称为“OOM”) 异常的可能。
- Java 堆溢出
- 虚拟机栈和本地方法栈溢出
- 方法区和运行时常量池溢出
从 JDK8 开始,就变成元数据区的内存溢出。
- 本机直接内存溢出
1)Java 堆溢出
《Java 堆溢出》 文章。
另外,Java 堆溢出的原因,有可能是内存泄露,可以使用 MAT 进行分析。
2)虚拟机栈和本地方法栈溢出
由于在 HotSpot 虚拟机中并不区分虚拟机栈和本地方法栈,因此,对于 HotSpot 来说,虽然 参数(设置本地方法栈大小)存在,但实际上是无效的,栈容量只由 参数设定。
关于虚拟机栈和本地方法栈,在 Java 虚拟机规范中描述了两种异常:
- 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError 异常。
StackOverflowError 不属于 OOM 异常。 - 如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出 OutOfMemoryError 异常。
参见 《OutOfMemoryError 异常 —— 虚拟机栈和本地方法栈溢出》 文章。
3)运行时常量池溢出
因为 JDK7 将常量池和静态变量放到 Java 堆里,所以无法触发运行时常量池溢出。如果想要触发,可以使用 JDK6 的版本。
4)方法区的内存溢出
因为 JDK8 将方法区溢出,所以无法触发方法区的内存溢出溢出。如果想要触发,可以使用 JDK7 的版本。
5)元数据区的内存溢出
实际上,方法区的内存溢出在 JDK8 中,变成了元数据区的内存溢出。需要增加 VM 配置项。
6)本机直接内存溢出
当出现了内存溢出,你怎么排错/strong>
1、首先,控制台查看错误日志。
2、然后,使用 JDK 自带的 工具查看系统的堆栈日志。
3、定位出内存溢出的空间:堆,栈还是永久代(JDK8 以后不会出现永久代的内存溢出)。
- 如果是堆内存溢出,看是否创建了超大的对象。
- 如果是栈内存溢出,看是否创建了超大的对象,或者产生了死循环。
三、垃圾收集器与内存分配策略
3.1 什么是垃圾回收机制/h2>
Java 中对象是采用 或者的方法创建的,这些对象的创建都是在堆(Heap)中分配的,所有对象的回收都是由 Java 虚拟机通过垃圾回收机制完成的。GC 为了能够正确释放对象,会监控每个对象的运行状况,对他们的申请、引用、被引用、赋值等状况进行监控。
Java 程序员不用担心内存管理,因为垃圾收集器会自动进行管理。
可以调用下面的方法之一: 或 ,但 JVM 也可以屏蔽掉显示的垃圾回收调用。
为什么不建议在程序中显式的声明 System.gc() /strong>
因为显式声明是做堆内存全扫描,也就是 ,是需要停止所有的活动的(Stop The World Collection),对应用很大可能存在影响。
另外,调用 System.gc() 方法后, Full GC ,而是虚拟机自己决定的。
如果一个对象的引用被设置为 null , GC 会立即释放该对象的内存么/strong>
不会, 这个对象将会在下一次 GC 循环中被回收。
finalize() 方法什么时候被调用的目的是什么/strong>
finallize() 方法,是在释放该对象内存前由 GC (垃圾回收器)调用。
通常建议在这个方法中释放该对象持有的资源,例如持有的堆外内存、和远程服务的长连接。
一般情况下,不建议重写该方法。
对于一个对象,该方法。
3.2 如何判断一个对象是否已经死去/h2>
有两种方式:引用计数,可达性分析
1)引用计数
每个对象有一个引用计数属性,新增一个引用时计数加 1 ,引用释放时计数减 1 ,计数为 0 时可以回收。此方法简单,无法解决对象的问题。目前在用的有 Python、ActionScript3 等语言。
2)可达性分析(Reachability Analysis)
从 GC Roots 开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的。不可达对象。目前在用的有 Java、C# 等语言。
如果 A 和 B 对象循环引用,是否可以被 GC/strong>
可以,因为 Java 采用可达性分析的判断方式。
在 Java 语言里,可作为 GC Roots 的对象包括以下几种/strong>
虚拟机栈(栈帧中的本地变量表)中引用的对象。
方法区中的类静态属性引用的对象。
方法区中常量引用的对象。
本地方法栈中 JNI(即一般说的 Native 方法)中引用的对象。
方法区是否能被回收/strong>
方法区可以被回收,但是价值很低,主要回收废弃的常量和无用的类。
- 如何判断无用的类,需要完全满足如下三个条件:
该类所有都被回收(Java 堆中没有该类的对象)。
加载该类的 已经被回收。
该类对应的 java.lang.Class 对象没有在任何地方被,无法在任何地方利用反射访问该类。
3.3 Java 对象有哪些引用类型/h2>
1)强引用
当内存空间不足,Java 虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。
2)软引用(SoftReference)
如果一个对象只具有软引用,那就类似于可有可无的生活用品。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的。
软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA 虚拟机就会把这个软引用加入到与之关联的引用队列中。
3)弱引用(WeakReference)
如果一个对象只具有弱引用,那就类似于可有可无的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它 所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。
弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。
4)虚引用(PhantomReference)
“虚引用”顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。
虚引用主要用来跟踪对象被垃圾回收的活动。虚引用与软引用和弱引用的一个区别在于:虚引用和引用队列(ReferenceQueue)联合使用。当垃 圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
WeakReference 与 SoftReference的区别/strong>
虽然 WeakReference 与 SoftReference 都有利于提高 GC 和 内存的效率。
但是 WeakReference 一旦失去最后一个强引用,就会被 GC 回收
而 SoftReference 虽然不能阻止被回收,但是可以延迟到 JVM 内存不足的时候。
为什么要有不同的引用类型/strong>
不像 C 语言,我们可以控制内存的申请和释放,在 Java 中有时候我们需要适当的控制对象被回收的时机,因此就诞生了不同的引用类型,可以说不同的引用类型实则是对 GC 回收时机不可控的妥协。有以下几个使用场景可以充分的说明:
- 利用软引用和弱引用解决 OOM 问题。用一个 HashMap 来保存图片的路径和相应图片对象关联的软引用之间的映射关系,在内存不足时,JVM 会自动回收这些缓存图片对象所占用的空间,从而有效地避免了 OOM 的问题.
- 通过软引用实现 Java 对象的高速缓存。比如我们创建了一 Person 的类,如果每次需要查询一个人的信息,哪怕是几秒中之前刚刚查询过的,都要重新构建一个实例,这将引起大量 Person 对象的消耗,并且由于这些对象的生命周期相对较短,会引起多次 GC 影响性能。此时,通过软引用和 HashMap 的结合可以构建高速缓存,提供性能。
3.4 JVM 垃圾回收算法/h2>
有四种算法:标记-清除算法;标记-整理算法;复制算法;分代收集算法
1)标记-清除算法
标记-清除(Mark-Sweep)算法,是现代垃圾回收算法的思想基础。
标记-清除算法将垃圾回收分为两个阶段:和
一种可行的实现是,在标记阶段,首先通过根节点,标记所有从根节点开始的可达对象。因此,未被标记的对象就是未被引用的垃圾对象(好多资料说标记出要回收的对象,其实明白大概意思就可以了)。然后,在清除阶段,清除所有未被标记的对象。
- 缺点:
- 1、效率问题,标记和清除两个过程的效率都不高。
- 2、空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大的对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
2)标记-整理算法
标记整理算法,类似与标记清除算法,不过它标记完对象后,不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。
-优点:
-1、相对标记清除算法,解决了内存碎片问题。
-2、没有内存碎片后,对象创建内存分配也更快速了(可以使用TLAB进行分配)。
-缺点:
-1、效率问题,(同标记清除算法)标记和整理两个过程的效率都不高。
3)复制算法
复制算法,可以解决效率问题,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块,当这一块内存用完了,就将还存活着的对象复制到另一块上面,然后再把已经使用过的内存空间一次清理掉,这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可(还可使用TLAB进行高效分配内存)。
- 优点:
- 1、效率高,没有内存碎片。
- 缺点:
- 1、浪费一半的内存空间。
- 2、复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。
4)分代收集算法
当前商业虚拟机都是采用分代收集算法,它根据对象存活周期的不同将内存划分为几块,一般是把 Java 堆分为新生代和老年代,然后根据各个年代的特点采用最适当的收集算法。
在新生代中,每次垃圾收集都发现有大批对象死去,只有少量存活,就选用。
而老年代中,因为对象存活率高,没有额外空间对它进行分配担保,就必须使用“”算法来进行回收。
- 对象分配策略:
- 对象优先在 Eden 区域分配,如果对象过大直接分配到 Old 区域。
- 长时间存活的对象进入到 Old 区域。
- 改进自复制算法
-现在的商业虚拟机都采用这种收集算法来回收新生代,将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor 。当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。 - 如果 Eden 区无法分配,那么尝试把活着的对象放到 Survivor0 中去(Minor GC)
- 如果 Survivor0 可以放入,那么放入之后清除 Eden 区。
- 如果 Survivor0 不可以放入,那么尝试把 Eden 和 Survivor0 的存活对象放到 Survivor1 中。
- 如果 Survivor1 可以放入,那么放入 Survivor1 之后清除 Eden 和 Survivor0 ,之后再把 Survivor1 中的对象复制到 Survivor0 中,保持 Survivor1 一直为空。
- 如果 Survivor1 不可以放入,那么直接把它们放入到老年代中,并清除 Eden 和 Survivor0 ,这个过程也称为分配担保。
- ps:清除 Eden、Survivor 区,就是 Minor GC 。
总结来说,分配的顺序是:新生代(Eden => Survivor0 => Survivor1)=> 老年代 - 这样做的目的是,避免在 Eden 区和两个 Survivor 区之间发生大量的内存拷贝(新生代采用复制算法收集内存)。
- 虚拟机为每个对象定义了一个年龄计数器,如果对象经过了 1 次 Minor GC 那么对象会进入 Survivor 区,之后每经过一次 Minor GC 那么对象的年龄加 1 ,知道达到阀值对象进入老年区。
- 为了更好的适用不同程序的内存情况,虚拟机并不是永远要求对象的年龄必须达到 MaxTenuringThreshold 才能晋升老年代。
- 如果 Survivor 区中相同年龄的所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代。
- 1、在执行 Young GC 之前,JVM 会进行——如果老年代的连续空间小于新生代对象的总大小(或历次晋升的平均大小),则触发一次 Full GC 。
- 2、大对象直接进入老年代,从年轻代晋升上来的老对象,尝试在老年代分配内存时,但是老年代。
- 3、显式调用 方法时。
- 循环的末尾 (防止大循环的时候一直不进入 Safepoint ,而其他线程在等待它进入 Safepoint )。
- 方法返回前。
- 调用方法的 Call 之后。
- 抛出异常的位置。
- 每个这样的实例用来表示一个 Java 类。通过此实例的 Class#newInstance(…) 方法,就可以创建出该类的一个。
- 实际的情况可能更加复杂,比如 Java 字节代码可能是通过工具动态生成的,也可能是通过 络下载的。
- Bootstrap ClassLoader :根类加载器,负责加载 Java 的核心类,它不是 java.lang.ClassLoader 的子类,而是由 JVM 自身实现。
- Extension ClassLoader :扩展类加载器,扩展类加载器的加载路径是 JDK 目录下 jre/lib/ext 。扩展加载器的 #getParent() 方法返回 null ,实际上扩展类加载器的父类加载器是根加载器,只是根加载器并不是 Java 实现的。
- System ClassLoader :系统(应用)类加载器,它负责在 JVM 启动时加载来自 Java 命令的 -classpath 选项、java.class.path 系统属性或 CLASSPATH 环境变量所指定的 jar 包和类路径。程序可以通过 #getSystemClassLoader() 来获取系统类加载器。系统加载器的加载路径是程序运行的当前路径。
- 在同一个命名空间中,不会出现类的完整名字(包括类的包名)相同的两个类。
- 在不同的命名空间中,有可能会出现类的完整名字(包括类的包名)相同的两个类)。
- 类加载器负责加载所有的类,同一个类(一个类用其全限定类名(包名加类名)标志)只会被加载一次。
- 每个类加载器都有自己的加载缓存,当一个类被加载了以后就会放入缓存,等下次加载的时候就可以直接返回了。
- 委托父类加载器去加载,父类加载器采用同样的策略,首先查看自己的缓存,然后委托父类的父类去加载,一直到 bootstrap ClassLoader。
- 当所有的父类加载器都没有加载的时候,再由当前的类加载器加载,并将其放入它自己的缓存中,以便下次有加载请求的时候直接返回。
-
声明:本站部分文章及图片源自用户投稿,如本站任何资料有侵权请您尽早请联系jinwei@zod.com.cn进行处理,非常感谢!
大对象直接进入老年代(大对象是指需要大量连续内存空间的对象)。
长期存活的对象进入老年代。
动态判断对象的年龄。
空间分配担保。
从年轻代空间(包括 Eden 和 Survivor 区域)回收内存被称为 Minor GC
对老年代GC称为Major GC 而
FullGC是对整个堆来说的,在最近几个版本的JDK里默认包括了对永生带即方法区的回收(JDK8中无永生带了),出现FullGC的时候经常伴随至少一次的Minor GC,但非绝对的。
什么情况下会出现 Young GC/strong>
对象优先在新生代 Eden 区中分配,如果 Eden 区没有足够的空间时,就会触发一次 Young GC 。
什么情况下回出现 Full GC/strong>
Full GC 的触发条件有多个,FULL GC 的时候会 。
3.7 什么是安全点/h2>
SafePoint 安全点,顾名思义是指一些特定的位置,当线程运行到这些位置时,线程的一些状态可以被确定(the thread’s representation of it’s Java machine state is well described),比如记录OopMap 的状态,从而确定 GC Root 的信息,使 JVM 可以安全的进行一些操作,比如开始 GC 。
SafePoint 指的特定位置主要有:
3.8 JVM 垃圾收集器有哪些/h2>
如何排查线程 Full GC 频繁的问题/h2>
《线上 Full GC 频繁的排查》
《触发 JVM 进行 Full GC 的情况及应对策略》
JVM 的永久代中会发生垃圾回收么/strong>
Young GC 不会发生在永久代。
如果永久代满了或者是超过了临界值,会触发完全垃圾回收(Full GC)。如果我们仔细查看垃圾收集器的输出信息,就会发现永久代也是被回收的。这就是为什么正确的永久代大小对避免 Full GC 是非常重要的原因。
五、虚拟机类加载机制
类加载器(ClassLoader),用来加载 Java 类到 Java 虚拟机中。一般来说,Java 虚拟机使用 Java 类的方式如下:Java 源程序( 文件)在经过 Java 编译器编译之后就被转换成 Java 字节代码( 文件)。
类加载器,负责,并转换成 java.lang.Class 。
类加载发生的时机是什么时候/h2>
虚拟机严格规定,有且仅有 5 种情况必须对类进行加载:
注意,有些文章会称为对类进行“初始化”。
1、遇到 这四条字节码指令时,如果类还没进行初始化,则需要先触发其初始化。
2、使用 java.lang.reflect 包的方法对类进行的时候,如果类还没进行初始化,则需要先触发其初始化。
3、当初始化了一个类的时候,如果发现其,则需要先触发其父类的初始化。
4、当虚拟机启动时,用户需要指定一个执行的主类,即,虚拟机则会先初始化该主类。
5、当使用 JDK7 的时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。
类加载器是如何加载 Class 文件的/h2>
每个类加载器都有自己的命名空间(由该加载器及所有父类加载器所加载的类组成。
Java 虚拟机是如何判定两个 Java 类是相同的/strong>
Java 虚拟机不仅要看是否相同,还要看加载此类的是否一样。只有两者都相同的情况,才认为两个类是相同的。即便是同样的字节代码,被不同的类加载器加载之后所得到的类,也是不同的。
双亲委派模型的工作过程/strong>
1、当前 ClassLoader 首先从自己已经加载的类中,查询是否此类已经加载,如果已经加载则直接返回原来已经加载的类。
2、当前 ClassLoader 的缓存中没有找到被加载的类的时候
为什么优先使用父 ClassLoader 加载类/strong>
1、共享功能:可以避免重复加载,当父亲已经加载了该类的时候,子类不需要再次加载,一些 Framework 层级的类一旦被顶层的 ClassLoader 加载过就缓存在内存里面,以后任何地方用到都不需要重新加载。
2、隔离功能:主要是为了安全性,避免用户自己编写的类动态替换 Java 的一些核心类,比如 String ,同时也避免了重复加载,因为 JVM 中区分不同类,不仅仅是根据类名,相同的 class 文件被不同的 ClassLoader 加载就是不同的两个类,如果相互转型的话会抛 java.lang.ClassCaseException 。