Java多線程之線程安全問(wèn)題詳細(xì)解析
一. 線程安全概述
1. 什么是線程安全問(wèn)題
我們知道操作系統(tǒng)中線程程的調(diào)度是搶占式執(zhí)行的, 宏觀上上的感知是隨機(jī)的, 這就導(dǎo)致了多線程在進(jìn)行線程調(diào)度時(shí)線程的執(zhí)行順序是不確定的, 因此多線程情況下的代碼的執(zhí)行順序可能就會(huì)有無(wú)數(shù)種, 我們需要保證這無(wú)數(shù)種線程調(diào)度順序的情況下, 代碼的執(zhí)行結(jié)果都是正確的, 只要有一種情況下, 代碼的結(jié)果沒(méi)有達(dá)到預(yù)期, 就認(rèn)為線程是不安全的, 對(duì)于多線程并發(fā)時(shí)會(huì)使程序出現(xiàn)BUG的代碼稱(chēng)作線程不安全的代碼, 這就是線程安全問(wèn)題.
2. 一個(gè)存在線程安全問(wèn)題的程序
定義一個(gè)變量count, 初始值為0, 我們想要利用兩個(gè)線程將變量count自增10萬(wàn)次, 每個(gè)線程各自負(fù)責(zé)5萬(wàn)次的自增任務(wù).
于是寫(xiě)出了如下代碼:
class Counter {
public int count = 0;
public void add() {
count++;
}
}
public class TestDemo12 {
public static void main(String[] args) {
Counter counter = new Counter();
// 搞兩個(gè)線程, 兩個(gè)線程分別針對(duì) counter 來(lái) 調(diào)用 5w 次的 add 方法
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.add();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.add();
}
});
// 啟動(dòng)線程
t1.start();
t2.start();
// 等待兩個(gè)線程結(jié)束
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// 打印最終的 count 值
System.out.println("count = " + counter.count);
}
}
執(zhí)行結(jié)果:

我們預(yù)期的結(jié)果應(yīng)該時(shí)10萬(wàn), 但得到得結(jié)果明顯要比10萬(wàn)小很多, 你可以嘗試將程序多運(yùn)行幾次你會(huì)發(fā)現(xiàn)程序每次運(yùn)行的結(jié)果都不一樣, 但絕大部分結(jié)果, 都會(huì)比預(yù)期值要小, 下面就來(lái)分析這種結(jié)出現(xiàn)的原因.
二. 線程不安全的原因和線程加鎖
1. 案例分析
在上面, 我們使用多線程所寫(xiě)的程序?qū)⒁粋€(gè)初始值為0的變量自增10萬(wàn)次, 但得到的實(shí)際得到的結(jié)果要比預(yù)期的10萬(wàn)小, 萬(wàn)惡之源還是線程的搶占式執(zhí)行, 線程調(diào)度的順序是隨機(jī)的, 就造成線程間自增的指令集交叉, 導(dǎo)致運(yùn)行時(shí)出現(xiàn)兩次或者多次自增但值只會(huì)自增一次的情況, 導(dǎo)致得到的結(jié)果會(huì)偏小.
一次的自增操作本質(zhì)上可以分成三步:
- 把內(nèi)存中變量的值讀取到CPU的寄存器中(
load). - 在寄存器中執(zhí)行自增操作(
add) - 將寄存器的值保存至內(nèi)存中(
save)
如果是兩個(gè)線程并發(fā)的執(zhí)行count++, 此時(shí)就相當(dāng)于兩組 load, add, save進(jìn)行執(zhí)行, 此時(shí)不同的線程調(diào)度順序就可能會(huì)產(chǎn)生一些結(jié)果上的差異.
下面的時(shí)間軸總結(jié)了一個(gè)變量由兩個(gè)線程并發(fā)進(jìn)行兩次自增時(shí), 常見(jiàn)幾種常見(jiàn)的情況:
- 情況1
線程間指令集無(wú)交叉, 實(shí)際結(jié)果和預(yù)期結(jié)果一致.

- 情況2
線程間指令集存在交叉, 實(shí)際結(jié)果小于預(yù)期結(jié)果.

- 情況3
線程間指令集完全交叉, 實(shí)際結(jié)果小于預(yù)期結(jié)果.

上面列舉的三種情況并不是所有可能狀況, 其他狀況也類(lèi)似, 可以自己嘗試推導(dǎo)一下, 觀察上面列出的情況情況, 我們不難發(fā)現(xiàn)出當(dāng)多線程的指令集沒(méi)有交叉情況出現(xiàn)的時(shí)侯, 程序就可以得到正確的結(jié)果; 而一旦指令集間有了交叉, 結(jié)果就可能會(huì)比預(yù)期的要小, 也就是說(shuō)造成這里線程安全問(wèn)題的原因在于這里的自增操作不是原子性的.
那么再觀察上面有問(wèn)題的結(jié)果, 思考結(jié)果一定是大于5萬(wàn)嗎, 其實(shí)不一定, 只是這種可能性比較小, 當(dāng)線程當(dāng)t2自增兩次或多次,t1只自增一次, 最后的效果是加1.

當(dāng)然也有可能最后計(jì)算出來(lái)的結(jié)果是正確的, 不過(guò)再這種有問(wèn)題的情況下可能就更小了, 但并不能說(shuō)完全沒(méi)有可能.
那么如何解決上面的線程安全問(wèn)題呢, 我們只需要想辦法讓自增操作變成原子性的即可, 也就是讓load, add, save三步編程一個(gè)整體, 也就是下面介紹的對(duì)對(duì)象加鎖.
2. 線程加鎖
2.1 理解加鎖
為了解決由于 “搶占式執(zhí)行” 所導(dǎo)致的線程安全問(wèn)題, 我們可以針對(duì)當(dāng)前所操作的對(duì)象進(jìn)行加鎖, 當(dāng)一個(gè)線程拿到該對(duì)象的鎖后, 就會(huì)將該對(duì)象鎖起來(lái), 其他線程如果需要執(zhí)行該對(duì)象所限制任務(wù)時(shí), 需要等待該線程執(zhí)行完該對(duì)象這里的任務(wù)后才可以.
用現(xiàn)實(shí)生活中的例子來(lái)理解, 假設(shè)小明要去銀行的ATM機(jī)子上辦理業(yè)務(wù), 我們知道為了安全, 每臺(tái)ATM一般都在一個(gè)單獨(dú)的小房間里面, 這個(gè)小房間由一扇門(mén)和一把鎖, 當(dāng)小明進(jìn)入房間使用ATM時(shí), 門(mén)就會(huì)自動(dòng)鎖上, 此時(shí)如果其他人想要使用這臺(tái)ATM就得等小明使用完從房間里面出來(lái)才行, 那么這里的 “小明” 就相當(dāng)于一個(gè)線程, ATM就相當(dāng)于一個(gè)對(duì)象, 房間就相當(dāng)于一把鎖, 其他想使用這臺(tái)ATM機(jī)子的人就相當(dāng)于其他的線程.



在Java中最常用的加鎖操作就是使用synchronized關(guān)鍵字進(jìn)行加鎖.
2.2 synchronized的使用
synchronized 會(huì)起到互斥效果, 某個(gè)線程執(zhí)行到某個(gè)對(duì)象的 synchronized 中時(shí), 其他線程如果也執(zhí)行到同一個(gè)對(duì)象 synchronized 就會(huì)阻塞等待.
線程進(jìn)入 synchronized 修飾的代碼塊, 相當(dāng)于加鎖, 退出 synchronized 修飾的代碼塊, 相當(dāng)于解鎖.
- 使用方式1
使用synchronized關(guān)鍵字修飾普通方法, 這樣會(huì)給方法所對(duì)在的對(duì)象加上一把鎖.
以上面的自增代碼為例, 對(duì)add()方法和加鎖, 實(shí)質(zhì)上是個(gè)一個(gè)對(duì)象加鎖, 在這里這個(gè)鎖對(duì)象就是this.
class Counter {
public int count = 0;
synchronized public void add() {
count++;
}
}
對(duì)代碼做出如上修改后, 執(zhí)行結(jié)果如下:

- 使用方式2
使用synchronized關(guān)鍵字對(duì)代碼段進(jìn)行加鎖, 需要顯式指定加鎖的對(duì)象.
還是基于最開(kāi)始的代碼進(jìn)行修改, 如下:
class Counter {
public int count = 0;
public void add() {
synchronized (this) {
count++;
}
}
}
執(zhí)行結(jié)果:

- 使用方式3
使用synchronized關(guān)鍵字修飾靜態(tài)方法, 相當(dāng)于對(duì)當(dāng)前類(lèi)的類(lèi)對(duì)象進(jìn)行加鎖.
class Counter {
public static int count = 0;
synchronized public static void add() {
count++;
}
}
執(zhí)行結(jié)果:

2.3 再次分析案例
我們這里再來(lái)分析一下, 為什么上鎖之后, 線程就安全了, 代碼如下:
class Counter {
public int count = 0;
public void add() {
count++;
}
}
public class TestDemo12 {
public static void main(String[] args) {
Counter counter = new Counter();
// 搞兩個(gè)線程, 兩個(gè)線程分別針對(duì) counter 來(lái) 調(diào)用 5w 次的 add 方法
Thread t1 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.add();
}
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 50000; i++) {
counter.add();
}
});
// 啟動(dòng)線程
t1.start();
t2.start();
// 等待兩個(gè)線程結(jié)束
try {
t1.join();
t2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// 打印最終的 count 值
System.out.println("count = " + counter.count);
}
}
加鎖, 其實(shí)就是想要保證這里自增操作 load, add, save的原子性, 但這里上鎖后并不是說(shuō)讓這三步一次完成, 也不是在執(zhí)行這三步過(guò)程中其他線程不進(jìn)行調(diào)度, 加鎖后其實(shí)是讓其他想操作的線程阻塞等待了.
比如我們考慮兩個(gè)線程指令集交叉的情況下, 加鎖操作是如何保證線程安全的, 不妨記加鎖為lock,解鎖為unlock, t1和t2兩個(gè)線程的運(yùn)行過(guò)程如下:
t1線程首先獲取到目標(biāo)對(duì)象的鎖, 對(duì)對(duì)象進(jìn)行了加鎖, 處于lock狀態(tài), t1線程load操作之后, 此時(shí)t2線程來(lái)執(zhí)行自增操作時(shí)會(huì)發(fā)生阻塞, 直到t1線程的自增操作執(zhí)行完成后, 釋放鎖變?yōu)?code>unlock狀態(tài), 線程才能成功獲取到鎖開(kāi)始執(zhí)行l(wèi)oad操作… , 如果有兩個(gè)以上的線程以此類(lèi)推…

