Racy Lazy Initialization with Side Effects
Racy Lazy Initialization with Side Effects: practice a Java concurrency bug with symptoms like Rare inconsistent behavior, Flaky initialization,...
- Safe publication
- Safe Publication
- Initialization
- Java
- Intermediate
Production symptoms
- Rare inconsistent behavior
- Flaky initialization
- Hard-to-reproduce bug
Failure scenario
Code
class ParserRegistry {
private static Parser parser;
static Parser parser() {
if (parser == null) {
parser = Parser.loadRulesAndStartRefresher();
}
return parser;
}
}
Prod Symptoms
A lazily initialized parser or routing helper loads rules and starts a background refresher on first use.
- Logs around first use show rules loaded twice for the same registry
- Two refresher threads keep polling the same config endpoint
- A first request sees an empty rule table or missing listener registration
- Later requests look normal after one instance is stored and warmed up
- Thread dumps show ordinary request or refresher threads, not a clear wait or deadlock
Run Locally
- The demo often reports that more than one RuleRegistry was created
- Each created RuleRegistry starts a simulated refresher side effect
- Only one RuleRegistry reference eventually remains in the field, but side effects from losing instances already happened
- A quiet run does not prove the code safe; it only means the timing did not expose the race
- Real unsafe publication can show up as duplicate initialization, stale state, or rare inconsistent behavior
Inspect hints
- Look for lazily initialized static or singleton fields without volatile, synchronized, final holder idiom, or class initialization safety
- Thread dumps are usually less useful than reviewing the first-access path
javac BrokenLazyInitDemo.java
java BrokenLazyInitDemo
for i in {1..10}; do java BrokenLazyInitDemo; done
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
public class BrokenLazyInitDemo {
private static final int THREADS = 16;
private static final int ATTEMPTS = 500;
private static RuleRegistry registry;
private static final AtomicInteger created = new AtomicInteger();
private static final AtomicInteger refreshersStarted = new AtomicInteger();
static RuleRegistry registry() {
if (registry == null) {
sleepQuietly(1);
registry = new RuleRegistry(created.incrementAndGet(), refreshersStarted);
}
return registry;
}
public static void main(String[] args) throws Exception {
for (int attempt = 1; attempt <= ATTEMPTS; attempt++) {
registry = null;
created.set(0);
refreshersStarted.set(0);
Set<Integer> seenIds = ConcurrentHashMap.newKeySet();
CountDownLatch start = new CountDownLatch(1);
ExecutorService pool = Executors.newFixedThreadPool(THREADS);
for (int i = 0; i < THREADS; i++) {
pool.submit(() -> {
start.await();
seenIds.add(registry().id);
return null;
});
}
start.countDown();
pool.shutdown();
pool.awaitTermination(5, TimeUnit.SECONDS);
if (created.get() > 1) {
System.out.println("attempt " + attempt + " created "
+ created.get() + " registries: " + seenIds);
System.out.println("refreshers started = " + refreshersStarted.get());
return;
}
}
System.out.println("No race observed. Run again or increase THREADS.");
}
static final class RuleRegistry {
final int id;
final int[] table;
RuleRegistry(int id, AtomicInteger refreshersStarted) {
this.id = id;
refreshersStarted.incrementAndGet();
this.table = new int[1_000];
for (int i = 0; i < table.length; i++) {
table[i] = i;
}
}
}
private static void sleepQuietly(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
Note: This stress demo exposes the race by counting duplicate registries and duplicate initialization side effects. Unsafe partial publication is a broader risk, but harder to force reliably in a small demo.
Diagnosis and fix
Explanation
This lazy initialization path has two related problems. The visible failure is duplicate initialization: multiple threads can observe registry == null and run initialization. The deeper JMM issue is unsafe publication: the initialized reference is stored in a shared field without volatile, synchronized, class initialization, or another happens-before edge.
Key signal: The production symptom is duplicate initialization. Unsafe publication is why the same pattern can also expose not-fully-visible initialized state.
- Multiple threads can observe registry == null
- More than one thread can run expensive initialization
- Each initialization can start side effects: refreshers, listeners, metrics, connection pools, or cache warm-up
- Only one reference eventually remains in the field, but side effects from losing instances may keep running
- Without a happens-before edge, another thread can read the shared reference without a guarantee that it also sees all writes performed during initialization
- In practice, that can look like null or default fields, missing rules, empty config, or listeners that were supposed to be registered
- These bugs are rare and very hard to reproduce after the fact
- This code has no safe publication edge, so the pattern can fail even when duplicate side effects are not obvious
How to Diagnose
Start with code review around shared lazy fields and first-access paths.
- Look for static or singleton fields initialized on demand
- Check for if (field == null) followed by assignment without synchronized, volatile, holder idiom, enum singleton, or class initialization safety
- Correlate first-request or cold-start failures with duplicate initialization logs
- Check whether initialization has side effects: scheduled refreshers, listener registration, metric registration, connection pools, cache warm-up, files, or background tasks
attempt 1 created 8 registries: [1, 2, 3, 4, 5, 6, 7, 8]
refreshers started = 8
Note: Duplicate construction is visible here because initialization has a side effect. The broader problem is unsafe publication of shared initialized state.
How to Fix
This fixed demo uses the initialization-on-demand holder idiom, a well-known Effective Java pattern for lazy initialization of static fields. It is safe here because JVM class initialization runs once and safely publishes Holder.INSTANCE to all threads.
- Prefer eager initialization or static final when lazy loading is not needed
- Use the initialization-on-demand holder idiom for simple parameterless lazy singletons
- Use synchronized lazy initialization when parameters or checked failures make a holder awkward
- Avoid double-checked locking unless the field is volatile and the pattern is implemented carefully
- Keep initialization side effects idempotent where possible
- Do not start refreshers, listeners, or other external side effects from a racy lazy initializer; losing instances can leave those side effects running
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
public class LazyInitHolderFixed {
private static final int THREADS = 16;
private static final AtomicInteger created = new AtomicInteger();
private static final AtomicInteger refreshersStarted = new AtomicInteger();
static RuleRegistry registry() {
return Holder.INSTANCE;
}
private static class Holder {
private static final RuleRegistry INSTANCE = createRegistry();
private static RuleRegistry createRegistry() {
sleepQuietly(1);
return new RuleRegistry(created.incrementAndGet(), refreshersStarted);
}
}
public static void main(String[] args) throws Exception {
Set<Integer> seenIds = ConcurrentHashMap.newKeySet();
CountDownLatch start = new CountDownLatch(1);
ExecutorService pool = Executors.newFixedThreadPool(THREADS);
for (int i = 0; i < THREADS; i++) {
pool.submit(() -> {
start.await();
seenIds.add(registry().id);
return null;
});
}
start.countDown();
pool.shutdown();
pool.awaitTermination(5, TimeUnit.SECONDS);
System.out.println("created registries = " + created.get());
System.out.println("refreshers started = " + refreshersStarted.get());
System.out.println("seen ids = " + seenIds);
}
static final class RuleRegistry {
final int id;
final int[] table;
RuleRegistry(int id, AtomicInteger refreshersStarted) {
this.id = id;
refreshersStarted.incrementAndGet();
this.table = new int[1_000];
for (int i = 0; i < table.length; i++) {
table[i] = i;
}
}
}
private static void sleepQuietly(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}