不堆概念、換個角度聊多執行緒並發編程

大家好,又見面了。

在上一篇文檔《JAVA基於CompletableFuture的流水線並行處理深度實踐,滿滿乾貨》中,我們一起探討了JAVA中並行編碼的相關內容,在文中也一起比較了並行與並發的區別。作為姊妹篇,這裡我們就再展開聊一聊關於並發相關的內容。

image.png

俗話說,雙拳難敵四手
俗話還說,人多力量大

在現實生活中,我們通過團隊化的方式來獲得比單兵作戰更高的單位時間內整體產出速度。同樣,在編碼世界中,為了提升處理效率,並發一直以來都是軟體開發設計場景中無法繞過的話題。不管是微觀層面的單個進程內多執行緒處理模式,還是宏觀層面整個系統集群化多節點部署策略,為了提升系統的整體並發吞吐量,程式設計師們可謂是煞費苦心。

image.png

當然,俗話也說,人多眼雜林子大了什麼鳥都有。在現實中,團隊中多人一起配合工作的時候,一系列的問題又會顯現:

  • 同一個事情,老王和小張都以為還沒處理,結果都去處理了,最後造成了成員工作量的浪費、甚至因為重複處理了一遍導致數據錯誤
  • 兩個有關聯的事情分別給了老王和翠花,結果老王在等待翠花先給出結果再開始處理自己的事情,翠花也在等待老王先給出結果然後再處理自己的事情,結果兩個人就這麼一致等下去,事情一直沒完成
  • 同一個文檔,小張和翠花各自更新的時候,出現相互覆蓋的情況

image.png

編碼源於生活、程式碼世界其實也處處體現著生活中的樸素哲學思維。縱然並發場景存在一些可能的隱患問題,但我們也不必因噎廢食,正所謂先了解它、再掌控它。

作為提升吞吐性能的不二良方,下面我們就一起來嘗試按照問題解決型的思路一步步推進,換個角度探討下多執行緒並發相關的內容,全面了解下多執行緒並發世界的各種關聯,進而更從容優雅的讓並發為我們所用,成為我們提升系統性能的神兵利器。

多執行緒——並發第一步

並發探險的第一關,就是如何支援並發。下面大概列舉下常見的幾種方式:

⭐️子執行緒⭐️

一些簡單的場景中,我們為了提升主執行緒的處理性能,會將過程中一些耗時操作放到一個單獨的子執行緒中進行同步處理。在程式碼中可以通過創建臨時子執行緒的方式來執行即可:

public void buyProduct() {
    int price = getPrice();
    // 子執行緒同步處理部分操作
    new Thread(this::printTicket).start();
    // 主執行緒繼續處理其它邏輯
    doOtherOperations(price);
}

⭐️執行緒池⭐️

頻繁創建執行緒、銷毀執行緒的操作屬於一種消耗性能的操作,而且創建執行緒的數量不可控。所以對於一些固定需要在子執行緒中並行處理的任務場景,我們可以通過創建執行緒池的方式,固定維護著一批可用執行緒,循環利用,去處理任務,以實現提升效率與便於管控的訴求:

private ExecutorService threadPool = Executors.newFixedThreadPool(3);

public void testReleaseThreadLocalSafely() {
    // 任務直接放到執行緒池中進行處理
    threadPool.submit(this::mockServiceOperations);
}

⭐️定時器⭐️

定時器是一種比較特殊的多執行緒並發場景,也是經常可能會被忽視掉的一種情況。定時器也是在子執行緒中執行的,多個定時器之間、定時器執行緒與主執行緒之間、定時器執行緒與業務子執行緒之間都會以多執行緒的形式並發處理。

@Scheduled(cron = "0 0/10 * * * ?")
public void syncBusinessInfo() {
    // do something here...
}

⭐️Tomcat等容器⭐️

常見的服務運行容器,比如Tomcat等,都是支援並發請求執行的。而常見的基於SpringBoot實現的服務,其service類都是由Spring進行託管單例對象。這種場景是比較常見的多執行緒場景。

改為多執行緒並發執行,雖然效率是提升了,但是問題也來了——數據執行結果不準確

結果不對,顯然是我們無法接受的。所以擺在我們面前的下一難題,就是要保證執行結果數據的準確。

synchronized與lock

