JVM - 堆内存

1 概述

  • 一个java程序启动,那么就会启动一个进程,一个进程对应一个JVM实例,一个JVM实例只存在一个堆内存,堆也是Java内存管理的核心区域
  • Java堆区在JVM启动的时候即被创建,其空间大小也就确定了(但可以调节),是JVM管理的最大一块内存空间
  • 《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续
  • 所有的线程共享Java堆内存,在这里还可以划分线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)
  • 《Java虚拟机规范》中对Java堆的描述是:所有(其实不是所有,应是几乎)的对象实例以及数组都应当在运行时分配在堆上
    数组和对象可能永远不会存储在栈上(有例外),因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置
  • 在方法结束后,堆中的对象不会马上被回收,仅仅在垃圾收集的时候才会被移除
  • 堆是GC执行垃圾回收的重点区域

2 内存细分

现代垃圾收集器大部分都基于分代收集理论设计,堆空间细分为:
Java7 及之前内存逻辑上分为三部分:新生区+养老区+永久区

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

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

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

事实上永久区(1.7)和元空间(1.8)都不存储在堆中,只是逻辑上放在堆中,后续再方法区会再详细跟进讲解

3 设置堆空间大小

  • -Xms:用于标识堆区的起始内存,等价于-XX:InitialHeapSize
  • -Xmx:用于标识堆区的最大内存,等价于-XX:MaxHeapSize

默认情况下:

  • 起始内存大小=物理电脑内存大小 / 64
  • 最大内存大小=物理电脑内存大小 / 4

设置JVM参数:-Xms600M -Xmx600M,运行以下程序

