Analysis of the problems caused by Java Concurrent multithreading and how to ensure thread safety

Time:2021-6-10

Analysis of various problems in Java multithreading

Before we start, we need to mention the preceding chapter

To get a better understanding of what’s going on in this section

  1. Basic concepts of Java concurrency
  2. Java Concurrent Process vs thread

First of all, let’s talk about the advantages of concurrency, and according to the characteristics of advantages, we will introduce the security issues that concurrency should pay attention to

1 advantages of concurrency

With the development of technology, the performance of CPU, memory and I / O devices is also improving. However, there is always a core contradictionThere are speed differences among CPU, memory and I / O devices. CPU is much faster than memory, memory is much faster than I / O device.

According to the bucket short board theory, how much water a bucket can hold depends on the shortest board. The overall performance of the program depends on the slowest operation I / O, that is, it is invalid to improve the CPU performance unilaterally.

In order to make rational use of the high performance of CPU and balance the speed differences among them, computer architecture, operating system and compiler have made contributions

  • CPU increases cacheTo balance the speed difference with memory;
  • The operating system adds process and threadIn order to balance the speed difference between CPU and I / O device, the CPU is time-sharing multiplexed;
  • Compiler optimizes instruction execution orderSo that the cache can be used more reasonably.

Among them, the process and thread make the computer and program have the ability to deal with tasks concurrentlyTwo important advantages

  • Improve resource utilization
  • Reduce program response time

1.1 improve resource utilization

image.png

​ When reading files from disk, most CPU time is spent waiting for disk to read data. During this period, the CPU is very idle. It can do something else. By changing the operation order, we can make better use of CPU resources. The use of concurrency is not necessarily disk IO, but also network IO and user input. However, no matter what kind of IO is much slower than CPU and memory io. Threads can not improve the speed, but can do other things when performing a time-consuming function. Multithreading allows your program to process files without appearing stuck

1.2 reduce program response time

​ In order to shorten the response time of the program, it is also a common way to use multithreaded applications. Another common purpose of turning a single threaded application into a multithreaded application is to achieve a faster response application. Imagine a server application that listens for incoming requests on a certain port. When a request arrives, it processes the request and then returns to listen.

The process of the server is as follows:

public class SingleThreadWebServer {
    public static void main(String[] args) throws IOException{
        ServerSocket socket = new ServerSocket(80);
        while (true) {
            Socket connection = socket.accept();
            handleRequest(connection);
        }
    }
}

If a request takes a lot of time to process, the new client will not be able to send the request to the server during this time. Only when the server is listening can the request be received. Another design is that the listener thread passes the request to the worker thread and immediately returns to listen. The worker thread can process the request and send a reply to the client. This design is as follows:

public class ThreadPerTaskWebServer {
    public static void main(String[] args) throws IOException {
        ServerSocket socket = new ServerSocket(80);
        while (true) {
            final Socket connection = socket.accept();
            Runnable workerThread = new Runnable() {
                public void run() {
                    handleRequest(connection);        
                }    
            };    
         }  
     } 
}

In this way, the server thread quickly returns to listen. Therefore, more clients can send requests to the server. The service has also become more responsive.

The same is true for desktop applications. If you click a button to start running a time-consuming task, the thread will not only execute the task, but also update the window and button, then the application will not seem to respond during the task execution. Instead, the task can be passed to the worker thread. When the worker thread is busy processing tasks, the window thread can freely respond to the requests of other users. When the worker thread completes the task, it sends a signal to the window thread. The window thread can update the application window and display the results of the task. For users, this kind of program with worker thread design appears to be more responsive.

2. Security problems caused by concurrency

Concurrency security is to ensure that the results of concurrent processing are in line with expectations
Concurrency security needs to be guaranteedThree characteristics:

Atomicity:Generally speaking, the related operations will not be interfered by other threads in the middle of the process. Generally speaking, the synchronization mechanism (locking:sychronizedLock)Implementation.

Order:Ensure the serial semantics within the thread, avoid instruction rearrangement and so on

