说明

本文来源于《深入理解 JAVA 虚拟机》一书的摘抄,网络资料,以及自己的理解。

前言

计算机系统基础

这里有一些相关的东西,配合使用。

不管什么语言,最终都需要转化为可以被物理机器直接识别的机器指令(再底层就是电流的变化等形式了,不提),机器语言程序与所运行的计算机硬件与软件之间有一个“桥梁”,这个在软件与硬件之间的东西叫做 指令集体系结构 (ISA)。

“虚拟机”是一个相对于“物理机”的概念,两者都有代码执行能力,物理机的执行引擎是直接建立在处理器,硬件,指令集,操作系统层面上的。而虚拟机的执行引擎则由自己实现,因此可以自行制定指令集和执行引擎的结构体系。虽然“虚拟机”可以自己搞执行引擎,但最后还是得翻译为机器指令才能使计算机识别。

JAVA 与 C / C++ 比较,便多加了一道程序——翻译结果交给 JVM,让它处理为计算机可以识别的东西。而这多带来的一道程序带来的好处便是“屏蔽掉各种硬件和操作系统的内存访问差异”。C 等语言编译生成的程序(.o 等)与平台相关,而 Java 的翻译结果( .class文件)可以在拥有 Jvm 的任何平台上运行。

Java 虚拟机并不是专为 Java 语言设计的。Java 虚拟机只与 class 文件关联,它并不在乎 class 文件来源于何种语言。那些能生成 class 的语言,如 JRuby 也是 Java 技术体系的一员。同时,在说“class文件”时也不仅指某个具体磁盘中的文件,而应当是一串二进制的字节流,无论以何种形式存在都可以。(远程调用之类)

流程

Java 代码首先转换为 class 文件,class 文件结构的学习可以和 C 的符号表类比。

然后是虚拟机来加载字节码。涉及到虚拟机类加载机制。

又涉及到内存的分配,垃圾回收。

  1. JVM 主要分为三块:类加载系统,运行时数据区,执行引擎
  2. JVM 主要由类加载器子系统,运行时数据区(内存空间),执行引擎以及本地方法接口等组成。其中运行时数据区又由方法区,堆,Java栈,PC寄存器,本地方法栈组成。
  3. 内存空间中方法区和堆是所有 Java线程 共享的,而 Java栈,本地方法栈,PC寄存器 则由每个线程私有。(这会引出一些问题)

JVM 执行的简单流程

  1. 类加载系统(Class Loader Subsystem):负责验证并加载 .class 文件,主要可划分为 3 个步骤。
    1. 加载(Loading):类在这一块被加载到内存中去
      1. Boot Strap class Loader,加载系统引导类($JAVA_HOME/jre/lib)
      2. Extension class Loader,加载扩展类($JAVA_HOME/jre/lib/ext)
      3. Application class Loader,也被称为 User class Loader,加载应用层级的类
    2. 链接(Linking)
      1. Verify,验证字节码是否正确
      2. Prepare,分配静态变量并且设置默认值
      3. Resolve,原始引用所有的符号引用都会被替换成指向方法区的
    3. 初始化(Initialization):所有的静态变量都会被赋值。执行顺序,由父类到子类。
  2. 运行时数据区(Runtime Data Area)
    1. 方法区:保存类数据信息,包括成员信息,父类和接口信息,运行时常亮池等。(JVM 共享)
    2. 堆区:保存所有的对象信息,JVM 共享
    3. 栈区:每个线程独有自己的栈,生命周期和线程一致。
    4. PC寄存器区:储存当前执行指令的地址,若执行的是本地方法,pc 为 null。
    5. 本地方法栈区:和栈区一致,只不过存放的是本地方法信息。
  3. 执行引擎(Execution Emgine):负责执行代码,执行引擎会依次读取字节码并且按顺序执行。一般来说可划分为如下几项。
    1. 字节码解释器(Bytecode Interpreter):执行字节码。优点:执行开销小。缺点:执行效率低。
    2. 模板解释器:和字节码解释器差不多,不同点在于直接把对应的指令集转成本地代码。
    3. JIT 编译器:可以针对热点代码优化,执行开销较大,但是能够针对性的优化,效率最高。
    4. 垃圾回收器:负责回收不再使用的对象,释放和整理内存。
    5. Java Native Interface:JNI,暴露了本地方法的接口,使 Java 可以调用本地方法。
    6. Native Method Libraries:本地方法库。

