【JVM系列】- 挖掘·JVM堆内存结构

挖掘·JVM堆内存结构

😄生命不息,写作不止

🔥 继续踏上学习之路,学之分享笔记

👊 总有一天我也能像各位大佬一样

🏆 博客首页 @怒放吧德德 To记录领地

🌝分享学习心得,欢迎指正,大家一起学习成长!

堆的核心概念

JVM(Java虚拟机)中的 "堆" 是指Java程序运行时用来存储对象实例的一块内存区域。堆内存是JVM管理的最大一块内存区域之一,它的主要作用是存储对象实例,包括类实例、数组和其他对象。

以下是运行时数据区的整体结构图,其中方法区和堆区都是属于线程共有的,也就是一个进程中只有一个堆区和方法区。

我们所运行的Java文件就可以看作为一个进程,而与之对应的是JVM实例,进程中会包含许多的线程,而这些线程就会共享方法区和堆区。

堆的特点

  • 一个JVM实例就只是对应着一个堆区,堆是Java内存管理的一大核心区域。
  • 堆内存的大小在JVM启动时可以指定,但通常是在运行时动态分配的。这意味着堆内存可以根据程序的需要动态增长或缩小,以容纳新创建的对象。
  • 堆的存储可以是在物理上不连续,但是在逻辑上连续。[1]
  • 虽然堆是线程共有的,但是可以划分线程私有缓冲区(Thread Local Allocation Buffer, TLAB)[2]
  • 对象和数组都不会存储在栈上,栈帧中保存的是引用,这个引用指向的是对象或数组在堆中的位置。
  • 在方法结束之后,堆的对象不会立马被清除,仅仅只会在垃圾收集的时候才被移除。
  • 堆是垃圾回收的重点区域。

[1]:对于物理不连续,逻辑连续,可以通过以下几个内容来认识。

  • 磁盘存储:在硬盘驱动器或固态硬盘等存储设备上,数据通常存储在不同的磁道、扇区或块上。物理上,这些数据存储单元可能不是紧密相连的,它们分布在存储介质上的不同位置。然而,逻辑上,操作系统和文件系统会将这些数据组织成文件和文件系统,以使它们在逻辑上看起来是连续的。
  • 内存分配:在计算机内存中,变量和数据结构可以分散存储在不同的内存地址上。这些内存地址在物理上可以是不连续的,但在编程中,我们可以通过变量名或指针引用来访问它们,从逻辑上看起来它们是连续的。
  • 数据库表:在数据库中,表中的行和列可以以物理上不连续的方式存储在磁盘上,但从查询和应用程序的角度来看,它们是逻辑上连续的。数据库管理系统负责处理物理存储和逻辑视图之间的映射。

[2]:TLAB旨在提高多线程环境中的对象分配性能,减少锁竞争和减小垃圾回收的开销。当数据存放在堆区,由于多线程对此数据进行操作,这就会导致并发问题。

堆的内存结构

堆内存是JVM管理的最大的内存区域之一,它通常用于存储在运行时创建的对象,包括类实例、数组等。

堆内存逻辑结构主要分为三部分:新生区、养老区、永久代/元数据区,在jdk7之前是叫永久代,而在jdk8之后就开始称为元数据区。

内存划分

新生代/新生区(Young Generation)

  • 新生代主要分为三个部分:Eden区、Survivor区1(S0或From区)和Survivor区2(S1或To区)(幸存者区[3])。
  • 新对象首先会被分配到Eden区。
  • 当Eden区满时,一部分对象会被移动到Survivor区1或Survivor区2,经过多次循环后,仍然存活的对象会被晋升到老年代。
  • Minor GC(年轻代垃圾收集)通常在新生代执行,回收不再存活的对象。

老年代(Tenured Generation)

  • 老年代是存储已经经过多次Minor GC并且存活的对象。
  • 老年代都是具有较长的生命周期。Full GC(全局垃圾收集)通常会在老年代执行,回收老年代中的垃圾对象。

永久代(或元数据区)(PermGen 或 MetaSpace)

  • 永久代(JDK1.7之前)用于存储类的元数据信息、常量池、静态变量等。
  • 在较新的JVM版本中(JDK1.8之后),永久代被元数据区(MetaSpace)取代,元数据区不再固定大小,而是由操作系统动态分配,从而减轻了永久代内存问题。
  • 这些区域中的对象通常具有较长的生命周期。

[3]: Survivor Space(幸存者区)是Java虚拟机(JVM)中新生代(Young Generation)的一部分,用于存储在垃圾收集过程中仍然存活的对象。幸存者区分为两部分,大小是相同的,Survivor区1(S0或From区)和Survivor区2(S1或To区),其中Survivor区1和Survivor区2一般被用来交替地存储幸存的对象。在执行垃圾回收的时候Eden区域不能被回收的对象被放入到空的survivor(也就是To Survivor,同时Eden区域的内存会在垃圾回收的过程中全部释放),另一个survivor(即From Survivor)里不能被回收的对象也会被放入这个survivor(即To Survivor),然后To Survivor 和 From Survivor的标记会互换,始终保证一个survivor是空的。

设置堆空间的大小与OOM

