06 What Are the Three Types of Thread Safety Issues

06 What Are the Three Types of Thread Safety Issues #

In this lesson, we will learn about the 3 types of thread safety issues.

What is Thread Safety #

To understand the 3 types of thread safety issues, we first need to understand what thread safety is. Thread safety is often mentioned in work scenarios, such as “your object is not thread-safe” or “your thread has a safety error.” Although thread safety is frequently mentioned, we may not have a clear definition of what it means.

According to Brian Goetz, the author of “Java Concurrency in Practice,” thread safety means that when multiple threads access an object, if we don’t need to consider the scheduling and interleaving issues regarding these threads during runtime and don’t need to perform any additional synchronization, and calling the object’s behavior always produces the correct results, then the object is considered thread-safe.

In fact, what Brian Goetz wants to express is that if an object is thread-safe, then for the user, there is no need to consider coordinating between methods when using it. For example, there is no need to consider the issues of simultaneous writing or the inability to read and write concurrently, and there is no need to consider any additional synchronization issues, such as manually adding synchronized locks. Only then can the object be considered thread-safe. It can be seen that the definition of thread safety is very strict.

However, in practical development, we often encounter situations where threads are not safe. So, what are the 3 types of typical thread safety issues?

  1. Incorrect results;
  2. Publication and initialization causing thread safety issues;
  3. Liveness problems.

Incorrect Results #

Firstly, let’s look at the incorrect results caused by multiple threads operating on the same variable.

public class WrongResult {

   volatile static int i;

   public static void main(String[] args) throws InterruptedException {

       Runnable r = new Runnable() {

           @Override

           public void run() {

               for (int j = 0; j < 10000; j++) {

                   i++;

               }

           }

       };

       Thread thread1 = new Thread(r);

       thread1.start();

       Thread thread2 = new Thread(r);

       thread2.start();

       thread1.join();

       thread2.join();

       System.out.println(i);

    }

}

As shown in the code, a static int variable i is defined, and then two threads are started to perform 10,000 i++ operations on the variable i. In theory, the result should be 20,000, but the actual result is much smaller than the expected result, such as 12,996 or 13,323, and the result is different each time. Why is that?

It is because in multi-threading, the CPU schedules execution in time slices. Each thread can obtain a certain amount of time slice. But if a thread’s time slice is used up, it will be suspended and give up CPU resources to other threads. This can potentially lead to thread safety issues. For example, the i++ operation may seem to be just a line of code, but it is not an atomic operation. It mainly consists of three steps, and there is a possibility of interruption between each step.

  • The first step is reading;
  • The second step is incrementing;
  • The third step is saving.

Next, let’s see how thread safety issues occur. img

Let’s follow the direction of the arrows. Thread 1 first obtains the result i=1 and then performs the i+1 operation. But at this point, the result of i+1 is not saved, and thread 1 is switched out. Then CPU starts executing thread 2, which performs the same i++ operation as thread 1. Now, let’s think about what value of i thread 2 obtains. In fact, it gets the same value of 1 as thread 1, why is that? Because although thread 1 performs the +1 operation on i, the result is not saved, so thread 2 cannot see the modified result.

Now, let’s assume that after thread 2 performs the +1 operation on i, it switches back to thread 1 to complete the unfinished operation, namely, saving the result of i+1 as 2. Then it switches back to thread 2 to complete the saving operation of i=2. Although both threads perform the +1 operation on i, the final result is i=2 instead of the expected i=3. This is how thread safety issues occur, resulting in incorrect data results. This is also the most typical thread safety issue.

Publication and Initialization Causing Thread Safety Issues #

The second type is thread safety issues caused by the publication and initialization of objects. Creating and publishing objects for other classes or objects to use is a common operation. However, if the timing or location of our operations is not appropriate, it may lead to thread safety issues.

public class WrongInit {

    private Map<Integer, String> students;

    public WrongInit() {

        new Thread(new Runnable() {

            @Override

            public void run() {

                students = new HashMap<>();

                students.put(1, "王小美");

                students.put(2, "钱二宝");

                students.put(3, "周三");

                students.put(4, "赵四");

            }

        }).start();

     }

    public Map<Integer, String> getStudents() {

        return students;

    }

    public static void main(String[] args) throws InterruptedException {

        WrongInit multiThreadsError6 = new WrongInit();

        System.out.println(multiThreadsError6.getStudents().get(1));

    }

}
public class WrongInit {
    
    private Map<Integer, String> students;
    
    public WrongInit() {
        Thread thread = new Thread(() -> {
            students = new HashMap<>();
            students.put(1, "王小美");
            students.put(2, "钱二宝");
            students.put(3, "周三");
            students.put(4, "赵四");
        });
        thread.start();
    }
    
    public Map<Integer, String> getStudents() {
        return students;
    }
    
