jvm的类加载

概要

jvm运行时的整体结构如下
在这里插入图片描述
一个Car类,类跟Car对象的转换过程如下:
在这里插入图片描述

  • 加载后的class类信息存放于方法区;
  • ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定;
  • 如果调用构造器实例化对象,则该对象存放在堆区;

其中类的加载总体流程如下:
在这里插入图片描述

加载

类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。

加载是类加载的第一个阶段。有两种时机会触发类加载

  • 预加载
    虚拟机启动时加载,加载的是JAVA_HOME/lib/下的rt.jar下的.class文件,这个jar包里面的内容是程序运行时非常常
    常用到的,像java.lang.*、java.util.、java.io. 等等,因此随着虚拟机一起加载
  • 运行时加载
    虚拟机在用到一个.class文件的时候,会先去内存中查看一下这个.class文件有没有被加载,如果没有就会按照类的全限定名来加载这个类

那么,加载阶段做了什么,其实加载阶段做了有三件事情:

  • 获取.class文件的二进制流
  • 将类信息、静态变量、字节码、常量这些.class文件中的内容放入方法区中
  • 在内存中生成一个代表这个.class文件的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。一般这个Class是在堆里的,不过HotSpot虚拟机比较特殊,这个Class对象是放在方法区中的

类加载器分类

jvm提供了3个系统加载器,分别是Bootstrp loaderExtClassLoaderAppClassLoader

在这里插入图片描述

