1. 程式人生 > 其它 >Java併發問題和執行緒同步

Java併發問題和執行緒同步

併發問題

多執行緒是一個非常強大的工具,它使我們能夠更好地利用系統資源,但是在讀寫由多個執行緒共享的資料時,我們需要特別小心。

當多個執行緒嘗試同時讀寫共享資料時,會出現兩種型別的問題:

  1. 執行緒干擾錯誤
  2. 記憶體一致性錯誤

讓我們一一理解這些問題。

執行緒干擾錯誤(競態條件)

考慮下面的Counter類,該類包含一個increment()在每次呼叫時將計數加一的方法-

class Counter {
	int count = 0;

	public void increment() {
		count = count + 1;
	}

	public int getCount() {
		return count;
	}
}

現在,假設幾個執行緒試圖通過increment()同時呼叫方法來增加計數-

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class RaceConditionExample {

    public static void main(String[] args) throws InterruptedException {
        ExecutorService executorService = Executors.newFixedThreadPool(10);

        Counter counter = new Counter();

        for(int i = 0; i < 1000; i++) {
			executorService.submit(() -> counter.increment());
        }

        executorService.shutdown();
        executorService.awaitTermination(60, TimeUnit.SECONDS);
    
        System.out.println("Final count is : " + counter.getCount());
    }
}

您認為上述程式的結果是什麼?因為我們要呼叫增量1000次,最終計數將為1000嗎?

好吧,答案是否定的!只需執行上面的程式,然後自己檢視輸出即可。而不是產生最終計數1000,而是每次執行都給出不一致的結果。我在計算機上運行了上述程式3次,輸出為992、996和993。

讓我們更深入地研究該程式,並瞭解為什麼該程式的輸出不一致-

當執行緒執行crement()方法時,將執行以下三個步驟:1.檢索count的當前值2.將檢索到的值遞增1 3.將遞增的值儲存回count

現在,假設兩個執行緒(ThreadA和ThreadB)按以下順序執行這些操作-

  1. ThreadA:檢索計數,初始值= 0
  2. ThreadB:檢索計數,初始值= 0
  3. ThreadA:遞增的檢索值,結果= 1
  4. ThreadB:遞增的檢索值,結果= 1
  5. ThreadA:儲存增量值,現在計數為1
  6. ThreadB:儲存增量值,現在計數為1

兩個執行緒都嘗試將計數加1,但是最終結果是1而不是2,因為執行緒執行的操作相互交錯。在上述情況下,由ThreadA完成的更新將丟失。

上面的執行順序只是一種可能。這些操作可以執行很多這樣的順序,從而使程式的輸出不一致。

當多個執行緒嘗試同時讀取和寫入共享變數,並且這些讀取和寫入操作在執行中重疊時,最終結果取決於讀取和寫入發生的順序,這是無法預測的。這種現象稱為競態條件

程式碼中訪問共享變數的部分稱為關鍵部分

可以通過同步訪問共享變數來避免執行緒干擾錯誤。我們將在下一節中學習同步。

首先讓我們看一下多執行緒程式中發生的第二種錯誤-記憶體一致性錯誤。

記憶體一致性錯誤

當不同的執行緒對同一資料的檢視不一致時,將發生記憶體不一致錯誤。當一個執行緒更新某些共享資料,但此更新不會傳播到其他執行緒,並且最終使用舊資料時,會發生這種情況。

為什麼會這樣?好吧,可能有很多原因。編譯器對程式進行了一些優化,以提高效能。它還可能會對指令進行重新排序以優化效能。處理器也會嘗試優化事物,例如,處理器可能會從臨時暫存器(包含變數的最後讀取值)而不是主儲存器(具有變數的最新值)讀取變數的當前值。 。

考慮以下示例,該示例演示了操作中的記憶體一致性錯誤-

public class MemoryConsistencyErrorExample {
	private static boolean sayHello = false;

	public static void main(String[] args) throws InterruptedException {

		Thread thread = new Thread(() -> {
			while (!sayHello) {
			}

			System.out.println("Hello World!");

			while (sayHello) {
			}

			System.out.println("Good Bye!");
		});

		thread.start();

		Thread.sleep(1000);
		System.out.println("Say Hello..");
		sayHello = true;

		Thread.sleep(1000);
		System.out.println("Say Bye..");
		sayHello = false;
	}
}

在理想情況下,上述程式應-

  1. 等待一秒鐘,然後Hello World!在顯示為sayHello真後進行列印。
  2. 再等待一秒鐘,然後Good Bye!sayHello出現錯誤之後進行列印。
# Ideal Output
Say Hello..
Hello World!
Say Bye..
Good Bye!

但是,執行上述程式後,我們是否獲得了所需的輸出?好吧,如果您執行該程式,您將看到以下輸出-

# Actual Output
Say Hello..
Say Bye..

此外,該程式甚至不會終止。

等待。什麼?那怎麼可能?

