Kotlin Lazy Delegate Thread Safety Modes Explained
Most Kotlin developers use lazy without realizing they’re choosing a concurrency strategy. The three thread safety modes—NONE, SYNCHRONIZED, and PUBLICATION—have vastly different performance and safety characteristics. Understanding these modes helps you write faster, safer concurrent code.
How Kotlin’s Lazy Delegate Works Internally
Before diving into the thread safety modes, let me explain the underlying mechanism. The Lazy interface defines the contract:
interface Lazy<T> { val value: T fun isInitialized(): Boolean}The implementation uses an _value field that starts as UNINITIALIZED_VALUE (a singleton marker object). When you first access value, it runs your initializer, caches the result, and sets the initializer reference to null for garbage collection optimization. The thread safety mode determines HOW this transition happens under concurrent access.
LazyThreadSafetyMode.NONE - The Fastest but Unsafe
This mode uses UnsafeLazyImpl with no synchronization whatsoever.
val unsafeData by lazy(LazyThreadSafetyMode.NONE) { // DANGEROUS: runs multiple times if accessed concurrently computeExpensiveValue()}What happens: Multiple threads can race into the initializer block simultaneously. If your initializer creates objects, opens connections, or modifies shared state, you’ll get multiple instances and potential corruption.
When to use: Only when you can guarantee single-threaded access. For example, Android UI code runs on a single thread:
class MainActivity : AppCompatActivity() { // RIGHT: Only accessed from main thread val viewBinding by lazy(LazyThreadSafetyMode.NONE) { ActivityMainBinding.inflate(layoutInflater) }}When NOT to use: Any shared object in a multithreaded context. The performance gain isn’t worth the debugging pain.
LazyThreadSafetyMode.SYNCHRONIZED - The Safe Default
This is the DEFAULT mode using SynchronizedLazyImpl. It implements the double checked locking pattern.
val safeData by lazy(LazyThreadSafetyMode.SYNCHRONIZED) { // Default: thread-safe double checked locking computeExpensiveValue()}
// Or simply:val safeData by lazy { /* SYNCHRONIZED is default */ }How double checked locking works:
private var _value: Any? = UNINITIALIZED_VALUE
val value: T get() { // First check: fast path - no lock overhead if (_value !== UNINITIALIZED_VALUE) { @Suppress("UNCHECKED_CAST") return _value as T }
return synchronized(lock) { // Second check: prevents race between first check and lock if (_value === UNINITIALIZED_VALUE) { val typedValue = initializer!!() _value = typedValue initializer = null // Allow GC } @Suppress("UNCHECKED_CAST") _value as T } }Why two checks? The first check avoids lock overhead on every access after initialization. The second check prevents multiple threads from initializing when they race between the first check and lock acquisition. This guarantees the initializer executes exactly once.
When to use: Most scenarios. It’s safe for multithreading and has minimal overhead after initialization.
LazyThreadSafetyMode.PUBLICATION - Lock-Free Concurrency
This mode uses SafePublicationLazyImpl with AtomicReferenceFieldUpdater for lock-free initialization via compare-and-set (CAS).
val publishedData by lazy(LazyThreadSafetyMode.PUBLICATION) { // Multiple threads may run this, but only one value wins fetchFromCache() // Cheap operation}How the CAS pattern works:
private val updater = AtomicReferenceFieldUpdater.newUpdater( SafePublicationLazyImpl::class.java, Any::class.java, "_value")
val value: T get() { val current = updater.get(this) if (current !== UNINITIALIZED_VALUE) { @Suppress("UNCHECKED_CAST") return current as T }
val newValue = initializer!!() // Atomic: only update if still UNINITIALIZED_VALUE if (updater.compareAndSet(this, UNINITIALIZED_VALUE, newValue)) { initializer = null // Winner: allow GC return newValue }
// Loser: use winner's value, discard ours @Suppress("UNCHECKED_CAST") return updater.get(this) as T }The CAS race: Multiple threads compute values simultaneously, but only one wins the compareAndSet operation. The others discard their result and use the winner’s value. This trades redundant computation for lock-free concurrency.
When PUBLICATION beats SYNCHRONIZED:
- The initializer is CHEAP (redundant computation is acceptable)
- Many threads access simultaneously (high contention)
- Lock contention would bottleneck throughput
Example: Caching frequently-accessed configuration:
val configCache by lazy(LazyThreadSafetyMode.PUBLICATION) { // Cheap: in-memory cache lookup loadConfigFromMemory()}When PUBLICATION is worse than SYNCHRONIZED:
- The initializer is EXPENSIVE (database query, network call, complex computation)
- Redundant execution wastes significant resources
// WRONG: Expensive initializer with PUBLICATIONval database by lazy(LazyThreadSafetyMode.PUBLICATION) { // Multiple threads might create multiple connections createDatabaseConnection() // Expensive!}Performance Comparison
| Mode | Initialization Speed | Thread Safety | Redundant Computation | Best Use Case |
|---|---|---|---|---|
| NONE | Fastest | None (unsafe) | Possible (race) | Single-threaded only |
| SYNCHRONIZED | Fast after init | Complete | Never | Default choice |
| PUBLICATION | Fast (lock-free) | Complete | Possible (acceptable) | High contention + cheap init |
Memory overhead:
- NONE: No synchronization objects
- SYNCHRONIZED: One lock object per lazy instance
- PUBLICATION:
AtomicReferenceFieldUpdater(shared static, no per-instance overhead)
Decision flow:
-
Can you guarantee single-threaded access?
- Yes → NONE (fastest)
- No → Continue
-
Is the initializer expensive (DB, network, complex computation)?
- Yes → SYNCHRONIZED (avoid redundant work)
- No → Continue
-
Will many threads access simultaneously (high contention)?
- Yes → PUBLICATION (lock-free throughput)
- No → SYNCHRONIZED (simpler, default)
Common Pitfalls and Best Practices
Pitfall 1: Using NONE in multithreaded code
// WRONG: Shared object with NONE modeclass SharedService { val config by lazy(LazyThreadSafetyMode.NONE) { // Multiple threads might initialize multiple times loadConfig() }}Pitfall 2: Overusing PUBLICATION for expensive operations
// WRONG: Expensive initializer with PUBLICATIONval database by lazy(LazyThreadSafetyMode.PUBLICATION) { // Multiple threads might create multiple database connections createDatabaseConnection() // Expensive!}Best Practice: Default to SYNCHRONIZED
// RIGHT: Safe by defaultval service by lazy { // SYNCHRONIZED is default - safe for 99% of cases initializeService()}Best Practice: Use NONE for single-threaded UI code
// RIGHT: Android UI is single-threadedval viewBinding by lazy(LazyThreadSafetyMode.NONE) { // Only accessed from main thread ActivityMainBinding.inflate(layoutInflater)}The lazy delegate isn’t magic—it’s a choice of concurrency strategy. Understanding these three modes transforms how you write concurrent Kotlin code. You stop guessing and start making informed decisions about thread safety.
Key takeaways:
- SYNCHRONIZED (default) is right for most cases
- NONE for guaranteed single-threaded scenarios
- PUBLICATION for lock-free high-contention scenarios with cheap initializers
Check your codebase for lazy delegates. Are you using the right mode?
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 5 Kotlin Internals You Should Know
- 👨💻 Kotlin Lazy Delegate Documentation
- 👨💻 Double-Checked Locking Pattern
- 👨💻 AtomicReferenceFieldUpdater JavaDoc
- 👨💻 Java Concurrency in Practice
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments