Skip to main content
spring boot the mechanics of magic

Scopes and Thread Safety

6 min read Chapter 8 of 24
Summary

This section explains Spring bean scopes and thread...

This section explains Spring bean scopes and thread safety, focusing on Singleton and Prototype. The Singleton scope, the default, creates one instance per container, cached and reused, which introduces thread safety concerns for stateful beans. The draft demonstrates a non-thread-safe singleton cache using HashMap and a thread-safe version using ConcurrentHashMap and AtomicInteger. The Prototype scope creates a new instance per request, suitable for stateful, non-thread-safe objects like a CSV parser; the container does not manage its destruction. To inject a prototype bean into a singleton, the draft shows two methods: using a scoped proxy (via @Scope with proxyMode) and using ObjectFactory for manual retrieval. It briefly mentions custom scopes and thread safety considerations. Key code artifacts include StatefulInventoryCache, ThreadSafeInventoryCache, NonThreadSafeCsvParser, and injection examples. Citations reference Spring documentation and Java concurrency utilities.

Scopes and Thread Safety

In the Spring Framework, bean scope management and thread safety are not configuration concerns—they are architectural decisions with direct implications for system stability, memory consistency, and concurrency correctness. This section dissects the operational mechanics of Singleton and Prototype scopes, analyzes failure modes under concurrent access, and prescribes strategies for enforcing thread safety in singleton beans. Custom scopes and proxy mechanisms are evaluated not as conveniences, but as tools with measurable runtime and lifecycle trade-offs.

Singleton Scope

The default scope in Spring Framework is Singleton: one instance per BeanFactory. This instance is eagerly cached and returned on every resolution request. While memory-efficient, this model introduces concurrency hazards when the bean maintains mutable state.

Consider a stateful singleton used in a LogisticsCore warehouse inventory system:

@Service
public class InventoryCache {
    private final java.util.Map<String, Integer> cache = new java.util.HashMap<>();
    private int accessCount = 0;

    public void update(String itemId, Integer quantity) {
        cache.put(itemId, quantity);
        accessCount++;
    }

    public Integer get(String itemId) {
        return cache.get(itemId);
    }

    public int getAccessCount() {
        return accessCount;
    }
}

This implementation is not thread-safe. The failure modes are rooted in the Java Memory Model (JMM) [3]. First, HashMap is not synchronized: concurrent put operations can corrupt internal structure, leading to infinite loops or data loss. Second, the increment accessCount++ is not atomic—it involves read, increment, and write steps. Under concurrent access, increments can be lost due to race conditions. Third, without proper synchronization, changes to accessCount may not be visible to other threads due to CPU caching and instruction reordering.

To enforce thread safety, the JMM mandates either mutual exclusion or the use of thread-safe constructs. The prescribed fix is to replace HashMap with ConcurrentHashMap and int with AtomicInteger:

import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;

@Service
public class ThreadSafeInventoryCache {
    private final ConcurrentHashMap<String, Integer> cache = new ConcurrentHashMap<>();
    private final AtomicInteger accessCount = new AtomicInteger(0);

    public void update(String itemId, Integer quantity) {
        cache.put(itemId, quantity);
        accessCount.incrementAndGet();
    }

    public Integer get(String itemId) {
        return cache.get(itemId);
    }

    public int getAccessCount() {
        return accessCount.get();
    }
}

ConcurrentHashMap ensures thread-safe access with minimal contention, and AtomicInteger provides atomic updates with volatile semantics, guaranteeing visibility across threads [4]. This is not a workaround—it is the correct alignment with JMM semantics.

Prototype Scope

Prototype scope should be deployed when state encapsulation per invocation is required, and thread safety cannot be guaranteed through synchronization. Each request to the container—via dependency injection or getBean()—triggers the creation of a new instance.

This scope is appropriate for short-lived, stateful parsers or transformers. For example, a CSV parser in LogisticsCore that maintains parsing position:

public record CsvLine(String content) {}

@Component
@Scope("prototype")
public class CsvParser {
    private String currentLine;
    private int position;

