【深入理解JVM】1、在JVM类加载如何加载?双亲委派加载类及混合模式,代码演示【面试必备】

本文详细介绍了JVM加载类的过程,包括加载、验证、准备、解析和初始化五个阶段。此外,还探讨了类加载器的工作机制及其双亲委派模型,以及类加载时机和JIT编译的相关内容。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

JVM加载顺序

 javac编译器-->字节码-->类加载器-->内存,其中类加载这步需要完成三个步骤:

Class文件中的“类”从加载到JVM内存中,到卸载出内存过程有七个生命周期阶段。类加载机制包括了前五个阶段。

加载——》验证——》准备——》解析——》初始化——》使用——》卸载

其中加载、验证、准备、初始化、卸载的开始顺序是确定的,注意,只是按顺序开始,进行和结束的顺序并不一定。解析阶段可能在初始化之后开始。这里JVM的优化,导致半实例化的现象。

1、loading(类的加载)

我们平常说的加载大多不是指的类加载机制,只是类加载机制中的第一步加载。在这个阶段,JVM主要完成三件事:

1、通过一个类的全限定名(包名与类名)来获取定义此类的二进制字节流(Class文件)。而获取的方式,可以通过jar包、war包、网络中获取、JSP文件生成等方式。

2、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。这里只是转化了数据结构,并未合并数据。(方法区就是用来存放已被加载的类信息,常量,静态变量,编译后的代码的运行时内存区域)

3、在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。这个Class对象并没有规定是在Java堆内存中,它比较特殊,虽为对象,但存放在方法区中。

相对于类加载的其他阶段而言,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载。

加载阶段完成后,虚拟机外部的 二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在Java堆中也创建一个java.lang.Class类的对象,这样便可以通过该对象访问方法区中的这些数据。

2、linking(类的关联)

类的加载过程后生成了类的java.lang.Class对象,接着会进入连接阶段,连接阶段负责将类的二进制数据合并入JRE(Java运行时环境)中。类的连接大致分三个阶段

  1. verification(验证):验证被加载后的类(Class文件的字节流)是否有正确的结构,类数据是否会符合虚拟机的要求,确保不会危害虚拟机安全。不同的虚拟机对类验证的实现可能会有所不同,但大致都会完成以下四个阶段的验证:文件格式的验证、元数据的验证、字节码验证和符号引用验证。
    1. 文件格式验证:验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理,该验证的主要目的是保证输入的字节流能正确地解析并存储于方法区之内。经过该阶段的验证后,字节流才会进入内存的方法区中进行存储,后面的三个验证都是基于方法区的存储结构进行的。
    2. 元数据验证:对类的元数据信息进行语义校验(其实就是对类中的各数据类型进行语法校验),保证不存在不符合Java语法规范的元数据信息。
    3. 字节码验证:该阶段验证的主要工作是进行数据流和控制流分析,对类的方法体进行校验分析,以保证被校验的类的方法在运行时不会做出危害虚拟机安全的行为。
    4. 符号引用验证:这是最后一个阶段的验证,它发生在虚拟机将符号引用转化为直接引用的时候(解析阶段中发生该转化,后面会有讲解),主要是对类自身以外的信息(常量池中的各种符号引用)进行匹配性的校验。
  2. preparation(准备):给class的静态(类)变量赋默认值,一般基本类型为0,引用类型为null。
    1. 准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。

      这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中。

      这里所设置的初始值通常情况下是数据类型默认的零值(如0、0L、null、false等),而不是被在Java代码中被显式地赋予。

      如static int a = 100; 静态变量a就会在准备阶段被赋默认值0。另外,静态常量(static final filed)会在准备阶段赋程序设定的初值,如static final int a = 666;  静态常量a就会在准备阶段被直接赋值为666,对于静态变量,这个操作是在初始化阶段进行的。

    2. 注意:

      对基本数据类型来说,对于类变量(static)和全局变量,如果不显式地对其赋值而直接使用,则系统会为其赋予默认的零值,而对于局部变量来说,在使用前必须显式地为其赋值,否则编译时不通过。

      对于同时被static和final修饰的常量,必须在声明的时候就为其显式地赋值,否则编译时不通过;而只被final修饰的常量则既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值,总之,在使用前必须为其显式地赋值,系统不会为其赋予默认零值。

      对于引用数据类型reference来说,如数组引用、对象引用等,如果没有对其进行显式地赋值而直接使用,系统都会为其赋予默认的零值,即null。

      如果在数组初始化时没有对数组中的各元素赋值,那么其中的元素将根据对应的数据类型而被赋予默认的零值。
       

  3. resolution(处理):将class类中常量池中的地址符号转换成为直接的内存地址,及可访问的内存地址(这里是没有进行真正的初始化赋值的,很多人习惯这里搞混)
    1. 符号的引用:符号引用以一组符号来描述所引用的目标,符号可以是任意形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。
    2. 直接引用:直接引用可以是直接指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。直接饮用是与内存布局相关的。
    3. 类或接口的解析
    4. 字段的解析
    5. 类方法解析
    6. 接口方法解析

