查漏補缺1

 調度方式有兩種:協同式線程調度與搶佔式線程調度

Reactor模式即反應器模式,是併發系統常用的多線程處理方式,用以節省系統的資源,提高系統的吞吐量。舉一個餐廳吃飯的例子,可能會更好理解。

Reactor 指南中文版


前言

Java的部分有基礎、設計模式、IO、NIO、多線程,之後有時間還會把集合這部分補上去,這麼多內容裏面,難免有一些知識點遺漏,本文主要是講解這些遺漏的知識點。這些知識點,不是特別大的難點,所以沒有必要專門寫一篇文章講解;但是這些知識點,也不是一兩句話就說得清楚的,所以放在這裏。查漏補缺系列文章,每篇5個知識點,只要有值得研究的問題就會寫上來。

 

Thread.sleep(XXX)方法消耗CPU嗎?

這個知識點是我之前認識一直有錯誤的一個知識點,在我以前的認識裏面,我一直認爲Thread.sleep(1000)的這一秒鐘的時間內,線程的休眠是一直佔用着CPU的時間片休眠的,查看了資料和仔細思考之後發現不是。Thread.sleep(1000)的意思是:代碼執行到這兒,1秒鐘之內我休息一下,就不參與CPU競爭了,1秒鐘之後我再過來參與CPU競爭。

說到這兒,就要順便再提sleep和wait的區別了,JDK源碼提供給我們的註釋是非常嚴謹的:

複製代碼

 1 /**    
 2  * Causes the currently executing thread to sleep (temporarily cease 
 3  * execution) for the specified number of milliseconds, subject to 
 4  * the precision and accuracy of system timers and schedulers. The thread 
 5  * does not lose ownership of any monitors.
 6  *
 7  * @param      millis   the length of time to sleep in milliseconds.
 8  * @exception  InterruptedException if any thread has interrupted
 9  *             the current thread.  The <i>interrupted status</i> of the
10  *             current thread is cleared when this exception is thrown.
11  * @see        Object#notify()
12  */
13 public static native void sleep(long millis) throws InterruptedException;

複製代碼

複製代碼

 1 /**
 2  * Causes the current thread to wait until another thread invokes the 
 3  * {@link java.lang.Object#notify()} method or the 
 4  * {@link java.lang.Object#notifyAll()} method for this object. 
 5  * In other words, this method behaves exactly as if it simply 
 6  * performs the call <tt>wait(0)</tt>.
 7  * <p>
 8  * The current thread must own this object's monitor. The thread 
 9  * releases ownership of this monitor and waits until another thread 
10    ...
11  */
12 public final void wait() throws InterruptedException {
13 wait(0);
14 }

複製代碼

看sleep方法的第4、第5行,"The thread does not lose ownership of any monitors"

看wait方法的第8、第9行,"The thread releases ownership of this monitor"

所以二者的差別就來了,差別就在"monitor"也就是監視器上,sleep和wait方法的執行都會釋放CPU資源,但是sleep方法不會釋放掉監視器的所有權,而wait方法會釋放掉監視器的所有權。所謂監視器,就是假如sleep方法和wait方法處於同步方法/同步方法塊中,它們所持有的對象鎖

 

Thread.sleep(0)的作用

講這個問題,要先講一下兩種線程調度的方法,周志明老師的《深入理解Java虛擬機:JVM高級特性與最佳實踐》第12章第4節對這塊內容有比較清楚的解釋。

線程調度指的是系統爲線程分配處理器使用權的過程,主要調度方式有兩種:協同式線程調度搶佔式線程調度

1、協同式線程調度

使用協同式線程調度的多線程系統,線程的執行時間由線程本身來控制,線程把自己的工作執行完了之後,要主動通知系統切換到另外一個線程上。這種調度方式最大的好處就是實現簡單,而且由於線程要把自己的事情幹完了纔會進行線程切換,切換操作對線程自己是可知的,所以沒有什麼線程同步問題。不過協同式線程調度的壞處也很明顯:線程執行時間不受控制。如果一個線程編寫有問題,一直不告訴系統進行線程切換,那麼程序便會一直阻塞在那兒。所以這種方式非常不穩定,一個線程堅持不讓出CPU執行時間就可能會導致整個系統崩潰。

2、搶佔式線程調度

