Java virtual machine

工作中不直接和Java 虚拟机打交道,没有进行过调优等工作,所以对虚拟机体会不深,这里简单总结下Java虚拟机的基础知识,对更好的理解Java语言有帮助。
章节如下:

  1. 数据类型
  2. 运行时数据区
  3. 垃圾收集机制和内存分配策略
  4. Class 文件的结构
  5. 虚拟机的类加载机制

1.数据类型

数据类型
    ├── 原始类型
    │   ├── boolean类型
    │   ├── returnAddress类型
    │   └── 数字类型(Numberic type)
    │       ├── 整数类型
    │       │   ├── byte
    │       │   ├── char(0-63335(2^16 -1))
    │       │   ├── int
    │       │   └── short
    │       └── 浮点类型
    │           ├── float
    │           └── double
    └── 引用类型
        ├── 类 类型
        ├── 数组类型
        └── 接口类型

returnAddress用于java 虚拟机的jsr, ret, jsr_w指令; 这种类型的值指向的是java虚拟机指令的操作码。
和数字类型不同,returnAddress在java语言中没有对应的类型,也不能被运行的程序修改。

尽管Java虚拟机定义了boolean类型,但是对这种类型的支持并不多,并没有专门用于操作boolean类型的指令。Java语言中对boolean类型值的操作,在编译完成后会使用Java虚拟机的int类型。Java 虚拟机也不直接支持boolean类型的数组,"newarray"指令可以创建boolean 类型数组,boolean类型数组的访问和修改都是使用的byte数组的指令。Oracle的Java虚拟机实现是将Java中的boolean数组编码为Java虚拟机的byte 数组,每个成员占用8个bit。

对象可以分为两类: 1.动态分配的类实例。2.数组。


2.运行时数据区

这里写图片描述

一部分运行时数据区是在Java虚拟机启动是创建的,并且只有当Java虚拟机退出是销毁。一部分是线程启动时创建,在线程退出时销毁。

程序计数器:
程序计数器是一块比较小的内存空间。
因为Java虚拟机的多线程是通过线程轮流切换并分配处理器时间的方式实现的,所以每个线程都要有自己的程序计数器,来记录程序执行的位置,以便线程切换后线程可以恢复到正确的执行位置。
如果线程当前执行的不是native方法,那么程序计数器记录的是Java虚拟机的指令地址,如果是native方法,那么程序计数器的内容是未知的;另外,程序计数器也可以容纳returnAddress或者指定平台的native指针。

Java虚拟机栈:
Java虚拟机栈是线程私有的,每个线程在被创建时,都会有对应的Java虚拟机栈被创建; 当线程退出时,相应的Java虚拟机栈也会被销毁。Java虚拟机栈描述的是Java方法执行的内存模型,每个方法在执行时都会创建一个栈帧,用于存储局部变量表,操作数栈以及方法出口等信息。所以一个方法调用的过程对应了一个栈帧在Java虚拟机中入栈出栈的过程。
Java虚拟机栈可以是固定大小的也可以是可扩展的,缩小的; 如果是固定大小的栈,那么当请求的栈深度大于Java虚拟机所允许的深度时,那么将会抛出StackOverflowError;如果是可扩展的,那么如果扩展时不能申请到足够的内存, 那么将抛出OutOfMemoryError。
Java虚拟机栈的内存空间可以不是连续的。

堆(Heap):
堆是空间最大的一块内存空间了,在Java虚拟机启动时被创建; 是所有线程共享的运行时数据区。虚拟机规范里说所有的类对象和数组都会在这里创建,但是由于JIT编译器的发展,逃逸分析技术的发展以及栈上分配、标量替换优化技术的出现,所有对象都在堆上分配已经变的没那么绝对了。
堆中的空间由GC来回收。
堆的内存空间可以不是连续的。
堆的大小可以固定,也可以扩展,缩小。如果堆无法成功扩展,那么会抛出OutOfMemoryError。