Visibility: when a thread modifies a shared variable, its state can be immediately known by other threads, which is usually interpreted as reflecting the local state of the thread to the main memory,volatileIs responsible for ensuring visibility

PS: forvolatileThis keyword needs to be written separately, and the following updates should be kept on the official account.Java dictionary

2.1 atomicity

​ In the early days, CPU speed is much faster than IO operation. When a program reads a file, it can mark itself as “sleep state” and give up the right to use the CPU. After waiting for data to be loaded into memory, the operating system will wake up the process, and then it will have the opportunity to regain the right to use the CPU
​ These operations will cause process switching, different processes do not share memory space, so process to do task switching must switch memory mapping address
All threads created by a process share the same memory space, so the cost of task switching is very low
So what we’re talking about nowTask switchingIt’s all aboutThread switch

In high-level language, a statement often needs multiple CPU instructions to complete, such as:

count += 1, requires at least three CPU instructions

  • Instruction 1: first, you need to load the variable count from memory into the CPU register;
  • Instruction 2: after that, execute the + 1 operation in the register;
  • Instruction 3: finally, write the result to memory (the cache mechanism causes the CPU cache to be written instead of memory).

Atomic problems:

​ For the above three instructions, let’s assume count = 0. If thread a switches threads after instruction 1 is executed, and thread a and thread B execute according to the sequence shown in the figure below, we will find that both threads execute count + = 1, but the result is not 2 we expect, but 1.
image.png

We call the feature that one or more operations are not interrupted in the process of CPU execution as atomicity. The atomic operations that CPU can guarantee are CPU instruction level, not high-level language operators, which is against our intuition. Therefore, many times we need to ensure the atomicity of operations at the high-level language level.

2.2 order

​ As the name suggests, orderliness refers to the sequence in which programs are executed. In order to optimize performance, compilers sometimes change the order of statements in a program

for instance:

​ Double check to create singleton object. In the method of getting instance getinstance(), we first determine whether the instance is empty. If it is empty, lock singleton.class and check again whether the instance is empty. If it is still empty, create an instance of singleton

public class Singleton {
  static Singleton instance;
  static Singleton getInstance(){
    if (instance == null) {
      synchronized(Singleton.class) {
        if (instance == null)
          instance = new Singleton();
        }
    }
    return instance;
  }
}

​ If threads a and B call getinstance() method to get instance at the same time, they will check that instance is null at the same time, and then lock singleton.class. At this time, the JVM guarantees that only one lock is successful, and the other thread will wait for the state; Suppose thread a locks successfully, then thread a releases the lock after a new instance, thread B wakes up, and thread B locks again. At this time, the locking is successful, and thread B checks whether the instance is null, and finds that it has been instantiated and will not create another instance

This piece of code and logic seems to be OK, but in fact there is a problem with the getInstance () method. The problem lies in the new operation. We think that the new operation should be:

1. Allocate memory

2. Initialize the singleton object in this memory

3. Give the memory address to the instance variable

But the actual operation of the optimized JVM is as follows:

1 allocate memory

2 give the address to the instance variable

3 initialize singleton object in memory

image.png

After optimization, we will end the instantiation operation when another thread accesses the member variable of instance and gets that the object is not null. Returning instance will trigger a null pointer exception.

2.3 visibility issues

The modification of a shared variable by one thread that another thread can see immediately is calledvisibility

In modern multi-core CPU, each core has its own cache. When multiple threads execute on different CPU cores, threads operate on different CPU caches,

image.png

Examples of thread insecurity

In the following code, every time the add10k () method is executed, 10000 count + = 1 operations will be cycled. In the calc () method, we create two threads. Each thread calls the add10k () method once. Let’s think about the result of executing the calc () method?

class Test {
    private static long count = 0;
    private void add10K() {
        int idx = 0;
        while(idx++ < 10000) {
            count += 1;
        }
    }