在JAVA中提到執行緒同步,使用最簡單、應用頻率最高的非synchronized關鍵字莫屬了。它是 Java 內建的一種同步機制,代表了某種內在鎖定的概念,當一個執行緒對某個共享資源加鎖後,其他想要獲取共享資源的執行緒必須進行等待,synchronized 也具有互斥排他的語義。具體用法如下:

  • synchronized 修飾實例方法,相當於是對類的實例(this)進行加鎖,進入同步程式碼前需要獲得當前實例的鎖
public synchronized void test() {
    //...
}
  • synchronized 修飾程式碼塊,相當於是給對象(syncObject)進行加鎖,在進入程式碼塊前需要先獲得對象的鎖
public void test() {
    synchronized(syncObject) {
        //允許訪問控制的程式碼   
    }
    // 其它操作
}
  • synchronized 修飾靜態方法,相當於是對LockTest.class)進行加鎖
public class LockTest {
    public synchronized static void test() {
        //...
    }
}

對於被鎖的目標對象而言,鎖是具有排他性的,也就是同一個對象上的多個帶鎖方法,同一時刻只有1個執行緒可以搶到鎖,其餘都會被阻塞住。比如下面的程式碼,執行緒A和執行緒B分別同時請求method1method2,雖然調用的是不同的方法,但是兩個執行緒其實是在爭奪同一把鎖

public class LockTest {
    public synchronized void method1() {
        // ...   
    }
    public synchronized void method2() {
        // ...
    }
}

image.png

由於synchronized屬於JVM關鍵字,屬於一種比較重量級的鎖。在JDK中還提供了個Lock類,提供了眾多不同類型的鎖,供各種不同場景訴求使用。

public void test() {
    Lock lock = ...;
    lock.lock();
    try{
         // ...
    }catch(Exception ex){
         // ...
    }finally{
        // ...
        lock.unlock(); 
    }
}

synchronized不同,使用Lock的時候需要特別注意最後一定要可靠的釋放掉佔用的鎖

到這裡,再測試會發現,多執行緒並發執行,數據結果也對,似乎是沒什麼問題——但是這樣真的就結束了嗎?

如果並發編程僅僅就這麼點內容,那顯然對不上它在編碼界的地位。我們接著往下看。

死鎖——不期而遇的小驚嚇

經過前面的內容,我們知道了使用多執行緒的方式來實現並發處理,也知曉了可以通過加鎖的方式來保證對共享數據編輯的順序性與準確性。而加了鎖之後稍不留神間,也許就會出現死鎖

image.png

一個執行緒A已經持有一個鎖的情況下同時又去請求調用另一個加鎖的對象或者程式碼塊,而這個被請求的對象又被另一個執行緒B所持有,而這個執行緒B,又恰好在等待此時被執行緒A所持有的加鎖資源或程式碼塊,於是兩個執行緒都在沉默中無限等待下去,便會出現死鎖

看一個實際業務場景:

一個運維管理系統,用於維護虛擬機資源以及部署的業務進程資訊,且支援按照虛擬機維度和業務進程維度進行分別查看相關資訊。即:

  1. 查看虛擬機VM資訊,需要一併獲取到上面部署的Process資訊
  2. 查看Process資訊,需要一併獲取其所位於的虛擬機的資訊。

假定基於SpringBoot框架進行程式碼實現,DeployedProcessServiceVmService實例由Spring框架進行託管,為單例對象,然後彼此自動注入對方實例。假定由於業務邏輯需要,對兩個服務類的執行方法進行了加鎖處理。部署進程管理服務DEMO程式碼如下:

@Service
public class DeployedProcessService {
    @Autowired
    VmService vmService;
    
    public synchronized void manageDeployedProcessInfo() {
        // 獲取進程資訊
        collectProcessInfo();
        // 獲取進程所在VM資訊
        vmService.manageVmInfo(this);
    }    
}
@Service
public class VmService {
    @Autowired
    DeployedProcessService deployedProcessService;

    public synchronized void manageVmInfo() {
        // 獲取此VM基礎資訊
        collectVmBasicInfo();
        // 獲取此VM上已部署的進程資訊
        deployedProcessService.manageDeployedProcessInfo(this);
    }
}

我們使用兩個獨立進程同時分別去查詢VM資訊以及Process資訊,模擬並發操作的場景,會發現永遠等不到結果。為啥呀?因為死鎖了!

