讀了源碼,發(fā)現(xiàn) Volatile 如此重要!
大家好,我是悟空。
最近卷 Eureka 源碼,發(fā)現(xiàn) volatile 在很多地方都用到了,復(fù)習(xí)一波。此文全面講解了 volatile 的用法和細(xì)節(jié),建議收藏轉(zhuǎn)發(fā)后再看。
一、Volatile怎么念?
看到這個(gè)單詞一直不知道怎么發(fā)音
英 [ˈvɒlətaɪl] 美 [ˈvɑːlətl]
adj. [化學(xué)] 揮發(fā)性的;不穩(wěn)定的;爆炸性的;反復(fù)無常的
那Java中volatile又是干啥的呢?
二、Java中volatile用來干啥?
Volatile是Java虛擬機(jī)提供的輕量級(jí)的同步機(jī)制(三大特性)
- 保證可見性
- 不保證原子性
- 禁止指令重排
要理解三大特性,就必須知道Java內(nèi)存模型(JMM),那JMM又是什么呢?
三、JMM又是啥?
這是一份精心總結(jié)的Java內(nèi)存模型思維導(dǎo)圖,拿去不謝。
原理圖1-Java內(nèi)存模型
3.1 為什么需要Java內(nèi)存模型?
Why:屏蔽各種硬件和操作系統(tǒng)的內(nèi)存訪問差異
JMM是Java內(nèi)存模型,也就是Java Memory Model,簡(jiǎn)稱JMM,本身是一種抽象的概念,實(shí)際上并不存在,它描述的是一組規(guī)則或規(guī)范,通過這組規(guī)范定義了程序中各個(gè)變量(包括實(shí)例字段,靜態(tài)字段和構(gòu)成數(shù)組對(duì)象的元素)的訪問方式。
3.2 到底什么是Java內(nèi)存模型?
- 1.定義程序中各種變量的訪問規(guī)則
- 2.把變量值存儲(chǔ)到內(nèi)存的底層細(xì)節(jié)
- 3.從內(nèi)存中取出變量值的底層細(xì)節(jié)
3.3 Java內(nèi)存模型的兩大內(nèi)存是啥?
原理圖2-兩大內(nèi)存
- 主內(nèi)存
- Java堆中對(duì)象實(shí)例數(shù)據(jù)部分
- 對(duì)應(yīng)于物理硬件的內(nèi)存
- 工作內(nèi)存
- Java棧中的部分區(qū)域
- 優(yōu)先存儲(chǔ)于寄存器和高速緩存
3.4 Java內(nèi)存模型是怎么做的?
Java內(nèi)存模型的幾個(gè)規(guī)范:
- 1.所有變量存儲(chǔ)在主內(nèi)存
- 2.主內(nèi)存是虛擬機(jī)內(nèi)存的一部分
- 3.每條線程有自己的工作內(nèi)存
- 4.線程的工作內(nèi)存保存變量的主內(nèi)存副本
- 5.線程對(duì)變量的操作必須在工作內(nèi)存中進(jìn)行
- 6.不同線程之間無法直接訪問對(duì)方工作內(nèi)存中的變量
- 7.線程間變量值的傳遞均需要通過主內(nèi)存來完成
由于JVM運(yùn)行程序的實(shí)體是線程,而每個(gè)線程創(chuàng)建時(shí)JVM都會(huì)為其創(chuàng)建一個(gè)工作內(nèi)存(有些地方稱為棧空間),工作內(nèi)存是每個(gè)線程的私有數(shù)據(jù)區(qū)域,而Java內(nèi)存模型中規(guī)定所有變量都存儲(chǔ)在主內(nèi)存,主內(nèi)存是共享內(nèi)存區(qū)域,所有線程都可以訪問,但線程對(duì)變量的操作(讀取賦值等)必須在工作內(nèi)存中進(jìn)行,首先要將變量從主內(nèi)存拷貝到自己的工作內(nèi)存空間,然后對(duì)變量進(jìn)行操作,操作完成后再將變量寫會(huì)主內(nèi)存,不能直接操作主內(nèi)存中的變量,各個(gè)線程中的工作內(nèi)存中存儲(chǔ)著主內(nèi)存中的變量副本拷貝,因此不同的線程間無法訪問對(duì)方的工作內(nèi)存,線程間的通信(傳值)必須通過主內(nèi)存來完成,其簡(jiǎn)要訪問過程:
原理圖3-Java內(nèi)存模型
3.5 Java內(nèi)存模型的三大特性
可見性(當(dāng)一個(gè)線程修改了共享變量的值時(shí),其他線程能夠立即得知這個(gè)修改)
原子性(一個(gè)操作或一系列操作是不可分割的,要么同時(shí)成功,要么同時(shí)失敗)
有序性(變量賦值操作的順序與程序代碼中的執(zhí)行順序一致)
關(guān)于有序性:如果在本線程內(nèi)觀察,所有的操作都是有序的;如果在一個(gè)線程中觀察另一個(gè)線程,所有的操作都是無序的。前半句是指“線程內(nèi)似表現(xiàn)為串行的語義”(Within-Thread As-If-Serial Semantics),后半句是指“指令重排序”現(xiàn)象和“工作內(nèi)存與主內(nèi)存同步延遲”現(xiàn)象。
四、能給個(gè)示例說下怎么用volatile的嗎?
考慮一下這種場(chǎng)景:
有一個(gè)對(duì)象的字段number初始化值=0,另外這個(gè)對(duì)象有一個(gè)公共方法setNumberTo100()可以設(shè)置number = 100,當(dāng)主線程通過子線程來調(diào)用setNumberTo100()后,主線程是否知道number值變了呢?
答案:如果沒有使用volatile來定義number變量,則主線程不知道子線程更新了number的值。
(1)定義如上述所說的對(duì)象:ShareData
- class ShareData {
- int number = 0;
- public void setNumberTo100() {
- this.number = 100;
- }
- }
(2)主線程中初始化一個(gè)子線程,名字叫做子線程
子線程先休眠3s,然后設(shè)置number=100。主線程不斷檢測(cè)的number值是否等于0,如果不等于0,則退出主線程。
- public class volatileVisibility {
- public static void main(String[] args) {
- // 資源類
- ShareData shareData = new ShareData();
- // 子線程 實(shí)現(xiàn)了Runnable接口的,lambda表達(dá)式
- new Thread(() -> {
- System.out.println(Thread.currentThread().getName() + "\t come in");
- // 線程睡眠3秒,假設(shè)在進(jìn)行運(yùn)算
- try {
- TimeUnit.SECONDS.sleep(3);
- } catch (InterruptedException e) {
- e.printStackTrace();
- }
- // 修改number的值
- myData.setNumberTo100();
- // 輸出修改后的值
- System.out.println(Thread.currentThread().getName() + "\t update number value:" + myData.number);
- }, "子線程").start();
- while(myData.number == 0) {
- // main線程就一直在這里等待循環(huán),直到number的值不等于零
- }
- // 按道理這個(gè)值是不可能打印出來的,因?yàn)橹骶€程運(yùn)行的時(shí)候,number的值為0,所以一直在循環(huán)
- // 如果能輸出這句話,說明子線程在睡眠3秒后,更新的number的值,重新寫入到主內(nèi)存,并被main線程感知到了
- System.out.println(Thread.currentThread().getName() + "\t 主線程感知到了 number 不等于 0");
- /**
- * 最后輸出結(jié)果:
- * 子線程 come in
- * 子線程 update number value:100
- * 最后線程沒有停止,并行沒有輸出"主線程知道了 number 不等于0"這句話,說明沒有用volatile修飾的變量,變量的更新是不可見的
- */
- }
- }
沒有使用volatile
(3)我們用volatile修飾變量number
- class ShareData {
- //volatile 修飾的關(guān)鍵字,是為了增加多個(gè)線程之間的可見性,只要有一個(gè)線程修改了內(nèi)存中的值,其它線程也能馬上感知
- volatile int number = 0;
- public void setNumberTo100() {
- this.number = 100;
- }
- }
輸出結(jié)果:
- 子線程 come in
- 子線程 update number value:100
- main 主線程知道了 number 不等于 0
- Process finished with exit code 0
mark
「小結(jié):說明用volatile修飾的變量,當(dāng)某線程更新變量后,其他線程也能感知到?!?/p>
五、那為什么其他線程能感知到變量更新?
其實(shí)這里就是用到了“窺探(snooping)”協(xié)議。在說“窺探(snooping)”協(xié)議之前,首先談?wù)劸彺嬉恢滦缘膯栴}。
5.1 緩存一致性
當(dāng)多個(gè)CPU持有的緩存都來自同一個(gè)主內(nèi)存的拷貝,當(dāng)有其他CPU偷偷改了這個(gè)主內(nèi)存數(shù)據(jù)后,其他CPU并不知道,那拷貝的內(nèi)存將會(huì)和主內(nèi)存不一致,這就是緩存不一致。那我們?nèi)绾蝸肀WC緩存一致呢?這里就需要操作系統(tǒng)來共同制定一個(gè)同步規(guī)則來保證,而這個(gè)規(guī)則就有MESI協(xié)議。
如下圖所示,CPU2 偷偷將num修改為2,內(nèi)存中num也被修改為2,但是CPU1和CPU3并不知道num值變了。
原理圖4-緩存一致性1
5.2 MESI
當(dāng)CPU寫數(shù)據(jù)時(shí),如果發(fā)現(xiàn)操作的變量是共享變量,即在其它CPU中也存在該變量的副本,系統(tǒng)會(huì)發(fā)出信號(hào)通知其它CPU將該內(nèi)存變量的緩存行設(shè)置為無效。如下圖所示,CPU1和CPU3 中num=1已經(jīng)失效了。
原理圖5-緩存一致性2
當(dāng)其它CPU讀取這個(gè)變量的時(shí),發(fā)現(xiàn)自己緩存該變量的緩存行是無效的,那么它就會(huì)從內(nèi)存中重新讀取。
如下圖所示,CPU1和CPU3發(fā)現(xiàn)緩存的num值失效了,就重新從內(nèi)存讀取,num值更新為2。
原理圖6-緩存一致性3
5.3 總線嗅探
那其他CPU是怎么知道要將緩存更新為失效的呢?這里是用到了總線嗅探技術(shù)。
每個(gè)CPU不斷嗅探總線上傳播的數(shù)據(jù)來檢查自己緩存值是否過期了,如果處理器發(fā)現(xiàn)自己的緩存行對(duì)應(yīng)的內(nèi)存地址被修改,就會(huì)將當(dāng)前處理器的緩存行設(shè)置為無效狀態(tài),當(dāng)處理器對(duì)這個(gè)數(shù)據(jù)進(jìn)行修改操作的時(shí)候,會(huì)重新從內(nèi)存中把數(shù)據(jù)讀取到處理器緩存中。
原理圖7-緩存一致性4
5.4 總線風(fēng)暴
總線嗅探技術(shù)有哪些缺點(diǎn)?
由于MESI緩存一致性協(xié)議,需要不斷對(duì)主線進(jìn)行內(nèi)存嗅探,大量的交互會(huì)導(dǎo)致總線帶寬達(dá)到峰值。因此不要濫用volatile,可以用鎖來替代,看場(chǎng)景啦~
六、能演示下volatile為什么不保證原子性嗎?
原子性:一個(gè)操作或一系列操作是不可分割的,要么同時(shí)成功,要么同時(shí)失敗。
「這個(gè)定義和volatile啥關(guān)系呀,完全不能理解呀?Show me the code!」
考慮一下這種場(chǎng)景:
當(dāng)20個(gè)線程同時(shí)給number自增1,執(zhí)行1000次以后,number的值為多少呢?
在單線程的場(chǎng)景,答案是20000,如果是多線程的場(chǎng)景下呢?答案是可能是20000,但很多情況下都是小于20000。
示例代碼:
- package com.jackson0714.passjava.threads;
- /**
- 演示volatile 不保證原子性
- * @create: 2020-08-13 09:53
- */
- public class VolatileAtomicity {
- public static volatile int number = 0;
- public static void increase() {
- number++;
- }
- public static void main(String[] args) {
- for (int i = 0; i < 50; i++) {
- new Thread(() -> {
- for (int j = 0; j < 1000; j++) {
- increase();
- }
- }, String.valueOf(i)).start();
- }
- // 當(dāng)所有累加線程都結(jié)束
- while(Thread.activeCount() > 2) {
- Thread.yield();
- }
- System.out.println(number);
- }
- }
執(zhí)行結(jié)果:第一次19144,第二次20000,第三次19378。
volatile第一次執(zhí)行結(jié)果
volatile第二次執(zhí)行結(jié)果
volatile第三次執(zhí)行結(jié)果
我們來分析一下increase()方法,通過反編譯工具javap得到如下匯編代碼:
- public static void increase();
- Code:
- 0: getstatic #2 // Field number:I
- 3: iconst_1
- 4: iadd
- 5: putstatic #2 // Field number:I
- 8: return
number++其實(shí)執(zhí)行了3條指令:
getstatic:拿number的原始值 iadd:進(jìn)行加1操作 putfield:把加1后的值寫回
執(zhí)行了getstatic指令number的值取到操作棧頂時(shí),volatile關(guān)鍵字保證了number的值在此時(shí)是正確的,但是在執(zhí)行iconst_1、iadd這些指令的時(shí)候,其他線程可能已經(jīng)把number的值改變了,而操作棧頂?shù)闹稻妥兂闪诉^期的數(shù)據(jù),所以putstatic指令執(zhí)行后就可能把較小的number值同步回主內(nèi)存之中。
總結(jié)如下:
在執(zhí)行number++這行代碼時(shí),即使使用volatile修飾number變量,在執(zhí)行期間,還是有可能被其他線程修改,沒有保證原子性。
七、怎么保證輸出結(jié)果是20000呢?
7.1 synchronized同步代碼塊
我們可以通過使用synchronized同步代碼塊來保證原子性。從而使結(jié)果等于20000
- public synchronized static void increase() {
- number++;
- }
synchronized同步代碼塊執(zhí)行結(jié)果
但是使用synchronized太重了,會(huì)造成阻塞,只有一個(gè)線程能進(jìn)入到這個(gè)方法。我們可以使用Java并發(fā)包(JUC)中的AtomicInterger工具包。
7.2 AtomicInterger原子性操作
我們來看看AtomicInterger原子自增的方法getAndIncrement()
AtomicInterger
- public static AtomicInteger atomicInteger = new AtomicInteger();
- public static void main(String[] args) {
- for (int i = 0; i < 20; i++) {
- new Thread(() -> {
- for (int j = 0; j < 1000; j++) {
- atomicInteger.getAndIncrement();
- }
- }, String.valueOf(i)).start();
- }
- // 當(dāng)所有累加線程都結(jié)束
- while(Thread.activeCount() > 2) {
- Thread.yield();
- }
- System.out.println(atomicInteger);
- }
多次運(yùn)行的結(jié)果都是20000。
getAndIncrement的執(zhí)行結(jié)果
八、禁止指令重排又是啥?
說到指令重排就得知道為什么要重排,有哪幾種重排。
如下圖所示,指令執(zhí)行順序是按照1>2>3>4的順序,經(jīng)過重排后,執(zhí)行順序更新為指令3->4->2->1。
原理圖8-指令重排
會(huì)不會(huì)感覺到重排把指令順序都打亂了,這樣好嗎?
可以回想下小學(xué)時(shí)候的數(shù)學(xué)題:2+3-5=?,如果把運(yùn)算順序改為3-5+2=?,結(jié)果也是一樣的。所以指令重排是要保證單線程下程序結(jié)果不變的情況下做重排。
8.1 為什么要重排
計(jì)算機(jī)在執(zhí)行程序時(shí),為了提高性能,編譯器和處理器常常會(huì)對(duì)指令做重排序。
8.2 有哪幾種重排
1.編譯器優(yōu)化重排:編譯器在不改變單線程程序語義的前提下,可以重新安排語句的執(zhí)行順序。
2.指令級(jí)的并行重排:現(xiàn)代處理器采用了指令級(jí)并行技術(shù)來將多條指令重疊執(zhí)行。如果不存在數(shù)據(jù)依賴性,處理器可以改變語句對(duì)應(yīng)機(jī)器指令的執(zhí)行順序。
3.內(nèi)存系統(tǒng)的重排:由于處理器使用緩存和讀/寫緩沖區(qū),這使得加載和存儲(chǔ)操作看上去可能是在亂序執(zhí)行。
原理圖9-三種重排
注意:
- 單線程環(huán)境里面確保最終執(zhí)行結(jié)果和代碼順序的結(jié)果一致
- 處理器在進(jìn)行重排序時(shí),必須要考慮指令之間的數(shù)據(jù)依賴性
- 多線程環(huán)境中線程交替執(zhí)行,由于編譯器優(yōu)化重排的存在,兩個(gè)線程中使用的變量能否保證一致性是無法確定的,結(jié)果無法預(yù)測(cè)。
8.3 舉個(gè)例子來說說多線程中的指令重排?
設(shè)想一下這種場(chǎng)景:定義了變量num=0和變量flag=false,線程1調(diào)用初始化函數(shù)init()執(zhí)行后,線程調(diào)用add()方法,當(dāng)另外線程判斷flag=true后,執(zhí)行num+100操作,那么我們預(yù)期的結(jié)果是num會(huì)等于101,但因?yàn)橛兄噶钪嘏诺目赡?,num=1和flag=true執(zhí)行順序可能會(huì)顛倒,以至于num可能等于100
- public class VolatileResort {
- static int num = 0;
- static boolean flag = false;
- public static void init() {
- num= 1;
- flag = true;
- }
- public static void add() {
- if (flag) {
- num = num + 5;
- System.out.println("num:" + num);
- }
- }
- public static void main(String[] args) {
- init();
- new Thread(() -> {
- add();
- },"子線程").start();
- }
- }
先看線程1中指令重排:
num= 1;flag = true; 的執(zhí)行順序變?yōu)閒lag=true;num = 1;,如下圖所示的時(shí)序圖
原理圖10-線程1指令重排
如果線程2 num=num+5 在線程1設(shè)置num=1之前執(zhí)行,那么線程2的num變量值為5。如下圖所示的時(shí)序圖。
原理圖11-線程2在num=1之前執(zhí)行
8.4 volatile怎么實(shí)現(xiàn)禁止指令重排?
我們使用volatile定義flag變量:
- static volatile boolean flag = false;
「如何實(shí)現(xiàn)禁止指令重排:」
原理:在volatile生成的指令序列前后插入內(nèi)存屏障(Memory Barries)來禁止處理器重排序。
「有如下四種內(nèi)存屏障:」
四種內(nèi)存屏障
「volatile寫的場(chǎng)景如何插入內(nèi)存屏障:」
- 在每個(gè)volatile寫操作的前面插入一個(gè)StoreStore屏障(寫-寫 屏障)。
- 在每個(gè)volatile寫操作的后面插入一個(gè)StoreLoad屏障(寫-讀 屏障)。
原理圖12-volatile寫的場(chǎng)景如何插入內(nèi)存屏障
StoreStore屏障可以保證在volatile寫(flag賦值操作flag=true)之前,其前面的所有普通寫(num的賦值操作num=1) 操作已經(jīng)對(duì)任意處理器可見了,保障所有普通寫在volatile寫之前刷新到主內(nèi)存。
「volatile讀場(chǎng)景如何插入內(nèi)存屏障:」
- 在每個(gè)volatile讀操作的后面插入一個(gè)LoadLoad屏障(讀-讀 屏障)。
- 在每個(gè)volatile讀操作的后面插入一個(gè)LoadStore屏障(讀-寫 屏障)。
原理圖13-volatile讀場(chǎng)景如何插入內(nèi)存屏障
LoadStore屏障可以保證其后面的所有普通寫(num的賦值操作num=num+5) 操作必須在volatile讀(if(flag))之后執(zhí)行。
十、volatile常見應(yīng)用
這里舉一個(gè)應(yīng)用,雙重檢測(cè)鎖定的單例模式
- package com.jackson0714.passjava.threads;
- /**
- 演示volatile 單例模式應(yīng)用(雙邊檢測(cè))
- * @author: 悟空聊架構(gòu)
- * @create: 2020-08-17
- */
- class VolatileSingleton {
- private static VolatileSingleton instance = null;
- private VolatileSingleton() {
- System.out.println(Thread.currentThread().getName() + "\t 我是構(gòu)造方法SingletonDemo");
- }
- public static VolatileSingleton getInstance() {
- // 第一重檢測(cè)
- if(instance == null) {
- // 鎖定代碼塊
- synchronized (VolatileSingleton.class) {
- // 第二重檢測(cè)
- if(instance == null) {
- // 實(shí)例化對(duì)象
- instance = new VolatileSingleton();
- }
- }
- }
- return instance;
- }
- }
代碼看起來沒有問題,但是 instance = new VolatileSingleton();其實(shí)可以看作三條偽代碼:
- memory = allocate(); // 1、分配對(duì)象內(nèi)存空間
- instance(memory); // 2、初始化對(duì)象
- instance = memory; // 3、設(shè)置instance指向剛剛分配的內(nèi)存地址,此時(shí)instance != null
步驟2 和 步驟3之間不存在 數(shù)據(jù)依賴關(guān)系,而且無論重排前 還是重排后,程序的執(zhí)行結(jié)果在單線程中并沒有改變,因此這種重排優(yōu)化是允許的。
- memory = allocate(); // 1、分配對(duì)象內(nèi)存空間
- instance = memory; // 3、設(shè)置instance指向剛剛分配的內(nèi)存地址,此時(shí)instance != null,但是對(duì)象還沒有初始化完成
- instance(memory); // 2、初始化對(duì)象
如果另外一個(gè)線程執(zhí)行:if(instance == null)時(shí),則返回剛剛分配的內(nèi)存地址,但是對(duì)象還沒有初始化完成,拿到的instance是個(gè)假的。如下圖所示:
原理圖14-雙重檢鎖存在的并發(fā)問題
解決方案:定義instance為volatile變量
- private static volatile VolatileSingleton instance = null;
十一、volatile都不保證原子性,為啥我們還要用它?
奇怪的是,volatile都不保證原子性,為啥我們還要用它?
volatile是輕量級(jí)的同步機(jī)制,對(duì)性能的影響比synchronized小。
典型的用法:檢查某個(gè)狀態(tài)標(biāo)記以判斷是否退出循環(huán)。
比如線程試圖通過類似于數(shù)綿羊的傳統(tǒng)方法進(jìn)入休眠狀態(tài),為了使這個(gè)示例能正確執(zhí)行,asleep必須為volatile變量。否則,當(dāng)asleep被另一個(gè)線程修改時(shí),執(zhí)行判斷的線程卻發(fā)現(xiàn)不了。
「那為什么我們不直接用synchorized,lock鎖?它們既可以保證可見性,又可以保證原子性為何不用呢?」
因?yàn)閟ynchorized和lock是排他鎖(悲觀鎖),如果有多個(gè)線程需要訪問這個(gè)變量,將會(huì)發(fā)生競(jìng)爭(zhēng),只有一個(gè)線程可以訪問這個(gè)變量,其他線程被阻塞了,會(huì)影響程序的性能。
注意:當(dāng)且僅當(dāng)滿足以下所有條件時(shí),才應(yīng)該用volatile變量
對(duì)變量的寫入操作不依賴變量的當(dāng)前值,或者你能確保只有單個(gè)線程更新變量的值。
該變量不會(huì)與其他的狀態(tài)一起納入不變性條件中。
在訪問變量時(shí)不需要加鎖。
十二、volatile和synchronzied的區(qū)別
- volatile只能修飾實(shí)例變量和類變量,synchronized可以修飾方法和代碼塊。
- volatile不保證原子性,而synchronized保證原子性
- volatile 不會(huì)造成阻塞,而synchronized可能會(huì)造成阻塞
- volatile 輕量級(jí)鎖,synchronized重量級(jí)鎖
- volatile 和synchronized都保證了可見性和有序性
十三、小結(jié)
volatile 保證了可見性:當(dāng)一個(gè)線程修改了共享變量的值時(shí),其他線程能夠立即得知這個(gè)修改。
volatile 保證了單線程下指令不重排:通過插入內(nèi)存屏障保證指令執(zhí)行順序。
volatitle不保證原子性,如a++這種自增操作是有并發(fā)風(fēng)險(xiǎn)的,比如扣減庫(kù)存、發(fā)放優(yōu)惠券的場(chǎng)景。
volatile 類型的64位的long型和double型變量,對(duì)該變量的讀/寫具有原子性。
volatile 可以用在雙重檢鎖的單例模式中,比synchronized性能更好。
volatile 可以用在檢查某個(gè)狀態(tài)標(biāo)記以判斷是否退出循環(huán)。
代碼已提交到github/碼云:https://gitee.com/jayh2018/PassJava-Learning
參考資料:
《深入理解Java虛擬機(jī)》
《Java并發(fā)編程的藝術(shù)》
《Java并發(fā)編程實(shí)戰(zhàn)》
本文轉(zhuǎn)載自微信公眾號(hào)「悟空聊架構(gòu)」,可以通過以下二維碼關(guān)注。轉(zhuǎn)載本文請(qǐng)聯(lián)系悟空聊架構(gòu)公眾號(hào)。