在一般性開發中,筆者經常看到很多同學在對待java並發開發模型中只會使用一些基礎的方法。比如Volatile,synchronized。像Lock和atomic這類進階並發包很多人並不經常使用。我想大部分原因都是來之於對原理的不屬性導致的。在繁忙的開發工作中,又有誰會很準確的把握和使用正確的並行存取模型呢?
所以最近基於這個思想,本人打算把並發控制機制這部分整理成一篇文章。既是對自己掌握知識的一個回憶,也是希望這篇講到的類容能協助到大部分開發人員。
並行程式開發不可避免地要涉及多線程、多任務的協作和資料共用等問題。在JDK中,提供了多種途徑實現多線程間的並發控制。比如常用的:內部鎖、重入鎖、讀寫鎖和訊號量。
Java記憶體模型
在java中,每一個線程有一塊工作記憶體區,其中存放著被所有線程共用的主記憶體中的變數的值的拷貝。當線程執行時,它在自己的工作記憶體中操作這些變數。
為了存取一個共用的變數,一個線程通常先擷取鎖定並且清除它的工作記憶體區,這保證該共用變數從所有線程的共用記憶體區正確地裝入到線程的工作記憶體區,當線程解鎖時保證該工作記憶體區中變數的值協會到共用記憶體中。
當一個線程使用某一個變數時,不論程式是否正確地使用線程同步操作,它擷取的值一定是由它本身或者其他線程儲存到變數中的值。例如,如果兩個線程把不同的值或者對象引用儲存到同一個共用變數中,那麼該變數的值要麼是這個線程的,要麼是那個線程的,共用變數的值不會是由兩個線程的引用值組合而成。
一個變數時Java程式可以存取的一個地址,它不僅包括基本類型變數、參考型別變數,而且還包括數群組類型變數。儲存在主記憶體區的變數可以被所有線程共用,但是一個線程存取另一個線程的參數或者局部變數時不可能的,所以開發人員不必擔心局部變數的安全執行緒問題。
volatile變數–多線程間可見
由於每個線程都有自己的工作記憶體區,因此當一個線程改變自己的工作記憶體中的資料時,對其他線程來說,可能是不可見的。為此,可以使用volatile關鍵字破事所有線程軍讀寫記憶體中的變數,從而使得volatile變數在多線程間可見。
聲明為volatile的變數可以做到如下保證:
1、其他線程對變數的修改,可以及時反應在當前線程中;
2、確保當前線程對volatile變數的修改,能及時寫回到共用記憶體中,並被其他線程所見;
3、使用volatile聲明的變數,編譯器會保證其有序性。
同步關鍵字synchronized
同步關鍵字synchronized是Java語言中最為常用的同步方法之一。在JDK早期版本中,synchronized的效能並不是太好,值適合於鎖競爭不是特別激烈的場合。在JDK6中,synchronized和非公平鎖的差距已經縮小。更為重要的是,synchronized更為簡潔明了,代碼可讀性和維護性比較好。
鎖定一個對象的方法:
public synchronized void method(){}
當method()方法被調用時,調用線程首先必須獲得當前對象所,若當前對象鎖被其他線程持有,這調用線程會等待,犯法結束後,對象鎖會被釋放,以上方法等價於下面的寫法:
public void method(){synchronized(this){// do something …}}
其次,使用synchronized還可以構造同步塊,與同步方法相比,同步塊可以更為精確控制同步代碼範圍。一個小的同步代碼非常有離與鎖的快進快出,從而使系統擁有更高的輸送量。
public void method(Object o){// beforesynchronized(o){// do something ...}// after}
synchronized也可以用於static函數:
public synchronized static void method(){}
這個地方一定要注意,synchronized的鎖是加在當前Class對象上,因此,所有對該方法的調用,都必須獲得Class對象的鎖。
雖然synchronized可以保證對象或者程式碼片段的安全執行緒,但是僅使用synchronized還是不足以控制擁有複雜邏輯的線程互動。為了實現多線程間的互動,還需要使用Object對象的wait()和notify()方法。
典型用法:
synchronized(obj){ while(<?>){ obj.wait(); // 收到通知後,繼續執行。 }}
在使用wait()方法前,需要獲得對象鎖。在wait()方法執行時,當前線程或釋放obj的獨佔鎖,供其他線程使用。
當等待在obj上線程收到obj.notify()時,它就能重新獲得obj的獨佔鎖,並繼續運行。注意了,notify()方法是隨機喚起等待在當前對象的某一個線程。
下面是一個阻塞隊列的實現:
public class BlockQueue{ private List list = new ArrayList(); public synchronized Object pop() throws InterruptedException{ while (list.size()==0){ this.wait(); } if (list.size()>0){ return list.remove(0); } else{ return null; } } public synchronized Object put(Object obj){ list.add(obj); this.notify(); }}
synchronized配合wait()、notify()應該是Java開發人員必須掌握的基本技能。
Reentrantlock重入鎖
Reentrantlock稱為重入鎖。它比synchronized擁有更加強大的功能,它可以中斷、可定時。在高並發的情況下,它比synchronized有明顯的效能優勢。
Reentrantlock提供了公平和非公平兩種鎖。公平鎖是對鎖的擷取是先進先出,而非公平鎖是可以插隊的。當然從效能上分析,非公平鎖的效能要好得多。因此,在無特殊需要,應該優選非公平鎖,但是synchronized提供鎖業不是絕對公平的。Reentrantlock在構造的時候可以指定鎖是否公平。
在使用重入鎖時,一定要在程式最後釋放鎖。一般釋放鎖的代碼要寫在finally裡。否則,如果程式出現異常,Loack就永遠無法釋放了。synchronized的鎖是JVM最後自動釋放的。
經典使用方式如下:
try { if (lock.tryLock(5, TimeUnit.SECONDS)) { //如果已經被lock,嘗試等待5s,看是否可以獲得鎖,如果5s後仍然無法獲得鎖則返回false繼續執行 // lock.lockInterruptibly();可以響應中斷事件 try { //操作 } finally { lock.unlock(); } }} catch (InterruptedException e) { e.printStackTrace(); //當前線程被中斷時(interrupt),會拋InterruptedException }
Reentrantlock提供了非常豐富的鎖控制功能,靈活應用這些控制方法,可以提高應用程式的效能。不過這裡並非是極力推薦使用Reentrantlock。重入鎖算是JDK中提供的進階開發工具。
ReadWriteLock讀寫鎖
讀寫分離是一種非常常見的資料處理思想。在sql中應該算是必須用到的技術。ReadWriteLock是在JDK5中提供的讀寫分離鎖。讀寫分離鎖可以有效地協助減少鎖競爭,以提升系統效能。讀寫分離使用情境主要是如果在系統中,讀操作次數遠遠大於寫操作。使用方式如下:
private ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();private Lock readLock = readWriteLock.readLock();private Lock writeLock = readWriteLock.writeLock();public Object handleRead() throws InterruptedException { try { readLock.lock(); Thread.sleep(1000); return value; }finally{ readLock.unlock(); }}public Object handleRead() throws InterruptedException { try { writeLock.lock(); Thread.sleep(1000); return value; }finally{ writeLock.unlock(); }}
Condition對象
Conditiond對象用於協調多線程間的複雜協作。主要與鎖相關聯。通過Lock介面中的newCondition()方法可以產生一個與Lock綁定的Condition執行個體。Condition對象和鎖的關係就如用Object.wait()、Object.notify()兩個函數以及synchronized關鍵字一樣。
這裡可以把ArrayBlockingQueue的源碼摘出來看一下:
public class ArrayBlockingQueue extends AbstractQueueimplements BlockingQueue, java.io.Serializable {/** Main lock guarding all access */final ReentrantLock lock;/** Condition for waiting takes */private final Condition notEmpty;/** Condition for waiting puts */private final Condition notFull;public ArrayBlockingQueue(int capacity, boolean fair) { if (capacity <= 0) throw new IllegalArgumentException(); this.items = new Object[capacity]; lock = new ReentrantLock(fair); notEmpty = lock.newCondition(); // 產生與Lock綁定的Condition notFull = lock.newCondition();}public void put(E e) throws InterruptedException { checkNotNull(e); final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { while (count == items.length) notFull.await(); insert(e); } finally { lock.unlock(); }}private void insert(E x) { items[putIndex] = x; putIndex = inc(putIndex); ++count; notEmpty.signal(); // 通知}public E take() throws InterruptedException { final ReentrantLock lock = this.lock; lock.lockInterruptibly(); try { while (count == 0) // 如果隊列為空白 notEmpty.await(); // 則消費者隊列要等待一個非空的訊號 return extract(); } finally { lock.unlock(); }}private E extract() { final Object[] items = this.items; E x = this.<E>cast(items[takeIndex]); items[takeIndex] = null; takeIndex = inc(takeIndex); --count; notFull.signal(); // 通知put() 線程隊列已有空閑空間 return x;}// other code}
Semaphore訊號量
訊號量為多線程協作提供了更為強大的控制方法。訊號量是對鎖的擴充。無論是內部鎖synchronized還是重入鎖ReentrantLock,一次都允許一個線程訪問一個資源,而訊號量卻可以指定多個線程同時訪問某一個資源。從建構函式可以看出:
public Semaphore(int permits) {}
public Semaphore(int permits, boolean fair){} // 可以指定是否公平
permits指定了訊號量的准入書,也就是同時能申請多少個許可。當每個線程每次只申請一個許可時,這就相當於指定了同時有多少個線程可以訪問某一個資源。這裡羅列一下主要方法的使用:
public void acquire() throws InterruptedException {} //嘗試獲得一個準入的許可。若無法獲得,則線程會等待,知道有線程釋放一個許可或者當前線程被中斷。
public void acquireUninterruptibly(){} // 類似於acquire(),但是不會響應中斷。
public boolean tryAcquire(){} // 嘗試擷取,如果成功則為true,否則false。這個方法不會等待,立即返回。
public boolean tryAcquire(long timeout, TimeUnit unit) throws InterruptedException {} // 嘗試等待多長時間
public void release() //用於在現場訪問資源結束後,釋放一個許可,以使其他等待許可的線程可以進行資源訪問。
下面來看一下JDK文檔中提供使用訊號量的執行個體。這個執行個體很好的解釋了如何通過訊號量控制資源訪問。
public class Pool {private static final int MAX_AVAILABLE = 100;private final Semaphore available = new Semaphore(MAX_AVAILABLE, true);public Object getItem() throws InterruptedException { available.acquire(); // 申請一個許可 // 同時只能有100個線程進入取得可用項, // 超過100個則需要等待 return getNextAvailableItem();}public void putItem(Object x) { // 將給定項放回池內,標記為未被使用 if (markAsUnused(x)) { available.release(); // 新增了一個可用項,釋放一個許可,請求資源的線程被啟用一個 }}// 僅作樣本參考,非真實資料protected Object[] items = new Object[MAX_AVAILABLE]; // 用於對象池複用對象protected boolean[] used = new boolean[MAX_AVAILABLE]; // 標記作用protected synchronized Object getNextAvailableItem() { for (int i = 0; i < MAX_AVAILABLE; ++i) { if (!used[i]) { used[i] = true; return items[i]; } } return null;}protected synchronized boolean markAsUnused(Object item) { for (int i = 0; i < MAX_AVAILABLE; ++i) { if (item == items[i]) { if (used[i]) { used[i] = false; return true; } else { return false; } } } return false;}}
此執行個體簡單實現了一個對象池,對象池最大容量為100。因此,當同時有100個對象請求時,對象池就會出現資源短缺,未能獲得資源的線程就需要等待。當某個線程使用對象完畢後,就需要將對象返回給對象池。此時,由於可用資源增加,因此,可以啟用一個等待該資源的線程。
ThreadLocal線程局部變數
在剛開始接觸ThreadLocal,筆者很難理解這個線程局部變數的使用情境。當現在回過頭去看,ThreadLocal是一種多線程間並發訪問變數的解決方案。與synchronized等加鎖的方式不同,ThreadLocal完全不提供鎖,而使用了以空間換時間的手段,為每個線程提供變數的獨立副本,以保障安全執行緒,因此它不是一種資料共用的解決方案。
ThreadLocal是解決安全執行緒問題一個很好的思路,ThreadLocal類中有一個Map,用於儲存每一個線程的變數副本,Map中元素的鍵為線程對象,而值對應線程的變數副本,由於Key值不可重複,每一個“線程對象”對應線程的“變數副本”,而到達了安全執行緒。
特別值得注意的地方,從效能上說,ThreadLocal並不具有絕對的又是,在並發量不是很高時,也行加鎖的效能會更好。但作為一套與鎖完全無關的安全執行緒解決方案,在高並發量或者所競爭激烈的場合,使用ThreadLocal可以在一定程度上減少鎖競爭。
下面是一個ThreadLocal的簡單使用:
public class TestNum { // 通過匿名內部類覆蓋ThreadLocal的initialValue()方法,指定初始值 private static ThreadLocal seqNum = new ThreadLocal() { public Integer initialValue() { return 0; } }; // 擷取下一個序列值 public int getNextNum() { seqNum.set(seqNum.get() + 1); return seqNum.get();}public static void main(String[] args) { TestNum sn = new TestNum(); //3個線程共用sn,各自產生序號 TestClient t1 = new TestClient(sn); TestClient t2 = new TestClient(sn); TestClient t3 = new TestClient(sn); t1.start(); t2.start(); t3.start(); }private static class TestClient extends Thread { private TestNum sn;public TestClient(TestNum sn) { this.sn = sn; }public void run() { for (int i = 0; i < 3; i++) { // 每個線程打出3個序列值 System.out.println("thread[" + Thread.currentThread().getName() + "] --> sn[" + sn.getNextNum() + "]"); } } } }
輸出結果:
thread[Thread-0] –> sn[1]
thread[Thread-1] –> sn[1]
thread[Thread-2] –> sn[1]
thread[Thread-1] –> sn[2]
thread[Thread-0] –> sn[2]
thread[Thread-1] –> sn[3]
thread[Thread-2] –> sn[2]
thread[Thread-0] –> sn[3]
thread[Thread-2] –> sn[3]
輸出的結果資訊可以發現每個線程所產生的序號雖然都共用同一個TestNum執行個體,但它們並沒有發生相互幹擾的情況,而是各自產生獨立的序號,這是因為ThreadLocal為每一個線程提供了單獨的副本。
鎖的效能和最佳化
“鎖”是最常用的同步方法之一。在平常開發中,經常能看到很多同學直接把鎖加很大一段代碼上。還有的同學只會用一種鎖方式解決所有共用問題。顯然這樣的編碼是讓人無法接受的。特別的在高並發的環境下,激烈的鎖競爭會導致程式的效能下降德更加明顯。因此合理使用鎖對程式的效能直接相關。
1、線程的開銷
在多核情況下,使用多線程可以明顯提高系統的效能。但是在實際情況中,使用多線程的方式會額外增加系統的開銷。相對於單核系統任務本身的資源消耗外,多線程應用還需要維護額外多線程特有的資訊。比如,線程本身的中繼資料,線程調度,線程內容相關的切換等。
2、減小鎖持有時間
在使用鎖進行並發控制的程式中,當鎖發生競爭時,單個線程對鎖的持有時間與系統效能有著直接的關係。如果線程持有鎖的時間很長,那麼相對地,鎖的競爭程度也就越激烈。因此,在程式開發過程中,應該儘可能地減少對某個鎖的佔有時間,以減少線程間互斥的可能。比如下面這一段代碼:
public synchronized void syncMehod(){beforeMethod();mutexMethod();afterMethod();}
此執行個體如果只有mutexMethod()方法是有同步需要的,而在beforeMethod(),和afterMethod()並不需要做同步控制。如果beforeMethod(),和afterMethod()分別是重量級的方法,則會花費較長的CPU時間。在這個時候,如果並發量較大時,使用這種同步方案會導致等待線程大量增加。因為當前執行的線程只有在執行完所有任務後,才會釋放鎖。
下面是最佳化後的方案,只在必要的時候進行同步,這樣就能明顯減少線程持有鎖的時間,提高系統的輸送量。代碼如下:
public void syncMehod(){beforeMethod();synchronized(this){mutexMethod();}afterMethod();}
3、減少鎖粒度
減小鎖粒度也是一種削弱多線程鎖競爭的一種有效手段,這種技術典型的使用情境就是ConcurrentHashMap這個類。在普通的HashMap中每當對集合進行add()操作或者get()操作時,總是獲得集合對象的鎖。這種操作完全是一種同步行為,因為鎖是在整個集合對象上的,因此,在高並發時,激烈的鎖競爭會影響到系統的輸送量。
如果看過源碼的同學應該知道HashMap是數組+鏈表的方式做實現的。ConcurrentHashMap在HashMap的基礎上將整個HashMap分成若干個段(Segment),每個段都是一個子HashMap。如果需要在增加一個新的表項,並不是將這個HashMap加鎖,二十搜線根據hashcode得到該表項應該被存放在哪個段中,然後對該段加鎖,並完成put()操作。這樣,在多線程環境中,如果多個線程同時進行寫入操作,只要被寫入的項不存在同一個段中,那麼線程間便可以做到真正的並行。具體的實現希望讀者自己花點時間讀一讀ConcurrentHashMap這個類的源碼,這裡就不再做過多描述了。
4、鎖分離
在前面提起過ReadWriteLock讀寫鎖,那麼讀寫分離的延伸就是鎖的分離。同樣可以在JDK中找到鎖分離的源碼LinkedBlockingQueue。
public class LinkedBlockingQueue extends AbstractQueueimplements BlockingQueue, java.io.Serializable {/* Lock held by take, poll, etc /private final ReentrantLock takeLock = new ReentrantLock();/** Wait queue for waiting takes */private final Condition notEmpty = takeLock.newCondition();/** Lock held by put, offer, etc */private final ReentrantLock putLock = new ReentrantLock();/** Wait queue for waiting puts */private final Condition notFull = putLock.newCondition();public E take() throws InterruptedException { E x; int c = -1; final AtomicInteger count = this.count; final ReentrantLock takeLock = this.takeLock; takeLock.lockInterruptibly(); // 不能有兩個線程同時讀取資料 try { while (count.get() == 0) { // 如果當前沒有可用資料,一直等待put()的通知 notEmpty.await(); } x = dequeue(); // 從頭部移除一項 c = count.getAndDecrement(); // size減1 if (c > 1) notEmpty.signal(); // 通知其他take()操作 } finally { takeLock.unlock(); // 釋放鎖 } if (c == capacity) signalNotFull(); // 通知put()操作,已有空餘空間 return x;}public void put(E e) throws InterruptedException { if (e == null) throw new NullPointerException(); // Note: convention in all put/take/etc is to preset local var // holding count negative to indicate failure unless set. int c = -1; Node<E> node = new Node(e); final ReentrantLock putLock = this.putLock; final AtomicInteger count = this.count; putLock.lockInterruptibly(); // 不能有兩個線程同時put資料 try { /* * Note that count is used in wait guard even though it is * not protected by lock. This works because count can * only decrease at this point (all other puts are shut * out by lock), and we (or some other waiting put) are * signalled if it ever changes from capacity. Similarly * for all other uses of count in other wait guards. */ while (count.get() == capacity) { // 隊列滿了 則等待 notFull.await(); } enqueue(node); // 排入佇列 c = count.getAndIncrement();// size加1 if (c + 1 < capacity) notFull.signal(); // 如果有足夠空間,通知其他線程 } finally { putLock.unlock();// 釋放鎖 } if (c == 0) signalNotEmpty();// 插入成功後,通知take()操作讀取資料}// other code }
這裡需要說明一下的就是,take()和put()函數是相互獨立的,它們之間不存在鎖競爭關係。只需要在take()和put()各自方法內部分別對takeLock和putLock發生競爭。從而,削弱了鎖競爭的可能性。
5、鎖粗化
上面說到的減小鎖時間和粒度,這樣做就是為了滿足每個線程持有鎖的時間盡量短。但是,在粒度上應該把握一個度,如果對用一個鎖不停地進行請求、同步和釋放,其本身也會消耗系統寶貴的資源,反而加大了系統開銷。
我們需要知道的是,虛擬機器在遇到一連串聯續的對同一鎖不斷進行請求和釋放的操作時,便會把所有的鎖操作整合成對鎖的一次請求,從而減少對鎖的請求同步次數,這樣的操作叫做鎖的粗化。下面是一段整合執行個體示範:
public void syncMehod(){synchronized(lock){method1();}synchronized(lock){method2();}} JVM整合後的形式: public void syncMehod(){synchronized(lock){method1();method2();}}
因此,這樣的整合給我們開發人員對鎖粒度的把握給出了很好的示範作用。
無鎖的並行計算
上面花了很大篇幅在說鎖的事情,同時也提到過鎖是會帶來一定的環境切換的額外資源開銷,在高並發時,”鎖“的激烈競爭可能會成為系統瓶頸。因此,這裡可以使用一種非阻塞同步方法。這種無鎖方式依然能保證資料和程式在高並發環境下保持多線程間的一致性。
1、非阻塞同步/無鎖
非阻塞同步方式其實在前面的ThreadLocal中已經有所體現,每個線程擁有各自獨立的變數副本,因此在並行計算時,無需相互等待。這裡筆者主要推薦一種更為重要的、基於比較並交換(Compare And Swap)CAS演算法的無鎖並發控制方法。
CAS演算法的過程:它包含3個參數CAS(V,E,N)。V表示要更新的變數,E表示預期值,N表示新值。僅當V值等於E值時,才會將V的值設為N,如果V值和E值不同,則說明已經有其他線程做了更新,則當前線程什麼都不做。最後CAS返回當前V的真實值。CAS操作時抱著樂觀的態度進行的,它總是認為自己可以成功完成操作。當多個線程同時使用CAS操作一個變數時,只有一個會勝出,並成功更新,其餘俊輝失敗。失敗的線程不會被掛起,僅是被告知失敗,並且允許再次嘗試,當然也允許失敗的線程放棄操作。基於這樣的原理,CAS操作及時沒有鎖,也可以發現其他線程對當前線程的幹擾,並且進行恰當的處理。
2、原子量操作
JDK的java.util.concurrent.atomic包提供了使用無鎖演算法實現的原子操作類,代碼內部主要使用了底層native代碼的實現。有興趣的同學可以繼續跟蹤一下native層面的代碼。這裡就不貼表層的代碼實現了。
下面主要以一個例子來展示普通同步方法和無鎖同步的效能差距:
public class TestAtomic {private static final int MAX_THREADS = 3;private static final int TASK_COUNT = 3;private static final int TARGET_COUNT = 100 * 10000;private AtomicInteger acount = new AtomicInteger(0);private int count = 0;synchronized int inc() { return ++count;}synchronized int getCount() { return count;}public class SyncThread implements Runnable { String name; long startTime; TestAtomic out; public SyncThread(TestAtomic o, long startTime) { this.out = o; this.startTime = startTime; } @Override public void run() { int v = out.inc(); while (v < TARGET_COUNT) { v = out.inc(); } long endTime = System.currentTimeMillis(); System.out.println("SyncThread spend:" + (endTime - startTime) + "ms" + ", v=" + v); }}public class AtomicThread implements Runnable { String name; long startTime; public AtomicThread(long startTime) { this.startTime = startTime; } @Override public void run() { int v = acount.incrementAndGet(); while (v < TARGET_COUNT) { v = acount.incrementAndGet(); } long endTime = System.currentTimeMillis(); System.out.println("AtomicThread spend:" + (endTime - startTime) + "ms" + ", v=" + v); }}@Testpublic void testSync() throws InterruptedException { ExecutorService exe = Executors.newFixedThreadPool(MAX_THREADS); long startTime = System.currentTimeMillis(); SyncThread sync = new SyncThread(this, startTime); for (int i = 0; i < TASK_COUNT; i++) { exe.submit(sync); } Thread.sleep(10000);}@Testpublic void testAtomic() throws InterruptedException { ExecutorService exe = Executors.newFixedThreadPool(MAX_THREADS); long startTime = System.currentTimeMillis(); AtomicThread atomic = new AtomicThread(startTime); for (int i = 0; i < TASK_COUNT; i++) { exe.submit(atomic); } Thread.sleep(10000);}}
測試結果如下:
testSync():
SyncThread spend:201ms, v=1000002
SyncThread spend:201ms, v=1000000
SyncThread spend:201ms, v=1000001
testAtomic():
AtomicThread spend:43ms, v=1000000
AtomicThread spend:44ms, v=1000001
AtomicThread spend:46ms, v=1000002
相信這樣的測試結果將內部鎖和非阻塞同步演算法的效能差異體現的非常明顯。因此筆者更推薦直接視同atomic下的這個原子類。
結束語
終於把想表達的這些東西整理完成了,其實還有一些想CountDownLatch這樣的類沒有講到。不過上面的所講到的絕對是並發編程中的核心。也許有些讀者朋友能在網上看到很多這樣的知識點,但是個人還是覺得知識只有在對比的基礎上才能找到它合適的使用情境。因此,這也是小編整理這篇文章的原因,也希望這篇文章能幫到更多的同學。
以上就是本文的全部內容,希望對大家的學習有所協助,也希望大家多多支援雲棲社區。