並發編程之美(基礎篇)- 筆記

閱讀 《Java並發編程之美》 – 翟陸續 (作者) 的筆記

第一章 並發編程線程基礎

什麼是線程

進程和線程的關係:

  • 線程是進程中的一個實體,線程本身是不會獨立存在的
  • 進程是系統資源分配和調度的基本單位
  • 一個進程中至少有一個線程,進程中的多個線程共享進程的資源
  • CPU資源比較特殊,是分配到線程的

Java內存區域:一個進程中有多個線程,線程共享進程的堆和方法區,但線程有自己的程序計數器、虛擬機棧、本地方法棧

img

圖片來源:Java內存區域(運行時數據區域)詳解、JDK1.8與JDK1.7的區別 – 傑0327

線程的創建與運行

創建線程的三種方式:

  1. 繼承 Thread 類
  2. 實現 Runnable 接口
  3. 使用 FutureTask 類

直接繼承 Thread 並重寫 run() 方法:

public class MyThread extends Thread{
    @Override
    public void run() {
        // code...
    }
}
new MyThread().start();

實現 Runnable 接口:

public class Task implements Runnable{
    @Override
    public void run() {
        // code...
    }
}
new Thread(new Task()).start();

實現 Callable 接口,可以有返回值:

public class Task implements Callable<Integer> {

    @Override
    public Integer call() throws Exception {
        return null;
    }
}
FutureTask<Integer> futureTask = new FutureTask<>(new Task());
new Thread(futureTask).start();
futureTask.get();

總結:使用繼承 Thread 的方式並沒有將任務邏輯和線程機制分離,每次執行任務時都需要創建一個線程。而使用 Runnable 或 Callable 接口,可以使用一個線程執行多個任務。(最直接的方式就是將任務提交到線程池)

線程的等待與通知

// 阻塞線程的方法,直到:
// * 其他線程調用了 notify() 或 notifyAll()
// * 線程被中斷,則拋出中斷異常
// * 線程超時返回
// * 虛假喚醒
obj.wait();
obj.wait(5000);

obj.notify();
obj.notifyAll();

調用以上方法需要獲取對象的監視器鎖,有兩種方式可以獲得對象的監視器鎖:

// 獲取對象本身的監視器鎖
public synchronized void method(){
    while (!condition)
    	wait();
}
// 獲取 obj 對象的監視器鎖
synchronized(obj){
    while (!condition)
        obj.wait();
}
  • 獲取監視器鎖後先檢查條件是否滿足,否則調用 wait() 將線程掛起並釋放鎖(使用 while循環 是為了避免虛假喚醒)
  • 當其他線程喚醒正在等待的線程時,被喚醒的線程會先競爭鎖,得到鎖的線程會從 wait() 方法返回並再次檢查條件

線程中的方法

thread.join(); // 掛起調用線程,直到目標線程執行結束
Thread.sleep(1000); // 在指定時間內不參與CPU調度(靜態方法)
Thread.yield(); // 提示CPU線程希望提前廢棄CPU時間(靜態方法)

線程中斷:

thread.interrupt(); // 中斷線程對象 
thread.isInterrupted(); // 判斷線程對象是中斷
Thread.interrupted(); // 判斷調用線程是否被中斷並清除中斷標誌(靜態方法)

中斷線程只是將標誌置位,具體如何響應取決於線程自身(可能拋出異常或繼續執行)

ReentrantLock reentrantLock = new ReentrantLock();
reentrantLock.lock(); // 等待線程獲得鎖再拋出中斷異常
reentrantLock.lockInterruptibly(); // 立即拋出線程中斷

線程上下文切換

系統的調度方式有兩種:搶佔式和非搶佔式

在搶佔式的系統中,線程通過時間片輪詢的方式佔用CPU,當時間片用完時讓出CPU,執行線程上下文切換。

這種調度方式決定了當執行CPU密集型任務時,最多只能啟動和CPU數同等的線程數;而執行IO密集型任務時,一般可以啟動更多的線程。

線程死鎖

死鎖是指兩個或兩個以上的線程在執行過程中,因爭奪資源而造成的相互等待的現象,在無外力的作用下,這些線程會一直等待而無法繼續運行下去。