搶佔式線程調度方式是由系統來分配執行時間的,線程切換不由線程本身來決定。在這種實現線程調度的方式下,線程的執行時間是系統可控的,也不會有一個線程導致整個進程阻塞的問題。

很明顯Java採取的是搶佔式調度方式。Java的線程是通過映射到系統的原生線程上實現的,在某個線程掛起或者分給它的CPU時間片到了之後,操作系統會根據線程優先級、線程飢餓程度等算出一個總的優先級出來,然後再挑選一個線程,分給它時間片。

講了這麼多,回到我們的主題上,我總結兩點:

1、CPU分出來的時間片,可以競爭的線程都是會去競爭獲取的

2、調用了Thread.sleep(XXX)方法的線程,意味着在XXX毫秒的時間內,該線程不參與CPU時間片的競爭

那麼Thread.sleep(0)是幹什麼用的呢?它的作用就是:強制操作系統觸發一次CPU計算優先級並分配時間片的動作。比如線程A獲得了5毫秒的CPU執行時間,如果在執行了2毫秒的時候遇到了Thread.sleep(0)語句,那麼後面的3毫秒的時間片就不運行了,操作系統重新計算一次優先級,並分配下一個CPU時間片給哪個線程。

這個小細節對於系統運行是有好處的:

1、避免了某一線程長時間佔用CPU資源,我們知道在Java中比如開了兩個非守護線程,線程優先級爲10的線程A與線程優先級爲5的線程B同時執行,這意味着操作系統基本上絕大多數時間都在運行線程A,基本不會把CPU控制權交給線程B

2、避免了系統假死

3、讓線程有比較平均的機會獲得CPU資源

不過Thread.sleep(0)雖然好,但是不要去濫用它。一個系統CPU佔用率高是好事情,這意味着CPU在做事情,沒有閒着。但是CPU佔用率高還得保證CPU做的事情是應該做的事情,比如CPU佔用率高,但是在死循環,有意義嗎?這就是代碼寫得有問題。Thread.sleep(0)也一樣,這句語句觸發了操作系統計算優先級、分配時間片的動作,勢必佔用CPU的時間,如果在很多線程裏面都濫用這個方法的話,CPU使用率是上去了,但大多數時間做的都是無意義的事情。我認爲這個動作的目的更多是爲了優化系統,而不是代碼必須執行的一部分

 

可變對象(immutable)和不可變對象(mutable)

這個是之前一直忽略的一個知識點,比方說說起String爲什麼是一個不可變對象,只知道因爲它是被final修飾的所以不可變,而沒有抓住不可變三個字的重點:

1、不可變對象就是那些一旦被創建,它們的狀態就不能被改變的對象,每次對它們的改變都是產生了新的對象

2、可變對象就是那些創建後,狀態依然可以被改變的對象

舉個例子:String和StringBuilder,String是不可變的,因爲每次對String對象的修改都將產生一個新的String對象,而原來的對象保持不變;StringBuilder是可變的,因爲每次對StringBuilder的修改都作用於該對象本身,並沒有新的對象產生。如果這麼說還不夠清楚,截取兩段源碼,首先是String的concat方法,用戶向已有的字符串後面拼接新的字符串:

複製代碼

 1 public String concat(String str) {
 2     int otherLen = str.length();
 3     if (otherLen == 0) {
 4         return this;
 5     }
 6     char buf[] = new char[count + otherLen];
 7     getChars(0, count, buf, 0);
 8     str.getChars(0, otherLen, buf, count);
 9     return new String(0, count + otherLen, buf);
10     }

複製代碼

看到第9行,new了一個新的String出來。然後看一下StringBuilder,StringBuilder最常用的應該就是append方法了,append一個字符串的時候會調用StringBuilder的父類AbstractStringBuilder的append方法:

複製代碼

1 public AbstractStringBuilder append(String str) {
2         if (str == null) str = "null";
3         int len = str.length();
4         ensureCapacityInternal(count + len);
5         str.getChars(0, len, value, count);
6         count += len;
7         return this;
8     }

複製代碼

第5行的這個value就是一個char型數組"char[] value;",每次對StringBuilder的操作都是對value的改變。

不可變的對象對比可變對象有兩點優勢:

1、保證對象的狀態不被改變

2、不使用鎖機制就能被其他線程共享

實際上JDK本身就自帶了一些不可變類,比如String、Integer、Float以及其他的包裝類,判斷的方式就是看它們真正的那個對象是不是final的就好了。