我們可以通過jstack命令來看下此時的JVM內執行緒堆棧情況,會發現有提示Found one Java-level deadlock,然後可以看到死鎖的堆棧:

Found one Java-level deadlock:
=============================
"ForkJoinPool.commonPool-worker-2":
  waiting to lock monitor 0x000000001cf532b8 (object 0x000000076c29bf28, a com.veezean.skills.lock.VmService),
  which is held by "ForkJoinPool.commonPool-worker-1"
"ForkJoinPool.commonPool-worker-1":
  waiting to lock monitor 0x000000001fce9f88 (object 0x000000076c29f460, a com.veezean.skills.lock.DeployedProcessService),
  which is held by "ForkJoinPool.commonPool-worker-2"

Java stack information for the threads listed above:
===================================================
"ForkJoinPool.commonPool-worker-2":
        at com.veezean.skills.lock.VmService.manageVmInfo(VmService.java:14)
        - waiting to lock <0x000000076c29bf28> (a com.veezean.skills.lock.VmService)
        at com.veezean.skills.lock.DeployedProcessService.manageDeployedProcessInfo(DeployedProcessService.java:19)
        - locked <0x000000076c29f460> (a com.veezean.skills.lock.DeployedProcessService)
        at com.veezean.skills.lock.Main.lambda$main$1(Main.java:19)
"ForkJoinPool.commonPool-worker-1":
        at com.veezean.skills.lock.DeployedProcessService.manageDeployedProcessInfo(DeployedProcessService.java:15)
        - waiting to lock <0x000000076c29f460> (a com.veezean.skills.lock.DeployedProcessService)
        at com.veezean.skills.lock.VmService.manageVmInfo(VmService.java:18)
        - locked <0x000000076c29bf28> (a com.veezean.skills.lock.VmService)
        at com.veezean.skills.lock.Main.lambda$main$0(Main.java:18)

Found 1 deadlock.

關於死鎖的產生原因,網上或者書中給出的答案無外乎就是說如下四個原因要同時成立,就會死鎖:

  1. 互斥
  2. 佔有並等待
  3. 非搶佔
  4. 循環等待

不知道大家看到上面這個解釋是啥感覺?懂還是不懂?反正我的經歷是:在我懂之後,看這4點中的每一點都很在理;而我不懂時,我依舊不知道啥原因導致的死鎖。其實,用白話解釋死鎖的產生原因,就是兩個或者多個執行緒各自拿到了一個鎖,然後自己依賴別人的鎖,別人依賴自己的鎖,然後彼此都在相互等待,永遠沒有辦法等到。

那麼應該如何解決呢?

還是以上面程式碼為例,一個最簡單的方式,就是兩個Service類的加鎖方法不要相互調用,各自Service類中獨立實現所有邏輯即可。

小提示:

一個好的經驗,就是加鎖的方法嵌套調用另一個加鎖的方法時,多留個心眼,看看會不會出現相互依賴或者循環依賴的情況。

鎖優化思想——降低鎖的影響

規避了可能存在的死鎖問題之後,另一個問題又出現在我們面前——性能。我們採用多執行緒並發編程的初衷,是為了儘可能的提升整體的處理性能,但是加鎖之後,加鎖的地方反而成為了整個並發處理的一個堵點,導致整個多執行緒並發的效果大打折扣。

image.png

所以,如何降低鎖對多執行緒並發處理的影響,成為飄在程式設計師面前的一團新的烏雲。為此也衍生出了多種處理與應對策略,比如_降低鎖的範圍以減少鎖持有時間_、縮小鎖粒度以降低奪鎖競爭、_利用讀寫鎖減少加鎖場景_等等。

降低鎖範圍

這個其實很好理解,因為加鎖範圍越大,意味著持鎖執行的時間就會越久,那麼其他執行緒阻塞等待的時間就會越久,這樣整個系統的堵點就會越發明顯。而如果能夠將一些並不需要放到同步鎖內執行的邏輯放到外部去並行執行,這樣就會降低鎖內邏輯的處理時長,其餘執行緒阻塞等待時間也就會縮短。

image.png

舉個例子。假如現在有個更新文章內容的需求,其處理邏輯如下:

  1. 校驗當前用戶是否有權更新
  2. 校驗文章內容重複度
  3. 檢查文章中是否有違禁詞
  4. 更新到資料庫中
  5. 載入到ES中

