跳到主要内容

8. 堆

abstract

Java虚拟机(JVM)堆是JVM内存模型的一部分,是用来存储对象实例和数组的内存区域。在Java应用程序运行时,所有的对象实例都在堆上分配。堆是由垃圾收集器管理的,这意味着当对象不再被引用时,垃圾收集器会自动回收这些对象占用的内存。本文的主要内容包括堆空间的内部结构、内存管理(分代)、对象的分配过程、逃逸分析、标量替换等内容。

笔记

栈管运行,堆管存储

一些单词的读音及其意思
  • minor /ˈmaɪnə(r)/ adj.未成年的;次要的;较小的;小调的;二流的
  • major /ˈmeɪdʒə(r)/ adj.主要的;重要的;主修的;较多的
  • survivor /səˈvaɪvə(r)/ n.幸存者;生还者;残存物
  • ratio /ˈreɪʃiəʊ/ n.比率,比例
  • threshold /ˈθreʃhəʊld/ n.极限;临界值

8.1 堆的核心概念

堆针对一个JVM进程来说是唯一的,也就是一个进程只有一个JVM,但是进程包含多个线程,他们是共享同一堆空间的。

JVM内存结构
JVM内存结构
  • 一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域。
  • Java堆区在JVM启动的时候即被创建,其空间大小也就确定了。是JVM管理的最大一块内存空间。
    • 堆内存的大小是可以调节的。
  • 《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。(可以通过操作系统相关课程中的虚拟内存章节做详细的了解。)
  • 所有的线程共享Java堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)。
-Xms10m:最小堆内存
-Xmx10m:最大堆内存
笔记
/**
* -Xms10M -Xmx10M
*/
public class HeapDemo {

public static void main(String[] args) {
System.out.println("start...");

try {
Thread.sleep(10000000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}

System.out.println("end...");
}
}

java

VisualVM 内存空间

VisualVM 内存空间-HeapDemo
VisualVM 内存空间-HeapDemo

Eden Space + Survivor + Old Gen = 10MB

笔记

VisualVM 中本身不带 VisualGC,这是 VisualVM 的一个插件,安装方式如下:

通过 Tools -> Plugins -> Available Plugins 可以安装此插件,安装完之后需要重启VisualVM。

  • 《Java虚拟机规范》中对Java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。(The heap is the run-time data area from which memory for all class instances and arrays is allocated)
    • 我要说的是:“几乎”所有的对象实例都在这里分配内存。——从实际使用角度看的。 因为还有一些对象是在栈上分配的
  • 数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。
  • 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。
笔记
  • 经过逃逸分析后如果对象没有逃逸,可能会直接在栈上分配此对象。
  • 栈帧中的局部变量并不是在方法结束后就立即被回收的,而是触发了GC的时候,才会进行回收
  • 如果堆中对象马上被回收,那么堆空间中的GC频率会特别高,用户线程就受到到影响,因为有 Stop the World (STW)
  • 堆,是GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。
堆的核心概念
堆的核心概念
笔记
public class SimpleHeap {

public int id;

public SimpleHeap(int id) {
this.id = id;
}

public void show() {
System.out.println("");
}

public static void main(String[] args) {
SimpleHeap s1 = new SimpleHeap(1);
SimpleHeap s2 = new SimpleHeap(2);

int[] arr = new int[10];
Object[] arr1 = new Object[10];
}
}
java

main 方法的字节码如下:

 0 new #6 <com/atguigu/java/SimpleHeap>
3 dup
4 iconst_1
5 invokespecial #7 <com/atguigu/java/SimpleHeap.<init> : (I)V>
8 astore_1
9 new #6 <com/atguigu/java/SimpleHeap>
12 dup
13 iconst_2
14 invokespecial #7 <com/atguigu/java/SimpleHeap.<init> : (I)V>
17 astore_2
18 bipush 10
20 newarray 10 (int)
22 astore_3
23 bipush 10
25 anewarray #8 <java/lang/Object>
28 astore 4
30 return
java

new 会在堆空间中为对象分配内存。

8.1.1 堆内存细分

Java 7及之前堆内存逻辑上分为三部分:新生区+养老区+永久区

  • Young Generation Space 新生区 Young/New 又被划分为Eden区和Survivor区
  • Tenure generation space 养老区 Old/Tenure
  • Permanent Space永久区 Perm

Java 8及之后堆内存逻辑上分为三部分:新生区养老区+元空间

  • Young Generation Space新生区 Young/New 又被划分为Eden区和Survivor区
  • Tenure generation space 养老区 Old/Tenure
  • Meta Space 元空间 Meta

约定:新生区 \Harr 新生代 \Harr 年轻代 、 养老区 \Harr 老年区 \Harr 老年代、 永久区 \Harr 永久代

堆和方法区图
堆和方法区图

8.1.2 堆空间内部结构(JDK7)

堆空间-java7
堆空间-java7

8.1.3 堆空间内部结构(JDK8)

堆空间-java8
堆空间-java8

堆空间内部结构,JDK1.8之前从永久代 替换成 元空间

JDK8之前和之后的结构
JDK8之前和之后的结构

8.2 设置堆内存大小与OOM

8.2.1. 堆空间大小的设置

