The theory of

Java multithreaded programming is a very important part of Java, to write efficient and safe multithreaded concurrent programs, developers need to have a deep understanding of the underlying principles of the operating system.

participatory

There are no thread-safety issues with data modification under a single thread. Once you get to multi-threaded environments, where multiple threads need to share the same data, you need to consider data security.

Sharing is a problem caused by multithreading and needs to be solved. The purpose is to achieve the same piece of data is shared by multiple threads at the same time, but also to ensure data security.

The following features are proposed to ensure this data security.

Mutual exclusivity

As mentioned above, if multiple threads modify the same data at the same time, data security issues are inevitable. Then we can propose a mutually exclusive resource policy so that the same resource can only be accessed by one thread at a time. The simplest implementation in Java uses the synchronized keyword.

atomic

Atomicity means that an operation is the smallest, indivisible whole. The easiest way to ensure atomicity is to use operating system instructions such as CAS.

But for example, many operating systems divide long operation into high-order and low-order operation, which does not meet the atomicity. The i++ operation is not actually an atomic operation (read, write, update memory). In Java we can do this using a wrapper class under the Atomic atomic package.

visibility

Understanding visibility requires an understanding of the JVM memory model, as follows,

As you can see from the figure, there is a worker thread for each thread in the JVM (to narrow the gap between memory module and CPU processing speed and improve performance). For shared variables, the thread reads a copy of the shared variable in working memory, modifies the value of the copy, and synchronizes the value from working memory to main memory at some point in time.

The problem is that thread 1 has made changes to the variable, and thread 2 May not see the changes made by thread 1, causing thread 2 to use the old value. Problems occur in some real-time scenarios.

public class Test {
    private static boolean done;
    private static int result;

    private static class ReaderThread extends Thread {
        public void run(a) {
            try {
                Thread.sleep(20);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            if (!done) {
                System.out.println(done);
            }
            System.out.println(result);
        }
    }

    private static class WriterThread extends Thread {
        public void run(a) {
            try {
                Thread.sleep(20);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            result = 100;
            done = true; }}public static void main(String[] args) {
        new WriterThread().start();
        newReaderThread().start(); }}Copy the code

Run it a few times and the possible results are as follows,

// First runtrue100 // Second runfalse
0
Copy the code

First run:

When executing if (! Done) has not read the result of the writer thread, but it does when println(done) follows.

Second run:

When the reader thread println(result) does not get the value 100 updated by the writer thread.

In Java, visibility can be guaranteed by using the keyword volatile.

order

In order to improve the performance of the program, the compiler and CPU may reorder the instructions,

  1. Compiler optimized reordering
  2. Instruction – level parallel reordering is adopted by modern processorsInstruction Level Parallelism (ILP)To execute multiple instructions on top of each other. If there is no data dependency, the processor can change the execution order of the machine instructions corresponding to the statement.
  3. Memory system reordering. Because the processor uses caching and read/write buffers, this makes the load and store operations appear to be out of order.

Thread collaboration

Thread primary state

Thread state switching can be summarized in a diagram,

Among them,

  • New: indicates the New statusnew Thread, did not callstartThe former.
  • Runnable: Ready state, callstartMethod, the thread enters the ready state, waiting for CPU resources. The thread in the ready state is the thread scheduler of the Java runtime system(thread scheduler)To scheduling.
  • Running: Indicates the Running status
  • Blocked: A thread that has not completed execution and is Blocked due to some reason.
  • Dead: Indicates that the thread execution is complete or abnormal.

Wait, notify, and notifyAll

!!!!! Because their implementation is implemented through monitor ownership of objects, it can only be done in Java with the synchronized keyword. That is, these methods must be called in a synchronized method, method block.

The wait method suspends the current thread and cedes monitor ownership until a notify/notifyAll or timeout awakens the thread.

If notify/notifyAll is called on the same object, it wakes up the threads waiting on the corresponding Monitor. And the difference between them is,

  1. notifyOnly one thread on monitor can be awakened, with no effect on other threads
  2. notifyAllAll threads are woken up

Await, signal, signalAll

The Condition class is provided in JUC (java.util.Concurrent) for coordination between threads. The await() method can be called on the Condition to make the thread wait, and other threads call signal() or signalAll() to wake up the waiting thread.

Because conditions can be initialized many times, each of which can be a separate wait-notify Condition, this approach is more flexible.

Imagine an example where we need to create a bounded buffer with two methods put and take,

  1. Put: When the buffer is full, the thread will block until there is free space
  2. Take: When the fetched buffer is empty, the thread blocks until data is available

The implementation’s appeal is to be able to separate the wait items of the PUT and take threads so that it can be optimized to wake up only a single corresponding thread when the above conditions are met. This requirement can then be implemented using two Condition instances,

class BoundedBuffer<T> {
    private final Lock lock = new ReentrantLock();
    
    // No condition instance
    private final Condition notFull  = lock.newCondition();
    // There is no empty condition instance
    private final Condition notEmpty = lock.newCondition();