JVM中的堆空间是用来存储对象实例的,然而这个堆大小在JVM启动的时候就已经确定了大小了,我们可以通过-Xms-Xmx来设置。

  • -Xms: 用来设置堆空间(年轻代+老年代)的初始内存大小,等同-XX:InitialHeapSize
  • -Xmx: 用来设置堆空间(年轻代+老年代)的最大内存大小,等同-XX:MaxHeapSize

如果堆内存大于了堆空间的最大内存,那么就会出现OOM即抛出OutOfMemoryError异常。

*在开发中建议是将Xms和Xmx的值设置成一样,其目的是为了能够在垃圾回收机制清理完堆区后不再需要重新分隔计算堆的大小,从而提高性能。

如果没有设置堆空间大小,我们可以通过Java代码来查看默认的堆空间大小。通过Runtime,每个Java应用程序都有一个Runtime类实例,该类允许应用程序与运行应用程序的环境进行交互。

java 复制代码
public class HeapSpaceInitial {
    public static void main(String[] args) throws InterruptedException {
        // JVM推内存容量
        long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
        // JVM最大堆内存量
        long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;
        System.out.println("-Xms: " + initialMemory + "M");
        System.out.println("-Xmm: " + maxMemory + "M");
        System.out.println("系统内存大小: " + initialMemory * 64.0 / 1024 + "G");
        System.out.println("系统内存大小: " + maxMemory * 4.0 / 1024 + "G");

    }
}

在默认情况下,堆空间的大小都有各自的计算方式:

  • 初始内存大小:物理电脑内存大小 / 64
  • 最小内存大小:物理电脑内存大小 / 4

手动设置堆内存大小的值

通过-Xms和-Xmx,在idea中,我们可以更改运行配置,在VM options 中输入-Xms600m -Xmx600m,来设置600M的内存大小。我们直接运行以上代码,看一下7/8行所打印出来的数据。

java 复制代码
-Xms: 575M
-Xmm: 575M

我们明明设置了600M,但为何只剩下575M了呢?

首先,可以通过在VM Options中追加配置-XX:+PrintGCDetails来打印GC的详细信息,他是基于程序运行结束之后显示的数据。运行以上代码出现的结果

java 复制代码
-Xms: 575M
-Xmm: 575M
Heap
 PSYoungGen      total 179200K, used 12288K [0x00000000f3800000, 0x0000000100000000, 0x0000000100000000)
  eden space 153600K, 8% used [0x00000000f3800000,0x00000000f44001b8,0x00000000fce00000)
  from space 25600K, 0% used [0x00000000fe700000,0x00000000fe700000,0x0000000100000000)
  to   space 25600K, 0% used [0x00000000fce00000,0x00000000fce00000,0x00000000fe700000)
 ParOldGen       total 409600K, used 0K [0x00000000da800000, 0x00000000f3800000, 0x00000000f3800000)
  object space 409600K, 0% used [0x00000000da800000,0x00000000da800000,0x00000000f3800000)
 Metaspace       used 3315K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 364K, capacity 388K, committed 512K, reserved 1048576K

我们看一下如何计算

首先是新生代(PSYoungGen),其总数是179200K,这个179200K=153600K+25600K,也就是幸存者区只会计算一个区域的数值,因为会始终保持一个区域是为空的。

这样算的话,那么内存大小就是:(PSYoungGen(179200K)+ParOldGen(409600K)) / 1024 = 575M

我们设置的600M=(eden(153600K)+from(25600K)+to(25600K)+ParOldGen(409600K) )/ 1024

也就是说,计算内存大小的时候,并不会吧两个幸存者区都计算进去的,因为实际上有一个是不存储数据的。

补:还可以利用命令的方式来查看GC信息

jstat -gc 进程号

OOM

OutOfMemoryError(OOM),说明你的Java应用程序耗尽了可用的堆内存资源,导致无法继续分配更多的内存。

我们来做一个例子,让它能够导致OOM错误,并通过jvisualvm工具来查看具体情况,这个工具是jdk内置含有的,在Java#bin目录下。我们通过死循环去执行创建链表数组,并且每次new一个对象实例。设置内存大小为500M,-Xms500m -Xmx500m

java 复制代码
public class OOMTest {
    public static void main(String[] args) throws InterruptedException {
        List<Images> list = new ArrayList<>();
        int i = 0;
        while (true) {
            Thread.sleep(10);
            list.add(new Images(1024 * 1024));
        }
    }
}
class Images {
    private byte[] bytes;
    public Images(int len) {
        this.bytes = new byte[len];
    }
}

打开jvisualvm工具,可以在概述页面看到程序的一些基础信息。

通过抽样器可以查看内存中,那些内容占据的大小是最多的。

通过Visual GC就可以看到内存的变化,直到最后会把old区域打满。(这里如果没有安装Visual GC插件,可以点击 工具->插件->可用插件->选择下载Visual GC)

堆的内存分配

在JVM中,存储的对象有的生命周期是比较短的,还有一些是比较长的。Java堆划分为年轻代和老年代。年轻代又划分为Eden(伊甸园)、Survivor区1(S0或From区)和Survivor区2(S1或To区)幸存者区。

在默认情况下,年轻代和老年代的比例是1:2,并且可以通过-XX:NewRation来设置它们的比例。

