Doug Lea併發設計模式(JUC學習前奏)

zanwensicheng發表於2019-02-21

個人技術部落格:www.zhenganwen.top

引言

眾所周知,JUC出自併發大師Doug Lea之手,他對Java併發效能的提升做出了巨大的貢獻。而在JDK1.5未引入JUC之前,Doug Lea其實就已經寫了一套JUC類庫並受到社群的大力支援。本文就是介紹Doug Lea寫JUC之前提出的一些方法論,JUC是基於這些方法論實踐的結果(外國學者的這一點品質值得我們學習:先研究出一套可行的方法論,那麼實踐便是有據可依,必有成效)。

觀察者模式

Doug Lea併發設計模式(JUC學習前奏)

如圖所示觀察者模式角色關係圖,主題暴露addObserver方法讓任何觀察者都可以觀察自己感興趣的主題,當主題發生變動時自己會主動通知通過addObserver註冊到自己身上的所有觀察者。相比較觀察者不斷輪詢主題而言,這種機制能夠大大減輕觀察者的負擔而使觀察者專注於當主題發生變化後應執行的業務。

模板

Observablle/Subject

public interface Observable {

    void addObserver(Observer observer);

    void deleteObserver(Observer observer);

    void notifyAllObservers();
}
複製程式碼

Observer

public interface Observer {
    void notifyUpdate();
}
複製程式碼

IntegerObservable(concrete subject)

public class IntegerObservable implements Observable {

    private int state;

    private Collection<Observer> observers;

    public IntegerObservable() {
        this.observers = new ArrayList<>();
    }

    public int getState() {
        return state;
    }

    public void setState(int state) {
        if (state == this.state) {
            return;
        }
        this.state = state;
        notifyAllObservers();
    }

    @Override
    public void addObserver(Observer observer) {
        observers.add(observer);
    }

    @Override
    public void deleteObserver(Observer observer) {
        observers.remove(observer);
    }

    @Override
    public void notifyAllObservers() {
        observers.forEach(Observer::notifyUpdate);
    }
}
複製程式碼

BinaryObserver(concrete observer)

public class BinaryObserver implements Observer {

    private IntegerObservable observable;

    public BinaryObserver(IntegerObservable observable) {
        this.observable = observable;
        observable.addObserver(this);
    }

    @Override
    public void notifyUpdate() {
        System.out.println(this.getClass().getName() + " received the notify: state -> " + Integer.toBinaryString(this.observable.getState()));
    }
}
複製程式碼

OctalObserver(concrete observer)

public class OctalObserver implements Observer {

    private IntegerObservable observable;

    public OctalObserver(IntegerObservable observable) {
        this.observable = observable;
        observable.addObserver(this);
    }

    @Override
    public void notifyUpdate() {
        System.out.println(this.getClass().getName() + " received the notify: state -> " + Integer.toOctalString(this.observable.getState()));
    }
}
複製程式碼

ObserverPatternClient(for test)

public class ObserverPatternClient {

    public static void main(String[] args) {
        IntegerObservable integerObservable = new IntegerObservable();
        Observer observer1 = new OctalObserver(integerObservable);
        Observer observer2 = new BinaryObserver(integerObservable);
        integerObservable.setState(5);  // changed
        integerObservable.setState(5);  // do write, but not changed
        integerObservable.setState(10); // changed
    }
}

designpattern.observer.OctalObserver received the notify: state -> 5
designpattern.observer.BinaryObserver received the notify: state -> 101
designpattern.observer.OctalObserver received the notify: state -> 12
designpattern.observer.BinaryObserver received the notify: state -> 1010
複製程式碼

Observer to monitor Thread Lifecycle

需求

我們需要監控任務的執行狀態,比如任務何時開始被執行、何時執行結束、是否正常執行完畢、如果執行時出現異常那麼原因是什麼。

解決方案

  • 不斷輪詢

    我們可以不斷輪詢判斷該執行緒是否存活,Thread#join就是一個典型的例子,它通過while(isAlive)輪詢判斷它等待的執行緒是否終結從而使當前執行緒從join的呼叫中返回。這種方案的缺點是觀察執行緒的負擔較重從而導致CPU負擔較重並且靈活性較低。

  • 使用觀察者模式將要觀察的可執行任務封裝成一個可觀察的物件

    當執行緒的狀態發生改變時主動通知觀察執行緒,這樣不僅減小了觀察執行緒的負擔,與此同時觀察執行緒還能著手其他的事而沒必要在一直無限迴圈中耗著。

程式碼示例