3、initalizing(初始化):调用类构造器<clinit>方法的过程,给静态成员变量赋初始值,这里才开始真正的初始化赋值。(与2.2之间的指令重排也是造成半实例化的原因,具体可以去用插件BinEd-Binary插件去看编译的字节码文件,这也是单例模式中懒加载用volatile的主要原因)

类加载器

1.Bootstrap类加载器(启动类加载器)为顶级的classloader加载路径为<JAVA_HOME>\lib目录中jar文件/charset.jar等核心类,C++实现。
2.Extension类加载器(扩展类加载器)负责加载<JAVA_HOME>\lib\ext目录中的jar文件或者由-Djava.ext.dirs指定。
3.App类加载器(系统类加载器负责加载来自在命令java中的classpath或者java.class.path系统属性或者CLASSPATH操作系统属性所指定的JAR类包和类路径,如果应用程序中没有自定义自己的类加载器,一般情况下这个就是程序中默认的类加载器。
4.Custom为(自定义类加载器),由用户自己实现。

双亲委派的过程图

双亲委派机制。

在这里插入图片描述

我们先用代码去论证双亲委派的存在。(可以自行验证)

这里补充一个问题:

parent是如何指定的?

源码是用super(parent)指定的。

还有如果没有特别指定,默认是用用的AppClassLoader@18b4aac2,可以自行用代码试一下,源码我没有找到默认值。

public class T004_ParentAndChild {
    public static void main(String[] args) {
        System.out.println(T004_ParentAndChild.class.getClassLoader());
        System.out.println(T004_ParentAndChild.class.getClassLoader().getClass().getClassLoader());
        System.out.println(T004_ParentAndChild.class.getClassLoader().getParent());
        System.out.println(T004_ParentAndChild.class.getClassLoader().getParent().getParent());
        //System.out.println(T004_ParentAndChild.class.getClassLoader().getParent().getParent().getParent());

    }
}

打印出来是这样:
sun.misc.Launcher$AppClassLoader@18b4aac2
null
sun.misc.Launcher$ExtClassLoader@12bb4df8
null

(ClassLoader加载过程用的是 模板方法模式 设计模式)

JVM是按需动态加载,采用双亲委派机制,自底部往上检查该类是否加载(图中1>2>3的顺序),如果没有classloader加载过该类则会自上而下(4>5>6)再去查找加载class,直到找到该class并加载到内存中。

采用双亲委派机制的好处是为了安全,避免外部加载进来的类和内部classloader链中产生冲突,做恶意破坏,如果有同名的类被加载进来JVM首先会判断是否有这样一个类已经加载过,如果已经加载则不会再加载一次该类。

可以看一下ClassLoader的源码:
name参数为class的名称,实际调用的是loadClass(String name)

 public Class<?> loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false);
    }

调用loadClass(String name, boolean resolve)

执行顺序如上图:

  1. 先去调用findLoadedClass(name)看是否之前有加载过相同的类,至于去哪找呢?听说先去内存里面的hashset表里面找(源代码我是没找到,找到的可以说一下)。
  2. 如果找不到再去调用parent.loadClass();这里是个迭代。直到找到。
  3. 如果实在找不到会调用findClass(name);方法抛出ClassNotFoundException(name)异常。
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) {
                long t0 = System.nanoTime();
                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.
                    long t1 = System.nanoTime();
                    c = findClass(name);

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

为什么JVM要使用双亲委派呢?

  1. 为了安全(最主要)
    1. 如果不用双亲委派,把自定义的加载器加载java.lang.String然后打包发给客户,就会覆盖掉自带的String类库,一般我们保存密码都是用String存储。假如这时候我在自定义String类里面加一个发送邮箱的业务或者存储到我对象的数据库的业务线,是不是相当于用我的自定义的String我都会很轻松的获取密码。
  2. 为了效率

扩展:

1、类加载器加密

可以在类加载器加载的时候进行加密,代码如下:

这里用的亦或加密的。

public class T007_MSBClassLoaderWithEncription extends ClassLoader {

    public static int seed = 0B10110110;

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        File f = new File("c:/test/", name.replace('.', '/').concat(".msbclass"));

        try {
            FileInputStream fis = new FileInputStream(f);
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            int b = 0;

            while ((b=fis.read()) !=0) {
                baos.write(b ^ seed);
            }

            byte[] bytes = baos.toByteArray();
            baos.close();
            fis.close();//可以写的更加严谨

            return defineClass(name, bytes, 0, bytes.length);
        } catch (Exception e) {
            e.printStackTrace();
        }
        return super.findClass(name); //throws ClassNotFoundException
    }

    public static void main(String[] args) throws Exception {

        encFile("com.mashibing.jvm.hello");

        ClassLoader l = new T007_MSBClassLoaderWithEncription();
        Class clazz = l.loadClass("com.mashibing.jvm.Hello");
        Hello h = (Hello)clazz.newInstance();
        h.m();

        System.out.println(l.getClass().getClassLoader());
        System.out.println(l.getParent());
    }

    /**
     * 用亦或加密
     * @param name
     * @throws Exception
     */
    private static void encFile(String name) throws Exception {
        File f = new File("c:/test/", name.replace('.', '/').concat(".class"));
        FileInputStream fis = new FileInputStream(f);
        FileOutputStream fos = new FileOutputStream(new File("c:/test/", name.replaceAll(".", "/").concat(".msbclass")));
        int b = 0;

        while((b = fis.read()) != -1) {
            // 亦或一个数,再亦或的话就解密了
            fos.write(b ^ seed);
        }

        fis.close();
        fos.close();
    }
}

2、类加载器什么时候开始初始化?

JVM规范并没有规定何时加载。但是严格规定了什么时候必须初始化。

类的加载不是在初始化时loading所有的类,而是在用到的时候才会去加载class。即会发生懒加载,懒加载有五种情况:

  1. new对象,获取和访问静态变量,记住访问final变量除外。

  2. java.lang.reflect对类进行反射调用时。

  3. 初始化子类的时候,父类首先初始化。

  4. 虚拟机启动时,被执行的主类必须初始化。

  5. 动态语言支持java.lang.invoke.MethodHandle解析的结果为REF_getstatic REF_putstatic REF_invokestatic的方法句柄时,该类必须初始化。

3、如何打破双亲委派机制?

我们从上述源码可以推断,只要重写ClassLoader的loadClass()方法,在loadClass方法中不在调用父类的loadClass(),而是直接去load自己的class,这样就可以打破双亲委派机制