    public static void main(String[] args) {
        WrongInit wrongInit = new WrongInit();
        // Sleep for a short period to allow the new thread to finish its work
        try {
            Thread.sleep(100);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        
        // Print the information of student 1
        Map<Integer, String> students = wrongInit.getStudents();
        String student1Info = students.get(1);
        System.out.println(student1Info);
    }
}

In the class WrongInit, a member variable students of type Map is defined, where the key is an Integer representing the student number, and the value is a String representing the student name. Then, a new thread is started in the constructor to assign values to students.

  • Student 1: number = 1, name = 王小美;
  • Student 2: number = 2, name = 钱二宝;
  • Student 3: number = 3, name = 周三;
  • Student 4: number = 4, name = 赵四.

Only when all the assignment operations in the run() method of the thread are completed, the information of the 4 students will be fully initialized. However, as we can see in the main() function, after initializing the WrongInit object, without any delay, the information of student 1 is directly printed. What would happen in this case? In fact, a null pointer exception will occur.

Exception in thread "main" java.lang.NullPointerException
    at lesson6.WrongInit.main(WrongInit.java:32)

Why does this happen? Because the initialization and assignment operations of the students member variable are performed in the new thread created in the constructor, and starting a thread takes some time. However, our main function does not wait and directly retrieves the data, resulting in the getStudents method returning null. This is a thread safety issue caused by publishing or initializing at the wrong time or location.

Liveness Problems #

The third type of thread safety issue is called liveness problems, which has three typical types: deadlock, livelock, and starvation.

What is a liveness problem? It refers to the situation where the program never reaches a final result. Compared to the data errors or error messages caused by the first two types of thread safety issues, the consequences of liveness problems may be more serious. For example, a deadlock can cause the program to completely freeze and cannot continue running.

Deadlock #

The most common liveness problem is deadlock, which refers to the situation where two threads are waiting for each other’s resources, but none of them yield, both wanting to execute first, as shown in the code.

public class MayDeadLock {

    Object o1 = new Object();

    Object o2 = new Object();

    public void thread1() throws InterruptedException {

        synchronized (o1) {

            Thread.sleep(500);

            synchronized (o2) {

                System.out.println("Thread 1 successfully acquires two locks");

            }

        }

    }

    public void thread2() throws InterruptedException {

        synchronized (o2) {

            Thread.sleep(500);

            synchronized (o1) {

                System.out.println("Thread 2 successfully acquires two locks");

            }

        }

    }

    public static void main(String[] args) {

        MayDeadLock mayDeadLock = new MayDeadLock();

        new Thread(new Runnable() {

            @Override

            public void run() {

                try {

                    mayDeadLock.thread1();

                } catch (InterruptedException e) {

                    e.printStackTrace();

                }

            }

        }).start();

        new Thread(new Runnable() {

            @Override

            public void run() {

                try {

                    mayDeadLock.thread2();

                } catch (InterruptedException e) {

                    e.printStackTrace();

                }

            }

        }).start();

    }

}

In the code, two Object instances are created as synchronized locks. Thread 1 acquires o1 lock first, sleeps for 500 milliseconds, and then acquires o2 lock. Thread 2 has the opposite execution order as Thread 1, first acquiring o2 lock, sleeping for 500 milliseconds, and then acquiring o1 lock. Assuming that two threads enter sleep almost at the same time, after waking up, Thread 1 wants to acquire the o2 lock, and Thread 2 wants to acquire the o1 lock. At this point, a deadlock occurs, where the two threads do not voluntarily resolve the situation or exit, and they wait stubbornly for each other to release the resources first, causing the program to not produce any results and cannot stop running.

Livelock #

The second type of liveness problem is livelock, which is very similar to deadlock. In livelock, the running thread is not blocked, and it keeps running but cannot reach a result.

For example, let’s say there is a message queue where various messages that need to be processed are stored. A message is not being processed correctly due to a writing mistake, causing an error when executed. However, the queue’s retry mechanism will put it back at the front of the queue for priority retry processing. But no matter how many times this message is executed, it cannot be processed correctly. Each time it fails, it will be put back to the front of the queue for retry, repeating the process endlessly. This causes the thread to be busy all the time but the program cannot reach a result, resulting in a livelock problem.

Starvation #

The third typical liveness problem is starvation. Starvation occurs when a thread cannot obtain certain resources, especially CPU resources, causing the thread to be idle for a long time without running. In Java, there is a concept of thread priority, ranging from 1 to 10, with 1 being the lowest and 10 being the highest. If we set the priority of a thread to 1, the lowest priority, in this case, the thread may never be allocated CPU resources, and it will be idle for an extended time. Another example is when a thread continuously holds a lock on a file, and other threads need to acquire the lock to modify the file, the thread wanting to modify the file will be in a state of starvation and cannot run for a long time.

Alright, we have finished today’s content. Through this lesson, we have learned about the three types of thread safety issues: incorrect results caused by situations like i++, usually due to concurrent read and write operations; objects not being published or initialized at the correct time and location; and liveness problems, including deadlock, livelock, and starvation.