Java併發編程基礎(下)

書接上回,繼續分享Java併發編程基礎內容。

Deadlock、Livelock和Thread Starvation

Deadlock

Deadlock 是兩個或多個線程無法繼續執行的情況,因爲它們都在等待其他線程釋放資源或鎖。這會導致任何線程都無法取得進展的停滯狀態。死鎖通常是由於不正確的同步或針對導致阻塞的資源的資源分配而引起的。請看一個涉及兩個線程和兩個鎖的死鎖場景的示例:

  
private static final Object lock1 = new Object()  
  
private static final Object lock2 = new Object()  
  
static void main(String[] args) {  
    Thread thread1 = new Thread(() -> {  
        synchronized (lock1) {  
            println("線程1持有鎖1")  
            try {  
                Thread.sleep(100)  
            } catch (InterruptedException e) {  
            }  
            println("線程1等待獲取鎖2")  
            synchronized (lock2) {  
                println("線程池持有鎖2")  
            }  
        }  
    })  
  
    Thread thread2 = new Thread(() -> {  
        synchronized (lock2) {  
            println("線程2持有鎖2")  
            try {  
                Thread.sleep(100)  
            } catch (InterruptedException e) {  
            }  
            println("線程2等待獲取鎖1")  
            synchronized (lock1) {  
                println("線程2持有鎖1")  
            }  
        }  
    })  
    thread1.start()  
    thread2.start()  
}

在這個例子中:

  • thread1獲取lock1然後等待lock2
  • thread2獲取lock2然後等待lock1

兩個線程現在都在等待對方持有的資源,從而導致死鎖。該程序將無限期掛起。如果我們此時打印現成轉儲則可以看到兩個線程的狀態:

"Thread-1" #28 [25091] prio=5 os_prio=31 cpu=13.87ms elapsed=24.40s tid=0x00007fceea86d000 nid=25091 waiting for monitor entry  [0x0000700010268000]
   java.lang.Thread.State: BLOCKED (on object monitor)
	at com.funtest.temp.ImmutablePerson$_main_closure2.doCall(ImmutablePerson.groovy:35)
	- waiting to lock <0x000000070e355c90> (a java.lang.Object)
	- locked <0x000000070e32b478> (a java.lang.Object)

克服僵局

可以通過多種技術來避免或解決死鎖:

  1. 使用超時:設置獲取鎖的超時時間。如果線程無法在指定時間內獲取鎖,它可以釋放其持有的任何鎖並重試或中止。ReentrantLock使用該包可以輕鬆實現此功能java.util.concurrent.locks
  2. 鎖順序:建立跨所有線程獲取鎖的一致順序,以防止循環等待,如下例所示。
  3. 資源分配圖:使用資源分配圖等算法來檢測死鎖並從死鎖中恢復。
  4. 避免死鎖的設計:設計多線程代碼以最大限度地減少死鎖的可能性,例如使用類等更高級別的抽象java.util.concurrent

    private static final Lock lock1 = new ReentrantLock()
    private static final Lock lock2 = new ReentrantLock()

    static void main(String[] args) {
        Runnable acquireLocks = () -> {
            lock1.lock()
            try {
                println(Thread.currentThread().getName() + ": 持有鎖1")
                try {
                    Thread.sleep(100)
                } catch (InterruptedException e) {
                }
                println(Thread.currentThread().getName() + ": 等待獲取鎖2")
                
                boolean acquiredLock2 = lock2.tryLock(500, TimeUnit.MILLISECONDS)
                if (acquiredLock2) {
                    try {
                        println(Thread.currentThread().getName() + ": 獲取鎖2")
                    } finally {
                        lock2.unlock()
                    }
                } else {
                    println(Thread.currentThread().getName() + ": 獲取鎖2超時")
                }
            } finally {
                lock1.unlock()
            }
        }
        Thread thread1 = new Thread(acquireLocks)
        Thread thread2 = new Thread(acquireLocks)
        thread1.start()
        thread2.start()
    }
    

Livelock

Livelock是多線程編程中一種特殊形式的死鎖,其中線程之間通過不斷地響應彼此而無法繼續執行的情況。與傳統死鎖不同,Livelock中的線程並不被阻塞,而是不斷嘗試執行,但總是在彼此之間交替失敗。

