跳过正文
全网最硬核 JDK 解析 - 6. 通过 JFR 快速定位 Java 堆 OOM 实战与底层原理
  1. 文章/

全网最硬核 JDK 解析 - 6. 通过 JFR 快速定位 Java 堆 OOM 实战与底层原理

·7190 字·15 分钟
NeatGuyCoding
作者
NeatGuyCoding

0. 观前提醒
#

上一篇文章我们解析了Heap dump 与错误处理诊断相关演进与最佳实践,其中我们提到了,我们不建议打开 -XX:+HeapDumpOnOutOfMemoryError 选项而是通过 JFR 快速定位 Java 堆 OOM。这一篇我们就来详细讲解一下如何通过 JFR 快速定位 Java 堆 OOM 的实战与底层原理。

首先需要准备好如下工具或者环境:

  • Java 25(其实 Java 21+ 就行,我们为了虚拟线程特性)
  • JDK Mission Control(JMC) 8.3+
  • Jmeter
  • 代码库:https://github.com/spring-projects/spring-petclinic.git
  • maven 或者 gradle

1. 我们需要模拟哪些场景?
#

我们这里就模拟在打开 -XX:+HeapDumpOnOutOfMemoryError 选项下,Java 堆 OOM 会触发 Heap Dump 的场景:

  • java.lang.OutOfMemoryError: Java heap space:Java 对象堆空间耗尽(Java 对象堆 OOM)和 java.lang.OutOfMemoryError: GC overhead limit exceeded:GC 开销超限(Java 对象堆相关 OOM)
    • 场景 1: 某个请求有 bug,导致全表扫描,冲爆了 Java 对象堆内存。抛出了 OutOfMemoryError,但是这是异常情况,可能无法输出堆栈日志,在茫茫众多的请求中很难找到这个请求。
    • 场景 2: 用户累计订单量随着你的系统成熟越来越多,大历史订单量的用户越来越多。之前的代码有 bug,用户订单列表实际是拉取每个用户的所有订单内存分页。可能两个大历史订单量的用户同时查询的时候就会抛出 OutOfMemoryError,就算不抛出也会频繁 GC 影响性能。
    • 场景 3: 某个请求会触发分配一个小对象放入类似于缓存的地方,但是这个小对象一直没有被回收,日积月累导致 FullGC 越来越频繁,最后 OutOfMemoryError。
  • java.lang.OutOfMemoryError: Metaspace / Compressed class space:元空间耗尽(会触发 Java 对象堆转储,因为加载的类在 Java 对象堆上有 Class 对象)
    • 场景 4: 动态生成类(如 CGLIB、Javassist 等)过多,最终导致元空间耗尽:这个会有更直接的异常展现出来,不会被淹没。所以也不需要 JFR 来定位。
    • 场景 5:本身元空间不足,动态加载类失败:这个也不会被淹没,不需要 JFR 来定位。
  • java.lang.OutOfMemoryError: Requested array size exceeds VM limit:数组大小超限(会触发 Java 对象堆转储,因为通常表示集合过大)
    • 场景 6: 某个请求有 bug,导致分配了一个超大数组,最终导致数组大小超限:这个也不会被淹没,不需要 JFR 来定位。

我们来分析需要 JFR 定位的场景 1~3

2. 场景 1: 某个请求有 bug,导致全表扫描,冲爆了 Java 对象堆内存
#

git clone 下来代码库后,我们使用 maven 构建。打开项目根目录的 pom.xml,确保 Java 语言级别为 21 及以上的版本:

img_15.png

