浅谈JVM:类加载机制

Posted 专治八阿哥的孟老师

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了浅谈JVM:类加载机制相关的知识,希望对你有一定的参考价值。

上一篇:
浅谈JVM(一):Class文件解析

类加载机制

​ Java虚拟机把类的描述数据从Class文件加载到内存,并对数据进行校验、转换解析、初始化,最终形成可以被虚拟机直接使用的Java类型,这个过程就是虚拟机的类加载机制。

《Java虚拟机规范》中说:JVM能动态地加载(Loading)、连接(Linking)、初始化(Initializing)类和接口。

  • 加载是查找具有特定名称的类或接口类型的二进制表示,并根据该二进制表示创建类或接口的过程。

  • 连接是获取类或接口并将其组合到JVM的运行时状态中以便执行的过程。

  • 初始化包括执行类或接口初始化方法,是编译后自动生成的方法。

​ 虚拟机规范中使用的名词是"二进制表示"(binary representation),它并非只限定为磁盘中的.class文件,而可以理解为是一个二进制字节流,这个二进制字节流可以从压缩包中获取、通过动态代理生成、通过网络获取等。为了简化描述,统一使用Class文件指代。

​ 此外,虚拟机规范中多数时候的用词是"类或接口"(a class or an interface),多数时候类和接口的描述是一样的,所以我们统一简化用"类"描述,如果class和interface描述不同时,会再区分类和接口。

​ 虚拟机规范中没有指明如何具体实现类加载过程,不同虚拟机在具体实现上有所差异,本文为以HotSpot虚拟机为例进行阐述。

2.1.加载

​ 类加载过程(Class Loading)和加载(Loading)是不一样的概念,类加载包含加载、连接、初始化三个阶段,加载只是类加载过程的第一个阶段。加载过程与类加载器(class loader)密切相关,将在下一篇文章中介绍类加载器详细内容。

非数组类加载动作是由类加载器完成的,在加载阶段,完成三件事:

  1. 通过类的全限定名获取定义该类的二进制字节流。

  2. 将这个字节流所代表的静态存储结构转化为方法区(Method Area)的运行时数据结构(Run-Time Data)。

  3. 在内存中生成代表该类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

​ 数组类情况有所不同(数组类型如TestArrayClass[ ]),数组类本身不通过类加载器创建,它是由Java虚拟机直接在内存中动态构造出来的。但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型(指数组去掉维度后的类型,如TestArrayClass)还是要靠类加载器加载。数组加载规则如下:

  1. 如果数组元素类型是引用类型,那就递归采用本节中定义的加载过程去加载这个组件类型,数组将被标识在加载该组件类型的类加载器的类名称空间上。

  2. 如果数组的组件类型不是引用类型(例如int[]数组的组件类型为int),Java虚拟机将会把数组标记为与引导类加载器关联。

  3. 数组类的可访问性与它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类的可访问性将默认为public,可被所有的类和接口访问到。

加载与连接的部分动作可能是交叉进行的,加载尚未完成连接可能已经开始。

2.2.连接

连接阶段又分为验证(Verification)、准备(Preparation)、解析(Resolution)三个阶段。

​ 验证阶段的目的是要保证Class文件的信息符合虚拟机规范,保证代码运行后不会危害虚拟机自身安全。当字节流信息不符合虚拟机的Class格式规范时,会抛出一个VerifyError。验证阶段可能触发其他相关类的加载动作,但不需要对它们进行验证。

验证阶段大致包含以下四种验证:

  1. 文件格式验证:如魔数、版本号、常量池等。

  2. 元数据验证:对字节码数据进行语义分析,如继承关系(除java.lang.Object外所有类都要有父类、不能继承final类)、非抽象类是否实现了父类或接口的所有要求实现的方法、方法重载是否正确等。

  3. 字节码验证:对方法体(Class文件中的Code属性)进行校验分析,比如父类对象赋值给子类类型、操作数与操作指令不匹配等。

  4. 符号引用验证:验证该类是否缺少或被禁止访问某些外部类、方法、字段。如根据字符串描述的类的全限定名能否找到该类,类、字段、方法的访问权限(private、protected、public、default)等。

​ 准备阶段为类变量(static修饰的,不包括实例变量)分配内存并赋默认值。注意这一阶段的赋值不是初始值,而是默认零值。