典型的Livelock場景包括兩個或多個線程試圖通過調整自己的狀態來避免衝突,但卻在不斷地互相干擾中無法取得進展。例如,兩個人試圖在狹窄的通道中讓對方通過,但卻不斷地向相同的一側讓路,導致兩人都無法通過。在軟件系統中,Livelock可能會發生在處理併發請求時,其中多個線程試圖避免爭用資源,但由於彼此之間的反應而無法順利進行。

解決Livelock的方法通常涉及引入隨機性或者超時機制,以打破線程之間的循環競爭。例如,通過引入隨機的等待時間或者限制重試次數來破壞線程之間的死循環,使它們最終能夠恢復正常執行。預防Livelock的關鍵在於設計良好的併發控制機制,避免線程在競爭資源時陷入無法解決的循環狀態。

public class LivelockExample {  
    // 勺子類  
    static class Spoon {  
        private Diner owner;  
  
        public Spoon(Diner owner) {  
            this.owner = owner;  
        }  
  
        // 使用勺子  
        public synchronized void use() {  
            System.out.println(owner.getName() + "使用了勺子");  
        }  
  
        // 設置勺子的所有者  
        public synchronized void setOwner(Diner owner) {  
            this.owner = owner;  
        }  
    }  
  
    // 就餐者類  
    static class Diner {  
        private String name;  
        private boolean isHungry;  
  
        public Diner(String name) {  
            this.name = name;  
            this.isHungry = true;  
        }  
  
        public String getName() {  
            return name;  
        }  
  
        public boolean isHungry() {  
            return isHungry;  
        }  
  
        // 與配偶一起進餐  
        public void eatWith(Spoon spoon, Diner spouse) {  
            while (isHungry) {  
                // 如果勺子不屬於當前就餐者,繼續等待  
                if (spoon.owner != this) {  
                    continue;  
                }  
  
                // 如果配偶也在等待就餐,讓配偶先喫  
                if (spouse.isHungry()) {  
                    System.out.println(getName() + ": 親愛的 " + spouse.getName() + ",你先喫吧。");  
                    spoon.setOwner(spouse);  
                    continue;  
                }  
  
                // 否則就餐者使用勺子,標記自己不再飢餓,並將勺子所有權交給配偶  
                spoon.use();  
                isHungry = false;  
                System.out.println(getName() + ": 我喫完了,你可以吃了 " + spouse.getName() + ".");  
                spoon.setOwner(spouse);  
            }  
        }  
    }  
  
    public static void main(String[] args) {  
        final Diner husband = new Diner("丈夫");  
        final Diner wife = new Diner("妻子");  
  
        final Spoon sharedSpoon = new Spoon(husband);  
  
        // 創建丈夫線程,與妻子一起進餐  
        Thread husbandThread = new Thread(() -> husband.eatWith(sharedSpoon, wife));  
        husbandThread.start();  
  
        // 創建妻子線程,與丈夫一起進餐  
        Thread wifeThread = new Thread(() -> wife.eatWith(sharedSpoon, husband));  
        wifeThread.start();  
    }  
}

Thread Starvation

線程飢餓(Thread Starvation)是指某些線程由於無法獲得所需的資源而被持續地阻塞的情況。這種情況通常發生在多線程系統中,其中一些線程可能會因爲長時間等待資源而無法執行,而其他線程則可以持續地訪問這些資源。線程飢餓可能導致性能下降和系統響應時間延遲,甚至可能導致系統崩潰。常見的線程飢餓場景包括資源競爭激烈、優先級反轉等。解決線程飢餓問題的方法包括使用公平的資源分配機制、優化資源的使用方式以及合理設置線程的優先級等。

java.util.concurrent 包

java.util.concurrent包提供了大量支持併發和多線程編程的類和接口。這些類提供了管理線程、同步和併發數據結構的高級抽象,使編寫高效且線程安全的代碼變得更加容易。以下是一些最流行的類和接口的概述。

Executor和ExecutorService

Executor是一個接口,表示能夠異步執行任務的對象。它將任務提交與任務執行解耦。ExecutorService是它的子接口Executor,它通過提供管理執行器生命週期和控制任務執行的方法來擴展功能。換句話說,ExecutorService是線程池的核心接口。

