Java併發程式設計(1)-執行緒安全基礎概述
文章目錄
Java併發程式設計第一篇部落格,主要講解執行緒的安全性,有無狀態類是什麼,以及原子性,原子操作,競爭操作,複合操作機制,最後講解鎖機制,使用內部鎖以及內部鎖的解讀。
本文內容均總結自《Java併發程式設計實踐》第二章 執行緒安全 的內容 ,詳情可以查閱該書。
一、執行緒安全性
當多個執行緒同時訪問一個類時,如果無需考慮這些執行緒在執行環境下的排程和交替執行,並且不需要進行額外的同步處理操作,這個類的執行結果仍然正確,那麼可以稱這個類的執行緒安全類。
1.1、無狀態類
無狀態類可以理解為在多個執行緒同時訪問的情況下,每個執行緒都能得到正確的相應結果,因為無狀態類中的變數和資料都是無狀態(stateless)
的,下面通過一個Servlet來說明什麼是無狀態類:
public class StateLessServlet extends HttpServlet{
@Override
public void service (ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
String paramName = servletRequest.getParameter("paramName");
servletResponse.getWriter().write(paramName);
}
}
這個自定義的Servlet類做的事情很簡單,即接受前臺的一個特定的引數並且向前臺響應,怎麼判斷這個類是不是無狀態的類呢?我們可以設想有多個執行緒去訪問這個類,無論是執行緒同步或者不同步訪問,都會獲得和輸出特定的、正確的結果
所以,無狀態類一定是執行緒安全類,它永遠執行緒安全。
1.2、有狀態類
有狀態類,即包含了有狀態變數或者有狀態物件的類,這樣的類一般是執行緒不安全類。以下自定義Servlet類可說明什麼是有狀態類。
public class StatefulServlet extends HttpServlet{
//用於記錄請求該Servlet的次數
private long count = 0;
@Override
public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
String paramName = servletRequest.getParameter("paramName");
count++;//請求次數加1
servletResponse.getWriter().write(paramName+count);
}
}
這個Servlet中有一個有狀態量count
,在單一執行緒下,這個類是安全的,因為在每次請求時,count都唯一且確定,請求後自增即可。但是在多執行緒情況下,這個類是執行緒不安全的,因為線上程同時訪問時,這個有狀態量count
會出現錯誤,比如執行緒Thread1
剛讀到count為1時,執行緒Thread2
此時進來也讀到count為1,那麼這兩個執行緒的響應結果都為2,可是這明顯不對,正確的結果是有一個執行緒應該響應結果為3,即該Servlet被訪問了3次。
現在可以下一個小小的結論:
無狀態類一定是執行緒安全的,而有狀態類一般執行緒不安全
二、原子性
從上面的計數例子可以看出,每個執行緒對count這個變數的自增並不是一次操作完成的,而分成了三步:讀-改-寫
,首先讀取了count的當前值,然後再將其值加一,並且寫入原先變數中,它並不是一個原子操作
, 這就是導致了執行緒不安全的原因。
2.1、原子操作
所謂原子操作是指不會被執行緒排程機制打斷的操作;這種操作一旦開始,就一直執行到結束,類似於MySql資料庫中的事務操作:要不就完成該語句後Commit,中間出現錯誤就回滾(RollBack)。
2.2、競爭操作
用以下注冊器的例子來講解什麼是競爭操作:
public class LazyInitRace(){
//註冊物件
private RegisterObject instance = null;
//獲得註冊物件
public static RegisterObject getInstance(){
if(instance == null){
//註冊物件為null
return new RegisterObject();
}
//註冊物件不為null
return instance;
}
}
當有多個執行緒去請求這個類的getInstance()方法時,每個程序爭奪
註冊物件的條件為instance是否為null
,這就是執行緒的競爭條件,這就是一個競爭操作
。競爭操作會引發執行緒的不安全,比如當程序Thread1和程序Thread2同時執行到getInstance,1看到instance是null,並且例項化一個新的註冊物件。同時2也在檢查instance是否為null,此時刻instance是否為null,這依賴於時序,是無法預期的。它包括排程的無常性,以及1初始化註冊物件並設定instance域的耗時。如果2檢查到instance為null,兩個getInstance的呼叫者會得到不同的結果,然而,我們期望getInstance總是返回相同的例項,而不論執行緒的差異。上面的程式,又稱為惰性初始化
。
2.3、複合操作
在上面的計數器例子中,若count++這個自增是原子操作,那麼就不會發生執行緒的不安全,那麼每次自增都會產生預期的結果,即計數器準確地加一。這個讀-改-寫
操作的全部執行過程可以看作是複合操作
:為了保證執行緒的安全,必須讓這一系列的複合操作原子地執行。
public class StatefulServlet extends HttpServlet{
//使用concurent併發包中的atomic工具包下的原子變數類,保證多執行緒請求下的原子操作
private AtomicLong count = new AtomicLong(0);
@Override
public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
String paramName = servletRequest.getParameter("paramName");
count.incrementAndGet();//自增1
servletResponse.getWriter().write(paramName+count);
}
}
java.util.concurrent是多執行緒開發常用的併發包,其中的atomic是併發包的原子變數工具類,使用它們代替基本變數或者物件,可以保證在多執行緒請求的情況下,每次都執行的是原子操作。
三、鎖
先來看下這段用於快取前臺數字的查詢,它一共有兩個有狀態量,但是這兩個量都已經使用了原子變數(Atomic Variable)
代替了,並且使用了原子變數的set
、get
方法,那麼這段查詢是執行緒安全的嗎?
public class SychronizedFactorizer extends HttpServlet{
private AtomicReference<Integer> cacheNumber = new AtomicReference<Integer>();
private AtomicReference<List<Integer>> cacheNumbers = new AtomicReference<List<Integer>>();
@Override
public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
String paramNumber = servletRequest.getParameter("paramNumber");
if(Integer.parseInt(paramNumber) == cacheNumber.get()){
//是最新的快取
servletResponse.getWriter().write(paramNumber);
}else{
//不是最新的快取,將其替換成快取
cacheNumber.set(Integer.parseInt(paramNumber));
//計入快取集合中
List<Integer> integers = cacheNumbers.get();
//快取陣列尾部插入新快取
integers.add(Integer.parseInt(paramNumber));
}
}
}
答案是這段快取程式並不是執行緒安全的,因為執行緒和執行緒之間仍存在競爭操作,雖然每個set呼叫都是原子的,但是程式無法保證會同時更新cacheNumber和cacheNumbers;當某個執行緒只修改了cacheNumber而另一個變數還沒開始修改的時候,其他執行緒將看到Servlet違反了不變約束,這樣會形成一個程式漏洞,所以為了保護狀態的一致性,要在單一的原子操作中更新相互關聯的狀態變數。
那麼,應該如何讓兩個set量子操作合併為1個量子操作呢?這就涉及到程式碼塊的量子操作,需要用鎖(lock)
來實現。
3.1、使用內部鎖
Java提供了強制原子性的內部鎖機制:synchronized塊
。一個鎖物件有兩部分,分別是對鎖synchronized的引用,以及鎖需要保護的程式碼塊。當synchronized
關鍵字放在方法宣告時,那麼表明對整個方法的程式碼進行強制原子性,當synchronized
關鍵字單獨使用時,表明是對{}中的程式碼塊進行強制原子性,即上鎖。
(1)對整個方法上鎖
public synchronized void function(){...}
(2)對特定程式碼塊上鎖
synchronized(this){...}
上述的快取程式碼,可以改造成這樣:
ublic class SychronizedFactorizer extends HttpServlet{
private long cacheNumber = 0;
private List<Integer> cacheNumbers = new ArrayList<Integer>();
@Override
public synchronized void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
String paramNumber = servletRequest.getParameter("paramNumber");
if(Integer.parseInt(paramNumber) == cacheNumber){
//是最新的快取
servletResponse.getWriter().write(paramNumber);
}else{
//不是最新的快取,將其替換成快取
cacheNumber = Integer.parseInt(paramNumber);
//快取陣列尾部插入新快取
cacheNumbers.add(Integer.parseInt(paramNumber));
}
}
}
3.2、內部鎖解讀
執行執行緒進入synchronized塊之前會自動獲得鎖(可以理解為獲得了該段程式碼的控制權):而無論通過正常控制路徑退出。還是從塊中丟擲異常,執行緒都會在放棄對synchronized塊的控制時自動釋放鎖(可以理解為放棄對該段程式碼的控制權),從而能讓其他執行緒去獲得該鎖。獲得內部鎖的唯一途徑是:進入這個內部鎖保護的同步塊或方法。
內部鎖在Java中扮演了互斥鎖的角色,意味著至多隻有一個執行緒可以擁有鎖,如圖所示,當執行緒Thread2嘗試請求一個被執行緒Thread1佔用的鎖時,執行緒Thread2必須等待或者阻塞,直到Thread1釋放鎖,Thread2將永遠等下去。