為了保證並發更新操作的準確性,對方法添加synchronized同步鎖,保證多執行緒順序執行:

public synchronized void updateArticle() {
    verifyAuthorInfo();
    checkArticleDuplication();
    checkBlackWords();
    saveToDb();
    loadToEs();
}

但是實際分析下,其實幾個操作其實只有一個環節需要做同步鎖處理,其餘的操作其實並不會有任何的同步問題,因此我們按照縮小鎖範圍的優化策略,可以將synchronized鎖範圍縮小:

public void updateArticle() {
    verifyAuthorInfo();
    checkArticleDuplication();
    checkBlackWords();
    saveToDb();
    loadToEs();
}

private synchronized void saveToDb() {
    // ...
}

縮小鎖粒度

鎖的粒度越大,多執行緒請求的時候對鎖的競爭壓力越大,對性能的影響越大。如果將鎖的粒度拆分小一些,這樣同時請求到同一把鎖的概率就會降低,這樣執行緒間爭奪鎖的競爭壓力就會降低。

可以看下下面的示意圖,4個執行緒請求同一鎖時,其中1個執行緒可以搶到鎖,其餘三個執行緒將處於等待;而將鎖拆分為3個子鎖的時候,這樣4個執行緒中只有1個執行緒處於等待:

image.png

上面演示的就是分段鎖的概念。在JAVA7之前,面試的時候經常會遇到的一個問題就是ConcurrentHashMapHashTable都是執行緒安全的,為啥ConcurrentHashMap的性能上會更好些呢?其實就是因為ConcurrentHashMap使用了分段鎖Segment)的方式實現的:

image.png

⭐️補充一下⭐️

上面為啥要強調是JAVA7之前呢?因為JAVA7開始,ConcurrentHashMap的執行緒安全策略變了,改為了基於CAS的策略了

細化鎖場景

對於同一個共享數據的各種操作,很多時候並不是所有多執行緒操作都會出數據錯亂問題,一般情況下只有寫操作才會改變數據的內容,而多個執行緒同時執行讀取操作的時候並不會對數據產生影響,所以這個_讀取的場景其實無需和寫操作使用相同的同步鎖邏輯_。所以為了滿足此場景,出現了讀寫鎖

image.png

讀寫鎖的特點就是,針對讀操作和寫操作,提供了不同的加鎖同步策略,具體而言:

  1. 讀讀不互斥
  2. 讀寫互斥
  3. 寫寫互斥

在 Java 中,讀寫鎖是使用 ReentrantReadWriteLock 類來實現的,其中:

  • **ReentrantReadWriteLock.ReadLock **表示讀鎖,它提供了 lock 方法進行加鎖、unlock 方法進行解鎖。
  • **ReentrantReadWriteLock.WriteLock **表示寫鎖,它提供了 lock 方法進行加鎖、unlock 方法進行解鎖。

程式碼示例如下。 創建讀寫鎖,然後通過readLock和writeLock方法,可以分別獲取到讀鎖和寫鎖:

// 創建讀寫鎖
final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
// 獲得讀鎖
final ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();
// 獲得寫鎖
final ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();

在讀取操作的場景,直接使用讀鎖,使用完成後需要可靠釋放鎖:

public String readObject() {
    // 讀鎖使用
    readLock.lock();
    try {
        // 業務程式碼...
    } finally {
        readLock.unlock();
    }
}

在寫操作的場景使用寫鎖,使用完成後同樣需要可靠釋放鎖:

public void writeObject() {
    // 寫鎖使用
    writeLock.lock();
    try {
        // 業務程式碼...
    } finally {
        writeLock.unlock();
    }
}

其它策略

除了上述介紹的各種鎖優化策略,還有很多不同類型的鎖,整體思路大體相同,此處不再展開描述,具體可以參見這篇文章:《不可不說的JAVA「鎖」事》:

image.png

無鎖勝有鎖——就是要站著還把錢掙了

為了保證多執行緒的數據安全,我們引入了同步鎖;為了降低同步鎖的影響,我們絞盡腦汁去降低鎖競爭幾率。但是勤勞的程式設計師永遠不會滿足眼前的結果、不然頭頂也不會這麼早的鋥光瓦亮。於是,一個靈魂拷問又飄了出來:能不能既使用多執行緒並發處理、又不用加同步鎖?

image.png

於是乎,一些無鎖解決方案開始在某些特定的並發場景內嶄露頭角。

ThreadLocal空間換時間

很多時候,編碼世界匯總對程式性能的優化,無外乎是時間與空間的權衡。當系統更關注服務的處理響應時長,就會使用一些快取的策略,降低CPU的重複計算,以此來提升性能。

回到我們多執行緒的場景,為了保證多個執行緒對同一個共享記憶體對象的訪問安全,所以通過同步鎖的方式來保證串列訪問,這樣就會造成CPU的排隊等待,性能受阻。那麼,如果各個記憶體不去訪問這個統一的共享對象,而是訪問自己獨享的對象,這樣不就互不干擾、無需阻塞等待了嗎?

比如下面圖中的收費站場景,多條路最後需要經由同一個收費站,所以導致收費站這裡會出現堵塞。而如果每條路都建一個自己的收費站,這樣就有效避免了堵塞的狀況。

image.png

image.png

仿照相同的原理,ThreadLocal便出現了。它通過冗餘副本的方式,使得某個記憶體共享對象在各個執行緒上都有自己的拷貝副本。在嘗試去了解ThreadLocal結構與原理前,可以先看下ThreadLocalset方法實現源碼:

public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}

翻譯成白話文,先獲取到當前執行緒資訊,然後獲取到當前執行緒對應的ThreadLocalMap對象,然後將當前對象以及要存儲的內容值存到Map中。也就是說:ThreadLocal只是一個方法封裝,具體的數據實際存儲在ThreadLocalMap中,而這個ThreadLocalMap每個執行緒都有自己專屬副本,裡面存儲著這個執行緒執行過程中使用的所有ThreadLocal對象以及對應數值(程式碼裡面可能會new多個不同的ThreadLocal對象,比如有的用於存儲當前用戶,有的用於存儲當前token資訊之類的)。

image.png

從ThreadLocal的實現原理中,我們可以發現_其適用的場景是有限的_,即只適用於需要在單個執行緒內全局共享的場景,而不適用於需要在多個執行緒間做數據交互共享的場景

⭐️適用場景舉例⭐️

一個SpringBoot構建的後端服務系統,對外以Controller方式提供諸多Restful介面方法供客戶端調用。客戶端調用的時候會攜帶token資訊,然後鑒權邏輯中根據token獲取到具體用戶資訊並快取到記憶體中,後續的業務處理邏輯中有多處會需要獲取該用戶資訊。

這是ThreadLocal使用的一個典型場景,在通過token鑒權完成後,將用戶資訊設置到ThreadLocal對象中,這樣後續所有需要用的地方,直接從ThreadLocal中獲取就行了。

為了方便後續使用,我們先封裝一個工具類,提供些靜態方法,便於對ThreadLocal進行操作:

public class CurrentUserHolder{
    private static final ThreadLocal<UserDetail> CURRENT_USER = ThreadLocal.withInitial(() -> null);
    
    public static void cacheUserDetail(UserDetail userDetail) {
        CURRENT_USER.set(userDetail);
    }
    
    public static UserDetail getCurrentUser() {
        CURRENT_USER.get();
    }
    
    public static void clearCache() {
        CURRENT_USER.remove();
    }
}

在業務處理開始之前先統一設置下用戶的快取資訊。因為是基於SpringBoot項目來講解,所以我們實現一個HandlerInyerceptor的實現類,並在preHandle方法中根據token獲取到用戶詳情並快取到ThreadLocal中:

public class AuthorityInterceptor implements HandlerInterceptor {
    private static final ThreadLocal<UserDetail> CURRENT_USER = ThreadLocal.withInitial(() -> null);
    
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        log.info("request IN, url: {}", request.getRequestURI());
        try {
            UserDetail userDetail = userAuthService.authUser(request.getHeader("token"));
            // 校驗通過,快取用戶資訊
            CurrentUserHolder.cacheUserDetail(userDetail);
            return true;
        } catch (Exception e) {
            // 校驗沒通過,清理執行緒數據
            CurrentUserHolder.clearCache();
            return false;
        }
    }
}

