Causes and solutions of Java thread safety problems

Table of contents

Causes of Thread Safety Issues

1: Preemptive execution

2: Multiple threads modify the same variable

3: Modified operations are not atomic

solution

4: Memory Visibility

5: Instruction reordering

solution


Causes of Thread Safety Issues

When we use java for multi-threaded programming in our daily development, thread safety issues cannot be avoided. So how does thread safety issue specifically reflect in our code? Let's look down.

In fact, the essential reason for the thread safety problem is obvious:

The scheduling of our threads on the CPU is random, unpredictable, and out of order. It is preemptive execution. It can be said that this is the culprit that causes thread safety problems.

Of course, there are other reasons, not just this one that leads to thread safety. Here's what causes thread safety issues:

1: Preemptive execution

2: Multiple threads modify the same variable

3: Modified operations are not atomic

Let's first look at the bugs caused by preemptive execution, multiple threads modifying the same variable, and modification operations that are not atomic.

The so-called preemptive execution means that when multiple threads execute concurrently on the CPU, they are out of order and random, which makes our program unpredictable, and the results will be inconsistent with our expected results, which leads to bugs .

Let's look at a chestnut to understand what is preemptive execution

Let's look at the following code first:

class conner {
    public int count = 0;
    public void add () {
        count++;
    }
    public int get() {
        return count;
    }
}
public class threadDemo11{
    public static void main(String[] args) throws InterruptedException {
        conner conner = new conner();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                conner.add();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                conner.add();
            }
        }); 
        t1.start();  
        t2.start();
        t1.join();
        t2.join();
        System.out.println(conner.get());
    }
}

From the above code, we can see that when the program is executed, we have 2 threads executing concurrently. At this time, the 2 threads perform ++ operations on the same variable at the same time, and one thread performs ++ operations 50,000 times. Perform ++ operations 100,000 times, so our expected result is 100,000. Let's see how it turns out?

 But at this time we found that the results were inconsistent with our expectations, and this caused a bug.

So why is there such a reason? 

In fact, the reason is very simple, that is, multiple threads on the CPU are random, out of order, and executed preemptively, and our add operation is not atomic.

public void add()
   count++;
}

Atoms are actually very simple to understand:

The so-called atom is the smallest unit that cannot be divided. Why is the add operation not atomic?

Because the count++ operation under add is not one instruction on our CPU, but is split into three instructions, namely load add save.

load is to read the value in the memory to the CPU register.

add performs +1 operation on the value on the register.

save writes the value in the register back to memory. 

Since the scheduling order of multi-threads is out of order and random, in the actual execution process, there are actually many permutations of the ++ operations of these two threads.

For example, the following picture:

t1 and t2 have their independent registers.

 

At this time, the t1 thread first executes the load and add operations. Before the save operation is executed, the CPU suddenly schedules the t2 thread to also start to execute the load operation. The value after +1 is saved to the memory, so the value on the memory is still 0 at this time, and the value read by the t2 thread is still 0

If the CPU suddenly schedules to the t1 thread at this time, since the t1 thread executed the save operation last time, the t1 thread will write the value in the register back to the memory at this time, and now the value in the memory is 1. Scheduled to the t2 thread, the t2 thread continues to execute, add, save operations, after the execution, the t2 thread writes the value in the register back to the memory, and a bug occurs at this time.

 It can be seen that the t1 and t2 threads respectively perform a ++ operation on the value in the memory, but the actual value in the memory is 1.

The reason for this bug is that multiple threads try to modify the same variable at this time, and the modification operation is not atomic and can be split into multiple CPU instructions, and because it is split into multiple CPU instructions, and The execution order of threads is preemptive and out of order. This leads to many changes in the order of CPU instructions during the execution of the two threads.

That's the sequence of CPU instructions above that cause the bug, and it's just one of those that cause the bug. 

So how to solve the above problems?

solution

In fact, the solution is very simple. According to the above, we can see that the problem is mainly in the preemptive execution of threads and modification operations. We can perform locking operations on the add method, so that the above problems can be solved.

class Conner01 {
    public int count = 0;
    public void add() {
        synchronized (this) {  
            count++;
        }
    }
}
public class threadDemo14 {
    public static void main(String[] args) throws InterruptedException {
        Conner01 conner01 = new Conner01();
        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                conner01.add();
            }
        });
        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 50000; i++) {
                conner01.add();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        ;
        System.out.println(conner01.count);
    }
}

 It can be seen that we have performed a locking operation in the add method, and this locking operation will solve our thread safety problem.

At this point we can see that the execution result of the code is consistent with our expected result.

So now let's take a closer look at the lock operation in this add aspect.

public void add() {
       synchronized (this) {
           count++;
       }
    }

Entering the synchronized modified code block will trigger the lock operation, where this is to lock the current object.

At this time, if t1 and t2 threads try to lock the same object, lock competition will occur. If t1 and t2 lock different objects, there will be no lock competition.

When the synchronized modified code block comes out, it will be unlocked.

If the t1 thread is successfully locked at this time, then the t2 thread can only block and wait, and it does not involve modifying a variable at the same time. It also solves the problem of thread preemptive execution, and synchronized can also guarantee atomicity. It will make the ++ operation the smallest unit that cannot be divided.

4: Memory Visibility

5: Instruction reordering

What is memory visibility?

Let's look at the following code

public class threadDemo13 {
    //public static int flag = 0;
    public static int flag = 0; 
    public static void main(String[] args) {

        Thread t1 = new Thread(() -> {
           while (flag == 0) {

           }
            System.out.println("t1线程结束");
        });

        Thread t2 = new Thread(() -> {
           Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个结束t1线程的标志");
            flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

Execute this code according to our logic, the t1 thread executes in a loop, and the t2 thread inputs a flag to let the t1 thread end.

But when we run the code, we find that the t1 thread will not end.

 

 At this point the program does not end, but the t1 thread continues in an infinite loop.

The reason for this problem is our compiler optimization strategy.

Compiler optimization in multi-threading will cause code execution results to be inconsistent with our expected solution results, and bugs will occur.

flag == 0 This operation has two instructions in the CPU:

load : load the flag into memory

cmp: Then compare

But the overhead of load is much higher than that of cmp.

Then the compiler finds that the value of the flag is the same every time, so the compiler simply does not read the value in the memory every cycle, and optimizes the load, but reuses the previous value for comparison .

The means of compiler optimization:

Intelligently adjust the code execution logic to ensure that the result of the program remains unchanged, through addition and subtraction statements, through statement transformation, and a series of operations, the execution efficiency of the entire program is greatly improved. Compiler optimization is very good under single thread, but bugs will appear in multi-thread.

solution

Then our mechanism is to let the compiler suspend optimization:

We use the volatile keyword to tell the compiler to suspend optimizations.

 volatile public static int flag = 0; //此时这个变量就禁止编译器进行优化了  代码也就不会出现bug了

At this time, the compiler will not optimize this variable, and it will read data in memory every time instead of reusing the previous value.

But our volatile does not guarantee atomicity, and it is suitable for one thread level and one thread writing situation.

Synchronized can guarantee atomicity, and two threads write together.

Volatile also has the effect of prohibiting instruction reordering:

Instruction reordering is also an optimization strategy of the compiler

The execution order of the code is adjusted to make the program more efficient, provided that the overall logic of the code remains unchanged.

Here is the final code:

public class threadDemo13 {
   
    public volatile static int flag = 0; 
    public static void main(String[] args) {

        Thread t1 = new Thread(() -> {
           while (flag == 0) {

           }
            System.out.println("t1线程结束");
        });

        Thread t2 = new Thread(() -> {
           Scanner scanner = new Scanner(System.in);
            System.out.println("请输入一个结束t1线程的标志");
            flag = scanner.nextInt();
        });
        t1.start();
        t2.start();
    }
}

 At this point, the thread can end normally.

Guess you like

Origin blog.csdn.net/qq_63525426/article/details/129832560