關於Java併發編程的總結和思考

  編寫優質的併發代碼是一件難度極高的事情。Java語言從第一版本開始內置了對多線程的支持,這一點在當年是非常了不起的,但是當我們對併發編程有了更深刻的認識和更多的實踐後,實現併發編程就有了更多的方案和更好的選擇。本文是對併發編程的一點總結和思考,同時也分享了Java 5以後的版本中如何編寫併發代碼的一點點經驗。

爲什麼需要併發

  併發其實是一種解耦合的策略,它幫助我們把做什麼(目標)和什麼時候做(時機)分開。這樣做可以明顯改進應用程序的吞吐量(獲得更多的CPU調度時間)和結構(程序有多個部分在協同工作)。做過Java Web開發的人都知道,Java Web中的Servlet程序在Servlet容器的支持下采用單實例多線程的工作模式,Servlet容器爲你處理了併發問題。

誤解和正解

  最常見的對併發編程的誤解有以下這些:

-併發總能改進性能(併發在CPU有很多空閒時間時能明顯改進程序的性能,但當線程數量較多的時候,線程間頻繁的調度切換反而會讓系統的性能下降)
-編寫併發程序無需修改原有的設計(目的與時機的解耦往往會對系統結構產生巨大的影響)
-在使用Web或EJB容器時不用關注併發問題(只有瞭解了容器在做什麼,才能更好的使用容器)

  下面的這些說法纔是對併發客觀的認識:

-編寫併發程序會在代碼上增加額外的開銷
-正確的併發是非常複雜的,即使對於很簡單的問題
-併發中的缺陷因爲不易重現也不容易被發現
-併發往往需要對設計策略從根本上進行修改

併發編程的原則和技巧

單一職責原則

分離併發相關代碼和其他代碼(併發相關代碼有自己的開發、修改和調優生命週期)。

限制數據作用域

兩個線程修改共享對象的同一字段時可能會相互干擾,導致不可預期的行爲,解決方案之一是構造臨界區,但是必須限制臨界區的數量。

使用數據副本

數據副本是避免共享數據的好方法,複製出來的對象只是以只讀的方式對待。Java 5的java.util.concurrent包中增加一個名爲CopyOnWriteArrayList的類,它是List接口的子類型,所以你可以認爲它是ArrayList的線程安全的版本,它使用了寫時複製的方式創建數據副本進行操作來避免對共享數據併發訪問而引發的問題。

線程應儘可能獨立

讓線程存在於自己的世界中,不與其他線程共享數據。有過Java Web開發經驗的人都知道,Servlet就是以單實例多線程的方式工作,和每個請求相關的數據都是通過Servlet子類的service方法(或者是doGet或doPost方法)的參數傳入的。只要Servlet中的代碼只使用局部變量,Servlet就不會導致同步問題。Spring MVC的控制器也是這麼做的,從請求中獲得的對象都是以方法的參數傳入而不是作爲類的成員,很明顯Struts 2的做法就正好相反,因此Struts 2中作爲控制器的Action類都是每個請求對應一個實例。

Java 5以前的併發編程