4、何时打破双亲委派机制?

  1. 在JDK1.2版本之前,自定义自定义ClassLoader都必须重写loadClass()。
  2. ThreadContextClassLoader可以实现基础类调用实现类代码,通过thread.setContextClassLoader指定 。
  3. 热启动 。osgi tomcat都有自己的模块指定classloader(可以加载同一类库的不同版本)
    1. tomcate的热部署就是打破了双亲委派机制,修改一个class文件可以立即同步到上下文中,其实本质就是重新加载了一次该class,如果有感兴趣的可以去了解一下tomcat的实现原理里面大致应该也是重写了loadClass()方法。

重写loadClass()代码:

public class T012_ClassReloading2 {
    private static class MyLoader extends ClassLoader {
        @Override
        public Class<?> loadClass(String name) throws ClassNotFoundException {

            File f = new File("C:/work/ijprojects/JVM/out/production/JVM/" + name.replace(".", "/").concat(".class"));

            if(!f.exists()) return super.loadClass(name);

            try {

                InputStream is = new FileInputStream(f);

                byte[] b = new byte[is.available()];
                is.read(b);
                return defineClass(name, b, 0, b.length);
            } catch (IOException e) {
                e.printStackTrace();
            }

            return super.loadClass(name);
        }
    }

    public static void main(String[] args) throws Exception {
        MyLoader m = new MyLoader();
        Class clazz = m.loadClass("com.mashibing.jvm.Hello");

        m = new MyLoader();
        Class clazzNew = m.loadClass("com.mashibing.jvm.Hello");

        System.out.println(clazz == clazzNew);
    }
}

5、JVM的混合模式

默认情况下是一种混合模式(混合使用解释器+热点代码编译)。

java是解释执行的,class文件到内存之后,通过java的解释器-bytecode intepreter来执行,

JIT(Just In-Time Compiler):有些代码会编译成本地格式的代码来执行。

所以说java不能单纯说是解释型语言还是编译型语言。

一、什么时候会用JIT编译成本地代码呢?

    写了一段代码,刚刚开始是用解释器执行,结果发现在执行过程中有某一段代码执行的频率特别高(1s中执行几十万次),JVM就会把这段代码编译成本地代码(类似用C语言编译本地*.exe的文件),再执行该段代码时就不会用解释器解释来执行了,提升效率。

二、为什么不直接编译成本地代码,提高执行效率呢?

  1. java解释器的执行效率其实也很高了,在某些代码的执行效率上不一定输于执行本地代码。
  2. 如果执行的代码引用类库特别多,在执行启动时时间会非常长。

三、用参数改变模式:

  1. -Xmixed:默认为混合模式,启动速度较快,对热点代码实行检测和编译。
  2. -Xint:使用解释模式,启动很快,执行稍慢。
  3. -Xcomp:使用纯编译模式,执行很快,启动很慢(很多类库的时候)

代码验证:

Edit Configurations-->VM options

  • 混合模式:
    • 不修改任何参数用默认配置
    • 执行时间:2700左右
  • 解释模式:
    • 将Edit Configurations-->VM options-->-Xint
    • 执行时间:执行时间太长减一个循环的0,19000
  • 编译模式:
    • 将Edit Configurations-->VM options-->-Xcomp
    • 执行时间:2600
public class T009_WayToRun {
    public static void main(String[] args) {
        for(int i=0; i<10_0000; i++)
            m();

        long start = System.currentTimeMillis();
        for(int i=0; i<10_0000; i++) {
            m();
        }
        long end = System.currentTimeMillis();
        System.out.println(end - start);
    }

    public static void m() {
        for(long i=0; i<10_0000L; i++) {
            long j = i%3;
        }
    }
}

下一篇:【深入理解JVM】3、CPU储存器+MESI+CPU伪共享+CPU乱序问题及代码论证【面试必备】

评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Hello-zhou

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值