JVM--类加载

一、类加载时机

  类加载主要有四个时机:

    1、遇到 new 、 getstatic 、 putstatic 和 invokestatic 这四条指令时,如果对应的类没有初始化,则要对对应的类先进行初始化。

    2、使用 java.lang.reflect 包方法时,对类进行反射调用的时候。

    3、初始化一个类的时候发现其父类还没初始化,要先初始化其父类

    4、当虚拟机开始启动时,用户需要指定一个主类(main),虚拟机会先执行这个主类的初始化。

  一个类从被加载到内存到卸载出内存为止,它的整个生命周期会经历加载、验证、准备、解析、初始化、使用、卸载这七个阶段。其中验证、准备、解析三哥部分统称为连接。

  加载、连接、初始化、使用、卸载这五个步骤都顺序是固定的,但是再连接阶段,并不一定固定,验证、准备、解析是按步骤开始,但并不一定按该顺序结束,有可能穿插执行。

  

  对于加载和连接在什么情况下进行,JVM虚拟机规范并没有进行强制约定,但是明确规定了类初始化的八种场景:

    1、当虚拟机启动的时候,用于需要指定一个要执行的主类,虚拟机会初始化该类

    2、使用new关键字实例化对象的时候

    3、调用一个类的静态方法的时候,初始化静态方法所在的类

    4、读取或设置一个类的静态字段的时候(被final修饰、已在编译期把结果放入常量值的静态字段除外),初始化静态字段所在的类

    5、当初始化的时候,如果发现父类还没有进行过初始化,则需要先触发其父类的初始化

    6、当一个接口中定义了JDK8新加入的默认(default)方法时,如果有这个接口的实现类发生了初始化,那该接口要在其初始化前进行初始化。

    7、使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类没有初始化,则需要先触发其初始化。

    8、当初次调用MethodHandle实例时,初始化MethodHandle指向的方法所在的类。

  有六个场景不会进行类初始化

    1、通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。

    2、定义对象数组,不会触发该类的初始化。

    3、常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常量所在的类。

    4、通过类名获取 Class 对象,不会触发类的初始化,Hello.class 不会让 Hello 类初始化。

    5、通过 Class.forName 加载指定类时,如果指定参数 initialize 为 false 时,也不会触发类初始化,其实这个参数是告诉虚拟机,是否要对类进行初始化。(Class.forName”jvm.Hello”)默认会加载 Hello 类。

    6、通过 ClassLoader 默认的 loadClass 方法,也不会触发初始化动作(加载了,但是不初始化)。

二、类加载过程

  类加载主要做三件事:

    1、全限定名称 ==> 二进制字节流加载class文件

    2、字节流的静态数据结构 ==> 方法区的运行时数据结构

    3、创建字节码Class对象

  类的加载过程是指的类的加载、验证、准备、解析、初始化这五个阶段。

        

(一)加载

         

  加载主要做三件事情:

    1、通过类的全限定名来获取定义此类的二进制字节流

    2、将二进制字节流所代表的静态存储结构转化为方法区的运行时数据结构

    3、在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

  对于要加载类的来源,JVM虚拟机规范并没有明确要求,其可以是从zip、jar、war压缩包中读取的,也可以是从网络中读取的,也可以是运行时计算得到的,也可以是由其他文件生成,甚至还可以是从数据库读取的等。

  上面的过程是对于类的加载,如果是数组类型,如果数组的组件类型是引用类型,就需要递归的去加载,然后数组将被标识在加载该组件的类加载器的类名称空间上。

  如果数组是非引用类型,java虚拟机将会把数组标记为与引导类加载器关联。

  加载阶段与连接阶段的部分动作是交叉进行的,比如一部分字节码格式的校验,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的一部分。

(二)验证

  验证主要是确保Class文件的字节流包含的信息符合JVM虚拟机规范,保证这些信息被当作代码运行后不会危害虚拟机的安全。

  验证主要包括对文件格式验证、元数据验证、字节码验证、符号引用验证。

  1、文件格式验证

    校验是否以指定的魔数开头

    校验主次版本号是否在java虚拟机接受的范围之内

    常量池的常量中是否有不支持的常量类型(检查tag标志)

    指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量

    CONSTANT_Utf8_info类型的常量中是否有不符合UTF8编码的数据

    ........

  2、元数据验证

    对字节码描述的信息进行语义分析,以保证其描述的信息符合JAVA语言规范的要求,主要校验

    这个类是否有父类(除Object类之外,所有的类都需要有父类)

    这个类的父类是否继承了不允许被继承的类(final修饰)

    如果这个类不是抽象类,是否实现了其父类或接口中要求实现的所有方法

    类中的字段、方法是否与父类产生矛盾(final方法)

  3、字节码验证

    字节码验证的目的是要通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。

  4、符号引用的验证

    最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用[3]的时候,这个转化动作将在连接的第三阶段——解析阶段中发生

(三)准备--为static分配内存并设置初始值(1.7以前分配到方法区,1.7之后分配到堆)  

  准备阶段是正式为类中定义的变量(static修饰的类变量)分配内存并设置初始值的过程。在1.7之前会被分配到方法区,在1.7以后会被分配到堆中。

  例如下面的代码,准备阶段后,赋的初始值为0

public static int i = 123;

  但是这里并不包括被final修饰的static变量,因为使用final修饰的变量在编译的时候就会分配内存。

  类变量会被分配到方法区中,而实例变量会随类被分配到堆中

(四)解析--符号引用转换为直接引用  

  解析是将常量池的符号引用转换为直接引用的过程。

  解析动作主要针对接口或类、类方法、接口方法、字段这四类符号引用进行。分别对应常量池中的CONSTANT_Class_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_Fieldref_info四种常量类型。

  1、类或接口解析

  判断需要转化的类是数组类型还是普通对象类型的引用,然后进行不同的解析。

  2、字段解析

  首先查找当前类中是否有简单名称和字段描述符都与目标相匹配的字段,如果有则返回。

  如果没有,则查找其实现的接口,按照接口的继承关系从上到下查看是否存在名称和描述符都与目标匹配的字段。

  如果还没有,则查找其父类,从上至下查找。

  3、类方法解析

  对类中方法的解析和堆字段的解析差不多,差别就是判断该方法是处于类中还是处于接口中,如果是处于类中,那么先查找父类,再查找接口

  4、接口方法解析

  接口方法解析与类方法解析基本上一致,由于接口不会实现接口,音质不存在查找接口。

(五)初始化--方法调用

  初始化就是调用类初始化方法,为static变量赋值的过程(在准备阶段已经赋了初始值,在初始化阶段要按照程序中的内容进行赋值),同时调用类中的静态代码块。

  初始化方法是编译器自动收集类变量和静态代码块自动产生的,编译器收集的信息由在其在代码中的顺序界定,因此静态代码块中只能访问在其前面的类变量,而不能访问在其后面的类变量。

  实例构造器需要显示的调用父类的构造器,但是类的初始化方法不需要调用父类的初始化方法,JVM虚拟机会确保在当前类初始化之前完成对父类的初始化,因此在JVM中第一个被初始化的类是java.lang.Object

  如果一个类或者接口中既没有类变量也没有静态代码块,那么编译器就不会为该类生成初始化方法。

  接口也需要通过初始化方法为接口中的类变量赋值。

  虚拟机会保证在多线程环境下,一个类的方法被正确的加锁,以确保在同一类加载器中,一个类只会被初始化一次。

三、类加载器

(一)类加载器

  类加载器分为启动类加载器、扩展类加载器、应用程序类加载器和自定义类加载器。

  启动类加载器(Bootstrap ClassLoader):负载JAVA_HOMR/lib/rt.jar目录中的、或通过-Xbootstrap参数指定路径中的且被虚拟机认可的类,该类加载器由C++实现,不是ClassLoader的子类

  扩展类加载器(Extension ClassLoader):负责加载JAVA_HOME/lib/ext目录中的、或通过java.ext.dirs系统变量指定路径中的类库。

  应用程序类加载器(Application ClassLoader):负责加载用户路径(classpath)上的类库

  自定义加载器(User ClassLoader):通过java.lang.ClassLoader的子类自定义加载class

    

   JVM的类加载器通过ClassLoader及其子类完成,类的层次关系和加载顺序可以由下图来描述:

    

  加载过程是自下而上的逐层检查,看是否已经被加载,只有有一个ClassLoader加载了该类,就认为该类已经被加载,一个类只会被一个类加载器加载,且只会被加载一次。而加载的顺序则是自上而下的。

  类加载器有三个特点:

    双亲委派:当一个类加载器收到一个类的加载任务时,其会先将类交给父类的加载器进行加载,因此最终会将加载任务传递到启动类加载器进行加载;父类如果加载成功,返回对象的引用,如果加载不成功,当前加载器才能加载该类。

    负责依赖:如果类加载器去加载一个类并可以加载,同时该类又对其他类存在引用,那么将由该类加载器去加载相关引用的类

    缓存加载:一个类只会被加载一次,加载完成后,会存入JVM内存中。