    private final Object[] items = new Object[5];
    private int putptr, takeptr, count;

    public void put(T x) throws InterruptedException {
        lock.lock();
        try {
            while (count == items.length)
                notFull.await();
            items[putptr] = x;
            if (++putptr == items.length) putptr = 0;
            ++count;
            notEmpty.signal();
        } finally{ lock.unlock(); }}@SuppressWarnings("unchecked")
    public T take(a) throws InterruptedException {
        lock.lock();
        try {
            while (count == 0)
                notEmpty.await();
            T x = (T) items[takeptr];
            if (++takeptr == items.length) takeptr = 0;
            --count;
            notFull.signal();
            return x;
        } finally{ lock.unlock(); }}}Copy the code

The test code is as follows,

class Test {
    public static void main(String[] args) {
        final BoundedBuffer<Integer> boundedBuffer = new BoundedBuffer<Integer>();
        new Thread(new Runnable() {
            public void run(a) {
                for (int i = 0; i < 10; i++) {
                    try {
                        boundedBuffer.put(i);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }
        }).start();
        new Thread(new Runnable() {
            public void run(a) {
                for (int i = 0; i < 10; i++) {
                    try {
                        System.out.print(boundedBuffer.take() + "");
                    } catch(InterruptedException e) { e.printStackTrace(); } } } }).start(); }} Output:0 1 2 3 4 5 6 7 8 9 
Copy the code

According to the output results, it meets the requirements. It can be seen that Condition is used for inter-thread coordination, which has more flexibility and higher efficiency.

Sleep, yield, join

Sleep allows the current thread to pause for a specified time.

  1. Wait requires synchronization, that is, at the keywordsynchronizedWhile sleep can be called directly
  2. Sleep temporarily releases the CPU time slice without releasing the lock, while wait releases the lock

The yield method suspends the current thread so that other threads have a chance to execute. There is no guarantee that the current thread will stop immediately. The yield method simply changes the Running state to a Runnable state. This method is rarely used in scenarios, and is mostly used for testing and debugging.

The join method allows the parent thread to wait for the child thread to complete its execution. That is, asynchronous threads are merged into synchronous threads. Join is also implemented via wait/notify.

Principle of synchronized

Let’s see how synchronized works by decompiling the following code,

class Test {
    public void method(a) {
        synchronized (this) {
            System.out.println("start"); }}}Copy the code

Decompilation is implemented by using the tool Javap provided by Java. The output information is as follows:

->: javap -c Test

Compiled from "Test.java"
class com.dv.Test {
  com.dv.Test();
    Code:
       0: aload_0
       1: invokespecial #1 // Method java/lang/Object."
      
       ":()V
      
       4: returnpublic void method(); Code: 0: aload_0 1: DUP 2: astore_1 3: monitorenter // Key 4: getstatic#2 // Field java/lang/System.out:Ljava/io/PrintStream;
       7: ldc           #3 // String start
       9: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;) V12: ALOAD_1 13: Monitorexit // Keypoint 14: GOto 22 17: astore_2 18: ALOAD_1 19: Monitorexit // keypoint 20: ALOad_2 21: athrow 22:return
    Exception table:
       from    to  target type
           4    14    17   any
          17    20    17   any
}
Copy the code

Looking at the decompiled JVM instructions above, monitorenter and Monitorexit are at the heart of implementing the synchronized keyword.

The JVM has rules for monitors, one for each object. The monitor is locked when it is occupied, and the monitorenter directive attempts to acquire monitor ownership.

  1. There’s a pair insidemonitorenterStudent: If at this pointmonitorenterIf the number of entries is 0, the thread getsmonitorOwnership, and set it to 1.
  2. If the thread already ownsmonitorOwnership, re-entry will add +1 to the statistic.
  3. ifmonitorThe current thread is blocked until it is occupied by another threadmonitorenterIs 0, try again to obtainmonitorOwnership.

Monitorexit is the reverse logic of monitorenter in the same way. Each time it executes this command, it decreases the count by one until it reaches zero, freeing monitor ownership.

Principle of volatile

As explained above, multi-threaded manipulation of shared variables may result in data insecurity due to variables not being visible between threads. This is solved by the keyword volatile.

Volatile, on the other hand, can also prevent reordering. Let’s look at the underlying rationale for volatile.

Visibility implementation

The main differences between volatile writes and normal variables are as follows:

  1. Changes to volatile variables are forced to flush to main memory
  2. Changing a volatile variable invalidates a copy of the value corresponding to the working memory in other threads, so that the variable can be read from main memory again

Prevent reordering implementations

This is actually a concept from the JSR specification, happens-before, which means that writes to volatile variables occur before reads (writes are visible to reads).

The implementation is implemented in the Java memory model, which restricts reordering of volatile values.

The memory barrier

To achieve volatile visibility and happens-before, the JVM does this through memory barriers.

A memory barrier, also known as a memory barrier, is a set of processor instructions that ensure that instruction reordering does not place instructions behind the barrier in front of it, that is, by the time the barrier instructions are executed, all previous operations have been completed.