Imagine you’re building a logging system for your application. You need exactly one logger instance—not two, not three, one. You want every part of your code to write to the same file, maintain consistent formatting, and never accidentally create duplicate loggers that step on each other’s toes.
That’s a singleton. It’s a class that ensures only one instance exists throughout your application’s lifetime, and provides a global point of access to that instance.
In this post, we’ll explore how to implement singletons correctly in Java, Kotlin, Scala 2, and Scala 3. We’ll see that some languages make it embarrassingly simple, while others require careful attention to thread safety and serialization.
The Problem: One Logger to Rule Them All
Let’s say you need a database connection manager that:
- Must have exactly one instance (connections are expensive)
- Is accessed from multiple threads simultaneously
- Should be initialized lazily (only when first needed)
- Survives serialization and deserialization correctly
This is precisely what singletons solve.
Key Concepts
Thread Safety
When multiple threads might access the singleton simultaneously, we need guarantees that only one instance is ever created:
| Approach | Thread-Safe | Lazy | Complexity |
|---|---|---|---|
| Eager static initialization | ✓ | ✗ | Low |
| Double-checked locking | ✓ | ✓ | High |
| Holder pattern | ✓ | ✓ | Medium |
| Language keyword (Kotlin/Scala) | ✓ | ✓ | Low |
Initialization Strategies
- Eager: Instance created when the class loads (simple, but wastes resources if never used)
- Lazy: Instance created only when first requested (saves resources, but needs synchronization)
- Language-level: Built into the language (like Kotlin’s
objector Scala’sobject)
The Solution: Singleton Implementations Across Languages
Java 21: Eager Initialization
The simplest and most recommended approach in Java. The instance is created when the class is loaded—the class loader guarantees thread safety, so no synchronization is needed.
public class DatabaseConnection {
private static final DatabaseConnection instance =
new DatabaseConnection();
// Private constructor prevents external instantiation
private DatabaseConnection() {
System.out.println("DatabaseConnection instance created");
}
public static DatabaseConnection getInstance() {
return instance;
}
public void connect() {
System.out.println("Connected to database");
}
}
object DatabaseConnection {
init {
println("DatabaseConnection instance created")
}
fun connect() {
println("Connected to database")
}
}
// Usage: DatabaseConnection.connect()
object DatabaseConnection {
println("DatabaseConnection instance created")
def connect(): Unit = println("Connected to database")
}
// Usage: DatabaseConnection.connect()
object DatabaseConnection:
println("DatabaseConnection instance created")
def connect(): Unit = println("Connected to database")
// Usage: DatabaseConnection.connect()
Key differences:
- Java: Class loading guarantees thread safety. No synchronization needed, but instance is created immediately.
- Kotlin: The
objectkeyword handles everything. Lazy initialization and thread safety are automatic. - Scala 2/3: Like Kotlin,
objectis the idiomatic way. Scala handles all the synchronization and lazy initialization.
When Lazy Initialization Matters
Sometimes you don’t want to create the singleton immediately. Imagine a configuration loader that’s never used in your tests—why create it?
Java 21: Holder Pattern (Recommended)
The holder pattern combines lazy initialization with the simplicity of eager initialization. The inner class is only loaded when you call getInstance().
public class HolderDatabaseConnection {
private HolderDatabaseConnection() { }
private static class ConnectionHolder {
static final HolderDatabaseConnection instance =
new HolderDatabaseConnection();
}
public static HolderDatabaseConnection getInstance() {
return ConnectionHolder.instance;
}
}
This is the modern Java best practice: thread-safe, lazy, and simple.
Kotlin/Scala: Automatic
Kotlin’s object and Scala’s object both provide lazy initialization by default. No special pattern needed.
// Kotlin - lazy by default
object LazyLogger {
init { println("Logger initialized") }
fun log(msg: String) = println(msg)
}
// Only creates instance when LazyLogger.log() is first called
Comparison: Language Implementations
| Language | Syntax | Boilerplate | Thread-Safe | Lazy | Comments |
|---|---|---|---|---|---|
| Java 21 | static final + private constructor |
High | ✓ (by default) | ✗ | Use holder pattern for lazy |
| Kotlin | object keyword |
Minimal | ✓ | ✓ | Idiomatic, automatic serialization |
| Scala 2 | object keyword |
Minimal | ✓ | ✓ | Idiomatic, similar to Kotlin |
| Scala 3 | object keyword |
Minimal | ✓ | ✓ | Same as Scala 2, cleaner syntax |
The Pitfalls: How to Break (and Fix) a Singleton
Java Pitfall 1: Reflection Attacks
Even a private constructor can be bypassed with reflection:
// This BREAKS your singleton!
Constructor<DatabaseConnection> constructor =
DatabaseConnection.class.getDeclaredConstructor();
constructor.setAccessible(true);
DatabaseConnection fake = constructor.newInstance(); // ❌ New instance!
Fix: Add a check in the constructor:
private DatabaseConnection() {
if (instance != null) {
throw new IllegalStateException("Singleton already instantiated");
}
}
Java Pitfall 2: Serialization Creates Copies
Deserializing a singleton creates a new instance:
DatabaseConnection original = DatabaseConnection.getInstance();
byte[] serialized = serialize(original);
DatabaseConnection deserialized = deserialize(serialized);
// ❌ deserialized != original (new instance!)
Fix: Implement readResolve():
protected Object readResolve() {
return getInstance();
}
Kotlin/Scala: No Pitfalls (Built-In Safety)
Because object is a language keyword, the compiler and runtime handle these issues. You simply can’t accidentally create two instances.
When NOT to Use Singletons
Singletons are often misused as “convenient global state.” Before reaching for a singleton, ask:
- Do I really need exactly one instance? (Or just want to avoid passing parameters?)
- Is this testable? (Singletons make mocking difficult)
- Can I use dependency injection instead? (Usually yes, and it’s better)
Common anti-pattern:
// ❌ DON'T DO THIS
public class BadConfig {
public static final BadConfig instance = new BadConfig();
public String apiKey; // Mutable global state!
}
// Someone changes it unexpectedly:
BadConfig.instance.apiKey = "wrong-key"; // 😱
Better approach:
// ✓ Use dependency injection
public class AppService {
private final Config config;
public AppService(Config config) { // Injected, testable
this.config = config;
}
}
Real-World Use Cases (Where Singletons Actually Make Sense)
Logger
Logger.getInstance().log("User logged in");
There should genuinely be one logger writing to one file.
Database Connection Pool
ConnectionPool pool = ConnectionPool.getInstance();
Connection conn = pool.getConnection();
A pool manages a fixed set of connections—exactly what a singleton should manage.
Configuration (Immutable)
AppConfig config = AppConfig.getInstance();
String apiKey = config.getApiKey();
If the configuration is immutable and read-only, a singleton is reasonable.
Comparison Table: Quick Reference
| Question | Java 21 | Kotlin | Scala |
|---|---|---|---|
| How do I write it? | Static field + private constructor | object keyword |
object keyword |
| How many lines of code? | ~10–20 | ~2–5 | ~2–5 |
| Is it thread-safe? | Yes | Yes | Yes |
| Is it lazy-initialized? | No (but use holder pattern) | Yes | Yes |
| Can I test it? | Difficult | Difficult | Difficult |
| Can I serialize it? | Yes (with readResolve()) |
Yes | Yes |
Testing Singletons: Proving It Works
To verify that our singleton implementations are truly creating only one instance across concurrent access, here are test examples for each language. These tests demonstrate thread safety using CountDownLatch to coordinate 100 concurrent threads:
@Test
@DisplayName("Should return the same instance")
void testSingletonInstances() {
DatabaseConnection conn1 =
DatabaseConnection.getInstance();
DatabaseConnection conn2 =
DatabaseConnection.getInstance();
assertSame(conn1, conn2);
}
@Test
@DisplayName("Should handle concurrent access")
fun testConcurrentAccess() {
val numThreads = 100
val executor =
Executors.newFixedThreadPool(10)
repeat(numThreads) {
executor.submit {
DatabaseConnection.connect()
}
}
}
@Test
@DisplayName("Should handle concurrent access")
def testConcurrentAccess(): Unit = {
val numThreads = 100
val executor =
Executors.newFixedThreadPool(10)
for (_ <- 1 to numThreads) {
executor.submit(...)
}
}
@Test
@DisplayName("Should handle concurrent access")
def testConcurrentAccess(): Unit =
val numThreads = 100
val executor =
Executors.newFixedThreadPool(10)
for _ <- 1 to numThreads do
executor.submit(...)
All tests verify:
- Single instance: Multiple calls to
getInstance()or direct access returns the same object (usingassertSame) - Thread safety: 100 concurrent threads all access the singleton without creating duplicates
- No exceptions: Connect/disconnect operations complete without errors under concurrent load
- Lazy initialization: Tests run without blocking even with 100 threads
Code Samples
All examples in this post are available in the repository:
Implementation files:
- Java 21: DatabaseConnection.java, LazyDatabaseConnection.java, HolderDatabaseConnection.java
- Kotlin: DatabaseConnection.kt
- Scala 2: DatabaseConnection.scala
- Scala 3: DatabaseConnection.scala
Test files:
- Java 21: DatabaseConnectionTest.java
- Kotlin: DatabaseConnectionTest.kt
- Scala 2: DatabaseConnectionTest.scala
- Scala 3: DatabaseConnectionTest.scala
Key Takeaways
-
In Java 21: Use the holder pattern for lazy initialization, or eager initialization if resources aren’t a concern. Remember
readResolve()for serialization. -
In Kotlin/Scala: Use the
objectkeyword. It’s idiomatic, thread-safe by default, and handles lazy initialization automatically. -
Singletons are often overused: Consider dependency injection first—it’s usually more testable and flexible.
-
When a singleton makes sense: Loggers, connection pools, and immutable configuration objects are legitimate use cases.
-
Thread safety and serialization are non-negotiable: If you implement a singleton, handle both correctly.
This is part of our Design Patterns in JVM Languages series. Check out the full design patterns guide for more patterns and interview preparation.