这三个加载器互相成父子继承关系

  • Bootstrp loader: Bootstrp加载器是用C++语言写的,它在Java虚拟机启动后初始化;它主要负责加载以下路径的文件:
    %JAVA_HOME%/jre/lib/*.jar
    %JAVA_HOME%/jre/classes/*
    -Xbootclasspath参数指定的路径
    可通过:System.out.println(System.getProperty("sun.boot.class.path"));打印查看

  • ExtClassLoaderExtClassLoader是用Java写的,具体来说就是sun.misc.Launcher$ExtClassLoader,其主要加载:
    %JAVA_HOME%/jre/lib/ext/
    ext下的所有classes目录
    java.ext.dirs系统变量指定的路径中类库
    可通过:System.getProperty("java.ext.dirs")打印查看

  • AppClassLoader: AppClassLoader也是用Java写成的,它的实现类是sun.misc.Launcher$AppClassLoader,另外我们知道ClassLoader中有个getSystemClassLoader方法,此方法返回的就是它。
    负责加载 -classpath 所指定的位置的类或者是jar文档,也是Java程序默认的类加载器
    System.getProperty("java.class.path")

双亲委派模型

什么是双亲委派

双亲委派模型工作过程是:如果一个类加载器收到类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器完成。每个类加载器都是如此,只有当父加载器在自己的搜索范围内找不到指定的类时(即 ClassNotFoundException ),子加载器才会尝试自己去加载。

为什么需要双亲委派模型?

采用双亲委派模式的是好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父加载器已经加载了该类时,就没有必要子加载器再加载一次。
其次是考虑到安全因素,java核心api中定义类型不会被随意替换,假设通过网络传递一个名为 java.lang.Integer 的类,通过双亲委派模型传递到启动类加载器,而启动类加载器发现这个名字的类,发现该类已被加载,就不会重新加载网络传递过来的 java.lang.Integer ,而直接返回已加载过的Integer.class ,这样便可以防止核心API库被随意篡改。

双亲委派能否打破?
可以的,比如在tomcat中,tomcat通过 war 包进行应用的发布,它其实是违反了双亲委派机制原则,
看一下tomcat类加载的层次结构如下:
在这里插入图片描述

比如:Tomcat的 webappClassLoader 加载web应用下的class文件,不会传递给父类加载器,问题:tomcat的类加载器为什么要打破该模型?
首先一个tomcat启动后是会起一个jvm进程的,它支持多个web应用部署到同一个tomcat里,为此

  • 对于不同的web应用中的class和外部jar包,需要相互隔离,不能因为不同的web应用引用了相同的jar或者有相同的class导致一个加载成功了另一个加载不了。
  • web容器支持jsp文件修改后不用重启,jsp文件也是要编译成.class文件的,每一个jsp文件对应一个JspClassLoader,它的加载范围仅仅是这个jsp文件所编译出来的那一个.class文件,当Web容器检测到jsp文件被修改时,会替换掉目前JasperLoader的实例,并通过再建立一个新的Jsp类加载器来实现JSP文件的热部署功能。

如何实现双亲委派模型

双亲委派模型的原理很简单,实现也简单。每次通过先委托父类加载器加载,当父类加载器无法加载时,再自己加载。其实 ClassLoader 类默认的 loadClass 方法已经帮我们写好了,我们无需去写

几个重要的函数

loadClass 默认实现如下:

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

再看看 loadClass(String name, boolean resolve) 函数:

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;
        }
    }

从上面代码可以明显看出, loadClass(String, boolean) 函数即实现了双亲委派模型!整个大致过程如下:

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

也就是说,如果要自定义类加载器,就要重写fiindClass方法。

抽象类 ClassLoaderfindClass 函数默认是抛出异常的。而前面我们知道, loadClass 在父加载器无法加载类的时候,就会调用我们自定义的类加载器中的 findeClass 函数,因此我们必须要在 loadClass 这个函数里面实现将一个指定类名称转换为 Class 对象

自定义加载器

除了上面的系统提供的3种loader,jvm允许自己定义类加载器,典型的在tomcat上:
在这里插入图片描述

为什么要自定义类加载器:

  • 隔离加载类
    模块隔离,把类加载到不同的应用选中。比如tomcat这类web应用服务器,内部自定义了好几中类加载器,用于隔离web应用服务器上的不同应用程序。
  • 修改类加载方式
    除了Bootstrap加载器外,其他的加载并非一定要引入。根据实际情况在某个时间点按需进行动态加载。
  • 扩展加载源
    比如还可以从数据库、网络、或其他终端上加载
  • 防止源码泄露
    java代码容易被编译和篡改,可以进行编译加密,类加载需要自定义还原加密字节码

自定义类加载器的加载流程

在这里插入图片描述
自定义加载器:

public class MyClassLoader extends ClassLoader {
    private String codePath;

    protected MyClassLoader(ClassLoader parent, String path) {
        super(parent);
        this.codePath = path;
    }

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

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        String fileName = codePath + name + ".class";
        try (   // 输入流
                BufferedInputStream bis = new BufferedInputStream(new FileInputStream(fileName));
                // 输出流
                ByteArrayOutputStream baos = new ByteArrayOutputStream()) {

            int len;
            byte[] data = new byte[1024];
            while ((len = bis.read(data)) != -1) {
                baos.write(data, 0, len);
            }

            //5.获取内存中字节数组
            byte[] byteCode = baos.toByteArray();

            //6.调用defineClass 将字节数组转成Class对象
            Class<?> defineClass = defineClass(null, byteCode, 0, byteCode.length);
            return defineClass;

        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
        return null;
    }
}

有以下注意点:

  • 所有用户自定义类加载器都应该继承ClassLoader类

  • 在自定义ClassLoader的子类是,我们通常有两种做法:

    • 重写loadClass方法(是实现双亲委派逻辑的地方,修改他会破坏双亲委派机制,不推荐)
    • 重写findClass方法 (推荐)

验证

连接阶段的第一步,这一阶段的目的是为了确保.class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
Java语言本身是相对安全的语言(相对C/C++来说),但是前面说过,.class文件未必要从Java源码编译而来,可以使用任何途径产生,甚至包括用十六进制编辑器直接编写来产生.class文件。在字节码语言层面上,Java代码至少从语义上是可以表达出来的。虚拟机如果不检查输入的字节流,对其完全信任的话,很可能会因为载入了有害的字节流而导致系统崩溃,所以验证是虚拟机对自身保护的一项重要工作。

验证阶段主要做以下几方面的工作:

  • 文件格式验证:是不是CAFEBABYE开头,主次版本号是否在当前jvm虚拟机可运行的范围内等
  • 元数据验证:段主要验证属性、字段、类关系、方法等是否合规
  • 字节码验证:这里主要验证class里定义的方法,看方法内部的code是否合法
  • 符号引用验证:字节码里有的是直接引用,有的是指向了其他的字节码地址。而符号引用验证的就是,这些引用的对应的内容是否合法

准备

准备阶段是正式为类变量分配内存并设置其初始值的阶段,这些变量所使用的内存都将在方法区中分配。关于这点,有两个地方注意一下

  • 这时候进行内存分配的仅仅是类变量(被static修饰的变量),而不是实例变量,实例变量将会在对象实例化的时候随着对象一起分配在Java堆中
  • 这个阶段赋初始值的变量指的是那些不被final修饰的static变量,比如public static int value = 123value在准备阶段过后是0而不是123,给value赋值为123的动作将在初始化阶段才进行;比如public static final int value =123;就不一样了,在准备阶段,虚拟机就会给value赋值为123。

各个数据类型的零值如下表:
在这里插入图片描述

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。来了解一下符号引用和直接引用有什么区别:

符号引用
符号引用是一种定义,可以是任何字面上的含义,而直接引用就是直接指向目标的指针、相对偏移量。
这个其实是属于编译原理方面的概念,符号引用包括了下面三类常量:

  • 类和接口的全限定名
  • 字段的名称和描述符
  • 方法的名称和描述符

看一段代码:

public class TestMain {
    private static int i;
    private double d;

    public static void print() {
    }

    private boolean trueOrFalse() {
        return false;
    }
}

反编译后得到:

Constant pool:
   #1 = Methodref          #3.#17         // java/lang/Object."<init>":()V
   #2 = Class              #18            // com/ocean/classloading/TestMain
   #3 = Class              #19            // java/lang/Object
   #4 = Utf8               i
   #5 = Utf8               I
   #6 = Utf8               d
   #7 = Utf8               D
   #8 = Utf8               <init>
   #9 = Utf8               ()V
  #10 = Utf8               Code
  #11 = Utf8               LineNumberTable
  #12 = Utf8               print
  #13 = Utf8               trueOrFalse
  #14 = Utf8               ()Z
  #15 = Utf8               SourceFile
  #16 = Utf8               TestMain.java
  #17 = NameAndType        #8:#9          // "<init>":()V
  #18 = Utf8               com/ocean/classloading/TestMain
  #19 = Utf8               java/lang/Object

可以看到常量池中有22项内容,其中带"Utf8"的就是符号引用。比如#2,它的值是"com/ocean/classloading/TestMain",表示的是这个类的全限定名;又比如#4为i,#5为I,它们是一对的,表示变量时Integer(int)类型的,名字叫做i;#12、#16表示的都是方法的名字。

符号引用就是对于类、变量、方法的描述。符号引用和虚拟机的内存布局是没有关系的,引用的目标未必已经加载到内存中了。

直接引用

直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同

解析阶段负责把整个类激活,串成一个可以找到彼此的网。那这个阶段都做了哪些工作呢?大体可以分为:

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

初始化

最后一个步骤,经过这个步骤后,类信息完全进入了jvm内存,直到它被垃圾回收器回收。
前面几个阶段都是虚拟机来搞定的。我们也干涉不了,从代码上只能遵从它的语法要求。而这个阶段,是赋值,才是我们应用程序中编写的有主导权的地方

初始化阶段就是执行类构造器()方法的过程。 ()方法并不是程序员在Java代码中直接编写 的方法, 它是Javac编译器的自动生成物,()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块) 中的 语句合并产生的。

()方法与类的构造函数(即在虚拟机视角中的实例构造器()方法) 不同, 它不需要显 式地调用父类构造器, Java虚拟机会保证在子类的()方法执行前, 父类的()方法已经执行 完毕。 因此在Java虚拟机中第一个被执行的()方法的类型肯定是java.lang.Object

由于父类的()方法先执行, 也就意味着父类中定义的静态语句块要优先于子类的变量赋值 操作

()方法对于类或接口来说并不是必需的, 如果一个类中没有静态语句块, 也没有对变量的 赋值操作, 那么编译器可以不为这个类生成()方法。 接口中不能使用静态语句块, 但仍然有变量初始化的赋值操作, 因此接口与类一样都会生成 ()方法
但接口与类不同的是, 执行接口的()方法不需要先执行父接口的()方法, 因为只有当父接口中定义的变量被使用时, 父接口才会被初始化。 此外, 接口的实现类在初始化时也 一样不会执行接口的()方法

Java虚拟机必须保证一个类的()方法在多线程环境中被正确地加锁同步, 如果多个线程同 时去初始化一个类, 那么只会有其中一个线程去执行这个类的()方法, 其他线程都需要阻塞等 待, 直到活动线程执行完毕()方法。 如果在一个类的()方法中有耗时很长的操作, 那就 可能造成多个进程阻塞, 在实际应用中这种阻塞往往是很隐蔽的

class TestDeadLoop {
    static class DeadLoopClass {
        static {
// 如果不加上这个if语句, 编译器将提示“Initializer does not complete normally”并拒绝编译
            if (true) {
                System.out.println(Thread.currentThread() + "init DeadLoopClass");
                while (true) {
                }
            }
        }
    }

    public static void main(String[] args) {
        Runnable script = new Runnable() {
            public void run() {
                System.out.println(Thread.currentThread() + "start");
                DeadLoopClass dlc = new DeadLoopClass();
                System.out.println(Thread.currentThread() + " run over");
            }
        };
        Thread thread1 = new Thread(script);
        Thread thread2 = new Thread(script);
        thread1.start();
        thread2.start();
    }
}

上面说的()方法可以理解为是<cinit>,对象的初始化方法(构造函数),也就是反编译之后看到方法是<init>,这两者什么区别呢?
看一段代码

public class ParentA {
    static {
        System.out.println("1");
    }

    public ParentA() {
        System.out.println("2");
    }
}

public class SonB extends ParentA {
    static {
        System.out.println("a");
    }

    public SonB() {
        System.out.println("b");
    }

    public static void main(String[] args) {
        ParentA ab = new SonB();
        ab = new SonB();
    }

}

上面的打印结果是:

1
a
2
b
2
b

其中 static 字段和 static 代码块,是属于类的,在类的加载的初始化阶段就已经被执行。类信息会被存放在方法区,在同一个类加载器下,这些信息有一份就够了,所以上面的 static 代码块只会执行一次,它对应的是 <cinit>方法。

在这里插入图片描述

所以,上面代码的 static 代码块只会执行一次,对象的构造方法执行两次。再加上继承关系的先后原则,不难分析出正确结果
小结:
方法<cinit>的执行时期: 类初始化阶段(该方法只能被jvm调用, 专门承担类变量的初始化工作) ,只执行一次
方法 <init>的执行时期: 对象的初始化阶段

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值