我们跑一个Java程序,设置内存大小为600M,然后Visual GC工具来查看其默认比例。

我们可以计算出年轻代:150+25+25=200M,老年代为400M,比例为1:2

  • 新生代中的Eden:From Survivor:To Survivor 的比例是 8:1:1,可以通过-XX:SurvivorRatio来配置。
  • 还能够通过-XX:SurvivorRatio来设置新生代中Eden与Survivor的比例。
  • JDK中会自动默认开启了-XX:+UseAdaptiveSizePolicy:自适应的内存分配策略。
  • 可以通过-Xmn来设置新生代的最大内存大小(一般是不会去设置)
  • 需要注意的是 几乎所有的Java对象都是在Eden区中new出来的。
  • 绝大部分的Java对象的销毁都在新生代进行。

*对象分配过程

  1. new对象会先放在伊甸园区(Eden Space),这个区域会有大小限制。
  2. 当伊甸园的空间填满时,程序又需要创建对象,JVM 的垃圾回收器将对伊甸园区进行垃圾回收(Minor GC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区。
  3. 然后将伊甸园中的剩余对象移动到幸存者 0 区,年龄计数器将会被加1。
  4. 如果再次发生垃圾回收,上次幸存者0区的数据如果没被回收,就会移动到幸存者1区(每次的垃圾回收都会使得幸存者区域进行一次交换)
  5. 经过多次垃圾回收后,如果年龄计数器的值达到了一个阈值,那么,就会触发Promotion(晋升),将数据移动到养老区。这个可以通过-XX:MaxTenuringThreshold=<N>来修改

说明一下对上图的理解

  • 第一阶段,当对象实例之后会存放在Eden区,直到Eden区满了之后,会触发垃圾回收(YGC/Minor GC),将不用的对象数据回收,如果还有用的就会被移动到S0区中,并且年龄计数器会加1。
  • 第二阶段,当Eden区再次满了之后,还会触发垃圾回收,剩余的对象会被移动到S1区(此时S1是空的,每次都会有一个空的区域,要么是S0,要么是S1),如果S0此时的对象还不能回收,那就会被移动到S1区中。
  • 第三阶段,这里代表过了多次垃圾回收后的情况。当Eden区还有需要保留的对象,都会放在S0/S1(看交换到谁,此图画的是移动到S0),然而当S1也有不能销毁的数据,并且年龄达到了阈值(图中设置为15),将会被晋升到老年代中。

*注:这里需要注意的是,幸存者区(S0、S1)在移动的时候是会进行交换的,都会有一个为空。在垃圾回收方面,幸存者区是不会主动触发垃圾回收的,而是在伊甸园区触发垃圾回收的时候进行垃圾回收。对于垃圾回收,频繁的在新生区收集,很少在养老区,几乎不会再永久代/元空间收集。

GC垃圾回收概念

Minor GC、Major GC、Full GC

在进行 GC 时,并非每次都对堆内存(新生代、老年代;方法区)区域一起回收的,大部分时候回收的都是指新生代。针对 HotSpot VM 的实现,它里面的 GC 按照回收区域又分为两大类:部分收集(Partial GC),整堆收集(Full GC)。
Minor GC:

  • Minor GC,也称为年轻代垃圾回收,是针对年轻代(Young Generation)的垃圾回收操作。
  • 年轻代包括伊甸园区(Eden Space)和幸存者区(Survivor Spaces)。
  • 在Minor GC中,Java虚拟机会检查并回收年轻代中不再被引用的对象,通常采用标记-复制(Mark and Copy)或标记-整理(Mark and Compact)算法。
  • Minor GC的目标是回收年轻代中的垃圾对象,将存活的对象晋升到老年代。
  • Minor GC会引发STW,暂停其他用户的线程,等待垃圾回收结束,用户线程才会恢复运行。

Major GC(或称为 "Tenured GC"):

  • Major GC 是对老年代(Old Generation)的垃圾回收操作。
  • 老年代中的对象具有较长的生命周期,通常经过多次Minor GC后才会被回收。
  • Major GC通常使用标记-清除(Mark and Sweep)或标记-整理(Mark and Compact)算法。
  • 目标是回收老年代中的不再被引用的对象,以释放内存。
  • 经常会伴随着至少一次的Minor GC,在老年代空间不足时,会先尝试触发Minor GC,如果空间还是不足,则触发Major GC。
  • Major GC的速度一般会比Minor GC慢10倍以上,STW的时间更长。
  • 如果垃圾回收后还是不足,就会出现OOM。

Full GC:

  • Full GC 是对整个堆内存的垃圾回收操作,包括年轻代和老年代,以及元空间(Metaspace)或永久代(在Java 8之前的版本)。
  • Full GC的执行通常需要暂停整个应用程序,因为它可能涉及到大量的内存回收和整理。
  • Full GC的目标是在发现内存不足的情况下,进行一次全面的内存回收,以避免OutOfMemoryError。
  • 导致触发的可能有:
    • 调用了System.gc(),系统建议执行Full GC,但是不必然执行。
    • 老年代空间不足、方法区空间不足。
    • 通过Minor GC后进入老年代的平均大小大于老年代的可用内存。
    • 有伊甸园区、幸存者区(S0/S1)向幸存者区(S1/S0)复制时,对象大于(S1/S0对应的区)可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小。

*注:Full GC是开发或调优中尽量要避免的。

案例与日志分析

我们先定义一个案例,死循环去给拼接字符串,并且存到一个ArrayList数组中。

java 复制代码
public class GCTest {
    public static void main(String[] args) {
        int count = 0;
        try {
            // 定义数组
            ArrayList<String> list = new ArrayList<>();
            String text = "不断的拼接##"; // 字符串存在堆空间
            while (true) {
                list.add(text);
                text = count + text;
                count++;
            }
        } catch (Exception e) {
            e.printStackTrace();
            System.out.println("遍历次数: " + count);
        }
    }
}

这里需要配置VM Options设置为-Xms9m -Xmx9m -XX:+PrintGCDetails,把空间设置小一点,这样就能狗明显看到GC日志。

java 复制代码
[GC (Allocation Failure) [PSYoungGen: 2040K->488K(2560K)] 2040K->943K(9728K), 0.0006030 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 2536K->504K(2560K)] 2991K->1961K(9728K), 0.0005728 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 2552K->512K(2560K)] 4009K->3178K(9728K), 0.0004624 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 2559K->512K(2560K)] 5225K->4140K(9728K), 0.0004935 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 2560K->512K(2560K)] 6188K->5163K(9728K), 0.0004562 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 2560K->512K(1536K)] 7211K->6191K(8704K), 0.0004587 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 1536K->960K(2048K)] 7215K->6991K(9216K), 0.0003252 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 960K->0K(2048K)] [ParOldGen: 6031K->6073K(7168K)] 6991K->6073K(9216K), [Metaspace: 3311K->3311K(1056768K)], 0.0047554 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [PSYoungGen: 1024K->672K(2048K)] 7097K->6745K(9216K), 0.0003614 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 672K->0K(2048K)] [ParOldGen: 6073K->6457K(7168K)] 6745K->6457K(9216K), [Metaspace: 3311K->3311K(1056768K)], 0.0111496 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
[Full GC (Ergonomics) [PSYoungGen: 1021K->0K(2048K)] [ParOldGen: 6457K->6928K(7168K)] 7478K->6928K(9216K), [Metaspace: 3311K->3311K(1056768K)], 0.0161660 secs] [Times: user=0.00 sys=0.00, real=0.02 secs] 
[Full GC (Ergonomics) [PSYoungGen: 1024K->492K(2048K)] [ParOldGen: 6928K->6928K(7168K)] 7952K->7421K(9216K), [Metaspace: 3313K->3313K(1056768K)], 0.0011506 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 1024K->756K(2048K)] [ParOldGen: 6928K->6928K(7168K)] 7952K->7685K(9216K), [Metaspace: 3320K->3320K(1056768K)], 0.0012778 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 1023K->886K(2048K)] [ParOldGen: 6928K->6928K(7168K)] 7951K->7814K(9216K), [Metaspace: 3320K->3320K(1056768K)], 0.0010096 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 1020K->946K(2048K)] [ParOldGen: 6928K->6928K(7168K)] 7948K->7874K(9216K), [Metaspace: 3320K->3320K(1056768K)], 0.0009717 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 1017K->976K(2048K)] [ParOldGen: 6928K->6928K(7168K)] 7945K->7904K(9216K), [Metaspace: 3320K->3320K(1056768K)], 0.0109962 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
[Full GC (Ergonomics) [PSYoungGen: 1023K->996K(2048K)] [ParOldGen: 6928K->6928K(7168K)] 7951K->7924K(9216K), [Metaspace: 3320K->3320K(1056768K)], 0.0013137 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 1015K->1006K(2048K)] [ParOldGen: 6928K->6928K(7168K)] 7944K->7935K(9216K), [Metaspace: 3325K->3325K(1056768K)], 0.0013365 secs] [Times: user=0.02 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 1021K->1006K(2048K)] [ParOldGen: 6928K->6928K(7168K)] 7950K->7935K(9216K), [Metaspace: 3325K->3325K(1056768K)], 0.0011589 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 1019K->1016K(2048K)] [ParOldGen: 6928K->6928K(7168K)] 7947K->7945K(9216K), [Metaspace: 3325K->3325K(1056768K)], 0.0012006 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 1023K->1007K(2048K)] [ParOldGen: 7164K->7063K(7168K)] 8187K->8070K(9216K), [Metaspace: 3325K->3325K(1056768K)], 0.0010010 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 1019K->1017K(2048K)] [ParOldGen: 7063K->7063K(7168K)] 8083K->8080K(9216K), [Metaspace: 3325K->3325K(1056768K)], 0.0009194 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 1019K->1007K(2048K)] [ParOldGen: 7165K->7124K(7168K)] 8184K->8131K(9216K), [Metaspace: 3325K->3325K(1056768K)], 0.0009735 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 1019K->1017K(2048K)] [ParOldGen: 7124K->7114K(7168K)] 8143K->8131K(9216K), [Metaspace: 3325K->3325K(1056768K)], 0.0009866 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 1019K->1017K(2048K)] [ParOldGen: 7165K->7144K(7168K)] 8184K->8162K(9216K), [Metaspace: 3325K->3325K(1056768K)], 0.0009173 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 1019K->1017K(2048K)] [ParOldGen: 7165K->7155K(7168K)] 8184K->8172K(9216K), [Metaspace: 3325K->3325K(1056768K)], 0.0008445 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 1019K->1017K(2048K)] [ParOldGen: 7165K->7155K(7168K)] 8184K->8172K(9216K), [Metaspace: 3325K->3325K(1056768K)], 0.0009346 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 1019K->1017K(2048K)] [ParOldGen: 7165K->7165K(7168K)] 8184K->8182K(9216K), [Metaspace: 3325K->3325K(1056768K)], 0.0009103 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Allocation Failure) [PSYoungGen: 1017K->1017K(2048K)] [ParOldGen: 7165K->7147K(7168K)] 8182K->8165K(9216K), [Metaspace: 3325K->3325K(1056768K)], 0.0166929 secs] [Times: user=0.00 sys=0.00, real=0.02 secs] 
[Full GC (Ergonomics) [PSYoungGen: 1019K->1017K(2048K)] [ParOldGen: 7157K->7147K(7168K)] 8177K->8165K(9216K), [Metaspace: 3325K->3325K(1056768K)], 0.0011293 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 1019K->1017K(2048K)] [ParOldGen: 7157K->7157K(7168K)] 8177K->8175K(9216K), [Metaspace: 3325K->3325K(1056768K)], 0.0010992 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Allocation Failure) [PSYoungGen: 1017K->1017K(2048K)] [ParOldGen: 7157K->7157K(7168K)] 8175K->8175K(9216K), [Metaspace: 3325K->3325K(1056768K)], 0.0010983 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (Ergonomics) [PSYoungGen: 1021K->0K(2048K)] [ParOldGen: 7167K->731K(7168K)] 8189K->731K(9216K), [Metaspace: 3344K->3344K(1056768K)], 0.0029345 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 PSYoungGen      total 2048K, used 29K [0x00000000ffd00000, 0x0000000100000000, 0x0000000100000000)
  eden space 1024K, 2% used [0x00000000ffd00000,0x00000000ffd077a0,0x00000000ffe00000)
  from space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
  to   space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
 ParOldGen       total 7168K, used 731K [0x00000000ff600000, 0x00000000ffd00000, 0x00000000ffd00000)
  object space 7168K, 10% used [0x00000000ff600000,0x00000000ff6b6c70,0x00000000ffd00000)
 Metaspace       used 3356K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 368K, capacity 388K, committed 512K, reserved 1048576K
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
	at java.util.Arrays.copyOfRange(Arrays.java:3664)
	at java.lang.String.<init>(String.java:207)
	at java.lang.StringBuilder.toString(StringBuilder.java:407)
	at com.lyd.testboot.jvm.GCTest.main(GCTest.java:19)

