JVM学习

JVM的结构

首先先放一张JVM的工作流程图:

就像上图所说的那样, 我们编写的代码——也就是.java文件, 会被编译器编译成类文件——也就是.class文件. 如果大家有兴趣, 可以自己在电脑里找找. 对IDEA来说, 生成的.class文件会放在同项目下与src文件夹同级的out文件夹内.

类文件是一个二进制文件. 编译器会将类的一系列属性按照一定顺序存储在对应的类文件中. 由于我们关注的问题不是Java的编译, 所以类文件的具体组织形式这里就不多说了. 总之, 大家可以把类文件理解成是Java的可执行文件. 只要有类文件, Java的程序就可以执行, 这也就是Java的“一次编译, 到处运行”特点中“一次编译”的由来.

这里再顺便说另外一个问题. 在刚学Java的时候, 我也有这么一个问题: Java是编译型语言吗?

可能很多和我一样, 觉得既然Java有编译器了, 那肯定是编译型语言了. 但是这想法是不对的, 因为编译的定义是从源语言编写的源程序产生目标程序, 但是我们产生的类文件是任何一个平台可以直接运行的程序吗? 不是. 从流程图中我们可以看到, 类文件最终还是要被JVM的类加载器解析. 那么很显然, Java就不是编译型语言了, 它是解释型语言. JVM将类文件根据操作系统的不同解释为对应的操作, 就实现了Java的“到处运行”的特性了.

回到正题, 我们继续说Java的结构. 从流程图中我们看到, 类文件要在JVM中才能得到执行. JVM包括类加载器、执行引擎和运行时数据区三个部分. 前面两个很好理解, 那运行时数据区是什么呢?

上图是运行数据区的一个图解. 红色是所有线程共享的数据区, 绿色是线程私有的数据区. 下面我们来看一下图中的各部分.

程序计数器

首先来看程序计数器(Program Counter). 顾名思义, 它就是指示当前执行的程序位置的. 具体来说, Java的程序计数器指示的是当前线程执行的类文件字节码的偏移量. Java的解释器通过改变计数器的内容来改变执行的程序, 实现循环、跳转等流程控制.

正因为程序计数器关系到线程的执行, 所有线程的程序计数器在线程被创建的时候就会被一同创建起来. 即使它可能永远都为空(当该线程执行的是操作系统自带的功能函数时, 由于用不到字节码, 程序计数器当然就是空的了).

关于程序计数器还有一点要知道的是, 它不会发生内存溢出错误(Out Of Memory Error). 这也是很好理解的, 因为计数器只记录偏移量, 而偏移量在进行改变时也只会覆盖原来的值, 不需要开辟新的内存空间.

虚拟机栈

虚拟机栈和程序计数器一样, 生命周期和线程的周期一样长. 虚拟机栈负责记录该线程执行的方法的栈帧. 什么是栈帧? 你可以理解为一种描述一个方法的数据集合, 它主要包括局部变量表、操作数栈、动态链接、方法返回地址. 既然是栈, 那么当然只有栈顶的栈帧才是有效的. 换句话说, 只有当前被线程执行的方法的栈帧才会被使用. 当线程新调用一个方法时, 就会创建一个栈帧并且将其入栈, 方法执行完后再将其出栈.

虚拟机栈的大小是有限制的. JVM规定了两条关于虚拟机栈大小的规定:

1.当虚拟机栈超过了JVM允许的大小时, JVM会抛出Stack OverFlow Error异常.

2.当虚拟机栈在扩充时无法申请到足够的内存空间时, JVM会抛出Out Of Memory Error异常.

可以看到, 影响虚拟机栈大小的有JVM允许的栈临界值和内存大小两个. 一般来说, 栈临界值在JDK5.0前是256K, JDK5.0后是1M. 当然你也可以自己通过-Xss来调整. 当临界值小时, 理论上可以创建更多的线程, 但是首先太小的栈很容易发生溢出, 其次操作系统会限制一个进程创建的线程数量, 所以副作用很大; 当临界值大时, 栈的深度就有了保证, 你可以递归更多次, 但相应地你也不能创建很多线程.

接下来我们再回头看一看栈帧. 刚才说到, 栈帧主要包括局部变量表、操作数栈、动态链接、方法返回地址, 那这些都是什么呢?

