Java併發編程之應用詳解

個人博客請訪問 http://www.x0100.top           

1. 併發編程介紹

1.1 併發的出現

單CPU時代,單任務在一個時間點只能執行單一程序。

多任務階段,計算機能在同一時間點並行執行多進程。多個任務或進程共享一個CPU,並交由操作系統來完成多任務間對CPU的運行切換,以使得每個任務都有機會獲得一定的時間片運行。

現代的計算機多核CPU,在一個程序內部能擁有多個線程並行執行,多個CPU同時執行該程序。一個進程就包括了多個線程,每個線程負責一個獨立的子任務。

進程讓操作系統的併發性成爲可能,而線程讓進程的內部併發成爲可能。

一個進程雖然包括多個線程,但是這些線程是共同享有進程佔有的資源和地址空間的。

進程是操作系統進行資源分配的基本單位,而線程是操作系統進行調度的基本單位。

1.2 併發編程優點

1)資源利用率更好

舉例:

一個程序讀取文件(5s)和處理文件(2s),處理2個文件。

5秒讀取文件A
2秒處理文件A
5秒讀取文件B
2秒處理文件B

總共需要14秒。讀取文件的時候,CPU空閒等待讀取數據,浪費CPU資源。

併發處理:

5秒讀取文件A
5秒讀取文件B + 2秒處理文件A
2秒處理文件B

總共需要12秒。當第二文件在被讀取的時候,利用CPU的空閒去處理第一個文件。

2)程序設計在某些情況下更簡單

如上述讀取處理文件舉例中,如果使用單線程實現,需要每個文件讀取和處理的狀態;而使用多線程,每個線程處理一個文件的讀取和處理,不需要記錄文件讀取和處理狀態,實現更簡單。

3)程序響應更快

併發編程缺點

1)設計更復雜

由於多個線程是共同佔有所屬進程的資源和地址空間的,那麼就會存在多個線程同時訪問同一個資源的問題,可能導致線程安全問題。避免多線程編程中線程安全設計較複雜。

2)上下文切換的開銷

CPU從執行一個線程切換到執行另外一個線程的時候,需要先存儲當前線程的本地的數據,程序指針等,然後載入另一個線程的本地數據,程序指針等,最後纔開始執行。這種切換稱爲上下文切換

對於線程的上下文切換實際上就是存儲和恢復CPU狀態的過程,它使得線程執行能夠從中斷點恢復執行。

雖然多線程可以使得任務執行的效率得到提升,但是由於在線程切換時同樣會帶來一定的開銷代價,並且多個線程會導致系統資源佔用的增加,所以在進行多線程編程時要注意這些因素。

3)增加資源消耗

線程在運行的時候需要從計算機裏面得到一些資源。除了CPU,線程還需要一些內存來維持它本地的堆棧。它也需要佔用操作系統中一些資源來管理線程。

2. 線程安全問題

競態條件:當多個線程同時訪問同一個資源,其中的一個或者多個線程對這個資源進行了寫操作,對資源的訪問順序敏感,就稱存在競態條件。多個線程同時讀同一個資源不會產生競態條件。

臨界區:導致競態條件發生的代碼區稱作臨界區。在臨界區中使用適當的同步就可以避免競態條件。

public class Counter {
    protected long count = 0;
    public void add(long value){
        this.count = this.count + value;
    }
}

多線程同時執行上面的代碼可能會出錯:多線程同時執行臨界區代碼this.count = this.count + value時,同時對同一資源this.count進行寫操作,產生了競態條件。

基本上所有的併發模式在解決線程安全問題時,都採用“序列化訪問臨界資源”的方案,即在同一時刻,只能有一個線程訪問臨界資源,也稱作同步互斥訪問。通常來說,是在訪問臨界資源的代碼前面加上一個鎖,當訪問完臨界資源後釋放鎖,讓其他線程繼續訪問。

在Java中,提供了兩種方式來實現同步互斥訪問:synchronized和Lock。

3. 線程通信

線程通信的目標是使線程間能夠互相發送信號。另一方面,線程通信使線程能夠等待其他線程的信號。

通過共享對象通信

// 必須是同一個MySignal實例,通過共享變量hasDataToProcess通信
public class MySignal {
    protected boolean hasDataToProcess = false;

    public synchronized boolean hasDataToProcess() {
        return this.hasDataToProcess;
    }

    public synchronized void setHasDataToProcess(boolean hasData) {
        this.hasDataToProcess = hasData;
    }
}