Java的線程模型建立在搶佔式線程調度的基礎上,也就是說:

  • 所有線程可以很容易的共享同一進程中的對象。
  • 能夠引用這些對象的任何線程都可以修改這些對象。
  • 爲了保護數據,對象可以被鎖住。

  Java基於線程和鎖的併發過於底層,而且使用鎖很多時候都是很萬惡的,因爲它相當於讓所有的併發都變成了排隊等待。
  在Java 5以前,可以用synchronized關鍵字來實現鎖的功能,它可以用在代碼塊和方法上,表示在執行整個代碼塊或方法之前線程必須取得合適的鎖。對於類的非靜態方法(成員方法)而言,這意味這要取得對象實例的鎖,對於類的靜態方法(類方法)而言,要取得類的Class對象的鎖,對於同步代碼塊,程序員可以指定要取得的是那個對象的鎖。
  不管是同步代碼塊還是同步方法,每次只有一個線程可以進入,如果其他線程試圖進入(不管是同一同步塊還是不同的同步塊),JVM會將它們掛起(放入到等鎖池中)。這種結構在併發理論中稱爲臨界區(critical section)。這裏我們可以對Java中用synchronized實現同步和鎖的功能做一個總結:

  • 只能鎖定對象,不能鎖定基本數據類型
  • 被鎖定的對象數組中的單個對象不會被鎖定
  • 同步方法可以視爲包含整個方法的synchronized(this) { … }代碼塊
  • 靜態同步方法會鎖定它的Class對象
  • 內部類的同步是獨立於外部類的
  • synchronized修飾符並不是方法簽名的組成部分,所以不能出現在接口的方法聲明中
  • 非同步的方法不關心鎖的狀態,它們在同步方法運行時仍然可以得以運行
  • synchronized實現的鎖是可重入的鎖。

  在JVM內部,爲了提高效率,同時運行的每個線程都會有它正在處理的數據的緩存副本,當我們使用synchronzied進行同步的時候,真正被同步的是在不同線程中表示被鎖定對象的內存塊(副本數據會保持和主內存的同步,現在知道爲什麼要用同步這個詞彙了吧),簡單的說就是在同步塊或同步方法執行完後,對被鎖定的對象做的任何修改要在釋放鎖之前寫回到主內存中;在進入同步塊得到鎖之後,被鎖定對象的數據是從主內存中讀出來的,持有鎖的線程的數據副本一定和主內存中的數據視圖是同步的 。
  在Java最初的版本中,就有一個叫volatile的關鍵字,它是一種簡單的同步的處理機制,因爲被volatile修飾的變量遵循以下規則:

  • 變量的值在使用之前總會從主內存中再讀取出來。
  • 對變量值的修改總會在完成之後寫回到主內存中。

  使用volatile關鍵字可以在多線程環境下預防編譯器不正確的優化假設(編譯器可能會將在一個線程中值不會發生改變的變量優化成常量),但只有修改時不依賴當前狀態(讀取時的值)的變量才應該聲明爲volatile變量。
  不變模式也是併發編程時可以考慮的一種設計。讓對象的狀態是不變的,如果希望修改對象的狀態,就會創建對象的副本並將改變寫入副本而不改變原來的對象,這樣就不會出現狀態不一致的情況,因此不變對象是線程安全的。Java中我們使用頻率極高的String類就採用了這樣的設計。如果對不變模式不熟悉,可以閱讀閻宏博士的《Java與模式》一書的第34章。說到這裏你可能也體會到final關鍵字的重要意義了。

Java 5的併發編程

不管今後的Java向着何種方向發展或者滅忙,Java 5絕對是Java發展史中一個極其重要的版本,這個版本提供的各種語言特性我們不在這裏討論(有興趣的可以閱讀我的另一篇文章《Java的第20年:從Java版本演進看編程技術的發展》),但是我們必須要感謝Doug Lea在Java 5中提供了他里程碑式的傑作java.util.concurrent包,它的出現讓Java的併發編程有了更多的選擇和更好的工作方式。Doug Lea的傑作主要包括以下內容:

  • 更好的線程安全的容器
  • 線程池和相關的工具類
  • 可選的非阻塞解決方案
  • 顯示的鎖和信號量機制

下面我們對這些東西進行一一解讀。

原子類

Java 5中的java.util.concurrent包下面有一個atomic子包,其中有幾個以Atomic打頭的類,例如AtomicInteger和AtomicLong。它們利用了現代處理器的特性,可以用非阻塞的方式完成原子操作,代碼如下所示:

/**
 ID序列生成器
*/
public class IdGenerator {
    private final AtomicLong sequenceNumber = new AtomicLong(0);

