JVM—类加载器、双亲委派机制
目录
什么是类加载器
类加载器的分类
Bootstrap启动类加载器
通过启动类加载器加载用户jar包
Extension扩展类加载器和Application应用程序类加载器
通过扩展类加载器加载用户jar包
双亲委派机制
打破双亲委派机制
自定义类加载器
线程上下文类加载器
Osgi框架的类加载器
总结
JDK9之后的类加载器
什么是类加载器
类加载器(ClassLoader)是Java虚拟机提供给应用程序去实现获取类和接口字节码数据的技术,类加载器只参与加载过程中的字节码获取并加载到内存这一部分。
类加载器会通过二进制流的方式获取到字节码文件的内容,接下来将获取到的数据交给Java虚拟机,虚拟机会在方法区和堆上生成对应的对象保存字节码信息。
类加载器的分类
类加载器分为两类,一类是Java代码中实现的,一类是Java虚拟机底层源码实现的。
1. JDK中默认提供或者自定义:JDK中默认提供了多种处理不同渠道的类加载器,程序员也可以自己根据需求定制,使用Java语言。所有Java中实现的类加载器都需要继承ClassLoader这个抽象类。
2. 虚拟机底层实现:源代码位于Java虚拟机的源码中,实现语言与虚拟机底层语言一致,比如Hotspot使用C++。主要目的是保证Java程序运行中基础类被正确地加载,比如java.lang.String,Java虚拟机需要确保其可靠性。
类加载器的设计JDK8和8之后的版本差别较大,首先来看JDK8及之前的版本,这些版本中默认的类加载器有如下几种:
Bootstrap启动类加载器
1. 启动类加载器(Bootstrap ClassLoader)是由Hotspot虚拟机提供的、使用C++编写的类加载器。
2. 默认加载Java安装目录/jre/lib下的类文件,比如rt.jar,tools.jar,resources.jar等。
/**
* 启动程序类加载器案例
*/
public class BootstrapClassLoaderDemo {
public static void main(String[] args) throws IOException {
ClassLoader classLoader = String.class.getClassLoader();
System.out.println(classLoader);
System.in.read();
}
}
这段代码通过String类获取到它的类加载器(Bootstrap)并且打印,结果是null
。这是因为启动类加载器在JDK8中是由C++语言来编写的,在Java代码中去获取既不适合也不安全,所以返回null。
通过启动类加载器加载用户jar包
如果用户想扩展一些比较基础的jar包,并让启动类加载器加载,有以下两种途径。
1. 放入jre/lib下进行扩展(不推荐):尽可能不要去更改JDK安装目录中的内容,会出现即时放进去由于文件名不匹配的问题也不会正常地被加载;
2. 使用参数进行扩展(推荐):使用-Xbootclasspath/a:jar包目录/jar包名 进行扩展,参数中的/a代表新增。
如下图,在IDEA配置中添加虚拟机参数,就可以加载D:/jvm/jar/classloader-test.jar
这个jar包了。
Extension扩展类加载器和Application应用程序类加载器
1. ClassLoader类定义了具体的行为模式,简单来说就是先从本地或者网络获得字节码信息,然后调用虚拟机底层的方法创建方法区和堆上的对象。这样的好处就是让子类只需要去实现如何获取字节码信息这部分代码;
2. SecureClassLoader提供了证书机制,提升了安全性;
3. URLClassLoader提供了根据URL获取目录下或者指定jar包进行加载,获取字节码的数据;
4. 扩展类加载器和应用程序类加载器继承自URLClassLoader,获得了上述的三种能力。
通过扩展类加载器加载用户jar包
1. 放入/jre/lib/ext下进行扩展(不推荐):尽可能不要去更改JDK安装目录中的内容;
2. 使用参数进行扩展使用参数进行扩展(推荐):使用-Djava.ext.dirs=jar包目录 进行扩展,这种方式会覆盖掉原始目录,可以用;(windows系统)或:(macos/linux系统)符号进行分隔追加上原始目录,如下图。
使用引号将整个地址包裹起来,这样路径中即便是有空格也不需要额外处理。路径中要包含原来ext文件夹,同时在最后加上扩展的路径。
双亲委派机制
面试题1:什么是双亲委派机制
1、当一个类加载器在加载某个类时,会先自底向上查找是否有加载器加载过,如果加载过就直接返回,如果一直到最顶层的类加载器都没有加载,再由顶向下进行加载;
2、应用程序类加载器的父类加载器是扩展类加载器,扩展类加载器的父类加载器是启动类加载器。
面试题2:双亲委派的作用/好处有哪些?
1.保证类加载的安全性。通过双亲委派机制避免恶意代码替换JDK中的核心类库,比如java.lang.String,确保核心类库的完整性和安全性;
2.避免重复加载。双亲委派机制可以避免同一个类被多次加载。
面试题3:如果一个类重复出现在三个类加载器的加载位置,应该由谁来加载?
答:由启动类加载器加载,根据双亲委派机制,它的优先级是最高的。
面试题4:String类能被覆盖吗,在自己的项目中创建一个java.lang.String类,会被加载吗?
不会,因为存在双亲委派机制,类不会被重复加载,会返回启动类加载器加载在rt.jar包中的String类。
打破双亲委派机制
打破双亲委派机制历史上有如下三种方式,但本质上只有第一种算是真正的打破了双亲委派机制。
1. 自定义类加载器并且重写loadClass方法。Tomcat通过这种方式实现应用之间类隔离;
2. 线程上下文类加载器。利用上下文类加载器加载类,比如JDBC和JNDI等;
3. Osgi框架的类加载器。历史上Osgi框架实现了一套新的类加载器机制,允许同级之间委托进行类的加载,目前很少使用。
自定义类加载器
一个Tomcat程序中是可以运行多个Web应用的,如果这两个应用中出现了相同限定名的类,比如Servlet类,Tomcat就需要保证这两个类都能加载并且它们应该是不同的类。如果不打破双亲委派机制,当应用类加载器加载Web应用1中的MyServlet之后,Web应用2中相同限定名的MyServlet类就无法被加载了。
那么自定义加载器是如何做到打破双亲委派机制的呢?首先我们需要了解双亲委派机制的代码到底在哪里,接下来只需要把这段代码消除即可。
ClassLoader中包含了4个核心方法,双亲委派机制的核心代码就位于loadClass方法中。
//类加载的入口,提供了双亲委派机制。内部会调用findClass [重要]
public Class<?> loadClass(String name)
//由类加载器子类实现,获取二进制数据调用defineClass
//比如URLClassLoader会根据文件路径去获取类文件中的二进制数据。[重要]
protected Class<?> findClass(String name)
//做一些类名的校验,然后调用虚拟机底层的方法将字节码信息加载到虚拟机内存中
protected final Class<?> defineClass(String name, byte[] b, int off, int len)
//执行类生命周期中的连接阶段
protected final void resolveClass(Class<?> c)
loadClass方法核心代码如下
//先查找是否加载过,加载过就返回
Class<?> c = findLoadedClass(name);
//如果没有加载过,则委派给父类加载
if(c == null){
//parent等于null则说明父类加载器是启动类加载器
if(parent != null){
c = parent.loadClass(name,false);//由父类加载
else
c = findBootstrapClassOrNull(name);//由启动类加载器加载
//若父类加载器无法加载,则由本加载器加载
if(c == null)
c = findClass(name);
}
return c;
自定义加载器通过重写 loadClass 方法,清除了其中有关双亲委派机制的逻辑,因此打破了双亲委派机制。
按照loadClass方法的逻辑,如果父类加载失败,会调用自己的findClass方法来完成类的加载。如果用户在实现自定义类加载器时,希望按照自己的意愿去加载类,但又想保证自定义类加载器是符合双亲委派机制的,则可以重写findClass方法,在该方法中实现类的加载逻辑,而不必重写loadClass方法,从而保留了双亲委派机制的逻辑。
自定义类加载器打破双亲委派机制代码如下
package classloader.broken;//package com.itheima.jvm.chapter02.classloader.broken;
import org.apache.commons.io.IOUtils;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.ProtectionDomain;
import java.util.regex.Matcher;
/**
* 打破双亲委派机制 - 自定义类加载器
*/
public class BreakClassLoader1 extends ClassLoader {
private String basePath;
private final static String FILE_EXT = ".class";
//设置加载目录
public void setBasePath(String basePath) {
this.basePath = basePath;
}
//使用commons io 从指定目录下加载文件
private byte[] loadClassData(String name) {
try {
String tempName = name.replaceAll("\\.", Matcher.quoteReplacement(File.separator));
FileInputStream fis = new FileInputStream(basePath + tempName + FILE_EXT);
try {
return IOUtils.toByteArray(fis);
} finally {
IOUtils.closeQuietly(fis);
}
} catch (Exception e) {
System.out.println("自定义类加载器加载失败,错误原因:" + e.getMessage());
return null;
}
}
//重写loadClass方法,不再走双亲委派机制
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
//在加载默认的Object父类时,需要交由父类加载
if(name.startsWith("java.")){
return super.loadClass(name);
}
//从磁盘中指定目录下加载
byte[] data = loadClassData(name);
//调用虚拟机底层方法,方法区和堆区创建对象
return defineClass(name, data, 0, data.length);
}
public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException, IOException {
//第一个自定义类加载器对象
BreakClassLoader1 classLoader1 = new BreakClassLoader1();
classLoader1.setBasePath("D:\\lib\\");
Class<?> clazz1 = classLoader1.loadClass("com.itheima.my.A");
//第二个自定义类加载器对象
BreakClassLoader1 classLoader2 = new BreakClassLoader1();
classLoader2.setBasePath("D:\\lib\\");
Class<?> clazz2 = classLoader2.loadClass("com.itheima.my.A");
System.out.println(clazz1 == clazz2);
Thread.currentThread().setContextClassLoader(classLoader1);
System.out.println(Thread.currentThread().getContextClassLoader());
System.in.read();
}
}
默认情况下自定义类加载器的父类加载器是应用程序类加载器。
两个自定义类加载器加载相同限定名的类,不会冲突吗?
不会冲突,在同一个Java虚拟机中,只有相同类加载器+相同的类限定名才会被认为是同一个类。
线程上下文类加载器
利用线程上下文类加载器加载类,比如JDBC和JNDI等,现对JDBC案例进行讨论,首先需要介绍java SPI机制。
Java SPI机制
定义:SPI 是一种基于接口的编程模式,它允许服务提供者在不修改原有系统代码的情况下,通过实现特定接口并将其部署到应用程序的类路径下,从而被应用程序自动加载和使用。服务使用者只需定义接口规范并由服务提供者负责对接口进行实现,服务使用者不用关心具体的实现细节。这使得代码的维护和升级更加容易,降低了模块之间的耦合度。
Java SPI 的详细工作原理如下
1. 定义服务接口: 首先,定义一个接口,这个接口将作为服务的规范,规定所有实现类必须遵循的方法;
2. 实现服务接口: 接着,创建一个或多个接口实现类,每个实现类代表一个具体的服务提供者;
3. 注册服务实现: 在实现类的JAR 包中,创建一个 META-INF/services/
目录,并在其中创建一个以服务接口全名命名的文件。文件内容包含实现类的全名,每行一个;
4. 加载和使用服务: 使用 ServiceLoader
类来加载这些服务实现。ServiceLoader
会查找 META-INF/services
目录中的服务定义,并加载所有实现类。
在 Java 中,数据库连接是一个典型的 SPI 应用场景。Java 的java.sql.Driver接口就是一个服务接口。不同数据库厂商(如 MySQL、Oracle、SQL Server 等)提供了各自的Driver实现类。
JDBC中使用了DriverManager来管理项目中引入的不同数据库的驱动,比如mysql驱动、oracle驱动。
DriverManager类由JDK提供,位于rt.jar包中,由启动类加载器加载。DriverManager类需要去加载服务提供者引入的jar包中的驱动类(SPI机制),而jar包中的驱动类需要委派应用程序类加载器去加载,这种父类加载器去请求子类加载器完成类的加载行为,打破了双亲委派机制。Java中涉及SPI的加载基本上都采用这种方式来完成。
Osgi框架的类加载器
历史上,OSGi模块化框架存在同级之间的类加载器的委托加载。OSGi还使用类加载器实现了热部署的功能。热部署指的是在服务不停止的情况下,动态地更新字节码文件到内存中。
以下内容节选自《深入理解 Java 虚拟机》
总结:在 OSGi 环境下,类加载器不再遵循双亲委派模型所推荐的树状结构,而是拥有一套自身的类加载规则,其中部分规则打破了类的双亲委派机制。
总结
在上述介绍的三种打破双亲委派机制的方式中,本质上只有第一种方式算是真正打破了该机制。这是因为双亲委派机制的核心代码在 loadClass 中,而只有第一种方式重写了 loadClass 方法,清除了其中有关双亲委派机制的逻辑。
至于其他两种方式,从宏观层面,也就是类的调用层面来看,违背了双亲委派机制。但这两种方式并没有重写loadClass方法,并未清除有关双亲委派机制逻辑的代码,所以从单个类的角度而言,是符合双亲委派机制的。
JDK9之后的类加载器
JDK8及之前的版本中,扩展类加载器和应用程序类加载器的源码位于rt.jar包中的sun.misc.Launcher.java。
由于JDK9引入了module的概念,类加载器在设计上发生了很多变化。
1.启动类加载器使用Java编写,位于jdk.internal.loader.ClassLoaders类中。
Java中的BootClassLoader继承自BuiltinClassLoader,实现从模块中找到要加载的字节码资源文件。启动类加载器依然无法通过java代码获取到,返回的仍然是null,保持了统一。
2、扩展类加载器被替换成了平台类加载器(Platform Class Loader)。
平台类加载器遵循模块化方式加载字节码文件,所以继承关系从URLClassLoader变成了BuiltinClassLoader,BuiltinClassLoader实现了从模块中加载字节码文件。平台类加载器的存在更多的是为了与老版本的设计方案兼容,自身没有特殊的逻辑。