修改 application.properties 开启虚拟线程(spring.threads.virtual.enabled=true

img_3.png

增加一个会返回大量数据的查询,模拟问题:

public interface OwnerRepository extends JpaRepository<Owner, Integer> {
    // ... 省略其他现有代码 ...
	
	@Query(
		value = "SELECT REPEAT('A', :count) as repeated_string",
		nativeQuery = true
	)
	String repeatString(@Param("count") int count);
}

增加一个 Controller 方法调用这个查询:

@Controller
class CrashController {

    // ... 省略其他现有代码 ...

	@Autowired
	private OwnerRepository repository;

	@ResponseBody
	@PostMapping("/large-oom-query")
	public String triggerLargeOomQuery() {
		return repository.repeatString(1024 * 1024 * 1024);
	}
}

之后找到 jmeter 脚本,位于 src/test/jmeter/petclinic.jmx

img.png

我们将线程数量改为 50,循环次数无限,模拟持续不断的请求的环境,类似于一个不断被访问的线上环境:

img_1.png

增加 jfc 配置文件,开启一些 JFR 事件,名字命名为 test-oom.jfc

<?xml version="1.0" encoding="UTF-8"?>
<configuration version="2.0">
	<event name="jdk.AllocationRequiringGC">
		<setting name="enabled" control="gc-enabled-high">true</setting>
		<setting name="stackTrace">true</setting>
	</event>
	<event name="jdk.ZAllocationStall">
		<setting name="enabled">true</setting>
		<setting name="stackTrace">true</setting>
		<setting name="threshold">0 ms</setting>
	</event>
</configuration>

启动 Spring Boot 应用,JVM 参数:

-XX:+UseG1GC
-Xmx256m 
-XX:StartFlightRecording=disk=true,maxsize=5000m,maxage=2d,settings=./test-oom.jfc 
-XX:FlightRecorderOptions=maxchunksize=128m,repository=./,stackdepth=256,preserve-repository=true
  • -XX:+UseG1GC:使用 G1 垃圾收集器,目前我用的是 JDK 25,默认垃圾收集器就是 G1
  • -Xmx256m:限制最大堆内存为 256MB,方便我们触发 Java 对象堆 OOM
  • -XX:StartFlightRecording=disk=true,maxsize=5000m,maxage=2d,settings=./test-oom.jfc:开启 JFR,设置最大大小为 5000MB,最大保存时间为 2 天,使用我们自定义的配置文件 test-oom.jfc,并且启动 JFR 事件的临时文件 chunk
  • -XX:FlightRecorderOptions=maxchunksize=128m,repository=./,stackdepth=256,preserve-repository=true:设置 JFR chunk 的最大大小为 128MB,JFR 事件临时文件存储位置为当前目录,堆栈深度为 256,并且保留 JFR 事件临时文件 chunk

启动 jmeter 脚本,模拟持续不断的请求。

之后,发送请求: POST http://localhost:8080/large-oom-query ,就可以触发 Java 对象堆 OOM,可以从日志中看到大量异常输出,OutOfMemoryError: Java heap space 已经被淹没:

2025-11-09T10:47:01.322+08:00  INFO 6708 --- [at-handler-2731] [                                                 ] o.h.e.internal.DefaultLoadEventListener  : HHH000327: Error performing load command

org.hibernate.exception.JDBCConnectionException: JDBC exception executing SQL [The database has been closed [90098-232]] [select o1_0.id,o1_0.address,o1_0.city,o1_0.first_name,o1_0.last_name,o1_0.telephone,p1_0.owner_id,p1_0.id,p1_0.birth_date,p1_0.name,t1_0.id,t1_0.name from owners o1_0 left join pets p1_0 on o1_0.id=p1_0.owner_id left join types t1_0 on t1_0.id=p1_0.type_id where o1_0.id=? order by p1_0.name]
	at org.hibernate.exception.internal.SQLExceptionTypeDelegate.convert(SQLExceptionTypeDelegate.java:49) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.exception.internal.StandardSQLExceptionConverter.convert(StandardSQLExceptionConverter.java:34) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.engine.jdbc.spi.SqlExceptionHelper.convert(SqlExceptionHelper.java:115) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.sql.results.jdbc.internal.DeferredResultSetAccess.executeQuery(DeferredResultSetAccess.java:304) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.sql.results.jdbc.internal.DeferredResultSetAccess.getResultSet(DeferredResultSetAccess.java:200) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.sql.results.jdbc.internal.JdbcValuesResultSetImpl.<init>(JdbcValuesResultSetImpl.java:72) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.sql.exec.internal.JdbcSelectExecutorStandardImpl.resolveJdbcValues(JdbcSelectExecutorStandardImpl.java:372) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.sql.exec.internal.JdbcSelectExecutorStandardImpl.resolveJdbcValuesSource(JdbcSelectExecutorStandardImpl.java:332) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.sql.exec.internal.JdbcSelectExecutorStandardImpl.doExecuteQuery(JdbcSelectExecutorStandardImpl.java:135) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.sql.exec.internal.JdbcSelectExecutorStandardImpl.executeQuery(JdbcSelectExecutorStandardImpl.java:100) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.sql.exec.spi.JdbcSelectExecutor.executeQuery(JdbcSelectExecutor.java:64) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.sql.exec.spi.JdbcSelectExecutor.list(JdbcSelectExecutor.java:138) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.loader.ast.internal.SingleIdLoadPlan.load(SingleIdLoadPlan.java:143) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.loader.ast.internal.SingleIdLoadPlan.load(SingleIdLoadPlan.java:115) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.loader.ast.internal.SingleIdEntityLoaderStandardImpl.load(SingleIdEntityLoaderStandardImpl.java:66) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.persister.entity.AbstractEntityPersister.doLoad(AbstractEntityPersister.java:3532) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.persister.entity.AbstractEntityPersister.load(AbstractEntityPersister.java:3521) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.event.internal.DefaultLoadEventListener.loadFromDatasource(DefaultLoadEventListener.java:596) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.event.internal.DefaultLoadEventListener.loadFromCacheOrDatasource(DefaultLoadEventListener.java:570) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.event.internal.DefaultLoadEventListener.load(DefaultLoadEventListener.java:554) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.event.internal.DefaultLoadEventListener.doLoad(DefaultLoadEventListener.java:538) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.event.internal.DefaultLoadEventListener.load(DefaultLoadEventListener.java:204) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.event.internal.DefaultLoadEventListener.loadWithRegularProxy(DefaultLoadEventListener.java:284) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.event.internal.DefaultLoadEventListener.proxyOrLoad(DefaultLoadEventListener.java:236) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.event.internal.DefaultLoadEventListener.doOnLoad(DefaultLoadEventListener.java:109) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.event.internal.DefaultLoadEventListener.onLoad(DefaultLoadEventListener.java:68) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.event.service.internal.EventListenerGroupImpl.fireEventOnEachListener(EventListenerGroupImpl.java:151) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.internal.SessionImpl.fireLoadNoChecks(SessionImpl.java:1287) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.internal.SessionImpl.fireLoad(SessionImpl.java:1274) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.internal.SessionImpl.load(SessionImpl.java:1256) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.loader.internal.IdentifierLoadAccessImpl.doLoad(IdentifierLoadAccessImpl.java:181) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.loader.internal.IdentifierLoadAccessImpl.lambda$load$1(IdentifierLoadAccessImpl.java:167) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.loader.internal.IdentifierLoadAccessImpl.perform(IdentifierLoadAccessImpl.java:134) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.loader.internal.IdentifierLoadAccessImpl.load(IdentifierLoadAccessImpl.java:167) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.internal.SessionImpl.find(SessionImpl.java:2403) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at org.hibernate.internal.SessionImpl.find(SessionImpl.java:2377) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[na:na]
	at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[na:na]
	at org.springframework.orm.jpa.SharedEntityManagerCreator$SharedEntityManagerInvocationHandler.invoke(SharedEntityManagerCreator.java:317) ~[spring-orm-7.0.0-M9.jar:7.0.0-M9]
	at jdk.proxy2/jdk.proxy2.$Proxy166.find(Unknown Source) ~[na:na]
	at org.springframework.data.jpa.repository.support.SimpleJpaRepository.findById(SimpleJpaRepository.java:344) ~[spring-data-jpa-4.0.0-M6.jar:4.0.0-M6]
	at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[na:na]
	at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[na:na]
	at org.springframework.aop.support.AopUtils.invokeJoinpointUsingReflection(AopUtils.java:359) ~[spring-aop-7.0.0-M9.jar:7.0.0-M9]
	at org.springframework.data.repository.core.support.RepositoryMethodInvoker$RepositoryFragmentMethodInvoker.lambda$new$0(RepositoryMethodInvoker.java:278) ~[spring-data-commons-4.0.0-M6.jar:4.0.0-M6]
	at org.springframework.data.repository.core.support.RepositoryMethodInvoker.doInvoke(RepositoryMethodInvoker.java:169) ~[spring-data-commons-4.0.0-M6.jar:4.0.0-M6]
	at org.springframework.data.repository.core.support.RepositoryMethodInvoker.invoke(RepositoryMethodInvoker.java:158) ~[spring-data-commons-4.0.0-M6.jar:4.0.0-M6]
	at org.springframework.data.repository.core.support.RepositoryComposition$RepositoryFragments.invoke(RepositoryComposition.java:545) ~[spring-data-commons-4.0.0-M6.jar:4.0.0-M6]
	at org.springframework.data.repository.core.support.RepositoryComposition.invoke(RepositoryComposition.java:290) ~[spring-data-commons-4.0.0-M6.jar:4.0.0-M6]
	at org.springframework.data.repository.core.support.RepositoryFactorySupport$ImplementationMethodExecutionInterceptor.invoke(RepositoryFactorySupport.java:708) ~[spring-data-commons-4.0.0-M6.jar:4.0.0-M6]
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179) ~[spring-aop-7.0.0-M9.jar:7.0.0-M9]
	at org.springframework.data.repository.core.support.QueryExecutorMethodInterceptor.doInvoke(QueryExecutorMethodInterceptor.java:171) ~[spring-data-commons-4.0.0-M6.jar:4.0.0-M6]
	at org.springframework.data.repository.core.support.QueryExecutorMethodInterceptor.invoke(QueryExecutorMethodInterceptor.java:146) ~[spring-data-commons-4.0.0-M6.jar:4.0.0-M6]
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179) ~[spring-aop-7.0.0-M9.jar:7.0.0-M9]
	at org.springframework.data.projection.DefaultMethodInvokingMethodInterceptor.invoke(DefaultMethodInvokingMethodInterceptor.java:69) ~[spring-data-commons-4.0.0-M6.jar:4.0.0-M6]
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179) ~[spring-aop-7.0.0-M9.jar:7.0.0-M9]
	at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:369) ~[spring-tx-7.0.0-M9.jar:7.0.0-M9]
	at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:118) ~[spring-tx-7.0.0-M9.jar:7.0.0-M9]
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179) ~[spring-aop-7.0.0-M9.jar:7.0.0-M9]
	at org.springframework.dao.support.PersistenceExceptionTranslationInterceptor.invoke(PersistenceExceptionTranslationInterceptor.java:135) ~[spring-tx-7.0.0-M9.jar:7.0.0-M9]
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179) ~[spring-aop-7.0.0-M9.jar:7.0.0-M9]
	at org.springframework.data.jpa.repository.support.CrudMethodMetadataPostProcessor$CrudMethodMetadataPopulatingMethodInterceptor.invoke(CrudMethodMetadataPostProcessor.java:167) ~[spring-data-jpa-4.0.0-M6.jar:4.0.0-M6]
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179) ~[spring-aop-7.0.0-M9.jar:7.0.0-M9]
	at org.springframework.data.util.NullnessMethodInvocationValidator.invoke(NullnessMethodInvocationValidator.java:99) ~[spring-data-commons-4.0.0-M6.jar:4.0.0-M6]
	at org.springframework.aop.framework.ReflectiveMethodInvocation.proceed(ReflectiveMethodInvocation.java:179) ~[spring-aop-7.0.0-M9.jar:7.0.0-M9]
	at org.springframework.aop.framework.JdkDynamicAopProxy.invoke(JdkDynamicAopProxy.java:222) ~[spring-aop-7.0.0-M9.jar:7.0.0-M9]
	at jdk.proxy2/jdk.proxy2.$Proxy176.findById(Unknown Source) ~[na:na]
	at org.springframework.samples.petclinic.owner.PetController.findOwner(PetController.java:69) ~[classes/:na]
	at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) ~[na:na]
	at java.base/java.lang.reflect.Method.invoke(Method.java:565) ~[na:na]
	at org.springframework.web.method.support.InvocableHandlerMethod.doInvoke(InvocableHandlerMethod.java:256) ~[spring-web-7.0.0-M9.jar:7.0.0-M9]
	at org.springframework.web.method.support.InvocableHandlerMethod.invokeForRequest(InvocableHandlerMethod.java:188) ~[spring-web-7.0.0-M9.jar:7.0.0-M9]
	at org.springframework.web.method.annotation.ModelFactory.invokeModelAttributeMethods(ModelFactory.java:142) ~[spring-web-7.0.0-M9.jar:7.0.0-M9]
	at org.springframework.web.method.annotation.ModelFactory.initModel(ModelFactory.java:111) ~[spring-web-7.0.0-M9.jar:7.0.0-M9]
	at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.invokeHandlerMethod(RequestMappingHandlerAdapter.java:918) ~[spring-webmvc-7.0.0-M9.jar:7.0.0-M9]
	at org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter.handleInternal(RequestMappingHandlerAdapter.java:852) ~[spring-webmvc-7.0.0-M9.jar:7.0.0-M9]
	at org.springframework.web.servlet.mvc.method.AbstractHandlerMethodAdapter.handle(AbstractHandlerMethodAdapter.java:86) ~[spring-webmvc-7.0.0-M9.jar:7.0.0-M9]
	at org.springframework.web.servlet.DispatcherServlet.doDispatch(DispatcherServlet.java:963) ~[spring-webmvc-7.0.0-M9.jar:7.0.0-M9]
	at org.springframework.web.servlet.DispatcherServlet.doService(DispatcherServlet.java:866) ~[spring-webmvc-7.0.0-M9.jar:7.0.0-M9]
	at org.springframework.web.servlet.FrameworkServlet.processRequest(FrameworkServlet.java:1003) ~[spring-webmvc-7.0.0-M9.jar:7.0.0-M9]
	at org.springframework.web.servlet.FrameworkServlet.doGet(FrameworkServlet.java:892) ~[spring-webmvc-7.0.0-M9.jar:7.0.0-M9]
	at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:622) ~[tomcat-embed-core-11.0.11.jar:6.1]
	at org.springframework.web.servlet.FrameworkServlet.service(FrameworkServlet.java:874) ~[spring-webmvc-7.0.0-M9.jar:7.0.0-M9]
	at jakarta.servlet.http.HttpServlet.service(HttpServlet.java:710) ~[tomcat-embed-core-11.0.11.jar:6.1]
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:130) ~[tomcat-embed-core-11.0.11.jar:11.0.11]
	at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:53) ~[tomcat-embed-websocket-11.0.11.jar:11.0.11]
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:109) ~[tomcat-embed-core-11.0.11.jar:11.0.11]
	at org.springframework.web.servlet.resource.ResourceUrlEncodingFilter.doFilter(ResourceUrlEncodingFilter.java:66) ~[spring-webmvc-7.0.0-M9.jar:7.0.0-M9]
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:109) ~[tomcat-embed-core-11.0.11.jar:11.0.11]
	at org.springframework.web.filter.RequestContextFilter.doFilterInternal(RequestContextFilter.java:100) ~[spring-web-7.0.0-M9.jar:7.0.0-M9]
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.0-M9.jar:7.0.0-M9]
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:109) ~[tomcat-embed-core-11.0.11.jar:11.0.11]
	at org.springframework.web.filter.FormContentFilter.doFilterInternal(FormContentFilter.java:93) ~[spring-web-7.0.0-M9.jar:7.0.0-M9]
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.0-M9.jar:7.0.0-M9]
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:109) ~[tomcat-embed-core-11.0.11.jar:11.0.11]
	at org.springframework.web.filter.ServerHttpObservationFilter.doFilterInternal(ServerHttpObservationFilter.java:110) ~[spring-web-7.0.0-M9.jar:7.0.0-M9]
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.0-M9.jar:7.0.0-M9]
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:109) ~[tomcat-embed-core-11.0.11.jar:11.0.11]
	at org.springframework.web.filter.CharacterEncodingFilter.doFilterInternal(CharacterEncodingFilter.java:199) ~[spring-web-7.0.0-M9.jar:7.0.0-M9]
	at org.springframework.web.filter.OncePerRequestFilter.doFilter(OncePerRequestFilter.java:116) ~[spring-web-7.0.0-M9.jar:7.0.0-M9]
	at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:109) ~[tomcat-embed-core-11.0.11.jar:11.0.11]
	at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:167) ~[tomcat-embed-core-11.0.11.jar:11.0.11]
	at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:79) ~[tomcat-embed-core-11.0.11.jar:11.0.11]
	at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:483) ~[tomcat-embed-core-11.0.11.jar:11.0.11]
	at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:116) ~[tomcat-embed-core-11.0.11.jar:11.0.11]
	at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:93) ~[tomcat-embed-core-11.0.11.jar:11.0.11]
	at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:74) ~[tomcat-embed-core-11.0.11.jar:11.0.11]
	at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:343) ~[tomcat-embed-core-11.0.11.jar:11.0.11]
	at org.apache.coyote.http11.Http11Processor.service(Http11Processor.java:396) ~[tomcat-embed-core-11.0.11.jar:11.0.11]
	at org.apache.coyote.AbstractProcessorLight.process(AbstractProcessorLight.java:63) ~[tomcat-embed-core-11.0.11.jar:11.0.11]
	at org.apache.coyote.AbstractProtocol$ConnectionHandler.process(AbstractProtocol.java:903) ~[tomcat-embed-core-11.0.11.jar:11.0.11]
	at org.apache.tomcat.util.net.NioEndpoint$SocketProcessor.doRun(NioEndpoint.java:1780) ~[tomcat-embed-core-11.0.11.jar:11.0.11]
	at org.apache.tomcat.util.net.SocketProcessorBase.run(SocketProcessorBase.java:52) ~[tomcat-embed-core-11.0.11.jar:11.0.11]
	at java.base/java.lang.VirtualThread.run(VirtualThread.java:456) ~[na:na]
