Atomicity Guarantees in Java
This article is inspired by an old article by Nitsan. That article was testing a similar concept when using Direct ByteBuffer. But in Incorta, we’re relying on the direct use of UNSAFE.allocateMemory. So, we needed to repeat the tests with our use case and add further tests.
The focus of this article is not on the Java Virtual Machine (JVM) itself, but rather on the atomicity of OffHeap memory access in Java. Before diving in, let’s clarify some key terms:
- Java Memory Model: Defines the JVM’s data reading guarantees, particularly in concurrent scenarios. It explains how threads interact with data written by other threads. For a deeper understanding, refer to this comprehensive article.
- Off-Heap Memory Operations: Reserving and handling the memory outside the umbrella and guarantees of the JVM’s memory model.
- Read/Write Atomicity: Refers to the integrity of memory operations. An operation is atomic if a value is either fully read or not read at all. Partial reads or writes render an operation non-atomic. For example, if we have a thread writing 0xFFFF, in memory, but another thread is reading while the data writing in progress, it could get 0xFF00. In this case the operation is said to be non-Atomic.
- Cache Line Size: Represents the smallest data unit transferred between main memory and cache. This size varies across different CPUs and systems. On my Mac, it’s 128, retrievable via the command “sysctl -n hw.cachelinesize”.
- Cache Alignment: Ensures that a data unit fully resides in a single cache line, preventing it from spanning multiple cache lines.
The core question this article addresses is the atomicity of memory access in Java when two threads are involved. We examine this through a simple example featuring two threads, T1-writer and T2-reader, operating concurrently on the same memory location without synchronization.
/*
* In this example:
* sharedVar: is the shared variable.
* The writer thread: modifies sharedVar.
* The reader thread: reads sharedVar and checks if it's an expected value (0 or 1)
*/
public class UnsynchronizedExample {
// Shared variable
public static volatile int sharedVar = 0;
public static void main(String[] args) {
// Writer thread
Thread writerThread = new Thread(() -> {
for (int i = 0; i < 100000000; i++) {
sharedVar = i % 2 == 0 ? 0 : -1;
}
});
// Reader thread
Thread readerThread = new Thread(() -> {
long localCopy = 0;
Set<Long> unexpected = new HashSet<>();
for (int i = 0; i < 100000000; i++) {
localCopy = sharedVar;
// Check if the variable is even or not (just as an example check)
if (localCopy != 0 && localCopy != -1) {
// Output when an odd value is read (expecting only even values)
unexpected.add(localCopy);
}
}
System.out.println("Count of Unexpected values detected: " + unexpected.size());
for (long v : unexpected) {
System.out.println(v);
}
});
// Start both threads
writerThread.start();
readerThread.start();
// Wait for both threads to complete
try {
writerThread.join();
readerThread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
Our findings indicate that all values read were either ZERO or ONE, affirming the JVM’s atomicity guarantees for primitive variables.
For more rigorous testing, OpenJDK offers a test suite called jcstress, which includes tests for atomicity. Interestingly, off-heap access does not guarantee atomicity, especially when data crosses cache line boundaries. This is evident from various test results, some of which are shown below:
One of the checks are for atomicity. Which you can check here: https://raw.githubusercontent.com/openjdk/jcstress/master/jcstress-samples/src/main/java/org/openjdk/jcstress/samples/jmm/basic/BasicJMM_02_AccessAtomicity.java
The tests there, shows how you can check for Atomicity. Interrestingly, for Off-Heap access, there is no Atomicity Guarantees. Check the following test snippet from the JCStree:
// These are the test outcomes.
//@Outcome(id = "1, 1", expect = ACCEPTABLE_INTERESTING, desc = "Both actors came up with the same value: atomicity failure.")
//@Outcome(id = "1, 2", expect = ACCEPTABLE, desc = "actor1 incremented, then actor2.")
//@Outcome(id = "2, 1", expect = ACCEPTABLE, desc = "actor2 incremented, then actor1.")
@Outcome(id = "0", expect = ACCEPTABLE, desc = "Seeing the default value: writer had not acted yet.")
@Outcome(id = "-1", expect = ACCEPTABLE, desc = "Seeing the full value.")
@Outcome( expect = ACCEPTABLE_INTERESTING, desc = "Other cases are allowed, because reads/writes are not atomic.")
// This is a state object
@State
public class ReadWriteWordBoundaryRaceTest {
private static final Random RANDOM = new Random();
private static final int CACHE_LINE_SIZE = 128;
private static final boolean alignAddresses = false;
private final long page;
private final long offset;
public ReadWriteWordBoundaryRaceTest() {
page = OffHeapUtils.allocatePage(2 * CACHE_LINE_SIZE);
offset = alignAddresses ? getAlignedOffset(page) : ThreadLocalRandom.current().nextInt( 2 * CACHE_LINE_SIZE - 8);;
}
private static long getAlignedOffset(long page) {
return (int) (CACHE_LINE_SIZE - (page & (CACHE_LINE_SIZE - 1)));
}
int getSize() {
return 8;
}
@Actor
public void writer() {
OffHeapUtils.setLong(page, offset, -1L);
}
@Actor
public void reader(L_Result r) {
r.r1 = OffHeapUtils.getLong(page, offset);
}
}
You can find the OffHeapUtils definition here, but it’s simply a wrapper around UNSAFE
The above test would check whether we would get un-expected results (Other than 0 & -1). And interrestingly enough, yes, we get plenty of them, when the off-head data is across Cache Line boundaries (Mis-aligned access). Following are some results from the above test:
/*
----------------------------------------------------------------------------------------------------------
However, even if the misaligned accesses is supported by hardware, it would never be guaranteed atomic.
For example, reading the value that spans two cache-lines would not be atomic, even if we manage to issue
a single instruction for access.
x86_64:
RESULT SAMPLES FREQ EXPECT DESCRIPTION
-1 127,819,822 48.55% Acceptable Seeing the full value.
-16777216 17 <0.01% Interesting Other cases are allowed, because reads/writes are not ato...
-256 17 <0.01% Interesting Other cases are allowed, because reads/writes are not ato...
-65536 11 <0.01% Interesting Other cases are allowed, because reads/writes are not ato...
0 134,990,763 51.27% Acceptable Seeing the default value: writer had not acted yet.
16777215 154,265 0.06% Interesting Other cases are allowed, because reads/writes are not ato...
255 154,643 0.06% Interesting Other cases are allowed, because reads/writes are not ato...
65535 154,446 0.06% Interesting Other cases are allowed, because reads/writes are not ato...
*/
We’ve added more tests checking the atomicity of further cases. Like Atomicity when the access is aligned. And atomicity when both threads are acessing neighbor places in Memory, whether the access is aligned or not.
Check the additional tests here:
https://github.com/mostafaelganainy/jcstress/pull/1/files
It worth noting that, failing to show a problematic case, doesn’t mean it’s guaranteed to work. So, take our tests and jcstress tests with a grain of sault anyway.
Results:
Case 1: Aligned access, same memory location
- Atomicity is guaranteed (Under our tests)
Case 2: Mis-aligned access, same memory location
- OnHeap (JVM Memory): Atomicity is guaranteed for primitive values.
- OffHeap: Atomicity is NOT guaranteed
Case 3: Aligned/Mis-aligned Adjacent Memory location
- Atomicity is guranteed (Under our tests)
How to run the test
Step1: Get the code:
cd $ROOT
git clone git@github.com:mostafaelganainy/jcstress.git
cd jcstress
Step2: Build
mvn clean install -pl :jcstress-samples
Step3: Run the tests
java -jar jcstress-samples/target/jcstress.jar -t ReadWriteWordBoundaryRaceTest
Conclusion
In summary, we have two primary scenarios concerning misaligned access:
Case 1:
- OnHeap (JVM Memory): Atomicity is guaranteed for primitive values.
- OffHeap: Atomicity is not guaranteed when access is misaligned.
That also is one of so many examples, for why you should not use the Off-Heap mode directly, unless you know very well what you’re doing.