Java併發程式設計的藝術開篇——初識多執行緒

目錄:

一、多執行緒概念

併發與並行

程序與執行緒

二。 併發程式設計的挑戰

上下文切換

死鎖

資源限制的挑戰

一、多執行緒概念

1。 併發與並行

“併發”指的是程式的結構,“並行”指的是程式執行時的狀態

即使不看詳細解釋,也請記住這句話。下面來具體說說:

並行(parallelism)

這個概念很好理解。所謂並行,就是同時執行的意思,無需過度解讀。判斷程式是否處於並行的狀態,就看同一時刻是否有超過一個“工作單位”在執行就好了。所以,單執行緒永遠無法達到並行狀態。

要達到並行狀態,最簡單的就是利用多執行緒和多程序。但是 Python 的多執行緒由於存在著名的 GIL,無法讓兩個執行緒真正“同時執行”,所以實際上是無法到達並行狀態的。

併發(concurrency)

要理解“併發”這個概念,必須得清楚,併發指的是程式的“結構”。當我們說這個程式是併發的,實際上,這句話應當表述成“這個程式採用了支援併發的設計”。好,既然併發指的是人為設計的結構,那麼怎樣的程式結構才叫做支援併發的設計?

正確的併發設計的標準是:使多個操作可以在重疊的時間段內進行

(two tasks can start, run, and complete in overlapping time periods)

這句話的重點有兩個。我們先看“(操作)在重疊的時間段內進行”這個概念。它是否就是我們前面說到的並行呢?是,也不是。並行,當然是在重疊的時間段內執行,但是另外一種執行模式,也屬於在重疊時間段內進行。這就是

協程

使用協程時,程式的執行看起來往往是這個樣子:

Java併發程式設計的藝術開篇——初識多執行緒

task1, task2 是兩段不同的程式碼,比如兩個函式,其中黑色塊代表某段程式碼正在執行。注意,這裡從始至終,

在任何一個時間點上都只有一段程式碼在執行

,但是,由於 task1 和 task2 在重疊的時間段內執行,所以這是一個支援併發的設計。與並行不同,單核單執行緒能支援併發。

經常看到這樣一個說法,叫做

併發執行

。現在我們可以正確理解它。有兩種可能:

1。 原本想說的是“並行執行”,但是用錯了詞

2。 指多個操作可以在重疊的時間段內進行,即,真的並行,或是類似上圖那樣的執行模式。

我的建議是儘可能不使用這個詞,容易造成誤會,尤其是對那些併發並行不分的人。但是讀到這裡的各位顯然能正確區分,所以下面為了簡便,將使用併發執行這個詞。

第二個重點是“

可以

在重疊的時間段內進行”中的“可以”兩個字。“可以”的意思是,正確的併發設計使併發執行成為可能,但是程式在實際執行時卻不一定會出現多個任務執行時間段 overlap 的情形。比如:我們的程式會為每個任務開一個執行緒或者協程,只有一個任務時,顯然不會出現多個任務執行時間段重疊的情況,有多個任務時,就會出現了。這裡我們看到,併發並不描述程式執行的狀態,它描述的是一種設計,是程式的結構,比如上面例子裡“為每個任務開一個執行緒”的設計。併發設計和程式實際執行情況沒有直接關聯,但是正確的併發設計讓併發執行成為可能。反之,如果程式被設計為執行完一個任務再接著執行下一個,那就不是併發設計了,因為做不到併發執行。

那麼,如何實現支援併發的設計?兩個字:

拆分

之所以併發設計往往需要把流程拆開,是因為如果不拆分也就不可能在同一時間段進行多個任務了。這種拆分可以是平行的拆分,比如抽象成同類的任務,也可以是不平行的,比如分為多個步驟。

併發和並行的關係

Different concurrent designs enable different ways to parallelize.

這句話來自著名的talk:

Concurrency is not parallelism

。它足夠concise,以至於不需要過多解釋。但是僅僅引用別人的話總是不太好,所以我再用之前文字的總結來說明:

併發設計讓併發執行成為可能,而並行是併發執行的一種模式

2。 執行緒與程序

2.1 什麼是程序?為什麼要有程序?

一個在

記憶體

