u04-类的加载

141 阅读18分钟

1. 运行三部曲

概念: 一个java代码可以执行,需要经过三个步骤:

  • 编写:在硬盘上编写java代码,需要符合Java开发规范,是我们的核心工作。
  • 编译:通过javac命令将java代码编译成一个或多个class文件。
    • 编译的底层过程主要是类型和格式的检查,如:Person.java -> 词法分析器 -> 语法分析器 -> 语义分析器 -> 字节码生成器 -> Person.class
    • 字节码包括class文件相关信息,java源码中的声明和常量信息(元数据),源码中的语句和表达式等。
    • JVM和Java语言本身没什么关系,JVM只和class文件有关系,其他语言也能具有class文件。
  • 运行:运行字节码文件中的内容。
    • 加载:将class文件中的字节码从硬盘上加载到JVM内存中,准备进行操作。
    • 解释:逐行执行。

2. 类加载器

概念: 类加载器ClassLoader的主要工作就是将.class文件中的字节码加载的JVM内存中。

  • 类加载器分类:级别自上而下
    • 启动类加载器 BootstrapClassLoader:用C++语言实现,是JVM的一部分,负责加载 %JAVA_HOME%\jre\lib 中的class文件和其他类加载器。
    • 拓展类加载器 ExtClassLoader:由Java语言实现,独立于JVM外部,继承自 ClassLoader,负责加载 %JAVA_HOME%\jre\lib\ext 中的class文件。
    • 系统类加载器 AppClassLoader:由Java语言实现,独立于JVM外部,继承自 ClassLoader,负责加载 classpath 下的class文件。
    • 自定义类加载器 UserClassLoader:自己定义的类加载器,JVM规范中并没有规定要从什么地方加载字节码,所以我们可以通过自定义类加载器的方式加载任何地方的字节码。

只有在代码中被使用到的类才会被加载。

2.1 双亲委派模型

概念:

  • 重复加载问题:类加载器都在自己独立的空间内进行加载工作,如果我在classpath下写一个 java.lang.Object 类的话:
    • AppClassLoader 会加载我自己写的Object类。
    • BootstrapClassLoader 会加载 %JAVA_HOME%\lib\rt.jar 中的Object类。
    • 结果内存中出现两个Object类,发生错误,为了解决这个问题,JVM引入了双亲委派模型。
  • 双亲委派模型 Parent Delegation Model:除了启动类加载器外,所有的加载器都有一个父加载器,当你一个加载器接到请求的时候,不会马上加载类,而是将这个请求向上传递给他的父加载器,看父加载器能不能加载这个类,如果全都不行,自己再出手。

双亲委派模型理解:如果父类能干,我就不干了,如果父类不能干,我就自己干。

2.2 类加载器的执行顺序

概念: 我们可以在main方法(不要使用junit测试)的运行过程中使用 -verbose:class 命令来详细地看一下各个ClassLoader执行加载的情况。

流程:

  1. run - Edit Configurations
  2. VM options 一栏填入 -verbose:class
  3. 运行代码,控制台查看内容。

源码: /javase-oop/

  • src: c.y.classloader.ClassLoaderTest
/**
 * @author yap
 */
public class ClassLoaderTest {
    public static void main(String[] args) {
        new ClassA();
        System.out.println("main...");
        new ClassB();
        new ClassC(); new ClassC();
        new ClassD(); new ClassD();
        System.out.println(Scanner.class);
    }
}

class ClassA { }

class ClassB { }

class ClassC {
    static { System.out.println("classC-static-block..."); }
}

class ClassD {
    static { System.out.println("classD-dynamic-block..."); }
}

3. 类的加载过程

