目录

很有名 の weblog

X

类加载机制深度剖析

1.类加载过程

多个java文件经过编译打包生成可运行jar包,最终由java命令运行某个主类的main函数启动程序,这里首先需要通过类加载器把主类加载到JVM。主类在运行过程中如果使用到其他类,会逐步加载这些类。也就是说,jar包中的类并不是一次性全部加载的,是使用到时候才进行加载。

类加载到使用整个过程有如下几步:

加载\Rightarrow验证\Rightarrow准备\Rightarrow解析\Rightarrow初始化\Rightarrow使用\Rightarrow卸载

这里对每一个步骤进行说明

  • 加载:在硬盘上查找并通过IO读入字节码文件,使用到类的时候才会加载,例如调用类的main()方法,新建对象等。
  • 验证:校验字节码文件的正确性。字节码文件的头几位会判断Java版本,当java8生成的字节码用java6启动的时候可能会导致报错。
  • 准备:将类的静态变量分配内存,赋予默认值
  • 解析:将符号引用替换为直接引用,该阶段会把一些静态方法(符号引用,比如main()方法替换为指向数据所存内存的指针或句柄等(直接引用)),这就是所谓的静态链接过程(类加载期间完成),动态链接是程序运行期间完成的,将符号引用替换为直接引用
  • 初始化:对类的静态变量赋予指定值,执行静态代码块image.png

2. 类加载器和双亲委派机制

上面类的加载过程主要是通过类加载器来实现的,在类加载的第一阶段“加载”过程中,需要通过一个类的全限定名来获取定义此类的二进制字节流,完成这个动作的代码块就是类加载器。这一动作是放在Java虚拟机外部去实现的,以便让应用程序自己决定如何获取所需的类。

所以你可以从ZIP包中读取从网络中获取运行时计算生成(动态代理)从其他文件生成(.jsp本质是class)

Java里有如下几种类加载器

  • 启动类加载器:负责将 Java_Home/lib下面的类库加载到内存中(比如rt.jar)。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。
  • 扩展类加载器:是由 Sun 的 ExtClassLoader(sun.misc.Launcher$ExtClassLoader)实现的。它负责将Java_Home /lib/ext或者由系统变量 java.ext.dir指定位置中的类库加载到内存中。开发者可以直接使用标准扩展类加载器。
  • 应用程序类加载器:负责加载classpath路径下的类包。主要就是加载你自己写的类
  • 自定义加载器:负责加载用户自定义路径下的类包

启动类加载器属于虚拟机的一部分,它是用C++写的,看不到源码,所以如果试图通过

System.out.println(String.class.getClassLoader());

打印的是null值

2.1 自定义一个类加载示例

自定义类加载器只需要继承java.lang.ClasslLoader类,该类有两个核心方法,一个是loadClass(String,boolean),实现了双亲委派机制,大体逻辑是

  1. 首先,检查一下指定名称的类是否已经加载过,如果加载过,就不需要再加载,直接返回
  2. 如果此类没有加载过,那么判断是否有父类加载器。如果有父类加载器,便通过父类加载器加载(即调用parent.loadClass(name,false);),或者是调用bootstrap类加载器来加载
  3. 如果父加载器及bootstrap类加载器都没有找到指定类,那么调用当前类加载器的findClass方法来完成加载。

还有一个方法是findClass,默认实现是抛出异常,所以我们自定义类加载器主要是重写findClass方法

自定义类加载器示例:

自定义类加载器只需要继承 java.lang.ClassLoader 类,该类有两个核心方法,一个是loadClass(String, boolean),实现了双亲委派机制,还有一个方法是findClass,默认实现是空方法,所以我们自定义类加载器主要是重写findClass方法。

public class MyClassLoaderTest {
    static class MyClassLoader extends ClassLoader {
        private String classPath;

        public MyClassLoader(String classPath) {
            this.classPath = classPath;
        }

        private byte[] loadByte(String name) throws Exception {
            name = name.replaceAll("\\.", "/");
            FileInputStream fis = new FileInputStream(classPath + "/" + name
                    + ".class");
            int len = fis.available();
            byte[] data = new byte[len];
            fis.read(data);
            fis.close();
            return data;
        }