public class HeapSpaceInitial {
    public static void main(String[] args) {
        Runtime runtime = Runtime.getRuntime();
        //获得Java虚拟机中的堆内存总量
        long initialMemory = runtime.totalMemory() / 1024 / 1024;
        //获得Java虚拟机中的堆内存最大内存
        long maxMemory = runtime.maxMemory() / 1024 / 1024;

        System.out.println("-Xms : " + initialMemory + "M");
        System.out.println("-Xmx : " + maxMemory + "M");

        //让进程不停止
        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

如何查看已设置的参数:
 方式一:在cmd下输入jps、jstat -gc 进程id
在这里插入图片描述
将上图中的所有新生代与老年代的内存大小相加,再除以1024,即为刚刚设置的堆内存大小:

  • (25600+25600+153600+409600)=600

 方式二:设置虚拟机参数-XX:+PrintGCDetails

在这里插入图片描述
为什么设置的是600M,而显示出来的只有575M?

  • 因为JVM只会取幸存者0区或幸存者1区(只能2选1)来存放对象,因此计算时将一个幸存者区的25M忽略了,因此为575M

4 新生代与老年代

存储在JVM中的Java对象可以被划分为两类

  • 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速
  • 一类是对象的生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致
  • Java堆区进一步细分的话,可以划分为新生代老年代
  • 其中新生代又可以划分为Eden伊甸园区Survivor0幸存者0区Survivor1幸存者1区(有时也叫做from区、to区)
    在这里插入图片描述

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

  • -XX:NewRatio=num:表示新生代占1,老年代占num,默认是2
  • 在HotSpot中,Eden与另外两个Survivor占比是8:1:1,当然也可以自己设置
  • -XX:SurvivorRatio=num:表示Eden : Survivor0 : Survivor1 = num:1:1,默认是8
  • 几乎所有的Java对象都是在Eden区被new出来的
  • 绝大部分的Java对象的销毁(GC回收)都在新生代中进行了
  • 研究表明:新生代中80%的对象(比如方法内的对象)都是“朝生夕死”的
  • 可以使用选项 -Xmn 设置新生代的最大内存(了解),不过一般使用默认即可

5 对象分配过程

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

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
解读:

  • new的对象先存放于伊甸园区。此区有大小限制
  • 当伊甸园的空间满时,想再存放对象,则会触发伊甸园的YGC进行垃圾回收,将伊甸园区不再被引用的对象销毁,同时将有用的对象移至幸存者0区(此时年龄计数器为1),这时候才会再在伊甸园区存放对象
  • 如果再次触发垃圾回收,在幸存者0区的仍然有用的对象将会移至幸存者1区,此时0区为空,即为To区,同时年龄计数器+1
  • 当年龄计数器为15(默认,可自行设置:-XX:MaxTenuringThreshold=num)时,将会晋升为养老区进行存放
  • 注意:伊甸园区满了会触发YGC进行垃圾回收,但幸存者区满了,是不会触发垃圾回收的!!!但并不代表幸存者区不会进行垃圾回收,在伊甸园区进行垃圾时,会顺带地进行幸存者区的垃圾回收(可理解为被动回收)
  • 针对幸存者区S0、S1区的总结:复制之后有交换,谁空谁是To区,另一个则为from
  • 关于垃圾回收:垃圾回收频繁在新生代收集,很少在养老区收集,几乎不在永久区/元空间收集

下图为详细过程:
在这里插入图片描述

  • ①插入对象如果发现Eden满时,会触发YGC回收,完后继续判断Eden是否放得下,如果放得下则放入Eden中,如果放不下(意味着超大对象),则判断Old能否放得下,如果放不下则触发FGC回收,再次判断,如果仍放不下,则报OOM异常
  • ②因为Eden满时会触发YGC回收,同时会将有用的对象移至幸存者区,如果幸存者区放不下,则会其会直接晋升为老年代。
  • 注意:如果Survivor区中相同年龄的所有对象大小的总和大于Survivor空间的一半,那么年龄大于或等于该年龄的对象可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄才进入老年代

为什么需要把Java堆分代?不分代就不能正常工作了吗?
其实不分代完全可以,分代的唯一理由就是优化GC性能,如果没有分代,那所有的对象都在一块,就如同把一个学校的人都关在一个教室。GC的时候要找到哪些对象没用,这样就会对堆的所有区域进行扫描,会极大降低性能,可以将那些长期有用的对象放入其他地方,避免GC频繁扫描,提高了性能。

6 MinorGC、MajorGC、FullGC

  • JVM在进行GC时,并非每次都对三个内存区域(新生代、老年代、方法区)一起回收的,大部分时候回收的都是指新生代
  • GC按照回收区域分为:部分收集、整堆收集

部分收集

  • 新生代收集(Minor GC / Young GC):只负责新生代的垃圾收集
  • 老年代收集(Major GC / Old GC):只负责老年代的垃圾收集
    • 目前,只有CMS是单独收集老年代的
    • 注意,很多时候Major GC会和Full GC混淆使用,需要具体分辨是老年代回收还是整堆回收
  • 混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集
    目前,只有G1是收集整个新生代和老年代的

整堆收集(Full GC)

  • 收集整个java堆和方法区的垃圾收集

年轻代Minor GC触发机制

  • 当年轻代空间不足时,就会触发Minor GC,这里的年轻代满指的是Eden满,Survivor满不会触发GC。
  • 因为 Java 对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。
  • Minor GC会引发STW,暂停其他用户线程,等待垃圾回收结束,用户线程才会恢复运行

老年代Major GC触发机制

  • 指发生在老年代的GC,对象从老年代消失时,说明Major 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区、幸存者0区向幸存者1区复制时,对象大小大于To的可用内存,则把对象转存到老年代,且老年代的可用内存小于该对象的大小
  • 注意:Full GC是开发或调优中尽量要避免的,这样暂停的时间会短一些

7 TLAB

  • 从内存模型的角度看,对Eden区域继续进行划分,JVM为每个线程分配了一个私有缓存区域,即TLAB,它包含在Eden空间(即堆空间)内
  • 多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略
  • 尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选,且是默认开启TLAB的
    选项-XX:UseTLAB设置是否开启TLAB空间
  • 默认情况下,TLAB空间的内存非常小,仅占有整个Eden空间的1%,当然我们可以通过-XX:TLABWasteTargetPercent设置TLAB空间所占用的Eden空间的百分比大小
  • 一旦对象在TLAB空间分配内存失败时,JVM机会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存

加入TLAB后的对象分配过程
在这里插入图片描述
堆空间一定都是共享的吗?

  • 不是,TLAB是堆中Eden区线程私有的,每个线程占用一份

如果新生代中的比例设置不合适,会导致什么情况?

  • 如果Eden区过大,幸存者区过小,则堆分代的效果会下降,会导致很多对象直接进入老年代,YGC的作用也不太大
  • 如果Eden区过小,幸存者区过大,则在Eden区中会频繁进行YGC,导致用户线程暂停过多时间,效率降低

8 堆空间中常用参数

-XX:+PrintFlagsInitial:查看所有的参数的默认初始值
-XX:+PrintFlagsFinal:查看所有的参数的最终值
-Xms:初始堆空间内存(默认为物理内存的1/64)
-Xmx:最大堆空间内存(默认为物理内存的1/4)
-Xmn:设置新生代的大小(初始值及最大值)
-XX:NewRatio:设置老年代与新生代在堆结构的占比
-XX:SurvivorRatio:设置新生代中Eden和S0/S1空间的比例
-XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄
-XX:+PrintGCDetails:输出详细的GC处理日志
-XX:HandlePromotionFailure:是否设置空间分配担保

空间分配担保:

  • jdk7以后:在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,或检查老年代最大可用的连续空间是否大于历次晋升至老年代的所有对象平均大小如果大于就会进行Minor GC否则将进行Full GC

9 逃逸分析

堆是分配对象存储的唯一选择吗?

  • 《深入理解Java虚拟机》说明:随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了
  • 在Java虚拟机中,对象是在Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无需进行垃圾回收了,这也是最常见的堆外存储技术

逃逸分析技术

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

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

  • 栈上分配。将堆分配转化为栈分配,如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配
  • JIT编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话(成员变量赋值、方法返回值、实例引用传递),就可能被优化成栈上分配,分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收,这样对象就不用进行垃圾回收了
  • 锁消除。如果一个对象被发现只能从一个线程被访问到,那么这个对象的操作可以不考虑同步
public void f(){
    Object obj = new Object();
    synchronized (obj){
        System.out.println(obj);
    }
}
  • 以上代码中对 obj 这个对象进行加锁,但是 obj 对象的生命周期只在该方法中有效,并不会被其他线程所访问到,所以在 JIT 编译阶段就被优化掉,优化成如下代码,即锁消除
public void f(){
    Object obj = new Object();
    System.out.println(obj);
}
  • 分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中
  • 标量是指一个无法再分解成更小的数据的数据,Java中的基本数据类型就是标量
  • 相对的,那些还可以分解的数据叫做聚合量,Java中的对象(引用数据类型)就是聚合量,因为它可以分解成其他聚合量和标量
  • 在JIT编译器中,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个成员变量来代替,这个过程就是标量替换,允许对象打散分配在栈上
  • -XX:+EliminateAllocations:开启标量替换,默认打开
public static void main(String[] args){
    alloc();
}
private static void alloc(){
    Point point = new Point(1, 2);
}
class Point{
    private int x;
    private int y;
}
  • 以上过程,经过标量替换后,alloc()就会变成如下形式
private static void alloc(){
    int x = 1;
    int y = 2;
}
  • 其实逃逸分析并不成熟,根本原因是无法保证逃逸分析的性能消耗一定能高于它的消耗,虽然经过逃逸分析可以做栈上分配、锁消除、标量替换,但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程
  • 一个极端的例子,就是经过逃逸分析后,发现没有一个对象是不逃逸的,那这个逃逸分析过程就白白浪费了

10 总结

  • 新生代是对象的诞生、成长、消亡的区域,一个对象在这里产生、应用,最后被垃圾回收器收集、结束生命
  • 老年代放置长生命周期的对象,通常都是从Survivor区筛选拷贝而来的。当然也有特殊情况,如果对象太大,以至于新生代经过垃圾回收后仍不能放得下的,那么将会直接放进老年代
  • 当GC只发生在新生代中,回收新生代对象的行为被称为MinorGC。发生在老年代时被称为MajorGC或FullGC(FullGC其实是整堆收集)。一般的,MinorGC发生频率要比MajorGC高得多,因为很多新生代对象都是“朝生夕死”的
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Sadness°

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值