概念: 类加载的过程最终会形成一些可以被JVM直接使用的Java类型,这个过程只有在运行期才会触发,所以会牺牲一些程序启动的性能,但是却可以提供更高的灵活度。

  • 类加载必经的五个过程:
    • 加载:在JVM的方法区中,创建一个对应的instanceKlass区域,然后将class文件中的字节码内容逐行加载对应的instanceKlass中,然后在堆中创建一个对应instanceKlass的Class对象(称为instanceKlass的java镜像)中。
    • 连接-校验:检查Class对象中的代码是否正确。
    • 连接-准备:为类的静态属性和方法分配内存,并设置初始值(final修饰的直接赋真正值)。
    • 连接-解析:将对应的instanceKlass区域中的符号引用转成直接引用。
    • 初始化:为类的静态属性赋真正的值,执行静态块。
  • 类加载的触发条件,假设我是一个类,那么:
    • 有人new我的时候,我会被加载,连接,初始化。
    • 有人访问我的静态成员(属性和方法)的时候,我会被加载,连接,初始化。
    • 有人反射,克隆,反序列化我的时候,我会被加载,连接,初始化。
    • 我的子类被加载,连接,初始化的时候,我会先被加载,连接,初始化。
    • 如果我拥有main方法,我会被加载,连接,初始化。
  • 一个类在每次被使用前,都会先检查是否被加载,连接,初始化过:
    • 如果已经被加载,连接,初始化过,则直接使用。
    • 如果仍未被加载,连接,初始化过,则先去执行加载,连接,初始化。

方法和属性不同,方法无论静态与否,在instanceKlass中只有存有一份,无论该类有多少个实例,都共同调用这一份。

3.1 加载Loading

概念: 加载过程就是将class文件中的字节码 bytecode 一行一行读入JVM内存中的方法区,但不会执行。

  • 加载过程的本质是通过类全名来获取定义这个类的二进制字节流,字节码文件中的静态存储结构,被这个流传送到方法区中,并最终转换成为JVM支持的运行时数据结构,这部分的工作都全部由类加载器完成。
  • 加载的字节码会被封装到方法区中的一个 instanceKlass 对象中保存起来(包括常量池,字段,方法等),这个对象不对外暴露,所以JVM会在堆内存中再创建一个 instanceKlass 对应的 java.lang.Class 实例作为方法区中 instanceKlass 的访问入口,这个Class实例的堆内存地址也会保存到方法区中的 instanceKlass 对象中以作关联。
  • JVM在加载数组的时候为了提高效率,减少重复加载(数组中类型统一且固定),加载的是数组的类型,多维数组也是递归加载类型,直到发现非数组类型停止,而数组的创建由JVM直接完成。
  • 基本数据类型和引用数据类型的加载过程没有差别,因为在编译阶段,基本数据类型就会被封装成对应的包装类。

3.2 连接Linking

概念: 连接又分为三个步骤:验证、准备和解析:

  • 验证 Verification:检查被加载的类是否有正确的内部结构,包括代码是否正确,类与类之间的关系是否合理等。
    • 编译时期的检查,主要负责过滤一些编写出来就已经报错的代码。
    • 运行时期的验证,主要负责过滤一些只有在程序运行起来之后才会报错的代码。
  • 准备 Preparation:为类的静态属性和方法分配内存,并设置初始值,以保证在不赋值的情况下也可以正确使用。
  • 解析 Resolution:将类的二进制数据中的符号引用替换为直接引用(符号引用也会保留)。
    • 符号引用是在 class文件中 使用一组任何形式的字面量符号来表示我和你的关系,比如全路径名,方法名称等,只要是能无歧义地定位到目标即可。
    • 在编译时期,由于类还没有被加载到内存中,类的实例也没有被创造出来,并没有实际的内存地址,所以类与类的关系只能用一些符合JVM规范格式的符号来表示,比如"A中引用了B","B中引用了C"等,不同的JVM实现的内存布局可能不一样。
    • 直接引用是在 JVM内存中 使用内存地址的形式来表示我和你的关系,一个类拥有了内存地址就说明它一定被成功的加载到了内存中,直接引用可以是直接指向目标的指针,还可以是一个可以间接定位到目标的句柄。

解析流程图

image.png

3.3 初始化Initialization

概念: 对静态成员进行赋真正的值,执行静态块(如果有的话)。

  • 如果我被初始化了,内部的执行顺序是:静态变量 -> 静态块。
  • 如果我有父类,且我被初始化了,内部的执行顺序是:父类初始化 -> 我初始化。
  • 在一个类加载器中,类只能初始化一次。

4. 类的使用过程

4.1 实例的创建方式

概念:

  • 类加载完成之后,才允许根据类来new对应的实例,在new的时候会使用到方法区中对应instanceKlass对象的数据信息。
  • new的过程就是为实例分配一块连续的堆内存空间,方式有两种:
    • 指针碰撞:分配内存空间包括开辟一块新的内存和移动指针两个步骤。
    • 空闲列表:分配内存空间包括开辟一块新的内存和修改空闲列表两个步骤。
  • new实例的过程是非原子的,可能会出现并发问题(两个线程争抢到同一块内存),JVM采用CAS乐观锁的方式来解决:
    • 赵四为某个实例进行new操作,申请到一块内存0x9527,开始操作,查看版本号为000。
    • 刘能为某个实例进行new操作,申请到同块内存0x9527,开始操作,查看版本号为000。
    • 赵四操作完成,再次查看版本号,仍为000,提交操作,将版本号更改为001。
    • 刘能操作完成,再次查看版本号,发现版本号变为了001,放弃这次操作,去申请其他内存。
    • 最终结果永远不会有两个线程申请到同块内存。

除了CAS乐观锁的方式可以解决并发问题,JVM还提供了一种本地线程缓冲内存的方式,即每个线程在Java堆中都先提前分配一小块内存区域,称为本地线程分配缓冲T-LAB,各线程给实例分配内存时会在该线程的T-LAB上进行分配,这样各个线程即可互不影响。

4.2 实例的存储方式

概念: 在JVM中,实例在内存中的存储也是很有规律的,存储的布局可以分为三块区域:

  • 对象头区 Object Header
    • 8字节的MarkWord:包括自身的运行时数据和运行时数据,HashCode信息,GC中分代年龄信息,和锁的状态信息等。
    • 4字节的类型指针:指向方法区的类型实例内存地址的一个指针。
    • 如果是数组对象,还多了一个数组长度。
  • 实例数据区 Instance Data
    • 存储真正有效的数据,即在程序中定义的各种类型的字段数据,这部分数据有一部分是从父类中继承下来的,也有在子类中定义的,总之都要被记录下来。
    • 各字段的分配策略为long/double,int、short/char,byte/boolean,相同宽度的字段总是被分配到一起,便于之后取数据。
  • 对齐填充区 Padding:对齐填充并不一定是必然存在的,因为HotSpot虚拟机内存管理的要求是给实例分配内存的大小必须是8字节的整数倍,所以不够的部分需要填充。又因为对象头部分正好是 8 字节的倍数,所以对齐填充实际上补全的是实例数据区域,对齐填充的数据并没有特殊的含义,仅仅是起到填充占位符的作用。
  • 可以使用JOL(Java Object layout)工具来查看实例在内存中的布局,需要提前引入jol-core.jar包。

源码: /javase-oop/

  • src: c.y.classloader.JavaObjectLayoutTest
package com.yap.classloader;

import org.junit.Test;
import org.openjdk.jol.info.ClassLayout;

/**
 * @author yap
 */
public class JavaObjectLayoutTest {
    @Test
    public void jol(){
        Object obj = new Object();
        /*
         * line1 - line2 is mark-word message...
         * line3 is class pointer...
         * line4 is padding...
         */
        System.out.println(ClassLayout.parseInstance(obj).toPrintable());
    }

}

4.3 实例的调用方式

概念: 在不同的虚拟机中,对象的访问方式也是不同的,主流的访问方式有使用句柄和直接指针两种。

  • 句柄池方式:是一种间接使用指针访问实例的方式,因为它需要先在Java堆中划分出一块内存区域作为句柄池,栈中的变量存储的是句柄池中稳定的句柄的地址,而句柄中包含的才是Java堆中的实例和对应的java.lang.Class各自的具体地址。
    • 使用句柄访问方式的最大好处是栈中的变量存储的是稳定的句柄地址,在实例被移动时只会改变句柄中的类型数据指针,而栈中变量本身不需要被修改。
  • 直接指针方式:栈中的变量直接存储的是实例的地址。
    • 使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的的时间开销。
    • HotSpot实现中采用的是本方式。

图:句柄池调用流程图

image.png

4.4 方法的调用过程

概念: 方法调用都是发生在栈里,一个方法对应一个栈帧。

源码: /javase-oop/

  • src: c.y.classloader.UserTest
/**
 * @author yap
 */
public class UserTest{
    public static void main(String[] args){
        User user = new User();
        user.methodA();
    }
}

class User{
    private String name;
    void methodA(){
        methodB();
    }
    void methodB(){
        System.out.println("b");
    }
}

总结: UserTest.java 经历了如下流程:

  1. main方法压入栈中。
  2. JVM发现new关键字,将new变成一个字节码指令。
  3. 检查UserTest类的常量池中是否有User类的符号引用,以判断这个类是否存在。
  4. 检查User类是否被加载,连接、初始化过,如果没有重新加载。
  5. 为user实例分配一块连续的堆内存空间。
  6. 为user实例中的属性name分配初始默认值null,保证了user可以不赋初始值就直接使用。
  7. 对user实例进行必要的对象头信息设置,比如GC分代年龄,类型指针,HashCode等。
  8. methodA()入栈,压在main()上。
  9. methodB()入栈,压在methodA()上。
  10. methodB()执行完毕,弹出。
  11. methodA()执行完毕,弹出。
  12. main()执行完毕,弹出,程序结束,JVM退出。

image.png

5. 字节码文件

概念: 源代码中的各种变量,关键字和运算符号的语义最终都会编译成多条字节码命令,并封装在一个 ByteCodeTest.class 文件中,其中的16进制代码可以使用linux命令打开查看:

  1. 编译源文件生成字节码文件:
    • 在IDEA中右键 ByteCodeTest.java 文件,选择 Recomplie
  2. 找到字节码文件所在目录,右键进入Git命令行。
    • 在IDEA中右键 ByteCodeTest.class 文件,选择 Show in Explorer
  3. 键入命令 vim ByteCodeTest.class 发现乱码。
  4. 键入命令 :%!xxd,成功查看16进制内容,查看结束后使用 :wq 保存退出。
    • cafe babe:前4个字节为魔数,只有以 cafe babe 开头的class文件方可被虚拟机所接受,这样的设计可以恶意避免篡改文件后缀带来的安全隐患。
    • 0000:JDK小版本号,转为10进制是0。
    • 0034:JDK大版本号,转为10进制是52,JDK的1.1版本的数值是45,推算下来,52表示JDK1.8。
    • 继续往下是常量池内容,后文再做分析。

一个十六进制的数只占0.5个字节。

源码: /javase-oop/

  • src: c.y.classloader.ByteCodeDemo
/**
 * @author yap
 */
public class ByteCodeDemo {
    private int num;

    public int method() {
        return num++;
    }
}

6. 反解析字节码指令

概念: javap.exe 是jdk自带的反解析工具,它的作用就是根据class字节码文件,反解析出当前类对应的code区(汇编指令)、本地变量表、异常表和代码行偏移量映射表、常量池等等信息。

  • CMD反解析:使用命令 javap <options> <classes>
    • <options>:反解析的选项,可以使用 javap -help 来查看。
    • <classes>:你要反编译的.class文件。
  • IDEA反解析方式:
    • view - bytecode 来查看文件的字节码。
    • 需要先运行过一次生成字节码文件之后才可以查看。

6.1 基本信息指令

概念: 基本信息指令包括类文件的基本信息,JDK版本号,类的访问标识等,类的访问标志有如下几种助记符:

  • ACC_PUBLIC (0x0001):被public修饰。
  • ACC_FINAL (0x0010):被final修饰。
  • ACC_SUPER (0x0020):允许使用Invokespecial指令。
  • ACC_INTERFACE (0x0200):是一个接口。
  • ACC_ABSTRACT (0x0040):是抽象的,包括接口和抽象类。
  • ACC_SYNTHETIC (0x1000):这个类并非由用户代码产生。
  • ACC_ANNOTATION (0x2000):是一个注解类。
  • ACC_ENUM (0x4000):是一个枚举类。

字节码: 字节码文件前8行解析

  • Classfile ...ByteCodeTest.class:标识class文件当前所在位置
    • Last modified 2019-9-29:标识文件最后修改日期
    • size 309 bytes:标识文件字节数
    • MD5 checksum:标识MD5值
    • Compiled from "*.java":标识这个class文件是编译自哪个文件
  • public class ...ByteCodeTest:类的全限定名
    • minor version:JDK次版本号
    • major version:JDK主版本号
    • flags:类的访问标志