        protected Class<?> findClass(String name) throws ClassNotFoundException {
            try {
                byte[] data = loadByte(name);
                //defineClass将一个字节数组转为Class对象,这个字节数组是class文件读取后最终的字节数组。
                return defineClass(name, data, 0, data.length);
            } catch (Exception e) {
                e.printStackTrace();
                throw new ClassNotFoundException();
            }
        }

    }

    public static void main(String args[]) throws Exception {
        //初始化自定义类加载器,会先初始化父类ClassLoader,其中会把自定义类加载器的父加载器设置为应用程序类加载器AppClassLoader
        MyClassLoader classLoader = new MyClassLoader("D:/test");
        //D盘创建 test/com/tuling/jvm 几级目录,将User类的复制类User1.class丢入该目录
        Class clazz = classLoader.loadClass("com.tuling.jvm.User1");
        Object obj = clazz.newInstance();
        Method method = clazz.getDeclaredMethod("sout", null);
        method.invoke(obj, null);
        System.out.println(clazz.getClassLoader().getClass().getName());
    }
}

运行结果
=======自己的加载器加载类调用方法=======
com.tuling.jvm.MyClassLoaderTest$MyClassLoader

2.2 双亲委派机制

有一个描述类加载器加载类过程的术语:双亲委派模型。然而这是一个很有误导性的术语,它应该叫做单亲委派模型(Parent-Delegation Model)。但是没有办法,大家都已经这样叫了。所谓双亲委派,这个就是指ClassLoader里的全局变量parent,也就是父加载器

双亲委派的具体过程如下:

  1. 当一个类加载器接收到类加载任务时,先查缓存中有没有,如果没有,将任务委托给它的父类加载器去执行
  2. 父类加载器也做统一的过程,一层一层网上委托,直到最顶层的启动类加载器为止。
  3. 如果启动类加载器没有找到所需加载的类,便将此加载任务退回给下一级类加载器去执行,而下一级的类加载器也做同样的事情
  4. 如果最底层类加载器仍然没有找到所需要的class文件,则抛出异常

所以是一条线传上再传下,并没有什么“双亲”。整个过程的Java也没有什么神秘的:

public abstract class ClassLoader {
    // name: Class文件的绝对路径
    // resolve: 找到后是否立即解析(什么是解析?)
    protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        synchronized (lock) {
            // 尝试从缓存获取,这也是为什么修改了Class后需重启JVM才能生效
            Class<?> target = findLoadedClass(name); // native方法
            if (target == null) {
                try {
                    if (parent != null) {
                        // 委托给父加载器, 只查找不解析
                        target = parent.loadClass(name, false);
                    } else {
                        // 父加载器为null,则委托给启动类加载器BootstrapClassloader
                        target = findBootstrapClassOrNull(name); // native方法
                    }
                } catch (ClassNotFoundException e) {...}
			
			
                if (target == null) {
                    // 父加载器没有找到,才调用自己的findClass()方法
                    target = findClass(name);
                }
            }
            if (resolve) {
                resolveClass(target); // native方法
            }
            return target;
        }
    }
  
    // findClass是模板方法,需要重写
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        throw new ClassNotFoundException(name);
    }  
}

2.3 为什么要设计双亲委派机制

  • 沙箱安全机制:自己写的java.lang.String.class类不会被加载,这样可以防止核心API库被篡改
  • 避免类的重复加载:当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次,保证被加载类的唯一性。

如果你打算自己写一个String类,然后在其中写main()方法,这是可以做到的,但是运行的时候会抛出异常:在类java.lang.String中找不到main()方法。

如果你说想打破双亲委派机制?

沙箱安全机制示例,尝试打破双亲委派机制,用自定义类加载器加载我们自己实现的

public class MyClassLoaderTest {
    static class MyClassLoader extends ClassLoader {
        private String classPath;

        public MyClassLoader(String classPath) {
            this.classPath = classPath;
        }

        private byte[] loadByte(String name) throws Exception {
            name = name.replaceAll("\\.", "/");
            FileInputStream fis = new FileInputStream(classPath + "/" + name
                    + ".class");
            int len = fis.available();
            byte[] data = new byte[len];
            fis.read(data);
            fis.close();
            return data;

        }

