fundamentals\java\Concurrency2


原文鏈接:Concurrency Fundamentals: Deadlocks and Object Monitors

1.線程的生存

當開發高併發的應用程序時,可能會遇到不同線程可能相互阻塞的情況。使得應用程序的執行速度變慢,也就是說說,應用程序不會在預期的時間內完成。在本節中,我們將更詳細地瞭解可能危及多線程應用程序的生存的問題。

1.1、死鎖

死鎖這個術語對於軟件開發人員來說是衆所周知的,即使是大多數普通的計算機用戶也經常使用這個術語,但是並不是所有人都能正確的理解他。嚴格來說,死鎖意味着兩個(或更多)線程都在等待另一個線程釋放已鎖定的資源,而線程本身已鎖定另一個線程正在等待的資源:

Thread 1: locks resource A, waits for resource B
Thread 2: locks resource B, waits for resource A

爲了更好地理解這個問題,讓我們看一下以下源代碼:

public class Deadlock implements Runnable {
    private static final Object resource1 = new Object();
    private static final Object resource2 = new Object();
    private final Random random = new Random(System.currentTimeMillis());
 
    public static void main(String[] args) {
        Thread myThread1 = new Thread(new Deadlock(), "thread-1");
        Thread myThread2 = new Thread(new Deadlock(), "thread-2");
        myThread1.start();
        myThread2.start();
    }
 
    public void run() {
        for (int i = 0; i < 10000; i++) {
            boolean b = random.nextBoolean();
            if (b) {
                System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 1.");
                synchronized (resource1) {
                    System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 1.");
                    System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 2.");
                    synchronized (resource2) {
                        System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 2.");
                    }
                }
            } else {
                System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 2.");
                synchronized (resource2) {
                    System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 2.");
                    System.out.println("[" + Thread.currentThread().getName() + "] Trying to lock resource 1.");
                    synchronized (resource1) {
                        System.out.println("[" + Thread.currentThread().getName() + "] Locked resource 1.");
                    }
                }
            }
        }
    }
}

上面的代碼可啓動了兩個線程,並試圖鎖定兩個靜態資源。但是要行成一個死鎖,兩個線程的鎖定順序必須不同,因此我們使用隨機數來選擇線程首先要鎖定的資源。如果布爾變量b爲真,則首先鎖定資源1,然後線程嘗試獲取資源2的鎖。如果b爲false,則線程首先鎖定resource2,然後嘗試鎖定resource1。這個程序不需要運行很長時間,就會出現第一個死鎖,也就是說,如果我們不終止它,程序將永遠掛起:

[thread-1] Trying to lock resource 1.
[thread-1] Locked resource 1.
[thread-1] Trying to lock resource 2.
[thread-1] Locked resource 2.
[thread-2] Trying to lock resource 1.
[thread-2] Locked resource 1.
[thread-1] Trying to lock resource 2.
[thread-1] Locked resource 2.
[thread-2] Trying to lock resource 2.
[thread-1] Trying to lock resource 1.

在此次執行中,線程1持有資源2的鎖並等待資源1的鎖,而線程2持有資源1的鎖並等待資源2。
如果我們將上面示例代碼中的布爾變量b永遠設置爲true,我們將不會遇到任何死鎖,因爲線程1和線程2請求鎖的順序總是相同的。因此,兩個線程中的一個首先獲取鎖,然後請求第二個鎖,因爲其他線程等待第一個鎖,所以第二個鎖仍然可用。
通常,發生死鎖有以下要求:

  1. 獨佔資源:有一個資源在任何時間點只能由一個線程訪問。
  2. 資源持有:在持有(鎖定)了一個資源時,線程會嘗試獲取其他獨佔資源上的另一個鎖。
  3. 非搶佔:一個持有鎖的線程,沒有機制在一定時間段後釋放資源。
  4. 循環等待:在運行時,在一系列線程中,其中兩個(或多個)線程各自等待另一個線程釋放已鎖定的資源。

