JVM(六)堆

JVM(六)堆


1 核心概述

image-20230518135053190
  • 几乎所有的对象实例和数组都是分配在堆上的(栈不会存储数组和对象,栈帧中的局部变量表只会存储指向堆中实例的引用)

    image-20230518144126050

  • 一个Java进程对应一个JVM实例,一个JVM实例只存在一个堆内存,堆也是内存管理的核心区域

  • 堆和方法区是线程共享的,但堆也有划分的线程私有缓冲区(TLAB Thread Local Allocation Buffer)程序计数器、本地方法栈和虚拟机栈是线程私有的

  • 堆可以处于不连续的内存空间中,但在逻辑上应该被视作是连续的(栈必须是物理上连续的空间)

  • 在方法结束之后,堆内的数据不会被马上移除,而是只在垃圾回收的时候才被移除(频繁的垃圾回收会影响用户线程)

  • 堆是垃圾回收器(GC,Garbage Collection)执行垃圾回收的重点区域(栈只有入栈和出栈操作,不需要垃圾回收)

  • Java堆区在Java启动的时候就会被创建,这时候空间也就被确定了,是Java管理的最大的一块内存空间

  • Java的堆内存是可以调节的,在使用java命令启动程序的时候添加:

    java xxx -Xms 初始大小 -Xmx 最大大小
    
    image-20230518141402709

    然后使用jvisualvm工具查看堆内存刚好10m:

    image-20230518141559734
堆的内存细分
image-20230518144550814
  • jdk7及之前的堆内存逻辑上分为三部分,新生代+老年代+永久代

    • Young Generation Space:新生代,又划分为Eden区和Survivor区
    • Tenure Generation Space:老年代
    • Permanent Space:永久代
  • jdk8及以后的堆内存逻辑上分为三部分,新生代+老年代+元空间

    • Young Generation Space:新生代,又划分为Eden区和Survivor区
    • Tenure Generation Space:老年代
    • Meta Space:元空间

    新生代 = 新生区 = 年轻代

    老年代 = 老年区 = 养老区

    永久区 = 永久代

堆空间大小设置和查看

堆空间大小设置:

  • -Xms 设置堆空间(新生代+老年代)的初始内存大小
    • -X 是jvm的运行参数
    • ms 是memory start的缩写
  • -Xmx:设置堆空间(新生代+老年代)的最大内存大小

