memory barrier

Memory Ordering in Java: Opaque, Acquire/Release, Volatile

I came across the paper “The Silently Shifting Semicolon.” The authors argue that a safe and productive language should make sequential consistency a default guarantee across threads, not only within a single thread. That is, if a program says A; B, it should always mean do A, then do B.

In practice, this is not what happens in most programming languages today: compilers can reorder independent operations, and modern CPUs execute memory operations out of order. This freedom across threads can make code appear as if B happened before A unless we add explicit synchronization. In this post, we first explain how ordering is handled by the compiler and the CPU, and then show how to enforce it in Java using VarHandle modes (Opaque, Acquire/Release, Volatile) and with volatile.

Ordering in the compiler and the CPU

The order in which you write your source code (the program order) is not guaranteed across threads. The compiler can legally reorder independent reads and writes as long as a single thread would behave as if the code ran in order (the as-if-serial rule). In other words, if reordering has no observable effect within that thread, the compiler is free to make it.


Modern CPUs also execute instructions out of order. That means they don’t always run instructions in the exact order you write them. They rearrange them internally when it is safe to do so.
If one instruction is waiting for data, the CPU may run another that is ready instead.
CPU cores also have a store buffer where they temporarily keep values that have been written but not yet sent to main memory.
A value written into that buffer may not become visible immediately to other cores unless we enforce it. At the same time, your later instructions, such as reads from different memory addresses, can still go ahead.


This is done for performance reasons. It is efficient because it doesn’t keep the CPU idle while waiting for slow operations to complete, but it also means other cores may temporarily see memory updates in a different order.

When ordering breaks

memory ordering

Let us consider a simple email system with many workers that send emails from a shared queue. For each individual email job (say, “send invoice to user X”), we want to make sure it is sent at most once, even if multiple workers happen to pick it up around the same time.

By program order this feels really safe. Our code suggests that at most one of them will send this message. When Worker 1 sets emailJob.claimedByWorker1 = true, we expect Worker 2 to see that flag and skip sending, and vice versa.

However, as explained earlier, the compiler and CPU may reorder independent operations, and each core has a store buffer that can delay writes from becoming visible to other cores. A write like emailJob.claimedByWorker1 = true or emailJob.claimedByWorker2 = true can still be sitting in a store buffer while the later read goes ahead.

In this window, both workers can end up sending the email, so the same email is sent twice. To prevent this, we need explicit memory barriers.

Enforcing order with Varhandle

To prevent reordering bugs, Java provides the VarHandle API. VarHandles let us control how reads and writes behave across threads, giving us explicit control over visibility and ordering at the memory level. VarHandles expose access modes: plain, opaque, acquire/release, and volatile.

Plain mode

Plain mode is what we are used to writing, like regular reads and writes. There is no guarantee about visibility or ordering across threads. The compiler and CPU can freely reorder reads and writes. While this has less overhead than stronger modes, it offers the least guarantees, so we should only use it in single threaded code or when the data is confined to a single thread.

Here is a simple test you can run to see the effect of plain mode.

import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;

final class EmailJob {
    boolean claimedByWorker1;
    boolean claimedByWorker2;

    static final VarHandle CLAIMED_BY_WORKER1;
    static final VarHandle CLAIMED_BY_WORKER2;

    static {
        try {
            var lookup = MethodHandles.lookup();
            CLAIMED_BY_WORKER1 = lookup.findVarHandle(
                    EmailJob.class, "claimedByWorker1", boolean.class);
            CLAIMED_BY_WORKER2 = lookup.findVarHandle(
                    EmailJob.class, "claimedByWorker2", boolean.class);
        } catch (Exception e) {
            throw new Error(e);
        }
    }
}

public class PlainEmailClaimDemo {

    static final int ROUNDS = 200_000;
    static volatile int start;

    public static void main(String[] args) throws Exception {
        var job = new EmailJob();
        long doubleSends = 0;

        for (int i = 0; i < ROUNDS; i++) {
            job.claimedByWorker1 = false;
            job.claimedByWorker2 = false;
            start = 0;
            final int[] sent = {0, 0};

            Thread worker1 = new Thread(() -> {
                while (start == 0) {
                    Thread.onSpinWait();
                }
                EmailJob.CLAIMED_BY_WORKER1.set(job, true);             // plain write
                boolean other = (boolean) EmailJob.CLAIMED_BY_WORKER2.get(job); // plain read
                if (!other) {
                    sent[0] = 1; // worker 1 sends the email
                }
            });

            Thread worker2 = new Thread(() -> {
                while (start == 0) {
                    Thread.onSpinWait();
                }
                EmailJob.CLAIMED_BY_WORKER2.set(job, true);             // plain write
                boolean other = (boolean) EmailJob.CLAIMED_BY_WORKER1.get(job); // plain read
                if (!other) {
                    sent[1] = 1; // worker 2 sends the email
                }
            });

            worker1.start();
            worker2.start();
            start = 1;
            worker1.join();
            worker2.join();

            if (sent[0] == 1 && sent[1] == 1) {
                doubleSends++;
            }
        }

        System.out.println("double sends (plain): " + doubleSends + " / " + ROUNDS);
    }
}

After running this code, you can see that there are instances in which the same message was sent by both threads. This is what we want to avoid.

❯javac PlainEmailClaimDemo.java
❯ taskset -c 0,2 java PlainEmailClaimDemo
double sends (plain): 10 / 200000
❯ taskset -c 0,2 java PlainEmailClaimDemo
double sends (plain): 11 / 200000
❯ taskset -c 0,2 java PlainEmailClaimDemo
double sends (plain): 15 / 200000

Opaque mode

Opaque mode is very similar to plain access. It ensures the JVM always performs the read or write exactly when your code asks for it; it can’t skip it or reuse an earlier value during optimization. However, opaque mode still gives no visibility or ordering guarantees between threads, so another core may see the update late or not at all.
To try opaque mode in the example above, keep the same code and just change the four VarHandle calls from set/get to setOpaque/getOpaque for CLAIMED_BY_WORKER1 and CLAIMED_BY_WORKER2.

Just like with plain mode, we still see cases in which the email is sent twice.

❯ javac OpaqueEmailClaimDemo.java
❯ taskset -c 0,2 java OpaqueEmailClaimDemo
double sends (opaque): 16 / 200000
❯ taskset -c 0,2 java OpaqueEmailClaimDemo
double sends (opaque): 8 / 200000
❯ taskset -c 0,2 java OpaqueEmailClaimDemo
double sends (opaque): 8 / 200000

Acquire/Release mode

Acquire/Release gives us one-way ordering between threads. A release write (setRelease) makes sure that everything the thread did before that write becomes visible to another thread after it reads the same variable with getAcquire. An acquire read (getAcquire) makes sure that anything that comes after the read in this thread cannot be moved before it.

This works well for one-directional communication, where one thread writes some data, then sets a “ready” flag, and another thread waits for that flag before reading the data.
However, in our case, each worker writes one flag and reads a different flag. There is no single shared “ready” variable that both sides synchronize on, so Acquire/Release is not enough to stop both workers from sending the same email.

To try Acquire/Release in the same test, we just replace the four plain accesses:

  • For the writes → use setRelease
  • For the reads → use getAcquire

So Worker 1 calls setRelease on its flag and getAcquire on Worker 2’s flag, and Worker 2 does the same in reverse. The test shows similar results.

❯ javac AcquireReleaseEmailClaimDemo.java
❯ taskset -c 0,2 java AcquireReleaseEmailClaimDemo
double sends (acquire/release): 8 / 200000
❯ taskset -c 0,2 java AcquireReleaseEmailClaimDemo
double sends (acquire/release): 7 / 200000
❯ taskset -c 0,2 java AcquireReleaseEmailClaimDemo
double sends (acquire/release): 5 / 200000

Volatile mode

Volatile mode provides us with the strongest guarantees. It enforces a global order across all threads, and it also guarantees visibility. This means that, when one thread writes a volatile variable, other threads will always observe that write in the proper order, never out of order and when a thread reads a volatile variable, it always sees the latest value written by any thread.

To try volatile mode in the test, replace the four operations the same way as before:

  • reads → getVolatile
  • writes → setVolatile
❯ javac VolatileEmailClaimDemo.java
❯ taskset -c 0,2 java VolatileEmailClaimDemo
double sends (volatile): 0 / 200000
❯ taskset -c 0,2 java VolatileEmailClaimDemo
double sends (volatile): 0 / 200000
❯ taskset -c 0,2 java VolatileEmailClaimDemo
double sends (volatile): 0 / 200000

For the first time we see double sends drop to 0, because the volatile ordering prevents the outcome where both workers see the other flag as false at the same time.

Conclusion

Sequential consistency is not guaranteed across threads. The compiler and CPU are free to reorder instructions as long as each individual thread still appears to run in order. In this post we showed how that freedom can lead to surprising bugs, such as both workers sending the same email, even when the code looks perfectly safe.

This is why it is important to understand the different VarHandle modes and choose the right level of ordering: Plain, Opaque, Acquire/Release, or Volatile. Each one gives you a different amount of visibility and ordering control.

Hopefully this post helped you see that writing A; B does not always mean “do A, then B” once several threads are involved.

Oval@3x 2 1024x570

Don’t miss a post!

Lobe Serge
Lobe Serge
Articles: 12

Leave a Reply

Your email address will not be published. Required fields are marked *