记一次gc超时排查过程
背景
最近发现监控偶尔集中出现接口异常、超时等报警,查询日志发现是调用接口超时,由于每次出现的报警时间都不一致,并且也很快恢复,以为是偶发的大批量任务导致的,因此没有进行集中排查。直到发现基本每天都开始出现,并且也逐渐影响到其他业务线获取数据,开始仔细观察排查原因。
排查到具体现象如下,每次出现超时,GC监控都有如下现象:老年代突然激增,然后没有触发fullgc,而是通过多次的youonggc清理内存,并且yonggc的耗时时间也很高,导致在这段时间内,提供给外部的接口调用超时。
排查过程
问题定位
根据gc监控图,开始逐步排查定位出现问题的原因:
1 内存飙升首先想到的是不是存在内存溢出或泄露的场景,但是在对应机器日志里搜索OOM关键字,没有找到内存溢出的异常日志。jvm相关的配置中,也只有发生OOM,才会dump下对应的堆转储文件,但是现在没有出现OOM,获取不到对应的堆转储文件,因此没办法直接进行内存分析。
2 如果存在大批量任务调用,可能也会导致短时间内存飙升。由于这个异常是最近才出现的,所以检查了最近新增的定时任务,以及发生时间段内的定时任务,发现都正常,查看机器调用量也都在正常水平。并且gc的现象并不是每天固定12点左右出现的,有时也会出现在凌晨、下午等时间,都是随机出现,因此排除是定时任务的原因
3 是不是存在大json,导致直接晋升到老年代。导出了出现问题前后10分钟左右的日志,由于历史原因,该应用提供的接口有300个,所以日志很多,排查也更加麻烦、耗时。不过查看日志并分析之后,也没有发现存在大json的情况。
排查到此有点陷入困境,没有发生问题时刻的堆dump文件,没办法进行堆分析;问题发生的时间也都是随机的,所以也没办法通过实时诊断工具来提前预判进行采样;日志分析也基本排除了代码问题,只能把发生问题时的堆dump下来。
dump堆文件
出现问题时,没有发生OOM,所以就无法通过jvm自动dump堆文件。思路有两个:写定时任务,通过代码获取gc相关状态,提前预判可能出现问题,dump出堆文件;第二种思路和第一种基本一致,不过不需要改业务代码,而是通过脚本进行监控。 将对应的脚本写入jvm配置里的start_after.sh、start_before.sh。
具体逻辑
监控java进程的gc状态,在老年代内存使用率过高时,dump出堆文件。此处使用【老年代内存使用率】来作为一个评判标准,这也是从前几次线上gc监控得出的结论,每次都是老年代激增打满,触发多次yonggc。如果通过yonggc耗时进行监控的话,逻辑上会有问题:如果监控younggc耗时过高,在dump文件的话,实际上在监控到耗时过高的时候,gc行为已经发生了,否则也统计不到时间,所以此时老年代已经清理了,在dump文件也是清理之后的,分析不出来。所以需要清理之前的堆文件。