ExecutorService實現類提供了多種同時管理和執行任務的方法,每種方法都有自己的優點和用例。您可以在下表中找到最常用的。根據您的具體要求選擇適當的實現,但請記住,在調整線程池大小時,根據運行代碼的計算機具有的邏輯核心數量來確定線程池的大小通常很有用。您可以通過調用獲得該值Runtime.getRuntime().availableProcessors()

執行器服務實現 描述
ThreadPoolExecutor 一種多功能且可定製的執行器服務,允許您創建具有指定核心和最大線程數、自定義線程工廠等的線程池。
ScheduledThreadPoolExecutor 擴展ThreadPoolExecutor以提供在特定時間或間隔執行任務的調度功能。
ForkJoinPool 專門ExecutorService爲並行執行而設計,特別適合使用 Fork-Join 框架的遞歸任務和算法。
WorkStealingPool 其實現ForkJoinPool使用工作竊取算法在工作線程之間有效分配任務。
SingleThreadExecutor 創建具有單個工作線程的執行程序服務,適合一次順序執行一個任務。
FixedThreadPool 固定大小的線程池執行器,管理預定數量的工作線程,非常適合固定工作負載。
CachedThreadPool 線程池執行器,可以根據任務需求自適應調整線程數量,適合短生命週期和突發性任務。
SingleThreadScheduledExecutor 創建一個單線程調度執行器,它允許調度任務在特定時間或以固定速率間隔執行。
FixedScheduledThreadPool 具有調度能力的固定大小線程池,將固定大小線程池的特性與任務調度相結合。
此外,java.util.concurrent還提供了Executors包含靜態工廠方法的類,用於輕鬆創建上述線程池類型等。

可用的任務類型如下表所示。

Runnable Tasks 可運行任務是簡單的、無返回的任務,它們實現Runnable接口並執行操作而不產生結果。
Runnable Tasks 可調用任務與可運行任務類似,但可以返回結果或引發異常。他們實現了Callable<V>接口。
Runnable Tasks 異步任務通常由Future<V>接口表示,並且可以獨立於調用線程運行。FutureTask是它的具體實現Future,允許您包裝CallableorRunnable並將其與執行器一起使用。
ExecutorService#submit使用、ExecutorService#invokeAll或將任務提交給執行器服務ExecutorService#invokeAny

ExecutorService返回實例的大多數方法Future<V>Future是一個表示異步計算結果的接口。它公開了檢查計算是否完成或阻塞直到結果可用的方法。下面是一個例子。

static void main(String[] args) {  
    // 創建一個固定大小爲2的線程池  
    ExecutorService executorService = Executors.newFixedThreadPool(2)  
    // Runnable接口的run()方法沒有返回值,所以使用Runnable接口的任務不會返回結果  
    Runnable runnableTask = () -> {  
        String threadName = Thread.currentThread().getName()  
        println("執行線程 " + threadName)  
    }  
    // Callable接口的call()方法返回一個結果,所以使用Callable接口的任務會返回結果  
    List<Callable<String>> callableTasks = List.of(  
            () -> {  
                String threadName = Thread.currentThread().getName()  
                return "任務執行線程:   " + threadName  
            },  
            () -> {  
                String threadName = Thread.currentThread().getName()  
                return "任務執行線程:   " + threadName  
            }  
    )  
    // 提交Runnable任務給線程池執行  
    executorService.submit(runnableTask)  
    try {  
        // 提交Callable任務給線程池執行,invokeAll()方法等待所有任務完成  
        List<Future<String>> futures = executorService.invokeAll(callableTasks)  
        // 獲取所有任務的結果  
        for (Future<String> future : futures) {  
            println(future.get())  
        }  
        // 使用invokeAny提交一組Callable任務並等待第一個完成的任務  
        String firstResult = executorService.invokeAny(callableTasks)  
        println("第一個任務完成結果: " + firstResult)  
    } catch (Exception e) {  
        e.printStackTrace()  
    }  
    // 關閉線程池  
    executorService.shutdown()  
}

控制檯輸出:

執行線程 pool-1-thread-1
任務執行線程:   pool-1-thread-2
任務執行線程:   pool-1-thread-2
第一個任務完成結果: 任務執行線程:   pool-1-thread-2

Semaphore

