浅谈JVM模型以及Class文件是怎么被加载的


前言

本文分为3个方面入手带你充分了解class文件是怎么进入JVM中的

大家好! 这里是Yve菌, 一个梦想成为大佬的程序员. 学Java的小伙伴一定对JVM并不陌生, 毕竟JVM作为java的重中之重每一位Java程序员都应该充分理解, 总不能别人问你JVM是个啥, 你支支吾吾光回答个JVM就是个java虚拟机吧. 好了,废话不多说让我们开始正文吧


一. 什么是JVM

首先我们来看一幅图
在这里插入图片描述
没错!最底下那一行就是咱们的主角JVM了, 别看人家小, 但是人家可厉害着呢. JVM是Java Virtual Machine(Java虚拟机)的缩写, 我们辛辛苦苦写出的java文件通过javac指令编译后生成的class文件信息就是保存在JVM的方法区(后面会提到)当中, 并且我们知道, 我们写出的java文件可以在不同的系统中运行而不用重新修改的原因就是JVM在不同操作系统中的版本不同, 所以我们java语言也具有平台无关性, 相同的java代码在不同的系统中都可以运行.
平台无关性


二、JVM类加载机制

1.类加载的概念

首先我们要明白一个概念: 当我们使用java运行一个class文件时, 这个文件是怎么被jvm发现的呢?
流程图

如上图所示, 当我们执行java Test.class指令的时候, 首先系统底层会调用相关的C++代码来生成一个引导类加载器(BootstrapClassLoader) 来获取一个虚拟机实例Launcher, 获取到虚拟机实例Launcher之后会调用getClassLoader()方法来获取到对应的扩展类加载器(ExtClassLoader) 和 应用类加载器(AppClassLoader), 之后会通过AppClassLoader调用loadClass()方法来将类装载到jvm内部的方法区中.

2.类加载具体流程

在上面的大体的类加载中, 其中最重要的就是loadClass()方法了, 这一步是把一个class对象从磁盘中存放到jvm中的核心步骤, 那么在loadClass方法中发生了什么事情呢?
类加载流程

loadClass()方法分为5个步骤:

第一步:加载

jvm首先会在磁盘中寻找class文件的位置, 并在jvm内存里生成一个对应的class文件的信息,作为使用访问这个类的入口.
注意:jvm默认是使用懒加载方式进行类加载,只有当用到了这个类才会进行加载.

第二步:验证

jvm会检查字节码文件的正确性, 确保字节码文件没有问题

第三步: 准备

jvm会为静态变量分配内存, 并赋予该数据类型的默认值
这一点尤其注意! 在这一阶段并不是直接赋给你的指定值, 而是该数据类型的默认值. 例: Integer类型为0, boolean类型为false

第四步: 解析

在这一步会把程序中的符号引用更改为直接引用
我们的代码中每一个关键字,变量名都被称为符号, 但是jvm并不知道这些符号是什么意思, 所以他会将这些符号转换为其对应的内存地址.

第五步: 初始化

为静态变量赋值, 并执行相应的静态代码块
在这一步静态变量才会被赋予真正的值, 代码中的静态块也会在这里执行

3.类加载器

我们在 1.类加载的概念中提到了3种加载器: 引导类加载器(BootstrapClassLoader), 扩展类加载器(ExtClassLoader)和应用程序类加载器(AppClassLoader), 那他们究竟有什么作用呢?

3.1 引导类加载器(BootstrapClassLoader)

由于java语言是无法直接与系统底层进行交互的, 所以系统会先生成一个引导类加载器来生成jvm并帮助生成扩展类加载器(ExtClassLoader)和应用程序类加载器(AppClassLoader).并且因为类引导加载器不是java生成的类,所以无法直接打印出来:

System.out.println(String.class.getClassLoader())        
//运行结果: null;  

引导类加载器主要加载jre lib目录下的核心类,例如:rt.jar, charset.jar.

3.2 扩展类加载器(ExtClassLoader)

扩展类加载器主要是加载jre lib/ext目录下的扩展类