我們自己也可以創建不可變對象,創建不可變對象應該遵循幾個原則:

1、不可變對象的狀態在創建之後就不能發生改變,任何對它的改變都應該產生一個新的對象

2、不可變對象的所有屬性應該都是final的

3、對象必須被正確地創建,比如對象引用在創建過程中不能泄露

4、對象應該是final的,以此來限制子類繼承父類,以避免子類改變了父類的不可變特性

使用不可變類的好處:

1、不可變類是線程安全的,可以不被synchronized修飾就在併發環境中共享

2、不可變對象簡化了程序開發,因爲它無需使用額外的鎖機制就可以在線程之間共享

3、不可變對象提高了程序的性能,因爲它減少了synchronized的使用

4、不可變對象時可以被重複利用的,你可以將它們緩存起來,就像字符串字面量和整型數值一樣,可以使用靜態工廠方法來提供類似於valueOf這樣的方法,它可以從緩存中返回一個已經存在的不可變對象,而不是重新創建一個

不可變對象雖然好,但是它有一個很大的缺點就是會製造出大量的垃圾,給垃圾收集帶來很大的麻煩,由於它們不能被重用而且,所以不可變對象的使用依賴於開發人員合理的使用。另外,不可變對象也有一些安全問題,比如密碼就建議不要用String,因爲:

如果密碼是以明文的形式保存成字符串 ,那麼它將一直留在內存中,直到垃圾收集器把它清除。而由於字符創被放在字符串緩存池中以方便重用,所以它就可以在內存中被保留很長時間,而這將導致安全隱患,因爲任何能夠訪問內存的人都可以清晰地看到文本中的密碼,這也是爲什麼總是應該用加密的形式而不是明文來保存密碼。由於字符串是不可變的,所以沒有任何方式可以修改字符串的值,因爲每次修改都將產生新的字符串,而如果使用char[]來保存密碼,就可以將其中所有元素都設置爲空或者是零。所以將密碼保存到字符數組中很明顯地降低了密碼被竊的風險。

 

計算密集型任務和IO密集型任務

在Java併發編程方面,計算密集型和IO密集型是兩個非常典型的例子,講解一下這方面的內容:

1、計算密集型

計算密集型,顧名思義就是應用程序需要非常多的CPU計算資源,在多核CPU時代,我們要讓每一個CPU核心都參與計算,將CPU性能充分利用起來,這樣纔算是沒有浪費服務器配置,如果在非常好的服務器配置上還運行着單線程程序那將是多麼大的浪費。對於計算密集型的應用,完全是靠CPU的核數來工作的,所以爲了讓它的優勢完全發揮出來,避免過多的上下文切換,比較理想的方案是兩種:

(1)線程數 = CPU核數 + 1

(2)線程數 = CPU核數 * 2

2、IO密集型

對於IO密集型的應用,就很好理解了,我們現在做的大部分開發都是WEB應用,涉及到大量的網絡傳輸,不僅如此,與數據庫、與緩存之間的交互也涉及IO,一旦發生IO,線程就會處於等待狀態,當IO結束,數據準備好之後,線程纔會繼續執行。因此從這裏可以發現,對於IO密集型的應用,我們可以多設置一些線程池中的線程數量,這樣就能讓在等待IO的這段時間內,線程可以去做其他事情,提供併發處理效率。但是這個線程池的線程數量也不是可以隨意增大,因爲線程上下文切換是有代價的,對於IO密集型的應用,線程數的計算有一個公式:

線程數 = CPU核心數 / (1 - 阻塞係數)

這個阻塞係數需要根據實際業務來調整,並不是絕對的。關於計算密集型,我原來理解得並不深,現在想來,似乎有些入門,我的筆記本是雙核的CPU,舉個例子,我定義了一個線程,這明顯是一個計算密集型的任務,因爲線程無限循環做i++和i--兩個操作,這兩個操作都是要不斷消耗CPU的:

複製代碼

private static class T extends Thread
{
    public void run()
    {
        int i = 0;
        while (true)
        {
            i++;
            i--;
        }
    }
}

複製代碼

測試結果爲:

(1)開一條線程,整個CPU佔用率50%左右,其中35%都在執行我們的Java代碼

(2)開兩條線程,整個CPU佔用率75%左右,其中65%都在執行我們的Java代碼

(3)開三條線程,整個CPU佔用率99%左右,其中90%都在執行我們的Java代碼

