一、概述

虚拟机的类加载:指的是将类的 .class 文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个 java.lang.Class 对象,用来封装类在方法区内的数据结构。类的加载的最终结果是位于 JVM 堆区中的 Class 对象,Class 对象封装了类在方法区内的数据结构,并且向 Java 程序员提供了访问方法区内的数据结构的接口。

虚拟机的类加载机制:Java虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型这一过程。

与那些在编译时需要进行连接的语言不同,在 Java 语言里面,类型的加载、连接和初始化过程都是在程序运行期间完成的,这种策略让 Java 语言进行提前编译会面临额外的困难,也会让类加载时稍微增加一些性能开销,但是却为 Java 应用提供了极高的扩展性和灵活性, Java 天生可以动态扩展的语言特性就是依赖运行期动态加载和动态连接这个特点实现的。

二、类的生命周期

Java 类的完整生命周期包括以下 7 个阶段:

  • 加载(Loading)
  • 链接(Linking)
    • 验证(Verification)
    • 准备(Preparation)
    • 解析(Resolution)
  • 初始化(Initialization)
  • 使用(Using)
  • 卸载(Unloading)

如上图,类的加载、验证、准备、初始化和卸载这 5 个阶段的顺序是确定的,类的加载过程必须按照这个顺序按部就班地开始。 而解析阶段则不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定特性

类加载过程是指加载、验证、准备、解析和初始化这 5 个阶段。

2.1 (一) 加载

加载(Loading)是类加载(Class Loading)的一个阶段,注意不要混淆。

加载(Loading):查找类字节流,并根据此创建类的过程。共完成三件事:

  • 通过一个类的全限定名在获取定义此类的二进制字节流。
  • 将这个字节流所代表的的静态存储结构转化为方法区的运行时存储结构。(静态存储 —> 方法区)
  • 在内存(一般在堆中)中生成一个代表这个类的 Class 对象,作为方法区这个类的各种数据的访问入口。(在堆中生成类的 Class 对象)

《Java虚拟机规范》规定的这三点要求并不是很具体,故留给虚拟机具体实现与 Java 应用的灵活度都是相当大的。如第一条:并未规定获取此定义此类的二进制字节流从哪里、如何获取,开发人员通过定义自己的类加载器去控制字节流的获取方式(重写一个类加载器的 findClass()loadClass() 方法)。

所以二进制字节流可以从以下方式获取:

  • 从 ZIP 包读取,这很常见,最终成为日后 JAR、EAR、WAR 格式的基础。
  • 从网络中获取,这种场景最典型的应用是 Applet。
  • 运行时计算生成,这种场景使用得最多得就是动态代理技术,在 java.lang.reflect.Proxy 中,就是用了 ProxyGenerator.generateProxyClass 的代理类的二进制字节流。
  • 由其他文件生成,典型场景是 JSP 应用,即由 JSP 文件生成对应的 Class 类。
  • 从数据库读取,这种场景相对少见,例如有些中间件服务器(如 SAP Netweaver)可以选择把程序安装到数据库中来完成程序代码在集群间的分发。

2.2 (二) 验证

验证是链接阶段的第一步。目的是确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。

从整体上看,验证阶段大致上会完成下面四个阶段的检验动作:

  • 文件格式验证–> 验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。(注意:这阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证之后,这段字节流才被允许进入 Java 虚拟机内存的方法区中进行存储,所以后面的三个验证阶段全部是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了。)
  • 元数据验证—-> 对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java语言规范》的要求。(父类,继承,抽象类等语义)
  • 字节码验证—-> 该阶段是整个验证过程中最复杂的一个阶段,通过数据流和控制流分析,确保程序语义是合法、符合逻辑的。(类的方法体)
  • 符号引用验证–> 发生在虚拟机将符号引用转化为直接引用的时候,符号引用验证可以看作是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验。(private、static、public、<package>)

2.3 (三) 准备

类变量是被 static 修饰的变量,准备阶段为 static 变量在方法区分配内存并初始化为默认值,使用的是方法区的内存。JDK 8以后,类变量则会随着Class对象一起存放在Java堆中

实例变量不会在这阶段分配内存,它将会在对象实例化时随着对象一起分配在 Java 堆中。(实例化不是类加载的一个过程,类加载发生在所有实例化操作之前,并且类加载只进行一次,实例化可以进行多次)

