• JVM — 类加载机制


    1. 引言

      java 类被虚拟机编译之后成为一个 Class 的字节码文件,该字节码文件中包含各种描述信息,最终都需要加载到虚拟机中之后才能运行和使用。那么虚拟机是如何加载这些 Class 文件?Class 文件中的信息进入虚拟机之后会发生什么变化?接下来我们一个一个探讨。

    2. 类加载的时机

      类的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载七个阶段,其中验证、准备、解析 3 个部分统称为连接。

      在上图中,加载、验证、准备、初始化和卸载这 5 个阶段的顺序是确定的,类的加载过程必须按照这个过程按部就班的开始,中间可以再插入另一个类的加载过程。那么,什么情况下需要开始类加载过程的第一个阶段呢?虚拟机规范严格规定了有且只有 5 种情况必须立即对类进行「初始化」。

    • 遇到 new、getstatic、putstatic 或 invokestatic 这 4 条字节码指令时,如果类没有初始化,则需要先触发其初始化。生成这 4 条指令的最常见的 java 代码场景是:使用 new 关键字实例化对象的时候、读取或设置一个类的静态字段(被 final、已在编译器把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法时。
    • 使用 java.lang.reflec 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。
    • 当初始化一个类的时候,如果发现父类没有初始化过,则需要先触发其父类的初始化
    • 当虚拟机启动时,用户需要指定一个执行的类(包含 main 方法的那个类),虚拟机会先初始化这个主类
    • 当使用 JDK1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例后的解析结果 REF_getStatic、REF_pubStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先初始化这个类。(这一点还不理解是什么)

    3. 类加载过程

      了解了类是什么时候开始加载之后,我们来了解一下类加载的全过程。也就是加载、验证、准备、解析和初始化这个 5 个阶段的具体动作。

     3.1 加载

     注 :「加载」是「类加载」过程的一个阶段,在加载阶段,虚拟机需要完成下面 3 件事情:

    1. 通过一个类的全限定名来获取定义此类的二进制字节流,其中,类的全限定名可 多个以从途径获得,例如 ZIP 包、网络、动态代理等等。
    2. 将这个字节流所代表的的静态存储结构转化为方法区的运行时数据结构
    3. 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。

     3.2 验证

      在加载阶段中,Class 文件并不一定要求用 java 源码编译而来,可以使用任何途径产生,甚至包括用十六进制编辑器直接编写来产生 Class 文件。因此,为了保证虚拟机的安全,验证阶段是非常有必要的,验证阶段的工作量在虚拟机的类加载子系统中占了相当大的一部分。其大致会完成下面 4 个阶段的检验动作。

    • 文件格式验证
    • 元数据验证,即文件描述信息是否符合 java 的语法规则,主要验证类的数据类型是否正确,例如这个类是否有父类,该类的父类是否继承了不允许被继承的类等等。
    • 字节码验证,这个验证过程主要是针对类的方法体,保证被检验的类方法不会做出危害虚拟机的事。
    • 符号引用验证,判断该类中引用的类信息能否访问,或者有权访问(更具 private、protected 修饰符访问)。

     3.3 准备

      准备阶段是正式为 类变量 分配内存并设置类变量初始化的阶段,这些变量所使用的内存都将在方法区中进行分配。

      这里需要注意两点,首先,这个阶段初始化的变量是类变量,即 static 修饰的变量,不包括实例变量。实例变量将会在对象初始化时随对象一起分配到 java 堆中。其次,这里的初始化「通常情况」下是数据类型的 零值,例如一个变量 public static int value = 123,其先初始化为 0,等到这个类首次被初始化之后才变为 123。

      上面说了通常情况下是那样,当然也存在一些「不通常的情况」,例如public static final int value = 123。final 修饰的变量在此阶段就会生产对应的值。

     3.4 解析

     解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。其完成的任务是验证阶段的符号引用验证。主要由下面 4 种解析过程

    • 类或接口解析
    • 字段解析
    • 类方法解析
    • 接口方法解析

     3.5 初始化

      在准备阶段,主要是对类变量进行赋值(一般类型赋为 0,boolean 赋为 false 等等),而初始化阶段是初始化类变量和其他资源,这是执行 类构造器<clinit>() 方法的过程。下面介绍一些可能会影响到程序运行行为的特点和细节:

    • <clinit>() 方法收集类变量的赋值动作和执行静态语句块的语句。静态语句块只能为定义在语句块后面的变量赋值,但是不能访问定义在语句块后面的变量。例如
    public class Test{
    	static{
    		i = 0;                 // 给变量赋值可以正常编译通过
    		System.out.println(i); // 这句编译器会提示「非法向前引用」
    	}
    
    	static int i = 1;
    }
    
    • <clinit>() 方法与类的构造函数不同,它不需要显示地调用父类构造函数,虚拟机保证子类的 <clinit>() 方法执行之前,父类的 <clinit>() 已经执行完毕。因此,第一个在虚拟机中被执行 <clinit>() 方法的类一定是 java.lang.Object
    • 由于父类的 <clinit>() 方法先执行,因此父类定义的静态语句块要先于子类的静态语句块。
    • 虚拟机会保证一个类的<clinit>() 方法在多线程的环境中被正确的加锁、同步,如果多个线程同时初始化一个类,刚好这个类的<clinit>() 方法耗时很长的操作,就可能造成多个进程的阻塞。例如
    static class DeadLoadClass{
    	static{
    		if(true){
    			System.out.println(Thread.currentThread()+"init DeadLoopClass");
    			while(true){}
    		}
    	}
    }
    
    public static void main(String[] args){
    	Runnable srcipt = new Runnable(){
    		public void run(){
    			System.out.println(Thread.currentThread()+"start");
    			DeadLoadClass dlc = DeadLoadClass();
    			System.out.println(Thread.currentThread()+"run over");
    		}
    	};
    	Thread t1 = new Thead(script);
    	Thread t2 = new Thead(script);
    	t1.start();
    	t2.start();
    }
    

    运行结果如下,即一个线程在死循环中长时间操作,另一个线程发生阻塞,一直等待。

    Thread[Thread-0,5,main]start
    Thread[Thread-1,5,main]start
    Thread[Thread-0,5,main]init DeadLoopClass
    

    4. 类加载器

      虚拟机设计团队把类加载阶段中的「通过一个类的全限定名来获取描述此类的二进制字节流」这个动作放在了 java 虚拟机外部去实现,以便让应用程序自己决定如何去获取需要的类。实现这个动作的代码模块称为「类加载器」。

     4.1 类与类加载器

      类加载器在 java 程序中起到的作用远远不限于类的加载阶段。在运行阶段,比较两个类是否「相等」只有在这两个类来源于用一个 Class 文件,被同一个虚拟机加载,并且使同一个类加载器加载,这两个类才会相等。

      这里所指的「相等」,包括代表类的 Class 对象的 equals 方法、isAssignableFrom方法、isInstance 方法的返回结果,也包括使用 instanceof关键字做对象所属关系判定等情况。

     4.2 双亲委派模型

     绝大部分 java 程序都会使用到以下 3 种系统提供的类加载器

    • 启动类加载器:这个类加载器负责将存放在 <JAVA_HOME>lib 目录中的类库加载到虚拟机内存中。
    • 扩展类加载器:这个加载器由 sun.misc.Launcher$ExtClassLoader 实现,它负责加载 <JAVA_HOME>libext 目录中,或被 java.ext.dirs 系统变量所指定的所有类库。
    • 应用程序类加载器:这个类库加载器由 sun.misc.Launcher$AppClassLoader 实现,它负责加载用户路径上(ClassPath)所指定的类库。如果应用程序中没有自定的类加载器,一般情况下这个就是默认的类加载器。

     我们的应用程序都是由这 3 种类加载器相互配合进行加载的,如果有必要,还可以自定义类加载器。这些类加载器之间的关系如下图所示,这种层次关系被称为类加载器的双亲委派模型。

      双亲委派模型的工作过程是:如果一个类加载器收到了类加载的要求,它首先自己不会去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此,所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法加载这个请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

      这种模型的一个好处就是由于类加载器有一种层次关系,导致类也有一种层次关系,从而有了优先级。比如类java.lang.Object,它存放在rt.jar中,无论哪个类加载器要加载这个类,最终都要委派给启动类加载器去加载,因此Object类在各个类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器去自行加载,如果用户自己编写了一个称为java.lang.Object的类,并放在程序的ClassPath中,那系统将会有多个不同的Object类,java类型体系中最基础的行为也就没有办法保证了。

    自定义类加载器

     为什么需要自定义类加载器? 

      网上的大部分自定义类加载器文章,几乎都是贴一段实现代码,然后分析一两句自定义ClassLoader的原理。但是我觉得首先得把为什么需要自定义加载器这个问题搞清楚,因为如果不明白它的作用的情况下,还要去学习它显然是很让人困惑的。

     首先介绍自定义类的应用场景

      (1)加密:Java代码可以轻易的被反编译,如果你需要把自己的代码进行加密以防止反编译,可以先将编译后的代码用某种加密算法加密,类加密后就不能再用Java的ClassLoader去加载类了,这时就需要自定义ClassLoader在加载类的时候先解密类,然后再加载。 

      (2)从非标准的来源加载代码:如果你的字节码是放在数据库、甚至是在云端,就可以自定义类加载器,从指定的来源加载类。 

      (3)以上两种情况在实际中的综合运用:比如你的应用需要通过网络来传输 Java 类的字节码,为了安全性,这些字节码经过了加密处理。这个时候你就需要自定义类加载器来从某个网络地址上读取加密后的字节代码,接着进行解密和验证,最后定义出在Java虚拟机中运行的类。

     1. 双亲委派模型

      在实现自己的ClassLoader之前,我们先了解一下系统是如何加载类的,那么就不得不介绍双亲委派模型的实现过程。

    //双亲委派模型的工作过程源码
    protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException{
        // First, check if the class has already been loaded
        Class c = findLoadedClass(name);
        if (c == null) {
            try {
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    c = findBootstrapClassOrNull(name);
                }
            } 
            catch (ClassNotFoundException e) {
                // ClassNotFoundException thrown if class not found
                // from the non-null parent class loader
                //父类加载器无法完成类加载请求
            }
     
            if (c == null) {
                // If still not found, then invoke findClass in order to find the class
                //子加载器进行类加载 
                c = findClass(name);
            }
        }
     
        if (resolve) {
            //判断是否需要链接过程,参数传入
            resolveClass(c);
        }
     
        return c;
    }

     2. 双亲委派模型的工作过程如下

      (1)当前类加载器从自己已经加载的类中查询是否此类已经加载,如果已经加载则直接返回原来已经加载的类。

      (2)如果没有找到,就去委托父类加载器去加载(如代码c = parent.loadClass(name, false)所示)。父类加载器也会采用同样的策略,查看自己已经加载过的类中是否包含这个类,有就返回,没有就委托父类的父类去加载,一直到启动类加载器。因为如果父加载器为空了,就代表使用启动类加载器作为父加载器去加载。

      (3)如果启动类加载器加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class),则会抛出一个异常ClassNotFoundException,然后再调用当前加载器的findClass()方法进行加载。 

     3. 双亲委派模型的好处:

      (1)主要是为了安全性,避免用户自己编写的类动态替换 Java的一些核心类,比如 String。

      (2)同时也避免了类的重复加载,因为 JVM中区分不同类,不仅仅是根据类名,相同的 class文件被不同的 ClassLoader加载就是不同的两个类。 

    2. 自定义类加载器

      (1)从上面源码看出,调用loadClass时会先根据委派模型在父加载器中加载,如果加载失败,则会调用当前加载器的findClass来完成加载。

      (2)因此我们自定义的类加载器只需要继承ClassLoader,并覆盖findClass方法,下面是一个实际例子,在该例中我们用自定义的类加载器去加载我们事先准备好的class文件。

     2.1 自定义一个People.java类做例子

    public class People {
    //该类写在记事本里,在用javac命令行编译成class文件,放在d盘根目录下
        private String name;
     
        public People() {}
     
        public People(String name) {
            this.name = name;
        }
     
        public String getName() {
            return name;
        }
     
        public void setName(String name) {
            this.name = name;
        }
     
        public String toString() {
            return "I am a people, my name is " + name;
        }
     
    }

     2.2 自定义类加载器

      自定义一个类加载器,需要继承ClassLoader类,并实现findClass方法。其中defineClass方法可以把二进制流字节组成的文件转换为一个java.lang.Class(只要二进制字节流的内容符合Class文件规范)。

    import java.io.ByteArrayOutputStream;
    import java.io.File;
    import java.io.FileInputStream;
    import java.nio.ByteBuffer;
    import java.nio.channels.Channels;
    import java.nio.channels.FileChannel;
    import java.nio.channels.WritableByteChannel;
    
    public class MyClassLoader extends ClassLoader{
        public MyClassLoader() {
            super(null);
        }
        
        public MyClassLoader(ClassLoader parent) {
            super(parent);
        }
        
        @Override
        protected Class<?> findClass(String name) throws ClassNotFoundException {
            File file = new File("D:/People.class");
            try {
                byte[] bytes = getClassBytes(file);
                //defineClass方法可以把二进制流字节组成的文件转换为一个java.lang.Class
                Class<?> c = this.defineClass(name, bytes, 0, bytes.length);
                return c;
            } catch (ClassFormatError e) {
                e.printStackTrace();
            } catch (Exception e) {
                e.printStackTrace();
            }
    
            return super.findClass(name);
        }
        
        private byte[] getClassBytes(File file) throws Exception
        {
            // 这里要读入.class的字节,因此要使用字节流
            FileInputStream fis = new FileInputStream(file);
            FileChannel fc = fis.getChannel();
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            WritableByteChannel wbc = Channels.newChannel(baos);
            ByteBuffer by = ByteBuffer.allocate(1024);
            
            while (true){
                int i = fc.read(by);
                if (i == 0 || i == -1)
                break;
                by.flip();
                wbc.write(by);
                by.clear();
            }
            fis.close();
            return baos.toByteArray();
        }
    
    }

     2.3 在主函数里使用

    MyClassLoader mcl = new MyClassLoader(); 
    Class<?> clazz = Class.forName("com.gdut.classLoader1.People", true, mcl); 
    Object obj = clazz.newInstance();
           
    System.out.println(obj);
    System.out.println(obj.getClass().getClassLoader());//打印出我们的自定义类加载器

     2.4 运行结果

    资料:  https://www.liangzl.com/get-article-detail-13809.html

         https://blog.csdn.net/SEU_Calvin/article/details/52315125

  • 相关阅读:
    DocKer 创建容器 镜像端口映射失败
    Window中的Docker 拉取Mysql镜像 并在本地Navicate链接
    Mysql之主从复制
    Nginx 实现负载均衡
    如何将composer设置为全局变量?
    Linux安装telnet
    LINUX 安装扩展(笔记)
    PHP 开发 APP 接口 学习笔记与总结
    504 Gateway Time-out
    Luogu P1542包裹快递【实数域二分】 By cellur925
  • 原文地址:https://www.cnblogs.com/myseries/p/10913456.html
Copyright © 2020-2023  润新知