在dump过一次文件之后,脚本停止,接下来就是实现每一步的脚本。
获取java进程pid
获取java进程pid,需要检测java进程启动,项目需检测tomcat启动成功之后,再进行gc的监控。直接监控启动日志里的【startup in】关键字进行判断
APP_LOG="/export/Logs/Domains/agent_soa.jzt.jd.com/server1/logs/catalina.out" #应用启动日志路径
STARTUP_LOG_FILE="$LOG_DIR/java_startup.log" #应用启动监控日志
# 监控tomcat启动成功
check_app_started_by_log() {
local log_file="${1:-$APP_LOG}"
local max_attempts=60
local attempt=0
echo "[$(date "+%Y-%m-%d %H:%M:%S")] INFO: 开始检测应用启动日志..." >> "$STARTUP_LOG_FILE"
# 等待日志文件出现
while [ $attempt -lt $max_attempts ] && [ ! -f "$log_file" ]; do
echo "[$(date)] INFO: 等待日志文件出现... ($((attempt+1))/$max_attempts)" >> "$STARTUP_LOG_FILE"
sleep 20
attempt=$((attempt+1))
done
if [ ! -f "$log_file" ]; then
echo "[$(date)] ERROR: 日志文件未找到: $log_file" >> "$STARTUP_LOG_FILE"
return 1
fi
# 检测启动完成标志
attempt=0
while [ $attempt -lt $max_attempts ]; do
# 检查是否有 "startup in" 或类似的启动完成标志
if grep -q "startup in" "$log_file"; then
echo "[$(date)] SUCCESS: 检测到应用启动完成标志" >> "$STARTUP_LOG_FILE"
return 0
fi
echo "[$(date)] INFO: 等待应用启动完成... ($((attempt+1))/$max_attempts)" >> "$STARTUP_LOG_FILE"
sleep 20
attempt=$((attempt+1))
done
echo "[$(date)] ERROR: 在日志中未检测到启动完成标志" >> "$STARTUP_LOG_FILE"
return 1
}
当catalina.out中出现日志时,每20s检测一次日志文件中是否存在【startup in】成功的标志。通过这种方式来判断tomcat启动成功。并且在启动成功之后,通过应用名获取对应java进程的pid,tomcat应用对应的应用名是:org.apache.catalina.startup.Bootstrap
APP_NAME="org.apache.catalina.startup.Bootstrap" #tomcat应用名
jps -l | grep "$APP_NAME" | grep -v "jps" | awk '{print $1}'
获取gc相关状态
jstat -gc pid
返回的有17个属性,顺序和含义如下:
| S0C | S1C | S0U | S1U | EC | EU | OC | OU | MC | MU | CCSC | CCSU | YGC | YCGT | FGC | FGCT | GCT |
| 第一个幸存者空间容量 | 第二个幸存者空间容量 | 第一个幸存者空间使用量 | 第二个幸存者空间使用量 | 伊甸园空间容量 | 伊甸园空间使用率 | 老年代容量 | 老年代使用量 | 元空间容量 | 元空间使用量 | 压缩类空间容量 | 压缩类空间使用量 | Young GC次数 | Young GC总耗时(秒) | Full GC次数 | Full GC总耗时(秒) | GC总耗时(秒) |
脚本主要使用OC和OU,即第7和第8个属性,通过这两个属性计算出老年代使用率,与阈值进行对比,具体逻辑如下:
CHECK_INTERVAL=5 # 检查间隔(秒)
OLD_MEMORY_THRESHOLD=90 # 老年代使用率阈值
# 监控内存使用率(eden,old),在gc发生前进行dump
monitor_memory_used() {
local pid=$1
echo "[$(date)] 开始监控堆内存使用率,触发阈值: ${OLD_MEMORY_THRESHOLD}%" >> "$MEMORY_MONITOR_LOG"
while true; do
# 检查进程是否还存在
if ! ps -p $pid > /dev/null 2>&1; then
echo "[$(date "+%Y-%m-%d %H:%M:%S")] INFO: Java进程已退出,停止监控" >> "$MEMORY_MONITOR_LOG"
return 0
fi
sleep $CHECK_INTERVAL
# 获取堆内存信息
local memory_info=$(jstat -gc $pid 2>/dev/null | tail -1)
local eden_used=$(echo "$memory_info" | awk '{print $6}')
local eden_capacity=$(echo "$memory_info" | awk '{print $5}')
local old_used=$(echo "$memory_info" | awk '{print $8}')
local old_capacity=$(echo "$memory_info" | awk '{print $7}')
echo "[$(date)] 内存情况,eden_used:${eden_used},eden_capacity:${eden_capacity};old_used:${old_used},old_capacity:${old_capacity}" >> "$MEMORY_MONITOR_LOG"
# 计算eden、old内存使用率
local eden_usage=$(echo "$eden_used $eden_capacity" | awk '{printf "%d", $1/$2*100}')
local old_usage=$(echo "$old_used $old_capacity" | awk '{printf "%d", $1/$2*100}')
# 计算是否超过阈值
local judge_result=$(echo "$old_usage $OLD_MEMORY_THRESHOLD" | awk '{print $1 - $2}')
echo "[$(date)] Eden使用率: ${eden_usage}%, Old使用率: ${old_usage}%, 是否超过阈值:${judge_result}" >> "$MEMORY_MONITOR_LOG"
if [ $judge_result -gt 0 ]; then
echo "[$(date)] ALERT: 内存使用率超过阈值 ${OLD_MEMORY_THRESHOLD}%" >> "$MEMORY_MONITOR_LOG"
dump_heap_info $pid "内存使用率过高 Eden:${eden_usage}% Old:${old_usage}%"
return 0
fi
done
}
每5秒获取一次gc状态,获取OC和OU,计算出老年代使用率,判断是否大于阈值,如果大于阈值,调用方法执行dump命令,获取此时的堆文件。
dump文件
满足阈值条件后,执行dump动作,除了dump堆文件,还可以输出线程信息及内存对象统计等
#输出堆内存详情
dump_heap_info() {
local pid=$1
local reason=$2
local timestamp=$(date "+%Y%m%d_%H%M%S")
echo "[$(date "+%Y-%m-%d %H:%M:%S")] WARNING: $reason" >> "$MEMORY_MONITOR_LOG"
echo "开始收集堆内存信息..." >> "$MEMORY_MONITOR_LOG"
#并行执行多个诊断命令
echo "=== 堆内存快照 (PID: $pid, 时间: $timestamp, 原因: $reason) ===" >> "$MEMORY_MONITOR_LOG"
# 1. 生成标准的.hprof堆dump文件
echo "生成.hprof堆dump文件..." >> "$MEMORY_MONITOR_LOG"
jmap -dump:live,format=b,file="$DUMP_DIR/heap_${timestamp}.hprof" $pid > /dev/null 2>&1
# 2. 输出jmap heap概要信息
echo "执行jmap -heap..." >> "$MEMORY_MONITOR_LOG"
jmap -heap $pid > "$DUMP_DIR/heap_summary_${timestamp}.log" 2>/dev/null
# 3. 输出jstat GC详细信息
echo "执行jstat -gc..." >> "$MEMORY_MONITOR_LOG"
jstat -gc $pid 1000 3 > "$DUMP_DIR/gc_detail_${timestamp}.log" 2>/dev/null
# 4. 输出jstack线程信息
echo "执行jstack..." >> "$MEMORY_MONITOR_LOG"
jstack $pid > "$DUMP_DIR/threads_${timestamp}.log" 2>/dev/null
# 5. 输出jmap histo内存对象统计
echo "执行jmap -histo..." >> "$MEMORY_MONITOR_LOG"
jmap -histo $pid > "$DUMP_DIR/histo_${timestamp}.log" 2>/dev/null
# 记录操作结果到监控日志
echo "堆内存信息收集完成" >> "$MEMORY_MONITOR_LOG"
echo "生成的文件:" >> "$MEMORY_MONITOR_LOG"
ls -la "$DUMP_DIR" | grep "$timestamp" >> "$MEMORY_MONITOR_LOG"
# 检查.hprof文件是否成功生成
if [ -f "$DUMP_DIR/heap_${timestamp}.hprof" ]; then
hprof_size=$(du -h "$DUMP_DIR/heap_${timestamp}.hprof" | awk '{print $1}')
echo "成功生成.hprof文件,大小: $hprof_size" >> "$MEMORY_MONITOR_LOG"
else
echo "警告: .hprof文件生成可能失败" >> "$MEMORY_MONITOR_LOG"
fi
echo "==============================================" >> "$MEMORY_MONITOR_LOG"
}
# 主函数
main() {
echo "[$(date "+%Y-%m-%d %H:%M:%S")] 开始检测Tomcat进程启动状态..." >> "$STARTUP_LOG_FILE"
# 等待Java进程启动
if check_app_started_by_log; then
echo "[$(date)] SUCCESS: 应用启动完成" >> "$STARTUP_LOG_FILE"
# 获取PID用于GC监控
local pid=$(get_java_pid)
if [ -n "$pid" ]; then
echo "[$(date)] INFO: 找到Java进程PID: $pid" >> "$STARTUP_LOG_FILE"
echo "[$(date)] INFO: 开始内存监控..." >> "$STARTUP_LOG_FILE"
monitor_memory_used $pid
echo "[$(date)] INFO: 内存监控已完成" >> "$STARTUP_LOG_FILE"
else
echo "[$(date)] ERROR: 未找到Java进程" >> "$STARTUP_LOG_FILE"
fi
exit 0
else
echo "[$(date)] ERROR: 应用启动检测失败" >> "$STARTUP_LOG_FILE"
exit 1
fi
}
#执行主函数
main
分析dump文件
原因推断
从概览中看,堆使用大小一共只有670M左右,但jvm监控老年代已经大于3G。继续查看支配树,也没有占比特别大的类型。但在【不可达对象】里,字符类型占据了2.77G,却没有触发fullGc回收。追溯到问题刚开始发生的时间附近,并没有大批量处理数据的场景,分析可能是jvm参数配置的问题。
应用配置的jvm参数
-Xms8192m -Xmx8192m -XX:MaxMetaspaceSize=1024m -XX:MetaspaceSize=1024m -XX:MaxDirectMemorySize=1966m -XX:InitiatingHeapOccupancyPercent=40 -XX:G1ReservePercent=10 -XX:ParallelGCThreads=8 -XX:CICompilerCount=3 -Djava.library.path=/usr/local/lib -server -XX:+UseG1GC -XX:MaxGCPauseMillis=200
使用G1垃圾回收机制,XX:InitiatingHeapOccupancyPercent=40在内存使用率达到40%时,触发G1并发标记周期。在G1垃圾回收器中,老年代通过mixedGC进行回收,而mixedGC是在并发标记完成之后才启动。那么反过来老年代一直没有回收,是因为本次并发标记一直未完成,或者还没有开始并发标记,因此产生该现象的原因可能是并发标记周期过长。
并发标记的触发条件,即XX:InitiatingHeapOccupancyPercent=40的配置,根据上述的jvm参数配置,在堆内存达到3.2G时触发并发标记。在【并发标记】周期内,应用还在同时创建大量对象,年轻代满后再触发younggc,younggc回收后导致对象引用关系发生变化,写屏障记录变化,【并发标记】需要处理这些变化,标记任务堆积,【并发标记】线程繁忙(出现问题时间,cpu有20%左右升高),最终导致并发标记处理效率变低,从而延迟。
观察参考线上其他正常的应用的jvm配置,是没有设置-XX:InitiatingHeapOccupancyPercent和-XX:G1ReservePercent的,可能是参数过度调优了。将这两个参数去掉,充分发挥G1的自适应机制,G1会根据历史的GC数据,动态计算最有的IHOP的值,重新部署代码上线,一周没有在出现超时异常的报警。
问题复现
按上述逻辑,删除-XX:InitiatingHeapOccupancyPercent和-XX:G1ReservePercent之后,线上很长时间都没出现问题,本以为已经解决。大概过了半个月,发现younggc耗时还是有一个突刺
现象和之前的一样,只不过younggc的耗时没有之前那么高,CPU也没有升很多(4%左右),所以没有导致接口出现超时,但是问题还是存在。为了详细查看下gc到底是哪个步骤超时,在jvm配置中添加一下配置,打印gc详细日志:
-XX:+UnlockExperimentalVMOptions
-XX:G1LogLevel=finest
-Xloggc:/export/Logs/gc.log
-XX:+PrintGC
-XX:+PrintGCDetails
-XX:+PrintGCTimeStamps
-XX:+PrintGCDateStamps
该配置打印出每次gc的详细过程。部署一天之后,查看监控问题再次发生,下载出对应的gc日志,搜索younggc耗时过高时间前后的gc日志。
[GC pause (G1 Evacuation Pause) (young) (to-space exhausted), 1.2502035 secs]
[Parallel Time: 1008.2 ms, GC Workers: 8]
[GC Worker Start (ms): 16097834.2 16097834.3 16097834.3 16097834.3 16097834.3 16097834.4 16097834.4 16097834.4
Min: 16097834.2, Avg: 16097834.3, Max: 16097834.4, Diff: 0.2]
[Ext Root Scanning (ms): 8.1 2.5 4.7 2.6 2.4 2.5 2.5 2.5
Min: 2.4, Avg: 3.5, Max: 8.1, Diff: 5.7, Sum: 27.7]
[Thread Roots (ms): 0.0 1.2 1.2 1.2 1.1 1.1 1.1 1.1
Min: 0.0, Avg: 1.0, Max: 1.2, Diff: 1.2, Sum: 7.9]
[StringTable Roots (ms): 0.0 1.5 0.0 1.6 1.6 1.0 1.5 1.6
Min: 0.0, Avg: 1.1, Max: 1.6, Diff: 1.6, Sum: 8.8]
[Universe Roots (ms): 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[JNI Handles Roots (ms): 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[ObjectSynchronizer Roots (ms): 0.0 0.0 0.0 0.0 0.0 0.5 0.0 0.0
Min: 0.0, Avg: 0.1, Max: 0.5, Diff: 0.5, Sum: 0.5]
[FlatProfiler Roots (ms): 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Management Roots (ms): 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[SystemDictionary Roots (ms): 0.0 0.0 3.6 0.0 0.0 0.0 0.0 0.0
Min: 0.0, Avg: 0.4, Max: 3.6, Diff: 3.6, Sum: 3.6]
[CLDG Roots (ms): 8.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
Min: 0.0, Avg: 1.0, Max: 8.0, Diff: 8.0, Sum: 8.0]
[JVMTI Roots (ms): 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[CodeCache Roots (ms): 26.9 32.1 30.1 32.1 32.1 32.1 32.1 32.1
Min: 26.9, Avg: 31.2, Max: 32.1, Diff: 5.2, Sum: 249.4]
[CM RefProcessor Roots (ms): 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Wait For Strong CLD (ms): 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Weak CLD Roots (ms): 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[SATB Filtering (ms): 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0
Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Update RS (ms): 21.7 26.8 24.8 26.9 26.9 26.8 26.9 26.9
Min: 21.7, Avg: 26.0, Max: 26.9, Diff: 5.3, Sum: 207.7]
[Processed Buffers: 255 334 232 235 297 291 320 334
Min: 232, Avg: 287.2, Max: 334, Diff: 102, Sum: 2298]
[Scan RS (ms): 5.2 5.2 5.2 5.2 5.2 5.2 5.2 5.1
Min: 5.1, Avg: 5.2, Max: 5.2, Diff: 0.1, Sum: 41.5]
[Code Root Scanning (ms): 0.0 0.1 0.1 0.0 0.0 0.1 0.0 0.0
Min: 0.0, Avg: 0.0, Max: 0.1, Diff: 0.1, Sum: 0.2]
[Object Copy (ms): 973.1 973.3 973.2 973.3 973.3 973.3 973.3 973.4
Min: 973.1, Avg: 973.3, Max: 973.4, Diff: 0.2, Sum: 7786.1]
[Termination (ms): 0.0 0.0 0.0 0.1 0.1 0.0 0.0 0.0
Min: 0.0, Avg: 0.0, Max: 0.1, Diff: 0.1, Sum: 0.4]
[Termination Attempts: 44 34 47 36 37 39 46 1
Min: 1, Avg: 35.5, Max: 47, Diff: 46, Sum: 284]
[GC Worker Other (ms): 0.0 0.1 0.1 0.0 0.1 0.1 0.0 0.0
Min: 0.0, Avg: 0.1, Max: 0.1, Diff: 0.1, Sum: 0.6]
[GC Worker Total (ms): 1008.1 1008.1 1008.0 1008.1 1008.0 1008.0 1008.0 1007.9
Min: 1007.9, Avg: 1008.0, Max: 1008.1, Diff: 0.2, Sum: 8064.2]
[GC Worker End (ms): 16098842.3 16098842.4 16098842.3 16098842.4 16098842.4 16098842.3 16098842.3 16098842.3
Min: 16098842.3, Avg: 16098842.4, Max: 16098842.4, Diff: 0.0]
[Code Root Fixup: 0.1 ms]
[Code Root Purge: 0.0 ms]
[Clear CT: 0.7 ms]
[Other: 241.2 ms]
[Evacuation Failure: 227.5 ms]
[Recalculate Used: 0.0 ms]
[Remove Self Forwards: 227.5 ms]
[Restore RemSet: 0.0 ms]
[Choose CSet: 0.0 ms]
[Ref Proc: 8.9 ms]
[Ref Enq: 0.0 ms]
[Redirty Cards: 0.9 ms]
[Parallel Redirty: 0.7 0.7 0.7 0.6 0.6 0.6 0.6 0.6
Min: 0.6, Avg: 0.6, Max: 0.7, Diff: 0.1, Sum: 5.1]
[Redirtied Cards: 79330 73702 69015 73797 73221 68409 69670 61368
Min: 61368, Avg: 71064.0, Max: 79330, Diff: 17962, Sum: 568512]
[Humongous Register: 0.6 ms]
[Humongous Total: 72]
[Humongous Candidate: 52]
[Humongous Reclaim: 1.5 ms]
[Humongous Reclaimed: 36]
[Free CSet: 0.8 ms]
[Young Free CSet: 0.8 ms]
[Non-Young Free CSet: 0.0 ms]
[Eden: 4884.0M(4884.0M)->0.0B(3168.0M) Survivors: 28.0M->52.0M Heap: 7934.4M(8192.0M)->4016.0M(8192.0M)]
[Times: user=2.96 sys=0.57, real=1.25 secs]
本次younggc总体耗时1.25秒,GC出现【to-space exhausted】空间耗尽,表明G1在进行年轻代垃圾回收时,没有空闲region存放存活的对象,查看每个步骤的耗时:
1> 发现在对象copy阶段耗时最高【Object Copy (ms): 973.1 973.3 973.2 973.3 973.3 973.3 973.3 973.4】,达到970ms,
2> Evacuation Failure(转移失败): 227.5 ms,当G1无法申请到新region时(堆空间已满),会触发转移失败。在gc过程中,发现无法为某个对象分配区域region时,gc会停止copy过程,在原内存地址给该对象一个"自我转发"的指针,表示该对象本该被移走,但是因为没有空间了,所以还保留在这里。
G1ParScanThreadState::do_copy_to_survivor_space(G1HeapRegionAttr const region_attr,
oop const old,
markWord const old_mark) {
//源对象region必须在回收的cset集合,且不能已经被转发
assert(region_attr.is_in_cset(),
"Unexpected region attr type: %s", region_attr.get_type_str());
assert(!old_mark.is_forwarded(), "precondition");
Klass* klass = UseCompactObjectHeaders
? old_mark.klass()
: old->klass();
const size_t word_sz = old->size_given_klass(klass);
//特殊对象处理:对象所在的region被固定,且对象是基本类型的数组,不允许移动
if (region_attr.is_pinned() && klass->is_typeArray_klass()) {
return handle_evacuation_failure_par(old, old_mark, klass, region_attr, word_sz, true /* cause_pinned */);
}
uint age = 0;
//根据对象的年龄,决定目标区域是拷贝到survivor区域还是晋升到老年代;并尝试分配
G1HeapRegionAttr dest_attr = next_region_attr(region_attr, old_mark, age);
G1HeapRegion* const from_region = _g1h->heap_region_containing(old);
uint node_index = from_region->node_index();
// 先尝试在GC线程本地的plab中快速分配内存
HeapWord* obj_ptr = _plab_allocator->plab_allocate(dest_attr, word_sz, node_index);
//plab分配失败
if (obj_ptr == nullptr) {
//尝试在共享的全局空间中分配
obj_ptr = allocate_copy_slow(&dest_attr, klass, word_sz, age, node_index);
if (obj_ptr == nullptr) {
//全局分配也失败,表示内存空间不足,对应gc日志的(to-space exhausted)
return handle_evacuation_failure_par(old, old_mark, klass, region_attr, word_sz, false /* cause_pinned */);
}
}
assert(obj_ptr != nullptr, "when we get here, allocation should have succeeded");
assert(_g1h->is_in_reserved(obj_ptr), "Allocated memory should be in the heap");
//钩子函数,模拟失败场景测试
if (inject_allocation_failure(from_region->hrm_index())) {
undo_allocation(dest_attr, obj_ptr, word_sz, node_index);
return handle_evacuation_failure_par(old, old_mark, klass, region_attr, word_sz, false /* cause_pinned */);
}
//执行对象拷贝
Prefetch::write(obj_ptr, PrefetchCopyIntervalInBytes);
Copy::aligned_disjoint_words(cast_from_oop<HeapWord*>(old), obj_ptr, word_sz);
const oop obj = cast_to_oop(obj_ptr);
const oop forward_ptr = old->forward_to_atomic(obj, old_mark, memory_order_relaxed);
//处理成功更新gc相关数据
if (forward_ptr == nullptr) {
{
const uint young_index = from_region->young_index_in_cset();
assert((from_region->is_young() && young_index > 0) ||
(!from_region->is_young() && young_index == 0), "invariant" );
_surviving_young_words[young_index] += word_sz;
}
if (dest_attr.is_young()) {
if (age < markWord::max_age) {
age++;
obj->incr_age();
}
_age_table.add(age, word_sz);
} else {
update_bot_after_copying(obj, word_sz);
}
{
assert(dest_attr.is_young() == _g1h->heap_region_containing(obj)->is_survivor(), "must be");
G1SkipCardEnqueueSetter x(&_scanner, dest_attr.is_young());
do_iterate_object(obj, old, klass, region_attr, dest_attr, age);
}
return obj;
} else {
//因为是并发,其他线程如果先处理完成,撤回指针设置操作,返回其他线程设置的指针
_plab_allocator->undo_allocation(dest_attr, obj_ptr, word_sz, node_index);
return forward_ptr;
}
}
//空间耗尽的核心处理逻辑
G1ParScanThreadState::handle_evacuation_failure_par(oop old, markWord m, Klass* klass, G1HeapRegionAttr attr, size_t word_sz, bool cause_pinned) {
//cause_pinned: false表示空间耗尽;true表示分区被固定
assert(_g1h->is_in_cset(old), "Object " PTR_FORMAT " should be in the CSet", p2i(old));
//原子性的自我转发,对像old的mark word修改为一个指向自身的特殊转发指针
oop forward_ptr = old->forward_to_self_atomic(m, memory_order_relaxed);
if (forward_ptr == nullptr) {
//记录失败信息,包含region对象信息和失败原因
G1HeapRegion* r = _g1h->heap_region_containing(old);
record_evacuation_failed_region(r, _worker_id, cause_pinned);
//标记失败对象bitmap为存活,避免被当做垃圾回收掉
_g1h->mark_evac_failure_object(_worker_id, old, word_sz);
_evacuation_failed_info.register_copy_failure(word_sz);
{
//处理对象引用
G1SkipCardEnqueueSetter x(&_scanner, false /* skip_card_enqueue */);
do_iterate_object(old, old, klass, attr, attr, m.age());
}
return old;
} else {
//其他线程已处理,直接返回当前
assert(old == forward_ptr || !_g1h->is_in_cset(forward_ptr),
"Object " PTR_FORMAT " forwarded to: " PTR_FORMAT " "
"should not be in the CSet",
p2i(old), p2i(forward_ptr));
return forward_ptr;
}
}
在发送Evacuation Failure时,G1会触发担保机制,执行一次STW、单线程的FullGc,但是从监控上看并没有执行FullGc操作。查看源码如上,为G1垃圾回收器对象复制逻辑,在空间耗尽时(handle_evacuation_failure_par),执行一个原子性操作,将对象old设置一个指向自己的指针。该操作也是并发执行,所以可能其他线程提前已经设置成功,那么就直接返回。如果本次设置成功,则还会处理记录对应的失败信息及原因。源码中也没有调用对应的do_full_collection触发FullGC。在早期的JDK(jdk7)中,由于G1还不太成熟,处理Evacuation Failure比较简单粗暴,会直接使用FullGC。而对于当前的版本,G1的核心算法得到了重大增强,其中一个关键的改进就是引入了Evacuation Failure的原地处理机制。首要策略:尽力在原地处理Evacuation Failure,将其作为一次延长了的Young GC的一部分,这是默认且首选的行为,避免由于FullGC导致的长时间暂停。
当前G1垃圾回收发生Evacuation Failure,除了上述停止拷贝、设置自我转发、尽力完成本次GC(eden空间释放),还会进行空间分配的策略调整,减少eden区空间,以降低下一次gc压力。本次gc完成后,G1策略逻辑会评估当前堆状态,例如监测到老年代堆空间很高,G1会异步的、很快的启动一次并发标记用于触发mixed gc回收老年代空间。
从下面gc日志也可以看出:发生超时前一次gc,eden空间是4G。在发生超时后,eden空间调整为3G;并且在超时后的下一次gc开始,看到有【initial-mark】标记,表示本次gc暂停,同时也启动并发标记周期,他被整合在了一次younggc中,避免多次STW。紧随其后的是完整的并发标记周期:根区域扫描 –> 并发标记 –> 最终标记(STW) –> 清理(并发清理)。
并发标记之后,会通过多次mixed gc进行回收(年轻代、老年代),三次mixed回收了大量老年代的空间,并且在最后一次mixed gc后,eden区空间获取巨量提升。
前一次gc:[Eden: 4888.0M(4888.0M)->0.0B(4884.0M) Survivors: 24.0M->28.0M Heap: 6269.3M(8192.0M)->1388.4M(8192.0M)]
超时gc:[Eden: 4884.0M(4884.0M)->0.0B(3168.0M) Survivors: 28.0M->52.0M Heap: 7934.4M(8192.0M)->4016.0M(8192.0M)]
后一次gc启动:[GC pause (G1 Evacuation Pause) (young) (initial-mark), 0.0927648 secs]
并发标记:
[GC concurrent-root-region-scan-start]
[GC concurrent-root-region-scan-end, 0.0373856 secs]
[GC concurrent-mark-start]
[GC concurrent-mark-end, 0.5074515 secs]
[Finalize Marking, 0.0053514 secs]
[GC ref-proc, 0.0240335 secs]
[System Dictionary Unloading, 0.0088980 secs]
[Parallel Unloading, 0.0493995 secs]
[Deallocate Metadata, 0.0028566 secs], 0.0624284 secs], 0.0938051 secs]
[Times: user=0.33 sys=0.15, real=0.09 secs]
[GC cleanup 4079M->3059M(8192M), 0.0067314 secs]
[Times: user=0.02 sys=0.00, real=0.00 secs]
[GC concurrent-cleanup-start]
[GC concurrent-cleanup-end, 0.0006846 secs]
#多次mixed gc
[GC pause (G1 Evacuation Pause) (young), 0.0426753 secs]
[Eden: 3132.0M(3132.0M)->0.0B(344.0M) Survivors: 68.0M->64.0M Heap: 6144.9M(8192.0M)->3007.3M(8192.0M)]
[GC pause (G1 Evacuation Pause) (mixed), 0.0244336 secs]
[Eden: 344.0M(344.0M)->0.0B(388.0M) Survivors: 64.0M->20.0M Heap: 3351.3M(8192.0M)->2185.8M(8192.0M)]
[GC pause (G1 Evacuation Pause) (mixed), 0.0362865 secs]
[Eden: 388.0M(388.0M)->0.0B(4892.0M) Survivors: 20.0M->20.0M Heap: 1804.0M(8192.0M)->1200.9M(8192.0M)]
从监控上看老年代突增突降,有可能是存在大对象直接分配到老年代,不过从gc日志上看,并没有【Humongous Allocation】类型的标识,因此不是巨型对象导致的。另一种可能是短时间内创建了大量的对象,迅速填满eden区触发younggc,并且还在不停的创建,在导致Survivor区也填满时,直接晋升到老年代,不过从监控上看,虽然对应时间内调用量有所增加,但是并没有很明显的突刺,也算是在日常正常的水平内。
目前看现象大概率是第二种原因,但是因为应用提供的服务非常多(300多个对外接口),直接从日志中查看比较困难,并且在同一时间只有一个或几个机器出现该现象,其他机器正常。所以本次先通过参数调优进行解决,出现以上问题有两个重要因素:一是eden区空间过大,日常最高达到5G(内存一共8G),触发younggc频率低,但是处理数据量大。二是预留空间及Survivor区空间不足,极端情况下,无法容纳本该在Survivor区的对象,导致直接晋升老年代。
对于上述两种情况,增加以下jvm参数,调整预留空间大小,及eden区大小范围:
-XX:G1ReservePercent=15
-XX:G1NewSizePercent=20
-XX:G1MaxNewSizePercent=40
添加以上参数上线后,观察到老年代突增突降的现象还存在,不过现象出现时,younggc耗时已经降至毫秒级,暂时不影响其他的业务。
后续调整
1、最重要的还是将应用拆分,由于历史原因,代码中融合的服务非常多,并且随着业务发展,后续也会越来越多。一旦出现问题,影响的范围也会更大,需要尽快按照业务域的不同,将应用拆分成多个微服务,这样也同时能避免由于机器问题不同业务间相互影响。
2、继续观察发生问题期间日志情况,排查出调用量较高,或者批量查询的情况,对代码进行优化。
线程监控
使用脚本直接dump文件是很危险的行为,但是是否可以用脚本来监控线程,比如检测内存使用率过高,导出当前线程的一些状态指标进行简单分析,导出线程状态对机器的性能影响,要远远小于dump文件。