【JDK8】從 synchronized、Lock 到 StampedLock


之前在 StampedLock Idioms 中介紹了 JDK8 新的 StampedLock,剛好我書中也有幾個類似的例子,於是想說也整理在一起來個東施效顰好了。

首先來看個簡易的 ArrayList 實作好了:

package cc.openhome;

import java.util.Arrays;

public class ArrayList<E> {
    private Object[] elems;
    private int next;

    public ArrayList(int capacity) {
        elems = new Object[capacity];
    }

    public ArrayList() {
        this(16);
    }

    public void add(E e) {
        if(next == elems.length) {
            elems = Arrays.copyOf(elems, elems.length * 2);
        }
        elems[next++] = e;
    }

    public E get(int index) {
        return (E) elems[index];
    }

    public int size() {
        return next;
    }
}

如果將這個 ArrayList 用在只有主執行緒的環境中時,它沒有什麼問題。如果有兩個以上的執行緒同時使用它會如何?例如:

package cc.openhome;

public class ArrayListDemo {
    public static void main(String[] args) {
        ArrayList list = new ArrayList();
        new Thread(() -> {
            while (true) {
                list.add(1);
            }
        }).start();

        new Thread(() -> {
            while (true) {
                list.add(2);
            }
        }).start();
    }
}

在這個範例中建立了兩個執行緒,分別於 while 迴圈中對同一個 ArrayList 實例進行 add,如果你嘗試執行程式,「有可能」會發生 ArrayIndexOutOfBoundsException 例外:

Exception in thread "Thread-1" java.lang.ArrayIndexOutOfBoundsException: 64
    at cc.openhome.ArrayList.add(ArrayList.java:21)
    at cc.openhome.ArrayListDemo.lambda$main$1(ArrayListDemo.java:15)
    at cc.openhome.ArrayListDemo$$Lambda$2/1072591677.run(Unknown Source)
    at java.lang.Thread.run(Thread.java:744)

synchronized

學過多執行緒的都知道,這是機率問題,有可能發生,也有可能沒發生,就先因陣列長度過長,JVM 分配到的記憶體不夠,而發生 java.lang.OutOfMemoryError。這是多個執行緒存取同一物件相同資源時所引發的競速情況(Race condition),也知道解決的方法之一可以在 add 等方法上加上 synchronized 關鍵字。例如:

package cc.openhome;

import java.util.Arrays;

public class SynchronizedArrayList<E> {
    private Object[] elems;
    private int next;

    public SynchronizedArrayList(int capacity) {
        elems = new Object[capacity];
    }

    public SynchronizedArrayList() {
        this(16);
    }

    public synchronized void add(E e) {
        if(next == elems.length) {
            elems = Arrays.copyOf(elems, elems.length * 2);
        }
        elems[next++] = e;
    }

    public synchronized E get(int index) {
        return (E) elems[index];
    }

    public synchronized int size() {
        return next;
    }
}

這是學習 Java 多執行緒時一定會接觸到的基本概念,如果在方法上標示 synchronized,則執行方法必須取得該實例的鎖定,這是避免競速問題的作法之一。不過直接使用 synchronized 有許多限制,未取得鎖定的執行緒會直接被阻斷,如果你希望的功能是執行緒可嘗試取得鎖定,無法取得鎖定時就先作其他事,直接使用 synchronized 必須透過一些設計才可完成這個需求。

ReentrantLock

java.util.concurrent.locks 套件中提供了 ReentrantLock,,可以達到synchronized 的作用,也提供額外的功能,如果單純用來達到 synchronized 的作用,可以如下改寫方才的範例:

package cc.openhome;

import java.util.Arrays;
import java.util.concurrent.locks.*;

public class ReentrantLockArrayList<E> {
    private Lock lock = new ReentrantLock();
    private Object[] elems;
    private int next;

    public ReentrantLockArrayList(int capacity) {
        elems = new Object[capacity];
    }

    public ReentrantLockArrayList() {
        this(16);
    }

    public void add(E elem) {
        lock.lock();
        try {
            if (next == elems.length) {
                elems = Arrays.copyOf(elems, elems.length * 2);
            }
            elems[next++] = elem;
        } finally {
            lock.unlock();
        }
    }

    public E get(int index) {
        lock.lock();
        try {
            return (E) elems[index];
        } finally {
            lock.unlock();
        }
    }

    public int size() {
        lock.lock();
        try {
            return next;
        } finally {
            lock.unlock();
        }
    }
}

如果有兩個執行緒都想呼叫 getsize 方法,由於鎖定的關係,其中一個執行緒只能等待另一執行緒解除鎖定,無法兩個執行緒同時呼叫 getsize,然而這兩個方法都只是讀取物件狀態,並沒有變更物件狀態,如果只是讀取操作,可允許執行緒同時並行的話,那對讀取效率將會有所改善,你可以使用兩個 Lock 物件,透過設計來達到這項需求,不過 JDK 本身提供有 ReentrantReadWriteLock 可以使用。

ReentrantReadWriteLock

ReentrantReadWriteLockreadLock 方法會傳回 ReentrantReadWriteLock.ReadLock 實例,writeLock 方法會傳回 ReentrantReadWriteLock.WriteLock 實例。呼叫 ReadLocklock 方法時,若沒有任何 WriteLock 呼叫過 lock 方法,也就是沒有任何寫入鎖定時,就可以取得讀取鎖定。呼叫 WriteLocklock 方法時,若沒有任何 ReadLockWriteLock 呼叫過 lock 方法,也就是沒有任何讀取或寫入鎖定時,才可以取得寫入鎖定。

例如可使用 ReadWriteLock 改寫先前的 ArrayList,改進讀取效率:

package cc.openhome;

import java.util.Arrays;
import java.util.concurrent.locks.*;

public class ReentrantReadWriteLockArrayList<E> {
    private ReadWriteLock lock = new ReentrantReadWriteLock();
    private Object[] elems;
    private int next;

    public ReentrantReadWriteLockArrayList(int capacity) {
        elems = new Object[capacity];
    }

    public ReentrantReadWriteLockArrayList() {
        this(16);
    }

    public void add(E elem) {
        lock.writeLock().lock();
        try {
            if (next == elems.length) {
                elems = Arrays.copyOf(elems, elems.length * 2);
            }
            elems[next++] = elem;
        } finally {
            lock.writeLock().unlock();
        }
    }

    public E get(int index) {
        lock.readLock().lock();
        try {
            return (E) elems[index];
        } finally {
            lock.readLock().unlock();
        }
    }

    public int size() {
        lock.readLock().lock();
        try {
            return next;
        } finally {
            lock.readLock().unlock();
        }
    }
}

如此設計之後,若執行緒都多是在呼叫 getsize 方法,就比較不會因等待鎖定而進入阻斷狀態,可以增加讀取效率。

StampedLock

ReentrantReadWriteLock 在沒有任何讀取或寫入鎖定時,才可以取得寫入鎖定,這可用於實現悲觀讀取(Pessimistic Reading),如果執行緒進行讀取時,經常可能有另一執行緒有寫入需求,為了維持資料一致,ReentrantReadWriteLock 的讀取鎖定就可派上用場。

然而,如果讀取執行緒很多,寫入執行緒甚少的情況下,使用 ReentrantReadWriteLock 可能會使得寫入執行緒遭受飢餓(Starvation)問題,也就是寫入執行緒可能遲遲無法競爭到鎖定,而一直處於等待狀態。

JDK8 新增了 StampedLock 類別,可支援樂觀讀取(Optimistic Reading)實作,也就是若讀取執行緒很多,寫入執行緒甚少的情況下,你可以樂觀地認為,寫入與讀取同時發生的機會甚少,因此不悲觀地使用完全的讀取鎖定,程式可以查看資料讀取之後,是否遭到寫入執行緒的變更,再採取後續的措施(重新讀取變更後的資料,或者是拋出例外)。

假設之前的 ArrayList 範例會用於讀取執行緒多而寫入執行緒少的情況,而你想要實作樂觀讀取,如何使用 StampedLock 類別來實現:

package cc.openhome;

import java.util.Arrays;
import java.util.concurrent.locks.*;

public class StampedLockArrayList<E> {
    private StampedLock lock = new StampedLock();
    private Object[] elems;
    private int next;

    public StampedLockArrayList(int capacity) {
        elems = new Object[capacity];
    }

    public StampedLockArrayList() {
        this(16);
    }

    public void add(E elem) {
        long stamp = lock.writeLock();
        try {
            if (next == elems.length) {
                elems = Arrays.copyOf(elems, elems.length * 2);
            }
            elems[next++] = elem;
        } finally {
            lock.unlockWrite(stamp);
        }
    }

    public E get(int index) {
        long stamp = lock.tryOptimisticRead();
        Object elem = elems[index];
        if (!lock.validate(stamp)) {
            stamp = lock.readLock();
            try {
                elem = elems[index];
            } finally {
                lock.unlockRead(stamp);
            }
        }
        return (E) elem;
    }

    public int size() {
        long stamp = lock.tryOptimisticRead();
        int size = next;
        if (!lock.validate(stamp)) {
            stamp = lock.readLock();
            try {
                size = next;
            } finally {
                lock.unlockRead(stamp);
            }
        }
        return size;
    }
}

範例中使用了 StampedLock,可以使用 writeLock 方法取得寫入鎖定,這會傳回一個 long 整數代表鎖定戳記(Stamp),可用於使用解除鎖定或透過 tryConvertToXXX 方法轉換為其他鎖定。

在範例 get 中示範了一種樂觀讀取的實作方式,tryOptimisticRead 不會真正執行讀取鎖定,而是傳回鎖定戳記,如果有其他排他性鎖定的話,戳記會是 0,在這之後將資料暫讀出至區域變數,validate 方法用來驗證戳記是不是被其他排他性鎖定取得了,如果是的話就傳回 false,如果戳記是 0 也會傳回 false。如果 if 驗證出戳記被其他排他性鎖定取得,重新使用 readLock 做真正的讀取鎖定,並在鎖定時更新區域變數,而後解除讀取鎖定,如 if 驗證條件不成立,只要直接傳回區域變數的值。範例中的 size 方法也是類似的實作方式。

validate 之後發生寫入而傳回結果不一致是有可能的,如果你在意這樣的不一致,應當採用完全的鎖定。