主頁 > 軟體設計 > 深入理解Java并發框架AQS系列(五):條件佇列(Condition)

深入理解Java并發框架AQS系列(五):條件佇列(Condition)

2021-04-29 09:10:40 軟體設計

深入理解Java并發框架AQS系列(一):執行緒
深入理解Java并發框架AQS系列(二):AQS框架簡介及鎖概念
深入理解Java并發框架AQS系列(三):獨占鎖(Exclusive Lock)
深入理解Java并發框架AQS系列(四):共享鎖(Shared Lock)
深入理解Java并發框架AQS系列(五):條件佇列(Condition)

一、前言

AQS中的條件佇列相比較前文中的“獨占鎖”、“共享鎖”等比較獨立,即便沒有條件佇列也絲毫不影響諸如ReentrantLockSemaphore類的實作,那如此說來條件佇列是否就是一個可有可無的產物?答案是否定的,我們來看下直接或間接用到條件佇列的JDK并發類:

  • ReentrantLock 獨占鎖經典類
  • ReentrantReadWriteLock 讀寫鎖
  • ArrayBlockingQueue 基于陣列的阻塞佇列
  • CyclicBarrier 回圈柵欄,解決執行緒同步問題
  • DelayQueue 延時佇列
  • LinkedBlockingDeque 雙向阻塞佇列
  • PriorityBlockingQueue 支持優先級的無界阻塞佇列
  • ThreadPoolExecutor 執行緒池構造器
  • ScheduledThreadPoolExecutor 可基于時間調度的執行緒池構造器
  • StampedLock 郵戳鎖,1.8后引入,更高效的讀寫鎖

如此豪華的陣容,可見Condition的地位不可小覷

我們簡單描述下條件佇列實作的功能:有3個執行緒A、B、C,分別呼叫wait/await方法后,執行緒進入阻塞,在沒有其他執行緒去喚醒的情況下,3個執行緒將永遠處于阻塞狀態,此時如果有另外執行緒呼叫notify/signal,那么A、B、C執行緒中的某一個將被激活(根據其進入條件佇列的順序而定),從而執行后續的邏輯;如果呼叫notifyAll/signalAll的話,那么3個執行緒都將被激活,這可能是我們對條件佇列的簡單認識,這樣的描述是否準確呢?可能不太嚴謹,我們引入JDK的條件佇列來做說明

統一話術:其實語法層面支持的wait/notify與AQS都屬于JDK的范疇,但為了區分兩者,我們定義如下:

  • JDK條件佇列:語法層面提供支持的wait/notify,即Object類中的wait()/notify()方法
  • AQS條件佇列:AQS提供的條件佇列,即AQS內部的ConditionObject

二、JDK中的條件佇列(wait/notify)

眾所周知,在JDK中,wait/notify/notifyAll是根物件Object中內置的方法,且方法均被定義為native本地方法

// 等待
public final native void wait(long timeout) throws InterruptedException;
// 喚醒
public final native void notify();
// 喚醒所有等待執行緒
public final native void notifyAll();

2.1、wait

// 步驟1
synchronized (obj) {
  // 步驟2
  before();
  // 步驟3
  obj.wait();
  // 步驟4
  after();
}

相信大家對上述代碼并不陌生,我們將JDK的條件佇列抽象為4步,逐一闡述

  • 步驟1: synchronized (obj)
    • 在jdk中如果想呼叫Object.wait()方法,必須首先獲取該物件的synchronized鎖,當前步驟,如果成功獲取到鎖,那么將進入“步驟2”,如果存在并發,當前執行緒將會進入阻塞(執行緒狀態為BLOCKED),知道獲取到鎖為止
  • 步驟2: before()
    • 我們知道synchronized是獨占鎖,所以在執行步驟2代碼時,程式是不存在并發的,即同一時刻,只有一個執行緒正在執行,此處也相對好理解
  • 步驟3: obj.wait()
    • 此步驟是將當前執行緒放入條件佇列,同時釋放obj的同步鎖,此處跟我們對synchronized的認知有悖,我們一般認為synchronized (obj) {......}在大括號中的代碼會一直持有鎖,而事實情況卻是,當程式執行wait()方法時,會釋放obj的同步鎖
  • 步驟4: after()
    • 此步驟是并發執行還是串行執行?假設我們現在有3個執行緒A、B、C都已經執行完畢wait()方法,并進入了條件佇列,等待其他執行緒喚醒;此時另外一個執行緒執行了notifyAll()時,后續的激活流程是怎么樣的?
      • 錯誤觀點:有很多同學直觀感受是,執行緒A、B、C同時被激活,所以步驟4是并發執行的;就像是百米賽跑,所有同學都準備就緒(wait),一聲槍響后(notifyAll),所有人開始賽跑,并跑到終點(步驟4
      • 正確觀點:其實“步驟4”是串行執行的,大家再檢查下代碼后便可發現,“步驟4”處于synchronized的大括號之間;還是拿上述賽跑舉例,如果認為從聽到槍響至跑到終點是“步驟4”的話,那真實的場景應該是這樣的:一聲槍響后,A起跑,B、C原地不動;A跑到終點后,B開始起跑,C原地不動;最后是C跑到終點

由此我們斷定,obj.wait()雖然是native方法,但其內部經歷了釋放鎖、重新搶鎖的兩個大環節

2.2、notify

synchronized (obj) {
  obj.notify();
  // obj.notifyAll();
}

所有因obj.wait()阻塞的執行緒,都要通過notify來喚醒

  • notify() 喚醒條件佇列中,隊首節點
  • notifyAll() 喚醒條件佇列中所有節點

三、AQS中的條件佇列(await/signal)

我們初看AQS中的條件佇列時,發現其提供了與JDK條件佇列幾乎一致的功能

JDK AQS
wait await
notify singal
notifyAll singalAll

用法上也及其相似:

await

// 初始化
Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();
try {
  lock.lock();
  condition.await();
} catch (InterruptedException e) {
  e.printStackTrace();
} finally {
  lock.unlock();
}

singal

Lock lock = new ReentrantLock();
Condition condition = lock.newCondition();
try {
  lock.lock();
  condition.signal();
} finally {
  lock.unlock();
}

3.1、條件佇列

我們知道在AQS內部維護了一個阻塞佇列,資料結構如下:

阻塞佇列FIFO資料結構

上圖描述的是一個長度為 3 的FIFO阻塞佇列,因為頭結點常駐記憶體,所以不算在內;我們可以發現阻塞佇列中每個節點都包含了前、后參考

那AQS內部的另一個條件佇列又是什么樣的資料結構呢?

條件佇列資料結構

可見,條件佇列為單向串列,只有指向下一個節點的參考;沒有被喚醒的節點全部存盤在條件佇列上,上圖描述的是一個長度為 5 的條件佇列,即有5個執行緒執行了await()方法;與阻塞佇列不同,條件佇列沒有常駐記憶體的“head結點”,且一個處于正常狀態節點的waitStatus -2 ,當有新節點加入時,將會追加至佇列尾部

3.2、喚醒

當我們呼叫signal()方法時,會發生什么?我們還是拿長度為 5 的條件佇列舉例說明,在AQS內部會經歷佇列轉移,即由條件佇列轉移至阻塞佇列

signal條件佇列向阻塞佇列轉移

signalAll()執行時,具體執行流程與signal()類似,即會將條件佇列中的所有節點全部轉移至阻塞佇列(并發度為1,按順序依次激活)中,依靠阻塞佇列自身依次喚醒的機制,達到激活所有執行緒的目的

四、JDK vs AQS

經過上文的介紹,似乎AQS做了與wait/notify相同的功能,相比較而言,甚至JDK的寫法更簡潔;那他們在性能上的表現如何呢?讓我們來做個對比

4.1、對比

我們模擬這樣的一個場景:啟動10個執行緒,分別呼叫wait()方法,當所有執行緒都進入阻塞后,呼叫notifyAll(),10個執行緒均被喚醒并執行完畢后,方法結束, 上述方法執行10000次,對比JDK與AQS耗時

JDK測驗代碼:

public class ConditionCompareTest {

  @Test
  public void runTest() throws InterruptedException {
    long begin = System.currentTimeMillis();
    for (int i = 0; i < 10000; i++) {
      if (i % 1000 == 0) {
        System.out.println(i);
      }
      jdkTest();
    }
    long cost = System.currentTimeMillis() - begin;
    System.out.println("耗時: " + cost);
  }
  
  public void jdkTest() throws InterruptedException {
    Object lock = new Object();
    List<Thread> list = Lists.newArrayList();
    // 步驟一:啟動10個執行緒,并進入wait等待
    for (int i = 0; i < 10; i++) {
      Thread thread = new Thread(() -> {
        try {
          synchronized (lock) {
            lock.wait();
          }
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
      });
      thread.start();
      list.add(thread);
    }

    // 步驟二:等待10個執行緒全部進入wait方法
    while (true) {
      boolean allWaiting = true;
      for (Thread thread : list) {
        if (thread.getState() != Thread.State.WAITING) {
          allWaiting = false;
          break;
        }
      }
      if (allWaiting) {
        break;
      }
    }

    // 步驟三:喚醒10個執行緒
    synchronized (lock) {
      lock.notifyAll();
    }

    // 步驟四:等待10個執行緒全部執行完畢
    for (Thread thread : list) {
      thread.join();
    }
  }
}

AQS測驗代碼:

public class ConditionCompareTest {
  private ReentrantLock lock = new ReentrantLock();
  private Condition condition = lock.newCondition();

  @Test
  public void runTest() throws InterruptedException {
    long begin = System.currentTimeMillis();
    for (int i = 0; i < 10000; i++) {
      if (i % 1000 == 0) {
        System.out.println(i);
      }
      aqsTest();
    }
    long cost = System.currentTimeMillis() - begin;
    System.out.println("耗時: " + cost);
  }

  @Test
  public void aqsTest() throws InterruptedException {
    AtomicInteger lockedNum = new AtomicInteger();
    List<Thread> list = Lists.newArrayList();
    // 步驟一:啟動10個執行緒,并進入wait等待
    for (int i = 0; i < 10; i++) {
      Thread thread = new Thread(() -> {
        try {
          lock.lock();
          lockedNum.incrementAndGet();
          condition.await();
          lock.unlock();
        } catch (InterruptedException e) {
          e.printStackTrace();
        }
      });
      thread.start();
      list.add(thread);
    }

    // 步驟二:等待10個執行緒全部進入wait方法
    while (true) {
      if (lockedNum.get() != 10) {
        continue;
      }
      boolean allWaiting = true;
      for (Thread thread : list) {
        if (thread.getState() != Thread.State.WAITING) {
          allWaiting = false;
          break;
        }
      }
      if (allWaiting) {
        break;
      }
    }

    // 步驟三:喚醒10個執行緒
    lock.lock();
    condition.signalAll();
    lock.unlock();

    // 步驟四:等待10個執行緒全部執行完畢
    for (Thread thread : list) {
      thread.join();
    }
  }
}
條件佇列 耗時1 耗時2 耗時3 耗時4 耗時5 平均耗時(ms)
JDK 5000 5076 5054 5089 4942 5032
AQS 5358 5440 5444 5473 5472 5437

4.2、基準測驗Q&A

基于以上的測驗我們還是有一些疑問的,不要小看這些疑問,通過這些疑問我們可以把之前的知識點全都串聯起來

  • Q:AQS測驗中的“步驟二”,為什么在判斷“等待10個執行緒全部進入wait方法”時,要引入lockedNum.get() != 10的判斷?直接通過判斷所有執行緒是否均為waiting方法不可以嗎?
  • A:如果真的洗掉lockedNum.get() != 10的判斷,在多次并發測驗時,會有較小的概率出現程式死鎖的情況(作者電腦的環境是平均5萬次呼叫會出現一次),為什么會出現死鎖呢?我們追AQS原始碼就會發現,不管是呼叫lock()還是await,掛起執行緒使用的方法均為LockSupport.park()方法,此方法會將執行緒置為WAITING狀態,也就是執行緒狀態是WAITING狀態時,有可能執行緒剛進入lock()方法,從而導致awaitthread.join()的死鎖

  • Q:既然是這樣,為什么JDK的測驗沒有出現死鎖?
  • A:我們看到JDK的加鎖是通過synchronized關鍵字完成的,而當執行緒因為等待synchronized資源而阻塞時,執行緒狀態將變為BLOCKED,而進入wait()方法后,狀態才會變為WAITING

  • Q:那看來只有通過引入AtomicInteger lockedNum變數才能解決死鎖問題了
  • A:其實解決問題的方式有很多種,我們甚至可以簡單將ReentrantLock lock置為公平鎖,也能解決上述死鎖問題;因為當前場景發生死鎖的情況是,singalAll()先于await()發生,而當所有執行緒都變成WAITING狀態后,公平鎖則確保了singalAll()一定是在所有執行緒都呼叫了await(),但因為synchronized本身是非公平鎖,故如果AQS使用公平鎖的話,性能偏差較大

  • Q:那這樣看來,AQS中的阻塞佇列相對比JDK的沒有優勢可言啊,用法上沒有JDK簡潔,性能上還沒人家快
  • A:的確,如果真是只是單純的使用阻塞、喚醒功能的話,還是建議使用JDK內置的方式;但AQS的優勢并不在此

五、再說AQS條件佇列

AQS的優勢在于,其提供了豐富的api可以查詢條件佇列的狀態;例如當我們想看一下在條件佇列中等待節點的個數時,使用JDK的wait/notify時,是無法做的;AQS提供的api如下:

  • boolean hasWaiters() 阻塞佇列中是否有等待節點
  • int getWaitQueueLength() 獲取阻塞佇列長度
  • Collection<Thread> getWaitingThreads() 獲取阻塞佇列中執行緒物件

這些api為程式提供了更靈活的控制,條件佇列對于javaer已不是黑盒;當然使用AQS的條件佇列必然要引入獨占鎖,例如ReentrantLock,自然地我們還可以通過它查看條件佇列外圍的一些指標,例如:

  • Interrupted 回應中斷,借助獨占鎖,提供回應中斷能力; wait/notify不提供,因為雖然wait方法回應中斷,但是synchronized關鍵字是會一直阻塞的
  • boolean tryLock() 嘗試獲取鎖; wait/notify不提供
  • int getHoldCount() 獲取阻塞執行緒的數量
  • boolean isLocked() 是否持有鎖
  • fair/nonFair 提供公平/非公平鎖
  • ...

可見整個AQS體系相比較Objectwait/notify方法是相當靈活的,提供了很多監控條件佇列、阻塞佇列的指標

六、致謝

這里要特別感謝一下神策資料的架構師金滿倉,同時也是我私下的摯友,他功力深厚,對程式有著自己獨到的見地,在整個AQS撰寫期間,不厭其煩地給我提供了很多理論及資料上的支持,幫我拓寬視野,再次感謝!

轉載請註明出處,本文鏈接:https://www.uj5u.com/ruanti/281180.html

標籤:架構設計

上一篇:【高效編碼】IDEA中這些插件必知必會!!(Maven Helper等等)

下一篇:“一學就會,一做就廢”微服務的架構模式:一個服務一個資料庫模式

標籤雲
其他(157675) Python(38076) JavaScript(25376) Java(17977) C(15215) 區塊鏈(8255) C#(7972) AI(7469) 爪哇(7425) MySQL(7132) html(6777) 基礎類(6313) sql(6102) 熊猫(6058) PHP(5869) 数组(5741) R(5409) Linux(5327) 反应(5209) 腳本語言(PerlPython)(5129) 非技術區(4971) Android(4554) 数据框(4311) css(4259) 节点.js(4032) C語言(3288) json(3245) 列表(3129) 扑(3119) C++語言(3117) 安卓(2998) 打字稿(2995) VBA(2789) Java相關(2746) 疑難問題(2699) 细绳(2522) 單片機工控(2479) iOS(2429) ASP.NET(2402) MongoDB(2323) 麻木的(2285) 正则表达式(2254) 字典(2211) 循环(2198) 迅速(2185) 擅长(2169) 镖(2155) 功能(1967) .NET技术(1958) Web開發(1951) python-3.x(1918) HtmlCss(1915) 弹簧靴(1913) C++(1909) xml(1889) PostgreSQL(1872) .NETCore(1853) 谷歌表格(1846) Unity3D(1843) for循环(1842)

熱門瀏覽
  • 面試突擊第一季,第二季,第三季

    第一季必考 https://www.bilibili.com/video/BV1FE411y79Y?from=search&seid=15921726601957489746 第二季分布式 https://www.bilibili.com/video/BV13f4y127ee/?spm_id_fro ......

    uj5u.com 2020-09-10 05:35:24 more
  • 第三單元作業總結

    1.前言 這應該是本學期最后一次寫作業總結了吧。總體來說,對作業的節奏也差不多掌握了,作業做起來的效率也更高了。雖然和之前的作業一樣,作業中都要用到新的知識,但是相比之前,更加懂得了如何利用工具以及資料。雖然之間卡過殼,但總體而言,這幾次作業還算完成的比較好。 2.作業程序總結 相比前兩個單元,此單 ......

    uj5u.com 2020-09-10 05:35:41 more
  • 北航OO(2020)第四單元博客作業暨課程總結博客

    北航OO(2020)第四單元博客作業暨課程總結博客 本單元作業的架構設計 在本單元中,由于UML圖具有比較清晰的樹形結構,因此我對其中需要進行查詢操作的元素進行了包裝,在樹的父節點中存盤所有孩子的參考。考慮到性能問題,我采用了快取機制,一次查詢后盡可能快取已經遍歷過的資訊,以減少遍歷次數。 本單元我 ......

    uj5u.com 2020-09-10 05:35:48 more
  • BUAA_OO_第四單元

    一、UML決議器設計 ? 先看下題目:第四單元實作一個基于JDK 8帶有效性檢查的UML(Unified Modeling Language)類圖,順序圖,狀態圖分析器 MyUmlInteraction,實際上我們要建立一個有向圖模型,UML中的物件(元素)可能與同級元素連接,也可與低級元素相連形成 ......

    uj5u.com 2020-09-10 05:35:54 more
  • 6.1邏輯運算子

    邏輯運算子 1. && 短路與 運算式1 && 運算式2 01.運算式1為true并且運算式2也為true 整體回傳為true 02.運算式1為false,將不會執行運算式2 整體回傳為false 03.只要有一個運算式為false 整體回傳為false 2. || 短路或 運算式1 || 運算式2 ......

    uj5u.com 2020-09-10 05:35:56 more
  • BUAAOO 第四單元 & 課程總結

    1. 第四單元:StarUml檔案決議 本單元采用了圖模型決議UML。 UML檔案可以抽象為圖、子圖、邊的邏輯結構。 在實作中,圖的節點包括類、介面、屬性,子圖包括狀態圖、順序圖等。 采用了三次遍歷UML元素的方法建圖,第一遍遍歷建點,第二、三次遍歷設定屬性、連邊,實作圖物件的初始化。這里借鑒了一些 ......

    uj5u.com 2020-09-10 05:36:06 more
  • 談談我對C# 多型的理解

    面向物件三要素:封裝、繼承、多型。 封裝和繼承,這兩個比較好理解,但要理解多型的話,可就稍微有點難度了。今天,我們就來講講多型的理解。 我們應該經常會看到面試題目:請談談對多型的理解。 其實呢,多型非常簡單,就一句話:呼叫同一種方法產生了不同的結果。 具體實作方式有三種。 一、多載 多載很簡單。 p ......

    uj5u.com 2020-09-10 05:36:09 more
  • Python 資料驅動工具:DDT

    背景 python 的unittest 沒有自帶資料驅動功能。 所以如果使用unittest,同時又想使用資料驅動,那么就可以使用DDT來完成。 DDT是 “Data-Driven Tests”的縮寫。 資料:http://ddt.readthedocs.io/en/latest/ 使用方法 dd. ......

    uj5u.com 2020-09-10 05:36:13 more
  • Python里面的xlrd模塊詳解

    那我就一下面積個問題對xlrd模塊進行學習一下: 1.什么是xlrd模塊? 2.為什么使用xlrd模塊? 3.怎樣使用xlrd模塊? 1.什么是xlrd模塊? ?python操作excel主要用到xlrd和xlwt這兩個庫,即xlrd是讀excel,xlwt是寫excel的庫。 今天就先來說一下xl ......

    uj5u.com 2020-09-10 05:36:28 more
  • 當我們創建HashMap時,底層到底做了什么?

    jdk1.7中的底層實作程序(底層基于陣列+鏈表) 在我們new HashMap()時,底層創建了默認長度為16的一維陣列Entry[ ] table。當我們呼叫map.put(key1,value1)方法向HashMap里添加資料的時候: 首先,呼叫key1所在類的hashCode()計算key1 ......

    uj5u.com 2020-09-10 05:36:38 more
最新发布
  • 【中介者設計模式詳解】C/Java/JS/Go/Python/TS不同語言實作

    * 中介者模式是一種行為型設計模式,它可以用來減少類之間的直接依賴關系,
    * 將物件之間的通信封裝到一個中介者物件中,從而使得各個物件之間的關系更加松散。
    * 在中介者模式中,物件之間不再直接相互互動,而是通過中介者來中轉訊息。 ......

    uj5u.com 2023-04-20 08:20:47 more
  • 露天煤礦現場調研和交流案例分享

    他們集團的資訊化公司及研究院在一個礦區正在做智能礦山的統一平臺的 試點,專案投資大概1億,包括了礦山的各方面的內容,顯示得我們這次交流有點多余。他們2年前開始做智能礦山的規劃,有很多煤礦行業專家的加持,他們的描述是非常完美,但是去年底應該上線的平臺,現在還沒有看到影子。他們確實有很多場景需求,但是被... ......

    uj5u.com 2023-04-20 08:20:25 more
  • 《社區人員管理》實戰案例設計&個人案例分享

    設計是一個讓人夢想成真程序,開始編碼、測驗、除錯之前進行需求分析和架構設計,才能保證關鍵方面都做正確 ......

    uj5u.com 2023-04-20 08:20:17 more
  • 軟體架構生態化-多角色交付的探索實踐

    作為一個技術架構師,不僅僅要緊跟行業技術趨勢,還要結合研發團隊現狀及痛點,探索新的交付方案。在日常中,你是否遇到如下問題 “ 業務需求排期長研發是瓶頸;非研發角色感受不到研發技改提效的變化;引入ISV 團隊又擔心質量和安全,培訓周期長“等等,基于此我們探索了一種新的技術體系及交付方案來解決如上問題。 ......

    uj5u.com 2023-04-20 08:20:10 more
  • 【中介者設計模式詳解】C/Java/JS/Go/Python/TS不同語言實作

    * 中介者模式是一種行為型設計模式,它可以用來減少類之間的直接依賴關系,
    * 將物件之間的通信封裝到一個中介者物件中,從而使得各個物件之間的關系更加松散。
    * 在中介者模式中,物件之間不再直接相互互動,而是通過中介者來中轉訊息。 ......

    uj5u.com 2023-04-20 08:19:44 more
  • 露天煤礦現場調研和交流案例分享

    他們集團的資訊化公司及研究院在一個礦區正在做智能礦山的統一平臺的 試點,專案投資大概1億,包括了礦山的各方面的內容,顯示得我們這次交流有點多余。他們2年前開始做智能礦山的規劃,有很多煤礦行業專家的加持,他們的描述是非常完美,但是去年底應該上線的平臺,現在還沒有看到影子。他們確實有很多場景需求,但是被... ......

    uj5u.com 2023-04-20 08:19:07 more
  • 《社區人員管理》實戰案例設計&個人案例分享

    設計是一個讓人夢想成真程序,開始編碼、測驗、除錯之前進行需求分析和架構設計,才能保證關鍵方面都做正確 ......

    uj5u.com 2023-04-20 08:18:57 more
  • 軟體架構生態化-多角色交付的探索實踐

    作為一個技術架構師,不僅僅要緊跟行業技術趨勢,還要結合研發團隊現狀及痛點,探索新的交付方案。在日常中,你是否遇到如下問題 “ 業務需求排期長研發是瓶頸;非研發角色感受不到研發技改提效的變化;引入ISV 團隊又擔心質量和安全,培訓周期長“等等,基于此我們探索了一種新的技術體系及交付方案來解決如上問題。 ......

    uj5u.com 2023-04-20 08:18:49 more
  • 05單件模式

    #經典的單件模式 public class Singleton { private static Singleton uniqueInstance; //一個靜態變數持有Singleton類的唯一實體。 // 其他有用的實體變數寫在這里 //構造器宣告為私有,只有Singleton可以實體化這個類! ......

    uj5u.com 2023-04-19 08:42:51 more
  • 【架構與設計】常見微服務分層架構的區別和落地實踐

    軟體工程的方方面面都遵循一個最基本的道理:沒有銀彈,架構分層模型更是如此,每一種都有各自優缺點,所以請根據不同的業務場景,并遵循簡單、可演進這兩個重要的架構原則選擇合適的架構分層模型即可。 ......

    uj5u.com 2023-04-19 08:42:41 more