How Spring Solves Circular Bean Dependencies with a Three‑Level Cache

The article explains Spring's three‑level cache mechanism for resolving singleton circular bean dependencies, walks through bean lifecycle stages, compares field, setter and constructor injection, shows configuration to enable circular references, and details the internal code paths that expose early bean references.

Shepherd Advanced Notes
Shepherd Advanced Notes
Shepherd Advanced Notes
How Spring Solves Circular Bean Dependencies with a Three‑Level Cache

1. Overview

Spring manages bean creation, property population, initialization and destruction. During bean creation a circular reference may appear when two beans depend on each other, leading to an infinite loop. Spring solves this problem for singleton beans by using a three‑level cache to expose partially created beans early.

2. Circular‑Dependency Cases

2.1 What is Dependency Injection?

DI is the core concept of Spring IoC. The article lists three common annotation‑based injection styles:

Field injection

Setter injection

Constructor injection

2.2 Example of Circular Dependency

Two beans, UserService and RoleService, reference each other via field injection. When the application starts Spring Boot 2.7 reports a circular‑dependency error:

APPLICATION FAILED TO START
Description:
The dependencies of some of the beans in the application context form a cycle:
┌─────┐
|  UserService (field private com.plasticene.fast.service.impl.RoleService com.plasticene.fast.service.impl.UserService.roleService)
↑     ↓
|  RoleService (field private com.plasticene.fast.service.impl.UserService com.plasticene.fast.service.impl.RoleService.userService)
└─────┘

Adding spring.main.allow-circular-references: true to the configuration allows the project to start when using field injection.

Switching the same beans to constructor injection still fails, because Spring cannot resolve constructor‑based circular references and throws BeanCurrentlyInCreationException.

3. Solution – Three‑Level Cache

The core idea is “early exposure” of beans using three caches defined in DefaultSingletonBeanRegistry:

public class DefaultSingletonBeanRegistry {
  // 1️⃣ First‑level cache – fully initialized singletons
  Map<String, Object> singletonObjects = new ConcurrentHashMap(256);
  // 2️⃣ Second‑level cache – early references (half‑finished beans)
  Map<String, Object> earlySingletonObjects = new ConcurrentHashMap(16);
  // 3️⃣ Third‑level cache – factories that can create early references
  Map<String, ObjectFactory<?>> singletonFactories = new HashMap(16);
}

When #getSingleton(beanName, allowEarlyReference) is invoked, Spring checks the caches in order: first‑level, then second‑level, and finally third‑level. If the bean is found only in the third‑level factory, the factory creates an early reference, stores it in the second‑level cache, and removes the factory.

protected Object getSingleton(String beanName, boolean allowEarlyReference) {
  Object singletonObject = this.singletonObjects.get(beanName);
  if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) {
    singletonObject = this.earlySingletonObjects.get(beanName);
    if (singletonObject == null && allowEarlyReference) {
      synchronized (this.singletonObjects) {
        singletonObject = this.singletonObjects.get(beanName);
        if (singletonObject == null) {
          singletonObject = this.earlySingletonObjects.get(beanName);
          if (singletonObject == null) {
            ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName);
            if (singletonFactory != null) {
              singletonObject = singletonFactory.getObject();
              this.earlySingletonObjects.put(beanName, singletonObject);
              this.singletonFactories.remove(beanName);
            }
          }
        }
      }
    }
  }
  return singletonObject;
}

During bean creation Spring decides whether to expose the bean early:

boolean earlySingletonExposure = (mbd.isSingleton() && this.allowCircularReferences && isSingletonCurrentlyInCreation(beanName));
if (earlySingletonExposure) {
  addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));
}

The addSingletonFactory method stores a lambda that returns the early reference in the third‑level cache:

protected void addSingletonFactory(String beanName, ObjectFactory<?> singletonFactory) {
  synchronized (this.singletonObjects) {
    if (!this.singletonObjects.containsKey(beanName)) {
      this.singletonFactories.put(beanName, singletonFactory);
      this.earlySingletonObjects.remove(beanName);
      this.registeredSingletons.add(beanName);
    }
  }
}

The early reference may be a proxy created by AOP. #getEarlyBeanReference applies any SmartInstantiationAwareBeanPostProcessor (e.g., AOP proxy) to the bean before returning it.

protected Object getEarlyBeanReference(String beanName, RootBeanDefinition mbd, Object bean) {
  Object exposedObject = bean;
  if (!mbd.isSynthetic() && hasInstantiationAwareBeanPostProcessors()) {
    for (BeanPostProcessor bp : getBeanPostProcessors()) {
      if (bp instanceof SmartInstantiationAwareBeanPostProcessor) {
        SmartInstantiationAwareBeanPostProcessor ibp = (SmartInstantiationAwareBeanPostProcessor) bp;
        exposedObject = ibp.getEarlyBeanReference(exposedObject, beanName);
      }
    }
  }
  return exposedObject;
}

After all properties are set and the bean is fully initialized, Spring moves the bean to the first‑level cache via #addSingleton:

protected void addSingleton(String beanName, Object singletonObject) {
  synchronized (this.singletonObjects) {
    this.singletonObjects.put(beanName, singletonObject);
    this.singletonFactories.remove(beanName);
    this.earlySingletonObjects.remove(beanName);
    this.registeredSingletons.add(beanName);
  }
}

Thus the circular‑dependency resolution flow is:

Create bean A (half‑finished).

When A needs B, put A into the early‑reference cache.

Create bean B (half‑finished).

B finds A in the early cache and injects it.

Finish initializing B and move it to the first‑level cache.

A retrieves the fully initialized B from the first‑level cache and completes its own initialization.

Finally, move A to the first‑level cache.

Spring only supports this mechanism for singleton beans. Prototype beans with circular references are rejected outright because each request creates a new instance, making caching impossible.

4. Summary

Spring solves singleton circular dependencies by exposing partially created beans through a three‑level cache (singletonObjects, earlySingletonObjects, singletonFactories). Field and setter injection can be resolved, while constructor injection cannot. The process respects Spring’s design principle of creating AOP proxies only after full bean creation, which is why the third‑level cache stores factories rather than concrete proxy instances.

Circular dependency diagram
Circular dependency diagram
Spring Boot circular‑dependency error
Spring Boot circular‑dependency error
Spring bean creation flowchart
Spring bean creation flowchart
Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

SpringDependency Injectioncircular dependencySingletonThree-level CacheBean Lifecycle
Shepherd Advanced Notes
Written by

Shepherd Advanced Notes

Dedicated to sharing advanced Java technical insights, daily work snippets, and the power of persistent effort.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.