6.2 常量池指令

概念: 常量池 Constant pool 中标识的是常量池信息,可以理解成Class文件中的资源仓库,主要存放的是两大类常量:

  • 字面量:先天常量。
  • 符号引用:类和接口的全限定名、字段的名称和描述符,方法的名称和描述符等。

字节码: 常量池内容解析

  • #1 = Methodref #4.#18 //java/lang/Object."<init>":()V
    • 常量池中的第1个常量是一个方法,引用了第4个和第8个常量,最终可以拼成后面的注释内容。
    • <init>:()V:无参构造器,V 表示 void,在字节码中构造器也被标记为 void 方法。
  • #2 = Fieldref #3.#19 //com/joezhou/classload/ByteCodeTest.num:I
    • 常量池中的第2个常量是一个属性,引用了第3个和第19个常量,最终可以拼成后面的注释内容。
    • I 表示该属性为int类型,类似的还有:
      • B(byte) / S(short) / I(int) / J(long)
      • F(float) / D(double) / Z(boolean) / C(char)
      • L(对象类型):以分号结尾,如 Ljava/lang/Object;
      • [(数组类型):以分号结尾,如 [L/java/lang/String;
      • [[(二维数组):以分号结尾,如 [[L/java/lang/String;
  • #3 = Class #20 //com/joezhou/classload/ByteCodeTest
    • 常量池中的第3个常量是一个类,引用了第20个常量,最终可以拼成后面的注释内容。
    • 这里就是类的符号引用。
  • #5 = Utf8 num
    • 常量池中的第5个常量是一个字符串。
  • #18 = NameAndType #7:#8 //"<init>":()V
    • 常量池中的第18个常量是一个变量名和变量类型拼成的字符串常量,引用了第7个和第8个常量,最终可以拼成后面的注释内容。

6.3 方法代码指令

概念: 常量池内容之后的是方法描述信息,在字节码中被放在一个 {} 集合里面,其中比较重要的概念有:

  • stack:最大操作数栈深度,JVM运行时会根据这个值来分配栈帧中的操作栈深度。
  • locals:局部变量表大小,catch块中的异常变量,方法参数,this,局部变量等全存这里。
    • 局部变量表的单位为Slot(4byte),Slot是JVM为局部变量分配内存时所使用的最小单位。
    • locals的大小并不一定等于所有局部变量所占的Slot之和,因为局部变量中的Slot是可以重用的。
  • args_size:方法参数个数,包括隐藏参数this。
  • LineNumberTable:行号表,每个方法都有一个行号表,记录了字节码偏移量(源码行号与字节码行号之间的对应关系)
    • line 1:0:源码中的1号位置代码对应的是字节码中的0号位置的指令。
  • LocalVariableTable:局部变量表,每个方法都有一个局部变量表,描述帧栈中局部变量与源码中定义的变量之间的关系。
    • Start:该局部变量从字节码的哪一行开始可见。
    • Length:该局部变量在多少行字节码指令之内可见。
    • Slot:该局部变量在局部变量表的几号位置。
    • Name:该局部变量的名称。
    • Signature:该局部变量的类型。

字节码: 方法代码内容解析

  • public ...ByteCodeTest();:构造器
    • descriptor: ()V:描述这个构造器为无参无返回值。
    • flags: ACC_PUBLIC:描述这个构造器为public修饰。
    • stack=1, locals=1, args_size=1:最大操作数栈深1,局部变量表大小为1Slot,方法参数1个。
      • 0: aload_0:字节码第0行指令,将第1个引用数据类型局部变量推到栈顶。
      • 1: invokespecial #1:字节码第1行指令,以构造方法的形式调用常量池的第一个常量。
      • 4: return:字节码第4行指令,返回。
    • LineNumberTable: line 6: 0:源码中第6行执行的是字节码中的第0行指令。
    • LocalVariableTable:Start=0 Length=5 Slot=0 Name=this Signature=L...
      • ByteCodeTest类型的this变量处于局部变量表的0号位,从字节码第1行开始,5行之内都可见。