zoukankan      html  css  js  c++  java
  • JVM学习笔记(八):类加载

    1 来源

    • 来源:《Java虚拟机 JVM故障诊断与性能优化》——葛一鸣
    • 章节:第十章

    本文是第十章的一些笔记整理。

    2 概述

    本文主要讲述了类加载器以及类加载的详细流程。

    3 类加载流程

    类加载的流程可以简单分为三步:

    • 加载
    • 连接
    • 初始化

    而其中的连接又可以细分为三步:

    • 验证
    • 准备
    • 解析

    下面会分别对各个流程进行介绍。

    3.1 类加载条件

    在了解类接在流程之前,先来看一下触发类加载的条件。

    JVM不会无条件加载类,只有在一个类或接口在初次使用的时候,必须进行初始化。这里的使用是指主动使用,主动使用包括如下情况:

    • 创建一个类的实例的时候:比如使用new创建,或者使用反射、克隆、反序列化
    • 调用类的静态方法的时候:比如使用invokestatic指令
    • 使用类或接口的静态字段:比如使用getstatic/putstatic指令
    • 使用java.lang.reflect中的反射类方法时
    • 初始化子类时,要求先初始化父类
    • 含有main()方法的类

    除了以上情况外,其他情况属于被动使用,不会引起类的初始化。

    比如下面的例子:

    public class Main {
        public static void main(String[] args){
            System.out.println(Child.v);
        }
    }
    
    class Parent{
        static{
            System.out.println("Parent init");
        }
        public static int v = 100;
    }
    
    class Child extends Parent{
        static {
            System.out.println("Child init");
        }
    }
    

    输出如下:

    Parent init
    100
    

    而加上类加载参数-XX:+TraceClassLoading后,可以看到Child确实被加载了:

    [0.068s][info   ][class,load] com.company.Main
    [0.069s][info   ][class,load] com.company.Parent
    [0.069s][info   ][class,load] com.company.Child
    Parent init
    100
    

    但是并没有进行初始化。另外一个例子是关于final的,代码如下:

    public class Main {
        public static void main(String[] args){
            System.out.println(Test.STR);
        }
    }
    
    class Test{
        static{
            System.out.println("Test init");
        }
        public static final String STR = "Hello";
    }
    

    输出如下:

    [0.066s][info   ][class,load] com.company.Main
    Hello
    

    Test类根本没有被加载,因为final被做了优化,编译后的Main.class中,并没有引用Test类:

    0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
    3: ldc           #4                  // String Hello
    5: invokevirtual #5                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
    

    在字节码偏移3的位置,通过ldc将常量池第4项入栈,此时在字节码文件中常量池第4项为:

    #3 = Class              #24            // com/company/Test
    #4 = String             #25            // Hello
    #5 = Methodref          #26.#27        // java/io/PrintStream.println:(Ljava/lang/String;)V
    

    因此并没有对Test类进行加载,只是直接引用常量池中的常量,因此输出没有Test的加载日志。

    3.2 加载

    类加载的时候,JVM必须完成以下操作:

    • 通过类的全名获取二进制数据流
    • 解析类的二进制数据流为方法区内的数据结构
    • 创建java.lang.Class类的实例,表示该类型

    第一步获取二进制数据流,途径有很多,包括:

    • 字节码文件
    • JAR/ZIP压缩包
    • 从网络加载

    等等,获取到二进制数据流后,JVM进行处理并转化为一个java.lang.Class实例。

    3.3 验证

    验证的操作是确保加载的字节码是合法、合理并且规范的。步骤简略如下:

    在这里插入图片描述

    • 格式检查:判断二进制数据是否符合格式要求和规范,比如是否以魔数开头,主版本号和小版本号是否在当前JVM支持范围内等等
    • 语义检查:比如是否所有类都有父类存在,一些被定义为final的方法或类是否被重载了或者继承了,是否存在不兼容方法等等
    • 字节码验证:会试图通过对字节码流的分析,判断字节码是否可以正确被执行,比如是否会跳转到一条不存在的指令,函数调用是否传递了正确的参数等等,但是却无法100%判断一段字节码是否可以被安全执行,只是尽可能检查出可以预知的明显问题。如果无法通过检查,则不会加载这个类,如果通过了检查,也不能说明这个类完全没有问题
    • 符号引用验证:检查类或方法是否确实存在,并且确定当前类有没有权限访问这些数据,比如无法找到一个类就抛出NoClassDefFoundError,无法找到方法就抛出NoSuchMethodError

    3.4 准备

    类通过验证后,就会进入准备阶段,在这个阶段,JVM为会类分配相应的内存空间,并设置初始值,比如:

    • int初始化为0
    • long初始化为0L
    • double初始化为0f
    • 引用初始化为null

    如果存在常量字段,那么这个阶段也会为常量赋值。

    3.5 解析

    解析就是将类、接口、字段和方法的符号引用转为直接引用。符号引用就是一些字面量引用,和JVM的内存数据结构和内存布局无关,由于在字节码文件中,通过常量池进行了大量的符号引用,这个阶段就是将这些引用转为直接引用,得到类、字段、方法在内存中的指针或直接偏移量。

    另外,由于字符串有着很重要的作用,JVMString进行了特别的处理,直接使用字符串常量时,就会在类中出现CONSTANT_String,并且会引用一个CONSTANT_UTF8常量项。JVM运行时,内部的常量池中会维护一张字符串拘留表(intern),会保存其中出现过的所有字符串常量,并且没有重复项。使用String.intern()可以获得一个字符串在拘留表的引用,比如下面代码:

    public static void main(String[] args){
        String a = 1 + String.valueOf(2) + 3;
        String b = "123";
        System.out.println(a.equals(b));
        System.out.println(a == b);
        System.out.println(a.intern() == b);
    }
    

    输出:

    true
    false
    true
    

    这里b就是常量本身,因此a.intern()返回在拘留表的引用后就是b本身,比较结果为真。

    3.6 初始化

    初始化阶段会执行类的初始化方法<clinit><clinit>是由编译期生成的,由静态成员的赋值语句以及static语句共同产生。

    另外,加载一个类的时候,JVM总是会试图加载该类的父类,因此父类的<clinit>方法总是在子类的<clinit>方法之前被调用。另一方面,需要注意的是<clinit>会确保在多线程环境下的安全性,也就是多个线程同时初始化同一个类时,只有一个线程可以进入<clinit>方法,换句话说,在多线程下可能会出现死锁,比如下面代码:

    package com.company;
    
    import java.util.concurrent.TimeUnit;
    
    public class Main extends Thread{
        private char flag;
        public Main(char flag){
            this.flag = flag;
        }
        
        public static void main(String[] args){
            Main a = new Main('A');
            a.start();
            Main b = new Main('B');
            b.start();
        }
    
        @Override
        public void run() {
            try{
                Class.forName("com.company.Static"+flag);
            }catch (ClassNotFoundException e){
                e.printStackTrace();
            }
        }
    }
    
    class StaticA{
        static {
            try {
                TimeUnit.SECONDS.sleep(1);
            }catch (InterruptedException e){
                e.printStackTrace();
            }
            try{
                Class.forName("com.company.StaticB");
            }catch (ClassNotFoundException e){
                e.printStackTrace();
            }
            System.out.println("StaticA init ok");
        }
    }
    
    class StaticB{
        static {
            try {
                TimeUnit.SECONDS.sleep(1);
            }catch (InterruptedException e){
                e.printStackTrace();
            }
            try{
                Class.forName("com.company.StaticA");
            }catch (ClassNotFoundException e){
                e.printStackTrace();
            }
            System.out.println("StaticB init ok");
        }
    }
    

    在加载StaticA的时候尝试加载StaticB,但是由于StaticB已经被加载中,因此加载StaticA的线程会阻塞在Class.forName("com.company.StaticB")处,同理加载StaticB的线程会阻塞在Class.forName("com.company.StaticA")处,这样就出现死锁了。

    4 ClassLoader

    4.1 ClassLoader简介

    ClassLoader是类加载的核心组件,所有的Class都是由ClassLoader加载的,ClassLoader通过各种各样的方式将Class信息的二进制数据流读入系统,然后交给JVM进行连接、初始化等操作。因此ClassLoader负责类的加载流程,无法通过ClassLoader改变类的连接和初始化行为。

    ClassLoader是一个抽象类,提供了一些重要接口定义加载流程和加载方式,主要方法如下:

    • public Class<?> loadClass(String name) throws ClassNotFoundException:给定一个类名,加载一个类,返回这个类的Class实例,找不到抛出异常
    • protected final Class<?> defineClass(byte[] b, int off, int len):根据给定字节流定义一个类,offlen表示在字节数组中的偏移和长度,这是一个protected方法,在自定义子类中才能使用
    • protected Class<?> findClass(String name) throws ClassNotFoundException:查找一个类,会在loadClass中被调用,用于自定义查找类的逻辑
    • protected Class<?> findLoadedClass(String name):寻找一个已经加载的类

    4.2 类加载器分类

    在标准的Java程序中,JVM会创建3类加载器为整个应用程序服务,分别是:

    • 启动类加载器:Bootstrap ClassLoader
    • 扩展类加载器:Extension ClassLoader
    • 应用类加载器(也叫系统类加载器):App ClassLoader

    另外,在程序中还可以定义自己的类加载器,从总体看,层次结构如下:

    在这里插入图片描述

    一般来说各个加载器负责的范围如下:

    • 启动类加载器:负责加载系统的核心类,比如rt.jar包中的类
    • 扩展类加载器:负责加载lib/ext/*.jar下的类
    • 应用类加载器:负责加载用户程序的类
    • 自定义加载器:加载一些特殊途径的类,一般是用户程序的类

    4.3 双亲委派

    默认情况下,类加载使用双亲委派加载的模式,具体来说,就是类在加载的时候,会判断当前类是否已经被加载,如果已经被加载,那么直接返回已加载的类,如果没有,会先请求双亲加载,双亲也是按照一样的流程先判断是否已加载,如果没有在此委托双亲加载,如果双亲加载失败,则会自己加载。

    在这里插入图片描述

    在上图中,应用类加载器的双亲为扩展类加载器,扩展类加载器的双亲为启动类加载器,当系统需要加载一个类的时候,会先从底层类加载器开始进行判断,当需要加载的时候会从顶层开始加载,依次向下尝试直到加载成功。

    在所有加载器中,启动类加载器是最特别的,并不是使用Java语言实现,在Java中没有对象与之相对应,系统核心类就是由启动类加载器进行加载的。换句话说,如果尝试在程序中获取启动类加载器,得到的值是null

    System.out.println(String.class.getClassLoader() == null);
    

    输出结果为真。

  • 相关阅读:
    SDN大作业
    个人作业——软件工程实践总结作业
    2019 SDN上机第7次作业
    2019 SDN上机第6次作业
    SDN课程阅读作业(2)
    个人作业——软件评测
    2019 SDN上机第5次作业
    2019 SDN上机第4次作业
    SDN阅读作业
    ThinkPHP3.2.2中开启REWRITE模式
  • 原文地址:https://www.cnblogs.com/6b7b5fc3/p/14729754.html
Copyright © 2011-2022 走看看