局部变量表

局部变量表是一个顺序结构, 它以槽(Slot)为单位. 槽是一个32位的结构, 可以用来存储boolean、byte、char、short、int、float、reference(对象的引用)和returnAddresss(一条程序地址)方法的返回这些32位以内的数据. 像long、double这样64位的数据, 就需要两个连续的槽来进行高位在前的存放.

局部变量表通过索引值来进行定位. 0位默认存放方法所属的实例的引用, 也就是我们说的this. 其余的就按照出现的先后顺序依次排列.

还有一点要说的是, 局部变量表是可重用的. 当一个局部变量的生命周期结束后(即它的作用域已经过了), 如果有需要的话它会被新的局部变量替换. 局部变量表的重用不仅节约了空间, 也对JVM的垃圾回收有影响.

操作数栈

顾名思义, 操作数栈就是用来进行加减法、赋值等一些列运算操作的. 当一个方法开始执行时, 操作数栈是空的, 随着字节码指令不断地进栈出栈, 操作栈才不断地存入和放出数据. 从这一点我们可以看出, JVM的执行引擎是依赖操作数栈的, 这就是它被称为“基于栈的执行引擎”的由来, 我们也因此成JVM是基于栈的.

这里再提一下另外一个小问题. Android虚拟机是基于寄存器的虚拟机. 我们把JVM和AVM的指令集进行一下对比.

比如一个加法: c = a + b;

ARM的指令集就是一条三地址指令: ADD DST SRC1 SRC2. 我们看到, 这条指令太长了, 从指令集编写的角度来说开销很大.

x86系列的指令集采用了好一点的二地址指令: ADD DST SRC. 也就是说在指令层面上把c = a + b改成了a += b. 这样开销就比三地址指令好些了.

当然, 也有一些很古老的机器的指令集是单地址指令: ADD SRC. 它把存放结果的寄存器省了, 其实不是省了, 而是默认指定了一个叫累加器的寄存器. 这应该大家都知道, 不知道的可以在网上看一看简单的计算机组成或者汇编原理.

那么有没有更省的办法? 当然了, 还有零地址指令嘛. 如果所有的操作数都放到了栈里, 那么我要做加法的时候直接弹出最上面两个数, 加完再放回去就行了, 根本不需要指明地址.

可能大家都已经想到了, AVM使用的就是二地址和三地址指令, JVM使用的是零地址指令. 这就是基于栈的指令集和基于寄存器的指令集的区别. 我们可以很明显地看出, 基于栈的指令集只需要很小的空间就可以放下所有指令, 从开销来看显然是更划算的. 但是为什么ARM、x86这些真正的处理器都用基于寄存器的指令集呢? 那当然是因为快了. 因为同样的一系列操作, 零地址指令集要完成的话, 就要使用比二地址三地址指令集更多数量的指令, 这意味着访存次数就多了, 当然也就慢了.

从虚拟机的角度来看, 采用基于栈的指令集的话就不需要太多的寄存器, 这样对宿主机的要求就低, 可移植性就好, 不过速度就会慢; 反过来, 基于寄存器的指令集可移植性就低, 但是速度就更快.

动态链接

我们知道, 一个方法里会有很多的符号引用. 对于像final、static这样不允许被改变的引用, 在类文件被加载或者第一次被使用的时候就会被转化为实际的值, 这被称为静态链接; 相对的, 那些可能会变化的引用就需要实时的去寻找它们引用的值, 这就是动态链接了. 为了支持动态链接, 每个方法的栈帧都会包含一个指向运行时常量池(就是上图中的常量池)中该帧所属方法的引用.

方法返回地址

这个就很好理解了, 方法执行完后要继续执行调用该方法的方法, 那就有必要知道对应的字节码偏移量, 这就是方法返回位置了.

本地方法栈

本地方法栈和虚拟机栈的工作原理一样, 不同点在于虚拟机栈服务于类文件中的方法, 而本地方法栈服务操作系统自带的方法.

堆区

堆区是JVM中内存最大的一块区域, 它被所有线程共享. 几乎所有的对象实例和数组都在这里被分配内存, 因此这里也是垃圾回收器的主要工作区域, 故被称为“GC堆”.

