多線程(英語:multithreading),是指從軟件或者硬件上實現(xiàn)多個線程并發(fā)執(zhí)行的技術。具有多線程能力的計算機因有硬件支持而能夠在同一時間執(zhí)行多于一個線程,進而提升整體處理性能。具有這種能力的系統(tǒng)包括對稱多處理機、多核心處理器以及芯片級多處理或同時多線程處理器。
粗粒度交替多線程
一個線程持續(xù)運行,直到該線程被一個事件擋住而制造出長時間的延遲(可能是內存load/store操作,或者程序分支操作)[4]。
舉例來說
周期 i :接收線程 A 的指令 j周期 i+1:接收線程 A 的指令 j+1周期 i+2:接收線程 A 的指令 j+2,而這指令緩存失敗周期 i+3:線程調度器介入,切換到線程 B周期 i+4:接收線程 B 的指令 k周期 i+5:接收線程 B 的指令 k+1[5]硬件成本
此種多線程硬件支持的目標,是允許在擋住的線程與已就緒的線程中快速切換。
這些新增功能的硬件有這些優(yōu)勢:
線程切換能夠在一個 CPU 周期內完成(實際上可以沒有開銷,上個周期在運行線程A,下個周期就已在運行線程B)。這樣子看起來像是每個線程是獨自運行的,沒有其他線程與目前共享硬件資源。對操作系統(tǒng)來說,通常每個虛擬線程都被視做一個處理器。這樣就不需要很大的軟件變更(像是特別寫支持多線程的操作系統(tǒng))。為了要在各個現(xiàn)行中的線程有效率的切換,每個現(xiàn)行中的線程需要有自己的暫存設置(register set)。像是為了能在兩個線程中快速切換,硬件的寄存器需要兩次例示(instantiated)。
細粒度交替式多線程
執(zhí)行過程很像桶形處理器(Barrel Processor)就像這樣:
周期 i :接收線程 A 的一個指令周期 i+1:接收線程 B 的一個指令周期 i+2:接收線程 C 的一個指令這種線程的效果是會將所有從運行流水線中的資料從屬(data dependency)關系移除掉。因為每個線程是相對獨立,流水線中的一個指令層次結構需要從已跑完流水線中的較舊指令代入輸出的機會就相對的變小了。而在概念上,這種多線程與操作系統(tǒng)的核心先占多任務(pre-exemptive multitasking)相似。
1.基本概念
進程:在操作系統(tǒng)中運行的程序就是進程,比如:QQ、播放器等。
線程:一個進程可以有多個線程,如:視頻可以同時聽聲音、看圖像。
并行:多個cpu實例或者多臺機器同時執(zhí)行各自的處理邏輯,是真正的同時。
并發(fā):通過cpu調度算法,讓用戶看上去同時執(zhí)行,實際上從cpu操作層面不是真正的同時。
2.線程的生命周期
1)有新建(New)、就緒(Runnable)、運行(Running)、阻塞(Blocked)、死亡(Dead)共5種狀態(tài)。
新建狀態(tài):new關鍵字新建一個線程后,該線程就處于新建狀態(tài),此時僅由JVM為其分配內存,并初始化成員變量的值。
就緒狀態(tài):線程對象調用start()方法之后,該線程處于就緒狀態(tài)。Java虛擬機會為其創(chuàng)建方法調用棧和程序計數(shù)器,等待調度運行。
運行狀態(tài):處于就緒狀態(tài)的線程獲得了CPU,開始執(zhí)行run()方法的線程執(zhí)行體,此時該線程處于運行狀態(tài)。
阻塞狀態(tài):處于運行狀態(tài)的線程失去所占用資源后,便進入阻塞狀態(tài)。
死亡狀態(tài):線程run()方法執(zhí)行結束后進入死亡狀態(tài)。此外,如果線程執(zhí)行了interrupt()或stop()方法,也會以異常退出的方式進入死亡狀態(tài)。
2)線程狀態(tài)的控制:
start():啟動當前線程,自動調用當前線程的run()方法。
run():通常需要重寫Thread類中的此方法,將創(chuàng)建的線程要執(zhí)行的操作方法聲明在此方法中。
yield():釋放當前CPU的執(zhí)行權。
join():若線程a中調用線程b的join(),則線程a進入阻塞狀態(tài),直到線程b執(zhí)行完成后線程a才結束阻塞狀態(tài)。
sleep(long militime):讓線程睡眠指定的毫秒數(shù),指定時間內,線程是阻塞狀態(tài),不會釋放鎖。該方法可以再任何場景下調用。
wait():執(zhí)行此方法,當前線程會進入阻塞狀態(tài),并釋放同步監(jiān)視器(鎖)。該方法必須在同步代碼塊和同步方法中才能調用。
notify():喚醒被wait的一個線程,多個線程wait時,喚醒優(yōu)先度最高的。
notifyAll():喚醒所有被wait的線程。
LockSupport():LockSupport.park()和LockSupport.unpark()實現(xiàn)線程的阻塞和喚醒。
3.多線程的5種創(chuàng)建方式
① 繼承Thread類,重寫run()方法。
② 實現(xiàn)Runnable接口,重寫run()方法。
③ 匿名內部類的方式,重寫run()方法。相當于繼承了Thread類。new Thread(){ public void run(){邏輯功能} }.start();
④ Lambda表達式創(chuàng)建,相當于實現(xiàn)Runnable接口的方法。new Thread(()->{邏輯功能}).start();
⑤ 線程池創(chuàng)建。Executor pool = Executors.newFixedThreadPool(); pool.excute(new Runnable(){public void run(){邏輯功能}});
線程池創(chuàng)建的一些參數(shù):
corePoolSize:隊列沒滿時,線程最大并發(fā)數(shù)。
maximumPoolSizes:隊列滿后線程能夠達到的最大并發(fā)數(shù)。
keepAliveTime:空閑線程過多久被回收的時間限制。
unit:keepAliveTime的時間單位。
workQueue:阻塞的隊列類型。
RejectedExecutionHandler:超出maximumPoolSizes + workQueue時,任務會交給RejectedExecutionHandler來處理。
4.線程的同步
為了防止多個線程訪問一個數(shù)據對象時,對數(shù)據造成破壞,采用線程同步來保證多線程安全訪問競爭資源。
1)普通同步方法:synchronized關鍵字加在普通方法上,此時鎖就是當前實例對象,進入同步方法前要獲取當前實例的鎖。
2)靜態(tài)同步方法:synchronized關鍵字加在靜態(tài)方法上,此時鎖就是當前類的class對象,進入同步方法前要獲取當前類對象的鎖。
3)同步方法塊:synchronized關鍵字加在代碼塊前,小括號中指定鎖是什么,進入同步代碼塊前就需要獲取指定的鎖。
synchronized底層實現(xiàn):
數(shù)據在JVM內存的存儲:Java對象頭、moniter對象監(jiān)視器。
① 在JVM虛擬機中,對象在內存中的存儲布局分為三個區(qū)域:對象頭(Header)、實例數(shù)據(Instance Data)、對齊填充(Padding)。Java對象頭包括:類型指針(Klass Pointer)和標記字段(Mark Word)。類型指針是對象只想它的類元數(shù)據的指針,虛擬機通過這個指針來確定該對象是哪個類的實例。標記字段用于存儲對象自身的運行時數(shù)據,比如哈希碼、鎖狀態(tài)標志、線程持有的鎖等。所以,synchronized使用的鎖對象是存儲在Java對象頭里的標記字段里。
② moniter:對象監(jiān)視器可以類比為一個特殊的房間,房間中有一些被保護的數(shù)據,monitor保證每次只有一個線程能進入房間,進入即為持有monitor,退出即為釋放monitor。使用synchronized加鎖的同步代碼塊在字節(jié)碼引擎中執(zhí)行時,主要就是通過鎖對象monitor的取用(monitorenter)與釋放(monitorexit)來實現(xiàn)的。
5.多線程引入問題
1)線程安全問題
① 原子性:常通過synchronized或者ReentrantLock來保證原子性。
② 可見性:指一個線程修改了某個變量值,其他線程能夠立即得到這個修改的值。每個線程都有自己的工作內存,工作內存和主存間要通過store和load進行交互??梢娦詥栴}常使用volatile關鍵字解決。當一個共享變量被volatile修飾時,它會保證修改的值立即更新到主存,當其他線程需要讀取時,會去主存中讀取新值,而普通共享變量不能保證可見性,因為其被修改后刷新回主存的時間是不確定的。
2)線程死鎖
由于兩個或多個線程互相持有對方所需的資源,導致線程都處于等待狀態(tài),無法繼續(xù)執(zhí)行。
3)上下文切換
多線程有線程創(chuàng)建和線程上下文切換的開銷。CPU通常會給不同的線程分配時間片,當CPU從一個線程切換到另外一個線程的時候,CPU需要保存當前線程的本地數(shù)據,程序指針等狀態(tài),并加載下一個要執(zhí)行的線程的本地數(shù)據、程序指針等,這個切換就稱為上下文切換。通常使用無鎖并發(fā)編程、CAS算法、協(xié)程等方式解決。
6.使用ReentrantLock
Java語言直接提供了synchronized關鍵字用于加鎖,但這種鎖存在兩個問題:①很重,②獲取時必須一直等待,沒有額外的嘗試機制。java.util.concurrent.locks包提供的ReentrantLock用于替代synchronized加鎖。調用ReentrantLock()對象的lock()方法獲取鎖,最后調用unlock()方法手動釋放鎖。ReentrantLock是可重入鎖,和synchronized一樣,一個線程可以多次獲取同一個鎖。和synchronized不同的是,ReentrantLock可以嘗試獲取鎖,即:調用ReentrantLock()對象的tryLock()方法,其中傳入等待時間,時間單位,如果在這個時間后仍然沒有獲取到鎖,tryLock()方法會返回false,程序可以去做其他事情。
synchronized可以配合wait()和notify()實現(xiàn)線程在條件不滿足時等待,條件滿足時喚醒,而ReentrantLock則需要借助Condition對象來實現(xiàn),注意Condition對象必須來自于ReentrantLock()對象調用newCondition()方法,這樣才能獲得一個綁定了ReentrantLock實例的Condition實例。Condition提供了await()、signal()、signalAll()方法,與wait()、notify()、notifyAll()是一致的。
① await():會釋放當前鎖,進入等待狀態(tài);
② signal():會喚醒某個等待線程;
③ signalAll():會喚醒所有等待線程;
此外,和tryLock()類似,await()可以在等待指定時間后,如果還沒有被其他線程通過signal()或signalAll()喚醒,可以自己醒來:
if ( condition.await(1, TimeUnit.SECOND) ) {
// 被其他線程喚醒
} else {
// 指定時間內沒有被其他線程喚醒
}
7.使用ReadWriteLock
ReentrantLock保證了只有一個線程可以執(zhí)行臨界區(qū)代碼,但是有些時候,這種保護有點過頭。有些方法只是讀取數(shù)據,并不修改數(shù)據,此時應該允許多個線程同時調用才對。使用ReadWriteLock可以解決這個問題,它可以保證:
① 只允許一個線程寫入(此時其他線程既不能寫入也不能讀取);
② 沒有寫入時,多個線程允許同時讀(提高性能);
使用方法:new出來ReadWriteLock()對象實例后,調用該對象的readLock()和writeLock()分別獲取讀鎖和寫鎖實例,接著在讀方法中使用讀鎖,寫方法中使用寫鎖。
private final ReadWriteLock rwlock = new ReentrantReadWriteLock();
private final Lock rlock = rwlock.readLock();
private final Lock wlock = rwlock.writeLock();
8.使用StampedLock
ReadWriteLock讀的過程中不允許寫,是一種悲觀的讀鎖。其實讀的過程中大概率不會有寫操作的發(fā)生,所以并發(fā)效率有待提高。StampedLock和ReadWriteLock相比,讀的過程中也允許獲取寫鎖后寫入。這樣一來,我們讀的數(shù)據就可能不一致,所以,需要一點額外的代碼來判斷讀的過程中是否有寫入,這種讀鎖是一種樂觀鎖。StampedLock是不可重入鎖,不能在一個線程中反復獲取同一個鎖。其提供了將悲觀讀鎖升級為寫鎖的功能,它主要使用在if-then-update的場景:即先讀,如果讀的數(shù)據滿足條件,就返回,如果讀的數(shù)據不滿足條件,再嘗試寫。
使用方法:new出來StampedLock()對象實例后,調用該對象的readLock()和writeLock()可以分別獲取讀鎖和寫鎖實例并上鎖,同時還會返回版本號,釋放的時候調用unclockWrite()或者unlockRead()方法需要傳入版本號。調用tryOptimisticRead()獲得樂觀讀鎖,同時返回版本號,它在操作數(shù)據前并沒有通過 CAS設置鎖的狀態(tài),僅僅通過位運算測試,所以不需要顯式地釋放鎖。通常獲取樂觀鎖,讀入數(shù)據后,會調用validate()方法傳入版本號stamp進行驗證,如果中途有寫入,則版本號會發(fā)生變化,方法會返回false,此時需要通過獲得悲觀鎖再重新讀入數(shù)據。
9.使用Semaphore
上面的鎖保證同一時刻只有一個線程能訪問(ReentrantLock),或者只有一個線程能寫入(ReadWriteLock)。還有一種受限資源,需要保證同一時刻最多有N個線程能訪問,比如同一時刻最多創(chuàng)建100個數(shù)據庫連接,最多允許10個用戶下載等。這種限制數(shù)量的鎖,用Lock數(shù)組來實現(xiàn)很麻煩,此時就可以使用可以使用Semaphore。其本質上就是一個信號計數(shù)器,用于限制同一時間的最大訪問數(shù)量。
使用方法:new出來Semaphore()對象實例,其中傳入允許訪問的線程數(shù)量,在需要控制的方法中,調用acquire()方法,接著完成功能邏輯,最后調用release()方法釋放。調用acquire()可能會進入等待,直到滿足條件為止。也可以使用tryAcquire()指定等待時間。
10.使用Future
Runnable接口有個問題,它的方法沒有返回值。如果任務需要一個返回結果,那么只能保存到變量,還要提供額外的方法讀取,非常不方便。Callable接口和Runnable接口比,多了一個返回值功能,并且Callable接口是一個泛型接口,可以返回指定結果的類型。線程池對象的submit()方法提交任務執(zhí)行后會返回一個Future對象,也支持泛型,其表示一個未來能獲得結果的對象。當我們提交一個Callable任務后,我們會同時獲得一個Future對象,然后,我們在主線程某個時刻調用Future對象的get()方法,就可以獲得異步執(zhí)行的結果。在調用get()時,如果異步任務已經完成,我們就直接獲得結果。如果異步任務還沒有完成,那么get()會阻塞,直到任務完成后才返回結果。
Future接口定義的方法有:
get():獲取結果(可能會等待);
get(long timeout, TimeUnit unit):獲取結果,但只等待指定的時間;
cancel(boolean mayInterruptIfRunning):取消當前任務;
isDone():判斷任務是否已完成。
11.使用CompletableFuture
使用Future獲得異步執(zhí)行結果時,要么調用阻塞方法get(),要么輪詢看isDone()是否為true,這兩種方法都不是很好,因為主線程也會被迫等待。CompletableFuture針對Future做了改進,可以傳入回調對象,當異步任務完成或者發(fā)生異常時,自動調用回調對象的回調方法。
使用方式:調用CompletableFuture的supplyAsync()創(chuàng)建CompletableFuture對象,其中傳入實現(xiàn)了Supplier接口的對象(無傳入值,有返回值),他會被提交給默認的線程執(zhí)行。調用thenAccept()方法,其接收實現(xiàn)了Consumer接口的對象(有傳入值,無返回值),設置執(zhí)行完成時的回調方法。調用exceptionally()方法,接收實現(xiàn)了Function接口的對象,設置報異常時的回調方法。
12.使用ForkJoin
Java 7開始引入了一種新的Fork/Join線程池,它可以執(zhí)行一種特殊的任務:把一個大任務拆成多個小任務并行執(zhí)行。
比如:計算一個超大數(shù)組的和,可以把數(shù)組拆成兩部分,分別計算,最后加起來就是最終結果,這樣可以用兩個線程并行執(zhí)行,如果拆成兩部分還是很大,我們還可以繼續(xù)拆,用4個線程并行執(zhí)行。這就是Fork/Join任務的原理:判斷一個任務是否足夠小,如果是,直接計算,否則,就分拆成幾個小任務分別計算。這個過程可以反復“裂變”成一系列小任務。
使用方法:新建類繼承RecursiveTask,其中重寫compute方法,設定閾值,如果任務小于設定的閾值,就直接計算,最后返回結果。如果任務大于設定的閾值,就分裂成兩個小任務,調用invokeAll()傳入兩個小任務,再分別調用兩個小任務的join()方法來得到返回結果,最后將兩個結果加起來返回。
13. 使用ThreadLocal
上下文(Context):在一個線程中,橫跨若干方法調用,需要傳遞的對象,通常稱之為上下文(Context),它是一種狀態(tài),可以是用戶身份、任務信息等。
Web應用程序是典型的多任務應用,每個用戶請求頁面時,我們都會創(chuàng)建一個任務,去完成類似以下的工作:檢查權限、做工作、保存狀態(tài)、發(fā)送響應。如果這些工作中也需要用到上下文(context),此處就是user實例,可以簡單地直接通過參數(shù)傳入,但是往往一個方法又會調用其他很多方法,這樣會導致User傳遞到所有地方,但是給每個方法都增加一個Context參數(shù)非常麻煩,而且如果調用鏈中有無法修改源碼的第三方庫,context就傳不進去了。ThreadLocal就很適合解決這個問題,它可以在一個線程中傳遞同一個對象。
public void process(User user) {
checkPermission();
doWork();
saveStatus();
sendResponse();
}
使用方法:ThreadLocal實例通??偸且造o態(tài)字段初始化,通過設置一個User實例關聯(lián)到ThreadLocal中,在移除之前,所有方法都可以隨時獲取到該User實例。普通的方法調用一定是同一個線程執(zhí)行的,所以,該線程中所有方法調用threadLocalUser.get()獲取的User對象是同一個實例。ThreadLocal相當于給每個線程都開辟了一個獨立的存儲空間,各個線程的ThreadLocal關聯(lián)的實例互不干擾,特別注意ThreadLocal一定要在finally中清除。因為當前線程執(zhí)行完相關代碼后,很可能會被重新放入線程池中,如果ThreadLocal沒有被清除,該線程執(zhí)行其他代碼時,會把上一次的狀態(tài)帶進去。其實,可以ThreadLocal看成一個全局Map:每個線程獲取ThreadLocal變量時,總是使用Thread自身作為key。