Small knowledge, big challenge! This paper is participating in theEssentials for programmers”Creative activities

Understanding the Java memory model is an integral part of an in-depth study of Java concurrency. The Java Memory Model, or JMM for short, defines the visibility of shared variables between multiple threads and how to synchronize shared variables when needed.

The JMM specifies that communication between Java threads takes the form of shared memory. In Java, all member variables, static variables, and array elements are stored in heap memory, which is shared between threads within the heap, so they are often referred to as shared variables.

The JMM defines an abstract relationship between threads and main memory: Shared variables between threads are stored in Main Memory. Each thread has a private Local Memory (also known as Work Memory) where it stores copies of shared variables to read/write. Local memory is an abstraction of the JMM and does not really exist. It covers caching, write buffers, registers, and other hardware and compiler optimizations.

The JMM abstract

An abstract diagram of the JMM looks like this:

Thread-safety issues arise when multiple threads simultaneously read and write to the same shared variable. So why doesn’t the CPU operate directly on memory, instead of adding various buffers such as caches and registers between the CPU and memory? Because the CPU’s computing speed is much faster than the memory’s reading and writing speed, if the CPU directly operates the memory, it is bound to spend a long time waiting for the arrival of data, so the emergence of cache is mainly to solve the contradiction between the CPU’s computing speed and the memory’s reading and writing speed.

Memory interaction protocol

The JMM specifies the protocol for the interaction between main memory and working memory. That is, the implementation details of how a variable is copied from main memory to working memory and synchronized from working memory to main memory include the following eight steps:

  • Lock: A variable that acts on main memory and identifies a variable as a thread-exclusive state.
  • Unlock: Applies to a main memory variable. It releases a locked variable before it can be locked by another thread.
  • Read: Acts on a main memory variable, transferring the value of a variable from main memory to the thread’s working memory for subsequent load action
  • Load: Variable acting on working memory, which puts the value of the variable obtained from main memory by the read operation into a copy of the variable in working memory.
  • Use: variable applied to working memory, passing the value of a variable in working memory to the execution engine. This operation is performed whenever the virtual machine reaches a bytecode instruction that requires the value of the variable to be used.
  • Assign: a working memory variable that assigns a value received from the execution engine to the working memory variable. This operation is performed whenever the virtual machine accesses a bytecode instruction that assigns a value to the variable.
  • Store: Variable applied to working memory that transfers the value of a variable in working memory to main memory for subsequent write operations.
  • Write: a variable operating on main memory that transfers store operations from the value of a variable in working memory to a variable in main memory.

Lock, UNLOCK needs to be implemented in code with a lock.

These eight steps must comply with the following rules:

  1. Do not allow one of the read and load, store and write operations to occur separately.
  2. A thread is not allowed to discard its most recent assign operation. That is, a variable changes in working memory and the account must be synchronized back to main memory.
  3. A new variable is only allowed to be born in main memory. Working memory is not allowed to use uninitialized variables directly.
  4. A variable can only be locked by one thread at a time. However, the same thread can be locked several times, and then it must perform the same number of UNLOCK operations.
  5. If you lock a variable, the value of the variable will be cleared from working memory.
  6. It is not allowed to unlock a variable that has not been locked, nor to unlock a variable that has been locked by another thread.
  7. If a variable performs an UNLOCK operation, it must first be synchronized back to main memory.

Instruction rearrangement

When executing a program, the compiler and processor often reorder instructions to improve performance. The sequence of instructions from the Java source code to the actual execution goes through the following three reorders:

  1. Compiler optimized reordering. The compiler can rearrange the execution order of statements without changing the semantics of a single-threaded program.
  2. Instruction – level parallel reordering. Modern processors use Instruction-LevelParallelism (ILP) to execute multiple instructions on top of each other. If there is no data dependency, the processor can change the execution order of the machine instructions corresponding to the statement.
  3. Memory system reordering. Because the processor uses caching and read/write buffers, this makes the load and store operations appear to be out of order.

If two operations access the same variable and one of them is a write operation, there is a data dependency between the two operations. The compiler and processor do not change the execution order of two operations that have a data dependency relationship, that is, they are not reordered. No matter how reordered, the result of execution in a single thread cannot be changed. The compiler, runtime, and processor must obey the as-if-serial semantics.

The memory barrier

Memory barriers prevent reordering of certain types of instructions. The JMM divides memory barriers into four types:

Barrier type The sample describe
LoadLoad Barriers Load1-LoadLoad-Load2 The Load1 data loading process precedes Load2 and all subsequent data loading processes
StoreStore Barriers Store1-StoreStore-Store2 Store1 refreshes data to memory before Strore2 and all subsequent refreshes
LoadStore Barriers Load1-LoadStore-Store2 Load1 data loading precedes Strore2 and all subsequent flushing of data to memory
StoreLoad Barriers Store1-StoreLoad-Load2 Store1 flushers data to memory before Load2 and all subsequent data loading processes

The implementation of the volatile keyword in Java is done through memory barriers.

happens-before

Starting with JDK5, Java uses the new JSR-133 memory model to address memory visibility between operations, based on the concept of happens-before.

In the JMM, if the result of an operation needs to be visible to another operation, there must be a happens-before relationship between the two operations, which can either be in the same thread or in two different threads.

The happens-before rule, which is closely related to programmers, is as follows:

  • Procedure order rule: Every action in a thread happens-before any subsequent action in that thread.
  • Monitor lock rule: the happens-before action to unlock a lock is followed by action to lock the lock.
  • Volatile field rule: Writes to a volatile field are happens-before any subsequent reads to that volatile field by any thread.
  • The transitive rule: If A happens-before B, and B happens-before C, then A happens-before C.

Note: The happens-before relationship between two actions does not mean that the former action must be executed before the latter! Only the execution result of the previous operation is required to be visible to the latter operation, and the former operation is ordered before the latter operation.