Preface
When a program runs, it is converted into a process, which usually contains multiple threads. Typically, time-consuming operations within a process (such as long loops, file uploads and downloads, network resource acquisition, etc.) often use multithreading to solve the problem.
For example, in real life, issues like withdrawing money from a bank or multiple ticket sales windows for train tickets usually involve concurrency issues, thus requiring multithreading technology.
When multiple concurrent threads in a process enter a critical data code block, there is a high possibility of triggering thread safety issues during the data modification process, leading to data anomalies. For instance, under normal logic, a train ticket with the same number can only be sold once, but due to thread safety issues, it may be sold multiple times, causing actual business anomalies.
Generally, we say that certain classes are thread-safe, while others are not. In fact, thread safety is not a "black or white" single-choice question. According to the degree of "thread safety" from strong to weak, we can categorize various operations on shared data in the Java language into the following five types:
-
Immutable
In the Java language, immutable objects are always thread-safe. Whether it is the implementation of the object's methods or the caller of the methods, no additional thread safety measures are needed. For example, data modified by the final keyword cannot be changed, providing the highest reliability.
Absolute thread safety
Absolute thread safety fully meets the definition of thread safety given by Brian Goetz. This definition is quite strict; for a class to achieve "no matter the runtime environment, the caller does not need any additional synchronization measures," it usually comes at a high cost. -
Relatively thread-safe
Relatively thread-safe refers to what we commonly mean when we say a class is "thread-safe." It needs to ensure that operations on this object are thread-safe individually, and we do not need to take additional safety measures when calling it. However, for some specific sequential calls, additional synchronization measures may be needed at the calling end to ensure the correctness of the calls.
In the Java language, most thread-safe classes belong to the category of relatively thread-safe, such as Vector, HashTable, and the synchronizedCollection() method of Collections that guarantees collections. -
Thread-compatible
Thread-compatible means that a class is not thread-safe.
Thread-compatible refers to the fact that the object itself is not thread-safe, but it can be safely used in a concurrent environment by correctly using synchronization measures at the calling end. Most classes in the Java API are thread-compatible, such as ArrayList and HashMap, which correspond to Vector and HashTable. -
Thread-opposed
Thread-opposed refers to code that cannot be used concurrently in a multithreaded environment, regardless of whether synchronization is implemented at the calling end. Due to the inherent multithreading characteristics of the Java language, code that is thread-opposed is rarely encountered.
An example of thread-opposed is the suspend() and resume() methods of the Thread class. If two threads hold a thread object simultaneously, one tries to interrupt the thread while the other tries to resume it, if executed concurrently, regardless of whether synchronization is implemented during the call, the target thread is at risk of deadlock. For this reason, these two methods have been deprecated.
Note: The following is the main content of this article, and the examples below can be referenced.
The first way to achieve thread safety
Synchronized code block
public class ThreadSynchronizedSecurity {
static int tickets = 15;
class SellTickets implements Runnable {
@Override
public void run() {
while (tickets > 0) {
// Synchronized code block
synchronized (this) {
if (tickets <= 0) {
return;
}
System.out.println(Thread.currentThread().getName() + "---> Sold ticket number: " + tickets);
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
tickets--;
}
if (tickets <= 0) {
System.out.println(Thread.currentThread().getName() + "---> Ticket sales ended!");
}
}
}
}
public static void main(String[] args) {
SellTickets sell = new ThreadSynchronizedSecurity().new SellTickets();
Thread thread1 = new Thread(sell, "Window 1");
Thread thread2 = new Thread(sell, "Window 2");
Thread thread3 = new Thread(sell, "Window 3");
Thread thread4 = new Thread(sell, "Window 4");
thread1.start();
thread2.start();
thread3.start();
thread4.start();
}
}
Note:
When using synchronized code blocks, they can be used with wait(), notify(), and notifyAll() to further achieve thread communication. The wait() method releases the object lock held, and the current thread enters the waiting pool, releasing the CPU, allowing other waiting threads to seize this lock. The thread that obtains the lock can then run the program; the thread's sleep() method indicates that the current thread will sleep for a period of time, temporarily releasing the CPU but not the object lock. This means that during the sleep period, other threads still cannot enter the code protected by synchronization. When the current thread's sleep ends, it will regain CPU execution rights and execute the synchronized protected code. The biggest difference between wait() and sleep() is that wait() releases the object lock, while sleep() does not.
The notify() method wakes up threads that are in a waiting state due to calling the object's wait(), giving that thread a chance to acquire the object lock. After calling notify(), the current thread does not immediately release the lock but continues executing the current code until all code in the synchronized block is executed, at which point it releases the object lock. The JVM will schedule a thread among the waiting threads to acquire the object lock and execute the code.
It is important to note that wait() and notify() must be called within a synchronized code block. NotifyAll() wakes up all waiting threads.
Next, we will use the following program to alternate printing "A" and "B" ten times each by two threads. Please see the example code below:
package com.my.annotate.thread;
public class ThreadDemo {
static final Object obj = new Object();
// First child thread
static class ThreadA implements Runnable {
@Override
public void run() {
int count = 5;
while (count > 0) {
synchronized (ThreadDemo.obj) {
System.out.println("A-----" + count);
count--;
synchronized (ThreadDemo.obj) {
// The notify() method will wake up threads that are in a waiting state due to calling the object's wait(), giving that thread a chance to acquire the object lock.
// After calling notify(), the current thread does not immediately release the lock but continues executing the current code until all code in the synchronized block is executed.
ThreadDemo.obj.notify();
try {
ThreadDemo.obj.wait();
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
}
}
}
static class ThreadB implements Runnable {
@Override
public void run() {
int count = 5;
while (count > 0) {
synchronized (ThreadDemo.obj) {
System.out.println("B-----" + count);
count--;
synchronized (ThreadDemo.obj) {
// The notify() method will wake up threads that are in a waiting state due to calling the object's wait(), giving that thread a chance to acquire the object lock.
// After calling notify(), the current thread does not immediately release the lock but continues executing the current code until all code in the synchronized block is executed.
ThreadDemo.obj.notify();
try {
ThreadDemo.obj.wait();
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
}
}
}
public static void main(String[] args) {
new Thread(new ThreadB()).start();
new Thread(new ThreadA()).start();
}
}
The second way to achieve thread safety
Synchronized method
package com.my.annotate.thread;
public class ThreadSynchroniazedMethodSecurity {
static int tickets = 15;
class SellTickets implements Runnable {
@Override
public void run() {
// Synchronized method
while (tickets > 0) {
synMethod();
try {
Thread.sleep(1);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
if (tickets <= 0) {
System.out.println(Thread.currentThread().getName() + "---> Ticket sales ended");
}
}
}
synchronized void synMethod() {
synchronized (this) {
if (tickets <= 0) {
return;
}
System.out.println(Thread.currentThread().getName() + "----> Sold ticket number " + tickets);
tickets--;
}
}
}
public static void main(String[] args) {
SellTickets sell = new ThreadSynchroniazedMethodSecurity().new SellTickets();
Thread thread1 = new Thread(sell, "Window 1");
Thread thread2 = new Thread(sell, "Window 2");
Thread thread3 = new Thread(sell, "Window 3");
Thread thread4 = new Thread(sell, "Window 4");
thread1.start();
thread2.start();
thread3.start();
thread4.start();
}
}
The third way to achieve thread safety
Lock mechanism, by creating a Lock object, using lock() to lock and unlock() to protect the specified code block
package com.my.annotate.thread;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ThreadLockSecurity {
static int tickets = 15;
class SellTickets implements Runnable {
Lock lock = new ReentrantLock();
@Override
public void run() {
// Lock mechanism
while (tickets > 0) {
try {
lock.lock();
if (tickets <= 0) {
return;
}
System.out.println(Thread.currentThread().getName() + "---> Sold ticket number: " + tickets);
tickets--;
} catch (Exception e1) {
// TODO Auto-generated catch block
e1.printStackTrace();
} finally {
lock.unlock();
try {
Thread.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
if (tickets <= 0) {
System.out.println(Thread.currentThread().getName() + "---> Ticket sales ended!");
}
}
}
public static void main(String[] args) {
SellTickets sell = new ThreadLockSecurity().new SellTickets();
Thread thread1 = new Thread(sell, "Window 1");
Thread thread2 = new Thread(sell, "Window 2");
Thread thread3 = new Thread(sell, "Window 3");
Thread thread4 = new Thread(sell, "Window 4");
thread1.start();
thread2.start();
thread3.start();
thread4.start();
}
}
Since synchronized is implemented at the JVM level, the system can monitor whether the lock is released; whereas ReentrantLock is implemented using code, and the system cannot automatically release the lock. It needs to be explicitly released in the finally clause of the code using lock.unlock(). Additionally, in cases of low concurrency, using synchronized is a good choice; however, in cases of high concurrency, its performance can decline significantly, making ReentrantLock a good solution.
II. Methods to achieve thread safety
To ensure thread safety, we can classify methods based on whether synchronization measures are needed, dividing them into synchronized solutions and non-synchronized solutions.
- Mutual exclusion synchronization
Mutual exclusion synchronization is the most common means of ensuring concurrent correctness. Synchronization means that when multiple threads concurrently access shared data, it ensures that shared data is only used by one thread at a time (at any given moment, only one thread operates on shared data). Mutual exclusion is a means to achieve synchronization, with critical sections, mutexes, and semaphores being the main implementations of mutual exclusion. Therefore, among these four words, mutual exclusion is the cause, and synchronization is the effect; mutual exclusion is the method, and synchronization is the goal.
In Java, the most basic means of mutual exclusion synchronization is the synchronized keyword. After compilation, the synchronized keyword will generate two bytecode instructions, monitorenter and monitorexit, before and after the synchronized block, respectively. These two bytecode instructions require a reference type parameter to indicate the object to be locked and unlocked.
Additionally, ReentrantLock also implements synchronization through mutual exclusion. In basic usage, ReentrantLock is quite similar to synchronized, as they both possess the same thread reentrance characteristics.
The main issue with mutual exclusion synchronization is the performance problems caused by thread blocking and waking. Therefore, this type of synchronization is also referred to as blocking synchronization. From the perspective of handling issues, mutual exclusion synchronization belongs to a pessimistic concurrency strategy, always assuming that if proper synchronization measures (such as locking) are not taken, problems will definitely arise, regardless of whether shared data will actually compete; it will always lock.
- Non-blocking synchronization
With the development of hardware instruction sets, optimistic concurrency strategies based on conflict detection have emerged. Simply put, it means performing an operation first, and if no other threads compete for shared data, the operation succeeds; if there is contention for shared data, resulting in a conflict, other compensatory measures are adopted (the most common compensatory error is to keep retrying until successful). This optimistic concurrency strategy often does not require suspending threads, so this type of synchronization operation is called non-blocking synchronization.
Non-blocking implementation CAS (compare and swap): The CAS instruction requires three operands: a memory address (understood as the variable's memory address in Java, denoted as V), an old expected value (denoted as A), and a new value (denoted as B). When executing the CAS instruction, it updates the value at V to B only if the value at V matches the old expected value A; otherwise, it does not perform the update. Regardless of whether the value at V is updated, it will return the old value of V. The above process is an atomic operation.
Disadvantages of CAS:
ABA problem: Because CAS needs to check whether the value has changed when operating on the value, it updates only if it has not changed. However, if a value was originally A, changed to B, and then changed back to A, using CAS for checking will find that its value has not changed, but in reality, it has changed.
The solution to the ABA problem is to use version numbers. By appending a version number before the variable, each time the variable is updated, the version number is incremented. Thus, A-B-A becomes 1A-2B-3C. The JDK's atomic package provides a class called AtomicStampedReference to solve the ABA problem. The compareAndSet method of this class first checks whether the current reference equals the expected reference and whether the current flag equals the expected flag. If both are equal, it atomically sets the reference and flag values to the given update values.
- Non-synchronized solutions
To ensure thread safety, it is not always necessary to implement synchronization; the two do not have a causal relationship. Synchronization is merely a means to ensure correctness when shared data is contended. If a method does not involve shared data, it naturally does not require any synchronization operations to ensure correctness. Therefore, some code is inherently thread-safe.
- Reentrant code
Reentrant code (ReentrantCode), also known as pure code, can be interrupted at any moment during its execution to execute another piece of code, and when control returns, the original program will not encounter any errors. All reentrant code is thread-safe, but not all thread-safe code is reentrant.
The characteristics of reentrant code are that it does not rely on data stored on the heap or shared system resources, the state variables used are passed in through parameters, and it does not call non-reentrant methods, etc.
(Analogy: synchronized has the feature of lock reentrance, meaning that when a thread obtains an object lock while using synchronized, it can again obtain the lock for that object when requesting it again.)
- Thread-local storage
If the data required by a piece of code must be shared with other code, we should check whether the code that shares this data can ensure execution within the same thread. If it can be guaranteed, we can limit the visibility of shared data to the same thread. This way, synchronization is unnecessary, and we can ensure that there are no contention issues between threads.
Applications that meet this characteristic are not uncommon. Most architectures using consumer queues (such as the "producer-consumer" model) will try to complete the consumption process of products within a single thread. One of the most important application examples is the handling method in the classic web interaction model of "one request corresponds to one server thread (Thread-per-Request)," which is widely used, allowing many web server applications to use thread-local storage to solve thread safety issues.
III. The lifecycle of a thread and five basic states
Java threads have five basic states:
New state (New): When a thread object is created, it enters the new state, e.g., Thread t = new MyThread();
Ready state (Runnable): When the start() method of the thread object is called, the thread enters the ready state. A thread in the ready state indicates that it is ready and waiting for CPU scheduling to execute; calling start() does not mean the thread will execute immediately.
Running state (Running): When the CPU schedules a thread in the ready state, the thread is actually executed, entering the running state.
Note: The ready state is the only entry point to the running state, meaning that the prerequisite for a thread to enter the running state is that it has already entered the ready state.
Blocked state (Blocked): A thread in the running state temporarily relinquishes its CPU usage rights and stops executing for some reason, entering the blocked state. It can only return to the ready state to be called by the CPU again when it is ready. The blocked state can be divided into three types based on the reasons for blocking:
Waiting block --> A running thread executes the wait() method, causing it to enter the blocked state.
Synchronization block --> A thread fails to acquire a synchronization lock because it is occupied by other threads, causing it to enter the synchronization blocked state.
Other blocks --> A thread enters the blocked state when it calls sleep(), join(), or issues an I/O request. When the sleep() state times out, join() waits for the thread to terminate or time out, or the I/O processing is completed, the thread will return to the ready state.
Dead state (Dead): A thread ends its lifecycle when it finishes executing or exits the run() method due to an exception.
Reference code can be found at Gitee