目录
简介
Java技术体系中所提倡的自动内存管理最终可以归结为自动化地解决了两个问题: 给对象分配内存以及回收分配给对象的内存。
对象的内存分配,往大方向上讲,就是在堆上分配(但也可能经过JIT编译后被拆 散为标量类型并间接地在栈上分配)。
对象主要分配在新生代的Eden区上,如果启动 了本地线程分配缓冲,将按线程优先在TLAB分配。少数情况下也可能会直接分配在 老年代中,分配的规则并不是百分之百固定的,其细节取决于当前使用的是哪一种垃圾 收集器组合,还有虚拟机中与内存相关的参数的设置。
接下来我们将会讲解几条最普遍的内存分配规则,并通过代码去验证这些规则。代码在测试时使用Client模式虚拟机运行,没有手工指定收集器组合,换句话 说,验证的是使用Serial / Serial Old收集器下(ParNew / Serial Old收集器组合的规则 也基本一致)的内存分配和回收的策略。
对象优先在Eden分配
大多数情况下,对象在新生代Eden区中分配。
当Eden区没有足够的空间进行分配 时,虚拟机将发起一次Minor GC。
尝试分配3个2MB大小和1个4MB 大小的对象,在运行时通过-Xms20M. -Xmx20M和-XmnlOM这3个参数限制Java 堆大小为20MB,且不可扩展,其中10MB分配给新生代,剩下的10MB分配给老 年代。-XX:SurvivorRatio=8决定了新生代中Eden区与一个Survivor区的空间比例 是8比1,从输出的结果也能清晰地看到"eden space 8192K、from space 1024K、to space 1024K"的信息,新生代总可用空间为9216KB (Eden区+1个Survivor区的总 容量)。
分配allocation4对象的语句时会发生一次Minor GC(原来6m,加入4m,eden8m,空间不够)。
这次GC的结果是新生代6651KB变为148KB,而总内存占用量则几乎没有减少(因为 allocations 2、3三个对象都是存活的,虚拟机几乎没有找到可回收的对象)。这次GC 发生的原因是给allocation分配内存的时候,发现Eden已经被占用了 6MB,剩余空间 已不足以分配allocation4所需的4MB内存,因此发生Minor GC。GC期间虚拟机又发 现已有的3个2MB大小的对象全部无法放入Survivor空间(Survivor空间只有1MB大 小),所以只好通过分配担保机制提前转移到老年代去。
这次GC结束后,4MB的allocation对象被顺利分配在Eden中。因此程序执行完 的结果是Eden占用4MB (被allocation占用),Survivor空闲,老年代被占用6MB (被 allocation 1. 2、3占用)。
GC的分类
针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:
部分收集 (Partial GC):
1 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
2 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
3 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。
整堆收集 (Full GC):收集整个 Java 堆和方法区。
大对象直接进入老年代
所谓大对象就是指,需要大量连续内存空间的Java对象,最典型的大对象就是那种很 长的字符串及数组(byte[]数组就是典型的大对象)。大对象对虚拟机的内存 分配来说就是一个坏消息(替Java虚拟机抱怨一句,比遇到一个大对象更加坏的消息就是 遇到一群“朝生夕灭”的“短命大对象”,写程序的时候应当避免),经常出现大对象容易 导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。
虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接 在老年代中分配。这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内 存拷贝。
分配一个4MB的byte数组后,我们看到Eden空间 几乎没有被使用,而老年代10MB的空间被使用了 40%,也就是4MB的allocation 对象直接就分配在老年代中,这是因为PretenureSizeThreshold被设置为3MB (就是 3145728B,这个参数不能与-Xmx之类的参数一样直接写3MB),因此超过3MB的对 象都会直接在老年代中进行分配。
为什么要这样呢?
为了避免为大对象分配内存时由于分配担保机制带来的复制而降低效率。
长期存活的对象将进入老年代
虚拟机既然采用了分代收集的思想来管理内存,那内存回收时就必须能识别哪些对 象应当放在新生代,哪些对象应放在老年代中。
为了做到这点,虚拟机给每个对象定义 了一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次Minor GC后仍然 存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1。 对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定 程度(默认为15岁)时,就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以 通过参数-XX:MaxTenuringThreshold 来设置。
读者可以试试分别以-XX:MaxTenuringThreshold=1 和-XX:MaxTenuringThreshold= 15 两种设置。
allocation对象需要256KB的内存空间,Survivor空间可以容纳。当MaxTenuringThreshold= 1 时,allocationl对象在第二次GC发生时进入老年代,新生代已使用的内存GC后 会非常干净地变成0KB。而MaxTenuringThreshold=15时,第二次GC发生后, allocationl对象则还留在新生代Survivor空间,这时候新生代仍然有404KB的空间 被占用。
关于默认的晋升年龄是 15,这个说法的来源大部分都是《深入理解 Java 虚拟机》这本书。 如果你去 Oracle 的官网阅读相关的虚拟机参数 (opens new window),你会发现-XX:MaxTenuringThreshold=threshold
这里有个说明
Sets the maximum tenuring threshold for use in adaptive GC sizing. The largest value is 15. The default value is 15 for the parallel (throughput) collector, and 6 for the CMS collector.默认晋升年龄并不都是 15,这个是要区分垃圾收集器的,CMS 就是 6.
动态对象年龄判定
为了能更好地适应不同程序的内存状况,虚拟机并不总是要求对象的年龄必须达到 MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小 的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年 代,无须等到MaxTenuringThreshold中要求的年龄。
“Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了 survivor 区的一半时,取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值”。
大小阈值默认值是 50%,可以通过 -XX:TargetSurvivorRatio=50
来设置
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;
...
}
设置参数-XX: MaxTenuringThreshold= 15,会发现运行结果中Survivor的空间占用仍然为0%,而老年 代比预期增加了 6%,也就是说allocation 1. allocation2对象都直接进入了老年代,而没 有等到15岁的临界年龄。因为这两个对象加起来已经达到了 512KB,并且它们是同年 的,满足同年对象达到Survivor空间的一半规则。我们只要注释掉其中一个对象的new 操作,就会发现另外一个不会晋升到老年代中去了。
空间分配担保
JDK 6 Update 24 之前,在发生 Minor GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次 Minor GC 可以确保是安全的。如果不成立,则虚拟机会先查看 -XX:HandlePromotionFailure
参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者 -XX: HandlePromotionFailure
设置不允许冒险,那这时就要改为进行一次 Full GC。
JDK 6 Update 24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行 Full GC。
在发生Minor GC时,虚拟机会检测之前每次晋升到老年代的平均大小是否大于 老年代的剩余空间大小,如果大于,则改为直接进行一次Full GC。如果小于,则査看 HandlePromotionFailure设置是否允许担保失败:如果允许,那只会进行Minor GC :如 果不允许,则也要改为进行一次Full GC。
前面提到过,新生代使用复制收集算法,但为了内存利用率,只使用其中一个 Survivor空间来作为轮换备份,因此当出现大量对象在Minor GC后仍然存活的情况时 (最极端就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,让 Survivor无法容纳的对象直接进入老年代。
与生活中的贷款担保类似,老年代要进行这 样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下 来,在实际完成内存回收之前是无法明确知道的,所以只好取之前毎一次回收晋升到老 年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行 Full GC来让老年代腾出更多空间。
取平均值进行比较其实仍然是一种动态概率的手段,也就是说如果某次Minor GC存活后的对象突增,远远高于平均值的话,依然会导致担保失败(Handle Promotion Failure)。
如果出现了 HandlePromotionFailure失败,那就只好在失败后 重新发起一次Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还 是会将HandlePromotionFailure开关打开,避免Full GC过于频繁
内存结构图
总流程
首先,新增一个新对象,如果 对象很大,直接进入老年代。如果不大,进入eden,如果eden区不够 ,进行minor GC。
在发生Minor GC时,虚拟机会检测之前每次晋升到老年代的平均大小是否大于 老年代的剩余空间大小,如果大于,则改为直接进行一次Full GC。如果小于,则査看 HandlePromotionFailure设置是否允许担保失败:如果允许,那只会进行Minor GC :如 果不允许,则也要改为进行一次Full GC。
minor GC将这次的eden和survior放入另一个survior,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1)
“Hotspot 遍历Survivor的所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了 survivor 区的一半时,取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值”,年龄大于或等于该年龄的对象就可以直接进入老年代。
经过这次 GC 后,Eden 区和"From"区已经被清空。这个时候,"From"和"To"会交换他们的角色,也就是新的"To"就是上次 GC 前的“From”,新的"From"就是上次 GC 前的"To"。不管怎样,都会保证名为 To 的 Survivor 区域是空的。
Minor GC 会一直重复这样的过程,在这个过程中,有可能当次 Minor GC 后,Survivor 的"From"区域空间不够用,有一些还达不到进入老年代条件的实例放不下,则放不下的部分会提前进入老年代。
当它的年龄增加到一定程度(默认为大于 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold
来设置默认值,这个值会在虚拟机运行过程中进行调整,可以通过-XX:+PrintTenuringDistribution
来打印出当次 GC 后的 Threshold。
如果对象进入老年代后内存不够,进行Full GC。