Java中的常見併發陷阱

優銳課java學習分享筆記

1.簡介
在本教程中,我們將看到一些Java中最常見的併發問題。 我們還將學習如何避免它們及其主要原因。

2.使用線程安全對象

2.1. 共享對象
線程主要通過共享對相同對象的訪問進行通信。 因此,在對象變化時讀取可能會產生意外的結果。 同樣,同時更改對象可能會使它處於損壞或不一致的狀態。

我們避免此類併發問題並構建可靠代碼的主要方法是使用不可變對象。 這是因爲它們的狀態無法通過多線程的干擾進行修改。
但是,我們不能總是使用不可變的對象。 在這些情況下,我們必須找到使可變對象成爲線程安全的方法。

2.2。 使集合成爲線程安全的
像任何其他對象一樣,集合在內部維護狀態。 這可以通過多個線程同時更改集合來更改。 因此,我們可以在多線程環境中安全使用集合的一種方法是同步它們:

1
2	Map<String, String> map = Collections.synchronizedMap(new HashMap<>());
List<Integer> list = Collections.synchronizedList(new ArrayList<>());

通常,同步有助於我們實現互斥。 更具體地說,這些集合一次只能由一個線程訪問。 因此,我們可以避免使集合處於不一致狀態。

2.3。 專家多線程集合
現在讓我們考慮一個場景,我們需要更多的讀取而不是寫入。 通過使用同步集合,我們的應用程序可能會遭受重大的性能後果。 如果兩個線程要同時讀取集合,則一個線程必須等待另一個線程完成。

因此,Java提供併發集合,例如CopyOnWriteArrayList和ConcurrentHashMap,可以由多個線程同時訪問:

1
2	CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
Map<String, String> map = new ConcurrentHashMap<>();

CopyOnWriteArrayList通過爲諸如添加或刪除之類的可變操作創建基礎數組的單獨副本來實現線程安全性。 儘管它的寫操作性能比

Collections.synchronizedList差,但當我們需要的讀操作比寫操作多時,它爲我們提供了更好的性能。

ConcurrentHashMap本質上是線程安全的,並且比圍繞非線程安全Map的Collections.synchronizedMap包裝器性能更高。 它實際上是線程安全映射的線程安全映射,允許不同的活動在其子映射中同時發生。

2.4. 使用非線程安全類型
我們經常使用諸如SimpleDateFormat之類的內置對象來解析和格式化日期對象。 SimpleDateFormat類在執行操作時會更改其內部狀態。

我們需要非常小心,因爲它們不是線程安全的。 由於競爭條件等原因,它們的狀態在多線程應用程序中可能變得不一致。

那麼,我們如何安全地使用SimpleDateFormat? 我們有幾種選擇:

每次使用時創建一個新的SimpleDateFormat實例
限制使用ThreadLocal 對象創建的對象數。 它保證每個線程都有其自己的SimpleDateFormat實例
使用同步的關鍵字或鎖同步多個線程的併發訪問

SimpleDateFormat只是其中的一個示例。 我們可以將這些技術用於任何非線程安全類型。

3.比賽條件
當兩個或多個線程訪問共享數據並且它們試圖同時更改它們時,就會發生競爭狀態。 因此,競爭條件可能導致運行時錯誤或意外結果。

3.1。 比賽條件示例
讓我們考慮以下代碼:

	class Counter {
    private int counter = 0;
 
    public void increment() {
        counter++;
    }
 
    public int getValue() {
        return counter;
    }
}

Counter類的設計使得每次調用遞增方法都會將1加到計數器上。 但是,如果從多個線程引用了一個Counter對象,則線程之間的干擾可能會阻止這種情況按預期發生。

我們可以將Counter ++語句分解爲3個步驟:

檢索計數器的當前值
將檢索到的值增加1
將增加的值存儲回計數器

現在,讓我們假設兩個線程,thread1和thread2,同時調用了增量方法。 他們交錯的動作可能遵循以下順序:

thread1讀取計數器的當前值; 0
thread2讀取計數器的當前值; 0
thread1增加檢索到的值; 結果是1
thread2增加檢索到的值; 結果是1
thread1將結果存儲在計數器中; 現在的結果是1
thread2將結果存儲在計數器中; 現在的結果是1

3.2. 基於同步的解決方案

我們可以通過同步關鍵代碼來解決不一致問題:

class SynchronizedCounter {
    private int counter = 0;
 
    public synchronized void increment() {
        counter++;
    }
 
    public synchronized int getValue() {
        return counter;
    }
}

一次僅允許一個線程使用對象的同步方法,因此這會強制計數器的讀寫一致性。

3.3。 內置解決方案
我們可以將上述代碼替換爲內置的AtomicInteger對象。 此類提供除其他外的原子方法,用於增加整數,是比編寫自己的代碼更好的解決方案。 因此,我們可以直接調用其方法而無需同步:

AtomicInteger atomicInteger = new AtomicInteger(3);
atomicInteger.incrementAndGet();

在這種情況下,SDK可以爲我們解決問題。 否則,我們也可以編寫自己的代碼,將關鍵部分封裝在自定義線程安全的類中。 這種方法有助於我們最大程度地減少代碼的複雜性並最大程度地提高代碼的可重用性。

4.收藏品的比賽條件
4.1. 問題

我們可以陷入的另一個陷阱是,認爲同步收集比實際提供的保護更多。
讓我們檢查下面的代碼:

	List<String> list = Collections.synchronizedList(new ArrayList<>());
if(!list.contains("foo")) {
    list.add("foo");
}

我們列表的每個操作都是同步的,但是多個方法調用的任何組合都不會同步。 更具體地說,在兩個操作之間,另一個線程可以修改我們的集合,從而導致不良結果。

例如,兩個線程可以同時進入if塊,然後更新列表,每個線程將foo值添加到列表中。

4.2。 列表解決方案

我們可以使用同步保護代碼避免一次被多個線程訪問:

synchronized (list) {
    if (!list.contains("foo")) {
        list.add("foo");
    }
}

我們沒有在功能中添加同步關鍵字,而是創建了一個與列表有關的關鍵部分,該部分一次只允許一個線程執行此操作。

我們應該注意,我們可以在list對象的其他操作上使用synchronized(list),以保證一次只有一個線程可以對此對象執行任何操作。

4.3。 內置解決方案

對於ConcurrentHashMap
現在,出於相同的原因,考慮使用地圖,即僅在不存在時才添加條目。
ConcurrentHashMap爲此類問題提供了更好的解決方案。 我們可以使用其原子的ifIfAbsent方法:for ConcurrentHashMap

	Map<String, String> map = new ConcurrentHashMap<>();
map.putIfAbsent("foo", "bar");

或者,如果我們想計算該值,則使用其原子的computeIfAbsent方法:

1	map.computeIfAbsent("foo", key -> key + "bar");

我們應該注意,這些方法是Map接口的一部分,它們提供了一種便捷的方法來避免圍繞插入編寫條件邏輯。 當嘗試進行多線程調用時,它們確實可以幫助我們。

5.內存一致性問題
當多個線程對應爲相同數據的視圖不一致時,將發生內存一致性問題。
根據Java內存模型,除主內存(RAM)外,每個CPU都有自己的緩存。 因此,任何線程都可以緩存變量,因爲與主內存相比,它提供了更快的訪問。

5.1。 問題
讓我們回想一下我們的Counter示例:

	class Counter {
    private int counter = 0;
 
    public void increment() {
        counter++;
    }
 
    public int getValue() {
        return counter;
    }
}`在這裏插入代碼片`

讓我們考慮以下情形:線程1遞增計數器,然後線程2讀取其值。 可能會發生以下事件序列:

thread1從其自己的緩存中讀取計數器值; 計數器爲0
thread1遞增計數器並將其寫回到其自己的緩存中; 計數器是1
thread2從其自己的緩存中讀取計數器值; 計數器爲0

當然,預期的事件順序也可能發生,並且thread2將讀取正確的值(1),但是不能保證一個線程所做的更改每次都會對其他線程可見。

5.2。 解決方案
爲了避免內存一致性錯誤,我們需要建立事前發生的關係。 這種關係只是對一個特定語句的內存更新對另一特定語句可見的保證。
有幾種策略可以創建事前發生的關係。 其中之一是同步,我們已經介紹過了。

同步可確保互斥和內存一致性。 但是,這會帶來性能成本。
我們還可以通過使用volatile關鍵字來避免內存一致性問題。 簡而言之,對volatile變量的任何更改始終對其他線程可見。
讓我們使用volatile重寫我們的Counter示例:

class SyncronizedCounter {
    private volatile int counter = 0;
 
    public synchronized void increment() {
        counter++;
    }
 
    public int getValue() {
        return counter;
    }
}

我們應該注意,我們仍然需要同步增量操作,因爲volatile不能確保我們相互排斥。 使用簡單的原子變量訪問比通過同步代碼訪問這些變量更有效。

6.濫用同步
同步機制是實現線程安全的強大工具。 它依賴於內部和外部鎖的使用。 我們還記得以下事實:每個對象都有一個不同的鎖,一次只能有一個線程獲得一個鎖。
但是,如果我們不注意併爲關鍵代碼仔細選擇正確的鎖,則可能會發生意外行爲。

6.1。 同步此參考
方法級同步是許多併發問題的解決方案。 但是,如果使用過多,它也可能導致其他併發問題。 這種同步方法依賴於此引用作爲鎖,也稱爲內在鎖。
我們可以在以下示例中看到如何將這個引用作爲鎖將方法級同步轉換爲塊級同步。
這些方法是等效的:

public synchronized void foo() {
    //...
}
	public void foo() {
    synchronized(this) {
      //...
    }
}

當線程調用這種方法時,其他線程無法同時訪問該對象。 由於所有操作最終都以單線程運行,因此這可能會降低併發性能。 當讀取的對象多於更新的對象時,此方法特別糟糕。
此外,我們代碼的客戶也可能會獲得此鎖。 在最壞的情況下,此操作可能導致死鎖。

6.2。 僵局
死鎖描述了兩個或多個線程相互阻塞,每個線程都等待獲取某個其他線程擁有的資源的情況。
讓我們考慮示例:

public class DeadlockExample {
 
    public static Object lock1 = new Object();
    public static Object lock2 = new Object();
 
    public static void main(String args[]) {
        Thread threadA = new Thread(() -> {
            synchronized (lock1) {
                System.out.println("ThreadA: Holding lock 1...");
                sleep();
                System.out.println("ThreadA: Waiting for lock 2...");
 
                synchronized (lock2) {
                    System.out.println("ThreadA: Holding lock 1 & 2...");
                }
            }
        });
        Thread threadB = new Thread(() -> {
            synchronized (lock2) {
                System.out.println("ThreadB: Holding lock 2...");
                sleep();
                System.out.println("ThreadB: Waiting for lock 1...");
 
                synchronized (lock1) {
                    System.out.println("ThreadB: Holding lock 1 & 2...");
                }
            }
        });
        threadA.start();
        threadB.start();
    }
}

在上面的代碼中,我們可以清楚地看到第一個線程A獲取lock1和線程B獲取lock2。 然後,線程A嘗試獲取已由線程B獲取的lock2,並且線程B嘗試獲取已由線程A獲取的lock1。 因此,他們兩個都不會繼續前進,這意味着他們陷入了僵局。

我們可以通過更改其中一個線程的鎖定順序來輕鬆解決此問題。
我們應該注意,這只是一個例子,還有許多其他例子可能導致僵局。

7.結論
在本文中,我們探討了在多線程應用程序中可能遇到的併發問題的幾個示例。
首先,我們瞭解到我們應該選擇不可變或線程安全的對象或操作。
然後,我們看到了一些競爭條件的示例,以及如何使用同步機制避免它們。 此外,我們瞭解了與內存相關的競爭條件以及如何避免它們。
儘管同步機制可以幫助我們避免許多併發問題,但是我們可以輕鬆地濫用它並創建其他問題。 因此,我們研究了這種機制使用不當時可能會遇到的幾個問題。

喜歡這篇文章的可以點個贊,歡迎大家留言評論,記得關注我,每天持續更新技術乾貨、職場趣事、海量面試資料等等
如果你對java技術很感興趣也可以+ qq羣:907135806 交流學習,共同學習進步。
不要再用"沒有時間“來掩飾自己思想上的懶惰!趁年輕,使勁拼,給未來的自己一個交代

文章寫道這裏,歡迎完善交流。最後奉上近期整理出來的一套完整的java架構思維導圖,分享給大家對照知識點參考學習。有更多JVM、Mysql、Tomcat、Spring Boot、Spring Cloud、Zookeeper、Kafka、RabbitMQ、RockerMQ、Redis、ELK、Git等Java乾貨加vx:ddmsiqi 領取啦

在這裏插入圖片描述

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