Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save carefree-ladka/ba9d4415ee82508b5483f45d891ee649 to your computer and use it in GitHub Desktop.

Select an option

Save carefree-ladka/ba9d4415ee82508b5483f45d891ee649 to your computer and use it in GitHub Desktop.
Spring Internals: From `new` to IoC Container

Spring Internals: From new to IoC Container

A deep dive into how Spring manages object creation, dependency injection, singleton guarantees, and circular dependency resolution — without any magic hand-waving.


Table of Contents

  1. Normal Java — No Spring
  2. The Spring Way — Manual Beans, No @Autowired
  3. The @Configuration Class
  4. Bootstrapping the Spring Context
  5. What Actually Happens Internally
  6. What Happens If You Remove @Configuration
  7. Where Is new Actually Used?
  8. Bean Lifecycle — Internal Flow
  9. Why This Is Powerful
  10. Circular Dependency Resolution
  11. Key Internal Classes to Know
  12. Interview Cheat Sheet

1. Normal Java — No Spring

In vanilla Java, you are responsible for everything.

class Engine {
    public void start() {
        System.out.println("Engine started");
    }
}

class Car {
    private Engine engine;

    public Car() {
        this.engine = new Engine(); // manual dependency wiring
    }

    public void drive() {
        engine.start();
        System.out.println("Car running");
    }
}

public class Main {
    public static void main(String[] args) {
        Car car = new Car(); // YOU create the object
        car.drive();
    }
}

Characteristics:

Concern Who Handles It
Object creation You
Dependency wiring You
Lifecycle management You
Singleton guarantee You

Every new Car() call produces a brand-new instance. There is no shared state, no lifecycle hooks, and no proxy support.


2. The Spring Way — Manual Beans, No @Autowired

We hand off object creation responsibility to Spring — but keep things explicit with constructor injection and no field injection magic.

class Engine {
    public void start() {
        System.out.println("Engine started");
    }
}

class Car {
    private final Engine engine;

    public Car(Engine engine) { // dependency injected via constructor
        this.engine = engine;
    }

    public void drive() {
        engine.start();
        System.out.println("Car running");
    }
}

Car no longer knows how to build its own Engine. It declares what it needs and Spring provides it.


3. The @Configuration Class

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
class AppConfig {

    @Bean
    public Engine engine() {
        return new Engine(); // new is still called — but here, not in business code
    }

    @Bean
    public Car car() {
        return new Car(engine()); // manual dependency wiring via method call
    }
}

What to notice:

  • new is still used — Spring doesn't eliminate it; it relocates it.
  • No @Autowired, no field injection — this is pure constructor wiring.
  • Dependencies are resolved by calling other @Bean methods directly.

4. Bootstrapping the Spring Context

import org.springframework.context.annotation.AnnotationConfigApplicationContext;

public class Main {
    public static void main(String[] args) {
        AnnotationConfigApplicationContext context =
            new AnnotationConfigApplicationContext(AppConfig.class);

        Car car = context.getBean(Car.class);
        car.drive();
    }
}

This single line kicks off the entire Spring machinery:

new AnnotationConfigApplicationContext(AppConfig.class);

5. What Actually Happens Internally

Step 1: Scan the Configuration Class

Spring detects @Configuration on AppConfig and treats it as a source of bean definitions. Each @Bean method is registered as a BeanDefinition in the BeanFactory.

Step 2: CGLIB Proxy Creation

Spring does not use your AppConfig class directly. Instead it generates a subclass at runtime:

AppConfig$$EnhancerBySpringCGLIB$$<hash>

This proxy intercepts every call to a @Bean method. This is the foundation of the singleton guarantee.

Step 3: Singleton Magic

Consider this code:

@Bean
public Car car() {
    return new Car(engine()); // appears to call engine() as a normal method
}

You might expect engine() to invoke new Engine() every single time. It does not.

Because AppConfig is a CGLIB proxy, every @Bean method call is intercepted. The intercepted logic is roughly:

