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)

Open
#50,831 5 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

status: feedback-provided status: waiting-for-triage
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.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):

  • #50756 / PR #50773 — introduced the fallbackToDefaultValue behavior (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 — IndexedElementsBinder filter optimization removed in 3.5.4 (the index-binding area is noted as
    regression-prone).
  • #50795 — another 4.1.0 @ConfigurationProperties binding regression ('validationType' must not be null).

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.