一、線程基礎
線程是現代軟件系統中十分重要的概念,我們從線程的概念,線程的調度,線程安全,用戶線程與內核線程之前的映射關係來了解。
什麼是線程?
線程(Thread),有時被稱爲輕量級進程,是程序執行流的最小單元。一個標準的線程由線程ID,當前指令指針(PC),寄存器集合和堆棧組成。通常意義上,一個進程由一個到多個線程組成,各個線程之間共享程序的內存空間(包括代碼段,數據段,堆等)及其一些進程資源(如打開文件和信號)。
一般什麼時候使用多線程呢?
- 某個操作陷入長時間的等待,等待的線程會陷入睡眠狀態,無法繼續執行。如網絡響應。
- 某個操作會消耗大量的時間,單線程下程序和用戶之間交互中斷。如大量的計算。
- 程序要求併發操作。如多端下載
- 多CPU多核計算機。
- 多進程應用。多線程在數據共享上應用效率高。
線程的訪問權限。
線程也有着自己的私有存儲空間。
- 棧(儘管並非無法被其他線程訪問,一般是認爲是線程私有的數據)
- 線程局部存儲(Thread Local Storage,TLS)某些操作系統單獨提供的私有空間,但通常容量有限。
寄存器(包括PC寄存器),它是執行流基本的數據。
程序員的角度來看數據在線程之間是否私有。
線程調度與優先級
其實線程真實的存在併發,是發生在多處理器計算機上的。當線程數量不大於處理器數量時,不同的線程運行在不同的處理器上,彼此互不相關。但是當線程數量數量大於處理器時,線程併發會發生一些阻礙,因爲總至少有一個處理器運行多個線程。單核處理器對應多線程只不過是一種模擬的假象。操作系統會讓這些多線程程序輪流執行,每次執行一小段時間(通常是幾十毫秒到幾百毫秒),這樣看起來像是在“併發”。這樣一個不斷在處理器上切換不同的線程的行爲稱之爲線程的調度。線程調度至少有三種狀態:
- 運行:此時正在執行
- 就緒:可執行,但是CPU被佔用
- 等待:等待某一件事(IO事件)無法被執行。
主流的調度方式各不相同,但都帶有優先級調度和輪換法的痕跡。所謂輪換法,就是線程輪流執行一小段時間(這個時間就是時間片,當時間片用完,線程也就由運行狀態進入就緒狀態)。這決定了線程之間的交錯執行的特點。而優先級決定了線程按照什麼順序輪流執行。線程具有自己的優先級。優先級越高就越先被執行。
線程的優先級在主流的liunx和Windows上不僅可以手動設置,而且操作系統也可以自己調整,以至於有更高的效率。一般頻繁進入等待狀態的線程(通常稱爲IO密集型線程)比頻繁進行大量計算,以至於每次把時間片用完的線程(CPU密集型線程)容易得到優先級的提升。因爲頻繁進入等待的線程通常佔用時間少(CPU也喜歡先捏軟柿子)。
在優先級的調度下,存在一種餓死現象,一個線程被餓死,是說它優先級比較低,在執行之前,總有優先級較高的線程試圖執行,因此自己始終無法執行。調度系統爲了防止這種現象的發生,會逐步提升這些線程的優先級,直到能被執行到爲止。
可搶佔線程不可搶佔線程
當線程用完時間片之後會被剝奪繼續執行的權力,而進入就緒狀態,這個過程叫搶佔。早起的系統也有不可搶佔的。線程必須手動發出一個放棄執行的命令,才能讓其他線程執行。不可搶佔線程中,線程主動放棄執行無非兩種情況:試圖等待某個事件(io);主動放棄時間片。在不可搶佔線程執行有一個特點,就是線程調度只發生在線程主動放棄執行或線程等待某事件的時候。這樣可以避免因爲搶佔線程調度時機不確定而產生的問題(線程安全)。即便如此,非搶佔線程在今日也很少見。
二、線程安全
多線程處於一個可變的環境中,可訪問的全局變量和堆數據隨時都可以被其他的線程改變,因此怎麼保證多線程數據安全是一個非常重要的問題。
競爭與原子操作
先舉一個例子:
package ThreadTest;
public class ThreadTest extends Thread {
static int i = 5;
public static void main(String[] args) {
new ThreadTest().start();
new ThreadTest().start();
}
@Override
public void run() {
for(;;) {
if(i < 0 ) {
break;
}
i -- ;
System.out.println("i = " + i);
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
執行結果:
i = 3
i = 3
i = 1
i = 1
i = 0
在許多結構體系上,i--的實現方法如下:
- 讀取 i 到某個寄存器X
- X --
- 將X的內容存儲回 i。
由於兩個線程是併發執行,因此可能會出現,當線程A自減一次(4)還沒寫回到內存,但是有可能線程B已經執行過兩次自減了(3)且寫回到內存了,當線程A寫回時已經覆蓋了原先數據。爲了避免這種情況發生,Java提供了一種同步鎖(synchronized)來保證數據安全。我們把單指令的操作稱爲原子的(Atomic),因爲無論如何,單指令的執行是不會被打斷的。
加上synchronized之後上面的例子:
package ThreadTest;
public class ThreadTest extends Thread {
static int i = 5;
static final String lock = "lock";
public static void main(String[] args) {
new ThreadTest().start();
new ThreadTest().start();
}
@Override
public void run() {
for (;;) {
if (i <= 0) {
break;
}
synchronized (lock) {
i--;
System.out.println("i = " + i);
}
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
輸出:
i = 4
i = 3
i = 2
i = 1
i = 0
同步與鎖
爲了保證多線程同時讀寫同一數據而產生不可預料的後果。我們需要將各個線程對同一數據的訪問同步。所謂的同步,就是一個線程訪問數據未結束的時候,其他線程不對同一個數據進行訪問。如此對數據訪問就被原子化了。
同步常見的就是使用鎖(Lock)。鎖是一種非強制機制,每一個線程在訪問數據或者資源之前都會嘗試獲取鎖,並在訪問結束的時候釋放鎖。在鎖已經被佔用的時候嘗試獲取鎖,線程會等待,知道鎖重新可得。
二元信號量:是最簡單的一種鎖,它只有兩種狀態,佔用與非佔用。它適合只能唯一一個線程獨佔訪問資源。
互斥量:與二元信號量相似,但是不同的是,信號量在整個系統可以被任意線程獲取並釋放,也就是說同一信號量可以被系統中一個線程獲取之後由另一個線程釋放。而互斥量要求哪個線程獲取就有哪個線程釋放,其他線程釋放是無效的。
臨界區:是比互斥量更加嚴格的手段。術語中,把臨界區的鎖獲取叫做進入臨界區,釋放鎖叫做離開臨界區。在系統中二元信號量和互斥量對進程是可見的,也就是說一個進程創建了一個互斥量或信號量。另一個進程去獲取是合法的。然而,臨界區的作用範圍僅限於本進程。除此之外。臨界區和互斥量有相同的性質。
讀寫鎖:致力於一種更加特定的場合的同步。對於一段數據,多個線程讀是沒問題,問題在於寫,可能會出現覆蓋。但是爲了滿足大量的讀,只有少量的寫的環境。讀寫鎖有兩種獲取方式:共享鎖或獨佔鎖。如果鎖處於共享狀態,那麼任意線程以共享的方式獲取,都會成功。要是以獨佔的方式獲取,那麼就必須等待所有持有該鎖的線程釋放共享鎖。相應的處於獨佔狀態的鎖將阻止任何線程來獲取。
條件變量:作爲一種同步手段,作用類似於一個柵欄。使用條件變量可以讓許多線程一起等待某個事件發生,當事件發生(條件變量被喚醒),所有線程可以一起恢復執行。
上面這些概念都是C++裏面的,雖然Java鎖這一塊被封裝起來了,但鎖的思想其實還是有些相似的。一般,Java會使用synchronized關鍵字實現一系列的鎖。具體可參考:Java中各種鎖的詳細介紹。
說起數據安全,我想大家還想到的一個就是volatile關鍵字。再說之前再來舉一個例子。
x = 0 | |
Thread1 | Thread2 |
locak(); | locak(); |
x++ | x++ |
unlock(); | unlock(); |
由於有lock和unlock的保護,x++的行爲不會被併發破壞,那麼x的值必然是2了。然而。如果編譯器爲了提高x的訪問速度,把x放到某個寄存器裏(各個線程之間寄存器是互相獨立的),則線程可能出現如下情況:
- Thread1:讀取x的值到某一個寄存器R[1](R[1] = 0)
- Thread1:R[1]++(可能之後還會訪問,所以暫時不講R[1]寫回x)
- Thread2:讀取x的值到某一個寄存器R[2](R[2] = 0)
- Thread2:R[2]++(R[2]=1)
- Thread2:將R[2]寫回至x(x=1)
- Thread1:(很久之後)將R[1]寫回至x(x=1)
可見這樣的情況下即使正確的加鎖,也是不能保證多線程的安全。這時就可以使用volatiles關鍵字了,volatile基本上做到兩件事:
- 阻止編譯器爲了提高速度將一個變量緩存到寄存器內而不寫回。
- 阻止編譯器調整操作volatile變量的指令順序。
可見volatile可以很好的解決這一問題,但是volatile也有不能解決的,那就是無法阻止CPU動態的調度換序。在C++中,或者Java中new一個對象一般會分爲三個步驟,1)分配內存空間,2)調用構造函數,3)將內存地址賦值給實例。這個步驟中2,3循序是完全可以顛倒的。有一個典例就是單例模式:
package Singleton;
public class Singleton {
static final Object lock = new Object();
private static Singleton singleton = null;
public static Singleton getSingleton() {
if (singleton == null) {
synchronized (lock) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
在C++中,知道是可以申明一個指針的。假如按照C++中吧 singleton申明成一個指針,那麼拋開邏輯,乍一看代碼是沒問題的,當函數返回時,singleton總是指向一個有效的對象。假如出現這樣的情況:singleton的值已經不是空了,但任然沒有構造完畢,這個時候如果出現另一個線程對getSingleton()調用,此時singleton==null 返回false,所以這個調用會直接返回尚未構造完全的對象地址提供用戶使用。這樣會不會出現問題呢?
在java中,singleton也不過是申明的一個引用。當new Singleton()過程中,會出現分配內存,singleton會指向該內存地址。那麼這兩個步驟是不是也會被打亂呢?也會出現像C++這樣的情況呢?
可以看見CPU亂序執行能力讓我們保證多線程安全變得異常困難,遺憾的是現在並不存在可移植的阻止換序的方法,通常情況下會調用CPU提供的一種指令--barrier,該指令會阻止CPU在該指令之前的指令交換到該指令之後。可以想象成barrier指令就是一個攔水壩,防止程序“穿透”這個大壩。
三、線程內部情況
三種線程模型
線程的併發執行是由多個處理器或操作系統調度來實現的。實際情況可能更爲複雜一些;大多數操作系統是可以通過內核線程的,然而用戶實際是使用的線程並不是內核線程,而是存在於用戶態的用戶線程。用戶線程並不是在操作系統中對應同等量的內核線程。例如某些輕量級的線程庫,對用戶來說如果有三個線程同時執行,對內核來說很可能只有一個線程。
一對一線程
一個用戶線程就唯一對應一個內核使用的線程(反過來不一定,一個內核線程在用戶態不一定有對應的線程存在)。優點是線程之間是正在的併發。一個線程阻塞不會影響到其他線程。再多處理器上有更好的表現。缺點是,由於許多操作系統限制了內核數量,因此一對一線程會讓用戶線程數量收到了限制;其次許多操作系統內核調度時,上下文切換的開銷大,導致用戶線程執行效率底下。
多對一模型
多對一模型將多個用戶線程映射到一個內核線程上,線程之間的切換有用戶態的代碼來進行,因此相對一對多模型,多對一模型線程切換要快的多。但是它最大的一個問題就是,一個用戶線程阻塞,那麼所有的線程就無法執行。再多處理器上處理器增多對多對一模型線程性能沒有明顯的幫助。好處在於高效的上下文切換和無限制的線程數量。
多對多模型
多對多模型結合了一對一模型個多對一模型,將多個用戶線程映射到少數但不止一個內核線程上。這樣就綜合了以上兩個模型的優點。一個用戶線程阻塞不會影響到其他用戶線程,數量上也沒有什麼限制,在多處理器上,多對多模型也得到了一定性能的提升。但是幅度沒有一對一的明顯。
四、小結
以上都是從操作系統的角度,或者說從低層來看線程的是怎麼分配的,怎麼出現數據安全的問題,已經怎麼同步來保證數據安全的。因此一部分是借鑑了C系語言裏面的概念。在Java帝國,多線程也是比較複雜的,這裏拓展一下,簡單的溫故溫故。
五、拓展
至於jJava線程的創建,啓動已經生命週期大家都耳熟能詳了。簡單說一下線程的同步,以及線程通信,和線程池。
線程同步
java關鍵詞synchronized,是常用的的同步手段它可以修飾代碼塊,修飾方法。那麼任何線程進入同步代碼塊。同步方法之前,必須現獲取對同步監視器(某一對象,說白了就是一把鎖)的鎖定。何時會釋放對同步監視器的鎖定呢?程序無法顯示釋放對同步監視器的鎖定,在以下情況下會釋放:
- 當前線程同步方法、同步代碼塊執行結束,或者出現異常結束了,當前線程釋放同步監視器。
- 當前線程執行同步代碼塊或同步方法,程序執行了同步監視器對象的wait()方法,則當前線程暫停,並釋放同步監視器
以下請情況是不會釋放同步監視器:
- 線程執行同步代碼塊或者同步方法,程序程序出現Thread.sleep()、Thread.yield()方法來暫停當前線程執行,當前線程不會釋放同步監視器
- 線程執行同步代碼塊時,其他線程調用該線程的suspend()方法將線程掛起,該線程是不會釋放同不監視器。當然程序應該儘量避免使用suspend()和resume()方法來控制線程。
Java1.5開始,Java提供了一種更強大的同步機制——顯示定義同步鎖對象來實現同步。在Java1.8有新增新型的StampedLock類,那麼常用的是ReentrantLock(可重入鎖)。該Lock對象可以顯式的加鎖、釋放鎖。如:
package Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockTest {
private final ReentrantLock lock = new ReentrantLock();
public void m() {
lock.lock();
try {
//需要保護的安全的代碼
//....method body
} finally {
lock.unlock();
}
}
}
使用ReentrantLock 對象來進行同步,加鎖和釋放鎖出現在不同的作用範圍。通常建議使用finally塊來確保在必要時釋放鎖。
同步方法或同步代碼塊使用與競爭資源相關、隱式的同步監視器,且強制要求加鎖和釋放鎖要出現同一塊結構中。而且當獲取多個鎖時。它們必須以相反的方向釋放,且必須要在所有鎖被獲取時相同的範圍內釋放。雖然這樣很方便且可以避免一些涉及到鎖的一些常見錯誤。但是,Lock 提供了同步方法或同步代碼塊沒有的功能。如:tryLock()方法,以及試圖獲取可中斷鎖的lockInterruptibly()方法,還有獲取超時失效鎖tryLock(long,TimeUtil)方法。
ReentrantLock 具有可重入性,一個線程可以對已經被ReentrantLock 鎖再次加鎖,ReentrantLock 對象會維持一個計數器來追蹤lock()方法嵌套的使用,線程每次調用lock()加鎖後,必須顯示調用unLock()來釋放鎖,所以一段被鎖保護的代碼可以調用另一個被相同鎖保護的方法。
死鎖:當兩個線程互相等待對方釋放同步監視器資源時,就會觸發死鎖,java虛擬機沒有監測,也沒有采取任何措施來處理死鎖情況,多線程編程應該避免死鎖。一旦出現死鎖,整個程序不會發生異常,也沒有任何提示,只是所有的線程處於阻塞狀態,無法繼續。
package Lock;
public class DeadLock {
static final Object lock1 = new Object();
static final Object lock2 = new Object();
public static void main(String[] args) {
new Lock1().start();
new Lock2().start();
}
}
class Lock1 extends Thread {
@Override
public void run() {
try {
System.out.println("Lock1 runing..........");
for (;;) {
synchronized (DeadLock.lock1) {
System.out.println("Lock1 lock Object1");
Thread.sleep(100);
synchronized (DeadLock.lock2) {
System.out.println("Lock2 lock Object2");
}
}
}
} catch (Exception e) {
}
}
}
class Lock2 extends Thread {
@Override
public void run() {
try {
System.out.println("Lock2 runing..........");
for (;;) {
synchronized (DeadLock.lock2) {
System.out.println("Lock2 lock Object2");
Thread.sleep(100);
synchronized (DeadLock.lock1) {
System.out.println("Lock1 lock Object1");
}
}
}
} catch (Exception e) {
}
}
}
輸出:
Lock1 runing..........
Lock1 lock Object1
Lock2 runing..........
Lock2 lock Object2
線程一直處於阻塞狀態。由於線程的suspend()方法也很容易導致死鎖,故Java不在推薦使用該方法來暫停線程。
線程通信
傳統的實現通信,可以藉助Object類提供的wait()、notify()和notifyAll()三個方法,這三個方法並不屬於Thread類,而是Object。這三個方法必須由同步監視器來調用,可分爲一下兩種情況。
- 對於使用synchronized修飾的同步方法,因爲默認使用實例(this)就是同步監視器所以可以在同步方法中直接調用這三個方法。
- 對於使用synchronized修飾代碼塊,同步監視器是synchronized括號內的對象,所以必須使用該對象調用。
這裏也可以明白爲什麼wait()、notify()和notifyAll()是屬於Object類而不是Thread類了。關於這三個方法理解如下
wait():導致當前線程等待,直到其他線程調用該同步監視器的notify()和notifyAll()方法來喚醒該線程。該方法可以傳入時間參數,表示等待該時間之後自動甦醒。
notify():喚醒該監視器上等待的單個線程。如果所有的線程都在該監視器上等待,則喚醒其中一個線程。選擇是任意的。
notifyAll():喚醒該監視器上所有的等待線程。
當然假如程序不使用synchronized關鍵字來同步,而是直接使用Lock對象來保持同步。Java提供了一個Condition對象從而替代了監視器的功能。具體方法就不一一介紹了。
使用阻塞隊列(BlockingQueue)控制線程通信
BlockingQueue有這樣的一個特徵;當生成線程試圖放入元素時,若隊列已滿,則該線程被阻塞。當消費線程試圖取出元素時,如果隊列已空,則該線程處於阻塞狀態。當然Java1.8新增了Java.util.Concurrent,提供了很多類似的集合。比較常用的有,ConcurrentHashMap,ConcurrentLinkedQueue等。
Java使用ThreadGroup來表示線程組,他可以對一批線程進行分類管理,Java允許直接對線程組進制控制。用戶創建的所有線程都可以指定線程組,如果沒有顯示指定線程屬於哪個線程組,則該線程屬於默認線程組。在默認的情況下子線程和創建它的父線程屬於同一個線程組。關於它的構造方法有:ThreadGroup(String name),ThreadGroup(ThreadGroup parent, String name)。
線程池
系統啓用一個新線程的成本是比較高的,因爲它涉及到與操作系統交互。如當程序需要大量創建生存期很短的線程時,應該考慮使用線程池。比較常見的例子就是數據庫連接池。數據庫的建立及關閉是極消耗資源的操作,在多層結構的應用環境中,這種資源的消耗對系統的影響尤爲明顯。因此數據庫連接池解決方案是:當應用程序啓動時,系統主動建立足夠的數據庫連接,並將這些連接組成一個連接池。每次應用請求數據庫連接時,無須重新打開連接,而是從連接池中取出已有的連接使用,使用之後也無需關閉連接,而是直接將連接歸還給連接池。Java提供了通過靜態工廠來創建不同的類型的連接池:
- newCachedThreadPool():創建一個具有緩存功能的線程池,系統更具需要創建線程,這些線程將緩存在線程池中。
- newFixedThreadPool(int nThreads):創建一個可重用、具有固定線程數的線程池。
- newSingleThreadExecutor():創建一個單線程的線程池。內部實現了newFixedThreadPool(1)。
- newScheduledThreadPool(int corePoolSize):創建具有指定線程數的線程池,它可以在指定延遲後執行線程任務。corePoolSize是池中保存的線程數,即使線程是空閒的也被保存在線程池內。
- newSingleThreadScheduleExector():創建只有一個線程的線程池,它可以在指定延遲後執行線程任務。
- ExecutorServicenewWorkStealingPool(int parallelism):創建持有足夠的線程池來支持給定的並行級別,該方法還會使用多個隊列來減少競爭。
- ExecutorServicenewWorkStealingPool():該方法是前一個方法的簡化,如果當前及其是4個CPU,則目標級別是4,也就是前一個方法參數傳入4。
用完一個線程池後,應該調用線程池的shutdown()方法,該方法將啓動線程池的關閉序列,調用shutdown方法後的線程不會在接收新的任務,但會將以前提交的任務執行完成。當池中所有線程都執行完後,池中所有池線程都會死亡。也可以調用showdownNow()方法,該方法會試圖停止所有正在執行的活動任務,暫停處理等待的任務,並返回等待執行任務的列表。
使用線程來執行線程任務步驟如下:
- 調用Executors類的靜態工廠方法創建一個ExecutorService對象,該對象代表一個線程池。
- 創建Runable實現類或Callable實現實例,作爲線程執行任務。
- 調用線程池的submit()來提交Runable或Callable實現實例
- 當不想提交任何任務時,調用shutdown()方法,關閉線程。
Java1.8之後增加了一種特殊的線程池ForkJoinPool。爲了充分利用CPU的多核能力,計算機軟件系統應該充分“挖掘”每個CPU的計算機能力,決不能讓某一個CPU處於空閒狀態。因此可以考慮把一個任務拆分成多個小任務,把小任務放到多核處理器上並行執行,然後再把多個小任務的結果合併總的計算結果。ForkJoinPool是ExecutorService的實現類,是一種特殊的線程池。
線程相關ThreadLocal類
ThreadLocal是線程局部的變量,怎麼理解呢?就是每一個使用該變量的線程都提供來了一個變量值的副本,使每一個線程都可以獨立改變自己的副本,而不會和其他線程副本發生衝突。從線程的角度來看,就好像每個線程都完全擁有該變量一樣。ThreadLocal也提供了泛型的支持,即:ThreadLocal<T>。
ThreadLocal提供了三個public方法:
- T get():返回此線程局部變量中當前的線程的副本變量。
- void remove():刪除此線程局部變量中當前的線程副本的值
- void set(T value):設置此線程局部變量中當前的線程副本的值
ThreadLocal和其他同步機制一樣,都是爲了解決多線程對同一變量的訪問衝突。在普通的同步機制中,是通過加鎖來實現多線程對同一變量的安全訪問。而ThreadLocal從另一個角度,就是將併發訪問資源複製多份,每個線程擁有一份資源,從而也就沒有必要對該變量進行同步了。ThreadLocal提供了線程的安全共享對象,可以把不安全的整個變量封裝進ThreadLocal,或者把該對象與線程相關狀態使用ThreadLocal保存。
值得注意的是ThreadLocal並不能代替同步機制,通常建議:如果如果多個線程之間需要共享資源,從而達到 線程之間的通信,就使用同步機制。如果僅僅需要隔離多個線程之間的共享衝突,則可以使用ThreadLocal。
線程不安全集合與安全集合
不安全集合:ArrayList,LinkedList,HashSet,TreeSet,HashMap,TreeMap。可使用Collections提供的靜態方法來包裝從而轉爲線程安全集合。
安全集合:(以Concurrent開頭的集合類)ConcurrentHashMap,ConcurrentLinkedQueue等。
思考:
結合ThreadLocal的思想,線程之間的通信,共享資源也可以不使用同步機制了。假如一個線程線程想修改另一個線程的數據,可以把要修改的數據局串行化發送給另一個線程,讓其自己修改,修改完後再一次串行化,回調給請求線程。
比較經典的應用就是:Netty內部的線程通信,還有就是RPC框架。
參考:《程序員自我修養——鏈接、裝載與庫》,《瘋狂java講義》,《Netty權威指南》