JVM 内存分配及 GC 详解

JVM中的内存使用方式,包括虚拟机内存区域的划分,Java对象分配时的处理原则和逻辑,以及我们日常开发中最需要关心的GC回收的策略和算法,是开发出拥有出色而稳定的Java软件产品所必须深刻理解的。从各种途径阅读到的讲解JVM内存管理,GC过程和策略的资料也都从不同的侧重点讲述了这些话题。我在这里按照自己的理解总结一下,算是整理下自己的思路,以让自己JVM方面的知识体系结构化,打下更扎实的基础。

  1. JVM的运行时数据区域的划分

JVM在执行Java程序过程中会将其管理的内存划分为不同的部分作为各自的不同用途。其中,JVM下所有线程共享的部分有: 方法区及堆;而每个独立运行的线程,各自又享有各自的1)虚拟机栈 2)程序计数器 3)本地方法栈。

本文重点总结一下方法区及堆的内存使用和分配。各个线程独享的虚拟机栈本地方法栈等等以后再补充。

1)方法区

其实就是平时我们常说的永久代(PermGen),在Java程序调试时候会碰到的"java.lang.OutOfMemoryError: PermGen Space" 就发生在这个区域。这个永久代是各个线程共享的JVM内存区域,用于存储虚拟机已加载的类信息,常量,静态变量,即使编译器编译后的代码等等数据,还会包括一些跟类有关的对象数组和类型数组,JVM使用的内部对象,以及编译器优化的使用信息等。在过去的JVM版本中,常量池也是放在永久代的。但在HotSpot 的JDK 1.7开始,字符串常量池已经从永久代中移出了。方法区的大小,在JVM启动参数里可以用-XX:MaxPermSize=XXXM来设置

  1. Java堆

这是JVM所管理的最大的一块内存区域,被所有线程共享。该区域唯一的用途就是用来存放对象实例。所有的Java对象实例及数组都要在堆上分配内存。于是堆也成了垃圾收集器管理的最主要区域,本文总结的对象分配及垃圾回收的策略,主要就是针对堆这个内存区域的啦。JVM启动参数-Xmx及-Xms可用来控制堆内存大小。

  1. 创建对象时的分配策略和原则

普通Java对象的创建,通常是以new关键字来调用一个类的构造方法开始的。程序执行到此时,在Java堆内存中的对象分配工作便开始了。

1)第一步,检查该new指令的参数,是否在常量池中能够定位到一个类的引用。如String对象,如果字符串常量池中已有该String的引用,则直接返回。字符串常量池又是另一个话题,这里先不讨论了。查找常量池后,则要检查该类是否已被加载,解析及初始化过。如未加载该类,则先要执行相应的类加载过程。在类加载完成之后,该new指令需要分配多大的内存空间就可以被确定了。

2)第二步,就是给该对象分配内存了。详细的分配策略及细节,跟使用的垃圾收集器组合有关系,还有JVM中与堆内存相关的参数设置。这里总结一下一些通用的规则。

通常情况下,对象在新生代的Eden区进行分配。如果Eden区没有足够空间进行分配时,JVM将发起一次MinorGC。

大对象,即新生代没有足够的连续空间可供其使用的对象,通常是那种需要大量连续内存空间的长字符串或者数组对象,在其所需空间大于 -XX:PretenureSizeThreshold参数时,直接在老年代分配。手动设置这个值可避免在年轻代的Eden区及Survivor区之间发生大量的内存复制,而复制后依然不够分配。很消耗CPU运行时间的喔

我们在设计程序时候亦应该尽量避免大对象的分配,尤其是生命周期很短的大对象分配。因为这会导致Eden区还有大量空间的情况下(但空间不连续,没法容纳我们需要的大对象),提前触发GC来获得连续空间。

  1. GC回收策略及算法
  1. GC分代的方式

JVM为了更有效地管理和回收堆内存,以HotSpot虚拟机为例,其基于以下假设,将堆内存从物理上划分为两个部分,即年轻代(Young Generation)和年老代(Old Generation)。这两个假设便是:

  • 大多数对象的生命周期都不会很长。也就意味着这些对象的引用会很快变得不可达;
  • 只有很少的由老对象(创建时间较长的对象)指向新生对象的引用

对于年轻代,绝大多数新分配对象会在这块区域被创建;

年老代,占用空间会比年轻代多,年轻代中进行minor GC时存活下来的对象最终会进入这里。年老代中发生GC(即Full GC)的次数要少得多;

而年轻代又会被划分为三个区域,通常是一个较大的区域主要用作对象分配的,叫Eden区;以及两个Survivor区。Eden区无需多说,对象分配时首先从这里分配空间。而两个Survivor的作用在于,Eden中经过一次minor GC后存活下来的对象,会进入其中一个Survivor区,而另一个Survivor区域则留作当前Survivor区的备份空间。当Survivor A区域中的空间饱和的时候,此时发生的Minor GC会将Survivor A中依然存活的对象,以及Eden中存活的对象,都复制到Survivor B,然后清空Survivor A。就这样循环往复,两个Survivor之间互相复制。

GC采用分代收集思想,于是就有了对象年龄计数这一概念。出生在Eden的对象,经过一次Minor GC仍存活,年龄+1。如果其能被Survivor区容纳,将进入Survivor区域。也就是说只要其活过一次minorGC,就可进入survivor区了。随后每一次minorGC, 活下来的对象年龄都+1,达到一定年龄的对象将进入老年代(默认是15岁)。该阈值可通过 -XX:MaxTenuringThreshold设置。

同时,如果survivor区内很多年龄不太大的对象怎么办呢,大家年龄都不足以进入老年代,但数量太多,survivor也吃不消啊。于是还有一条规则,就是survivor区内所有年龄相同的对象大小总和如果超过survivor区空间的一半,年龄大于等于该年龄的对象都直接进入老年代,不受参数MaxTenuringThreshold参数的限制了。

a) Minor GC: 新生代GC。发生相对频繁,回收速度也较快。

b) Major GC/Full GC: 老年代GC。通常会伴随一次Minor GC,速度较慢,通常比Minor GC耗时多10倍以上。

  1. GC回收策略

JVM回收的对象,是那些已经不再被使用的对象。而判断是否不再被使用的原则,可以从两个方面来描述。简单来说,第一是不可到达,第二是引用计数为0。这两点,其实说的是一回事,从不同的方面来描述罢了。

详细来说,不可到达,就是说从GC Root出发,对象之间的引用链没有指向的对象,我们称之为不可到达。引用计数为0其实说的就是从GC Root开始的对象引用链到达该对象的引用计数为0,即没有可到达的引用路径指向它了。

可作为GC Root的对象包括:*方法区中加载的类的静态字段引用的对象,常量引用的对象;*每个线程对象的虚拟机栈中引用的对象;*本地方法栈中引用的本地对象或常量。

既然说了可到达,那么提一句弱引用及软引用。弱引用即不影响可到达性的引用,如果没有强引用只有弱引用的对象,GC照样回收,也就是说弱引用是被GC直接忽略的。软引用则比弱引用略强硬一些,通常用来描述有用但非必须的对象。在系统将要发生内存溢出时会把软引用对象进行一次回收。

至于具体的对象回收算法,其实现就要取决于垃圾收集器了。不同的垃圾收集器有其不同的回收算法。概括地讲,比较典型的包括“标记-清除”,“标记-复制-清除”,“标记-清除-整理”等等。