    public void parse(CsvLine line) {
        this.currentLine = line.content();
        this.position = 0;
    }

    public String nextToken() {
        if (currentLine == null || position >= currentLine.length()) {
            return null;
        }
        position++;
        return "token"; // Simplified
    }
}

However, the Spring Framework does not invoke destruction callbacks (e.g., @PreDestroy) on prototype beans. Lifecycle management is the responsibility of the client code.

When injecting a prototype-scoped bean into a singleton, direct injection results in a single prototype instance being cached by the singleton. To override this, scoped proxies are required. Using @Scope with proxyMode = ScopedProxyMode.TARGET_CLASS generates a CGLIB-based proxy, which creates a new instance on each method call:

@Configuration
public class ParserConfig {

    @Bean
    @Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
    public CsvParser csvParser() {
        return new CsvParser();
    }
}

Alternatively, ObjectFactory<CsvParser> or javax.inject.Provider<CsvParser> can be used to explicitly control instance creation:

@Service
public class BatchProcessor {

    @Autowired
    private ObjectFactory<CsvParser> parserFactory;

    public void process(CsvLine[] lines) {
        for (CsvLine line : lines) {
            CsvParser parser = parserFactory.getObject();
            parser.parse(line);
            System.out.println(parser.nextToken());
        }
    }
}

ObjectFactory avoids proxying overhead and makes instantiation explicit, reducing opacity. It is the preferred mechanism when the client must control timing and scope of creation.

Programmatically, scoped proxies can be registered via ConfigurableBeanFactory.registerScope(), though annotation-driven configuration is typical in Spring Boot applications. The distinction is critical: Spring Framework provides the mechanism; Spring Boot configures it by convention.

Custom Scopes

Custom scopes must be implemented when neither singleton nor prototype aligns with domain lifecycle requirements. A common use case is a ThreadLocal-backed scope, where a bean instance is bound to the current thread—useful for per-thread context holders in LogisticsCore’s shipment processing pipeline.

To implement:

  1. Define a class implementing org.springframework.beans.factory.config.Scope.
  2. Register it via ConfigurableBeanFactory.registerScope("threadScope", new ThreadLocalScope()).

This is a low-level mechanism and should be used sparingly, as it increases complexity and debugging difficulty.

Thread Safety Considerations

Thread safety in Spring is not abstracted away by the container. The framework manages object creation and wiring, but concurrency correctness is the developer’s responsibility. In a singleton-dominated architecture, mutable state must either be eliminated (preferred) or synchronized using JMM-compliant constructs.

Visibility and atomicity are not optional—they are enforced by the JMM. Without volatile, synchronized, or atomic classes, there is no guarantee that writes will be observed by other threads. Spring’s dependency injection does not alter this reality.

Scoped proxies (CGLIB or JDK Dynamic Proxy) and ObjectFactory are tools to decouple lifecycle from injection, but they do not eliminate the need for thread-safe design. CGLIB proxies, used when proxyMode = TARGET_CLASS, subclass the target; JDK proxies require interfaces and may not intercept internal method calls. These are not implementation details—they are failure vectors under misuse.

Conclusion

The choice between Singleton and Prototype is a trade-off between memory efficiency and state isolation. Singleton beans must be stateless or rigorously thread-safe; any deviation risks data corruption under load. Prototype scope eliminates shared state but shifts lifecycle management to the client and may increase GC pressure.

Scoped proxies (CGLIB-based in the absence of interfaces) enable injection-time indirection but obscure instantiation. ObjectFactory makes creation explicit and is preferable when transparency is required.

Spring Framework provides the mechanisms; Spring Boot configures them. Understanding the underlying JVM concurrency model is non-negotiable. Thread safety is not a feature—it is a requirement enforced by hardware and specification.

Sources

[1] Spring Framework Documentation: Bean Scopes [2] Spring Framework Documentation: Custom Scopes [3] Java Language Specification, Java Memory Model, 3rd ed. Addison-Wesley, 2005. [4] J. Bloch, Effective Java, 3rd ed. Addison-Wesley, 2018.