理解Java-虚拟机

JVM介绍

Java虚拟机(Java Virtual Machine)简称JVM,其作用是加载与运行Class文件; JVM屏蔽了与操作系统平台相关的信息,使得Java程序只需要生成在Java虚拟机上运行的目标代码(字节码),就可在多种平台上不加修改的运行,这也是Java能够一次编译,到处运行的原因.

JVM结构

JVM需要管理Java运行期间的各种对象的生命周期,所以它在执行Java程序的时候会把它所管辖的内存分成若干个不同的数据区域,根据《Java虚拟机规范》规定,JVM包括下面几个运行时的内存区域:

程序计数器

线程私有区域,用于指向当前线程下一条需要执行的字节码指令.

本地方法栈

JVM采用本地方法堆栈来支持native方法的执行,此区域用于存储每个native方法调用的状态.

虚拟机栈

线程私有区域, 虚拟机栈是一个后入先出的栈,用于描述Java方法执行的内存模型;每调用一个方法就会为每个方法生成一个栈帧,用于存储局部变量表、操作数栈、常量池引用等信息;每一个方法从调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程.
可以通过-Xss这个虚拟机参数来指定一个程序的 Java 虚拟机栈内存大小.

该区域可能抛出以下异常:

  • 当线程请求的栈深度超过最大值,会抛出 StackOverflowError 异常;
  • 栈进行动态扩展时如果无法申请到足够内存,会抛出 OutOfMemoryError 异常.

线程共享区域,存放类实例以及数组;我们平时听到的GC(垃圾回收)就是在堆上进行的,所有对象实例都在这里分配内存.
现代的垃圾收集器基本都是采用分代收集算法,主要思想是针对不同的对象采取不同的垃圾回收算法.
虚拟机把 Java 堆分成以下三块:

  • 新生代(Young Generation)
  • 老年代(Old Generation)
  • 永久代(Permanent Generation)

当一个对象被创建时,它首先进入新生代,之后有可能被转移到老年代中.
新生代存放着大量的生命很短的对象,因此新生代在三个区域中垃圾回收的频率最高.为了更高效地进行垃圾回收,把新生代继续划分成以下三个空间:

  • Eden(伊甸园)
  • From Survivor(幸存者)
  • To Survivor

Java 堆不需要连续内存,并且可以动态增加其内存,增加失败会抛出 OutOfMemoryError 异常.
可以通过-Xms-Xmx两个虚拟机参数来指定一个程序的Java堆内存大小,第一个参数设置初始值,第二个参数设置最大值.

方法区

线程共享区域,用于存储被虚拟机加载的类信息、final常量、静态变量、编译器即时编译后的代码等数据等数据;和Java堆一样不需要连续的内存,并且可以动态扩展,动态扩展失败一样会抛出 OutOfMemoryError 异常.
对这块区域进行垃圾回收的主要目标是对常量池的回收和对类的卸载,但是一般比较难实现.

  • JDK1.7之前,HotSpot虚拟机把它当成PermGen space(永久代)来进行垃圾回收(JDK1.7中的永久代).
  • JDK1.8之后,取消了永久代,用metaspace(元空间)区替代(JDK1.8中的元空间).

运行时常量池

运行时常量池,存放的为类中的固定的常量信息、方法和Field的引用信息(编译器生成,会在类加载后被放入这个区域)等,是方法区的一部分;
其空间从方法区域中分配, 除了在编译期生成的常量,还允许动态生成; 例如: String 类的 intern().

垃圾收集

程序计数器、虚拟机栈和本地方法栈这三个区域属于线程私有的,只存在于线程的生命周期内,线程结束之后也会消失,因此不需要对这三个区域进行垃圾回收。垃圾回收主要是针对 Java 堆和方法区进行.

  • 对新生代的对象的收集称为minor GC;
  • 对旧生代的对象的收集称为Full GC;
  • 程序中主动调用System.gc()强制执行的GC为Full GC。

GC介绍

内存处理器是编程人员容易出现问题的地方,忘记或者错误的内存回收导致程序或者系统的不稳定甚至崩溃,所以我们要及时的对内存中的一些无用对象清楚和回收,这个过程叫做垃圾回收,简称GC(Gabage Collection).

