前言
本章節主要分享下,多線程並發在電商系統下的應用。主要從以下幾個方面深入:線程相關的基礎理論和工具、多線程程序下的性能調優和電商場景下多線程的使用。
多線程J·U·C
線程池
概念
回顧線程創建的方式
- 繼承Thread
- 實現Runnable
- 使用FutureTask
線程狀態
NEW:剛剛創建,沒做任何操作
RUNNABLE:調用run,可以執行,但不代表一定在執行(RUNNING,READY)
WATING:使用了waite(),join()等方法
TIMED_WATING:使用了sleep(long),wait(long),join(long)等方法
BLOCKED:搶不到鎖
TERMINATED:終止
線程池基本概念
根據上面的狀態,普通線程執行完,就會進入TERMINA TED銷毀掉,而線程池就是創建一個緩沖池存放線程,執行結束以后,該線程並不會死亡,而是再次返回線程池中成為空閑狀態,等候下次任務來臨,這使得線程池比手動創建線程有着更多的優勢:
- 降低系統資源消耗,通過重用已存在的線程,降低線程創建和銷毀造成的消耗;
- 提高系統響應速度,當有任務到達時,通過復用已存在的線程,無需等待新線程的創建便能立即執行;
- 方便線程並發數的管控。因為線程若是無限制的創建,可能會導致內存占用過多而產生OOM
- 節省cpu切換線程的時間成本(需要保持當前執行線程的現場,並恢復要執行線程的現場)。
- 提供更強大的功能,延時定時線程池。(Timer vs ScheduledThreadPoolExecutor)
常用線程池類結構
說明:
- 最常用的是ThreadPoolExecutor
- 調度用的ScheduledThreadPoolExecutor
- Executors是工具類,協助創建線程池
工作機制
在線程池的編程模式下,任務是提交給整個線程池,而不是直接提交給某個線程,線程池在拿到任務后,就在內部尋找是否有空閑的線程,如果有,則將任務交給某個空閑的線程。一個線程同時只能執行一個任務,但可以同時向一個線程池提交多個任務。
線程池狀態
-
RUNNING:初始化狀態是RUNNING。線程池被一旦被創建,就處於RUNNING狀態,並且線程池中的任務數為0。RUNNING狀態下,能夠接收新任務,以及對已添加的任務進行處理。
-
SHUTDOWN:SHUTDOWN狀態時,不接收新任務,但能處理已添加的任務。調用線程池的shutdown()接口時,線程池由RUNNING -> SHUTDOWN。
//shutdown后不接受新任務,但是task1,仍然可以執行完成 ExecutorService poolExecutor = Executors.newFixedThreadPool(5); poolExecutor.execute(new Runnable() { public void run() { try { Thread.sleep(1000); System.out.println("finish task 1"); } catch (InterruptedException e) { e.printStackTrace(); } } }); poolExecutor.shutdown(); poolExecutor.execute(new Runnable() { public void run() { try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); } } }); System.out.println("ok");
-
STOP:不接收新任務,不處理已添加的任務,並且會中斷正在處理的任務。調用線程池的shutdownNow()接口時,線程池由(RUNNING 或SHUTDOWN ) -> STOP
//改為shutdownNow后,任務立馬終止,sleep被打斷,新任務無法提交,task1停止 poolExecutor.shutdownNow();
-
TIDYING:所有的任務已終止,ctl記錄的”任務數量”為0,線程池會變為TIDYING。線程池變為TIDYING狀態時,會執行鈎子函數terminated(),可以通過重載terminated()函數來實現自定義行為
//自定義類,重寫terminated方法 public class MyExecutorService extends ThreadPoolExecutor { public MyExecutorService(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue) { super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue); } @Override protected void terminated() { super.terminated(); System.out.println("treminated"); } //調用 shutdownNow, ternimated方法被調用打印 public static void main(String[] args) throws InterruptedException { MyExecutorService service = new MyExecutorService(1,2,10000,TimeUnit.SECONDS,new LinkedBlockingQueue<Runnable>(5)); service.shutdownNow(); } }
-
TERMINA TED:線程池處在TIDYING狀態時,執行完terminated()之后,就會由TIDYING ->TERMINA TED
結構說明
任務提交流程
- 添加任務,如果線程池中的線程數沒有達到coreSize,會創建線程執行任務
- 當達到coreSize,把任務放workQueue中
- 當queue滿了,未達maxsize創建心線程
- 線程數也達到maxsize,再添加任務會執行reject策略
- 任務執行完畢,超過keepactivetime,釋放超時的非核心線程,最終恢復到coresize大小
源碼剖析
execute方法
//任務提交階段
public void execute(Runnable command) {
if (command == null)
throw new NullPointerException();
int c = ctl.get();
//判斷當前workers中的線程數量有沒有超過核心線程數
if (workerCountOf(c) < corePoolSize) {
//如果沒有則創建核心線程數(參數true指的就是核心線程)
if (addWorker(command, true))
return;
c = ctl.get();
}
//如果超過核心線程數了 先校驗線程池是否正常運行后向阻塞隊列workQueue末尾添加任務
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
//再次檢查線程池運行狀態,若不在運行則移除該任務並且執行拒絕策略
if (! isRunning(recheck) && remove(command))
reject(command);
//若果沒有線程在執行
else if (workerCountOf(recheck) == 0)
//則創建一個空的worker 該worker從隊列中獲取任務執行
addWorker(null, false);
}
//否則直接添加非核心線程執行任務 若非核心線程也添加失敗 則執行拒絕策略
else if (!addWorker(command, false))
reject(command);
}
線程創建:addWorker()方法
//addWorker通過cas保證了並發安全性
private boolean addWorker(Runnable firstTask, boolean core) {
//第一部分 計數判斷,不符合返回false
retry:
for (;;) {
int c = ctl.get();
int rs = runStateOf(c);
// Check if queue empty only if necessary.
if (rs >= SHUTDOWN &&
! (rs == SHUTDOWN &&
firstTask == null &&
! workQueue.isEmpty()))
return false;
for (;;) {
int wc = workerCountOf(c);
//判斷線程數,最大29位(CAPACITY=29位二進制),所以設置線程池的線程數不是任意大的
if (wc >= CAPACITY ||
//判斷工作中的核心線程是否大於設置的核心線程或者設置的最大線程數
wc >= (core ? corePoolSize : maximumPoolSize))
return false;
//通過cas新增 若添加失敗會一直重試 若成功則跳過結束retry
if (compareAndIncrementWorkerCount(c))
break retry;
c = ctl.get(); // Re-read ctl
//再次判斷運行狀態 若運行狀態改變則繼續重試
if (runStateOf(c) != rs)
continue retry;
// else CAS failed due to workerCount change; retry inner loop
}
}
//第二部分:創建新的work放入works(一個hashSet)
boolean workerStarted = false;
boolean workerAdded = false;
Worker w = null;
try {
//將task任務封裝在新建的work中
w = new Worker(firstTask);
//獲取正在執行該任務的線程
final Thread t = w.thread;
if (t != null) {
final ReentrantLock mainLock = this.mainLock;
mainLock.lock();
try {
// Recheck while holding lock.
// Back out on ThreadFactory failure or if
// shut down before lock acquired.
int rs = runStateOf(ctl.get());
if (rs < SHUTDOWN ||
(rs == SHUTDOWN && firstTask == null)) {
if (t.isAlive()) // precheck that t is startable
throw new IllegalThreadStateException();
//將work加入到workers中
workers.add(w);
int s = workers.size();
if (s > largestPoolSize)
largestPoolSize = s;
workerAdded = true;
}
} finally {
mainLock.unlock();
}
if (workerAdded) {
//上述work添加成功了,就開始執行任務操作了
t.start();
workerStarted = true;
}
}
} finally {
//如果上述添加任務失敗了,會執行移除該任務操作
if (! workerStarted)
addWorkerFailed(w);
}
return workerStarted;
}
獲取任務getTask()方法
private Runnable getTask() {
boolean timedOut = false; // Did the last poll() time out?
for (;;) {
int c = ctl.get();
int rs = runStateOf(c);
// Check if queue empty only if necessary.
if (rs >= SHUTDOWN && (rs >= STOP || workQueue.isEmpty())) {
decrementWorkerCount();
return null;
}
int wc = workerCountOf(c);
// Are workers subject to culling?
//這里判斷是否要做超時處理,這里決定了當前線程是否要被釋放
boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;
//檢查當前worker中線程數量是否超過max 並且上次循環poll等待超時了,則將隊列數量進行原子性減
if ((wc > maximumPoolSize || (timed && timedOut))
&& (wc > 1 || workQueue.isEmpty())) {
if (compareAndDecrementWorkerCount(c))
return null;
continue;
}
try {
//線程可以被釋放,那就是poll,釋放時間就是keepAliveTime
//否則,線程不會被釋放,take一直阻塞在這里,直至新任務繼續工作
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();
if (r != null)
return r;
//到這里說明可被釋放的線程等待超時,已經銷毀,設置該標記,下次循環將線程數減少
timedOut = true;
} catch (InterruptedException retry) {
timedOut = false;
}
}
}
注意點
線程池是如何保證不被銷毀的
當隊列中沒有任務時,核心線程會一直阻塞獲取任務的方法,直至獲取到任務再次執行
線程池中的線程會處於什么狀態
WAITING , TIMED_WAITING ,RUNNABLE
核心線程與非核心線程有本質區別嗎?
答案:沒有。被銷毀的線程和創建的先后無關。即便是第一個被創建的核心線程,仍然有可能被銷毀
驗證:看源碼,每個works在runWork的時候去getTask,在getTask內部,並沒有針對性的區分當前work是否是核心線程或者類似的標記。只要判斷works數量超出core,就會調用poll(),否則take()
鎖
鎖的分類
1)樂觀鎖/悲觀鎖
樂觀鎖顧名思義,很樂觀的認為每次讀取數據的時候總是認為沒人動過,所以不去加鎖。但是在更新的時候回去對比一下原來的值,看有沒有被別人更改過。適用於讀多寫少的場景。mysql中類比version號更新java中的atomic包屬於樂觀鎖實現,即CAS(下節會詳細介紹)
悲觀鎖在每次讀取數據的時候都認為其他人會修改數據,所以讀取數據的時候也加鎖,這樣別人想拿的時候就會阻塞,直到這個線程釋放鎖,這就影響了並發性能。適合寫操作比較多的場景。mysql中類比for update。synchronized實現就是悲觀鎖(1.6之后優化為鎖升級機制),悲觀鎖書寫不當很容易影響性能。
2)獨享鎖/共享鎖
很好理解,獨享鎖是指該鎖一次只能被一個線程所持有,而共享鎖是指該鎖可被多個線程所持有。
案例一:ReentrantLock,獨享鎖
public class PrivateLock {
Lock lock = new ReentrantLock();
long start = System.currentTimeMillis();
void read() {
lock.lock();
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
lock.unlock();
}
System.out.println("read time = "+(System.currentTimeMillis() - start));
}
public static void main(String[] args) {
final PrivateLock lock = new PrivateLock();
for (int i = 0; i < 10; i++) {
new Thread(new Runnable() {
public void run() {
lock.read();
}
}).start();
}
}
}
結果分析:每個線程結束的時間點逐個上升,鎖被獨享,一個用完下一個,依次獲取鎖
案例二:ReadWriteLock,read共享,write獨享
public class SharedLock {
ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
Lock lock = readWriteLock.readLock();
long start = System.currentTimeMillis();
void read() {
lock.lock();
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}finally {
lock.unlock();
}
System.out.println("end time = "+(System.currentTimeMillis() - start));
}
public static void main(String[] args) {
final SharedLock lock = new SharedLock();
for (int i = 0; i < 10; i++) {
new Thread(new Runnable() {
public void run() {
lock.read();
}
}).start();
}
}
}
結果分析:每個線程獨自跑,各在100ms左右,證明是共享的
案例三:同樣是上例,換成writeLock
Lock lock = readWriteLock.writeLock();
結果分析:恢復到了1s時長,變為獨享
小結:
- 讀鎖的共享鎖可保證並發讀是非常高效的,讀寫,寫讀 ,寫寫的過程是互斥的。
- 獨享鎖與共享鎖也是通過AQS來實現的,通過實現不同的方法,來實現獨享或者共享。
3)分段鎖
從Map一家子說起....
HashMap是線程不安全的,在多線程環境下,使用HashMap進行put操作時,可能會引起死循環,導致CPU利用率接近100%,所以在並發情況下不能使用HashMap。
於是有了HashT able,HashT able是線程安全的。但是HashT able線程安全的策略實在不怎么高明,將get/put所有相關操作都整成了synchronized的。
那有沒有辦法做到線程安全,又不這么粗暴呢?基於分段鎖的ConcurrentHashMap誕生...
ConcurrentHashMap使用Segment(分段鎖)技術,將數據分成一段一段的存儲,Segment數組的意義就是將一個大的table分割成多個小的table來進行加鎖,Segment數組中每一個元素一把鎖,每一個Segment元素存儲的是HashEntry數組+鏈表,這個和HashMap的數據存儲結構一樣。當訪問其中一個段數據被某個線程加鎖的時候,其他段的數據也能被其他線程訪問,這就使得ConcurrentHashMap不僅保證了線程安全,而且提高了性能。
但是這也引來一個負面影響:ConcurrentHashMap 定位一個元素的過程需要進行兩次Hash操作,第一次Hash 定位到Segment,第二次Hash 定位到元素所在的鏈表。所以Hash 的過程比普通的HashMap 要長。
備注:JDK1.8ConcurrentHashMap中拋棄了原有的Segment 分段鎖,而采用了 CAS + synchronized來保證並發安全性。
4)可重入鎖
可重入鎖指的獲取到鎖后,如果同步塊內需要再次獲取同一把鎖的時候,直接放行,而不是等待。其意義在於防止死鎖。前面使用的synchronized 和ReentrantLock 都是可重入鎖。
實現原理實現是通過為每個鎖關聯一個請求計數器和一個占有它的線程。如果同一個線程再次請求這個鎖,計數器將遞增,線程退出同步塊,計數器值將遞減。直到計數器為0鎖被釋放。
場景見於父類和子類的鎖的重入(調super方法),以及多個加鎖方法的嵌套調用。
案例一:父子可重入
public class ParentLock {
byte[] lock = new byte[0];
public void f1(){
synchronized (lock){
System.out.println("f1 from parent");
}
}
}
public class SonLock extends ParentLock {
public void f1() {
synchronized (super.lock){
super.f1();
System.out.println("f1 from son");
}
}
public static void main(String[] args) {
SonLock lock = new SonLock();
lock.f1();
}
}
案例二:內嵌方法可重入
public class NestedLock {
public synchronized void f1(){
System.out.println("f1");
}
public synchronized void f2(){
f1();
System.out.println("f2");
}
public static void main(String[] args) {
NestedLock lock = new NestedLock();
//可以正常打印 f1,f2
lock.f2();
}
}
5)公平鎖/非公平鎖
基本概念:
公平鎖就是在並發環境中,每個線程在獲取鎖時會先查看此鎖維護的等待隊列,如果為空,或者當前線程是等待隊列的第一個,就占有鎖,否則就會加入到等待隊列中,直到按照FIFO的規則從隊列中取到自己。
非公平鎖與公平鎖基本類似,只是在放入隊列前先判斷當前鎖是否被線程持有。如果鎖空閑,那么他可以直接搶占,而不需要判斷當前隊列中是否有等待線程。只有鎖被占用的話,才會進入排隊。在現實中想象一下游樂場旋轉木馬插隊現象......
優缺點:
公平鎖的優點是等待鎖的線程不會餓死,進入隊列規規矩矩的排隊,遲早會輪到。缺點是整體吞吐效率相對非公平鎖要低,等待隊列中除第一個線程以外的所有線程都會阻塞,CPU喚醒阻塞線程的開銷比非公平鎖大。
非公平鎖的性能要高於公平鎖,因為線程有幾率不阻塞直接獲得鎖。ReentrantLock默認使用非公平鎖就是基於性能考量。但是非公平鎖的缺點是可能引發隊列中的線程始終拿不到鎖,一直排隊被餓死。
編碼方式:
很簡單,ReentrantLock支持創建公平鎖和非公平鎖(默認),想要實現公平鎖,使用new ReentrantLock(true)。
背后原理:
AQS,后面還會詳細講到。AQS中有一個state標識鎖的占用情況,一個隊列存儲等待線程。
state=0表示鎖空閑。如果是公平鎖,那就看看隊列有沒有線程在等,有的話不參與競爭乖乖追加到尾部。如果是非公平鎖,那就直接參與競爭,不管隊列有沒有等待者。
state>0表示有線程占着鎖,這時候無論公平與非公平,都直接去排隊(想搶也沒有)
備注:
因為ReentrantLock是可重入鎖,數量表示重入的次數。所以是>0而不是簡單的0和1而synchronized只能是非公平鎖
6)鎖升級
java中每個對象都可作為鎖,鎖有四種級別,按照量級從輕到重分為:無鎖、偏向鎖、輕量級鎖、重量級鎖。
如何理解呢?A占了鎖,B就要阻塞等。但是,在操作系統中,阻塞就要存儲當前線程狀態,喚醒就要再恢復,這個過程是要消耗時間的...
如果A使用鎖的時間遠遠小於B被阻塞和掛起的執行時間,那么我們將B掛起阻塞就相當的不合算。
於是出現自旋:自旋指的是鎖已經被其他線程占用時,當前線程不會被掛起,而是在不停的試圖獲取鎖(可以理解為不停的循環),每循環一次表示一次自旋過程。顯然這種操作會消耗CPU時間,但是相比線程下文切換時間要少的時候,自旋划算。
而偏向鎖、輕量鎖、重量鎖就是圍繞如何使得cpu的占用更划算而展開的。
舉個生活的例子,假設公司只有一個會議室(共享資源)
偏向鎖:
前期公司只有1個團隊,那么什么時候開會都能滿足,就不需要詢問和查看會議室的占用情況,直接進入使用
狀態。會議室門口掛了個牌子寫着A使用,A默認不需要預約(ThreadID=A)
輕量級鎖:
隨着業務發展,擴充為2個團隊,B團隊肯定不會同意A無法無天,於是當AB同時需要開會時,兩者競爭,誰搶
到誰算誰的。偏向鎖升級為輕量級鎖,但是未搶到者在門口會不停敲門詢問(自旋,循環),開完沒有?開完
沒有?
重量級鎖:
后來發現,這種不停敲門的方式很煩,A可能不理不睬,但是B要不停的鬧騰。於是鎖再次升級。
如果會議室被A占用,那么B團隊直接閉嘴,在門口安靜的等待(wait進入阻塞),直到A用完后會通知
B(notify)。
注意點:
-
上面幾種鎖都是JVM自己內部實現,我們不需要干預,但是可以配置jvm參數開啟/關閉自旋鎖、偏
向鎖。 -
鎖可以升級,但是不能反向降級:偏向鎖→輕量級鎖→重量級鎖
-
無鎖爭用的時候使用偏向鎖,第二個線程到了升級為輕量級鎖進行競爭,更多線程時,進入重量級鎖阻塞
鎖 | 優點 | 缺點 | 使用場景 |
---|---|---|---|
偏向鎖 | 加鎖和解鎖不需要CAS操作,沒有額外的性能消耗,和執行非同步方法相比僅存在納秒級的差距 | 若線程間存在鎖競爭,會帶來額外的鎖撤銷的消耗 | 只有一個線程訪問同步塊或者同步方法 |
輕量鎖 | 競爭的線程不會阻塞,提高了程序的響應速度 | 若線程長時間競爭不到鎖,自旋會消耗CPU 性能 | 線程交替執行同步塊或者同步方法,追求響應時間,鎖占用時間很短,阻塞還不如自旋的場景 |
重量鎖 | 線程競爭不使用自旋,不會消耗CPU | 線程阻塞,響應時間緩慢,在多線程下,頻繁的獲取釋放鎖,會帶來巨大的性能消耗 | 追求吞吐量,鎖占用時間較長 |
7)互斥鎖/讀寫鎖
典型的互斥鎖:synchronized,ReentrantLock,讀寫鎖:ReadWriteLock 前面都用過了互斥鎖屬於獨享鎖,讀寫鎖里的寫鎖屬於獨享鎖,而讀鎖屬於共享鎖
案例:互斥鎖用不好可能會失效,看一個典型的鎖不住現象!
public class ObjectLock {
public static Integer i=0;
public void inc(){
synchronized (this){
int j=i;
try {
Thread.sleep(100);
j++;
} catch (InterruptedException e) {
e.printStackTrace();
}
i=j;
}
}
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 10; i++) {
new Thread(new Runnable() {
public void run() {
//重點!
new ObjectLock().inc();
}
}).start();
}
Thread.sleep(3000);
//理論上10才對。可是....
System.out.println(ObjectLock.i);
}
}
結果分析:每個線程內都是new對象,所以this不是同一把鎖,結果鎖不住,輸出1
1.this,換成static的i 變量試試?
2.換成ObjectLock.class 試試?
3.換成String.class
4.去掉synchronized塊,外部方法上加static synchronized
原子操作(atomic)
概念
原子(atom)本意是“不能被進一步分割的最小粒子”,而原子操作(atomic operation)意為"不可被中斷的一個或一系列操作" 。類比於數據庫事務,redis的multi。
CAS
Compare And Set(或Compare And Swap),翻譯過來就是比較並替換,CAS操作包含三個操作數——內存位置(V)、預期原值(A)、新值(B)。從第一視角來看,理解為:我認為位置V 應該是A,如果是A,則將B 放到這個位置;否則,不要更改,只告訴我這個位置現在的值即可。
計數器問題發生歸根結底是取值和運算后的賦值中間,發生了插隊現象,他們不是原子的操作。前面的計數器使用加鎖方式實現了正確計數,下面,基於CAS的原子類上場....
public class AtomicCounter {
private static AtomicInteger i = new AtomicInteger(0);
public int get(){
return i.get();
}
public void inc(){
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
i.incrementAndGet();
}
public static void main(String[] args) throws InterruptedException {
final AtomicCounter counter = new AtomicCounter();
for (int i = 0; i < 10; i++) {
new Thread(new Runnable() {
public void run() {
counter.inc();
}
}).start();
}
Thread.sleep(3000);
//同樣可以正確輸出10
System.out.println(counter.i.get());
}
}
atomic
上面展示了AtomicInteger,關於atomic包,還有很多其他類型:
基本類型
AtomicBoolean:以原子更新的方式更新boolean;
AtomicInteger:以原子更新的方式更新Integer;
AtomicLong:以原子更新的方式更新Long;
引用類型
AtomicReference : 原子更新引用類型
AtomicReferenceFieldUpdater :原子更新引用類型的字段
AtomicMarkableReference : 原子更新帶有標志位的引用類型
數組
AtomicIntegerArray:原子更新整型數組里的元素。
AtomicLongArray:原子更新長整型數組里的元素。
AtomicReferenceArray:原子更新引用類型數組里的元素。
字段
AtomicIntegerFieldUpdater:原子更新整型的字段的更新器。
AtomicLongFieldUpdater:原子更新長整型字段的更新器。
AtomicStampedReference:原子更新帶有版本號的引用類型。
注意
使用atomic要注意原子性的邊界,把握不好會起不到應有的效果,原子性被破壞。
public class BadAtomic {
AtomicInteger i = new AtomicInteger(0);
static int j=0;
public void badInc(){
int k = i.incrementAndGet();
try {
Thread.currentThread().sleep(new Random().nextInt(100));
} catch (InterruptedException e) {
e.printStackTrace();
}
j=k;
}
public static void main(String[] args) throws InterruptedException {
BadAtomic atomic = new BadAtomic();
for (int i = 0; i < 10; i++) {
new Thread(()->{
atomic.badInc();
}).start();
}
Thread.sleep(3000);
System.out.println(atomic.j);
}
}
結果分析:
每次都不一樣,總之不是10
在badInc上加synchronized,問題解決
這章節目前就介紹這么多,后續將擴展更多的多線程相關的類,以及從項目中解讀多線程的應用。