單線程A完成某一操作M之後,調用setHasDataToProcess(true),將hasDataToProcess置爲true,表示操作M完成。

線程B調用hasDataToProcess()獲取hasDataToProcess爲true,就知道操作M已經完成。

wait() - notify()/notifyAll()

//A線程調用doWait()等待, B線程調用doNotify()喚醒A線程
public class MyWaitNotify {
    MonitorObject myMonitorObject = new MonitorObject();

    public void doWait(){
        synchronized(myMonitorObject){
            try{
                myMonitorObject.wait();
            } catch(InterruptedException e){...}
        }
    }

    public void doNotify() {
        synchronized (myMonitorObject) {
            myMonitorObject.notify();
        }
    }
}

優化:

  1. 增加boolean wasSignalled,記錄是否收到喚醒信號。只有沒收到過喚醒信號時纔可以wait,避免信號丟失導致永久wait。

  2. while()自旋鎖,線程被喚醒之後可以保證再次檢查條件是否滿足,避免虛假信號。

public class MyWaitNotify3 {
    MonitorObject myMonitorObject = new MonitorObject();
    boolean wasSignalled = false;

    public void doWait() {
        synchronized (myMonitorObject) {
            while (!wasSignalled) {
                try {
                    myMonitorObject.wait();// 如果被虛假喚醒,再回while循環檢查條件wasSignalled
                } catch (InterruptedException e) {
                }
            }
            wasSignalled = false;
        }
    }

    public void doNotify() {
        synchronized (myMonitorObject) {
            wasSignalled = true;
            myMonitorObject.notify();
        }
    }
}

4. 死鎖

死鎖:多個線程同時但以不同的順序請求同一組鎖的時候,線程之間互相循環等待鎖導致線程一直阻塞。

如果線程1鎖住了A,然後嘗試對B進行加鎖,同時線程2已經鎖住了B,接着嘗試對A進行加鎖,這樣線程1持有鎖A等待鎖B,線程2持有鎖B等待鎖A,就會發生死鎖。

死鎖可能不止包含2個線程,可以包含多個線程。如線程1等待線程2,線程2等待線程3,線程3等待線程4,線程4等待線程1。

舉例:

public class Test {
    static Object lockObject1 = new Object();
    static Object lockObject2 = new Object();

    public static void main(String[] args) {
        new Thread() {
            @Override
            public void run() {
                synchronized (lockObject1) {
                    try {
                        Thread.sleep(500);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    synchronized (lockObject2) {
                        System.out.println(1);
                    }
                }
            }
        }.start();

        new Thread() {
            @Override
            public void run() {
                synchronized (lockObject2) {
                    synchronized (lockObject1) {
                        System.out.println(1);
                    }
                }
            }
        }.start();
    }
}

如何避免死鎖?

1)按順序加鎖

多個線程請求的一組鎖按順序加鎖可以避免死鎖。

死鎖:如果線程1鎖住了A,然後嘗試對B進行加鎖,同時線程2已經鎖住了B,接着嘗試對A進行加鎖,發生死鎖。

解決:規定鎖A和鎖B的順序,某個線程需要同時獲取鎖A和鎖B時,必須先拿鎖A再拿鎖B。線程1和線程2都先鎖A再鎖B,不會發生死鎖。

問題:需要事先知道所有可能會用到的鎖,並對這些鎖做適當的排序。

2)加鎖時限(超時重試機制)

設置一個超時時間,在嘗試獲取鎖的過程中若超過了這個時限該線程則放棄對該鎖請求,回退並釋放所有已經獲得的鎖,然後等待一段隨機的時間再重試。

這段隨機的等待時間讓其它線程有機會嘗試獲取相同的這些鎖,並且讓該應用在沒有獲得鎖的時候可以繼續運行乾點其它事情。

問題:

  1. 當線程很多時,等待的這一段隨機的時間會一樣長或者很接近,因此就算出現競爭而導致超時後,由於超時時間一樣,它們又會同時開始重試,導致新一輪的競爭,帶來了新的問題。

  2. 不能對synchronized同步塊設置超時時間。需要創建一個自定義鎖,或使用java.util.concurrent包下的工具。

3)死鎖檢測

主要是針對那些不可能實現按序加鎖並且鎖超時也不可行的情況。