Semaphore(信號量)是一種用於管理併發訪問資源的同步機制,常用於多線程編程中。Semaphore維護了一個內部的計數器,該計數器表示可用的許可證數量。線程在訪問共享資源之前必須先獲取許可證,Semaphore控制着許可證的發放和歸還。

Semaphore通常用於限制同時訪問某個資源的線程數量,或者在資源有限的情況下控制併發訪問的數量。通過在初始化Semaphore時指定初始的許可證數量,可以限制併發訪問的數量。當線程需要訪問資源時,首先嚐試從Semaphore獲取許可證,如果許可證可用,則允許線程訪問資源,並將許可證數量減少;如果許可證不可用,則線程將被阻塞,直到有其他線程釋放了許可證。

Semaphore提供了兩個主要的方法:acquire()release()acquire()方法用於獲取許可證,如果許可證不可用則線程將被阻塞,直到許可證可用;release()方法用於釋放許可證,使得其他等待許可證的線程可以獲取許可證並訪問資源。

Semaphore的應用場景包括但不限於:限制數據庫連接池中的併發連接數、控制線程池中同時執行的任務數量、限制文件資源的併發訪問數量等。在這些場景下,Semaphore可以幫助管理併發訪問,避免資源競爭和過度使用資源,從而提高系統的性能和穩定性。

Semaphore是一種強大的同步工具,能夠有效地管理併發訪問資源,控制線程的數量,並提供了靈活的併發編程解決方案。

CountDownLatch

CountDownLatch(倒計時門栓)是一種多線程同步工具,用於控制一個或多個線程等待其他線程完成操作後再執行。CountDownLatch維護了一個計數器,該計數器在初始化時被設置爲一個正整數,表示需要等待完成的操作數量。每當一個線程完成了一個操作,它會調用CountDownLatch的countDown()方法來將計數器減一。其他線程可以通過調用await()方法來等待計數器的值變爲零,一旦計數器的值爲零,所有等待的線程將被釋放,並可以繼續執行。

CountDownLatch的主要應用場景包括但不限於:在主線程等待所有子線程完成任務後再繼續執行、實現併發測試中的線程同步、等待多個服務初始化完成後再啓動應用程序等。例如,在併發測試中,可以使用CountDownLatch來確保所有測試線程都完成了測試任務後再進行結果彙總和分析。

CountDownLatch提供了靈活的併發編程解決方案,能夠幫助開發人員處理複雜的線程同步問題。通過合理設置計數器的初始值和調用countDown()方法的時機,可以實現精確控制線程的等待和執行順序。然而,需要注意的是,一旦計數器的值被設置爲零,就無法重置,因此CountDownLatch只能被使用一次。

CountDownLatch是一種簡單而強大的多線程同步工具,能夠幫助開發人員實現線程之間的協調和同步,提高系統的併發性能和可靠性。

CyclicBarrier

CyclicBarrier(循環柵欄)是一種多線程同步工具,用於在多個線程之間創建一個屏障,只有當所有參與線程都到達了屏障位置時,才允許它們繼續執行。與CountDownLatch不同的是,CyclicBarrier的計數器可以被重置並循環使用。

CyclicBarrier維護了一個計數器和一個屏障動作,計數器初始化時設置爲要等待的線程數量。每個線程在到達屏障位置時,會調用CyclicBarrier的await()方法來等待其他線程。當所有線程都到達了屏障位置時,CyclicBarrier會觸發屏障動作,所有線程同時開始執行下一階段的任務。一旦所有線程都離開了屏障,計數器會被重置,可以繼續使用。

CyclicBarrier通常用於將任務分解爲多個子任務,各個線程獨立執行子任務,最終等待所有子任務完成後再彙總結果。它也常用於並行計算中的分階段計算和任務流水線等場景。例如,在並行排序算法中,可以將數據分成多個區塊,每個線程對一個區塊進行排序,然後使用CyclicBarrier等待所有線程完成排序後再進行合併排序。

CyclicBarrier提供了一種簡單而強大的線程同步機制,能夠幫助開發人員實現複雜的併發任務協調和同步。通過合理設置計數器的初始值和定義屏障動作,可以實現靈活的多線程協作方案。然而,需要注意的是,CyclicBarrier的計數器只能被重置一次,因此在重複使用時需要格外小心。

