前期回顧:
JAVA線程安全及性能的優(yōu)化筆記(三)——Volatile關鍵字
本期正文:
如果你的代碼所在的進程中有多個線程在同時運行,而這些線程可能會同時運行這段代碼曙痘。如果每次運行結果和單線程運行的結果是一樣的芳悲,而且其他的變量的值也和預期的是一樣的,就是線程安全的边坤。
或者說:一個類或者程序所提供的接口對于線程來說是原子操作或者多個線程之間的切換不會導致該接口的執(zhí)行結果存在二義性,也就是說我們不用考慮同步的問題名扛。
線程安全問題都是由全局變量及靜態(tài)變量引起的。
若每個線程中對全局變量茧痒、靜態(tài)變量只有讀操作肮韧,而無寫操作,一般來說文黎,這個全局變量是線程安全的惹苗;若有多個線程同時執(zhí)行寫操作殿较,一般都需要考慮線程同步耸峭,否則就可能影響線程安全。
比如一個 ArrayList 類淋纲,在添加一個元素的時候劳闹,它可能會有兩步來完成:1. 在 Items[Size] 的位置存放此元素;2. 增大 Size 的值洽瞬。
在單線程運行的情況下本涕,如果 Size = 0,添加一個元素后伙窃,此元素在位置 0菩颖,而且 Size=1;
而如果是在多線程情況下为障,比如有兩個線程晦闰,線程 A 先將元素存放在位置 0。但是此時 CPU 調度線程A暫停鳍怨,線程 B 得到運行的機會呻右。線程B也向此 ArrayList 添加元素,因為此時 Size 仍然等于 0 (注意哦鞋喇,我們假設的是添加一個元素是要兩個步驟哦声滥,而線程A僅僅完成了步驟1),所以線程B也將元素存放在位置0侦香。然后線程A和線程B都繼續(xù)運行落塑,都增加 Size 的值饱须。
那好,現(xiàn)在我們來看看 ArrayList 的情況夷蚊,元素實際上只有一個冒冬,存放在位置 0,而 Size
卻等于 2缠沈。這就是“線程不安全”了膘壶。
一、線程安全性
類要成為線程安全的洲愤,首先必須在單線程環(huán)境中有正確的行為颓芭。如果一個類實現(xiàn)正確(這是說它符合規(guī)格說明的另一種方式),那么沒有一種對這個類的對象的操作序列(讀或者寫公共字段以及調用公共方法)可以讓對象處于無效狀態(tài)柬赐,觀察到對象處于無效狀態(tài)亡问、或者違反類的任何不可變量、前置條件或者后置條件的情況肛宋。
此外州藕,一個類要成為線程安全的,在被多個線程訪問時酝陈,不管運行時環(huán)境執(zhí)行這些線程有什么樣的時序安排或者交錯床玻,它必須仍然有如上所述的正確行為,并且在調用的代碼中沒有任何額外的同步沉帮。其效果就是锈死,在所有線程看來,對于線程安全對象的操作是以固定的穆壕、全局一致的順序發(fā)生的待牵。
正確性與線程安全性之間的關系非常類似于在描述 ACID(原子性、一致性喇勋、獨立性和持久性)事務時使用的一致性與獨立性之間的關系:從特定線程的角度看缨该,由不同線程所執(zhí)行的對象操作是先后(雖然順序不定)而不是并行執(zhí)行的。
線程安全性不是一個非真即假的命題川背。 Vector 的方法都是同步的贰拿,并且 Vector 明確地設計為在多線程環(huán)境中工作。但是它的線程安全性是有限制的渗常,即在某些方法之間有狀態(tài)依賴(類似地壮不,如果在迭代過程中 Vector 被其他線程修改,那么由 Vector.iterator() 返回的 iterator會拋出ConcurrentModifiicationException)皱碘。
對于 Java 類中常見的線程安全性級別询一,沒有一種分類系統(tǒng)可被廣泛接受,不過重要的是在編寫類時盡量記錄下它們的線程安全行為。
Bloch 給出了描述五類線程安全性的分類方法:不可變健蕊、線程安全菱阵、有條件線程安全、線程兼容和線程對立缩功。只要明確地記錄下線程安全特性晴及,那么您是否使用這種系統(tǒng)都沒關系。這種系統(tǒng)有其局限性 -- 各類之間的界線不是百分之百地明確嫡锌,而且有些情況它沒照顧到 -- 但是這套系統(tǒng)是一個很好的起點虑稼。這種分類系統(tǒng)的核心是調用者是否可以或者必須用外部同步包圍操作(或者一系列操作)。下面幾節(jié)分別描述了線程安全性的這五種類別势木。
①. 不可變
不可變的對象一定是線程安全的蛛倦,并且永遠也不需要額外的同步[1]。因為一個不可變的對象只要構建正確啦桌,其外部可見狀態(tài)永遠也不會改變溯壶,永遠也不會看到它處于不一致的狀態(tài)。Java 類庫中大多數(shù)基本數(shù)值類如 Integer 甫男、 String 和 BigInteger 都是不可變的且改。
②. 線程安全
線程安全的對象具有在上面“線程安全”一節(jié)中描述的屬性 -- 由類的規(guī)格說明所規(guī)定的約束在對象被多個線程訪問時仍然有效,不管運行時環(huán)境如何排列板驳,線程都不需要任何額外的同步又跛。這種線程安全性保證是很嚴格的 -- 許多類,如 Hashtable 或者 Vector 都不能滿足這種嚴格的定義笋庄。
③. 有條件的線程安全
有條件的線程安全類對于單獨的操作可以是線程安全的效扫,但是某些操作序列可能需要外部同步。條件線程安全的最常見的例子是遍歷由 Hashtable 或者 Vector 或者返回的迭代器 -- 由這些類返回的 fail-fast 迭代器假定在迭代器進行遍歷的時候底層集合不會有變化直砂。為了保證其他線程不會在遍歷的時候改變集合,進行迭代的線程應該確保它是獨占性地訪問集合以實現(xiàn)遍歷的完整性浩习。通常静暂,獨占性的訪問是由對鎖的同步保證的 -- 并且類的文檔應該說明是哪個鎖(通常是對象的內部監(jiān)視器(intrinsic monitor))。
如果對一個有條件線程安全類進行記錄谱秽,那么您應該不僅要記錄它是有條件線程安全的洽蛀,而且還要記錄必須防止哪些操作序列的并發(fā)訪問。用戶可以合理地假設其他操作序列不需要任何額外的同步疟赊。
④. 線程兼容
線程兼容類不是線程安全的郊供,但是可以通過正確使用同步而在并發(fā)環(huán)境中安全地使用。這可能意味著用一個 synchronized 塊包圍每一個方法調用近哟,或者創(chuàng)建一個包裝器對象驮审,其中每一個方法都是同步的(就像 Collections.synchronizedList() 一樣)。也可能意味著用 synchronized 塊包圍某些操作序列。為了最大程度地利用線程兼容類疯淫,如果所有調用都使用同一個塊地来,那么就不應該要求調用者對該塊同步。這樣做會使線程兼容的對象作為變量實例包含在其他線程安全的對象中熙掺,從而可以利用其所有者對象的同步未斑。
許多常見的類是線程兼容的,如集合類 ArrayList 和 HashMap 币绩、 java.text.SimpleDateFormat 蜡秽、或者 JDBC 類 Connection 和 ResultSet 。
⑤. 線程對立
線程對立類是那些不管是否調用了外部同步都不能在并發(fā)使用時安全地呈現(xiàn)的類缆镣。線程對立很少見载城,當類修改靜態(tài)數(shù)據(jù),而靜態(tài)數(shù)據(jù)會影響在其他線程中執(zhí)行的其他類的行為费就,這時通常會出現(xiàn)線程對立诉瓦。線程對立類的一個例子是調用 System.setOut() 的類。
二力细、深入研究Servlet線程安全性問題
Servlet/JSP技術和ASP睬澡、PHP等相比,由于其多線程運行而具有很高的執(zhí)行效率眠蚂。由于Servlet/JSP默認是以多線程模式執(zhí)行的煞聪,所以,在編寫代碼時需要非常細致地考慮多線程的安全性問題逝慧。然而昔脯,很多人編寫Servlet/JSP程序時并沒有注意到多線程安全性的問題,這往往造成編寫的程序在少量用戶訪問時沒有任何問題笛臣,而在并發(fā)用戶上升到一定值時云稚,就會經常出現(xiàn)一些莫明其妙的問題。
1. Servlet的多線程機制
Servlet體系結構是建立在Java多線程機制之上的沈堡,它的生命周期是由Web容器負責的静陈。當客戶端第一次請求某個Servlet時,Servlet容器將會根據(jù)web.xml配置文件實例化這個Servlet類诞丽。當有新的客戶端請求該Servlet時鲸拥,一般不會再實例化該Servlet類,也就是有多個線程在使用這個實例僧免。Servlet容器會自動使用線程池等技術來支持系統(tǒng)的運行刑赶,如圖1所示。
這樣懂衩,當兩個或多個線程同時訪問同一個Servlet時撞叨,可能會發(fā)生多個線程同時訪問同一資源的情況金踪,數(shù)據(jù)可能會變得不一致。所以在用Servlet構建的Web應用時如果不注意線程安全的問題谒所,會使所寫的Servlet程序有難以發(fā)現(xiàn)的錯誤热康。
2. Servlet的線程安全問題
Servlet的線程安全問題主要是由于實例變量使用不當而引起的,這里以一個現(xiàn)實的例子來說明劣领。
Importjavax.servlet.*;
Importjavax.servlet.http.*;
Importjava.io.*;
PublicclassConcurrentTestextendsHttpServlet{
PrintWriteroutput;
Publicvoidservice(HttpServletRequestrequest,
HttpServletResponseresponse)throwsServletException,IOException{
Stringusername;
Response.setContentType("text/html;charset=gb2312");
Username=request.getParameter("username");
Output=response.getWriter();
Try{
Thread.sleep(5000);
//為了突出并發(fā)問題姐军,在這設置一個延時
}
Catch(InterruptedExceptione){
}
output.println("用戶名:"+Username+"<BR>");
}
}
該Servlet中定義了一個實例變量output,在service方法將其賦值為用戶的輸出尖淘。當一個用戶訪問該Servlet時奕锌,程序會正常的運行,但當多個用戶并發(fā)訪問時村生,就可能會出現(xiàn)其它用戶的信息顯示在另外一些用戶的瀏覽器上的問題惊暴。這是一個嚴重的問題。為了突出并發(fā)問題趁桃,便于測試辽话、觀察,我們在回顯用戶信息時執(zhí)行了一個延時的操作卫病。假設已在web.xml配置文件中注冊了該Servlet油啤,現(xiàn)有兩個用戶a和b同時訪問該Servlet(可以啟動兩個IE瀏覽器,或者在兩臺機器上同時訪問),即同時在瀏覽器中輸入:
a:http://localhost:8080/servlet/ConcurrentTest?Username=a
b:http://localhost:8080/servlet/ConcurrentTest?Username=b
如果用戶b比用戶a回車的時間稍慢一點蟀苛,將得到如圖2所示的輸出:
從圖2中可以看到益咬,Web服務器啟動了兩個線程分別處理來自用戶a和用戶b的請求,但是在用戶a的瀏覽器上卻得到一個空白的屏幕帜平,用戶a的信息顯示在用戶b的瀏覽器上幽告。該Servlet存在線程不安全問題。下面我們就從分析該實例的內存模型入手,觀察不同時刻實例變量output的值來分析使該Servlet線程不安全的原因裆甩。
Java的內存模型JMM(JavaMemoryModel)JMM主要是為了規(guī)定了線程和內存之間的一些關系冗锁。根據(jù)JMM的設計,系統(tǒng)存在一個主內存(MainMemory)淑掌,Java中所有實例變量都儲存在主存中蒿讥,對于所有線程都是共享的。每條線程都有自己的工作內存(WorkingMemory)抛腕,工作內存由緩存和堆棧兩部分組成,緩存中保存的是主存中變量的拷貝媒殉,緩存可能并不總和主存同步担敌,也就是緩存中變量的修改可能沒有立刻寫到主存中;堆棧中保存的是線程的局部變量廷蓉,線程之間無法相互直接訪問堆棧中的變量全封。根據(jù)JMM马昙,我們可以將論文中所討論的Servlet實例的內存模型抽象為圖3所示的模型。
下面根據(jù)圖3所示的內存模型刹悴,來分析當用戶a和b的線程(簡稱為a線程行楞、b線程)并發(fā)執(zhí)行時,Servlet實例中所涉及變量的變化情況及線程的執(zhí)行情況土匀,如圖4所示子房。
從圖4中可以清楚的看到,由于b線程對實例變量output的修改覆蓋了a線程對實例變量output的修改就轧,從而導致了用戶a的信息顯示在了用戶b的瀏覽器上证杭。如果在a線程執(zhí)行輸出語句時,b線程對output的修改還沒有刷新到主存妒御,那么將不會出現(xiàn)圖2所示的輸出結果解愤,因此這只是一種偶然現(xiàn)象,但這更增加了程序潛在的危險性乎莉。
3. 設計線程安全的Servlet
通過上面的分析送讲,我們知道了實例變量不正確的使用是造成Servlet線程不安全的主要原因。下面針對該問題給出了三種解決方案并對方案的選取給出了一些參考性的建議惋啃。
①. 實現(xiàn)SingleThreadModel接口
該接口指定了系統(tǒng)如何處理對同一個Servlet的調用哼鬓。如果一個Servlet被這個接口指定,那么在這個Servlet中的service方法將不會有兩個線程被同時執(zhí)行,當然也就不存在線程安全的問題肥橙。這種方法只要將前面的ConcurrentTest類的類頭定義更改為:
PublicclassConcurrentTestextendsHttpServletimplementsSingleThreadModel{
…………
}
②. 同步對共享數(shù)據(jù)的操作
使用synchronized關鍵字能保證一次只有一個線程可以訪問被保護的區(qū)段魄宏,在本論文中的Servlet可以通過同步塊操作來保證線程的安全。同步后的代碼如下:
…………
PublicclassConcurrentTestextendsHttpServlet{
…………
Username=request.getParameter("username");
Synchronized(this){
Output=response.getWriter();
Try{
Thread.Sleep(5000);
}
Catch(InterruptedExceptione){
}
output.println("用戶名:"+Username+"<BR>");
}
}
}
③. 避免使用實例變量
本實例中的線程安全問題是由實例變量造成的存筏,只要在Servlet里面的任何方法里面都不使用實例變量宠互,那么該Servlet就是線程安全的。
修正上面的Servlet代碼椭坚,將實例變量改為局部變量實現(xiàn)同樣的功能予跌,代碼如下:
……
PublicclassConcurrentTestextendsHttpServlet{
publicvoidservice(HttpServletRequestrequest,HttpServletResponse
Response)throwsServletException,IOException{
PrintWriteroutput;
Stringusername;
Response.setContentType("text/html;charset=gb2312");
……
}
}
對上面的三種方法進行測試,可以表明用它們都能設計出線程安全的Servlet程序善茎。但是券册,如果一個Servlet實現(xiàn)了SingleThreadModel接口,Servlet引擎將為每個新的請求創(chuàng)建一個單獨的Servlet實例垂涯,這將引起大量的系統(tǒng)開銷烁焙。SingleThreadModel在Servlet2.4中已不再提倡使用;同樣如果在程序中使用同步來保護要使用的共享的數(shù)據(jù)耕赘,也會使系統(tǒng)的性能大大下降骄蝇。這是因為被同步的代碼塊在同一時刻只能有一個線程執(zhí)行它,使得其同時處理客戶請求的吞吐量降低操骡,而且很多客戶處于阻塞狀態(tài)九火。另外為保證主存內容和線程的工作內存中的數(shù)據(jù)的一致性赚窃,要頻繁地刷新緩存,這也會大大地影響系統(tǒng)的性能。所以在實際的開發(fā)中也應避免或最小化Servlet中的同步代碼岔激;在Serlet中避免使用實例變量是保證Servlet線程安全的最佳選擇勒极。從Java內存模型也可以知道,方法中的臨時變量是在棧上分配空間虑鼎,而且每個線程都有自己私有的椚枘洌空間,所以它們不會影響線程的安全震叙。
4. 小結
Servlet的線程安全問題只有在大量的并發(fā)訪問時才會顯現(xiàn)出來掀鹅,并且很難發(fā)現(xiàn),因此在編寫Servlet程序時要特別注意媒楼。線程安全問題主要是由實例變量造成的,因此在Servlet中應避免使用實例變量乐尊。如果應用程序設計無法避免使用實例變量,那么使用同步來保護要使用的實例變量划址,但為保證系統(tǒng)的最佳性能扔嵌,應該同步可用性最小的代碼路徑。