Two key problems with concurrent programming
Concurrent programming deals with two key issues: how threads communicate with each other and how threads synchronize with each other.
Communication refers to the mechanism by which threads exchange information. There are two communication mechanisms between threads: shared memory and messaging.
In the shared memory model, threads share the common state of a program and communicate implicitly through the common state in read-write memory. Multiple threads share a piece of memory. The sender writes the message to the memory, and the receiver reads the message from the memory, thus realizing the message transfer.
In the messaging model, threads communicate explicitly by sending messages.
Synchronization is the mechanism used in a program to control the relative order in which operations occur between different threads. In the shared memory model, explicit synchronization is required. Programmers must explicitly specify that certain pieces of code need to be executed mutually exclusive between threads. In the messaging model, the message must be sent before the message is received, so synchronization is implicit.
Java uses the shared memory model.
Java memory model
In Java, all instance fields, static fields, and array elements are stored in heap memory, which is shared between threads in the heap. Local variables, method definition parameters, and exception handler parameters are not shared between threads.
The Java memory model is not consistent with the hardware memory architecture. Most of the data, whether heap or stack, is stored in memory, and some of the stack and heap data may be stored in CPU registers. The Java memory model attempts to mask the differences in memory access between hardware and operating systems, so that Java programs can achieve a consistent memory access effect across all platforms.
The Java memory model has three major features: atomicity, visibility, and sequentiality
atomic
Atomicity means that an operation either succeeds or fails. The Java memory model allows virtual machines to divide reads and writes to 64-bit data (longs, doubles) that are not volatile into two 32-bit operations.
The operation I ++ is actually divided into three steps: obtaining I, incrementing I, and assigning to I. If such atomic operation is to be realized, atomic classes need to be used, or synchronized mutex can also be used to ensure atomicity of the operation.
CAS
CAS, also known as CompareAndSet, can be looped through CAS to implement atomic operations in Java. Within the JVM, except for bias locking, the JVM implements the lock using CAS, which is used to acquire the lock when a thread wants to enter a synchronized block and to release the lock when it exits.
visibility
Visibility means that when one thread changes the value of a shared variable, other threads are immediately aware of the change. The Java memory model implements visibility by synchronizing the new value back to main memory after a variable is modified and flushing the value from main memory before the variable is read.
reorder
When executing a program, the compiler and processor often reorder instructions to improve performance.
- Compiler optimization reordering: The compiler rearranges the execution order of statements without changing the semantics of a single-threaded program
- Instruction-level parallel reordering: The processor uses instruction-level parallelism to overlap multiple instructions. If no data dependencies exist, the processor can change the statement correspondence and its execution order.
- Reordering of memory systems: The processor uses caching and read/write buffers, making loading and storing operations appear to be out of order.
Reordering can cause memory visibility problems in multithreaded programs. The JMM ensures consistent memory visibility across different compilers and processor platforms by inserting specific types of memory barrier instructions to prohibit certain types of processor reordering.
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. When reordering, data dependencies are observed and the order of the two operations that have data dependencies is not changed, that is, reordering is not done. However, this is for a single processor or a single thread, and data dependencies between multiple threads or processors are not taken into account.
as-if-serial
The result of a single-threaded program cannot be changed, no matter how it is reordered. The as-IF-serial semantics allow single-threaded programmers to avoid reordering.
Reordering may change the results of a multithreaded program, as shown in the figure below
happens-before
On the one hand, the JMM should provide programmers with strong enough memory visibility guarantees. On the other hand, restrictions on compilers and processors should be as relaxed as possible.
The JMM takes different strategies for reordering of different natures:
- The JMM requires the compiler and processor to disallow reordering that changes the result of program execution
- The JMM does not require reordering that does not change the result of program execution. In other words, the JMM is based on the basic principle that the compiler and processor can be optimized as long as the results of the program are not changed.
The happens-before relationship is defined as follows in JSR-133:
- If one action happens-before the other, the execution result of the first action is visible to the second action, and the execution order of the first action precedes the second action.
- There is a happens-before relationship between the two operations, and the JMM allows reordering if the result of the reordering is the same as happends-before.
Happens-before compares with as-if-serial, which guarantees that the execution result of a program in a single thread will not be changed; The former ensures that the execution results of properly synchronized multithreaded programs are not changed.
The following happens-before rule is defined in JSR-133:
- Single thread principle: In a thread, the preceding operations of the program precede the following operations.
- Monitor lock rule: An UNLOCK action precedes a subsequent lock action on the same lock.
- Rule for volatile Variables: Writes to a volatile variable occur before reads, meaning that the values read must be the latest.
- Thread start rule: The start() method of the Thread object calls each action of the Thread first.
- Thread addition rule: The end of the Thread object occurs first when the join() method returns.
- Interrupt rule: A call to the interrupt() method occurs when code in the interrupted thread detects that an interrupt has occurred, which can be detected by the interrupted() method.
- Object finalization rule: The completion of an object’s initialization (completion of constructor execution) occurs first at the start of its Finalize () method.
- Transitivity: If operation A precedes operation B and operation B precedes operation C, then operation A precedes operation C.
Visibility implementation
Visibility can be implemented in three ways:
- volatile
- Synchronized Before performing an unlock operation on a variable, the value of the variable must be synchronized back to main memory
- Final Fields decorated with the final keyword can be seen by other threads in the constructor once the initialization is complete and no this escape has occurred (other threads access the half-initialized object through this reference).
sequential
Data competition
Write a variable in one thread, read a variable in another thread, and write and read are not sorted by synchronization.
Sequentiality in JMM
In the idealized sequential consistent memory model, there are two main characteristics:
- All operations in a thread must be executed in program order
- All threads see only a single order of operation execution.
The JMM guarantees the memory consistency of properly synchronized multithreaded programs as follows: If the program is properly synchronized, the execution of the program will have sequential consistency, that is, the execution result of the program will be the same as the execution result of the program in the sequential consistent memory model.
The JMM implementation guideline is to facilitate optimization as much as possible without changing the results of properly synchronized program execution. Thus, the JMM differs from the idealized sequence-consistent memory model above as follows:
- Sequential consistency model ensures that single-threaded operations are executed sequentially. JMM does not guarantee this (the critical region can be reordered)
- The JMM does not guarantee that all threads see the same order of execution
- The JMM does not guarantee atomicity for writes to 64-bit long and double variables.
In Java, you can use the volatile keyword to ensure orderliness, as well as synchronized and lock.
- The volatile keyword prevents instruction reordering by adding a memory barrier that does not place subsequent instructions in front of the barrier.
- Order is guaranteed by synchronized and lock, which ensures that only one thread executes the synchronized code at any one time, effectively ordering the threads to execute the synchronized code sequentially.
volatile
The volatile keyword addresses the memory visibility problem by flushing all reads and writes to volatile variables directly to main memory, ensuring visibility.
Note that the use of the volatile keyword only guarantees atomicity for operations on primitive variables (Boolean,int,long, etc.) and does not guarantee atomicity for operations (e.g. I++).
A single read/write on a volatile variable performs the same effect as a read/write synchronization on a normal variable using the same lock. The happens-before rule for locks ensures memory visibility between the threads that release and acquire the lock, meaning that a read to a volatile variable always sees the last write to that variable, thus achieving visibility. Note that read/write to any single volatile variable is atomic, but compound operations such as i++ are not.
When a volatile variable is written, the JMM flusher the value of the shared variable from the thread’s local memory to memory. When a volatile variable is read, the JMM invalidates the thread’s local memory. The thread will next read the shared variable from main memory.
In particular, thread A writes A volatile variable. Essentially, thread A sends its changes to the next thread that will read the volatile variable. Thread B reads a volatile variable, essentially receiving a change from a previous thread.
synchronized
The JVM synchronizes by entering and exiting the object monitor. Every object in Java can be used as a lock.
- For normal synchronous methods, the lock is the current instance object
- For statically synchronized methods, the lock is the Class object of the current Class
- For blocks of synchronized code, locks are objects configured in synchronized parentheses
Synchronized using
- https://juejin.cn/post/6844903733877293069
- https://juejin.cn/post/6844903734082797581
Lock the optimization
Synchronized was optimized in JDK 1.6 to introduce bias and lightweight locks to reduce the cost of acquiring and releasing locks. That is to say, there are a total of four lock states, from the lowest level to the highest are: no lock state, biased lock state, lightweight lock state and heavyweight lock state. Locks can be upgraded but not degraded.
Java head
Synchronized uses locks that are stored in Java object headers. If the object is an array, the virtual machine stores the object header with three Word widths, and if the object is not an array, the virtual machine stores the object header with two Word widths.
The Java header contains the Mark Word, which is used to store the object’s hashCode or lock information, and the data stored in it changes at run time as the lock flag bit changes.
Biased locking
In most cases, locks are not contested by multiple threads, but are always acquired multiple times by a unified thread. Biased locks are introduced to make the cost of acquiring locks lower for threads.
The core idea is that if a thread acquires a lock, the lock goes into bias mode. When the thread requests the lock again, no more synchronization is required. This saves a lot of lock requests and improves program performance. Therefore, in the case of almost no lock contention, biased locking has a better optimization effect, because it is highly likely that the same thread will request the same lock for several consecutive times. But for lock competition more intense occasion, its effect is not good.
Release the lock: when there is another thread to get the lock, holding a biased locking thread can lock is released, release when waiting for the global security point (this time without the bytecode running), then will suspend threads with biased locking, according to the lock object is locked to determine the object header Mark Word set to unlocked or lightweight lock state.
Lightweight lock
Lock: When the code enters the synchronization block, if the synchronization object is in lockless state, the current thread will create a Lock Record area in the stack frame, and copy the Mark Word in the Lock object header into the Lock Record, and then try to use CAS to update the Mark Word to the pointer to the Lock Record. If the update succeeds, the current thread acquires the lock. If the update fails, the JVM first checks whether the lock object’s Mark Word points to the current thread’s lock record. If yes, it indicates that the current thread has the lock of the lock object and can directly enter the synchronization block. If not, another thread preempted the lock and tried to acquire it using the spin lock.
** Unlock: ** Lightweight locks are unlocked using CAS, which attempts to replace the lock record with the Mark Word of the lock object. If the replacement succeeds, the synchronization is complete. If the replacement fails, another thread attempts to acquire the lock, and the suspended thread (which has ballooned to a weight lock) is awakened.
Comparison of the three locks:
The lock type | advantages | disadvantages | Usage scenarios |
---|---|---|---|
Biased locking | Locking and unlocking require no additional cost, and there is only a nanosecond difference compared to implementing asynchronous methods | If there is lock contention between threads, there is additional lock cancellation cost | This applies to scenarios where only one thread accesses a synchronized block |
Lightweight lock | Competing threads do not block, improving the response time of the program | If a thread that never gets a lock contention uses spin, it consumes CPU | Pursuit of response time, lock occupancy time is very short |
Heavyweight lock | Thread contention does not use spin and does not consume CPU | Threads are blocked and response time is slow | In pursuit of throughput, the lock takes a long time |
Compare volatile to synchronized
- Volatile essentially tells the JVM that the current value of a variable in working memory is indeterminate and needs to be read from main memory; Synchronized locks the current variable so that only the current thread can access it and other threads are blocked
- Volatile can only be used at the variable level; Synchronized can be used at the variable, method, and class levels
- Volatile only provides visibility, not atomicity; Synchronized guarantees visibility and atomicity of variables
- Volatile does not block threads; Synchronized can cause threads to block
- Variables with volatile flags are not optimized by the compiler, and variables with synchronized flags are optimized by the compiler
Locked memory semantics
Locking release has the same memory semantics as volatile writing. Thread A releases the lock when THREAD A sends A message to the thread that is acquiring the lock about A’s modification of the shared variable. The acquisition lock has the same memory semantics as a volatile read, in that thread B receives the message that the heap shared variable has been modified by the previous thread.
As you can see from ReentrantLock:
- A volatile variable state is required for both fair and unjust locks
- To obtain a fair lock, read the volatile variable first
- Access to unfair locks, CAS updates volatile variables, and memory semantics for volatile reads and writes
In the juC package source code implementation, you can find a common implementation pattern for communication between Java threads:
- First declare the shared variable volatile
- Synchronization between threads is achieved using atomic conditional updates of CAS
- The read-write semantics of volatile and the read-write semantics of CAS are used to implement interthread communication.
Final domain
Reordering rule
For final fields, follow two reordering rules:
- A write to a final field within a constructor and a subsequent assignment of a reference to the constructed object to a reference variable cannot be reordered
- There is no reordering between the first reading of a reference to an object containing a final field and the subsequent first reading of the final field.
public class FinalExample{
int i;
final int j;
static FinalExample obj;
public FinalExample(a){
i=1;
j=2;
}
public static void writer(a){
obj=new FinalExample();
}
public static void reader(a){
FinalExample object=obj;
int a=object.i;
intb=object.j; }}Copy the code
Suppose thread A executes writer() and thread B executes reader().
Reordering rules for writing final fields Reordering rules for writing final fields forbids reordering writes for final fields out of the constructor. This ensures that the final field of the object is properly initialized before the object reference is visible to any thread. In the code above, the final field of the object acquired by thread B must be correctly initialized, but the normal field of I is not.
Reordering rules for reading final fields In a thread, the JMM forbids the handler from reordering the first read object that refers to the final field that the object contains. This ensures that references to the object containing the final field of an object are always read before reading the final field of the object
The final field is a non-reordering method for writing a final reference to an object inside a constructor and then assigning a reference to the constructed object outside the constructor.
However, to achieve this effect, we need to ensure that inside the constructor, references to the object being constructed cannot be seen by other threads, i.e., there can be no this escape.
Double-checked locking and lazy initialization
https://juejin.cn/post/6844903736934924296
The resources
- The art of Concurrent programming in Java
- The art of Java multithreaded programming
- https://blog.csdn.net/suifeng3051/article/details/52611310
- https://github.com/CyC2018/CS-Notes/blob/master/docs/notes/Java%20%E5%B9%B6%E5%8F%91.md#%E5%8D%81java-%E5%86%85%E5%AD%98 %E6%A8%A1%E5%9E%8B
- https://blog.csdn.net/u010425776/article/details/54290526