Caused by: org.h2.jdbc.JdbcSQLNonTransientConnectionException: The database has been closed [90098-232]
	at org.h2.message.DbException.getJdbcSQLException(DbException.java:690) ~[h2-2.3.232.jar:2.3.232]
	at org.h2.message.DbException.getJdbcSQLException(DbException.java:489) ~[h2-2.3.232.jar:2.3.232]
	at org.h2.message.DbException.get(DbException.java:212) ~[h2-2.3.232.jar:2.3.232]
	at org.h2.engine.SessionLocal.getTransaction(SessionLocal.java:1616) ~[h2-2.3.232.jar:2.3.232]
	at org.h2.engine.SessionLocal.startStatementWithinTransaction(SessionLocal.java:1637) ~[h2-2.3.232.jar:2.3.232]
	at org.h2.command.Command.executeQuery(Command.java:190) ~[h2-2.3.232.jar:2.3.232]
	at org.h2.jdbc.JdbcPreparedStatement.executeQuery(JdbcPreparedStatement.java:130) ~[h2-2.3.232.jar:2.3.232]
	at com.zaxxer.hikari.pool.ProxyPreparedStatement.executeQuery(ProxyPreparedStatement.java:52) ~[HikariCP-7.0.2.jar:na]
	at com.zaxxer.hikari.pool.HikariProxyPreparedStatement.executeQuery(HikariProxyPreparedStatement.java) ~[HikariCP-7.0.2.jar:na]
	at org.hibernate.sql.results.jdbc.internal.DeferredResultSetAccess.executeQuery(DeferredResultSetAccess.java:285) ~[hibernate-core-7.1.1.Final.jar:7.1.1.Final]
	... 110 common frames omitted

