Small knowledge, big challenge! This article is participating in the creation activity of “Essential Tips for Programmers”.

The memory semantics of synchronized and Lock locks in Java are introduced in detail.

Java locks include synchronized and Lock locks. Both of them can ensure the correct implementation of the memory semantics of locks, but their underlying principles are different. The underlying principles of Lock locks use the memory semantics of volatile and CAS to implement the memory semantics of locks, while synchronized locks exist in Java object headers and are implemented based on the support of JVM.

Memory semantics for locks

Locking is the most important synchronization mechanism in Java concurrent programming. In addition to allowing critical sections to execute mutexes, locks allow the thread releasing the lock to send a message to the thread acquiring the same lock.

Semantics:

  1. When a thread releases the lock, the JMM fluses the shared variables in the thread’s local memory to the main memory. Lock release has the same memory semantics as volatile writes.
  2. When a thread acquires a lock, the JMM invalidates the thread’s local memory. This makes critical section code protected by the monitor have to read shared variables from main memory. Lock acquisition has the same memory semantics as volatile reads.

Summary of memory semantics for lock release and lock acquisition

  1. When thread A releases A lock, thread A essentially sends A message (thread A’s modification to the shared variable) to the next thread that will acquire the lock.
  2. Thread B acquires a lock, essentially receiving a message from a previous thread that modified the shared variable before releasing the lock.
  3. Thread A releases the lock and thread B acquires the lock, essentially sending A message to thread B through main memory.

2 Lock Memory semantics implementation

Take ReentrantLock for example. In ReentrantLock, the lock() method is called to obtain the lock. Call the unlock() method to release the lock.

Already implemented depends on Java AbstractQueuedSynchronizer synchronizer framework (Jane called AQS) in this paper. AQS maintains synchronization state with a volatile variable of integer type named state, which is key to the implementation of ReentrantLock’s memory semantics.

ReentrantLock is classified into fair and unfair locks. Let’s start with fair locking.

When using a fair lock, the lock method () is called as follows:

  1. ReentrantLock:lock()
  2. FairSync:lock()
  3. AbstractQueuedSynchronizer:acquire(int arg)
  4. FairSync:tryAcquire(int acquires)

To actually start locking in step 4, here’s part of the source code for this method:

protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    // To obtain the start of the lock, read the volatile variable state first
    int c = getState();
     / /...
Copy the code

As you can see from the above source code, the locking method first reads the volatile variable state.

When using a fair lock, the unlock method () is called as follows:

  1. ReentrantLock:unlock()
  2. AbstractQueuedSynchronizer:release(intarg)
  3. Sync:tryRelease(int releases)

To actually release the lock in step 3, here’s part of the source code for this method:

protected final boolean tryRelease(int releases) {
    int c = getState() - releases;
    if(Thread.currentThread() ! = getExclusiveOwnerThread())throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);
    }
    // Finally, write the volatile variable statesetState(c);return free;
}

Copy the code

As you can see from the above source code, the volatile variable state is written at the end of the lock release.

Fair locks write volatile state at the end of lock release and read it first on lock acquisition. According to the happens-before rule for volatile, shared variables visible to the thread that released the lock before writing to the volatile variable will become visible to the thread that acquired the lock immediately after the same volatile variable is read by the thread that acquired the lock.

Now let’s examine the implementation of the memory semantics of unfair locking. The release of an unfair lock is exactly the same as a fair lock, so only the acquisition of an unfair lock is analyzed here. When an unfair lock is used, the lock method () is called as follows:

  1. ReentrantLock:lock()
  2. NonfairSync:lock()
  3. AbstractQueuedSynchronizer:compareAndSetState(int expect,int update)

To actually start locking in step 3, here’s the source code for this method:

protected final boolean compareAndSetState(int expect, int 
update) {
    return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}

Copy the code

This method updates the state variable atomically, and Java’s compareAndSet() method call is abbreviated as CAS. The JDK documentation describes this method as follows: ** If the current state value is equal to the expected value, the synchronization state is set atomically to the given updated value. ** This operation has the memory semantics of volatile reads and writes.

