An article to understand Java concurrency and thread safety (1)

I. Introduction

    For a long time, I have been wanting to analyze the essence of Java thread safety, but because of some microscopic points I could not understand, so I put it on hold, and I slowly figured it out some time ago, so I connected all the points, and organized them while the thinking was clear. into such an article.

2. Introduction

    1. Why are there multiple threads?

    2. What is the essential problem of thread safety description?

    3. Java Memory Model (JMM) data visibility issues, instruction reordering, memory barriers

3. Reveal the answer

1. Why are there multiple threads?

    When it comes to multithreading, it is easy to equate with high performance, but this is not the case. For a simple example, adding from 1 to 100, computing with four threads is not necessarily faster than one thread. Because thread creation and context switching is a huge overhead.

    So what is the original intention of designing multithreading? Let's take a practical example of this. Computers usually need to interact with people. Suppose the computer has only one thread, and this thread is waiting for the user's input, then during the waiting process, the CPU can't do anything but wait, causing CPU utilization is low. If it is designed to be multi-threaded, while the CPU is waiting for resources, it can switch to other threads to improve CPU utilization.

    Most modern processors contain multiple CPU cores, so for tasks with a large amount of computation, they can be disassembled into multiple small tasks for concurrent execution in a multi-threaded manner to improve computing efficiency.

    To sum up, it is nothing more than two points, improving CPU utilization and improving computing efficiency.

2. The essence of thread safety

    Let's look at an example first:

public class Add {
	private int count = 0;

	public static void main(String[] args) {
		CountDownLatch countDownLatch = new CountDownLatch(4);
		Add add = new Add();
		add.doAdd(countDownLatch);
		try {
			countDownLatch.await();
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		System.out.println(add.getCount());

	}
	public void doAdd(CountDownLatch countDownLatch) {
		for (int i = 0; i < 4; i++) {
			new Thread(new Runnable() {
				public void run() {
					for (int j = 0; j < 25; j++) {
						count++;
					}
					countDownLatch.countDown();
				}
			}).start();
		}
	}

	public int getCount() {
		return count;
	}

}

    The above is an example of auto-incrementing a variable 100 times, but using 4 threads, each thread increments 25 times, using CountDownLatch and other 4 threads to finish executing, and print out the final result. Actually, we want the result of the program to be 100, but the result printed out is not always 100.

    This leads to the problem described by thread safety. Let's first describe thread safety in popular words:

    Thread safety is about making the program run as we want, or in other words, making the program execute as we see it.

    To explain the sentence I summed up, we first created an add object and called the doAdd method of the object. Originally, we hoped that each thread would increment 25 times in an orderly manner, and finally get the correct result. This object is thread-safe if the program runs as we pre-set it.

    Let's take a look at Brian Goetz's description of thread safety: when multiple threads access an object, if the scheduling and alternation of these threads in the runtime environment are not considered, there is no need for additional synchronization, or any additional synchronization on the caller. Other coordination operations, calling the behavior of this object can get the correct result, then this object is thread-safe.

    Let's analyze why this code does not always ensure correct results.

    3. Java Memory Model (JMM) data visibility issues, instruction reordering, memory barriers

    Let's start with the hardware efficiency of the computer. The computing speed of the CPU is several orders of magnitude faster than that of the memory. In order to balance the contradiction between the CPU and the memory, the cache is introduced. Each CPU has a cache, and even a multi-level cache L1, L2 and L3, then the interaction between cache and memory requires a cache coherence protocol, which will not be explained in depth here. Then the interaction between the final processor, cache, and main memory is as follows:

    Then the Java Memory Model (JMM for short) also defines the relationship between threads, working memory, and main memory, which is very similar to the definition of hardware.

    Here, by the way, the area division of the Java virtual machine runtime memory

    Method area: storage class information, constants, static variables, etc., shared by each thread

    Virtual machine stack: The execution of each method will create a stack frame, which is used to store local variables, operand stacks, dynamic links, etc. The virtual machine stack mainly stores this information, and the thread is private

    Native method stack: Native method services used by the virtual machine, such as c programs, thread private

    Program counter: record which line the program runs to, equivalent to the line number counter of the current thread bytecode, thread private

    Heap: New instance objects are stored in this area, which is the main battlefield of GC and shared by threads.

    Therefore, for the main memory defined by JMM, most of the time, it can correspond to the area shared by threads such as heap memory and method area. This is only a conceptual correspondence. In fact, some program counters, virtual machine stacks, etc. are also placed in the main memory. machine design.

    Well, after understanding the JMM memory model, let's analyze why the above program did not get the correct result. See the figure below. Threads A and B simultaneously read the initial count value of the main memory and store it in their respective working memory. At the same time, they perform an auto-increment operation and write it back to the main memory, and finally get the wrong result.

    Let's take a closer look at the essential reasons for this error:

    (1) Visibility, the latest value of working memory does not know when it will be written back to main memory

    (2) Orderly, the shared variables must be accessed in an orderly manner between threads. We use the concept of "horizon" to describe this process. From the perspective of thread B, when he sees that thread A has completed the operation, After writing the value back to the memory, immediately read the latest value to do the operation. A thread should also read B immediately after the operation is completed, and perform the operation, so that the correct result is obtained.

    Next, let's analyze in detail why it is limited in terms of visibility and orderliness.

    Adding the volatile keyword to count ensures visibility.

private volatile int count = 0;

    The volatile keyword will add the lock prefix to the final compiled instruction, and the lock prefix instruction does three things

    (1) Prevent instruction reordering (the analysis of this problem is not important here, which will be described in detail later)

    (2) Lock the bus or use the lock cache to ensure the atomicity of execution. Early processing may use the method of locking the bus, so that other processors cannot access the memory through the bus, and the overhead is relatively large. The current processors use The way of locking the cache is solved in cooperation with cache coherence.

    (3) Write all the data in the buffer back to the main memory, and ensure that the variables cached by other processors are invalid

    Since the visibility is guaranteed and the volatile keyword is added, why can't you get the correct result? The reason is count++, not an atomic operation, count++ is equivalent to the following steps:

   (1), read count from the main memory and assign it to the thread copy variable:

            temp=count

    (2), add 1 to the thread copy variable

            temp=temp+1

    (3), the thread copy variable is written back to the main memory

            count=temp

    Even if it is really strict to lock the bus, only one processor can access the count variable at the same time, but when the operation of step (2) is performed, other CPUs can already access the count variable. At this time, the latest operation The result has not been flushed back to the main memory, resulting in a wrong result, so the order must be guaranteed.

    Then the essence of guaranteeing the sequence is to ensure that only one CPU can execute the critical section code at the same time. At this time, the method is usually to add locks. There are two types of locks: pessimistic locks and optimistic locks. Such as the typical pessimistic lock synchronized, the typical optimistic lock ReentrantLock under the JUC package.

    To sum up: To ensure thread safety, two points must be guaranteed: the visibility of shared variables and the sequentiality of critical section code access.

    The next blog will look at an out-of-order Java world from the perspective of threads, from the microscopic perspective of instruction reordering, memory barriers, etc., please pay attention to the next blog "An article to understand Java concurrency and thread safety (two )"

   

Happiness comes from sharing.

   This blog is original by the author, please indicate the source for reprinting

        

Guess you like

Origin http://43.154.161.224:23101/article/api/json?id=325443793&siteId=291194637