一、基礎概念
1、進程和線程
進程是程序運行資源分配的最小單位
進程是操作系統進行資源分配的最小單位,其中資源包括:CPU、內存空間、磁盤 IO 等,同一進程中的多條線程共享該進程中的全部系統資源,而進程和進程之間是相互獨立的。進程是具有一定獨立功能的程序關于某個數據集合上的一次運行活動,進程是系統進行資源分配和調度的一個獨立單位。顯然,程序是死的、靜態的,進程是活的、動態的。進程可以分為系統進程和用戶進程。凡是用于完成操作系統的各種功能的進程就是系統進程,它們就是處于運行狀態下的操作系統本身,用戶進程就是所有由你啟動的進程。
線程是 CPU 調度的最小單位,必須依賴于進程而存在
線程是進程的一個實體,是 CPU 調度和分派的基本單位,它是比進程更小的、能獨立運行的基本單位。線程自己基本上不擁有系統資源,只擁有一點在運行中必不可少的資源(如程序計數器,一組寄存器和棧),但是它可與同屬一個進程的其他的線程共享進程所擁有的全部資源。
2、CPU 核心數和線程數的關系
多核心:也指單芯片多處理器( Chip Multiprocessors,簡稱 CMP),CMP 是由美國斯坦福大學提出的,其思想是將大規模并行處理器中的 SMP(對稱多處理器)集成到同一芯片內,各個處理器并行執行不同的進程。這種依靠多個 CPU 同時并行地運行程序是實現超高速計算的一個重要方向,稱為并行處理。
多線程: Simultaneous Multithreading.簡稱 SMT.讓同一個處理器上的多個線程同步執行并共享處理器的執行資源。
核心數、線程數:目前主流 CPU 都是多核的。增加核心數目就是為了增加線程數,因為操作系統是通過線程來執行任務的,一般情況下它們是 1:1 對應關系,也就是說四核 CPU 一般擁有四個線程。但 Intel 引入超線程技術后,使核心數與線程數形成 1:2 的關系
3、CPU 時間片輪轉機制
我們平時在開發的時候,感覺并沒有受 cpu 核心數的限制,想啟動線程就啟動線程,哪怕是在單核 CPU 上,為什么?這是因為操作系統提供了一種 CPU 時間片輪轉機制。時間片輪轉調度是一種最古老、最簡單、最公平且使用最廣的算法,又稱 RR調度。每個進程被分配一個時間段,稱作它的時間片,即該進程允許運行的時間。百度百科對 CPU 時間片輪轉機制原理解釋如下:如果在時間片結束時進程還在運行,則 CPU 將被剝奪并分配給另一個進程。如果進程在時間片結束前阻塞或結束,則 CPU 當即進行切換。調度程序所要做的就是維護一張就緒進程列表,當進程用完它的時間片后,它被移到隊列的末尾。時間片輪轉調度中唯一有趣的一點是時間片的長度。從一個進程切換到另一個進程是需要一定時間的,包括保存和裝入寄存器值及內存映像,更新各種表格和隊列等。假如進程切換( processwitch),有時稱為上下文切換( context switch,),需要 5ms, 再假設時間片設為 20ms,則在做完 20ms 有用的工作之后,CPU 將花費 5ms 來進行進程切換。CPU 時間的 20%被浪費在了管理開銷上了。為了提高 CPU 效率,我們可以將時間片設為 5000ms。這時浪費的時間只有0.1%。但考慮到在一個分時系統中,如果有 10 個交互用戶幾乎同時按下回車鍵, 將發生什么情況?假設所有其他進程都用足它們的時間片的話,最后一個不幸的進程不得不等待 5s 才獲得運行機會。多數用戶無法忍受一條簡短命令要 5 s才能做出響應,同樣的問題在一臺支持多道程序的個人計算機上也會發生。結論可以歸結如下:時間片設得太短會導致過多的進程切換,降低了 CPU 效率:而設得太長又可能引起對短的交互請求的響應變差。將時間片設為 100ms 通常是一個比較合理的折衷。
在 CPU 死機的情況下,其實大家不難發現當運行一個程序的時候把 CPU 給弄到了 100%再不重啟電腦的情況下,其實我們還是有機會把它 KⅢ掉的,我想也正是因為這種機制的緣故。?
4、澄清并行和并發
我們舉個例子,如果有條高速公路 A 上面并排有 8 條車道,那么最大的并行車輛就是 8 輛此條高速公路 A 同時并排行走的車輛小于等于 8 輛的時候,車輛就可以并行運行。CPU 也是這個原理,一個 CPU 相當于一個高速公路 A,核心數或者線程數就相當于并排可以通行的車道;而多個 CPU 就相當于并排有多條高速公路,而每個高速公路并排有多個車道。當談論并發的時候一定要加個單位時間,也就是說單位時間內并發量是多少?離開了單位時間其實是沒有意義的。俗話說,一心不能二用,這對計算機也一樣,原則上一個 CPU 只能分配給一個進程,以便運行這個進程。我們通常使用的計算機中只有一個 CPU,也就是說只有一顆心,要讓它一心多用同時運行多個進程,就必須使用并發技術。實現并發技術
相當復雜,最容易理解的是“時間片輪轉進程調度算法”。綜合來說:并發:指應用能夠交替執行不同的任務,比如單 CPU 核心下執行多線程并非是同時執行多個任務,如果你開兩個線程執行,就是在你幾乎不可能察覺到的速度不斷去切換這兩個任務,已達到"同時執行效果",其實并不是的,只是計算機的速度太快,我們無法察覺到而已. 并行:指應用能夠同時執行不同的任務,例:吃飯的時候可以邊吃飯邊打電話, 這兩件事情可以同時執行兩者區別:一個是交替執行,一個是同時執行.
5、高并發編程的意義、好處
由于多核多線程的 CPU 的誕生,多線程、高并發的編程越來越受重視和關注。多線程可以給程序帶來如下好處。?
(1)充分利用 CPU 的資源
從上面的 CPU 的介紹,可以看的出來,現在市面上沒有 CPU 的內核不使用多線程并發機制的,特別是服務器還不止一個 CPU,如果還是使用單線程的技術做思路, 明顯就 out 了。因為程序的基本調度單元是線程,并且一個線程也只能在一個 CPU的一個核的一個線程跑,如果你是個 i3 的 CPU 的話,最差也是雙核心 4 線程的運算能力:如果是一個線程的程序的話,那是要浪費 3/4 的 CPU 性能:如果設計一個多線程的程序的話,那它就可以同時在多個 CPU 的多個核的多個線程上跑,可以充分地利用 CPU,減少 CPU 的空閑時間,發揮它的運算能力,提高并發量。就像我們平時坐地鐵一樣,很多人坐長線地鐵的時候都在認真看書,而不是為了坐地鐵而坐地鐵,到家了再去看書,這樣你的時間就相當于有了兩倍。這就是為什么有些人時間很充裕,而有些人老是說沒時間的一個原因,工作也是這樣,有的時候可以并發地去做幾件事情,充分利用我們的時間,CPU 也是一樣,也要充分利用。
(2)加快響應用戶的時間
比如我們經常用的迅雷下載,都喜歡多開幾個線程去下載,誰都不愿意用一個線程去下載,為什么呢?答案很簡單,就是多個線程下載快啊。我們在做程序開發的時候更應該如此,特別是我們做互聯網項目,網頁的響應時間若提升 1s,如果流量大的話,就能增加不少轉換量。做過高性能 web 前端調優的都知道,要將靜態資源地址用兩三個子域名去加載,為什么?因為每多一個子域名,瀏覽器在加載你的頁面的時候就會多開幾個線程去加載你的頁面資源,提升網站的響應速度。多線程,高并發真的是無處不在。
(3)可以使你的代碼模塊化,異步化,簡單化
例如我們實現電商系統,下訂單和給用戶發送短信、郵件就可以進行拆分,將給用戶發送短信、郵件這兩個步驟獨立為單獨的模塊,并交給其他線程去執行。這樣既增加了異步的操作,提升了系統性能,又使程序模塊化,清晰化和簡單化。多線程應用開發的好處還有很多,大家在日后的代碼編寫過程中可以慢慢體會它的魅力。?
6、多線程程序需要注意事項
(1)線程之間的安全性
從前面的章節中我們都知道,在同一個進程里面的多線程是資源共享的,也就是都可以訪問同一個內存地址當中的一個變量。例如:若每個線程中對全局變量、靜態變量只有讀操作,而無寫操作,一般來說,這個全局變量是線程安全的:若有多個線程同時執行寫操作,一般都需要考慮線程同步,否則就可能影響線程安全。
(2)線程之間的死鎖
為了解決線程之間的安全性引入了 Java 的鎖機制,而一不小心就會產生 Java線程死鎖的多線程問題,因為不同的線程都在等待那些根本不可能被釋放的鎖,從而導致所有的工作都無法完成。假設有兩個線程,分別代表兩個饑餓的人,他們必須共享刀叉并輪流吃飯。他們都需要獲得兩個鎖:共享刀和共享叉的鎖。假如線程 A 獲得了刀,而線程 B 獲得了叉。線程 A 就會進入阻塞狀態來等待獲得叉,而線程 B 則阻塞來等待線程 A 所擁有的刀。這只是人為設計的例子,但盡管在運行時很難探測到,這類情況卻時常發生
(3)線程太多了會將服務器資源耗盡形成死機當機
線程數太多有可能造成系統創建大量線程而導致消耗完系統內存以及 CPU的“過渡切換”,造成系統的死機,那么我們該如何解決這類問題呢?
某些系統資源是有限的,如文件描述符。多線程程序可能耗盡資源,因為每個線程都可能希望有一個這樣的資源。如果線程數相當大,或者某個資源的侯選線程數遠遠超過了可用的資源數則最好使用資源池。一個最好的示例是數據庫連接池。只要線程需要使用一個數據庫連接,它就從池中取出一個,使用以后再將它返回池中。資源池也稱為資源庫。多線程應用開發的注意事項很多,希望大家在日后的工作中可以慢慢體會它的危險所在。?
二、認識 Java 里的線程
1、Java 程序天生就是多線程的
一個 Java 程序從 main()方法開始執行,然后按照既定的代碼邏輯執行,看似沒有其他線程參與,但實際上 Java 程序天生就是多線程程序,因為執行 main()方法的是一個名稱為 main 的線程。
[6] Monitor Ctrl-Break //監控 Ctrl-Break 中斷信號的
[5] Attach Listener //內存 dump,線程 dump,類信息統計,獲取系統屬性等
[4] Signal Dispatcher // 分發處理發送給 JVM 信號的線程
[3] Finalizer // 調用對象 finalize 方法的線程
[2] Reference Handler//清除 Reference 的線程
[1] main //main 線程,用戶程序入口
2、線程的啟動與中止
(1)啟動
啟動線程的方式有:
1、X extends Thread;,然后 X.start
2、X implements Runnable;然后交給 Thread 運行
Thread 和 Runnable 的區別
Thread 才是 Java 里對線程的唯一抽象,Runnable 只是對任務(業務邏輯)的抽象。Thread 可以接受任意一個 Runnable 的實例并執行。
(2)中止
線程自然終止
要么是 run 執行完成了,要么是拋出了一個未處理的異常導致線程提前結束。
stop
暫停、恢復和停止操作對應在線程 Thread 的 API 就是 suspend()、resume()和 stop()。但是這些 API 是過期的,也就是不建議使用的。不建議使用的原因主要有:以 suspend()方法為例,在調用后,線程不會釋放已經占有的資源(比如鎖),而是占有著資源進入睡眠狀態,這樣容易引發死鎖問題。同樣,stop()方法在終結一個線程時不會保證線程的資源正常釋放,通常是沒有給予線程完成資源釋放工作的機會,因此會導致程序可能工作在不確定狀態下。正因為 suspend()、resume()和 stop()方法帶來的副作用,這些方法才被標注為不建議使用的過期方法。
中斷
安全的中止則是其他線程通過調用某個線程 A 的 interrupt()方法對其進行中斷操作, 中斷好比其他線程對該線程打了個招呼,“A,你要中斷了”,不代表線程 A 會立即停止自己的工作,同樣的 A 線程完全可以不理會這種中斷請求。
因為 java 里的線程是協作式的,不是搶占式的。線程通過檢查自身的中斷標志位是否被置為 true 來進行響應,線程通過方法 isInterrupted()來進行判斷是否被中斷,也可以調用靜態方法Thread.interrupted()來進行判斷當前線程是否被中斷,不過 Thread.interrupted()會同時將中斷標識位改為 false。如果一個線程處于了阻塞狀態(如線程調用了 thread.sleep、thread.join、thread.wait 等),則在線程在檢查中斷標示時如果發現中斷標示為 true,則會在這些阻塞方法調用處拋出 InterruptedException 異常,并且在拋出異常后會立即將線程的中斷標示位清除,即重新設置為 false。不建議自定義一個取消標志位來中止線程的運行。因為 run 方法里有阻塞調用時會無法很快檢測到取消標志,線程必須從阻塞調用返回后,才會檢查這個取消標志。這種情況下,使用中斷會更好,因為,
一、一般的阻塞方法,如 sleep 等本身就支持中斷的檢查,
二、檢查中斷位的狀態和檢查取消標志位沒什么區別,用中斷位的狀態還可以避免聲明取消標志位,減少資源的消耗。
注意:處于死鎖狀態的線程無法被中斷
3、深入理解 run()和 start()
Thread類是Java里對線程概念的抽象,可以這樣理解:我們通過new Thread()其實只是 new 出一個 Thread 的實例,還沒有和操作系統中真正的線程掛起鉤來。只有執行了 start()方法后,才實現了真正意義上的啟動線程。start()方法讓一個線程進入就緒隊列等待分配 cpu,分到 cpu 后才調用實現的 run()方法,start()方法不能重復調用,如果重復調用會拋出異常。而 run 方法是業務邏輯實現的地方,本質上和任意一個類的任意一個成員方法并沒有任何區別,可以重復執行,也可以被單獨調用。?
4、其他的線程相關方法
(1)yield()方法
使當前線程讓出 CPU 占有權,但讓出的時間是不可設定的。也不會釋放鎖資源。注意:并不是每個線程都需要這個鎖的,而且執行 yield( )的線程不一定就會持有鎖,我們完全可以在釋放鎖后再調用 yield 方法。所有執行 yield()的線程有可能在進入到就緒狀態后會被操作系統再次選中馬上又被執行。
(2)wait()/notify()/notifyAll()
后面會單獨講述
5、join 方法
把指定的線程加入到當前線程,可以將兩個交替執行的線程合并為順序執行。
比如在線程 B 中調用了線程 A 的 Join()方法,直到線程 A 執行完畢后,才會繼續
執行線程 B。(此處為常見面試考點)
6、線程的優先級
在 Java 線程中,通過一個整型成員變量 priority 來控制優先級,優先級的范圍從 1~10,在線程構建的時候可以通過 setPriority(int)方法來修改優先級,默認優先級是 5,優先級高的線程分配時間片的數量要多于優先級低的線程。
設置線程優先級時,針對頻繁阻塞(休眠或者 I/O 操作)的線程需要設置較高優先級,而偏重計算(需要較多 CPU 時間或者偏運算)的線程則設置較低的優先級,確保處理器不會被獨占。在不同的 JVM 以及操作系統上,線程規劃會存在差異,有些操作系統甚至會忽略對線程優先級的設定。?
7、守護線程
Daemon(守護)線程是一種支持型線程,因為它主要被用作程序中后臺調度以及支持性工作。這意味著,當一個 Java 虛擬機中不存在非 Daemon 線程的時候,Java 虛擬機將會退出。可以通過調用 Thread.setDaemon(true)將線程設置為 Daemon 線程。我們一般用不上,比如垃圾回收線程就是 Daemon 線程。
Daemon 線程被用作完成支持性工作,但是在 Java 虛擬機退出時 Daemon 線程中的 finally 塊并不一定會執行。在構建 Daemon 線程時,不能依靠 finally 塊中的內容來確保執行關閉或清理資源的邏輯。
三、 線程間的共享和協作
1、線程間的共享
(1)synchronized 內置鎖
線程開始運行,擁有自己的棧空間,就如同一個腳本一樣,按照既定的代碼一步一步地執行,直到終止。但是,每個運行中的線程,如果僅僅是孤立地運行,那么沒有一點兒價值,或者說價值很少,如果多個線程能夠相互配合完成工作,包括數據之間的共享,協同處理事情。這將會帶來巨大的價值。Java 支持多個線程同時訪問一個對象或者對象的成員變量,關鍵字synchronized 可以修飾方法或者以同步塊的形式來進行使用,它主要確保多個線程在同一個時刻,只能有一個線程處于方法或者同步塊中,它保證了線程對變量訪問的可見性和排他性,又稱為內置鎖機制。
對象鎖和類鎖
對象鎖是用于對象實例方法,或者一個對象實例上的,類鎖是用于類的靜態方法或者一個類的 class 對象上的。我們知道,類的對象實例可以有很多個,但是每個類只有一個 class 對象,所以不同對象實例的對象鎖是互不干擾的,但是每個類只有一個類鎖。但是有一點必須注意的是,其實類鎖只是一個概念上的東西,并不是真實存在的,類鎖其實鎖的是每個類的對應的 class 對象。類鎖和對象鎖之間也是互不干擾的。
對象鎖和類鎖,以及鎖 static 變量之間的運行情況,請參考包cn.enjoyedu.ch1.syn 下的代碼。
?(2)錯誤的加鎖和原因分析
參見代碼 cn.enjoyedu.ch1. syn.TestIntegerSyn
原因:雖然我們對 i 進行了加鎖,但是但是當我們反編譯這個類的 class 文件后,可以看到 i++實際是,本質上是返回了一個新的 Integer 對象。也就是每個線程實際加鎖的是不同的 Integer 對象。
(3)volatile,最輕量的同步機制
volatile 保證了不同線程對這個變量進行操作時的可見性,即一個線程修改了某個變量的值,這新值對其他線程來說是立即可見的。參見代碼:
cn.enjoyedu.ch1.vola. VolatileCase
不加 volatile 時,子線程無法感知主線程修改了 ready 的值,從而不會退出循環,而加了 volatile 后,子線程可以感知主線程修改了 ready 的值,迅速退出循環。但是 volatile 不能保證數據在多個線程下同時寫時的線程安全,參見代碼:
cn.enjoyedu.ch1.vola. NotSafe
volatile 最適用的場景:一個線程寫,多個線程讀。
2、ThreadLocal 辨析
(1)與 Synchonized 的比較
ThreadLocal 和 Synchonized 都用于解決多線程并發訪問。可是 ThreadLocal與 synchronized 有本質的差別。synchronized 是利用鎖的機制,使變量或代碼塊在某一時該僅僅能被一個線程訪問。而 ThreadLocal 為每個線程都提供了變量的副本,使得每個線程在某一時間訪問到的并非同一個對象,這樣就隔離了多個線程對數據的數據共享。Spring 的事務就借助了 ThreadLocal 類。Spring 會從數據庫連接池中獲得一個connection,然會把 connection 放進 ThreadLocal 中,也就和線程綁定了,事務需要提交或者回滾,只要從 ThreadLocal 中拿到 connection 進行操作。為何 Spring的事務要借助 ThreadLocal 類?以 JDBC 為例,正常的事務代碼可能如下:
dbc = new DataBaseConnection();//第 1 行
Connection con = dbc.getConnection();//第 2 行
con.setAutoCommit(false);// //第 3 行
con.executeUpdate(...);//第 4 行
con.executeUpdate(...);//第 5 行
con.executeUpdate(...);//第 6 行
con.commit();////第 7 行
上述代碼,可以分成三個部分:
事務準備階段:第 1~3 行
業務處理階段:第 4~6 行
事務提交階段:第 7 行
可以很明顯的看到,不管我們開啟事務還是執行具體的 sql 都需要一個具體的數據庫連接。現在我們開發應用一般都采用三層結構,如果我們控制事務的代碼都放在DAO(DataAccessObject)對象中,在 DAO 對象的每個方法當中去打開事務和關閉事務,當 Service 對象在調用 DAO 時,如果只調用一個 DAO,那我們這樣實現則效果不錯,但往往我們的 Service 會調用一系列的 DAO 對數據庫進行多次操作,那么,這個時候我們就無法控制事務的邊界了,因為實際應用當中,我們的 Service調用的 DAO 的個數是不確定的,可根據需求而變化,而且還可能出現 Service 調用 Service 的情況。如果不使用 ThreadLocal,代碼大概就會是這個樣子:
但是需要注意一個問題,如何讓三個 DAO 使用同一個數據源連接呢?我們就必須為每個 DAO 傳遞同一個數據庫連接,要么就是在 DAO 實例化的時候作為構造方法的參數傳遞,要么在每個 DAO 的實例方法中作為方法的參數傳遞。這兩種方式無疑對我們的 Spring 框架或者開發人員來說都不合適。為了讓這個數據庫連接可以跨階段傳遞,又不顯示的進行參數傳遞,就必須使用別的辦法。Web 容器中,每個完整的請求周期會由一個線程來處理。因此,如果我們能將一些參數綁定到線程的話,就可以實現在軟件架構中跨層次的參數共享(是隱式的共享)。而 JAVA 中恰好提供了綁定的方法--使用 ThreadLocal。結合使用 Spring 里的 IOC 和 AOP,就可以很好的解決這一點。只要將一個數據庫連接放入 ThreadLocal 中,當前線程執行時只要有使用數據庫連接的地方就從 ThreadLocal 獲得就行了。
(2)ThreadLocal 的使用
ThreadLocal 類接口很簡單,只有 4 個方法,我們先來了解一下:
? void set(Object value)
設置當前線程的線程局部變量的值。
? public Object get()
該方法返回當前線程所對應的線程局部變量。
? public void remove()
將當前線程局部變量的值刪除,目的是為了減少內存的占用,該方法是 JDK5.0 新增的方法。需要指出的是,當線程結束后,對應該線程的局部變量將自動被垃圾回收,所以顯式調用該方法清除線程的局部變量并不是必須的操作,但它可以加快內存回收的速度。
? protected Object initialValue()
返回該線程局部變量的初始值,該方法是一個 protected 的方法,顯然是為了讓子類覆蓋而設計的。這個方法是一個延遲調用方法,在線程第 1 次調用 get()或 set(Object)時才執行,并且僅執行 1 次。ThreadLocal 中的缺省實現直接返回一個 null。
public final static ThreadLocal<String> RESOURCE = newThreadLocal<String>();RESOURCE代表一個能夠存放String類型的ThreadLocal對象。此時不論什么一個線程能夠并發訪問這個變量,對它進行寫入、讀取操作,都是線程安全的。
(3)實現解析
上面先取到當前線程,然后調用 getMap 方法獲取對應的 ThreadLocalMap,ThreadLocalMap 是 ThreadLocal 的靜態內部類,然后 Thread 類中有一個這樣類型成員,所以 getMap 是直接返回 Thread 的成員。看下 ThreadLocal 的內部類 ThreadLocalMap 源碼:
可以看到有個 Entry 內部靜態類,它繼承了 WeakReference,總之它記錄了兩個信息,一個是 ThreadLocal<?>類型,一個是 Object 類型的值。getEntry 方法則是獲取某個 ThreadLocal 對應的值,set 方法就是更新或賦值相應的 ThreadLocal對應的值。
回顧我們的 get 方法,其實就是拿到每個線程獨有的 ThreadLocalMap然后再用 ThreadLocal 的當前實例,拿到 Map 中的相應的 Entry,然后就可以拿到相應的值返回出去。當然,如果 Map 為空,還會先進行 map 的創建,初始化等工作。
(4)引發的內存泄漏分析
預備知識
引用
Object o = new Object();
這個 o,我們可以稱之為對象引用,而 new Object()我們可以稱之為在內存中產生了一個對象實例。
當寫下 o=null 時,只是表示 o 不再指向堆中 object 的對象實例,不代表這個對象實例不存在了。
強引用就是指在程序代碼之中普遍存在的,類似“Object obj=new Object()”這類的引用,只要強引用還存在,垃圾收集器永遠不會回收掉被引用的對象實例。
軟引用是用來描述一些還有用但并非必需的對象。對于軟引用關聯著的對象,在系統將要發生內存溢出異常之前,將會把這些對象實例列進回收范圍之中進行第二次回收。如果這次回收還沒有足夠的內存,才會拋出內存溢出異常。在 JDK1.2 之后,提供了 SoftReference 類來實現軟引用。
弱引用也是用來描述非必需對象的,但是它的強度比軟引用更弱一些,被弱引用關聯的對象實例只能生存到下一次垃圾收集發生之前。當垃圾收集器工作時,無論當前內存是否足夠,都會回收掉只被弱引用關聯的對象實例。在 JDK 1.2 之后,提供了 WeakReference 類來實現弱引用。
虛引用也稱為幽靈引用或者幻影引用,它是最弱的一種引用關系。一個對象實例是否有虛引用的存在,完全不會對其生存時間構成影響,也無法通過虛引用來取得一個對象實例。為一個對象設置虛引用關聯的唯一目的就是能在這個對象實例被收集器回收時收到一個系統通知。在 JDK 1.2 之后,提供了PhantomReference 類來實現虛引用。內存泄漏的現象執行 cn.enjoyedu.ch1.threadlocal 下的 ThreadLocalOOM,并將堆內存大小設置為-Xmx256m,我們啟用一個線程池,大小固定為 5 個線程
場景 1,首先任務中不執行任何有意義的代碼,當所有的任務提交執行完成后,可以看見,我們這個應用的內存占用基本上為 25M 左右
場景 2,然后我們只簡單的在每個任務中 new 出一個數組,執行完成后我們可以看見,內存占用基本和場景 1 同
場景 3,當我們啟用了 ThreadLocal 以后:執行完成后我們可以看見,內存占用變為了 100M 左右
場景 4,于是,我們加入一行代碼,再執行,看看內存情況:可以看見,內存占用基本和場景 1 同。
這就充分說明,場景 3,當我們啟用了 ThreadLocal 以后確實發生了內存泄漏。
分析
根據我們前面對 ThreadLocal 的分析,我們可以知道每個 Thread 維護一個ThreadLocalMap,這個映射表的 key 是 ThreadLocal 實例本身,value 是真正需要存儲的 Object,也就是說 ThreadLocal 本身并不存儲值,它只是作為一個 key來讓線程從 ThreadLocalMap 獲取 value。仔細觀察 ThreadLocalMap,這個 map是使用 ThreadLocal 的弱引用作為 Key 的,弱引用的對象在 GC 時會被回收。因此使用了 ThreadLocal 后,引用鏈如圖所示
圖中的虛線表示弱引用。這樣,當把 threadlocal 變量置為 null 以后,沒有任何強引用指向 threadlocal實例,所以 threadlocal 將會被 gc 回收。這樣一來,ThreadLocalMap 中就會出現key 為 null 的 Entry,就沒有辦法訪問這些 key 為 null 的 Entry 的 value,如果當前線程再遲遲不結束的話,這些 key 為 null 的 Entry 的 value 就會一直存在一條強引用鏈:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value,而這塊 value 永遠不會被訪問到了,所以存在著內存泄露。只有當前 thread 結束以后,current thread 就不會存在棧中,強引用斷開,Current Thread、Map value 將全部被 GC 回收。最好的做法是不在需要使用ThreadLocal 變量后,都調用它的 remove()方法,清除數據。所以回到我們前面的實驗場景,場景 3 中,雖然線程池里面的任務執行完畢了,但是線程池里面的 5 個線程會一直存在直到 JVM 退出,我們 set 了線程的localVariable 變量后沒有調用 localVariable.remove()方法,導致線程池里面的 5 個線程的 threadLocals 變量里面的 new LocalVariable()實例沒有被釋放。其實考察 ThreadLocal 的實現,我們可以看見,無論是 get()、set()在某些時候,調用了 expungeStaleEntry 方法用來清除 Entry 中 Key 為 null 的 Value,但是這是不及時的,也不是每次都會執行的,所以一些情況下還是會發生內存泄露。只有 remove()方法中顯式調用了 expungeStaleEntry 方法。從表面上看內存泄漏的根源在于使用了弱引用,但是另一個問題也同樣值得思考:為什么使用弱引用而不是強引用?
下面我們分兩種情況討論:
key 使用強引用:對 ThreadLocal 對象實例的引用被置為 null 了,但是ThreadLocalMap 還持有這個 ThreadLocal 對象實例的強引用,如果沒有手動刪除,ThreadLocal 的對象實例不會被回收,導致 Entry 內存泄漏。
key 使用弱引用:對 ThreadLocal 對象實例的引用被被置為 null 了,由于ThreadLocalMap 持有 ThreadLocal 的弱引用,即使沒有手動刪除,ThreadLocal 的對象實例也會被回收。value 在下一次 ThreadLocalMap 調用 set,get,remove 都有機會被回收。比較兩種情況,我們可以發現:由于 ThreadLocalMap 的生命周期跟 Thread 一樣長,如果都沒有手動刪除對應 key,都會導致內存泄漏,但是使用弱引用可以多一層保障。因此,ThreadLocal 內存泄漏的根源是:由于 ThreadLocalMap 的生命周期跟Thread 一樣長,如果沒有手動刪除對應 key 就會導致內存泄漏,而不是因為弱引用。
總結
JVM 利用設置 ThreadLocalMap 的 Key 為弱引用,來避免內存泄露。
JVM 利用調用 remove、get、set 方法的時候,回收弱引用。
當 ThreadLocal 存儲很多 Key 為 null 的 Entry 的時候,而不再去調用 remove、get、set 方法,那么將導致內存泄漏。
使用線程池+ ThreadLocal 時要小心,因為這種情況下,線程是一直在不斷的重復運行的,從而也就造成了 value 可能造成累積的情況。
(5)錯誤使用 ThreadLocal 導致線程不安全
參見代碼 cn.enjoyedu.ch1.threadlocal. ThreadLocalUnsafe
運行后的結果為
如果我們加入 SleepTools.ms(2);會看的更明顯
為什么每個線程都輸出 5?難道他們沒有獨自保存自己的 Number 副本嗎?
為什么其他線程還是能夠修改這個值?仔細考察 ThreadLocal 和 Thead 的代碼,我們發現 ThreadLocalMap 中保存的其實是對象的一個引用,這樣的話,當有其他線程對這個引用指向的對象實例做修改時,其實也同時影響了所有的線程持有的對象引用所指向的同一個對象實例。這也就是為什么上面的程序為什么會輸出一樣的結果:5 個線程中保存的是同一 Number 對象的引用,在線程睡眠的時候,其他線程將 num 變量進行了修改,而修改的對象 Number 的實例是同一份,因此它們最終輸出的結果是相同的。而上面的程序要正常的工作,應該的用法是讓每個線程中的 ThreadLocal 都應該持有一個新的 Number 對象。?
3、線程間的協作
線程之間相互配合,完成某項工作,比如:一個線程修改了一個對象的值,而另一個線程感知到了變化,然后進行相應的操作,整個過程開始于一個線程,而最終執行又是另一個線程。前者是生產者,后者就是消費者,這種模式隔離了“做什么”(what)和“怎么做”(How),簡單的辦法是讓消費者線程不斷地循環檢查變量是否符合預期在 while 循環中設置不滿足的條件,如果條件滿足則退出 while 循環,從而完成消費者的工作。卻存在如下問題:
1) 難以確保及時性。
2)難以降低開銷。如果降低睡眠的時間,比如休眠 1 毫秒,這樣消費者能更加迅速地發現條件變化,但是卻可能消耗更多的處理器資源,造成了無端的浪費。
等待/通知機制
是指一個線程 A 調用了對象 O 的 wait()方法進入等待狀態,而另一個線程 B調用了對象 O 的 notify()或者 notifyAll()方法,線程 A 收到通知后從對象 O 的 wait()方法返回,進而執行后續操作。上述兩個線程通過對象 O 來完成交互,而對象上的 wait()和 notify/notifyAll()的關系就如同開關信號一樣,用來完成等待方和通知方之間的交互工作。
notify():
通知一個在對象上等待的線程,使其從 wait 方法返回,而返回的前提是該線程
獲取到了對象的鎖,沒有獲得鎖的線程重新進入 WAITING 狀態。
notifyAll():
通知所有等待在該對象上的線程
wait()
調用該方法的線程進入 WAITING 狀態,只有等待另外線程的通知或被中斷才會返回.需要注意,調用 wait()方法后,會釋放對象的鎖
wait(long)
超時等待一段時間,這里的參數時間是毫秒,也就是等待長達n 毫秒,如果沒有通知就超時返回
wait (long,int)
對于超時時間更細粒度的控制,可以達到納秒等待和通知的標準范式等待方遵循如下原則。
1)獲取對象的鎖。
2)如果條件不滿足,那么調用對象的 wait()方法,被通知后仍要檢查條件。
3)條件滿足則執行對應的邏輯。
通知方遵循如下原則。
1)獲得對象的鎖。
2)改變條件。
3)通知所有等待在對象上的線程。
在調用 wait()、notify()系列方法之前,線程必須要獲得該對象的對象級別鎖,即只能在同步方法或同步塊中調用 wait()方法、notify()系列方法,進入 wait()方法后,當前線程釋放鎖,在從 wait()返回前,線程與其他線程競爭重新獲得鎖,執行 notify()系列方法的線程退出調用了 notifyAll 的 synchronized代碼塊的時候后,他們就會去競爭。如果其中一個線程獲得了該對象鎖,它就會繼續往下執行,在它退出 synchronized 代碼塊,釋放鎖后,其他的已經被喚醒的線程將會繼續競爭獲取該鎖,一直進行下去,直到所有被喚醒的線程都執行完畢。notify 和 notifyAll 應該用誰盡可能用 notifyall(),謹慎使用 notify(),因為 notify()只會喚醒一個線程,我們無法確保被喚醒的這個線程一定就是我們需要喚醒的線程,具體表現參見代碼:
包 cn.enjoyedu.ch1.wn 下
等待超時模式實現一個連接池
調用場景:調用一個方法時等待一段時間(一般來說是給定一個時間段),如果該方法能夠在給定的時間段之內得到結果,那么將結果立刻返回,反之,超時返回默認結果。假設等待時間段是 T,那么可以推斷出在當前時間 now+T 之后就會超時等待
持續時間:REMAINING=T。
?超時時間:FUTURE=now+T。
// 對當前對象加鎖
public synchronized Object get(long mills) throws InterruptedException {
long future = System.currentTimeMillis() + mills;
long remaining = mills;
// 當超時大于 0 并且 result 返回值不滿足要求
while ((result == null) && remaining > 0) {
wait(remaining);
remaining = future - System.currentTimeMillis();
}
return result;
}
具體實現參見:包下 cn.enjoyedu.ch1.pool 的代碼
客戶端獲取連接的過程被設定為等待超時的模式,也就是在 1000 毫秒內如果無法獲取到可用連接,將會返回給客戶端一個 null。設定連接池的大小為 10個,然后通過調節客戶端的線程數來模擬無法獲取連接的場景。它通過構造函數初始化連接的最大上限,通過一個雙向隊列來維護連接,調用方需要先調用 fetchConnection(long)方法來指定在多少毫秒內超時獲取連接,當連接使用完成后,需要調用 releaseConnection(Connection)方法將連接放回線程池
四、面試題
調用 yield() 、sleep()、wait()、notify()等方法對鎖有何影響?
yield() 、sleep()被調用后,都不會釋放當前線程所持有的鎖。
調用 wait()方法后,會釋放當前線程持有的鎖,而且當前被喚醒后,會重新去競爭鎖,鎖競爭到后才會執行 wait 方法后面的代碼。
調用 notify()系列方法后,對鎖無影響,線程只有在 syn 同步代碼執行完后才會自然而然的釋放鎖,所以 notify()系列方法一般都是 syn 同步代碼的最后一行。