前言
本文整理了关于堆的相关内容,包括:
- 堆内存划分
- 对象分配过程
- 分带垃圾回收触发机制
- TLAB
- 逃逸分析
1、堆的核心概念
堆是用来存放对象的内存空间,几乎所有的对象都存储在堆中。
堆还具有以下特点:
- Java堆是JVM管理的最大一块内存空间,也是GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。
- 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。
- 堆是线程共享的,在一个进程中被多个线程共享。
可以通过选项-Xmx和-Xms来进行设置堆内存的大小。
-Xms用于表示堆区的起始内存,等价于-XX:InitialHeapSize-Xmx则用于表示堆区的最大内存,等价于-XX:MaxHeapSize
一旦堆区中的内存大小超过
-Xmx所指定的最大内存时,将会抛出OutOfMemoryError异常。
通常会将
-Xms和-Xmx两个参数配置相同的值,其目的是为了能够在ava垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。
默认情况下:初始内存大小为物理电脑内存大小 / 64;最大内存大小为物理电脑内存大小 / 4
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
不管是永久代还是元空间,虽然逻辑上属于堆,但其实可以认为是独立与堆存在的,关于这部分内容将在下一篇《方法区》中介绍,本文主要关注新生代和老年代。
2、年轻代与老年代
经过上面的介绍,我们已经知道,堆可以划分为新生代和老年代。
存储在JVM中的Java对象可以被划分为两类:
- 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速
- 另外一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致
这两类对象,正好对应新生代和老年代;其中年轻代又可以划分为Eden空间、Survivor0空间和Survivor1空间(有时也叫做from区、to区)。
几乎所有的Java对象都是在Eden区被new出来的。绝大部分的Java对象的销毁都在新生代进行了。
- 默认
-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3 - 默认
-xx:SurvivorRatio=8,表示Eden、s1、s2的比例为 8:1:1
可以使用选项
-Xmn设置新生代最大内存大小,这个参数一般使用默认值就可以了,因为堆大小、新生代老年代大小比例确定了,新生代大小也就确定了。
对象分配过程
为新对象分配内存是一件非常严谨和复杂的任务,JVM的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间中产生内存碎片。
- new的对象先放伊甸园区。此区有大小限制。
- 当伊甸园的空间填满时,程序又需要创建对象,JVM的垃圾回收器将对伊甸园区进行垃圾回收(MinorGC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区
- 然后将伊甸园中的剩余对象移动到幸存者0区。
- 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者0区的,如果没有回收,就会放到幸存者1区。
- 如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。
- 啥时候能去养老区呢?可以设置次数。默认是15次。
可以设置参数:进行设置-Xx:MaxTenuringThreshold= N
- 在养老区,相对悠闲。当养老区内存不足时,再次触发GC:Major GC,进行养老区的内存清理
- 若养老区执行了Major GC之后,发现依然无法进行对象的保存,就会产生OOM异常。
此外,如果new的对象伊甸园区放不下,会执行
MinorGC,如果还是放不下,会尝试直接放入养老区,如果养老区也放不下,执行Major GC,这时候如果还放不下,就会报 OOM 了。 年轻代在进行垃圾回收的时候,会把存活的对象放入幸存者区,如果幸存者区放不下某个大对象,则会直接放入养老区。 下图清楚的展示了以上过程:
为什么要把Java堆分代?其实不分代完全可以,分代的唯一理由就是优化GC性能。如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。GC的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描。而很多对象都是朝生夕死的,这部分对象需要频繁的回收,如果分代的话,把新创建的对象放到某一地方,当GC的时候先把这块存储“朝生夕死”对象的区域进行回收,避免了在整个堆内存空间上频繁扫描进行垃圾回收。
Minor GC,MajorGC、Full GC
针对Hotspot VM的实现,它里面的GC按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(FullGC)
部分收集:不是完整收集整个Java堆的垃圾收集。其中又分为:
- 新生代收集(Minor GC / Young GC):只是新生代的垃圾收集
- 老年代收集(Major GC / Old GC):只是老年代的圾收集。
- 混合收集(MixedGC):收集整个新生代以及部分老年代的垃圾收集。 整堆收集(Full GC):收集整个java堆和方法区的垃圾收集。
不同垃圾收集器有会所区别,目前,只有CMSGC会有单独收集老年代的行为;只有G1 GC会有混合收集;很多时候Major GC会和Full GC混淆使用,需要具体分辨是老年代回收还是整堆回收。
年轻代GC(Minor GC)触发机制
- 当年轻代空间不足时,就会触发MinorGC,这里的年轻代满指的是Eden代满,Survivor满不会引发GC。(所以Survivor区的垃圾回收是被动回收,只有当Eden区满时才会触发Survivor区的垃圾回收。)
- 因为Java对象大多都具备朝生夕灭的特性.,所以Minor GC非常频繁,一般回收速度也比较快。这一定义既清晰又易于理解。
- Minor GC会引发STW,暂停其它用户的线程,等垃圾回收结束,用户线程才恢复运行
老年代GC(Major GC / Full GC)触发机制
- 指发生在老年代的GC,对象从老年代消失时,我们说 “Major GC” 或 “Full GC” 发生了
- 出现了Major Gc,经常会伴随至少一次的Minor GC(但非绝对的,在Paralle1 Scavenge收集器的收集策略里就有直接进行MajorGC的策略选择过程)
也就是在老年代空间不足时,会先尝试触发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 是开发或调优中尽量要避免的。这样暂时时间会短一些
关于垃圾回收:频繁在新生区收集,很少在老年代收集,几乎不再永久代和元空间进行收集
TLAB
TLAB 的全称是 Thread Local Allocation Buffer,即线程本地分配缓存区,是属于 Eden 区的,这是一个线程专用的内存分配区域,线程私有,默认开启的(当然也不是绝对的,也要看哪种类型的虚拟机)
堆是全局共享的, 在同一时间,可能会有多个线程在堆上申请空间,但每次的对象分配需要同步的进行(虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性)但是效率却有点下降
所以用 TLAB 来避免多线程冲突,在给对象分配内存时,每个线程使用自己的 TLAB,这样可以使得线程同步,提高了对象分配的效率
当然并不是所有的对象都可以在 TLAB 中分配内存成功,如果失败了就会使用加锁的机制来保持操作的原子性。
也就是说,虚拟机采用两种方式来保证线程安全:
- CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
- TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配
逃逸分析
Java 世界中“几乎”所有的对象都在堆中分配,但是,随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。
逃逸分析的基本行为就是分析对象动态作用域:
-
当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
-
当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。
使用逃逸分析,编译器可以对代码做如下优化:
- 同步省略:如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。
- 将堆分配转化为栈分配:如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。
- 分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在 CPU 寄存器中。
标量替换举例:
public static void main(String args[]) {
alloc();
}
private static void alloc() {
Point point = new Point(1,2);
System.out.println("point.x" + point.x + ";point.y" + point.y);
}
class Point {
private int x;
private int y;
}
以上代码,经过标量替换后,就会变成
private static void alloc() {
int x = 1;
int y = 2;
System.out.println("point.x = " + x + "; point.y=" + y);
}
总结
概述:
Java堆是JVM管理的最大一块内存空间,也是GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域,堆是线程共享的,在一个进程中被多个线程共享,几乎所有的对象都存储在堆中。
堆区划分:
- 新生区+养老区+元空间 (1.7之前:新生区+养老区+永久区);
- 其中年轻代又可以划分为Eden空间、Survivor0空间和Survivor1空间(有时也叫做from区、to区)
- 这样分代的好处:很多对象都是朝生夕死的,这部分对象需要频繁的回收,把他们集中在新生代进行回收,避免了在整个堆内存空间上频繁扫描进行垃圾回收。
当年轻代空间不足时,就会触发MinorGC,这里的年轻代满指的是Eden代满,Survivor满不会引发GC。(所以Survivor区的垃圾回收是被动回收,只有当Eden区满时才会触发Survivor区的垃圾回收。)
TLAB:
- TLAB 的全称是 Thread Local Allocation Buffer,即线程本地分配缓存区,是属于 Eden 区的,这是一个线程专用的内存分配区域,线程私有。用 TLAB 来避免多线程冲突,在给对象分配内存时,每个线程使用自己的 TLAB,这样可以使得线程同步,提高了对象分配的效率。
- 并不是所有的对象都可以在 TLAB 中分配内存成功,如果失败了就会使用加锁的机制来保持操作的原子性。
逃逸分析:
逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。
堆相关参数设置:
-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://是否设置空间分配担保