加鎖本質(zhì)上就是把并發(fā)變成了串行執(zhí)行, 這樣的話這里的自增操作其實(shí)和單線程是差不多的, 甚至上由于add方法, 要做的事情多了加鎖和解鎖的開(kāi)銷(xiāo), 多線程完成自增可能比單線程的開(kāi)銷(xiāo)還要大, 那么多線程是不是就沒(méi)用了呢? 其實(shí)不然, 對(duì)方法加鎖后, 線程運(yùn)行該方法才會(huì)加鎖, 執(zhí)行完該方法的操作后就會(huì)解鎖, 此方法外的代碼并沒(méi)有受到限制, 這部分程序還是可以多線程并發(fā)執(zhí)行的, 這樣整體上多線程的執(zhí)行效率還是要比單線程要高許多的.
注意:
- 加鎖, 一定要明確是對(duì)哪個(gè)對(duì)象加的鎖, 如果兩個(gè)線程針對(duì)同一個(gè)對(duì)象加鎖, 會(huì)產(chǎn)生阻塞等待(鎖競(jìng)爭(zhēng)/鎖沖突); 而如果兩個(gè)線程針對(duì)不同對(duì)象加鎖, 不會(huì)產(chǎn)生鎖沖突.
3. 線程不安全的原因
- 最根本的原因: 搶占式執(zhí)行, 隨機(jī)調(diào)度, 這個(gè)原因我們無(wú)法解決.
- 代碼結(jié)構(gòu).
我們最初給出的代碼之所以有線程安全的原因, 是因?yàn)槲覀冊(cè)O(shè)計(jì)的代碼是讓兩個(gè)線程同時(shí)去修改一個(gè)相同的變量.
如果我們將代碼設(shè)計(jì)成一個(gè)線程修改一個(gè)變量, 多個(gè)線程讀取同一個(gè)變量, 多個(gè)線程修改多個(gè)不同的變量等, 這些情況下, 都是線程安全的; 所以我們可以通過(guò)調(diào)整代碼結(jié)構(gòu)來(lái)規(guī)避這個(gè)問(wèn)題, 但代碼結(jié)構(gòu)是來(lái)源于需求的, 這種調(diào)整有時(shí)候不是一個(gè)普適性特別高的方案.
- 原子性.
如果我們的多線程操作中修改操作是原子的, 那出問(wèn)題的概率還比較小, 如果是非原子的, 出現(xiàn)問(wèn)題的概率就非常高了, 就比如我們最開(kāi)頭寫(xiě)的程序以及上面的分析.
- 指令重排序和內(nèi)存可見(jiàn)性問(wèn)題
主要是由于編譯器優(yōu)化造成的指令重排序和內(nèi)存可見(jiàn)性無(wú)法保證, 就是當(dāng)線程頻繁地對(duì)同一個(gè)變量進(jìn)行讀取操作時(shí), 一開(kāi)始會(huì)讀內(nèi)存中的值, 到了后面可能就不會(huì)讀取內(nèi)存中的值了, 而是會(huì)直接從寄存器上讀值, 這樣如果內(nèi)存中的值做出修改時(shí), 線程就感知不到這個(gè)變量已經(jīng)被修改, 就會(huì)導(dǎo)致線程安全問(wèn)題, 歸根結(jié)底這是編譯器優(yōu)化的結(jié)果, 編譯器/jvm在多線程環(huán)境下產(chǎn)生了誤判, 結(jié)合下面的代碼進(jìn)行理解:
import java.util.Scanner;
class MyCounter {
volatile public int flag = 0;
}
public class TestDemo13 {
public static void main(String[] args) {
MyCounter myCounter = new MyCounter();
Thread t1 = new Thread(() -> {
while (myCounter.flag == 0) {
// 這個(gè)循環(huán)體咱們就空著
}
System.out.println("t1 循環(huán)結(jié)束");
});
Thread t2 = new Thread(() -> {
Scanner scanner = new Scanner(System.in);
System.out.println("請(qǐng)輸入一個(gè)整數(shù): ");
myCounter.flag = scanner.nextInt();
});
t1.start();
t2.start();
}
}
執(zhí)行結(jié)果:

上面的代碼中, t2線程修改flag的值讓t1線程結(jié)束, 但當(dāng)我們修改了flag的值后線程t1線程并沒(méi)有終止, 這就是編譯優(yōu)化導(dǎo)致線程感知不到內(nèi)存的變化, 從而導(dǎo)致線程不安全.
while (myCounter.flag == 0) {
// 這個(gè)循環(huán)體咱們就空著
}
t1線程中的這段代碼用匯編來(lái)理解, 大概是下面兩步操作:
load, 把內(nèi)存中flag的值讀取到寄存器中.cmp, 把寄存器的值和0進(jìn)行比較, 根據(jù)比較結(jié)果, 決定下一步往哪個(gè)地方執(zhí)行(條件跳轉(zhuǎn)指令).
要知道, 計(jì)算機(jī)中上面這個(gè)循環(huán)的執(zhí)行速度是極快的, 一秒鐘執(zhí)行百萬(wàn)次以上, 在這許多次循環(huán)中, 在t2真正修改之前, load得到的結(jié)果都是一樣的, 另一方面, CPU 針對(duì)寄存器的操作, 要比內(nèi)存操作快很多, 也就是說(shuō)load操作和cmp操作相比, 速度要慢的多, 此時(shí)jvm就針對(duì)這些操作做出了優(yōu)化, jvm判定好像是沒(méi)人修改flag的值的, 于是在之后就不再真正的重復(fù)load, 而是直接讀取寄存器當(dāng)中的值.
所以總結(jié)這里的內(nèi)存可見(jiàn)性問(wèn)題就是, 一個(gè)線程針對(duì)一個(gè)變量進(jìn)行讀取操作, 同時(shí)另一個(gè)線程針對(duì)這個(gè)變量進(jìn)行修改, 此時(shí)讀到的值, 不一定是修改之后的值, 這個(gè)讀線程沒(méi)有感知到變量的變化.
但實(shí)際上flag的值是有人修改的, 為了解決這個(gè)問(wèn)題, 我們可以使用volatile關(guān)鍵字保證內(nèi)存可見(jiàn)性, 我們可以給flag這個(gè)變量加上volatile關(guān)鍵字, 意思就是告訴編譯器,這個(gè)變量是 “易變” 的, 一定要每次都重新讀取這個(gè)變量的內(nèi)存內(nèi)容, 不可以進(jìn)行優(yōu)化了.
class MyCounter {
volatile public int flag = 0;
}
修改后的執(zhí)行結(jié)果:

編譯器優(yōu)化除了導(dǎo)致的內(nèi)存可見(jiàn)性問(wèn)題會(huì)有線程安全問(wèn)題, 還有指令重排序也會(huì)導(dǎo)致線程安全問(wèn)題, 指令重排序通俗點(diǎn)來(lái)講就是編譯器覺(jué)得你寫(xiě)的代碼太垃圾了, 就把你的代碼自作主張進(jìn)行了調(diào)整, 也就是編譯器會(huì)智能的在保持原有邏輯不變的情況下, 調(diào)整代碼的執(zhí)行順序, 從而加快程序的執(zhí)行效率.
上面所說(shuō)的原因并不是造成線程安全的全部原因, 一個(gè)代碼究竟是線程安全還是不安全, 都得具體問(wèn)題具體分析, 難以一概而論, 如果一個(gè)代碼踩中了上面的原因,也可能是線程安全, 而如果一個(gè)代碼沒(méi)踩中上面的原因,也可能是線程不安全的, 我們寫(xiě)出的多線程代碼, 只要不出bug, 就是線程安全的.
JMM模型 :
在看內(nèi)存可見(jiàn)性問(wèn)題時(shí), 還可能碰到JMM(Java Memory Model)模型, 這里簡(jiǎn)單介紹一下, JMM其實(shí)就是把操作系統(tǒng)中的寄存器, 緩存(cache)和內(nèi)存重新封裝了一下, 在JMM中寄存器和緩存稱(chēng)為工作內(nèi)存, 內(nèi)存稱(chēng)為主內(nèi)存; 其中緩存和寄存器一樣是在CPU上的, 分為一級(jí)緩存L1, 二級(jí)緩存L2和三級(jí)緩存L3, 從L1到L3空間越來(lái)越大, 最大也比內(nèi)存空間小, 最小也比寄存器空間大,訪問(wèn)速度越來(lái)越慢, 最慢也比內(nèi)存的訪問(wèn)速度快, 最快也沒(méi)有寄存器訪問(wèn)快.
synchronized與volatile關(guān)鍵字的區(qū)別:
synchronized關(guān)鍵字能保證原子性, 但是是否能夠保證內(nèi)存可見(jiàn)性是不一定的, 而volatile關(guān)鍵字只能保證內(nèi)存可見(jiàn)性不能保證原子性.
三. 線程安全的標(biāo)準(zhǔn)類(lèi)
Java 標(biāo)準(zhǔn)庫(kù)中很多都是線程不安全的, 這些類(lèi)可能會(huì)涉及到多線程修改共享數(shù)據(jù), 又沒(méi)有任何加鎖措施, 這些類(lèi)在多線代碼中使用要格外注意,下面列出的就是一些線程不安全的集合:
- ArrayList
- LinkedList
- HashMap
- TreeMap
- HashSet
- TreeSet
- StringBuilder
但是還有一些是線程安全的, 使用了一些鎖機(jī)制來(lái)控制, 如下:
- Vector (不推薦使用)
- HashTable (不推薦使用)
- ConcurrentHashMap
- StringBuffer
比如我們可以看一下StringBuffer中的方法, 絕大多數(shù)都是加鎖了的.

