- JVM垃圾回收
- 1.揭开JVM内存分配与回收的神秘面纱
- 1.1 对象优先在eden区分配
- 1.2 大对象直接进入老年代
- 1.3 长期存活的对象进入老年代
- 1.4动态对象年龄判定
- 1.5 主要进行gc的区域
- 1.6 空间分配担保
- 2.判断对象是否死亡?
- 2.1 引用计数法
- 2.2 可达性分析算法
- 2.3 再谈引用
- 2.4 不可达的对象并非“非死不可”
- 2.5 如何判断一个常量是
- 废弃常量?
- 2.6 如何判断一个类是无用的类
- 3.垃圾回收算法
- 3.1 标记-清除算法
- 3.2 标记-复制算法
- 3.3 标记-整理算法
- 3.4 分代收集算法
Java的自动内存管理主要是针对对象内存的回收和对象内存的分配。同时,Java自动内存管理最核心的功能是堆内存中对象的分配与回收。
Java堆是垃圾收集器管理的主要区域,因此也被称为GC堆 (Garbage Collected Heap),从垃圾回收的角度,由于现在收集器基本多采用分代垃圾收集算法,所以Java堆还可以细分为:新生代和老生代:再细致一点有:Eden空间、From Survivor、To Survivor空间等。进一步划分的目的是更好地回收内存,或者更好地分配内存。
堆空间的基本结构:
上图所示的Eden区、From Survivor0(“From”)区、To Survivor1(“To”)区都属于新生代,Old Memory区属于老年代。
大部分情况,对象都会首先在Eden区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入s0或者s1,并且对象的年龄还会加1(Eden-Survivor区后对象的初始年龄变为1),当它的年龄增加到一定程度(默认大于15岁),就会晋升到老年代的年龄阀值 ,可以通过参数 -XX:MaxTenuringThreshold 来设置默认值,这个值会在虚拟机运行过程中进行调整,可以通过-XX:+PrintTenuringDistribution来打印出当次GC后的Threshold。
uint ageTable::compute_tenuring_threshold(size_t survivor_capacity) {
//survivor_capacity是survivor空间的大小
size_t desired_survivor_size = (size_t)((((double)survivor_capacity)*TargetSurvivorRatio)/100);
size_t total = 0;
uint age = 1;
while (age < table_size) {
//sizes数组是每个年龄段对象大小
total += sizes[age];
if (total > desired_survivor_size) {
break;
}
age++;
}
uint result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;
...
}
经过这次 GC 后,Eden 区和"From"区已经被清空。这个时候,“From"和"To"会交换他们的角色,也就是新的"To"就是上次 GC 前的“From”,新的"From"就是上次 GC 前的"To”。不管怎样,都会保证名为 To 的 Survivor 区域是空的。Minor GC 会一直重复这样的过程,在这个过程中,有可能当次Minor GC后,Survivor 的"From"区域空间不够用,有一些还达不到进入老年代条件的实例放不下,则放不下的部分会提前进入老年代。
接下来我们提供一个调试脚本来测试这个过程。
调试代码参数如下
1.1 对象优先在eden区分配目前主流的垃圾收集器都会采用分代回收算法,因此需要将堆内存分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾回收算法。
大多数情况下,对象在新生代中eden区分配。当eden区没有足够空间进行分配时,虚拟机将发起一次Minor Gc,下面我们来进行实例测试以下。
测试:
public class GCTest {
public static void main(String[] args) {
byte[] allocation1, allocation2;
allocation1 = new byte[30900*1024];
//allocation2 = new byte[900*1024];
}
}
通过以下方式运行:
添加的参数:-XX:+PrintGCDetails
运行结果 (红色字体描述有误,应该是对应于 JDK1.7 的永久代):
从上图我们可以看出 eden 区内存几乎已经被分配完全(即使程序什么也不做,新生代也会使用 2000 多 k 内存)。假如我们再为 allocation2 分配内存会出现什么情况呢?
allocation2 = new byte[900*1024];Copy to clipboardErrorCopied
简单解释一下为什么会出现这种情况: 因为给 allocation2 分配内存的时候 eden 区内存几乎已经被分配完了,我们刚刚讲了当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC.GC 期间虚拟机又发现 allocation1 无法存入 Survivor 空间,所以只好通过 分配担保机制 把新生代的对象提前转移到老年代中去,老年代上的空间足够存放 allocation1,所以不会出现 Full GC。执行 Minor GC 后,后面分配的对象如果能够存在 eden 区的话,还是会在 eden 区分配内存。可以执行如下代码验证:
public class GCTest {
public static void main(String[] args) {
byte[] allocation1, allocation2,allocation3,allocation4,allocation5;
allocation1 = new byte[32000*1024];
allocation2 = new byte[1000*1024];
allocation3 = new byte[1000*1024];
allocation4 = new byte[1000*1024];
allocation5 = new byte[1000*1024];
}
}
1.2 大对象直接进入老年代
大对象就是需要大量连续存储内存空间的对象(比如:字符串、数组)。
为什么要这样呢?
为了避免为大对象分配内存时由于分配担保机制带来的复制而降低效率。
1.3 长期存活的对象进入老年代既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。
如果对象在Eden出生并经过第一次Minor Gc后仍然能存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1,对象在Survivor中每熬过一次MinorGC,年龄就增加1,当它的年龄增加到一定程度**(默认为15岁)**,就会晋升到老年代。对象晋升到老年代的年龄阀值,可以通过参数 -XX:MaxTenuringThreshold 来设置。
1.4动态对象年龄判定大部分情况,对象都会首先在Eden区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入s0或者s1,并且对象的年龄还会加1(Eden区->Survivor区后对象的初始化年龄变为1),当它的年龄增加到一定程度(默认15岁),就会被晋升到老年代中。对象晋升到老年代的年龄阀值,可以通过参数 -XX:MaxTenuringThreshold 来设置。
动态年龄计算的代码如下:
uint ageTable::compute_tenuring_threshold(size_t survivor_capacity) {
//survivor_capacity是survivor空间的大小
size_t desired_survivor_size = (size_t)((((double)survivor_capacity)*TargetSurvivorRatio)/100);
size_t total = 0;
uint age = 1;
while (age < table_size) {
//sizes数组是每个年龄段对象大小
total += sizes[age];
if (total > desired_survivor_size) {
break;
}
age++;
}
uint result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;
...
}
默认晋升年龄并不都是 15,这个是要区分垃圾收集器的,CMS 就是 6.
1.5 主要进行gc的区域总结:
针对HotSpot VM的实现,它里面的GC其实准确分类只有两大种:
部分收集:
- 新生代收集(Minor GC/Young GC):只对新生代进行垃圾收集;
- 老年代收集(Major GC/Old GC):只对老年代进行垃圾收集; 需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
- 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集;
整堆收集(Full GC):收集整个Java堆和方法区。
1.6 空间分配担保空间分配担保是为了确保在MInor GC之前老年代本身还有容纳新生代所有对象的剩余空间。
2.判断对象是否死亡?堆中几乎存放所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)。
2.1 引用计数法给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1;当引用失效,计数器就减1;任何时候计数器为0的对象就是不可能在被使用的。
这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。所谓对象之间的相互引用的问题:除了对象objA和objB相互引用对方之外,这两个对象之间再没有任何引用。但是他们因为相互引用对方,导致它们的引用计数器都不为0,于是引用计数器算法无法通知GC回收器回收他们。
public class ReferenceCountingGc {
Object instance = null;
public static void main(String[] args) {
ReferenceCountingGc objA = new ReferenceCountingGc();
ReferenceCountingGc objB = new ReferenceCountingGc();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
}
}
2.2 可达性分析算法
这个算法的基本思想就是通过一系列的称为**“GC Roots”的对象作为起点**,从这些节点开始向下搜索,节点所走过的路径被称为引用链,当一个对象到GC Roots没有任何引用链相连的话,则证明此对象是不可用的。
可作为 GC Roots 的对象包括下面几种:
- 虚拟机栈(栈帧的本地变量表中)引用的对象
- 本地方法栈(Native)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 所有被同步锁持有的对象
无论是通过引用计数法和可达性分析法判断独享的引用链是否可达,判定对象的村后都与“引用”有关。
DK1.2 之前,Java 中引用的定义很传统:如果 reference 类型的数据存储的数值代表的是另一块内存的起始地址,就称这块内存代表一个引用。
JDK1.2 以后,Java 对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用四种(引用强度逐渐减弱)
1.强引用(StrongReference)
以前我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用。如果一个对象具有强引用,那就类似于必不可少的生活用品,垃圾回收器绝不会回收它。当内存空间不足,Java 虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。
2.软引用(SoftReference)
如果一个对象只具有软引用,那就类似于可有可无的生活用品。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。
软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA 虚拟机就会把这个软引用加入到与之关联的引用队列中。
3.弱引用(WeakReference)
如果一个对象只具有弱引用,那就类似于可有可无的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。
弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。
4. 虚引用(PhantomReference)
"虚引用"顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。
虚引用主要用来跟踪对象被垃圾回收的活动。
虚引用与软引用和弱引用的一个区别在于: 虚引用必须和引用队列(ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。程序如果发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。
特别注意,在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。
2.4 不可达的对象并非“非死不可”即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候他们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程,可达性分析法中的不可达对象第一次标记并且进行一次筛选,筛选的条件就是此对象是否有必要执行finalize方法。当对象没有覆盖finalize方法,或finalize方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。
被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。
2.5 如何判断一个常量是 废弃常量?运行时常量池主要回收的是废弃的常量。那么,我们如何判断一个常量是废弃的常量呢?
假如字符串常量池中存在字符串“abc”,如果当前没有任何String对象引用该字符串常量的话,就说明常量“abc’”就是废弃常量,如果这时发生内存回收的话而且有必要的话,“abc”就会被系统清理出常量池。
2.6 如何判断一个类是无用的类方法区主要回收的是无用的类,那么如何判断一个类是无用的类的呢?
判断一个常量是否是“废弃常量”比较简单,而要判断一个类是否是“无用的类”的条件相对苛刻许多。类需要同时满足下面3个条件才能算是**“无用的类”**:
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类的任何实例
- 加载该类的ClassLoader已经本回收
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
虚拟机可以对满足上述3个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。
3.垃圾回收算法 3.1 标记-清除算法该算法分为“标记”和“清除”阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。它是最基础的收集算法,后续的算法都是对其不足进行改进得到。这种垃圾回收算法会带来两个明显的问题:
- 效率问题
- 空间问题(标记清除后产生大量不连续的碎片)
为了解决效率问题,“标记-复制”收集算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉,这样就使每次的内存回收都是对内存区间的一半进行回收。
3.3 标记-整理算法根据老年代的特点提出的一种标记算法,标记过程仍然与“标记-清理”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边线以外的内存。
3.4 分代收集算法当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同分为几块。一般将Java堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。
比如新生代中,每次垃圾收集都会有大量对象死去,所以可以选择“标记-复制”算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集
延伸面试题:HotSpot为什么要分为新生代和老年代?
根据上面的对分代收集算法的介绍回答、