方法区:
方法区也是Java虚拟机内所有线程共享的,在Java虚拟机启动时被创建。用于存储已加载的类信息,比如运行时常量池,数据域和方法数据,以及方法和构造函数的code,包括那些用于类和接口初始化,实例初始化的特殊方法。
方法区是heap的一个逻辑分区,大小可以是固定,也可以扩展,缩小;可以不实现垃圾收集。
方法区内存空间可以不是连续的。
如果方法区不能请求到足够的内存,那么会抛出OutOfMemoryError。

运行时常量池:
运行时常量池在方法区中,当Java虚拟机加载类或者接口的时候会被创建。
每个类或者接口编译后生成的class文件中都有一个"constatn_pool"表,运行时常量池就是这个表格的运行时代表。运行时常量池中包含了各种常量,包括数字型的字面值和符号引用,这些内容将在类或者接口加载后进入方法区的运行时常量池中存放。
运行时常量池不同于class文件中的"constant_pool"表,"constant_pool"表是在编译时就确定了的,而运行时常量池是动态的。Java语言并不要求常量一定是只有编译期才能产生,即并非预置到"constant_pool"中的常量才能进入运行时常量池,运行时产生的常量依然可以放入运行时常量池中,常见的是String的intern()方法产生的常量。
当常量池无法申请到内存是会抛出OutOfMemoryError。

本地方法栈:
本地方法栈和Java虚拟机栈的作用类似,Java虚拟机栈为执行Java方法服务,而本地方法栈为执行native方法服务。
本地方法栈也是线程私有的。另外,本地方法栈同样会有StackOverflowError和OutOfMemoryError。

栈帧:
栈帧在方法调用时创建,当方法调用结束后销毁。栈帧存储了局部变量表,操作数栈,方法出口以及当前方法所属类的运行常量池的引用(为了动态链接)。
栈帧的空间是在Java虚拟机栈上分配的,所以是线程私有的,不能被其他线程共享。
任何时间点只有一个栈帧是active状态,即当前栈帧,与之相关的是当前方法,当前类。

局部变量表的大小是在编译时就确定了的,具体内容由方法的code决定。单个局部变量可以存储boolean, byte, char, short, int, float, reference和returnAddress类型。long和double类型
会占用两个连续的局部变量空间。

操作数栈的大小也是编译时确定的,具体内容由方法的code决定。

动态链接:将class文件中的符号转化为具体的方法引用,对于未知的符号会加载相应的类; 并将变量访问转化为运行时变量存储结构的合适偏移量。这种延迟绑定可以减少类之间的关联性,一个类改变不会导致其他类变化。

局部变量表
局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序被编译成Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。

对象的存储布局和访问定位:
当虚拟机遇到一条new指令时,首先会去检查指令参数是否能在常量池中定位到一个类的符号引用,并检查这个类是否已经加载,解析和初始化过。如果没有,便去执行类的加载过程。加载完成后,虚拟机会为对象分配内存,一般一个对象在内存中的布局分为三部分:对象头实例数据对齐填充对象头用于存储对象自身的运行时数据; 实例数据是对象真正存储的有效信息,也是程序代码中所定义的各种类型的字段内容,包括从父类继承的以及本身定义的; 对齐填充没有有用信息,只是为了便于内存管理。内存分配完成之后,虚拟机将对象的实例数据部分初始化为零值,所以对象的实例字段不赋初始值也可以直接使用。至此,虚拟机层面的对象已经创建完成,但是Java层面的对象还没有创建完成,new指令之后<init>方法会被调用,实例字段会按照程序代码进行初始化。
Java程序是通过栈上的reference引用来操作堆里的对象的,使用reference定位堆里对象的方法一般有两种:句柄和直接访问。
下图展现的是用句柄定位对象的方式:
这里写图片描述
首先Java堆中划分出一部分内存作为句柄池,reference中存储的是指向对象句柄的地址。句柄中包含两部分,一部分是到对象实例数据的指针,另外一部分是到对象类型数据的指针。这种方式的好处是reference中可以保持比较稳定的句柄地址,当对象被移动时,不需要改变reference的值。

