JavaRush /Java Blog /Random-TW /你不能用線程破壞 Java:第三部分 - 交互
Viacheslav
等級 3

你不能用線程破壞 Java:第三部分 - 交互

在 Random-TW 群組發布
簡單概述線程交互的特點。之前,我們了解了線程如何相互同步。這次我們將深入探討線程互動時可能出現的問題,並討論如何避免這些問題。我們還將提供一些有用的連結以供更深入的研究。 你不能用線程毀掉 Java:第三部分 - 互動 - 1

介紹

因此,我們知道 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你不能用線程毀掉 Java:第三部分 - 互動 - 2如果JVisualVM中安裝了插件(透過Tools -> Plugins),我們可以看到死鎖發生在哪裡:
"Thread-1" - Thread t@12
   java.lang.Thread.State: BLOCKED
    at Deadlock$Friend.bowBack(Deadlock.java:16)
    - waiting to lock &lt33a78231> (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
...
從範例中可以看出,兩個執行緒交替嘗試捕獲兩個鎖,但都失敗了。此外,他們並沒有陷入僵局,也就是說,從視覺上看,他們一切都很好,他們正在做自己的工作。 你不能用線程毀掉 Java:第三部分 - 互動 - 3根據 JVisualVM,我們看到睡眠期和停放期(這是當線程嘗試佔用鎖時,它進入停放狀態,正如我們之前在談論線程同步時討論的那樣)。關於活鎖這個主題,可以看一個例子:《Java - 線程活鎖》。

飢餓

除了阻塞(死鎖和活鎖)之外,使用多執行緒時還有另一個問題 - 飢餓或「飢餓」。這種現象與阻塞的不同之處在於,執行緒並未被阻塞,但它們只是沒有足夠的資源供每個人使用。因此,雖然某些執行緒接管了所有執行時間,但其他執行緒卻無法執行: 你不能用線程毀掉 Java:第三部分 - 互動 - 4

https://www.logicbig.com/

一個超級例子可以在這裡找到:「Java - Thread Starvation and Fairness」。此範例展示了執行緒在飢餓狀態下如何運作,以及從 Thread.sleep 到 Thread.wait 的一個小變更如何能夠均勻分配負載。 你不能用線程毀掉 Java:第三部分 - 互動 - 5

競賽條件

使用多執行緒時,存在「競爭條件」之類的情況。這種現象的原因在於,執行緒之間共享一定的資源,而程式碼的編寫方式在這種情況下無法提供正確的操作。讓我們來看一個例子:
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 中看到提示: 你不能用線程毀掉 Java:第三部分 - 互動 - 6此檢查(Inspection)已作為問題IDEA-61117的一部分添加到 IntelliJ Idea 中,該問題已在 2010 年的發行說明中列出。

原子性

原子操作是不可分割的操作。例如,為變數賦值的操作是原子的。不幸的是,增量不是一個原子操作,因為 增量需要多達三個操作:取得舊值、加一、儲存該值。為什麼原子性很重要?在增量範例中,如果發生競爭條件,則共享資源(即共享值)隨時可能突然改變。此外,重要的是 64 位元結構也不是原子的,例如longdouble。您可以在這裡閱讀更多內容:「讀寫 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 的範例」或維基百科的「與交換的比較」一文中閱讀更多相關資訊。 你不能用線程毀掉 Java:第三部分 - 互動 - 8

http://jeremymanson.blogspot.com/2008/11/what-volatile-means-in-java.html

發生在之前

有一件有趣而神秘的事——發生在之前。說到流量,它也值得一讀。Happens Before 關係指示執行緒之間的操作的顯示順序。有很多種解釋和詮釋。關於此主題的最新報告之一是此報告:
這段影片最好不要透露任何內容。所以我只會留下影片的連結。您可以閱讀《Java - 理解發生在關係之前》。

結果

在這篇評論中,我們研究了線程互動的特性。我們討論了可能出現的問題以及檢測和消除這些問題的方法。有關該主題的附加資料清單: #維亞切斯拉夫
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION