Java类加载机制
三句话总结JDK8的类加载机制:
- 类缓存:每个类加载器对他加载过的类都有一个缓存。
- 双亲委派:向上委托查找,向下委托加载。
- 沙箱保护机制:不允许应用程序加载JDK内部的系统类。
JDK8的类加载体系
java
public class LoaderDemo {
public static String a ="aaa";
public static void main(String[] args) throws ClassNotFoundException {
// 父子关系 AppClassLoader <- ExtClassLoader <- BootStrap Classloader
ClassLoader cl1 = LoaderDemo.class.getClassLoader();
System.out.println("cl1 > " + cl1);
System.out.println("parent of cl1 > " + cl1.getParent());
// BootStrap Classloader由C++开发,是JVM虚拟机的一部分,本身不是JAVA类。
System.out.println("grant parent of cl1 > " + cl1.getParent().getParent());
// String,Int等基础类由BootStrap Classloader加载。
ClassLoader cl2 = String.class.getClassLoader();
System.out.println("cl2 > " + cl2);
System.out.println(cl1.loadClass("java.util.List").getClass().getClassLoader());
// java指令可以通过增加-verbose:class -verbose:gc 参数在启动时打印出类加载情况
// 这些参数来自于 sun.misc.Launcher 源码
// BootStrap Classloader,加载java基础类。
System.out.println("BootStrap ClassLoader加载目录:" + System.getProperty("sun.boot.class.path"));
// Extention Classloader 加载一些扩展类。 可通过-D java.ext.dirs另行指定目录
System.out.println("Extention ClassLoader加载目录:" + System.getProperty("java.ext.dirs"));
// AppClassLoader 加载CLASSPATH,应用下的Jar包。可通过-D java.class.path另行指定目录
System.out.println("AppClassLoader加载目录:" + System.getProperty("java.class.path"));
}
}
可以看到JDK8中的两个类加载体系:

左侧是JDK中实现的类加载器,通过parent属性形成父子关系。应用中自定义的类加载器的parent都AppClassLoader
右侧是JDK中的类加载器实现类。通过类继承的机制形成体系。未来我们就可以通过继承相关的类实现自定义类加载器。
简而言之,左侧是对象,右侧是类。
JDK8中的类加载器都继承于一个统一的抽象类ClassLoader,类加载的核心也在这个父类中。其中,加载类的核心方法如下:
java
//类加载器的核心方法
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 每个类加载起对他加载过的类都有一个缓存,先去缓存中查看有没有加载过
Class<?> c = findLoadedClass(name);
if (c == null) {
//没有加载过,就走双亲委派,找父类加载器进行加载。
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
}
if (c == null) {
long t1 = System.nanoTime();
// 父类加载起没有加载过,就自行解析class文件加载。
c = findClass(name);
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
//这一段就是加载过程中的链接Linking部分,分为验证、准备,解析三个部分。
// 运行时加载类,默认是无法进行链接步骤的。
if (resolve) {
resolveClass(c);
}
return c;
}
}
这个方法就是最为核心的双亲委派机制。并且这个方法是protected声明的,这意味着,这个方法是可以被子类覆盖的。所以,双亲委派机制也是可以被打破的。
当一个类加载器要加载一个类时,整体的过程就是通过双亲委派机制向上委托查找,如果没有查找到,就向下委托加载。整个过程整理如下图:

沙箱保护
java
private ProtectionDomain preDefineClass(String name, ProtectionDomain pd)
{
if (!checkName(name))
throw new NoClassDefFoundError("IllegalName: " + name);
// 不允许加载核心类
if ((name != null) && name.startsWith("java.")) {
throw new SecurityException
("Prohibited package name: " +
name.substring(0, name.lastIndexOf('.')));
}
if (pd == null) {
pd = defaultDomain;
}
if (name != null) checkCerts(name, pd.getCodeSource());
return pd;
}
这个方法会用在JAVA在内部定义一个类之前。这种简单粗暴的处理方式,当然是有很多时代的因素。也因此在JDK中,你可以看到很多javax开头的包。这个奇怪的包名也是跟这个沙箱保护机制有关系的。
Linking链接过程
在ClassLoader的loadClass方法中,还有一个不起眼的步骤,resolveClass。这是一个native方法。而其实现的过程称为linking-链接。链接过程的实现功能如下图:

其中关于半初始化状态就是JDK在处理一个类的static静态属性时,会先给这个属性分配一个默认值,作用是占住内存。然后等连接过程完成后,在后面的初始化阶段,再将静态属性从默认值修改为指定的初始值。
注意,static静态的属性,是属于类的,他是在类初始化过程中维护的。而普通的属性是属于对象的,他是在创建对象的过程中维护的。这两个不要搞混了
对应到class文件当中,一个是方法,一个是方法。
java
class Apple{
static Apple apple = new Apple(10);
static double price = 20.00;
double totalpay;
public Apple (double discount) {
System.out.println("===="+price);
totalpay = price - discount;
}
}
public class PriceTest01 {
public static void main(String[] args) {
System.out.println(Apple.apple.totalpay);
}
}
程序打印出的结果是-10,而不是10。这感觉有点反直觉,为什么呢?就是因为这个半初始化状态。
其中Apple.apple访问了类的静态变量,会触发类的初始化,即加载-》链接-》初始化
当main方法执行构造函数时,price还没有初始化完成,处于链接阶段的准备阶段,其值为默认值0。这时构造函数的price就是0,所以最终打印出来的结果是-10而不是10。
要想正常打印10可以让price初始化先于Apple构造函数:
price提前:
java
//把 price 提前
// 当执行构造函数时,price 已经完成了初始化。
class Apple2{
static double price = 20.00;
static Apple2 apple = new Apple2(10);
double totalpay;
public Apple2 (double discount) {
System.out.println("===="+price);
totalpay = price - discount;
}
}
public class PriceTest2 {
public static void main(String[] args) {
System.out.println(Apple2.apple.totalpay);
}
}
使用final关键字:
java
// 给price添加final关键字,price的初始化过程会提前到编译阶段之前。
class Apple3{
static Apple3 apple = new Apple3(10);
final static double price = 20.00;
double totalpay;
public Apple3 (double discount) {
System.out.println("===="+price);
totalpay = price - discount;
}
}
public class PriceTest3 {
public static void main(String[] args) {
System.out.println(Apple3.apple.totalpay);
}
}
在链接过程还有许多其他概念,如符号引用和直接引用。如果A类中有一个静态属性,引用了另一个B类。那么在对类进行初始化的过程中,因为A和B这两个类都没有初始化,JVM并不知道A和B这两个类的具体地址。所以这时,在A类中,只能创建一个不知道具体地址的引用,指向B类。这个引用就称为符号引用 。而当A类和B类都完成初始化后,JVM自然就需要将这个符号引用转而指向B类具体的内存地址,这个引用就称为直接引用。
不知你是否有注意到在ClassLoader加载类时resolve恒为false,表示不进行链接。就是说搞了一个resolve参数却不让我们传递,这到底是为什么呢?
java
public Class<?> loadClass(String name) throws ClassNotFoundException {
return loadClass(name, false);
}
创建的时候jvm需要分配内存,为了更好的内存管理,jvm希望在进程启动的时候就将类全部都创建好,而不是运行过程中再重新分配内存。这样内存管理就更加不可控,所以一般情况下运行时类加载都不会进行链接。
java
public class Test {
public static void main(String[] args) throws ClassNotFoundException {
ClassLoader cl = Test.class.getClassLoader();
cl.loadClass("com.roy.cl.LoaderDemo");
System.out.println("===================");
Class.forName("com.roy.cl.LoaderDemo");
}
}
输出:
scss
===================
LoaderDemo static block
可以看到使用loadClass时并不会打印静态代码块中的内容,也就是并未进行链接过程,而使用Class.forName则执行了静态代码块中的方法,是进行了链接的。因此从安全角度考虑肯定是loadClass更加安全一些。
类加载器引入外部jar包
其实对于那些流程比较统一,但是具体实现规则经常容易变化的场景,可以将jar包放到外部加载。例如:规则引擎、统一审批规则、订单状态规则等。
利用URLClassLoader可以定义URL从远程Web服务器加载Jar包。drools规则引擎则是实现了从maven仓库远程加载核心规则文件。
java
import java.net.URL;
import java.net.URLClassLoader;
public class OADemo2 {
public static void main(String[] args) throws Exception {
Double salary = 15000.00;
Double money = 0.00;
URL jarPath = new URL("file:/Users/roykingw/DevCode/ClassLoadDemo/out/artifacts/SalaryCaler_jar/SalaryCaler.jar");
URLClassLoader urlClassLoader = new URLClassLoader(new URL[] {jarPath});
//模拟不停机状态
while (true) {
try {
money = calSalary(salary,urlClassLoader);
System.out.println("实际到手Money:" + money);
}catch(Exception e) {
e.printStackTrace();
System.out.println("加载出现异常 :"+e.getMessage());
}
Thread.sleep(5000);
}
}
private static Double calSalary(Double salary,ClassLoader classloader) throws Exception {
Class<?> clazz = classloader.loadClass("com.roy.oa.SalaryCaler");
if(null != clazz) {
Object object = clazz.newInstance();
return (Double)clazz.getMethod("cal", Double.class).invoke(object, salary);
}
return -1.00;
}
}
自定义类加载器实现热加载
上面那种做法每次替换jar包都需要重启服务才能生效,那么有没有什么办法能够让jar包修改自动生效呢?
其实深入分析就很容易找到原因。SalaryCaler类无法及时更新的根本原因就在于SalaryJARLoader对他加载过的类都保存了一个缓存。只要这个缓存存在,SalaryClassLoader就不会去jar包中加载,而是从缓存当中加载。而这个缓存是在JVM层面实现的,JAVA代码接触不到这个缓存,所以解决的思路自然就只能简单粗暴的连这个SalaryJARLoader也一起重新创建一个了。
自定义jar源的类加载器
java
public class SalaryJARLoader extends SecureClassLoader {
private String jarFile;
public SalaryJARLoader(String jarFile) {
this.jarFile = jarFile;
}
@Override
protected Class<?> findClass(String fullClassName) throws ClassNotFoundException {
String classFilepath = fullClassName.replace('.', '/').concat(".class");
System.out.println("重新加载类:"+classFilepath);
int code;
try {
// 访问jar包的url
URL jarURL = new URL("jar:file:" + jarFile + "!/" + classFilepath);
// InputStream is = jarURL.openStream();
URLConnection urlConnection = jarURL.openConnection();
// 不使用缓存 不然有些操作系统下会出现jar包无法更新的情况
urlConnection.setUseCaches(false);
InputStream is = urlConnection.getInputStream();
ByteArrayOutputStream bos = new ByteArrayOutputStream();
while ((code = is.read()) != -1) {
bos.write(code);
}
byte[] data = bos.toByteArray();
is.close();
bos.close();
return defineClass(fullClassName, data, 0, data.length);
} catch (Exception e) {
e.printStackTrace();
System.out.println("加载出现异常 :"+e.getMessage());
throw new ClassNotFoundException(e.getMessage());
// return null;
}
}
}
java
public class OADemo5 {
public static void main(String[] args) throws Exception {
Double salary = 15000.00;
Double money = 0.00;
//模拟不停机状态
while (true) {
try {
money = calSalary(salary);
System.out.println("实际到手Money:" + money);
}catch(Exception e) {
System.out.println("加载出现异常 :"+e.getMessage());
}
Thread.sleep(5000);
}
}
private static Double calSalary(Double salary) throws Exception {
SalaryJARLoader salaryClassLoader = new SalaryJARLoader("/Users/roykingw/lib/SalaryCaler.jar");
System.out.println(salaryClassLoader.getParent());
Class<?> clazz = salaryClassLoader.loadClass("com.roy.oa.SalaryCaler");
if(null != clazz) {
Object object = clazz.newInstance();
return (Double)clazz.getMethod("cal", Double.class).invoke(object, salary);
}
return -1.00;
}
}
通过这种方式,每次都是创建出一个新的SalaryJARLoader对象,那么他的缓存肯定是空的。那么他自然就只能每次都从jar包当中加载类了。于是就可以愉快的随时切换jar包,实现热更新了。Jrebel和Arthas之类的热加载也都是基于此实现的。
不过这种热加载机制需要创建出非常多的ClassLoader对象。而这些不用的ClassLoader对象加载过的缓存对象也会随之成为垃圾。这会让JVM中本来就不大的元数据区带来很大的压力,极大的增加GC线程的压力。
把SalaryJARLoader加载过的类打印出来,你会发现,在加载SalaryCaler时,其实不光加载了这个类,同时还加载了Double和Object两个类。
这两个类哪里来的?这就是JVM实现的懒加载机制。JVM为了提高类加载的速度,并不是在启动时直接把进程当中所有的类一次加载完成,而是在用到的时候才去加载。也就是懒加载。
打破双亲委派,实现同类多版本共存
上面定义的SalaryJARLoader走的还是双亲委派模型,其parent属性指向的是AppClassLoader。而AppClassLoader会加载当前系统中的所有代码,如果此时有人也定义了一个相同SalaryCaler类时,就会导致无法加载jar包中的SalaryCaler类了。
所以,要保持热加载机制不失效,那就只能对这个双亲委派机制下手了。
下手的逻辑也很简单,我们只需要让这个SalaryCaler类优先从jar包中加载就可以了。
java
public class SalaryJARLoader6 extends SecureClassLoader {
private String jarFile;
public SalaryJARLoader6(String jarFile) {
this.jarFile = jarFile;
}
@Override
public Class<?> loadClass(String name,boolean resolve) throws ClassNotFoundException {
// 把双亲委派机制反过来,先到子类加载器中加载,加载不到再去父类加载器中加载。
Class<?> c = null;
synchronized (getClassLoadingLock(name)) {
c = findLoadedClass(name);
if(c == null){
c = findClass(name);
if(c == null){
c = super.loadClass(name,resolve);
}
}
}
return c;
}
@Override
protected Class<?> findClass(String fullClassName) throws ClassNotFoundException {
String classFilepath = fullClassName.replace('.', '/').concat(".class");
System.out.println("重新加载类:"+classFilepath);
int code;
try {
// 访问jar包的url
URL jarURL = new URL("jar:file:" + jarFile + "!/" + classFilepath);
URLConnection urlConnection = jarURL.openConnection();
urlConnection.setUseCaches(false);
InputStream is = urlConnection.getInputStream();
// InputStream is = jarURL.openStream();
ByteArrayOutputStream bos = new ByteArrayOutputStream();
while ((code = is.read()) != -1) {
bos.write(code);
}
byte[] data = bos.toByteArray();
is.close();
bos.close();
return defineClass(fullClassName, data, 0, data.length);
} catch (Exception e) {
// e.printStackTrace();
//当前类加载器出现异常,就会通过双亲委派,交由父加载器去加载
// System.out.println("加载出现异常 :"+e.getMessage());
// throw new ClassNotFoundException(e.getMessage());
return null;
}
}
}
双亲委派机制是非常基础的一个底层体系,很多重要框架都需要进行定制。
例如Tomcat的类加载体系如下:

tomcat的几个主要类加载器:
-
commonLoader:Tomcat最基本的类加载器,加载路径中的class可以被Tomcat容器本身以及各个Webapp访问;
-
catalinaLoader:Tomcat容器私有的类加载器,加载路径中的class对于Webapp不可见;
-
sharedLoader:各个Webapp共享的类加载器,加载路径中的class对于所有Webapp可见,但是对于Tomcat容器不可见;
-
WebappClassLoader:各个Webapp私有的类加载器,加载路径中的class只对当前Webapp可见,比如加载war包里相关的类,每个war包应用都有自己的。WebappClassLoader,实现相互隔离,比如不同war包应用引入了不同的spring版本,这样实现就能加载各自的spring版本;
-
Jsp类加载器:针对每个JSP页面创建一个加载器。这个加载器比较轻量级,所以Tomcat还实现了热加载,也就是JSP只要修改了,就创建一个新的加载器,从而实现了JSP页面的热更新。
总结
Java类加载机制的核心要点:
三大机制:
- 类缓存:每个类加载器维护已加载类的缓存
- 双亲委派:向上委托查找,向下委托加载,保证类加载的安全性
- 沙箱保护:禁止应用程序加载JDK核心类
类加载过程: 加载 → 链接(验证、准备、解析)→ 初始化
实际应用:
- 通过URLClassLoader实现外部jar包动态加载
- 通过自定义类加载器实现热加载机制
- 通过打破双亲委派实现同类多版本共存(如Tomcat)
关键点: 理解类加载的时机、缓存机制和双亲委派的可重写性是掌握Java类加载机制的关键。