JVM常用概念之堆未提交

发布于:2025-03-18 ⋅ 阅读:(17) ⋅ 点赞:(0)

问题

如果想回收内存,该怎么办?

基础知识

JVM 使用内存的原因各不相同,既可以将其内部 VM 状态存储在本机内存中,也可以为 Java 对象提供存储空间(“Java 堆”)。而对于Java应用而言,我们在这里讨论的主要是Java堆内存空间的回收。

Java 堆通常由自动内存管理器(有时称为垃圾收集器)管理。简单的 GC 会从底层操作系统内存管理器分配大块内存,然后自行对其进行切片以接受分配。这意味着,即使堆中只有少数 Java 对象,从操作系统的角度来看,JVM 进程也已获取了 Java 堆的所有可能的内存空间。

因此,如果我们想将操作系统已经分配给Java堆的空间,但是Java堆没有充分使用这些内存空间,我们需要通过GC进行回收。

有两种方法可以实现这种效果:更频繁地执行 GC,而不是将 Java 堆“扩展”到-Xmx ;或者显式取消提交 Java 堆中未使用的部分,即使在 Java 堆膨胀到-Xmx之后也是如此。第一种方法只能起到有限的帮助,并且通常在应用程序生命周期的早期阶段,但是最终,应用程序会分配大量空间。在本文中,我们将集中讨论第二部分,即当堆已经膨胀时该怎么办。

实验

测试用例

内存占用测量比较棘手,因为我们必须定义内存占用到底是什么。由于我们是从操作系统的角度讨论内存占用,因此测量整个 JVM 进程的 RSS(Resident Set Size,常驻内存,一个进程在物理内存中实际占用的空间,)是最有意义的,其中包括本机 VM 内存和 Java 堆。

另一个重要问题是何时测量占用空间。应用程序生命周期不同阶段的应用程序数据量是不同的,这是理所当然的。当应用程序刻意优化占用空间时尤其如此,只有实际工作出现时才会出现懒惰/延迟的操作。在规划占用空间的容量时,最容易犯的错误是启动此类应用程序,对其占用空间进行快照,然后在实际工作出现时破坏所有估计。

自动内存管理器通常会根据应用程序发生的情况做出反应:它们根据分配压力、可用空间可用性、空闲状态等触发 GC。仅在活动阶段测量内存占用可能也不是很有说服力。观察发现,世界上大多数应用程序(高负载服务器除外)大多数时间都处于空闲状态,或以低占空比运行,这进一步加剧了内存空间的浪费。

所有这些意味着我们需要让应用程序经历不同的生命周期阶段,才能看到内存占用情况的面貌。让我们以简单的spring-boot-petclinic项目为例,并使用不同的 GC 运行它。这些是我们使用的配置:

  • 串行 GC:小堆应用程序的首选 GC。它具有较低的本机开销、更激进的 GC 策略等;
  • 串行 GC:小堆应用程序的首选 GC。它具有较低的本机开销、更激进的 GC 策略等;
  • Shenandoah GC :Red Hat 的并发 GC。我们将其包含在此处以展示一些精通内存占用的 GC 的行为。出于本次实验的目的,Shenandoah 以两种模式运行:默认模式和紧凑模式,后者可调整收集器以实现最低内存占用。

源码

#!/bin/bash

J=~/trunks/shenandoah-jdk11/build/linux-x86_64-normal-server-release/images/jdk/bin/java
J_OPTS="-Xmx1g -Xlog:gc -XX:+UnlockDiagnosticVMOptions -XX:+UnlockExperimentalVMOptions -XX:ConcGCThreads=2 -XX:ParallelGCThreads=2"

W=~/trunks/wrk2/wrk