每當一個線程獲得了鎖,會在線程和鎖相關的數據結構中(比如map)將其記下。當一個線程請求鎖失敗時,這個線程可以遍歷鎖的關係圖看看是否有死鎖發生。

例如:線程1請求鎖A,但是鎖A這個時候被線程2持有,這時線程1就可以檢查一下線程2是否已經請求了線程1當前所持有的鎖。

如果線程2確實有這樣的請求,那麼就是發生了死鎖(線程1擁有鎖B,請求鎖A;線程B擁有鎖A,請求鎖B)。

當檢測出死鎖時,可以有兩種做法:

  1. 釋放所有鎖,回退,並且等待一段隨機的時間後重試。(類似超時重試機制)

  2. 給這些線程設置優先級,讓一個(或幾個)線程回退,剩下的線程就像沒發生死鎖一樣繼續保持着它們需要的鎖。

5. 嵌套管程鎖死

線程1獲得A對象的鎖。
線程1獲得對象B的鎖(A對象鎖還未釋放)。
線程1調用B.wait(),從而釋放了B對象上的鎖,但仍然持有對象A的鎖。
線程2需要同時持有對象A和對象B的鎖,才能向線程1發信號B.notify()。
線程2無法獲得對象A上的鎖,因爲對象A上的鎖當前正被線程1持有。
線程2一直被阻塞,等待線程1釋放對象A上的鎖。
線程1一直阻塞,等待線程2的信號,因此不會釋放對象A上的鎖。

舉例:

public class Lock {
    protected MonitorObject monitorObject = new MonitorObject();
    protected boolean isLocked = false;

    public void lock() throws InterruptedException {
        synchronized (this) {
            while (isLocked) {
                synchronized (this.monitorObject) {
                    this.monitorObject.wait();
                }
            }
            isLocked = true;
        }
    }

    public void unlock() {
        synchronized (this) {
            this.isLocked = false;
            synchronized (this.monitorObject) {
                this.monitorObject.notify();
            }
        }
    }
}

線程1調用lock()方法,Lock對象鎖和monitorObject鎖,調用monitorObject.wait()阻塞,但仍然持有Lock對象鎖。

線程2調用unlock()方法解鎖時,無法獲取Lock對象鎖,因爲線程1一直持有Lock鎖,造成嵌套管程鎖死。

6. 重入鎖死

如果一個線程持有某個對象上的鎖,那麼它就有權訪問所有在該對象上同步的塊,這就叫可重入。synchronized、ReentrantLock都是可重入鎖。

如果一個線程持有鎖A,鎖A是不可重入的,該線程再次請求鎖A時被阻塞,就是重入鎖死。

重入鎖死舉例:

public class Lock {
    private boolean isLocked = false;

    public synchronized void lock() throws InterruptedException {
        while (isLocked) {
            wait();
        }
        isLocked = true;
    }

    public synchronized void unlock() {
        isLocked = false;
        notify();
    }
}

如果一個線程兩次調用lock()間沒有調用unlock()方法,那麼第二次調用lock()就會被阻塞,這就出現了重入鎖死。

7. 飢餓和公平

如果一個線程因爲CPU時間全部被其他線程搶走而得不到CPU運行時間,這種狀態被稱之爲飢餓。

導致線程飢餓原因:

  1. 高優先級線程吞噬所有的低優先級線程的CPU時間。

  2. 線程始終競爭不到鎖。

  3. 線程調用object.wait()後沒有被喚醒。

解決飢餓的方案被稱之爲公平性,即所有線程均能公平地獲得運行機會。關於公平鎖會在之後ReentrantLock中詳細介紹。

總結

併發編程可以更好的利用CPU資源,更高效快速的響應程序,但是設計較複雜,並且上下文切換會造成一定的消耗。

併發編程中,由於多個線程同時訪問同一個資源,可能造成線程安全問題,Java中可以通過synchronized和Lock的方式實現同步解決線程安全問題。

更好的發揮多線程的優勢需要線程之間通信,常用的線程通信方式是通過共享對象的狀態通信和wait()/notify()。

多個線程同時但以不同的順序請求同一組鎖的時候,線程之間互相循環等待鎖導致線程一直阻塞,造成死鎖。最常用的解決死鎖的方式是按順序加鎖。

線程持有不可重入鎖之後再次請求不可重入鎖時被阻塞,就是重入鎖死。

如果一個線程因爲CPU時間全部被其他線程搶走而得不到CPU運行時間,這種狀態被稱之爲飢餓。

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