好怕怕的类加载器
面试官:请说说你理解的类加载器。
我:通过一个类的全限定名来获取描述此类的二进制字节流这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。实现这个动作的代码模块称为“类加载器”。
面试官:说说有哪几种类加载器,他们的职责分别是什么,他们之前存在什么样的约定。
我:emmmm,我在纸上边画边讲吧。
类加载的结构如下:
- BootstrapClassLoader:启动类类加载器,它用来加载<JAVA_HOME>/jre/lib路径,-Xbootclasspath参数指定的路径以<JAVA_HOME>/jre/classes中的类。BootStrapClassLoader是由c++实现的。
- ExtClassLoader:拓展类类加载器,它用来加载<JAVA_HOME>/jre/lib/ext路径以及java.ext.dirs系统变量指定的类路径下的类。
- AppClassLoader:应用程序类类加载器,它主要加载应用程序ClassPath下的类(包含jar包中的类)。它是java应用程序默认的类加载器。
- 用户自定义类加载器:用户根据自定义需求,自由的定制加载的逻辑,继承AppClassLoader,仅仅覆盖findClass()即将继续遵守双亲委派模型。
- *ThreadContextClassLoader:线程上下文加载器,它不是一个新的类型,更像一个类加载器的角色,ThreadContextClassLoader可以是上述类加载器的任意一种,但往往是AppClassLoader,作用我们后面再说。
在虚拟机启动的时候会初始化BootstrapClassLoader,然后在Launcher类中去加载ExtClassLoader、AppClassLoader,并将AppClassLoader的parent设置为ExtClassLoader,并设置线程上下文类加载器。
Launcher是JRE中用于启动程序入口main()的类,让我们看下Launcher的代码
public Launcher() {
Launcher.ExtClassLoader var1;
try {
//加载扩展类类加载器
var1 = Launcher.ExtClassLoader.getExtClassLoader();
} catch (IOException var10) {
throw new InternalError("Could not create extension class loader", var10);
}
try {
//加载应用程序类加载器,并设置parent为extClassLoader
this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
} catch (IOException var9) {
throw new InternalError("Could not create application class loader", var9);
}
//设置默认的线程上下文类加载器为AppClassLoader
Thread.currentThread().setContextClassLoader(this.loader);
//此处删除无关代码。。。
}
上面画的几种类加载器是遵循双亲委派模型的,其实就是,当一个类加载器去加载类时先尝试让父类加载器去加载,如果父类加载器加载不了再尝试自身加载。这也是我们在自定义ClassLoader时java官方建议遵守的约定。
面试官插嘴:ExtClassLoader为什么没有设置parent?
我:别急啊,我还没说完。
让我们看看下面代码的输出结果
public static void main(String[] args) throws ClassNotFoundException {
ClassLoader classLoader = Test.class.getClassLoader();
System.out.println(classLoader);
System.out.println(classLoader.getParent());
System.out.println(classLoader.getParent().getParent());
}
看看结果是啥
sun.misc.Launcher$AppClassLoader@18b4aac2
sun.misc.Launcher$ExtClassLoader@5a61f5df
null
因为BootstrapClassLoader是由c++实现的,所以并不存在一个Java的类,因此会打印出null,所以在ClassLoader中,null就代表了BootStrapClassLoader(有些片面)。
那么双亲委派的好处是什么呢?
双亲委派模型能保证基础类仅加载一次,不会让jvm中存在重名的类。比如String.class,每次加载都委托给父加载器,最终都是BootstrapClassLoader,都保证java核心类都是BootstrapClassLoader加载的,保证了java的安全与稳定性。
面试官:那自己怎么去实现一个ClassLoader呢?请举个实际的例子。
我:好的(脸上笑嘻嘻,心里mmp)。
自己实现ClassLoader时只需要继承ClassLoader类,然后覆盖findClass(String name)方法即可完成一个带有双亲委派模型的类加载器。
我们看下ClassLoader#loadClass的代码
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 查看是否已经加载过该类,加载过的类会有缓存,是使用native方法实现的
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
//父类不为空则先让父类加载
if (parent != null) {
c = parent.loadClass(name, false);
} else {
//父类是null就是BootstrapClassLoader,使用启动类类加载器加载
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 父类类加载器不能加载该类
}
//如果父类未加载该类
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
//让当前类加载器加载
c = findClass(name);
}
}
return c;
}
}
经典的模板方法模式,子类只需要实现findClass,关心从哪里加载即可。
还有一点,parent需要自己设置哦,可以放在构造函数做这个事情。
面试官插嘴:为什么不继承AppClassLoader呢?
我:因为它和ExtClassLoader都是Launcher的静态类,都是包访问路径权限的。
面试官:good,你继续。
我:emmmmm,那我举个实际的
代码热替换,在不重启服务器的情况下可以修改类的代码并使之生效。
- 首先是自定义一个ClassLoader
public class MyClassLoader extends ClassLoader {
//用于读取.Class文件的路径
private String swapPath;
//用于标记这些name的类是先由自身加载的
private Set<String> useMyClassLoaderLoad;
public MyClassLoader(String swapPath, Set<String> useMyClassLoaderLoad) {
this.swapPath = swapPath;
this.useMyClassLoaderLoad = useMyClassLoaderLoad;
}
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
Class<?> c = findLoadedClass(name);
if (c == null && useMyClassLoaderLoad.contains(name)){
//特殊的类让我自己加载
c = findClass(name);
if (c != null){
return c;
}
}
return super.loadClass(name);
}
@Override
protected Class<?> findClass(String name) {
//根据文件系统路径加载class文件,并返回byte数组
byte[] classBytes = getClassByte(name);
//调用ClassLoader提供的方法,将二进制数组转换成Class类的实例
return defineClass(name, classBytes, 0, classBytes.length);
}
private byte[] getClassByte(String name) {
String className = name.substring(name.lastIndexOf('.') + 1, name.length()) + ".class";
try {
FileInputStream fileInputStream = new FileInputStream(swapPath + className);
byte[] buffer = new byte[1024];
int length = 0;
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
while ((length = fileInputStream.read(buffer)) > 0){
byteArrayOutputStream.write(buffer, 0, length);
}
return byteArrayOutputStream.toByteArray();
} catch (IOException e) {
e.printStackTrace();
}
return new byte[]{};
}
}
2. 自定义一个示例类,用于被我们自己的类加载器加载
public class Test {
public void printVersion(){
System.out.println("当前版本是1哦");
}
}
3. 写个定时任务,一直调用printVersion方法,观察输出,看我们是否替换成功。
public static void main(String[] args) {
//创建一个2s执行一次的定时任务
new Timer().schedule(new TimerTask() {
@Override
public void run() {
String swapPath = MyClassLoader.class.getResource("").getPath() + "swap/";
String className = "com.example.Test";
//每次都实例化一个ClassLoader,这里传入swap路径,和需要特殊加载的类名
MyClassLoader myClassLoader = new MyClassLoader(swapPath, Sets.newHashSet(className));
try {
//使用自定义的ClassLoader加载类,并调用printVersion方法。
Object o = myClassLoader.loadClass(className).newInstance();
o.getClass().getMethod("printVersion").invoke(o);
} catch (InstantiationException |
IllegalAccessException |
ClassNotFoundException |
NoSuchMethodException |
InvocationTargetException ignored) {
}
}
}, 0,2000);
}
操作步骤:
- 先编译下工程,将Test.class拷贝到swap文件夹下。
- 运行main方法,可观察到控制台一直输出“当前版本是1哦”。
- 修改Test#pringtVersion方法的源代码,将输出的内容改为"当前版本是2哦",然后编译工程,将新的Test.class拷贝到swap文件件下,并替换之前的Test.class。
输出结果如图所示。
可以看到,我们再没有重启的情况下,完成了类的热替换。
(截图不是很完整,要相信我,我是摸着良心的操作的,绝对不是大猪蹄子)
面试官插嘴:为什么需要o.getClass().getMethod("printVersion").invoke(o);这样通过反射获取method调用,不能先强转成Test,然后test.printVersion()吗?
我:因为如果你这么写
Test test = (Test)o;
o.printVersion();
Test.class会隐性的被加载当前类的ClassLoader加载,当前Main方法默认的ClassLoader为AppClassLoader,而不是我们自定义的MyClassLoader。
面试官插嘴:那会发生什么呢?
我:会抛出ClassCastException,因为一个类,就算包路径完全一致,但是加载他们的ClassLoader不一样,那么这两个类也会被认为是两个不同的类。
面试官:嗯,其实你刚才写的ClassLoader已经破坏了双亲委派模型的约定,你不是说这是java官方推荐的约定吗?
我:java明明自己也悄悄的破坏了这个双亲委派模型。
举个jdbc的例子。
Class.forName("com.mysql.jdbc.Driver");
这句话在jdk1.6之前(准确的说是jdbc4.0之前)是调用方必须要写的,否则会找不到数据库驱动。
那在jdk1.6之后是怎么做到自动加载驱动的呢?核心在与java.sql.DriverManager。
//我们日常调用只需要这样既可获取连接,包含了自动扫描驱动
Connection connection = DriverManager.getConnection("jdbc://localhost:3306");
DriverManager有这么一段代码
static {
loadInitialDrivers();
println("JDBC DriverManager initialized");
}
private static void loadInitialDrivers() {
String drivers;
AccessController.doPrivileged(new PrivilegedAction<Void>() {
public Void run() {
//重点,在classPath下加载Driver的实现类
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
Iterator<Driver> driversIterator = loadedDrivers.iterator();
try{
while(driversIterator.hasNext()) {
//访问一下什么都不做,其实就是加载该类
driversIterator.next();
}
} catch(Throwable t) {
// Do nothing
}
return null;
}
});
println("DriverManager.initialize: jdbc.drivers = " + drivers);
}
我精简了代码,只留下了最关键的部分,当DriverManager被加载时,静态代码块执行loadInitialDrivers方法,就是加载初始驱动。
java.util.ServiceLoader.load(Class)可以查询classPath下,所有META-INF下给定Class名的文件,并将其内容返回,使用迭代器遍历,这里遍历的内部其实就是使用Class.forName加载了该类。
public static <S> ServiceLoader<S> load(Class<S> service) {
//重点重点!!!
ClassLoader cl = Thread.currentThread().getContextClassLoader();
return ServiceLoader.load(service, cl);
}
public static <S> ServiceLoader<S> load(Class<S> service,
ClassLoader loader)
{
return new ServiceLoader<>(service, loader);
}
直接看下ServiceLoader中返回的迭代器的next()方法,其实最终调用的是nextService()方法
public S next() {
if (acc == null) {
return nextService();
} else {
PrivilegedAction<S> action = new PrivilegedAction<S>() {
public S run() { return nextService(); }
};
return AccessController.doPrivileged(action, acc);
}
}
private S nextService() {
if (!hasNextService())
throw new NoSuchElementException();
Class<?> c = null;
try {
//使用当前示例的成员变量loader加载,就是上面设置的ThreadContextClassLoader
c = Class.forName(cn, false, loader);
} catch (ClassNotFoundException x) {
fail(service,
"Provider " + cn + " not found");
}
}
这样就完成了在BootstrapClassLoader加载的类(就是包名java开头的类)中通过ThreadContextClassLoader加载了应用程序的实现类。
面试官插嘴:等等,serviceLoader怎么加载的实现类?你没提啊。
我:忘了说hasNextService()方法了,
private boolean hasNextService() {
if (nextName != null) {
return true;
}
if (configs == null) {
try {
//重点 前缀 + 要查找的类的全限定名
String fullName = PREFIX + service.getName();
if (loader == null)
configs = ClassLoader.getSystemResources(fullName);
else
//使用ClassLoader的getResource方法查询资源
configs = loader.getResources(fullName);
} catch (IOException x) {
fail(service, "Error locating configuration files", x);
}
}
while ((pending == null) || !pending.hasNext()) {
if (!configs.hasMoreElements()) {
return false;
}
pending = parse(service, configs.nextElement());
}
nextName = pending.next();
return true;
}
说到底,加载类、加载资源还是得靠ClassLoader。
PREFIX的定义
private static final String PREFIX = "META-INF/services/";
咦,这个值是不是很熟悉,其实这就是java SPI机制中的PREFIX,SPI先不展开。
emmmm,
ServiceLoader.load的原理就是去classPath下寻找PREFIX + className的文件,并读取其内容作为实现类的全限定名返回,依托的是classLoader的getResources系列方法。
在这里就是去classPath下寻找META-INF/services/java.sql.Driver的文件。
好,那我们看看mysql怎么做的。
和描述如出一辙,可以填写多个实现类,也表示DriverManager是支持多个驱动的。
(MySQL Fabric 是oracle2014年推出的自动分片、自动选主的东东,不在这里展开)
至此DriverManager通过ThreadContextClassLoader“作弊”的事情就讲完了。
面试官:你刚才提到的SPI机制,可以说说它吗?你知道有哪些常用的框架使用到了它吗?
我:SPI(Service Provider Interface)服务提供接口。它是jdk内置的一种服务发现机制(不是微服务里的服务发现哦),它可以让服务定义与实现分离、解耦,大大提升了程序的扩展性。
SPI加载的核心就是ClassLoader的getResource系列方法,jdk提供了一个工具类,就是上面说的ServiceLoader。
还有像比如Spring中就实现了自己的SPI机制,举个最典型的例子,
SpringBoot Starter的原理就是依托Spring的SpringFactoriesLoader
SpringFactoriesLoader是一个Spring根据自己的需求实现的与ServiceLoader功能相仿的工具类,用于以SPI的方式加载应用程序的扩展类。
看下核心方法感受一下
private static Map<String, List<String>> loadSpringFactories(@Nullable ClassLoader classLoader) {
//弄个缓存,以免每次都重新寻找,这可是查询整个classPath下的所有jar包呢
MultiValueMap<String, String> result = (MultiValueMap)cache.get(classLoader);
if (result != null) {
return result;
} else {
try {
//spring定义的PREFIX为METF-INF/spring.factories,查找所有该文件并读取内容
Enumeration<URL> urls = classLoader != null ? classLoader.getResources("META-INF/spring.factories") : ClassLoader.getSystemResources("META-INF/spring.factories");
LinkedMultiValueMap result = new LinkedMultiValueMap();
//包装成一个map返回
while(urls.hasMoreElements()) {
URL url = (URL)urls.nextElement();
UrlResource resource = new UrlResource(url);
Properties properties = PropertiesLoaderUtils.loadProperties(resource);
Iterator var6 = properties.entrySet().iterator();
while(var6.hasNext()) {
Entry<?, ?> entry = (Entry)var6.next();
List<String> factoryClassNames = Arrays.asList(StringUtils.commaDelimitedListToStringArray((String)entry.getValue()));
result.addAll((String)entry.getKey(), factoryClassNames);
}
}
//放入缓存
cache.put(classLoader, result);
return result;
} catch (IOException var9) {
throw new IllegalArgumentException("Unable to load factories from location [META-INF/spring.factories]", var9);
}
}
}
springboot自动配置的原因是因为使用了@EnableAutoConfiguration注解。
当程序包含了EnableAutoConfiguration注解,那么就会执行下面的方法,然后会加载所有spring.factories文件,将其内容封装成一个map,spring.factories其实就是一个名字特殊的properties文件。
在spring-boot应用启动时,会调用loadFactoryNames方法,其中传递的FactoryClass就是EnableAutoConfiguration。
public static List<String> loadFactoryNames(Class<?> factoryClass, @Nullable ClassLoader classLoader) {
// 此例就是:org.springframework.boot.autoconfigure.EnableAutoConfiguration
String factoryClassName = factoryClass.getName();
return (List)loadSpringFactories(classLoader).getOrDefault(factoryClassName, Collections.emptyList());
}
protected Class<?> getSpringFactoriesLoaderFactoryClass() {
return EnableAutoConfiguration.class;
}
这里可能描述的不清楚,那我们稍微看下mybatis-spring-boot-starter
真相大白!!!
我可以休息会儿了吧。
面试官:嗯,了解的还是蛮全面的。不过原本期待听到的例子是OSGI。
我:哦。。。。。。。。。
面试官:那我们换个话题,上面你提到Spring,那我们聊一聊Spring吧。
我:能不能让我歇会儿。。。
面试官:这样啊,那今天的面试到此结束,你回去歇着等通知吧。
我:*!&(*$*!@@¥(!)(***%#¥@。
yy 了一段关于类加载器的面试过程,根据问题的不断深入,较系统的回顾了下类加载器的相关知识及应用。
知乎处女作,希望大佬们热情的轻喷。
作者能力有限,如有不正,别宠溺我。