Java Multithreading Series II (Thread Safety)

1. Thread unsafe

If the result of running the code in a multi-threaded environment is in line with our expectations, that is, the result that should be achieved in a single-threaded environment, then the program is said to be thread-safe. Otherwise it is called thread unsafe.

Reasons why threads are unsafe:

  1. For preemptive execution, it can be said that "out-of-order thread scheduling" is the culprit and the root of all evil! ! ! (It is implemented by the operating system kernel and cannot be controlled by programmers)
  2. Multiple threads modify the same variable.
  3. Modification operations are not atomic (the smallest indivisible unit). A certain operation is atomic if it corresponds to a single CPU instruction. If a single operation corresponds to multiple CPU instructions, it is most likely not atomic. Precisely because it is not atomic, there are more variables in the instruction arrangement of multiple threads.
  4. Memory visibility, causing thread insecurity.
  5. Instructions are rearranged, causing thread insecurity.

2. Thread unsafe cases and solutions

1. Modify shared resources

That is, for the situation where multiple threads modify the same variable, since the modification operation may not be atomic (single CPU instruction), under random scheduling of multi-threads, there will be more variables in the arrangement of instructions of multiple threads.

For example, the following code:

class Counter {
    
    
    private int count = 0;
	public void add() {
    
    
            count++;
    }

    public int getCount() {
    
    
        return count;
    }
}

public class ThreadExample_unsafe {
    
    
    public static void main(String[] args) throws InterruptedException {
    
    
        Counter counter = new Counter();
        Thread t1 = new Thread(() -> {
    
    
            for (int i = 0; i < 50000; i++) {
    
    
                counter.add();
            }
        });
        Thread t2 = new Thread(() -> {
    
    
            for (int i = 0; i < 50000; i++) {
    
    
                counter.add();
            }
        });
        t1.start();
        t2.start();

        //等两个线程结束后查看结果
        t1.join();
        t2.join();

        System.out.println(counter.getCount());
    }
}

Result analysis:
For the above code, the two threads t1 and t2 each increment the count 50,000 times. Theoretically, the result should be 100,000, but the actual running result is less than 100,000, even though it is run multiple times. The above phenomenon is precisely because when the two threads t1 and t2 modify count, since each ++ operation is not atomic and can be divided into (1. Read 2. Modify 3. Write), the system randomly schedules With the blessing, there will be many possibilities for the actual order of instructions in the t1 and t2 thread++ operations, which will eventually lead to abnormal results. The following figure depicts two possible situations:

Solution-Lock

For the above scenario, while ensuring concurrent execution, since random scheduling of threads is implemented by the system kernel and cannot be controlled by programmers, and multiple threads modifying the same variable is a business requirement, so thread safety in this scenario must be ensured. We can consider making modification operations atomic.And "locking" can ensure the atomic effect. synchronizedis the keyword used to implement locks in Java. We will introduce it in detail below:

synchronized use

Used in Java synchronizedfor “对象头”locking,synchronized must be used with a specific object

(1) synchronized locks ordinary methods

// 给实例方法加锁
public void add() {
    
    
    synchronized (this) {
    
    
        count++;
    }
}

//如果直接给方法使用synchronized修饰,此时就相当于以this为锁对象
synchronized public void add() {
    
    
       count++;
}

(2) synchronized locks static methods

//给静态方法加锁
public static void test2() {
    
    
	// Counter.class相当于类对象
	synchronized (Counter.class) {
    
    
		
	}
}
//如果直接给方法使用synchronized修饰,此时就相当于以Counter.class为锁对象
synchronized public static void test() {
    
    

}

(3) synchronized locks any code block

// 自定义锁对象
Object locker = new Object();

synchronized (locker) {
    
    
    // 代码逻辑
    // . . .
}

Extension : synchronizedThe method modified by is also called a synchronized method; synchronizedthe code block modified by is also called a synchronized code block.

synchronized property

  1. Entering the synchronized modified code block is equivalent to locking. Exiting the synchronized modified code block is equivalent to unlocking.
  2. Synchronized modified code blocks have atomic effects. That is, locking allows a certain part of multiple threads to be serialized.
  3. The object in synchronized() () can be any Object object. This object is also called a lock object. The lock used by synchronized is stored in the Java object header. It can be roughly understood as: when each object is stored in memory, there is a piece of memory representing the current "locked" state. If it is currently in the "unlocked" state, then It can be used. It needs to be set to the "locked" state when using it. If it is currently in the "locked" state, other threads cannot use it and can only阻塞等待
  4. synchronized is a mutual exclusion lock. The so-called mutual exclusion means that multiple threads cannot lock the same object at the same time. Instead, only one thread can acquire the lock at the same time, and other threads are blocked and waiting. Therefore, if multiple threads try to lock the same lock object, lock competition will occur. If they lock different objects, there will be no lock competition.
  5. Blocking waiting: For each lock, the operating system maintains a waiting queue internally. When the lock is occupied by a thread, other threads try to lock it, but cannot add it, and they will block and wait until After the previous thread is unlocked, the operating system wakes up a new thread and acquires the lock again.
  6. Principle of acquiring locks: After the previous thread is unlocked, the next thread does not acquire the lock immediately. Instead, it depends on the operating system to "wake up". This is part of the work of operating system thread scheduling. Suppose there are three threads ABC. Thread A acquires the lock first, then B tries to acquire the lock, and then C tries to acquire the lock. At this time, both B and C are waiting in the blocking queue. But after A releases the lock, although B is faster than C comes first, but B may not be able to obtain the lock. Instead, it competes with C again, and does not follow the first-come, first-served rule.
  7. Extension: synchronized is both a pessimistic lock and an optimistic lock. It is both a lightweight lock and a heavyweight lock. The lightweight lock is partially implemented based on the spin lock, and the heavyweight lock is partially implemented based on the pending wait lock. It is a mutex lock, not a read-write lock, but an unfair lock. (Introduction to follow)

