一、Java类加载机制

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类加载机制的关键。

相关推荐
LL_break8 小时前
线程1——javaEE 附面题
java·开发语言·面试·java-ee
王中阳Go8 小时前
面试官:“聊聊最复杂的项目?”90%的人开口就凉!我面过最牛的回答,就三句话
java·后端·面试
virtuousOne8 小时前
线程池详解
java
不吃肉的羊8 小时前
log4j2使用
java·后端
王中阳Go8 小时前
为什么很多公司都开始使用Go语言了?为啥这个话题这么炸裂?
java·后端·go
玉衡子8 小时前
三、JVM对象创建
java
先知后行。8 小时前
Reactor模型和类图设计
java·开发语言
玉衡子8 小时前
二、JVM内存模型
java
洛小豆9 小时前
为什么 Integer a = 100; 不创建新对象?从编译到运行的全流程拆解
java·后端·spring