    public long next() {
        return sequenceNumber.getAndIncrement(); 
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

顯示鎖

基於synchronized關鍵字的鎖機制有以下問題:

  • 鎖只有一種類型,而且對所有同步操作都是一樣的作用
  • 鎖只能在代碼塊或方法開始的地方獲得,在結束的地方釋放
  • 線程要麼得到鎖,要麼阻塞,沒有其他的可能性

Java 5對鎖機制進行了重構,提供了顯示的鎖,這樣可以在以下幾個方面提升鎖機制:

  • 可以添加不同類型的鎖,例如讀取鎖和寫入鎖
  • 可以在一個方法中加鎖,在另一個方法中解鎖
  • 可以使用tryLock方式嘗試獲得鎖,如果得不到鎖可以等待、回退或者乾點別的事情,當然也可以在超時之後放棄操作

顯示的鎖都實現了java.util.concurrent.Lock接口,主要有兩個實現類:

  • ReentrantLock - 比synchronized稍微靈活一些的重入鎖
  • ReentrantReadWriteLock - 在讀操作很多寫操作很少時性能更好的一種重入鎖

對於如何使用顯示鎖,可以參考我的Java面試系列文章《Java面試題集51-70》中第60題的代碼。只有一點需要提醒,解鎖的方法unlock的調用最好能夠在finally塊中,因爲這裏是釋放外部資源最好的地方,當然也是釋放鎖的最佳位置,因爲不管正常異常可能都要釋放掉鎖來給其他線程以運行的機會。

CountDownLatch

CountDownLatch是一種簡單的同步模式,它讓一個線程可以等待一個或多個線程完成它們的工作從而避免對臨界資源併發訪問所引發的各種問題。下面借用別人的一段代碼(我對它做了一些重構)來演示CountDownLatch是如何工作的。

import java.util.concurrent.CountDownLatch;

/**
 * 工人類
 * @author 駱昊
 *
 */
class Worker {
    private String name;        // 名字
    private long workDuration;  // 工作持續時間

    /**
     * 構造器
     */
    public Worker(String name, long workDuration) {
        this.name = name;
        this.workDuration = workDuration;
    }

    /**
     * 完成工作
     */
    public void doWork() {
        System.out.println(name + " begins to work...");
        try {
            Thread.sleep(workDuration); // 用休眠模擬工作執行的時間
        } catch(InterruptedException ex) {
            ex.printStackTrace();
        }
        System.out.println(name + " has finished the job...");
    }
}

/**
 * 測試線程
 * @author 駱昊
 *
 */
class WorkerTestThread implements Runnable {
    private Worker worker;
    private CountDownLatch cdLatch;

    public WorkerTestThread(Worker worker, CountDownLatch cdLatch) {
        this.worker = worker;
        this.cdLatch = cdLatch;
    }

    @Override
    public void run() {
        worker.doWork();        // 讓工人開始工作
        cdLatch.countDown();    // 工作完成後倒計時次數減1
    }
}

class CountDownLatchTest {

    private static final int MAX_WORK_DURATION = 5000;  // 最大工作時間
    private static final int MIN_WORK_DURATION = 1000;  // 最小工作時間

    // 產生隨機的工作時間
    private static long getRandomWorkDuration(long min, long max) {
        return (long) (Math.random() * (max - min) + min);
    }

    public static void main(String[] args) {
        CountDownLatch latch = new CountDownLatch(2);   // 創建倒計時閂並指定倒計時次數爲2
        Worker w1 = new Worker("駱昊", getRandomWorkDuration(MIN_WORK_DURATION, MAX_WORK_DURATION));
        Worker w2 = new Worker("王大錘", getRandomWorkDuration(MIN_WORK_DURATION, MAX_WORK_DURATION));

        new Thread(new WorkerTestThread(w1, latch)).start();
        new Thread(new WorkerTestThread(w2, latch)).start();

        try {
            latch.await();  // 等待倒計時閂減到0
            System.out.println("All jobs have been finished!");
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80

ConcurrentHashMap

  ConcurrentHashMap是HashMap在併發環境下的版本,大家可能要問,既然已經可以通過Collections.synchronizedMap獲得線程安全的映射型容器,爲什麼還需要ConcurrentHashMap呢?因爲通過Collections工具類獲得的線程安全的HashMap會在讀寫數據時對整個容器對象上鎖,這樣其他使用該容器的線程無論如何也無法再獲得該對象的鎖,也就意味着要一直等待前一個獲得鎖的線程離開同步代碼塊之後纔有機會執行。實際上,HashMap是通過哈希函數來確定存放鍵值對的桶(桶是爲了解決哈希衝突而引入的),修改HashMap時並不需要將整個容器鎖住,只需要鎖住即將修改的“桶”就可以了。HashMap的數據結構如下圖所示。
這裏寫圖片描述

  此外,ConcurrentHashMap還提供了原子操作的方法,如下所示:

  • putIfAbsent:如果還沒有對應的鍵值對映射,就將其添加到HashMap中。
  • remove:如果鍵存在而且值與當前狀態相等(equals比較結果爲true),則用原子方式移除該鍵值對映射
  • replace:替換掉映射中元素的原子操作

CopyOnWriteArrayList

  CopyOnWriteArrayList是ArrayList在併發環境下的替代品。CopyOnWriteArrayList通過增加寫時複製語義來避免併發訪問引起的問題,也就是說任何修改操作都會在底層創建一個列表的副本,也就意味着之前已有的迭代器不會碰到意料之外的修改。這種方式對於不要嚴格讀寫同步的場景非常有用,因爲它提供了更好的性能。記住,要儘量減少鎖的使用,因爲那勢必帶來性能的下降(對數據庫中數據的併發訪問不也是如此嗎?如果可以的話就應該放棄悲觀鎖而使用樂觀鎖),CopyOnWriteArrayList很明顯也是通過犧牲空間獲得了時間(在計算機的世界裏,時間和空間通常是不可調和的矛盾,可以犧牲空間來提升效率獲得時間,當然也可以通過犧牲時間來減少對空間的使用)。
這裏寫圖片描述

  可以通過下面兩段代碼的運行狀況來驗證一下CopyOnWriteArrayList是不是線程安全的容器。

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

class AddThread implements Runnable {
    private List<Double> list;

    public AddThread(List<Double> list) {
        this.list = list;
    }

    @Override
    public void run() {
        for(int i = 0; i < 10000; ++i) {
            list.add(Math.random());
        }
    }
}

public class Test05 {
    private static final int THREAD_POOL_SIZE = 2;

    public static void main(String[] args) {
        List<Double> list = new ArrayList<>();
        ExecutorService es = Executors.newFixedThreadPool(THREAD_POOL_SIZE);
        es.execute(new AddThread(list));
        es.execute(new AddThread(list));
        es.shutdown();
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31

上面的代碼會在運行時產生ArrayIndexOutOfBoundsException,試一試將上面代碼25行的ArrayList換成CopyOnWriteArrayList再重新運行。

List<Double> list = new CopyOnWriteArrayList<>();
  • 1

Queue

  隊列是一個無處不在的美妙概念,它提供了一種簡單又可靠的方式將資源分發給處理單元(也可以說是將工作單元分配給待處理的資源,這取決於你看待問題的方式)。實現中的併發編程模型很多都依賴隊列來實現,因爲它可以在線程之間傳遞工作單元。
  Java 5中的BlockingQueue就是一個在併發環境下非常好用的工具,在調用put方法向隊列中插入元素時,如果隊列已滿,它會讓插入元素的線程等待隊列騰出空間;在調用take方法從隊列中取元素時,如果隊列爲空,取出元素的線程就會阻塞。
這裏寫圖片描述
  可以用BlockingQueue來實現生產者-消費者併發模型(下一節中有介紹),當然在Java 5以前也可以通過wait和notify來實現線程調度,比較一下兩種代碼就知道基於已有的併發工具類來重構併發代碼到底好在哪裏了。

  • 基於wait和notify的實現
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

/**
 * 公共常量
 * @author 駱昊
 *
 */
class Constants {
    public static final int MAX_BUFFER_SIZE = 10;
    public static final int NUM_OF_PRODUCER = 2;
    public static final int NUM_OF_CONSUMER = 3;
}

/**
 * 工作任務
 * @author 駱昊
 *
 */
class Task {
    private String id;  // 任務的編號

    public Task() {
        id = UUID.randomUUID().toString();
    }

    @Override
    public String toString() {
        return "Task[" + id + "]";
    }
}

/**
 * 消費者
 * @author 駱昊
 *
 */
class Consumer implements Runnable {
    private List<Task> buffer;

    public Consumer(List<Task> buffer) {
        this.buffer = buffer;
    }

    @Override
    public void run() {
        while(true) {
            synchronized(buffer) {
                while(buffer.isEmpty()) {
                    try {
                        buffer.wait();
                    } catch(InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                Task task = buffer.remove(0);
                buffer.notifyAll();
                System.out.println("Consumer[" + Thread.currentThread().getName() + "] got " + task);
            }
        }
    }
}

/**
 * 生產者
 * @author 駱昊
 *
 */
class Producer implements Runnable {
    private List<Task> buffer;

    public Producer(List<Task> buffer) {
        this.buffer = buffer;
    }

    @Override
    public void run() {
        while(true) {
            synchronized (buffer) {
                while(buffer.size() >= Constants.MAX_BUFFER_SIZE) {
                    try {
                        buffer.wait();
                    } catch(InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                Task task = new Task();
                buffer.add(task);
                buffer.notifyAll();
                System.out.println("Producer[" + Thread.currentThread().getName() + "] put " + task);
            }
        }
    }

}

public class Test06 {

    public static void main(String[] args) {
        List<Task> buffer = new ArrayList<>(Constants.MAX_BUFFER_SIZE);
        ExecutorService es = Executors.newFixedThreadPool(Constants.NUM_OF_CONSUMER + Constants.NUM_OF_PRODUCER);
        for(int i = 1; i <= Constants.NUM_OF_PRODUCER; ++i) {
            es.execute(new Producer(buffer));
        }
        for(int i = 1; i <= Constants.NUM_OF_CONSUMER; ++i) {
            es.execute(new Consumer(buffer));
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101
  • 102
  • 103
  • 104
  • 105
  • 106
  • 107
  • 108
  • 109
  • 110
  • 111
  • 112
  • 113
  • 基於BlockingQueue的實現
import java.util.UUID;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingQueue;

/**
 * 公共常量
 * @author 駱昊
 *
 */
class Constants {
    public static final int MAX_BUFFER_SIZE = 10;
    public static final int NUM_OF_PRODUCER = 2;
    public static final int NUM_OF_CONSUMER = 3;
}

/**
 * 工作任務
 * @author 駱昊
 *
 */
class Task {
    private String id;  // 任務的編號

    public Task() {
        id = UUID.randomUUID().toString();
    }

    @Override
    public String toString() {
        return "Task[" + id + "]";
    }
}

/**
 * 消費者
 * @author 駱昊
 *
 */
class Consumer implements Runnable {
    private BlockingQueue<Task> buffer;

    public Consumer(BlockingQueue<Task> buffer) {
        this.buffer = buffer;
    }

    @Override
    public void run() {
        while(true) {
            try {
                Task task = buffer.take();
                System.out.println("Consumer[" + Thread.currentThread().getName() + "] got " + task);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

/**
 * 生產者
 * @author 駱昊
 *
 */
class Producer implements Runnable {
    private BlockingQueue<Task> buffer;

    public Producer(BlockingQueue<Task> buffer) {
        this.buffer = buffer;
    }

    @Override
    public void run() {
        while(true) {
            try {
                Task task = new Task();
                buffer.put(task);
                System.out.println("Producer[" + Thread.currentThread().getName() + "] put " + task);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

        }
    }

}

public class Test07 {

    public static void main(String[] args) {
        BlockingQueue<Task> buffer = new LinkedBlockingQueue<>(Constants.MAX_BUFFER_SIZE);
        ExecutorService es = Executors.newFixedThreadPool(Constants.NUM_OF_CONSUMER + Constants.NUM_OF_PRODUCER);
        for(int i = 1; i <= Constants.NUM_OF_PRODUCER; ++i) {
            es.execute(new Producer(buffer));
        }
        for(int i = 1; i <= Constants.NUM_OF_CONSUMER; ++i) {
            es.execute(new Consumer(buffer));
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101

  使用BlockingQueue後代碼優雅了很多。

併發模型

  在繼續下面的探討之前,我們還是重溫一下幾個概念:

概念 解釋
臨界資源 併發環境中有着固定數量的資源
互斥 對資源的訪問是排他式的
飢餓 一個或一組線程長時間或永遠無法取得進展
死鎖 兩個或多個線程相互等待對方結束
活鎖 想要執行的線程總是發現其他的線程正在執行以至於長時間或永遠無法執行



  重溫了這幾個概念後,我們可以探討一下下面的幾種併發模型。

生產者-消費者

  一個或多個生產者創建某些工作並將其置於緩衝區或隊列中,一個或多個消費者會從隊列中獲得這些工作並完成之。這裏的緩衝區或隊列是臨界資源。當緩衝區或隊列放滿的時候,生產這會被阻塞;而緩衝區或隊列爲空的時候,消費者會被阻塞。生產者和消費者的調度是通過二者相互交換信號完成的。

讀者-寫者

  當存在一個主要爲讀者提供信息的共享資源,它偶爾會被寫者更新,但是需要考慮系統的吞吐量,又要防止飢餓和陳舊資源得不到更新的問題。在這種併發模型中,如何平衡讀者和寫者是最困難的,當然這個問題至今還是一個被熱議的問題,恐怕必須根據具體的場景來提供合適的解決方案而沒有那種放之四海而皆準的方法(不像我在國內的科研文獻中看到的那樣)。

哲學家進餐

  1965年,荷蘭計算機科學家圖靈獎得主Edsger Wybe Dijkstra提出並解決了一個他稱之爲哲學家進餐的同步問題。這個問題可以簡單地描述如下:五個哲學家圍坐在一張圓桌周圍,每個哲學家面前都有一盤通心粉。由於通心粉很滑,所以需要兩把叉子才能夾住。相鄰兩個盤子之間放有一把叉子如下圖所示。哲學家的生活中有兩種交替活動時段:即吃飯和思考。當一個哲學家覺得餓了時,他就試圖分兩次去取其左邊和右邊的叉子,每次拿一把,但不分次序。如果成功地得到了兩把叉子,就開始吃飯,吃完後放下叉子繼續思考。
  把上面問題中的哲學家換成線程,把叉子換成競爭的臨界資源,上面的問題就是線程競爭資源的問題。如果沒有經過精心的設計,系統就會出現死鎖、活鎖、吞吐量下降等問題。
這裏寫圖片描述
  下面是用信號量原語來解決哲學家進餐問題的代碼,使用了Java 5併發工具包中的Semaphore類(代碼不夠漂亮但是已經足以說明問題了)。

//import java.util.concurrent.ExecutorService;
//import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;

/**
 * 存放線程共享信號量的上下問
 * @author 駱昊
 *
 */
class AppContext {
    public static final int NUM_OF_FORKS = 5;   // 叉子數量(資源)
    public static final int NUM_OF_PHILO = 5;   // 哲學家數量(線程)

    public static Semaphore[] forks;    // 叉子的信號量
    public static Semaphore counter;    // 哲學家的信號量

    static {
        forks = new Semaphore[NUM_OF_FORKS];

        for (int i = 0, len = forks.length; i < len; ++i) {
            forks[i] = new Semaphore(1);    // 每個叉子的信號量爲1
        }

        counter = new Semaphore(NUM_OF_PHILO - 1);  // 如果有N個哲學家,最多隻允許N-1人同時取叉子
    }

    /**
     * 取得叉子
     * @param index 第幾個哲學家
     * @param leftFirst 是否先取得左邊的叉子
     * @throws InterruptedException
     */
    public static void putOnFork(int index, boolean leftFirst) throws InterruptedException {
        if(leftFirst) {
            forks[index].acquire();
            forks[(index + 1) % NUM_OF_PHILO].acquire();
        }
        else {
            forks[(index + 1) % NUM_OF_PHILO].acquire();
            forks[index].acquire();
        }
    }

    /**
     * 放回叉子
     * @param index 第幾個哲學家
     * @param leftFirst 是否先放回左邊的叉子
     * @throws InterruptedException
     */
    public static void putDownFork(int index, boolean leftFirst) throws InterruptedException {
        if(leftFirst) {
            forks[index].release();
            forks[(index + 1) % NUM_OF_PHILO].release();
        }
        else {
            forks[(index + 1) % NUM_OF_PHILO].release();
            forks[index].release();
        }
    }
}

/**
 * 哲學家
 * @author 駱昊
 *
 */
class Philosopher implements Runnable {
    private int index;      // 編號
    private String name;    // 名字

    public Philosopher(int index, String name) {
        this.index = index;
        this.name = name;
    }

    @Override
    public void run() {
        while(true) {
            try {
                AppContext.counter.acquire();
                boolean leftFirst = index % 2 == 0;
                AppContext.putOnFork(index, leftFirst);
                System.out.println(name + "正在吃意大利麪(通心粉)...");   // 取到兩個叉子就可以進食
                AppContext.putDownFork(index, leftFirst);
                AppContext.counter.release();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

public class Test04 {

    public static void main(String[] args) {
        String[] names = { "駱昊", "王大錘", "張三丰", "楊過", "李莫愁" };   // 5位哲學家的名字
//      ExecutorService es = Executors.newFixedThreadPool(AppContext.NUM_OF_PHILO); // 創建固定大小的線程池
//      for(int i = 0, len = names.length; i < len; ++i) {
//          es.execute(new Philosopher(i, names[i]));   // 啓動線程
//      }
//      es.shutdown();
        for(int i = 0, len = names.length; i < len; ++i) {
            new Thread(new Philosopher(i, names[i])).start();
        }
    }

}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59
  • 60
  • 61
  • 62
  • 63
  • 64
  • 65
  • 66
  • 67
  • 68
  • 69
  • 70
  • 71
  • 72
  • 73
  • 74
  • 75
  • 76
  • 77
  • 78
  • 79
  • 80
  • 81
  • 82
  • 83
  • 84
  • 85
  • 86
  • 87
  • 88
  • 89
  • 90
  • 91
  • 92
  • 93
  • 94
  • 95
  • 96
  • 97
  • 98
  • 99
  • 100
  • 101
  • 102
  • 103
  • 104
  • 105
  • 106
  • 107

  現實中的併發問題基本上都是這三種模型或者是這三種模型的變體。

測試併發代碼

對併發代碼的測試也是非常棘手的事情,棘手到無需說明大家也很清楚的程度,所以這裏我們只是探討一下如何解決這個棘手的問題。我們建議大家編寫一些能夠發現問題的測試並經常性的在不同的配置和不同的負載下運行這些測試。不要忽略掉任何一次失敗的測試,線程代碼中的缺陷可能在上萬次測試中僅僅出現一次。具體來說有這麼幾個注意事項:

  • 不要將系統的失效歸結於偶發事件,就像拉不出屎的時候不能怪地球沒有引力。
  • 先讓非併發代碼工作起來,不要試圖同時找到併發和非併發代碼中的缺陷。
  • 編寫可以在不同配置環境下運行的線程代碼。
  • 編寫容易調整的線程代碼,這樣可以調整線程使性能達到最優。
  • 讓線程的數量多於CPU或CPU核心的數量,這樣CPU調度切換過程中潛在的問題纔會暴露出來。
  • 讓併發代碼在不同的平臺上運行。
  • 通過自動化或者硬編碼的方式向併發代碼中加入一些輔助測試的代碼。

Java 7的併發編程

  Java 7中引入了TransferQueue,它比BlockingQueue多了一個叫transfer的方法,如果接收線程處於等待狀態,該操作可以馬上將任務交給它,否則就會阻塞直至取走該任務的線程出現。可以用TransferQueue代替BlockingQueue,因爲它可以獲得更好的性能。
  剛纔忘記了一件事情,Java 5中還引入了Callable接口、Future接口和FutureTask接口,通過他們也可以構建併發應用程序,代碼如下所示。

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;


public class Test07 {
    private static final int POOL_SIZE = 10;

    static class CalcThread implements Callable<Double> {
        private List<Double> dataList = new ArrayList<>();

        public CalcThread() {
            for(int i = 0; i < 10000; ++i) {
                dataList.add(Math.random());
            }
        }

        @Override
        public Double call() throws Exception {
            double total = 0;
            for(Double d : dataList) {
                total += d;
            }
            return total / dataList.size();
        }

    }

    public static void main(String[] args) {
        List<Future<Double>> fList = new ArrayList<>();
        ExecutorService es = Executors.newFixedThreadPool(POOL_SIZE);
        for(int i = 0; i < POOL_SIZE; ++i) {
            fList.add(es.submit(new CalcThread()));
        }

        for(Future<Double> f : fList) {
            try {
                System.out.println(f.get());
            } catch (Exception e) {
                e.printStackTrace();
            }
        }

        es.shutdown();
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49

  Callable接口也是一個單方法接口,顯然這是一個回調方法,類似於函數式編程中的回調函數,在Java 8 以前,Java中還不能使用Lambda表達式來簡化這種函數式編程。和Runnable接口不同的是Callable接口的回調方法call方法會返回一個對象,這個對象可以用將來時的方式在線程執行結束的時候獲得信息。上面代碼中的call方法就是將計算出的10000個0到1之間的隨機小數的平均值返回,我們通過一個Future接口的對象得到了這個返回值。目前最新的Java版本中,Callable接口和Runnable接口都被打上了@FunctionalInterface的註解,也就是說它可以用函數式編程的方式(Lambda表達式)創建接口對象。
  下面是Future接口的主要方法:

  • get():獲取結果。如果結果還沒有準備好,get方法會阻塞直到取得結果;當然也可以通過參數設置阻塞超時時間。
  • cancel():在運算結束前取消。
  • isDone():可以用來判斷運算是否結束。

  Java 7中還提供了分支/合併(fork/join)框架,它可以實現線程池中任務的自動調度,並且這種調度對用戶來說是透明的。爲了達到這種效果,必須按照用戶指定的方式對任務進行分解,然後再將分解出的小型任務的執行結果合併成原來任務的執行結果。這顯然是運用了分治法(divide-and-conquer)的思想。下面的代碼使用了分支/合併框架來計算1到10000的和,當然對於如此簡單的任務根本不需要分支/合併框架,因爲分支和合並本身也會帶來一定的開銷,但是這裏我們只是探索一下在代碼中如何使用分支/合併框架,讓我們的代碼能夠充分利用現代多核CPU的強大運算能力。

import java.util.concurrent.ForkJoinPool;
import java.util.concurrent.Future;
import java.util.concurrent.RecursiveTask;

class Calculator extends RecursiveTask<Integer> {
    private static final long serialVersionUID = 7333472779649130114L;

    private static final int THRESHOLD = 10;
    private int start;
    private int end;

    public Calculator(int start, int end) {
        this.start = start;
        this.end = end;
    }

    @Override
    public Integer compute() {
        int sum = 0;
        if ((end - start) < THRESHOLD) {    // 當問題分解到可求解程度時直接計算結果
            for (int i = start; i <= end; i++) {
                sum += i;
            }
        } else {
            int middle = (start + end) >>> 1;
            // 將任務一分爲二
            Calculator left = new Calculator(start, middle);
            Calculator right = new Calculator(middle + 1, end);
            left.fork();
            right.fork();
            // 注意:由於此處是遞歸式的任務分解,也就意味着接下來會二分爲四,四分爲八...

            sum = left.join() + right.join();   // 合併兩個子任務的結果
        }
        return sum;
    }

}

public class Test08 {

    public static void main(String[] args) throws Exception {
        ForkJoinPool forkJoinPool = new ForkJoinPool();
        Future<Integer> result = forkJoinPool.submit(new Calculator(1, 10000));
        System.out.println(result.get());
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47

  伴隨着Java 7的到來,Java中默認的數組排序算法已經不再是經典的快速排序(雙樞軸快速排序)了,新的排序算法叫TimSort,它是歸併排序和插入排序的混合體,TimSort可以通過分支合併框架充分利用現代處理器的多核特性,從而獲得更好的性能(更短的排序時間)。

參考文獻

  1. Benjamin J. Evans, etc, The Well-Grounded Java Developer. Jul 21, 2012
  2. Robert Martin, Clean Code. Aug 11, 2008.
  3. Doug Lea, Concurrent Programming in Java: Design Principles and Patterns. 1999

本文轉自:http://blog.csdn.net/jackfrued/article/details/44499227

發佈了112 篇原創文章 · 獲贊 184 · 訪問量 49萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章