Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
堆是垃圾收集器管理的主要区域,又称为“GC 堆”,可以说是 Java 虚拟机管理的内存中最大的一块。
现在的虚拟机(包括 HotSpot VM)都是采用分代回收算法。在分代回收的思想中, 把堆分为:新生代 + 老年代 + 永久代(1.8没有了); 新生代又分为 Eden + From Survivor + To Survivor 区。
分代
Java 的堆内存分代是指将不同生命周期的堆内存对象存储在不同的堆内存区域中,这里的不同的堆内存区域被定义为“代”。这样做有助于提升垃圾回收的效率,因为这样的话就可以为不同的“代”设置不同的回收策略。
一般来说,Java 中的大部分对象都是朝生夕死的,同时也有一部分对象会持久存在。因为如果把这两部分对象放到一起分析和回收,这样效率实在是太低了。通过将不同时期的对象存储在不同的内存池中,就可以节省宝贵的时间和空间,从而改善系统的性能。
Java 的堆由新生代(Young Generation)和老年代(Old Generation)组成。新生代存放新分配的对象,老年代存放长期存在的对象。
很多对象都会出现在 Eden 区,当 Eden 区的内存容量用完的时候,GC 会发起,非存活对象会被标记为死亡,存活的对象被移动到 Survivor 区。
如果 Survivor 的内存容量也用完,那么存活对象会被移动到老年代。
老年代(Old)是对象存活时间最长的部分,它由单一存活区(Tenured)组成,并且把经历过若干轮 GC 回收还存活下来的对象移动而来。在老年代中,大部分对象都是活了很久的,所以 GC 回收它们会很慢。
晋升
一般情况下,对象将在新生代进行分配,首先会尝试在 Eden 区分配对象,当 Eden 内存耗尽,无法满足新的对象分配请求时,将触发新生代的 GC(Young GC、MinorGC),在新生代的 GC 过程中,没有被回收的对象会从 Eden 区被搬运到 Survivor 区,这个过程通常被称为“晋升”
同样的,对象也可能会晋升到老年代,触发条件主要看对象的大小和年龄。对象进入老年代的条件有三个,满足一个就会进入到老年代:
躲过 15 次 GC。每次垃圾回收后,存活的对象的年龄就会加 1,累计加到 15 次(jdk 8 默认的),也就是某个对象躲过了 15 次垃圾回收,那么 JVM 就认为这个是经常被使用的对象,就没必要再待在年轻代中了。具体的次数可以通过
-XX:MaxTenuringThreshold
来设置在躲过多少次垃圾收集后进去老年代。动态对象年龄判断。规则:如果在 Survivor 空间中小于等于某个年龄的所有对象大小的总和大于 Survivor 空间的一半时,那么就把大于等于这个年龄的对象都晋升到老年代。
大对象直接进入老年代。
-XX:PretenureSizeThreshold
来设置大对象的临界值,大于该值的就被认为是大对象,就会直接进入老年代。(PretenureSizeThreshold
默认是 0,也就是说,默认情况下对象不会提前进入老年代,而是直接在新生代分配。然后就 GC 次数和基于动态年龄判断来进入老年代。)
针对上面的三点来逐一分析。
动态年龄判断
为了能更好地适应不同程序的内存状况,HotSpot 虚拟机并不是永远要求对象的年龄必须达到 -XX:MaxTenuringThreshold
才能晋升老年代,他还有一个动态年龄判断的机制。
在《深入理解Java虚拟机(第三版)》中是这么描述动态年龄判断的过程的:
为了能更好地适应不同程序的内存状况,hospt虚拟机并不是永远要求对象的年龄必须达到
-XX:MaxTenuringThreshold
才能晋升老年代,如果在 survivor 空间中相同年龄所有对象大小的总和大于 sumvivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到-XX:MaxTenuringThreshold
中要求的年龄。
但是,这段描述是不正确的!
JVM 中,动态年龄判断的代码如下:
uint ageTable::compute_tenuring_threshold(size_t survivor_capacity) {
size_t desired_survivor_size = (size_t)((((double) survivor_capacity)*TargetSurvivorRatio)/100);
size_t total = 0;
uint age = 1;
while (age < table_size) {
total += sizes[age];
if (total > desired_survivor_size) break;
age++;
}
uint result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;
...
}
它的过程是从年龄小的对象开始,不断地累加对象的大小,当年龄达到 N 时,刚好达到 TargetSurvivorRatio 这个阈值,那么就把所有年龄大于等于 N 的对象全部晋升到老年代去!
所以,这过程应该是这样的:
如果在 Survivor 空间中小于等于某个年龄的所有对象大小的总和大于 Survivor 空间的一半时,那么就把大于等于这个年龄的对象都晋升到老年代。
新生代如果只有两个区域是否可以?
答案是不行,如果只有两个区域,也能实现复制算法,但是会大大浪费空间。
我们知道,新生代进一步区分了一个 Eden 区和 2 个 Survivor 区,一共有 Eden、Survivor From、Survivor To 这三个区域,那么,为什么需要三个区域呢?2 个行不行呢?
这其实涉及到新生代的垃圾回收算法了。
根据默认配置,新生代有一个 Eden 区,两个 Survivor 区,Eden 区占 80% 内存空间,每一块 Survivor 区占 10%
因为新生代主要使用的是标记-复制算法进行垃圾回收的。 刚开始对象都分配在 Eden 区,如果 Eden 区快满了就触发垃圾回收,把 Eden 区中的存活对象转移到一块空着的 Survivor 区,Eden 区清空,然后再次分配新对象到 Eden 区,再触发垃圾回收,就把 Eden 区存活的和 Survivor 区存活的转移到另一块空着的 Survivor。
那么也就是说,在平常的时候,新生代的区域中是只有一块 Eden 和一块 Survivor 区在被使用的,而另一块 Survivor 区是空着的,所以内存使用率大约 90%。
如果没有三个区域,只有两个,比如只有一个 Eden 和一个 Survivor:
如果此时 Eden 区进行 Young GC 之后,会如下图所示:
那么,接下来继续创建对象的时候,如果继续向 Eden 分配:
如果之后进行第二次 Young GC 的时候,就不能只扫描 Eden 区,还要扫描 Survivor 区。那么,就不能使用标记-复制算法了,因为标记-复制算法的要求是必须有一块区域是空着的。
而如果使用标记-清除算法或者标记-整理算法的话,就会存在碎片和效率等问题。
那么,如果改一下,从 Eden 复制到 Survivor 之后,再次分配新对象的时候分配到 Survivor 呢?然后 Survivor 满了再把对象复制到 Eden,这样循环往复?
这样做,或许可以实现复制算法了,但是带来的问题就是两个区域都会承担新对象的分配工作,那么他的内存就都得足够大,那么就要分配成 1:1,这样的话,整个新生代的同一时刻只能有 1/2 的空间被使用,利用率很低。
Survivor 不够怎么办?
在 Young GC 之后,如果存活的对象所需要的空间比 Survivor 区域的空间大怎么办呢?毕竟一块 Survivor 区域的比例只是年轻的 10% 而已。
这时候就需要把对象移动到老年代。
空间分配担保机制
如果 Survivor 区域的空间不够,就要分配给老年代,也就是说,老年代起到了一个兜底的作用。但是,老年代也是可能空间不足的。所以,在这个过程中就需要做一次空间分配担保(CMS):
在每一次执行 Young GC 之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。
如果大于,那么说明本次 Young GC 是安全的。
如果小于,那么虚拟机会查看 HandlePromotionFailure
参数设置的值判断是否允许担保失败。如果值为 true
,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小(一共有多少对象在内存回收后存活下来是不可预知的,因此只好取之前每次垃圾回收后晋升到老年代的对象大小的平均值作为参考)。如果大于,则尝试进行一次 Young GC,但这次 Young GC 依然是有风险的;如果小于,或者 HandlePromotionFailure=false
,则会直接触发一次 Full GC。
但是,需要注意的是 HandlePromotionFailure
这个参数,在 JDK 7 中就不再支持了
在 JDK 代码中,移除了这个参数的判断(https://github.com/openjdk/jdk/commit/cbc7f8756a7e9569bbe1a38ce7cab0c0c6002bf7 ),也就是说,在后续的版本中, 只要检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则认为担保成功。
但是需要注意的是,担保的结果可能成功,也可能失败。所以,在 Young GC 的复制阶段执行之后,会发生以下三种情况:
剩余的存活对象大小,小于Survivor区,那就直接进入 Survivor 区。
剩余的存活对象大小,大于Survivor区,小于老年代可用内存,那就直接去老年代。
剩余的存活对象大小,大于Survivor并且大于老年代,触发“Full GC”。