一、什么是类加载器(ClassLoader)
类加载器是指在系统运行过程中动态的将字节码文件加载到 JVM 中的工具,是一个类。基于这个工具的整套类加载流程,称作类加载机制。在 IDE 中编写的都是源代码文件,以后缀名为.java
的文件形式存在于磁盘上,经过编译后生成后缀名为.class
的字节码文件,类加载器加载的就是这些字节码文件。
首先 Java 源代码(.java
)文件会被 Java 编译器编译为字节码(.class
)文件,然后由 JVM 中的类加载器加载各个类的字节码文件,加载完毕之后,交由 JVM 执行。在整个程序执行过程中,JVM 会用一段空间来存储程序执行期间需要用到的数据和相关信息,这段空间一般被称作 Runtime Data Area (运行时数据区),也就是常说的 JVM 内存。因此 Java 中常说的内存管理就是针对这段空间进行的管理(如何分配和回收内存空间)。
二、类加载器的种类
Java 默认提供了三个类加载器,分别是根加载器(BootStrapClassLoader)
、扩展类加载器(ExtClassLoader)
、应用类加载器(AppClassLoader)
,依次前者分别是后者的【父加载器】。父加载器不是「父类」,三者之间没有继承关系,只是因为类加载的流程使三者之间形成了父子关系。还有一种是用户自定义类加载器(java.lang.ClassLoader的子类)。
从 Java 2 开始,类加载过程采取了双亲委派模型(Parents Delegation Model【PDM】),PDM 更好的保证了 Java 平台的安全性。在该机制中,JVM 自带的 BootStrapClassLoader 是根加载器,其他的加载器都有且仅有一个父类加载器。类的加载首先请求父类加载器加载,父类加载器无能为力时才由其子类加载器自行加载。JVM 不会向 Java 程序提供对 BootStrapClassLoader 的引用。
三、双亲委派模型工作过程
如果一个类加载器收到了类加载的请求,它自己不会首先去尝试加载这个类,而是把这个请求委派给父类加载器去完成。每一个层次的类加载器都是如此,因此所有的加载请求最终都会传送到顶层的启动类加载器中。只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
说明:
PDM 只是 Java 推荐的机制,并不是强制的。可以继承java.lang.ClassLoader类,实现自己的类加载器。如果想保持 PDM,就重写 findClass(name);如果想破坏 PDM,就重写 loadClass(name)。JDBC 使用线程上下文加载器打破了 PDM,原因是 JDBC 只提供了接口,并没有提供实现。
四、为什么需要双亲委派模型
1️⃣防止内存中出现多份同样的字节码。
反向思考,如果没有 PDM 而是由各个类加载器自行加载的话,用户编写了一个java.lang.Object的同名类并放在 ClassPath 中,多个类加载器都能加载这个类到内存中,系统中将会出现多个不同的 Object 类,那么类之间的比较结果及类的唯一性将无法保证。而且如果不使用 PDM 将会给虚拟机的安全带来隐患。所以,要让类对象进行比较有意义,前提是它们要被同一个类加载器加载。
2️⃣试想一个场景:
黑客自定义一个java.lang.String类,该类具有系统 String 类一样的功能,只是将某个方法稍作修改。比如在 equals 方法中,加入一些“病毒代码”,并且通过自定义类加载器加入到 JVM 中。此时,如果没有 PDM,那么 JVM 就可能误以为黑客自定义的java.lang.String类是系统的 String 类,导致“病毒代码”被执行。而有了 PDM,黑客自定义的java.lang.String类永远都不会被加载进内存。因为首先是最顶端的类加载器加载系统的java.lang.String类,最终自定义的类加载器无法加载java.lang.String类。
3️⃣在自定义的类加载器里面强制加载自定义的java.lang.String类,不去通过调用父加载器不就好了吗?
确实可行。但是,在 JVM 中,判断一个对象是否是某个类型时,如果该对象的实际类型与待比较的类型的类加载器不同,那么会返回 false。任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 JVM 中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。也就是说,判断两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则即使这两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,这两个类必定不相等。
例如:ClassLoader1、ClassLoader2 都加载java.lang.String类,对应 Class1、Class2 对象。那么 Class1 对象不属于 ClassLoader2 对象加载的java.lang.String类型。
五、线程上下文类加载器
并非所有的类加载机制都遵循这个模型,这个模型是被破坏过的。PDM 很好的解决了各个类加载器的基础类的同一问题,基础类是总被用户代码所调用的 API,但是基础类要调用用户的代码的时候,PDM 就出现了缺陷。比如 JNDI 服务,属于 rt.jar,它需要调用应用程序的代码来实现资源管理,但是启动类加载器并不能识别应用程序代码,因此出现了线程上下文类加载器。这个类加载器由 Thread 类的 setContextClassLoaser() 进行设置,如果线程未创建,它将会从主线程中继承一个。JNDI 可以使用线程上下文加载器来加载所需要的 SPI 代码,也就是父类加载器去请求子类加载器加载 Class。Java 中所有涉及 SPI 加载的基本上都采用这个方法。
六、关于几个类加载器的说明
1️⃣BootStrapClassLoader:根加载器,它是脱离 Java 语言,使用 C/C++ 编写的类加载器,所以当尝试使用 ExtClassLoader 的实例调用 getParent(),获取其父加载器时会得到一个 null 值,比如调用String.class.getClassLoader()。根加载器会默认加载系统变量 sun.boot.class.path 指定的类库(jar 文件和 .class 文件),默认是 $JRE_HOME/lib 下的类库,如 rt.jar、resources.jar 等,具体可以输出该环境变量的值来查看。除了加载这些默认的类库外,也可以使用 JVM 参数 -Xbootclasspath/a 来追加额外需要让根加载器加载的类库。
总之,对于 BootStrapClassLoader 这个根加载器需要知道三点:
- 根加载器使用 C/C++ 编写,无法在 Java 中获得其实例。
- 根加载器默认加载系统变量 sun.boot.class.path 指定的类库。
- 可以使用 -Xbootclasspath/a 参数追加根加载器的默认加载类库。
2️⃣ExtClassLoader:扩展类加载器,它是一个使用 Java 实现的类加载器(sun.misc.Launcher.ExtClassLoader),用于加载系统所需要的扩展类库。默认加载系统变量 java.ext.dirs 指定位置下的类库,通常是 $JRE_HOME/lib/ext 目录下的类库。
可以在启动时修改 java.ext.dirs 变量的值来修改扩展类加载器的默认类库加载目录,但通常并不建议这样做。如果真的有需要扩展类加载器在启动时加载的类库,可以将其放置在默认的加载目录下。
总之,对于 ExtClassLoader 这个扩展类加载器需要知道两点:
- 扩展类加载器是使用 Java 实现的类加载器,可以在程序中获得它的实例并使用。
- 通常不建议修改java.ext.dirs参数的值来修改默认加载目录,如有需要,可以将要加载的类库放到这个默认目录下。
3️⃣AppClassLoader:应用类加载器,它和 ExtClassLoader 一样,也是使用 Java 实现的类加载器(sun.misc.Launcher.AppClassLoader)。它的作用是加载应用程序 classpath 下所有的类库。它是应用最广泛的类加载器,是最常用的类加载器,在程序中调用的很多 getClassLoader() 返回的都是它的实例。在自定义类加载器时如果没有特别指定,那么自定义的类加载器的默认父加载器也是这个应用类加载器。
总之,对于 AppClassLoader 这个应用类加载器需要知道三点:
- 应用类加载器是使用 Java 实现的类加载器,负责加载应用程序 classpath 下的类库。
- 应用类加载器是最常用的类加载器。
4️⃣自定义类加载器:
除了上述三种 Java 默认提供的类加载器外,还可以通过继承java.lang.ClassLoader自定义类加载器。如果在创建自定义类加载器时没有指定父加载器,那么默认使用 AppClassLoader 作为父加载器。
七、类加载器的启动顺序
BootStrapClassLoader 是一个使用 C/C++ 编写的类加载器,它已经嵌入到了 JVM 的内核之中。当 JVM 启动时,BootStrapClassLoader 也会随之启动并加载核心类库。当核心类库加载完成后,BootStrapClassLoader 会创建 ExtClassLoader 和 AppClassLoader 的实例,两个 Java 实现的类加载器将会加载自己负责路径下的类库,这个过程可以在 sun.misc.Launcher 中窥见。
原理:
JVM 中类的加载是由类加载器和它的子类来实现的。Java 中的类加载器是一个重要的 Java 运行时系统组件,它负责在运行时查找和装入类文件中的类。由于 Java 的跨平台性,经过编译的 Java 源程序并不是一个可执行程序,而是一个或多个类文件。当 Java 程序需要使用某个类时,JVM 会确保这个类已经被加载
、连接(验证、准备和解析)
和初始化
。
八、关于Java静态代码块执行时机的解析
加载与类加载是两个截然不同的过程。
Java的类加载
是指类从被加载到虚拟机内存中开始,到卸载出虚拟机内存为止的整个生命周期中的整个过程,包括加载、验证、准备、解析和初始化五个阶段。加载
指的是类加载的第一个阶段。加载阶段,虚拟机需要完成以下3件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据结构的访问入口。
九、类初始化的条件
Java 虚拟机规范中严格规定了有且只有五种情况必须对类进行初始化:
- 使用 new 字节码指令创建类的实例,或者使用 getstatic、putstatic 读取或设置一个静态字段的值(放入常量池中的常量除外),或者调用一个静态方法的时候,对应类必须进行过初始化。
- 通过java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则要首先进行初始化。
- 当初始化一个类的时候,如果发现其父类没有进行过初始化,则首先触发父类初始化。
- 当虚拟机启动时,用户需要指定一个主类(包含main()的类),虚拟机会首先初始化这个类。
- 使用 jdk1.7 的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果 REF_getStatic、REF_putStatic、RE_invokeStatic 的方法句柄,并且这个方法句柄对应的类没有进行初始化,则需要先触发其初始化。
除了以上这五种情况,其他任何情况都不会触发类的初始化。比如下面这几种情况就不会触发类初始化:
- 通过子类调用父类的静态字段。此时父类符合情况一,而子类不符合任何情况。所以只有父类被初始化。
- 通过数组来引用类,不会触发类的初始化。因为 new 的是数组,而不是类。
- 调用类的静态常量不会触发类的初始化,因为静态常量在编译阶段就会被存入调用类的常量池中,不会引用到定义常量的类。
十、为什么静态方法不能调用非静态方法和变量
静态方法的内存分配时间与实例方法不同。
- 静态方法属于类,在类加载的时候就会分配内存,有了入口地址,可以通过“类名.方法名”直接调用。
- 非静态成员(变量和方法)属于类的对象,所以只有该对象初始化之后才会分配内存,然后通过类的对象去访问。
- 也就是说在静态方法中调用非静态成员变量,该变量可能还未初始化。因此编译器会报错。