  • Java堆区用于存储Java对象实例,那么堆的大小在JVM启动时就已经设定好了,大家可以通过选项"-Xmx"和"-Xms"来进行设置。
    • -Xms 用于表示堆区的起始内存,等价于-XX:InitialHeapSize
    • -Xmx 则用于表示堆区的最大内存,等价于-XX:MaxHeapSize
  • 一旦堆区中的内存大小超过 -Xmx 所指定的最大内存时,将会抛出 OutOfMemoryError 异常。
  • 通常会将 -Xms-Xmx 两个参数配置相同的值,其目的是 为了能够在ava垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能
  • 默认情况下
    • 初始内存大小:物理电脑内存大小/64
    • 最大内存大小:物理电脑内存大小/4
默认情况
/**
* 1. 设置堆空间大小的参数
* -Xms 用来设置堆空间(年轻代+老年代)的初始内存大小
* -X 是 JVM 的运行参数
* ms 是 memory start
* -Xmx:用来设置堆空间(年轻代+老年代)的最大内存大小
*
* 2. 默认堆空间的大小
* 3. 手动设置:-Xms600m -Xmx600m
* 开发中建议将初始堆内存和最大的堆内存设置成相同的值。
*/
public class HeapSpaceInitial {
public static void main(String[] args) {
// 返回Java虚拟机中的堆内存总量
long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
// 返回Java虚拟机试图使用的最大堆内存
long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;
System.out.println("-Xms:" + initialMemory + "M");
System.out.println("-Xmx:" + maxMemory + "M");

System.out.println("系统内存大小为:" + initialMemory * 64.0 / 1024 + "G");
System.out.println("系统内存大小为:" + maxMemory * 4.0 / 1024 + "G");
}
}
java

输出结果

运行此程序的电脑内存为64GB
-Xms:979M
-Xmx:14523M
系统内存大小为:61.1875G
系统内存大小为:56.73046875G
text
笔记

如果初始堆内存和最大堆内存设置的不一致,则在程序运行期间会不断的分配和释放内存(GC后会调整内存空间大小)。造成系统压力的同时,也会出现分配的物理内存碎片过多的问题。因为每次分配都有一定概率分配到不连续的物理内存上,久而久之就会出现很多不连续的物理内存空间。当然只是物理内存不连续,逻辑上还是连续的。

堆内存设置为600M
/**
* -Xms600m -Xmx600m
*/
public class HeapSpaceInitial {
public static void main(String[] args) {
// 返回Java虚拟机中的堆内存总量
long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
// 返回Java虚拟机试图使用的最大堆内存
long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;
System.out.println("-Xms:" + initialMemory + "M");
System.out.println("-Xmx:" + maxMemory + "M");

try {
Thread.sleep(10000000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
}
java

输出结果:

-Xms:575M
-Xmx:575M
text
question

为什么不是 600MB?

如何查看堆内存的内存分配情况

C:\Users\Administrator>jps
25040 Jps
5360 HeapSpaceInitial

C:\Users\Administrator>jstat -gc 5360
S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT CGC CGCT GCT
25600.0 25600.0 0.0 0.0 153600.0 15360.1 409600.0 0.0 4480.0 775.7 384.0 76.0 0 0.000 0 0.000 - - 0.000
shell
笔记

堆空间 = 新生代 + 老年代

  • 老年代:OC(总量)、OU(已使用)
  • 新生代:Eden、S0、S1
    • Eden:EC(总量)、EU(已使用)
    • S0:S0C(总量)、S0U(已使用)
    • S1:S1C(总量)、S1U(已使用)

堆区的内存总量 = S0C + S1C + EC + OC = (25600+25600+153600+409600)/1024=600(25600 + 25600+ 153600 + 409600)/1024 = 600.

为什么是575?

因为S0和S1是二选一的,这里主要是因为垃圾回收用的是复制算法,S0 和 S1 始终有一个空间是空的。在新生代中,能够放对象的只能是伊甸园区(Eden)加上一个 Survivor 区(S0 或 S1)。如果去掉S1,重新计算,就是575.

堆区的内存总量 = S0C + EC + OC = (25600+153600+409600)/1024=575(25600 + 153600 + 409600)/1024 = 575.

Java 中新生代和老年代的默认比例是 1:2,也即新生代占堆内存的 1/3,而老年代占 2/3。新生代 Eden 区和 Survivor 区的比例是 8:1:1

查看内存设置的参数
方式一:通过命令
jstat -gc <pid>
shell
方式二:通过设置 JVM 参数
-XX:+PrintGCDetails 
text

-XX:+PrintGCDetails 参数是在程序运行结束后打印。打印结果如下:

Heap
PSYoungGen total 179200K, used 12288K [0x00000000f3800000, 0x0000000100000000, 0x0000000100000000)
eden space 153600K, 8% used [0x00000000f3800000,0x00000000f44001b8,0x00000000fce00000)
from space 25600K, 0% used [0x00000000fe700000,0x00000000fe700000,0x0000000100000000)
to space 25600K, 0% used [0x00000000fce00000,0x00000000fce00000,0x00000000fe700000)
ParOldGen total 409600K, used 0K [0x00000000da800000, 0x00000000f3800000, 0x00000000f3800000)
object space 409600K, 0% used [0x00000000da800000,0x00000000da800000,0x00000000f3800000)
Metaspace used 3303K, capacity 4564K, committed 4864K, reserved 1056768K
class space used 358K, capacity 388K, committed 512K, reserved 1048576K
text

8.2.1 OutOfMemory举例

public class OOMTest {
public static void main(String[]args){
ArrayList<Picture> list = new ArrayList<>();
while(true){
try {
Thread.sleep(20);
} catch (InterruptedException e){
e.printStackTrace();
}
list.add(new Picture(new Random().nextInt(1024*1024)));
}
}
}

class Picture {
private byte[] pixels;

public Picture(int length) {
this.pixels = new byte[length];
}
}
java
Exception in thread "main" java.lang.OutofMemoryError: Java heap space
at com.atguigu. java.Picture.<init>(OOMTest. java:25)
at com.atguigu.java.O0MTest.main(OOMTest.java:16)
java

可以设置内存大小,内存设置的越小,OOM的速度越快。

-Xms10m -Xmx:10m
笔记

可以通过 VisualVM 中的 Visual GC 和抽样器(Sampler)来观察内存变化。

8.3 年轻代与老年代

  • 存储在 JVM 中的 Java 对象可以被划分为两类:
    • 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速
    • 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与 JVM 的生命周期保持一致。
  • Java 堆区进一步细分的话,可以划分为年轻代(YoungGen)和老年代(OldGen)
  • 其中年轻代又可以划分为 Eden 空间、Survivor0 空间和 Survivor1 空间(有时也叫做 from 区、to 区)
图8.3.1 堆空��间细节
图8.3.1 堆空间细节

下面这参数开发中一般不会调:

图8.3.2 新生代老年代内存比例
图8.3.2 新生代老年代内存比例
  • Eden:From:To \to 8:1:1
  • 新生代:老年代 \to 1 : 2
笔记

S0 并不一定就是 From 区,S0 和 S1 是不断交换的。复制算法使然。

配置新生代与老年代在堆结构的占比。

  • 默认-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3
  • 可以修改-XX:NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的1/5
笔记
/**
* -Xms600M -Xmx600M
* -XX:-UseAdaptiveSizePolicy
*/
public class EdenSurvivorTest {

public static void main(String[] args) {
System.out.println("我只是来打个酱油~");

try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
java
图8.3.3 默认比例1:2
图8.3.3 默认比例1:2

可以通过官方文档查看参数说明:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html

通过 jinfo 也可以查看 NewRatio 的设置

C:\Users\Administrator>jps
36656 Launcher
3156 Jps
36600 EdenSurvivorTest

C:\Users\Administrator>jinfo -flag NewRatio 36600
-XX:NewRatio=2
shell

设置 -XX:NewRatio=5 后的内存分配信息(新生代占 1/6,老年代占 5/6)如下,

图8.3.4 设置为1:6
图8.3.4 设置为1:6

当发现在整个项目中,生命周期长的对象偏多,那么就可以通过调整 老年代的大小,来进行调优

  • 在 HotSpot 中,Eden 空间和另外两个 survivor 空间缺省所占的比例是 8:1:18:1:1
  • 当然开发人员可以通过选项 -XX:SurvivorRatio 调整这个空间比例。比如 -XX:SurvivorRatio=8
笔记

默认情况下 JVM 有一个自适应机制,并不一定是 8:1:18:1:1,如老师课程上的程序运行起来就是 6:1:16:1:1(图8.3.3)。可以使用 -XX:-UseAdaptiveSizePolicy 来关掉自适应机制(参数中的减号表示禁用某个选项)。参数并不起作用

要想使比例为 8:1:18:1:1 需要使用 -XX:SurvivorRatio=8。官方说明如下:

-XX:+UseAdaptiveSizePolicy

Enables the use of adaptive generation sizing. This option is enabled by default. To disable adaptive generation sizing, specify -XX:-UseAdaptiveSizePolicy and set the size of the memory allocation pool explicitly (see the -XX:SurvivorRatio option).

重点:要想关闭自适应机制,需要需要指定 -XX:-UseAdaptiveSizePolicy 并显示的设置内存分配池的大小。即需要使用 -XX:-UseAdaptiveSizePolicy 来关闭自适应机制,并使用 -XX:SurvivorRatio 来显式的指定内存分配的比例。直接使用 -XX:SurvivorRatio 来显示的指定分配比例也是可以的。

  • 几乎所有的 Java 对象都是在 Eden 区被 new 出来的。
  • 绝大部分的 Java 对象的销毁都在新生代进行了。(有些大的对象在 Eden 区无法存储时候,将直接进入老年代)
    • IBM 公司的专门研究表明,新生代中 80% 的对象都是“朝生夕死”的。
  • 可以使用选项 -Xmn 设置新生代最大内存大小
    • 这个参数一般使用默认值就可以了。
笔记

-Xmn-XX:NewRatio 是矛盾的,如果设置了 -Xmn,则以 -Xmn 为准。

对象从新生代到老年代的过程
对象从新生代到老年代的过程

8.4 图解对象分配过程

8.4.1 对象分配过程:概念

为新对象分配内存是一件非常严谨和复杂的任务,JM 的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑 GC 执行完内存回收后是否会在内存空间中产生内存碎片。

  1. new 的对象先放伊甸园区。此区有大小限制。
  2. 当伊甸园的空间填满时,程序又需要创建对象,JVM 的垃圾回收器将对伊甸园区进行垃圾回收(MinorGC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区
  3. 然后将伊甸园中的剩余对象移动到幸存者 0 区。
  4. 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者 0 区的,如果没有回收,就会放到幸存者 1 区。
  5. 如果再次经历垃圾回收,此时会重新放回幸存者 0 区,接着再去幸存者 1 区。
  6. 啥时候能去养老区呢?可以设置次数。默认是 15 次。
    • 可以设置参数:进行设置 -Xx:MaxTenuringThreshold=<N> 进行设置
  7. 在养老区,相对悠闲。当养老区内存不足时,再次触发 GC:Major GC,进行养老区的内存清理
  8. 若养老区执行了 Major GC 之后,发现依然无法进行对象的保存,就会产生 OOM 异常。
    • java.lang.OutOfMemoryError: Java heap space

8.4.2 对象分配过程

新生代对象分配与回收过程
新生代对象分配与回收过程
笔记

举例:以当兵为例,正常人的晋升可能是 : 新兵 \to 班长 \to 排长 \to 连长

但是也有可能有些人因为做了非常大的贡献,直接从 新兵 \to 排长,跨级晋升。

思考:幸存区区满了后?

  • 特别注意,在 Eden 区满的时候,才会触发 MinorGC (Young GC),如果是幸存者区满了,并不会触发 MinorGC。
  • MinorGC 会将 Eden 区和 Survivor 区一起回收。Survivor 区是被动回收。
  • 如果 Survivor 区满了后,将会触发一些特殊的规则,也就是可能直接晋升老年代

  • 对象也有可能不经过新生代,直接到到老年代。
  • 新生代采用复制算法的目的:是为了减少内碎片

总结:

  • 针对幸存者 S0,S1 区的总结:复制之后有交换,谁空谁是 To
  • 关于垃圾回收:频繁在新生区收集,很少在养老区(老年代)收集,几乎不在永久区(永久代)/元空间收集

8.4.3 对象分配的特殊情况

对象从新生代到老年代的流程
对象从新生代到老年代的流程
/**
* -Xms600m -Xmx600m
*/
public class HeapInstanceTest {
byte [] buffer = new byte[new Random().nextInt(1024 * 200)];
public static void main(String[] args) throws InterruptedException {
ArrayList<HeapInstanceTest> list = new ArrayList<>();
while (true) {
list.add(new HeapInstanceTest());
Thread.sleep(10);
}
}
}
java
从Eden到Old
从Eden到Old
各个分代的内存走势以及GC情况
各个分代的内存走势以及GC情况

8.4.4 常用的调优工具

  • JDK命令行
  • Eclipse:Memory Analyzer Tool
  • Jconsole
  • Visual VM(实时监控 推荐~)
  • Jprofiler(推荐~)
  • Java Flight Recorder(实时监控)
  • GCViewer
  • GCEasy

8.5 Minor GC、MajorGC与Full GC

  • Minor GC:新生代的GC(等价于 Young GC、YGC)
  • Major GC:老年代的GC(或 Old GC)
  • Full GC:整堆收集,收集整个Java堆和方法区的垃圾收集

我们都知道,JVM 的调优的一个环节,也就是垃圾收集,我们需要尽量的避免垃圾回收,因为在垃圾回收的过程中,容易出现 STW 的问题。

而 Major GC 和 Full GC出现STW的时间,是Minor GC的10倍以上,重点是对 Major GC 和 Full GC 进行调优,减少它们的 GC 时间

8.5.1 Minor GC、Major GC、Full GC

JVM 在进行 GC 时,并非每次都对上面三个内存区域一起回收的,大部分时候回收的都是指新生代

针对 Hotspot VM 的实现,它里面的 GC 按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(Full GC)

  • 部分收集:不是完整收集整个 Java 堆的垃圾收集。其中又分为:
    • 新生代收集(MinorGC/YoungGC):只是新生代的垃圾收集
    • 老年代收集(MajorGC/o1dGC):只是老年代的圾收集。
      • 目前,只有 CMSGC 会有单独收集老年代的行为。
      • 注意,很多时候 Major GC 会和 Full GC 混淆使用,需要具体分辨是老年代回收还是整堆回收
    • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集。
      • 目前,只有 G1 GC 会有这种行为
  • 整堆收集(Full GC):收集整个 Java 堆和方法区的垃圾收集。
笔记

主要是因为 G1 是通过 Region 来管理内存,这个 Region 可能是新生代也可能是老年代,因此在收集的时候会产生混合收集。

8.5.2 最简单的分代式GC策略的触发条件

年轻代GC(Minor GC)触发机制:

  • 当年轻代空间不足时,就会触发 MinorGC,这里的年轻代满指的是 Eden 代满,Survivor 满不会引发 GC。(每次 Minor GC 会清理年轻代的内存。)
  • 因为 Java 对象大多都具备 朝生夕灭 的特性,所以 Minor GC 非常频繁,一般回收速度也比较快。这一定义既清晰又易于理解。
  • Minor GC 会引发 STW,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行

STW:stop the word

对象从新生代到老年代的过程
对象从新生代到老年代的过程

老年代GC(Minor GC/Full GC)触发机制:

  • 指发生在老年代的 GC,对象从老年代消失时,我们说 “Major Gc” 或 “Full GC” 发生了
  • 出现了 Major GC,经常会伴随至少一次的 Minor GC(但非绝对的,在 Parallel Scavenge 收集器的收集策略里就有直接进行 Major GC 的策略选择过程)
    • 也就是在老年代空间不足时,会先尝试触发 Minor Gc。如果之后空间还不足,则触发 Major GC
  • Major GC 的速度一般会比 Minor GC 慢 10 倍以上,STW 的时间更长
  • 如果 Major GC 后,内存还不足,就报 OOM 了

Full GC触发机制:(后面细讲)

触发 Full GC 执行的情况有如下五种:

  • 调用 System.gc() 时,系统建议执行 Full GC,但是不必然执行
  • 老年代空间不足
  • 方法区空间不足
  • 通过 Minor GC 后进入老年代的平均大小大于老年代的可用内存
  • 由 Eden 区、survivor space0(From Space)区向 survivor space1(To Space)区复制时,对象大小大于 To Space 可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小

说明:Full GC 是开发或调优中尽量要避免的。这样暂时时间会短一些

GC 举例

我们编写一个 OOM 的异常,因为我们在不断的创建字符串,是存放在元空间的

/**
* GC测试
*/
public class GCTest {
public static void main(String[] args) {
int i = 0;
try {
List<String> list = new ArrayList<>();
String a = "atguigu.com";
while(true) {
list.add(a);
a = a + a;
i++;
}
} catch (Throwable e) {
e.printStackTrace();
System.out.println("遍历次数为:" + i);
}
}
}
java
笔记

注意这里的细节,捕获的是 java.lang.Throwable。要注意这些 OutOfMemoryError 的继承关系。如果捕获的是 Exception catch 代码块是不会执行的。

设置JVM启动参数

-Xms10m -Xmx10m -XX:+PrintGCDetails
bash

打印出的日志

[GC (Allocation Failure) [PSYoungGen: 2041K->504K(2560K)] 2041K->720K(9728K), 0.0009112 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 2226K->502K(2560K)] 2442K->1477K(9728K), 0.0007261 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 2302K->502K(2560K)] 3277K->2357K(9728K), 0.0005671 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Ergonomics) [PSYoungGen: 1246K->0K(2560K)] [ParOldGen: 6079K->4863K(7168K)] 7325K->4863K(9728K), [Metaspace: 3072K->3072K(1056768K)], 0.0032423 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 0K->0K(2560K)] 4863K->4863K(9728K), 0.0005199 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[Full GC (Allocation Failure) [PSYoungGen: 0K->0K(2560K)] [ParOldGen: 4863K->4848K(7168K)] 4863K->4848K(9728K), [Metaspace: 3072K->3072K(1056768K)], 0.0027811 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Arrays.java:3332)
at java.lang.AbstractStringBuilder.ensureCapacityInternal(AbstractStringBuilder.java:124)
at java.lang.AbstractStringBuilder.append(AbstractStringBuilder.java:448)
at java.lang.StringBuilder.append(StringBuilder.java:142)
at com.atguigu.java.GCTest.main(GCTest.java:14)
遍历次数为:16
Heap
PSYoungGen total 2560K, used 185K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
eden space 2048K, 9% used [0x00000000ffd00000,0x00000000ffd2e528,0x00000000fff00000)
from space 512K, 0% used [0x00000000fff80000,0x00000000fff80000,0x0000000100000000)
to space 512K, 0% used [0x00000000fff00000,0x00000000fff00000,0x00000000fff80000)
ParOldGen total 7168K, used 4848K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
object space 7168K, 67% used [0x00000000ff600000,0x00000000ffabc078,0x00000000ffd00000)
Metaspace used 3144K, capacity 4486K, committed 4864K, reserved 1056768K
class space used 334K, capacity 386K, committed 512K, reserved 1048576K

触发 OOM 的时候,一定是进行了一次 Full GC,因为只有在老年代空间不足时候,才会爆出 OOM 异常。

8.6 堆空间分代思想

为什么要把Java堆分代?不分代就不能正常工作了吗?

  • 经研究,不同对象的生命周期不同。70%~99%70\% \text{\textasciitilde} 99\% 的对象是临时对象。
    • 新生代:有 Eden、两块大小相同的 Survivor(又称为from/to,S0/S1)构成,to 总为空。
    • 老年代:存放新生代中经历多次GC仍然存活的对象。
JDK7内存分代
JDK7内存分代

其实不分代完全可以,分代的唯一理由就是优化 GC 性能。如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。GC 的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描。而很多对象都是朝生夕死的,如果分代的话,把新创建的对象放到某一地方,当 GC 的时候先把这块存储“朝生夕死”对象的区域进行回收,这样就会腾出很大的空间出来。

JDK8内存分代
JDK8内存分代

8.7 内存分配策略

如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1。对象在 Survivor 区中每熬过一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁,其实每个 JVM、每个 GC 都有所不同)时,就会被晋升到老年代

对象晋升老年代的年龄阀值,可以通过选项 -XX:MaxTenuringThreshold 来设置

针对不同年龄段的对象分配原则如下所示:

  • 优先分配到 Eden
    • 开发中比较长的字符串或者数组,会直接存在老年代,但是因为新创建的对象 都是 朝生夕死的,所以这个大对象可能也很快被回收,但是因为老年代触发 Major GC 的次数比 Minor GC 要更少,因此可能回收起来就会比较慢
  • 大对象直接分配到老年代
    • 尽量避免程序中出现过多的大对象
  • 长期存活的对象分配到老年代即超过年龄阈值的对象,经过多次回收后依然存活的对象
  • 动态对象年龄判断
    • 如果 Survivor 区中相同年龄的所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。
  • 空间分配担保 大量的对象经过GC后都是存活的,极端情况下所有对象都存活。Survivor 区无法容纳的对象就会放到老年区
    • -XX:HandlePromotionFailure
笔记

下面的例子来测试大对象直接进入老年代

/**
* 测试:大对象直接进入老年代
* -Xms60m -Xmx60m -XX:NewRatio=2 -XX:SurvivorRatio=8 -XX:+PrintGCDetails
*/
public class YoungOldAreaTest {

public static void main(String[] args) {
byte[] buffer = new byte[1024 * 1024 * 20]; // 20MB
}
}
java
运行结果
Heap
PSYoungGen total 18432K, used 2638K [0x00000000fec00000, 0x0000000100000000, 0x0000000100000000)
eden space 16384K, 16% used [0x00000000fec00000,0x00000000fee93a38,0x00000000ffc00000)
from space 2048K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x0000000100000000)
to space 2048K, 0% used [0x00000000ffc00000,0x00000000ffc00000,0x00000000ffe00000)
ParOldGen total 40960K, used 20480K [0x00000000fc400000, 0x00000000fec00000, 0x00000000fec00000)
object space 40960K, 50% used [0x00000000fc400000,0x00000000fd800010,0x00000000fec00000)
Metaspace used 3354K, capacity 4564K, committed 4864K, reserved 1056768K
class space used 364K, capacity 388K, committed 512K, reserved 1048576K
text

对象占用了 20M,很明显 Eden 区、from 区、to 区是没有一个空间能够容纳这个对象的。这时我们关注下 Old 区的情况

ParOldGen total 40960K, used 20480K [0x00000000fc400000, 0x00000000fec00000, 0x00000000fec00000)

20480K 就是 20M,并且从上面的日志看,并没有经过一次 GC。由此可证明我们的这个大对象是直接进入到 Old 区的。

8.8 为对象分配内存:TLAB

笔记

问题:堆空间都是共享的么?

不一定,因为还有TLAB这个概念,在堆中划分出一块区域,为每个线程所独占

8.8.1 对象分配过程:TLAB

为什么有 TLAB(Thread Local Allocation Buffer)?

笔记

为每个线程单独分配了一个缓冲区

  • 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据
  • 由于对象实例的创建在 JVM 中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的
  • 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。

什么是TLAB?

  • 从内存模型而不是垃圾收集的角度,对 Eden 区域继续进行划分,JVM 为每个线程分配了一个私有缓存区域,它包含在 Eden 空间内。
  • 多线程同时分配内存时,使用 TLAB 可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略
  • 据我所知所有 OpenJDK 衍生出来的 JVM 都提供了 TLAB 的设计。
TLAB 从Eden区为每个线程单独分配一份空间
TLAB 从Eden区为每个线程单独分配一份空间

TLAB 的再说明:

  • 尽管不是所有的对象实例都能够在 TLAB 中成功分配内存,但 JVM 确实是将 TLAB 作为内存分配的首选
  • 在程序中,开发人员可以通过选项 -XX:UseTLAB 设置是否开启 TLAB 空间。
  • 默认情况下,TLAB 空间的内存非常小,仅占有整个 Eden 空间的 1%,当然我们可以通过选项 -XX:TLABWasteTargetPercent 设置 TLAB 空间所占用 Eden 空间的百分比大小。
  • 一旦对象在 TLAB 空间分配内存失败时,JVM 就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在 Eden 空间中分配内存。

TLAB分配过程

对象首先是通过 TLAB 开辟空间,如果不能放入,那么需要通过 Eden 来进行分配

第08章_对象分配过程.jpg
第08章_对象分配过程.jpg

8.9 小结:堆空间的参数设置

官网地址:https://docs.oracle.com/javase/8/docs/technotes/tools/windows/java.html

  • -XX:+PrintFlagsInitial:查看所有的参数的默认初始值
  • -XX:+PrintFlagsFinal:查看所有的参数的最终值(可能会存在修改,不再是初始值)
  • -Xms:初始堆空间内存(默认为物理内存的1/64)
  • -Xmx:最大堆空间内存(默认为物理内存的1/4)
  • -Xmn:设置新生代的大小。(初始值及最大值)
  • -XX:NewRatio:配置新生代与老年代在堆结构的占比
  • -XX:SurvivorRatio:设置新生代中Eden和S0/S1空间的比例
  • -XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄
  • -XX:+PrintGCDetails:输出详细的GC处理日志
    • 打印 GC 简要信息:①-XX:+PrintGC-verbose:gc
  • -XX:HandlePromotionFalilure:是否设置空间分配担保

在发生 Minor GC 之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。

  • 如果大于,则此次 Minor GC 是安全的
  • 如果小于,则虚拟机会查看 -XX:HandlePromotionFailure 设置值是否允担保失败。
    • 如果 HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小。
      • 如果大于,则尝试进行一次 Minor GC,但这次 Minor GC 依然是有风险的;
      • 如果小于,则改为进行一次 Full GC。
    • 如果 HandlePromotionFailure=false,则改为进行一次 Full GC。

在 JDK6 Update24 之后 (可直接记 JDK7 之后)HandlePromotionFailure 参数不会再影响到虚拟机的空间分配担保策略,观察 OpenJDK 中的源码变化,虽然源码中还定义了 HandlePromotionFailure 参数,但是在代码中已经不会再使用它。JDK6 Update24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行 Minor GC,否则将进行 Full GC。

8.10 堆是分配对象的唯一选择么?

扩展内容

在《深入理解 Java 虚拟机》中关于 Java 堆内存有这样一段描述:

随着 JIT 编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。

在 Java 虚拟机中,对象是在 Java 堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。

此外,前面提到的基于 OpenJDk 深度定制的 TaoBaoVM,其中创新的 GCIH(GC invisible heap)技术实现 off-heap,将生命周期较长的 Java 对象从 heap 中移至 heap 外,并且 GC 不能管理 GCIH 内部的 Java 对象,以此达到降低 GC 的回收频率和提升 GC 的回收效率的目的。

8.10.1 逃逸分析概述

  • 如何将堆上的对象分配到栈,需要使用逃逸分析手段。
  • 这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。
  • 通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。
  • 逃逸分析的基本行为就是分析对象动态作用域:
    • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。栈上分配
    • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。

逃逸分析举例

public void my_method() {
V v = new V(); // 作用域只在方法内部
// use v
// ....
v = null;
}
java

没有发生逃逸的对象,则可以分配到栈上,随着方法执行的结束,栈空间就被移除。

为什么要放到栈上
  1. 栈是线程私有的,每个线程一份,不用考虑同步问题,可以并行执行。
  2. 栈中是一个一个的栈帧,栈帧对应方法的调用,当我们方法执行完成后,方法对应的栈帧直接弹出栈。弹出栈之后对应的内存空间就得到了释放。在栈中不存在 GC

public static StringBuffer createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb; // 对象可能在方法外部被使用,发生了逃逸
}
java

上述代码如果想要 StringBuffer sb 不发生逃逸,可以这样写:

public static String createStringBuffer(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
java

完整的逃逸分析代码举例

/**
* 逃逸分析
*
* 如何快速的判断是否发生了逃逸分析,大家就看 new 的对象实体是否有可能在方法外被调用。
*/
public class EscapeAnalysis {

public EscapeAnalysis obj;

/**
* 方法返回EscapeAnalysis对象,发生逃逸
*/
public EscapeAnalysis getInstance() {
return obj == null ? new EscapeAnalysis():obj;
}

/**
* 为成员属性赋值,发生逃逸
*/
public void setObj() {
this.obj = new EscapeAnalysis();
}
// 思考:如果当前的 obj 声明为 static 的,上面的方法中 new 的对象是否会发生逃逸?仍然会发生逃逸。

/**
* 对象的作用于仅在当前方法中有效,没有发生逃逸
*/
public void useEscapeAnalysis() {
EscapeAnalysis e = new EscapeAnalysis();
}

/**
* 引用成员变量的值,发生逃逸
*/
public void useEscapeAnalysis2() {
EscapeAnalysis e = getInstance(); // 发生逃逸,因为是方法外创建的对象。
// getInstance().XXX 同样会发生逃逸
}
}
java

参数设置

  • 在 JDK 6u23 (或直接记JDK 1.7) 版本之后,HotSpot中默认就已经开启了逃逸分析

如果使用的是较早的版本,开发人员则可以通过:

  • 选项 -XX:+DoEscapeAnalysis 显式开启逃逸分析
  • 通过选项 -XX:+PrintEscapeAnalysis 查看逃逸分析的筛选结果

结论

开发中能使用局部变量的,就不要使用在方法外定义。

8.10.2 逃逸分析:代码优化

使用逃逸分析,编译器可以对代码做如下优化:

  • 一、栈上分配:将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会发生逃逸,对象可能是栈上分配的候选,而不是堆上分配
  • 二、同步省略:如果一个对象被发现只有一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
  • 三、分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。

栈上分配

  • JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。
  • 常见的栈上分配的场景
    • 在逃逸分析中,已经说明了。分别是给成员变量赋值、方法返回值、实例引用传递。
举例

我们通过举例来说明 开启逃逸分析 和 未开启逃逸分析时候的情况

/**
* 栈上分配
* -Xmx1G -Xms1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails
*/
class User {
private String name;
private String age;
private String gender;
private String phone;
}
public class StackAllocation {
public static void main(String[] args) throws InterruptedException {
long start = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println("花费的时间为:" + (end - start) + " ms");

// 为了方便查看堆内存中对象个数,线程sleep
Thread.sleep(10000000);
}

private static void alloc() {
// 未发生逃逸
User user = new User();
}
}
java

设置JVM参数,-DoEscapeAnalysis 表示禁用逃逸分析

-Xmx1G -Xms1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails

运行结果,同时还触发了GC操作

[GC (Allocation Failure) [PSYoungGen: 262144K->856K(305664K)] 262144K->864K(1005056K), 0.0009031 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 263000K->856K(305664K)] 263008K->872K(1005056K), 0.0009598 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 263000K->808K(305664K)] 263016K->824K(1005056K), 0.0007032 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 262952K->856K(305664K)] 262968K->872K(1005056K), 0.0006776 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 263000K->792K(305664K)] 263016K->808K(1005056K), 0.0009230 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 262936K->920K(348160K)] 262952K->936K(1047552K), 0.0009725 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 348056K->0K(347648K)] 348072K->704K(1047040K), 0.0007446 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 347136K->0K(347648K)] 347840K->704K(1047040K), 0.0010038 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 346112K->0K(347648K)] 346816K->704K(1047040K), 0.0005948 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
[GC (Allocation Failure) [PSYoungGen: 346112K->0K(347648K)] 346816K->704K(1047040K), 0.0008105 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
花费的时间为:275 ms

然后查看内存的情况,发现有大量的 User 存储在堆中

未开启逃逸分析的内存占用
未开启逃逸分析的内存占用

我们在开启逃逸分析

-Xmx1G -Xms1G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails

然后查看运行时间,我们能够发现花费的时间快速减少,同时不会发生GC操作

花费的时间为:5 ms

在看内存情况,我们发现只有很少的 User 对象,说明 User 未发生逃逸,因为它存储在栈中,随着栈的销毁而消失

开启逃逸分析的内存占用
开启逃逸分析的内存占用

将内存改小,改为 256M

-Xmx256M -Xms256M -XX:+DoEscapeAnalysis -XX:+PrintGCDetails

言外之意就是:堆空间内存变小,但是创建了对象又足够多,也就是说可能会发生 GC。

重新执行代码后发现根本没有发生 GC,也就是说,我们开启了逃逸分析后,上面的代码没有发生 GC,对象直接在栈帧弹出时被销毁。

同步省略

  • 线程同步的代价是相当高的,同步的后果是降低并发性和性能。

  • 在动态编译同步块的时候,JIT 编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么 JIT 编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除

笔记

好处就是提高了并发性和性能

例如下面的代码

public void f() {
Object hellis = new Object();
synchronized(hellis) { // 每个线程进来都是新创建的对象,起不到加锁的作用
System.out.println(hellis);
}
}
java

代码中对 hellis 这个对象加锁,但是 hellis 对象的生命周期只在 f() 方法中,并不会被其他线程所访问到,所以在 JIT 编译阶段就会被优化掉。优化成:

public void f() {
Object hellis = new Object();
System.out.println(hellis);
}
java

我们将其转换成字节码

 0 new #2 <java/lang/Object>
3 dup
4 invokespecial #1 <java/lang/Object.<init> : ()V>
7 astore_1
8 aload_1
9 dup
10 astore_2
11 monitorenter
12 getstatic #3 <java/lang/System.out : Ljava/io/PrintStream;>
15 aload_1
16 invokevirtual #4 <java/io/PrintStream.println : (Ljava/lang/Object;)V>
19 aload_2
20 monitorexit
21 goto 29 (+8)
24 astore_3
25 aload_2
26 monitorexit
27 aload_3
28 athrow
29 return
java

分离对象和标量替换

标量(scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。

相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。

在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。

public static void main(String args[]) {
alloc();
}
class Point {
private int x;
private int y;
}
private static void alloc() {
Point point = new Point(1,2);
System.out.println("point.x" + point.x + ";point.y" + point.y);
}
java

以上代码,经过标量替换后,就会变成

private static void alloc() {
int x = 1;
int y = 2;
System.out.println("point.x = " + x + "; point.y=" + y);
}
java

可以看到,Point这个聚合量经过逃逸分析后,发现他并没有逃逸,就被替换成两个标量了。那么标量替换有什么好处呢?就是可以大大减少堆内存的占用。因为一旦不需要创建对象了,那么就不再需要分配堆内存了。
标量替换为栈上分配提供了很好的基础。

代码优化之标量替换

上述代码在主函数中进行了1亿次alloc。调用进行对象创建,由于User对象实例需要占据约16字节的空间,因此累计分配空间达到将近1.5GB。如果堆空间小于这个值,就必然会发生GC。使用如下参数运行上述代码:

-server -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations
bash

这里设置参数如下:

  • 参数-server:启动 server 模式,因为在 server 模式下,才可以启用逃逸分析。
  • 参数-XX:+DoEscapeAnalysis:启用逃逸分析
  • 参数-Xmx10m:指定了堆空间最大为 10MB
  • 参数-XX:+PrintGC:将打印 GC 日志
  • 参数-XX:+EliminateAllocations:开启了标量替换(默认打开),允许将对象打散分配在栈上,比如对象拥有id和name两个字段,那么这两个字段将会被视为两个独立的局部变量进行分配

逃逸分析小结:逃逸分析并不成熟

  • 关于逃逸分析的论文在1999年就已经发表了,但直到JDK1.6才有实现,而且这项技术到如今也并不是十分成熟。
  • 其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。
  • 一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。
  • 虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段。
  • 注意到有一些观点,认为通过逃逸分析,JVM 会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是取决于 JVM 设计者的选择。据我所知,Oracle Hotspot JVM 中并未这么做,这一点在逃逸分析相关的文档里已经说明,所以可以明确所有的对象实例都是创建在堆上。
  • 目前很多书籍还是基于 JDK7 以前的版本,JDK 已经发生了很大变化,intern 字符串的缓存和静态变量曾经都被分配在永久代上,而永久代已经被元数据区取代。但是,intern 字符串缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,所以这一点同样符合前面一点的结论:对象实例都是分配在堆上。

小结

年轻代是对象的诞生、成长、消亡的区域,一个对象在这里产生、应用,最后被垃圾回收器收集、结束生命。

老年代放置长生命周期的对象,通常都是从survivor区域筛选拷贝过来的Java对象。当然,也有特殊情况,我们知道普通的对象会被分配在TLAB上;如果对象较大,JVM会试图直接分配在Eden其他位置上;如果对象太大,完全无法在新生代找到足够长的连续空闲空间,JVM就会直接分配到老年代。当GC只发生在年轻代中,回收年轻代对象的行为被称为MinorGc。

当GC发生在老年代时则被称为MajorGc或者FullGC。一般的,MinorGc的发生频率要比MajorGC高很多,即老年代中垃圾回收发生的频率将大大低于年轻代。