0. Important Notice#
In the previous article, we analyzed Heap dump and error handling diagnostic evolution and best practices, where we mentioned that we do not recommend enabling the -XX:+HeapDumpOnOutOfMemoryError option, but instead use JFR to quickly locate Java heap OOM. This article will provide a detailed explanation of how to quickly locate Java heap OOM using JFR with practical methods and underlying principles.
First, prepare the following tools or environment:
- Java 25 (actually Java 21+ is sufficient, we use it for virtual thread features)
- JDK Mission Control (JMC) 8.3+
- Jmeter
- Code repository: https://github.com/spring-projects/spring-petclinic.git
- maven or gradle
1. What Scenarios Do We Need to Simulate?#
Here we simulate scenarios where Java heap OOM would trigger Heap Dump when the -XX:+HeapDumpOnOutOfMemoryError option is enabled:
java.lang.OutOfMemoryError: Java heap space: Java heap space exhausted (Java heap OOM) andjava.lang.OutOfMemoryError: GC overhead limit exceeded: GC overhead limit exceeded (Java heap-related OOM)- Scenario 1: A request has a bug that causes a full table scan, overwhelming the Java heap memory. An OutOfMemoryError is thrown, but this is an exceptional situation that may not output stack trace logs, making it difficult to find this request among the vast number of requests.
- Scenario 2: As your system matures, users’ cumulative order volumes increase, and there are more and more users with large historical order volumes. Previous code had a bug where the user order list actually pulls all orders for each user for in-memory pagination. When two users with large historical order volumes query simultaneously, it may throw OutOfMemoryError, and even if it doesn’t, frequent GC will affect performance.
- Scenario 3: A request triggers allocation of a small object into a cache-like location, but this small object is never reclaimed, accumulating over time and causing FullGC to become more frequent, eventually leading to OutOfMemoryError.
java.lang.OutOfMemoryError: Metaspace/Compressed class space: Metaspace exhausted (will trigger Java heap dump because loaded classes have Class objects on the Java heap)- Scenario 4: Too many dynamically generated classes (such as CGLIB, Javassist, etc.) eventually lead to metaspace exhaustion: This will have more direct exception display and won’t be buried. So JFR is not needed for localization.
- Scenario 5: Insufficient metaspace itself, dynamic class loading fails: This also won’t be buried, JFR is not needed for localization.
java.lang.OutOfMemoryError: Requested array size exceeds VM limit: Array size exceeds VM limit (will trigger Java heap dump because it usually indicates an oversized collection)- Scenario 6: A request has a bug that causes allocation of an oversized array, eventually leading to array size limit exceeded: This also won’t be buried, JFR is not needed for localization.
We will analyze scenarios 1-3 that require JFR for localization.
2. Scenario 1: A Request Has a Bug That Causes a Full Table Scan, Overwhelming the Java Heap Memory#
After git cloning the code repository, we use maven to build. Open the pom.xml in the project root directory and ensure the Java language level is version 21 or above:

Modify application.properties to enable virtual threads (spring.threads.virtual.enabled=true)

Add a query that returns a large amount of data to simulate the problem:
public interface OwnerRepository extends JpaRepository<Owner, Integer> {
// ... omit other existing code ...
@Query(
value = "SELECT REPEAT('A', :count) as repeated_string",
nativeQuery = true
)
String repeatString(@Param("count") int count);
}
Add a Controller method to call this query:
@Controller
class CrashController {
// ... omit other existing code ...
@Autowired
private OwnerRepository repository;
@ResponseBody
@PostMapping("/large-oom-query")
public String triggerLargeOomQuery() {
return repository.repeatString(1024 * 1024 * 1024);
}
}
Then find the jmeter script located at src/test/jmeter/petclinic.jmx:

We change the thread count to 50 and set the loop count to infinite to simulate a continuously accessed environment, similar to an online environment that is constantly being accessed:

Add a jfc configuration file to enable some JFR events, named 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>
Start the Spring Boot application with JVM parameters:
-XX:+UseG1GC
-Xmx256m
-XX:StartFlightRecording=disk=true,maxsize=5000m,maxage=2d,settings=./test-oom.jfc
-XX:FlightRecorderOptions=maxchunksize=128m,repository=./,stackdepth=256
-XX:+UseG1GC: Use G1 garbage collector, I’m currently using JDK 25, where the default garbage collector is G1-Xmx256m: Limit maximum heap memory to 256MB to easily trigger Java heap OOM-XX:StartFlightRecording=disk=true,maxsize=5000m,maxage=2d,settings=./test-oom.jfc: Enable JFR, set maximum size to 5000MB, maximum retention time to 2 days, use our custom configuration filetest-oom.jfc, and start JFR event temporary file chunks-XX:FlightRecorderOptions=maxchunksize=128m,repository=./,stackdepth=256,preserve-repository=true: Set JFR chunk maximum size to 128MB, JFR event temporary file storage location to current directory, stack depth to 256
Start the jmeter script to simulate continuous requests.
Then, send a request: POST http://localhost:8080/large-oom-query to trigger Java heap OOM. You can see a large number of exception outputs in the logs, and OutOfMemoryError: Java heap space has been buried:
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-webmvc-7.0.0-M9.jar:7.0.0-M9]
at org.springframework.web.method.annotation.ModelFactory.initModel(ModelFactory.java:111) ~[spring-webmvc-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-webmvc-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-webmvc-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-webmvc-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
...... Large amount of logs omitted
Do not stop the process yet. Copy out the JFR event local chunk temporary files. The directory structure is similar to:
- 2025_11_09_10_54_11_7246
| 2025_11_09_10_54_11.jfr
Open this jfr file using JMC, view the Allocation Requiring GC event, sorted by size in descending order. You can find the largest object allocation request (this event is generated before allocation, regardless of whether the allocation succeeds):

Let’s try switching to 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
Since ZGC doesn’t have Allocation Requiring GC events, we can view the ZAllocationStall event (this event mechanism is similar, generated before allocation regardless of whether allocation succeeds), sorted by size in descending order. Similarly, we can find the largest object allocation request:

It can be seen that for scenario 1, we can quickly locate the request that triggered Java heap OOM through JFR, and then locate the code position for fixing, without relying on Heap Dump files generated by the -XX:+HeapDumpOnOutOfMemoryError option for offline analysis.
3. Scenario 2: Users’ Cumulative Order Volumes Increase as Your System Matures, More and More Users Have Large Historical Order Volumes#
Add a new Controller method to simulate user queries with large historical order volumes:
@ResponseBody
@GetMapping("/large-user-query")
public String largeUserQuery() {
return repository.repeatString(32 * 1024 * 1024);
}
Then modify the jmeter script to add a new request simulating many users accessing this interface:

Start the Spring Boot application with the same JVM parameters as above:
-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
Start the jmeter script to simulate continuous requests. After a while, you will see Java heap OOM, and you can see a large number of exception outputs in the logs. OutOfMemoryError: Java heap space has been buried.
Similarly, copy out the JFR event local chunk temporary files, open this jfr file using JMC, and view the Allocation Requiring GC event sorted by size in descending order. For this scenario, you can find many Allocation Requiring GC events with object sizes around 32MB, corresponding to our simulated request:

Let’s try switching to ZGC again:
-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
Since ZGC doesn’t have Allocation Requiring GC events, we can view the ZAllocationStall event sorted by size in descending order. Similarly, we can find many ZAllocationStall events with object sizes around 32MB, corresponding to our simulated request:

It can be seen that for scenario 2, we can also quickly locate the request that triggered Java heap OOM through JFR, and then locate the code position for fixing, without relying on Heap Dump files generated by the -XX:+HeapDumpOnOutOfMemoryError option for offline analysis.
4. Scenario 3: A Request Triggers Allocation of a Small Object into a Cache-Like Location, but This Small Object Is Never Reclaimed#
This cache is generally a container, such as Map, List, Queue, etc. The built-in implementations in JDK all involve arrays at the bottom layer and are dynamically expandable. Moreover, when the container is already relatively large, this expansion generally expands to 1.5 to 2 times the original size each time.
4.1. List Container Expansion Implementation#
4.1.1. ArrayList#
JDK 11:
jdk-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)];
}
}
Expansion Mechanism:
- Expansion Factor: 1.5x (
oldCapacity + (oldCapacity >> 1)) - Default Initial Capacity: 10
- Version Differences:
- JDK 11: Direct capacity calculation
- JDK 17+: Uses
ArraysSupport.newLengthfor unified processing, same logic but more unified code
4.1.2. Vector#
JDK 11:
jdk-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);
}
Expansion Mechanism:
- Expansion Strategy:
- If
capacityIncrement > 0: Expand by increment (oldCapacity + capacityIncrement) - If
capacityIncrement <= 0: 2x expansion (oldCapacity + oldCapacity)
- If
- Default Initial Capacity: 10
- Default capacityIncrement: 0 (therefore default is 2x expansion)
4.1.3. ArrayDeque#
JDK 11:
jdk-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;
}
}
Expansion Mechanism:
- Expansion Strategy:
- Capacity < 64: Add 2 (
oldCapacity + 2) - Capacity >= 64: 1.5x expansion (
oldCapacity + (oldCapacity >> 1))
- Capacity < 64: Add 2 (
- Default Initial Capacity: 16
4.1.4. CopyOnWriteArrayList#
JDK 11-25:
jdk-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;
}
}
Expansion Mechanism:
- Expansion Strategy: Exact expansion, only increases by 1 each time (
len + 1) - Characteristics: Copy-On-Write, each modification operation creates a new array
- Initial Capacity: 0 (empty array)
- Memory Impact: Since each write operation copies the entire array, frequent writes will cause significant memory allocation and GC pressure
4.2. Queue Container Expansion Implementation#
4.2.1. PriorityQueue#
JDK 11:
jdk-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);
}
Expansion Mechanism:
- Expansion Strategy:
- Capacity < 64: Add
oldCapacity + 2(close to 2x) - Capacity >= 64: 1.5x expansion (
oldCapacity + (oldCapacity >> 1))
- Capacity < 64: Add
- Default Initial Capacity: 11
- Version Differences:
- JDK 11: Direct capacity calculation
- JDK 17+: Uses
ArraysSupport.newLengthfor unified processing
4.2.2. PriorityBlockingQueue#
JDK 11:
jdk-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);
}
}
Expansion Mechanism:
- Expansion Strategy: Same as
PriorityQueue- Capacity < 64: Add
oldCapacity + 2 - Capacity >= 64: 1.5x expansion
- Capacity < 64: Add
- Default Initial Capacity: 11
- Thread Safety: Uses CAS and lock mechanisms to ensure concurrency safety
- Version Differences:
- JDK 11: Direct capacity calculation
- JDK 17+: Uses
ArraysSupport.newLengthfor unified processing
4.2.3. ArrayBlockingQueue#
JDK 11-25:
jdk-jdk-11-28/src/java.base/share/classes/java/util/concurrent/ArrayBlockingQueue.java
Expansion Mechanism:
- Expansion Strategy: Fixed capacity, does not support expansion
- Characteristics: Capacity specified at construction, capacity is immutable afterward
- Purpose: Bounded queue for producer-consumer patterns, preventing unlimited queue growth
4.2.4. LinkedBlockingQueue#
JDK 11-25:
jdk-jdk-11-28/src/java.base/share/classes/java/util/concurrent/LinkedBlockingQueue.java
Expansion Mechanism:
- Expansion Strategy: Linked list implementation, does not involve array expansion
- Characteristics: Each element is an independent
Nodeobject, dynamically created and destroyed - Capacity Limit: Can specify maximum capacity, default
Integer.MAX_VALUE(unbounded) - Memory Impact: Each node has additional object header overhead, memory utilization is lower than array implementation
4.2.5. ConcurrentLinkedQueue#
Expansion Mechanism:
- Expansion Strategy: Linked list implementation, does not involve array expansion
- Characteristics: Lock-free concurrent queue, uses CAS operations
- Capacity Limit: Unbounded queue
4.3. Map Container Expansion Implementation#
4.3.1. HashMap#
JDK 11-25:
jdk-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;
}
Expansion Mechanism:
- Expansion Factor: 2x (
newCap = oldCap << 1) - Default Initial Capacity: 16
- Load Factor: 0.75
- Expansion Trigger Condition:
size > threshold(threshold = capacity * loadFactor)
4.3.2. LinkedHashMap#
LinkedHashMap inherits from HashMap, and its expansion mechanism is the same as HashMap, both are 2x expansion.
4.3.3. Hashtable#
Hashtable’s expansion mechanism is similar to HashMap, also 2x expansion, but it is thread-safe.
4.3.4. ConcurrentHashMap#
ConcurrentHashMap’s expansion mechanism is also 2x expansion, but the implementation is more complex and supports multi-threaded concurrent expansion.
4.4. Version Comparison Summary#
| Container Type | JDK 11 | JDK 17+ | Expansion Strategy | Default Initial Capacity | Notes |
|---|---|---|---|---|---|
| List Containers | |||||
| ArrayList | Direct calculation | ArraysSupport.newLength | 1.5x | 10 | |
| Vector | Direct calculation | ArraysSupport.newLength | 2x (default) or by increment | 10 | |
| ArrayDeque | Direct calculation | Direct calculation | < 64: +2, >= 64: 1.5x | 16 | |
| CopyOnWriteArrayList | Exact expansion | Exact expansion | +1 each time | 0 | Copy-On-Write, each write operation creates new array |
| LinkedList | Linked list implementation | Linked list implementation | No array expansion | - | Based on linked list, dynamic node creation |
| Queue Containers | |||||
| PriorityQueue | Direct calculation | ArraysSupport.newLength | < 64: +oldCap+2, >= 64: 1.5x | 11 | |
| PriorityBlockingQueue | Direct calculation | ArraysSupport.newLength | < 64: +oldCap+2, >= 64: 1.5x | 11 | Thread-safe version |
| ArrayBlockingQueue | Fixed capacity | Fixed capacity | No expansion | Constructor specified | Bounded queue |
| LinkedBlockingQueue | Linked list implementation | Linked list implementation | No array expansion | Integer.MAX_VALUE | Based on linked list |
| ConcurrentLinkedQueue | Linked list implementation | Linked list implementation | No array expansion | Unbounded | Lock-free concurrent queue |
| Map Containers | |||||
| HashMap | Direct calculation | Direct calculation | 2x | 16 | |
| LinkedHashMap | Inherits HashMap | Inherits HashMap | 2x | 16 | |
| Hashtable | Direct calculation | Direct calculation | 2x | 11 | |
| ConcurrentHashMap | Direct calculation | Direct calculation | 2x | 16 | Supports concurrent expansion |
Version Evolution Characteristics:
- JDK 17+:
ArrayList,Vector,PriorityQueue,PriorityBlockingQueueuseArraysSupport.newLengthfor unified expansion calculation, improving code reusability and maintainability - Expansion Factors:
- Most containers use 2x expansion (
HashMap,Hashtable,ConcurrentHashMap, etc.) ArrayListandArrayDeque(large capacity),PriorityQueue(large capacity) use 1.5x expansionCopyOnWriteArrayListuses exact expansion (+1 each time), but each write operation copies the entire array
- Most containers use 2x expansion (
- Special Containers:
ArrayBlockingQueue: Fixed capacity, does not support expansionLinkedList,LinkedBlockingQueue,ConcurrentLinkedQueue: Based on linked lists, no array expansion involved
- Expansion Strategy: Multiplicative expansion can reduce expansion frequency but leads to memory waste, especially in memory leak scenarios
Most commonly used cache containers have array-based dynamic expansion mechanisms at the bottom layer. In memory leak scenarios, these containers will continuously expand, and there will definitely be relatively large array expansion requests recorded in JFR events, helping us quickly locate problems.
4.5. Locating Container Expansion-Triggered OOM Through JFR#
Continue using the Spring Boot application above, add a new Controller method to simulate a cache container continuously expanding:
private ConcurrentHashMap cache = new ConcurrentHashMap();
@ResponseBody
@GetMapping("/small-cumulative-task")
public String smallCumulativeTask() {
// Simulate a request that puts many small objects into the cache
for (int i = 0; i < 100_000; i++) {
cache.put(new Object(), new Object());
}
return "OK";
}
Then modify the jmeter script to add a new request simulating many users accessing this interface:
This time, we need to modify the JFR event collection configuration to add the Allocation Outside TLAB event (since this is a small memory continuous leak with many other types of concurrent requests, the array expansion request may not trigger Allocation Requiring GC or ZAllocationStall events, but the expansion process will definitely be reflected in 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>
Start the Spring Boot application with the same JVM parameters as above:
-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
Start the jmeter script to simulate continuous requests. After a while, you will see Java heap OOM, and there will be no stack trace information because this container expansion-triggered OutOfMemoryError is more severe and cannot be recovered. The first two scenarios generally trigger OutOfMemoryError exceptions with large allocation requests. After throwing OutOfMemoryError, the allocation is abandoned and memory is released. However, this scenario triggers OutOfMemoryError exceptions with many small objects continuously allocated, causing containers to continuously expand and eventually trigger OutOfMemoryError. In this case, memory cannot be released, and the console you see will be similar to the following:

Similarly, copy out the JFR event local chunk temporary files, open this jfr file using JMC, and first view the Allocation Requiring GC event sorted by size in descending order. You may not find obvious clues:

Then view the Allocation Outside TLAB event sorted by size in descending order. You can see obvious expansion request clues:

Next, let’s try switching to ZGC again:
-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
Since ZGC doesn’t have Allocation Requiring GC events, we can view the ZAllocationStall event and find it’s also not obvious:

But viewing the Allocation Outside TLAB event sorted by size in descending order, we can also see obvious expansion request clues:

It can be seen that for scenario 3, we can also quickly locate the request that triggered Java heap OOM through JFR, and then locate the code position for fixing, without relying on Heap Dump files generated by the -XX:+HeapDumpOnOutOfMemoryError option for offline analysis.
5. Summary#
Below are the collection timings for these three JFR events:
jdk.ObjectAllocationOutsideTLAB: Triggered when an object allocation request cannot be satisfied in TLAB (Thread-Local Allocation Buffer). TLAB is a memory area dedicated to each thread for fast allocation of small objects. When an object is too large or the current TLAB space is insufficient to accommodate the object, the JVM will try to allocate memory in other areas of the heap and trigger this event. For TLAB principles in JVM, you can refer to my other article: Complete TLAB Analysis. The difference from the other two events is that only successful allocations are recorded in this event. Through this event, you can clearly see the expansion trend of JDK internal containers, thereby locating the code position of memory leaks.jdk.AllocationRequiringGC: Triggered when an object allocation request cannot be satisfied in the heap, usually because the heap memory is insufficient to accommodate the object. The JVM will try to trigger garbage collection (GC) to free memory, then retry allocation. If the allocation request still cannot be satisfied after GC, the JVM will throw anOutOfMemoryErrorexception. This event is recorded regardless of whether allocation succeeds. Through this event, you can most likely see one-time large memory request anomalies causing Java heap OOM in non-ZGC, non-ShenandoahGC GC situations.jdk.ZAllocationStall: When using ZGC, if an object allocation request cannot be satisfied in the heap, this usually occurs when GC reclamation speed cannot meet allocation speed. This event is recorded regardless of whether allocation succeeds. Through this event, you can most likely see one-time large memory request anomalies causing Java heap OOM in ZGC GC situations.
Through these three events, we can cover most Java heap OOM scenarios, quickly locate the code position that triggered OOM, and then fix it, without relying on Heap Dump files generated by the -XX:+HeapDumpOnOutOfMemoryError option for offline analysis, greatly improving troubleshooting efficiency.



