Java Concurrent Programming
Java Concurrent Programming
1.1 What is the difference between threads and processes?
- A process is an instance of a running program, which contains threads, each executing different tasks.
- Different processes use different memory spaces, while all threads within the current process can share the same memory space.
- Threads are lighter; the cost of context switching between threads is generally lower than that of context switching between processes (context switching refers to switching from one thread to another).
1.2 What is the difference between parallelism and concurrency?
With multicore CPUs,
- Concurrency is the ability to handle multiple tasks at the same time, where multiple threads take turns using one or more CPUs.
- Parallelism is the ability to perform multiple tasks simultaneously, where a 4-core CPU executes 4 threads at the same time.
1.3 Four ways to create threads
In Java, there are four common ways to create threads:
- Inheriting the Thread class
- Implementing the Runnable interface
- Implementing the Callable interface
- Creating threads using a thread pool. Typically, we use thread pools to create threads in our projects.
1.4 What is the difference between Runnable and Callable?
- The run method of the Runnable interface has no return value; the call method of the Callable interface has a return value and is a generic type, which can be used with Future and FutureTask to obtain the results of asynchronous execution.
- The Callable interface supports returning execution results, which requires calling FutureTask.get(); this method will block the main process from continuing until it is called.
- The call() method of the Callable interface allows throwing exceptions, while the run() method of the Runnable interface can only handle exceptions internally and cannot propagate them.
1.5 What is the difference between the run() and start() methods of a thread?
- start(): Used to start a thread, which calls the run method to execute the logic defined in the run method. The start method can only be called once.
- run(): Encapsulates the code to be executed by the thread and can be called multiple times.
1.6 What states does a thread include, and how do they change?
In the JDK's Thread class, the State enumeration defines six thread states: New, Runnable, Terminated, Blocked, Waiting, and Timed Waiting. Regarding thread state transitions, here’s a brief introduction:
- When a thread object is created but has not yet called the start method, it is in the New state. Once the start method is called, it transitions to the Runnable state. If the thread's code has finished executing, it transitions from Runnable to Terminated.
- If a thread fails to acquire a lock, it transitions from Runnable to the Monitor's blocked queue. Only when the thread holding the lock releases it will the blocked threads in the queue be awakened according to certain rules, transitioning to the Runnable state.
- If a thread successfully acquires a lock but calls the wait() method due to unmet conditions, it transitions from Runnable to Waiting state. When other threads holding the lock call notify() or notifyAll(), it will return to the Runnable state.
- Additionally, calling sleep(long) will also transition from Runnable to Timed Waiting state, which does not require active awakening; it will naturally return to Runnable state when the timeout expires.
1.7 How to ensure that newly created threads T1, T2, and T3 execute in order?
There are various methods to ensure threads execute in a specific order in multithreading. You can use the join() method of the thread class to start another thread within one thread, allowing the other thread to complete before continuing execution. For example: using the join method, T3 calls T2, and T2 calls T1, ensuring that T1 completes first and T3 completes last.
public class JoinTest {
public static void main(String[] args) {
// 创建线程对象
Thread t1 = new Thread(() -> {
System.out.println("t1");
}) ;
Thread t2 = new Thread(() -> {
try {
t1.join(); // 加入线程t1,只有t1线程执行完毕以后,再次执行该线程
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t2");
}) ;
Thread t3 = new Thread(() -> {
try {
t2.join(); // 加入线程t2,只有t2线程执行完毕以后,再次执行该线程
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t3");
}) ;
t1.start();
t2.start();
t3.start();
}
}
1.8 What is the difference between notify() and notifyAll()?
- notifyAll: Wakes up all threads that are waiting.
- notify: Randomly wakes up one waiting thread.
1.9 What are the differences between wait and sleep methods in Java?
Commonalities
- wait(), wait(long), and sleep(long) all cause the current thread to temporarily give up CPU usage and enter a blocked state.
Differences
- Method ownership:
- sleep(long) is a static method of the Thread class.
- wait() and wait(long) are member methods of the Object class, available to every object.
- Wake-up timing:
- Threads executing sleep(long) and wait(long) will wake up after the specified milliseconds.
- wait(long) and wait() can also be awakened by notify; if wait() is not awakened, it will wait indefinitely.
- Both can be interrupted to wake up.
- Lock characteristics (key point):
- The wait method must first acquire the lock of the wait object, while sleep has no such restriction.
- The wait method releases the object lock after execution, allowing other threads to acquire that object lock (I give up CPU, but you can still use it).
- If sleep is executed within a synchronized block, it does not release the object lock (I give up CPU, but you cannot use it either).
1.10 How to stop a running thread?
There are three ways to stop a thread:
- Use an exit flag to allow the thread to exit normally, meaning the thread terminates when the run method completes.
- Use the stop method to forcefully terminate (not recommended, as this method is deprecated).
- Use the interrupt method to interrupt the thread.
2.1 Explain the underlying principle of the synchronized keyword.
The synchronized keyword uses a Monitor at the JVM level to determine whether the current thread has acquired the lock. If a thread acquires the lock, other threads cannot obtain it until the lock is released. Synchronized is a pessimistic lock. Since synchronized relies on the JVM-level Monitor, its performance is relatively low.
The monitor object exists in the header of every Java object, and the synchronized lock is obtained in this way, which is why any object in Java can serve as a lock. The monitor maintains three variables:
- WaitSet: Stores threads in the Waiting state.
- EntryList: Stores threads in the Blocked state.
- Owner: The thread holding the lock. Only one thread can successfully set the Owner flag in the monitor; a monitor can only have one Owner. During the locking process, if other threads attempt to acquire the lock, they enter the EntryList and become blocked. When the thread holding the lock finishes execution and releases it, it will wake up the waiting threads in the EntryList to compete for the lock, and this competition is unfair.
2.2 Advanced principles of the synchronized keyword.
Java's synchronized has three forms: biased locks, lightweight locks, and heavyweight locks, corresponding to locks held by a single thread, alternating locks held by different threads, and multi-threaded lock contention.
- Heavyweight lock: Implemented using Monitor, involving user mode and kernel mode switching, and process context switching, which is costly and has low performance.
- Lightweight lock: When thread locking times are staggered (i.e., no contention), lightweight locks can be used for optimization. Lightweight modifies the lock flag in the object header, significantly improving performance compared to heavyweight locks. Each modification is a CAS operation, ensuring atomicity.
- Biased lock: If a lock is used by a single thread for an extended period, a biased lock can be used. When the lock is first acquired, a CAS operation occurs, and when the thread attempts to acquire the lock again, it only needs to check if the mark word contains its thread ID, rather than performing the more costly CAS command. Once contention occurs, the lock will upgrade to a heavyweight lock.
2.3 JMM (Java Memory Model)
The Java Memory Model is a crucial memory model defined in the Java Virtual Machine specification. Its primary purpose is to describe the access rules for shared variables among threads in Java programs and how these variables are stored and read in the JVM, involving some low-level details. This model has several core characteristics. First, all shared variables, including instance variables and class variables, are stored in main memory, which is the computer's RAM. It is important to note that local variables are not included, as they are thread-private and thus do not have contention issues. Second, each thread has its own working memory, which holds copies of the variables used by the thread. This means that all operations on variables, whether reading or writing, must be completed in the thread's working memory and cannot directly read or write to the variables in main memory. Finally, different threads cannot directly access each other's working memory variables. If threads need to pass variable values to each other, this process must occur through main memory.
2.4 CAS (Compare and Swap)
CAS stands for Compare And Swap; it embodies the idea of optimistic locking, ensuring atomicity of thread operations on data in an unlocked state.
- CAS is used in many places: AQS framework, AtomicXXX classes.
- It uses spin locks when operating on shared variables, which is more efficient.
- The underlying implementation of CAS calls methods in the Unsafe class, which are provided by the operating system and implemented in other languages.
2.5 volatile
The volatile keyword can modify class member variables and class static member variables, primarily serving two functions:
- It ensures visibility when different threads operate on this variable, meaning if one thread modifies a variable's value, this new value is immediately visible to other threads. The volatile keyword forces the modified value to be immediately written to main memory.
- It prevents instruction reordering, ensuring the ordered execution of code. The underlying implementation principle is the addition of a memory barrier, which prevents instruction reordering optimizations before and after the memory barrier.
2.6 AQS
AQS refers to the AbstractQueuedSynchronizer class provided by the JDK, which serves as a framework for blocking locks and related synchronizer tools. It has a state attribute that indicates the resource's status, with the default state equal to 0, indicating that the lock has not been acquired, and state equal to 1 indicating that the lock has been acquired. The state is set using the CAS mechanism. It also provides a FIFO-based waiting queue, which is a doubly linked list, where:
- tail points to the last element in the queue.
- head points to the oldest element in the queue. The underlying implementation of ReentrantLock is based on AQS.
synchronized | AQS |
---|---|
Keyword, implemented in C++ in the JVM | Java language implementation provided by the JDK |
Pessimistic lock, automatically releases the lock | Pessimistic lock, manually opened and closed |
Heavyweight locks in case of intense competition, poor performance | Provides various solutions in case of intense competition |
2.7 Implementation principles of ReentrantLock
ReentrantLock is a reentrant lock: after calling the lock method to acquire the lock, calling lock again will not block; it directly increases the reentry count, indicating that this thread has repeatedly acquired the lock without waiting for its release. ReentrantLock belongs to the juc package and is an API-level lock, similar to synchronized, and is also a pessimistic lock. It uses lock() to acquire the lock and unlock() to release it. Its underlying implementation primarily utilizes the CAS + AQS queue. It supports both fair and unfair locks, with similar implementations. The constructor accepts an optional fairness parameter (default is unfair lock); when set to true, it indicates a fair lock; otherwise, it is an unfair lock. Fair locks often have lower efficiency than unfair locks.
2.8 What are the differences between synchronized and Lock?
First, at the syntax level:
- synchronized is a keyword, implemented in C++ in the JVM, and automatically releases the lock upon exiting the synchronized code block.
- Lock is an interface, provided by the JDK, implemented in Java, and requires manual invocation of the unlock method to release the lock.
Second, at the functional level:
- Both are pessimistic locks and possess basic mutual exclusion, synchronization, and lock reentry capabilities.
- Lock provides many features that synchronized does not, such as obtaining waiting status, fair locks, interruptibility, timeout, multiple condition variables, and can implement different scenarios like ReentrantLock and ReentrantReadWriteLock.
Third, at the performance level:
- When there is no contention, synchronized has many optimizations, such as biased locks and lightweight locks, and performs well.
- In cases of intense competition, Lock's implementation usually provides better performance. In summary, different locks should be chosen based on different scenarios.
2.9 What are the conditions that lead to deadlock?
A deadlock easily occurs when a thread needs to acquire multiple locks simultaneously. For example:
- Thread t1 acquires the lock on object A and then tries to acquire the lock on object B.
- Thread t2 acquires the lock on object B and then tries to acquire the lock on object A. At this point, both t1 and t2 are waiting for each other's locks, resulting in a deadlock.
2.10 How to diagnose deadlocks?
We can easily resolve this using JDK's automatic tools. First, we can use jps to view the current Java program's process ID. Then, we can use jstack to view this process ID, which will display the deadlock issue and can pinpoint the specific line numbers in the code, allowing us to locate and troubleshoot the corresponding code.
2.11 ConcurrentHashMap
ConcurrentHashMap is a thread-safe and efficient Map collection, with significant adjustments made in JDK 1.7 and 1.8.
- JDK 1.7's underlying implementation uses a segmented array + linked list.
- JDK 1.8 adopts a data structure similar to HashMap 1.8, which is an array + linked list/red-black tree. In JDK 1.7, ConcurrentHashMap contains a Segment array. The structure of a Segment is similar to that of HashMap, consisting of an array and linked list structure. Each Segment contains a HashEntry array, where each HashEntry is an element of a linked list. Each Segment guards the elements in its HashEntry array, and when modifying the data in the HashEntry array, the corresponding Segment's lock must be acquired first. A Segment is a reentrant lock (ReentrantLock), and each Segment guards the elements in its HashEntry array. When modifying the data in the HashEntry array, the corresponding Segment lock must be acquired first. In JDK 1.8, ConcurrentHashMap underwent significant optimizations, greatly improving performance. First, its data structure is entirely consistent with JDK 1.8's HashMap data structure. Second, it abandoned the bulky Segment design, replacing it with Node + CAS + Synchronized to ensure concurrent safety. Synchronized only locks the head node of the current linked list or red-black tree, so as long as there are no hash collisions, concurrency will not occur, thus improving efficiency.
2.12 What is the fundamental reason for issues in concurrent programs?
Java concurrent programming has three core characteristics: atomicity, visibility, and ordering.
- First, atomicity means that operations by a thread in the CPU cannot be paused or interrupted; they either complete or do not execute. For example, some simple operations like assignment may be atomic, but composite operations like incrementing are not atomic. To ensure atomicity, we can use the synchronized keyword or Lock from JUC to lock.
- Second, visibility means that modifications made by one thread to shared variables are visible to another thread. Since threads may cache copies of shared variables in their working memory, modifications made by one thread may not immediately reflect in other threads' working memory. To address this, we can use the synchronized keyword, volatile keyword, or Lock to ensure visibility.
- Finally, ordering refers to the fact that processors may optimize input code to improve program execution efficiency, leading to discrepancies between the execution order of statements in the program and the order in the code. Although the processor guarantees that the final execution result of the program is consistent with the result of executing the code in order, we may need to ensure specific execution orders in certain cases. To solve this, we can use the volatile keyword to prevent instruction reordering.
3.1 Core parameters of thread pools (Do you know the execution principles of thread pools?)
There are a total of 7 core parameters in a thread pool:
- corePoolSize: The number of core threads - the maximum number of threads that will be retained in the pool.
- maximumPoolSize: The maximum number of threads - the maximum number of core threads + emergency threads.
- keepAliveTime: The survival time - the survival time of emergency threads; if there are no new tasks during this time, the thread resources will be released.
- unit: The time unit - the unit of survival time for emergency threads, such as seconds, milliseconds, etc.
- workQueue: When there are no idle core threads, new tasks will be added to this queue for waiting; if the queue is full, emergency threads will be created to execute tasks.
- threadFactory: Thread factory - can customize the creation of thread objects, such as setting thread names, whether they are daemon threads, etc.
- handler: Rejection policy - when all threads are busy and the workQueue is full, the rejection policy will be triggered. There are four types of rejection policies: the first is to throw an exception, the second is to execute the task by the caller, the third is to discard the current task, and the fourth is to discard the earliest queued task. The default is to throw an exception directly.
3.2 What are the common blocking queues in thread pools?
The JDK provides many blocking queues, with two commonly used in development: ArrayBlockingQueue and LinkedBlockingQueue. ArrayBlockingQueue and LinkedBlockingQueue are two common blocking queues in Java, and they have some key differences in implementation and usage.
- First, ArrayBlockingQueue is a bounded queue that must specify its capacity upon creation, and this capacity cannot change. LinkedBlockingQueue, on the other hand, is unbounded by default but can also be specified with a maximum capacity to become a bounded queue.
- Second, they differ in their internal data structures. ArrayBlockingQueue is implemented based on an array, while LinkedBlockingQueue is implemented based on a linked list. This means that ArrayBlockingQueue may be faster when accessing elements because it can directly access elements in the array by index. LinkedBlockingQueue may be faster when adding and removing elements because it does not need to move other elements to fill space.
- Additionally, they differ in their locking mechanisms. ArrayBlockingQueue uses a single lock to control access to the queue, meaning that read and write operations are mutually exclusive. LinkedBlockingQueue uses two locks, one for controlling read operations and another for controlling write operations, which can improve concurrency performance.
3.3 How to determine the number of core threads?
- High concurrency and short task execution time --> (CPU core count + 1), to reduce thread context switching.
- Low concurrency and long task execution time:
- For I/O-intensive tasks --> (CPU core count * 2 + 1)
- For CPU-intensive tasks --> (CPU core count + 1)
- High concurrency and long business execution time: The key to solving this type of task lies not in the thread pool but in the overall architecture design. The first step is to see if some data in these businesses can be cached, and the second step is to increase the server.
3.4 What types of thread pools are available?
The JDK provides four default ways to create thread pools:
- newCachedThreadPool: Creates a cached thread pool that can flexibly recycle idle threads if the pool length exceeds the processing needs; if no threads can be recycled, new threads will be created.
- newFixedThreadPool: Creates a fixed-size thread pool that can control the maximum number of concurrent threads; excess threads will wait in the queue.
- newScheduledThreadPool: Creates a fixed-size thread pool that supports scheduled and periodic task execution.
- newSingleThreadExecutor: Creates a single-threaded executor that will use a single worker thread to execute tasks, ensuring that all tasks are executed in the specified order (FIFO, LIFO, priority).
3.5 Why is it not recommended to use Executors to create thread pools?
If you use Executors to create a thread pool, the default length of the request queue is Integer.MAX_VALUE, which may lead to a large backlog of requests, resulting in OOM (Out of Memory) errors. Therefore, we generally recommend using ThreadPoolExecutor to create thread pools, as this allows for clear specification of the thread pool parameters, avoiding resource exhaustion.
4.1 Scenarios for using thread pools: CountDownLatch, Future
Reference Scenario 1:
Bulk data import to ES Before our project went live, we needed to synchronize a large amount of data (around 10 million) to the ES index library at once, which was not feasible (OOM exception). If executed in batches, it would take too long. Therefore, I thought of using a thread pool for the import, utilizing CountDownLatch + Future to control the process, significantly improving the import time.
Reference Scenario 2:
In the e-commerce website I worked on, there was a data aggregation feature. After a user placed an order, it was necessary to query order information, obtain detailed information about the products in the order (which could be multiple), and check the logistics and shipping information. Since these three corresponded to three microservices, if operated sequentially, the waiting time would be quite long. Therefore, I thought of using a thread pool to allow multiple threads to process simultaneously and then aggregate the results, of course, using Future to obtain the results after each thread execution.
Reference Scenario 3:
I developed a search function for articles where users input keywords to search for articles while needing to save user search records (search history). In the design, to avoid affecting the user's normal search, we adopted an asynchronous method for saving, and to enhance performance, we incorporated a thread pool, meaning that when calling the asynchronous method, we directly obtained threads from the thread pool.
4.2 How to control the number of threads allowed to concurrently access a method?
The JDK provides a Semaphore class (semaphore). It offers two methods:
- semaphore.acquire(): Requests a semaphore, which can limit the number of threads; if the semaphore is -1, it indicates that the semaphore has been exhausted, and other threads need to block.
- semaphore.release(): Represents the release of a semaphore, increasing the semaphore count by 1.
5.1 ThreadLocal
ThreadLocal has two main functions:
- It can achieve thread isolation of resource objects, allowing each thread to use its own resource object, avoiding thread safety issues caused by contention.
- It enables resource sharing within a thread. ThreadLocal maintains a member variable of type ThreadLocalMap to store resource objects. When we call the set method, it uses ThreadLocal itself as the key and the resource object as the value, placing it in the current thread's ThreadLocalMap collection. When calling the get method, it uses ThreadLocal itself as the key to look up the associated resource value in the current thread. When calling the remove method, it uses ThreadLocal itself as the key to remove the associated resource value from the current thread. ThreadLocal memory overflow occurs because the keys in ThreadLocalMap are designed as weak references, which are passively released by the GC. However, only the keys can be released from memory, while the values remain strong references. When using ThreadLocal, it is often treated as a static variable (i.e., strong reference), making it impossible to rely on GC for recovery. It is recommended to actively call remove to release the key, thus avoiding memory overflow.