Here we examine how CAS has the memory semantics of both volatile read and volatile write from the perspective of the compiler and processor, respectively.

2.1 CAS semantic compiler implementation

The compiler does not reorder volatile reads or any memory operation following volatile reads; The compiler does not reorder volatile writes or any memory operations that precede volatile writes. Combining these two conditions means that the compiler cannot reorder any memory operations before and after CAS in order to implement the memory semantics of both volatile reads and volatile writes.

2.2 CAS semantic processor implementation

As with volatile, the processor automatically prefixes the CAS method instruction with the lock prefix, which can

  1. Ensure that read – change – write operations to memory atoms are performed. In Pentium and processors prior to Pentium, instructions prefixed with lock locked the bus during execution, temporarily preventing other processors from accessing memory through the bus. Obviously, this is expensive. Starting from PentiumIntel Xeon and P6 processors, Intel uses Cache Locking to ensure atomicity of instruction execution. Cache locking greatly reduces the execution overhead of lock prefix instructions.
  2. Disallows reordering of this instruction with previous and subsequent read and write instructions.
  3. Flushes all data in the write buffer to memory.

Points 2 and 3 above have the memory barrier effect of implementing the memory semantics of both volatile reads and volatile writes.

2.3 Lock Lock summary

Now summarize the memory semantics of fair and unfair locks.

  1. A volatile variable state is written at the end of both fair and unfair lock releases.
  2. For fair lock acquisition, volatile variables are read first.
  3. When an unfair lock is acquired, CAS is first used to update the volatile variable, which has the memory semantics of both volatile read and volatile write.

As you can see from the analysis of ReentrantLock above, lock-release-acquire memory semantics can be implemented in at least two ways.

  1. Memory semantics for write-reads using volatile variables.
  2. Leverage the memory semantics of volatile read and volatile write that come with CAS.

The cornerstone of the concurrent package implementation

Because Java CAS has the memory semantics for both volatile reads and volatile writes, Java threads now have the following four ways to communicate.

  1. Thread A writes the volatile variable, and thread B reads the volatile variable.
  2. Thread A writes the volatile variable, and thread B updates the volatile variable with CAS.
  3. Thread A updates A volatile variable with CAS, and thread B updates the volatile variable with CAS.
  4. Thread A updates A volatile variable with CAS, which thread B then reads.

Java’s CAS uses efficient machine-level atomic instructions provided on modern processors that perform atomic read-modi-write operations on memory, which are key to achieving synchronization across multiple processors.

Meanwhile, read/write of volatile variables and CAS enable communication between threads. Taken together, these features of CAS and volatile form the foundation for the implementation of the entire Concurrent package. If we look closely at the source code implementation of the Concurrent package, we will find a common implementation pattern:

  1. First, declare the shared variable volatile;
  2. Then, atomic conditional update of CAS is used to achieve synchronization between threads.
  3. At the same time, the thread communication is realized with volatile read/write and CAS memory semantics.

AQS, non-blocking data structure and the atomic variable classes (Java. Util. Concurrent. Atomic package’s classes), the concurrent is the base class in the package using this model, and the top class in the concurrent bag is dependent on the base class.

As a whole, the implementation of the Concurrent package looks like this:

4 summarizes

This article describes the memory semantics of locks and how our Lock locks implement them using the memory semantics of CAS and volatile, as well as the implementation cornerstones of the JUC package, CAS and Volatile. At the same time, Lock is more flexible than synchronized. Detailed implementation principles of synchronized and Lock will be presented in the following blog posts.

References:

  1. JSR133 Specification
  2. The Beauty of Concurrent Programming in Java
  3. Practical Java High Concurrency Programming
  4. The Art of Concurrent Programming in Java

If you don’t understand or need to communicate, you can leave a message. In addition, I hope to like, collect, pay attention to, I will continue to update a variety of Java learning blog!