因為鑒權通過之後,會將當前的用戶資訊添加到快取中,並進入到後續的業務實際處理程式碼中,所以業務處理的時候如果需要獲取當前登錄用戶資訊的時候,可以直接從CurrentUserHolder中獲取即可。

public void collectBookToMySpace(Book book) {
    UserDetail user = CurrentUserHolder.getCurrentUser();
    // 其他邏輯省略
}

藉助ThreadLocal可以讓我們實現在執行緒內部共享對象,以此規避多執行緒間的同步等待處理,但是使用完畢之後,還需要保證清除掉當前執行緒的快取數據值。為什麼要這麼做呢?拿執行緒池舉個例子:

既然是為每個執行緒拷貝一份獨立的副本,對於同一個執行緒而言拿到的數據是同一個,那麼對於使用執行緒池來處理多任務的場景,執行緒都是重複利用的,這樣會導致同一個執行緒中正在處理的任務可能會拿到上一個任務設置的共享值。對於業務處理而言可能會得到非預期結果。

當然,除了可能會導致業務處理的時候前後任務快取數據錯亂,使用完畢不清理快取,有些時候還容易導致記憶體泄漏的問題。所以編碼的時候、尤其涉及記憶體資源使用的時候,用完回收始終會是一個好習慣。

⭐️可靠清除執行緒副本⭐️

既然知道在使用完成之後需要可靠的清理掉當前執行緒的ThreadLocal副本數據,但是對於一些流程比較長、或者邏輯比較複雜的系統,其執行緒任務的退出分支可能有很多條,那麼怎麼樣才能做到可靠清理、避免有分支遺漏呢?

  1. 如果是自己實現的執行緒池或者執行緒分發操作,在子執行緒的調用頂層位置通過try...finally...包裹調用邏輯,並在finally中進行釋放操作。
public void testReleaseThreadLocalSafely() {
    threadPool.submit(() -> {
        try {
            // 設置token資訊
            TOKEN.set("123456");
            // 執行業務處理操作
            mockServiceOperations();
        } finally {
            // finally分支中可靠清除當前執行緒的ThreadLocal副本
            TOKEN.remove();
        }
    });
}
  1. 基於一些框架系統實現的場景,比如SpringBoot項目,可以訂製個Interceptor並在afterCompletion退出前回調方法中,添加上對應的清理邏輯。
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, @Nullable Exception ex) {
    CurrentUserHolder.clearCurrentThreadCache();
}

volatile保證可見性

synchronized保證數據同步處理的原理不一樣,volatile主要解決的是數據在多執行緒之間的可見性問題,但是不保證數據操作的原子性。volatile用於修飾變數,可以保證每個共享此變數數據的執行緒都可以第一時間拿到此值的真實值。

image.png

當把變數聲明為volatile類型後,編譯器與運行時都會注意到這個變數是共享的,因此不會將該變數上的操作與其他記憶體操作一起重排序。volatile變數不會被快取在暫存器或者對其他處理器不可見的地方,因此在讀取volatile類型的變數時總會返回最新寫入的值

但是因為它不保證原子操作,所以如果有多個執行緒同時來修改變數的值時,還是可能會出現問題。所以,volatile適合那種單個執行緒去修改值內容,但是多個執行緒會共享讀取變數結果的場景。

image.png

比如項目程式碼中,我們需要支援系統配置屬性的動態變更,我們可以將系統參數使用volatile修飾,然後使用固定一個執行緒進行系統屬性值的維護,其餘業務執行緒負責從記憶體中讀取即可。

發布訂閱模式

在並發編程中使用發布訂閱模式能夠解決絕大多數並發問題。

多執行緒協同配置的場景下,可以藉助MQ實現發布訂閱模式,可以保證每個任務都分配給不同的消費者進行處理,這樣就不會出現重複處理的問題、也減少了執行緒或者進程間資源爭奪的風險,正可謂是「無鎖勝有鎖、四兩撥千斤」的典型。

image.png

對於MQ的選型,如果是單進程內多執行緒間的使用,可以使用BlockingQueue來實現,而用於分散式系統內時,可以選用一些消息隊列中間件,比如RabbitMQKakfa等。

CAS樂觀鎖策略