下图展现的是直接访问对象的方式:
这里写图片描述
reference中存储的是对象在堆中的地址;这种方式因为节省了一次指针定位,所以会更快速 。


3.垃圾收集机制和内存分配策略

内存管理一直都很重要,java的垃圾收集机制为开发提供了方便,至少Java开发人员不用像C++,C开发人员那样为内存泄漏提心吊胆。
回收内存可以分成三个子问题,第一是回收那些对象-判断对象的是否依然存活; 第二是什么时候回收; 第三是如何回收对象。
第一,回收那些对象-判断对象的是否依然存活?常见的思路有:

  • 引用计数
  • 可达性分析

第二,什么时候回收?

  • 安全点,当程序执行到安全点时停下进行GC。
  • 安全区域,当程序执行到安全区域是进行GC。

第三,如何回收对象?
考虑到效率以及内存使用情况,常见的思路有:

  • 标记-清楚算法:首先标记要回收的对象,其次回收对象; 这个算法是最基础的算法,效率不高,内存碎片多,使用率也低。
  • 复制算法:这种算法的思路就是将内存分块,只使用其中一部分; 回收时,将存活对象移动到没有使用的内存中。
  • 标记-整理算法:这种算法的思路是将存活对象移动到内存的一端,然后清理到边界外的内存。
  • 分代收集算法:根据对象存活周期的不同,将内存划分为几块,一般是分为新生代和老年代。然后本别使用不同的算法进行收集。

4.class 文件的结构

ClassFile {
u4                  magic;
u2                  minor_version;
u2                  major_version;
u2                  constant_pool_count;
cp_info             constant_pool[constant_pool_count-1];
u2                  access_flags;
u2                  this_class;
u2                  super_class;
u2                  interfaces_count;
u2                  interfaces[interfaces_count];
u2                  fields_count;
field_info          fields[fields_count];
u2                  methods_count;
method_info         methods[methods_count];
u2                  attributes_count;
attribute_info      attributes[attributes_count];
}

magic魔法数用于确认文件的结构,取值0xCAFEBABE。
minor_versionmajor_version分别表示文件支持的最小版本和最打版本(含)。
constant_pool_count表示constant_pool中的数量。
constant_pool表中的每一项记录都是代表string常量, 类/接口名字等常量的结构体。
有效索引是1-constant_pool_count -1。
access_flag用于表明类或者接口的访问权限和属性。access_flag的值是多个flag按位与操作得到的; 常用的flag有ACC_PUBLIC(0x0001),ACC_FINAL(0x0002)等。
this_class的值必须是constant_pool中的一个有效索引,对应的记录是一个可以代表当前class文件所定义类/接口的CONSTANT_Class_info结构体。
super_class
对一个Class(类)来说,super_class的值必须是0或者constant_pool中的有效值,constant_pool中对应的记录也必须是一个CONSTANT_Class_info结构体。
对一个Interface(接口)来说,super_class的值必须是constant_pool中的有效值,constant_pool中对应的记录也必须是一个代表Class对象的CONSTANT_Class_info结构体。
interfaces_count的值表示当前类/接口的父接口数量
interfaces[interfaces_count] interfaces表中的每一个值都必须是constant_pool中的一个有效索引。
fields_count 表示当前Class/Interface内的变量的数量,包括所有的类变量和实例变量;但是不包括从父类/接口中继承来的变量。
fields[fields_count] 中的每一项都是field_info结构,field_info结构如下:

field_info {
u2                access_flags;
u2                name_index;
u2                descriptor_index;
u2                attributes_count;
attribute_info    attributes[attributes_count];
}

name_index是值必须是constant_pool中的一个有效索引。

methods_count表示methods表中的方法数量。
methods[methods_count] 方法表中的每一项记录都是method_info结构体;该表包含了当前类/接口声明的所有方法,包括实例方法,类方法,实例初始化方法和类/接口初始化方法,但是不包含从父类/父接口中继承的方法。
method_info结构体如下:

method_info {
u2                 access_flags;
u2                 name_index;
u2                 descriptor_index;
u2                 attributes_count;
attribute_info     attributes[attributes_count];
}

name_index是值也必须是constant_pool中的一个有效索引。

attributes_count表示属性表中的数量。
attributes[attributes_count] 属性表中的每项记录都是attribute_info结构体。
attributes在ClassFile,fields_info,method_info和Code_attribute都有有使用,不过统一使用了下面的结构体:

attribute_info {
u2 attribute_name_index;
u4 attribute_length;
u1 info[attribute_length];
}

attribute_name_index必须是constant_pool常量池中的有效索引; constant_pool常量池中的记录是CONSTANT_Utf8_info结构体,这个结构体是用来表示String常量的:

CONSTANT_Utf8_info {
u1 tag;
u2 length;
u1 bytes[length];
}

5.虚拟机的类加载机制

虚拟机将描述类/接口的数据从class文件加载到内存,并经过校验,准备,解析和初始化,最终形成可以被虚拟机直接调用的Java类型,这整个过程就是虚拟机的类加载机制。
和编译期就需要链接的语言不同(比如C语言),Java语言的加载,链接和初始化发生在运行期,虽然增加了一些性能开销,但是增加了灵活性和可扩展性。

虚拟机加载机制的流程可以用下图表示:
这里写图片描述
加载,校验,准备,初始化和卸载是按照顺序执行的(各阶段可能有交叉), 但是解析阶段可能发生在初始化之后。

虚拟机没有规定什么时候进行类/接口加载,但是规定了下面几种情况要执行类/接口的初始化:
类或者接口 C 什么情况下会被初始化?

  1. new, getstatic, putstatic和invokestatic指令被执行的时候; 即new一个对象的时候或者调用一个类或者接口的静态方法或者静态域时。
  2. 使用java.lang.reflect包对类进行反射调用时。
  3. 如果C是一个类,当初始化它的一个子类时,它也会被初始化。
  4. 如果C是一个接口,并且声明了一个非静态,非抽象的方法,那么当初始化一个直接或间接实现了C的类时,C也会被初始化。
  5. C本来就被设计做为Java虚拟机启动时的初始类/接口。
  6. 第一次调用java.lang.invoke.MethodHandle实例C,而C是对方法句柄REF_getStatic,REF_putStatic,REF_invokeStatic或REF_newInvokeSpecial进行句柄解析的结果。

在初始化之前一个类/接口必须被加载,验证,准备,解析(可选)。
Java虚拟机在初始化的过程中要解决多线程,以及循环初始化的问题。每个类或者接口都有一个初始化锁,在初始化的时候要拿到这个锁。

对于静态域(字段),只有定义这个域的类/接口才会被初始化,即在子类中调用父类/接口中的静态字段时,只会触发父类/接口的初始化,而不会触发子类的初始化。

常量在编译阶段会进入引用类的常量池中,所以引用常量不会触发定义常量类的初始化。

加载要完成以下工作:

  1. 根据一个类/接口的名字获取定义此类的二进制字节流。
  2. 将二进制字节流代表的静态存储结构转化为方法区的运行时数据结构。
  3. 在内存中生成一个代表这个类/接口的java.lang.Class对象,作为方法区这个类的各种数据的访问入口(这个对象可能不在堆上; 使用反射时获取的是这个Class对象?)。
    对于非数组类/接口,是有类加载器完成加载(系统提供的启动类加载器或者用户自定义的类加载器); 数组类是由虚拟机直接创建的。如果比较两个非数组类是否"相等", 只有在同一个类加载器的条件下才有意义,即使加载自同一class文件的类也会由于类加载器的不同而不相等。

验证阶段用于检查代表类/接口的二进制流格式是否正确以及符合要求,并且不会危害虚拟机的安全。
验证阶段大致完成下面4个阶段的工作:
文件格式验证:格式是否符合规范; 比如魔法数是否正确,版本号是否在有效范围内等内容。
元数据验证:进行语义分析,保证内容符合语言要求。
字节码验证:最复杂的一个阶段; 确定程序语义是否合法,是否符合逻辑。
符合引用验证:这个阶段发生在解析阶段,即将符合引用转为直接引用时,确保解析阶段能顺利进行。

准备阶段会正式为类变量(被static修饰的变量)分配内存并设置初始值这些变量所使用的内存都在方法区内分配; 零值通常为数据零值,而非程序员设定的值, 但是如果是常量则会被设置为设定的值,而非零值。
[例如]

public static int value = 123;

在准备阶段会被赋值为0,而非123。

public static final int value =123;

但是如果被final修饰那么value会被初始化为123, 而非0。

解析阶段是虚拟机将常量池内的符合引用转化为直接引用的过程。
符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要可以无歧义的定位到目标即可。符号引用和虚拟机内存布局无关,也和相关类是否加载无关。所以虚拟机内存实现可能不尽相同,但是各种虚拟机使用的符号引用形式都是一样的, 因为虚拟机规范明确定义了符号引用的字面量形式。
下面是虚拟机规定的常量池中数据的规范格式:

cp_info {
u1 tag;
u1 info[];
}

tag用于表明当前常量的类型;info的内容随tag变化。
除了规范格式,虚拟机规范还为各种类型都定义了格式,比如CONSTANT_Class_info,CONSTANT_MethodType_info和CONSTANT_Utf8_info等。

直接引用可以只直接定位目标的指针,相对偏移量或者是可以间接定位目标的句柄。直接引用和虚拟机的具体内存实现有关,同一个符合引用在不同的虚拟机实现上一般是不同的。

初始化阶段:
初始化阶段开始执行java代码(或者字节码),按照程序员的意愿初始化类变量和其他资源; 或者说初始化阶段是执行 <clint>的过程。
<clint>是编译器收集类中所有类变量的赋值动作和静态语句块(static {})中的语句合并产生的; 编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句快只能访问定义在它之前的变量,对于定义在它之后的变量静态语句块可以赋值,但是不能访问。如果一个类中没有静态语句块,也没有类变量的赋值操作,那么<clint>方法就不会生成。虚拟机会在调用子类的<clint>方法前调用父类的<clint>方法。接口虽然不能使用静态语句块,但是可以有变量赋值操作,所以同样会有<clint>方法,不过执行接口中的<clint>方法时不需要先执行父接口的<clint>方法,只有当父接口中定义的变量被使用时,父接口的<clint>方法才会被调用。对于实现了接口的类来说,初始化时,接口的<clint>方法也不会被先执行。虚拟机会对<clint>方法加锁,以应对多线程同时初始化一个类,所以<clint>方法中耗时问题可能会导致线程阻塞。
实例构造器<int>和类构造器<clint>都是在字节码生成的时候由编译器生成的(<int>并不是指默认构造函数,两者不是一回事)。这两个构造器都是在代码收敛的过程中产生的,编译器会把语句块(对于实例构造器是"{}", 对于类构造器是"static {}")、变量初始化(实例变量和类变量)、调用父类的实例构造器(类构造器不需要调用父类的<clint>方法,虚拟机会会保证父类构造器的执行,但是在<clint>方法中经常生成调用java.lang.Object的<int>方法的代码)等操作收敛到<int>和<clint>方法之中。并且保证一定是按先执行父类的实例构造器,然后初始化变量,最后执行语句块的顺序进行。
虚拟机规范的"2.9 Special Methods"章节对<int>和<clint>方法也有描述。

下图展现的是我们new一个对象时,虚拟机所执行的初始化操作:
图4.2

参考资料:

  1. The Java ® Virtual Machine Specification Java SE 9 Edition
  2. 《深入理解Java虚拟机:JVM高级特性和最佳实践》

猜你喜欢

转载自blog.csdn.net/Dylan_Sen/article/details/78988457