Java併發編程(七)高級別併發對象

7. 高級別併發對象

到目前爲止,本課程介紹了一些Java平臺初學者必須的低級別API。這些API對於簡單的任務足夠了,但是負責的任務需要一些高級別的構建塊。這對於利用現在的多處理器和多核系統的大規模併發應用更加正確。

在本節,我們將要介紹Java平臺5.0引入的一些高級別併發特性。他們大部分在java.util.concurrent包中實現。現在在Java Collections框架中也包括了一些新的數據結構。

  • Lock 對象支持鎖機制,其簡化了許多併發程序。
  • Executors 定義了啓動和管理線程的高級別API。java.util.concurrent提供了Executor類的實現,Executor類提供了一個適合大規模程序的線程池管理。
  • 併發集合(Concurrent collections)使得管理大規模的集合變得簡單,並且大大降低了同步的需要。
  • 原子(Atomic)變量降低了同步的需要,並且幫助避免內存一致性錯誤。
  • ThreadLocalRandom(JDK 7)提供了多線程中僞隨機序列的有效產生器。

7.1 Lock對象

同步代碼依賴於一種簡單的可重入鎖。該鎖使用起來很簡單,但是存在一些限制。大部分複雜的鎖方法都是由java.util.concurrent.locks包提供的。我們不詳細介紹該包,而着重介紹一個基本的接口,Lock。

Lock對象和隱式鎖工作非常像。和隱式鎖一樣,只有一個線程可以得到Lock對象。Lock對象同時也通過相對應的Condition對象提供了wait/notify機制。

Lock對象相對於隱式鎖最大的優勢就是,其可以退出嘗試獲取鎖的行爲。當鎖不可用是,tryLock 方法會立即退出或者在超時前(如果指定的話)。如果另一個線程在得到鎖之前發出了一箇中斷,lockInterruptibly 方法會退出。

讓我們使用Lock對象解決在活躍度小節出現的死鎖問題。Alphonse和Gaston受過訓練,知道朋友何時會鞠躬。我們要求Friend對象在鞠躬之前必須獲得兩個參與者的鎖。這是改進後模型Safelock的源代碼。爲了展示該方法的多用行,我們假設Alphonse和Gaston癡情於他們的新發現,即不能對彼此停止鞠躬。

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
import java.util.Random;

public class Safelock {
    static class Friend {
        private final String name;
        private final Lock lock = new ReentrantLock();

        public Friend(String name) {
            this.name = name;
        }

        public String getName() {
            return this.name;
        }

        public boolean impendingBow(Friend bower) {
            Boolean myLock = false;
            Boolean yourLock = false;
            try {
                myLock = lock.tryLock();
                yourLock = bower.lock.tryLock();
            } finally {
                if (! (myLock && yourLock)) {
                    if (myLock) {
                        lock.unlock();
                    }
                    if (yourLock) {
                        bower.lock.unlock();
                    }
                }
            }
            return myLock && yourLock;
        }

        public void bow(Friend bower) {
            if (impendingBow(bower)) {
                try {
                    System.out.format("%s: %s has"
                        + " bowed to me!%n", 
                        this.name, bower.getName());
                    bower.bowBack(this);
                } finally {
                    lock.unlock();
                    bower.lock.unlock();
                }
            } else {
                System.out.format("%s: %s started"
                    + " to bow to me, but saw that"
                    + " I was already bowing to"
                    + " him.%n",
                    this.name, bower.getName());
            }
        }

        public void bowBack(Friend bower) {
            System.out.format("%s: %s has" +
                " bowed back to me!%n",
                this.name, bower.getName());
        }
    }

    static class BowLoop implements Runnable {
        private Friend bower;
        private Friend bowee;

        public BowLoop(Friend bower, Friend bowee) {
            this.bower = bower;
            this.bowee = bowee;
        }

        public void run() {
            Random random = new Random();
            for (;;) {
                try {
                    Thread.sleep(random.nextInt(10));
                } catch (InterruptedException e) {}
                bowee.bow(bower);
            }
        }
    }


    public static void main(String[] args) {
        final Friend alphonse =
            new Friend("Alphonse");
        final Friend gaston =
            new Friend("Gaston");
        new Thread(new BowLoop(alphonse, gaston)).start();
        new Thread(new BowLoop(gaston, alphonse)).start();
    }
}

7.2 Executors

在之前的例子中,由Runnable對象定義的任務,和由Thread定義的線程之前有緊密的聯繫。在小程序中會工作地很好,但是在大規模程序中,有必要將線程的管理和創建從程序的其他部分分離。封裝了這些功能的類就是執行者(executors)。接下來的幾個小節將詳細介紹該類。

  • 執行者接口(Executor Interfaces )定義了三種執行者對象類型。
  • 線程池(Thread Pools)是最常見的執行者實現。
  • Fork/Join是利用多處理器實現的框架(JDK 7新增)

7.2.1 執行者接口

java.util.concurrent包定義了三個執行者接口,

  • Executor,一種最簡單的支持啓動新任務的接口。
  • ExecutorService,Executor的子接口,新增了一些特性,來幫助管理任務和執行者本身的生命週期。
  • ScheduledExecutorService,ExecutorService的子接口,支持任務的延期或週期執行。

典型地,指向執行者對象的變量被聲明成這三種類型之一,而不是executor類型。

1. Executor接口
Executor接口只提供了一個方法,execute,用來代替線程創建方法。如果r是Runnable對象,e是一個執行者對象,你可以將

(new Thread(r)).start();

替換成

e.execute(r);

然而,execute的定義卻不是很具體。該低級別方法新建一個線程並立即啓動。根據Executor的實現,execute可能會做相同的工作,然而更可能在一個現存的工作線程中執行r,或者將r放到一個執行隊列裏等待執行。(我們將在線程池中介紹工作線程)

java.util.concurrent包中的執行者實現,充分利用了更加高級的ExecutorService和ScheduledExecutorService接口,雖然這兩個也是基於Executor實現的。

2. ExecutorService接口
ExecutorService接口使用相似的、當更加多功能的submit方法補充了execute方法。和execute方法類似,submit方法接收Runnable對象,同時也接受Callable對象,Callable對象允許任務返回一個數值。submit方法返回一個Future對象,用來獲取Callable的返回值和管理Runnable與Callable任務。

ExecutorService還提供了提交大量Callable對象的方法。最後,ExecutorService提供了一系列關閉執行者的方法。爲了支持立即關閉,任務必須正確處理中斷。

3. ScheduledExecutorService接口
ScheduledExecutorService用schedule方法擴充了父類ExecutorService的方法。schedule方法延時執行Runnable或Callable任務。另外,定義了scheduleAtFixedRate 和scheduleWithFixedDelay方法,用來週期重複執行任務。

7.2.2 線程池

大部分java.util.concurrent包實現的執行者都使用了線程池,線程池包含了工作線程。這種線程和它要執行的Runnable和Callable對象分離開,並通常被用於執行多任務。

使用工作線程降低了線程創建導致的開銷。線程對象使用一定量的內存,並且在大規模程序中,分配和釋放線程對象會導致明顯的內存管理開銷。

一種常見的線程池是固定線程池(fixed thread pool)。這類線程池包含指定數量的線程;如果一個線程終止但是它還在使用中,它將會被新的線程代替。任務通過內部隊列被提交到線程池,隊列包含了超過線程數量的額外任務。

固定線程池的一個重要優勢就是使用它的應用可以巧妙地降級。爲了理解這,考慮一個web服務器應用,每一個HTTP請求被一個單獨的線程處理。如果該應用只是簡單地給每個請求新建一個線程,當系統收到的請求超過它能立即處理的數量時,它會向所有的請求停止響應如果這些線程的開銷超過了系統的能力。由於新建線程的數量有限,當大量請求過來的時候,該應用無法響應,但是如果系統可以維持的話,就能相應。

一個使用固定線程池創建執行者的簡單方法是調用java.util.concurrent.Executors類中的newFixedThreadPool工廠模式方法,該類也提供了一下的工廠模式方法:

  • newCachedThreadPool方法創建一個數量可擴充的線程池。該執行者適用於執行短期任務的程序。
  • newSingleThreadExecutor方法創建一個單一線程的線程池。
  • 其他一些方法是上述執行者的ScheduledExecutorService版本。

如果上述工廠模式提供的執行者無法滿足你的需求,創建java.util.concurrent.ThreadPoolExecutor或者java.util.concurrent.ScheduledThreadPoolExecutor實例可以給你提供額外的選擇。

7.2.3 Fork/Join

Fork/Join框架是ExecutorService接口的一個實現,來幫助你利用多處理器。它爲那些可以分解爲小片遞歸的任務設計。目標是所有處理器的能力來提高你應用的性能。

和所有ExecutorService的實現,fork/join框架將任務分發給線程池中的工作線程。fork/join框架很特別,因爲其採用的是工作竊取算法(work-stealing algorithm)。執行完任務的工作線程可以從其他還繁忙的工作線程中“竊取”任務。

fork/join框架的中心是ForkJoinPool類,該類是AbstractExecutorService的一個擴展。ForkJoinPool實現了工作竊取算法(work-stealing algorithm),並且可以執行ForkJoinTask進程。

1. 基本用法
使用fork/join框架的第一步就是編寫執行部分工作的代碼。你的代碼需要和下面的僞代碼類似:

if (my portion of the work is small enough)
  do the work directly
else
  split my work into two pieces
  invoke the two pieces and wait for the results

將這些代碼包裝在ForkJoinTask的子類中,典型地,可以使用更加具體的類型,RecursiveTask(可以返回一個結果)或者RecursiveAction。

當你的ForkJoinTask子類準備好後,創建一個代表所有任務執行完的對象,並將它傳遞給ForkJoinPool實例的invoke()方法。

2. Blurring for Clarity
爲了幫助你理解fork/join框架如何工作,考慮以下的例子。假設你想要一張圖片混亂。原始圖片保存在整型數組中,每個整數代表了一個像素點的顏色值。混亂後的目標圖片也保存在相同大小的整型數組中。

通過逐一遍歷原始數組來完成混淆。每個像素值計算它周圍的像素值(計算紅,綠,藍的平均值),並將結果存放在目標數組中。由於每個圖片是一個數組,這個過程可能得消耗一段時間。你可以通過fork/join框架實現該算法,來利用多處理器系統的並行計算。這是一個可能的例子:

public class ForkBlur extends RecursiveAction {
    private int[] mSource;
    private int mStart;
    private int mLength;
    private int[] mDestination;

    // Processing window size; should be odd.
    private int mBlurWidth = 15;

    public ForkBlur(int[] src, int start, int length, int[] dst) {
        mSource = src;
        mStart = start;
        mLength = length;
        mDestination = dst;
    }

    protected void computeDirectly() {
        int sidePixels = (mBlurWidth - 1) / 2;
        for (int index = mStart; index < mStart + mLength; index++) {
            // Calculate average.
            float rt = 0, gt = 0, bt = 0;
            for (int mi = -sidePixels; mi <= sidePixels; mi++) {
                int mindex = Math.min(Math.max(mi + index, 0),
                                    mSource.length - 1);
                int pixel = mSource[mindex];
                rt += (float)((pixel & 0x00ff0000) >> 16)
                      / mBlurWidth;
                gt += (float)((pixel & 0x0000ff00) >>  8)
                      / mBlurWidth;
                bt += (float)((pixel & 0x000000ff) >>  0)
                      / mBlurWidth;
            }

            // Reassemble destination pixel.
            int dpixel = (0xff000000     ) |
                   (((int)rt) << 16) |
                   (((int)gt) <<  8) |
                   (((int)bt) <<  0);
            mDestination[index] = dpixel;
        }
    }

  ...

現在你開始實現抽象compute()方法,該方法會直接結算混淆結果或者將其分解爲兩個較小的任務。可以使用數組長度閾值判斷任務是否需要分解爲小任務。

protected static int sThreshold = 100000;

protected void compute() {
    if (mLength < sThreshold) {
        computeDirectly();
        return;
    }

    int split = mLength / 2;

    invokeAll(new ForkBlur(mSource, mStart, split, mDestination),
              new ForkBlur(mSource, mStart + split, mLength - split,
                           mDestination));
}

如果這些方法在RecursiveAction類的子類中,那麼最直接的一個方法就是在ForkJoinPool執行任務,步驟如下:

1. 創建任務

// source image pixels are in src
// destination image pixels are in dst
ForkBlur fb = new ForkBlur(src, 0, src.length, dst);

2. 創建執行任務的ForkJoinPool

ForkJoinPool pool = new ForkJoinPool();

3. 執行任務

pool.invoke(fb);

所有的代碼,包括生成目標圖像文件的代碼,請參考ForkBlur例子。

3. 標準實現
除了在多處理器系統上使用fork/join框架來實現併發執行任務的算法外(例如上節的ForkBlur.java例子),Java SE中還有一些使用fork/join框架實現的特性。Java SE 8 引進的一個例子,使用在java.util.Arrays類的parallelSort()方法中。這些方法和sort()方法很像,但是通過fork/join使用了併發。在多處理器系統上,大數據的並行排序比串行排序要快許多。然而,本教程不介紹這些方法是如何使用fork/join框架的。這些信息可以參考Java API文檔。

fork/join的另一個實現,使用在java.util.streams包中,該包是Java SE 8 Lamba項目的一部分。更多的信息,請參考Lambda Expressions

7.3 併發集合(Concurrent Collections)

java.util.concurrent包包括了一系列對Java集合框架的擴充。根據提供的集合接口,可以如下分類:

  • BlockingQueue定義了一個先進先出的數據結構,當你往飽和的隊列中添加數據或者從一個空隊列中取數據會導致阻塞或者超時。
  • ConcurrentMap,java.util.Map的子類,定義了一些有用的原子操作。這些方法僅當key存在的時候,會刪除或替換一個key-value對,或者僅當key不存在的時候,會添加一個key-value對。將這些操作原子化避免了同步。ConcurrentHashMap是ConcurrentMap標準的實現,是一個併發的HashMap。
  • ConcurrentNavigableMap,ConcurrentMap的子接口,執行近似匹配。ConcurrentSkipListMap是ConcurrentNavigableMap的標準實現,是一個併發的TreeMap。

這些所有的集合,通過向集合添加對象的操作和後續訪問或刪除該對象的操作之間定義happens-before關係,來幫助避免內存一致性錯誤。

7.4 原子變量(Atomic Variables)

java.util.concurrent.atomic包定義了執行單個變量執行原子操作的類。所有的類都有get和set方法,類似於在volatile對象上的讀寫。也就是,set,在後續的相同變量上的get方法有happens-before關係。原子的compareAndSet方法也有這些內存一致性特性,正如整數原子變量上的原子計算方法。

爲了示例如何使用該類,讓我們看我們已經實現的Counter類,

class Counter {
    private int c = 0;

    public void increment() {
        c++;
    }

    public void decrement() {
        c--;
    }

    public int value() {
        return c;
    }

}

一個避免線程衝突的方法是讓Counter的方法同步, SynchronizedCounter所示:

class SynchronizedCounter {
    private int c = 0;

    public synchronized void increment() {
        c++;
    }

    public synchronized void decrement() {
        c--;
    }

    public synchronized int value() {
        return c;
    }

}

對於該簡單的類,同步是一個可以接受的方案。但是對於一個複雜的類,我們得避免非必要同步帶來的活躍度影響。使用AtomicInteger代替int域,使得我們不使用同步而避免線程衝突,AtomicCounter所示:

import java.util.concurrent.atomic.AtomicInteger;

class AtomicCounter {
    private AtomicInteger c = new AtomicInteger(0);

    public void increment() {
        c.incrementAndGet();
    }

    public void decrement() {
        c.decrementAndGet();
    }

    public int value() {
        return c.get();
    }

}

7.5 併發隨機數字(Concurrent Random Numbers)

在JDK 7中,java.util.concurrent包括了一個類,ThreadLocalRandom,該類方便程序在多線程或者ForkJoinTasks中使用隨機數。爲了併發訪問,使用ThreadLocalRandom而不是Math.random(),會帶來更少的衝突、更好的性能。你需要做的只是調用ThreadLocalRandom.current(),然後調用它的方法來獲得隨機數,這是一個例子:

int r = ThreadLocalRandom.current() .nextInt(4, 77);

文章翻譯自High Level Concurrency Objects,翻譯難免會有紕漏,歡迎讀者討論指正。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章