Java中的GC功能可以自动监测对象是否超过作用域从而达到自动回收内存的目的;从而有效的防止内存泄露, GC中用于回收的方法称为收集器,由于GC需要消耗一些资源和时间,Java在对对象的生命周期特征进行分析后,按照新生代、旧生代的方式来对对象进行收集,以尽可能的缩短GC对应用造成的暂停.

垃圾回收器通常是作为一个单独的低优先级的线程运行,不可预知的情况下对内存堆中已经死亡的或者长时间没有使用的对象进行清除和回收,程序员不能实时的调用垃圾回收器对某个对象或所有对象进行垃圾回收.回收机制有分代复制垃圾回收、标记垃圾回收、增量垃圾回收等方式

基本概念

  • 并行(Parallel): 指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态.
  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集程序运行于另一个CPU上.
  • STW(Stop The World):在执行垃圾收集算法时,除了垃圾收集线程外,Java应用程序的其他线程都被挂起的现象(Native 代码可以执行).

对象引用类型

不同的对象引用类型,GC会采用不同的方法进行回收,JVM对象的引用分为了四种类型.

强引用

默认情况下,对象采用的均为强引用(这个对象的实例没有其他对象引用,GC时才会被回收)

使用new一个新对象的方式来创建强引用.

1
Object obj = new Object();

软引用

软引用是Java中提供的一种比较适合于缓存场景的应用(只有在内存不够用的情况下才会被GC). 虚拟机在发生OutOfMemory时,肯定是没有软引用存在的.

使用 SoftReference 类来创建软引用.

1
2
3
Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null; // 使对象只被软引用关联

弱引用

弱引用与软引用类似,都是作为缓存来使用; 但与软引用不同,弱引用在进行垃圾回收时,是一定会被回收掉的,因此其生命周期只存在于一个垃圾回收周期内.

使用 WeakReference 类来实现弱引用.

1
2
3
Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
obj = null;

虚引用

虚引用只是用来得知对象是否被GC, 又称为幽灵引用或者幻影引用.一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用取得一个对象实例.

使用 PhantomReference 来实现虚引用.

1
2
3
Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj);
obj = null;

对象判死算法

由于程序计数器、Java虚拟机栈、本地方法栈都是线程独享,其占用的内存也是随线程生而生,随线程结束而回收.
Java堆和方法区则不同,属于线程共享区域,是GC的所关注的部分;在堆中几乎存在着所有对象,GC之前需要考虑哪些对象还活着不能回收,哪些对象已经死去可以回收.

有两种算法可以判定对象是否存活:

引用计数算法

给对象中添加一个引用计数器,每当一个地方应用了对象,计数器加1;当引用失效,计数器减1;当计数器为0表示该对象已死、可回收。
但是它很难解决两个对象之间相互循环引用的情况(此时引用计数器永远不为 0,导致无法对它们进行回收)。

1
2
3
4
5
6
7
8
9
10
public class ReferenceCountingGC {
public Object instance = null;

public static void main(String[] args) {
ReferenceCountingGC objectA = new ReferenceCountingGC();
ReferenceCountingGC objectB = new ReferenceCountingGC();
objectA.instance = objectB;
objectB.instance = objectA;
}
}

可达性分析算法

通过一系列称为“GC Roots”的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连(即对象到GC Roots不可达),则证明此对象已死、可回收。

Java 中 GC Roots 一般包含以下内容:

  • 虚拟机栈中引用的对象
  • 本地方法栈中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中的常量引用的对象

在主流的商用程序语言(如我们的Java)的主流实现中,都是通过可达性分析算法来判定对象是否存活的。

垃圾收集算法

标记-清除算法

最基础的算法,分标记和清除两个阶段:首先标记处所需要回收的对象,在标记完成后统一回收所有被标记的对象.
它有两点不足:

  • 效率问题,标记和清除过程都效率不高;
  • 空间问题,标记清除之后会产生大量不连续的内存碎片(类似于我们电脑的磁盘碎片),空间碎片太多导致需要分配大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾回收动作。

复制算法

为了解决效率问题,出现了”复制”算法,他将可用内存按容量划分为大小相等的两块,每次只需要使用其中一块。当一块内存用完了,将还存活的对象复制到另一块上面,然后再把刚刚用完的内存空间一次清理掉。这样就解决了内存碎片问题,但是代价就是可以用内容就缩小为原来的一半.

说明:

现在的商业虚拟机都采用这种收集算法来回收新生代,但是并不是将内存划分为大小相等的两块,而是分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 空间和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象一次性复制到另一块 Survivor 空间上,最后清理 Eden 和使用过的那一块 Survivor。HotSpot 虚拟机的 Eden 和 Survivor 的大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 空间就不够用了,此时需要依赖于老年代进行分配担保,也就是借用老年代的空间存储放不下的对象。

标记-整理算法

复制算法在对象存活率较高时就会进行频繁的复制操作,效率将降低。因此又有了标记-整理算法,标记过程同标记-清除算法,但是在后续步骤不是直接对对象进行清理,而是让所有存活的对象都向一侧移动,然后直接清理掉端边界以外的内存。

分代收集算法

当前商业虚拟机的GC都是采用分代收集算法,这种算法并没有什么新的思想,而是根据对象存活周期的不同将堆分为:新生代和老年代,方法区称为永久代(在新的版本中已经将永久代废弃,引入了元空间的概念,永久代使用的是JVM内存而元空间直接使用物理内存).
这样就可以根据各个年代的特点采用不同的收集算法.

一般将 Java 堆分为新生代和老年代。

  • 新生代使用:复制算法
  • 老年代使用:标记 - 清理 或者 标记 - 整理 算法

垃圾收集器

垃圾收集算法是方法论,垃圾收集器是具体实现。JVM规范对于垃圾收集器的应该如何实现没有任何规定,因此不同的厂商、不同版本的虚拟机所提供的垃圾收集器差别较大,这里只看HotSpot虚拟机。

JDK7/8后,HotSpot虚拟机所有收集器及组合(连线表示垃圾收集器可以配合使用).

  • 单线程与并行(多线程):单线程指的是垃圾收集器只使用一个线程进行收集,而并行使用多个线程。

  • 串行与并发:串行指的是垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序;并发指的是垃圾收集器和用户程序同时执行。除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行。

Serial收集器

Serial收集器是最基本、历史最久的收集器,曾是新生代手机的唯一选择。他是单线程的,只会使用一个CPU或一条收集线程去完成垃圾收集工作,并且它在收集的时候,必须暂停其他所有的工作线程,直到它结束,即“Stop the World”。停掉所有的用户线程。
尽管如此,它仍然是虚拟机运行在client模式下的默认新生代收集器:简单而高效(与其他收集器的单个线程相比,因为没有线程切换的开销等).

工作示意图如下:

ParNew收集器

ParNew收集器是Serial收集器的多线程版本,除了使用了多线程之外,其他的行为(收集算法、stop the world、对象分配规则、回收策略等)同Serial收集器一样。

是许多运行在Server模式下的JVM中首选的新生代收集器,其中一个很重还要的原因就是除了Serial之外,只有他能和老年代的CMS收集器配合工作。

默认开启的线程数量与 CPU 数量相同,可以使用-XX:ParallelGCThreads参数来设置线程数.

工作示意图如下:

Parallel Scavenge 收集器

与 ParNew 一样是并行的多线程收集器. 它的目标是达到一个可控的吞吐量(就是CPU运行用户代码的时间与CPU总消耗时间的比值,即 吞吐量=行用户代码的时间/[行用户代码的时间+垃圾收集时间]),这样可以高效率的利用CPU时间,尽快完成程序的运算任务,适合在后台运算而不需要太多交互的任务。

提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间 -XX:MaxGCPauseMillis 参数以及直接设置吞吐量大小的 -XX:GCTimeRatio 参数(值为大于 0 且小于 100 的整数)。缩短停顿时间是以牺牲吞吐量和新生代空间来换取的:新生代空间变小,垃圾回收变得频繁,导致吞吐量下降。

还提供了一个参数 -XX:+UseAdaptiveSizePolicy,这是一个开关参数,打开参数后,就不需要手工指定新生代的大小(-Xmn)、Eden 和 Survivor 区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种方式称为 GC 自适应的调节策略(GC Ergonomics)。

Serial Old收集器

Serial 收集器的老年代版本,单线程,“标记整理”算法,主要是给Client模式下的虚拟机使用。如果用在 Server 模式下,它有两大用途:

在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用。
作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。

工作示意图如下:

Parallel Old收集器

是 Parallel Scavenge 收集器的老年代版本。

在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。

参考文档

  • Java虚拟机规范SE8
  • 深入理解Java虚拟机