This article is a study note for the book The Art of Concurrent Programming in Java and several related articles. Because this piece of knowledge intersects with each other, it is difficult to work out a clear structure, and the first time to learn will feel very confused. Then sort out this article. If there is any mistake, welcome to correct, thank you.

Key issues in concurrent programming

In concurrent programming, two key issues need to be addressed: how threads communicate and synchronize.

In imperative programming, there are two communication mechanisms: the shared memory concurrency model and the messaging concurrency model.

  1. Shared memory Threads share the common state of a program and communicate implicitly through the common state in read-write memory.
  2. There is no common state between messaging threads and they must communicate by sending messages to display it.

In the message delivery concurrency model, synchronization is implicit because the message must be sent before the message is received. But in the shared memory concurrency model, synchronization is explicit. Programmers must explicitly specify that a method or code segment needs to be executed mutually exclusive between threads.

Concurrency in Java is a shared memory model, and if you don’t understand the communication mechanism between threads, you can run into a lot of problems, so the existence and understanding of the JMM is very important.

Java memory model

The Java Memory Model, or JMM (Java Memory Model), is an abstract concept that describes a set of specifications for controlling communication between Java threads. The JMM determines when a thread’s write to a shared variable is visible to another thread — that is, it defines an abstract relationship between threads and main memory.

Shared variables between threads are stored in main memory, and each thread has a private local memory. Threads cannot manipulate main memory variables directly and must do so through local memory. The thread copies variables from main memory to its own local memory, manipulates variables, and writes variables back to main memory.

Note: Local memory is abstract and does not exist. It covers caches, write buffers, registers, and other hardware and compiler optimizations

If there is a variable x=0 in main memory, thread AB each performs a +1 operation on it. Normally, thread A copies x=0 to local memory and then +1 to main memory, where x=1. Thread B reads the updated variable from main memory, copies x=1 to local memory, writes back to main memory after +1, and finally x=2. As you can see, thread A writes back to main memory and thread B reads from main memory is essentially thread A sending A message to thread B.

In an ideal situation, in reality, when two threads read x=0 from main memory and write back to main memory +1, the result is x=1, which is obviously not true. This is why we learned about the JMM, which provides us with memory visibility by controlling the interaction between main memory and local memory for each thread. (Memory visibility: Changes made by one thread to a shared variable can be seen by other threads in a timely manner)

Sequential consistency model

The sequential consistency model is a theoretical reference model that provides programmers with a strong guarantee of memory visibility. Under this theoretical model, programs (whether single-threaded or multithreaded) always execute in the order the programmer sees them. At design time, the sequential consistency memory model is used as reference for both the processor’s memory model and the programming language’s memory model. It has two main characteristics:

  1. All operations in a thread must be executed in program order.
  2. All threads (whether or not the program is synchronized) see a single order of execution. Each operation must be performed atomically and immediately visible to all threads.

That is, in the sequential consistency model, there is a unique global memory that can only be used by one thread at a time. And each thread must perform memory reads and writes in program order.

reorder

In computers, software technology and hardware technology have a common goal: to improve performance by maximizing parallelism without changing the results of program execution. Compilers and processors often reorder instructions. As mentioned above, both the compiler and the processor refer to the sequential consistency model, so the as-if-serial semantics are needed to ensure that the execution result of the program is not changed.

The as – if – serial semantics

No matter how reordered, the result of the (single-threaded) program does not change. To comply with the as-IF-serial semantics, the compiler and processor do not reorder operations that have data dependencies.

Data dependency

If two operations access the same variable, and one of them is a write operation, there is a data dependency between the two operations. As shown in the table:

The name of the Code sample instructions
Writing after reading a = 1; b = a; After writing a variable, read the variable
Write after a = 1; a = 2; After you write a variable, you write that variable
Got to write a = b; b = 1; After reading a variable, write the variable

As you can see, in these three cases, if you reorder the order of the two operations, the results of the program will change.

Reordering by the compiler and processor does not change the order in which two operations that have data dependencies are executed.

  • Note that this applies only to operations performed in a single processor, in a single thread. Data dependencies between different processors and threads are not considered.

If there are no data dependencies between operations, they will be reordered. For example, in the following example of calculating the area of a circle, operations 1 and 2 are reordered without changing the execution result:

1       double pi = 3.14;
2       double r = 1.0;
3       double area = pi * r * r;
Copy the code

with

1       double r = 1.0;
2       double pi = 3.14;
3       double area = pi * r * r;
Copy the code

The result is the same, but there are data dependencies between operations 3 and 1, 2, so 3 cannot be reordered before 1 or 2.

Control dependence
if (flag) { 
    int i = a * a;
}
Copy the code

Operations with control dependencies like the one above affect the parallelism of the instruction sequence. So the compiler and processor respond with “guess execution.” The thread executing the program can read and evaluate a * a ahead of time, then temporarily save the result to the reorder buffer, and write the result to variable I when if is judged to be true. The possible order of execution is as follows:

The impact of reordering on multithreading

See the following example of how reordering affects multithreaded programs.

