zoukankan      html  css  js  c++  java
  • JVM详解(二)-- 第2章 类加载器子系统

    一、JVM内存结构

    1.1 内存结构---概略图

    Alt

    1.2 内存结构--详细图

    Alt text

    二、类加载器子系统的作用

    • 类加载器子系统负责从文件系统或网络中加载.Class文件,文件需要有特定的标识(cafe babe)。
    • ClassLoader只负责.Class文件的加载,至于它是否可以运行,由执行引擎决定。
    • 加载的类信息存放于一块被称为“方法区”的内存空间。除了类信息外,方法区还会存放运行时常量池信息,可能还包括字符串字面量(字面量指的是固定值,初始值)和数字常量(这部分常量信息是.Class文件中常量池部分的内存映射)
    • .Class文件被解析加载到 JVM,类的对象加载到堆区,类信息被加载到方法区(java8 中方法区的实现是“元空间”)。这部分工作是类加载子系统完成的

    三、类加载的过程

    假设定义了一个类,名为HelloWorld,运行其 Main 方法,流程如图:
    Alt text

    3.1 加载(Loading)

    加载(Loading)是狭义上的加载,“类加载”中的加载是广义上的。

    1. 通过一个类的全限定名获取定义此类的二进制字节流;
    2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据机构;
    3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

    加载.class文件的方式

    • 从本地系统中直接加载。
    • 通过网络获取,典型场景:Web Applet。
    • 从zip压缩包中读取,成为日后Jar、war格式的基础。
    • 运行时计算生成,使用最多的是动态代理技术。
    • 有其他文件生成,典型场景是JSP应用。
    • 从专有数据库中提取.class文件,比较少见。
    • 从加密文件中获取,典型的防Class文件被反编译的保护措施。

    3.2 链接(Linking)

    链接可细分为三步:验证(verify)准备(prepare)、解析(resolve)

    3.2.1 验证

    • 目的在于确保class文件的字节流中包含信息符合当前虚拟机要求,保证被加载类的正确性,不会危害虚拟机自身安全。
    • 主要包括四种验证:文件格式验证、元数据验证、字节码验证、符号引用验证。

    3.2.2 准备

    • 为类的变量分配内存并设置默认初始值,即零值(不同数据类型的零值不同,布尔值为false,引用类型为null)。下面这个语句在这个阶段,a将被初始化为0。
    static int a = 2;
    
    • 这里不包含final修饰的static,因为final修饰的变量不再是变量而是不可更改的常量,在编译期就已经分配内存,准备阶段会将其显示初始化。如上面的赋值语句,加上final后,a的值就被初始化为2。
    final static int a = 2;
    
    • 这里不会为实例变量分配内存和初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中。

    3.2.3 解析

    • 将常量池内的符号引用转换为直接引用的过程。
    • 事实上,解析动作往往伴随着 JVM 在执行完初始化之后再执行。

    3.3 初始化(Initialization)

    1. 初始化阶段就是执行类构造方法<clinit>()的过程。
    2. 此方法不需要定义,是javac编译器自动收集类中所有类静态变量的赋值动作和静态代码块的语句合并而来。
      • 只有当有静态变量static int a = 1;或者静态代码块static {}时才会创建并执行该方法。
      • 静态方法并不会使得虚拟机创建执行该方法。
    3. <clinit>()中指令按语句在源文件中出现的顺序执行。具体表现就是一个静态变量的最后的取值决定于最后一行它的赋值语句。
    public class ClassInitTest {
       private static int num = 1;
    
       static{
           num = 2;
           number = 20;
           System.out.println(num);
           //System.out.println(number);//报错:非法的前向引用。
       }
    
       private static int number = 10;  //linking之prepare: number = 0 --> initial: 20 --> 10
    
        public static void main(String[] args) {
            System.out.println(ClassInitTest.num);//2
            System.out.println(ClassInitTest.number);//10
        }
    }
    

    在以上代码中,num和number的最终值分别为2和10。
    4. <clinit>()不同于我们常说的类的构造函数。类的构造函数在虚拟机中是<init>(),在任何时候都是会创建的,因为所有的类都至少有一个默认无参构造函数。
    5. 若该类具有父类,JVM会保证子类的 <clinit>()执行前,父类的 <clinit>()已经执行完毕。
    6. 虚拟机必须保证一个类的 <clinit>()方法在多线程下被同步加锁。保证同一个类只被虚拟机加载一次(只调用一次 <clinit>()),后续其它线程再使用类,只需要在虚拟机的缓存中获取即可。

    四、类加载器的分类

    类加载分为启动类加载器、扩展类加载器、应用程序类加载器(系统类加载器)、自定义加载器。如下图:
    Alt

    需要注意的是,它们四者并非子父类的继承关系。以下展示了如何获取类加载器:

    public class ClassLoaderTest {
        public static void main(String[] args) {
    
            //获取系统类加载器
            ClassLoader systemClassLoader = ClassLoader.getSystemClassLoader();
            System.out.println(systemClassLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2
    
            //获取其上层:扩展类加载器
            ClassLoader extClassLoader = systemClassLoader.getParent();
            System.out.println(extClassLoader);//sun.misc.Launcher$ExtClassLoader@1540e19d
    
            //获取其上层:获取不到引导类加载器
            ClassLoader bootstrapClassLoader = extClassLoader.getParent();
            System.out.println(bootstrapClassLoader);//null
    
            //对于用户自定义类来说:默认使用系统类加载器进行加载
            ClassLoader classLoader = ClassLoaderTest.class.getClassLoader();
            System.out.println(classLoader);//sun.misc.Launcher$AppClassLoader@18b4aac2
    
            //String类使用引导类加载器进行加载的。---> Java的核心类库都是使用引导类加载器进行加载的。
            ClassLoader classLoader1 = String.class.getClassLoader();
            System.out.println(classLoader1);//null
            
        }
    }
    

    关于几种类加载器具体的加载对象和双亲委派机制可以参考:玩命学JVM-类加载机制

    4.1 用户自定义类加载器

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

    1. 隔离加载类。
    2. 修改类加载的方式。除了bootstrap classloader是必须要用到的,其它的类加载器都不必须。
    3. 扩展加载源。
    4. 防止源码泄露。java代码很容易被反编译和篡改。因此有对源码进行加密的需求,在这个过程中就会用到自己定义的类加载器去完成解密和类加载。

    4.1.2 如何自定义类加载器

    步骤

    1. 继承java.lang.ClassLoader的方式,实现自己的类加载器。
    2. 建议不要去覆盖loadClass(),而是重写findClass()
    3. 如果没有太复杂的需求(解密、从不同的路径下加载),那么可直接继承URLClassLoader,这样可以避免自己去编写findClass()方法以及其获取字节码流的方式,使其自定义类加载器编写更加简洁。
      样例代码
    public class CustomClassLoader extends ClassLoader {
        @Override
        protected Class<?> findClass(String name) throws ClassNotFoundException {
    
            try {
                byte[] result = getClassFromCustomPath(name);
                if(result == null){
                    throw new FileNotFoundException();
                }else{
                    return defineClass(name,result,0,result.length);
                }
            } catch (FileNotFoundException e) {
                e.printStackTrace();
            }
    
            throw new ClassNotFoundException(name);
        }
    
        private byte[] getClassFromCustomPath(String name){
            //从自定义路径中加载指定类:细节略
            //如果指定路径的字节码文件进行了加密,则需要在此方法中进行解密操作。
            return null;
        }
    
        public static void main(String[] args) {
            CustomClassLoader customClassLoader = new CustomClassLoader();
            try {
                Class<?> clazz = Class.forName("One",true,customClassLoader);
                Object obj = clazz.newInstance();
                System.out.println(obj.getClass().getClassLoader());
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }
    

    4.2 关于ClassLoader

    它是一个抽象类,其后所有的类加载器都继承自ClassLoader(不包括启动类加载器)。
    API

    1. getParent()
      返回该类加载器的超类加载器。
    2. loadClass(String name)
      加载名称为name的类,返回结果为java.lang.Class类的实例。
    3. findClass(String name)
      查找名称为name的类,返回结果为java.lang.Class类的实例。
    4. findLoaderClass(String name)
      查找名称为name的已经被加载过的类,返回结果为java.lang.Class类的实例。
    5. defineClass(String name, byte[] b, int off, int len)
      把字节数组b中的内容转换为一个Java类,返回结果为java.lang.Class类的实例。与 findClass(String name) 搭配使用
    6. resolveClass(Class<?>c)
      连接一个指定的Java类

    4.3 双亲委派机制

    详见 https://www.cnblogs.com/cleverziv/p/13759175.html
    自定义的一个java.lang.String不能加载到的JVM中,原因:
    使用自定义的java.lang.String时,首先是应用类加载器向上委托到扩展类加载器,然后扩展类加载器向上委托给引导类加载器,引导类加载接收到类的信息,发现该类的路径时“java.lang.String”,这在引导类加载器的加载范围内,因此引导类加载器开始加载“java.lang.String”,只不过此时它加载的是jdk核心类库里的“java.lang.String”。这就是双亲委派机制中的向上委托。在完成向上委托之后,如到了引导类加载器,引导类加载器发现待加载的类不属于自己加载的类范围,就会再向下委托给扩展类加载器,让下面的加载器进行类的加载。
    优势

    1. 避免类的重复加载。类加载器+类本身决定了 JVM 中的类加载,双亲委派机制保证了只会有一个类加载器去加载类。
    2. 保护程序安全,防止核心api被篡改

    沙箱安全机制
    上文中提到的java.lang.String就是沙箱安全机制的表现,保证了对java核心源代码的保护。

    五、几个JVM常出现的术语解析

    5.1 字面量

    首先来看一下百度百科的定义:

    在计算机科学中, 字面量(literal)是用于表达源代码中一个固定值的表示法(notation)。几乎所有计算机编程语言都具有对基本值的字面量表示, 诸如: 整数, 浮点数以及字符串; 而有很多也对布尔类型和字符类型的值也支持字面量表示; 还有一些甚至对枚举类型的元素以及像数组, 记录和对象等复合类型的值也支持字面量表示法.

    这段话不太好理解,我们来拆解下(注意下面这段话纯属个人理解):

    “字面量(literal)是用于表达源代码中一个固定值的表示法(notation)”,这里说明了两点:第一,字面量是体现在源码中的;第二,字面量是对一个固定值的表示。接下来它提到了“几乎所有计算机编程语言都具有对基本值的字面量表示”,并以一些基本数据类型、枚举、数组等数据类型举例。它们都有一个特点,就是它们的赋值是可以做到“代码可视化的”。你可以在代码中给以上提到的类型进行赋值。而我们赋值时所给出的“值”,更准确来说是一种“表示”(比如给数组赋值时,约定了需要用大括号括起来)就是字面量的含义。
    举个例子:

    int i = 1;
    String s = "abs";
    int[] a = {1, 3, 4};
    // 以上 1,“abc”,{1,3,4}均是字面量
    

    5.2 符号引用、直接引用

    同样,先来看一下书面定义:

    符号引用:符号引用以一组符号来描述所引用的目标,符号引用可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。比如org.simple.People类引用了org.simple.Language类,在编译时People类并不知道Language类的实际内存地址,因此只能使用符号org.simple.Language(假设是这个,当然实际中是由类似于CONSTANT_Class_info的常量来表示的)来表示Language类的地址。各种虚拟机实现的内存布局可能有所不同,但是它们能接受的符号引用都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。

    直接引用: 直接引用可以是
    (1)直接指向目标的指针(比如,指向“类型”【Class对象】、类变量、类方法的直接引用可能是指向方法区的指针)
    (2)相对偏移量(比如,指向实例变量、实例方法的直接引用都是偏移量)
    (3)一个能间接定位到目标的句柄
    直接引用是和虚拟机的布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经被加载入内存中了。

    说实话看这种书面化语言抽象晦涩,下面给出一些自己的理解吧。首先找到一个.class文件(来源:玩命学JVM(一))反编译后的结果中常量池的部分:

    Constant pool:
       #1 = Methodref          #6.#15         // java/lang/Object."<init>":()V
       #2 = Fieldref           #16.#17        // java/lang/System.out:Ljava/io/PrintStream;
       #3 = String             #18            // Hello World
       #4 = Methodref          #19.#20        // java/io/PrintStream.println:(Ljava/lang/String;)V
       #5 = Class              #21            // Main
       #6 = Class              #22            // java/lang/Object
       #7 = Utf8               <init>
       #8 = Utf8               ()V
       #9 = Utf8               Code
      #10 = Utf8               LineNumberTable
      #11 = Utf8               main
      #12 = Utf8               ([Ljava/lang/String;)V
      #13 = Utf8               SourceFile
      #14 = Utf8               Main.java
      #15 = NameAndType        #7:#8          // "<init>":()V
      #16 = Class              #23            // java/lang/System
      #17 = NameAndType        #24:#25        // out:Ljava/io/PrintStream;
      #18 = Utf8               Hello World
      #19 = Class              #26            // java/io/PrintStream
      #20 = NameAndType        #27:#28        // println:(Ljava/lang/String;)V
      #21 = Utf8               Main
      #22 = Utf8               java/lang/Object
      #23 = Utf8               java/lang/System
      #24 = Utf8               out
      #25 = Utf8               Ljava/io/PrintStream;
      #26 = Utf8               java/io/PrintStream
      #27 = Utf8               println
      #28 = Utf8               (Ljava/lang/String;)V
    

    为什么只看常量池呢,因为在“解析”的定义中提到了:解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。我们来看看常量池中有什么:

    常量池:常量池指的是字节码文件中的Constant pool部分。它是静态的,当编译生成字节码文件直接就不变了。常量池中包括各种字面量和对类型、域和方法的符号引用。几种在常量池内存储的数据类型包括:数量值、字符串值、类引用、字段引用、方法引用。

    由此我们可以看出,上面我们给出的常量池中都属于“符号引用”(符号引用本身就是一种字面量)或字面量。我们不禁要问了了,那直接引用在哪呢?
    我找到了《深入理解Java虚拟机》中的一句话:

    对同一个符号引用进行多次解析请求是很常见的事情,除invokedynamic指令外,虚拟机实现可以对第一次解析的结果进行缓存(在运行时常量池中记录直接引用,并把常量标识为已解析状态)从而避免解析动作重复进行。

    关键是括号中话给了启发,说明直接引用是放在运行时常量池中的,接下来我们看看运行时常量池的一些定义或特性。

    运行时常量池是方法区的一部分。常量池用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。而加载类和接口到虚拟机后,就会创建对应的运行时常量池。
    运行时常量池与常量池的不同点在于:
    1. 运行时常量池中包括了编译期就已经明确的数值字面量,也包括在运行期解析后才能获得的方法或字段引用。但,请注意,此时的方法或字段引用已经不再是常量池中的“符号引用”,而是“直接引用”。
    2. 运行时常量池具备“动态性”。

    至此,关于“符号引用”和“直接引用”的解释就差不多了。最后我再多说一句,再分析的时候,我一直在疑惑“直接引用”到底是什么,我能不能像看到常量池中的内容一样看到“直接引用”。实际上,我们并不能拿到这样一个文件,里面整齐地写了直接引用的具体内容,因为直接引用不是所谓的“字面量”。但我们可以回到“直接引用”的最初定义:直接引用可以是指向目标的指针、相对偏移量或是能间接定位到目标的句柄,可以想象一下在运行时,在内存中存放的直接引用大概是什么内容。

    六、其它

    JVM 中两个Class对象是否为同一个类的必要条件

    1. 类的完整类名必须一致,包括包名。
    2. 加载这个类的ClassLoader(指ClassLoader实例对象)必须相同。
      对类加载器的引用
      JVM 必须知道一个类型是由启动类加载器加载的还是由用户类加载器加载的,如果一个类型是由用户类加载器加载的,那么 JVM 会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中,当解析一个类型到另一个类型的引用的时候, JVM 需要保证这两个类型的类加载器是相同的。

    类的主动使用和被动使用
    主动使用和被动使用的区别是,主动使用会导致类的初始化。
    主动使用有以下七种情况:

    1. 创建类的实例。
    2. 访问某个类或接口的静态变量,或者对该静态变量赋值。
    3. 调用类的静态方法。
    4. 反射(比如:Class.forName("com,atguigu.Test"))。
    5. 初始化一个类的子类。
    6. Java虚拟机启动时被标明为启动的类。
    7. JDK 7开始提供的动态语言支持:java.lang.invoke.MethodHandle 实例的解析结果 REF_getStatic、REF_putStatic、 REF_invokeStatic句柄对应的类没有初始化则初始化。

    参考文献:
    https://blog.csdn.net/u011069294/article/details/107489721
    https://www.cnblogs.com/cleverziv/p/13751488.html

  • 相关阅读:
    HDU-5534-Partial Tree
    Dire Wolf HDU
    HDU 5119 Happy Matt Friends (14北京区域赛 类背包dp)
    4 Values whose Sum is 0 POJ
    Fliptile POJ
    Face The Right Way POJ
    【Selenium学习】解决chromedriver.exe' executable needs to be in PATH
    【Jenkins学习】修改jenkins显示为中文语言
    【Jenkins学习】Jenkins 批量删除历史构建
    【Tomcat学习】tomcat 日志详解
  • 原文地址:https://www.cnblogs.com/cleverziv/p/13911119.html
Copyright © 2011-2022 走看看