中執行的應用程式。每個程序都有自己獨立的一塊記憶體空間,一個程序可以有多個執行緒,比如在Windows系統中,一個執行的xx。exe就是一個程序。

Java併發程式設計的藝術開篇——初識多執行緒

程序有一個相當精簡的解釋:程序是對作業系統上正在執行程式的一個抽象。

這個概念確實挺抽象,仔細想想卻也挺精準。

我們平常使用計算機,都會在同一時間做許多事,比如邊看電影,邊微信聊天,順便開啟瀏覽器百度搜索一下,我們所做的這麼多事情背後都是一個個正在執行中的軟體程式;這些軟體想要執行起來,首先在磁碟上需要有各自的程式程式碼,然後將程式碼載入到記憶體中,CPU會去執行這些程式碼,執行中會產生很多資料需要存放,也可能需要和網絡卡、顯示卡、鍵盤等外部裝置互動,這背後其實就涉及到程式對計算機資源的使用,存在這麼多程式,我們當然需要想辦法管理程式資源的使用。並且CPU如果只有一個,那麼還需要作業系統排程CPU分配給各個程式使用,讓使用者感覺這些程式在同時執行,不影響使用者體驗。

理所當然,作業系統會把每個執行中的程式封裝成獨立的實體,分配各自所需要的資源,再根據排程演算法切換執行。這個抽象程式實體就是程序。

所以很多對程序的官方解釋中都會提到:程序是作業系統進行資源分配和排程的一個基本單位。

2.2 什麼是執行緒?為什麼要有執行緒?

在早期的作業系統中並沒有執行緒的概念,程序是擁有資源和獨立執行的最小單位,也是程式執行的最小單位。任務排程採用的是時間片輪轉的搶佔式排程方式,而程序是任務排程的最小單位,每個程序有各自獨立的記憶體空間,使得各個程序之間記憶體地址相互隔離。

後來,隨著計算機行業的發展,程式的功能設計越來越複雜,我們的應用中同時發生著多種活動,其中某些活動隨著時間的推移會被阻塞,比如網路請求、讀寫檔案(也就是IO操作),我們自然而然地想著能不能把這些應用程式分解成更細粒度、能 準並行執行 多個順序執行實體,並且這些細粒度的執行實體可以共享程序的地址空間,也就是可以共享程式程式碼、資料、記憶體空間等,這樣程式設計模型會變得更加簡單。

其實很多計算機世界裡的技術演變,都是模擬現實世界。比如我們把一個程序當成一個專案,當專案任務變得複雜時,自然想著能不能將專案按照業務、產品、工作方向等分成一個個任務模組,分派給不同人員各自並行完成,再按照某種方式組織起各自的任務成果,最終完成專案。

需要多執行緒還有一個重要的理由就是:每個程序都有獨立的程式碼和資料空間(程式上下文),程式之間的切換會有較大的開銷;執行緒可以看做輕量級的程序,同一類執行緒共享程式碼和資料空間,每個執行緒都有自己獨立的執行棧和程式計數器,執行緒之間切換的開銷小。所以執行緒的建立、銷燬、排程效能遠遠優於程序。

在引入多執行緒模型後,程序和執行緒在程式執行過程中的分工就相當明確了,程序負責分配和管理系統資源,執行緒負責CPU排程運算,也是CPU切換時間片的最小單位。對於任何一個程序來講,即便我們沒有主動去建立執行緒,程序也是預設有一個主執行緒的。程序中的一個執行任務(控制單元),負責當前程序中程式的執行。一個程序至少有一個執行緒,一個程序可以執行多個執行緒,多個執行緒可共享資料。

與程序不同的是同類的多個執行緒共享程序的堆和方法區資源,但每個執行緒有自己的程式計數器、虛擬機器棧和本地方法棧,所以系統在產生一個執行緒,或是在各個執行緒之間作切換工作時,負擔要比程序小得多,也正因為如此,執行緒也被稱為輕量級程序。

Java 程式天生就是多執行緒程式,我們可以透過 JMX 來看一下一個普通的 Java 程式有哪些執行緒,程式碼如下。

