学习Java虚拟机

关键词:Java,虚拟机
作者:BIce 创建时间:2013-12-16 20:00:28

 

早就听说《深入理解Java虚拟机 JVM高级特性与最佳实践》是一本好书,但一直没有时间去看,最近正好有需要到Java的工作,遂买来阅读以增强Java功底,作笔记成本文。

Java虚拟机通常意义上是用来执行Java程序的,本文提到的Java虚拟机默认都指Sun HotSpot VM。本文首先概况一下Java程序的执行方式,然后分别对Java虚拟机的 (1) 编译系统 (2) 内存管理及GC (3) 执行系统 进行简要的记录。

1. Java程序执行概述

在传统意义上,Java是一门解释型语言,运行Java程序的过程需要经过:

(1)  Javac编译器编译Java源代码代码至.class字节码文件

(2)  虚拟机执行类加载过程,加载.class字节码文件,并根据.class文件内容,在内存中初始化类的相关信息

(3)  虚拟机执行字节码

(4)  随着字节码执行的深入,虚拟机的JIT编译器(Just In Time Compiler)会实时检查被执行字节码中的热点代码(即热点探测,Hot Spot Detection),并将其编译成本地使用的本地代码,以加快热点代码的执行速度

此过程如图所示:

Fig 1.  Java程序执行流程

下面我们按照顺序,首先描述JVM的编译系统。

 

2. 编译系统

本节中提到的编译系统,是将Java代码编译成Class字节码(ByteCode)的编译器,如Javac编译器。

2.1 字节码格式

在本节中,最重要的需要学习内容就是编译系统的输出:.class文件的格式。(此格式很复杂,是以二进制格式存在,具体格式在原书第6章由一章介绍,此处略过)

需要记录的是,一个Class字节码中所描述的内容大致有:常量池、访问标志、类索引、父类索引、接口索引集合、字段表集合、方法表集合、属性表集合。

其中对一个类的所有信息(包括方法代码)都进行了描述。(一个Class字节码和对应的Java 类文件是一种等价的存在)

2.2 字节码指令

上面提到了Class字节码中包含了方法的执行代码,而这些代码是被Javac编译器处理后存储到对应方法的Code属性中的,这些被处理后的代码被称为字节码指令。它们可以被Java虚拟机所执行。

字节码指令,Java虚拟机的可执行指令。(其关系等同于机器码与CPU的关系)。它由一个字节长度的、代表某种特定操作意义的数字(Opcode)以及跟随其后的零至多个代表此操作数的参数(Operands)构成。

CPU不同,Java虚拟机采用面向操作数栈而不是寄存器的架构。(即执行指令时,使用栈作为计算的方式(操作数压栈->弹出操作数计算->结果值返回栈),类似用栈的后缀表达式求值的方法,和CPU通用的mov ax, 0x01这种方式差别很大)

Java虚拟机能识别的字节码指令集合,就称为Java虚拟机的指令集。(和CPU的指令集类似啊)

具体指令详见原书第7章。

3. 内存管理及GC

内存管理和GCJava虚拟机中最重要的任务之一,下面首先描述下Java虚拟机的内存管理方法。

3.1 内存管理

如图所示为Java虚拟机运行时的内存分配方式,其中虚拟机栈、本地方法栈、程序计数器这三个区域是每个线程独立拥有的区域,而方法区和堆则是线程所共有的(也就是经常会出线程安全问题的区域)。下面分别对这几个内存区域功能划分做下描述。

(1) 程序计数器:一块较小的内存空间,当前线程所执行的字节码的行号指示器。每个线程都拥有一个独立的程序计数器。

(2) 虚拟机栈:是用来描述Java方法执行的内存模型,每个方法执行时都会创建一个栈帧(Stack Frame)用于存储局部变量表,操作数栈,动态链接,方法出口等信息。此区域是线程私有的。方法调用则创建栈帧、入虚拟机栈,方法调用结束则出虚拟机栈。

(3) 本地方法栈:与虚拟机栈类似,区别是其服务对象不是Java方法而虚拟机用到的本地方法(Native)。

(4) Java堆:Java虚拟机管理内存中最大的一块,被所有线程共享,虚拟机启动时创建,用来存放对象实例,所有的对象实例和对象数组都在此区域分配。当然也就是GC涉及到的主要区域了。在GC角度,Java堆还分为新生代和老年代,以及Eden空间、From Survivor空间、To Survivor空间等。此区域大小可以通过-Xmx参数和-Xms参数来控制。

(5)方法区:线程共享区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等。在HotSpot虚拟机中,由于HotSpot开发团队的GC方法采用永久代来实现方法区,此区域也被称为永久代(Permanent Generation)。

a) 运行时常量池:方法区的一部分,用来存档编译器生成的各种字面量和符合引用,这部分内容在类加载后进入方法区的运行时常量池中存放

(6)直接内存:非Java虚拟机管理的内存,属于本机直接内存。由于NIONew Input/Output)类的出现,在基于管道与缓冲区的模式下,可以使用Native函数库直接分配堆外内存(本机直接内存),使用DirectByteBuffer直接操作。(会被经常使用,也可能导致OutOfMemoryError异常)

3.2 垃圾回收 GC

在介绍完JVM的内存分配布局之后,就涉及到了内存相关的最重要的一个问题:垃圾回(GC)。由于Java语言采用自动的垃圾回收来完成对不使用的对象的内存回收,而不是让程序员直接控制,在提高了简便性的同时也给垃圾回收策略和效率带来了一些问题(内存的另外一个重要问题是,共享内存的线程安全性)。

判定对象是否存活的方法:可达性分析(Reachability Analysis)。通过一些“GC Roots”对象作为起点,从这些节点向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到所有的GC Roots都没有引用链相连时,证明此对象不可用。(另外一个方法是引用计数法,但是此方法无法解决互相循环引用的问题,故目前不被主流采用)

在被判定为不可用的对象之后,JVM会在合适的时间启动一个低优先级的Finalizer线程去完成对象的finalize方法。(此方法每个对象只能执行一次)

3.2.1垃圾回收算法

(1) 标记-清除(Mark - Sweep)算法:算法分为两个阶段,标记阶段选出所有需要回收的对象,回收阶段进行统一的垃圾回收。基础算法,效率不高,容易产生内存碎片。

