[AI Readability Summary]
Spring resolves circular dependencies in singleton beans by separating instantiation from initialization and exposing early references through a three-level cache. The real reason the third cache level matters is not the circular dependency itself, but the fact that AOP proxy creation happens later than dependency injection. Keywords: Spring, circular dependency, three-level cache.
Technical Snapshot
| Parameter | Description |
|---|---|
| Core language | Java |
| Framework | Spring Framework |
| Key mechanisms | IoC, AOP, singleton bean lifecycle |
| Core caches | singletonObjects / earlySingletonObjects / singletonFactories |
| Typical exception | BeanCurrentlyInCreationException |
| Proxy-related dependencies | BeanPostProcessor, SmartInstantiationAwareBeanPostProcessor |
| Protocols / scenarios | Singleton scope, dependency injection, runtime proxy |
| Original article engagement | 404 views, 10 likes, 8 bookmarks |
Circular dependency resolution depends on splitting the lifecycle
A circular dependency means A depends on B, and B depends on A. If an object must be fully constructed in one atomic step, Spring cannot find a valid starting point, so the creation process falls into an infinite loop.
Spring can handle this only because bean creation is split into two phases: first instantiate an empty object, then perform property injection and initialization. That allows Spring to expose a partially constructed object early so other beans can temporarily reference it.
Instantiation and initialization are not the same thing
| Phase | Action | Result |
|---|---|---|
| Instantiation | createBeanInstance() |
Creates an empty shell object with no injected properties |
| Property population | populateBean() |
Starts dependency injection |
| Initialization | initializeBean() |
Runs initialization logic and post-processing |
This distinction determines whether Spring has a chance to place an unfinished but already existing object into a cache first.
@Component
public class A {
@Autowired
private B b; // Field injection allows Spring to instantiate first and inject dependencies later
}
@Component
public class B {
@Autowired
private A a; // B can obtain an early reference to A during injection
}
This example shows that setter or field injection can be processed in separate phases, which makes circular dependency resolution possible.
Circular dependencies with constructor injection are unresolved by default
Constructor injection binds object creation and dependency readiness into one atomic action. A must obtain B before it can be constructed, while B must obtain A before it can be constructed. As a result, neither side can complete the first step.
@Component
public class A {
private final B b;
public A(B b) {
this.b = b; // A has a hard dependency on B during construction
}
}
This code shows why constructor injection cannot expose a partially constructed object early, so Spring does not automatically resolve this kind of circular dependency.
Common workarounds fit different scenarios
The first option is @Lazy, which makes Spring inject a lazy proxy instead of the real object. The second is to switch to setter injection. The third is to refactor the design by extracting shared logic into a third bean and removing the cycle directly.
@Component
public class A {
private final B b;
public A(@Lazy B b) {
this.b = b; // Inject a lazy proxy to avoid a hard collision during construction
}
}
This pattern defers access to the real dependency until first use, which avoids constructor-time deadlock.
The three-level cache exists to resolve proxy timing conflicts, not to show off complexity
Spring uses three caches for singleton circular dependency handling, and each cache stores objects in a different state. The lookup order is fixed: first-level, second-level, then third-level.
| Cache level | Variable name | Stored content | Purpose |
|---|---|---|---|
| First-level cache | singletonObjects |
Fully initialized bean instances | The final externally visible object |
| Second-level cache | earlySingletonObjects |
Early references, partially initialized objects, or early proxies | Prevents duplicate creation of early objects |
| Third-level cache | singletonFactories |
ObjectFactory factories |
Decides on demand whether to return the raw object or a proxy |
Without AOP, two cache levels are theoretically enough
If a bean does not need a proxy, the early exposed object is just the raw object. In that case, a finished-object cache and a partially constructed-object cache are theoretically sufficient for A and B to inject each other through early references.
The flow is straightforward: after A is instantiated, it enters the early cache. When Spring creates B and discovers that B depends on A, it retrieves the partially constructed A from the cache. After B finishes initialization, Spring injects B back into A. Finally, both beans enter the first-level cache.
In AOP scenarios, the third-level cache becomes indispensable
The problem is proxy creation timing. AOP proxies are usually created in the post-processing phase of BeanPostProcessor, while dependency injection happens earlier. If B receives the raw A during injection, then even if A is proxied later, B still holds the wrong reference.
That directly breaks transactional, authorization, logging, and similar aspects because method calls do not pass through the proxy.
The ObjectFactory in the third-level cache enables deferred decisions
The third-level cache does not store the bean itself. It stores an ObjectFactory. Only when another bean actually requests the object does Spring execute the factory logic and call getEarlyBeanReference() to determine whether it should return the raw object or an early proxy.
// Pseudocode: shows the design intent of the third-level cache
singletonFactories.put(beanName, () -> {
return getEarlyBeanReference(beanName, mbd, bean); // Let post-processors decide whether to create a proxy
});
This code illustrates the core value of the third-level cache: defer the decision about the object’s final form until the moment another bean references it.
The simplified AOP circular dependency flow can be summarized in eight steps
- A finishes instantiation, and Spring places its factory into the third-level cache.
- A injects B, which triggers B creation.
- B finishes instantiation and is also placed into the third-level cache.
- B injects A, but the first-level and second-level caches both miss.
- Spring triggers A’s third-level cache factory.
- The factory determines that A needs a proxy, returns A’s early proxy, and stores it in the second-level cache.
- B injects the proxied A and completes initialization.
- A then receives the fully initialized B, completes initialization, and enters the first-level cache.
The core conclusion is simple: B must hold A’s proxy, not the raw A instance.
Cache insertion and cleanup timing determines whether the mechanism stays stable
After instantiation, Spring calls addSingletonFactory() to place the bean into the third-level cache. When an early reference is first needed, getSingleton() triggers the factory and promotes the object into the second-level cache. After full initialization, addSingleton() places the bean into the first-level cache and clears the second-level and third-level caches.
This promotion flow ensures that an early reference is created only once, while the externally exposed object is always the fully initialized bean.
Prototype beans do not participate in the three-level cache mechanism
The three-level cache serves singletons only. A prototype bean is created from scratch on every getBean() call. Spring does not cache it or expose it early, so there is no infrastructure for resolving circular dependencies.
@Component
@Scope("prototype")
public class A {
@Autowired
private B b; // Spring cannot break circular dependencies between prototype beans through caching
}
@Component
@Scope("prototype")
public class B {
@Autowired
private A a;
}
This code triggers BeanCurrentlyInCreationException because prototype beans never enter the three-level cache path.
The images mainly provide branding and supporting visuals rather than critical technical detail