我们来分析以上日志,首先会将对象存放在伊甸园区,但是伊甸园区直接爆满,就会触发YGC(Minor GC),第一行日志中

java 复制代码
[GC (Allocation Failure) [PSYoungGen: 2040K->488K(2560K)] 2040K->943K(9728K), 0.0006030 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

第一部分[PSYoungGen: 2040K->488K(2560K)]2040K代表GC之前新生代占用的内存大小,488K代表GC之后新生代占用的内存大小,这里没有变0是因为还有一些数据在幸存者区,(2560K)是记录着新生代的总空间大小。第二部分2040K->943K(9728K)2040K是堆空间在垃圾回收前占用的大小,943K是堆空间垃圾回收之后占用的大小,(9728K)是堆空间的总大小。

再看第七行日志

java 复制代码
[GC (Allocation Failure) [PSYoungGen: 1536K->960K(2048K)] 7215K->6991K(9216K), 0.0003252 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

很明显7215K->6991K(9216K),堆空间的占用却变大了,这是因为数据放大了老年代里面了。

再看第八行日志

java 复制代码
[Full GC (Ergonomics) [PSYoungGen: 960K->0K(2048K)] [ParOldGen: 6031K->6073K(7168K)] 6991K->6073K(9216K), [Metaspace: 3311K->3311K(1056768K)], 0.0047554 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

随着放到老年代的数据增多,老年代的空间也就不足了,就会触发Full GC,触发之后[PSYoungGen: 960K->0K(2048K)]新生代就被清空掉了。而元数据区[Metaspace: 3311K->3311K(1056768K)]是没有变化的,因为没有涉及到内存卸载的情况。

到最后一次GC日志

java 复制代码
[Full GC (Ergonomics) [PSYoungGen: 1021K->0K(2048K)] [ParOldGen: 7167K->731K(7168K)] 8189K->731K(9216K), [Metaspace: 3344K->3344K(1056768K)], 0.0029345 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 

之后就内存不足了,也伴随着就会报OutOfMemoryError错误了。

*内存分配策略

这部分主要的是对象在伊甸园区创建并经过第一次Minor GC之后依然存活,并且能被幸存者区容纳,将被移动到幸存者空间之中,并且将对象年龄设置为1。对象在经过每次的Minor GC之后还存活,年龄就会增加1,当年龄达到一定的阈值(默认是15,每个JVM、每个GC都会有所不同),就会被晋升到老年代。修改这个阈值,可以通过-XX:MaxTenuringThreshold来设置。

对于不同的年龄段对象:

  • 优先分配到Eden
  • 大对象会直接分配到老年代
  • 长期存活的对象会分配到老年代
  • 动态对象年龄判断:如果幸存者区中相同年龄的所有对象大小的总和大于幸存者区空间的一半,年龄大于或者等于该年龄的对象可以直接进入老年代。

我们通过一个例子来看,如果幸存者空间不足以存放对象,对象将会放在老年代。

java 复制代码
public class YoungOldAreaTest {
    public static void main(String[] args) {
        byte[] bytes = new byte[1024 * 1024 * 20]; // 20m
    }
}

在运行VM Option配置中设置参数-Xms60m -Xmx60m -XX:NewRatio=2 -XX:SurvivorRatio=8 -XX:+PrintGCDetails,运行之后,我们可以看到都没有执行过Minor GC,就已经将对象放在老年代了。

java 复制代码
Heap
 PSYoungGen      total 18432K, used 3300K [0x00000000fec00000, 0x0000000100000000, 0x0000000100000000)
  eden space 16384K, 20% used [0x00000000fec00000,0x00000000fef39010,0x00000000ffc00000)
  from space 2048K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x0000000100000000)
  to   space 2048K, 0% used [0x00000000ffc00000,0x00000000ffc00000,0x00000000ffe00000)
 ParOldGen       total 40960K, used 20480K [0x00000000fc400000, 0x00000000fec00000, 0x00000000fec00000)
  object space 40960K, 50% used [0x00000000fc400000,0x00000000fd800010,0x00000000fec00000)
 Metaspace       used 3327K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 365K, capacity 388K, committed 512K, reserved 1048576K

因为后面参数带了-XX:+PrintGCDetails,如果有执行垃圾回收将会被打印出来。

TLAB(Thread-Local Allocation Buffer)

TLAB(Thread-Local Allocation Buffer)是一种用于提高多线程并发应用程序性能的内存分配优化技术。TLAB是专门为每个线程分配的小内存区域,用于分配对象实例。它的主要目的是减少线程之间的竞争和锁争用,以提高对象分配的效率。

什么是TLAB

  • 虽然堆是线程共有的,但里面含有一个线程私有的TLAB。
  • 从内存模型来看,它是对Eden区继续细分的一块区域,JVM 为每个线程分配了一个私有缓存区域。
  • 在多线程同时分配内存时,使用TLAB能够避免一些非线程安全的问题,同时还能够提升内存分配的吞吐量,这种内存分配也称为快速分配策略。

为什么要有TLAB

  • 堆区是线程共享的,任何线程都可以访问到堆区中的共享数据。
  • 由于对象实例的创建在 JVM 中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的。
  • 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度

尽管不是所有的对象实例都能够在 TLAB 中成功分配内存,但 JVM 确实是将 TLAB 作为内存分配的首选。 通过-XX:UseTLAB设置是否开启TLAB空间。 默认情况下,TLAB 空间的内存非常小,仅占有整个 Eden 空间的 1%,我们可以通过 -XX:TLABWasteTargetPercent 设置 TLAB 空间所占用 Eden 空间的百分比大小。 一旦对象在 TLAB 空间分配内存失败时,JVM 就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在 Eden 空间中分配内存。

有TLAB对象的分配过程

我们再来回顾一下对象分配过程,当对象在伊甸园区创建之后,首先会分配到TLAB,如果TLAB分配得下,就会进行对象实例化,如果分配不下,就会在伊甸园区分配(在里面的一系列分配规则同此文上面所描述的过程)。

*逃逸分析(Escape Analysis)

逃逸分析(Escape Analysis)是一种编译器和运行时优化技术,用于确定在程序中创建的对象是否会"逃逸"出它们创建的方法或作用域,也就是说,它们是否会在方法的外部被引用或访问。逃逸分析的主要目标是帮助编译器进行更有效的优化,减少内存分配和提高程序的性能。是一种可以有效减少Java程序中同步负载和内存分配压力的跨函数全局数据流分析算法。

逃逸分析的基本行为

逃逸分析的基本行为就是分析对象动态作用域:

  • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
  • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中,称为方法逃逸。

我们可以通过以下代码来了解。

java 复制代码
public static StringBuffer craeteStringBuffer(String s1, String s2) {
   StringBuffer sb = new StringBuffer();
   sb.append(s1);
   sb.append(s2);
   return sb;
}

如以上代码,StringBuffer sb是一个方法内部变量,上述代码中直接将sb返回,这样这个StringBuffer 有可能被其他方法所改变,这样它的作用域就不只是在方法内部,虽然它是一个局部变量,但是其能够逃逸到了方法外部。甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。

如果要防止它逃逸出去,可以在返回的时候转成String对象。

java 复制代码
public static String createStringBuffer(String s1, String s2) {
   StringBuffer sb = new StringBuffer();
   sb.append(s1);
   sb.append(s2);
   return sb.toString();
}

参数设置

  • 在JDK1.7之后,Hotspot就默认开启了逃逸分析。
  • 可以通过-XX:+DoEscapeAnalysis显式开启逃逸分析,通过-XX:+PrintEscapeAnalysis查看逃逸分析结果。

*注:在开发中能使用局部变量,就不要使用在方法外定义。

逃逸分析优化

开启逃逸分析后,编译程序能对代码进行优化,减少内存分配和提高程序的性能。

  • 栈上分配:如果逃逸分析确定一个对象不会逃逸出方法或作用域,编译器可以选择在栈上分配这个对象,而不是在堆上。栈上分配的对象具有较短的生命周期,不需要垃圾回收,因此可以提高程序的性能。
  • 同步消除:逃逸分析可以确定某些对象只能从一个线程访问到,不会被多个线程访问,从而可以避免不必要的同步操作,提高多线程程序的性能。
  • 分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而存储在 CPU 寄存器中。

逃逸分析是一种复杂的分析技术,通常由编译器和运行时系统共同实施。编译器会在编译阶段分析代码以确定对象的逃逸情况,然后生成更有效的代码。在Java虚拟机中,逃逸分析通常会结合垃圾回收策略和即时编译技术来实现

1) 代码优化-栈上分配

JIT 编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无需进行垃圾回收了。

常见栈上分配的场景:成员变量赋值、方法返回值、实例引用传递

我们通过案例来看一下执行时间,首先我们先准备一个类,在主线程中循环1000万次,每次调用allocation()方法,此方法会new一个对象,这个对象是不会发生逃逸的。

java 复制代码
public class StackAllocation {
    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        // 循环10000000次去执行allocation,相当于一直创建对象
        for (int i = 0; i < 10000000; i++) {
            allocation();
        }
        long end = System.currentTimeMillis();
        System.out.println("时间消耗:" + (end - start) + " ms");
        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }
    private static void allocation() {
        // 局部变量
        Show show = new Show();
    }
    static class Show {
    }
}

刚开始,我们在VM Options配置-Xmx1G -Xms1G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails,先把逃逸分析关闭。运行之后,我们到Visual VM查看抽样器,可以看到这个Show对象实例达到了1000万个。

运行结束后,我们看一下控制台,时间消耗达到了92ms。

java 复制代码
时间消耗:92 ms
Heap
 PSYoungGen      total 305664K, used 241173K [0x00000000eab00000, 0x0000000100000000, 0x0000000100000000)
  eden space 262144K, 92% used [0x00000000eab00000,0x00000000f9685588,0x00000000fab00000)
  from space 43520K, 0% used [0x00000000fd580000,0x00000000fd580000,0x0000000100000000)
  to   space 43520K, 0% used [0x00000000fab00000,0x00000000fab00000,0x00000000fd580000)
 ParOldGen       total 699392K, used 0K [0x00000000c0000000, 0x00000000eab00000, 0x00000000eab00000)
  object space 699392K, 0% used [0x00000000c0000000,0x00000000c0000000,0x00000000eab00000)
 Metaspace       used 9131K, capacity 9472K, committed 9728K, reserved 1058816K
  class space    used 1069K, capacity 1161K, committed 1280K, reserved 1048576K

接下来我们把逃逸分析打开,-Xmx1G -Xms1G -XX:+DoEscapeAnalysis -XX:+PrintGCDetails可以看到实例就没那么多了。

运行结束看看控制台,时间就经历了2ms。所以可以看到有了逃逸分析,在栈上分配会提高性能。

java 复制代码
时间消耗:2 ms
Heap
 PSYoungGen      total 305664K, used 89129K [0x00000000eab00000, 0x0000000100000000, 0x0000000100000000)
  eden space 262144K, 34% used [0x00000000eab00000,0x00000000f020a518,0x00000000fab00000)
  from space 43520K, 0% used [0x00000000fd580000,0x00000000fd580000,0x0000000100000000)
  to   space 43520K, 0% used [0x00000000fab00000,0x00000000fab00000,0x00000000fd580000)
 ParOldGen       total 699392K, used 0K [0x00000000c0000000, 0x00000000eab00000, 0x00000000eab00000)
  object space 699392K, 0% used [0x00000000c0000000,0x00000000c0000000,0x00000000eab00000)
 Metaspace       used 9245K, capacity 9600K, committed 9984K, reserved 1058816K
  class space    used 1069K, capacity 1161K, committed 1280K, reserved 1048576K

2) 代码优化-同步消除