//准备阶段赋值成0
public static int value = 123;

​ 上述代码在准备阶段过后,value的初始值是0。真正赋值成123的动作要在初始化阶段完成。赋值指令会被编译到""方法里,在初始化阶段执行。

特殊情况是变量由final修饰,在准备阶段就会赋值。

//准备阶段就赋值成123
public static final int value = 123;

​ 解析阶段将虚拟机常量池中的符号引用替换成直接引用(常量池见浅谈JVM(一):Class文件解析)。符号引用是用一组符号(任意形式字面量)来描述所引用的目标,和虚拟机布局无关;直接引用和虚拟机布局直接相关,是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。如描述符是"Ljava/lang/Integer"对应的就是"java.lang.Integer"类。

2.3.初始化

​ 初始化阶段会执行类构造方法"“,此处的"类构造方法"不是构造器(Constructor),而是由编译器自动生成的方法,自动收集类中所有类变量的赋值动作和静态代码块(static块),合并产生名为”“的方法。而创建对象的构造方法会被编译成”"方法。

package com.menglaoshi.test;
public class TestClass01     
    static         
        i = 2;//可以编译通过    
        
    public static int i = 1;

上述代码编译后如下:

​ 编译器对于类变量和静态语句中的语句进行合并,根据语句在源文件中的顺序进行收集,静态代码块可以对定义在它之后的变量进行赋值,但不能访问,如下面的代码就不能编译通过。

​ 类和接口初始化有所不同,类的方法在执行时,不需要显式调用父类的,虚拟机会保证子类执行时,父类已经执行完毕。如果类中没有静态语句,则不会产生方法;接口在初始化时,并不要求其父接口全部完成初始化,只有真正使用到父接口的时候才会初始化。

​ 虚拟机要保证一个类的方法在多线程情况下被正确地加同步锁,如果多个线程同时去初始化一个类,那么只会有其中一个线程去执行这个类的,其他线程都要阻塞,且其他线程唤醒后也不会再次执行,同一个类加载器下,一个类型只会初始化一次。

​ 虚拟机规范没有明确说明什么时候进行加载和连接,但明确规定了哪些情况下类和接口会初始化,而加载和连接必须在初始化之前完成。

  1. 遇到new、getstatic、putstatic或invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段。能够生成这四条指令的典型Java代码场景有:
  • 使用new关键字实例化对象的时候。
  • 读取或设置一个类型的静态字段的时候(被final修饰、已在编译期把结果放入常量池的静态字段除外)。
  • 调用一个类型的静态方法的时候。
  1. 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要先触发其初始化。
  2. 当初始化类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。
  3. 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
  4. 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化。
  5. 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

​ 对于这六种会触发类型进行初始化的场景,虚拟机规范中使用了一个非常强烈的限定语——“有且只有”,这六种场景中的行为称为对一个类型进行主动引用。除此之外,所有引用类型的方式都不会触发初始化,称为被动引用。

​ 通过子类引用父类静态字段,不会导致子类初始化,如下:

package com.menglaoshi.test;
public class Father 
    static 
    	System.out.println("Father init");
    
    public static int value = 123;

public class Sun extends Father
	static 
    	System.out.println("Sun init");
    

public class TestClass 
	public static void main(String[] args) 
    	//只会输出Father init和value值,子类不会初始化     
        System.out.println(Sun.value);    
    

​ 定义数组引用不会导致类的初始化,将main方法修改如下:

public static void main(String[] args)     Father[] fathers = new Father[10];

​ 运行后可以看到Father的静态代码块没有执行,查看编译后的代码:

​ 可以看到初始化的类是"**[**Lcom/menglaoshi/test/Facher;“,前面由一个”["代表这是一个数组,这并不是一个合法存在的类型,它是一个由虚拟机自动生成的、直接继承于java.lang.Object的子类,创建动作由字节码指令newarray触发。

public class ConstantClass     
    static         
        System.out.println("ConstantClass init"); 
        
    public static final String value = "a";

//测试类
public class TestClass     
    public static void main(String[] args)    
        System.out.println(ConstantClass.value);    
    

​ 运行上述代码,发现没有输出"ConstantClass init"。因为常量value的值在编译阶段通过常量传播优化,直接存在了测试类TestClass的常量池中。

浅谈JVM-图解类加载机制

一、目录