JVM规定堆区是逻辑上连续的而物理上可以不连续的. 当堆区已经被分配完且无法被扩展时, JVM会抛出Out Of Memory Error异常.

方法区

方法区是另外一个被所有线程共享的区域. JVM规定把方法区视作堆区的一个逻辑组成部分. 类文件被解析后产生的类信息、常量、静态变量和符号引用会被放进方法区, 让方法被执行时, 相关的常量数据会进入运行时常量池. 运行时常量池是可以被动态扩充的, 比如String类的intern()方法就可以向常量池中放入新的常量. 同堆区一样, 方法区也是逻辑上连续的而物理上可以不连续的. 当方法区已经被分配完且无法被扩展时, JVM会抛出Out Of Memory Error异常.

方法区、堆区和虚拟机栈的关系

我们举一个例子:

一个线程执行了这么一句话: Object object = new Object();

执行这句话的过程中, object作为一个Object实例的引用, 被存放在该线程虚拟机栈的对应方法的局部变量表中. 而实例本身则被放在了堆区中, 因为是堆区为它分配的空间. 但是实例的对象类型、方法、接口、父类等, 是被放在方法区中的, 因为这些属于类信息.

这里说明一下, 实例本身被放在了堆区, 指的是实例拥有的数据就存在堆区. 但是实例并不知道数据是被解析成Int还是char, 这是由实例的对象类型决定的.

引用的实现

我们再回头看一下引用(reference)这个数据类型. 我们都知道它是用来定位一个特定的实例对象的, 但是究竟是怎么定位的呢?

主流的方式有两种.

第一种是句柄访问. 就是在堆区中再创建一个句柄池, 存放所有实例的句柄. 句柄有一个指向堆区中实例本身的指针, 还有一个指向方法区实例类型数据的指针. 就像下图一样:

这个方式的优点在于当垃圾回收器移动实例的位置时, 只用改变句柄的实例指针, 不用改变reference. 这样reference的值就相对稳定了.

第二种是直接访问. reference直接指向实例数据, 实例数据再包含一个指向类型数据的指针. 就像下图一样:

这种方式的好处就是快. 目前Java默认采用的虚拟机就使用了这种方式.

类加载机制

JVM类加载机制分为加载、验证、准备、解析和初始化五个步骤. 在这基础上再加上使用和卸载两个步骤, 就构成了类从进入JVM内存到移出内存的全部流程. 在学习类加载的步骤前, 应该先要学习类加载的时机. JVM规范规定了类必须被初始化的四种情况:

  1. 遇到new、getstatic、pustatic、invokestatic这四个字节码指令的时候

  2. 使用java.lang.reflect包对类进行反射调用的时候

  3. 初始化子类的时, 作为父类没有被初始化的时候

  4. java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄所对应的类没有进行过初始化的时候

加载

加载步骤由JVM的类加载器完成, 主要有三个任务:

  1. 通过类的全扩展名加载对应的二进制字节码

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

  3. 在堆区创建一个代表此类的java.lang.Class对象, 作为访问上述数据结构的入口

我们都知道类文件是字节码的一个来源. 事实上, 类加载器不仅还可以通过zip、jar、txt等文件中加载字节码, 而且还可以通过网络获取字节码.

还值得一提的是, 类加载器其实也是一个Java类(继承自虚拟类Java.lang.ClassLoader). 这意味着我们也可以继承默认的类自定义自己的类加载器. 所以加载步骤是用户可以参与的.

验证

验证步骤主要是为了防止加载的字节码对虚拟机有破坏作用. 主要的验证任务有三个:

  1. 文件格式验证: 检查加载的字节码文件是否合乎规范以及是否能被当前版本的虚拟机处理

  2. 元数据验证: 对字节码进行语义分析, 保证其符合Java语言规范

  3. 字节码验证: 对类的方法体进行数据流和控制流分析, 保证其不会对虚拟机造成危害

准备

在准备阶段, 所有的类变量被正式分配内存并初始化

解析

解析阶段主要负责将常量池中关于类、接口、成员变量、方法的符号引用转换为直接引用. 在这个过程中, JVM还会进行一次符号引用验证, 对符号引用的进行匹配性检验

初始化

在初始化步骤, JVM才开始真正执行字节码. 前面说过, 在准备阶段, 所有的类变量被初始化过一次. 那次的初始化值是JVM要求的值, 而在初始化阶段进行的初始化才是字节码要求的初始值. 好比说, int a=5; 在准备阶段初始化后, a实际上是0; 在初始化阶段才被初始化为5.

类加载器

JVM的类加载器有一个偏序关系:

JVM的类加载使用了所谓的“双亲委托机制”. 简单点说就是先让高级的类处理, 它处理不了自己再处理. 启动类加载器就是最高级的类, 自定义加载器是最低级的类.

启动类加载器(Bottstrap ClassLoader)是用C++实现的加载类. 因为不是Java类, 所以并不能在Java中被访问. 启动类加载器负责加载Java.lang.Object、Java.lang.String这些系统级的Java类, 从逻辑关系可以推测, 扩展类加载器一下的类也应该是它加载的. 因为加载类文件的Java类不可能加载自己; 扩展类加载器是Java能访问到的最顶级的加载器, 负责加载$JAVA_HOME/lib/ext的类; 用户类加载器负责加载应用程序classpath下的.jar和.class文件; 自定义加载器是继承了java.lang.ClassLoader抽象类的Java类, 由用户自定义实现.

垃圾回收机制

垃圾的定义和搜索

在介绍垃圾回收算法之前, 我们要先明确一个问题: 到底什么才算是垃圾?

对于Java来说, 一个引用指向一块内存空间, 那么没有引用指向的内存空间自然算是垃圾了. 所以我们的目标就变成了寻找没有被引用的对象.

那么怎么去寻找没有被引用的对象呢? 下面是常见的两种算法.

引用计数

最容易想到的, 就是给每个对象分配一个引用计数器. 当这个对象被引用一次时, 计数器就加一; 引用引用被解除时计数器就减一; 当计数器为零时, 就可以通知垃圾回收器回收垃圾了.

看起来好像没有什么问题, 那实际上呢?

我们看一下上面的情况, 四个对象循环引用. 比如说这时候我们要解除对象4对对象1的引用, 会发生什么? 没错. 对象2、对象3、对象4都会被依次回收, 而这不是我们希望的. 所以说, 引用计数虽然简单, 但是不能处理循环引用的情况.

可达性分析

可达性分析是另外一种判断无引用对象的方法. 它根据对象的引用关系建立了一座森林, 在准备回收时, 从每棵树的根节点开始遍历, 所有可达的节点都是有引用的, 那不可达节点自然就是无引用的了. 那每棵树的根节点是什么呢? 我们称为GC Roots, 它包括虚拟机栈中引用的对象、本地方法栈中JNI引用的对象、方法区中类静态属性实体引用的对象和方法区中常量引用的对象.

垃圾的回收算法

标记-清除算法

标记-清楚算法是最简单也是最基础的算法. 它的执行过程就像它的名字一样: 先标记要清除的对象, 再统一将这些对象清除.

同样, 算法的缺点也很明显. 首先, 明显地, 标记和清除过程面向的都不是连续的内存, 效率肯定不是最好的; 其次, 标记清楚算法没有整理内存, 也就是说会产生很多内存碎片.

为什么说内存碎片不好呢? 对于小对象来说, 内存碎片也许够用. 但是对于大对象来说, 内存碎片虽然加起来的空间够, 但是因为没有整理, 所以还是没办法获得它需要的内存空间, 这就会使得GC再来一次垃圾回收. 而回收过程又是非常耗时的, 这就进一步降低了系统的效率.

复制算法

为了解决标记-清楚算法的问题, 复制算法被提了出来. 它将内存空间分为两个大小一样的内存块, 每次只使用一个. 当使用的块用完之后, 将所有存活的对象移到另外一个块中, 然后统一清理即将用完的这块.

复制算法解决了标记-清楚算法的效率和内存碎片问题: 统一清理即将用完的空间, 内存是连续的, 操起来很容易; 新使用的内存块中, 未使用的内存也是连续的, 不存在内存碎片.

但是, 复制算法也有它的缺点: 首先, 明显地, 它将内存变为了原来的一半; 其次, 当存活对象多的时候, 转移对象的时间开销就打了, 效率就会变低; 最后, 一个很极端的情况: 如果所有的对象都存活, 复制算法就没有了作用.

标记-整理算法

