参考书籍
(豆瓣) 深入理解Java虚拟机(第2版),以下简称为 书
简述
书
中的 3.6 节为 内存分配与回收策略,涉及以下5个小节
3.6.1 对象优先在 Eden 分配
3.6.2 大对象直接进入老年代
3.6.3 长期存活的对象将进入老年代
3.6.4 动态对象年龄判定
3.6.5 空间分配担保
本文将对其中的前3个小节进行简单的分析。本文中例子参考了书
中的代码
3.6.1 对象优先在 Eden 分配
下面是各个程序的堆内存分配情况(只考虑了main
函数和allocate
函数中产生的对象占据的堆内存)
程序名 | eden | from | to | tenured |
---|---|---|---|---|
Empty.java | 0 | 0 | 0 | 0 |
Enough.java | 4M | 0 | 0 | 0 |
StillGood.java | 6M | 0 | 0 | 0 |
Old.java | 2M | 0 | 0 | 6M |
首先是 Enough.java
Enough.java
public class Enough {
public static void main(String[] args) {
Object o1 = allocate(2); // 引用的对象大小为2M左右
Object o2 = allocate(2); // 引用的对象大小为2M左右
}
public static Object allocate(int N) {
// size is roughly N megabyte
Object o = new byte[N * 1024 * 1024];
return o;
}
}
在命令行执行如下命令
// -Xms20M 設定起始 Java 堆集大小=20M
// -Xmx20M 設定 Java 堆集大小上限=20M
// -XX:SurvivorRatio=8 新生代中 Eden 区域与 Survivor 区域的容量比值
// -XX:+UseSerialGC 使用 Serial+Serial Old 的收集器组合进行内存回收
// -XX:+PrintGCDetails 打印 GC 的详细信息
javac Enough.java && java -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:+PrintGCDetails -cp . Enough
结果为
运行结果
-
o1
引用的对象大小为2M
左右 -
o2
引用的对象大小为2M
左右
照理说 Eden 区域被使用的空间应该为 2M+2M=4M
左右(4M/8M=50%
)。但是从图中可以看到是 Eden 区域实际上被使用了 64%
左右。我猜测虚拟机运行时会有一些与main
函数没有直接关系的对象分配在 Eden 区域。为了进行验证,我们可以把 Enough.java
中 main
方法里的语句都删除掉,得到 Empty.java
如下
Empty.java
public class Empty {
public static void main(String[] args) {
}
public static Object allocate(int N) {
// size is roughly N megabyte
Object o = new byte[N * 1024 * 1024];
return o;
}
}
在命令行输入以下命令
// -Xms20M 設定起始 Java 堆集大小=20M
// -Xmx20M 設定 Java 堆集大小上限=20M
// -XX:SurvivorRatio=8 新生代中 Eden 区域与 Survivor 区域的容量比值
// -XX:+UseSerialGC 使用 Serial+Serial Old 的收集器组合进行内存回收
// -XX:+PrintGCDetails 打印 GC 的详细信息
javac Empty.java && java -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:+PrintGCDetails -cp . Empty
运行结果如下
由此可见,即使
main
函数什么都不做,Eden 区域也被占用了 14%
左右(具体数值应该和虚拟机有关)。而 Enough.java
中 o1
和 o2
引用的对象大小分别为 2M
左右,所以 o1
引用的对象占据 Eden 区域的比例有 25%
左右,o2
引用的对象占据 Eden 区域的比例也有 25%
左右。这样 14% + 25% + 25% = 64%
,就可以解释通了。
StillGood.java
我们在 Enough.java
中 main
函数的注基础上再分配一个对象,得到 StillGood.java
如下
public class StillGood {
public static void main(String[] args) {
Object o1 = allocate(2);
Object o2 = allocate(2);
Object o3 = allocate(2);
}
public static Object allocate(int N) {
// size is roughly N megabyte
Object o = new byte[N * 1024 * 1024];
return o;
}
}
和 Enough.java
中的 main
相比, StillGood.java
的 main
函数中多了一个 o3
,o3
引用的对象的大小约为 2M
(2M/8M=25%)。
所以如果运行的参数不变,StillGood.java
编译后运行时,Eden 区域被占用的比例应该为 64% + 25% = 89%
左右
我们在命令行输入以下命令
// -Xms20M 設定起始 Java 堆集大小=20M
// -Xmx20M 設定 Java 堆集大小上限=20M
// -XX:SurvivorRatio=8 新生代中 Eden 区域与 Survivor 区域的容量比值
// -XX:+UseSerialGC 使用 Serial+Serial Old 的收集器组合进行内存回收
// -XX:+PrintGCDetails 打印 GC 的详细信息
javac StillGood.java && java -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:+PrintGCDetails -cp . StillGood
运行结果如下
运行结果
Old.java
我们看到 StillGood.java
在运行时,Eden 空间已经被占据 89%
左右,如果 main
函数里再分配一个大小合适的对象,就会发生 Minor GC
了(因为 Eden 空间快要被分配完了)。
在 StillGood.java
的基础上,我们在 main
方法中再产生一个大小为 2M
左右的对象,对应的程序为 Old.java
,具体如下
public class Old {
public static void main(String[] args) {
Object o1 = allocate(2);
Object o2 = allocate(2);
Object o3 = allocate(2);
Object o4 = allocate(2);
}
public static Object allocate(int N) {
// size is roughly N megabyte
Object o = new byte[N * 1024 * 1024];
return o;
}
}
在命令行执行如下命令
// -Xms20M 設定起始 Java 堆集大小=20M
// -Xmx20M 設定 Java 堆集大小上限=20M
// -XX:SurvivorRatio=8 新生代中 Eden 区域与 Survivor 区域的容量比值
// -XX:+UseSerialGC 使用 Serial+Serial Old 的收集器组合进行内存回收
// -XX:+PrintGCDetails 打印 GC 的详细信息
javac Old.java && java -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:+PrintGCDetails -cp . Old
运行结果如下
可见
o1
, o2
, o3
引用的对象都被挪动到老年代了。我觉得是因为 Minor GC
的时候 to survivor
空间只有1M左右,不够放置 o1
, o2
, o3
所引用对象中的任意一个,所以只好把它们都挪动到老年代了。
3.6.2 大对象直接进入老年代
为了便于比较,先写一个 Naive.java
,代码如下
public class Naive {
public static void main(String[] args) {
Object o1 = allocate(1); // 约占 1M 内存
Object o2 = allocate(1); // 约占 1M 内存
}
public static Object allocate(int N) {
// size is roughly N megabyte
Object o = new byte[N * 1024 * 1024];
return o;
}
}
执行的命令为
// -Xms20M 設定起始 Java 堆集大小=20M
// -Xmx20M 設定 Java 堆集大小上限=20M
// -XX:SurvivorRatio=8 新生代中 Eden 区域与 Survivor 区域的容量比值
// -XX:+UseSerialGC 使用 Serial+Serial Old 的收集器组合进行内存回收
// -XX:+PrintGCDetails 打印 GC 的详细信息
javac Naive.java && java -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:+PrintGCDetails -cp . Naive
o1,o2引用的对象分配在 Eden 区域
计算如下
- 在给定的命令行选项下,Eden 区域的大小约为
8M
- 在给定的命令行选项下,即使
main
函数里没有代码,Eden 区域也会被占用14%
左右 -
o1
引用的对象约有1M
大小 -
o2
引用的对象约有1M
大小
所以14% + 1/8 + 1/8 = 39%
,和图示数据比较吻合。
我们在main
函数里再分配一个大一些的对象,具体如下
public class More {
public static void main(String[] args) {
Object o1 = allocate(1); // 约占 1M 内存
Object o2 = allocate(1); // 约占 1M 内存
Object o3 = allocate(2); // 约占 2M 内存
}
public static Object allocate(int N) {
// size is roughly N megabyte
Object o = new byte[N * 1024 * 1024];
return o;
}
}
执行如下命令,会看到o1
,o2
,o3
引用的对象都分配在 Eden 区域
// -Xms20M 設定起始 Java 堆集大小=20M
// -Xmx20M 設定 Java 堆集大小上限=20M
// -XX:SurvivorRatio=8 新生代中 Eden 区域与 Survivor 区域的容量比值
// -XX:+UseSerialGC 使用 Serial+Serial Old 的收集器组合进行内存回收
// -XX:+PrintGCDetails 打印 GC 的详细信息
javac More.java && java -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:+PrintGCDetails -cp . More
o1,o2,o3引用的对象都分配在 Eden 区域
计算如下
- 在给定的命令行选项下,Eden 区域的大小约为
8M
- 在给定的命令行选项下,即使
main
函数里没有代码,Eden 区域也会被占用14%
左右 -
o1
引用的对象约有1M
大小 -
o2
引用的对象约有1M
大小 -
o3
引用的对象约有2M
大小
所以14% + 1/8 + 1/8 + 2/8 = 64%
,和图示数据比较吻合。
如果加上 -XX:PretenureSizeThreshold=1500000
这个选项,可以让大于 1500000
字节(约等于1.5M)的对象直接在分配在老年代。那么o1
和o2
仍旧在 Eden 区域进行分配,而 o3
会直接分配在老年代。
书
中提到
PretenureSizeThreshold 参数只对 Serial 和 ParNew 两款收集器有效
完整的命令如下
// -Xms20M 設定起始 Java 堆集大小=20M
// -Xmx20M 設定 Java 堆集大小上限=20M
// -XX:SurvivorRatio=8 新生代中 Eden 区域与 Survivor 区域的容量比值
// -XX:+UseSerialGC 使用 Serial+Serial Old 的收集器组合进行内存回收
// -XX:+PrintGCDetails 打印 GC 的详细信息
// -XX:PretenureSizeThreshold 直接晋升到老年代的对象大小,设置这个参数后,大于这个参数的对象将直接在老年代分配
javac More.java && java -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:+PrintGCDetails -XX:PretenureSizeThreshold=1500000 -cp . More
o3引用的对象直接分配在老年代
可以看到o1
,o2
引用的对象分配在 Eden 区域,而o3
引用的对象直接分配在老年代。
计算如下
- 在给定的命令行选项下,Eden 区域的大小约为
8M
- 在给定的命令行选项下,老年代的大小约为
10M
- 在给定的命令行选项下,即使
main
函数里没有代码,Eden 区域也会被占用14%
左右 -
o1
引用的对象约有1M
大小 -
o2
引用的对象约有1M
大小 -
o3
引用的对象约有2M
大小
所以
- Eden 区域被占用的比例为
14% + 1/8 + 1/8 = 39%
- 老年代被占用的比例为
2/10 = 20%
和图中的数据比较吻合
3.6.3 长期存活的对象将进入老年代
假设 Eden 空间有 8M
左右,如果我们不停地分配大小 6M
左右的对象的话,就可以不停地触发 GC
(从第二次分配起,每分配一次就触发一次 GC
)。
public class Age {
public static void main(String[] args) {
Object o = allocate(0.16); // 一直存活至程序结束
// 在命令行执行循环执行的次数(执行 bound 次)
int bound = Integer.parseInt(args[0]);
if (bound < 0 || bound > 100) {// bound 的值不能太离谱
return;
}
for (int i = 0; i < bound;i++) {
// 不停地分配大小约为 6M 的内存,
// 从而不停地触发 GC(假设 Eden 区域的总大小为 8M 左右)
// 分配之后, 这 6M 左右的空间就立刻变为"垃圾", 可以再被回收
allocate(6);
}
}
public static Object allocate(double N) {
// size is roughly N megabyte
Object o = new byte[(int)(N * 1024) * 1024];
return o;
}
}
bound=0 的情况
命令行执行的命令如下
// -Xms20M 設定起始 Java 堆集大小=20M
// -Xmx20M 設定 Java 堆集大小上限=20M
// -XX:SurvivorRatio=8 新生代中 Eden 区域与 Survivor 区域的容量比值
// -XX:+UseSerialGC 使用 Serial+Serial Old 的收集器组合进行内存回收
// -XX:+PrintGCDetails 打印 GC 的详细信息
// -XX:MaxTenuringThreshold 晋升到老年代的对象年龄。每个对象在坚持过一次 Minor GC 之后,年龄就增加 1,当超过这个参数值时就进入老年代
javac Age.java && java -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:+PrintGCDetails -XX:MaxTenuringThreshold=2 -cp . Age 0
如果先不看这些选项的话
本质上是在执行 java Age 0
其中 0
指定了程序中 bound
变量的值
执行结果如下
计算如下
- 在给定的命令行选项下,Eden 区域的大小约为
8M
- 在给定的命令行选项下,即使
main
函数里没有代码,Eden 区域也会被占用14%
左右 -
o
引用的对象约有0.16M
大小
所以 Eden 空间被占用的比例为 14% + 0.16/8 = 16%
,和图中的数据吻合
bound=1 的情况
我们再执行 java Age 1
(完整的命令如下)
// 各个选项的值没有变化,只修改了位于行尾的参数(0->1)
javac Age.java && java -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:+PrintGCDetails -XX:MaxTenuringThreshold=2 -cp . Age 1
执行结果如下
Threshold=2, bound=1
计算如下
- 在给定的命令行选项下,Eden 区域的大小约为
8M
- 在给定的命令行选项下,即使
main
函数里没有代码,Eden 区域也会被占用14%
左右 -
o
引用的对象约有0.16M
大小 -
for
循环中调用allocate
函数时分配的对象约有6M
大小
所以 Eden 空间被占用的比例为 14% + 0.16/8 + 6/8 = 91%
,和图中的数据吻合
bound=2 的情况
我们再执行 java Age 2
(完整的命令如下)
// 各个选项的值没有变化,只修改了位于行尾的参数(1->2)
javac Age.java && java -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:+PrintGCDetails -XX:MaxTenuringThreshold=2 -cp . Age 2
Threshold=2, bound=2
计算如下
- 在给定的命令行选项下,Eden 区域的大小约为
8M
- 在给定的命令行选项下, 两个 Survivor 区域的大小都约为
1M
-
o
引用的对象约有0.16M
大小 -
for
循环在最后一次执行中,调用allocate
函数时分配的对象约有6M
大小
和 bound=1
相比,由于要多分配一个 6M
左右大小的对象,所以触发了 GC
。 GC
后,o
引用的对象会被移动到 Survivor 区域。
计算 Eden 区域和 Survivor 区域(理论上)被占用的比例
- Eden 区域被占用的比例为
6/8=75%
- To Survivor 区域(由于From/To
Survivor 是相对的,其实这里所说的 To Survivor 就对应图中的from space
)被占用的比例为0.24/1=24%
但是实际上可能有一些与 main
函数没有直接关系的对象也会分配在 Eden 空间(以及移动至 Survivor 区域),所以图中看到 Eden 区域被占用的比例为 77%
,From Survivor 被占用的比例为 43%
o
引用的对象经历了一次 Minor GC
的过程,年龄变为1
bound=3 的情况
我们再执行 java Age 3
(完整的命令如下)
// 各个选项的值没有变化,只修改了位于行尾的参数(2->3)
javac Age.java && java -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:+PrintGCDetails -XX:MaxTenuringThreshold=2 -cp . Age 3
Threshold=2, bound=3
计算如下
- 在给定的命令行选项下,Eden 区域的大小约为
8M
- 在给定的命令行选项下, 两个 Survivor 区域的大小都约为
1M
-
o
引用的对象约有0.16M
大小 -
for
循环在最后一次执行中,调用allocate
函数时分配的对象约有6M
大小
和 bound=2
相比,由于要多分配一个 6M
左右大小的对象,所以多触发了一次 GC
。 第一次 GC
后,o
引用的对象会被移动到 To Survivor 区域(之后 To Survivor 会被称为 From Survivor)。第二次 GC
后,o
引用的对象会从 From Survivor 移动至 To Survivor(之后 To Survivor 会被称为 From Survivor)。
计算 Eden 区域和 Survivor 区域(理论上)被占用的比例
- Eden 区域被占用的比例为
6/8=75%
- To Survivor 区域(由于From/To
Survivor 是相对的,其实这里所说的 To Survivor 就对应图中的from space
)被占用的比例为0.24/1=24%
但是实际上可能有一些与 main
函数没有直接关系的对象也会分配在 Eden 空间(以及移动至 Survivor 区域),所以图中看到 Eden 区域被占用的比例为 77%
,From Survivor 被占用的比例为 42%
o
引用的对象经历了两次 Minor GC
的过程,年龄变为2
,已经达到 -XX:MaxTenuringThreshold=2
选项所指定的阈值,所以下次 Minor GC
的时候,o
引用的对象就会被移动至老年代。
bound=4 的情况
我们再执行 java Age 4
(完整的命令如下)
// 各个选项的值没有变化,只修改了位于行尾的参数(3->4)
javac Age.java && java -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:+PrintGCDetails -XX:MaxTenuringThreshold=2 -cp . Age 4
Threshold=2, bound=4
计算如下
- 在给定的命令行选项下,Eden 区域的大小约为
8M
- 在给定的命令行选项下, 两个 Survivor 区域的大小都约为
1M
-
o
引用的对象约有0.16M
大小 -
for
循环在最后一次执行中,调用allocate
函数时分配的对象约有6M
大小
和 bound=3
相比,由于要多分配一个 6M
左右大小的对象,所以多触发了一次 GC
。 第一次 GC
后,o
引用的对象会被移动到 To Survivor 区域(之后 To Survivor 会被称为 From Survivor)。第二次 GC
后,o
引用的对象会从 From Survivor 移动至 To Survivor(之后 To Survivor 会被称为 From Survivor)。由于 o
引用的对象已经经历了两次 Minor GC
的过程,所以在第三次 GC
时,o
引用的对象会从 From Survivor 移动至老年代。
计算 Eden 区域和老年代(理论上)被占用的比例
- Eden 区域被占用的比例为
6/8=75%
- 老年代区域被占用的比例为
0.24/10=2.4%
但是实际上可能有一些与 main
函数没有直接关系的对象也会分配在 Eden 空间(以及移动至 Survivor 区域),所以图中看到 Eden 区域被占用的比例为 77%
,老年代被占用的比例为 4%
总结如下
bound 的值 | 程序结束时o 引用对象所在位置 |
GC 发生次数 |
---|---|---|
0 | Eden | 0 |
1 | Eden | 0 |
2 | Survivor | 1 |
3 | Survivor | 2 |
4 | 老年代 | 3 |
3.6.4 动态年龄判定
书
中提到
为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了
MaxTenuringThreshold
才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold
中要求的年龄。
和 3.6.3
小节类似,我们仍旧让 Eden 区域的大小为 8M 左右,通过不停地分配大小约为 6M
的对象,来不停地触发 GC
(从第二次分配起,每分配一次就触发一次 GC
)。
public class Play {
public static void main(String[] args) {
Object o = allocate(0.16); // 一直存活至程序结束
// 在命令行执行循环执行的次数(执行 bound 次)
int bound = Integer.parseInt(args[0]);
if (bound < 0 || bound > 100) {// bound 的值不能太离谱
return;
}
for (int i = 0; i < bound;i++) {
// 不停地分配大小约为 6M 的内存,
// 从而不停地触发 GC(假设 Eden 区域的总大小为 8M 左右)
// 分配之后, 这 6M 左右的空间就立刻变为"垃圾", 可以再被回收
allocate(6);
}
}
public static Object allocate(double N) {
// size is roughly N megabyte
Object o = new byte[(int)(N * 1024) * 1024];
return o;
}
}
与 3.6.3
小节的分析类似,如果我们保持各个选项的值不变 (包括-XX:MaxTenuringThreshold=2
这个选项),那么当 bound
参数的值为 0
时,
bound 的值 | 程序结束时o 引用对象所在位置 |
GC 发生次数 |
---|---|---|
0 | Eden | 0 |
1 | Eden | 0 |
2 | Survivor | 1 |
3 | Survivor | 2 |
4 | 老年代 | 3 |
以 bound=2
为例,完整的命令如下
javac Play.java && java -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:+UseSerialGC -XX:+PrintGCDetails -XX:MaxTenuringThreshold=2 -cp . Play 2
运行结果如下
Threshold=2, bound=2
o
引用的对象大小约为 0.16M
,理论上应该占据一个 Survivor 16%
左右的空间,现在由于可能有其他对象的分配,所以实际上占据了约 43%
的空间。那么可以认为有 43% - 16% = 27%
左右的空间是被其他对象占据的。按照刚才提到 书
中的结论,我们希望这个 Survivor 空间可以被占据超过 50%
。
50% - 27% = 23%
, 如果我们分配一个 0.24M
的对象,应该就可以满足要求了
未完待续
网友评论