The heap is the run-time data area from which memory for all class instances and arrays is allocated
《Java 虚拟机规范》中对堆的描述为:所有对象实例以及数组都应当在运行时分配在堆上。
《Java 虚拟机规范》规定:堆可以处于物理上不连续的内存空间中,但在逻辑上它应该是被视为连续的。其大小是可以调节的。
所有的线程共享 Java 堆内存,也可以在内划分线程私有的缓冲区(Thread Local Allocaltion Buffer,TLAB)
方法结束后、堆中的对象不会马上被清理,仅在垃圾回收时才会被清理。堆是垃圾回收的重点区域。
1. 堆内存划分
1.1 逻辑划分
约定:新生代(区)<=> 年轻代、老年代(区)<=> 养老区、永久代 <=> 永久区
1.2 堆内存大小设置
我们常说的堆区的内存设置指的是新生代
和老年代
的设置、对于永久代
或者元空间
的内存大小是需要单独设置的。
-Xms
用于设置堆区的初始内存,等价于-XX:InitialHeapSize
、默认主机内存大小 / 64-Xmx
用于设置堆区的最大内存,等价于-XX:MaxHeapSize
、 默认主机内存大小 / 4- 通常会把两个参数设置成一样的、可以在 Java 垃圾回收之后不再重新计算分配内存,以此提高性能。
2. 新生代和老年代
4. 默认-XX:NewRatio=2
表示新生代占 1,老年代占 2。(如果改成 -XX:NewRatio=4
则表示新生代占 1,老年代占据 4。)
5. 使用-Xmn
设置新生代
最大内存大小、一般都是使用默认值
6. 使用-XX:SurvivorRatio
设置新生代中 Eden
和 Survivor
的比值。(-XX:Survivor=8
表示 Eden:Survivor = 8:2
)
几乎所有的
Java
对象都是在Eden
区被new
出来的,而且绝大多数对象的销毁也是在Eden
区进行的。IBM 公司有研究表明,新生代中 80% 的对象都是朝生夕死的。
2.1 对象在堆空间的内存分配
new
的对象先放置到Eden
区Eden
区空间满了后,此时再new
一个新的对象,JVM 的垃圾回收器对Eden
区进行垃圾回收(MinorGC
/YGC
)。先将Eden
区不再被其他对象引用的对象进行销毁,然后把新的对象放到Eden
区。- 将
Eden
区中的剩余对象移动到幸存者S0
区 - 再次触发垃圾回收时,上次垃圾回收丢到
S0
区的幸存者如果这次垃圾回收还没有被回收、则丢到S1
区(此时Eden
区幸存对象也会移入S1
区,S0
区为空)(其实此时已经为空的S0
应该是S1
、便于理解就不乱换名字了) - 再次触发垃圾回收时,上次丢到
S1
点幸存者还没被回收,则再丢到S0
区(此时S1
区为空) - 什么时候进入老年代呢?可通过设置幸存次数、默认 15 次(
-XX:MaxTenuringThreshold=15
) - 老年代相对悠闲,只有当老年代内存不足时,会触发
MajorGC
/FullGC
- 当触发
MajorGC
之后,发现依然无法进行对象的存储,则会OOM
异常。(java.lang.OutofMemoryError: Java heap space
)
对于幸存者
S0 、S1
区,复制之后有交换、谁空谁是S1
对于垃圾回收、新生代频繁、老年代偶尔、永久代/元空间几乎不会
3. 堆区的垃圾回收
堆区的垃圾回收主要集中在新生代、并非每次都是整堆回收。
Hotspot
按照回收区域主要分两种:部分回收、整堆回收
- 部分回收:
- 新生代回收(
Minor GC / Young GC
):仅代表新生代的垃圾回收行为 - 老年代回收(
Major GC / Old GC
):仅代表老年代的回收- 很多时候
Major GC
会和Full GC
混淆在一起,需注意分辨
- 很多时候
- 混合回收(
Mixed GC
):回收整个新生代以及部分老年代(目前只有G1 GC
有此行为)
- 新生代回收(
- 整堆回收(
Full GC
):回收整个堆区(包含方法区)
3.1 新生代(Minor GC)的触发
一定是 Eden
区满的时候才会触发、幸存者 S 区满是不会触发的
因为 Java 对象大多都是朝生夕死,所以
Minor GC
特别频繁,新生代的回收速度也特别快
Minor GC
会引发STW
(暂停其他用户的线程、等垃圾回收结束后恢复)
S0
和S1
不是固定的、谁空谁为to
3.2 老年代(Major GC)的触发
- 出现了
Major GC
一般(是一般、有的虚拟机就直接进行Major GC
了)伴随着至少一次的Minor GC
、也就是说当触发了Major GC
时会先执行Minor GC
、执行完之后空间还是不足才会执行Major GC
Major GC
的速度一般会比Minor GC
慢 10 倍以上、STW
时间更长- 如果
Major GC
之后内存还是不足、就会OOM
了
3.3 Full GC 的触发
- 调用
System.gc()
时、不会立刻执行 - 老年代空间不足
- 方法区空间不足
Major GC
后可能会有对象要放入老年代、此时幸存者所需内存大于老年代可用内存、则触发Eden
区和S0
区幸存者向S1
区复制时,所需内存大于S1
剩余可用内存,此时会把该对象转存到老年代,若此时老年代也放不下、则触发
Full GC
是开发、调优中尽量避免的、极大避免STW
的时延
4. 堆区的分代思想
为什么要给堆区分新生代、老年代、Eden
区、S0
区、S1
区这么多区域呢。
不区分这些区域程序跑不起来么?当然可以。
但是,不同对象的生命周期是不同的,如果把短周期和长周期的都放一起,意味着每次垃圾回收的都是一次 Full GC
,需要逐个去确认对象是否有被引用,极大的损耗性能。
所以堆区分代只有一个理由便是优化 GC 性能。
朝生夕死的对象放到 Eden
区、频繁进行 Minor GC
大对象以及数次 Minor GC
还存活的对象放到老年代,偶尔进行 Full GC
5. 内存分配策略
对象初创建放到 Eden 区,经历第一次 Minor GC 之后仍然存活,如果 Survivor 区可用空间足够则移入,并将该对象年龄设置为 1 岁,没经历一次 Minor GC 则涨一岁,当增加到一定岁数后就可以去养老区。(通过 -XX:MaxTenuringThreshold
来设置)
对象分配原则如下:
- 优先分配 Eden
- 大对象直接分配到老年代(写代码的时候要避免大对象)
- 长期存活的对象熬到一定岁数分配到老年代
- 动态分配:如果 survivor 区中相同年龄的所有对象占用的总内存大于 survivor 空间的一半,则年龄等于或大于该年龄的对象可以直接进入老年代,无需等到
MaxTenuringThreshold
设置的年龄线 - 空间分配担保:
-XX:HandlerPromotionFailure
6. 私有缓冲区 TLAB(Thread Local Allocation Buffer)
6.1 为什么有 TLAB
堆区作为共享区域、是任何线程都可以访问的,也就是说在并发场景下频繁的创建实例对象并进行内存空间的划分是不安全的,而为了避免多个线程操作同一地址,需要采用加锁等机制,而这样的话问题就是会影响内存的分配速度。
6.2 TLAB 的分配
JVM 对 Eden 区继续划分,为每个线程分配了一个私有缓冲区,所以他是存在于 Eden 区的
多个线程同时分配内存时,使用 TLAB 可以避免一系列非线程安全问题,同时还能提升内存分配的吞吐量,这种方式称之为快速分配策略
- 并非所有对象都是通过 TLAB 分配内存的,JVM 只是将 TLAB 作为首选
- 可通过
-XX:UseTLAB
来设置是否开启 TLAB 空间 - 默认情况下,TLAB 占用内存极低,仅占用 Eden 空间的 1%,可通过
-XX:TLABWasteTargetPercent
来设置占用百分比 - 当 TLAB 分配内存失败的时候,JVM 会尝试采用加锁机制确保内存分配的原子性,以此在 Eden 中分配内存
7. 堆区参数设置
-Xms | 初始堆空间内存(默认为物理内存的1/64) |
-Xmn | 最大堆空间内存(默认为物理内存的1/4) |
-Xmx | 设置新生代的大小(初始值及最大值) |
-XX:+PrintFlagsInitial | 查看所有的参数的默认初始值 |
-XX:+PrintFlagsFinal | 查看所有的参数的最终值(可能会存在修改,不再是初始值) |
-XX:NewRatio | 配置新生代与老年代在堆结构的占比 |
-XX:SurvivorRatio | 设置新生代中Eden和S0/S1空间的比例 |
-XX:MaxTenuringThreshold | 设置新生代垃圾的最大年龄 |
-XX:+PrintGCDetails | 输出详细的GC处理日志 |
-XX:+PrintGC | 打印gc简要信息 |
-verbose:gc | 打印gc简要信息 |
-XX:HandlePromotionFalilure | 是否设置空间分配担保 |
发生 Minor GC
之前,JVM
会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间
- 如果大于,则本次
Minor GC
是安全的 - 如果小于,则
JVM
会查看-XX:HandlePromotionFailure
设置值是否允许担保失败- 如果
-XX:HandlePromotionFailure=true
、则继续检查老年代最大可用的连续空间是否大于历次晋升到老年代的平均对象大小- 如果大于:则尝试进行
Minor GC
、但不得不说本次Minor GC
是有风险的 - 如果小于:则直接进行
Full GC
- 如果大于:则尝试进行
- 如果
-XX:HandlePromotionFailure=false
、则直接进行Full GC
- 如果
JDK6 Update24 之后、-XX:HandlePromotionFailure
不在生效、并且默认为 true,也就意味着每次 Minor GC
之前都会检查老年代剩余可用最大连续内存空间:1.是否大于新生代所有对象占用的总空间、2.是否大于历次晋升到老年代的平均对象大小,只要有一个不满足,那么就直接进行 Full GC
8. 堆区是分配对象的唯一选择?
引入《深入理解 Java 虚拟机》一句话:随着 JIT 编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。
一路学来、对象都是在堆区分配内存。但是、如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就有可能被优化成栈上分配。这样就不用在堆区分配,也不用进行垃圾回收。这是场景是极为常见的堆外存储技术。
还有一些其他的 JVM 实现如 TaoBaoVM,他们把一些生命周期较长的对象直接放到了堆外存储,而且不让 GC 进行管理。
8.1 关于逃逸分析
逃逸分析就是 JVM 通过分析对象的实际作用域,是在方法内还是说蔓延到方法外了,以此来判断该对象在哪儿分配内存空间。
如何将本该分配到堆区的对象分配到栈,这就需要使用逃逸分析手段。
逃逸分析的基本行为就是分析对象的动态作用域:
- 当一个对象在方法内被定义后,对象仅仅在方法中被使用,则认为没有发生逃逸
- 当一个对象在方法内被定义后,然后被外部方法引用(如被当作参数传递到了其他方法中、或者在方法中为成员属性对象赋值),则认为发生逃逸
// 如果想让下面方法不发生逃逸:把 return sb; 改为 return sb.toString();
public static StringBuffer contact(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}
public class EscapeAnalysis {
public EscapeAnalysis obj;
// 方法返回EscapeAnalysis对象,发生逃逸
public EscapeAnalysis getInstance() {
return obj == null ? new EscapeAnalysis() : obj;
}
// 为成员属性赋值,发生逃逸
public void setObj() {
this.obj = new EscapeAnalysis();
}
// 对象的作用于仅在当前方法中有效,没有发生逃逸
public void useEscapeAnalysis() {
EscapeAnalysis e = new EscapeAnalysis();
}
// 引用成员变量的值,发生逃逸
public void useEscapeAnalysis2() {
EscapeAnalysis e = getInstance();
}
}
- 参数设置、在 JDK 6u23 之后,HotSpot 默认开启逃逸分析、在这个版本之前的可通过一下参数配置
-XX:+DoEscapeAnalysis
表示开启逃逸分析-XX:+PrintEscapeAnalysis
表示查看逃逸分析的筛选结果
注意:开发中能使用局部变量的,就不要在方法外定义
8.2 JVM 针对逃逸分析进行的优化操作
8.2.1 栈上分配
如果对象只在当前方法内运转,则将对象直接直接分配到栈上
8.2.2 同步省略(锁消除)
如果对象只有一个线程访问到,那么对于这个对象可以不考虑同步操作
线程同步的代价是很高的,同步的后果是降低并发性和性能。
在编译同步代码时,JIT 编译器可以借助逃逸分析来判断同步代码块所使用的锁对象是否只被一个线程所访问。如果是,那么 JIT 编译器就会在编译这块儿代码的时候取消同步,这样可以极大提升并发性和性能,这个取消同步的过程就是同步省略,也叫做锁消除。
public void f() {
Object hellis = new Object();
synchronized(hellis) {
System.out.println(hellis);
}
}
hellis 这个对象生命周期只在 f() 方法中,并不会被其他线程访问到,所以 JIT 编译器会将其优化为如下代码
public void f() {
Object hellis = new Object();
System.out.println(hellis);
}
8.2.3 分离对象 / 标量替换
如果对象不需要连续的内存空间来存储也可以进行访问,那么这个对象的部分或者是全部可以直接存储在 CPU 的寄存器中。
- 标量:无法在分解的最小数据单元(Java 中的基本数据类型)
- 聚合量:还可以在分解的数据(Java 中的对象)、可以分解成其他聚合量和标量
JIT 阶段、经过逃逸分析后,发现一个对象不会被外界访问的话,那么就会把这个对象进行拆解替换。
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;
}
point 对象仅在当前方法中使用,那么就直接分解 point 对象并替换为两个标量,这样就不用在创建对象了,也就不用在分配内存。
private static void alloc() {
int x = 1;
int y = 2;
System.out.println("point.x = " + x + "; point.y=" + y);
}
- 使用
-XX:EliminateAllocaltions
表示开启标量替换(默认开启)、允许将对象分解替换 - 使用如下配置进行调试分析
-server -Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations
-server
:启动 Server 模式、只有在 Server 模式下才可以开启逃逸分析-XX:+DoEscapeAnalysis
:启用逃逸分析-Xmx10m
:指定了堆空间最大为 10MB-XX:+PrintGC
:将打印 Gc 日志-XX:+EliminateAllocations
:开启了标量替换(默认打开),允许将对象打散分配在栈上,比如对象拥有 id 和 name 两个字段,那么这两个字段将会被视为两个独立的局部变量进行分配
上述举例代码中如果进行 1 亿次 alloc 方法,假设不开启逃逸分析,一个 point 对象占据 16 字节的空间,那么 10 亿次就是 1.5GB 的内存消耗。如果堆内存不够大就直接 OOM 了。而开启了逃逸分析后,我们进行了对象分解替换,转为栈内存储两个标量,方法结束直接回收。
8.2.4 逃逸分析一定好吗
逃逸分析的理论 1999 年就已经出现、而直到 JDK6 才实现,显然有很多顾虑。
最根本的原因就是无法保证逃逸分析的带来的性能提升是否大于它本身的损耗。假设一个极端的例子,经过一系列的逃逸分析操作之后,发现没有一个对象是不逃逸的,那么逃逸分析这一波操作就白进行了。
虽然目前逃逸分析的技术还不是很成熟,但它依然是即时编译优化技术中的一个十分重要的手段。