The question this answers
How does one thread safely hand a newly constructed object to another without the other seeing it half-built?
A loader thread constructs a four-hundred-entry routing table and publishes it to a shared reference that request threads read on every request.
The shared reference, and every field of the object it points at.
Any thread that observes a non-null published reference sees a fully constructed object — every field written before publication is visible.
Synchronization exists to preserve this sentence. If a schedule can make it false, the code is wrong no matter which primitive it uses.
The half-constructed read
The failure is specific and worth stating precisely, because the everyday intuition is so strong: the reader gets a non-null reference to a real, allocated, fully-constructed-in-memory object, and reads a field as zero. Nothing is null. Nothing throws. The object is genuinely finished in the writer's thread. The reader simply cannot see all of it yet, because no happens-before edge relates the writer's field writes to the reader's field reads.
The mechanism is either of the two from Reordering: The Compiler and the CPU Both Do It — the compiler may sink the field writes past the reference store, or the hardware may make the reference store visible before them — and, once again, the program cannot tell which and does not need to. Both are ruled out by the same construct.
The consequence in production is a particularly unpleasant class of bug. A partially visible object flows into normal code paths as if it were valid, so the failure surfaces far from the publication: a routing table with zero routes, a config object whose timeout is 0, a validated request that fails validation for no reason. The stack trace points at the consumer and the bug is in the producer.
| # | Loader thread | Request thread | State |
|---|---|---|---|
| 1 | allocate Table t | · | current=null |
| 2 | write t.version = 7; t.timeoutMs = 3000; t.routes = [400 entries] | · | current=null A: t fully built=yes |
| 3 | store current = &t (PLAIN) | · | current=&t |
| 4 | · | load current -> &t (non-null; no null check will help) | B.t=&t |
| 5 | · | read t.timeoutMs -> 0 | B.timeoutMs=0 ✕ B holds a valid pointer to a constructed object and reads an unwritten field. The request is issued with a zero timeout and fails instantly, three call frames away from anything to do with publication. |
| 6 | · | read t.routes -> empty | B.routes=0 ✕ Same cause, second symptom: a routing table with no routes. Every request 404s until the line happens to become visible. |
| 7 | [with RELEASE store and B doing an ACQUIRE load] store current = &t | · | B.timeoutMs=3000 B.routes=400 |
Every safe way to publish
The safe mechanisms are a short list, and every one of them works by creating a happens-before edge between construction and observation — they are applications of Happens-Before: The Edge That Makes a Write Visible rather than separate ideas. Knowing the list means you can always name which one you are using, and being unable to name one is the diagnosis.
Two entries deserve emphasis. Initialising before starting the thread is the cheapest correct answer and is used constantly without being recognised as synchronization: thread creation is an edge, so anything constructed before spawn is safely published to the child. And storing into a properly synchronized container — a concurrent queue, a channel, a map with internal synchronization — publishes safely because the container's own edge covers your object.
The Java entry is genuinely language-specific and genuinely useful: an object all of whose fields are final, and which does not leak this during construction, is safely published even through a data race, because the JMM gives final fields a freeze semantic at the end of the constructor. C++ has no equivalent guarantee, and assuming one is a real source of ported bugs.
| Mechanism | The edge it creates | Cost on the read path | Scope |
|---|---|---|---|
| Initialise before starting the reader thread | Thread start | None at all | Universal; the cheapest correct answer where it fits |
| Release store / acquire load of the reference | Release-acquire pair on the reference | One acquire load; free on x86-64 | C++, Java (volatile), Rust, C# |
| Construct and publish under a mutex readers also take | Unlock / lock of the same mutex | A lock acquisition per read | Universal; simplest to review |
| Hand the object through a concurrent queue or channel | The container's own send/receive edge | Whatever the container costs | Universal; hardest to get wrong |
| All-final immutable object (Java only) | Final-field freeze at end of construction | None | JAVA ONLY. C++ has no equivalent; do not assume it |
| Store into a synchronized container the reader reads from | The container's internal synchronization | The container's read cost | Universal, if the container documents it |
| postMessage to a worker (JavaScript) | Structured clone — no shared object exists | A copy | JS agents; sidesteps the problem rather than solving it |
| A plain store of the reference | None | None | BROKEN. This is the bug, not a mechanism |
The cheapest correct pattern
For read-mostly shared state — configuration, routing tables, feature flags, compiled rules — the pattern below is close to optimal and is worth knowing by shape. Build a new immutable object entirely off to the side, publish it with one release store, and let readers acquire-load the pointer once per request. Readers take no lock and touch a line that is read-shared between publications, which is the free case from What a Shared Write Costs.
Immutability is doing real work here beyond the edge. Because nothing mutates the object after publication, there is no second synchronization problem for readers holding it while a new version is published. The old version simply stays valid for whoever already has it, which is why this composes with Copy-on-Write as a Concurrency Strategy so naturally.
The lifetime question is the one thing this pattern does not solve for you in a language without a garbage collector: readers may hold the old table long after a new one is published, so freeing it requires knowing when the last reader is done. A reference-counted pointer handles it; in a GC language it is free. That is the same reclamation problem as A Lock-Free Stack, and What the Teaching Version Omits, in a much friendlier form.
1Table* current = nullptr; // plain pointer2 3void reload() {4 Table* t = new Table();5 t->version = 7;6 t->timeoutMs = 3000;7 t->routes = loadRoutes();8 current = t; // PLAIN store: publishes the pointer,9} // not the object10 11void handle(Request& r) {12 Table* t = current; // PLAIN load13 if (!t) return; // checks the one thing that was never wrong14 r.timeout = t->timeoutMs; // may read 015}1std::atomic<const Table*> current{nullptr};2 3void reload() {4 Table* t = new Table(); // built entirely off to the side5 t->version = 7;6 t->timeoutMs = 3000;7 t->routes = loadRoutes();8 // RELEASE: everything written above is visible to any acquiring reader.9 current.store(t, std::memory_order_release);10 // Lifetime: the previous table may still be held by in-flight readers.11 // Use shared_ptr, or a reclamation scheme, or leak deliberately.12}13 14void handle(Request& r) {15 const Table* t = current.load(std::memory_order_acquire);16 if (!t) return;17 r.timeout = t->timeoutMs; // guaranteed 300018}The only change is the ordering on the reference's store and load, and it covers every field of an arbitrarily large object. Note that the null check is identical in both versions and useless in the broken one — the pointer was always the part that arrived. Note also that the fix introduces a lifetime obligation the broken version did not have, which is the honest cost.
Key points
- Publishing a reference is not publishing an object. A reader can hold a valid, non-null pointer and read unwritten fields.
- A null check does not help — the reference is exactly the part that arrived. There is no defensive check on the reader side that fixes this.
- One release store paired with one acquire load publishes every field written before it, however many there are, none of them atomic.
- The safe mechanisms are a short list: initialise before starting the thread, release/acquire, a mutex both sides take, a concurrent container, or Java's all-final objects.
- Java's final-field guarantee is real and is Java's alone. C++ has no equivalent, and assuming otherwise is a common porting bug.
The loop, answered
Every field is required, which is why no lesson here can recommend concurrency without naming the interleaving that breaks it, the complexity it adds, and the simpler thing to consider first.
- • The writer constructs the object while it is unreachable from any other thread, so no synchronization is needed during construction.
- • The writer performs a publishing operation that creates a happens-before edge: a release store, an unlock, a queue send, a thread start.
- • The reader performs the matching operation — an acquire load, a lock, a queue receive — which is the other half of the edge.
- • By transitivity, every field written before the publish is ordered before every read after the observe, so the reader cannot see an unwritten field.
- • If the object is immutable after publication, no further synchronization is needed for its whole lifetime, which is what makes this pattern so cheap.
- • A builds the table, plain-stores the pointer; B plain-loads a non-null pointer and reads timeoutMs = 0. Valid pointer, invisible fields.
- • A builds the table, release-stores the pointer; B acquire-loads and reads every field correctly. One edge, four hundred entries.
- • A release-stores; C plain-loads. No edge, because an edge is a pair: C may still read unwritten fields while B, which acquires, sees everything.
- • A builds the table before spawning B; B reads it. Correct with no atomics at all, because thread creation is an edge.
- • A publishes table v2 while B still holds v1 from an earlier load. Both are valid; B finishes its request against v1. This is correct behaviour and the reason immutability matters — v1 must not be mutated or freed underneath B.
- • A constructs an object that registers itself in a global registry from inside its own constructor: it is reachable before it is finished, and no publishing mechanism can help, because the object escaped before publication.
- • Promises: with a paired publish and observe, everything written before publication is visible after observation.
- • Promises: the payload fields need no synchronization of their own — one edge covers all of them.
- • Promises: an immutable object, once safely published, needs no further synchronization for readers, ever.
- • Does NOT promise: anything if the object escapes during construction. Registering
thisin a shared structure from a constructor defeats every mechanism on the list. - • Does NOT promise: anything about subsequent mutation. Safe publication publishes a snapshot; a mutable object needs synchronization for every later write too.
- • Does NOT promise: a lifetime answer. Readers may hold the old version indefinitely, and in a language without a collector, freeing it is your problem.
- • Does NOT promise: that a null check protects the reader. It checks the reference, which is the part that always arrives first.
- • The read path is an acquire load of a line that is read-shared between publications — no ownership transfer, no lock, effectively free.
- • Each publication invalidates that line in every reader's cache, so a very high publication rate turns a read-mostly line into a write-shared one. See What a Shared Write Costs.
- • The mutex-based variant serialises every read, which is why it is the wrong choice for a hot read path even though it is the easiest to review.
- • Reference counting on the published pointer reintroduces a shared atomic on the read path, which can cost more than the read itself; this is why some systems deliberately leak old versions or reclaim them in a background epoch.
- • Partially visible object — the flagship failure; a valid reference to fields that read as zero or empty.
- • One-sided publication — release on the writer, plain load on the reader, producing code that reads as synchronized and is not.
- • Escaped
this— an object registered somewhere shared from inside its own constructor, reachable before it is finished. No publication mechanism can rescue this. - • Assumed final-field semantics — porting Java's all-final guarantee to C++, where it does not exist.
- • Use-after-free on the old version — freeing the previous object while an in-flight reader still holds it.
- • Mutation after publication — treating a published object as still-editable, which needs a completely different synchronization argument.
- • Read-mostly shared state: configuration, routing tables, feature flags, compiled rules, loaded models. The read path is a single acquire load.
- • Any handoff of a constructed object between threads, which is most of what worker pools and pipelines do.
- • It makes immutability pay off concretely — the reason immutable objects are easy to share is precisely that publication is the only synchronization they ever need.
- • It is a clean review question: for every shared object, which mechanism publishes it? A name or a bug, with no third answer.
- • When the object is small and mutable, where a mutex around the whole thing is simpler than publishing snapshots.
- • When publication is frequent enough that rebuilding the object dominates, at which point in-place mutation under a lock may be cheaper.
- • When lifetime management of superseded versions becomes more complex than the synchronization it replaced.
- • When applied to data that is never shared across threads, adding an atomic and implying a sharing that does not exist.
- • A thread sanitizer reports the missing edge directly: the field writes and the field reads are conflicting accesses with no happens-before path. This is the tool that finds it.
- • Add a sentinel field written last before publication and asserted first after observation — a magic number or a non-zero version. If the sentinel reads wrong, publication is unsafe.
- • Test on 64-bit ARM. This bug is frequently invisible on x86-64 and immediate on weakly ordered hardware.
- • Republish under load rather than only at startup. Startup-only publication is edge-covered by thread creation and hides the problem entirely. See Stress Testing: A Test That Passed Once Proves Nothing.
- • Audit for escaped
this: any constructor that registers itself, starts a thread, or passes itself to a callback is publishing an unfinished object regardless of what the caller does.
- • The publication mechanism becomes part of the object's contract and must be documented, because readers cannot infer it from the type.
- • Superseded versions create a lifetime problem that did not exist when the object was mutated in place.
- • Immutability, which makes the pattern work, constrains the API — no setters, and every change rebuilds.
- • The correctness argument is invisible in the reader's code, which is a single load, so it needs a comment or it will be simplified away.
- • Construct everything before starting the reader threads, so thread creation is the edge. Free, correct, and sufficient for a large amount of real code.
- • A mutex both sides take. Easiest to review, correct, and appropriate whenever the read path is not hot. See Mutexes: What They Protect and What They Do Not.
- • Hand the object through a concurrent queue or channel, where the edge cannot be half-applied. See Channels and Message Passing.
- • Copy the data to the consumer instead of sharing it — structured clone to a worker, a message to a process. See Copy or Share? and Web Workers.
- • A language-provided lazy initialiser:
std::call_once, a function-local static,functools.cache, a module-level constant. See Double-Checked Locking: The Canonical Cautionary Tale.
Publish a value, then a flag — which edge makes it visible?
Writer Reader
data = 42; while (ready == 0) { }
ready = 1; use(data);| # | Writer | Reader | State |
|---|---|---|---|
| 1 | data ← 42 | · | data=42 ready=0 reader sees=— |
| 2 | ready ← 1 (plain store) | · | data=42 ready=1 reader sees=— |
| 3 | · | read ready → 1 | data=42 ready=1 reader sees=— |
| 4 | · | read data → 0 | data=42 ready=1 reader sees=0 ✕ the reader observed ready = 1 and data = 0 — it saw the flag that announces the write without seeing the write |
Immutability lab
| # | Writer | Reader | State |
|---|---|---|---|
| 1 | account.a -= 10 | · | a=40 b=50 a+b=90 |
| 2 | · | read account.a, account.b | a=40 b=50 a+b=90 ✕ the reader observed a total of 90 — a state no writer ever intended |
| 3 | account.b += 10 | · | a=40 b=60 a+b=100 |
| 4 | · | read account.a, account.b | a=40 b=60 a+b=100 |
Both threads read 0, and both wrote first
x = y = 0
Thread 1 Thread 2
x = 1; y = 1;
r1 = y; r2 = x;
Sequential reasoning: one of the two stores must land first,
so at least one load must see a 1. r1 == 0 && r2 == 0 is impossible.| # | Thread 1 | Thread 2 | State |
|---|---|---|---|
| 1 | x ← 1 | · | x=1 y=0 r1=0 r2=0 |
| 2 | r1 ← y | · | x=1 y=0 r1=0 r2=0 |
| 3 | · | y ← 1 | x=1 y=1 r1=0 r2=0 |
| 4 | · | r2 ← x | x=1 y=1 r1=0 r2=1 |
What people believe, and what is true
The reference is not null, so the object is ready.
The reference is exactly the part that arrives first. Non-null tells you nothing about whether the fields are visible.
The constructor finished, so the object is fully built.
It is fully built in the writer's thread. Whether another thread can see all of it is a separate question answered only by a publishing edge.
Making every field atomic would fix it.
It would, expensively and clumsily, and it is the wrong shape. One ordered store of the reference publishes all the fields at once.
Go deeper
Overview
Handing another thread a pointer does not hand it the object. You need a synchronization construct on both sides, or the reader may see a valid pointer to blank fields.
Practical
Build the object privately, publish with one release store (or under a mutex, or through a queue), and have every reader use the matching half. Never publish with a plain store, and never trust a null check to protect you.
Advanced
Immutable object plus atomic pointer swap is the standard read-mostly pattern: lock-free reads, one release store per publication, and the only remaining problem is when to free the superseded version.
Internals
Java's final-field semantics insert a freeze at the end of the constructor, which is why an all-final object is safely published even through a race. C++ has no freeze, so the ordering must be supplied explicitly at the publication point — the same guarantee bought at a different layer.