JUC Lecture 2: Java Concurrency Theory Basics: Java Memory Model (JMM) and Threads

JUC Lecture 2: Java Concurrency Theory Basics: Java Memory Model (JMM) and Threads

This article is the second lecture of JUC: Java concurrency theory foundation, Java memory model (JMM) and threads. From a theoretical perspective, concurrency security issues and the principles of JMM to deal with concurrency issues are introduced .

1. Understand the interview questions from major BAT companies

Please continue with these questions, which will greatly help you better understand the theoretical basis of concurrency.

  • What problems does the emergence of multi-threading solve?
  • What does thread unsafety mean ? Give an example
  • What is the nature of thread insecurity when concurrency occurs ? Visibility, atomicity and orderliness.
  • How does Java solve concurrency problems ? 3 keywords, JMM and 8 Happens-Before
  • Is thread safety either true or false? No
  • What are the implementation ideas for thread safety ?
  • How to understand the difference between concurrency and parallelism?

2. Why multi-threading is needed

As we all know, the speeds of CPU, memory, and I/O devices are very different (you can refer to this article: Performance Lecture 2: Performance Optimization-Numbers that every programmer should know ). In order to make reasonable use of the high speed of the CPU , Performance, balancing the speed differences between the three, computer architecture, operating system, and compiler all contribute , mainly as follows:

  • The CPU adds cache to equalize the speed difference with memory; // causing 可见性problems
  • The operating system adds processes and threads to time-share the CPU and balance the speed difference between the CPU and I/O devices; // causing 原子性problems
  • The compiler optimizes the order of instruction execution so that the cache can be used more rationally. // cause 有序性problems

3. Thread unsafe example

If multiple threads access the same shared data without taking synchronization operations, the results of the operations will be inconsistent.

The following code demonstrates that 1000 threads perform an increment operation on cnt at the same time. After the operation is completed, its value may be less than 1000.

public class ThreadUnsafeExample {
    
    

    private int cnt = 0;

    public void add() {
    
    
        cnt++;
    }

    public int get() {
    
    
        return cnt;
    }
}

public static void main(String[] args) throws InterruptedException {
    
    
    final int threadSize = 1000;
    ThreadUnsafeExample example = new ThreadUnsafeExample();
    final CountDownLatch countDownLatch = new CountDownLatch(threadSize);
    ExecutorService executorService = Executors.newCachedThreadPool();
    for (int i = 0; i < threadSize; i++) {
    
    
        executorService.execute(() -> {
    
    
            example.add();
            countDownLatch.countDown();
        });
    }
    countDownLatch.await();
    executorService.shutdown();
    System.out.println(example.get());
}

997 // 结果总是小于1000

4. The root cause of concurrency problems: three elements of concurrency

Why is the output of the above code not 1000? What is the source of the concurrency problem?

4.1. Visibility: Caused by CPU cache

Visibility : Modifications to shared variables by one thread can be immediately seen by another thread.

For a simple example, look at the following code:

//线程1执行的代码
int i = 0;
i = 10;
 
//线程2执行的代码
j = i;

If thread 1 is executed by CPU1, thread 2 is executed by CPU2. It can be seen from the above analysis that when thread 1 executes the sentence i = 10, it will first load the initial value of i into the cache of CPU1, and then assign it to 10, then the value of i in the cache of CPU1 becomes 10 , but it is not written to the main memory immediately .

At this time, thread 2 executes j = i. It will first go to the main memory to read the value of i and load it into the cache of CPU2. Note that the value of i in the memory is still 0 at this time, then the value of j will be 0, and Not 10.

This is a visibility problem. After thread 1 modifies variable i, thread 2 does not immediately see the value modified by thread 1.

4.2. Atomicity: caused by time-sharing multiplexing

Atomicity: that is, an operation or multiple operations are either fully executed and the execution process will not be interrupted by any factors, or they are not executed at all.

For a simple example, look at the following code:

int i = 1;

// 线程1执行
i += 1;

// 线程2执行
i += 1;

What needs to be noted here is: i += 1three CPU instructions are required

  1. Read variable i from memory to CPU register;
  2. Perform i + 1 operation in CPU register;
  3. Write the final result i into the memory (the caching mechanism causes the CPU cache instead of the memory to be written).

Due to the existence of CPU time-sharing (thread switching), after thread 1 executes the first instruction, it switches to thread 2 for execution. If thread 2 executes these three instructions, it will switch to thread 1 to execute the next two instructions. , will cause the last i value written to the memory to be 2 instead of 3.