...... 大量日志省略

先不要停止进程,我们将 JFR 事件本地 chunk 临时文件复制出来,目录结构类似于:

 - 2025_11_09_10_54_11_7246
   | 2025_11_09_10_54_11.jfr

使用 JMC 打开这个 jfr 文件,查看 Allocation Requiring GC 事件,按照大小倒序排列,可以找到到最大的对象分配请求(不论分配是否成功,都会在分配前生成这个事件):

img_4.png

我们再试试改成用 ZGC:

-XX:+UseZGC
-Xmx256m
-XX:StartFlightRecording=disk=true,maxsize=5000m,maxage=2d,settings=./test-oom.jfc
-XX:FlightRecorderOptions=maxchunksize=128m,repository=./,stackdepth=256,preserve-repository=true

由于 ZGC 不会有 Allocation Requiring GC 事件,我们可以查看 ZAllocationStall 事件(这个事件机制类似,不论分配是否成功,都会在分配前生成这个事件),按照大小倒序排列,同样可以找到到最大的对象分配请求:

img_5.png

可以看出,针对情况一,我们可以通过 JFR 快速定位到触发 Java 对象堆 OOM 的请求,进而定位到代码位置进行修复,而不需要依赖于 -XX:+HeapDumpOnOutOfMemoryError 选项生成的 Heap Dump 文件进行离线分析。

3. 场景 2: 用户累计订单量随着你的系统成熟越来越多,大历史订单量的用户越来越多
#

增加一个新的 Controller 方法,模拟大历史订单量的用户查询:

@ResponseBody
@GetMapping("/large-user-query")
public String largeUserQuery() {
    return repository.repeatString(32 * 1024 * 1024);
}

然后修改 jmeter 脚本,增加一个新的请求,模拟大量用户访问这个接口:

img_6.png

启动 Spring Boot 应用,JVM 参数同上:

-XX:+UseG1GC
-Xmx256m
-XX:StartFlightRecording=disk=true,maxsize=5000m,maxage=2d,settings=./test-oom.jfc
-XX:FlightRecorderOptions=maxchunksize=128m,repository=./,stackdepth=256,preserve-repository=true

启动 jmeter 脚本,模拟持续不断的请求。一段时间就会看到 Java 对象堆 OOM,可以从日志中看到大量异常输出,OutOfMemoryError: Java heap space 已经被淹没。

同样的,将 JFR 事件本地 chunk 临时文件复制出来,使用 JMC 打开这个 jfr 文件,查看 Allocation Requiring GC 事件,按照大小倒序排列。针对这个场景,可以找到大量的 Allocation Requiring GC 事件,对象大小大概是 32MB 左右,对应我们模拟的请求:

img_7.png

再试试改成用 ZGC:

-XX:+UseZGC
-Xmx256m
-XX:StartFlightRecording=disk=true,maxsize=5000m,maxage=2,settings=./test-oom.jfc
-XX:FlightRecorderOptions=maxchunksize=128m,repository=./,stackdepth=256,preserve-repository=true

由于 ZGC 不会有 Allocation Requiring GC 事件,我们可以查看 ZAllocationStall 事件,按照大小倒序排列,同样可以找到大量的 ZAllocationStall 事件,对象大小大概是 32MB 左右,对应我们模拟的请求:

img_8.png

可以看出,针对情况二,我们同样可以通过 JFR 快速定位到触发 Java 对象堆 OOM 的请求,进而定位到代码位置进行修复,而不需要依赖于 -XX:+HeapDumpOnOutOfMemoryError 选项生成的 Heap Dump 文件进行离线分析。

4. 场景 3: 某个请求会触发分配一个小对象放入类似于缓存的地方,但是这个小对象一直没有被回收
#

这个缓存,一般是一个容器,比如 MapListQueue 等等。JDK 中的这个内置实现,底层都会涉及数组,并且都是动态扩容的。而且,这个扩容在容器已经比较大的时候一般还是每次扩容到原来的 1.5~2 倍。

4.1. List 容器扩容实现
#

4.1.1. ArrayList
#

JDK 11jdk-jdk-11-28/src/java.base/share/classes/java/util/ArrayList.java

    private int newCapacity(int minCapacity) {
        // overflow-conscious code
        int oldCapacity = elementData.length;
        int newCapacity = oldCapacity + (oldCapacity >> 1);
        if (newCapacity - minCapacity <= 0) {
            if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA)
                return Math.max(DEFAULT_CAPACITY, minCapacity);
            if (minCapacity < 0) // overflow
                throw new OutOfMemoryError();
            return minCapacity;
        }
        return (newCapacity - MAX_ARRAY_SIZE <= 0)
            ? newCapacity
            : hugeCapacity(minCapacity);
    }

JDK 17+jdk-jdk-17-35/src/java.base/share/classes/java/util/ArrayList.java

    private Object[] grow(int minCapacity) {
        int oldCapacity = elementData.length;
        if (oldCapacity > 0 || elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
            int newCapacity = ArraysSupport.newLength(oldCapacity,
                    minCapacity - oldCapacity, /* minimum growth */
                    oldCapacity >> 1           /* preferred growth */);
            return elementData = Arrays.copyOf(elementData, newCapacity);
        } else {
            return elementData = new Object[Math.max(DEFAULT_CAPACITY, minCapacity)];
        }
    }