    public static  long getCount(){
        return count;
    }
    public static void calc() throws InterruptedException {
        final Test test = new Test();
        //Create two threads and execute the add () operation
        Thread th1 = new Thread(()->{
            test.add10K();
        });
        Thread th2 = new Thread(()->{
            test.add10K();
        });
        //Start two threads
        th1.start();
        th2.start();
        //Wait for two threads to finish executing
        th1.join();
        th2.join();
    }

    public static void main(String[] args) throws InterruptedException {
        Test.calc();
        System.out.println(Test.getCount());
        //Run three times and output 11880 12884 14821 respectively
    }
}

​ Intuition tells us that it should be 20000, because when we call add10k () twice in a single thread, the value of count is 20000, but in fact, the result of calc () is a random number between 10000 and 20000. Why?

​ Suppose that thread a and thread B start to execute at the same time, then count = 0 will be read to their respective CPU cache for the first time. After count + = 1 is executed, the values in their respective CPU cache are all 1. After writing to the memory at the same time, we will find that the value in the memory is 1 instead of the expected 2. After that, because each CPU cache has the value of count, the two threads calculate based on the value of count in the CPU cache, so the final value of count is less than 20000. This is the visibility of the cache.

​ Cycle 10000 times count + = 1 operation, if you change to cycle 100 million times, you will find that the effect is more obvious, and the final value of count is close to 100 million instead of 200 million. If the number of loops is 10000, the value of count is close to 20000. The reason is that two threads are not started at the same time, and there is onetime difference

How to ensure concurrent security

To understand the method of ensuring concurrency security, we should first understand what synchronization is

Synchronization is to ensure that the shared data can be accessed by only one thread at the same time when multiple threads access the shared data concurrently

There are three ways to ensure concurrency security

1. Blocking synchronization (tragedies)

Blocking synchronization, also known as mutex synchronization, is a common means to ensure the correctness of concurrency. Critical sections, mutex and semaphore are the main implementation methods of mutex

The most typical case is the use ofsynchronizedorLock

The main problem of mutex synchronization is the performance problem caused by thread blocking and wake-upMutex synchronization is a pessimistic concurrency strategy. We always think that as long as we don’t do the right synchronization measures, there will be problems. No matter whether the shared data will compete or not, it has to lock (this is the conceptual model, in fact, virtual opportunity optimizes a large part of unnecessary locking), user mode core mode conversion, lock counter maintenance and check whether there are blocked threads that need to wake up.

2. Non blocking synchronization (leguan lock)

Optimistic concurrency strategy based on conflict detection: operate first, if there is no other thread competing for shared data, then the operation is successful, otherwise take compensation measures (try again and again until success). Many implementations of this optimistic concurrency strategy do not need to block threads, so this synchronization operation is called non blocking synchronization

Optimistic lock instructions are common

  • Test amd set
  • Fetch and increase
  • Swap (SWAP)
  • Compare and exchange (CAS)
  • Load linked / store conditional

Typical Java application scenario: atomic class in j.u.c package (based onUnsafeCAS (compare and swap) operation of class

3. No synchronization

To ensure thread safety, synchronization is not necessary. Synchronization is just to ensure the correctness of shared data contention. If a method does not involve shared data, it naturally does not need synchronization.

In JavaNo synchronization schemeyes:

  • Reentrant code-Also called pure code. If there’s a method, it’s easyThe return result is predictableThat is to say, as long as the same data is input, the same result can be returned, which satisfies the reentry property. The program can continue to execute at the interrupted place, and the execution result is not affected. Of course, it is thread safe.
  • Thread local storage-UseThreadLocalA local copy of the shared variable is created in each thread, this copy can only be accessed by the current thread, and other threads cannot access it, so naturally it is thread safe.

4 Summary

​ For the advantage of concurrency, we choose multithreading, which brings us benefits and problems. To deal with these security problems, we choose to lock the shared data to only one thread to ensure the data security during concurrency. At this time, locking also brings us many problems, such as deadlock, livelock, thread starvation and so on

We will analyze it in the next articleLiveness problems caused by lockingPlease look forward to it

Pay attention to the official account: Java treasure
a