4.3. Orderliness: caused by reordering

Orderliness: That is, the order of program execution is executed in the order of code . For a simple example, look at the following code:

int i = 0;   
boolean flag = false;
i = 1;                //语句1  
flag = true;          //语句2

The above code defines an int type variable and a boolean type variable, and then assigns values ​​to the two variables respectively. From the code sequence point of view, statement 1 is before statement 2. So when the JVM actually executes this code, will it guarantee that statement 1 will be executed before statement 2? Not necessarily, why? Instruction duplication may occur here. Sorting (Instruction Reorder).

To improve performance when executing programs, compilers and processors often reorder instructions. There are three types of reordering:

  • Compiler-optimized reordering . The compiler can rearrange the execution order of statements without changing the semantics of a single-threaded program.
  • Instruction-level parallel reordering . Modern processors use instruction-level parallelism (ILP) to execute multiple instructions in an overlapping manner. If there are no data dependencies, the processor can change the order in which statements correspond to machine instructions.
  • Memory system reordering . Because the processor uses cache and read/write buffers, this can cause load and store operations to appear to be executed out of order.

From the Java source code to the final actually executed instruction sequence, it will undergo the following three reorderings:

img

The above 1 belongs to the compiler reordering, and 2 and 3 belong to the processor reordering . These reorderings can cause memory visibility problems in multi-threaded programs. For compilers, JMM's compiler reordering rules prohibit certain types of compiler reordering (not all compiler reorderings are prohibited). For processor reordering, JMM's processor reordering rules require the Java compiler to insert specific types of memory barriers (called memory fences by Intel) instructions when generating the instruction sequence, and prohibit specific types of instructions through memory barrier instructions. Processor reordering (not all processor reordering must be disabled).

For details, please refer to: JVM Lecture 6: Reordering Chapter of Java Memory Model Detailed Explanation.

5. How does JAVA solve concurrency problems: JMM (Java Memory Model)

The Java memory model is a very complex specification. I strongly recommend you read this article: JVM Lecture 6: Detailed explanation of the Java memory model .

The first dimension of understanding: core knowledge points

JMM can essentially be understood as the Java memory model that specifies how the JVM provides methods for disabling caching and compilation optimization on demand . Specifically, these methods include:

  • The three keywords volatile, synchronized and final
  • Happens-Before Rule

The second dimension of understanding: visibility, order, atomicity

  • atomicity

In Java, reading and assigning operations to variables of basic data types are atomic operations, that is, these operations cannot be interrupted and are either executed or not. Please analyze which of the following operations are atomic operations:

x = 10;        //语句1: 直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中
y = x;         //语句2: 包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。
x++;           //语句3: x++包括3个操作:读取x的值,进行加1操作,写入新的值。
x = x + 1;     //语句4: 同语句3

Of the four statements above, only the operation of statement 1 is atomic.

In other words, only simple reading and assignment (and the number must be assigned to a variable, mutual assignment between variables is not an atomic operation) are atomic operations .

As can be seen from the above, the Java memory model only guarantees that basic reading and assignment are atomic operations . If you want to achieve atomicity for a larger range of operations, you can achieve it through synchronized and Lock. Since synchronized and Lock can ensure that only one thread executes the code block at any time, there is no atomicity problem, thus ensuring atomicity.

  • visibility

Java provides the volatile keyword to ensure visibility .

When a shared variable is modified volatile, it will ensure that the modified value will be updated to the main memory immediately . When other threads need to read it, it will read the new value from the memory.

Ordinary shared variables cannot guarantee visibility , because after an ordinary shared variable is modified, it is uncertain when it will be written to the main memory. When other threads read it, the original old value may still be in the memory at this time, so Visibility is not guaranteed.

In addition, visibility can also be guaranteed through synchronized and Lock. Synchronized and Lock can ensure that only one thread acquires the lock at the same time and then executes the synchronization code, and the modifications to the variables are flushed to the main memory before releasing the lock . Visibility is therefore guaranteed.

  • Orderliness

In Java, you can use the volatile keyword to ensure a certain "orderliness" (the specific principle is described in the next section). In addition, orderliness can be ensured through synchronized and Lock. Obviously, synchronized and Lock ensure that one thread executes synchronization code at each moment, which is equivalent to letting threads execute synchronization code sequentially, which naturally ensures orderliness. Of course, JMM ensures orderliness through Happens-Before rules .

5.1. Keywords: volatile, synchronized and final

The following three articles analyze these three keywords in detail:

5.2. Happens-Before rule