System.out.println(com.sun.crypto.provider.DESKeyFactory.class.getClassLoader().getClass().getName());
//运行结果:  sun.misc.Launcher$ExtClassLoader

3.3 应用类加载器(AppClassLoader)

应用类加载器主要是加载classpath下用户自定义的类, 也就是我们自己手写的类

//代码中的User类为自定义类
System.out.println(User.class.getClassLoader().getClass().getName());
//运行结果: sun.misc.Launcher$AppClassLoader

以下为jvm Launcher的源码, 里面创造了2种类加载器

//Launcher的构造方法
public Launcher() {
    Launcher.ExtClassLoader var1;
    try {
        //构造扩展类加载器,在构造的过程中将其父加载器设置为null
        var1 = Launcher.ExtClassLoader.getExtClassLoader();
    } catch (IOException var10) {
        throw new InternalError("Could not create extension class loader", var10);
    }

    try {
        //构造应用类加载器,在构造的过程中将其父加载器设置为ExtClassLoader,
        //Launcher的loader属性值是AppClassLoader,我们一般都是用这个类加载器来加载我们自己写的应用程序
        this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
    } catch (IOException var9) {
        throw new InternalError("Could not create application class loader", var9);
    }

    Thread.currentThread().setContextClassLoader(this.loader);
    String var2 = System.getProperty("java.security.manager");
    。。。 。。。 //省略一些不需关注代码

}

3.4 自定义类加载器

我们也可以添加一些自定义类加载器, 自定义类加载器需要继承java.lang.ClassLoader类, 该类中有两个重要方法, 一个是loadClass() 另一个是findClass(), 因为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/yve/jvm 几级目录,将User类的复制类User.class丢入该目录
        Class clazz = classLoader.loadClass("com.yve.jvm.User");
        Object obj = clazz.newInstance();
        Method method = clazz.getDeclaredMethod("sout", null);
        method.invoke(obj, null);
        System.out.println(clazz.getClassLoader().getClass().getName());
    }
}

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

4.双亲委派机制

提到类加载器就不得不说到一个重要的机制-双亲委派机制.
双亲委派机制
当我们需要一个类时该类加载器会先去寻找这个类之前是否加载过, 如果发现没有加载过这个类就会向自己的上一级父加载器委托加载, 上一级尝试寻找失败后会由子加载器自行加载
举个例子: 我们写了一个User类, 应用程序类加载器会先去查看User类之前加载过没有, 如果加载过自己就不用再加载了; 如果没找到就会委托给自己的上一级-扩展类加载器负责加载, 扩展类加载器收到请求后又会委托自己的上一级-引导类加载器负责加载. 引导类加载器收到请求后会尝试自己的目录下疯狂寻找, 如果找到直接加载; 如果找不到会告诉扩展类加载器: 我尽力了,你自己再去找找吧. 扩展类加载器知道之后会自己尝试寻找, 如果找到了就直接加载; 找不到就告诉应用程序类加载器: 我也找不到, 你加油! 应用程序类加载器知道后只能自己去找了.
简单来说就是: 只要类需要加载就会从最上层开始依次向下寻找

4.1 双亲委派机制的原因

那么可能就会有小伙伴发出疑问了, 明明到最后还得自己找, 为什么一开始不能直接自己找呢? 双亲委派机制存在的意义是什么呢?

1.沙箱安全机制

我们知道有很多API都是自带的, 例如String类, 当需要加载String类时会由最上层开始加载, 这样就能保证加载到的String类是从的核心类库中加载的, 这样做的好处是为了防止核心API库被篡改

2.防止类被重复加载

当我们要加载的类被上层加载过时, 子类加载器就不需要再进行重复加载了, 保证了加载类的唯一性

4.2 打破双亲委派机制

当我们写了很多自定义类加载器但是要加载不同版本的类时, 就需要打破双亲委派机制. 你想想, 当你吃着火锅唱着歌, 想着加载自己版本的类时, 突然被自己的父加载器劫了, 所以有些时候机制还是要打破滴~ 而打破双亲委派机制的核心就是重写loadClass()方法并修改其中双亲委派机制对应的逻辑

//ClassLoader的loadClass方法,里面实现了双亲委派机制
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) {
                // 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();
                //都会调用URLClassLoader的findClass方法在加载器的类路径里查找并加载该类
                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内存加载模型

JVM有一个庞大的内存模型, 总共分为4个部分: 类加载子系统, 运行时数据区, 执行引擎和本地方法接口
JVM内存模型

1.类加载子系统

类加载子系统就是我们在前文中提到的类加载器(ClassLoader), 负责将class文件加载进JVM方法区当中.

2. 运行时数据区

运行时数据区是JVM中最重要的一个部分, 也是面试中提问的重灾区, 里面包含了五个部分: 栈, 程序计数器, 本地方法栈, 堆和方法区.

2.1 程序计数器

程序计数器是一块很小的区域, 用来存储当前正在执行字节码的地址. 当此线程由于某些原因中断又再次恢复时就可以根据程序计数器记录的地址来找到之前正在执行的位置. 程序计数器是线程私有的,每一个线程都会有一个自己的程序计数器

2.2 栈(Stack)

栈(Stack)也常被叫做java栈, 线程栈, 虚拟机栈等等, 里面是线程运行的主要空间, 存储县城中的局部变量, 方法等的主要地区. 方法运行时占用的空间被叫做栈帧, 正在运行方法的栈帧被叫做活动栈帧. 栈是线程私有的,每一个线程都会有一个自己的栈.
在这里插入图片描述
我们从图里可以知道, 每个栈帧中都有4块区域:局部变量表, 操作数栈, 动态链接和方法出口
局部变量表:里面保存着变量的信息, 如果是基本变量就会保存变量和值, 如果是引用变量就会存储该变量在堆内存中对应的引用.
操作数栈: 我们为变量赋的值都被称为操作数, 赋值操作并不会在一开始就会进行, 首先数值会被存储到操作数栈中, 然后为变量在局部变量表中开辟空间, 最后将数值从操作数栈中弹出到局部量表量完成赋值
动态链接: 栈帧在活动时并不会直接加载所有的方法, 只有用到的时候才会进行加载, 因此这些方法的符号引用会被转换为直接引用存储到动态链接中, 供后续加载使用
方法出口: 记录着其他栈帧中最后执行调用此方法的地址, 执行完这个方法后通过方法出口回到之前的方法

2.3 本地方法栈

本地方法就是非java的代码部分, 通常由C/C++构成, 由于Java无法直接与底层互动, 所以有些功能只能通过调用本地方法栈来执行. 本地方法栈也是私有的.

2.4 堆

堆内存中存放的是我们new 的对象的真正位置, 它分为3个区域: Eden区, Survivor区和老年代
在这里插入图片描述

新建的对象都会统一分配进Eden区中, 当Eden区满了会进行Minor GC进行垃圾回收, 不再被引用的对象会被清理, 依然存活的对象会被移动到Survivor区中的一个,并且分代年龄+1, 之后每进行一次minor GC存活的对象都会被移动到survivor区中的另一个并且分代年龄+1. 当分代年龄到达15时(不同垃圾回收器年龄不同)会被移动到老年代中. 堆是线程公共的, 所有线程共享一个堆
具体的垃圾回收机制涉及到很多东西, 后续会出一篇关于垃圾回收的详细讲

2.5 方法区

在 jdk1.8之前被称为永久代, 是一块与堆内存相连的连续的内存地址, 里面存放着类信息, 常量以及静态变量等信息
但是在 jdk1.8 之后永久代被替换为元空间, 元空间改为用直接内存, 里面只保留类信息, 常量以及静态变量等信息转移到堆中存储. 元空间默认触发Full GC的大小为21M, 并且会根据清理垃圾的多少而对触发值进行扩大或缩小.

3. 执行引擎

JVM无法与系统直接交互, 所以内部的class文件只能通过执行引擎来执行

4. 本地方法接口

通过本地方法接口来调用操作系统的方法


总结

以上就是一个class文件怎么加载到jvm当中, 如果这篇文章帮助到你了就麻烦点个赞呗, 感谢大家!

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值