堆空间大小查看:

  • 方式一:添加虚拟机运行参数-XX:+PrintGCDetails

    [0.003s][warning][gc] -XX:+PrintGCDetails is deprecated. Will use -Xlog:gc* instead.
    [0.009s][info   ][gc,init] CardTable entry size: 512
    [0.009s][info   ][gc     ] Using G1
    [0.012s][info   ][gc,init] Version: 19.0.2+7-44 (release)
    [0.012s][info   ][gc,init] CPUs: 8 total, 8 available
    [0.012s][info   ][gc,init] Memory: 16257M
    [0.012s][info   ][gc,init] Large Page Support: Disabled
    [0.012s][info   ][gc,init] NUMA Support: Disabled
    [0.012s][info   ][gc,init] Compressed Oops: Enabled (32-bit)
    [0.012s][info   ][gc,init] Heap Region Size: 1M
    [0.012s][info   ][gc,init] Heap Min Capacity: 600M
    [0.012s][info   ][gc,init] Heap Initial Capacity: 600M
    [0.012s][info   ][gc,init] Heap Max Capacity: 600M
    [0.012s][info   ][gc,init] Pre-touch: Disabled
    [0.012s][info   ][gc,init] Parallel Workers: 8
    [0.012s][info   ][gc,init] Concurrent Workers: 2
    [0.012s][info   ][gc,init] Concurrent Refinement Workers: 8
    [0.012s][info   ][gc,init] Periodic GC: Disabled
    [0.013s][info   ][gc,metaspace] CDS archive(s) mapped at: [0x0000000800000000-0x0000000800c40000-0x0000000800c40000), size 12845056, SharedBaseAddress: 0x0000000800000000, ArchiveRelocationMode: 0.
    [0.013s][info   ][gc,metaspace] Compressed class space mapped at: 0x0000000801000000-0x0000000841000000, reserved size: 1073741824
    [0.013s][info   ][gc,metaspace] Narrow klass base: 0x0000000800000000, Narrow klass shift: 0, Narrow klass range: 0x100000000
    

    这是jdk8之后的版本显示,如果是早期版本,这里会显示内存大小要小于600M,原因在下面

  • 方式二:通过jps命令查看当前系统运行进程,然后根据jstat -gc pid查看堆空间内存占用情况

    image-20230518160854150

    C表示总共的大小,U表示已经使用的大小,S0、S1是幸存者1区和2区,E是eden区,O表示老年代,M元空间

    为什么新生代+老年代的加和会大于方式一,也就是为什么方式一查询到的要小于设定值?

    因为幸存者0区和幸存者1区只会有一个在工作,所以在计算的时候之后计算一次

  • 方式三:通过运行时状态Runtime.getRuntime()查看:

        public static void main(String[] args) {
            // jvm堆内存总量
            long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
            long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;
    
            System.out.println("-Xms" + initialMemory);
            System.out.println("-Xmx" + maxMemory);
            try {
                Thread.sleep(1000000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    

2 新生代与老年代

​ 存储在JVM中的对象主要有两类:

  • 一类对象的周期较短,对象的我创建和消亡都非常迅速
  • 另一类对象的声明周期非常长,在某些极端的情况下还能够与JVM的生命周期保持一致(如一些连接资源)

​ Java堆空间进行细分的话,分为 年轻代老年代

  • 其中年轻代又可以划分为Eden区Survivor0Survivor1区,三者的默认比例是8:1:1
  • 几乎所有的对象都是在Eden区被创建的
  • 绝大部分的对象都是在新生代进行的销毁
    • 80%的对象都是朝生夕死的
image-20230524140813596
2.1 设置新生代和老年代大小

-XXNewRatio设置新生代和老年代大小比例

-Xms600m -XX:NewRatio=2
  • 表示新生代和老年代的比例为2:1
  • 一般情况下不会调整这个大小,一些情况下如程序连接资源比较多,就需要把老年代大小调大一点

-XX:SurvivorRatio设置eden区和两个幸存者区的比例

-Xms600m -Xmx600m -XX:SurvivorRatio=8
  • 表示比例为8:1: 1,这个也是默认的大小

  • 因为有一个自动适应的设置,所以显示不是该比例,设置关闭即可:

    -Xms600m -Xmx600m -XX:SurvivorRatio=8 -XX:-UseAdaptiveSizePolicy
    

-Xmn:设置新生代的空间大小

​ 当与上面设置出现矛盾以此显示指定为准

image-20230524144759522
2.2 为什么要把堆进行分代,不分代不行吗

​ 分代主要是为了优化GC的性能,如果没有分代,那么所有的对象都会混在一起,GC的时候就需要对堆全部的区域进行扫描查找无用的对象,而如果进行分代,把一些朝生夕死的对象集中在一起GC的时候就可以先把这些区域的对象进行回收,较快地腾出很大的空间来。

3 对象分配的过程

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

image-20230524154756206
  1. 首先判断创建对象的类是否已经加载到内存,如果没有加载则应先获取字节码文件进行类的加载

  2. new的对象先会被放到Eden区的TLAB中,如果TLAB不能分配足够空间则在Eden共享空间中申请内存空间

  3. Eden区的空间不足的时候,程序又要创建对象,JVM的垃圾回收器将会对伊甸园区进行垃圾回收(Young GCMinor GC),将伊甸园区中的不再被其他对象引用的对象进行销毁(可达性分析算法),再加载新的对象到伊甸园区

  4. 如果经过YGC之后仍然不能将对象放入伊甸园区,则会直接将其放到老年代,如果老年代也放不下会触发一次Major GC,在老年代执行了垃圾回收之后,发现依然无法进行对象的保存,就会产生OOM异常

  5. 否则将伊甸园区的其他剩余对象放到幸存者0区,并设置年龄计数为1,表示经历过几次垃圾回收仍然存活的次数

    image-20230524153413999
  6. 再次触发垃圾回收的时候,幸存者0区中的对象也会被分析回收,并且剩余的对象会和伊甸园区存活对象一起放入幸存者1区,将其年龄计数+1(幸存者0区这时候就是to区,幸存者1区是from区),如果放不下则直接晋升为老年代

    image-20230524153711719
  7. 继续垃圾回收的时候,就会重新将没被回收的对象放入幸存者0区,轮流下次又被放入幸存者1区,将其年龄计数+1

  8. 年龄计数达到了阈值(默认15),则会被晋升为老年代

    image-20230524153747620

  9. 在老年代相对悠闲,当老年代内存相对不足的时候,再次触发垃圾回收,进行老年代的内存清理

注意:

  • 只有伊甸园区内存不足才会触发YGC垃圾回收,幸存者是被动进行垃圾回收的
  • 垃圾回收频繁发生在年轻代,很少发生在老年代,几乎不会发生在永久代/元空间中
  • S0和S1

​ 在jvisualVM中新生代和老年代对应的情况如下:

image-20230524160121286

​ 在VM参数设置里面开启GC日志-XX:+PrintGCDetails,并运行下面的代码:

public class GCTest {
    public static void main(String[] args) {
        var list = new ArrayList<String>();
        var a = "a";
        while(true) {
            list.add(a);
            a = a + a;
        }
    }
}

​ 可以看到堆内存溢出时候的GC情况,能够发生GC是因为字符串常量也是存在堆空间的

  • 第一个红框表示整个堆GC前和GC后的空间变化
  • 第二个表示年轻代、老年代和元空间的空间情况

image-20230524180152056

4.1 总结内存分配策略
  • 优先分配到Eden区

  • 大对象直接分配到老年代

  • 动态年龄判断:如果Survivor区中相同年龄的大小的总和占到整个Survivor区的一半,则大于等于该年龄的对象可以直接晋升老年代,无须等到年龄达到MaxTenuringThreshold要求的年龄阈值

  • 空间分配担保

    -XX:HandlePromotionFailure
    

4 JVM常用调优工具

  • JDK命令行:如jmap、jinfo、jstat、javap等
  • Jconsole
  • JvisualVM
  • Jprofiler
  • Java Flight Recorder
  • GCViewer
  • GC Easy

5 Minor GC、Major GC和Full GC

​ JVM在进行GC的时候,并不是对三个内存区域(新生代、老年代、方法区)一起进行回收,大部分时候回收都指的是新生代。

​ 针对HotSpot VM的实现,它里面的GC按照回收区域可以划分为:

  • 部分收集(Partial GC),不进行整个堆的垃圾收集,又分为

    • 新生代收集(Minor GC、Young GC)

    • 老年代收集(Major GC、Old GC)

      只有CMS GC有单独收集老年代的行为

      很多时候Major GC和Full GC混合使用,需要具体分辨是老年代回收还是整堆回收

    • 混合收集(Mixed GC):收集整个新生代和部分老年代的垃圾回收,目前只有G1 GC会有这种行为

  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集

5.1 年轻代GC(Minor GC)的触发机制
  • 年轻代的Eden区空间不足的时候会触发Minor GC(Survivor区不会触发,每次Minor GC会顺带清理)
  • Java对象大多朝生夕死,所以新生代的Minor GC非常频繁,一般回收速度也比较快
  • Minor GC会引发STW,暂停其他用户的线程,等垃圾回收技术用户程序才恢复运行
5.2 老年代GC(Major 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了
5.3 Full GC触发机制
  1. 调用System.gc()的时候,系统建议执行Full GC,但不一定执行
  2. 老年代空间不足或方法区空间不足
  3. 通过Minor GC进入老年代的平均大小大于老年代的可用内存
  4. 由Eden区、From space 向 To Space复制的时候,对象大于To Space可用内容,则将晋升老年代,但老年代的可用内存小于对象的大小

Full GC是要在开发、调优的时候避免的,这样暂停时间会短一些

6 堆空间为每个线程分配的TLAB

​ 之前也提到过,堆空间不一定都是共享的,这是由于堆空间还为每个线程都分配了一块TLAB(Thread Local Allocate Buffer)

  • 从内存模型而不是垃圾收集的角度,Eden区继续进行划分,JVM为每个线程都分配了TLAB
  • 这样每个线程可以直接使用这块私有的空间进行对象的创建,这样一方面能够避免使用堆的公共区间从而出现的线程安全问题
  • 另一方面还能够提升内存分配的吞吐量,这种策略也被称作是快速分配策略
  • TLAB是JVM分配对象内存空间的首选
  • TLAB仅占Eden区的1%,因此一旦JVM在TLAB创建对象失败,就会使用加锁的机制到Eden区分配内存
  • 目前OpenJDK衍生出来的JVM都提供了对TLAB的支持
6.1 为什么要设置TLAB?
  • 堆空间是线程共享的区域,但是因为创建对象在JVM中非常频繁,所以堆的共享在并发环境下是线程不安全的
  • 而直接对线程加锁避免在分配的时候访问同一个地址会影响分配的速度
image-20230524185024984

7 JVM在运行的时候,通常设置的一些参数?(空间分配担保策略)

  • -XX:+PrintFlagsInitial:查看所有参数的默认值

    -XX:+PrintFlagsInitial -XX:SurvivorRatio=5
    
    image-20230525154952111
  • -XX:+PrintFlagsFinal:查看所有参数的最终值

    image-20230525155300994
  • -Xms:初始堆空间内存(默认物理内存的1/64)

  • -Xmx:最大堆空间内存(默认1/4)

  • -Xmn:新生代大小(初始及最大值)

  • -XX:NewRatio:新生代:老年代(默认1:2)

  • -XX:SurvivorRatio:Eden区与S0、S1区占比(默认8,表示8:1:1)

    如果该比值过大,会导致伊甸园区内存不足的时候,无法放入幸存者区而导致没有达到年龄阈值过早进入老年代,这样就使得Minor GC失去了尽可能先让对象存储在新生代的意义。

    如果该比值较小,YGC则会频繁触发,影响用户线程降低吞吐量

  • -XX:MaxTenuringThreshold:新生代晋升老年代的年龄阈值,默认15

  • -XX:+PrintGCDeatils:输出详细的GC处理日志

    • -XX:PrintGC-verbose:GC:打印简要GC
  • -XX:HandlePromotionFailure:设置是否允许空间分配担保失败

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

    • 如果大于,则此次Minor GC是安全的

    • 否则如果允许担保失败,则继续检查老年代最大连续空间是否大于历次晋升到老年代的对象的平均大小,如果大于则进行Minor GC仍然是不安全的);否则进行一次Full GC

    • 不允许担保失败,则直接进行Full GC

    • JDK7之后,该参数不会再生效,默认为true,也就意味着发生YGC的时候,会检查老年代的最大连续空间是否大于新生代的所有对象的总空间检查老年代最大连续空间是否大于历次晋升到老年代的对象的平均大小

8 逃逸分析

堆空间是分配对象的唯一选择吗?

​ 不是,

  • JIT(即时)编译器在编译期间如果经过逃逸分析(逃逸即出现变量在方法外部被引用的情况)发现,某些对象并没有逃逸出方法的话,就可能被优化成栈上分配

  • 栈上分配的优点就是无须在堆上分配内存,从而不用进行垃圾回收

    栈只有入栈出栈操作,不存在GC

  • 这也是最常见的堆外分配技术

  • 使用-XX:+DoEscapeAnalysis开启逃逸分析

逃逸分析案例

  • 如下经过逃逸分析没有发生逃逸的对象,就可以分配到栈的局部变量表上,随着方法的调用结束栈空间就会被移除

    栈帧结构包括:局部变量表、方法返回地址、动态链接以及其他附加信息和操作数栈

    image-20230525181202528
  • 方法返回对象,发生逃逸:

    image-20230525182037342
  • 为成员属性赋值,发生逃逸:

    image-20230525182141497
  • 引用成员变量的值,发生逃逸

    image-20230525182545908

    是否发生逃逸要看对象的实体即new 的对象,而不要看引用变量,引用变量是被分配在栈上的局部变量表中的

9 代码优化

9.1 栈上分配
public static void main(String[] args) {
        long start = System.currentTimeMillis();

        for(int i = 0; i < 10000000; 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();
    }

​ 关闭逃逸分析,堆中出现100,0000个对象实例,创建时间83ms

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

​ 开启逃逸分析,堆中出现10w实例,创建时间5ms

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

image-20230525185110811

​ 关闭逃逸分析,降低堆内存,进行了GC,创建时间45ms

[GC (Allocation Failure) [PSYoungGen: 25600K->840K(29696K)] 25600K->848K(98304K), 0.0006240 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 26440K->808K(29696K)] 26448K->824K(98304K), 0.0007007 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 26408K->728K(29696K)] 26424K->744K(98304K), 0.0007841 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 26328K->792K(29696K)] 26344K->808K(98304K), 0.0007328 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 26392K->744K(29696K)] 26408K->760K(98304K), 0.0006703 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 26344K->728K(32768K)] 26360K->744K(101376K), 0.0010052 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
花费的时间为:45ms

​ 关闭逃逸分析,降低堆内存,没有进行GC,创建时间7ms

花费的时间为:7ms
9.2 同步省略
  • 如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步
  • 这是由于同步的代价是非常高的,会降低并发和性能
  • 具体原理是在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只被一个线程访问而没有被发布到其他的线程。如果没有则JIT在编译这个同步块的时候,就会取消对这段代码的同步。
  • 这个取消同步的过程就叫做同步省略或者锁消除
image-20230525185951785
  • 同步代码块在编译成字节码指令的时候,是这个样子的:

    image-20230525190332466

    monitorenter表示加锁、其他两个monitorexit表示正常退出和异常退出

9.3 分离对象、标量替换
  • 有的对象可能不需要作为一个连续的内存结构存在也能够被访问到,那么对象的部分(或者全部)可以不存在内存的堆中,也可以存在CPU的寄存器中(对于Java是基于栈的结构,这里应该是栈的局部变量表中)
  • 在JIT(即时编译)阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么JIT就可以对其进行优化,将其拆解成若干个成员变量来代替,这个过程就是标量替换
  • 标量是指一个无法分解成更小的数据的数据,Java的基本数据类型就是标量
  • 聚合量:可以被分解的数据,Java中的对象都是聚合量
  • HotSpot虚拟机并没有实现真正的栈上分配,而是通过标量替换的方式将对象存储到栈帧中的局部变量表中的