產生死鎖的四個必備條件:

  • 互斥條件:資源只能被一個線程持有(具有排他性)
  • 請求並持有:線程已持有資源,並請求其他被其他線程持有的資源
  • 不可剝奪條件:被持有的資源只有持有它的線程可以釋放
  • 環路等待條件:對資源的請求形成一個環形鏈

解決死鎖的方式:

  • 資源申請的有序性原則
  • (InnoDB)請求超時、圖算法

守護線程與用戶線程

JVM 進程在用戶線程都結束後退出,而不管是否在有守護線程在執行。

Thread thread = new Thread();
thread.setDaemon(true);
thread.start();

ThreadLocal

下面代碼每個線程都會擁有自己的SimpleDateFormat對象:

// 從SimpleDateFormat 的注釋可以得知,SimpleDateFormat 不是線程安全的
private ThreadLocal<SimpleDateFormat> DateFormatContext = ThreadLocal.withInitial(()->{
    return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
});

ThreadLocal 實現原理:

img

圖片來源:ThreadLocal explained – DuyHai

Thread.set() 方法:

// 獲取當前線程的 threadLocals 變量並將鍵值對(theadLocal, value)放到裏面
// 並且 threadLocals 是懶加載的
public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}
ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}

線程本地的變量:TheadLocalMap 是一個線程本地的散列表

public class Thread implements Runnable {
    /* ThreadLocal values pertaining to this thread. This map is maintained
     * by the ThreadLocal class. */
    ThreadLocal.ThreadLocalMap threadLocals = null;

    /*
     * InheritableThreadLocal values pertaining to this thread. This map is
     * maintained by the InheritableThreadLocal class.
     */
    ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
}

懶加載的 threadLocals:

void createMap(Thread t, T firstValue) {
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}
/**
 * The initial capacity -- MUST be a power of two.
 */
private static final int INITIAL_CAPACITY = 16;

ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
    table = new Entry[INITIAL_CAPACITY];
    int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
    table[i] = new Entry(firstKey, firstValue);
    size = 1;
    setThreshold(INITIAL_CAPACITY);
}

ThreadLocal.get() 方法:

public T get() {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        // 以 TheadLocal 對象為鍵,從線程本地的散列表取值
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    return setInitialValue();
}

如果 Map 或對應的鍵還未初始化,則會返回初始值:

private T setInitialValue() {
    T value = initialValue();
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
    return value;
}

初始值默認是 null:上面的示例則將初始值設置為 SimpleDateFormat 對象

protected T initialValue() {
    return null;
}
public static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier) {
    return new SuppliedThreadLocal<>(supplier);
}

static final class SuppliedThreadLocal<T> extends ThreadLocal<T> {

    private final Supplier<? extends T> supplier;

    SuppliedThreadLocal(Supplier<? extends T> supplier) {
        this.supplier = Objects.requireNonNull(supplier);
    }
	
    // 所以 supplier 本質是一個工廠,而 ThreadLocalMap 就是一個線程本地的容器
    // supplier.get() 會返回新創建的對象
    @Override
    protected T initialValue() {
        return supplier.get();
    }
}

ThreadLocal.remove():當本地對象不使用時要將其移除,防止內存溢出

public void remove() {
    ThreadLocalMap m = getMap(Thread.currentThread());
    if (m != null)
        m.remove(this);
}

ThreadLocal 總結:

  • ThreadLocal 其實就是一個工具殼,它會操作線程本地的散列表 threadLocals,散列表以 ThreadLocal 對象為鍵
  • 線程本地的 threadLocals 是懶加載的,初始容量是 16
  • 在不使用對象後應調用 remove() 避免內存溢出

InheritableThreadLocal

使用 TheadLocal,子線程訪問不了父線程的本地變量,InheritableThreadLocal 解決了該問題。

源碼分析:

public class InheritableThreadLocal<T> extends ThreadLocal<T> {
	// 根據父線程本地變量的值計算子線程本地變量的值(這裡是直接返回原值)
    protected T childValue(T parentValue) {
        return parentValue;
    }

    // 重寫父類 ThreadLocal 的方法,將 threadLocals 替換成 inheritableThreadLocals
    ThreadLocalMap getMap(Thread t) {
       return t.inheritableThreadLocals;
    }

    void createMap(Thread t, T firstValue) {
        t.inheritableThreadLocals = new ThreadLocalMap(this, firstValue);
    }
}