类文件结构

类文件(二进制编码)

  1. 一组以 8 位字节码为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在 class 文件中,中间没有任何分隔符。
  2. 当遇到需要占用 8 位字节以上空间的数据项时,则会按照 高位在前(Big-Endian,大端) 的方式分割为若干个 8 位字节进行储存。

格式

采用一种类似 C语言 结构体的伪结构来存储数据。

  1. 无符号数:基本的数据类型
  2. 表:多个无符号数或其他表作为数据项构成的复合数据类型

……等待补充 还没总结完

类加载

虚拟机把描述类的数据从 class 文件加载到内存,并对数据进行 效验,转换,解析,初始化。最终形成可被虚拟机直接使用的 java 类型。

这句话对应的就是 加载,连接,初始化。

在 Java 语言中,类型的加载,连接和初始化过程都是在程序的运行期间完成的,类加载时会稍微增加一些开销,但是为其添加了灵活性。(在 动态加载 等方面体现)

时机

  1. 加载(Loading):虚拟机把描述类的数据从 class 文件加载到内存

  2. 连接(Linking)

    1. 验证(Verification):对虚拟机的类加载机制来说,这是一个很重要但不是必要的阶段(对程序运行期无影响)。可以用 -Xverify:none 参数关闭大部分的类验证措施。

      1. 文件格式验证:验证字节流是否符合 Class 文件格式的规范,并且能够被虚拟机处理。主要目的是确保输入的字节流能正确地解析并存储于方法区之内。后面的三个验证阶段都是基于方法区的存储结构进行的,不会再操作字节流。
      2. 元数据验证:语义分析,保证不存在不符合 Java 语言规范的元数据信息。
      3. 字节码验证。确定程序语义合法,符合逻辑。
      4. 符号引用验证:可以看做是对类自身以外(常量池的各种符号引用)的信息进行匹配性效验。
    2. 准备(Preparation):正式为类变量分配内存并设置类变量初始值(0,null 等值)的阶段( 还不会执行任何方法,包括构造方法。 若存在 ConstantValue 的属性,就会初始化为 ConstantValue 属性指定的值)。

      public static int value = 123;//在这个阶段 value 值为 0

      public static final int value = 123;//在这个阶段 value 值为 123

      PS:经常见到这类题呀!其实考察的就是这个知识。

      看看这篇文章:Class类文件结构之ConstantValue属性

      final、static、static final修饰的字段赋值的区别

      • static修饰的字段在加载过程中准备阶段被初始化,但是这个阶段只会赋值一个默认的值(0或者null而并非定义变量设置的值)初始化阶段在类构造器中才会赋值为变量定义的值。
      • final修饰的字段在运行时被初始化,可以直接赋值,也可以在实例构造器中赋值,赋值后不可修改。
      • static final修饰的字段在javac编译时生成comstantValue属性,在类加载的准备阶段直接把constantValue的值赋给该字段。
        可以理解为在编译期即把结果放入了常量池中。
    3. 解析(Resolution):虚拟机将常量池内的符号引用替换为直接引用的过程。

      1. 直接引用和符号引用是什么?有什么关系?
  3. 初始化(Initialization):真正开始执行类中定义的 Java 程序代码。

  4. 使用(Using)

  5. 卸载(U6nloading)

最终形成可被虚拟机直接实用的 Java类型。

类加载器

只能用于实现类的加载动作。

待续…

内存管理机制

虚拟机如何使用内存?

JVM 在执行 JAVA 程序的过程中会把它所管理的内存划分为若干个不同的数据区域,这些区域都有各自的用途。

这里先提一下 JAVA 堆,所有的对象实例和数组都要在这款内存上分配(因为一些优化技术,这也并不绝对),上面的类加载完成后,就要开始在这块内存上创建对象。

注意:平时我们把管理的内存分为堆,栈,这两者和 JAVA 堆的区别。

注意:与内存回收相联系。

运行时数据区

程序计数器

计算机系统也有接触嘛,都类似…

  1. 工作方式:在虚拟机的概念模型里(实际实现可能会用其他更高效的方式),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
  2. 唯一一个在 JAVA 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。
  3. 线程私有!——> 为了线程切换后能恢复到正确的执行位置 ——> JAVA 虚拟机的多线程实现方式是通过线程轮流切换并分配处理器时间实现的。(与物理机类似嘛,所以在任意一个确定的时刻,一个处理器(内核)都只会执行一条线程中的指令。因为 cpu 速度太快,所以给人一种并行的感觉。)

