java内存分配机制

本博文对应《深入理解java虚拟机》第三章第三部分。

内存管理无非就是两件事——分配内存和回收内存,之前已经有讲述了垃圾回收机制,但是其实没有讲述一个对象在内存里是怎么分配的。

优先在Eden区

在没有读这本书之前,我一直以为是所有的对象都会直接分配到新生代的Eden区里。实际上是绝大部分的对象会被分配到这里,当这里没有足够的空间的时候,虚拟机就会触发一次Minor GC(次要垃圾回收)。示例代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class testAllocation {
public static final int MB = 1024 * 1024;

public testAllocation() {
byte[] a1, a2, a3, a4;
a1 = new byte[2 * MB];
a2 = new byte[2 * MB];
a3 = new byte[2 * MB];
a4 = new byte[4 * MB];
}

public static void main(String[] args) {
new testAllocation();
}
}

这段代码很简单,就是先后分配了2MB的空间给3个对象,然后最后一个对象分配到了4MB的空间。

然后编译执行这段代码:

1
2
javac testAllocation.java
java -Xms20m -Xmx20m -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 testAllocation
  • -Xms,初始的Heap的大小。这里分配了20M

  • -Xmx,最大Heap的大小。一般和Xms一样,也是20M,这样堆就不可以扩展了

  • -Xmn,新生代的大小,这里分配了10M,同样意味着老年代也是10M

  • -XX:+PrintGCDetails,显示详细的日志信息。

  • -XX:SurvivorRatio=8,新生代中,Eden一个survivor空间的比例。而新生代有两个survivor。所以它们之间的比例是8:1:1,也就是其实的Eden区域是8M,新生代的可用空间9M

代码中可以看到,分配a1,a2,a3这三个共计6M的空间是没有问题的,但是最后一个a4会发现当时的可用空间只剩下3M了,但是a4这个对象需要4M,所以会发生一次Minor GC,但是显然a123这三个对象无法被回收,而这三个对象每个都比一个survivor空间要大(2M>1M),所以它们只能被放到老年代。然后Eden空间就被清空了,就能够放得下a4了。实际运行结果如图所示:

image-20200104101349003

  1. 新生代可用空间,从6M多一点的空间变成了266K(因为三个对象被移走了),总共是9216K(新生代的可用空间,为eden+from的总量)
  2. GC之前堆的容量(6651K,和上面那条里的6651K是一样的),GC之后的堆的容量(这里我认为有问题)和堆的总容量
  3. 新生代共计9M可用空间,最后用了4M(用来存放a4)
  4. 新生代的三个区域的总空间和所用空间,可以看到确实是8:1:1

大对象直接入老年代

java虚拟机最讨厌什么?短命的大对象。大对象需要连续的空间,而短命大对象又只需要很短时间的连续空间,好不容易费力清理出空间,才住一小会就退宿了,差不多就是这种感觉。

大对象对于虚拟机来说比较好的办法是直接放到老年代,因为新生代的survivor区域一般来说很小,不如直接放到老年代比较划算。示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
public class testPretenureSizeThreshold {
public static final int MB = 1024 * 1024;

public testPretenureSizeThreshold() {
byte[] a1 = new byte[4 * MB];
}

public static void main(String[] args) {
new testPretenureSizeThreshold();
}
}

代码来说非常简单,创建一个4M大小的字节数组。然后编译并且运行:

1
2
javac testPretenureSizeThreshold.java
java -Xms20m -Xmx20m -Xmn10M -XX:+PrintGCDetails -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=3145728 testPretenureSizeThreshold

PS:Pretenure的意思是青春期(就是之前一直说的老年代…),且这里只能用字节数,不能直接写成3M

image-20200104104941525

可以看到红框内 新生代几乎是没有使用空间,而老年代刚好4M。

长期存活进入老年代

之前提到对象是在新生代的Eden空间中的,经过第一次的Minor GC之后,如果对象存活,就会被安置在survivor空间中,并且年龄加一。之后每次经过一次Minor GC,年龄就加一,到指定年龄之后(默认是15)就会被放到老年代里,你可以通过-XX:MaxTenuringThreshold=15来修改这个值。示例略过。

显然如果要手动指定这个会显得有点死板,所以其实还有另外一种机制,当survivor空间里相同年龄的对象所占空间超过survivor的一半的时候,就会把年龄大于或者等于该年龄的对象全部搬动到老年代里面。

空间分配担保

之前有讲到Minor GC的是把新生代中from survivor区域和Eden区域中的对象做一次垃圾回收然后放到to survivor区域中,但是to survivor区域并不能保证一定能够存放下(毕竟这个区域默认只占10%),之前讲到的是需要老年代来帮助执行,用的就是这个机制。

这个分成几种情况来进行讨论,首先是老年代的空间足够放得下Eden+from survivor区域的所有对象,那放心大胆进行操作好了不会出现任何问题。第二种情况是老年代放不下,这个时候可能会出现问题,但是其实出现问题概率不大,所以你可以通过设置HandlePromotionFailure来设置是否允许担保失败。建议还是打开,因为基本上很少出现某一次Minor GC突然有很多对象存活下来导致连老年代都放不下从而触发full gc的情况的。

这个在JDK6之后发生了变化,HandlePromotionFailure已经没有用了。