public class MultiThread { public static void main(String[] args) { // 獲取 Java 執行緒管理 MXBean ThreadMXBean threadMXBean = ManagementFactory。getThreadMXBean(); // 不需要獲取同步的 monitor 和 synchronizer 資訊,僅獲取執行緒和執行緒堆疊資訊 ThreadInfo[] threadInfos = threadMXBean。dumpAllThreads(false, false); // 遍歷執行緒資訊,僅列印執行緒 ID 和執行緒名稱資訊 for (ThreadInfo threadInfo : threadInfos) { System。out。println(“[” + threadInfo。getThreadId() + “] ” + threadInfo。getThreadName()); } }}

上述程式輸出如下(輸出內容可能不同,不用太糾結下面每個執行緒的作用,只用知道 main 執行緒執行 main 方法即可):

[6] Monitor Ctrl-Break //監聽執行緒轉儲或“執行緒堆疊跟蹤”的執行緒

[5] Attach Listener //負責接收到外部的命令,而對該命令進行執行的並且把結果返回給傳送者

[4] Signal Dispatcher // 分發處理給 JVM 訊號的執行緒

[3] Finalizer //在垃圾收集前,呼叫物件 finalize 方法的執行緒

[2] Reference Handler //用於處理引用物件本身(軟引用、弱引用、虛引用)的垃圾回收的執行緒

[1] main //main 執行緒,程式入口

從上面的輸出內容可以看出:

一個 Java 程式的執行是 main 執行緒和多個其他執行緒同時執行

2.3 它們在Linux核心中實現方式有何不同?

在Linux 裡面,無論是程序,還是執行緒,到了核心裡面,我們統一都叫任務(Task),由一個統一的結構 task_struct 進行管理,這個task_struct 資料結構非常複雜,囊括了程序管理生命週期中的各種資訊。

Java併發程式設計的藝術開篇——初識多執行緒

在Linux作業系統核心初始化時會建立第一個程序,即0號創始程序。隨後會初始化1號程序(使用者程序祖宗:/usr/lib/systemd/systemd),2號程序(核心程序祖宗:[kthreadd]),其後所有的程序執行緒都是在他們的基礎上fork出來的。

Java併發程式設計的藝術開篇——初識多執行緒

Java併發程式設計的藝術開篇——初識多執行緒

我們一般都是透過fork系統呼叫來建立新的程序,fork 系統呼叫包含兩個重要的事件,一個是將 task_struct 結構複製一份並且初始化,另一個是試圖喚醒新建立的子程序。

我們說無論是程序還是執行緒,在核心裡面都是task,管起來不是都一樣嗎?到底如何區分呢?其實,執行緒不是一個完全由核心實現的機制,它是由核心態和使用者態合作完成的。

建立程序的話,呼叫的系統呼叫是 fork,會將五大結構 files_struct、fs_struct、sighand_struct、signal_struct、mm_struct 都複製一遍,從此父程序和子程序各用各的資料結構。而建立執行緒的話,呼叫的是系統呼叫 clone,五大結構僅僅是引用計數加一,也即執行緒共享程序的資料結構。

Java併發程式設計的藝術開篇——初識多執行緒

2.4 併發與並行的區別?

功能: 程序是作業系統資源分配的基本單位,而執行緒是任務排程和執行的基本單位

開銷: 每個程序都有獨立的記憶體空間,存放程式碼和資料段等,程式之間的切換會有較大的開銷;執行緒可以看做輕量級的程序,共享記憶體空間,每個執行緒都有自己獨立的執行棧和程式計數器,執行緒之間切換的開銷小。

執行環境: 在作業系統中能同時執行多個程序;而在同一個程序(程式)中有多個執行緒同時執行(透過CPU排程,在每個時間片中只有一個執行緒執行)

建立過程: 在建立新程序的時候,會將父程序的所有五大資料結構複製新的,形成自己新的記憶體空間資料,而在建立新執行緒的時候,則是引用程序的五大資料結構資料,但是執行緒會有自己的私有資料、棧空間。

程序和執行緒其實在cpu看來都是task_struct結構的一個封裝,執行不同task即可,而且在cpu看來就是在執行這些task時候遵循對應的排程策略以及上下文資源切換定義,包括暫存器地址切換,核心棧切換。所以對於cpu而言,程序和執行緒是沒有區別的。

併發程式設計的挑戰

併發程式設計的目的是為了讓程式執行得更快,但是,並不是啟動更多的執行緒就能讓程式最大限度地併發執行。在進行併發程式設計時,如果希望透過多執行緒執行任務讓程式執行得更快,會面臨非常多的挑戰,比如上下文切換的問題、死鎖的問題,以及受限於硬體和軟體的資源限制問題,本章會介紹幾種併發程式設計的挑戰以及解決方案。

上下文切換

即使是單核處理器也支援多執行緒執行程式碼,CPU透過給每個執行緒分配CPU時間片來實現這個機制。時間片是CPU分配給各個執行緒的時間,因為時間片非常短,所以CPU透過不停地切換執行緒執行,讓我們感覺多個執行緒是同時執行的,時間片一般是幾十毫秒(ms)。

CPU透過時間片分配演算法來迴圈執行任務,當前任務執行一個時間片後會切換到下一個任務。但是,在切換前會儲存上一個任務的狀態,以便下次切換回這個任務時,可以再載入這個任務的狀態。所以任務從儲存到再載入的過程就是一次上下文切換。

這就像我們同時讀兩本書,當我們在讀一本英文的技術書時,發現某個單詞不認識,於是便開啟中英文字典,但是在放下英文技術書之前,大腦必須先記住這本書讀到了多少頁的第多少行,等查完單詞之後,能夠繼續讀這本書。這樣的切換是會影響讀書效率的,同樣上下文切換也會影響多執行緒的執行速度。

多執行緒一定快嗎?

下面的程式碼演示序列和併發執行並累加操作的時間,請分析:下面的程式碼併發執行一定比序列執行快嗎?

public class ConcurrencyTest { private static final long count = 10000l; public static void main(String[] args) throws InterruptedException { concurrency(); serial(); } private static void concurrency() throws InterruptedException { long start = System。currentTimeMillis(); Thread thread = new Thread(new Runnable() { @Override public void run() { int a = 0; for (long i = 0; i < count; i++) { a += 5; } } }); thread。start(); int b = 0; for (long i = 0; i < count; i++) { b——; } long time = System。currentTimeMillis() - start; thread。join(); System。out。println(“concurrency :” + time + “ms,b=” + b); } private static void serial() { long start = System。currentTimeMillis(); int a = 0; for (long i = 0; i < count; i++) { a += 5; } int b = 0; for (long i = 0; i < count; i++) { b——; } long time = System。currentTimeMillis() - start; System。out。println(“serial:” + time + “ms,b=” + b + “,a=” + a); }}

上述問題的答案是“不一定”,測試結果如表1-1所示。

表1-1 測試結果

Java併發程式設計的藝術開篇——初識多執行緒

表1-1 測試結果

從表1-1可以發現,當併發執行累加操作不超過百萬次時,速度會比序列執行累加操作要慢。那麼,為什麼併發執行的速度會比序列慢呢?這是因為執行緒有建立和上下文切換的開銷。

測試上下文切換次數和時長

下面我們來看看有什麼工具可以度量上下文切換帶來的消耗。

使用Lmbench3[1]可以測量上下文切換的時長。

使用vmstat可以測量上下文切換的次數。

下面是利用vmstat測量上下文切換次數的示例。

$ vmstat 1

procs ——————-memory—————— ——-swap—— ——-io—— ——system—— ——-cpu——-

r

b

swpd

free

buff

cache

si

so

bi

bo

in

cs

us

sy

id

wa

st

0

0

0

127876

398928

2297092

0

0

0

4

2

2

0

0

99

0

0

0

0

0

127868

398928

2297092

0

0

0

0

595

1171

0

1

99

0

0

0

0

0

127868

398928

2297092

0

0

0

0

590

1180

1

0

100

0

0

CS(Content Switch)表示上下文切換的次數,從上面的測試結果中我們可以看到,上下文每1秒切換1000多次。

如何減少上下文切換

減少上下文切換的方法有無鎖併發程式設計、CAS演算法、使用最少執行緒和使用協程。

·無鎖併發程式設計。多執行緒競爭鎖時,會引起上下文切換,所以多執行緒處理資料時,可以用一些辦法來避免使用鎖,如將資料的ID按照Hash演算法取模分段,不同的執行緒處理不同段的資料。

·CAS演算法。Java的Atomic包使用CAS演算法來更新資料,而不需要加鎖。

·使用最少執行緒。避免建立不需要的執行緒,比如任務很少,但是建立了很多執行緒來處理,這樣會造成大量執行緒都處於等待狀態。

·協程:在單執行緒裡實現多工的排程,並在單執行緒裡維持多個任務間的切換。

減少上下文切換實戰

透過減少線上大量WAITING的執行緒,來減少上下文切換次數。

第一步

:用jstack命令dump執行緒資訊,看看pid為3117的程序裡的執行緒都在做什麼。

sudo -u admin /opt/ifeve/java/bin/jstack 31177 > /home/tengfei。fangtf/dump17

第二步

:統計所有執行緒分別處於什麼狀態,發現300多個執行緒處於WAITING(onobject- monitor)狀態。

[tengfei。fangtf@ifeve ~]$ grep java。lang。Thread。State dump17 | awk ‘{print $2$3$4$5}’

| sort | uniq -c

39 RUNNABLE

21 TIMED_WAITING(onobjectmonitor)

6 TIMED_WAITING(parking)

51 TIMED_WAITING(sleeping)

305 WAITING(onobjectmonitor)

3 WAITING(parking)

第三步

:開啟dump檔案檢視處於WAITING(onobjectmonitor)的執行緒在做什麼。發現這些執行緒基本全是JBOSS的工作執行緒,在await。說明JBOSS執行緒池裡執行緒接收到的任務太少,大量執行緒都閒著。

“http-0。0。0。0-7001-97” daemon prio=10 tid=0x000000004f6a8000 nid=0x555e in Object。wait() [0x0000000052423000]

java。lang。Thread。State: WAITING (on object monitor) at java。lang。Object。wait(Native Method)

-waiting on <0x00000007969b2280> (a org。apache。tomcat。util。net。AprEndpoint$Worker) at java。lang。Object。wait(Object。java:485)

at org。apache。tomcat。util。net。AprEndpoint$Worker。await(AprEndpoint。java:1464)

-locked <0x00000007969b2280> (a org。apache。tomcat。util。net。AprEndpoint$Worker) at org。apache。tomcat。util。net。AprEndpoint$Worker。run(AprEndpoint。java:1489)

at java。lang。Thread。run(Thread。java:662)

第四步

:減少JBOSS的工作執行緒數,找到JBOSS的執行緒池配置資訊,將maxThreads降到

100。

maxPostSize=“512000” protocol=“HTTP/1。1”

enableLookups=“false” redirectPort=“8443” acceptCount=“200” bufferSize=“16384” connectionTimeout=“15000” disableUploadTimeout=“false” useBodyEncodingForURI= “true”>

第五步

:重啟JBOSS,再dump執行緒資訊,然後統計WAITING(onobjectmonitor)的執行緒,發現減少了175個。WAITING的執行緒少了,系統上下文切換的次數就會少,因為每一次從WAITTING到RUNNABLE都會進行一次上下文的切換。讀者也可以使用vmstat命令測試一下。

[tengfei。fangtf@ifeve ~]$ grep java。lang。Thread。State dump17 | awk ‘{print $2$3$4$5}’

| sort | uniq -c

44 RUNNABLE

22 TIMED_WAITING(onobjectmonitor)

9 TIMED_WAITING(parking)

36 TIMED_WAITING(sleeping)

130 WAITING(onobjectmonitor)

1 WAITING(parking)

死鎖

鎖是個非常有用的工具,運用場景非常多,因為它使用起來非常簡單,而且易於理解。但同時它也會帶來一些困擾,那就是可能會引起死鎖,一旦產生死鎖,就會造成系統功能不可用。讓我們先來看一段程式碼,這段程式碼會引起死鎖,使執行緒t1和執行緒t2互相等待對方釋放鎖。

public class DeadLockDemo { privat static String A = “A”; private static String B = “B”; public static void main(String[] args) { new DeadLockDemo()。deadLock(); } private void deadLock() { Thread t1 = new Thread(new Runnable() { @Override publicvoid run() { synchronized (A) { try { Thread。currentThread()。sleep(2000); } catch (InterruptedException e) { e。printStackTrace(); } synchronized (B) { System。out。println(“1”); } } } }); Thread t2 = new Thread(new Runnable() { @Override publicvoid run() { synchronized (B) { synchronized (A) { System。out。println(“2”); } } } }); t1。start(); t2。start(); }}

這段程式碼只是演示死鎖的場景,在現實中你可能不會寫出這樣的程式碼。但是,在一些更為

複雜的場景中,你可能會遇到這樣的問題,比如t1拿到鎖之後,因為一些異常情況沒有釋放鎖

(死迴圈)。又或者是t1拿到一個數據庫鎖,釋放鎖的時候丟擲了異常,沒釋放掉。

一旦出現死鎖,業務是可感知的,因為不能繼續提供服務了,那麼只能透過dump執行緒檢視到底是哪個執行緒出現了問題,以下執行緒資訊告訴我們是DeadLockDemo類的第42行和第31行引起的死鎖。

“Thread-2” prio=5 tid=7fc0458d1000 nid=0x116c1c000 waiting for monitor entry [116c1b00 java。lang。Thread。State: BLOCKED (on object monitor)

at com。ifeve。book。forkjoin。DeadLockDemo$2。run(DeadLockDemo。java:42)

-waiting to lock <7fb2f3ec0> (a java。lang。String)

-locked <7fb2f3ef8> (a java。lang。String) at java。lang。Thread。run(Thread。java:695)

“Thread-1” prio=5 tid=7fc0430f6800 nid=0x116b19000 waiting for monitor entry [116b1800 java。lang。Thread。State: BLOCKED (on object monitor)

at com。ifeve。book。forkjoin。DeadLockDemo$1。run(DeadLockDemo。java:31)

-waiting to lock <7fb2f3ef8> (a java。lang。String)

-locked <7fb2f3ec0> (a java。lang。String) at java。lang。Thread。run(Thread。java:695)

現在我們介紹避免死鎖的幾個常見方法。

·避免一個執行緒同時獲取多個鎖。

·避免一個執行緒在鎖內同時佔用多個資源,儘量保證每個鎖只佔用一個資源。

·嘗試使用定時鎖,使用lock。tryLock(timeout)來替代使用內部鎖機制。

·對於資料庫鎖,加鎖和解鎖必須在一個數據庫連線裡,否則會出現解鎖失敗的情況。

資源限制的挑戰

(1)什麼是資源限制

資源限制是指在進行併發程式設計時,程式的執行速度受限於計算機硬體資源或軟體資源。例如,伺服器的頻寬只有2Mb/s,某個資源的下載速度是1Mb/s每秒,系統啟動10個執行緒下載資 源,下載速度不會變成10Mb/s,所以在進行併發程式設計時,要考慮這些資源的限制。硬體資源限制有頻寬的上傳/下載速度、硬碟讀寫速度和CPU的處理速度。軟體資源限制有資料庫的連線數和socket連線數等。

(2)資源限制引發的問題

在併發程式設計中,將程式碼執行速度加快的原則是將程式碼中序列執行的部分變成併發執行, 但是如果將某段序列的程式碼併發執行,因為受限於資源,仍然在序列執行,這時候程式不僅不會加快執行,反而會更慢,因為增加了上下文切換和資源排程的時間。例如,之前看到一段程式使用多執行緒在辦公網併發地下載和處理資料時,導致CPU利用率達到100%,幾個小時都不能執行完成任務,後來修改成單執行緒,一個小時就執行完成了。

(3)如何解決資源限制的問題

對於硬體資源限制,可以考慮使用叢集並行執行程式。既然單機的資源有限制,那麼就讓程式在多機上執行。比如使用ODPS、Hadoop或者自己搭建伺服器叢集,不同的機器處理不同的資料。可以透過“資料ID%機器數”,計算得到一個機器編號,然後由對應編號的機器處理這筆資料。

對於軟體資源限制,可以考慮使用資源池將資源複用。比如使用連線池將資料庫和Socket 連線複用,或者在呼叫對方webservice介面獲取資料時,只建立一個連線。

(4)在資源限制情況下進行併發程式設計

如何在資源限制的情況下,讓程式執行得更快呢?方法就是,根據不同的資源限制調整程式的併發度,比如下載檔案程式依賴於兩個資源——頻寬和硬碟讀寫速度。有資料庫操作時,涉及資料庫連線數,如果SQL語句執行非常快,而執行緒的數量比資料庫連線數大很多,則某些執行緒會被阻塞,等待資料庫連線。