What this post covers
This is the fourth post in the "Digging into Spring's Source" series. Part 3 looked at the interfaces and classes that make up BeanFactory, the thing that hands out beans, and at BeanDefinition, the blueprint of a bean. This time we follow how a real object gets built from that blueprint.
There are three questions to answer.
- When
getBean("name")is called once, what happens inside, and in what order? - Where is the finished object kept, and why is it never built twice?
- When A needs B and B needs A (a circular reference), how does Spring get out of it — and why is the storage not one map but three?
| Item | Details |
|---|---|
| Source | Spring Framework 7.1.0-SNAPSHOT |
| Commit | f447f3c310 (2026-10-01, main branch) |
| Module read most | spring-beans |
| Classes read most | AbstractBeanFactory, AbstractAutowireCapableBeanFactory, DefaultSingletonBeanRegistry |
Every line number and every detail in this post refers to that commit. In other versions the line numbers will differ and some details may too. Part 1 explains how to get the source at the same commit.
My study notes go much deeper, line by line. This post keeps only the main thread that someone seeing Spring for the first time can follow. What was left out is named at the end, under "If you want to go deeper".
Words to know first
A bean is an object that Spring creates and looks after for you. Instead of calling new yourself, you tell Spring "I need an object like this" and Spring builds it.
The container is whatever builds and keeps those beans. In the source this job is done by the BeanFactory interface and the classes that implement it, and the method you call to ask for a bean is getBean(). Part 1 and Part 3 explain this in more detail.
Dependency injection (DI) means an object does not create the other objects it needs to do its work (its dependencies); the container hands them over. Putting one into a field is field injection; passing it as a constructor argument is constructor injection. That distinction matters later in this post.
A scope is the rule for how many instances of a bean exist and how long they live. Unless you say otherwise the scope is singleton: build one and share it. This post deals with singleton beans only.
A proxy is an object that wraps the original and stands in for it. From the outside it has the same type, but when a method is called it does something extra before and after (starting and finishing a transaction, for example) and then passes the call on to the original. Adding behaviour that many classes share, without editing each class, is called AOP (aspect-oriented programming), and Spring's AOP works by creating such proxies and slipping them in. How a proxy itself gets built is covered in Part 10, and how a proxy opens and closes a transaction in Part 17.
A lambda is "a piece of code to run later". You use one when you do not want to compute a value now and only want to hand over the way to compute it. It is what solves the last puzzle of this post.
1. A Spring singleton is not the "singleton pattern"
Anyone learning Java runs into the singleton pattern: a class that prevents more than one instance of itself from ever existing.
// An example written for this post (not Spring's code)
public class Config {
private static final Config INSTANCE = new Config();
private Config() { } // nobody outside can call new
public static Config getInstance() {
return INSTANCE; // always the same object
}
}
A Spring "singleton bean" shares the name and nothing else. A class used as a bean is an ordinary class: no hidden constructor, no static field. The one keeping it unique is not the class but the container. The container builds the object once, puts it into a map keyed by bean name, and takes it back out of that map every time somebody asks for that name.
Left: the class guards itself. Right: the container keeps the object in a map. The yellow box is the map this post is really about.
| Singleton pattern | Spring singleton | |
|---|---|---|
| Who keeps it unique | The class itself | The container (DefaultSingletonBeanRegistry) |
| Unique per what | Class | Bean name |
| Unique within what | The whole program | One container |
| Constructor | private |
Not hidden |
| Two objects of the same class | Impossible | Possible, if registered under different names |
Easy to get wrong: "It's a singleton, so why are there two objects?"
Uniqueness is tied to a name inside a container, not to the class. Three things follow.
- Register the same class as two beans with different names and you get two objects, because the map key is the name.
- Start two containers and each has its own bean under the same name. The map is not
static; it is a plain field of the container object (the code is in section 5). - The constructor is open, so your code can call
new UserService()directly. But that object is not in the map and the container knows nothing about it. Nothing gets injected into it either.
2. The journey of one getBean() call
You can call getBean() yourself, but most of the time it is Spring that calls it.
- When the container starts. At the end of start-up,
preInstantiateSingletons()callsgetBean()once for every singleton bean that has not been marked lazy, so they are all built up front (AbstractApplicationContextline 995,DefaultListableBeanFactoryline 1102, lines 1217–1227). Part 2 covers start-up as a whole. - In the middle of creating another bean. When bean B has to be injected into bean A, the container fetches B with
getBean()(DependencyDescriptorlines 224–230). SogetBean()being called from insidegetBean()is perfectly normal — and it is where the circular-reference problem below begins.
Either way, the real work happens in AbstractBeanFactory.doGetBean() (lines 249–413). For a singleton bean the main line looks like this.
Yellow is the cache (a map); blue is the creation procedure.
- Tidy up the name. If an alias was used, it is turned into the canonical name (line 253).
- Look in the cache first.
getSingleton(beanName)checks whether the object already exists (line 257); if it does, that object is returned. From the second call on, everything ends here. This is why a singleton bean is not built twice. - If it is not there, fetch the blueprint. The
BeanDefinitionfor this name — more precisely the merged blueprint described in Part 3 — is looked up (line 309). - For a singleton, delegate "build it and cache it".
getSingleton(beanName, lambda)is called, and the body of that lambda iscreateBean(...)— the code that really builds the bean (lines 343–355).
The getSingleton(name, lambda) of step 4 lives in DefaultSingletonBeanRegistry (lines 255–432). It is long because it deals with several threads arriving at once, but its skeleton is four lines.
- Add the name to the "in creation" set (
singletonsCurrentlyInCreation) (line 310, implemented at lines 539–543). - Run the lambda it was given. This is where the bean gets built (line 371).
- Remove the name from the "in creation" set (line 399).
- Put the finished object into the cache (line 404, implemented at lines 159–173).
The "in creation" set looks like a detail now, but it plays a central part once circular references come in. Keep it in mind.
Easy to get wrong: there are two methods called getSingleton
getSingleton(name, lambda)— creates the bean if it is missing and caches it. Step 4 above calls this one.getSingleton(name)— only looks in the cache. Step 2 above calls this one. Internally it callsgetSingleton(name, true)(lines 196–198).
The second half of this post is about the latter, the one that only looks.
Left out of this section: beans with a scope other than singleton, asking a parent container, depends-on (naming beans that must be created first), and FactoryBean (a special bean that produces other objects). They are all handled in the same method, but not here.
3. The three steps of building one bean
The createBean() that the lambda calls does a little preparation and passes the real work to doCreateBean() (AbstractAutowireCapableBeanFactory lines 555–648). You will see this pairing all over Spring's source: xxx() prepares and handles exceptions, doXxx() does the actual job. getBean() and doGetBean() are the same kind of pair.
doCreateBean() has three steps.
- Instantiate —
createBeanInstance()(line 564). The constructor is called and an object comes into existence. This is Java'snew. At this moment the object exists in memory, but its fields are still empty. - Inject dependencies —
populateBean()(line 601). The empty fields are filled with other beans. Those beans are fetched withgetBean(), so if one does not exist yet, the whole journey of creating that bean starts right here. - Initialise —
initializeBean()(line 602). Whatever set-up has to happen after injection is run, and, if needed, the object is wrapped in a proxy.
The yellow "Early exposure" box between ① and ② is explained in section 5. The blue boxes are where post-processors get their turn.
Where BeanPostProcessor gets a turn
The body of step 3, initializeBean(), is short. (A // ... line marks something I cut for this post; everything else is the source as it is.)
protected Object initializeBean(String beanName, Object bean, @Nullable RootBeanDefinition mbd) {
// ...
invokeAwareMethods(beanName, bean);
Object wrappedBean = bean;
if (mbd == null || !mbd.isSynthetic()) {
wrappedBean = applyBeanPostProcessorsBeforeInitialization(wrappedBean, beanName);
}
try {
invokeInitMethods(beanName, wrappedBean, mbd);
}
// ...
if (mbd == null || !mbd.isSynthetic()) {
wrappedBean = applyBeanPostProcessorsAfterInitialization(wrappedBean, beanName);
}
return wrappedBean;
}
invokeInitMethods in the middle is where the bean's own init methods are called. Before and after it there is one applyBeanPostProcessors… call each. What gets called there is a BeanPostProcessor.
BeanPostProcessor is an interface with just two methods (lines 67–111). postProcessBeforeInitialization is called before the init methods, postProcessAfterInitialization after them. The container calls every registered post-processor in turn, asking in effect "this bean is being built right now — is there anything you want to do to it?" It does so for every single bean it creates.
The important part is that both methods return an object. The code above stores the return value back into wrappedBean. If a post-processor returns the object it was given, nothing changes; if it returns a different object, that object becomes the bean. This is the door AOP proxies come in through. Spring's auto-proxy post-processor, in the after-initialisation hook, returns a proxy instead of the original whenever the bean is one that needs wrapping (AbstractAutoProxyCreator lines 285–293). How that post-processor recognises "a bean that needs wrapping" is covered in Part 11.
Easy to get wrong
- What ends up in the cache may not be the original. The object returned by
initializeBean()becomes the final bean and goes into the cache. If it was wrapped, what you receive fromgetBean()is the proxy. - The proxy is made at the very end. The rule is to wrap an original that has already been injected and initialised. The Javadoc of
BeanPostProcessorsays so too: post-processors that wrap beans with proxies normally implementpostProcessAfterInitialization(lines 27–30). This "at the very end" rule is the reason there are three maps in section 5. - Post-processors are not only around initialisation. Filling a field marked
@Autowiredis also done by a post-processor, inside step 2,populateBean()(AutowiredAnnotationBeanPostProcessorline 490). Calling a method marked@PostConstructhappens in the before-initialisation hook (InitDestroyAnnotationBeanPostProcessorlines 214–226). A great many Spring features are attached in this one way: by slipping a post-processor into the creation flow. The full list of hooks is in Part 6.
4. Circular references: when A needs B and B needs A
Now let us make a problem. Two beans each hold the other in a field.
// An example written for this post (not Spring's code)
@Component // "please register this class as a bean"
class A {
@Autowired B b; // "please put the B bean into this field"
}
@Component
class B {
@Autowired A a; // "please put the A bean into this field"
}
Follow the flow we have seen so far. Call the beans a and b.
getBean("a")— not in the cache, so build it. A is instantiated, and in the injection step it needs B.getBean("b")— not in the cache, so build it. B is instantiated, and in the injection step it needs A.getBean("a")— A is not finished, so it is not in the cache. So A starts being built again, which needs B again, and so on…
If only finished beans go into the cache, this loop never ends. This is the circular reference problem.
The way out appears if you look at step 3 from step 2's point of view. A is unfinished, but it has already been instantiated and exists in memory. And what B needs right now is not to use A, only to write down a reference to A in its own field. So hand B a reference to the half-built A first. B gets finished, then A gets finished too, and the reference B has been holding now points to a finished A.
Follow the solid line on the right from the red box and the loop never ends. The dotted line in the middle points to the fix covered in section 5.
A reference handed out before the bean is finished is called an early reference, and making it available is early exposure. That is the yellow box in the diagram of section 3.
5. How three maps solve it — the three-level cache
DefaultSingletonBeanRegistry does not have one map for singletons. It has three.
/** Cache of singleton objects: bean name to bean instance. */
private final Map<String, Object> singletonObjects = new ConcurrentHashMap<>(256);
/** Creation-time registry of singleton factories: bean name to ObjectFactory. */
private final Map<String, ObjectFactory<?>> singletonFactories = new ConcurrentHashMap<>(16);
// ...
/** Cache of early singleton objects: bean name to bean instance. */
private final Map<String, Object> earlySingletonObjects = new ConcurrentHashMap<>(16);
| Usual nickname | Field | What it holds |
|---|---|---|
| Level 1 | singletonObjects |
Finished beans. What we have been calling "the cache" |
| Level 2 | earlySingletonObjects |
Early references: objects standing for beans that are not finished yet |
| Level 3 | singletonFactories |
Lambdas that produce an early reference (ObjectFactory) |
"First-, second- and third-level cache" are nicknames people use; those words do not appear in the source. The numbers follow the order in which the maps are checked. ObjectFactory is an interface with a single method, getObject() — a container for a lambda.
The path of one bean through the three maps. A bean with no circular reference follows the dotted line on the right, straight from level 3 to level 1.
Putting in: a lambda is registered right after instantiation
In doCreateBean(), between instantiation and injection, there is this code.
// Eagerly cache singletons to be able to resolve circular references
// even when triggered by lifecycle interfaces like BeanFactoryAware.
boolean earlySingletonExposure = (mbd.isSingleton() && this.allowCircularReferences &&
isSingletonCurrentlyInCreation(beanName));
if (earlySingletonExposure) {
// ...
addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));
}
// Initialize the bean instance.
Object exposedObject = bean;
try {
populateBean(beanName, mbd, instanceWrapper);
exposedObject = initializeBean(beanName, exposedObject, mbd);
}
// ...
There are three conditions: the bean is a singleton, circular references are allowed by configuration (allowCircularReferences), and the bean is in the "in creation" set — the set from section 2. If they hold, addSingletonFactory puts a lambda into the level-3 map (DefaultSingletonBeanRegistry lines 183–188).
Two things are worth noticing. What goes in is not the freshly made object bean but a lambda. And this code sits before populateBean (injection). If a cycle shows up during injection, the lambda is already in the map.
Taking out: getSingleton(name, true)
This is the look-only getSingleton, the one doGetBean() calls first, as we saw in section 2. The parts that take a lock and re-check the first two maps are cut with // ....
protected @Nullable Object getSingleton(String beanName, boolean allowEarlyReference) {
// Quick check for existing instance without full singleton lock.
Object singletonObject = this.singletonObjects.get(beanName);
if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) {
singletonObject = this.earlySingletonObjects.get(beanName);
if (singletonObject == null && allowEarlyReference) {
// ...
ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName);
if (singletonFactory != null) {
singletonObject = singletonFactory.getObject();
// Singleton could have been added or removed in the meantime.
if (this.singletonFactories.remove(beanName) != null) {
this.earlySingletonObjects.put(beanName, singletonObject);
}
// ...
}
// ...
}
}
return singletonObject;
}
Reading from the top:
- Look in level 1 (
singletonObjects). If the finished bean is there, done. - If it is not, and the bean is "in creation", look in level 2 (
earlySingletonObjects). If somebody has already taken an early reference, hand out the same one. - If level 2 has nothing either, take the lambda from level 3 (
singletonFactories) and run it. The result is the early reference. - Remove the lambda from level 3 and put the result into level 2. The lambda runs exactly once per bean.
Walking through it with A and B
Let us redo the example from section 4, this time with the three maps. The three right-hand columns show which bean names are in each map when that step is over.
The middle lifeline is the three maps. ⑥ is the key moment: A's lambda runs only when B actually asks for A.
| Step | What happens | Level 3 | Level 2 | Level 1 |
|---|---|---|---|---|
| ① | getBean("a"). Not cached, so creation starts. a goes into "in creation" and A is instantiated (its b field is null) |
|||
| ② | A's lambda is registered in level 3 | a | ||
| ③④ | A needs B injected → getBean("b"). B goes down the same road: instantiated, lambda registered |
a, b | ||
| ⑤⑥ | B needs A injected → getBean("a"). Not in level 1, a is "in creation", not in level 2, so the level-3 lambda runs and its result moves to level 2 |
b | a | |
| ⑦⑧ | B stores the early reference to A in its field and finishes initialising. The finished B goes into level 1; B's lambda is removed without ever having run | a | b | |
| ⑨ | Back in A: the finished B is injected into the b field, and A is initialised |
a | b | |
| ⑩ | The finished A goes into level 1 and is removed from level 2 | a, b |
The loop that never ended in section 4 is cut at ⑤: the third getBean("a") does not start building A again. It leaves with an early reference.
Why two maps are not enough — proxies
So far level 3 looks unnecessary. Right after instantiation you could put the object itself into level 2 and hand that to whoever asks. Without proxies, that really would have been enough.
The trouble starts when A is a bean that has to be wrapped in a proxy. As section 3 showed, the final bean is then not the original A but a proxy around it. Yet the proxy is made at the very end, after initialisation, and B needs a reference to A long before that. If B were simply given the original A, B alone would hold the original while everybody else gets the proxy. A bean that should be one would be two, and the calls B makes on A would skip whatever the proxy adds.
So when an early reference is handed out, the rule has to be: if this bean is going to become a proxy later, create that proxy now and hand that out. That is the job of the lambda stored in level 3. It calls getEarlyBeanReference(), which asks the post-processors what to expose as the early reference (lines 965–973), and the auto-proxy post-processor creates the proxy on the spot and returns it (AbstractAutoProxyCreator lines 237–241).
Then why not ask that question unconditionally right after instantiation, store the answer in level 2, and get by with two maps? Because then every bean — including the great majority that are not in any cycle — would have its proxy decision made before injection and initialisation. The "proxy at the very end" rule would be broken for all of them. Spring stores only the way to ask, as a lambda. The lambda runs only if somebody really does ask for a bean that is still in creation; for a bean without a cycle it is thrown away without ever running, and that bean's proxy is made at the very end, by the rule.
The reason level 2 has to exist separately also comes from proxies.
- To hand out the same thing. While A is in creation, not only B but also C might ask for it. The auto-proxy post-processor builds a new proxy every time it is called for a bean that needs one (lines 321–345), so running the lambda twice would give B and C two different objects. Writing the result of the single run into level 2 and deleting the lambda means everyone gets the same object.
- To reconcile at the end. When A's initialisation is over,
doCreateBean()looks into level 2.
if (earlySingletonExposure) {
Object earlySingletonReference = getSingleton(beanName, false);
if (earlySingletonReference != null) {
if (exposedObject == bean) {
exposedObject = earlySingletonReference;
}
// ...
}
}
The false in getSingleton(beanName, false) means "do not run the level-3 lambda; look only as far as level 2". If something is in level 2, somebody took an early reference. So, provided initialisation did not replace the object (exposedObject == bean), that early reference becomes the final bean. What B took earlier and what goes into level 1 are the same object. The auto-proxy post-processor plays along: for a bean it has already proxied as an early reference, it does not wrap again in the after-initialisation hook (lines 285–293).
In short: level 3 is the recipe, level 2 is the result of running the recipe once, level 1 is the finished product.
Easy to get wrong
- The object behind an early reference is not finished. Its fields may be empty and its init methods have not run. Storing the reference in a field is fine; calling methods on it from your own initialisation can go wrong. The Javadoc in the source says as much: resolving a circular reference means one of the beans receives a reference to another bean that is not fully initialised yet, which can lead to side effects on initialisation (lines 223–238).
- Levels 2 and 3 are consulted only for beans that are "in creation". The first
ifof the lookup code containsisSingletonCurrentlyInCreation(beanName). An everydaygetBean()is done after one look into level 1. - Without a cycle, level 2 is never used. For most beans a lambda sits in level 3 for a moment, is removed when the bean is finished (lines 165–166), and the bean goes straight to level 1.
6. Why a cycle between constructors cannot be resolved
Change the same example to constructor injection.
// An example written for this post (not Spring's code)
@Component
class A {
private final B b;
@Autowired A(B b) { this.b = b; } // receives B as a constructor argument
}
@Component
class B {
private final A a;
@Autowired B(A a) { this.a = a; } // receives A as a constructor argument
}
The three-level cache cannot resolve this one. The reason is already in the order shown in section 5: the lambda is registered after instantiation is over. But constructor injection needs B during instantiation — at the very moment the constructor is called (ConstructorResolver lines 900–912).
Ask for another bean before early exposure (the yellow box) and there is nothing to hand out yet.
getBean("a")—agoes into "in creation"; A's constructor is about to be called, and it needs B as an argument.getBean("b")—bgoes into "in creation"; B's constructor is about to be called, and it needs A as an argument.getBean("a")— not in level 1, not in level 2, and not in level 3 either. A has not even got through its constructor, so it never registered a lambda. There is no object to expose yet.- Nothing was found in the cache, so Spring sets out to create A and tries to add
ato "in creation" — but it is already there. At this point aBeanCurrentlyInCreationExceptionis thrown (lines 539–543).
This is the second role of the "in creation" set: it notices a cycle that cannot be resolved instead of going round forever. The default message of that exception is "Requested bean is currently in creation: Is there an unresolvable circular reference or an asynchronous initialization dependency?" (BeanCurrentlyInCreationException lines 34–37).
Applying the same order of events, a cycle where only one side uses constructor injection turns out differently depending on which bean starts being created first. If the field-injected bean starts first, its lambda is registered first and the cycle resolves; if the constructor-injected bean starts first, the exception above is thrown for the same reason.
Even when a cycle can be resolved, avoid it
That Spring resolves field-injection cycles does not mean it recommends them. The Javadoc of setAllowCircularReferences, the setting that decides whether circular references are allowed, says (lines 223–238):
It is generally recommended to not rely on circular references between your beans. Refactor your application logic to have the two beans involved delegate to a third bean that encapsulates their common logic.
In other words: if two beans need each other, move what they share into a third bean.
In the source of this repository the default of allowCircularReferences is true (line 131). Set it to false and the early-exposure condition from section 5 is false, no lambda is ever registered, and a field-injection cycle ends in the same exception as a constructor-injection one. As a side note, Spring Boot is known to turn this setting off by default, but Spring Boot is a separate project from this repository, so that is not something this post verified in the source.
If you want to go deeper
A lot was left out to keep the main thread visible. The study notes cover these; here they are only named. The lock used when several threads create singletons at once (singletonLock), and the fallback path for when the lock cannot be taken (the source comment says "as of 6.2", lines 268–271). @Bean(bootstrap = BACKGROUND), which lets a bean be created on a background thread (also since 6.2, Bean lines 295–307). The destruction path that cleans beans up when the container closes (starting at AbstractBeanFactory line 1935). The prototype scope, which builds a new object every time, and the other scopes (lines 359–396). The exception thrown when an early reference was handed out and initialisation then replaced the object with a different one (lines 617–634). And @Lazy, which breaks a cycle by injecting "a proxy that looks the bean up later" in place of the bean (ContextAnnotationAutowireCandidateResolver lines 53–54). @Lazy comes back in section 6 of Part 5.
Recap
| Concept | In one line |
|---|---|
| Spring singleton | Kept unique by the container, not the class. Unique per bean name, within one container |
getBean() |
Looks in the cache first and builds only if nothing is there |
| "In creation" set | A name goes in when creation starts and comes out when it ends. A condition for early exposure, and the way an unresolvable cycle is noticed |
| The three creation steps | Instantiate → inject dependencies → initialise |
BeanPostProcessor |
A hook around initialisation. If it returns a different object, that object becomes the bean. This is how proxies get in |
| Circular reference | A needs B and B needs A. If only finished beans are cached, it never ends |
| Early reference | A reference to a bean that has only been instantiated, handed out ahead of time |
Level 1 singletonObjects |
Finished products |
Level 2 earlySingletonObjects |
The result of running the lambda once. Gives everyone the same early reference and is reconciled with the final bean at the end |
Level 3 singletonFactories |
The lambda that makes an early reference. It runs only when somebody asks, so a bean without a cycle still gets its proxy at the very end |
| Constructor-injection cycle | Asks for the other bean during instantiation, before the lambda is registered, so it cannot be resolved |
What to look at next
After where the container keeps its blueprints (Part 3), we have now seen how an object is built from a blueprint and where it is kept.
Several times in this post I said only "another bean is fetched with getBean() here" and moved on. Behind that sentence is the process of choosing which bean to put where an @Autowired is: what happens when several beans have the same type, and which constructor is called when there is more than one. That continues in Part 5: How @Autowired Picks the Bean to Inject — Dependency Resolution.