Class ReorderExample {int a = 0; boolean flag =false;
        
        public void writer() {
                a = 1;      // 1
                flag = true;    // 2 
        } 
        
        Public void reader() {
                if (f?lag) {     // 3
                        int i = a * a;      // 4
                        ……
                }
        }
}
Copy the code

Suppose you have two threads, A and B, with A first executing writer() and then THREAD B executing Reader (). When thread B performs operation 4, can we see thread A writing to the shared variable A on operation 1?

The answer is: not necessarily. Why is that?

Operations 1 and 2 have no data dependencies and can be reordered (as can 3 and 4)

  • When operations 1 and 2 reorder
The order Thread A Thread B
1 flag = true;
2 if (flag)
3 int i = a * a;
4 a = 1;

As can be seen, when thread B determines that flag is true and variable A is read, variable A has not yet been written by thread A. The program execution result is wrong.

  • When operations 3 and 4 reorder
The order Thread A Thread B
1 temp = a * a
2 a = 1;
3 flag = true;
4 if (flag)
5 int i = temp;

After reordering, thread B calculates the value of a * A and stores it temporarily (control dependencies above). Thread A assigns the value to variable A, and the program execution result is of course wrong.

The significance and role of the JMM

The guarantee of the JMM

In a single-threaded Java program, the compiler and processor have made order consistency guarantees when reordering, and the program is always executed sequentially. There is also no memory visibility problem, because any changes we made to the variable in our last operation can be read by subsequent operations.

This is not the case in the case of multiple threads. Because of reordering, one thread looks at another, and all operations are unordered. There are also memory visibility issues due to working memory.

In these cases, the JMM assures us that if the program is properly synchronized, its execution will be sequentially consistent — the result of the program’s execution will be the same as the result of the program’s execution in the sequentially consistent memory model. Synchronization here is synchronization in the broad sense, including the correct use of common synchronization primitives (synchronized, volatile, and final).

Internal means: happens-before principle

Happens-before is a central JMM concept.

In the JMM, if the results of one operation need to be visible to another, there must be a happens-before relationship between the two operations. Note that it can be either single-threaded or multi-threaded. (A happens-before B) The main rules are as follows:

  • Program order rule: Each operation in a thread must occur in any subsequent operation in that thread (i.e., program execution in code order in a single thread).
  • Monitor lock rule: unlocking a lock must occur before subsequent locking of the lock.
  • Volatile variable rules: Writes to a volatile field occur before reads to that field. (Volatile: In simple terms, a volatile variable is forcibly read from main memory each time it is read, and writes to it force the new value to be flushed to main memory.)
  • Thread start rule: A thread’s start() method precedes any of its other actions. (Other threads modify the shared variable before thread A executes the start() method. This modification is visible to thread A when thread A executes the start() method.)
  • Thread termination rule: All operations of a thread precede its termination.
  • Object finalization rule: Execution of object constructors before finalize() methods.
  • Transitivity rule: If A precedes B and B precedes C, then A must precede C.

Note: The above rules are JMM internal guarantees and do not require us to add any synchronization even in a multi-threaded environment.

But just because two operations have a happens-before relationship does not mean that the previous operation must be performed before the latter. Happens-before only requires that the previous action (the result of the execution) be visible to the next action, and that the previous action precedes the next action in order. Why is that? And then we look down.

There are two aspects to consider when designing a JMM. One is that programmers want the memory model to be easier to understand and program (the strong memory model). On the other hand, compilers and processors want the memory model to be more liberal and have made more optimizations (the weak memory model). The goal of JMM design is to find a balance between these two aspects.

Let’s go back to our previous example of calculating the area of a circle,

1       double pi = 3.14;
2       double r = 1.0;
3       double area = pi * r * r;
Copy the code

There are three happens-before relationships: ①>②, ②>③, ①>③. But ②>③, ①>③ is necessary, while ①>② is not necessary.

The JMM classifies reorders prohibited under the happens-before rule into two categories:

  1. Reordering that alters the results of program execution
  2. Reordering that does not change the results of program execution

The JMM simply requires the compiler and processor to disallow the first type of reordering. The JMM makes programmers think programs are executed in ①>②>③ order, but they are not.

The JMM actually follows the basic principle of sequential consistency, as long as the execution result is the same, you can optimize it any way you want. This gives the compiler and processor the most freedom and gives the programmer the clearest and simplest guarantee through the happens-before rule.

Happens-before is essentially the same thing as as-if-serial, and their purpose is to maximize the parallelism of program execution without changing the results of program execution.

External means: volatile, locking, final fields,

In addition to the happens-before rule, the JMM also provides volatile, synchronize, final, and lock mechanisms to synchronize threads and ensure correct execution ina multithreaded environment.

conclusion

OK, that concludes the sharing of the Java memory model. In a word: The JMM is a set of rules designed to address thread-safety issues that can occur in concurrent programming, providing built-in solutions (the coincidentally before principle) and externally available means of synchronization (synchronized/volatile, etc.). It ensures the atomicity, visibility and order of program execution in multithreaded environment.

The resources

  1. The Art of Concurrent Programming in Java
  2. A thorough understanding of the Java Memory model (JMM) and the volatile keyword zejian_