Explain Spring's three-level singleton cache and how it resolves a setter/field circular dependency, including what each level holds.
answer
- L1 singletonObjects = finished
- L2 earlySingletonObjects = early ref
- L3 singletonFactories = ObjectFactory
- getSingleton: 1→2→3, promote L3→L2
- 3rd level defers proxy via getEarlyBeanReference
basics
~20 sDefaultSingletonBeanRegistry keeps three maps: singletonObjects (fully built beans), earlySingletonObjects (raw early references), and singletonFactories (ObjectFactories that produce an early reference on demand). During a cycle, a dependent gets the early reference via the factory, which is promoted to the early-objects map, breaking the loop.
solid answer
~50 sSpring's DefaultSingletonBeanRegistry has three caches: level 1 singletonObjects holds fully initialized singletons; level 2 earlySingletonObjects holds early, not-yet-finished references; level 3 singletonFactories holds ObjectFactory instances that can lazily produce an early reference. After instantiating a bean, Spring calls addSingletonFactory to put an ObjectFactory in level 3. When a mid-creation dependent calls getSingleton, Spring checks level 1, then level 2, then level 3; on a level-3 hit it invokes the factory (which runs getEarlyBeanReference — allowing AOP post-processors to wrap the bean in a proxy if needed), stores the result in level 2, and removes the factory from level 3. This guarantees everyone in the cycle shares the same early reference. The level-3 factory exists specifically so the proxy decision is deferred and made only once, and only if the bean is actually referenced during a cycle.
code
java · 20 lines// Conceptual sketch of DefaultSingletonBeanRegistry lookup
Map<String,Object> singletonObjects; // L1 finished
Map<String,Object> earlySingletonObjects; // L2 early refs
Map<String,ObjectFactory<?>> singletonFactories; // L3 factories
Object getSingleton(String name, boolean allowEarly) {
Object bean = singletonObjects.get(name); // L1
if (bean == null && isCurrentlyInCreation(name)) {
bean = earlySingletonObjects.get(name); // L2
if (bean == null && allowEarly) {
ObjectFactory<?> f = singletonFactories.get(name); // L3
if (f != null) {
bean = f.getObject(); // runs getEarlyBeanReference (may proxy)
earlySingletonObjects.put(name, bean); // promote L3 -> L2
singletonFactories.remove(name);
}
}
}
return bean;
}go deeper
Just know there is a cache of half-built beans that breaks the cycle.
Name the three levels and their contents at a high level.
Walk the getSingleton lookup and the L3→L2 promotion, and explain the ObjectFactory's purpose.
Discuss the getEarlyBeanReference/AOP proxy timing and the consistency check that can throw if a late post-processor changes the bean identity.
## The three caches All live in `DefaultSingletonBeanRegistry`: | Level | Field | Type | Holds | |-------|-------|------|-------| | 1 | `singletonObjects` | `Map<String,Object>` | **Fully initialized** singletons (post init, post proxy). The 'real' bean registry. | | 2 | `earlySingletonObjects` | `Map<String,Object>` | **Early references** — the object (or its proxy) exposed mid-creation, before population/init finished. | | 3 | `singletonFactories` | `Map<String,ObjectFactory<?>>` | Factories that, when called, **produce** the early reference via `getEarlyBeanReference`. | ## The lookup order: getSingleton `getSingleton(beanName, allowEarlyReference)`: 1. Check `singletonObjects` (level 1). Hit → return finished bean. 2. Else, if the bean is currently in creation, check `earlySingletonObjects` (level 2). Hit → return early ref. 3. Else, if `allowEarlyReference`, check `singletonFactories` (level 3). Hit → call `factory.getObject()`, **move** result into level 2, **remove** the factory from level 3, return it. The promotion in step 3 (level 3 → level 2) ensures the *same* early reference is reused on subsequent lookups within the cycle — you never call the factory twice. ## Resolving A ↔ B (field injection) 1. Create A → constructor runs. 2. `addSingletonFactory("a", () -> getEarlyBeanReference("a", …, A))` — A now in **level 3**. 3. `populateBean(A)` needs B → create B. 4. B constructor runs → `addSingletonFactory("b", …)` (B in level 3). 5. `populateBean(B)` needs A → `getSingleton("a", true)`: level 1 miss, level 2 miss, **level 3 hit** → factory produces early A (possibly a proxy) → A moved to **level 2**, factory removed. 6. B receives early A, finishes `initializeBean(B)` → B moved to **level 1**. 7. A's `populateBean` receives the now-finished B → A finishes `initializeBean(A)`. At the end Spring checks: was A's early reference exposed and is it identical to the finished object? → A moved from level 2 to **level 1**. ## Why three levels and not two? A common interview trap. If there were only levels 1 and 2, Spring would have to eagerly create the early reference (and eagerly build any AOP proxy) for **every** bean right after instantiation, even ones never involved in a cycle. The **level-3 `ObjectFactory`** defers that work: the early reference (and the proxy wrapping via `SmartInstantiationAwareBeanPostProcessor.getEarlyBeanReference`) is materialized **only if and when** another bean actually asks for it during a cycle. This keeps proxy creation lazy and ensures the proxy is created **once** at the right moment, so the same proxy instance flows to both the dependent and the final singleton. ## AOP / proxy subtlety Normally proxies are created in the last `BeanPostProcessor` step (`initializeBean`). But if a bean in a cycle needs to be exposed early *and* it will be proxied, the proxy must be created early so the dependent gets the proxy, not the raw target. `getEarlyBeanReference` handles this. Spring later verifies consistency; if a `BeanPostProcessor` wrapped the bean *after* the early reference was already handed out and produced a different object, Spring throws `BeanCurrentlyInCreationException` (the early reference no longer matches). ## Gotchas - Only **singletons** use this cache. Prototype-scope cycles are never resolvable (no shared instance to cache) and throw immediately. - Constructor cycles never reach level 3 (no object to put in the factory). - Since Boot 2.6, even resolvable cycles are off by default (`allow-circular-references=false`).
- Why does the third-level cache store an ObjectFactory instead of the object directly?To defer creating the early reference — especially an AOP proxy — until a bean is actually referenced mid-cycle. This avoids eagerly proxying every bean and ensures the proxy is built exactly once, so the same instance reaches both the dependent and the final singleton.
- Are prototype-scoped circular dependencies resolvable by this cache?No. The cache only tracks singletons; prototypes have no shared cached instance to expose early, so Spring throws BeanCurrentlyInCreationException for prototype cycles regardless of injection style.
saying these in an interview costs you the question
- Saying the level-3 cache stores the finished bean
- Claiming two levels would work identically (misses lazy proxy creation)
- Thinking prototypes benefit from the cache