// What CGLIB does internally when engine() is called:
if (singletonCache.contains("engine")) {
    return singletonCache.get("engine");
} else {
    Engine bean = originalAppConfig.engine(); // calls new Engine() once
    singletonCache.put("engine", bean);
    return bean;
}

Result: no matter how many times engine() is called across your @Bean methods, only one Engine instance is ever created.

⚠️ This singleton guarantee only exists because of the CGLIB proxy. Remove @Configuration and you lose it.


6. What Happens If You Remove @Configuration

// Before
@Configuration
class AppConfig { ... }

// After — now it's a plain component class
class AppConfig { ... }

Without @Configuration:

  • No CGLIB proxy is generated.
  • engine() becomes a plain Java method.
  • Every invocation of engine() creates a new Engine instance.
  • You lose singleton guarantee for beans wired via method calls.

This is why @Configuration is not optional if you care about correctness.

Note: @Component classes with @Bean methods use lite mode — no proxy, no singleton interception between methods.


7. Where Is new Actually Used?

Spring never forbids new. It moves the responsibility:

Without Spring With Spring
You call new in business code Spring calls new inside @Bean methods
You manage object lifecycle Spring manages lifecycle
You wire dependencies manually Spring wires dependencies
No singleton guarantee Singleton by default

Internally, Spring still executes:

Object bean = method.invoke(configInstance); // which calls → return new Engine();

So new is always used — but only under container control, never scattered across your business logic.


8. Bean Lifecycle — Internal Flow

When AnnotationConfigApplicationContext starts, the following sequence runs:

1. Parse @Configuration classes → create BeanDefinition objects
2. Register BeanDefinitions in BeanFactory
3. For each bean:
   a. Resolve dependencies (topological ordering)
   b. Instantiate object (via constructor or @Bean method)
   c. Populate fields / setters
   d. Apply BeanPostProcessors (e.g., AOP proxying, @PostConstruct)
   e. Store in singleton cache
4. Context is ready

Core class: DefaultListableBeanFactory
Singleton storage: singletonObjects — a Map<String, Object> keyed by bean name


9. Why This Is Powerful

By giving up direct new calls in business code, you gain:

  • Singleton management — one instance shared across the app
  • AOP proxies — transparent cross-cutting concerns (logging, security)
  • Transaction management@Transactional works via proxies
  • Automatic dependency injection — no manual wiring in business code
  • Lifecycle hooks@PostConstruct, @PreDestroy
  • Circular dependency resolution — handled by the container (see below)
  • Testability — easy to swap beans in test configurations

10. Circular Dependency Resolution

What Is Circular Dependency?

class A {
    private B b;
    public A(B b) { this.b = b; }
}

class B {
    private A a;
    public B(A a) { this.a = a; }
}

A needs B to be created. B needs A to be created. Neither can go first — a deadlock.


Constructor Injection Fails

@Bean public A a(B b) { return new A(b); }
@Bean public B b(A a) { return new B(a); }

Spring cannot create either bean. It throws:

BeanCurrentlyInCreationException

Why? Constructor injection requires the full dependency before the object exists. There is no window to expose a partial reference.


Setter/Field Injection Works

class A {
    private B b;
    public void setB(B b) { this.b = b; }
}

class B {
    private A a;
    public void setA(A a) { this.a = a; }
}

Now Spring can:

  1. Create A (object exists, no dependencies yet)
  2. Create B (object exists, no dependencies yet)
  3. Inject A into B
  4. Inject B into A

The key insight: the object exists before its dependencies are injected.


The Three-Level Cache

Spring's circular dependency solution lives in DefaultSingletonBeanRegistry:

Map<String, Object>         singletonObjects;       // Level 1: Fully initialized beans
Map<String, Object>         earlySingletonObjects;  // Level 2: Partially initialized beans
Map<String, ObjectFactory>  singletonFactories;     // Level 3: Factories for early references
Cache Level Contents Purpose
singletonObjects Fully initialized, ready-to-use beans Normal bean retrieval
earlySingletonObjects Partially constructed beans Break circular dependency
singletonFactories ObjectFactory lambdas Generate early references (supports AOP)