初始值一般为 0 值,例如下面的类变量 value 被在准备阶段后的初始值为 0 而不是 123,在程序被编译过后执行构造器器<clinit>()方法之中才会赋值为 123:

1
public static int value = 123;

但是如果类变量是常量(Constant Value),那么会按照表达式来初始化,而不是赋值为 0:

1
public static final int value = 123;

编译时 Javac 将会为 value 生成 ConstantValue 属性,在准备阶段虚拟机就会根据 Con-stantValue 的设置将 value 赋值为 123。

注意:

  1. 对基本数据类型来说,对于类变量(static)和全局变量,如果不显式地对其赋值而直接使用,则系统会为其赋予默认的零值;而对于局部变量来说,在使用前必须显式地为其赋值,否则编译时不通过。
  2. 对于同时被 static 和 final 修饰的常量,必须在声明的时候就为其显式地赋值,否则编译时不通过;而只被 final 修饰的常量则既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值,总之,在使用前必须为其显式地赋值,系统不会为其赋予默认零值。
  3. 对于引用数据类型 reference 来说,如数组引用、对象引用等,如果没有对其进行显式地赋值而直接使用,系统都会为其赋予默认的零值,即 null。
  4. 如果在数组初始化时没有对数组中的各元素赋值,那么其中的元素将根据对应的数据类型而被赋予默认的零值。

2.4 (四) 解析

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

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符这 7 类符号引用进行,分别对应于常量池的CONSTANT_Class_infoCON-STANT_Fieldref_infoCONSTANT_Methodref_infoONSTANT_InterfaceMethodref_infoCONSTANT_MethodType_infoCONSTANT_MethodHandle_infoCONSTANT_Dyna-mic_infoCONSTANT_InvokeDynamic_info 8种常量类型。

  • 符号引用(Symbolic References) —> 符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。
  • 直接引用(Direct Reference) ———> 直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。

注意:
在 class 文件被加载至 Java 虚拟机之前,这个类无法知道其他类及其方法、字段所对应的具体地址,甚至不知道自己方法、字段的地址。因此,每当需要引用这些成员时,Java 编译器会生成一个符号引用。在运行阶段,这个符号引用一般都能够无歧义地定位到具体目标上。

举例来说,对于一个方法调用,编译器会生成一个包含目标方法所在类的名字、目标方法的名字、接收参数类型以及返回值类型的符号引用,来指代所要调用的方法。

2.5 (五) 初始化

在 Java 代码中,如果要初始化一个静态字段,可以在声明时直接赋值,也可以在静态代码块中对其赋值。

如果直接赋值的静态字段被 final 所修饰,并且它的类型是基本类型或字符串时,那么该字段便会被 Java 编译器标记成常量值(ConstantValue),其初始化直接由 Java 虚拟机完成。除此之外的直接赋值操作,以及所有静态代码块中的代码,则会被 Java 编译器置于同一方法中,并把它命名为 < clinit >

类的初始化阶段是类加载过程的最后一个步骤,之前介绍的几个类加载的动作里,除了在加载阶段用户应用程序可以通过自定义类加载器的方式局部参与外,其余动作都完全由Java虚拟机来主导控制。直到初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,将主导权移交给应用程序。总之,初始化阶段就是执行类构造器<clinit>()方法的过程

类初始化细节

类初始化 <clinit>() 方法的细节: 进行准备阶段时,变量已经赋过一次系统要求的初始零值,而在初始化阶段,则会根据程序员通过程序编码制定的主观计划去初始化类变量和其他资源。

  • <clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和**静态语句块(static{}块)**中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的。特别注意的是,静态语句块只能访问到定义在它之前的类变量,定义在它之后的类变量只能赋值,不能访问。例如以下代码:
1
2
3
4
5
6
7
public class Test {
    static {
        i = 0;                  //  给变量赋值可以正常变异通过
        System.out.print(i);    // 这句话编译器会提示”非法向前引用“(无法访问)
    }
    static int i = 1;
}
  • 与类的构造函数(或者说实例构造器 <init>())不同,不需要显式的调用父类的构造器。虚拟机会自动保证在子类的 <clinit>() 方法运行之前,父类的 <clinit>() 方法已经执行结束。因此虚拟机中第一个执行 <clinit>() 方法的类肯定为 java.lang.Object
  • 由于父类的 <clinit>() 方法先执行,也就意味着父类中定义的静态语句块要优于子类的变量赋值操作。例如以下代码:
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
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); // 输出的结果为父类的静态变量 A 的值,也就是 2.
}
  • <clinit>()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
  • 接口中不可以使用静态语句块,但仍然有类变量初始化的赋值操作,因此接口与类一样都会生成 <clinit>() 方法。但接口与类不同的是,执行接口的 <clinit>() 方法不需要先执行父接口的 <clinit>() 方法。只有当父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也一样不会执行接口的 <clinit>() 方法。
  • 虚拟机会保证一个类的 <clinit>() 方法在多线程环境下被正确的加锁和同步,如果多个线程同时初始化一个类,只会有一个线程执行这个类的 <clinit>() 方法,其它线程都会阻塞等待,直到活动线程执行 <clinit>() 方法完毕。如果在一个类的 <clinit>() 方法中有耗时的操作,就可能造成多个线程阻塞,在实际过程中此种阻塞很隐蔽。

类初始化方式

  • 声明类变量时指定初始值
  • 使用静态代码块为类变量指定初始值

类初始化步骤

  1. 如果类还没有被加载和链接,开始加载该类。
  2. 如果该类的直接父类还没有被初始化,先初始化其父类。
  3. 如果该类有初始化语句,则依次执行这些初始化语句。

类初始化时机

只有主动引用类的时候才会导致类的初始化。

(1) 主动引用

类的主动引用包括以下六种:

  • 创建类的实例 —-> new 一个对象
  • 访问静态变量 —-> 访问某个类或接口的静态变量,或者对该静态变量赋值
  • 访问静态方法 —->
  • 反射 ———–> 如Class.forName(“com.shengsiyuan.Test”)
  • 初始化子类 ——> 初始化某个类的子类,则其父类也会被初始化
  • 启动类 ——–> Java 虚拟机启动时被标明为启动类的类(Java Test),直接使用java.exe命令来运行某个主类

(2) 被动引用

以上 5 种场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。被动引用的常见例子包括:

  • 通过子类引用父类的静态字段,不会导致子类初始化。
1
System.out.println(SubClass.value); //value 字段在 SuperClass 中定义
  • 通过数组定义来引用类,不会触发此类的初始化。 该过程会对数组类进行初始化,数组类是一个由虚拟机自动生成的、直接继承自 Object 的子类,其中包含了数组的属性和方法(length, clone())。
1
SuperClass[] sca = new SuperClass[10];
  • 常量(final)在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。
1
System.out.println(ConstClass.HELLOWORLD);

三、ClassLoader

ClassLoader 即类加载器,负责将类加载到 JVM。在 Java 虚拟机外部实现,以便让应用程序自己决定如何去获取所需要的类。

JVM 加载 class 文件到内存有两种方式:

  • 隐式加载 - JVM 自动加载需要的类到内存中。
  • 显示加载 - 通过使用 ClassLoader 来加载一个类到内存中。

3.1 类与类加载器

如何判断两个类是否相等:类本身相等,并且使用同一个类加载器进行加载。这是因为每一个 ClassLoader 都拥有一个独立的类名称空间。

这里的相等,包括类的 Class 对象的 equals() 方法、isAssignableFrom()(是否为父类) 方法、isInstance()(是否为子类) 方法的返回结果为 true,也包括使用 instanceof 关键字做对象所属关系判定结果为 true

3.2 类加载器分类

站在 Java 虚拟机的角度来看,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用 C++ 语言实现,是虚拟机自身的一部分;另外一种就是其他所有类加载器,这些类加载器都由Java语言实现,独立存在于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader

(1) BootStrap ClassLoader

Bootstrap ClassLoader:即启动类加载器 ,负责加载 JVM 自身工作所需要的类

Bootstrap ClassLoader 会将存放在 <JAVA_HOME>\lib 目录中的,或者被 -Xbootclasspath 参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机内存中

Bootstrap ClassLoader 是由 C++ 实现的,它完全由 JVM 自己控制的,启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,直接使用 null 代替即可。

(2) ExtClassLoader

ExtClassLoader,即扩展类加载器,这个类加载器是由 ExtClassLoader(sun.misc.Launcher\$ExtClassLoader) 实现的。