As mentioned above, volatile and synchronized can be used to ensure orderliness. In addition, the JVM also stipulates the occurrence-before principle, allowing one operation to be completed before another operation without control .

1. Single thread principle

Single Thread rule

Within a thread, operations at the front of the program occur before operations at the back.

image

2. Monitor locking rules

Monitor Lock Rule

An unlock operation occurs before a subsequent lock operation on the same lock.

image

3. Volatile variable rules

Volatile Variable Rule

A write operation to a volatile variable occurs before a subsequent read operation to the variable .

Insert image description here

4. Thread startup rules

Thread Start Rule

The Thread object's start() method call precedes every action that occurs on this thread.

image

5. Thread joining rules

Thread Join Rule

The end of the Thread object occurs before the join() method returns.

image

6. Thread interruption rules

Thread Interruption Rule

The call to the thread's interrupt() method first occurs when the code of the interrupted thread detects the occurrence of an interrupt event. Whether an interrupt occurs can be detected through the interrupted() method.

7. Object termination rules

Finalizer Rule

The completion of initialization of an object (the end of constructor execution) occurs first at the beginning of its finalize() method.

8. Transitivity

Transitivity

If operation A occurs before operation B, and operation B occurs before operation C, then operation A occurs before operation C.

6. Thread safety: not a true or false proposition

A class is thread-safe when it can be called safely from multiple threads .

Thread safety is not a true or false proposition. Shared data can be divided into the following five categories in order of security: immutable, absolute thread safety, relative thread safety, thread compatibility and thread opposition .

6.1. Immutable

Immutable objects must be thread-safe and do not need to take any thread-safety measures. As long as an immutable object is constructed correctly, you will never see it in an inconsistent state across multiple threads.

In a multi-threaded environment, objects should be made immutable as much as possible to ensure thread safety .

Immutable types:

  • The basic data type modified by the final keyword
  • String
  • enumeration type
  • Some subclasses of Number , such as numerical packaging types such as Long and Double, and large data types such as BigInteger and BigDecimal. But the atomic classes AtomicInteger and AtomicLong, both of which are Number, are mutable.

For collection types, you can use Collections.unmodifiableXXX()the method to obtain an immutable collection .

public class ImmutableExample {
    
    
    public static void main(String[] args) {
    
    
        Map<String, Integer> map = new HashMap<>();
        Map<String, Integer> unmodifiableMap = Collections.unmodifiableMap(map);
        unmodifiableMap.put("a", 1);
    }
}
Exception in thread "main" java.lang.UnsupportedOperationException
    at java.util.Collections$UnmodifiableMap.put(Collections.java:1457)
    at ImmutableExample.main(ImmutableExample.java:9)

Collections.unmodifiableXXX()The original collection is copied first, and any method that needs to modify the collection directly throws an exception .

public V put(K key, V value) {
    
    
    throw new UnsupportedOperationException();
}

6.2. Absolute thread safety

Regardless of the runtime environment, the caller does not require any additional synchronization measures.

6.3. Relative thread safety

Relative thread safety requires ensuring that individual operations on this object are thread safe , and no additional safeguards are required when calling. However, for some specific sequences of consecutive calls, it may be necessary to use additional synchronization means on the calling side to ensure the correctness of the calls .

In the Java language, most thread-safe classes belong to this type, such as Vector, HashTable, collections wrapped by the synchronizedCollection() method of Collections, etc.

For the following code, if the thread that deletes the element deletes an element of the Vector, and the thread that gets the element tries to access an element that has been deleted, an ArrayIndexOutOfBoundsException will be thrown.

public class VectorUnsafeExample {
    
    
    private static Vector<Integer> vector = new Vector<>();

    public static void main(String[] args) {
    
    
        while (true) {
    
    
            for (int i = 0; i < 100; i++) {
    
    
                vector.add(i);
            }
            ExecutorService executorService = Executors.newCachedThreadPool();
            executorService.execute(() -> {
    
    
                for (int i = 0; i < vector.size(); i++) {
    
    
                    vector.remove(i);
                }
            });
            executorService.execute(() -> {
    
    
                for (int i = 0; i < vector.size(); i++) {
    
    
                    vector.get(i);
                }
            });
            executorService.shutdown();
        }
    }
}
Exception in thread "Thread-159738" java.lang.ArrayIndexOutOfBoundsException: Array index out of range: 3
    at java.util.Vector.remove(Vector.java:831)
    at VectorUnsafeExample.lambda$main$0(VectorUnsafeExample.java:14)
    at VectorUnsafeExample$$Lambda$1/713338599.run(Unknown Source)
    at java.lang.Thread.run(Thread.java:745)