(二)双亲委派

  双亲委派:当一个类加载器收到一个类的加载任务时,其会先将类交给父类的加载器进行加载,因此最终会将加载任务传递到启动类加载器进行加载;只有当父类不加载该类时,当前加载器才能加载该类。

  双亲委派的好处:可以保证同一个类无论被哪个类加载器加载,得到的都是同样的类对象。在JVM搜索类时,只有类和类加载器都相同,才认为是相同的类,那么双亲委派机制保证了同一个类,无论被哪个类加载器加载,最后都是使用相同的类加载器进行加载。例如java.lang.Object类,如论被哪个类加载器加载,最终都会被启动类加载器进行加载。

  为什么要使用双亲委派:这样可以避免重复加载;从安全角度考虑,如果不适用双亲委派,我们就可以使用自定义的对象来动态替换java核心API中提供的类型,例如我们自己定义一个String类。而是用双亲委派,就可以避免这种情况,因为String类已经被启动类加载器加载,因此用户自定义的类加载器永远也不会加载到。

  为什么要自定义类加载器:JVM中提供的默认的ClassLoader是加载指定位置的class文件,如果我们想加载其他位置的class文件,就需要我们自定义类加载器。例如加载网络上的class文件。

  破坏双亲委派:

  双亲委派模型是在JDK1.2之后才有的,但是有的类在JDK一开始就有了,因此某些情况下父类加载器需要加载的class文件由于受到加载范围的影响,不得不委托子类进行加载。

  而按照双亲委派模式的话,是子类委托父类进行加载,这时候需要破坏双亲委派才能加载成功父类要加载的类。

  以Driver接口为例,由于Driver接口定义在jdk当中的,而其实现由各个数据库的服务商来提供,比如mysql的就写了 MySQL Connector ,这些实现类都是以jar包的形式放到classpath目录下。类的全限定名完全相同,但是加载它的类加载器不同,那么在方法区中会产生不同的【Class】对象。DriverManager(也由jdk提供)要加载各个实现了Driver接口的实现类(classpath下),然后进行管理,但是DriverManager由启动类加载器加载,只能加载JAVA_HOME的lib下文件,而其实现是由服务商提供的,由系统类加载器加载,这个时候就需要启动类加载器来委托子类来加载Driver实现,从而破坏了双亲委派。

  类似情况还有很多,与双亲委派冲突还有:热加载技术、Tomcat加载多个应用程序 

public class JDBC {
    public static void main(String[] args) {
        Connection connection = null;
        PreparedStatement preparedStatement = null;
        ResultSet rs = null;
        try {
            // 加载数据库驱动
            Class.forName("com.mysql.jdbc.Driver");
            // 通过驱动管理类获取数据库链接 connection = DriverManager
            connection = DriverManager.getConnection("jdbc:mysql:///hero-mall?useUnicode=true&characterEncoding=UTF-8&serverTimezone=GMT%2B8", "root", "root");
            // 定义sql语句 ?表示占位符
            String sql = "select * from tb_user where id = ?";
            // 获取预处理 statement
            preparedStatement = connection.prepareStatement(sql);
            // 设置参数,第一个参数为 sql 语句中参数的序号(从 1 开始),第二个参数为设 置的
            preparedStatement.setInt(1, 16);
            // 向数据库发出 sql 执行查询,查询出结果集
            rs = preparedStatement.executeQuery(); 
            // 遍历查询结果集
            while (rs.next()) { 
                System.out.println("id = " + rs.getInt("id")); 
                System.out.println("real_name = " + rs.getString("real_name")); 
                System.out.println("gender = " + rs.getString("gender")); 
                System.out.println("profession = " + rs.getString("profession")); 
                System.out.println("nick_name = " + rs.getString("nick_name")); 
            } 
        } catch (Exception e) { 
            e.printStackTrace(); 
        } finally {
            // 释放资源 
            if (rs != null) { 
                try {
                    rs.close(); 
                } catch (SQLException e) {
                    e.printStackTrace(); 
                } 
            }
            if (preparedStatement != null) {
                try {
                    preparedStatement.close(); 
                } catch (SQLException e) { 
                    e.printStackTrace(); 
                } 
            }
            if (connection != null) { 
                try {
                    connection.close(); 
                } catch (SQLException e) {
                    
                } 
            } 
        }
    }
}

 

 

(三)JVM内置ClassLoader

        

  实际上,应用程序类加载器和扩展类加载器的父类都是URLClassLoader,而ClassLoader则是JVM内部实现的,Java代码中是拿不到的,但是可以使用sun.misc.Launcher.getBootstrapClassPath()获取启动类加载器,然后使用getURLs获取启动类加载器加载内容的URL。而对于应用程序类加载器,可以使用当前类.class.getClassLoader()可以获取当前类的类加载器,即应用程序类加载器,对于扩展类加载器可以使用应用程序类加载起的getParent()方法获取。

  获取到扩展类加载器和应用程序类加载器后,可以使用反射获取类加载器中的URLClassPath ucp属性,再使用反射获取URLClassPath中的ArrayList<URL> path属性(类加载器加载的内容)并打印

package com.example.jvm_demo;

import sun.misc.Launcher;

import java.lang.reflect.Field;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.ArrayList;

public class JvmClassLoaderPrintPath {
    public static void main(String[] args) {
        //启动类加载器:使用sun.misc.Launcher.getBootstrapClassPath()获取启动类加载器,然后使用getURLs获取启动类加载器加载内容的URL
        URL[] urls = Launcher.getBootstrapClassPath().getURLs();
        System.out.println("启动类加载器");
        for (URL url : urls){
            System.out.println(" ====>> " + url.toExternalForm());
        }

        // 扩展类加载器:使用当前类.class.getClassLoader()可以获取当前类的类加载器,即应用程序类加载器,再使用getParent()获取父类加载器,即扩展类加载器
        printClassLoader("扩展类加载器", JvmClassLoaderPrintPath.class.getClassLoader().getParent());

        // 应用程序类加载器:使用当前类.class.getClassLoader()可以获取当前类的类加载器,即应用程序类加载器
        printClassLoader("应用程序类加载器", JvmClassLoaderPrintPath.class.getClassLoader());
    }

    /**
     * 输出类加载器名称,并打印类加载器加载的文件
     * @param name 类加载器名称
     * @param CL 类加载器对象
     */
    public static void printClassLoader(String name, ClassLoader CL){
        if(CL != null){
            System.out.println(name + " ClassLoader ====>> " + CL.toString());
            printURLForClassLoader(CL);
        }else {
            System.out.println(name + " ClassLoader ====>> null ");
        }
    }

    /**
     * 使用反射获取类加载器中的URLClassPath ucp属性,再使用反射获取URLClassPath中的ArrayList<URL> path属性(类加载器加载的内容)并打印
     * @param CL 类加载器对象
     */
    public static void printURLForClassLoader(ClassLoader CL){
        Object ucp = insightField(CL, "ucp");
        Object path = insightField(ucp, "path");
        ArrayList ps = (ArrayList) path;
        for (Object p : ps){
            System.out.println(" ====>> " + p.toString());
        }
    }

    /**
     * 使用反射获取指定类中字段名称
     * @param obj
     * @param fName
     * @return
     */
    private static Object insightField(Object obj, String fName) {
        try {
            Field f = null;
            if(obj instanceof URLClassLoader){
                f = URLClassLoader.class.getDeclaredField(fName);
            }else {
                f = obj.getClass().getDeclaredField(fName);
            }
            f.setAccessible(true);
            return f.get(obj);
        }catch (Exception e){
            e.printStackTrace();
            return null;
        }
    }
}

  以上的方式可以用来排查加载不到类等异常情况。

(四)自定义ClassLoader

  定义一个类,在类中使用静态代码块输出一段内容;然后自定义一个类加载器,重写findClass方法。

