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

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/unpark still uses Unsafe even 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 field
  • offset — byte offset of the field within the object (obtained via objectFieldOffset)
  • expected — the value we expect the field to have
  • update — the new value to write if the field matches expected

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 stamp
  • AtomicMarkableReference — 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 most nanos nanoseconds
  • park(true, deadline) — park until absolute time deadline (milliseconds since epoch)

Returns when any of:

  1. unpark(thread) is called (by another thread or before park)
  2. The thread is interrupted (interrupt flag stays set — park doesn’t clear it)
  3. Spurious wakeup (rare, OS-level)
  4. 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

  1. Type safety — compile-time checking of field types and access patterns
  2. No offset math — fields resolved by name, arrays by index
  3. Public API — stable, documented, won’t break across JDK versions
  4. More access modes — Opaque, Acquire/Release not available in Unsafe
  5. 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

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