线程私有的区域只有,程序计数器,虚拟机栈,本地方法栈这三个。

所有线程共享:方法区,JAVA 堆

虚拟机栈

方法执行的内存模型

每一个方法的调用直到执行完成,就对应一个栈帧在虚拟机栈中入栈到出栈的过程。

这里就 相当于平时所说的栈 嘛,比如平时会说,形参在栈中分配…之类的。

这里的栈帧就是一个用于虚拟机进行方法调用和方法执行得数据结构,它存放的是一个函数(方法)的上下文,具体就是存放的执行的函数的一些数据(比如说有形参)。

实际上存放了:局部变量表,操作数栈,方法出口。

之前学操作系统,在汇编层面,执行代码,内存地址变化是,1,2,3,4…进入一个函数,地址就变为15,退出这个函数后,地址又返回4,再5,6…这里的变化实际上就是内存到了栈里去执行。

异常情况:StackOverflowError,outOfMemoryError

本地方法栈

类似上面。

本地方法就是其它语言封装的函数。

JAVA 堆

  1. 几乎所有的对象实例都在这分配。
  2. 内存回收的主要区域!
  3. 被所有线程共享的一块数据区域。
  4. 在 JVM 启动时创建。
  5. 唯一的目的是存放对象实例。
  6. 从垃圾回收的角度:分为新生代,老年代
  7. 从内存分配的角度:可能划分为多个线程私有的分配缓冲区

方法区

  1. 用于储存已被虚拟机加载的类信息,常亮,静态变量,即时编译器编译后的代码等数据。
  2. 线程共享:每个线程都能访问同一个类的静态变量对象。
  3. 这块区域在垃圾回收时,因为使用了反射机制,JVM 很难推测出哪个类的信息不再使用,难以实现垃圾回收。
  4. 这块区域针对常量池(运行时常量池,存放编译期生成的各种字面量和符号引用)回收,但 JDK1.7 ①把常量池转移至堆里。

PS:JDK 是用于支持 JAVA 程序开发的最小环境

可以把 JAVA API 类库中的 JAVA SE API 子集和 JAVA 虚拟机 两部分统称为 JRE

数据如何创建,布局,访问?

在类加载检查通过后,虚拟机为新生对象分配内存。

为对象分配内存的任务等同于把一块确定大小的内存从 JAVA 堆 中划分出来。

那么在虚拟机内存中,数据是如何创建,布局,访问的?

对象的创建(普通 java 对象,不包括数组和 class 对象等)

在代码层面,我们通常通过 new 指令来创建一个对象(这里只讨论这种方式,反射之类的不提。PS:所以有了那个笑话,没有对象怎么办?new 一个….),JVM 遇到这条 new 指令,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号的引用,并且检查这个符号引用代表的类是否已被加载,解析,初始化过。若没有,那就必须先执行相应的 类加载过程 (加载完成后便能确定对象所需的内存)。

为对象分配空间

分配方式的选择取决于 JAVA 堆是否规整。

  1. 绝对规整(用过的内存放一边,空闲的放另一边):指针碰撞的方式(直接划分就好啦)
  2. 并不规整(已使用的内存和空闲内存相互交错):空闲列表(需要一个列表来保存内存的信息,哪里可用哪里不能用)

因此,在使用 Serial,ParNew 等带 Compact (压缩整理)过程的垃圾收集器时,系统采用指针碰撞。

使用 CMS 这种基于 Mark-Sweep 算法的收集器时,用空闲列表。

(混合使用,各有优劣嘛…)

考虑到并发

创建对象这个动作是很频繁的,要考虑到线程安全。

两种方案。

  1. 对分配内存空间的动作进行同步处理,保证更新操作的原子性。
  2. 把内存分配动作按线程划分在不同区域。

内存分配完成后,将内存空间初始化为零值(不包括对象头)

why?

这一步操作保证了对象的实例字段在 java 代码中不附初始值就可以直接使用,程序能访问到这些字段的数据类型对应的零值

对对象进行设置

在对象头存放信息,这是谁的实例。如何才能找到类的元数据信息,对象的哈希码,对象的 GC 分代年龄等信息。

最后

经过上面步骤,在虚拟机的视角上,新的对象已经产生啦!

在 java 程序的角度,对象创建才刚刚开始。

接下来,紧跟着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

对象的内存布局