        protected Class<?> findClass(String name) throws ClassNotFoundException {
            try {
                byte[] data = loadByte(name);
                return defineClass(name, data, 0, data.length);
            } catch (Exception e) {
                e.printStackTrace();
                throw new ClassNotFoundException();
            }
        }

        /**
         * 重写类加载方法,实现自己的加载逻辑,不委派给双亲加载
         * @param name
         * @param resolve
         * @return
         * @throws ClassNotFoundException
         */
        protected Class<?> loadClass(String name, boolean resolve)
                throws ClassNotFoundException {
            synchronized (getClassLoadingLock(name)) {
                // First, check if the class has already been loaded
                Class<?> c = findLoadedClass(name);

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
                    c = findClass(name);

                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
                if (resolve) {
                    resolveClass(c);
                }
                return c;
            }
        }
    }

    public static void main(String args[]) throws Exception {
        MyClassLoader classLoader = new MyClassLoader("D:/test");
        //尝试用自己改写类加载机制去加载自己写的java.lang.String.class
        Class clazz = classLoader.loadClass("java.lang.String");
        Object obj = clazz.newInstance();
        Method method= clazz.getDeclaredMethod("sout", null);
        method.invoke(obj, null);
        System.out.println(clazz.getClassLoader().getClass().getName());
    }
}

运行结果
java.lang.SecurityException: Prohibited package name: java.lang
	at java.lang.ClassLoader.preDefineClass(ClassLoader.java:659)
	at java.lang.ClassLoader.defineClass(ClassLoader.java:758)

2.4 打破双亲委派机制

以tomcat为例,在tomcat中就实现了打破双亲委派机制,为什么需要这样做?

首先,tomcat是一个web容器,需要解决的问题有:

  1. 一个web容器可能需要部署多个应用程序,那么不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求同一个类库在同一个服务器只有一份,因此要保证每个应用程序的类库都是独立的,保证相互隔离
  2. 部署在同一个web容器中的相同类库相同的版本可以共享。否则,如果服务器有10个应用程序,那么会有10份相同的类库加载进虚拟机
  3. web容器也有自己的类库,不能与应用程序的类库混淆。基于安全考虑,应该让容器的类库和程序的类库隔离开来
  4. web容器要支持热部署。比如jsp文件最终也是要编译成class文件才能在虚拟机中运行,但是程序运行后修改jsp是司空见惯的事,web容器需要支持jsp修改后不用重启。

如果使用双亲委派机制,那么意味着无法加载两个相同类库的不同版本,默认的类加载器不管你是什么版本,只在乎你的全限定名,并且只有一份。

如果要实现jsp文件的热加载,对某个jsp进行修改,但是类名还是一样,类加载器会直接取方法区中已经存在的,修改后的jsp是不会重新加载的。所以要实现,我们需要卸载这jsp文件的类加载器,然后重新创建类加载器,重新加载jsp文件。

2.5 tomcat自定义类加载器

image.png

tomcat中的几个主要类加载器

  • commonClassLoadertomcat最基本的类加载器,加载路径中的class可以被tomcat容器本身以及各个webapp访问
  • catalinaLoader:tomcat容器私有的类加载器,加载路径中的class对于webapp不可见
  • sharedLoader各个Webapp共享的类加载器,加载路径中的class对于所有的webapp可见,但是对于tomcat容器不可见
  • webappClassLoader各个webapp私有的类加载器,加载路径中的class只对当前webapp可见

从图中的委派关系可以看出:

CommonClassLoader能加载的类都可以被CatalinaClassLoader和SharedClassLoader使用,从而实现了公有类库的共用,而CatalinaClassLoader和SharedClassLoader自己能加载的类则与对方相互隔离。

WebAppClassLoader可以使用SharedClassLoader加载到的类,但各个WebAppClassLoader实例之间相互隔离。

而JasperLoader的加载范围仅仅是这个JSP文件所编译出来的那一个.Class文件,它出现的目的就是为了被丢弃:当Web容器检测到JSP文件被修改时,会替换掉目前的JasperLoader的实例,并通过再建立一个新的Jsp类加载器来实现JSP文件的热加载功能。

tomcat通过打破Java的双亲委派机制,实现每个webappClassLoader加载自己的目录下的class文件,不会传递给父类加载器。


标题:类加载机制深度剖析
作者:MingGH
地址:https://runnable.run/articles/2021/03/19/1616147905794.html