If you want to ensure that the above code can be executed correctly, you need to synchronize the code for deleting elements and getting elements .

executorService.execute(() -> {
    
    
    synchronized (vector) {
    
    
        for (int i = 0; i < vector.size(); i++) {
    
    
            vector.remove(i);
        }
    }
});
executorService.execute(() -> {
    
    
    synchronized (vector) {
    
    
        for (int i = 0; i < vector.size(); i++) {
    
    
            vector.get(i);
        }
    }
});

6.4. Thread compatible

Thread compatibility means that the object itself is not thread-safe, but it can be ensured that the object can be used safely in a concurrent environment by correctly using synchronization means on the calling side . We usually say that a class is not thread-safe, and most of the time it means This is the case. Most classes in the Java API are thread-compatible, such as the collection classes ArrayList and HashMap corresponding to the previous Vector and HashTable.

6.5. Thread opposition

Thread opposition refers to code that cannot be used concurrently in a multi-threaded environment regardless of whether the calling end has taken synchronization measures. Since the Java language is inherently multi-threaded, code that excludes multi-threading such as thread opposition rarely occurs and is usually harmful and should be avoided as much as possible.

7. How to implement thread safety

7.1. Blocking synchronization

synchronized 和 ReentrantLock。

For a preliminary understanding, you can see:

For detailed analysis please see:

7.2. Non-blocking synchronization

The main problem of mutually exclusive synchronization is the performance problem caused by thread blocking and waking up , so this kind of synchronization is also called blocking synchronization.

Mutually exclusive synchronization is a pessimistic concurrency strategy . It is always believed that as long as correct synchronization measures are not taken, problems will definitely occur. Regardless of whether there is competition for shared data, it must be locked (what is discussed here is a conceptual model. In fact, the virtual machine optimizes a large part of unnecessary locking), user mode core mode conversion, maintenance of lock counters and Check whether there are blocked threads that need to be awakened and other operations.

(1) CAS

With the development of hardware instruction sets, we can use an optimistic concurrency strategy based on conflict detection : perform the operation first, and if no other threads compete for the shared data, the operation is successful, otherwise compensatory measures are taken (continuously retry until successful so far) . Many implementations of this optimistic concurrency strategy do not require threads to be blocked, so this synchronization operation is called non-blocking synchronization.

Optimistic locking requires the two steps of operation and conflict detection to be atomic . Mutex synchronization can no longer be used to ensure this, and it can only be accomplished by hardware . The most typical atomic operation supported by hardware is: Compare-and-Swap (CAS). The CAS instruction requires three operands, namely the memory address V, the old expected value A and the new value B. When the operation is performed, the value of V is updated to B only if the value of V is equal to A.

(2) AtomicInteger

The integer atomic class AtomicInteger in the JUC package uses the CAS operation of the Unsafe class in its methods such as compareAndSet() and getAndIncrement().

The following code uses AtomicInteger to perform an increment operation.

private AtomicInteger cnt = new AtomicInteger();

public void add() {
    
    
    cnt.incrementAndGet();
}

The following code is the source code of incrementAndGet(), which calls unsafe getAndAddInt().

public final int incrementAndGet() {
    
    
    return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}

The following code is the source code of getAndAddInt(). var1 indicates the object memory address, var2 indicates the offset of the field relative to the object memory address, and var4 indicates the value to be added for the operation, which is 1 here. Get the old expected value through getIntVolatile(var1, var2), and perform CAS comparison by calling compareAndSwapInt(). If the value in the memory address of this field is equal to var5, then update the variable with the memory address var1+var2 to var5+var4.

You can see that getAndAddInt() is performed in a loop. If a conflict occurs, it is constantly retried .