(2) 复制(Copying)算法:算法将可用内存分为大小相等的两块,每次仅使用其中一块,当其快用完时,将还存活的复制到另一块上面,然后一次性清理掉将满内存。内存利用率不高。现有JVM使用此算法的改进版回收新生代,但是不用1:1分配,而是分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和一块Survivor。回收时每次将Eden和一块Survivor中的存活对象复制到另一块Survivor上,然后清理Eden和第一块Survivor。(如果Survivor不够用,就需要老年代内存进行分配担保Handling Promotion

(3) 标记-整理(Mark - Compact)算法:与标记-清理算法类似,但是在标记过程完成时,不直接对对象进行清理,而是让所有存活对象都向一端移动,然后清除掉存活边界外对象。

(4) 分代收集(Generational Collection)算法:根据对象的存活周期的不同,将内存分为几块,一般是分为“新生代”和“老年代”,对不同内存块的对象采用不同的收集算法。(这是目前一般的采用方案。)

3.2.2 垃圾回收器

下面描述几个常用的典型垃圾回收器,不同的垃圾回收器各有优缺点,在适合的场景使用它们才是最好的办法。它们针对的内存代如图所示:

(1) Serial 收集器:最基本,历史最久的收集器。单线程的收集器,且在它进行回收时其他线程必须等待(Stop the World),直到其回收结束。新生代采取复制算法,老年代采取标记-整理算法。是默认的Client模式下新生代收集器。在某些情况下,简单而高效。(与单线程收集器相比)

(2) ParNew收集器:即Serial的多线程版本,使用多条线程进行垃圾收集。同样需要暂停其他线程。目前除了Serial之外,唯一可以与CMS收集器配合的收集器。

(3) Parallel Scavenge 收集器:一个新生代收集器,采用复制算法,多线程。关注点在于达到一个可控制的吞吐量。吞吐量(Throughput运行用户代码时间/(运行用户代码时间+垃圾回收时间)。即提高CPU的利用率,适合于在后台运行的计算任务。

(4) Parallel Old收集器:Parallel Scavenge收集器的老年代版本,使用多线程 标记-整理算法,可以与Parallel Scavenge配套使用,JDK 1.6后才提供。

(5) CMS(Concurrent Mark Sweep)收集器:以获取最短停顿时间为关注点的收集器。基于标记-清除算法,运行过程分为四个步骤:初始标记;并发标记;重新标记;并发清除。其中“初始标记”和“重新标记”步骤仍需要暂停其他线程,但是它们速度都很快。比较高效,适用于要求响应时间比较高的情况下。

(6) G1(Garbage First)收集器:面向服务端应用的、技术前沿的垃圾回收器,比较新,性能和稳定性待验证

下图为各种垃圾回收器中可能用到的运行参数:

在描述了JVM的内存分配策略以及GC算法和不同的回收器之后,我们开始描述JVM的执行系统。

4. 执行系统

执行系统主要负责完成程序的具体执行,是JVM中非常重要的部分,下面对其中的一些方面进行描述。

4.1 类加载过程

定义:虚拟机把Class字节码文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型。

类的声明周期如下图所示:

类加载的过程如下:

(1) 加载:根据类名找到Class字节码的二进制字节流;将此字节流数据代表的类结构转化成方法区的运行时数据结构;在内存中生成一个代表此类的java.lang.Class对象,作为方法区这个类型的访问入口。

(2) 验证:从文件格式上、元数据上、字节码上、符号引用上验证字节流的内容是否满足Java语言规范,即是不是可以执行的、安全的字节码。

(3) 准备:正式为类变量(java.lang.Class)分配内存并设置类变量初始值的阶段,这些变量(类变量,如静态的)的内存都在方法区中分配。

(4) 解析:将常量池中符号引用替换成直接引用的过程。

(5) 初始化:执行类构造器clinit方法的过程。clinit方法是由编译器手机类中类变量的赋值动作和静态语句块所合并而成的(顺序以语句在源文件中的顺序决定)。clinit方法会自动调用父类的clinit方法。

4.2 运行时栈帧结构

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(VM Stack)的栈元素。其基本内容如图所示:

在活动的线程中,只有在栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与这个栈帧相关联的方法称为当前方法(Current Method),即当前此线程正在执行的方法。

其中操作数栈是用来执行方法所进行的值计算的工具(计算表达式,存储运算结果和返回值),此栈也就是JVM称为基于栈的执行引擎的所指。

方法退出的过程:即将当前栈出栈的过程,还需要恢复上层方法的局部变量表和操作数栈,把返回值压入调用者的栈帧的操作数栈,调整程序计数器PC的值以指向方法调用指令后面的一条指令。

4.3 方法调用

方法调用的主要任务就是确定被调用的方法的版本。(就是调用哪个方法)

在面向对象的Java语言中,确定调用代码的版本有两个需要面对的情况:方法重载(Overload,即重名但参数不同的方法)和方法重写(Override,即多态实现的虚方法)

在方法调用中的目标方法在Class字节码文件中都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用。

Java虚拟机中定义了五条方法调用的字节码指令:

(1) invokestatic:调用静态方法

(2) invokespecial:调用实例构造器init方法、私有方法和父类方法

(3) invokevirtual:调用所有的虚方法

(4) invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象

(5) invokedynamic:在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是用户所设定的引导方法决定的。

(具体的调用过程比较繁琐,在原书8.3中有详细介绍,此处忽略)

4.4 基于栈的字节码解释执行引擎

说完了方法的调用和返回,就简单描述下方法执行的过程吧。

方法执行字节码指令是基于操作数栈的,其优点是可移植,不与寄存器挂钩,缺点是执行速度可能稍慢,其执行过程与计算表达式值的栈算法类似,下面就一个简单程序给出一个图示,用来说明执行方法

上图中,执行到第二条字节码指令,istore_1,即将操作数栈顶的整型值出栈并存放到局部变量表的第一个Slot中,操作结果如图所示(操作前栈顶为100,局部变量表1处无值)。

 

 

看了一周多的《深入理解Java虚拟机 JVM高级特性与最佳实践》,感觉收获很多,虽然还有很多没看懂的地方,但是还是对之前不了解的JVM执行机制有了一定了解,感觉和编译理论、操作系统等课程都可以关联的上啊,挺开心 感谢作者

PS:文中图是在网上找到的扫描版,但偶还是买了纸质版的哦,纸质版的书还是很好很不错滴,第二版也有挺多改进 赞。

留言功能已取消,如需沟通,请邮件联系博主sunswk@sina.com,谢谢:)