sun.misc.Unsafe and VarHandle (Java 9+) Mechanism
sun.misc.Unsafe and VarHandle (Java 9+)
The low-level primitives that power java.util.concurrent. Every CAS, volatile access, and memory barrier in AQS, StampedLock, ConcurrentHashMap, and Atomic* classes ultimately calls through one of these.
Document Structure:
- Overview — What Unsafe is, why it exists, why VarHandle replaced it
- CAS Operations — compareAndSwap internals, CPU instructions, ABA problem
- Volatile Access — getObjectVolatile, putObjectVolatile, why ConcurrentHashMap uses them
- Memory Barriers / Fences — loadFence, storeFence, fullFence, acquireFence
- Object and Field Manipulation — objectFieldOffset, allocateInstance
- Park / Unpark — LockSupport’s foundation, how AQS suspends threads
- VarHandle (Java 9+) — The safe replacement, access modes, acquire/release deep dive, migration from Unsafe
- Who Uses What — Which JUC classes use which primitives
- Connection to Other Docs
Overview
sun.misc.Unsafe is an internal JDK class that provides direct access to memory, CPU instructions, and thread primitives that Java normally hides. It’s “unsafe” because it bypasses all safety checks — wrong usage causes JVM crashes, not exceptions.
// You can't just new Unsafe() — it's restricted
private static final Unsafe UNSAFE;
static {
try {
Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
UNSAFE = (Unsafe) f.get(null);
} catch (Exception e) { throw new Error(e); }
}
Why it exists: Java’s synchronized and volatile are too coarse for building high-performance concurrent data structures. Unsafe provides the fine-grained primitives (CAS, volatile array access, park/unpark) that java.util.concurrent needs.
Why VarHandle replaced it (Java 9+): Unsafe is an internal API — no stability guarantees, no access control, can crash the JVM. VarHandle provides the same capabilities through a safe, public API with compile-time type checking.
Java 8 and earlier: Java 9+:
AQS → Unsafe.compareAndSwapInt() AQS → VarHandle.compareAndSet()
CHM → Unsafe.getObjectVolatile() CHM → VarHandle.getVolatile()
LockSupport → Unsafe.park/unpark LockSupport → Unsafe.park/unpark (unchanged)
Note:
LockSupport.park/unparkstill usesUnsafeeven in Java 17+ — there’s no VarHandle equivalent for thread suspension.
CAS Operations
CAS (Compare-And-Swap) is the foundation of lock-free programming. It atomically reads a value, compares it to an expected value, and writes a new value only if the comparison succeeds.
Unsafe CAS Methods
// Compare-and-swap for different types
boolean compareAndSwapInt(Object obj, long offset, int expected, int update);
boolean compareAndSwapLong(Object obj, long offset, long expected, long update);
boolean compareAndSwapObject(Object obj, long offset, Object expected, Object update);
Parameters:
obj— the object containing the fieldoffset— byte offset of the field within the object (obtained viaobjectFieldOffset)expected— the value we expect the field to haveupdate— the new value to write if the field matchesexpected
Returns: true if the swap succeeded, false if the field’s current value ≠ expected.
How AQS Uses CAS
// AQS.compareAndSetState — the core of every lock
private static final long STATE_OFFSET;
static {
STATE_OFFSET = UNSAFE.objectFieldOffset(
AbstractQueuedSynchronizer.class.getDeclaredField("state"));
}
protected final boolean compareAndSetState(int expect, int update) {
return UNSAFE.compareAndSwapInt(this, STATE_OFFSET, expect, update);
}
Every lock(), unlock(), acquire(), release() in AQS-based synchronizers goes through this single CAS on the state field.
CPU-Level Implementation
On x86, CAS compiles to the LOCK CMPXCHG instruction:
LOCK CMPXCHG [memory], new_value
1. Acquire exclusive cache line ownership (LOCK prefix)
2. Compare EAX (expected) with [memory]
3. If equal: [memory] = new_value, set ZF=1 (success)
4. If not equal: EAX = [memory], set ZF=0 (failure)
5. Release cache line
The LOCK prefix ensures atomicity by locking the cache line (not the entire bus on modern CPUs). This is the same mechanism that causes cache line bouncing in ReentrantReadWriteLock — see read-write-lock.md.
CPU Cache Hierarchy
Caches belong to cores, not threads. A thread runs on a core and uses that core’s caches:
┌─────────────────────────────────────────────────────────────┐
│ CPU Socket (1 physical chip) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ Core 0 │ │ Core 1 │ │ Core 2 │ │ Core 3 │ │
│ │ [L1 32K] │ │ [L1 32K] │ │ [L1 32K] │ │ [L1 32K] │ │ ← private per core
│ │ [L2 256K]│ │ [L2 256K]│ │ [L2 256K]│ │ [L2 256K]│ │ ← private per core
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
│ └──────────────┴──────────────┴──────────────┘ │
│ ┌───────────────────┐ │
│ │ L3 Cache (8-32MB)│ │ ← SHARED all cores
│ └─────────┬─────────┘ │
└──────────────────────────────┼───────────────────────────────┘
┌──────────┴──────────┐
│ Main Memory (DRAM)│ ← SHARED all sockets
└─────────────────────┘
| Level | Private/Shared | Size | Latency |
|---|---|---|---|
| L1 | Private per core | 32-64 KB | ~1-4 ns |
| L2 | Private per core | 256 KB - 1 MB | ~5-12 ns |
| L3 | Shared across all cores in one socket | 8-32 MB | ~20-40 ns |
| Cross-core transfer (same socket) | — | — | ~30-50 ns |
| Cross-socket (NUMA) | — | — | ~100-300 ns |
| Main memory | Shared across all sockets | 16-256 GB | ~60-100 ns |
Socket = one physical CPU chip on the motherboard. Multi-socket servers (2-4 chips) have higher cross-socket latency (NUMA). Most EC2 instances are single-socket.
Hyperthreading (SMT): Two hardware threads share the same core’s L1/L2 — no cache bouncing between them. Bouncing only happens between different physical cores.
MESI Protocol — Cache Coherence
MESI ensures all cores see a consistent view of memory. Each cache line is in one of four states:
| State | Meaning | Can read? | Can write? |
|---|---|---|---|
| M (Modified) | Only I have it, it’s dirty (differs from memory) | Yes | Yes |
| E (Exclusive) | Only I have it, it’s clean (same as memory) | Yes | Yes (→ M) |
| S (Shared) | Multiple cores have it, all clean | Yes | No (must invalidate first) |
| I (Invalid) | My copy is stale / I don’t have it | No | No |
CAS and MESI — The Full Sequence
When Core 0 CAS-es a field and Core 1 tries to CAS the same field:
Core 0 executes CAS (LOCK CMPXCHG):
1. Sends "Request for Ownership" (RFO) on interconnect
2. All other cores snoop → transition their copy to I (Invalid)
3. Core 0 transitions line to M (Modified) — exclusive ownership
4. CAS executes locally in L1 (compare + write)
→ write stays in L1 only (write-back policy, NOT flushed to memory)
Core 1 tries CAS:
1. Cache lookup → line is I (Invalid) → CACHE MISS
2. Sends "Read with Intent to Modify" (RWITM) on interconnect
3. Core 0 snoops → has line in M → must respond
4. Core 0 transfers dirty line directly to Core 1 (~30-50 ns)
→ Core 0 transitions to I (gives up ownership)
5. Core 1 receives line → transitions to M (now owns it)
6. Core 1 executes CAS locally in L1
→ reads value (may differ from expected → CAS fails → retry)
Key insight: The write happens in L1 only. It does NOT propagate to L2, L3, or main memory immediately. This is write-back caching:
After Core 0's CAS:
Core 0 L1: state = 0x101 (M — dirty, latest value)
Core 0 L2: state = 0x100 (stale)
L3: state = 0x100 (stale)
Main memory: state = 0x100 (stale)
The dirty line is written back only when: (1) another core requests it (snoop), (2) it’s evicted from L1 (capacity), or (3) explicit flush. MESI guarantees any requesting core gets the latest value via direct core-to-core transfer — main memory is the fallback, not the primary communication channel.
Common misconception:
WRONG: volatile write → flush to main memory → other cores read from memory
RIGHT: volatile write → write to L1 (M state) → other cores snoop → get from L1
CAS Memory Effects
CAS has the memory effects of both a volatile read and a volatile write:
- On success: acts as a full memory barrier (acquire + release)
- On failure: acts as a volatile read (acquire semantics — you see the current value)
This is why lock() using CAS provides happens-before: the successful CAS on state flushes all prior writes (release) and makes all subsequent reads see the latest values (acquire).
The ABA Problem
CAS checks “is the value still X?” but can’t detect if the value changed from X → Y → X:
Thread-A: reads state = 5
Thread-B: changes state 5 → 10 → 5
Thread-A: CAS(state, 5, 6) → SUCCESS (state was 5, now 6)
But Thread-A missed the intermediate change!
For AQS state (a simple counter), ABA is harmless — the count is the same regardless of intermediate changes. For pointer-based structures (lock-free stacks/queues), ABA can cause corruption. Solutions:
AtomicStampedReference— pairs the reference with a version stampAtomicMarkableReference— pairs the reference with a boolean mark
VarHandle CAS (Java 9+)
// Java 9+ equivalent
private static final VarHandle STATE;
static {
STATE = MethodHandles.lookup().findVarHandle(
AbstractQueuedSynchronizer.class, "state", int.class);
}
protected final boolean compareAndSetState(int expect, int update) {
return STATE.compareAndSet(this, expect, update);
}
Same semantics, same CPU instruction, but type-safe and part of the public API.
Volatile Access
Unsafe provides volatile read/write for array elements and arbitrary field offsets — something Java’s volatile keyword can’t do for arrays.
Why Arrays Need Unsafe
Java’s volatile applies to the reference, not the elements:
volatile Node[] table; // volatile reference — sees latest array object
table[i] = node; // NOT volatile — other threads may not see this!
ConcurrentHashMap needs volatile access to individual array slots:
// ConcurrentHashMap — volatile read of table[i]
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node<K,V>) U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}
// ConcurrentHashMap — volatile write to table[i]
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
U.putObjectVolatile(tab, ((long)i << ASHIFT) + ABASE, v);
}
// ConcurrentHashMap — CAS on table[i]
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i, Node<K,V> c, Node<K,V> v) {
return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}
ABASE is the base offset of the array data, ASHIFT is the log2 of the element size. Together they compute the byte offset of table[i].
VarHandle Array Access (Java 9+)
private static final VarHandle ATABLE = MethodHandles.arrayElementVarHandle(Node[].class);
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
return (Node<K,V>) ATABLE.getVolatile(tab, i); // no manual offset math
}
Memory Barriers / Fences
Memory barriers (fences) prevent CPU and compiler reordering of reads and writes across the barrier.
The Four Barrier Types
| Barrier | Prevents | x86 cost | ARM cost |
|---|---|---|---|
| LoadLoad | read₁ reordered after read₂ | Free (x86 TSO guarantees) | DMB ISHLD |
| LoadStore | read₁ reordered after write₂ | Free (x86 TSO guarantees) | DMB ISH |
| StoreStore | write₁ reordered after write₂ | Free (x86 TSO guarantees) | DMB ISHST |
| StoreLoad | write₁ reordered after read₂ | Expensive (~20-40 ns) | DMB ISH |
StoreLoad is the only expensive barrier on x86. x86’s Total Store Order (TSO) already guarantees the other three. But x86 allows a store to sit in the store buffer while a subsequent load executes from cache — StoreLoad forces the store buffer to drain first.
Without StoreLoad:
Thread-A: x = 1; // store → goes to store buffer
r1 = y; // load → executes immediately from cache
// other cores may not see x=1 yet!
With StoreLoad:
Thread-A: x = 1; // store → store buffer
[StoreLoad] // drain store buffer → x=1 visible to all cores
r1 = y; // load → now guaranteed to see any store to y
// that happened-before x=1 became visible
On x86: StoreLoad compiles to MFENCE or LOCK ADD [rsp], 0 (a no-op locked instruction that triggers the barrier as a side effect).
On ARM/AARCH64: All four barrier types require actual DMB (Data Memory Barrier) instructions. ARM has a weaker memory model — nothing is free.
Where Barriers Are Used in Java
| Java construct | Barriers inserted | Why |
|---|---|---|
volatile write |
StoreStore before + StoreLoad after | Ensure write visible before any subsequent read |
volatile read |
LoadLoad + LoadStore after | Ensure read completes before any subsequent access |
CAS (LOCK CMPXCHG) |
Full barrier (all four) | Implicit in the LOCK prefix |
synchronized enter |
LoadLoad + LoadStore (acquire) | See latest writes from previous unlock |
synchronized exit |
StoreStore + StoreLoad (release) | Flush all writes before releasing |
VarHandle.acquireFence() |
LoadLoad + LoadStore | Used by StampedLock.validate() |
VarHandle.releaseFence() |
LoadStore + StoreStore | Used for lazy-set semantics |
VarHandle.fullFence() |
All four | Equivalent to Unsafe.fullFence() |
Why volatile Write Is Expensive
A volatile write needs StoreLoad to ensure the write is globally visible (drained from the store buffer to L1 cache, where MESI makes it visible to other cores) before the writing thread proceeds to any subsequent reads. This is critical for cross-thread communication:
Thread-A: Thread-B:
data = 42; // normal store
flag = true; // volatile write
[StoreLoad] // drain store buffer → flag=true in L1 → visible via MESI
if (flag) { // volatile read
use(data); // sees 42 ✓
}
Without StoreLoad, flag = true could sit in Thread-A’s store buffer (invisible to Thread-B) while Thread-A continues executing. Thread-B’s volatile read of flag would still see false.
Store Buffer vs L1 Cache
┌─────────────────────────────────────────────┐
│ CPU Core │
│ │
│ ┌──────────┐ ┌──────────────────┐ │
│ │ Execution│────►│ Store Buffer │ │
│ │ Unit │ │ (write queue) │ │
│ └──────────┘ │ [x=42, pending] │ │
│ │ │ [y=10, pending] │ │
│ │ └────────┬─────────┘ │
│ │ │ drain │
│ ▼ ▼ │
│ ┌─────────────────────────────────────┐ │
│ │ L1 Cache │ │
│ │ (visible to other cores via MESI) │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────┘
| Store Buffer | L1 Cache | |
|---|---|---|
| Visible to other cores? | No (private to this core) | Yes (via MESI snooping) |
| Purpose | Hide write latency (don’t stall CPU waiting for cache ownership) | Fast data access + coherence |
| Size | ~32-56 entries | 32-64 KB |
| Same-thread reads see it? | Yes (store forwarding) | Yes |
| Other-thread reads see it? | No | Yes |
Store buffer forwarding: When the same core reads an address in its store buffer, the CPU returns the buffered value directly. So the writing thread always sees its own writes immediately — even before they reach L1. Other cores can’t see them until they drain.
StoreLoad = drain the store buffer:
Without StoreLoad:
store x=42 → store buffer (invisible to others)
load y → executes immediately (doesn't wait for x to drain)
With StoreLoad (MFENCE):
store x=42 → store buffer
MFENCE → STALL until store buffer empty (x=42 reaches L1)
load y → now x=42 is in L1, visible to all cores
The ~20-40 ns cost of StoreLoad is the pipeline stall while waiting for the store buffer to drain. That’s the price of global visibility.
Store Buffer vs Stack — Common Confusion
These are unrelated concepts at different levels:
| Store Buffer | Stack | |
|---|---|---|
| What | Hardware write queue inside CPU | Memory region in DRAM per thread |
| Level | CPU microarchitecture | Software/OS/JVM concept |
| Contains | Pending writes to ANY address | Local variables, method frames, return addresses |
| Location | On the CPU chip (between ALU and L1) | In DRAM (cached in L1/L2 like everything else) |
The store buffer handles writes to both stack and heap addresses — the CPU doesn’t distinguish:
int x = 42; // stack write → goes through store buffer → L1
this.field = 42; // heap write → goes through store buffer → L1
// Same hardware path. CPU sees memory addresses, not "stack" vs "heap".
Why stack variables don’t have visibility issues: Not because they bypass the store buffer, but because they’re private to one thread. No other thread can read them, so it doesn’t matter if the write is buffered. The store buffer only causes problems for shared memory (heap objects accessed by multiple threads).
A volatile read is cheap on x86 (just a normal load — LoadLoad and LoadStore are free). The asymmetry: volatile writes are expensive, volatile reads are cheap.
Object and Field Manipulation
objectFieldOffset
Returns the byte offset of a field within an object. Required for CAS and volatile access:
private static final long STATE_OFFSET = UNSAFE.objectFieldOffset(
AbstractQueuedSynchronizer.class.getDeclaredField("state"));
// Now CAS can target this specific field:
UNSAFE.compareAndSwapInt(this, STATE_OFFSET, expected, update);
The offset is a JVM-internal value — it depends on object layout, field ordering, and alignment. It’s computed once at class initialization and cached in a static final field.
allocateInstance
Creates an object without calling its constructor:
Object obj = UNSAFE.allocateInstance(MyClass.class);
// obj exists but constructor was NOT called — fields have default values (0, null, false)
Used by serialization frameworks (Kryo, Objenesis) and some testing libraries. Not used by java.util.concurrent.
putOrderedInt / putOrderedLong (Lazy Set)
A weaker form of volatile write — guarantees the write will eventually be visible, but doesn’t provide a full StoreLoad barrier:
UNSAFE.putOrderedInt(this, OFFSET, value); // "lazy set" — no StoreLoad barrier
Equivalent to VarHandle.setRelease() in Java 9+. Used in some JUC internals where immediate visibility isn’t required (e.g., clearing references for GC).
Park / Unpark
LockSupport.park() and LockSupport.unpark() are the thread suspension primitives used by AQS. They delegate directly to Unsafe:
// LockSupport source
public static void park(Object blocker) {
Thread t = Thread.currentThread();
setBlocker(t, blocker); // for diagnostic tools (jstack)
UNSAFE.park(false, 0L); // suspend thread
setBlocker(t, null); // clear blocker after wake
}
public static void unpark(Thread thread) {
if (thread != null)
UNSAFE.unpark(thread); // resume thread
}
Park Semantics
Unsafe.park(boolean isAbsolute, long time):
park(false, 0)— park indefinitely (until unpark/interrupt/spurious)park(false, nanos)— park for at mostnanosnanosecondspark(true, deadline)— park until absolute timedeadline(milliseconds since epoch)
Returns when any of:
unpark(thread)is called (by another thread or beforepark)- The thread is interrupted (interrupt flag stays set — park doesn’t clear it)
- Spurious wakeup (rare, OS-level)
- Timeout expires (for timed variants)
Permit Model
Park/unpark uses a binary permit (0 or 1, not a counter):
Initial: permit = 0
unpark(thread): permit = 1 (or stays 1 if already 1)
park(): if permit == 1 → permit = 0, return immediately (consume permit)
if permit == 0 → suspend thread
// Unpark before park — no suspension:
unpark(T); // permit = 1
park(); // permit was 1 → consume → return immediately (no suspend)
// Park before unpark — suspends:
park(); // permit = 0 → suspend
unpark(T); // permit = 1 → wake T → T consumes permit → returns
This is why unpark can be called before park without losing the signal — the permit is “saved.” But permits don’t accumulate: two unpark() calls followed by two park() calls will suspend on the second park().
unpark(T); // permit = 1
unpark(T); // permit still = 1 (not 2!)
park(); // permit = 1 → consume → return
park(); // permit = 0 → SUSPEND
Park vs Object.wait()
LockSupport.park() |
Object.wait() |
|
|---|---|---|
| Requires lock? | No | Yes (must hold monitor) |
| Permit model | Binary (0/1) | No permit — must be inside synchronized |
| Can unpark before park? | Yes (permit saved) | No (notify before wait is lost) |
| Interrupt behavior | Returns, flag stays set | Throws InterruptedException, flag cleared |
| Used by | AQS, StampedLock, ForkJoinPool | Legacy synchronized + wait/notify |
OS-Level Implementation
On Linux, park() maps to pthread_cond_wait() or futex(). On macOS, it uses pthread_cond_timedwait(). The JVM maintains a per-thread Parker object (C++ level) with a mutex + condition variable.
VarHandle (Java 9+)
VarHandle is the public, type-safe replacement for Unsafe field/array access. It provides multiple access modes with different memory ordering guarantees.
Access Modes
VarHandle vh = MethodHandles.lookup().findVarHandle(MyClass.class, "field", int.class);
// Plain — no ordering guarantees (like a normal field read/write)
int val = (int) vh.get(obj);
vh.set(obj, 42);
// Opaque — guarantees atomicity but no ordering
int val = (int) vh.getOpaque(obj);
vh.setOpaque(obj, 42);
// Acquire/Release — one-directional barriers
int val = (int) vh.getAcquire(obj); // reads after this see latest writes
vh.setRelease(obj, 42); // writes before this are visible to acquirers
// Volatile — full barrier (equivalent to volatile keyword)
int val = (int) vh.getVolatile(obj);
vh.setVolatile(obj, 42);
// CAS — atomic compare-and-set
boolean success = vh.compareAndSet(obj, expected, update);
// Get-and-set — atomic swap
int old = (int) vh.getAndSet(obj, newValue);
// Get-and-add — atomic increment
int old = (int) vh.getAndAdd(obj, delta);
Access Mode Ordering
From weakest to strongest:
Plain → no guarantees (compiler/CPU can reorder freely)
Opaque → atomic, but no ordering (other threads see updates eventually)
Acquire → reads after this see all writes before the matching Release
Release → writes before this are visible to threads doing Acquire
Volatile → full barrier (Acquire + Release + sequential consistency)
Acquire/Release Deep Dive
Acquire/Release is a one-directional barrier pair — cheaper than volatile because each side only prevents reordering in one direction.
What They Guarantee
getAcquire — prevents reads/writes after it from being reordered before it:
┌─── ACQUIRE FENCE ───┐
│ │
can move up ↑ │ getAcquire(obj) │ NOTHING moves above this
│ │
└─────────────────────┘
code below STAYS below
setRelease — prevents reads/writes before it from being reordered after it:
code above STAYS above
┌─── RELEASE FENCE ───┐
│ │
│ setRelease(obj,42) │ NOTHING moves below this
│ │
can move down ↓ └─────────────────────┘
Concrete Example — Publishing an Object
// Thread-A (producer)
data = new ExpensiveObject(); // (1) normal write
data.field1 = "hello"; // (2) normal write
data.field2 = 42; // (3) normal write
READY.setRelease(this, true); // (4) release write
// ↑ guarantees (1)(2)(3) cannot be reordered after (4)
// so when another thread sees (4), it also sees (1)(2)(3)
// Thread-B (consumer)
if ((boolean) READY.getAcquire(this)) { // (5) acquire read
// ↓ guarantees (6)(7) cannot be reordered before (5)
// so we see all writes that happened-before the release
use(data.field1); // (6) sees "hello" ✓
use(data.field2); // (7) sees 42 ✓
}
Why This Works — The Pairing
Acquire and Release form a happens-before edge when paired on the same variable:
Thread-A: Thread-B:
writes to data ──┐
│
setRelease(flag) ─┼──── happens-before ────→ getAcquire(flag)
│ │
└── all writes before release ───────┘── visible after acquire
The critical condition: Thread-B’s getAcquire must see the value written by Thread-A’s setRelease. If it reads a stale value (e.g., false), no happens-before is established and the data writes are NOT guaranteed visible.
Barriers Inserted Per Architecture
| Operation | Barriers | x86 | ARM/AArch64 |
|---|---|---|---|
getAcquire |
LoadLoad + LoadStore | Free (TSO guarantees both) | LDAR (load-acquire) |
setRelease |
LoadStore + StoreStore | Free (TSO guarantees both) | STLR (store-release) |
getVolatile |
LoadLoad + LoadStore | Free | LDAR |
setVolatile |
StoreStore + StoreLoad | Expensive (MFENCE) |
STLR + DMB ISH |
On x86, acquire/release compile to plain loads/stores — the hardware already provides those ordering guarantees via TSO. The only thing x86 doesn’t give for free is StoreLoad (which setVolatile needs). This is why acquire/release is strictly cheaper than volatile.
On ARM (Graviton), acquire/release use dedicated LDAR/STLR instructions which are cheaper than full DMB barriers but not free.
Volatile vs Acquire/Release — The Key Difference
Volatile = Acquire + Release + StoreLoad (sequential consistency).
The difference shows up in what happens to code after a write:
// With volatile (setVolatile):
x = 42; // (1)
vh.setVolatile(this, 1); // (2) volatile write + StoreLoad
r1 = y; // (3) CANNOT move before (2) — StoreLoad prevents it
// With release (setRelease):
x = 42; // (1)
vh.setRelease(this, 1); // (2) release write — no StoreLoad
r1 = y; // (3) CAN move before (2) — release doesn't block this!
Release only prevents things above from sinking below. It does NOT prevent things below from floating up. Volatile prevents both directions.
When does this matter? Only when you have multiple shared variables and need sequential consistency across them (e.g., Dekker’s algorithm, Peterson’s lock). For single-producer/single-consumer patterns, acquire/release is sufficient and cheaper.
When to Use Each
| Pattern | Use | Why |
|---|---|---|
| Producer publishes data, consumer reads it | Acquire/Release | One-directional visibility is enough |
| Flag-based communication (one writer, one reader) | Acquire/Release | Classic release-acquire pairing |
| Multiple threads read AND write same variable | Volatile | Need sequential consistency |
| Dekker/Peterson mutual exclusion | Volatile | StoreLoad required for correctness |
| Counter visible to multiple readers | Volatile | Readers need to see latest value immediately |
| Lazy initialization (publish once, read many) | Release (write) + Acquire (read) | Object publication pattern |
Real-World Usage in JDK
StampedLock.validate():
public boolean validate(long stamp) {
VarHandle.acquireFence(); // acquire fence (not tied to a specific variable)
return (stamp & SBITS) == (state & SBITS);
}
The acquire fence ensures reads done in the optimistic-read section (before validate()) are not reordered past the validation check. Without it, the CPU could speculatively execute reads using stale data while the stamp comparison succeeds.
AtomicReference.lazySet() / VarHandle.setRelease():
// Used when nulling out references for GC — don't need immediate visibility
ref.setRelease(null); // eventually visible, no StoreLoad cost
Cost Summary
setVolatile = setRelease + StoreLoad (x86: ~20-40 ns for the MFENCE)
getVolatile = getAcquire (x86: free — same instruction)
setRelease = release fence only (x86: free — plain store)
getAcquire = acquire fence only (x86: free — plain load)
The asymmetry: reads are always cheap on x86 regardless of mode. The cost difference is entirely on the write side — and only when you need the full StoreLoad guarantee of volatile.
Migration from Unsafe to VarHandle
| Unsafe | VarHandle | Notes |
|---|---|---|
compareAndSwapInt(obj, offset, e, u) |
vh.compareAndSet(obj, e, u) |
Type-safe, no offset math |
getObjectVolatile(arr, offset) |
arrayVH.getVolatile(arr, i) |
Index-based, no offset math |
putObjectVolatile(arr, offset, v) |
arrayVH.setVolatile(arr, i, v) |
Same |
putOrderedInt(obj, offset, v) |
vh.setRelease(obj, v) |
Lazy set = release semantics |
getIntVolatile(obj, offset) |
vh.getVolatile(obj) |
Same |
objectFieldOffset(field) |
Not needed | VarHandle resolves fields by name |
Why VarHandle Is Better
- Type safety — compile-time checking of field types and access patterns
- No offset math — fields resolved by name, arrays by index
- Public API — stable, documented, won’t break across JDK versions
- More access modes — Opaque, Acquire/Release not available in Unsafe
- No JVM crash risk — wrong usage throws exceptions, not segfaults
Who Uses What
| JUC Class | CAS | Volatile Access | Park/Unpark | Fences |
|---|---|---|---|---|
| AQS (ReentrantLock, Semaphore, etc.) | state, head, tail, waitStatus |
state (volatile field) |
Yes (acquireQueued) | — |
| ReentrantReadWriteLock | Via AQS | Via AQS | Via AQS | — |
| StampedLock | state (long), whead, wtail |
state (volatile long) |
Yes (acquireWrite/Read) | VarHandle.acquireFence() in validate() |
| ConcurrentHashMap | table[i] (casTabAt), sizeCtl, baseCount |
table[i] (tabAt/setTabAt) |
— | — |
| AtomicInteger/Long | value |
value (volatile) |
— | — |
| AtomicReference | value |
value (volatile) |
— | — |
| LongAdder/Striped64 | base, cells[i] |
base, cells (volatile) |
— | — |
| LockSupport | — | — | park/unpark |
— |
| Thread (internal) | threadStatus |
interrupted |
— | — |
更多推荐



所有评论(0)