Why can a forced reflective write to a field declared write-once be observed as two different values by different readers?
answer
- the declaration is a promise
- optimisers spend promises
- folded readers never load again
- publication covers the construction value only
- unspecified after construction, not merely stale
basics
~20 sA field the language declares assignable only during construction is a promise the compiler and runtime are allowed to spend: they may fold or cache its construction-time value into readers. A later forced write lands in the field, but readers holding the folded value never look again.
solid answer
~40 sDeclaring a field write-once tells every layer below you that the value cannot change after construction. Optimisers spend that promise: a value known at compile time may be substituted directly into each reader, and a value known only at run time may be loaded once and kept, since reloading it could not produce anything new. A forced reflective write updates the storage but cannot reach readers that no longer read it, so the same program can observe the old value at one site and the new value at another. The publication guarantee for such a field is also specified only for the value set during construction — a later write carries no ordering promise to other threads. Some runtimes therefore refuse the write outright; others accept it and leave the result unspecified.
go deeper
Recall that declaring a value unchangeable after construction is a promise other layers rely on, not just a hint to readers of the source. Changing it later is outside what the language describes.
Explain the mechanism both ways: a value known at compile time can be substituted into readers, and a value known only at run time can be loaded once because a reload was promised to be pointless.
Recognise the symptom in the field — two sites in one process reporting different values for one field — and name folding, hoisting and the construction-only publication guarantee as the three contributing causes.
Decide the standard: production code never depends on unspecified behaviour, and a type whose tests need a post-construction write to an immutable field is asking for a construction path it does not publish.
A field declared assignable only during construction is not merely documentation. It is a **promise to the implementation**, and the implementation spends it. Forcing a value into such a field afterwards breaks the promise without telling anyone, and the symptom is the one that makes engineers distrust their own eyes: two reads of one field in one process disagreeing. ## What the declaration promises The promise has two halves, and both are load-bearing: - **Immutability after construction.** Once the constructing code finishes, the field's value is the value it will always have. - **Safe publication of that value.** Where a runtime specifies a memory model, the value assigned during construction is guaranteed visible to any thread that obtains the object reference normally — without the reader needing its own synchronisation. Note which value that second guarantee covers: **the one set during construction**. A write performed later is outside what the model describes, so no ordering or visibility promise attaches to it at all. ## The three ways a later write becomes unobservable 1. **Constant substitution.** If the value was known when the reading code was compiled, the compiler may place the value itself into each reader instead of a load. Those readers never touch the field again, so no run-time write can ever reach them. This is the strongest form: even a reader compiled in the same run is unaffected. 2. **Load hoisting and caching.** If the value is known only at run time, an optimiser may still load it once — out of a loop, into a register, into an inlined copy — because the declaration says a reload cannot differ. The stale copy survives as long as that code does. 3. **No publication guarantee.** Even where nothing was folded, another thread reading the field is not promised to see the later write, because the model's guarantee was about the construction-time value. The combination is why the observation is *inconsistent* rather than simply *stale*: some sites were folded, some were hoisted, some reload every time, and each sees whichever value its own path produced. ## Ordinary field versus write-once field under a forced write | | Ordinary hidden field | Field assignable only during construction | |---|---|---| | Does the write land in storage? | yes | usually yes, where the runtime permits it at all | | Can readers have folded the value? | no | yes, if it was known at compile time | | Can a reader hold a hoisted copy? | only where normal rules allow | yes, licensed by the declaration | | Visibility to other threads | governed by the usual rules for that field | unspecified for a post-construction write | | Typical runtime stance | accepted | accepted with no promise, or refused outright | ## Why some runtimes simply refuse Refusing is not conservatism for its own sake. If a runtime accepted forced writes and still wanted its optimisations to be sound, it would have to stop folding and hoisting these fields everywhere — paying, in all code, for a capability used in a handful of fixtures. Refusal keeps the promise cheap and honest. This is the one refusal in this area that **no opening can lift**: it is a language guarantee rather than a permission, so no descriptor entry and no launch-time flag changes it. ## What to do instead - **Set it during construction.** Build the object through a path that assigns the field while constructing, even if that path exists only for tests within the declaring unit. - **Rebuild rather than mutate.** Construct a fresh instance carrying the state you need; for a value-like type this is usually shorter than the reflective code it replaces. - **If you must force it, force it before publication.** A write performed before any other code can reach the reference dodges the cross-thread half of the problem — but not the folding half, which nothing at run time can undo. - **Never rely on it in production code.** A behaviour that is unspecified will differ between runtimes, between optimisation levels, and between a cold run and a warmed-up one. The sentence to leave the interview with: a write-once declaration is a promise readers are allowed to spend, and forcing a write afterwards does not buy the promise back.
- Does forcing the value in before the object is published behave any differently?Partly. Writing before any other code can reach the reference removes the cross-thread visibility problem, since no reader existed to read the old value. It does nothing about substitution: a reader compiled against a value known at compile time never loads the field at all, so it still reports the original.
- Why do some runtimes refuse a forced write to such a field rather than allowing it quietly?Because accepting it would make the optimisations that the declaration licenses unsound everywhere. Keeping folding and hoisting available for all code is worth more than supporting a write used by a few fixtures, so the runtime protects the guarantee instead of the capability.
- What distinguishes this refusal from a packaging boundary refusing a suppression request?A boundary refusal is a permission the declaring unit's owner or the deployer can grant. A write-once refusal is the language's own guarantee about the value, so no descriptor entry and no launch-time instruction lifts it — the only legitimate assignment window is construction.
saying these in an interview costs you the question
- Assumes a successful forced write is therefore visible everywhere
- Thinks the field's storage is somehow read-only after construction
- Believes the publication guarantee extends to writes made after construction
- Expects adding synchronisation in readers to rescue a post-construction write
- Treats a runtime that accepts the write as one that specifies its outcome