CyclicBarrier是一種重要的多線程同步工具,能夠提高系統的併發性能和可靠性,爲開發人員提供了便捷而高效的併發編程解決方案。

併發集合

這些併發集合類爲各種用例提供​​線程安全的數據結構,允許多個線程併發訪問和修改數據,同時確保數據一致性並最大限度地減少爭用。選擇使用哪個類取決於併發應用程序的特定需求。

併發集合類 描述
ConcurrentHashMap 接口的高度併發、線程安全實現Map,專爲多線程環境中的高效讀寫操作而設計。
ConcurrentSkipListMap 基於跳躍列表數據結構的併發排序映射,提供併發訪問和排序順序。
BlockingQueue(LinkedBlockingQueueDelayQueuePriorityBlockingQueueSynchronousQueue) 阻塞隊列是線程安全的、有界或無界的隊列,支持生產者-消費者場景的阻塞操作。InDelayQueue元素根據其延遲被刪除,inPriorityBlockingQueue基於 aComparator和 inSynchronousQueue元素僅在新元素到達時才被刪除。
ConcurrentLinkedQueue 基於鏈式節點結構的線程安全、非阻塞、無界隊列,適用於高併發的生產者消費者場景。
ConcurrentLinkedDeque 線程安全、非阻塞、雙端隊列,支持兩端併發訪問和修改。
CopyOnWriteArrayList 每當進行修改時都會創建其內部數組的新副本的列表,以確保讀取繁重的工作負載的線程安全。
CopyOnWriteArraySet 由 a 支持的線程安全集CopyOnWriteArrayList,爲讀取繁重的集提供線程安全性。
ConcurrentSkipListSet 基於跳躍列表數據結構的併發排序集,提供併發訪問和排序順序。

原子學

java.util.concurrent.atomic包提供了支持對單個變量進行原子操作的類。這些類設計用於多線程應用程序,以確保對共享變量的操作以原子方式執行,而不需要顯式同步。這有助於避免數據競爭並確保線程安全。

常見原子類:

  1. AtomicInteger:可以原子遞增、遞減或更新的整數值。
  2. AtomicLong:支持原子操作的長值。
  3. AtomicBoolean:具有用於設置和獲取的原子操作的布爾值。
  4. AtomicReference:支持原子更新的通用引用類型。
  5. AtomicStampedReference:它的一個變體AtomicReference包括用於檢測更改的版本標記。
  6. AtomicIntegerArrayAtomicLongArrayAtomicReferenceArray:原子值數組。

它們適用於需要對變量執行增量、比較和設置和更新等操作,而又不會因併發訪問而導致數據損壞的情況。

synchronize 塊相比,鎖提供了更靈活、更先進的鎖定機制,包括可重入、公平性和讀寫鎖定等功能。該java.util.concurrent.locks包包含兩個接口,Lock以及ReadWriteLock它們的實現類ReentrantLockReentrantReadWriteLock分別。

ReentrantLock是一種可重入互斥鎖,其基本行爲與synchronized塊相同,但具有附加功能。它可用於控制對共享資源的訪問,並提供更多的靈活性和對鎖定的控制,例如獲取有關鎖定狀態的信息、非阻塞tryLock()和可中斷鎖定。在此示例中,我們使用 aReentrantLock來保護代碼的關鍵部分。

private static ReentrantLock lock = new ReentrantLock()  
  
static void main(String[] args) {  
    Runnable task = () -> {  
        lock.lock()// 獲取鎖  
        try {  
            System.out.println("Thread " + Thread.currentThread().getName() + " 獲取到了鎖")  
            // 模擬業務處理  
            Thread.sleep(1000)  
        } catch (InterruptedException e) {  
            Thread.currentThread().interrupt()  
        } finally {  
            lock.unlock() // 釋放鎖  
            System.out.println("Thread " + Thread.currentThread().getName() + " 釋放了鎖")  
        }  
    }  
    // 創建多個線程來訪問臨界區  
    for (i in 0..<2) {  
        new Thread(task).start()  
    }  
}

ReentrantReadWriteLock爲讀和寫提供單獨的鎖。它用於允許多個線程同時讀取共享資源,同時確保一次只有一個線程可以寫入該資源。

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