二、类加载机制流程

1、什么是类加载机制?

  JVM把class文件加载到内存里面,并对数据进行校验、准备、解析和初始化,最终能够被形成被JVM可以直接使用的Java类型的过程。

2、类加载流程图

3、加载

  1. 将class文件加载在内存中。
  2. 将静态数据结构(数据存在于class文件的结构)转化成方法区中运行时的数据结构(数据存在于JVM时的数据结构)。
  3. 在堆中生成一个代表这个类的java.lang.Class对象,作为数据访问的入口。

4、链接

 链接就是将Java类的二进制代码合并到java的运行状态中的过程。

  • 验证:确保加载的类符合JVM规范与安全。
  • 准备:为static变量在方法区中分配空间,设置变量的初始值。例如static int a=3,在此阶段会a被初始化为0,其他数据类型参考成员变量声明。
  • 解析:虚拟机将常量池的符号引用转变成直接引用。例如"aaa"为常量池的一个值,直接把"aaa"替换成存在于内存中的地址。
    • 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用 的目标并不一定已经加载到内存中。
    • 直接引用:直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,如果有了直接引用,那么引用的目标必定已经在内存中存在。

5、初始化

  初始化阶段是执行类构造器<clinit>()方法。在类构造器方法中,它将由编译器自动收集类中的所有类变量的赋值动作(准备阶段的a正是被赋值a)和静态变量与静态语句块static{}合并,初始化时机后续再聊。

6、使用

  正常使用。

7、卸载

  GC把无用对象从内存中卸载。

三、类加载与初始化时机

1、类加载时机

  当应用程序启动的时候,所有的类会被一次性加载吗?估计你早已知道答案,当然不能,因为如果一次性加载,内存资源有限,可能会影响应用程序的正常运行。那类什么时候被加载呢?例如,A a=new A(),一个类真正被加载的时机是在创建对象的时候,才会去执行以上过程,加载类。当我们测试的时候,最先加载拥有main方法的主线程所在类。

2、类初始化时机

 主动引用(发生类初始化过程)

  1. new一个对象。
  2. 调用类的静态成员(除了final常量)和静态方法。
  3. 通过反射对类进行调用。
  4. 虚拟机启动,main方法所在类被提前初始化。
  5. 初始化一个类,如果其父类没有初始化,则先初始化父类。

 被动引用(不会发生类的初始化)

  1. 当访问一个静态变量时,只有真正声明这个变量的类才会初始化。(子类调用父类的静态变量,只有父类初始化,子类不初始化)。
  2. 通过数组定义类引用,不会触发此类的初始化。
  3. final变量不会触发此类的初始化,因为在编译阶段就存储在常量池中。

四、图解分析类加载

 1 public class ClassLoaderProduce {
 2     static int d=3;
 3     static{
 4         System.out.println("我是ClassLoaderProduce类");
 5     }
 6     public static void main(String [] args){
 7         int b=0;
 8         String c="hello";
 9         SimpleClass simpleClass=new SimpleClass();
10         simpleClass.run();
11     }
12 }
13 
14 class SimpleClass{
15     static int  a=3;
16     static{
17         a=100;
18         System.out.println(a);
19     }
20 
21     public SimpleClass(){
22         System.out.println("对类进行加载!");
23     }
24 
25     public void run(){
26         System.out.println("我要跑跑跑!");
27     }
28 }

步骤一:装载ClassLoaderProduce类,在方法区生成动态数据结构(静态变量、静态方法、常量池、类代码),并且在堆中生成java.lang.Class对象;然后进行链接

步骤二:初始化:把static{}与静态变量合并存放在类构造器当中,对静态变量赋值。 1-5行执行完毕。

步骤三:执行main方法,首先在栈里面生成一个main方法的栈祯,定义变量b、c,注意此处的变量b、c存储的常量池存储的变量的地址,如图所示。

步骤四:创建SimpleClass对象;跟上面步骤类似:加载-链接-初始化。然后,调用run()方法的时候,它会通过classLoader局部变量的地址寻找到类的class对象并且调用run()方法

以上是关于浅谈JVM:类加载机制的主要内容,如果未能解决你的问题,请参考以下文章

java类加载机制

JVM类加载机制

JVM 类加载机制

JVM——类加载机制

JVM类加载机制

面试必问:JVM类加载机制详细解析