是!那就是記憶體一致性錯誤。第一個執行緒不知道主執行緒對sayHello變數所做的更改。

您可以使用volatile關鍵字來避免記憶體一致性錯誤。我們將在短期內詳細瞭解volatile關鍵字。

同步化

通過確保以下兩項,可以避免執行緒干擾和記憶體一致性錯誤:

  1. 一次只能有一個執行緒讀寫共享變數。當一個執行緒正在訪問共享變數時,其他執行緒應等待第一個執行緒完成。這樣可以確保對共享變數的訪問是Atomic,並且多個執行緒不會干擾。

  2. 每當任何執行緒修改共享變數時,它都會自動與其他執行緒隨後對共享變數的後續讀寫建立先發生關係。這樣可以保證一個執行緒所做的更改對其他執行緒可見。

幸運的是,Java有一個synchronized關鍵字,您可以使用該關鍵字同步對任何共享資源的訪問,從而避免兩種錯誤​​。

同步方法

以下是Counter類的同步版本。我們使用Java的synchronized關鍵字onincrement()方法來防止多個執行緒同時訪問它-

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

class SynchronizedCounter {
    private int count = 0;

    // Synchronized Method 
    public synchronized void increment() {
        count = count + 1;
    }

    public int getCount() {
        return count;
    }
}

public class SynchronizedMethodExample {
    public static void main(String[] args) throws InterruptedException {
        ExecutorService executorService = Executors.newFixedThreadPool(10);

        SynchronizedCounter synchronizedCounter = new SynchronizedCounter();

        for(int i = 0; i < 1000; i++) {
            executorService.submit(() -> synchronizedCounter.increment());
        }

        executorService.shutdown();
		executorService.awaitTermination(60, TimeUnit.SECONDS);

        System.out.println("Final count is : " + synchronizedCounter.getCount());
    }
}

如果執行上面的程式,它將產生期望的1000輸出。不會發生爭用情況,並且最終輸出始終是一致的。synchronized關鍵字可確保只有一個執行緒可以進入increment()一次的方法。

請注意,同步的概念始終繫結到物件。在上述情況下,對Leadincrement()的相同例項多次呼叫methodSynchonizedCounter會導致競爭條件。而且我們使用synchronized關鍵字來防止這種情況。但是執行緒可以在同一時間increment()在不同例項上安全地呼叫方法SynchronizedCounter,而這不會導致爭用條件。

對於靜態方法,同步與Class物件關聯。

同步塊

Java在內部使用所謂的固有鎖定或監視器鎖定來管理執行緒同步。每個物件都有一個與之關聯的固有鎖。

當執行緒在物件上呼叫同步方法時,它將自動獲取該物件的內在鎖,並在方法退出時釋放它。即使該方法引發異常,也會發生鎖定釋放。

在使用靜態方法的情況下,執行緒獲取Class與該類關聯的物件的固有鎖,這與該類的任何例項的固有鎖都不相同。

synchronized關鍵字也可以用作阻塞語句,但是與synchronized方法不同,synchronized語句必須指定提供內部鎖的物件-

public void increment() {
    // Synchronized Block - 

    // Acquire Lock
    synchronized (this) { 
        count = count + 1;
    }   
    // Release Lock
}

當執行緒獲取物件的固有鎖時,其他執行緒必須等待,直到釋放該鎖為止。但是,當前擁有該鎖的執行緒可以多次獲取它,而不會出現任何問題。

允許執行緒多次獲取同一鎖的想法稱為可重入同步

Volatile關鍵字

Volatile關鍵字用於避免多執行緒程式中的記憶體一致性錯誤。它告訴編譯器避免對變數進行任何優化。如果將變數標記為volatile,則編譯器將不會對該變數進行優化或對指令進行重新排序。

同樣,變數的值將始終從主儲存器而不是臨時暫存器中讀取。

以下是我們在上一節中看到的相同的MemoryConsistencyError示例,不同的是,這次,我們sayHello使用volatile關鍵字標記了變數。

public class VolatileKeywordExample {
	private static volatile boolean sayHello = false;

	public static void main(String[] args) throws InterruptedException {

		Thread thread = new Thread(() -> {
			while (!sayHello) {
			}

			System.out.println("Hello World!");

			while (sayHello) {
			}

			System.out.println("Good Bye!");
		});

		thread.start();

		Thread.sleep(1000);
		System.out.println("Say Hello..");
		sayHello = true;

		Thread.sleep(1000);
		System.out.println("Say Bye..");
		sayHello = false;
	}
}

執行上面的程式會產生所需的輸出-

# Output
Say Hello..
Hello World!
Say Bye..
Good Bye!

結論

在本教程中,我們瞭解了多執行緒程式中可能出現的不同併發問題,以及如何使用synchronized方法和塊避免它們。同步是一個功能強大的工具,但請注意,不必要的同步會導致其他問題,例如死鎖飢餓