2. Memory visibility

Java Memory Model (JMM)

Before introducing memory visibility, let's briefly understand the Java memory model:

  • Work memory-work memory: CPU register + cache
  • Main memory-main memory: memory
  1. Shared variables between threads exist in main memory (Main Memory).
  2. Each thread has its own "working memory".
  3. When a thread wants to read a shared variable, it will first copy the variable from main memory to working memory, and then read the data from working memory.
  4. When a thread wants to modify a shared variable, it will first modify the copy in the working memory and then synchronize it back to the main memory.

Why introduce working memory?

Working memory is introduced here mainly because the speed of the CPU accessing its own registers and the speed of the cache far exceeds the speed of accessing the memory. In some cases, this is also an important means of improving efficiency. For example, in a certain code, the value of a variable needs to be read 10,000 times in a row. If the value is read from the memory 10,000 times, the speed will be very slow. But if it is only the first time to read from memory, and the read result is cached in a certain register of the CPU, then there is no need to directly access the memory for the next 9999 times of reading data. The efficiency is greatly improved.

memory visibility issue

Memory visibility means that when a thread modifies the value of a variable, other threads can always know the variable change. That is to say, if thread A modifies the value of shared variable V, then thread B can immediately read the latest value of V when using the value of V.

What are the multi-thread safety issues caused by memory visibility?

Generally speaking, multi-threading problems caused by memory visibility are due to compiler optimization. For example:

public class ThreadExample_unsafe2 {
    
    
    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.print("请输入一个整数:");
            flag = scanner.nextInt();
        });

        t1.start();
        t2.start();
    }
}

The results are analyzed
in the above code. Flag == 0 in the t1 thread involves two CPU instructions. Assume that these two instructions are load-read data from memory to working memory (CPU register), cmp-compare whether the value in the register is 0. For these two operations, the time overhead of load is much higher than that of cmp. At this time, the compiler discovered during processing that the overhead of load is very high, and the result of each load is the same. At this time, the compiler made a very bold decision, that is, only the first load execution reads the work from the memory. Memory, the load of subsequent loops is directly read from the working memory. So even though an integer that is not 0 is entered, the program continues to run because the working memory data does not change.

Regarding compiler optimization:

The above thread safety issues are the result of compiler optimization. Regarding compiler optimization, this is a very common thing. Compiler optimization is the ability to intelligently adjust the execution logic of your code to ensure that the program results remain unchanged. By reducing statements and a series of operations such as statement transformation, the execution efficiency of the entire program is greatly improved.However, compiler optimization generally does not cause any problems in single-threaded situations, but there is no guarantee in multi-threaded situations.

solution

Use volatile modification: For variables modified by the keyword volatile, the compiler will disable optimizations such as the above, which can ensure that the data is re-read from the memory to the working memory every time, ensuring memory visibility.

3. Instruction rearrangement

Instruction rearrangement is also a means of program optimization. It is directly related to the optimization of the compiler and is also directly related to thread insecurity. If it is a single-threaded situation, such an adjustment is no problem, but in a multi-threaded situation, thread safety issues will occur.

For example, the following pseudocode:

Thread t1 s = new Student();can be roughly divided into three steps:

  1. Apply for memory space
  2. Call the constructor (initialize memory data)
  3. Assign the reference of the object to s (assignment of memory address)

If it is single-threaded, the above operation is easy to ensure. If it is multi-threaded, instructions 2 and 3 are rearranged to execute 3 first and then 2. After just executing instruction 3, the t2 thread executes s.learn(); A bug occurs.

solution

  1. Volatile modification can be used in the current scenario, because volatile has the function of preventing instruction reshooting, which can solve the above possible problems.
  2. You can lock the new operation -synchronized

4、synchronized 和 volatile

  1. synchronizedAtomicity is guaranteed, volatileatomicity is not guaranteed.
  2. Generally volatile is suitable for situations where one thread reads and one thread writes.
  3. Generally speaking, synchronized is suitable for writing by multiple threads.

5. Expand knowledge: Modifier order specifications

In Java, the order of modifiers can be arranged in any order, but for the convenience of reading and code consistency, they are generally arranged in the following order:

  1. Visibility modifiers (public, protected, private)
  2. Non-visibility modifiers (static, final, abstract)
  3. Type modifiers (class, interface, enum)
  4. Other modifiers (synchronized, transient, volatile, native, strictfp)

Guess you like

Origin blog.csdn.net/LEE180501/article/details/130450560