Internal Resolution Flow

Assume A depends on B and B depends on A.

Step 1 — Start creating A:

Spring begins doCreateBean("a")
Before injecting anything, registers early factory:
→ singletonFactories.put("a", () -> getEarlyBeanReference("a", ...))

Step 2 — A needs B, start creating B:

Spring begins doCreateBean("b")

Step 3 — B needs A:

Spring checks for "a":
  singletonObjects?     ❌ not fully ready
  earlySingletonObjects? ❌ not promoted yet
  singletonFactories?   ✅ found!

Spring calls the factory:
→ Gets early reference to partially constructed A
→ Moves A from singletonFactories → earlySingletonObjects
→ Injects early A reference into B

Step 4 — Finish B:

B is now fully initialized.
→ Move B to singletonObjects
→ Remove B from other caches

Step 5 — Finish A:

Inject fully initialized B into A.
A is now fully initialized.
→ Move A to singletonObjects
→ Remove A from earlySingletonObjects and singletonFactories

Final state:

singletonObjects:
  "a" → fully initialized A (holds reference to B)
  "b" → fully initialized B (holds reference to A)

The magic line in AbstractAutowireCapableBeanFactory.doCreateBean():

addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));

Why Three Levels and Not Two?

Because of AOP proxies.

If a bean has @Transactional or any AOP advice, Spring must return a proxy rather than the raw object as the early reference.

  • Level 3 (singletonFactories) stores an ObjectFactory that can generate the correct proxy when called.
  • Without level 3, if two beans needed AOP and were circular, Spring would inject the raw object instead of the proxy — breaking AOP.
Level 3 (factory) → called once → produces correct proxy → moved to Level 2
Level 2 (early reference) → used for injection into dependent beans
Level 1 (final) → fully initialized bean

Spring Boot 2.6+ Restriction

Starting from Spring Boot 2.6, circular dependencies are disabled by default:

# application.properties
spring.main.allow-circular-references=true  # must opt-in explicitly

This is intentional — circular dependencies are a code smell. They suggest your design has tight coupling that should be restructured (e.g., extract a third service, use events, or lazy injection).


11. Key Internal Classes to Know

Class Role
DefaultListableBeanFactory Core bean registry and factory
DefaultSingletonBeanRegistry Manages the three-level singleton cache
AbstractAutowireCapableBeanFactory Handles doCreateBean(), injection, and early references
AnnotationConfigApplicationContext Entry point for annotation-based configuration
BeanDefinition Metadata describing how to create a bean
BeanPostProcessor Hook for post-processing beans (AOP, validation, etc.)
CGLIB (via Spring) Generates subclasses to proxy @Configuration classes

12. Interview Cheat Sheet

Q: Does Spring use new internally?
Yes. Spring calls new inside @Bean methods via reflection: method.invoke(configInstance). It relocates new from business code to configuration code.

Q: Does Spring use reflection?
Yes — to invoke constructors, call @Bean methods, inject fields, and trigger lifecycle callbacks.

Q: Why is @Configuration proxied by CGLIB?
To intercept @Bean method calls and enforce singleton scope. Without the proxy, calling engine() twice would create two Engine instances.

Q: How does Spring resolve circular dependencies?
Via a three-level singleton cache. Spring exposes a partially constructed bean through a singletonFactory (level 3) before injection is complete. This early reference lets a circular dependency be injected with an in-progress instance, breaking the cycle.

Q: Why does constructor injection fail with circular dependencies?
Because the object cannot exist until the constructor completes, and the constructor cannot complete without a fully constructed dependency. There is no opportunity to expose an early reference.

Q: What is the singleton cache in Spring?
DefaultSingletonBeanRegistry.singletonObjects — a Map<String, Object> storing fully initialized singleton beans.


What to explore next:

  • How refresh() orchestrates the full context startup
  • BeanFactory vs ApplicationContext — differences and when each matters
  • How @Transactional works via AOP and CGLIB/JDK dynamic proxies
  • Lazy beans and prototype scope — when the singleton cache isn't used
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment