Hi everyone, I am “MAO and Fan”, a back-end engineer who loves technology, thank you for your attention!
In the previous two articles in the deep understanding of Java series | BlockingQueue usage, rounding and deep understanding of Java series | LinkedBlockingQueue usage explanation, mainly introduced the use of the blocking queue; Blocking queues in Java are mainly used for multi-threaded concurrent programming scenarios, so from the beginning of this article will be introduced from the shallow into the depth of Java concurrent programming related knowledge.
First, this article focuses on the basics of concurrent programming, so let’s get started.
What is concurrent programming?
The so-called concurrent programming, actually refers to a computer, can execute multiple tasks at the same time, the purpose is to improve the execution speed of the program as much as possible.
When it comes to concurrent programming, two concepts inevitably come up: processes and threads.
For many modern operating systems, all support multitasking, for example, we use a Windows computer, can use a browser, programming IDE, chat tools, mail and other programs, and each program for the operating system is actually a process.
So a process is a running program, a process is the operating system for resource allocation and management of the basic unit.
For a process, it can also perform multiple tasks at the same time. For example, when we use a browser, we can browse the web and download files at the same time. The execution of these sub-tasks is realized by thread. A thread is a component of a process. A process consists of at least one thread. Multiple threads share the resources of the current process. Thread is also the basic unit of task execution and scheduling in the operating system. The operating system can control multiple threads to run alternately to achieve the effect of concurrent execution.
OK, at this point, we need to understand how the operating system implements multithreaded execution.
As we know, modern computers usually consist of multiple cpus, which can perform multiple tasks at the same time. On a single CPU, multiple tasks can be performed. A single CPU performs multiple tasks by alternating threads, such as thread A performing 1ms, thread B performing 1ms, and thread A performing 1ms…… Because the time per thread is so short, it feels like multiple tasks are being executed at the same time. The CPU relies on the time slice allocation algorithm to switch tasks. When a task is switched from execution state to execution state, a context switch is completed.
Therefore, the operating system realizes the concurrent execution of tasks by means of multithreading mechanism. Therefore, concurrent programming is mainly aimed at the realization of programming in multi-threaded scenarios.
Why is concurrent programming so important?
Concurrent programming is a very important part of any programming language’s knowledge and a very complex area; In the interview process, concurrent programming is also the focus of the interviewer. So why is concurrent programming so important?
Understanding concurrent programming knowledge or not determines whether you can write the correct program in daily development, especially when multithreaded programming development is involved. In the process of concurrent programming, we often encounter the following problems and challenges:
1. Is multithreading faster?
Not necessarily.
In the above introduction, we know that multi-threaded concurrent execution depends on the CPU time slice allocation algorithm for task scheduling and switching. When a task is executed to the time, the state of the task needs to be saved, and then switch to the next task. If the task needs to be executed again, the task state needs to be loaded from memory and restored before it can be executed again. In this process, the task from save to load is a context switch.
Context switch of thread needs to store and read the task state. Because context switch takes a certain amount of time, if the time of context switch is longer than that of a task execution, the speed of using multi-thread will not be as good as that of a single thread.
You can verify the comparison of single-threaded and multi-threaded execution times for different amounts of data using the following code (see Section 1.1.1 of the Art of Concurrent Programming in Java) :
public class ConcurrencyTest {
private static final long[] count = new long[] {10000.100000.1000000.10000000.100000000};
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < count.length; i++) {
System.out.println("===> Test data volume:" + count[i]);
// Execute concurrently
concurrency(count[i]);
// Single thread executionserial(count[i]); }}private static void concurrency(long count) throws InterruptedException {
long start = System.currentTimeMillis();
Thread thread = new Thread(new Runnable() {
@Override
public void run(a) {
int a = 0;
for (int i = 0; i < count; i++) {
a += 5; }}}); thread.start();int b = 0;
for (int i = 0; i < count; i++) {
b--;
}
thread.join();
long end = System.currentTimeMillis();
System.out.println("Multithreaded execution:" + (end - start));
}
private static void serial(long count) {
long start = System.currentTimeMillis();
int a = 0;
for (int i = 0; i < count; i++) {
a += 5;
}
int b = 0;
for (int i = 0; i < count; i++) {
b--;
}
long end = System.currentTimeMillis();
System.out.println("Single thread execution:"+ (end - start)); }}Copy the code
2. Thread safety issues
Another very tricky issue in concurrent programming is thread safety. As mentioned above, threads are part of a process, and multiple threads share the resources of the process, including memory. Thread-safety issues can occur when multiple threads simultaneously read and write to a shared variable.
The following is a common example. For a shared variable num, we would expect the final result to be 100,000 using two threads of ++ operation 50,000 times. However, after the code is executed, the result of multiple runs is less than 100,000. This shows that there are thread safety problems in the process of multi-threaded execution.
public class ConcurrencyTest2 {
int num = 0;
public void testThread(a) throws InterruptedException {
Thread t1 = new Thread(() -> {
for (int j = 0; j < 50000; j++) { num++; }}); Thread t2 =new Thread(() -> {
for (int j = 0; j < 50000; j++) { num++; }}); t1.start(); t2.start(); t1.join(); t2.join();// The expectation is 100000
// The results are 79967 97156 59108 62268....
System.out.println("Num:" + num);
}
public static void main(String[] args) throws InterruptedException {
newConcurrencyTest2().testThread(); }}Copy the code
3. The deadlock
Generally, in order to solve the thread safety problem in concurrency, locks are used to protect shared resources, so that only one thread can read or write shared resources at the same time. In this way, concurrent updates of shared resources are avoided and data security is ensured.
Locks are very useful in solving thread safety problems, but using locks can cause deadlock problems and cause programs to run abnormally.
Let’s look at the following example:
public class ConcurrencyTest3 {
private static final Object lockA = new Object();
private static final Object lockB = new Object();
private static void runThread1(a) {
Thread thread = new Thread(new Runnable() {
@Override
public void run(a) {
synchronized (lockA) { // Get lock A
System.out.println(Thread.currentThread().getName() + "Lock A obtained successfully");
TimeUnit.SECONDS.sleep(10); / / omit try-catch
System.out.println(Thread.currentThread().getName() + "Try to obtain lock B");
synchronized (lockB) { // get lock B
System.out.println(Thread.currentThread().getName() + "Lock B obtained successfully"); }}}},"thread-1");
thread.start();
}
private static void runThread2(a) {
Thread thread = new Thread(new Runnable() {
@Override
public void run(a) {
synchronized (lockB) { // get lock B
System.out.println(Thread.currentThread().getName() + "Lock B obtained successfully");
TimeUnit.SECONDS.sleep(10); / / omit try-catch
System.out.println(Thread.currentThread().getName() + "Try to obtain lock A");
synchronized (lockA) { // get lock A
System.out.println(Thread.currentThread().getName() + "Lock A obtained successfully"); }}}},"thread-2");
thread.start();
}
public static void main(String[] args) { runThread1(); runThread2(); }}Copy the code
In this example, we can see that there are two lock objects lockA and lockB, and there are two threads thread-1 and Thread-2.
In Thread-1, lockA locks are acquired first, then sleep for 10 seconds, then lockB locks are acquired. In Thread-2, the lock for lockB is first acquired, then the lock for lockA is acquired for 10 seconds, at which point a deadlock occurs.
After Thread-1 sleeps for 10 seconds, it tries to acquire the lock of lockB, but the lock of lockB is occupied by Thread-2 and cannot be acquired. Therefore, it needs to block and wait. During the waiting process, Thread-1 will always hold the lockA lock; Similarly, when Thread-2 needs to acquire the lockA lock, it fails to acquire the lockA lock because Thread-1 does not release the lockA lock. Therefore, Thread-2 also waits and holds the lockB lock. A deadlock occurs when two threads wait for a lock held by each other and cannot obtain it.
So a deadlock is a situation where each thread is waiting for other threads to release resources, and other resources are waiting for each thread to release resources. If no thread has to release its resources first, a deadlock will occur, and all threads will wait indefinitely.
There are four conditions necessary to cause a deadlock:
Mutual exclusion conditions
: A resource is occupied by only one thread at a time. If another thread requests the resource at this time, it can only wait until the occupying thread releases the resource.Request and hold conditions
: indicates that a thread has at least one resource but needs a new resource. The resource is occupied by another thread. In this case, the thread blocks and retains the possession of the resource.Non-deprivation condition
: Indicates that the resources obtained by a thread cannot be stripped before they are used up. They can be released only when they are used up.Loop waiting for
: indicates that at least two threads must wait for each other when a deadlock occurs
For each of the four conditions, breaking one of them can break a deadlock.
Three big problems with thread safety
Of the several common concurrency issues discussed above, thread safety is the most complex and frequently encountered. The main reason why thread safety is the most complex issue is that it is influenced by computer hardware, operating system principles, Java virtual machine design, and so on.
Concurrency presents three main issues of thread safety: visibility, atomicity, and orderliness. Let’s go through them one by one.
1. The visibility
First of all, let’s briefly introduce the constitution and operation mechanism of CPU.
In an operating system, CPU and memory are two important components. The CPU is used to execute program instructions, while memory is used to store data during program execution. During execution, the CPU reads and updates data in the memory. But CPU execution is orders of magnitude faster than memory operation, and if every read and store of data is done from memory, CPU execution is very slow.
Therefore, a multi-level CPU Cache is added to avoid direct interaction between the CPU and memory and improve the efficiency of accessing memory. The SCHEMATIC diagram of CPU Cache is as follows:
- L1 cache has the smallest capacity and the fastest speed. Each core has L1 cache. L1 cache is generally divided into data cache L1D and instruction cache L1I
- L2 cache is larger and slower than L1, and each core has L2 cache
- L3 cache has the largest capacity and the slowest speed, and multiple cores share L3 cache
During execution, the CPU preferentially obtains data from the CPU Cache. When there is no data in the multi-cpu Cache, the CPU loads data from the main memory. Therefore, the CPU operates the Cache most of the time, and the data in the Cache of one CPU core is invisible to other CPU cores. For example, if a variable performs a +1 operation in core 1, but the +1 operation cannot be sensed in the other core 2, the value is still obtained before the +1 operation. If the calculation operation is performed in core 2, the data in the two cores will be inconsistent.
CPU scheduling of threads is through the time slice allocation algorithm, each thread of execution may be conducted on different CPU core context switch, so if a thread to a Shared variables in nuclear 1 updated, another thread scheduling to nuclear 2 execution can’t perceive the previous update, then came the data inconsistencies, That’s the visibility problem.
In this case, cache consistency protocols need to be enabled. For example, by adding volatile to shared variables, the variables are flushed to main memory immediately upon update. The bus also notifies other CPU caches that the shared variable is invalid and needs to be read from main memory again.
2. The atomicity
Atomicity refers to an operation that is either all or none performed. In the thread-safe code example above, we did num++, and the result of both threads’ calculations always ended up being less than the desired result. This is actually because the num++ operation is not an atomic operation. Let’s break it down.
The num++ operation reads the value of num, performs the num+1 operation, and reassigns the value of num+1 to num (write it back to memory).
When the two threads start executing, each thread reads the value of num into the CPU cache, performs the +1 operation, and writes the calculated value back to memory. Since the two threads are scheduled by the CPU during execution and context switches occur, the num++ operation can be split into multiple executions, i.e., non-atomic operations.
For example, if num = 0, write num = 1 back to memory when num++ becomes 1. Num = 1; num = 1; num = 1; num = 1; That’s where the atomicity problem comes in.
In Java, you can use synchronized, Lock, or atomic manipulation of classes to avoid atomic problems, which we’ll explore in more detail later.
3. The order
Orderliness means that programs are executed in order of code. The compiler sometimes changes the order of statements in order to optimize program performance.
For example, a = 1; b = 2; For both statements, compiler optimizations may be b = 2; a = 1; . Although the up-down order of these two statements has no effect on the result of execution, once the order of execution of a shared variable is adjusted in a multithreaded environment, it may affect the result of program execution.
In Java’s memory model definition, the happens-before principle is used to specify when the compiler and processor need to disallow reordering in order to ensure instruction execution, which we’ll discuss in more detail later.
4. Summary of the three major issues
Through the above content, we can make the following summary for the three thread safety:
- The design of the CPU cache creates visibility problems
- Multithreading introduces atomicity problems
- Reordering of instructions introduces orderliness problems
Therefore, the core of concurrent programming is how to effectively solve the above three problems and ensure the reliability of the program. In later chapters, we’ll delve more deeply into the highlights of concurrent programming.
conclusion
This article mainly introduces some basic knowledge of concurrent programming, and three core problems caused by concurrent programming. In the future, we will continue to discuss how to solve the problem of concurrent programming in Java. Welcome to continue to pay attention to you.
I’m MAO and Fan. If this article is helpful to you, please feel free to like, comment and follow it. Thank you