public class Hello {
    static {
        System.out.println("Hello Class Initialized!");
    }
}

public class HelloClassLoader extends ClassLoader{
    public static void main(String[] args) {
        try {
            new HelloClassLoader().findClass("com.example.jvm_demo.Hello").newInstance();
        } catch (InstantiationException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        }
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        String helloBase64 = "yv66vgAAADQAHAoABgAOCQAPABAIABEKABIAEwcAFAcAFQEABjxpbml0PgEAAygpVgEABENvZGUBAA9MaW5lTnVtYmVyVGFibGUBAAg8Y2xpbml0PgEAClNvdXJjZUZpbGUBAApIZWxsby5qYXZhDAAHAAgHABYMABcAGAEAGEhlbGxvIENsYXNzIEluaXRpYWxpemVkIQcAGQwAGgAbAQAaY29tL2V4YW1wbGUvanZtX2RlbW8vSGVsbG8BABBqYXZhL2xhbmcvT2JqZWN0AQAQamF2YS9sYW5nL1N5c3RlbQEAA291dAEAFUxqYXZhL2lvL1ByaW50U3RyZWFtOwEAE2phdmEvaW8vUHJpbnRTdHJlYW0BAAdwcmludGxuAQAVKExqYXZhL2xhbmcvU3RyaW5nOylWACEABQAGAAAAAAACAAEABwAIAAEACQAAAB0AAQABAAAABSq3AAGxAAAAAQAKAAAABgABAAAAAwAIAAsACAABAAkAAAAlAAIAAAAAAAmyAAISA7YABLEAAAABAAoAAAAKAAIAAAAFAAgABgABAAwAAAACAA0=";
        byte[] bytes = decode(helloBase64);
        return defineClass(name, bytes, 0, bytes.length);
    }

    public byte[] decode(String base64){
        return Base64.getDecoder().decode(base64);
    }
}

  这里模拟使用Base64加密后运行,首先将Hello类编译,然后对其进行Base64加密(在mac中可以直接使用base64 Hello.class进行加密),加密后将Hello类和class文件删除,再类加载器中直接使用加密后的Base64编码进行类的加载,其实这时候已经没有对应的java文件和class文件,但是仍然可以加载并输出静态代码块中的内容。

  这个例子是典型的自定义类加载器的案例,可以用来做类的动态加载、加密保护等。所以说很多时候我们可以把我们编译好的class文件做一个类似加密的操作,对我们的代码进行保护,运行的时候,在使用自定义的ClassLoader进行加载。

(五)添加外部代码到系统中

  如果想添加引用类,可以通过以下四种方式进行引用:

  1、使用扩展类加载器

    可以将引用类放在JDK的 lib/ext 目录下,也可使用 -Djava.ext.dirs 命令行参数添加扩展类加载器的加载路径

  2、使用应用程序类加载器

    可以将引用类放在 classpath下,也可以使用 java -cp/classpath 命令行参数将指定应用程序类加载器的加载路径

  3、使用自定义类加载器

    自定义类加载器,加载指定路径或文件

  4、使用当前类加载器

    拿到当前执行类的classLoader,反射调用 addUrl方法添加文件或路径。但是这种方式在JDK9中无效,因为在JDK9中URLClassLoader、扩展类加载器、应用程序类加载器属于平级关系,应用程序类加载器和扩展类加载器不能再转换为URLClassLoader,也就不能调用其addURL方法,不过可以在Class.forName方法的参数后面再加一个New URLClassloader,帮我们需要添加咋jar包或目录。

    public class PathClass {
        static {
            System.out.println("PathClass Class Initialized!");
        }
    }
public class JvmAppClassLoaderAddUrl {
    public static void main(String[] args) {
        String appPath = "file:/....../exercise/";
        URLClassLoader urlClassLoader = (URLClassLoader) JvmAppClassLoaderAddUrl.class.getClassLoader();

        try {
            Method addURL = URLClassLoader.class.getDeclaredMethod("addURL", URL.class);
            addURL.setAccessible(true);
            URL url = new URL(appPath);
            addURL.invoke(urlClassLoader, url);
            Class.forName("PathClass");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

}

 

posted @ 2021-06-25 17:50  李聪龙  阅读(167)  评论(0编辑  收藏  举报