Java concurrent Programming ④ – Java memory model

Reprint please indicate the source!

Previous articles:

  • Java Concurrent Programming basics ① – Threads
  • Java concurrent programming ② – Thread life cycle and state flow
  • [InheritableThreadLocal] [InheritableThreadLocal] [InheritableThreadLocal

preface

The Java Memory Model (JMM) is a “mechanism and specification” that conforms to the specification of the Memory Model, shielding the access differences of various hardware and operating systems, and ensuring that the Java program can get the same effect on the Memory access of various platforms.

JMM and Java memory regions are two confusing concepts that are both different and related:

  • The difference between

They are different conceptual levels. “The Java memory model is abstract, and it describes a set of rules” that govern how variables are accessed, around atomicity, order, visibility, and so on. The “Java runtime memory partition is concrete,” which is required when the JVM runs a Java program.

  • contact

There are private data areas and shared data areas. In general, the main memory in the JMM belongs to the shared data area, which contains the heap and method area. Similarly, local memory in the JMM is a private data area that contains program counters, local method stacks, and virtual machine stacks.

When studying the Java memory model, we often mention three features:

  • Visibility – Visibility
  • Atomicity – Atomicity
  • Ordering-ordering

The Java memory model is built around how these three features are handled during concurrency. These three features will also be covered in this article.

First, Java shared variables memory visibility problems

Before we discuss it, it’s important to revisit the JVM runtime memory area:


Thread-private variables are not shared between threads, so there are no memory visibility issues, and they are not affected by the memory model. Variables in the heap are shared, and this piece of data is called a “shared variable,” which is where the memory visibility problem is directed.


Ok, now that we know what the subject of the question is, let’s think about another question.

Why do variables on the heap have memory visibility problems?

JMM abstraction of hardware-level cache access

In fact, this will involve computer hardware cache access operations.

In modern computers, registers on the processor can be read and written several orders of magnitude faster than memory. To solve this speed contradiction, a cache is inserted between them.


Java’s memory access operations are highly comparable to the hardware caches described above:

The Java memory model specifies:

  • All variables are stored in main memory.
  • Each thread also has its own working memory, which stores copies of shared variables that the thread reads and writes.
  • Local memory (or working memory) is an abstraction from the Java memory model and does not really exist. It covers caches, write buffers, registers, and so on.
  • Threads can only manipulate variables in working memory directly, and variable values between threads need to be passed through main memory.

From an abstract perspective, “THE JMM defines an abstract relationship between threads and main memory.”

As described above for the JMM, when a thread operates on a shared variable, it copies the shared variable from main memory into its own working memory, processes the variable in working memory, and updates the value of the variable to main memory.

The presence of a Cache (working memory) can cause a problem where shared variables are not visible in memory (also known as Cache consistency), as shown in the following example:

  • Suppose we now have a shared variable X=0 in main memory;

  • Thread A first fetched the value of shared variable X, and since there was no hit in the Cache, it loaded the value of shared variable X in main memory, and cached the value of X=0 in working memory. Thread A performed the modification operation X++, and then wrote it to working memory and flushed it to main memory.

Thread -a working memory X=1

Main memory X is equal to1

Copy the code
  • Thread B starts to fetch the shared variable. Since the Cache does not hit, thread B loads the value of variable X in main memory and caches the value of X=1 into working memory. Thread B then performs the modificationX++, it is then written to working memory and flushed to main memory.
Thread-B working memory X=2

Thread -a working memory X=1

Main memory X is equal to2

Copy the code

Why does thread A get 1 when thread B has changed X to 2? This is the memory invisibility of shared variables, where values written by thread B are not visible to thread A.

How do I ensure visibility of memory

So how to ensure the visibility of memory, there are three main implementation methods:

  • The volatile keyword

    This keyword ensures that updates to a variable are immediately visible to other threads. When a variable is declared volatile, the thread writing to the variable does not cache the value in a register or elsewhere, but “flusher the value back to main memory.”

  • Sychronized keyword

    A thread cannot enter a synchronized block until it acquires a monitor lock. Once it does, “the thread’s cache of shared variables becomes invalid, so the read of shared variables in a synchronized block needs to be recaptured from main memory. You can get the latest value “.

    When a synchronized block exits, “data from the thread’s write buffer is flushed into main memory”, so operations on shared variables before or in a synchronized block follow the thread’s exit from the synchronized block. Is immediately visible to other threads (assuming, of course, that the thread goes to main memory to read the latest value).

  • The final keyword

    Set the final property in the constructor of the object, “and do not write references to this object to other threads until the initialization of the object is complete” (do not let references escape from the constructor). If this condition is met, when another thread sees the object, that thread will always see the final property of the properly initialized object. (Fields or array elements in objects referenced by final fields may change later, and without proper synchronization, other threads may not be able to see the most recently changed value, but “they must be able to see the value of the object field or array element at the moment when the fully initialized object or array was referenced by the final field.”)

    Final scene is relatively partial, generally the first two ways

    Extended link: JSR-133: JavaTM memory model and threading specification

Volatile and sychronized are the ones I think are more important and will be covered in separate sections.

Atomicity

JMM memory interactive operations

The Java memory model defines eight operations to interact with main and working memory

  • Read: Transfers the value of a variable from main memory to the thread’s working memory
  • Load: Executes after read, putting the value of read into a copy of the variable in the thread’s working memory
  • Use: Passes the value of a variable in the thread’s working memory to the execution engine
  • Assign: A variable that assigns a value received from the execution engine to working memory
  • Store: Transfers the value of a variable in working memory to main memory
  • Write: Executes after store, putting the value of store into a variable in main memory
  • Lock: Variable applied to main memory, marking a variable as a thread-exclusive state
  • Unlock: A variable that operates on main memory. It releases a variable that is locked before it can be locked by another thread.

The JMM definition rules for memory interaction are very strict and complicated. To facilitate understanding, the Java design team simplified the Operations of the Java memory model into four types: Read, Write, Lock, and UNLOCK. However, this is only equivalent simplification of language description, and the basic design of the Java memory model has not changed.

The JMM specification for atomicity

Atomic operations refer to a series of operations that are either all or none performed. There is no case where only part of the operations are performed.

The Java memory model guarantees atomicity for read, Load, use, assign, Store, write, Lock, and unlock operations. For example, the operation assign to a variable of type int is atomic. But the Java memory model allows the virtual machine to divide reads and writes to 64-bit data (long, double) that are not volatile into two 32-bit operations. That is, reads and writes to basic data types are atomic, except for long and double, which are non-atomic. “That is, load, Store, read, and write operations can be atomic-free.” In understanding the Java Virtual Machine, we are reminded that there is only one thing we need to know, and it is rare to actually use this knowledge.

Atomicity of shared variables

Here’s a classic example of a counter increment in concurrent conditions.

/ * *

* Three features of the memory model - atomicity verification comparison

 *

 * @author Richard_yyf

* /


public class AtomicExample {



    private static AtomicInteger atomicCount = new AtomicInteger();



    private static int count = 0;



    private static void add(a) {

        atomicCount.incrementAndGet();

        count++;

    }



    public static void main(String[] args) {

        final int threadSize = 1000;

        final CountDownLatch countDownLatch = new CountDownLatch(threadSize);

        ExecutorService executor = Executors.newCachedThreadPool();

        for (int i = 0; i < threadSize; i++) {

            executor.execute(() -> {

                add();

                countDownLatch.countDown();

            });

        }

        System.out.println("atomicCount: " + atomicCount);

        System.out.println("count: " + count);



        ThreadPoolUtil.tryReleasePool(executor);

    }

}

Copy the code

Output result:

atomicCount: 1000

count: 997

Copy the code

As you can see, although 1000 threads performed count++, the result was not the expected 1000.

And the reason for that is because count++ is not an atomic operation. You can use the following figure to help understand.


Count++ this simple operation according to the above principle analysis, you can know that memory operation is actually divided into read and write storage three steps; Since count is not atomic, two or more threads read the same old value, read it into the thread memory, write it, and store it back again. Then it is possible to have the same set value repeated in the main memory, as shown in the figure above. There’s actually only one valid operation.

How do you guarantee atomicity

To ensure atomicity, try the following methods:

  • “CAS” : Using atomic operation classes implemented based on CAS (such as AtomicInteger)
  • “Synchronized keyword” : Synchronized can be used to ensure atomicity of operations within a bounded critical region. The memory interaction operations are lock and unlock, and the bytecode instructions are Monitorenter and Monitorexit.

The former is optimistic (read more, write less) and the latter is pessimistic (read less, write more).

Third, order

reorder

When a computer executes a program, the compiler and processor rearrange instructions to improve performance.

Reordering is caused by several mechanisms:

  • Compiler optimization rearrangement

    The compiler can rearrange the execution order of statements “without changing the semantics of a single-threaded program.”

  • Instruction parallel rearrangement

    Modern processors use instruction-level parallelism to superimpose multiple instructions. If there is “no data dependency” (that is, the last statement executed does not depend on the results of the previous statement), the processor can change the execution order of the machine instructions corresponding to the statement.

  • The memory system is rearranged

    Because the processor uses the cache and reads and writes the cache flush, it can appear that the load and store operations are executed out of order because of the three-level cache, resulting in a time lag between memory and cached data synchronization.

How to ensure order

The Java memory model allows the compiler and processor to reorder instructions to improve performance, and only instructions that do not have a data dependency. This means that under the Java memory model, the compiler and processor can be optimized as long as the execution result of the program (single-threaded programs and properly synchronized multithreaded programs) is not changed. In a single thread, it is possible to guarantee that the result of the reordering optimization will be the same as the result of the sequential execution of the program (often referred to as as-if-serial semantics), but in multithreading, there are problems.

Reordering can lead to unexpected execution results in multiple threads. To ensure visibility, consider the following implementation:

  • volatile

    “Volatile creates memory barriers that prohibit instruction reordering.”

  • synchronized

    “Ensuring that only one thread enters the synchronized code block at any one time” is equivalent to having the threads execute the synchronized code sequentially.

summary

The set of running rules for the Java memory model may seem cumbersome, but in summary, it is “built around atomicity, visibility, and ordering characteristics.” The bottom line is “data consistency” across multiple threads of working memory for shared variables, enabling programs to run as expected in a multi-threaded, concurrent, instruction reordering optimized environment.

This article introduced the Java memory model and its surrounding orderliness, memory visibility, and atomicity. I have to say that if you really dig into the Java memory model, you can probably write a small book, and interested readers can refer to other resources for a deeper understanding.

Valotile and synchronized mentioned above are relatively important contents and will have separate chapters.

reference

  • The Beauty of Concurrent Programming in Java
  • In-depth Understanding of the Java Virtual Machine
  • JSR133 Chinese