线程同步的代价是相当高的,同步的后果是降低并发性和性能。

在动态编译同步块的时候,JIT 编译器可以借助逃逸分析来判断同步块所使用的锁对象是否能够被一个线程访问而没有被发布到其他线程。如果没有,那么 JIT 编译器在编译这个同步块的时候就会取消对这个代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫做同步省略,也叫锁消除。

我们看以下代码,其中对one对象进行了加锁,但是one对象的生命周期只有在fun()方法里面,并不会被其他线程访问,所以在JIT编译阶段就会被优化。

java 复制代码
public void fun() {
    Object one = new Object();
    synchronized (one) {
        System.out.println(one);
    }
}

会被优化成以下代码片段

java 复制代码
public void fun() {
    Object one = new Object();
    System.out.println(one);
}

3) 代码优化-标量替换

标量(Scalar)是指一个无法再分解成更小的数据的数据。Java 中的原始数据类型就是标量。
相对的,那些的还可以分解的数据叫做
聚合量(Aggregate)
,Java 中的对象就是聚合量,因为其还可以分解成其他聚合量和标量。

在 JIT 阶段,通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM 不会创建该对象,而会将该对象成员变量分解若干个被这个方法使用的成员变量所代替。这些代替的成员变量在栈帧或寄存器上分配空间。这个过程就是标量替换。

通过-XX:+EliminateAllocations可以开启标量替换(默认是开启的,允许将对象打散分配到栈上),-XX:+PrintEliminateAllocations查看标量替换情况。

我们通过一段代码来理解,同样的例子,只是我们内部有个Work类,里面有一个workId和workName字段,并且来查看统计消耗时间,首先我们在VM Options中配置-Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations先不开启标量替换。

java 复制代码
public class ScalarReplace {
    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        // 循环10000000次去执行allocation,相当于一直创建对象
        for (int i = 0; i < 10000000; i++) {
            allocation();
        }
        long end = System.currentTimeMillis();
        System.out.println("时间消耗:" + (end - start) + " ms");
    }
    private static void allocation() {
        // 局部变量
        Work work = new Work(); // 未发生逃逸
        work.workId = 1;
        work.workName = "working";
    }
    static class Work {
        public int workId;
        public String workName;
    }
}

我们可以看一下运行结果,很明显能够看到,对象分配会经历GC垃圾回收,消耗时间为35ms

java 复制代码
[GC (Allocation Failure)  25600K->1152K(98304K), 0.0009344 secs]
[GC (Allocation Failure)  26752K->1024K(98304K), 0.0005531 secs]
[GC (Allocation Failure)  26624K->976K(98304K), 0.0005057 secs]
[GC (Allocation Failure)  26576K->1040K(98304K), 0.0005935 secs]
[GC (Allocation Failure)  26640K->1008K(98304K), 0.0004587 secs]
[GC (Allocation Failure)  26608K->1072K(100864K), 0.0005802 secs]
[GC (Allocation Failure)  31792K->813K(100864K), 0.0005920 secs]
[GC (Allocation Failure)  31533K->813K(100864K), 0.0002328 secs]
时间消耗:35 ms

