堆的核心概述
- 一个 JVM 实例只存在一个堆内存,堆也是 Java 内存管理的核心区域
- Java 堆区在 JVM 启动的时候即被创建,其空间大小也就确定了。是 JVM 管理的最大一块内存空间
- 堆可以处于物理上不连续的内存空间中,但是在逻辑上它应该被视为连续的
- 所有的线程共享 Java 堆,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer, TLAB)
- 所有的对象实例以及数组都应该在运行时分配在堆上
- 数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置
- 在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除
- 堆,是 GC (Garbage Collection, 垃圾收集器) 执行垃圾回收的重点区域
/**
* 设置堆大小 -Xms10m -Xmx10m
*/
public class HeapDemo {
public static void main(String[] args) {
System.out.println("start ...");
try{
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("end ...");
}
}
/**
* 设置堆大小 -Xms20m -Xmx20m
*/
public class HeapDemo1 {
public static void main(String[] args) {
System.out.println("start ...");
try{
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("end ...");
}
}
HeapDemo 在 jvisualvm 查看:
HeapDemo1 在 jvisualvm 查看:
实例对象分配位置:
public class SimpleHeap {
private int id;
public SimpleHeap(int id) {
this.id = id;
}
public void show(){
System.out.println("My Id is " + id);
}
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 版本呢 | Code | 名称 | 说明 |
Java 7 及之前 | Young Generationn Space | 新生区 | Young / New, 又被划分为 Eden区和 Survivor 区 |
Tenure generation space | 养老区 | Old / Tenure | |
Permanent space | 永久区 | Perm | |
Java 8 及之后 | Young Generationn Space | 新生区 | Young / New, 又被划分为 Eden区和 Survivor 区 |
Tenure generation space | 养老区 | Old / Tenure | |
Meta Space | 元空间 | Meta |
设置堆内存大小与OOM
- Java 堆区用于存储 Java 对象实例,那么堆的大小在 JVM 启动时就已经设定好了,可以通过选项 "-Xmx" 和 "-Xms" 来进行设置, "-Xms" 用于表示堆区的起始内存,等价于 -XX:InitialHeapSize、"-Xmx" 则用于表示堆区的最大内存,等价于 -XX:MaxHeapSize
- 一旦堆区中的内存大小超过 "-Xmx" 所指定的最大内存时, 将会抛出 OutOfMemoryError 异常
- 通常情况下会将 -Xms 和 -Xmx 两个参数配置相同的值, 其目的是为了能够在 java 垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能
- 初始内存大小:物理内存大小 / 64
- 初始最大内存大小: 物理电脑内存大小 /4
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 * 1024 + "G");
System.out.println("系统内存大小为:" + maxMemory * 4 * 1024 + "G");
// 执行 jstat -gc 20332 需要进行延迟, -XX:+PrintGCDetails 不需要
// try{
// Thread.sleep(1000000);
// } catch (InterruptedException e) {
// e.printStackTrace();
// }
}
}
由于 S0 和 S1 仅能一个存储,所以计算时会比设置的少一部分空间
OutOfMemory 举例:
import java.util.ArrayList;
import java.util.Random;
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];
}
}
年轻代与老年代
- 存储在JVM 中的Java 对象可以划分为两类: 一类是生命周期较短的瞬时对象、这类对象的创建和消亡都非常迅速,另一类对象的生命周期却非常长,在某些极端的请开给你下还能够与 JVM 的生命周期保持一致
- Java 堆区进一步细分的话,可以划分为年轻代(YoungGen) 和 年老代(OldGen)
- 其中年轻代又可以划分为 Eden空间、Survivor0 空间和 Survivor1 空间(有时也叫做 from 区、to 区)
配置新生代和老年代在堆结构的占比:
- 默认 -XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的 1/3
- 可以修改 -XX:NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的 1/5
- 在HotSpot 中,Eden 空间和另外两个 Servivor 空间缺省所占的比例是 8:1:1, 可以通过 -XX:SurvivorRatio=8 进行调整比例空间,默认值是8,实际值是6,需要显示进行配置
- 可以使用选项 "-Xmn" 设置新生代最大内存大小,如果与设置 NewRatio 存在冲突,则以该配置为准
/**
* -Xms600m -Xmx600M
*/
public class EdenSurvivorTest {
public static void main(String[] args) {
System.out.println("我只是来打酱油~");
try{
Thread.sleep(1000000);
}catch (InterruptedException e) {
e.printStackTrace();
}
}
}
图解对象分配过程
概述:
为新对象分配内存是一件非常严谨和复杂的任务, JVM 设计者不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑 GC 执行完内存回收后是否在内存空间中产生内存碎片
- new 的对象先放伊甸园区。此区有大小限制
- 当伊甸园的空间填满时,程序又需要创建对象,JVM 的垃圾回收器堆伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区
- 然后将伊甸园中的剩余对象移动到幸存者0区
- 如果再次触发垃圾回收,此时上次幸存下来的放到幸存者0区的,如果没有回收,就会放到幸存者1区
- 如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区
- 默认 15次后,进入养老区,可以通过参数 -XX:MaxTenuringThreshold=<N> 进行设置
图解:
总结:
- 针对幸存者 s0, s1区的总结: 复制之后有交换, 谁空谁是 to
- 关于垃圾回收: 频繁在新生区收集,很少在养老区收集,几乎不再永久区 / 元空间收集
特殊情况:
常用调优工具:
- JDK 命令行
- Eclipse : Memory Analyzer Tool
- Jconsole
- VisualVM
- Jprofiler
- Java Flight Recorder
- GCViewer
- GC Easy
Minor GC、Major GC、Full GC
JVM 进行 GC时,并非每次都对上面三个内存(新生代、老年代; 方法区) 区域一起回收的,大部分时候回收的都是指新生代
针对 HotSpot VM 的实现,它里面的 GC 按照回收区域又分为两大种类型: 一种是部分收集 (Partial GC), 一种是整堆收集 (Full GC)
类型 | 名称 | 说明 |
部分收集 | Minor GC / Y | 只是新生代的(Eden \ s0, s1)垃圾收集 |
Major GC / Old GC | 只是老年代的垃圾收集, 只有CMS GC 会有单独收集老年代的行为。很多时候 Major GC 会和 Full GC 混肴使用,需要具体分辨是老年代回收还是整堆回收 | |
Mixed GC | 收集整个新生代以及部分老年代的垃圾收集,目前只有 G1 GC 会有这种行为 | |
整堆收集 | Full GC | 收集整个 Java 堆和方法区的垃圾收集 |
年轻代 GC(Minor GC) 触发机制:
- 当年轻代空间不足时,就会触发 Minor GC, 这里的年轻代满指的是 Eden 代满, Survivor 满不会引发 GC
- 因为 Java 对象 大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度比较快
- Minor GC 会引发 STW, 暂停其他用户的线程,等垃圾回收结束,用户线程才恢复运行
老年代 GC(Major 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 触发机制:
- 调用 System.gc()时,系统建议执行 Full GC, 但是不必然执行
- 老年代空间不足
- 方法区空间不足
- 通过 Minor GC后进入老年代的平均大小大于老年代的可用内存
- 由Eden区、survivor space0(From Space)区向 survivor space1(To Space)区复制时,对象大于 To Space 可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
GC 日志查看:
VM 配置 -XX:+PrintGCDetails :
import java.util.ArrayList;
import java.util.List;
public class GCTest {
public static void main(String[] args) {
int i = 0;
try{
List<String> list = new ArrayList<>();
String a = "hello world";
while(true) {
list.add(a);
a = a + a;
i++;
}
}catch (Throwable t){
t.printStackTrace();
System.out.println("遍历次数为: " + i);
}
}
}
堆空间分代思想
分代是为了优化 GC 性能
内存分配策略
- 如果对象在 Eden, 出生并经过第一次 MinorGC 后仍然存活,并且没有被 survivor 容纳的话,将被移动到 survivor 空间中,并且对象年龄设为1。对象在 survivor 区种每熬过一次 MinorGC, 年龄就增加 1 岁,当它的年龄增加到一定程度(默认为15岁,其实每个 JVM、每个 GC 都有所不同)时,就会被今生到老年代种
- 对象今生老年代的年龄阈值,可以通过选项 -XX:MaxTenuringThreshold 来设置
针对不同年龄段的对象分配原则:
- 优先分配到 Eden
- 大对象直接分配到老年代,尽量避免程序中出现过多的大对象
- 长期存活的对象分配到老年代
- 动态对象年龄判断,如果Survivor 区中相同年龄的所有对象总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄
- 空间分配担保: -XX:HandlePromotionFailure
为对象分配内存:TLAB(Thread Local Allocation Buffer)
- 堆区的线程共享区域,任何线程都可以访问到堆区中的共享数据
- 由于对象实例的创建在 JVM 中非常频繁,因此在并发环境下从堆中划分内存空间是线程不安全的
- 为避免多个线程操作同一个地址,需要使用加锁等机制,进而影响分配速度
什么是 TLAB:
- 从内存模型而不是垃圾收集的角度,对 Eden 区域继续进行划分, JVM 为每个线程分配了一个私有缓存区域,它包含在 Eden 空间内
- 多线程同时分配内存时,使用 TLAB 可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称为快速分配策略
- 据我所知所有 OpenJDK 衍生出来的 JVM 都提供了 TLAB 的设计
- 尽管不是所有的对象实例都能够在TLAB 中成功分配内存,但 JVM 确实是将 TLAB 作为内存分配的首选
- 在程序中,开发人员可以通过选项 "-XX:UseTLAB" 设置是否开启 TLAB 空间
- 默认情况下, TLAB 空间的内存非常小,仅占有整个 Eden空间的 1%, 当然我们可以通过选项 "-XX:TLABWasteTargetPercent" 设置 TLAB 空间所占用 Eden 空间的百分比大小
- 一旦对象在 TLAB 空间分配内存失败时, JVM 就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在 Eden 空间中分配内存
堆空间的参数设置
参数 | 说明 |
-XX:+PrintFlagsInitial | 查看所有的参数的默认初始值 |
-XX:+PrintFlagsFinal | 查看所有的参数的最终值(可能会存在修改,不再是初始值) |
jinfo -flag 参数 进程ID | CMD, 查看当前运行进程某参数设置情况 |
-Xms | 初始堆空间内存(默认为物理内存的 1/64) |
-Xmx | 最大堆空间内存(默认为物理内存的 1/4) |
-Xmn | 设置新生代的大小(初始值及最大值) |
-XX:NewRatio | 配置新生代和老年代在堆结构的占比 |
-XX:SurvivorRatio | 设置新生代中 Eden 和 s0 / s1 空间的比例 |
-XX:MaxTenuringThreshold | 设置新生代垃圾的最大年龄 |
-XX:+PrintGCDetails | 输出详细的 GC处理日志 |
-XX:+PrintGC | 打印 gc 简要信息 |
-XX:HandlePromotionFailure | 是否设置空间分配担保,jdk7后已失效 变为只要老年代的连续空间大于新生代总大小或者历次晋升的平均大小就会进行 Minor GC,否则将进行 Full GC |
堆是分配对象的唯一选择吗?
如果经过逃逸分析(Escape Analysis) 后发现,一个对象并没有逃逸出 方法的话,那么就可能被优化称栈上分配,这样就无需在堆上分配内存,也无须进行垃圾回收了,这也是最常见的对外存储技术
TaoBaoVM 其中创新的 GCIH(GC invisible heap) 技术实现 off-heap,将生命周期较长的 Java 对象从 heap 中移至 heap外,并且 GC 不能管理 GCIH 内部的 Java 对象,以此达到降低 GC 的回收频率和提升 GC 的回收效率的目的
逃逸分析概述:
- 如何将堆上的对象分配到栈,需要使用逃逸分析手段
- 这时一种可以有效减少 Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法
- 通过逃逸分析, Java Hotspot 编译期能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上
- 逃逸分析的基本行为就是分析对象动态作用域: 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。
/**
* new 的对象实体是否有可能在方法外被调用
*/
public class EscapeAnalysis {
public EscapeAnalysis obj;
/**
* 方法返回 EscapeAnalysis 对象,发生逃逸
* @return
*/
public EscapeAnalysis getInstance() {
return obj == null? new EscapeAnalysis() : obj;
}
/**
* 为成员属性,发生逃逸, obj 声明为 static 仍然发生逃逸
*/
public void setObj(){
this.obj = new EscapeAnalysis();
}
/**
* 对象的作用域在当前方法中有效,没有发生逃逸
*/
public void useEscapeAnalysis() {
EscapeAnalysis e = new EscapeAnalysis();
}
/**
* 引用成员变量的值,发生逃逸
*/
public void useEscapeAnalysis1() {
EscapeAnalysis e = getInstance();
}
}
参数设置:
- 在 JDK 6u23 版本之后,HotSpot 中摩尔呢就已经开启了逃逸分析
- 如果较早版本,选项 " -XX:+DoEscapeAnalysis " 显式开启逃逸分析。通过选项 "-XX:+PrintEscapeAnalysis" 查看逃逸分析的筛选结果
逃逸分析:代码优化
- 栈上分配: 将堆分配转换为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配
- 同步省略: 如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步
- 分离对象或标量替换: 有的对象可能不需要作为一个连续的内存结构存放也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在 CPU 的寄存器中
栈上分配:
- JIT 编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了
- 常见场景: 成员变量赋值,方法返回值,实例引用传递
/**
* -Xmx1G -Xms1G --XX:-DoEscapeAnalysis -XX:+PrintGCDetails
*/
public class StackAllocation {
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println("花费的时间为: " + (end - start) + " ms");
try{
Thread.sleep(1000000);
}catch (InterruptedException e){
e.printStackTrace();
}
}
private static void alloc(){
User user = new User();
}
static class User{}
}
同步省略:
- 线程同步的代价是相当高的,同步的后果是降低并发性和性能
- 在动态编译同步块的时候,JIT 编译期可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么JIT 编译期在编译同步块的时候就会取消这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就是同步省略,也叫锁消除
public class SynOmitTest {
public void f(){
Object hillis = new Object();
synchronized (hillis){
System.out.println(hillis);
}
}
/**
* JIT 优化后
*/
public void omitF(){
Object hillis = new Object();
System.out.println(hillis);
}
}
分离对象或标量替换:
- 是指一个无法再分解成更小的数据的数据。 Java 中的原始数据类型就是标量
- 可以分解的数据叫做聚合量(Aggregate),Java 中的对象就是聚合量,因为他们可以分解成其他聚合量和标量
- 在 JIT 阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过 JIT 优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来替换。这个过程就是标量替换
- 参数 -XX:+EliminateAllocations: 开启标量替换(默认打开),允许将对象打散分散在栈上
public class ScalarTest {
static class Point{
private int x;
private int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
}
private static void alloc(){
Point point = new Point(1,2);
System.out.println("point.x=" + point.x + "; point.y=" + point.y);
}
/**
* JIT 分析后
*/
private static void JITAlloc(){
int x = 1;
int y = 2;
System.out.println("point.x=" + x + "; point.y=" + y);
}
}