This image is a column cover and branding asset. It does not contain source structure, flowcharts, or runtime output.

AI Visual Insight: This image appears to be a supplementary diagram for the article. Based on the context, it likely helps illustrate the Spring bean lifecycle, cache hierarchy, or dependency injection path. However, the original input does not preserve enough visual detail to support more granular source-level conclusions.
The conclusions you actually need to remember are few
First, Spring resolves circular dependencies only for singleton beans with non-constructor injection. Second, without AOP, two cache levels are theoretically enough; with AOP, the third-level cache is essential. Third, the essence of the third-level cache is not one more storage layer, but one more opportunity to decide lazily whether a proxy is required.
FAQ
Q1: Why doesn’t Spring put partially constructed objects directly into the first-level cache?
A1: The first-level cache represents complete singletons that are safe to expose externally. Mixing partially constructed objects into it would break container consistency and could leak uninitialized objects to other beans.
Q2: Why can’t the second-level cache replace the third-level cache?
A2: The second-level cache can only store an already determined early object. It cannot dynamically decide at the moment of dependency resolution whether an AOP proxy is required, so it lacks deferred decision capability.
Q3: Which circular dependencies does Spring explicitly not handle?
A3: Spring does not automatically resolve constructor-injected circular dependencies, prototype-bean circular dependencies, or complex dependency cycles that should be removed through refactoring.
Core summary: This article systematically explains the nature of Spring circular dependencies, why separating instantiation from initialization makes singleton setter and field injection cycles resolvable, and how the three-level cache guarantees correct injected references in AOP proxy scenarios. It also clarifies the boundaries involving constructor injection, prototype beans, and cache behavior.