当我们把标量替换打开,我们可以看到就没有出现垃圾回收,所消耗的时间也就小很多。

java 复制代码
时间消耗:2 ms

逃逸分析的效果取决于具体的代码和编译器实现,不是所有的逃逸都可以被消除,但它可以在一些情况下提供显著的性能提升,特别是在高性能和低延迟应用程序中。

堆空间的参数设置总结

这里来整理一下堆空间的一些参数设置

  • -XX:PrintFlagsInitial:查看所有的参数的默认初始值。
  • -XX:PrintFlagsFinal:查看所有的参数的最终值。
  • -XX:PrintGCDetails:输出详细的GC处理日志。
    • 简要信息:-XX:PrintGC or -verbose:gc
  • -Xms:初始化堆空间内存(默认物理内存的1/64)
  • -Xmx:设置最大堆空间内存(默认物理内存的1/4)
  • -Xmn:设置新生代的大小()初始值即最大值。
  • -XX:NewRatio:配置新生代和老年代在堆结构的占比。
  • -XX:SurvivorRatio:设置新生代Eden和S0/S1空间比例。
  • -XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄。
  • -XX:HandlePromotionFailure:是否设置空间分配担保
  • -XX:+DoEscapeAnalysis:开启逃逸分析(+开启,-关闭)
  • -XX:+EliminateAllocations:开启标量替换(+开启,-关闭)
  • -Server:启动server模式,开启了才能使用逃逸分析,默认开启

