Lesson 33 of 71
Safe Publication and final Fields
This lesson covers publication: making an object reachable from code that runs in another thread. After it, a reader can tell whether a shared object is published safely, write a correct lazy singleton, and explain which memory barriers the JVM emits on x86 and ARM. It builds on the rules from Happens-Before and volatile.
| Idea | Short meaning |
|---|---|
| Publication | Storing a reference where another thread can read it |
| Escape | Publishing an object before its construction finishes |
final field semantics |
Values of final fields are visible once the constructor ends, even through a racy reference |
| Safe publication | Publishing through an action that creates a happens-before edge |
| Double-checked locking | Lazy init that skips the lock after the first call; needs volatile |
| Holder-class idiom | Lazy init through class initialization |
| Memory barrier | A CPU or compiler instruction that forbids some reorderings |
Example 1: unsafe publication
An object stored in a plain field without any happens-before edge is published unsafely. The block is compiled but not run, because its output is not fixed and it may never terminate.
public class UnsafePublication {
static class Point {
int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
}
static Point shared; // plain field
public static void main(String[] args) throws InterruptedException {
Thread reader = new Thread(() -> {
Point p;
while ((p = shared) == null) {
Thread.onSpinWait();
}
System.out.println(p.x + "," + p.y);
});
reader.start();
shared = new Point(1, 2);
reader.join();
}
}The outcome is nondeterministic by the JMM. The allocation, the writes to x and y, and the write of the reference to shared can be reordered, so the reader is allowed to print 1,2, 0,2, 1,0, or 0,0. The loop is also allowed to spin forever, because shared is a plain field and its read can be hoisted. On x86 with HotSpot the program usually prints 1,2, which is why such bugs survive testing and appear on ARM servers.
Example 2: escaping this from a constructor
this escapes when a constructor hands the object to other code before all fields are set. The bug is visible even in a single thread.
import java.util.ArrayList;
import java.util.List;
public class ThisEscape {
interface Listener { void onEvent(String e); }
static final List<Listener> registry = new ArrayList<>();
static class AuditListener implements Listener {
private final String prefix;
AuditListener() {
registry.add(this); // this escapes here
fire("constructing"); // callback runs before prefix is set
prefix = "audit";
}
public void onEvent(String e) { System.out.println(prefix + ": " + e); }
}
static void fire(String e) {
for (Listener l : registry) l.onEvent(e);
}
public static void main(String[] args) {
new AuditListener();
fire("ready");
}
}null: constructing
audit: readyThe first callback reads prefix before the constructor writes it, so it sees the default null, even though the field is final. If registry were read by another thread, that thread would get no final guarantee at all, because the guarantee only holds when this does not escape. The fix is a private constructor and a static factory that registers the object after construction.
Example 3: final field semantics
The JMM gives final fields a special rule (JLS 17.5): a thread that obtains a reference to an object after its constructor finished sees the correctly initialized values of its final fields, and of everything reachable through them as of the end of the constructor, even if the reference was published through a data race.
public class FinalFields {
static final class Price {
final String symbol;
final long ticks;
Price(String symbol, long ticks) {
this.symbol = symbol;
this.ticks = ticks;
}
}
static Price shared; // still a plain field
public static void main(String[] args) throws InterruptedException {
Thread writer = new Thread(() -> shared = new Price("ACME", 10_250));
writer.start();
writer.join();
Price p = shared;
System.out.println(p.symbol + " " + p.ticks);
}
}ACME 10250Here join() already makes the read safe. The final rule matters when there is no such edge: a racy reader may see shared == null, but if it sees the reference, it cannot see symbol == null or ticks == 0. HotSpot implements this with a StoreStore barrier at the end of a constructor that writes a final field (the "freeze"). The guarantee does not cover non-final fields of the same object, and it does not cover later changes to an object reached through a final field.
Example 4: the safe publication idioms
An object is published safely when the store of the reference and the load by the reader are ordered by happens-before. The standard ways:
| Idiom | Edge used |
|---|---|
| Static initializer | Class initialization lock (JLS 12.4.2) |
volatile field or AtomicReference |
Volatile write to read |
| Field guarded by a lock | Unlock to lock |
Concurrent collection (ConcurrentHashMap, BlockingQueue) |
The collection's documented edge |
final field of a properly constructed object |
Final field semantics |
AtomicReference is a wrapper around a volatile reference with atomic update methods; Atomic Classes and CAS teaches it.
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicReference;
public class SafeIdioms {
static class Account { // mutable, non-final fields
String id;
long balance;
Account(String id, long balance) { this.id = id; this.balance = balance; }
}
static final Account STATIC_ACC = new Account("static", 1); // 1
static volatile Account volatileAcc; // 2
static final AtomicReference<Account> atomicAcc = new AtomicReference<>(); // 3
static final ConcurrentHashMap<String, Account> map = new ConcurrentHashMap<>(); // 4
public static void main(String[] args) throws InterruptedException {
Thread writer = new Thread(() -> {
volatileAcc = new Account("volatile", 2);
atomicAcc.set(new Account("atomic", 3));
map.put("m", new Account("map", 4));
});
writer.start();
writer.join();
long total = STATIC_ACC.balance + volatileAcc.balance
+ atomicAcc.get().balance + map.get("m").balance;
System.out.println("total = " + total);
}
}total = 10Each idiom covers the state of the object at the moment of publication. Writes made to a mutable object after it was published need their own synchronization.
Example 5: double-checked locking, broken and fixed
Double-checked locking (DCL) checks a field without a lock, and takes the lock only while the field is still null. Without volatile it is broken. The broken version is compiled only; it has no main.
public class BrokenDcl {
static class Service { int port; Service() { port = 8080; } }
private static Service instance; // not volatile: broken
static Service get() {
if (instance == null) { // racy read
synchronized (BrokenDcl.class) {
if (instance == null) {
instance = new Service(); // reference may be visible before port = 8080
}
}
}
return instance;
}
}A thread that sees instance != null on the first check never enters the lock, so it has no happens-before edge with the writer. It may see a non-null reference with port == 0. The fix is to declare the field volatile. The local variable avoids a second volatile read on the fast path.
public class FixedDcl {
static class Service { int port; Service() { port = 8080; } }
private static volatile Service instance;
static Service get() {
Service local = instance; // one volatile read
if (local == null) {
synchronized (FixedDcl.class) {
local = instance;
if (local == null) {
local = new Service();
instance = local; // volatile write publishes port
}
}
}
return local;
}
public static void main(String[] args) throws InterruptedException {
Service[] seen = new Service[4];
Thread[] threads = new Thread[4];
for (int i = 0; i < 4; i++) {
int idx = i;
threads[i] = new Thread(() -> seen[idx] = get());
threads[i].start();
}
for (Thread t : threads) t.join();
boolean same = seen[0] == seen[1] && seen[1] == seen[2] && seen[2] == seen[3];
System.out.println("same instance: " + same + ", port " + seen[0].port);
}
}same instance: true, port 8080DCL with volatile has been correct since Java 5 (JSR-133). Making port final would also fix the visibility of port, but the plain read on the fast path would still be racy and is allowed to return null after another thread saw a value.
Example 6: the holder-class idiom
Class initialization is lazy and thread-safe: the JVM initializes a class on first active use, under a per-class lock, and every thread that later uses the class sees the initialized static fields.
public class HolderIdiom {
static class Registry {
private Registry() { System.out.println("Registry created"); }
private static class Holder {
static final Registry INSTANCE = new Registry();
}
static Registry getInstance() { return Holder.INSTANCE; }
}
public static void main(String[] args) {
System.out.println("main started");
Registry a = Registry.getInstance();
Registry b = Registry.getInstance();
System.out.println("same: " + (a == b));
}
}main started
Registry created
same: trueHolder is initialized on the first call to getInstance(), not when Registry loads. After initialization the JIT removes the check, so the fast path is a plain static field read. The idiom works only for static singletons; per-instance lazy fields still need DCL with volatile. A single-element enum gives the same laziness and also handles serialization.
Example 7: immutable objects in production code
An immutable object is safe to share through any publication, including a data race, when all of these hold: every field is final, this does not escape during construction, and mutable inputs are copied. Records satisfy the first rule automatically.
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class ImmutableBook {
record Level(long price, long qty) {}
record BookSnapshot(String symbol, long seq, List<Level> bids, List<Level> asks) {
BookSnapshot {
bids = List.copyOf(bids); // defensive, unmodifiable copies
asks = List.copyOf(asks);
}
long spread() { return asks.get(0).price() - bids.get(0).price(); }
}
static final Map<String, BookSnapshot> latest = new ConcurrentHashMap<>();
public static void main(String[] args) throws InterruptedException {
Thread feed = new Thread(() -> {
var bids = new java.util.ArrayList<>(List.of(new Level(10_000, 5)));
var asks = new java.util.ArrayList<>(List.of(new Level(10_002, 3)));
latest.put("ACME", new BookSnapshot("ACME", 1, bids, asks));
bids.clear(); // does not affect the snapshot
});
feed.start();
feed.join();
BookSnapshot s = latest.get("ACME");
System.out.println(s.symbol() + " seq=" + s.seq() + " bids=" + s.bids().size()
+ " spread=" + s.spread());
}
}ACME seq=1 bids=1 spread=2The feed thread mutates its own list after publishing, and the snapshot is unaffected because the compact constructor copied it. Readers on any number of threads can hold the snapshot without locks. A new market state is a new snapshot, not a change to the old one.
Memory barriers and hardware
A memory barrier (fence) forbids certain reorderings of loads and stores around it. The classic names describe which pair stays ordered:
| Barrier | Keeps ordered |
|---|---|
| LoadLoad | Earlier loads before later loads |
| StoreStore | Earlier stores before later stores |
| LoadStore | Earlier loads before later stores |
| StoreLoad | Earlier stores before later loads (the most expensive) |
The JMM is specified in terms of happens-before, not barriers. The barrier view is how JIT compilers implement it. In the JSR-133 cookbook mapping, a volatile store is preceded by StoreStore and LoadStore barriers and followed by a StoreLoad barrier; a volatile load is followed by LoadLoad and LoadStore barriers.
| CPU | Hardware reorders | What a volatile store costs |
|---|---|---|
| x86-64 (TSO) | Only store followed by load (store buffer) | LoadLoad, LoadStore, StoreStore are free; StoreLoad needs a locked instruction; HotSpot emits a lock addl to a stack slot after the store |
| ARMv8 (AArch64) | All four kinds | HotSpot compiles a volatile load to ldar (load-acquire) and a volatile store to stlr (store-release) |
On x86 a volatile load compiles to a plain mov; only the compiler is restricted. This is why code with missing volatile often passes on x86 and fails on ARM: the hardware already preserves most orders, and only compiler reordering exposes the bug.
Interview points
- Publication is safe only through an action with a happens-before edge: static init,
volatile,AtomicReference, a lock, a concurrent collection, orfinalfields. finalfields are visible after construction even through a racy reference, providedthisdid not escape from the constructor.- Starting a thread or registering a listener in a constructor lets
thisescape; use a static factory. - DCL needs a
volatilefield; without it a reader can see a reference to a partly constructed object. - The holder-class idiom gives lazy, lock-free static singletons through class initialization.
- x86 only reorders store-then-load, so only StoreLoad costs an instruction; ARM needs acquire and release instructions for volatile accesses.
Summary
- Unsafe publication can show default field values in another thread, and a plain-field spin loop may never end.
finalsemantics are implemented with a StoreStore "freeze" at the end of the constructor.- Immutable objects need all fields
final, nothisescape, and defensive copies of mutable inputs. - Safe publication covers the state at publication time; later mutations need their own synchronization.
- The four barrier types are LoadLoad, StoreStore, LoadStore, and StoreLoad; StoreLoad is the expensive one.
