Java 内存模型

垃圾回收

类文件结构

Class 文件中包含了 Java 虚拟机指令集、符号表以及若干其他辅助信息。

常量池

每个 Class 文件中都有一个常量池(constant_pool),常量池可以比喻为 Class 文件里的资源仓库。

由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项 u2 类型的数据,代表常量池容量计数值(constant_pool_count)。

编译后,Java 的方法体存在哪里?

Java 的方法编译后存在 Class 文件的常量池中,在常量池的方法表中,方法体的具体逻辑代码在方法表中的属性表中的 Code 字段。

ConstantValue 是什么?

三、类加载流程

什么是类加载?

类加载是指 Java 虚拟机(JVM)将描述类的字节码数据从 Class 文件中读取到内存,并经过一系列校验、转换、解析和初始化操作,最终在虚拟机中形成可直接使用的 Java 类型的过程。这个机制就是虚拟机的类加载机制。

类加载的时机

一个类从被加载到虚拟机内存开始,到最终被卸载为止,生命周期大致经历以下七个阶段:

  • 加载(Loading);

  • 连接(Linking):

    • 验证(Verification);

    • 准备(Preparation);

    • 解析(Resolution);

  • 初始化(Initialization);

  • 使用(Using);

  • 卸载(Unloading),

其中验证、准备、解析三个部分统称为连接,它们的先后顺序如下图所示:

《Java 虚拟机规范》并没有严格规定“加载”阶段何时开始,但对初始化阶段有明确要求:只有在以下六种情况中,类必须立即初始化(加载与连接会在此之前完成):

  • 遇到 newgetstaticputstaticinvokestatic 这四条字节码指令,如果类没用初始化,则先初始化;

    • 使用 new 关键字实例化对象;

    • 读取或设置一个类型的静态字段;

    • 调用一个类的静态方法;

  • 对类进行反射调用时,如果类没用初始化,则先初始化;

  • 当初始化类时,如果其父类还没有初始化,先触发父类的初始化;

  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类;

  • 当使用 JDK 7 新加入的动态语言支持时,如果一个 MethodHandle 实例最后的解析结果为 REF_getStaticREF_putStaticREF_invokeStaticREF_newInvokeSpecial 四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要先触发其初始化;

  • 当一个接口中定义了 JDK 8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

类加载的过程

加载

在加载阶段,Java 虚拟机需要完成以下三件事情:

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

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

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

验证

验证是连接阶段的第一步,目的是确保 Class 文件中的字节流符合《Java 虚拟机规范》的要求,从而保证加载的类在运行时不会危害虚拟机的安全。主要包括四类检查:

  • 文件格式验证;

  • 元数据验证;

  • 字节码验证;

  • 符号引用验证;

准备

准备阶段是为类变量(static 修饰的变量)分配内存,并设置初始值的过程。需要注意几点:

  • 只为类变量分配内存,不包括实例变量。实例变量会在对象实例化时随对象一起分配到堆中;

  • 这里的“初始值”通常是数据类型的零值,而非代码中显式赋的值。例如:

public static int value = 123;

在准备阶段,value 的值是 0 而不是 123。因为赋值动作对应的 putstatic 指令,会在类初始化阶段(<clinit>() 方法执行时)才真正生效。

Java 中各类型的零值如下:

  • 数值类型(intlongshortcharbytefloatdouble):0;

  • booleanfalse

  • reference(引用类型):null

如果字段被 final static 修饰,例如:

public final static int value = 123;

那么编译器会在编译期生成 ConstantValue 属性。在准备阶段,虚拟机会直接根据该属性为 value 赋值为 123,而不是零值。

解析

初始化

使用

卸载

方法分派

什么是方法分派?

就是确定调用谁的、哪个方法,需要针对方法重载的情况进行分析、针对方法覆写的情况进行分析。

先来看一个案例,有一个父类 SuperClass 和子类 SubClass:

public class SuperClass {
    public String getName() {
        return "Super";
    }
}

public class SubClass extends SuperClass{
    public String getName() {
        return "Sub";
    }
}

问题:下面的程序输出什么?

public class Question {
    public static void main(String[] args) {
        SuperClass obj = new SubClass();
        print(obj);
    }

    public static void print(SuperClass superClass) {
        System.out.println("Hello " + superClass.getName());
    }

    public static void print(SubClass subClass) {
        System.out.println("Hello " + subClass.getName());
    }
}

输出结果是什么?

答案是:Hello Sub。因为 Java 中的方法执行取决于运行时具体的实际类型,上述代码中 obj 实际是一个 SubClass,那么在调用 getName() 方法时,都是调用 SubClass 的 getName() 方法。

调用哪一个 print 方法呢?

如果有方法重载的情况,具体调用哪个方法取决于编译时声明的类型。上述案例中声明的类型是 SuperClass,那么 print() 方法调用的就是 print(SuperClass superClass),不会受到运行时的影响。

四、泛型

泛型编译后是什么?

在 Java 中泛型采用类型擦除实现,泛型类型编译时被擦除为 Object,不兼容基本类型。

为什么采用泛型擦除?

Java 的泛型从 JDK1.5 才出现,在此之前使用 List、ArrayList 时都是直接使用原始类型的。Java 是出名的兼容性好,为了向前兼容,也只能采用泛型擦除的方案。

采用泛型擦除还有个好处就是运行时内存负担小,Java 的类型在运行时都需要加载到方法区的,由于编译后泛型被擦除了,使用的是原始类型,运行时对方法区的内存负担自然就小。

Java 的泛型有哪些缺陷?

基本类型无法作为泛型实参

基本类型无法作为泛型实参,只能使用 Integer、Long 等,不可避免就有装箱拆箱的开销;

List<int> list1 = new ArrayList<>();     // ❌ 基本类型无法作为泛型类型
List<Integer> list2 = new ArrayList<>(); // ✔️ 需要使用装箱类型

泛型类型无法用作方法重载

泛型类型无法用作方法重载。原因很明显,因为编译后大家都是 List 的原始类型,两个方法的签名是一样的,当然无法重载。

public void print(List<Integer> list) {}
public void print(List<String> list) {}  // ❌ 二者无法重载

泛型类型无法作为真实类型使用

泛型类型无法作为真实类型使用。下面是 Java 中的泛型方法:

public <T> void genericMethod(T t) {}

其中的泛型参数 T t 编译完之后实际上是一个 Object:

public void genericMethod(Object a) {}

因此,在运行时并不知道其真实的类型是什么。所以 Java 中的泛型无法作为真实类型使用,有诸多的限制:

  • 无法实例化:T newInstance = new T();

  • 无法创建数组:T[] arr = new T[10];

  • 无法获取类对象:Class c = T.class;

  • 无法判断泛型:if (list instanceof List<String>),只能判断是否是 List,而不能判断内部的泛型类型

  • 可以用作其他泛型:List<T> list = new ArrayList<>();,因为最后都会被擦除。

静态方法无法引用类泛型参数

类型强制的运行时开销

虽然我们使用了泛型在写代码的过程中可以确定一个 List 中的具体类型,但是编译后 List 中存储的都是 Object,那么是怎么返回正确的类型呢?

List<String> list = new ArrayList<>();
list.add("Hello");
String val = list.get(0);

实际上是编译器在编译后做了类型强转:

invokeinterface 'java/util/List.get','(I)Ljava/lang/Object;'
checkcast 'java/lang/String'

因此,Java 的泛型会带来类型强转的运行时开销

泛型签名

其实泛型类型的签名信息在编译后被保留在字节码中,可以通过反射获取。

LOCALVARIABLE list Ljava/util/List; L1 L2 1
// signature Ljava/util/List<Ljava/lang/String;>;
// declaration: list extends java.util.List<java.lang.String>