总结

本次学习了堆的结构以及对象的分配过程,了解新生代、老年代以及永久代(元数据区)各自结构以及作用,对象分配优先经过那些地方,以及对Minor GC、Major GC、Full GC三种GC的使用和触发阶段。也透过了逃逸分析来了解三个优化代码方式,学到了几种参数配置的使用。

资料

JVM中新生代为什么要有两个Survivor(form,to)? - 知乎

👍创作不易,如有错误请指正,感谢观看!记得点赞哦!👍

相关推荐
码上一元2 小时前
SpringBoot自动装配原理解析
java·spring boot·后端
计算机-秋大田2 小时前
基于微信小程序的养老院管理系统的设计与实现,LW+源码+讲解
java·spring boot·微信小程序·小程序·vue
魔道不误砍柴功4 小时前
简单叙述 Spring Boot 启动过程
java·数据库·spring boot
失落的香蕉4 小时前
C语言串讲-2之指针和结构体
java·c语言·开发语言
枫叶_v4 小时前
【SpringBoot】22 Txt、Csv文件的读取和写入
java·spring boot·后端
wclass-zhengge4 小时前
SpringCloud篇(配置中心 - Nacos)
java·spring·spring cloud
路在脚下@4 小时前
Springboot 的Servlet Web 应用、响应式 Web 应用(Reactive)以及非 Web 应用(None)的特点和适用场景
java·spring boot·servlet
黑马师兄4 小时前
SpringBoot
java·spring
数据小小爬虫4 小时前
如何用Java爬虫“偷窥”淘宝商品类目API的返回值
java·爬虫·php
暮春二十四4 小时前
关于用postman调用接口成功但是使用Java代码调用却失败的问题
java·测试工具·postman