線程繼續增多,和第三條差不多,所以計算密集型的任務有結論是"線程數 = CPU核數 + 1",這樣才能真正發揮出多核CPU的性能來,讓CPU充分運行起來,做計算操作。

我原先不明白的一點是,操作系統是多進程的,爲什麼我寫了while死循環之後,操作系統90%以上都會執行我們的Java代碼,難道操作系統很少切換到別的進程去操作呢?即便是死循環,到時間了,還是會放棄CPU控制權,讓CPU執行別的進程的,不是嗎?

現在想想可以這麼理解這個問題。操作系統確實是管理着多個進程沒錯、也會在多個進程間切換沒錯,但是在分配CPU時間的時候,操作系統會根據進程執行的情況計算出一個優先級來,如果操作系統發現某個進程執行地特別勤快,那麼會優先分給它時間片。Java進程死循環就是這樣,操作系統每次時間片切過來,都發現在做CPU操作,於是就會給它一個比較高的優先級。

沒法直接證明這一點,但是可以間接證明,運行死循環代碼,用任務管理器看CPU佔用率,一開始Java進程的CPU佔用率還是慢慢上去的,然後在80%~90%之間浮動一兩秒,之後就一直在90%以上了,這種現象似乎印證了我的說法。

 

Reactor模式

這也是之前學習的時候一直沒有注意到的一個知識點。

Reactor模式即反應器模式,是併發系統常用的多線程處理方式,用以節省系統的資源,提高系統的吞吐量。舉一個餐廳吃飯的例子,可能會更好理解。

對於一個餐廳而言,每一個人來就餐是一個事件,客人會先看一下菜單,然後點餐,這就像一個網站會有很多的請求,要求服務器做一些事情,處理這些就餐事件的就需要我們的服務人員了。多線程處理的方式是這樣的:

1、來了一個人,一個服務員去服務,然後客人會看菜單、點菜,服務員將菜單給後廚

2、再來一個人,一個服務員去服務。。。

3、再來一個人,一個服務員去服務。。。

這就是多線程的處理方式,一個事件到來,就會有一個線程服務,很顯然這種方式在人少的情況下會有很好的用戶體驗,每個客人都覺得自己是VIP,專人服務的,如果餐廳一直這樣同一時間最多來5個客人,這家餐廳是客戶很好地服務下去的。

來了一個好消息,因爲這家店服務好,吃飯的人多起來了,同一時間會來10個客人,但是隻有5個服務員,這樣就不能一對一服務了,所以老闆又請了5個服務員,每個人又能享受VIP待遇了。然後人又多起來了,一時間會來20個客人,老闆不想請人了,再請人就賺不到錢了,還要給服務員開工錢呢,這時候怎麼辦?老闆想了想,10個服務員對付20個客人吧,服務員勤快點就好了,伺候完一個馬上伺候另外一個,應該還是可以的。這樣做是一個辦法,但是一個明顯的缺點就是,如果正在接受服務員服務的客人點菜很慢,其他客人可能要等待好長時間,有些脾氣火爆的客人可能等待不下去了。

Reactor模式怎麼處理這個問題呢:

老闆發現,客人點菜比較慢,大部分服務員都在等待客人點菜,其實幹的活不是太多。所以,老闆決定,客人點菜的時候,服務員去招呼其他客人,等客人點好了菜直接招呼一聲服務員,馬上就有一個服務員過去服務。有了這個新的方法之後,老闆就進行了一次裁員,只留了一個服務員!這就是利用單線程做多線程的事情。

其實Java很多東西也是來自於生活,就像上面說的Reactor模式。Reactor的中心思想是:將所有要處理的IO時間註冊到一箇中心IO多路複用器上,同時主線程阻塞在多路複用器上,一旦有IO時間到來或是準備就緒,多路複用器返回並將相應IO時間分發到對應的處理器當中,這就是一種典型的事件驅動機制。Reactor模式是編寫高性能網絡服務器的必備技術之一,它有如下優點:

1、響應快,不必爲同步單個時間所阻塞,雖然Reactor本身依然是同步的

2、編程相對簡單,可以最大程度地避免複雜的多線程及同步問題,並且避免了多線程/進程的切換開銷

3、可擴展性好,可以方便地通過增加Reactor實例個數來充分利用CPU資源

4、可複用性好,Reactor框架本身與具體事件處理邏輯無關

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章