ExtClassLoader 负责将 <JAVA_HOME>\lib\ext 或者被 java.ext.dir 系统变量所指定路径中的所有类库加载到内存中,开发者可以直接使用扩展类加载器。

(3) AppClassLoader

AppClassLoader,即应用程序类加载器,这个类加载器是由 AppClassLoader(sun.misc.Launcher\$AppClassLoader) 实现的。由于这个类加载器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,因此一般称为系统类加载器

AppClassLoader 负责加载用户类路径(即 classpath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

我们的应用程序都是由这3种类加载器互相配合进行加载的,如果有必要,还可以加入自己定义的类加载器。

(4) 自定义类加载器

自定义类加载器可以做到如下几点功能:

  • 在执行非置信代码之前,自动验证数字签名。
  • 动态地创建符合用户特定需要的定制化构建类。
  • 从特定的场所取得 java class,例如数据库中和网络中。

假设,我们需要自定义一个名为 FileSystemClassLoader 的类加载器,继承自 java.lang.ClassLoader,用于加载文件系统上的类。它首先根据类的全名在文件系统上查找类的字节代码文件(.class 文件),然后读取该文件内容,最后通过 defineClass() 方法来把这些字节代码转换成 java.lang.Class 类的实例。

java.lang.ClassLoader 类的方法 loadClass() 实现了双亲委派模型的逻辑,因此自定义类加载器一般不去覆写它,而是通过覆写 findClass() 方法。

ClassLoader 常用的场景:

  • 容器 - 典型应用:Servlet 容器(如:Tomcat、Jetty)、udf (Mysql、Hive)等。加载解压 jar 包或 war 包后,加载其 Class 到指定的类加载器中运行(通常需要考虑空间隔离)。
  • 热部署、热插拔 - 应用启动后,动态获得某个类信息,然后加载到 JVM 中工作。很多著名的容器软件、框架(如:Spring 等),都使用 ClassLoader 来实现自身的热部署。

【示例】自定义一个类加载器:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class FileSystemClassLoader extends ClassLoader {
    private String rootDir;
    public FileSystemClassLoader(String rootDir){
        this.rootDir = rootDir;
    }

    //寻找类名对应的字节流,如果没有则异常,否则将该字节码的字节流转化为 Class 对象
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] classData = getClassData(name);
        
        if(classData != null){
            return defineClass(name, classData, 0, classData.length);
        } else {
            throw new ClassNotFoundException();
        }
    }

    private byte[] getClassData(String className) {
        String path = classNameToPath(className);
        try {
            InputStream ins = new FileInputStream(path);
            ByteArrayOutPutStream baos = new ByteArrayOutputStream();
            int bufferSize = 4096;
            byte[] buffer = new byte[bufferSize];
            int bytesNumRead;
            while((bytesNumRead = ins.read(buffer)) != -1){
                baos.write(buffer, 0, bytesNumRead);
            }
            return baos.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }

    private String classNameToPath(String className) {
        return rootDir + File.separatorChar + className.replace('.', File.separatorChar) + ".class";
    }
}

3.3 双亲委派

【示例】寻找类加载示例:

1
2
3
4
5
6
public static void main(String[] args) {
    ClassLoader loader = Thread.currentThread().getContextClassLoader();
    System.out.println(loader);
    System.out.println(loader.getParent());
    System.out.println(loader.getParent().getParent());
}

输出:

1
2
3
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@19e1023e
null

从上面的结果可以看出,并没有获取到 ExtClassLoader 的父 Loader,原因是 Bootstrap Loader(引导类加载器)是用 C++ 语言实现的,找不到一个确定的返回父 Loader 的方式,于是就返回 null。

下图展示的类加载器之间的层次关系,称为类加载器的双亲委派模型(Parents Delegation Model)。该模型要求除了顶层的 Bootstrap ClassLoader 外,其余的类加载器都应有自己的父类加载器。这里类加载器之间的父子关系一般通过组合(Composition)关系来实现,而不是通过继承(Inheritance)的关系实现。

(1) 工作过程

一个类加载器首先将类加载请求传送到父类加载器,只有当父类加载器无法完成类加载请求时才尝试加载。

(2) 优势

使得 Java 类随着它的类加载器一起具有一种带有优先级的层次关系,从而使得基础类得到统一:

  • 系统类防止内存中出现多份同样的字节码
  • 保证 Java 程序安全稳定运行