我們不一定要按照模板生搬硬套,而應根據需求靈活變更,比如模板中的notifyUpdate()無參方法在此例中變成onEvent(T event),通過一個事件物件將觀察者和事件源解耦。

  • 可觀察的任務

    public class ObservableRunnable implements Runnable{
    
        private Runnable task;
    
        private LifecycleObserver observer;
    
        public ObservableRunnable(Runnable task, LifecycleObserver observer) {
            this.task = task;
            this.observer = observer;
        }
    
        public void notifyObserver(Event event) {
            observer.onEvent(event);
        }
    
        public static enum ThreadState {
            RUNNING,DONE,ERROR
        }
    
        public static class Event{
            private ThreadState state;
            private Throwable cause;
    
            public ThreadState getState() {
                return state;
            }
    
            public Throwable getCause() {
                return cause;
            }
    
            public Event(ThreadState state, Throwable cause) {
                this.state = state;
                this.cause = cause;
            }
        }
    
        @Override
        public void run() {
            notifyObserver(new Event(ThreadState.RUNNING,null));
            try {
                task.run();
            } catch (Throwable e) {
                notifyObserver(new Event(ThreadState.ERROR, e));
            }
            notifyObserver(new Event(ThreadState.DONE, null));
        }
    
    }
    複製程式碼
  • 生命週期觀察者抽象,在目標生命週期發生變化時根據接收的狀態做相應的處理

    public interface LifecycleObserver<T> {
        void onEvent(T event);
    }
    複製程式碼
  • 具體的生命週期觀察者,觀察任務的執行狀態:

    public class RunnableLifecycleObserver implements LifecycleObserver<ObservableRunnable.Event>{
    
        @Override
        public void onEvent(ObservableRunnable.Event event) {
            if (event.getCause() == null) {
                System.out.println(Thread.currentThread().getName() + " notify the state of task : state -> " + event.getState());
            } else {
                System.err.println(Thread.currentThread().getName() +" execute fail and the cause is " + event.getCause().getMessage());
                try {
                    // you can deal with the failing task
                    TimeUnit.SECONDS.sleep(3);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    複製程式碼
  • 測試1:任務正常執行完畢:

    public class RunnableLifecycleMonitorClient {
    
        public static void main(String[] args) {
            conccurentQueryForIds(Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9), new RunnableLifecycleObserver());
        }
    
        public static void conccurentQueryForIds(List<Integer> ids, RunnableLifecycleObserver listener) {
            ids.stream().forEach(id -> {
                new Thread(new ObservableRunnable(() -> {
                    try {
                        // sleep some seconds to simulate doing work
                        TimeUnit.SECONDS.sleep(ThreadLocalRandom.current().nextInt(5));
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                },listener)).start();
            });
        }
    
    }
    
    Thread-5 notify the state of task : state -> RUNNING
    Thread-2 notify the state of task : state -> RUNNING
    Thread-6 notify the state of task : state -> RUNNING
    Thread-1 notify the state of task : state -> RUNNING
    Thread-3 notify the state of task : state -> RUNNING
    Thread-4 notify the state of task : state -> RUNNING
    Thread-0 notify the state of task : state -> RUNNING
    Thread-7 notify the state of task : state -> RUNNING
    Thread-8 notify the state of task : state -> RUNNING
    Thread-8 notify the state of task : state -> DONE
    Thread-4 notify the state of task : state -> DONE
    Thread-6 notify the state of task : state -> DONE
    Thread-2 notify the state of task : state -> DONE
    Thread-1 notify the state of task : state -> DONE
    Thread-3 notify the state of task : state -> DONE
    Thread-0 notify the state of task : state -> DONE
    Thread-7 notify the state of task : state -> DONE
    Thread-5 notify the state of task : state -> DONE
    複製程式碼
  • 測試2:新增算術異常模擬程式執行異常:

    public static void conccurentQueryForIds(List<Integer> ids, RunnableLifecycleObserver listener) {
        ids.stream().forEach(id -> {
            new Thread(new ObservableRunnable(() -> {
                try {
                    // sleep some seconds to simulate doing work
                    TimeUnit.SECONDS.sleep(ThreadLocalRandom.current().nextInt(5));
                    if (id % 2 == 0) {
                        int i = 1 / 0;
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            },listener)).start();
        });
    }
    複製程式碼

    測試結果:

    Thread-2 notify the state of task : state -> RUNNING
    Thread-3 notify the state of task : state -> RUNNING
    Thread-5 execute fail and the cause is / by zero
    Thread-1 execute fail and the cause is / by zero
    Thread-1 notify the state of task : state -> RUNNING
    Thread-0 notify the state of task : state -> RUNNING
    Thread-4 notify the state of task : state -> RUNNING
    Thread-5 notify the state of task : state -> RUNNING
    Thread-6 notify the state of task : state -> RUNNING
    Thread-7 notify the state of task : state -> RUNNING
    Thread-8 notify the state of task : state -> RUNNING
    Thread-4 notify the state of task : state -> DONE
    Thread-7 execute fail and the cause is / by zero
    Thread-0 notify the state of task : state -> DONE
    Thread-2 notify the state of task : state -> DONE
    Thread-8 notify the state of task : state -> DONE
    Thread-5 notify the state of task : state -> DONE
    Thread-3 execute fail and the cause is / by zero
    Thread-1 notify the state of task : state -> DONE
    Thread-6 notify the state of task : state -> DONE
    Thread-7 notify the state of task : state -> DONE
    Thread-3 notify the state of task : state -> DONE
    複製程式碼

單執行緒執行設計模式

假設我們有這樣一個門:每個人想通過這扇門的時候需要報上自己的名號和去往的地方(第9~10行),只有當名號和目的地的首字母相同時才允許通過(第15行)

public class Gate {

    private String name = "nobody";
    private String destination = "nowhere";
    private int counter;

    public void pass(String name, String destination) {
        counter++;
        this.name = name;
        this.destination = destination;
        verify();
    }

    private void verify() {
        if (name.charAt(0) != destination.charAt(0)) {
            System.out.println("No." + counter + "==============broken : name -> " + name + " , destination -> " + destination);
        }
    }
}
複製程式碼

有門自然就有過門者,假設過門者的使命就是不斷地重複通過一扇門:

public class Walker extends Thread{

    private String name;
    private String destination;
    private Gate gate;

    public Walker(String name, String destination,Gate gate) {
        this.name = name;
        this.destination = destination;
        this.gate = gate;
    }

    @Override
    public void run() {
        System.out.println(name + " start walking...");
        while (true) {
            gate.pass(name, destination);
        }
    }
}
複製程式碼

在這個故事中,世界上只有一扇門,來者都需從此門過:

public class ConcurrentPassOneGate {

    public static void main(String[] args) {
        Gate gate = new Gate();
        Walker bob = new Walker("bob","beijing",gate);
        Walker jack = new Walker("jack","jinan",gate);
        Walker tom = new Walker("tom", "tianjin", gate);

        bob.start();
        jack.start();
        tom.start();
    }

} 

bob start walking...
tom start walking...
jack start walking...
No.255==============broken : name -> tom , destination -> tianjin
No.460==============broken : name -> tom , destination -> tianjin
No.736==============broken : name -> tom , destination -> tianjin
No.935==============broken : name -> tom , destination -> tianjin
No.1167==============broken : name -> bob , destination -> beijing
No.1386==============broken : name -> jack , destination -> jinan
No.1658==============broken : name -> tom , destination -> tianjin
No.1887==============broken : name -> tom , destination -> tianjin
......
複製程式碼

你會發現雖然這三個人都符合過門的條件,但是當三個人一同湧入時(同時報名號和目的地,而守門者只能聽清其中的一對)可能會發生:守門者只聽見了其中的bobtianjin(對應Gate類的第9,10行喊了bobbeijing,但在第16行被tom喊的tianjin蓋住了beijing)於是不讓他過。

這也就是為什麼各大景區入口都要設定單行檢票口的原因,不然檢票員拿著張三的身份證對著李四的臉看那不得鬧誤會嗎,自然只能一個一個來。Single Threaded Execution Pattern就對應這個單行檢票口,而技巧就是使用synchronized修飾臨界區,就本例而言,臨界區是pass方法,verifyprivate且內聯入了pass因此可以不予考慮。

public synchronized void pass(String name, String destination) {
    counter++;
    this.name = name;
    this.destination = destination;
    verify();
}
複製程式碼

再次執行:發現再也沒有過門失敗的了

bob start walking...
jack start walking...
tom start walking...

複製程式碼

不可變設計模式

以下摘自Oracle官網:

An object is considered immutable if its state cannot change after it is constructed. Maximum reliance on immutable objects is widely accepted as a sound strategy for creating simple, reliable code.

Immutable objects are particularly useful in concurrent applications. Since they cannot change state, they cannot be corrupted by thread interference or observed in an inconsistent state.

Programmers are often reluctant to employ immutable objects, because they worry about the cost of creating a new object as opposed to updating an object in place. The impact of object creation is often overestimated, and can be offset by some of the efficiencies associated with immutable objects. These include decreased overhead due to garbage collection, and the elimination of code needed to protect mutable objects from corruption.

當一個物件的狀態自其被建立之後就不可改變那麼該物件就成為不可變物件。推崇儘可能地依賴不可變物件,因為其建立簡單且執行緒安全。

不可變物件尤其適合用在併發應用程式,因為他們不會改變自身的狀態,自然就不會出現在多執行緒訪問下一個執行緒因為執行寫操作而導致另一個執行緒讀取到不一致的資料(對後者來說並不知道前者的存在,前者的寫操作導致後者的讀操作就像見了鬼一樣資料發生了莫名其妙的變化)。

開發者通常不希望使用不可變物件,他們擔心的是建立物件時就賦予不可變的狀態而不是建立後無法更新狀態。物件建立所帶來的開銷通常是無法估量的(自動記憶體管理系統需要維護它),但是如果有效的使用不可變物件就能夠降低甚至消除一些開銷(比如降低垃圾回收的開銷和消除保證可變物件執行緒安全的同步程式碼塊)。

規則

  • 類不可被繼承,使用final修飾
  • 例項欄位私有化且不可改變,使用private final修飾,靜態欄位常量化static final
  • 例項欄位需顯式初始化或在構造方法中初始化,且僅可提供讀方法而不不提供寫方法
  • 引用欄位的讀方法需返回一個不可變物件或者返回一個可變物件的副本

以下就是一個不可變物件的例子:

public final class ImmutableObject {

    private static final String COUNTRY = "CHINA";

    private final String name;
    private final String sex;
    private final Collection<String> hobbies;
    
    public ImmutableObject(String name, String sex, Collection<String> hobbies) {
        this.name = name;
        this.sex = sex;
        this.hobbies = hobbies;
    }

    public String getName() {
        return name;
    }

    public String getSex() {
        return sex;
    }

    public Collection<String> getHobbies() {
        return Collections.unmodifiableCollection(hobbies);
    }
}
複製程式碼

絕對安全和相對安全

不可變物件是絕對執行緒安全的,也就是說在任何時候呼叫它的行為都能夠得到一致的結果。像我們常說的執行緒安全的StringBuffer則是相對安全的,即保證單次操作是原子的、不被干擾的,但複合操作如str += “xxx”就不一定了。

讀寫鎖分離設計模式

有的併發系統中通常是讀多寫少的,門戶新聞網站就是一個典例。而讀操作是不會產生執行緒間干擾的,如果張三的讀請求還要等李四的讀請求處理完之後響應,那麼系統就是自己給自己找不快了。因此我們需要分析一下此種情況下的同步能否優化:

read write
read yes no
write no no

經分析可知,一個執行緒的讀並不會干擾另一個執行緒的讀,因此我們可以考慮將鎖一分為二:一把讀鎖和一把寫鎖,當讀鎖被持有時,後續的所有讀執行緒可繼續獲取讀鎖從而實現併發地讀,只有當寫鎖被持有時後續的讀寫執行緒才會被阻塞在臨界區之外。

讀共享

public class ReadWriteLock {

    private HashSet<Thread> readers;	//可以有多個執行緒同時獲取讀鎖
    private Thread writer;			    //同一時刻只有一個執行緒能夠持有寫鎖

    public ReadWriteLock() {
        readers = new HashSet<>();
        writer = null;
    }

    public synchronized void getReadLock() throws InterruptedException {
        while (writer != null) {
            this.wait();
        }
        readers.add(Thread.currentThread());
        System.out.println(Thread.currentThread().getName() + " got the read lock and do working...");
    }

    public synchronized void releaseReadLock() {
        Thread currentThread = Thread.currentThread();
        if (!readers.contains(currentThread)) {
            throw new IllegalStateException(currentThread.getName()+" didn't hold the read lock");
        }
        readers.remove(currentThread);
        System.out.println(currentThread.getName() + " release the read lock");
        this.notifyAll();
    }

    public synchronized void getWriteLock() throws InterruptedException {
        while (!readers.isEmpty() || writer != null) {
            this.wait();
        }
        writer = Thread.currentThread();
        System.out.println(writer.getName()+" got the write lock and start working...");
    }

    public synchronized void releaseWriteLock() {
        Thread currentThread = Thread.currentThread();
        if (currentThread != writer) {
            throw new IllegalStateException(currentThread.getName() + " is not owner of the write lock");
        }
        System.out.println(writer.getName() + " release the write lock");
        writer = null;
        this.notifyAll();
    }
}
複製程式碼

併發測試:10個執行緒讀、1個執行緒寫

public class ReadWriteLockTest {

    public static void main(String[] args) {
        ReadWriteLock readWriteLock = new ReadWriteLock();
        IntStream.range(0, 10).forEach(id -> {
            new Thread(() -> {
                try {
                    readWriteLock.getReadLock();
                    // do read
                    TimeUnit.SECONDS.sleep(ThreadLocalRandom.current().nextInt(5));
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }finally {  // ensure the release of lock
                    readWriteLock.releaseReadLock();
                }
            }).start();
        });
        new Thread(() -> {
            try {
                readWriteLock.getWriteLock();
                TimeUnit.SECONDS.sleep(ThreadLocalRandom.current().nextInt(10));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }finally {
                readWriteLock.releaseWriteLock();
            }
        }).start();
    }

}

Thread-0 got the read lock and do working...
Thread-2 got the read lock and do working...
Thread-1 got the read lock and do working...
Thread-3 got the read lock and do working...
Thread-4 got the read lock and do working...
Thread-5 got the read lock and do working...
Thread-6 got the read lock and do working...
Thread-7 got the read lock and do working...
Thread-8 got the read lock and do working...
Thread-9 got the read lock and do working...
Thread-5 release the read lock
Thread-8 release the read lock
Thread-1 release the read lock
Thread-9 release the read lock
Thread-7 release the read lock
Thread-0 release the read lock
Thread-2 release the read lock
Thread-4 release the read lock
Thread-6 release the read lock
Thread-3 release the read lock
Thread-10 got the write lock and start working...
Thread-10 release the write lock
複製程式碼

寫飢餓

至此讀寫分離的功能我們是實現了,但是上述執行結果表露出一個問題,在讀鎖以被持有後加入的寫執行緒會因為源源不斷加入的讀執行緒一直霸佔讀鎖而遲遲不能執行寫操作,這叫寫飢餓。

寫飢餓勢必會造成更新操作的延遲,這對訊息順時萬變的網際網路並不友好,那麼我們能否再改進一下,當有寫執行緒等待時優先讓其獲取寫鎖。

public class ReadWriteLock {

    private HashSet<Thread> readers;
    private Thread writer;
    private boolean preferWrite;
    private int waitingWriters;

    public ReadWriteLock() {
        this(true);
    }

    public ReadWriteLock(boolean preferWrite) {
        readers = new HashSet<>();
        writer = null;
        this.preferWrite = preferWrite;
        waitingWriters = 0;
    }

    public synchronized void getReadLock() throws InterruptedException {
        while (writer != null || (preferWrite && waitingWriters > 0)) {
            this.wait();
        }
        readers.add(Thread.currentThread());
        System.out.println(Thread.currentThread().getName() + " got the read lock and do working...");
    }

    public synchronized void releaseReadLock() {
        Thread currentThread = Thread.currentThread();
        if (!readers.contains(currentThread)) {
            throw new IllegalStateException(currentThread.getName() + " didn't hold the read lock");
        }
        readers.remove(currentThread);
        System.out.println(currentThread.getName() + " release the read lock");
        this.notifyAll();
    }

    public synchronized void getWriteLock() throws InterruptedException {
        waitingWriters++;
        while (!readers.isEmpty() || writer != null) {
            this.wait();
        }
        waitingWriters--;
        writer = Thread.currentThread();
        System.out.println(writer.getName() + " got the write lock and start working...");
    }

    public synchronized void releaseWriteLock() {
        Thread currentThread = Thread.currentThread();
        if (currentThread != writer) {
            throw new IllegalStateException(currentThread.getName() + " is not owner of the write lock");
        }
        System.out.println(writer.getName() + " release the write lock");
        writer = null;
        this.notifyAll();
    }
}
複製程式碼

併發測試:

public class ReadWriteLockTest {

    public static void main(String[] args) {
        ReadWriteLock readWriteLock = new ReadWriteLock();

        IntStream.range(0, 10).forEach(id -> {
            new Thread(() -> {
                if (id % 4 != 0) {
                    try {
                        readWriteLock.getReadLock();
                        // do read
                        TimeUnit.SECONDS.sleep(ThreadLocalRandom.current().nextInt(5));
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    } finally {  // ensure the release of lock
                        readWriteLock.releaseReadLock();
                    }
                } else {
                    try {
                        readWriteLock.getWriteLock();
                        TimeUnit.SECONDS.sleep(ThreadLocalRandom.current().nextInt(10));
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    } finally {
                        readWriteLock.releaseWriteLock();
                    }
                }
            }).start();
        });
    }

}

Thread-1 got the read lock and do working...
Thread-2 got the read lock and do working...
Thread-2 release the read lock
Thread-1 release the read lock
Thread-0 got the write lock and start working...
Thread-0 release the write lock
Thread-8 got the write lock and start working...
Thread-8 release the write lock
Thread-4 got the write lock and start working...
Thread-4 release the write lock
Thread-9 got the read lock and do working...
Thread-7 got the read lock and do working...
Thread-6 got the read lock and do working...
Thread-5 got the read lock and do working...
Thread-3 got the read lock and do working...
Thread-3 release the read lock
Thread-6 release the read lock
Thread-5 release the read lock
Thread-9 release the read lock
Thread-7 release the read lock
複製程式碼

測試發現,有寫請求時,讀寫鎖會根據preferWrite的設定來判斷是否偏向寫請求而擱置讀請求,倘若設定成false則輸出如下:

Thread-0 got the write lock and start working...
Thread-0 release the write lock
Thread-9 got the read lock and do working...
Thread-7 got the read lock and do working...
Thread-6 got the read lock and do working...
Thread-5 got the read lock and do working...
Thread-5 release the read lock
Thread-3 got the read lock and do working...
Thread-2 got the read lock and do working...
Thread-1 got the read lock and do working...
Thread-2 release the read lock
Thread-9 release the read lock
Thread-3 release the read lock
Thread-1 release the read lock
Thread-6 release the read lock
Thread-7 release the read lock
Thread-8 got the write lock and start working...
Thread-8 release the write lock
Thread-4 got the write lock and start working...
Thread-4 release the write lock
複製程式碼

Future設計模式

即未來者模式,當前執行緒不想阻塞著執行某個任務時可以將其提交給FutureService(未來者模式的入口),FutureService會立即返回一個票據(Future子類),當前執行緒可以通過此票據在未來的某個時間點呼叫get方法獲取任務執行結果(非同步票據的了邏輯是:任務執行完時會主動將執行結果注入到票據中,因此如果任務未執行完那麼get將會陷入阻塞)。

比如當前執行緒可能需要從資料庫或磁碟或網路請求一份資料,這通常會花費一些時間,於是當前執行緒可以該任務提交給FutureService,而FutureService則另開一個執行緒非同步的執行該任務並立即返回一個Future給當前執行緒讓其在未來的某個時間點通過Future獲取結果,當前執行緒就不用阻塞在這個任務上而可以先去做其他事從而高效利用執行緒資源。

Future介面,票據

public interface Future<T> {
    T get();
}
複製程式碼

T即任務返回的結果型別

AsyncFuture,非同步返回的票據

提交任務後獲得AsyncFuture並不代表已經獲取了執行結果,只有當任務被執行完並主動將結果注入到AsyncFuture時,方可通過get無阻塞地拿到結果,否則提前呼叫get仍會陷入阻塞

public class AsyncFuture<T> implements Future<T> {

    private T result;
    private boolean done;

    public AsyncFuture() {
        result = null;
        done = false;
    }

    public synchronized void done(T result) {
        this.result = result;
        done = true;
        this.notifyAll();
    }

    @Override
    public synchronized T get() throws InterruptedException {
        while (!done) {
            this.wait();
        }
        return result;
    }
}
複製程式碼

FutureService,遮蔽Future實現,接受非同步執行請求

public class FutureService<T> {

    public Future<T> submit(FutureTask<T> task) {
        AsyncFuture future = new AsyncFuture();
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName()+" execute task asynchronously...");
            T result = task.call();
            System.out.println(Thread.currentThread().getName()+" execute task asynchronously done...");
            future.done(result);
        }).start();
        return future;
    }

}
複製程式碼

5-10行另開一個執行緒非同步執行提交的任務,第11立即返回建立的票據,任務非同步執行結束時主動將結果注入回票據。

測試

public class AsyncFutureTest {

    public static void main(String[] args) throws InterruptedException {
        FutureService futureService = new FutureService();
        Future future = futureService.submit(() -> {
            try {
                // as if query from db
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return "document";
        });
        System.out.println(Thread.currentThread().getName() + " continue do other things...");
        TimeUnit.SECONDS.sleep(5);
        System.out.println(Thread.currentThread().getName() + " continue do other things finished");
        System.out.println(Thread.currentThread().getName() + " try to get future result:" + future.get());
    }

}

main continue do other things...
Thread-0 execute task asynchronously...
Thread-0 execute task asynchronously done...
main continue do other things finished
main try to get future result:document
複製程式碼

增加非同步回撥

上述模式仍存在一個缺點,那就是當前執行緒並不知道非同步任務何時會執行結束,也就是說當前執行緒如果在非同步任務執行結束之前呼叫future.get仍會陷入阻塞,上例測試中第15行預判非同步任務5秒後執行結束因此17行的get能夠直接返回,但實際上如果第7行非同步任務耗時較久呢,當前執行緒仍將阻塞,這也是JDK8之前Future為人所詬病的地方。

對於get的不確定性,我們可以引入非同步回撥機制,讓非同步執行緒在執行完非同步任務後直接執行我們提供的回撥函式對執行結果進行消費:

public interface Consumer<T> {
    void consum(T result);
}

public class FutureService<T> {
    public Future<T> submit(FutureTask<T> task, Consumer<T> consumer) {
        AsyncFuture future = new AsyncFuture();
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName()+" execute task asynchronously...");
            T result = task.call();
            System.out.println(Thread.currentThread().getName() + " execute task asynchronously done...");
            if (consumer != null) {
                consumer.consum(result);
            }
            future.done(result);
        }).start();
        return future;
    }
}

public class AsyncFutureTest {
    public static void main(String[] args) throws InterruptedException {
        FutureService futureService = new FutureService();
        Future future = futureService.submit(() -> {
            try {
                // as if query from db
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            return "document";
        }, System.out::println);
        System.out.println(Thread.currentThread().getName() + " continue do other things and don't care the task");
        TimeUnit.SECONDS.sleep(10);
    }
}


main continue do other things and don't care the task
Thread-0 execute task asynchronously...
Thread-0 execute task asynchronously done...
document
複製程式碼

通過第12-14行、32行,將非同步任務執行結果的處理封裝成回撥函式交給FutureService,你非同步執行完後也帶著處理結果好了,當前執行緒(main執行緒)就無需關心非同步執行結果了,因為它直到FutureService根據自己設定的回撥(第32行)幫忙處理的。

於此同時,main執行緒還是能通過future去主動地拿結果。

Guarded Suspension設計模式

現實生活中有很多Guarded Suspension的典型應用,諸如:

  • 我正在廚房做飯,此時有快遞需要簽收但我抽不開身,因此讓其代簽並將快遞放在門口,我稍後會拿進來
  • 送信小哥會在按門鈴沒有響應後將信件放入門口的信箱中並做上標記,家主看到後會去查收

以上暫放快遞、信件的門口、郵箱就是該設計模式的核心,也即訊息緩衝,用於平衡訊息傳送、接收兩份速率不對等的問題,以使生產者僅關注於訊息的生產,而消費者僅關注於訊息的接收,不必受雙方速率不對等的牽制。該模式也被廣泛用於服務端接收客戶端請求的訊息佇列。

MessageQueue

常用的訊息緩衝的實現方案就是佇列:

public class MessageQueue<T> {

    private LinkedList<T> queue;
    private int messageSize;
    public static final int DEFAULT_MAX_QUEUE_SIZE = Integer.MAX_VALUE;

    public MessageQueue() {
        this(DEFAULT_MAX_QUEUE_SIZE);
    }

    public MessageQueue(int messageSize) {
        this.queue = new LinkedList<>();
        this.messageSize = messageSize;
    }

    public synchronized void put(T message) throws InterruptedException {
        while (queue.size() == messageSize) {
            wait();
        }
        queue.addLast(message);
        notifyAll();
    }

    public synchronized T take() throws InterruptedException {
        while (queue.isEmpty()) {
            wait();
        }
        T message = queue.pollFirst();
        notifyAll();
        return message;
    }
}
複製程式碼

測試:

public class MessageQueueTest {
    public static void main(String[] args) {
        MessageQueue<String> messageQueue = new MessageQueue<>();
        for (int i = 0; i < 2; i++) {
            new Thread(() -> {
                while (true) {
                    try {
                        System.out.println(Thread.currentThread().getName() + " consume the message : " + messageQueue.take());
                        TimeUnit.SECONDS.sleep(ThreadLocalRandom.current().nextInt(5));
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }, "consumer-" + i).start();
        }

        AtomicInteger counter = new AtomicInteger();
        for (int i = 0; i < 2; i++) {
            new Thread(() -> {
                while (true) {
                    try {
                        TimeUnit.SECONDS.sleep(ThreadLocalRandom.current().nextInt(3));
                        String message = "message-" + counter.getAndIncrement();
                        System.out.println(Thread.currentThread().getName() + " produce the message : " + message);
                        messageQueue.put(message);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
            }, "producer-" + i).start();
        }
    }
}

producer-1 produce the message : message-0
consumer-1 consume the message : message-0
producer-1 produce the message : message-1
consumer-0 consume the message : message-1
producer-1 produce the message : message-2
consumer-0 consume the message : message-2
producer-0 produce the message : message-3
....
複製程式碼

Thread-Specific Storage

多執行緒引發的執行緒安全問題就是因為共享資料,如果我們能夠讓各執行緒僅訪問各自的本地變數,那麼就算有再多的執行緒也無需顧慮同步問題,因為執行緒間互不干擾。

執行緒保險箱ThreadLocal

public class ThreadLocalTest {
    public static void main(String[] args) {
        ThreadLocal<Integer> threadLocal = new ThreadLocal<>();
        IntStream.range(0,10).forEach(i->{
            new Thread(() -> {
                threadLocal.set(i);
                System.out.println(Thread.currentThread().getName()+" save value to threadLocal : "+i);
                try {
                    TimeUnit.SECONDS.sleep(ThreadLocalRandom.current().nextInt(5));
                    System.out.println(Thread.currentThread().getName() + " get value from threadLocal : " + threadLocal.get());
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }).start();
        });
    }
}

Thread-0 save value to threadLocal : 0
Thread-1 save value to threadLocal : 1
Thread-2 save value to threadLocal : 2
Thread-3 save value to threadLocal : 3
Thread-4 save value to threadLocal : 4
Thread-5 save value to threadLocal : 5
Thread-6 save value to threadLocal : 6
Thread-7 save value to threadLocal : 7
Thread-8 save value to threadLocal : 8
Thread-0 get value from threadLocal : 0
Thread-4 get value from threadLocal : 4
Thread-9 save value to threadLocal : 9
Thread-6 get value from threadLocal : 6
Thread-3 get value from threadLocal : 3
Thread-5 get value from threadLocal : 5
Thread-9 get value from threadLocal : 9
Thread-2 get value from threadLocal : 2
Thread-8 get value from threadLocal : 8
Thread-7 get value from threadLocal : 7
Thread-1 get value from threadLocal : 1
複製程式碼

上述程式碼通過ThreadLocal,每個執行緒都設定了一個Integer變數到本地記憶體(JMM的抽象概念,對應快取行,執行緒間不可見)中,每個變數都只能被其隸屬的執行緒訪問,因此無論這些執行緒讀寫如何交錯,都不會發生執行緒安全問題。

我們來來追溯一下threadLocal.set(JDK8):

public class ThreadLocal<T>{
    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;
    }
}

class Thread implements Runnable {
    ThreadLocal.ThreadLocalMap threadLocals = null;
}
複製程式碼

發現threadLocal會取出當前執行緒的ThreadLocal.ThreadLocalMap map,然後以threadLocalkeyvalue儲存到該map中。

threadLocal.get則是到當前執行緒的ThreadLocal.ThreadLocalMap mapthreadLocalkey取出對應的value。如果當前執行緒的map為空或者map中沒有對應的key,那麼就會幫當前執行緒初始化一個map並返回null

public class ThreadLocal<T>{
    public T get() {
        Thread t = Thread.currentThread();
        ThreadLocalMap map = getMap(t);
        if (map != null) {
            ThreadLocalMap.Entry e = map.getEntry(this);
            if (e != null) {
                T result = (T)e.value;
                return result;
            }
        }
        return setInitialValue();
    }

    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;
    }

    protected T initialValue() {
        return null;
    }

    void createMap(Thread t, T firstValue) {
        t.threadLocals = new ThreadLocalMap(this, firstValue);
    }
}
複製程式碼

每個執行緒的ThreadLocal.ThreadLocalMap map就是該執行緒的保險箱(Thread-Specific Storage),可以以ThreadLocal<T>例項為key不限數量地儲存各種型別的變數,這些變數只有map隸屬的執行緒能夠訪問到。

借鑑這種機制,你可以很容易地自定義一個執行緒保險箱:

public class MyThreadStorage<T> {

    private Map<Thread, T> storage = new HashMap<>();
    
    public synchronized void put(T value) {
        Thread currentThread = Thread.currentThread();
        storage.put(currentThread, value);
    } 
    
    public synchronized T take() {
        Thread currentThread = Thread.currentThread();
        if (!storage.containsKey(currentThread)) {
            return null;
        }
        return storage.get(currentThread);
    }
}
複製程式碼

多執行緒執行上下文

我們在MVC的開發中,常會有如下的編碼場景:

public class User {	//model

    private String name;
    private long cardId;

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public long getCardId() {
        return cardId;
    }

    public void setCardId(long cardId) {
        this.cardId = cardId;
    }
}

public class QueryNameFromDB {	//dao
    public void execute(User user) {
        System.out.println(Thread.currentThread().getName() + " query name from db by username...");
        try {
            TimeUnit.SECONDS.sleep(3);
            user.setName("Cowboy");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

public class QueryCardIdFromHttp {	//dao
    public void execute(User user) {
        if (user.getName() == null) {
            throw new IllegalArgumentException("name can't be null");
        }
        try {
            System.out.println(Thread.currentThread().getName()+" query card id from http");
            TimeUnit.SECONDS.sleep(5);
            user.setCardId(13546543156L);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

public class UserAction {		//controller
    QueryNameFromDB queryNameFromDB = new QueryNameFromDB();
    QueryCardIdFromHttp queryCardIdFromHttp = new QueryCardIdFromHttp();

    public void query() {
        User user = new User();

        queryNameFromDB.execute(user);
        queryCardIdFromHttp.execute(user);

        System.out.println("query finished, the name is " + user.getName() + " and the card id is " + user.getCardId());
    }

    public static void main(String[] args) {
        new UserAction().query();
    }
}
複製程式碼

在這個例子中我們不知不覺的就用了執行緒保險箱這個設計模式,只是我們不知道而已:55中建立了一個區域性的context,因此併發呼叫query的多個執行緒在整個查詢邏輯中都只是在訪問自己本地記憶體中的context,因此不會引發執行緒安全問題。

但是這樣寫有一個瑕疵,那就是區域性變數context會被頻繁地在方法之間以入參的形式傳遞,這樣未免顯得有些重複贅餘,這時我們可以通過執行緒保險箱ThreadLocal實現執行緒上下文,使得在需要的時候直接通過它來拿。

核心思想就是利用ThreadLocal實現一個對應上下文的靜態工廠類:

public final class UserContext {
    
    private ThreadLocal<User> threadLocal = new ThreadLocal<User>() {
        @Override
        protected User initialValue() {
            return new User();
        }
    };

    private UserContext() {}

    private static class ContextHolder{
        private static final UserContext USER_CONTEXT = new UserContext();
    }

    public static UserContext getUserContext() {
        return ContextHolder.USER_CONTEXT;
    }

    public User getUser() {
        return threadLocal.get();
    }
}

public class UserAction {
    QueryNameFromDB queryNameFromDB = new QueryNameFromDB();
    QueryCardIdFromHttp queryCardIdFromHttp = new QueryCardIdFromHttp();

    public void query() {
        queryNameFromDB.execute();
        queryCardIdFromHttp.execute();
        User user = UserContext.getUserContext().getUser();
        System.out.println("query finished, the name is " + user.getName() + " and the card id is " + user.getCardId());
    }

    public static void main(String[] args) {
        new UserAction().query();
    }
}

public class QueryNameFromDB {
    public void execute() {
        System.out.println(Thread.currentThread().getName() + " query name from db by username...");
        try {
            TimeUnit.SECONDS.sleep(3);
            UserContext.getUserContext().getUser().setName("Cowboy");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

public class QueryCardIdFromHttp {
    public void execute() {
        User user = UserContext.getUserContext().getUser();
        if (user.getName() == null) {
            throw new IllegalArgumentException("name can't be null");
        }
        try {
            System.out.println(Thread.currentThread().getName()+" query card id from http...");
            TimeUnit.SECONDS.sleep(5);
            user.setCardId(13546543156L);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
複製程式碼

Balking設計模式

balk的意思是突然止步不前,該設計模式可以聯想生活中的如下場景:

  • 場景1:咖啡廳中有兩類服務員,一類是對自己接待的顧客週期性的(比如每半小時)主動給顧客續杯;另一類是巡視服務員,職責是巡邏觀察是否有主動提出服務要求的客戶以及時滿足。這就好比有兩個執行緒,一個執行緒看到我招手示意來給我續杯,這被另一個從遠處走來的週期性給我續杯的執行緒看到了,於是後者立即balk(突然止步不前,取消準備執行的行為)
  • 場景2:很多文件編輯都有周期性自動儲存的設定,假設我們設定了每30秒自動儲存,如果在某個間隔中我們手動儲存了一次並且在本次間隔的自動儲存到來之前沒有其它修改,那麼自動儲存被觸發時會首先檢查一下檔案是否是修改狀態,如果不是則並不會執行更新寫入磁碟的操作(balk)。

下面以程式碼的形式模擬場景2:

  • 文件(共享資料)
public class Document {

    private String filename;
    private String content;
    private boolean changed;

    public Document(String filename) {
        this.filename = filename;
        content = "";
        changed = false;
    }

    public String getContent() {
        return content;
    }

    public void setContent(String content) {
        if (content.equals(this.content)) {
            return;
        }
        this.content = content;
        changed = true;
    }

    public void save() {
        if (!changed) {
            System.out.println(Thread.currentThread().getName()+"'s update operation is balking");
            return;     // balk
        }
        doSave();
        changed = false;
    }

    private void doSave() {
        File file = new File(filename);
        try (FileWriter writer = new FileWriter(file)) {
            writer.write(content);
            System.out.println(Thread.currentThread().getName()+" execute update operation successfully");
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
複製程式碼
  • 編輯文件的使用者
public class CustomerThread extends Thread {  // 使用文件編輯軟體的顧客

    private Document document;

    public CustomerThread(Document document) {
        super("Customer");
        this.document = document;
    }

    @Override
    public void run() {
        for (int i = 0; i < 20; i++) {
            document.setContent("No." + i);
            System.out.println("Customer change content to "+document.getContent());
            try {
                TimeUnit.SECONDS.sleep(ThreadLocalRandom.current().nextInt(10)); //顧客可能不定期手動儲存
                document.save();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}
複製程式碼
  • 編輯軟體後臺自動儲存服務
public class WaiterThread extends Thread{ //文件軟體後臺服務執行緒,按照顧客設定執行週期性自動儲存

    static final int AUTOSAVE_INTERVAL = 3000;    //ms
    private Document document;

    public WaiterThread(Document document) {
        super("Waiter");
        this.document = document;
    }

    @Override
    public void run() {
        while (true) {
            synchronized (this) {
                try {
                    wait(AUTOSAVE_INTERVAL);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            System.out.println("Waiter execute auto save");
            document.save();
        }
    }
}
複製程式碼
  • 測試
public class BalkingTest {
    public static void main(String[] args) {
        Document document = new Document("C:\\Users\\zaw\\Desktop\\balk.txt");
        new CustomerThread(document).start();
        new WaiterThread(document).start();
    }
}


Customer change content to No.0
Waiter execute auto save
Waiter execute update operation successfully
Waiter execute auto save
Waiter's update operation is balking
Waiter execute auto save
Waiter's update operation is balking
Customer's update operation is balking
Customer change content to No.1
Customer execute update operation successfully
Customer change content to No.2
Waiter execute auto save
Waiter execute update operation successfully
...
複製程式碼

CountDonw設計模式

CountDown就是一個計數器,每呼叫一次down()就將其遞減1,只有當若干個執行緒呼叫其down()使得計數器為0時,所有阻塞在其await上的方法才能夠返回。

public class CountDown {

    private int counter;

    public CountDown(int counter) {
        if (counter<=0) {
            throw new IllegalArgumentException("counter must >= 0");
        }
        this.counter = counter;
    }

    public synchronized void down() {
        counter--;
        notifyAll();
    }

    public synchronized void await() throws InterruptedException {
        while (counter > 0) {
            wait();
        }
    }
}
複製程式碼

測試:

public class CountDownTest {
    public static void main(String[] args) {
        int threadCount = 5;
        CountDown countDown = new CountDown(threadCount);
        IntStream.range(0, threadCount).forEach(id -> {
            new Thread(() -> {
                System.out.println(Thread.currentThread().getName() + " start working...");
                try {
                    TimeUnit.SECONDS.sleep(ThreadLocalRandom.current().nextInt(5));
                    System.out.println(Thread.currentThread().getName() + " finished done");
                    countDown.down();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }).start();
        });

        try {
            countDown.await();
            System.out.println("all finished done");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

Thread-0 start working...
Thread-1 start working...
Thread-2 start working...
Thread-3 start working...
Thread-4 start working...
Thread-3 finished done
Thread-0 finished done
Thread-4 finished done
Thread-2 finished done
Thread-1 finished done
all finished done
複製程式碼

相關文章