Java中一个类从它的class文件被加载进入虚拟机到被JVM卸载,其生命周期大致可以分为以下几步:
每个步骤详细内容可以参考相关JVM书籍,在整个流程中除了加载之外的几个步骤主要都是由JVM控制执行的,留给我们手动干涉的空间不是很大。加载这个步骤是由ClassLoader来控制的,而且允许我们继承ClassLoader来控制其相关加载行为,本篇文章将简单介绍ClassLoader相关内容。
一.JDK默认实现
JDK中对于类加载流程的默认实现就是大名鼎鼎的双亲委托机制,双亲委托加载机制是一个分层次的类加载机制,不同的类加载器负责加载不同的类。JDK默认的实现中相关的类加载器主要有Bootstrap ClassLoader 、Extension ClassLoader、Application ClassLoader。
具体的加载流程:当一个类被加载时,默认会调用Application ClassLoader,但AppClassLoader并不会直接加载该类,而是通过parent变量找到其父类加载器(Extension ClassLoader),委托给其父类进行加载,ExtClassLoader会委托给Bootstrap ClassLoader,Bootstrap CLassLoader没有父类了。此时Bootstrap CLassLoader会对需要加载类尝试进行加载,如果无法加载则返回ExtClassLoader进行加载。同理,如果ExtClassLoader无法加载则返回AppClassLoader进行加载,如果AppClassLoader也无法加载则抛出ClassNotFoundException异常。上述过程有一个类加载器可以完成相关加载任务,则加载成功,存放其元数据并创建Class对象(JDK8之后Class对象是在堆区)。
这里需要注意一点,JVM的类加载传到规则是默认情况下main方法是由AppClassLoader加载的,而且会选择当前类的类加载器来加载所有该类的引用的类。
看源码之前,先介绍一下这几个类加载器各自负责加载类。
Bootstrap CLassLoader:负责加载你配置的环境变量JAVA_HOME/lib下的核心类库。
Extension ClassLoader:负责加载JAVA_HOME/lib/ext下的扩展类库。
Application ClassLoader:负责加载CLASSPATH下指定的类库。
下面来结合代码具体看一下JDK中默认的类加载器:
public class TestClassLoader {@Testpublic void t1_classLoader(){ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();System.out.println("systemClassLoader: " + systemClassLoader);ClassLoader extClassLoader = systemClassLoader.getParent();System.out.println("extClassLoader: " + extClassLoader);ClassLoader bootClassLoader = extClassLoader.getParent();System.out.println("bootClassLoader: " + bootClassLoader);ClassLoader defaultClassLoader = TestClassLoader.class.getClassLoader();System.out.println("defaultClassLoader: " + defaultClassLoader);ClassLoader stringClassLoader = String.class.getClassLoader();System.out.println("stringClassLoader: " + stringClassLoader);System.out.println("loggingClassLoader: " + Logging.class.getClassLoader());}}
双亲委托的加载流程是从ClassLoader的loadClass()开始的,下面我们来看一下JDK的具体实现。
根据上面的流程,可以看出来:
1.loadClass方法是整个双亲委托模型的模板函数,覆盖该函数可以改变默认的加载行为。
2.某个类加载器的加载逻辑是由findClass来控制的。
因此如果想要改变双亲委托模型的流程,则需要覆盖loadClass方法,不需要改变双亲委托模型的流程只是想让自己的类加载器加载某个指定目录的文件则只需要覆盖findClass方法。
二.自定义类加载器
为什么需要自定义类加载器?
可能的原因包括:
- 类隔离机制
- 热加载、热部署
- 加密
类隔离机制
在JVM中表示两个Class对象是否为同一个类有两部分组成:
- 类的完全限定名需要一致(包含包名在内的路径)
- 加载这个类的ClassLoader必须相同
这两个条件给我们提供了一种实现类隔离机制的契机。
但是,我们还是要先思考一下,什么是类隔离?为什么需要类隔离?
假设这样一个场景,在微服务架构下,某个业务逻辑模块(模块A)需要调用两个其它的模块(我们记作模块B、模块C)。模块B、C都依赖与一个相同给的jar包(比如fastjson,并且C中的版本大于B),而且使用的fastjson的版本是不同的,此时模块A添加了相应的Maven依赖之后到底是使用拿个模块的fastjson呢?这依赖于Maven内部的引用路径最短原则,这里我们假设它会引用模块B的jar包。
问题在于,不同的fastjson版本对于某一个类可能会有所不同。记某一个相同类为Class Conflict:
模块B的fastjson中该类为:
Class Conflict{methodA(){}
}
模块C的fastjson中该类为:
Class Conflict{methodA(){}methodB(){}
}
由于此时业务模块引用的是模块B的jar包,那此时业务模块嗲用methodB就会抛出异常。
这个问题在微服务架构下很常见,如何解决?
让所有模块都使用同一个版本的jar包?如果项目不大可能是一个办法,但是项目很大,沟通不便很容易出错。而且模块B C有其本身的逻辑,要保证其稳定性,不同的fastjson版本可能会导致B、C逻辑产生错误,为了保证模块B、C自身的稳定性,也不太可能随意改变依赖的jar包的版本。
这时候就用到类隔离机制了!
使用类隔离机制如何解决这个问题?很简单让不同的模块使用不同给的类加载器(可以是不同的类加载实现,也可以是同一个实现的不同实例),这样再调用methodB时候不用用模块B的jar包,而会用模块C自己的jar包,这也是Pandora Boot实现类隔离的基础。
总之就一句话:让不同模块的 jar 包用不同的类加载器加载。
但是一个模块有很多jar包,难道对于每一个jar包我们都需要自己指定类加载器?当然不需要,上面说过类加载的传导规则。根据传导规则,只需要让该模块的main方法使用自定义的类加载器来加载,后续所有类都会使用该自定义的类加载器,有兴趣可以了解下 OSGi 和 SofaArk 能够实现类隔离的原理。
我们看一下以下代码:
public class TestA {public static void main(String[] args) {TestA testA = new TestA();testA.hello();}public void hello() {System.out.println("TestA: " + this.getClass().getClassLoader());TestB testB = new TestB();testB.hello();}
}
public class TestB {public void hello() {System.out.println("TestB: " + this.getClass().getClassLoader());}
}
public class MyClassLoader1 extends ClassLoader{private Map<String, String> classPathMap = new HashMap<>();public MyClassLoader1() {String rootPath = System.getProperty("user.dir");classPathMap.put("t_classloader.TestA", rootPath + "/classesDir/TestA.class");classPathMap.put("t_classloader.TestB", rootPath + "/classesDir/TestB.class");}// 重写了 findClass 方法@Overridepublic Class<?> findClass(String name) throws ClassNotFoundException {String classPath = classPathMap.get(name);File file = new File(classPath);if (!file.exists()) {throw new ClassNotFoundException();}byte[] classBytes = getClassData(file);if (classBytes == null || classBytes.length == 0) {throw new ClassNotFoundException();}return defineClass(classBytes, 0, classBytes.length);}private byte[] getClassData(File file) {try (InputStream ins = new FileInputStream(file); ByteArrayOutputStream baos = newByteArrayOutputStream()) {byte[] buffer = new byte[4096];int bytesNumRead = 0;while ((bytesNumRead = ins.read(buffer)) != -1) {baos.write(buffer, 0, bytesNumRead);}return baos.toByteArray();} catch (FileNotFoundException e) {e.printStackTrace();} catch (IOException e) {e.printStackTrace();}return new byte[] {};}
}
public class MyTest {public static void main(String[] args) throws Exception {MyClassLoader1 myClassLoader1 = new MyClassLoader1();Class testAClass = myClassLoader1.findClass("t_classloader.TestA");Method mainMethod = testAClass.getDeclaredMethod("main", String[].class);mainMethod.invoke(null, new Object[]{args});}
}
这里重写了findClass方法,因此并没有改变双亲加载的流程。
我们在main方法中手动调用MyClassLoader1的findclass方法,因此TestA确实是用MyClassLoader1加载的。而在执行到hello时,需要加载TestB,此时TestA的默认类加载器确实是MyClassLoader1,但是由于双亲委托机制的存在导致,会调用CLassloader的loadclass方法进而是用AppClassLoader进行加载。
我们可以重写loadClass方法来实现让TestB也使用和TestA一样的类加载器进行加载。
但是重写时候需要注意:
1.选择合适的载入点(自己起的名字),什么意思呢?上例中是因为类加载机制会使得AppClassloader加载了TestB,那么我们只需要改变类加载机制的规则让其跳过AppClassloader即可。
2.对于Bootstrap类加载器加载的类是不允许被破坏的,而ExtClassloader加载的类是可以被自定义类来代替的,所以我们必须保证自己重写的loadclass方法能实现这一基本特征。
对于上例,代码如下:
public class MyClassLoader2 extends ClassLoader{private ClassLoader jdkClassLoader;private Map<String, String> classPathMap = new HashMap<>();public MyClassLoader2(ClassLoader jdkClassLoader) {this.jdkClassLoader = jdkClassLoader;String rootPath = System.getProperty("user.dir");classPathMap.put("t_classloader.TestA", rootPath + "/classesDir/TestA.class");classPathMap.put("t_classloader.TestB", rootPath + "/classesDir/TestB.class");}@Overrideprotected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {Class result = null;try {//这里要使用 JDK 的类加载器加载 java.lang 包里面的类result = jdkClassLoader.loadClass(name);} catch (Exception e) {//忽略}if (result != null) {return result;}String classPath = classPathMap.get(name);File file = new File(classPath);if (!file.exists()) {throw new ClassNotFoundException();}byte[] classBytes = getClassData(file);if (classBytes == null || classBytes.length == 0) {throw new ClassNotFoundException();}return defineClass(classBytes, 0, classBytes.length);}private byte[] getClassData(File file) {try (InputStream ins = new FileInputStream(file); ByteArrayOutputStream baos = newByteArrayOutputStream()) {byte[] buffer = new byte[4096];int bytesNumRead = 0;while ((bytesNumRead = ins.read(buffer)) != -1) {baos.write(buffer, 0, bytesNumRead);}return baos.toByteArray();} catch (FileNotFoundException e) {e.printStackTrace();} catch (IOException e) {e.printStackTrace();}return new byte[] {};}
}
public static void main(String[] args) throws Exception {//这里取AppClassLoader的父加载器也就是ExtClassLoader作为MyClassLoaderCustom的jdkClassLoaderMyClassLoader2 myClassLoader2 = new MyClassLoader2(Thread.currentThread().getContextClassLoader().getParent());Class testAClass = myClassLoader2.loadClass("t_classloader.TestA");Method mainMethod = testAClass.getDeclaredMethod("main", String[].class);mainMethod.invoke(null, new Object[]{args});}
结果与我们预期一致。
热加载、热部署
**热加载:**针对的是单个字节码文件,指的是修改源文件后不需要重启应用 ,应用程序就可以加载使用新的class文件。
**热部署(Hot Deploy):**热部署针对的是 容器或者是整个应用,包括运行需要使用到的各种文件(jar包、JS、CSS、html、配置文件),新的资源或者修改了一些代码,需要在不停机的情况下的重新加载整个应用;
为什么需要热加载?一个简单的原因就是进行项目开发时我们需要不停的调试,如果项目太大,每一次修改启动都需要很长时间,如果采用热加载就可以不用节省这部分时间。
热加载的实现思路:
默认的JVM虚拟机行为只会在启动时加载类,如果后期有一个类需要更新的话,单纯替换编译的 class 文件,Java 虚拟机是不会更新正在运行的 class。
1.可以修改JV源码,改变类加载器的行为。(JRebel和美团的Sonic就是这样做的)
2.使用一个线程不停的轮询需要热加载的类(或者是某一个文件夹下的类)
这里由于个人水平有限,以第二种方式进行演示,整体思路如下:
实现自己的类加载器。
从自己的类加载器中加载要热加载的类。
不断轮训要热加载的类 class 文件是否有更新。
如果有更新,重新加载。
public static void main(String[] args) throws Exception {while(true){//这里取AppClassLoader的父加载器也就是ExtClassLoader作为MyClassLoaderCustom的jdkClassLoaderMyClassLoader2 myClassLoader2 = new MyClassLoader2(Thread.currentThread().getContextClassLoader().getParent());Class testAClass = myClassLoader2.loadClass("t_classloader.TestA");Method mainMethod = testAClass.getDeclaredMethod("main", String[].class);mainMethod.invoke(null, new Object[]{args});TimeUnit.SECONDS.sleep(10);}}
类加载器同上文,然后运行看效果。在运行过程中动态替换TestA.class文件,不用重启应用也可以进行加载。
这是一个简单的实现思路,但缺点很明显每次类变更,需要重新new一个类加载器,开销太大。不过本文知识简单介绍一下相关知识,有兴趣可以看一下这篇那文章:
https://blog.51cto.com/u_14006572/5711336
加密
JVM是通过加载class文件来执行程序的指令流的,而class文件的格式又是公开的,因此使用反编译软件或者自己手写一个字节码解析小程序都很容易就能获得class文件对应的源代码。有时候我们并不是很希望别人能看到我们的源码但同时又需要保证字节码可以被正确解析,此时我们可以对class文件的生成加载过程进行适当更改来达到这个目的。
整体思路如下:
如上图,可以对生成的class文件采用加密算法进行加密,然后用自定义的类加载器来完成解密操作,这样就可以在一定程度上保证了源代码的安全性。
三.SPI机制
SPI( Service Provider Interface) 是一种服务发现机制。它通过在ClassPath路径下的META-INF/services文件夹查找文件,自动加载文件里所定义的类。通过SPI机制可以让接口和实现类分离,服务提供者只提供接口,第三方来进行实现。
SPI的一个经典应用就是JDBC,JAVA只提供操作数据库的接口,而各个数据库厂商根据接口提供相应的实现,然后按照规则编写配置文件,使用者引入jar包就可以使用。
这里之所以介绍SPI是因为这个机制给类加载机制带来了一些困扰。
具体表现在:接口是写在JDK中的,而且是由Bootstrap Classloader来加载的,而实现类是作为第三方包来加载的(默认是AppClassloader来加载),根据类的委派规则,Bootstrap Classloader是不能主动委托AppclassLoader来加载第三方包的!
这个问题JDK是通过线程上下文类加载器来解决的。本文不介绍JDBC代码如何编写,如有需要请自行查阅有关资料。
我们来看DriverManager执行流程,是怎么实现加载相关的类的。
上面的流程可以看出,Bootstrap Classloader通过线程上下文类加载器(默认是AppClassloader)会去加载路径META-INF/services/接口全限定名下的包,这样也就完成了相关加载操作(成功实现了由Bootstrap Classloader去委派AppClassloader执行家在任务)。
基于这个思想,我们也可以简单写一个SPI程序,如下:
- 定义接口/抽象类
- 实现类
- 在META-INF/services下,创建一个以接口的全限定名为名称的文件,内容是提供是该接口的实现类的全限定名。
- 使用ServiceLoader.load()方法来加载实现类
项目结构如下
public interface MyService {String spiMethod();
}
public class MyServiceImpl implements MyService{@Overridepublic String spiMethod() {return "NUAA";}
}
public class SPIBootStrap {public static void main(String[] args) {ServiceLoader<MyService> myServices = ServiceLoader.load(MyService.class);Iterator<MyService> iterator = myServices.iterator();while (iterator.hasNext()) {MyService demoService = iterator.next();System.out.println(demoService.getClass().getName());System.out.println(demoService.getClass().getClassLoader());System.out.printf(demoService.spiMethod());}}
}
可以看到成功加载了实现类。
但是上述JDK中的SPI机制也有不足之处:
1.不能按需加载,需要遍历所有的实现,并实例化,然后在循环中才能找到我们需要的实现。如果不想用某些实现类,或者某些类实例化很耗时,它也被载入并实例化了,这就造成了浪费。
2.获取某个实现类的方式不够灵活,只能通过 Iterator 形式获取,不能根据某个参数来获取对应的实现类。
3.多个并发多线程使用 ServiceLoader 类的实例是不安全的。
针对以上缺点可以参考Dubbo中给的SPI机制是如何实现的。