所謂CAS,也即Compare And Swap,也即在對數據執行寫操作前,先比較下數據是否有變更,沒有變更的情況下才去執行寫操作,否則重新讀取最新記錄並重新執行計算後,再執行比對操作,直到數據寫入完成。CAS是一種典型的樂觀鎖策略,其與常規的加同步鎖的處理策略有很大的不同,屬於一種比較經典的無鎖機制

image.png

並發場景對公共存儲(比如MySQL)中的數據進行更新的時候,經常會需要考慮並發更新某個記錄的情況,尤其是一些介面編輯更新的場景更是常見。這個場景下使用CAS機制可以有效解決問題。

先看個問題場景:

有個需求任務跟蹤管理系統,團隊內的成員可以編輯團隊內的待辦需求事項的進展描述,如果團隊內有兩個人都打開了某一個需求頁面進行編輯進展說明,那麼第一個人改動完成存儲的內容,會被第二個人保存改動時直接覆蓋掉。

image.png

使用CAS的思路來解決上面場景提及的更新覆蓋問題,我們可以對DB中的記錄數據增加一個version欄位,更新的時候必須保證version欄位值與自己最初拿到的version值一致時才能更新成功,同時在每次update的時候更新下version欄位,這樣問題就解決啦,看下過程:

image.png

程式碼實現起來也很簡單:

public void updateItem(Item  item) {
    int updateResult = updateContentByIdAndVersion(item.getContent(), item.getId(), item.getVersion());
    if (updateResult == 0) {
        // 沒有更新成功任何記錄,說明version比對失敗已經有別人更新了
        // 要麼放棄處理,要麼重試
    }
}

CAS始終按照無鎖的策略進行數據的處理、處理失敗則重試或放棄。在競爭不是很激烈的並發場景下,可以有效的提升整體的處理效率,因為大部分的場景下都會執行成功,只有在少量的請求出現並發衝突的時候,才會進入自旋重試。但當競爭很激烈的場景下,會導致寫入操作高頻率失敗進入自旋,這要會大大的浪費CPU資源,且因為自旋其實就是執行緒不停的循環,所以大量自旋可能會使得CPU的佔用比較高。

image.png

補充說明

在單進程內的多執行緒間使用CAS機制保證並發的時候,需要結合volatile一起使用,以此來保證原子性與可見性。

另外,我們在前面有提到JAVA7之前ConcurrentHashMap使用的是分段鎖的技術,而從JAVA7之後ConcurrentHashMap執行緒安全保護的實現邏輯是改為了CAS+synchronized的方式來實現,以此來獲取更好的性表現。

分散式鎖——跨越進程的相逢

前面介紹了單進程內的一些多執行緒高並發場景的應對方案。但高並發的場景,除了單執行緒內的多執行緒間的並發之外,還有分散式系統集群內的多個進程之間的並發。所以分散式鎖應運而生。

舉個例子:

資料庫有一張「熱議話題」表,表中每條記錄有個「當前熱度」欄位,熱度計算服務需要每隔5分鐘執行一次計算,然後更新表中每條記錄的熱度欄位。

為了保證系統的高可用,熱度計算服務部署了多個進程節點,由定時器觸發,每隔5分鐘計算一次。

image.png

分散式鎖的實現,有多種方式,比較常見的是基於Redis或者MySQL來實現。分散式鎖在實現以及使用的時候,需要關注幾個要點;

  • 客戶端請求鎖的整體操作需要是個原子操作,即需要保證鎖分配結果的唯一性
  • 客戶端獲取到鎖之後進行自身業務邏輯處理,處理完成之後必須要主動釋放鎖需要注意判斷下是否是自己所持有的鎖
  • 鎖要有兜底退出機制,防止某個客戶端獲取到鎖之後出現宕機等異常情況,導致鎖被持有後無法釋放,其它客戶端也無法繼續申請

image.png

比如基於Redis實現分散式鎖的時候,使用示意如下:

// 獲取鎖
public boolean accuireLock(String lockName) {
    return stringRedisTemplate.opsForValue().setIfAbsent(lockName, "", 1L, TimeUnit.MINUTES);
}

// 釋放鎖
public void releaseLock(String lockName) {
    stringRedisTemplate.delete(lockName);
}

上面程式碼中,簡單的使用setNx命令來實現分散式鎖的申請,又設置了redis超時時間,一旦在設定的時間內依舊沒有主動釋放鎖,則redis將主動釋放鎖,供其餘客戶端再來請求。