儘管需求列表看起來很長,但複雜的多線程應用程序死鎖並不少見。但是,如果您能夠解決上面列出的某個要求,則可以嘗試避免死鎖:

  1. 避免獨佔資源:一般來說這是不能避免的需求,因爲資源必須獨佔式使用。但情況並非總是如此。當使用DBMS系統時,一個可能的解決方案不是對必須更新的錶行使用悲觀鎖,而是使用一種稱爲樂觀鎖的技術。
  2. 避免資源持有:一個可能的解決方案是在算法開始時鎖定所有必要的資源,如果無法獲取所有鎖,則釋放所有資源。當然,這並不總是可能的,也許要鎖定的資源無法預先知曉,或者它在浪費資源。
  3. 設置搶佔機制:如果不能立即獲得鎖,則可以通過引入超時來避免可能的死鎖。例如,SDK類ReentrantLock能指定超時時間。
  4. 避免循環等待:正如我們從上面的示例代碼中看到的,如果所有線程加鎖的順序不存在差異,則不會出現死鎖。如果能夠將所有鎖代碼放入一個方法中,所有線程都必須通過該方法,那麼可以輕鬆地控制這一點。

在更復雜的應用程序中,您甚至可以考慮實現死鎖檢測系統。在這裏,您必須實現某種線程監視,其中每個線程都報告成功獲取鎖和嘗試獲取鎖。如果線程和鎖被建模爲有向圖,則可以檢測兩個不同的線程何時持有資源,同時請求另一個被阻塞的資源。如果您能夠強制阻塞線程釋放獲得的資源,那麼您就能夠自動解決死鎖情況。

1.2 線程飢餓

線程調度程序決定下一步應該執行哪個處於可運行狀態的線程。該決定基於線程的優先級;因此優先級較低的線程比優先級較高的線程獲得更少的CPU時間。聽起來合理的功能在使用不當時也會導致問題。如果大多數具有高優先級的線程被執行,那麼具有較低優先級的線程會處於“飢餓”狀態,因爲它們不能獲得足夠的CPU時間來。因此,僅在理由充分時才建議設置線程的優先級。
線程“飢餓”的一個典型用例是finalize()方法。Java語言的finalize()方法中的代碼將在對象被垃圾收集時執行。但是,當您查看finalize()線程的優先級時,您可能會發現它不是以最高優先級運行的。因此,如果對象的finalize()中的代碼相比方法主體來說費時太長,則有可能導致finalize()線程處於“飢餓”狀態。
線程在執行時間上的另一個問題是,它沒有定義線程執行同步塊的順序。當許多線程併發的訪問封裝在sync關鍵字內的代碼時,可能會發生某些線程必須比其他線程等待更長的時間才能執行的情況。理論上,他們可能永遠不會被執行。
後一個問題的解決方案是所謂的“平凡”鎖。fair鎖在選擇下一個要執行的線程時,會考慮線程的等待時間。JavaSDK提供了一個fair鎖的示例實現:

Java.util.competition.locks.reentrantlock.如果使用了bool=true的構造器,ReentrantLock保證等待時間最長的線程將被執行,但同時也引入了一個問題,即線程優先級沒有被考慮在內,因此具有較低優先級的線程(通常在該處於等待狀態)可能會更頻繁地執行。最後,當然ReentrantLock類只會計算,當前正在等待的線程,也就是說,頻繁執行的線程更多的得到鎖。如果線程優先級太低,可能不會頻繁的得到鎖。

2. 使用wait()和notify()實現對象鎖

在多線程編程中,一個常見的任務是讓一些工作線程等待生產者生產產品————生產消費模式。但是我們知道,無論是讓線程在一個循環中不停的等待或是循環的檢查某個狀態的值都是對CPU時間資源的一種浪費。而當我們希望消費者在生產者提交請求後立即開始執行,thread.sleep()方法也沒有多大價值。
因此Java編程語言有另一種機制,可以應對這種場景:Wait()和Notify()。每個對象都從java.lang.object類繼承wait()方法,這個方法可用於暫停當前線程執行,並等待其他線程使用notify()方法喚醒。爲了正確工作,線程獲得一個鎖開始執行synchronized關鍵字內的代碼,執行完成後調用wait()時,鎖被釋放,與其他線程一起等待,直到擁有鎖的另一個線程在同一對象實例上調用notify()方法。
在多線程應用程序中,可能有多個線程在等待某個對象被喚醒。因此,喚醒線程有兩種不同的方法:notify()和notifyall()。notify只喚醒其中一個等待的線程,但是notifyAll方法會喚醒所有等待的線程。但請注意,與synchronized關鍵字類似,沒有辦法指定調用notify時接下來喚醒哪個線程。在一個簡單的生產——消費者的例子中,這並不重要,因爲我們對具體的哪個線程被喚醒不感興趣。
下面的代碼演示瞭如何使用wait()和notify()機制讓消費者線程等待從某個生產者線程推送到隊列中的新工作:

import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;
 
public class ConsumerProducer {
    private static final Queue queue = new ConcurrentLinkedQueue();
    private static final long startMillis = System.currentTimeMillis();
 
    public static class Consumer implements Runnable {
 
        public void run() {
            while (System.currentTimeMillis() < (startMillis + 10000)) {
                synchronized (queue) {
                    try {
                        queue.wait();
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                if (!queue.isEmpty()) {
                    Integer integer = queue.poll();
                    System.out.println("[" + Thread.currentThread().getName() + "]: " + integer);
                }
            }
        }
    }
 
    public static class Producer implements Runnable {
 
        public void run() {
            int i = 0;
            while (System.currentTimeMillis() < (startMillis + 10000)) {
                queue.add(i++);
                synchronized (queue) {
                    queue.notify();
                }
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            synchronized (queue) {
                queue.notifyAll();
            }
        }
 
    }
 
    public static void main(String[] args) throws InterruptedException {
        Thread[] consumerThreads = new Thread[5];
        for (int i = 0; i < consumerThreads.length; i++) {
            consumerThreads[i] = new Thread(new Consumer(), "consumer-" + i);
            consumerThreads[i].start();
        }
        Thread producerThread = new Thread(new Producer(), "producer");
        producerThread.start();
        for (int i = 0; i < consumerThreads.length; i++) {
            consumerThreads[i].join();
        }
        producerThread.join();
    }
}

Main()方法啓動五個消費者線程和一個生產者線程,然後等待它們完成。然後,生產者線程將一個新值插入隊列,然後通知所有等待線程。消費者線程獲取隊列鎖執行完之後進入休眠狀態,以便稍後在隊列再次填充時被喚醒。當生產者線程完成其工作後,它通知所有使用者線程喚醒。如果我們不執行最後一步,消費者線程將永遠等待下一個通知,因爲我們沒有爲等待指定任何超時。相反,我們可以使用wait(時長)在經過一段時間後將喚醒。

2.1.使用wait()和notify()以及內嵌的synchronized代碼塊

正如在上一節中提到的,在對象鎖上調用wait()只釋放這個對象上的鎖。線程持有的其他鎖不會被釋放。我們很容易想到,在日常工作中,線程在調用wait()方法時,本身可能持有更多的鎖。如果其他線程也在等待這些鎖,則可能發生死鎖情況。讓我們看一下下面的示例代碼:

public class SynchronizedAndWait {
    private static final Queue queue = new ConcurrentLinkedQueue();
 
    public synchronized Integer getNextInt() {
        Integer retVal = null;
        while (retVal == null) {
            synchronized (queue) {
                try {
                    queue.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                retVal = queue.poll();
            }
        }
        return retVal;
    }
 
    public synchronized void putInt(Integer value) {
        synchronized (queue) {
            queue.add(value);
            queue.notify();
        }
    }
 
    public static void main(String[] args) throws InterruptedException {
        final SynchronizedAndWait queue = new SynchronizedAndWait();
        Thread thread1 = new Thread(new Runnable() {
            public void run() {
                for (int i = 0; i < 10; i++) {
                    queue.putInt(i);
                }
            }
        });
        Thread thread2 = new Thread(new Runnable() {
            public void run() {
                for (int i = 0; i < 10; i++) {
                    Integer nextInt = queue.getNextInt();
                    System.out.println("Next int: " + nextInt);
                }
            }
        });
        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
    }
}

正如我們之前所瞭解的,將synchronized添加到方法等於創建一個synchronized(this){}同步塊。在上面的示例中,我們意外地將synchronized關鍵字添加到了方法中,然後又在隊列對象中進行wait()同步,以便在等待隊列中的下一個值時使當前線程進入睡眠狀態。然後,當前線程將會釋放隊列對象上的鎖,但不會釋對象鎖。putint()方法通知休眠線程已添加新值。但不小心我們也添加了關鍵字synchronized到這個方法中。當第一個線程睡着時,它仍然持有方法上的synchronized鎖。第二個線程然後不能進入方法putint(),因爲這個鎖被第一個線程持有。因此,我們遇到了死鎖情況,程序掛起。如果您執行上面的代碼,死鎖將在程序開始執行之後立刻發生。
在正式的開發環境中,情況可能不像上述栗子那樣簡單明白。線程持有的鎖可能取決於運行時參數和條件,導致有問題的同步塊可能離wait()調用的位置很遠。這使得這些問題很難被找到,如果可能的話,這些問題只會在執行一段時間後或在高負載下出現。

2.2.帶條件的同步代碼

通常,在對同步對象執行某些操作之前,必須檢查是否滿足了某些條件。例如,當您有一個隊列時,您希望直到該隊列中有值的時候再執行操作。因此,您可以編寫一個方法來檢查隊列是否有值。如果沒有,則將當前線程置於休眠狀態,直到有值之後才被喚醒:

public Integer getNextInt() {
    Integer retVal = null;
    synchronized (queue) {
        try {
            while (queue.isEmpty()) {
                queue.wait();
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
    synchronized (queue) {
        retVal = queue.poll();
        if (retVal == null) {
            System.err.println("retVal is null");
            throw new IllegalStateException();
        }
    }
    return retVal;
}

上面的代碼在隊列對象加wait()鎖,然後在while循環中等待,直到隊列至少有一個元素。第二個同步塊再次使用這個隊列對象鎖。它對隊列中的值進行輪詢。出於演示目的,當poll()返回空值時將引發IllegalstateException。當隊列中沒有值時,就會拋出異常。
當運行此示例時,您將看到很快就會拋出IllegalstateException。儘管我們在隊列對象上進行了正確的同步操作,但依舊會引發異常。這裏的原因是我們有兩個單獨的同步代碼塊。假設我們有兩個線程到達了第一個同步塊。第一個線程進入塊並進入休眠狀態,因爲隊列是空的。第二個線程也是如此。現在,當兩個線程都喚醒時(由一個線程操作隊列對象調用notifyall()時),它們都會在隊列中看到一個值(生產者添加的值)。然後兩個線程都到達第二個屏障。在這裏,第一個線程進入並輪詢隊列中的值。當第二個線程進入時,隊列已經是空的。因此,poll()調用返回空值,並拋出異常。
爲避免出現上述情況,您必須將依賴對象鎖的所有操作放在同一個同步塊中:

public Integer getNextInt() {
    Integer retVal = null;
    synchronized (queue) {
        try {
            while (queue.isEmpty()) {
                queue.wait();
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        retVal = queue.poll();
    }
    return retVal;
}

我們在isEmpty()方法相同的同步塊中執行poll()方法。通過同步塊,我們可以確定在給定的時間點上只有一個線程在此監視器上執行方法。因此,沒有其他線程可以從isEmpty()和poll()的調用之間從隊列中刪除元素。

3.多線程設計方法

正如我們在最後幾節中看到的,實現多線程應用程序有時比說起來要複雜得多。因此,在啓動項目時,有一個清晰的設計是很重要的。

3.1. 不可變對象

多線程設計中一個被認爲非常重要的概念是不可變性。如果在不同線程之間共享對象實例,那您必須注意兩個線程會不會同時修改同一對象。而不可修改的對象由於其無法更改性(當您想要修改數據時,總是必須構造一個新的實例),使之能輕易的應對多線程同時修改共享對象實例的問題。基本類java.lang.string是一個不可變類的一個例子。每次要更改字符串時,都會得到一個新實例:

String str = "abc";
String substr = str.substring(1);

雖然對象創建不時不需要成本,但這些成本常常被高估了。試想,如果使用不可變對象使得設計變得簡單,比之於不使用不可變對象,但是與之俱來的時併發錯誤風險(在項目中可能很晚才觀察到這些錯誤),您必須進行權衡之中的利弊了。
在下面的內容中,是一組規則,當您想使類不可變時,可以應用以下規則:

  1. 所有字段都應該是final 的和private的。
  2. 不應該有setter方法。
  3. 類本身應該聲明爲final,以防止子類違反不可變原則。
  4. 如果字段不是基元類型,而是對其他對象的引用:
    1. 這些引用的對象不應該有直接訪問的getter方法。
    2. 這些引用的對象不能被更改(或者至少更改這些引用對於對象的客戶端是不可見的)。

以下類的實例表示一個消息:包含主題、消息正文和幾個鍵/值對:

public final class ImmutableMessage {
    private final String subject;
    private final String message;
    private final Map<String,String> header;
 
    public ImmutableMessage(Map<String,String> header, String subject, String message) {
        this.header = new HashMap<String,String>(header);
        this.subject = subject;
        this.message = message;
    }
 
    public String getSubject() {
        return subject;
    }
 
    public String getMessage() {
        return message;
    }
 
    public String getHeader(String key) {
        return this.header.get(key);
    }
 
    public Map<String,String> getHeaders() {
        return Collections.unmodifiableMap(this.header);
    }
}

本類是不可變的,因爲它的所有字段都是最終字段和私有字段。沒有任何方法能夠在實例構造後修改其狀態。返回對subject和message的引用是安全的,因爲字符串本身是不可變的類。例如,獲得消息引用的調用者不能直接修改它。對於Headers的Map,我們必須更加註意。只要返回對映射的引用,調用方就可以更改其內容。因此,我們必須返回通過調用collections.unmodifiableMap()獲得的不可修改的映射。這將返回映射上的一個視圖,該視圖允許調用方讀取值(這些值又是字符串),但不允許修改。嘗試修改映射實例時,將引發UnsupportedOperationException。在本例中,返回特定鍵的值也是安全的,就像在getheader(String key)中一樣,因爲返回的字符串再次是不可變的。如果映射包含本身不可變的對象,則此操作將不具有線程安全性。

3.2. API設計技巧

在設計類的公共方法時,即該類的API,您也可以嘗試將其設計爲多線程使用。您可能想通過狀態來控制方法的執行。解決這種情況的一個簡單的解決方案是有一個private標誌,它檢查我們處於哪個狀態並在非法狀態拋出異常,例如當調用特定方法時出現非法狀態異常:

public class Job {
    private boolean running = false;
    private final String filename;
 
    public Job(String filename) {
        this.filename = filename;
    }
 
    public synchronized void start() {
        if(running) {
            throw new IllegalStateException("...");
        }
        ...
    }
 
    public synchronized List getResults() {
        if(!running) {
            throw new IllegalStateException("...");
        }
        ...
    }
}

上面的模式通常也被稱爲“冒泡模式”,因爲方法一旦在錯誤的狀態下執行就會冒泡。但是,您可以使用靜態工廠方法設計相同的功能,而不必在每個方法中檢入對象的狀態:

public class Job {
    private final String filename;
 
    private Job(String filename) {
        this.filename = filename;
    }
 
    public static Job createAndStart(String filename) {
        Job job = new Job(filename);
        job.start();
        return job;
    }
 
    private void start() {
        ...
    }
 
    public synchronized List getResults() {
        ...
    }
}

靜態工廠方法使用私有構造函數創建作業的新實例,並已對該實例調用start()。作業的返回引用已處於要使用的正確狀態,因此getResults()方法只需要同步,但不必檢查對象的狀態。

3.3. 線程本地存儲

到目前爲止,我們已經看到線程共享相同的內存。在性能方面,這是一種在線程之間共享數據的好方法。如果我們使用多個進程來並行執行代碼,那麼我們將有更多繁重的數據交換方法,如遠程過程調用,文件系統或網絡級別的同步。如果沒有正確同步,那麼不同線程之間的共享內存很難滿足要求。
Java中提供線程專有的內存JavaLang.threadLocal 類:

public class ThreadLocalExample implements Runnable {
    private static final ThreadLocal threadLocal = new ThreadLocal();
    private final int value;
 
    public ThreadLocalExample(int value) {
        this.value = value;
    }
 
    @Override
    public void run() {
        threadLocal.set(value);
        Integer integer = threadLocal.get();
        System.out.println("[" + Thread.currentThread().getName() + "]: " + integer);
    }
 
    public static void main(String[] args) throws InterruptedException {
        Thread threads[] = new Thread[5];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(new ThreadLocalExample(i), "thread-" + i);
            threads[i].start();
        }
        for (int i = 0; i < threads.length; i++) {
            threads[i].join();
        }
    }
}

您可能會奇怪,雖然變量threadlocal聲明爲靜態,但每個線程都會準確地輸出它通過構造函數獲得的值。threadlocal的內部實現確保每次調用set()時,給定的值都存儲在只有當前線程可以訪問的內存區域中。因此,當您以後調用get()時,您將檢索以前設置的值,儘管在此期間其他線程可能調用了set()。
JavaEE世界中的應用服務器在使用許多並行線程時會大量使用TyLeadLocal特性,但每個線程都有其自身的事務或安全上下文。由於您不想在每個方法調用中傳遞這些對象,所以只需將其存儲在線程自己的內存中,並在以後需要時訪問它。

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