runWith() {
  $J $J_OPTS $2 $3 $4 $5 $6 $7 $8 -jar target/spring-petclinic-2.1.0.BUILD-SNAPSHOT.jar > /dev/null &
  J_PID=$!

  START=`date +%s.%3N`

  ( while [ -e /proc/$J_PID ]; do
      C=`date +%s.%3N`
      TS=`echo "$C - $START" | bc -l`
      echo -n "$TS " >> $1
      cat /proc/$J_PID/status | grep VmRSS | awk '{ print $2; }'>> $1
      sleep 0.1
    done
  ) &
  S_PID=$!

  ( while [ -e /proc/$J_PID ]; do
      C=`date +%s.%3N`
      TS=`echo "$C - $START" | bc -l`
      echo -n "$TS " >> $1.cpu
      cat /proc/$J_PID/stat | awk '{ print $14; }' >> $1.cpu
      sleep 0.5
    done
  ) &
  SC_PID=$!

  ( while [ -e /proc/$J_PID ]; do
      C=`date +%s.%3N`
      TS=`echo "$C - $START" | bc -l`
      YGC=`jstat -gcutil local://$J_PID 2>&1 | tail -n 1 | awk '{ print $7; }' | tr '-' '0'`
      FGC=`jstat -gcutil local://$J_PID 2>&1 | tail -n 1 | awk '{ print $9; }' | tr '-' '0'`
      GC=`echo "$YGC + $FGC" | bc -l`
      echo "$TS $GC" >> $1.gc
      sleep 0.5
    done
  ) &
  SG_PID=$!

  sleep 20

  $W -c 16 -t 16 -d 20 -R 1000 http://localhost:8080/

  sleep 20

  for GC in `seq 1 5`; do
    jcmd $J_PID GC.run
    sleep 1
  done

  sleep 10

  kill $S_PID
  kill $SC_PID
  kill $SG_PID
  kill $J_PID
}

# Very short runs, need to trim down ShenandoahGuaranteedGCInterval and ShenandoahUncommitDelay from their default 5 minutes.
# "compact" does trim them down, but not to the values we want for this test.
rm *.data*
runWith 1.data -XX:+UseSerialGC
runWith 2.data -XX:+UseG1GC
runWith 3.data -XX:+UseShenandoahGC -XX:ShenandoahGuaranteedGCInterval=5000 -XX:ShenandoahUncommitDelay=5000
runWith 4.data -XX:+UseShenandoahGC -XX:ShenandoahGuaranteedGCInterval=5000 -XX:ShenandoahGCHeuristics=compact 

cat > plot.gnu << EOL
set term svg size 1200,800 linewidth 3 font "Helvetica,24"
set border 0

set key outside below
set grid xtics ytics
set xlabel "time, sec"
set ylabel "RSS, MB"

set grid xtics ytics mxtics mytics lc '#BBBBBB'
set key outside bottom box lw 1 lc '#AAAAAA'

set xrange [0:75]
set xtics 10
set mxtics 10

# line styles
set style line 1 lw 2 lt 1 lc rgb '#0072bd' # blue
set style line 4 lw 2 lt 1 lc rgb '#d95319' # orange
set style line 2 lw 2 lt 1 lc rgb '#edb120' # yellow
set style line 3 lw 2 lt 1 lc rgb '#77ac30' # green
set style line 5 lw 2 lt 1 lc rgb '#7e2f8e' # purple
set style line 6 lw 2 lt 1 lc rgb '#4dbeee' # light-blue
set style line 7 lw 2 lt 1 lc rgb '#a2142f' # red

# palette
set palette maxcolors 8
set palette defined ( 0 '#1B9E77',\
    	    	      1 '#D95F02',\
		      2 '#7570B3',\
		      3 '#E7298A',\
		      4 '#66A61E',\
		      5 '#E6AB02',\
		      6 '#A6761D',\
		      7 '#666666' )

set yrange [0:1600]
set ytics 400
set mytics 5

set arrow 1 from 6,0 to 6,1600 nohead
set arrow 2 from 20,0 to 20,1600 nohead
set arrow 3 from 40,0 to 40,1600 nohead
set arrow 4 from 60,0 to 60,1600 nohead
set arrow 5 from 65,0 to 65,1600 nohead

set label 1 "Start" at 1, 1520
set label 2 "Idle" at 11, 1520
set label 3 "Load" at 28, 1520
set label 4 "Idle" at 49, 1520
set label 5 "GC()" at 61, 1520
set label 6 "Idle" at 68, 1520


set title "spring-boot-petclinic, wrk2 http test, 1000 RPS, OpenJDK 11 x86-64, -Xmx1g"

set output "out-memory.svg"
plot '1.data' using (\$1):(\$2/1000) with lines ls 1 title 'Serial', \
     '2.data' using (\$1):(\$2/1000) with lines ls 2 title 'G1', \
     '3.data' using (\$1):(\$2/1000) with lines ls 3 title 'Shenandoah (default)', \
     '4.data' using (\$1):(\$2/1000) with lines ls 4 title 'Shenandoah (compact)'

old_v = NaN
min(a,b) = a >= b ? b : a
samples(n) = min(int(\$0), n)
avg_data = ""