在上面歸納的分散式鎖實現與使用的注意要點中,在提及業務處理完成之後要主動釋放鎖的時候,有特別補充了一個要求:需要判斷下是否是自己的鎖,只能釋放自己的鎖!為什麼一定要強調這一點呢?以上述程式碼為例,看一種可能的情況:

image.png

從上圖可以看出,Client-1申請到了_鎖1_,但是Client-1執行超時導致_鎖1_被強制釋放掉了,而Client-2隨後獲取到了_鎖2_並開始執行處理邏輯。此時Client-1的任務終於執行完成了,然後去釋放了鎖(Client-1自己不知道自己超時,還是按照正常邏輯去釋放鎖),結果_Client-3_此時又申請到了_鎖3_,然後開始執行自己的任務。這個時候就會出現了Client-2Client-3同時執行的異常情況了。

整個問題出現的原因就是釋放鎖的時候沒有校驗是否是自己的鎖,所以出現了越權釋放了別人的鎖的情況。為了避免此情況的發生,我們對前面的分散式鎖實現使用邏輯稍加改動即可:

首先是申請分散式鎖的時候,可以生成個隨機UUID作為鎖的value值,如果申請成功,則直接返回此鎖的UUID唯一標識:

/**
 * 獲取鎖,如果獲取成功,則返回鎖的value值(UUID隨機)
 */
public String accuireLock(String lockName) {
    String uuid = UUID.randomUUID().toString();
    Boolean result = stringRedisTemplate.opsForValue().setIfAbsent(lockName, uuid, 1L,
            TimeUnit.MINUTES);
    if (result == null || !result) {
        throw new RuntimeException("獲取鎖失敗");
    }
    return uuid;
}

鎖釋放的時候,需要同時提供鎖名稱與鎖的唯一UUID標識值,先根據鎖名稱嘗試獲取下已存在的鎖,然後比對下鎖value值是否一致,如果一致,則表名當前的鎖是自己鎖持有的這把鎖,然後將其釋放即可:

/**
 * 釋放鎖,先比對鎖value一致,才會釋放
 */
public void releaseLock(String lockName, String lockUuid) {
    String lockValue = stringRedisTemplate.opsForValue().get(lockName);
    if (!StringUtils.equals(lockValue, lockUuid)) {
        throw new RuntimeException("鎖釋放失敗,鎖不存在");
    }
    stringRedisTemplate.delete(lockName);
}

當然啦,我們這裡舉例是使用的Redis的setNx命令來實現的,此實現可以輕鬆的應對大部分的使用場景。但是,上述的釋放鎖實現程式碼中可以看出,由於獲取鎖內容、比對鎖內容、釋放鎖內容三個操作是獨立分開的,存在無法保證操作原子性的弊端。如果項目的要求級別較高,可以考慮使用LUA腳本封裝為原子命令操作來解決,或者使用redis官方提供的redission來實現。

補充:並發與並行

本文主要討論了多執行緒並發編程相關的內容,提到並發,往往還有個容易混淆的概念,叫並行。關於並行的具體介紹與實現策略,以及並發與並行的詳細區別,可以參見我的另一個文檔《JAVA基於CompletableFuture的流水線並行處理深度實踐,滿滿乾貨》,此處不述。

綜合而言:

  1. 如果業務處理邏輯是CPU密集型的操作,優先使用基於執行緒池實現並發處理方案(可以避免執行緒間切換導致的系統性能浪費)。
  2. 如果業務處理邏輯中存在較多需要阻塞等待的耗時場景、且相互之間沒有依賴,比如本地IO操作、網路IO請求等等,這種情況優先選擇使用並行處理策略(可以避免寶貴的執行緒資源被阻塞等待)。

總結

好啦,關於多執行緒並發場景常見問題的相關應對策略,這裡就探討到這裡啦。那麼看到這裡,相信您應該有所收穫吧?那麼你是否有實際應對過多執行緒並發場景的開發呢?那你是如何處理的呢?是否有發現過什麼問題呢?評論區一起討論下吧、我會認真對待您的每一個評論~~

此外

我是悟道,聊技術、又不僅僅聊技術~

如果覺得有用,請點贊 + 關注讓我感受到您的支援。也可以關注下我的公眾號【架構悟道】,獲取更及時的更新。

期待與你一起探討,一起成長為更好的自己。