Skip to content

@ConfigurationProperties binding of a List<JavaBean> can loop until OutOfMemoryError in 4.1.0 (was a bounded BindException in 4.0.6) #50831

Description

@Lumenol

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.0Terminating 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):

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions