[Learning JAVA from scratch | Thirty-nine articles] In-depth multithreading

Table of contents

Foreword:        

1. Thread life cycle​

2. Thread safety issues

3. lock

Synchronized code block:

Synchronization method:

Deadlock:

4. Producer and consumer mode (waiting for wake-up mechanism)

Summarize:


Foreword:        

        Multithreaded programming has become a vital skill in today's software development world. However, it is not easy to write efficient and reliable multithreaded programs. Multithreaded programming faces many challenges, such as thread safety, resource sharing, deadlock and other issues. Therefore, for beginners, it is very important to have a deep understanding of the working principle and mechanism of Java multithreading. Only by mastering the core concepts of multithreading, understanding common problems and solutions, can we write robust and high-performance multithreaded applications.

        This article will gradually introduce the important concepts and mechanisms of Java multithreading. We'll start with thread creation and startup, discuss how to use thread pools to manage threads, and discuss inter-thread communication and synchronization techniques. We will also introduce some commonly used multithreading design patterns and best practices to help readers better apply multithreading technology to solve practical problems.

1. Thread life cycle

The life cycle of a thread describes the entire process of a thread from creation to termination, and generally includes the following stages:

  1. New state (New):

    • When a thread object is created, it is in the new state.
    • At this point, the thread has not been started, that is, the start() method has not been called.
  2. Runnable state (Runnable):

    • When the thread calls the start() method, it enters the runnable state.
    • When a thread is in this state, it may be executing or it may be waiting for system resources.
  3. Running status (Running):

    • Threads in the runnable state are scheduled for execution by the system and are in the running state.
    • The thread executes the task code in the run() method.
  4. Blocked state (Blocked):

    • The blocked state means that the thread temporarily stops executing for some reason, such as waiting for a resource, waiting for the release of a lock, and so on.
    • When a certain condition is met, the thread will enter the blocked state and be woken up after the waiting condition is met.
  5. Waiting state indefinitely (Waiting):

    • When a thread calls the wait() method without parameters under certain conditions, it will enter an indefinite wait state.
    • This state can only be released when another thread explicitly calls notify() or notifyAll(), or is interrupted.
  6. Timed Waiting:

    • When a thread calls methods such as wait(), sleep(), join() or LockSupport.parkNanos() with a timeout parameter under certain conditions, it will enter a time-limited waiting state.
    • The thread will be woken up once the time has elapsed, or if it is notified of a specific event.
  7. Terminated state (Terminated):

    • After the thread executes the task code in the run() method, or the thread ends prematurely due to an exception, it will enter the terminated state.
    • Once a thread enters the terminated state, it cannot switch to another state.

It should be noted that the state of the thread can be switched between each other, and the specific conversion is determined by the Java thread scheduler and the operating system. Thread life cycle and state transition are very important for multi-threaded programming. Reasonable management of thread state can improve the performance and concurrency of the program.

2. Thread safety issues

We use a case to illustrate:

Now we are going to open three windows to buy tickets, there are 100 tickets in total, please use the knowledge of multi-threading to complete.


class MyThread extends Thread {
    static int  tick=0;
    public void run() {
        // 定义线程要执行的任务

        while(true)
       {
           if(tick<100)
           {

               try {
                   Thread.sleep(100);
               } catch (InterruptedException e) {
                   throw new RuntimeException(e);
               }
               tick++;
               System.out.println(getName()+"正在卖第"+tick+"张票");
           }
           else
           {
               break;
           }
       }
    }
}