public final int getAndAddInt(Object var1, long var2, int var4) {
    
    
    int var5;
    do {
    
    
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
    return var5;
}

(3) ABA

If a variable is first read with value A, its value is changed to B, and later changed back to A, the CAS operation will mistakenly think that it has never been changed.

The JUC package provides a marked atomic reference class AtomicStampedReference to solve this problem, which can ensure the correctness of CAS by controlling the version of the variable value . In most cases, ABA problems will not affect the correctness of program concurrency. If you need to solve ABA problems, switching to traditional mutually exclusive synchronization may be more efficient than atomic classes.

For detailed analysis of CAS, Unsafe and atomic classes, please see:

7.3. No synchronization solution

To ensure thread safety, synchronization is not necessarily necessary. If a method does not involve sharing data , then it naturally does not require any synchronization measures to ensure correctness.

(1) Stack closure

When multiple threads access local variables of the same method , thread safety problems will not occur because local variables are stored in the virtual machine stack and are thread-private.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class StackClosedExample {
    
    
    public void add100() {
    
    
        int cnt = 0;
        for (int i = 0; i < 100; i++) {
    
    
            cnt++;
        }
        System.out.println(cnt);
    }
}
public static void main(String[] args) {
    
    
    StackClosedExample example = new StackClosedExample();
    ExecutorService executorService = Executors.newCachedThreadPool();
    executorService.execute(() -> example.add100());
    executorService.execute(() -> example.add100());
    executorService.shutdown();
}
100
100

For a more detailed analysis, please see the detailed explanation of the thread pool in JUC:

(2) Thread Local Storage (Thread Local Storage)

If the data required in a piece of code must be shared with other code , then see if the code that shares the data can be guaranteed to execute in the same thread . If it can be guaranteed, we can limit the visible range of shared data to the same thread . In this way, we can ensure that there is no data contention problem between threads without synchronization.

Applications that meet this characteristic are not uncommon. Most architectural patterns that use consumption queues (such as the "producer-consumer" model) will try to consume the product in one thread. One of the most important application examples is the "Thread-per-Request" processing method in the classic Web interaction model. The widespread application of this processing method allows many Web server applications to use threads . Local storage to solve thread safety issues .

You can use the java.lang.ThreadLocal class to implement thread local storage functionality.

For the following code, threadLocal is set to 1 in thread1 and threadLocal is set to 2 in thread2. After a period of time, threadLocal read by thread1 is still 1, which is not affected by thread2.

public class ThreadLocalExample {
    
    
    public static void main(String[] args) {
    
    
        ThreadLocal threadLocal = new ThreadLocal();
        Thread thread1 = new Thread(() -> {
    
    
            threadLocal.set(1);
            try {
    
    
                Thread.sleep(1000);
            } catch (InterruptedException e) {
    
    
                e.printStackTrace();
            }
            System.out.println(threadLocal.get());
            threadLocal.remove();
        });
        Thread thread2 = new Thread(() -> {
    
    
            threadLocal.set(2);
            threadLocal.remove();
        });
        thread1.start();
        thread2.start();
    }
}

Output results

1

In order to understand ThreadLocal, first look at the following code:

public class ThreadLocalExample1 {
    
    
    public static void main(String[] args) {
    
    
        ThreadLocal threadLocal1 = new ThreadLocal();
        ThreadLocal threadLocal2 = new ThreadLocal();
        Thread thread1 = new Thread(() -> {
    
    
            threadLocal1.set(1);
            threadLocal2.set(1);
        });
        Thread thread2 = new Thread(() -> {
    
    
            threadLocal1.set(2);
            threadLocal2.set(2);
        });
        thread1.start();
        thread2.start();
    }
}

The corresponding underlying structure diagram is:
image

Each Thread has a ThreadLocal.ThreadLocalMap object, and the ThreadLocal.ThreadLocalMap member is defined in the Thread class .

/* ThreadLocal values pertaining to this thread. This map is maintained by the ThreadLocal class. */
ThreadLocal.ThreadLocalMap threadLocals = null;

When calling a ThreadLocal's set(T value) method, first obtain the ThreadLocalMap object of the current thread, and then insert the ThreadLocal->value key-value pair into the Map.

public void set(T value) {
    
    
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}

The get() method is similar.

public T get() {
    
    
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
    
    
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
    
    
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();
}

ThreadLocal is not theoretically designed to solve multi-thread concurrency problems because there is no multi-thread competition at all .

In some scenarios (especially when using thread pools), ThreadLocal may have memory leaks due to the underlying data structure of ThreadLocal.ThreadLocalMap. You should manually call remove() after each use of ThreadLocal as much as possible to avoid ThreadLocal classics. Memory leaks are even a risk of causing chaos to your own business**.

For a more detailed analysis, see: ThreadLocal/InheritableThreadLocal detailed explanation/TTL-MDC log context practice

(3) Reentrant Code

This kind of code is also called pure code. It can interrupt the code at any time during its execution and switch to executing another piece of code (including recursively calling itself). After control returns, the original program will not appear. any errors.

Reentrant code has some common characteristics, such as not relying on data stored on the heap and public system resources, all state variables used are passed in as parameters, and non-reentrant methods are not called.

Guess you like

Origin blog.csdn.net/qq_28959087/article/details/132967295