sum_n(data, n) = ( n <= 0 ? 0 : word(data, words(data) - n) + sum_n(data, n - 1))

avg(x, n) = ( avg_data = sprintf("%s %f", (int(\$0)==0)?"":avg_data, x), sum_n(avg_data, samples(n))/samples(n)) 

delta_v(x) = ( vD = x - old_v, old_v = x, vD)


set ylabel "GC invocations, #/sec"

set arrow 1 from 6,0 to 6,24 nohead
set arrow 2 from 20,0 to 20,24 nohead
set arrow 3 from 40,0 to 40,24 nohead
set arrow 4 from 60,0 to 60,24 nohead
set arrow 5 from 65,0 to 65,24 nohead

set label 1 "Start" at 1, 22
set label 2 "Idle" at 11, 22
set label 3 "Load" at 28, 22
set label 4 "Idle" at 49, 22
set label 5 "GC()" at 61, 22
set label 6 "Idle" at 68, 22

set yrange [0:24]
#set log y
set ytics 4
set mytics 4

# Shenandoah jstat reports 4 pauses, while it is actually a single GC

set output "out-gc.svg"
plot \
     '1.data.gc' using (\$1):(delta_v(\$2))   with lines ls 1 title 'Serial', \
     '2.data.gc' using (\$1):(delta_v(\$2))   with lines ls 2 title 'G1', \
     '3.data.gc' using (\$1):(delta_v(\$2/4)) with lines ls 3 title 'Shenandoah (default)', \
     '4.data.gc' using (\$1):(delta_v(\$2/4)) with lines ls 4 title 'Shenandoah (compact)'
     
old_v = NaN

set ylabel "Java user CPU, %"

set yrange [0:800]
set ytics 200
set mytics 5

set arrow 1 from 6,0 to 6,800 nohead
set arrow 2 from 20,0 to 20,800 nohead
set arrow 3 from 40,0 to 40,800 nohead
set arrow 4 from 60,0 to 60,800 nohead
set arrow 5 from 65,0 to 65,800 nohead

set label 1 "Start" at 1, 720
set label 2 "Idle" at 11, 720
set label 3 "Load" at 28, 720
set label 4 "Idle" at 49, 720
set label 5 "GC()" at 61, 720
set label 6 "Idle" at 68, 720

set output "out-cpu.svg"
plot \
     '1.data.cpu' using (\$1):(delta_v(\$2)) with lines ls 1 title 'Serial', \
     '2.data.cpu' using (\$1):(delta_v(\$2)) with lines ls 2 title 'G1', \
     '3.data.cpu' using (\$1):(delta_v(\$2)) with lines ls 3 title 'Shenandoah (default)', \
     '4.data.cpu' using (\$1):(delta_v(\$2)) with lines ls 4 title 'Shenandoah (compact)'

old_v = NaN

EOL

gnuplot plot.gnu

执行结果

启动和空闲

 RSS

图1.启动和启动后空闲阶段常驻内存占用时序图

在启动期间,所有 GC 都会尝试处理较小的初始堆,并且许多 GC 会频繁执行。这可以防止它们过度膨胀堆。初始活动阶段完成后,工作负载会稳定在某个特定的占用空间级别。在没有任何 GC 触发器的情况下,此级别将主要由用于在启动期间触发 GC 的启发式方法定义,即使存储在堆中的数据量相同。当启发式方法必须从 100 多个 GC 选项中猜测用户想要什么时,这种情况会变得特别奇怪。

负载

在这里插入图片描述

图2.负载阶段常驻内存占用时序图

当负载到来时,GC 启发式算法又必须决定一些事情。根据 GC 及其实现和配置,它必须决定是否扩展堆,或者执行更积极的 GC 循环。

在这里,串行 GC 决定执行更多循环。G1 膨胀到最大堆的 3/4 左右,并开始进行中等频率的循环以应对分配压力。默认模式下的 Shenandoah 是在密集堆中运行的并发 GC,它选择尽可能膨胀堆以保持应用程序并发性,而不会出现太频繁的循环。紧凑模式下的 Shenandoah 被指示保持较低的占用空间,因此选择进行更积极的循环。

在这里插入图片描述

图3.负载阶段GC频率时序图

更频繁的 GC 周期也意味着需要更多的 CPU 来处理 GC 工作:

在这里插入图片描述

图4.负载阶段CPU资源占用时序图

虽然这里的大多数行都很嘈杂,但我们可以清楚地看到“Shenandoah(紧凑)”需要相当多的额外时间才能工作。这是我们为了获得更密集的占用空间而必须付出的代价。或者换句话说,这是吞吐量-延迟-占用空间权衡的表现。当然,有可调设置来说明我们想要权衡多少,而这个实验只显示了两个相当极端的默认值之间的差异:优先吞吐量和优先占用空间。由于 Shenandoah 是并发 GC,即使有效地执行连续的 GC 也不会使应用程序停滞太多。

空闲

在这里插入图片描述

图5.空间阶段常驻内存占用时序图

当应用程序空闲时,GC 可能会决定返回一些资源。显而易见的做法是取消提交部分空堆。如果堆已经分成独立的块,例如当您拥有区域化收集器(如 G1 或 Shenandoah)时,这相当简单。不过,GC 必须决定是否/何时执行此操作。

许多 OpenJDK GC 仅与实际 GC 周期结合执行与 GC 相关的操作。但有趣的事情发生了。大多数 OpenJDK GC 都是分配触发的,这意味着它们在达到特定堆占用率时启动周期。如果应用程序突然进入空闲状态,则意味着它也停止分配,因此无论现在的占用率水平如何,都会持续到发生某些事情为止。这对于STW的 GC来说有些道理,因为我们并不想仅仅因为我们想要就启动较长的 GC 暂停。

一开始,不需要特别将取消提交与 GC 周期挂钩。在 Shenandoah 的情况下,有一个异步定期取消提交,我们可以在空闲阶段的第一次大幅下降时看到它发挥作用。对于这个实验,取消提交延迟被故意设置为 5 秒,我们可以看到它确实在空闲几秒钟后发生了。这对上一个 GC 周期清空且尚未分配的区域执行了取消提交。

但是,故事还有另一个重要部分:由于应用程序突然进入空闲状态,因此有一些我们希望收集的浮动垃圾。这为定期 GC 提供了动机,该 GC 应该可以清除残留垃圾。定期 GC 负责空闲阶段的第二次大幅度下降。它释放了新的区域,以便以后定期取消提交处理。

如果 GC 周期已经足够频繁(参见“Shenandoah(紧凑)”),那么所有这些的影响基本上就不重要了,因为占用空间已经很低了,并且没有提交任何过多的内容。

完整GC

在这里插入图片描述

图6.完整GC阶段常驻内存占用时序图

同样,使用并发 GC 实现执行定期 GC 的干扰性更小:如果在 GC 周期中期负载恢复,则不会发生任何不好的事情。这与 STW GC 形成对比,STW GC 必须猜测执行主要 GC 周期是否是个好主意。在最坏的情况下,我们必须明确告诉 JVM 执行它,并且至少 G1 可以可靠地响应此请求。请注意,大多数收集器的占用空间在完整 GC 之后如何降至同一水平,以及定期 GC 和取消提交如何在没有用户干预的情况下更早地达到这一水平。

总结

可以达到回收内存空间的方法如下:

  • 定期 GC。执行定期 GC 循环有助于清除残留垃圾。并发 GC 通常会执行定期 GC 循环:众所周知,Shenandoah 和 ZGC 会这样做。G1 应该会在 JDK 12 中通过JEP 346获得此功能。否则,可以使用外部或内部代理在合适的时间定期调用 GC,困难的部分在于定义合适的时间。
  • 堆取消提交。许多 GC 在认为这是个好主意时就已经执行了堆取消提交:Shenandoah 即使没有 GC 请求也会异步执行,G1 肯定会在显式 GC 请求时执行,Serial 和 Parallel 在某些情况下肯定会执行。ZGC 也将很快做到这一点,希望 JDK 12 能做到。G1 应该通过使用 JDK 12 中的JEP 346执行定期 GC 循环来处理同步性。当然,这有一个权衡:重新提交内存可能需要一段时间,因此实际实现会在取消提交之前施加一些超时。
  • 以占用空间为目标的 GC。许多 GC 提供灵活的选项,使 GC 周期更频繁,以优化占用空间。即使是增加定期 GC 的频率之类的操作也有助于更早地清除垃圾。一些 GC 可能会为您提供预先准备好的配置包,指导实现做出占用空间明智的选择,包括配置更频繁/定期的 GC 周期和取消提交,例如 Shenandoah 的“紧凑”模式。

网站公告

今日签到

点亮在社区的每一天
去签到