标记-整理算法是另外一种在标记-清除算法的基础上提出的算法. 它针对标记-清除算法的内存碎片的缺点, 提出了这样的改良: 标记完对象后, 将标记的对象整理在一个连续的区域内, 然后再清理这个区域之外的内存.

同复制算法比, 标记-整理算法提高了内存的利用率. 但是显然, 它在效率上并不如复制算法表现出色.

分代收集算法

分代收集算法是前面三种算法的一个结合, 它建立在这样一个假设上: 绝大部分对象的生命周期都很短.

在这样的假设上, 我们将对象分为两类. 一类叫做新生代, 这类对象的生命周期短, 但是数量很多; 另一类叫做老年代, 这类对象的生命周期长, 但是数量少.

对于新生代, 因为它们的生命周期短, 复制算法所不能解决的极端情况就不容易出现, 所以就对它们用复制算法; 对于老年代, 它们的生命周期长, 但是数量又少, 标记-清楚或标记-整理算法的低效率在这里并不明显, 所以我们就用这两种算法.

上图是分代收集算法对内存的一个分配. 我们看到, 新生代使用的空间是老年代使用空间的一半. 但是我们又知道, 新生代的数量是非常多的, 那这么一点地方怎么够呢? 答案是Minor GC.

什么是Minor GC? 它是专门针对新生代的一次轻量级的垃圾回收, 因为只面向新生代, 而且新生代采用的复制算法又很快, 所以它的效率也很高. 这样就解决了新生代空间不足的问题.

那下一个问题, 我们怎么知道哪个对象是新生代, 哪个对象是老年代呢?

仔细看一下新生代的区域, 我们发现它被分为了Eden、from和to三个区域. 实际上, 新生代对象的创建是在Eden区上的. 当Eden区空间不够用后, 系统就会发起一次Minor GC来整理新生代的区域. 在这次整理中, 存活的对象会从Eden进入from或者to. 如果from和to满了, 存活的对象就会直接进入老年代的区域. 对于一直在from和to中的存活对象, 如果它撑过了16次Minor GC, 那么我们就把它当做老年代对象, 将它移入老年代区域.

这样一来, 我们就知道了老年代区域对象的来源: Eden区域直接送过来的对象和from、to区域撑过16次Minor GC的对象. 当老年代区域也满了的时候, 系统就会进行Major GC. Major GC是重量级的垃圾回收过程, 它会暂停JVM中其它任务的运行, 也就是我们说的“stop the world”.

经典的垃圾收集器

CMS收集器

CMS收集器是一种以获取最短回收停顿时间(也就是“stop the world”的时间)为目标的垃圾收集器, 它的工作流程分为以下四个阶段:

第一阶段是初始标记阶段. 这一阶段中, 收集器只标记与GC Roots直接相连的对象. 由于存在产生新的与GC Roots直接相连的对象的可能, 所以这一阶段需要“stop the world”. 但是, 因为直接与GC Roots相连的对象数量不会很多, 所以并不会花费很多时间.

第二阶段是并发标记阶段. 在这一阶段, 收集器会进行可达性分析, 标记所有可达的对象. 因为工作量很大, 耗时会很大, 所以这阶段不进行“stop the world”, 而是并发执行.

第三阶段是重新标记阶段. 因为第二阶段是并发的, 一些对象可能会出现可达性发生变化的情况. 第三阶段就是为了处理这种情况, 对第二阶段的可达性结果进行检查和矫正. 自然, 这一阶段也需要“stop the world”. 不过因为本身是一个矫正的过程, 所以并不会很费时.

第四阶段就是并发清除阶段, 意义很明显, 这里就不再多说了.

我们看到, CMS收集器应用了标记-清楚算法, 并且使”stop the world”的时间尽可能地短, 这正符合了老年代垃圾收集的要求, 因此我们一般把它所谓老年代的收集器.

Serial收集器

Serial收集器是JVM最古老的一个收集器, 它采用的就是上面介绍的分代收集算法. 不足的是, Serial收集器是一个单线程的收集器, 在进行垃圾回收时, 无论是回收新生代还是老年代, 都会触发“stop the world”.

为了解决Serial的问题, 后面又推出了它的多线程版本ParNew收集器, 真正做到了并发收集的实现. 目前, 只有Serial收集器和ParNew收集器能与CMS收集器搭配使用.