簡單概述線程交互的特點。之前,我們了解了線程如何相互同步。這次我們將深入探討線程互動時可能出現的問題,並討論如何避免這些問題。我們還將提供一些有用的連結以供更深入的研究。
一個超級例子可以在這裡找到:「Java - Thread Starvation and Fairness」。此範例展示了執行緒在飢餓狀態下如何運作,以及從 Thread.sleep 到 Thread.wait 的一個小變更如何能夠均勻分配負載。
這段影片最好不要透露任何內容。所以我只會留下影片的連結。您可以閱讀《Java - 理解發生在關係之前》。
介紹
因此,我們知道 Java 中有線程,您可以在評論“線程不能破壞 Java:第一部分 - 線程”中閱讀有關線程的內容,並且線程可以彼此同步,我們在評論“中對此進行了處理”線程不能破壞Java “破壞:第二部分- 同步”。現在是時候討論線程如何相互交互了。他們如何分享公共資源?這可能會出現什麼問題?僵局
最嚴重的問題是死鎖。當兩個或多個執行緒永遠相互等待時,稱為死鎖。我們以Oracle網站上對「死鎖」概念的描述為例:public class Deadlock {
static class Friend {
private final String name;
public Friend(String name) {
this.name = name;
}
public String getName() {
return this.name;
}
public synchronized void bow(Friend bower) {
System.out.format("%s: %s has bowed to me!%n",
this.name, bower.getName());
bower.bowBack(this);
}
public synchronized void bowBack(Friend bower) {
System.out.format("%s: %s has bowed back to me!%n",
this.name, bower.getName());
}
}
public static void main(String[] args) {
final Friend alphonse = new Friend("Alphonse");
final Friend gaston = new Friend("Gaston");
new Thread(() -> alphonse.bow(gaston)).start();
new Thread(() -> gaston.bow(alphonse)).start();
}
}
這裡的死鎖可能不會第一次出現,但如果你的程式執行卡住了,就該運行了jvisualvm
: 如果JVisualVM中安裝了插件(透過Tools -> Plugins),我們可以看到死鎖發生在哪裡:
"Thread-1" - Thread t@12
java.lang.Thread.State: BLOCKED
at Deadlock$Friend.bowBack(Deadlock.java:16)
- waiting to lock <33a78231> (a Deadlock$Friend) owned by "Thread-0" t@11
線程 1 正在等待線程 0 的鎖。為什麼會發生這種情況?Thread-1
開始執行並執行該方法Friend#bow
。它標有關鍵字synchronized
,即我們透過 拿起顯示器this
。在該方法的入口處,我們收到了另一個Friend
. 現在,該線程Thread-1
想要在另一個線程上執行一個方法Friend
,從而也從他那裡獲得鎖。但是,如果另一個執行緒(在本例中Thread-0
)設法進入該方法bow
,則該鎖定已經繁忙並Thread-1
正在等待Thread-0
,反之亦然。阻塞是無解的,所以是Dead,也就是死了。既是死亡之握(無法釋放)又是無法逃脫的死鎖。關於死鎖的主題,您可以觀看影片:「死鎖 - 並發 #1 - 高級 Java」。
活鎖
如果有死鎖,那麼是否有活鎖?是的,有)活鎖就是線程表面上看起來是活的,但同時它們卻不能做任何事情,因為… 他們試圖繼續工作的條件無法滿足。本質上,活鎖類似於死鎖,但線程不會「掛」在系統上等待監視器,而是始終在做某事。例如:import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class App {
public static final String ANSI_BLUE = "\u001B[34m";
public static final String ANSI_PURPLE = "\u001B[35m";
public static void log(String text) {
String name = Thread.currentThread().getName(); //like Thread-1 or Thread-0
String color = ANSI_BLUE;
int val = Integer.valueOf(name.substring(name.lastIndexOf("-") + 1)) + 1;
if (val != 0) {
color = ANSI_PURPLE;
}
System.out.println(color + name + ": " + text + color);
try {
System.out.println(color + name + ": wait for " + val + " sec" + color);
Thread.currentThread().sleep(val * 1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
Lock first = new ReentrantLock();
Lock second = new ReentrantLock();
Runnable locker = () -> {
boolean firstLocked = false;
boolean secondLocked = false;
try {
while (!firstLocked || !secondLocked) {
firstLocked = first.tryLock(100, TimeUnit.MILLISECONDS);
log("First Locked: " + firstLocked);
secondLocked = second.tryLock(100, TimeUnit.MILLISECONDS);
log("Second Locked: " + secondLocked);
}
first.unlock();
second.unlock();
} catch (InterruptedException e) {
e.printStackTrace();
}
};
new Thread(locker).start();
new Thread(locker).start();
}
}
此程式碼的成功取決於 Java 執行緒調度程序啟動執行緒的順序。如果它先啟動Thead-1
,我們將得到 Livelock:
Thread-1: First Locked: true
Thread-1: wait for 2 sec
Thread-0: First Locked: false
Thread-0: wait for 1 sec
Thread-0: Second Locked: true
Thread-0: wait for 1 sec
Thread-1: Second Locked: false
Thread-1: wait for 2 sec
Thread-0: First Locked: false
Thread-0: wait for 1 sec
...
從範例中可以看出,兩個執行緒交替嘗試捕獲兩個鎖,但都失敗了。此外,他們並沒有陷入僵局,也就是說,從視覺上看,他們一切都很好,他們正在做自己的工作。 根據 JVisualVM,我們看到睡眠期和停放期(這是當線程嘗試佔用鎖時,它進入停放狀態,正如我們之前在談論線程同步時討論的那樣)。關於活鎖這個主題,可以看一個例子:《Java - 線程活鎖》。
飢餓
除了阻塞(死鎖和活鎖)之外,使用多執行緒時還有另一個問題 - 飢餓或「飢餓」。這種現象與阻塞的不同之處在於,執行緒並未被阻塞,但它們只是沒有足夠的資源供每個人使用。因此,雖然某些執行緒接管了所有執行時間,但其他執行緒卻無法執行:https://www.logicbig.com/
競賽條件
使用多執行緒時,存在「競爭條件」之類的情況。這種現象的原因在於,執行緒之間共享一定的資源,而程式碼的編寫方式在這種情況下無法提供正確的操作。讓我們來看一個例子:public class App {
public static int value = 0;
public static void main(String[] args) {
Runnable task = () -> {
for (int i = 0; i < 10000; i++) {
int oldValue = value;
int newValue = ++value;
if (oldValue + 1 != newValue) {
throw new IllegalStateException(oldValue + " + 1 = " + newValue);
}
}
};
new Thread(task).start();
new Thread(task).start();
new Thread(task).start();
}
}
此程式碼第一次可能不會產生錯誤。它可能看起來像這樣:
Exception in thread "Thread-1" java.lang.IllegalStateException: 7899 + 1 = 7901
at App.lambda$main$0(App.java:13)
at java.lang.Thread.run(Thread.java:745)
正如您所看到的,在分配時newValue
出現了問題,而且newValue
還有更多問題。競爭條件中的一些線程設法在這兩支球隊之間發生變化value
。正如我們所看到的,線程之間出現了競爭。現在想像一下,在金錢交易中不犯類似的錯誤是多麼重要…範例和圖表也可以在這裡看到:「在Java 執行緒中模擬競爭條件的程式碼」。
易揮發的
說到線程的交互,特別值得注意的是關鍵字volatile
。讓我們來看一個簡單的例子:
public class App {
public static boolean flag = false;
public static void main(String[] args) throws InterruptedException {
Runnable whileFlagFalse = () -> {
while(!flag) {
}
System.out.println("Flag is now TRUE");
};
new Thread(whileFlagFalse).start();
Thread.sleep(1000);
flag = true;
}
}
最有趣的是,它很有可能不起作用。新線程不會看到變化flag
。要解決此問題,flag
您需要為該欄位指定關鍵字volatile
。如何以及為何?所有操作均由處理器執行。但計算結果需要儲存在某個地方。為此,處理器上有主記憶體和硬體快取。這些處理器快取就像一小塊內存,存取資料的速度比存取主內存更快。但一切也都有缺點:快取中的資料可能不是最新的(如上例所示,當標誌值未更新時)。因此,該關鍵字volatile
告訴 JVM 我們不想快取變數。這使您可以看到所有線程中的實際結果。這是一個非常簡化的公式。關於這個主題,volatile
強烈建議閱讀「JSR 133 (Java Memory Model) FAQ」的翻譯。我還建議您閱讀更多有關「 Java Memory Model」和「Java Volatile Keyword 」的資料。此外,重要的是要記住,volatile
這是關於可見性,而不是關於更改的原子性。如果我們從「Race Condition」中取得程式碼,我們將在 IntelliJ Idea 中看到提示: 此檢查(Inspection)已作為問題IDEA-61117的一部分添加到 IntelliJ Idea 中,該問題已在 2010 年的發行說明中列出。
原子性
原子操作是不可分割的操作。例如,為變數賦值的操作是原子的。不幸的是,增量不是一個原子操作,因為 增量需要多達三個操作:取得舊值、加一、儲存該值。為什麼原子性很重要?在增量範例中,如果發生競爭條件,則共享資源(即共享值)隨時可能突然改變。此外,重要的是 64 位元結構也不是原子的,例如long
和double
。您可以在這裡閱讀更多內容:「讀寫 64 位元值時確保原子性」。原子性問題的一個例子可以在下面的例子中看到:
public class App {
public static int value = 0;
public static AtomicInteger atomic = new AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 10000; i++) {
value++;
atomic.incrementAndGet();
}
};
for (int i = 0; i < 3; i++) {
new Thread(task).start();
}
Thread.sleep(300);
System.out.println(value);
System.out.println(atomic.get());
}
}
用於使用原子的特殊類別Integer
將始終向我們顯示 30000,但value
它會不時發生變化。關於這個主題有一個簡短的概述「Java 中原子變數簡介」。Atomic 是基於比較和交換演算法。您可以在 Habré 的文章「無鎖演算法的比較 - CAS 和 FAA 使用 JDK 7 和 8 的範例」或維基百科的「與交換的比較」一文中閱讀更多相關資訊。
http://jeremymanson.blogspot.com/2008/11/what-volatile-means-in-java.html
GO TO FULL VERSION