转载

Java类文件结构与类加载机制

前言

本文是对«深入理解Java虚拟机»第六章第七章以及其他博客的总结, 权做笔记~

正文

一. 类文件结构

Class 文件是一组以 8 位字节为基础单位的二进制流, 当遇到需要占据 8 位字节以上空间的数据项时, 则会按照高位在前的方式分割成若干个 8 位字节进行存储

Class 文件格式采用一种类似于 C 语言结构体的伪结构来存储数据, 这种伪结构中只有两种数据类型: 无符号数和表; 无符号数是基本数据类型, 以 u1 , u2 , u4 , u8 来分别代表 1 个字节, 2 个字节, 4 个字节和 8 个字节的无符号数, 无符号数可以用来描述数字, 索引引用, 数量值或者按照 UTF-8 编码构成字符串值; 表由多个无符号数或者其他表作为数据项构成的复合数据类型, 以 _info 结尾

由于 Class 文件中没有分隔符, 所以哪个字节代表什么含义, 长度是多少, 先后顺序如何, 都被严格限定且不允许改变

Class 文件格式见下表

类型 名称 数量
u4 magic 1
u2 minor_version 1
u2 major_version 1
u2 constant_pool_count 1
cp_info constant_pool constant_pool_count-1
u2 access_flags 1
u2 this_class 1
u2 super_class 1
u2 interfaces_count 1
u2 interfaces interfaces_count
u2 fields_count 1
field_info fields fields_count
u2 methods_count 1
method_info methods methods_count
u2 attributes_count 1
attribute_info attributes attributes_count

1.1 简介

  1. 魔数: 每个 Class 文件头 4 个字节称为魔数, 唯一作用是确定该文件是否是一个能被虚拟机接受的 Class 文件

  2. 版本号: 紧接着魔数的四个字节存储的是 Class 文件的版本号: 第 5 和第 6 个字节是次版本号( Minor Version ), 第 7 和第 8 个字节是主版本号( Major Version ); 虚拟机必须拒绝执行超过其版本号的 Class 文件

  3. 常量池: 紧接着主版本号之后的是常量池入口, 是一个表类型数据项; Class 文件中只有常量池的容量计数是从 1 开始的; 常量池中主要存放两大类常量: 字面量和符号引用

final
  1. 访问标志: 常量池结束之后, 紧接着的两个字节代表访问标志; 用于表示一些类或者接口的层次信息

  2. 类索引, 父类索引和接口索引集合: 用于确定类的继承关系; 除了 java.lang.Object 之外, 所有的 Java 类都有父类, 因此除了 java.lang.Object 外, 所有 Java 类的父类索引都不为 0

  3. 字段表: 描述接口或者类中声明的变量; 字段包括类级变量以及实例级变量, 但不包括方法内部声明的局部变量

  4. 方法表: 包括访问标志, 名称索引, 描述符索引, 属性表集合; 如果父类方法在子类中没有被重写, 方法表集合中就不会出现来自父类的方法信息, 但是, 同样的, 有可能会出现由编译器自动添加的方法, 最典型的便是类构造器 <clinit> 方法和实例构造器 <init> 方法

  5. 属性表: Java 虚拟机运行时会忽略掉它不认识的属性

1.2 常见问题

  1. Java 程序中如果定义了超过 64KB 英文字符的变量或方法名, 将无法编译:

由于 Class 文件中方法, 字段等都需要引用 CONSTANT_Utf8_info 型常量来描述名称, 所以 CONSTANT_Utf8_info 型常量的最大长度也就是 Java 中方法, 字段名的最大长度; u2 类型能表达的最大值是 65535 , 所以 Java 程序中如果定义了超过 64KB 英文字符的变量或方法名, 将无法编译

二. 虚拟机类加载机制

2.1 类加载的时机

类从被加载到虚拟机内存中开始, 到卸载出内存为止, 其生命周期如下图:

Java类文件结构与类加载机制

其中, 加载, 验证, 准备, 初始化和卸载这五个阶段的顺序是确定的, 解析阶段则不一定: 在某些情况下, 可以在初始化阶段之后再 开始 (注意, 是开始, 而不是 进行 或者 完成 )

遇到以下五种情况必须立即对类进行 初始化 (而加载, 验证, 准备自然要在这之前完成):

  1. 遇到 new , getstatic , putstaticinvokestatic4 条字节码指令时, 如果类没有初始化, 需要出发初始化; 生成这 4 条指令的常见 Java 代码场景是: 使用 new 关键字实例化对象的时候, 读取或设置一个类的静态字段的时候(注意: 被 final 修饰, 已在编译期把结果放入常量池的静态字段除外(由此可见, 添加 final 修饰可以避免不必要的类加载)), 以及调用一个类的静态方法的时候
  2. 使用 java.lang.reflect 包的方法对类进行反射调用的时候
  3. 当初始化一个类, 如果发现其父类没有初始化, 则需先初始化其父类
  4. 虚拟机启动时, 用户需要指定一个要执行的主类( main() 所在的类), 虚拟机会先初始化这个主类
  5. 当使用 JDK1.7 的动态语言支持(比如: Groovy 等)时, 如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic , REF_putStatic , REF_invokeStatic 的方法句柄, 并且这个方法句柄所对应的类没有进行过初始化, 则需先触发其初始化

2.2 主动引用与被动引用

2.2.1 构造函数, 代码块, 静态代码块的执行时机

考虑下列代码:

public class Practice {

    static {
        System.out.println("Practice static code block");
    }

    {
        System.out.println("Practice code block");
    }

    public Practice() {
        System.out.println("Practice constructor");
    }
}

new 一个 Practice 对象时, 输出如下; 说明三者的执行顺序是: 静态代码块 –> 代码块 –> 构造函数

Practice static code block
Practice code block
Practice constructor

关于三者的执行时机与作用:

  1. 静态代码块 :
main()
  1. 代码块 :
  1. 实例对象相关, 给所有对象进行统一初始化(因为构造函数可以重载)
  1. 构造函数 :
  1. 初始化实例

2.2.2 被动引用示例

前面所说的触发初始化的五个场景对应叫做主动引用

  1. 通过子类引用父类的静态字段, 不会导致子类初始化: 对于静态字段, 只有直接定义这个字段的类才会被初始化
public class NotInitialization {
    public static void main(String[] args) {
        System.out.println(SubClass.value);  // 不会初始化子类~
    }
}

class SuperClass {
    static {
        System.out.println("SuperClass init");
    }

    public static int value = 123;
}

class SubClass extends SuperClass{
    static {
        System.out.println("Child init");
    }
}

注: 后文为节省篇幅, 将重用这里的 SuperClassSubClass , 而不再重写~

  1. 通过数组定义来引用类, 不会触发此类的初始化: 虚拟机会为数组自动生成一个直接继承自 Object 的子类; Java 中对数组的访问比 C/C++ 更安全, 是因为这个类封装了数组元素的访问方法, 而 C/C++ 直接翻译为数组指针的移动
public class NotInitialization {
    public static void main(String[] args) {
        SuperClass[] sca = new SuperClass[0];
    }
}
  1. 常量在编译阶段会存入被调用类的常量池中, 本质上并没有直接引用到常量的类, 因此不会出发定义常量的类的初始化: 在编译阶段经过常量传播优化, 已经将次 Hello World 常量存储到了 NotInitialization 类的常量池中了, 以后 Initialization 对常量 ConstClass.HELLOWORLD 的引用都被转化为 NotInitialization 类对自身常量池的引用了~
public class NotInitialization {
    public static void main(String[] args) {
        System.out.println(ConstClass.CONST);
    }
}

class ConstClass {
    static {
        System.out.println("ConstClass init");
    }

    public static final String HELLOWORLD="Hello World"; // 注意这里不能从static来理解, 因为即使有static, 但是没有final的话仍然会触发ConstClass的初始化~
}

2.3 类的加载过程

结合前面的图咯~

2.3.1 加载

  1. 通过一个类的全限定名来获取定义此类的二进制字节流

  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构

  3. 在内存中生成一个代表这个类的 java.lang.Class 对象(该 Class 对象并没有明确规定是在 Java 堆中, 对于 HotSpot 虚拟机而言, Class 对象比较特殊, 它虽然是对象, 但是存在于方法区中), 作为方法区这个类的各种数据的访问入口

2.3.2 验证

  1. 文件格式验证:
0xCAFEBABE
  1. 元数据验证
final
final
  1. 字节码验证

对类的方法体进行校验分析, 保证被校验的类在运行时不会危害虚拟机

  1. 符号引用验证
  1. 符号引用中通过字符串描述的全限定名是否能找到对应的父类
  2. 符号引用中的类, 字段, 方法的访问性是否可以被当前类访问
  3. 指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段 …

2.3.3 准备

正式为类变量分配内存并为其设置初始值

2.3.4 解析

将常量池中的符号引用替换为直接引用

2.3.5 初始化

真正开始执行 Java 程序代码(字节码)或者说初始化阶段是执行类构造器 <clinit>() 方法的过程

2.3.5-1 类构造器 ()方法

  1. <clinit>() 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块( static 代码块)中的语句合并产生的; 静态语句块只能访问到定义在静态语句块之前的变量, 定义在它之后的变量, 在前面的静态语句块可以赋值, 但是不能访问, 如下代码
class Temp {
    static {
        value = 2;
//        System.out.println(value); // 不能访问
    }

    static int value = 1;
}
  1. <clinit>() 方法与构造函数( <init>() )不同, 虚拟机会保证子类的 <clinit>() 方法执行之前, 父类的 <clinit>() 方法已经执行完毕; 因此在虚拟机中第一个被执行的 <clinit>() 方法的类肯定是 java.lang.Object

  2. 由于父类的 <clinit>() 方法先于子类的执行, 也就意味着父类中的静态语句块要先于子类的变量赋值操作; 注意如下代码;

class SuperClass {
    public static int A = 1;
    static {
        A = 2;
    }
}

class SubClass extends SuperClass{
    public static int B = A; // 注意这里的B应该为2, 而不是1
}
  1. <clinit>() 方法对于类或接口来说不是必需的, 如果一个类没有静态语句块, 也没有对变量的赋值操作, 那么编译器可以不为这个类生成 <clinit>() 方法

  2. 接口中不能有静态语句块, 但是可以有变量的初始化赋值操作, 因此接口与类一样也会生成 <clinit>() 方法; 但接口与类不同的是, 执行接口的 <clinit>() 方法不需要先执行父接口的 <clinit>() 方法, 只有当父接口中定义的变量使用时, 父接口才会初始化; 另外, 接口的实现类在初始化时也一样不会执行接口的 <clinit>() 方法

  3. 虚拟机会保证一个类的 <clinit>() 方法在多线程环境中被正确的加锁, 同步; 只能有一个线程去执行 <clinit>()

2.4 类加载器

任意一个类, 都是由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性; 更通俗一些是: 比较两个类是否 相等 , 只有在这两个类是由同一个类加载器加载的前提下才有意义, 否则, 即使这两个类来自同一个 Class 文件, 被同一个虚拟机加载, 只要加载它们的类加载器不同, 那这两个类就必定不相等(这里所谓的相等, 包括代表 Class 对象的 equals() 方法, isAssignableFrom() 方法, isInstance() 方法的返回结果, 以及使用 instanceof 关键字做对象所属关系判断等情况); 如下代码:

import java.io.IOException;
import java.io.InputStream;

public class ClassLoaderTest {
    public static void main(String[] args) throws Exception {
        ClassLoader loader = new ClassLoader() {
            @Override
            public Class<?> loadClass(String name) throws ClassNotFoundException {
                try {
                    String fileName = name.substring(name.lastIndexOf(".") + 1) + ".class";
                    InputStream inputStream = getClass().getResourceAsStream(fileName);
                    if (inputStream == null) {
                        return super.loadClass(name);
                    }
                    byte[] bytes = new byte[inputStream.available()];
                    inputStream.read(bytes);
                    return defineClass(name, bytes, 0, bytes.length);
                } catch (IOException e) {
                }
                return null;
            }
        };

        Object obj = loader.loadClass("Practice").newInstance();
        System.out.println(obj.getClass());
        System.out.println(obj instanceof Practice); // 及时是Practice实例, 但是这里仍然返回false
    }
}

2.5 双亲委派模型

2.5.1 类加载器分类

  1. 启动类加载器( Bootstrap ClassLoader ): C++ 实现, 虚拟机自身一部分

  2. 扩展类加载器( Extension ClassLoader )

  3. 应用程序类加载器( Application ClassLoader ): 如果没有定义自己的类加载器, 则为程序中默认的类加载器

2.5.2 双亲委派模型含义

如下图; 双亲委派模型要求除了顶层的启动类加载器外, 其余的类加载器都应当有自己的父类加载器;

Java类文件结构与类加载机制

如果一个类加载器收到了类加载的请求, 它首先不会自己去尝试加载这个类, 而是把这个请求委派给父类加载器去完成, 每一个层次的类加载器都是如此; 因此所有的加载请求最终都应该传送到顶层的启动类加载器中, 只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时, 子加载器才会尝试自己去加载

  • ← Previous Post
原文  https://husteryp.github.io/2018/10/02/Java类文件结构与类加载机制/
正文到此结束
Loading...