扩容机制

  • 扩容倍数:1.5 倍(oldCapacity + (oldCapacity >> 1)
  • 默认初始容量:10
  • 版本差异
    • JDK 11:直接计算新容量
    • JDK 17+:使用 ArraysSupport.newLength 统一处理,逻辑相同但代码更统一

4.1.2. Vector
#

JDK 11jdk-jdk-11-28/src/java.base/share/classes/java/util/Vector.java

    private int newCapacity(int minCapacity) {
        // overflow-conscious code
        int oldCapacity = elementData.length;
        int newCapacity = oldCapacity + ((capacityIncrement > 0) ?
                                         capacityIncrement : oldCapacity);
        if (newCapacity - minCapacity <= 0) {
            if (minCapacity < 0) // overflow
                throw new OutOfMemoryError();
            return minCapacity;
        }
        return (newCapacity - MAX_ARRAY_SIZE <= 0)
            ? newCapacity
            : hugeCapacity(minCapacity);
    }

扩容机制

  • 扩容策略
    • 如果 capacityIncrement > 0:按增量扩容(oldCapacity + capacityIncrement
    • 如果 capacityIncrement <= 0:2 倍扩容(oldCapacity + oldCapacity
  • 默认初始容量:10
  • 默认 capacityIncrement:0(因此默认是 2 倍扩容)

4.1.3. ArrayDeque
#

JDK 11jdk-jdk-11-28/src/java.base/share/classes/java/util/ArrayDeque.java

    private void grow(int needed) {
        // overflow-conscious code
        final int oldCapacity = elements.length;
        int newCapacity;
        // Double capacity if small; else grow by 50%
        int jump = (oldCapacity < 64) ? (oldCapacity + 2) : (oldCapacity >> 1);
        if (jump < needed
            || (newCapacity = (oldCapacity + jump)) - MAX_ARRAY_SIZE > 0)
            newCapacity = newCapacity(needed, jump);
        final Object[] es = elements = Arrays.copyOf(elements, newCapacity);
        // Exceptionally, here tail == head needs to be disambiguated
        if (tail < head || (tail == head && es[head] != null)) {
            // wrap around; slide first leg forward to end of array
            int newSpace = newCapacity - oldCapacity;
            System.arraycopy(es, head,
                             es, head + newSpace,
                             oldCapacity - head);
            for (int i = head, to = (head += newSpace); i < to; i++)
                es[i] = null;
        }
    }

扩容机制

  • 扩容策略
    • 容量 < 64:增加 2(oldCapacity + 2
    • 容量 >= 64:1.5 倍扩容(oldCapacity + (oldCapacity >> 1)
  • 默认初始容量:16

4.1.4. CopyOnWriteArrayList
#

JDK 11-25jdk-jdk-11-28/src/java.base/share/classes/java/util/concurrent/CopyOnWriteArrayList.java

    public boolean add(E e) {
        synchronized (lock) {
            Object[] es = getArray();
            int len = es.length;
            es = Arrays.copyOf(es, len + 1);
            es[len] = e;
            setArray(es);
            return true;
        }
    }

扩容机制

  • 扩容策略:精确扩容,每次只增加 1(len + 1
  • 特点:写时复制(Copy-On-Write),每次修改操作都会创建新数组
  • 初始容量:0(空数组)
  • 内存影响:由于每次写操作都复制整个数组,频繁写入会导致大量内存分配和 GC 压力

4.2. Queue 容器扩容实现
#

4.2.1. PriorityQueue
#

JDK 11jdk-jdk-11-28/src/java.base/share/classes/java/util/PriorityQueue.java

    private void grow(int minCapacity) {
        int oldCapacity = queue.length;
        // Double size if small; else grow by 50%
        int newCapacity = oldCapacity + ((oldCapacity < 64) ?
                                         (oldCapacity + 2) :
                                         (oldCapacity >> 1));
        // overflow-conscious code
        if (newCapacity - MAX_ARRAY_SIZE > 0)
            newCapacity = hugeCapacity(minCapacity);
        queue = Arrays.copyOf(queue, newCapacity);
    }

JDK 17+jdk-jdk-17-35/src/java.base/share/classes/java/util/PriorityQueue.java

    private void grow(int minCapacity) {
        int oldCapacity = queue.length;
        // Double size if small; else grow by 50%
        int newCapacity = ArraysSupport.newLength(oldCapacity,
                minCapacity - oldCapacity, /* minimum growth */
                oldCapacity < 64 ? oldCapacity + 2 : oldCapacity >> 1
                                           /* preferred growth */);
        queue = Arrays.copyOf(queue, newCapacity);
    }

扩容机制

  • 扩容策略
    • 容量 < 64:增加 oldCapacity + 2(接近 2 倍)
    • 容量 >= 64:1.5 倍扩容(oldCapacity + (oldCapacity >> 1)
  • 默认初始容量:11
  • 版本差异
    • JDK 11:直接计算新容量
    • JDK 17+:使用 ArraysSupport.newLength 统一处理

4.2.2. PriorityBlockingQueue
#

JDK 11jdk-jdk-11-28/src/java.base/share/classes/java/util/concurrent/PriorityBlockingQueue.java

    private void tryGrow(Object[] array, int oldCap) {
        lock.unlock(); // must release and then re-acquire main lock
        Object[] newArray = null;
        if (allocationSpinLock == 0 &&
            ALLOCATIONSPINLOCK.compareAndSet(this, 0, 1)) {
            try {
                int newCap = oldCap + ((oldCap < 64) ?
                                       (oldCap + 2) : // grow faster if small
                                       (oldCap >> 1));
                if (newCap - MAX_ARRAY_SIZE > 0) {    // possible overflow
                    int minCap = oldCap + 1;
                    if (minCap < 0 || minCap > MAX_ARRAY_SIZE)
                        throw new OutOfMemoryError();
                    newCap = MAX_ARRAY_SIZE;
                }
                if (newCap > oldCap && queue == array)
                    newArray = new Object[newCap];
            } finally {
                allocationSpinLock = 0;
            }
        }

JDK 17+jdk-jdk-17-35/src/java.base/share/classes/java/util/concurrent/PriorityBlockingQueue.java

    private void tryGrow(Object[] array, int oldCap) {
        lock.unlock(); // must release and then re-acquire main lock
        Object[] newArray = null;
        if (allocationSpinLock == 0 &&
            ALLOCATIONSPINLOCK.compareAndSet(this, 0, 1)) {
            try {
                int growth = (oldCap < 64)
                    ? (oldCap + 2) // grow faster if small
                    : (oldCap >> 1);
                int newCap = ArraysSupport.newLength(oldCap, 1, growth);
                if (queue == array)
                    newArray = new Object[newCap];
            } finally {
                allocationSpinLock = 0;
            }
        }
        if (newArray == null) // back off if another thread is allocating
            Thread.yield();
        lock.lock();
        if (newArray != null && queue == array) {
            queue = newArray;
            System.arraycopy(array, 0, newArray, 0, oldCap);
        }
    }

扩容机制

  • 扩容策略:与 PriorityQueue 相同
    • 容量 < 64:增加 oldCapacity + 2
    • 容量 >= 64:1.5 倍扩容
  • 默认初始容量:11
  • 线程安全:使用 CAS 和锁机制保证并发安全
  • 版本差异
    • JDK 11:直接计算新容量
    • JDK 17+:使用 ArraysSupport.newLength 统一处理

4.2.3. ArrayBlockingQueue
#

JDK 11-25jdk-jdk-11-28/src/java.base/share/classes/java/util/concurrent/ArrayBlockingQueue.java

扩容机制

  • 扩容策略固定容量,不支持扩容
  • 特点:构造时指定容量,之后容量不可变
  • 用途:有界队列,用于生产者-消费者模式,防止队列无限增长

4.2.4. LinkedBlockingQueue
#

JDK 11-25jdk-jdk-11-28/src/java.base/share/classes/java/util/concurrent/LinkedBlockingQueue.java

扩容机制

  • 扩容策略基于链表实现,不涉及数组扩容
  • 特点:每个元素都是独立的 Node 对象,动态创建和销毁
  • 容量限制:可指定最大容量,默认 Integer.MAX_VALUE(无界)
  • 内存影响:每个节点都有额外的对象头开销,内存利用率低于数组实现

4.2.5. ConcurrentLinkedQueue
#

扩容机制

  • 扩容策略基于链表实现,不涉及数组扩容
  • 特点:无锁并发队列,使用 CAS 操作
  • 容量限制:无界队列

4.3. Map 容器扩容实现
#

4.3.1. HashMap
#

JDK 11-25jdk-jdk-11-28/src/java.base/share/classes/java/util/HashMap.java

    final Node<K,V>[] resize() {
        Node<K,V>[] oldTab = table;
        int oldCap = (oldTab == null) ? 0 : oldTab.length;
        int oldThr = threshold;
        int newCap, newThr = 0;
        if (oldCap > 0) {
            if (oldCap >= MAXIMUM_CAPACITY) {
                threshold = Integer.MAX_VALUE;
                return oldTab;
            }
            else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
                     oldCap >= DEFAULT_INITIAL_CAPACITY)
                newThr = oldThr << 1; // double threshold
        }
        else if (oldThr > 0) // initial capacity was placed in threshold
            newCap = oldThr;
        else {               // zero initial threshold signifies using defaults
            newCap = DEFAULT_INITIAL_CAPACITY;
            newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
        }
        if (newThr == 0) {
            float ft = (float)newCap * loadFactor;
            newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                      (int)ft : Integer.MAX_VALUE);
        }
        threshold = newThr;
        @SuppressWarnings({"rawtypes","unchecked"})
        Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
        table = newTab;
        if (oldTab != null) {
            for (int j = 0; j < oldCap; ++j) {
                Node<K,V> e;
                if ((e = oldTab[j]) != null) {
                    oldTab[j] = null;
                    if (e.next == null)
                        newTab[e.hash & (newCap - 1)] = e;
                    else if (e instanceof TreeNode)
                        ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                    else { // preserve order
                        Node<K,V> loHead = null, loTail = null;
                        Node<K,V> hiHead = null, hiTail = null;
                        Node<K,V> next;
                        do {
                            next = e.next;
                            if ((e.hash & oldCap) == 0) {
                                if (loTail == null)
                                    loHead = e;
                                else
                                    loTail.next = e;
                                loTail = e;
                            }
                            else {
                                if (hiTail == null)
                                    hiHead = e;
                                else
                                    hiTail.next = e;
                                hiTail = e;
                            }
                        } while ((e = next) != null);
                        if (loTail != null) {
                            loTail.next = null;
                            newTab[j] = loHead;
                        }
                        if (hiTail != null) {
                            hiTail.next = null;
                            newTab[j + oldCap] = hiHead;
                        }
                    }
                }
            }
        }
        return newTab;
    }

扩容机制

  • 扩容倍数:2 倍(newCap = oldCap << 1
  • 默认初始容量:16
  • 负载因子:0.75
  • 扩容触发条件size > thresholdthreshold = capacity * loadFactor

4.3.2. LinkedHashMap
#

LinkedHashMap 继承自 HashMap,扩容机制与 HashMap 相同,都是 2 倍扩容。

4.3.3. Hashtable
#

Hashtable 的扩容机制与 HashMap 类似,也是 2 倍扩容,但它是线程安全的。

4.3.4. ConcurrentHashMap
#

ConcurrentHashMap 的扩容机制也是 2 倍扩容,但实现更复杂,支持多线程并发扩容。

4.4. 版本对比总结
#

容器类型JDK 11JDK 17+扩容策略默认初始容量说明
List 容器
ArrayList直接计算ArraysSupport.newLength1.5 倍10
Vector直接计算ArraysSupport.newLength2 倍(默认)或按增量10
ArrayDeque直接计算直接计算< 64: +2, >= 64: 1.5 倍16
CopyOnWriteArrayList精确扩容精确扩容每次 +10写时复制,每次写操作都创建新数组
LinkedList链表实现链表实现不涉及数组扩容-基于链表,动态创建节点
Queue 容器
PriorityQueue直接计算ArraysSupport.newLength< 64: +oldCap+2, >= 64: 1.5 倍11
PriorityBlockingQueue直接计算ArraysSupport.newLength< 64: +oldCap+2, >= 64: 1.5 倍11线程安全版本
ArrayBlockingQueue固定容量固定容量不支持扩容构造时指定有界队列
LinkedBlockingQueue链表实现链表实现不涉及数组扩容Integer.MAX_VALUE基于链表
ConcurrentLinkedQueue链表实现链表实现不涉及数组扩容无界无锁并发队列
Map 容器
HashMap直接计算直接计算2 倍16
LinkedHashMap继承 HashMap继承 HashMap2 倍16
Hashtable直接计算直接计算2 倍11
ConcurrentHashMap直接计算直接计算2 倍16支持并发扩容

版本演进特点

  • JDK 17+ArrayListVectorPriorityQueuePriorityBlockingQueue 使用 ArraysSupport.newLength 统一处理扩容计算,提高了代码复用性和可维护性
  • 扩容倍数
    • 大多数容器采用 2 倍扩容(HashMapHashtableConcurrentHashMap 等)
    • ArrayListArrayDeque(大容量时)、PriorityQueue(大容量时)采用 1.5 倍扩容
    • CopyOnWriteArrayList 采用精确扩容(每次 +1),但每次写操作都复制整个数组
  • 特殊容器
    • ArrayBlockingQueue:固定容量,不支持扩容
    • LinkedListLinkedBlockingQueueConcurrentLinkedQueue:基于链表,不涉及数组扩容
  • 扩容策略:倍数扩容可以减少扩容次数,但会导致内存浪费,特别是在内存泄漏场景下

大部分我们常用的缓存容器,底层实现都有基于数组的动态扩容机制。而在内存泄漏的场景下,这些容器会不断扩容,最终肯定会有比较大的数组扩容请求,被记录到 JFR 事件中,从而帮助我们快速定位问题。

4.5. 通过 JFR 定位容器扩容触发的 OOM
#

继续使用上面的 Spring Boot 应用,增加一个新的 Controller 方法,模拟一个缓存容器不断扩容的场景:

private ConcurrentHashMap cache = new ConcurrentHashMap();

@ResponseBody
@GetMapping("/small-cumulative-task")
public String smallCumulativeTask() {
   //模拟一个请求,向缓存中放入大量小对象
   for (int i = 0; i < 100_000; i++) {
      cache.put(new Object(), new Object());
   }
   return "OK";
}

然后修改 jmeter 脚本,增加一个新的请求,模拟大量用户访问这个接口:

img_9.png

这次,我们需要修改下 JFR 采集事件配置,增加上 Allocation Outside TLAB 事件(由于本次是小内存持续泄漏,其他种类的并发请求很多,可能扩容的数组那次请求没有触发 Allocation Requiring GC 或者 ZAllocationStall 事件,但是扩容的历程一定能在 Allocation Outside TLAB 中体现出来):

<?xml version="1.0" encoding="UTF-8"?>
<configuration version="2.0">
	<event name="jdk.ObjectAllocationOutsideTLAB">
		<setting name="enabled">true</setting>
		<setting name="stackTrace">true</setting>
	</event>
	<event name="jdk.AllocationRequiringGC">
		<setting name="enabled" control="gc-enabled-high">true</setting>
		<setting name="stackTrace">true</setting>
	</event>
	<event name="jdk.ZAllocationStall">
		<setting name="enabled">true</setting>
		<setting name="stackTrace">true</setting>
		<setting name="threshold">0 ms</setting>
	</event>
</configuration>

启动 Spring Boot 应用,JVM 参数同上:

-XX:+UseG1GC
-Xmx256m
-XX:StartFlightRecording=disk=true,maxsize=5000m,maxage=2d,settings=./test-oom.jfc
-XX:FlightRecorderOptions=maxchunksize=128m,repository=./,stackdepth=256,preserve-repository=true

启动 jmeter 脚本,模拟持续不断的请求。一段时间就会看到 Java 对象堆 OOM,并且没有堆栈信息,因为这种容器扩容触发的 OutOfMemoryError 更严重无法恢复。前面的两个场景触发 OutOfMemoryError 异常的一般是大分配请求,抛出 OutOfMemoryError 之后分配就被放弃内存就被释放了。但是这种场景触发 OutOfMemoryError 异常的是大量小对象持续分配,导致容器不断扩容,最终触发 OutOfMemoryError,这种情况下内存无法被释放,最后看到的控制台会类似于下面的:

img_10.png

同样的,将 JFR 事件本地 chunk 临时文件复制出来,使用 JMC 打开这个 jfr 文件,首先查看 Allocation Requiring GC 事件,按照大小倒序排列,可能找不到明显的线索:

img_11.png

接着查看 Allocation Outside TLAB 事件,按照大小倒序排列,可以看出明显的扩容请求线索:

img_12.png

接下来,我们继续试试改成用 ZGC:

-XX:+UseZGC
-Xmx256m
-XX:StartFlightRecording=disk=true,maxsize=5000m,maxage=2,settings=./test-oom.jfc
-XX:FlightRecorderOptions=maxchunksize=128m,repository=./,stackdepth=256,preserve-repository=true

由于 ZGC 不会有 Allocation Requiring GC 事件,我们可以查看 ZAllocationStall 事件,发现也不明显:

img_13.png

但是查看 Allocation Outside TLAB 事件,按照大小倒序排列,同样可以看到明显的扩容请求线索:

img_14.png

可以看出,针对情况三,我们同样可以通过 JFR 快速定位到触发 Java 对象堆 OOM 的请求,进而定位到代码位置进行修复,而不需要依赖于 -XX:+HeapDumpOnOutOfMemoryError 选项生成的 Heap Dump 文件进行离线分析。

5. 总结
#

下面是这三个 JFR 事件的采集时机:

  • jdk.ObjectAllocationOutsideTLAB:当一个对象分配请求无法在 TLAB(Thread-Local Allocation Buffer)中满足时触发。TLAB 是每个线程专用的一块内存区域,用于快速分配小对象。当一个对象太大或者当前 TLAB 空间不足以容纳该对象时,JVM 会尝试在堆的其他区域分配内存,并触发该事件。 JVM 中 TLAB 原理可以参考我的另一篇文章:TLAB 全面解析。和另外两个事件的区别是,只有分配成功的才会被记录到这个事件中。通过这个事件,可以明显看出 JDK 内部容器的扩容趋势,从而定位到内存泄漏的代码位置
  • jdk.AllocationRequiringGC:当一个对象分配请求无法在堆中满足时触发,这通常是因为堆内存不足以容纳该对象。JVM 会尝试触发垃圾回收(GC)以释放内存,然后重新尝试分配。如果 GC 后仍然无法满足分配请求,JVM 会抛出 OutOfMemoryError 异常。这个事件无论是否分配成功都会记录通过这个事件,可以大概率在非 ZGC、ShenandoahGC 的 GC 情况下看到一次性的大内存请求异常导致的 Java 堆 OOM
  • jdk.ZAllocationStall:当使用 ZGC 时,如果一个对象分配请求无法在堆中满足,通常发生在 GC 回收速度不满足分配速度的时候。这个事件无论是否分配成功都会记录通过这个事件,可以大概率在 ZGC 的 GC 情况下看到一次性的大内存请求异常导致的 Java 堆 OOM

通过这三个事件,我们可以覆盖大部分 Java 对象堆 OOM 的场景,快速定位到触发 OOM 的代码位置,从而进行修复,而不需要依赖于 -XX:+HeapDumpOnOutOfMemoryError 选项生成的 Heap Dump 文件进行离线分析,极大地提升了排查效率。

相关文章

使用 JFR 排查 SSL 性能瓶颈

·868 字·2 分钟
深入分析微服务性能问题,包括 CPU 峰值和数据库连接异常。通过 JFR 分析,我们发现根本原因是 Java SecureRandom 在 /dev/random 上阻塞,并提供使用 /dev/urandom 的解决方案。

全网最硬核 JDK 分析 - 1. TLAB 全面解析

·21531 字·43 分钟
深入探讨 JVM 的线程本地分配缓冲区(TLAB)机制,涵盖设计原理、实现细节、性能优化和源代码分析。了解 TLAB 如何提高多线程环境中的内存分配效率,并掌握 TLAB 调优技术。