public class StackAllocation {
    public static void main(String[] args) {
        long start = System.currentTimeMillis();

        for(int i = 0; i < 10000000; 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();
        user.userName = "abc";
    }
}

​ 测试开启逃逸分析关闭标量替换,发现有35w是在堆上分配产生了GC,耗费74ms

-XX:+PrintGCDetails -XX:+DoEscapeAnalysis -XX:-EliminateAllocations -Xmx100m -Xms100m
image-20230525194505585
[GC (Allocation Failure) [PSYoungGen: 25600K->808K(29696K)] 25600K->816K(98304K), 0.0010639 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 26408K->792K(29696K)] 26416K->800K(98304K), 0.0008157 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 26392K->680K(29696K)] 26400K->688K(98304K), 0.0006721 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 26280K->680K(29696K)] 26288K->688K(98304K), 0.0010085 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 26280K->712K(29696K)] 26288K->720K(98304K), 0.0014343 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 26312K->744K(32768K)] 26320K->752K(101376K), 0.0007214 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
花费的时间为:47ms
[GC (Allocation Failure) [PSYoungGen: 32488K->1024K(32768K)] 32496K->2196K(101376K), 0.0020474 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
posted @ 2023-07-11 15:02  Tod4  阅读(48)  评论(0编辑  收藏  举报