在創建線程時,會調用 init() 方法:將父線程的本地變量淺拷貝到子線程

public Thread() {
    init(null, null, "Thread-" + nextThreadNum(), 0);
}
private void init(ThreadGroup g, Runnable target, String name,
                  long stackSize, AccessControlContext acc) {

    //...
    
    // 如果父線程的 inheritableThreadLocals 不為 null,則執行以下代碼
    if (parent.inheritableThreadLocals != null)
        this.inheritableThreadLocals =
            ThreadLocal.createInheritedMap(parent.inheritableThreadLocals);
	
    //...
}
static ThreadLocalMap createInheritedMap(ThreadLocalMap parentMap) {
    return new ThreadLocalMap(parentMap);
}
private ThreadLocalMap(ThreadLocalMap parentMap) {
    Entry[] parentTable = parentMap.table;
    int len = parentTable.length;
    setThreshold(len);
    table = new Entry[len];

    // 變量父線程散列表鍵值對
    for (int j = 0; j < len; j++) {
        Entry e = parentTable[j];
        if (e != null) {
            // 如果鍵不為 null
            @SuppressWarnings("unchecked")
            ThreadLocal<Object> key = (ThreadLocal<Object>) e.get();
            if (key != null) {
                // 獲取鍵對應的值
                Object value = key.childValue(e.value);
                // 創建新的鍵值對
                Entry c = new Entry(key, value);
                int h = key.threadLocalHashCode & (len - 1);
                // 使用線性探測法解決散列衝突
                while (table[h] != null)
                    h = nextIndex(h, len);
                table[h] = c;
                size++;
            }
        }
    }
}

InheritableThreadLocal 總結:InheritableThreadLocal 在創建線程時通過將父線程的本地變量複製到子線程以實現子線程可以訪問父線程本地變量的目的。

第二章 並發編程的其他基礎知識

什麼是多線程並發編程

並發與並行的概念:

  • 並發是指同一時間段內多個任務同時在執行
  • 並行是指單位時間內多個任務同時在執行

時間段由多個單位時間組成

任務類型:

  • IO 密集型任務:對於IO密集型任務,我們應該盡量減少線程阻塞時對CPU的佔用,減少CPU空閑時間
  • CPU密集型任務:對於CPU密集型任務,我們應該盡量減少線程上下文切換的開銷

Java 中的線程安全問題

共享資源:可以被多個線程讀寫的資源

當多個線程同時對共享資源進行讀寫時,就必須進行同步。

Java 內存模型規定,將所有變量都存放在主內存中,當線程使用變量時,會把內存裏面的變量複製到自己的工作內存,線程讀寫變量時操作的都是自己工作內存中的變量。

這時,就需要通過同步機制來保證對共享資源操作的原子性

image-20201102152438158

Java 中共享變量的內存可見性

Java 的內存模型是一個抽象的概念,工作內存對應到硬件架構就是CPU內的存儲器、一級緩存、二級緩存。

image-20201102152855951

緩存導致的內存可見性問題:

  1. 線程A對共享變量1進行讀寫,並將結果同步到緩存和主內存
  2. 線程B對共享變量1進行讀寫(從二級緩存讀取),並將結果同步到緩存和內存
  3. 此時,線程A在此對共享變量A進行讀寫,就會從一級緩存讀到臟數據

Java 中的 synchronized 關鍵字

synchronized 是對象內部的一個監視器鎖,它是一個排他鎖。

synchronized 的內存語義:

  • 進入 synchronized 塊的內存語義是把 synchronized 塊內使用到的變量從線程的工作內存中清除,這樣在 synchronized 塊內使用到該變量時就不會從線程的工作內存中獲取,而是直接從主內存中獲取;
  • 退出 synchronized 的語義是把 synchronized 塊內對共享變量的修改刷新到主內存。

synchronized 可以解決共享變量內存的可見性問題,也可以用來實現原子性操作

Java 中的 volatile 關鍵字

當一個變量被聲明為 volatile 時,線程在寫入變量時不會把值緩存在寄存器或其他地方,而是會把值刷新會主內存;當其他線程讀取該共享變量時,會從主內存重寫獲取最新值,而不是使用當前線程工作內存中的值。

volatile 可以解決共享變量內存的可見性問題和指令重排序問題,但不能保證操作的原子性

Java 中的 CAS 操作

CAS 通過硬件保證操作的原子性。

如果被操作的值存在環形轉換,使用CAS算法就可能會出現ABA問題。解決的方式是增加一個遞增的版本號或時間戳。

Unsafe 類

Unsafe 類中的方法:

// 獲取變量的偏移值
public native long objectFieldOffset(Field f);
// 獲取數組第一元素的地址
public native int arrayBaseOffset(Class<?> arrayClass);
// 獲取數組一個元素的佔用的位元組
public native int arrayIndexScale(Class<?> arrayClass);

// 原子性地更新
public final native boolean compareAndSwapLong(Object o, long offset,
                                               long expected,
                                               long x);

// 獲取 Long 類型的值(具有 volatile 語義)
public native long getLongVolatile(Object o, long offset);
// 設置 Long 類型的值(具有 volatile 語義)
public native void putLongVolatile(Object o, long offset, long x);
// 設置 Long 類型的值(不具有 volatile 語義)
public native void putOrderedLong(Object o, long offset, long x);


// 阻塞當前線程
public native void park(boolean isAbsolute, long time);
// 喚醒指定線程
public native void unpark(Object thread);
// 封裝 CAS 算法的方法
public final long getAndSetLong(Object o, long offset, long newValue) {
    long v;
    do {
        v = getLongVolatile(o, offset);
    } while (!compareAndSwapLong(o, offset, v, newValue));
    return v;
}

public final long getAndAddLong(Object o, long offset, long delta) {
    long v;
    do {
        v = getLongVolatile(o, offset);
    } while (!compareAndSwapLong(o, offset, v, v + delta));
    return v;
}

獲取 Unsafe 對象:

public final class Unsafe {

    private static native void registerNatives();
    static {
        registerNatives();
        sun.reflect.Reflection.registerMethodsToFilter(Unsafe.class, "getUnsafe");
    }

    private Unsafe() {}

    private static final Unsafe theUnsafe = new Unsafe();
    
    @CallerSensitive
    public static Unsafe getUnsafe() {
        Class<?> caller = Reflection.getCallerClass();
        if (!VM.isSystemDomainLoader(caller.getClassLoader()))
            throw new SecurityException("Unsafe");
        return theUnsafe;
    }
}	

由於 Unsafe 類做了限制,這裡需要使用反射來獲取 Unsafe 對象:

public class UnsafeTest {
    volatile long value;
    static long valueOffset;
    static Unsafe unsafe;

    static {
        try {
            Field unsafeField = Unsafe.class.getDeclaredField("theUnsafe");
            unsafeField.setAccessible(true);
            unsafe = (Unsafe) unsafeField.get(null);
            valueOffset = unsafe.objectFieldOffset(UnsafeTest.class.getDeclaredField("value"));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) {
        UnsafeTest test = new UnsafeTest();
        boolean success = UnsafeTest.unsafe.compareAndSwapLong(test, UnsafeTest.valueOffset, 0, 1);
        System.out.println(success);
        System.out.println(test.value);
    }
}

指令重排序

Java 內存模型允許編譯器和處理器對指令重排序以提高運行性能。

在單線程下重排序可以保證最終執行的結果與程序順序執行的結果一致,但是在多線程下就會存在問題。

volatile 可以解決指令重排序的導致的問題。

偽共享

偽共享出現的原因是因為緩存和主內存進行數據交換的單位是緩存行,多線程去修改同一緩存行的不同變量時,只有一個線程可以去修改緩存行的變量,因為緩存一致性協議會使其他線程的同一緩存行失效,使線程只能重新從二級緩存或主內存讀取,從而造成性能下降。

在單線程下,緩存行可以充分利用程序運行的局部性原理,從而提高程序性能。

多線程解決緩存行的方法:

  1. 位元組填充
  2. @sun.misc.Contended 註解

使用 @Contended 註解使需要使用參數:-XX:-RestrictContended

默認寬度是 128 位元組,可以使用 -XX:ContendedPaddingWidth 自定義寬度;

鎖的概述

  • 悲觀鎖與樂觀鎖
  • 獨佔鎖與共享鎖
  • 公平鎖與非公平鎖
  • 可重入鎖
  • 自旋鎖 (默認次數是10次,-XX:PreBlockSpinsh