還有的雖然沒(méi)有加鎖, 但是不涉及 “修改”, 仍然是線程安全的:
- String
我們需要的知道的是加速操作是有副作用的, 在加鎖的同時(shí), 會(huì)帶來(lái)額外的時(shí)間開(kāi)銷(xiāo), 那些線程安全的類(lèi)已經(jīng)強(qiáng)制加鎖了, 但有些情況下, 不使用多線程是沒(méi)有線程安全問(wèn)題的, 這個(gè)時(shí)候使用那些線程不安全感的類(lèi)更好一些, 而且使用這些線程不安全的類(lèi)更靈活, 就算面臨線程安全問(wèn)題, 我們可以自行手動(dòng)加鎖, 有更多的選擇空間.
總結(jié)
到此這篇關(guān)于Java多線程之線程安全問(wèn)題的文章就介紹到這了,更多相關(guān)Java線程安全問(wèn)題內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
一篇文章帶你使用SpringBoot基于WebSocket的在線群聊實(shí)現(xiàn)
這篇文章主要介紹了一篇文章帶你使用SpringBoot基于WebSocket的在線群聊實(shí)現(xiàn),文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2020-10-10
在 Spring Boot 3 中接入生成式 AI的操作方法
本文介紹了如何在SpringBoot3中集成生成式AI,以O(shè)penAI的GPT模型為例,通過(guò)代碼示例展示了如何實(shí)現(xiàn),SpringBoot3的優(yōu)勢(shì)和OpenAI的生成式AI技術(shù)結(jié)合,為開(kāi)發(fā)者提供了高效集成生成式AI的方法,感興趣的朋友跟隨小編一起看看吧2025-01-01
通過(guò)Spring Security魔幻山谷講解獲取認(rèn)證機(jī)制核心原理
這篇文章主要介紹了通過(guò)Spring Security魔幻山谷講解獲取認(rèn)證機(jī)制核心原理,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2021-04-04
SpringBoot靜態(tài)資源css,js,img配置方案
這篇文章主要介紹了SpringBoot靜態(tài)資源css,js,img配置方案,下文給大家分享了三種解決方案,需要的朋友可以參考下2017-07-07
SpringBoot監(jiān)聽(tīng)事件和處理事件程序示例詳解
這篇文章主要介紹了SpringBoot監(jiān)聽(tīng)事件和處理事件程序示例,監(jiān)聽(tīng)和處理事件是一種常用的模式,用于在應(yīng)用程序的不同部分之間傳遞信息,Spring 的事件發(fā)布/訂閱模型允許我們創(chuàng)建自定義事件,并在這些事件發(fā)生時(shí)由注冊(cè)的監(jiān)聽(tīng)器進(jìn)行處理,需要的朋友可以參考下2022-06-06
Eclipse中@SpringBootTest注解報(bào)紅的解決方案
這篇文章主要介紹了Eclipse中@SpringBootTest注解報(bào)紅的解決方案,文中給出了原因分析和解決方案,并通過(guò)圖文結(jié)合的方式介紹的非常詳細(xì),需要的朋友可以參考下2024-03-03
Spring Security登錄接口兼容JSON格式登錄實(shí)現(xiàn)示例
前后端分離中,前端和后端的數(shù)據(jù)交互通常是JSON格式,本文主要介紹了Spring Security登錄接口兼容JSON格式登錄實(shí)現(xiàn)示例,具有一定的參考價(jià)值,感興趣的可以了解一下2024-01-01
IDEA項(xiàng)目代碼上傳gitlab遠(yuǎn)程倉(cāng)庫(kù)過(guò)程圖解
這篇文章主要介紹了IDEA項(xiàng)目代碼上傳gitlab遠(yuǎn)程倉(cāng)庫(kù)過(guò)程圖解,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2020-09-09

