1. 程式人生 > >Java併發程式設計(1)-執行緒安全基礎概述

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類做的事情很簡單,即接受前臺的一個特定的引數並且向前臺響應,怎麼判斷這個類是不是無狀態的類呢?我們可以設想有多個執行緒去訪問這個類,無論是執行緒同步或者不同步訪問,都會獲得和輸出特定的、正確的結果

,所以這個類是一個典型的無狀態類。其實絕大多數是Servlet都可以實現無狀態,只有當該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)代替了,並且使用了原子變數的setget方法,那麼這段查詢是執行緒安全的嗎?

  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將永遠等下去。