spring-projects / spring-projects/spring-boot
@ConfigurationProperties binding of a List<JavaBean> can loop until OutOfMemoryError in 4.1.0 (was a bounded BindException in 4.0.6)
Nobody has claimed this yet.
- Dominant language
- Java
- Stars
- 81.5k
- Forks
- 42.7k
- Avg merge
- 2d 4h
- Merged PRs (30d)
- 65
Description
Spring Boot version
- Reproduced on 4.1.0
- Behaves differently on 4.0.6 (no OOM — see below)
- JVM: Eclipse Temurin 25.0.3+9 (LTS) · OS: macOS 26.5.1 (arm64)
- (the binder logic is plain Java, so it is unlikely to be JVM/OS-specific, but this is the configuration I verified)
Describe the bug
When a @ConfigurationProperties object has a List<SomeJavaBean> property, and an indexed element
(prefix.list[i]) resolves to a non-null but empty value, binding does not terminate: the application
fails to start with a deterministic java.lang.OutOfMemoryError: Java heap space during context
initialization. The heap is exhausted allocating ConfigurationPropertyName instances inside
IndexedElementsBinder.bindIndexed.
On 4.0.6 the exact same setup does not OOM — binding stops quickly with a clear, bounded error:
Failed to bind properties under 'app.routes[0]' to com.example.demo.DemoApplication$RouteConfig
So a previously bounded failure has become an unbounded allocation / OOM.
I hit this while upgrading a real application from 4.0.6 to 4.1.0 (the app's context OOMed on startup binding a
List<...> @ConfigurationProperties). The snippet below is a reduced, standalone reproduction.
Stack trace (4.1.0)
Caused by: java.lang.OutOfMemoryError: Java heap space
at org.springframework.boot.context.properties.bind.IndexedElementsBinder.bindIndexed(IndexedElementsBinder.java:117)
at org.springframework.boot.context.properties.bind.IndexedElementsBinder.bindIndexed(IndexedElementsBinder.java:92)
at org.springframework.boot.context.properties.bind.IndexedElementsBinder.bindIndexed(IndexedElementsBinder.java:76)
at org.springframework.boot.context.properties.bind.CollectionBinder.bindAggregate(CollectionBinder.java:50)
at org.springframework.boot.context.properties.bind.JavaBeanBinder.bind(JavaBeanBinder.java:127)
at org.springframework.boot.context.properties.bind.JavaBeanBinder.bind(JavaBeanBinder.java:115)
at org.springframework.boot.context.properties.bind.JavaBeanBinder.bind(JavaBeanBinder.java:71)
How to reproduce
Full runnable project (just spring-boot-starter, single Java file, run.sh with a small heap):
https://github.com/Lumenol/spring-boot-4.1-binder-oom — flip the parent version between 4.1.0 and
4.0.6 in pom.xml.
The two relevant files are inlined below.
DemoApplication.java:
package com.example.demo;
import java.util.ArrayList;
import java.util.List;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.event.ApplicationEnvironmentPreparedEvent;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.ApplicationListener;
import org.springframework.core.env.PropertySource;
@SpringBootApplication
@EnableConfigurationProperties(DemoApplication.AppProperties.class)
public class DemoApplication {
public static void main(String[] args) {
SpringApplication app = new SpringApplication(DemoApplication.class);
// A high-priority, NON-iterable property source (registered before the application config) that
// returns a non-null EMPTY value for every app.routes[i]. This is the shape of a non-iterable
// source that answers a whole prefix (similar in spirit to RandomValuePropertySource for random.*).
app.addListeners((ApplicationListener<ApplicationEnvironmentPreparedEvent>) e ->
e.getEnvironment().getPropertySources().addFirst(new PropertySource<Void>("misbehaving") {
private boolean isRoutesIndex(String n) { return n.matches("app\\.routes\\[\\d+\\]"); }
@Override public boolean containsProperty(String n) { return isRoutesIndex(n); }
@Override public Object getProperty(String n) { return isRoutesIndex(n) ? "" : null; }
}));
app.run(args);
}
@ConfigurationProperties("app")
public static class AppProperties {
private List<RouteConfig> routes = new ArrayList<>();
public List<RouteConfig> getRoutes() { return routes; }
public void setRoutes(List<RouteConfig> routes) { this.routes = routes; }
}
public static class RouteConfig {
private String id;
public String getId() { return id; }
public void setId(String id) { this.id = id; }
}
}
src/main/resources/application.yaml:
spring:
main:
web-application-type: none
app:
routes:
- id: r0
Result:
- 4.1.0 →
Terminating due to java.lang.OutOfMemoryError: Java heap space(stack above). - 4.0.6 (only change: parent version) → no OOM;
Failed to bind properties under 'app.routes[0]'.
Expected behavior
Binding should terminate (as on 4.0.6). At minimum, an empty / non-convertible indexed element should not
cause an unbounded loop that allocates up to Integer.MAX_VALUE and OOMs — turning a bounded, diagnosable
failure into a heap exhaustion is a regression.
🤖 Optional: AI-assisted root-cause analysis — please feel free to ignore
Disclaimer. Everything in this collapsed section was put together with AI assistance. I'm sharing it
only in case it saves you time; I personally don't have the expertise to vouch for it, so please treat it as
a hint, not a claim, and skip it entirely if it's noise. The reproduction above stands on its own.
Suspected mechanism. IndexedElementsBinder.bindIndexed stops only when an element binds to null:
for (int i = 0; i < Integer.MAX_VALUE; i++) {
ConfigurationPropertyName name = appendIndex(root, i);
Object value = elementBinder.bind(name, Bindable.of(elementType), source);
if (value == null) { break; }
collection.get().add(value);
}
Comparing v4.0.6...v4.1.0, the binder change appears to be the new fallbackToDefaultValue path
(Binder / JavaBeanBinder / ValueObjectBinder / DataObjectBinder). In Binder.bindObject:
catch (ConverterNotFoundException ex) {
boolean fallbackToDefaultValue = ObjectUtils.isEmpty(property.getValue());
Object instance = bindDataObject(name, target, handler, context, allowRecursiveBinding, fallbackToDefaultValue);
...
}
In ValueObjectBinder.bind, when the target is a plain JavaBean (valueObject == null) and
fallbackToDefaultValue is true, it now returns a non-null default instance instead of null:
if (fallbackToDefaultValue) {
return getNewDefaultValueInstanceIfPossible(context, target.getType());
}
// getNewDefaultValueInstanceIfPossible(...) for a JavaBean ends at:
return (resolved != null) ? BeanUtils.instantiateClass(resolved) : null; // non-null
So an empty-valued routes[i] now yields a non-null empty bean, the loop never reaches null, and it
allocates a ConfigurationPropertyName per index up to Integer.MAX_VALUE → OOM. On 4.0.6 the same element
surfaced the ConverterNotFoundException as a bounded BindException.
Possibly related issues/PRs (again, AI-surfaced, may be wrong):
- #50756 / PR #50773 — introduced the
fallbackToDefaultValuebehavior (empty arrays/collections/maps). - #48958 — explicitly-empty YAML nodes now materialize empty-string properties (how an empty value can appear
at an indexed name in real apps). - #46196 —
IndexedElementsBinderfilter optimization removed in 3.5.4 (the index-binding area is noted as
regression-prone). - #50795 — another 4.1.0
@ConfigurationPropertiesbinding regression ('validationType' must not be null).
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with the standalone reproduction linked in the issue, comparing the parent version in pom.xml under 4.1.0 and 4.0.6. Then read IndexedElementsBinder.bindIndexed and the fallbackToDefaultValue paths in Binder, JavaBeanBinder, ValueObjectBinder, and DataObjectBinder. Done means the empty indexed element terminates binding with a bounded diagnostic instead of allocating until OutOfMemoryError.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- java, spring-boot
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 68/100