例如: java.lang.Object 存放在 rt.jar 中,如果编写另外一个 java.lang.Object 的类并放到 classpath 中,程序可以编译通过。因为双亲委派模型的存在,所以在 rt.jar 中的 Object 比在 classpath 中的 Object 优先级更高,因为 rt.jar 中的 Object 使用的是启动类加载器,而 classpath 中的 Object 使用的是应用程序类加载器。正因为 rt.jar 中的 Object 优先级更高,因为程序中所有的 Object 都是这个 Object。

(3) 实现

以下是抽象类 java.lang.ClassLoader 的代码片段,其中的 loadClass() 方法运行过程如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public abstract class ClassLoader {
    // The parent class loader for delegation
    private final ClassLoader parent;

    public Class<?> loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false);
    }

    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        synchronized (getClassLoadingLock(name)) {
            // 首先判断该类型是否已经被加载
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                // 如果没有被加载,就委托给父类加载或者委派给启动类加载器加载
                try {
                    if (parent != null) {
                        // 如果存在父类加载器,就委派给父类加载器加载
                        c = parent.loadClass(name, false);
                    } else {
                        // 如果不存在父类加载器,就检查是否是由启动类加载器加载的类,通过调用本地方法native Class findBootstrapClass(String name)
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // 如果父类加载器加载失败,会抛出 ClassNotFoundException
                }

                if (c == null) {
                    // 如果父类加载器和启动类加载器都不能完成加载任务,才调用自身的加载功能
                    c = findClass(name);
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

    protected Class<?> findClass(String name) throws ClassNotFoundException {
        throw new ClassNotFoundException(name);
    }
}

【说明】:

  • 先检查类是否已经加载过,如果没有则让父类加载器去加载。
  • 当父类加载器加载失败时抛出 ClassNotFoundException,此时尝试自己去加载。

3.4 ClassLoader 参数

在生产环境上启动 java 应用时,通常会指定一些 ClassLoader 参数,以加载应用所需要的 lib

1
java -jar xxx.jar -classpath lib/*

ClassLoader 相关参数选项:

参数选项 ClassLoader 类型 说明
-Xbootclasspath Bootstrap ClassLoader 设置 Bootstrap ClassLoader 搜索路径。【不常用】
-Xbootclasspath/a Bootstrap ClassLoader 把路径添加到已存在的 Bootstrap ClassLoader 搜索路径后面。【常用】
-Xbootclasspath/p Bootstrap ClassLoader 把路径添加到已存在的 Bootstrap ClassLoader 搜索路径前面。【不常用】
-Djava.ext.dirs ExtClassLoader 设置 ExtClassLoader 搜索路径。
-Djava.class.path 或 -cp 或 -classpath AppClassLoader 设置 AppClassLoader 搜索路径。

四、类的加载

4.1 类加载方式

类加载方式有三种:

  • 命令行启动应用时候由 JVM 初始化加载。
  • 通过 Class.forName() 方法动态加载。
  • 通过 ClassLoader.loadClass() 方法动态加载。

Class.forName()ClassLoader.loadClass() 区别

  • Class.forName()将类的.class文件加载到 jvm 中之外,还会对类进行解释,执行类中的 static 块;
  • ClassLoader.loadClass() 只干一件事情,就是将 .class 文件加载到 jvm 中,不会执行 static 中的内容,只有在 newInstance 才会去执行 static 块。
  • Class.forName(name, initialize, loader) 带参函数也可控制是否加载 static 块。并且只有调用了 newInstance() 方法采用调用构造函数,创建类的对象。

4.2 加载类错误

  1. ClassNotFoundException: 表示当前 classpath 下找不到指定类。 常见问题原因:
    • 调用 ClassforName() 方法,未找到类。
    • 调用 ClassLoader 中的 loadClass() 方法,未找到类。
    • 调用 ClassLoader 中的 findSystemClass() 方法,未找到类。
  2. NoClassDefFoundError 常见问题原因:
    • 类依赖的 Class 或者 jar 不存在。
    • 类文件存在,但是存在不同的域中。
  3. UnsatisfiedLinkError: 常是在 JVM 启动的时候如果一不小心将在 JVM 中的某个 lib 删除了,可能就会报这个错误了。
  4. ClassCastException: 异常通常是在程序中强制类型转换失败时出现。