public class test05 {
    public static void main(String[] args) {
        MyThread  t1 = new MyThread();
        MyThread  t2 = new MyThread();
        MyThread  t3 = new MyThread();

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

Many students will write such a simple multi-thread at the first time, but when we run it, there will be an obvious problem: there will be a situation where a ticket is sold twice, and there will also be overselling this phenomenon.

 Let's explain why

Thread 1, thread 2, and thread 3 are all vying for cpu scheduling. Assuming that after thread 1 grabs it, it first enters the if statement, but there is a sleep in the if statement. After executing here, thread 1 will be blocked and sleep. When thread 2 and thread 3 grab the cpu scheduling again, thread 2 will sleep after entering the if statement after grabbing the resource, and then thread 3 will sleep when entering the resource. With the end of these three sleep cycles, the code in the if will be executed again. When tick has not had time to print, thread 2 wakes up and snatches cpu resources again. If it grabs it, it will execute tick++ again, and then thread 3. This cycle will cause two tickets to be sold and Possibly oversold as a result.

Through this case, we can see that there is an important hidden danger when multi-threading is executed:

 Thread execution is random

Then our simplest idea is:

Design a method so that if a thread is executing code, other threads must wait. Only after this thread is executed, other threads can seize CPU resources. That's what we're going to cover next

3. lock

Synchronized code block:

Lock the code block with a lock

synchronized(锁)
{
    操作共享数据的代码
}

Features:

  •         The lock is opened by default, and if a process enters, the lock will be automatically closed.
  •         All the code inside is executed, the thread comes out, and the lock is automatically opened

So let's try to improve it with locks


class MyThread extends Thread {
    static int  tick=0;
    static Object oj = new Object();
    public void run() {
        // 定义线程要执行的任务

        while(true)
        {

           try {
            Thread.sleep(10);
        } 
         catch (InterruptedException e)
        {
            throw new RuntimeException(e);
        }
             synchronized (oj)
          {

              if(tick<10000)
              {

                  tick++;
                  System.out.println(getName()+"正在卖第"+tick+"张票");
              }
              else
              {
                  break;
              }
          }
      }
    }
}

Note on locks:

  1. Lock granularity : On the premise of ensuring thread safety, the scope of locks should be reduced as much as possible. Excessive lock granularity may cause unnecessary thread blocking and affect performance. You can consider using fine-grained locks or using concurrent collection classes to improve concurrency performance.

  2. Lock fairness : Locks can be fair or unfair. Fair locks will acquire locks in the order in which threads request locks, while unfair locks do not guarantee the order in which threads acquire locks. When choosing a lock, choose a fair or unfair lock according to the specific situation.

  3. Deadlock situation : A deadlock is a situation in which two or more threads wait for each other to release the lock held by each other, resulting in the inability of all threads to continue executing. To avoid deadlocks, it is necessary to carefully design the order in which locks are acquired, and try to avoid nested locks.

  4. Lock release : When using locks, it is necessary to ensure that the locks are released correctly to avoid problems such as resource leaks or thread starvation. You can generally use a try-finally block to ensure that the lock is still properly released when an exception occurs.

  5. Lock performance : Lock competition will bring certain performance overhead, and excessive lock competition may affect the concurrency performance of the application. Alternatives such as read-write locks, lock-free data structures, or concurrent collection classes can be considered to reduce the performance overhead caused by lock competition.

  6. Deadlock detection and avoidance : Once a deadlock occurs, all threads will not be able to continue execution. In order to avoid deadlock, tools can be used for deadlock detection, and the order of lock acquisition and release can be reasonably designed to avoid potential deadlock situations.

Synchronization method:

lock the method

修饰符   synchronized  返回值类型  方法名  (方法参数){...}

Features:

  • A synchronized method is to lock all the code inside the method
  • The lock object cannot be specified by itself

non-static: this

static: the bytecode file for the current class

 Then we can rewrite the previous as:

class MyThread   extends Thread  {
    static int  tick=0;
    static final Object oj = new Object();
    public synchronized void run() {
        // 定义线程要执行的任务

        while(true)
        {
                if (tick < 100) {

                    tick++;
                    System.out.println(getName() + "正在卖第" + tick + "张票");
                } else {

                    break;
                }

            }
      }
    }

Deadlock:

Deadlock means that in multi-threaded programming, two or more threads hold resources that each other needs, so that they cannot continue to execute, which is called a deadlock phenomenon.

The occurrence of deadlock usually needs to meet the following four conditions, also known as the necessary conditions for deadlock:

  1. Mutually exclusive conditions: At least one resource can only be held by one thread at the same time.
  2. Request and hold conditions: A thread requests resources held by other threads while holding a certain resource.
  3. Non-alienable condition: A resource that has been allocated to a thread cannot be forcibly deprived, it can only be explicitly released by the thread holding the resource.
  4. Circular wait condition: Multiple threads form a loop to wait for a series of resources, and each thread is waiting for the resource held by the next thread.

When the above four conditions are met, a deadlock may occur. When a deadlock occurs, these threads will not be able to continue to execute, and some strategies need to be used to solve it, such as avoiding deadlocks, detecting deadlocks, and removing deadlocks.

There are generally several ways to solve deadlocks:

  1. Avoid deadlock: By breaking one of the necessary conditions for deadlock, such as avoiding circular waiting, the order of resource allocation is ensured.
  2. Detection and recovery: Detect the occurrence of deadlocks through resource allocation graphs, banker's algorithms, etc., and then adopt corresponding strategies for recovery, such as terminating certain threads and reclaiming resources.
  3. Deadlock prevention: prevent deadlocks in the design phase through some algorithms and strategies, such as resource orderly allocation method, resource deprivation, etc.
  4. Ignore deadlock: For some systems, the probability of deadlock is low and the resolution cost is high, so you can choose to ignore deadlock. When a deadlock occurs, it can be restored to normal through system restart or manual intervention.
public class DeadlockExample {
    public static void main(String[] args) {
        final Object resource1 = new Object();
        final Object resource2 = new Object();

        Thread thread1 = new Thread(() -> {
            synchronized (resource1) {
                System.out.println("Thread 1 acquired lock on resource1");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (resource2) {
                    System.out.println("Thread 1 acquired lock on resource2");
                }
            }
        });

        Thread thread2 = new Thread(() -> {
            synchronized (resource2) {
                System.out.println("Thread 2 acquired lock on resource2");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (resource1) {
                    System.out.println("Thread 2 acquired lock on resource1");
                }
            }
        });

        thread1.start();
        thread2.start();

        // 等待两个线程执行完毕
        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Execution completed");
    }
}

In the above code, two threads  thread1 and and  thread2 respectively try to acquire  resource1 and  resource2 lock. But the order in which they acquire locks is reversed, that is,   the lock  thread1 acquired first  , and then  the lock acquired; and  the lock  acquired first   , and then   the lock acquired.resource1resource2thread2resource2resource1

In this case, if two threads start at the same time,  thread1 acquire  resource1 the lock and wait for  resource2 the lock to be released, and  thread2 acquire  resource2 the lock and wait for  resource1 the lock to be released. Since the two threads are waiting for each other's lock, they will be in a deadlock state and cannot continue to execute.

4. Producer and consumer mode (waiting for wake-up mechanism)

In Java, the producer-consumer model is a common multi-threaded cooperation model, which is used to solve data exchange and synchronization problems between producers and consumers .

In the previous multi-threading, we will find that each thread is executed randomly, it may be

A A A A B B A A B A 

The waiting wake-up mechanism can make the alternation of threads become regular, becoming

A B A B A B A B A B A

Producers are threads that generate data, and consumers are threads that consume data. The following is a detailed introduction to producers and consumers in Java:

  1. Producer:

    • Producers are responsible for producing data and putting it into shared buffers or queues for consumption by consumers.
    • Producer threads typically execute in a loop, producing data and adding it to a buffer.
    • When the buffer is full, the producer waits until there is enough room for new data.
  2. consumer:

    • Consumers are responsible for getting data from the buffer and consuming or processing it.
    • The consumer thread usually executes in a loop, fetching data from the buffer and performing corresponding processing operations.
    • When the buffer is empty, the consumer waits until new data is available for consumption.
  3. Shared buffer:

    • Data exchange between producers and consumers is usually done through shared buffers or queues.
    • The buffer can be an array, a queue or other data structure, which is used to store the data generated by the producer for the consumer to retrieve.
    • The size of the buffer is limited, when the buffer is full, the producer must wait; when the buffer is empty, the consumer must wait.
    • Producers add data to the end of the buffer, and consumers consume data from the front of the buffer.

In order to implement the producer-consumer pattern, one of the following methods can be used:

  1. wait() and notify():

    • Use the object's wait() and notify() methods to implement the thread's waiting and waking operations.
    • The producer calls the wait() method to wait when the buffer is full , and calls the notify() method to wake up the consumer after producing data.
    • The consumer calls the wait() method to wait when the buffer is empty , and calls the notify() method to wake up the producer after consuming data.
  2. Condition and Lock:

    • Use  java.util.concurrent.locks.Condition and  java.util.concurrent.locks.Lock interfaces to implement thread waiting and wakeup operations.
    • Producers and consumers use different condition variables to wait and wake up, respectively.
    •  Use the Lock object to protect access to shared data, and perform thread waiting and wake-up operations through condition variables  await() and  methods.signal()

The producer-consumer model can help solve data synchronization and data exchange problems under the condition of multi-thread concurrency, and ensure the coordinated operation between producers and consumers. This pattern has applications in many concurrent programming scenarios, such as thread pools, message queues, producer-consumer problems, etc.

Significance of producer and consumer mode:

  1. Decoupling producers and consumers:

    • The producer-consumer pattern decouples the data production and consumption process, so that producers and consumers can operate independently.
    • The producer only needs to care about generating data and putting it into the buffer, and does not need to care about how the data is consumed.
    • Consumers only need to focus on getting data from the buffer and processing it accordingly, and don't need to care about the data generation process.
  2. Improve the concurrency and throughput of the system:

    • Producers and consumers can be executed in parallel, thereby improving the concurrent performance of the system.
    • The producer does not have to wait for the consumer to finish processing the data, but can continue to produce new data.
    • Consumers do not have to wait for producers to generate new data, but can process existing data in parallel.
  3. Buffers balance production and consumption rates:

    • Data exchange and synchronization are performed between producers and consumers through shared buffers.
    • Buffers act as an intermediary between producers and consumers, balancing the rate of production and consumption between them.
    • When the producer is faster than the consumer, data is stored in a buffer for the consumer to consume.
    • When the consumer is faster than the producer, the consumer can get data from the buffer without waiting for the producer to generate.
  4. To achieve communication and synchronization between threads:

    • The producer-consumer pattern provides an efficient way to communicate and synchronize between threads.
    • Producers and consumers can use the wait and wakeup mechanism to achieve thread synchronization and collaboration.
    • Producers wait when the buffer is full until space is available; consumers wait when the buffer is empty until there is data to consume.
    • When producers generate new data or consumers consume data, they can notify and wake up each other.

To sum up, the producer-consumer model is an important multi-threaded programming model, which can improve the concurrency, throughput and efficiency of the system, realize decoupling and collaboration between producers and consumers, and ensure that data Correctness and reliability of exchange and synchronization. Widely used in concurrent programming and asynchronous systems.

import java.util.LinkedList;

class Producer implements Runnable {
    private LinkedList<Integer> buffer;
    private int maxSize;
    
    public Producer(LinkedList<Integer> buffer, int maxSize) {
        this.buffer = buffer;
        this.maxSize = maxSize;
    }
    
    @Override
    public void run() {
        for (int i = 1; i <= 10; i++) {
            try {
                produce(i);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    
    private void produce(int value) throws InterruptedException {
        synchronized (buffer) {
            while (buffer.size() == maxSize) {
                System.out.println("缓冲区已满,生产者等待...");
                buffer.wait();
            }
            
            buffer.add(value);
            System.out.println("生产者生产: " + value);
            
            buffer.notifyAll();
        }
    }
}

class Consumer implements Runnable {
    private LinkedList<Integer> buffer;
    
    public Consumer(LinkedList<Integer> buffer) {
        this.buffer = buffer;
    }
    
    @Override
    public void run() {
        while (true) {
            try {
                consume();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
    
    private void consume() throws InterruptedException {
        synchronized (buffer) {
            while (buffer.size() == 0) {
                System.out.println("缓冲区为空,消费者等待...");
                buffer.wait();
            }
            
            int value = buffer.removeFirst();
            System.out.println("消费者消费: " + value);
            
            buffer.notifyAll();
        }
    }
}

public class ProducerConsumerExample {
    public static void main(String[] args) {
        LinkedList<Integer> buffer = new LinkedList<>();
        int maxSize = 5;
        
        Producer producer = new Producer(buffer, maxSize);
        Consumer consumer = new Consumer(buffer);
        
        Thread producerThread = new Thread(producer);
        Thread consumerThread = new Thread(consumer);
        
        producerThread.start();
        consumerThread.start();
    }
}

In this example, the producer thread   produces data  produce() in it through the method  , and the consumer thread consumes data  from it through the method  . Among them,  is a shared buffer, using the waiting and wake-up mechanism to achieve thread synchronization.bufferconsume()bufferbuffer

Note that buffer is used in the example  LinkedList , but this is just a data structure used in the example. In fact, other thread-safe data structures can be used, such as  ArrayBlockingQueue or  LinkedBlockingQueue to achieve a more efficient producer-consumer pattern.

After running the code example, you can observe that the producer generates data one by one and puts it into the buffer, while the consumer takes the data out of the buffer one by one for consumption, and the execution between them is carried out alternately. When the buffer is full, the producer thread waits; when the buffer is empty, the consumer thread waits. In this way, data exchange and synchronization between producers and consumers is realized.

Summarize:

        Today we learned the necessary and interesting life cycle in multithreading, locks and a classic multithreading mode: producer and consumer mode. As an important technology to deal with high concurrency and high throughput, multithreading should have a good grasp of its knowledge points, so that we can use multithreading proficiently.

If my content is helpful to you, please like, comment and bookmark . Creation is not easy, everyone's support is my motivation to persevere!

Guess you like

Origin blog.csdn.net/fckbb/article/details/132120624