4實戰java高併發程序設計--4.鎖的優化及注意事項

鎖是最常用的同步方法之一。在高併發的環境下,激烈的鎖競爭會導致程序的性能下降,因此我們有必要討論一些有關鎖的性能問題,以及一些注意事項,比如避免死鎖、減小鎖粒度、鎖分離等

對於單任務或者單線程的應用而言,其主要資源消耗都花在任務本身。它既不需要維護並行數據結構間的一致性狀態,也不需要爲線程的切換和調度花費時間。但對於多線程應用來說,系統除了處理功能需求外,還需要額外維護多線程環境的特有信息,如線程本身的元數據、線程的調度、線程上下文的切換等。

事實上,在單核CPU上採用並行算法的效率一般要低於原始的串行算法的效率,其根本原因也在於此。因此,並行計算之所以能提高系統的性能,並不是因爲它“少幹活”了,而是因爲並行計算可以更合理地進行任務調度,充分利用各個CPU資源。因此,合理的併發,才能將多核CPU的性能發揮到極致。


4.1 有助於提高鎖性能的幾點建議

4.1.1 減少鎖持有時間

對於使用鎖進行併發控制的應用程序而言,在鎖競爭過程中,單個線程對鎖的持有時間與系統性能有着直接的關係。如果線程持有鎖的時間越長,那麼相對地,鎖的競爭程度也就越激烈。以下面的代碼段爲例:

在syncMethod()方法中,假設只有mutextMethod()方法是有同步需要的,而othercode1()方法和othercode2()方法並不需要做同步控制。如果othercode1()和othercode2()分別是重量級的方法,則會花費較長的CPU時間。如果在併發量較大時,使用這種對整個方法做同步的方案,則會導致等待線程大量增加。因爲一個線程,在進入該方法時獲得內部鎖,只有在所有任務都執行完後,纔會釋放鎖

一個較爲優化的解決方案是,只在必要時進行同步,這樣就能明顯減少線程持有鎖的時間,提高系統的吞吐量

注意:減少鎖的持有時間有助於降低鎖衝突的可能性,進而提升系統的併發能力。

 

4.1.2 減小鎖粒度

減小鎖粒度也是一種削弱多線程鎖競爭的有效手段。這種技術典型的使用場景就是ConcurrentHashMap類的實現

對於HashMap來說,最重要的兩個方法就是get()和put()。一種最自然的想法就是,對整個HashMap加鎖從而得到一個線程安全的對象,但是這樣做,加鎖粒度太大。對於ConcurrentHashMap類,它內部進一步細分了若干個小的HashMap,稱之爲段(SEGMENT)。在默認情況下,一個ConcurrentHashMap類可以被細分爲16個段

如果需要在ConcurrentHashMap類中增加一個新的表項,並不是將整個HashMap加鎖,而是首先根據hashcode得到該表項應該被存放到哪個段中,然後對該段加鎖,並完成put()方法操作。在多線程環境中,如果多個線程同時進行put()方法操作,只要被加入的表項不存放在同一個段中,線程間便可以做到真正的並行

由於默認有16個段,因此,如果夠幸運的話,ConcurrentHashMap類可以接受16個線程同時插入(如果都插入不同的段中),從而大大提升其吞吐量。下面代碼顯示了put()方法操作的過程。第5~6行代碼根據key獲得對應段的序號。接着在第9行得到段,然後將數據插入給定的段中。

但是,減小鎖粒度會帶來一個新的問題,即當系統需要取得全局鎖時,其消耗的資源會比較多。仍然以ConcurrentHashMap類爲例,雖然其put()方法很好地分離了鎖,但是當試圖訪問ConcurrentHashMap類的全局信息時,就需要同時取得所有段的鎖方能順利實施。比如ConcurrentHashMap類的size()方法,它將返回ConcurrentHashMap類的有效表項的數量,即ConcurrentHashMap類的全部有效表項之和。要獲取這個信息需要取得所有子段的鎖,因此,其size()方法的部分代碼如下:

可以看到在計算總數時,先要獲得所有段的鎖再求和。但是,ConcurrentHashMap類的size()方法並不總是這樣執行的,事實上,size()方法會先使用無鎖的方式求和,如果失敗纔會嘗試這種加鎖的方法。但不管怎麼說,在高併發場合ConcurrentHashMap類的size()方法的性能依然要差於同步的HashMap。

因此,只有在類似於size()方法獲取全局信息的方法調用並不頻繁時,這種減小鎖粒度的方法才能在真正意義上提高系統的吞吐量。

注意:所謂減小鎖粒度,就是指縮小鎖定對象的範圍,從而降低鎖衝突的可能性,進而提高系統的併發能力。

4.1.3 用讀寫分離鎖來替換獨佔鎖

如果說減小鎖粒度是通過分割數據結構實現的,那麼讀寫分離鎖則是對系統功能點的分割

注意:在讀多寫少的場合使用讀寫鎖可以有效提升系統的併發能力。

4.1.4 鎖分離

如果將讀寫鎖的思想進一步延伸,就是鎖分離。讀寫鎖根據讀寫操作功能上的不同,進行了有效的鎖分離。依據應用程序的功能特點,使用類似的分離思想,也可以對獨佔鎖進行分離。一個典型的案例就是java.util.concurrent.LinkedBlockingQueue的實現(我們在之前已經討論了它的近親ArrayBlockingQueue的內部實現)。在LinkedBlockingQueue的實現中,take()函數和put()函數分別實現了從隊列中取得數據和往隊列中增加數據的功能。雖然兩個函數都對當前隊列進行了修改操作,但由於LinkedBlockingQueue是基於鏈表的,因此兩個操作分別作用於隊列的前端和尾端,從理論上說,兩者並不衝突。

通過takeLock和putLock兩把鎖,LinkedBlockingQueue實現了取數據和寫數據的分離,使兩者在真正意義上成爲可併發的操作。

4.1.5 鎖粗化

通常情況下,爲了保證多線程間的有效併發,會要求每個線程持有鎖的時間儘量短,即在使用完公共資源後,應該立即釋放鎖。只有這樣,等待在這個鎖上的其他線程才能儘早地獲得資源執行任務。但是,凡事都有一個度,如果對同一個鎖不停地進行請求、同步和釋放,其本身也會消耗系統寶貴的資源,反而不利於性能的優化

爲此,虛擬機在遇到一連串連續地對同一個鎖不斷進行請求和釋放的操作時,便會把所有的鎖操作整合成對鎖的一次請求,從而減少對鎖的請求同步的次數,這個操作叫作鎖的粗化.

在開發過程中,大家也應該有意識地在合理的場合進行鎖的粗化,尤其當在循環內請求鎖時。以下是一個循環內請求鎖的例子,在這種情況下,意味着每次循環都有申請鎖和釋放鎖的操作。但在這種情況下,顯然是沒有必要的

注意:性能優化就是根據運行時的真實情況對各個資源點進行權衡折中的過程。鎖粗化的思想和減少鎖持有時間是相反的,但在不同的場合,它們的效果並不相同,因此要根據實際情況進行權衡。


4.2 Java虛擬機對鎖優化所做的努力

4.2.1 鎖偏向

鎖偏向是一種針對加鎖操作的優化手段。它的核心思想是:如果一個線程獲得了鎖,那麼鎖就進入偏向模式。當這個線程再次請求鎖時,無須再做任何同步操作。這樣就節省了大量有關鎖申請的操作,從而提高了程序性能。因此,對於幾乎沒有鎖競爭的場合,偏向鎖有比較好的優化效果,因爲連續多次極有可能是同一個線程請求相同的鎖。而對於鎖競爭比較激烈的場合,其效果不佳。因爲在競爭激烈的場合,最有可能的情況是每次都是不同的線程來請求相同的鎖。這樣偏向模式會失效,因此還不如不啓用偏向鎖。使用Java虛擬機參數-XX:+UseBiasedLocking可以開啓偏向鎖。

4.2.2 輕量級鎖(說的啥玩意)

如果偏向鎖失敗,那麼虛擬機並不會立即掛起線程,它還會使用一種稱爲輕量級鎖的優化手段。輕量級鎖的操作也很方便,它只是簡單地將對象頭部作爲指針指向持有鎖的線程堆棧的內部,來判斷一個線程是否持有對象鎖。如果線程獲得輕量級鎖成功,則可以順利進入臨界區。如果輕量級鎖加鎖失敗,則表示其他線程搶先爭奪到了鎖,那麼當前線程的鎖請求就會膨脹爲重量級鎖。

4.2.3 自旋鎖

鎖膨脹後,爲了避免線程真實地在操作系統層面掛起,虛擬機還會做最後的努力—自旋鎖。當前線程暫時無法獲得鎖,而且什麼時候可以獲得鎖是一個未知數,也許在幾個CPU時鐘週期後就可以得到鎖。如果這樣,簡單粗暴地掛起線程可能是一種得不償失的操作。系統會假設在不久的將來,線程可以得到這把鎖。因此,虛擬機會讓當前線程做幾個空循環(這也是自旋的含義),在經過若干次循環後,如果可以得到鎖,那麼就順利進入臨界區。如果還不能獲得鎖,纔會真的將線程在操作系統層面掛起。

4.2.4 鎖消除

鎖消除是一種更徹底的鎖優化。Java虛擬機在JIT編譯時,通過對運行上下文的掃描,去除不可能存在共享資源競爭的鎖。通過鎖消除,可以節省毫無意義的請求鎖時間

說到這裏,細心的讀者可能會產生疑問,如果不存在競爭,爲什麼程序員還要加上鎖呢?這是因爲在Java軟件開發過程中,我們必然會使用一些JDK的內置API,比如StringBuffer、Vector等。你在使用這些類的時候,也許根本不會考慮這些對象到底內部是如何實現的。比如,你很有可能在一個不可能存在併發競爭的場合使用Vector。而衆所周知,Vector內部使用了synchronized請求鎖,比如下面的代碼:

注意上述代碼中的Vector,由於變量v只在createStrings()函數中使用,因此它只是一個單純的局部變量。局部變量是在線程棧上分配的,屬於線程私有的數據,因此不可能被其他線程訪問。在這種情況下,Vector內部所有加鎖同步都是沒有必要的。如果虛擬機檢測到這種情況,就會將這些無用的鎖操作去除。

鎖消除涉及的一項關鍵技術爲逃逸分析。所謂逃逸分析就是觀察某一個變量是否會逃出某一個作用域。在本例中,變量v顯然沒有逃出createStrings()函數之外。以此爲基礎,虛擬機纔可以大膽地將變量v內部的加鎖操作去除。如果createStrings()函數返回的不是String數組,而是變量v本身,那麼就認爲變量v逃逸出了當前函數,也就是說變量v有可能被其他線程訪問。如果是這樣,虛擬機就不能消除變量v中的鎖操作。

逃逸分析必須在-server模式下進行,可以使用-XX:+DoEscapeAnalysis參數打開逃逸分析。使用-XX:+EliminateLocks參數可以打開鎖消除。


4.3 人手一支筆:ThreadLocal

除了控制資源的訪問外,我們還可以通過增加資源來保證所有對象的線程安全。比如,讓100個人填寫個人信息表,如果只有一支筆,那麼大家就得挨個填寫,對於管理人員來說,必須保證大家不會去哄搶這僅存的一支筆,否則,誰也填不完。從另外一個角度出發,我們可以準備100支筆,人手一支,那麼所有人很快就能完成表格的填寫工作。如果說鎖使用的是第一種思路,那麼ThreadLocal使用的就是第二種思路.

4.3.1 ThreadLocal的簡單使用

從ThreadLocal的名字上可以看到,這是一個線程的局部變量。也就是說,只有當前線程可以訪問。既然是隻有當前線程可以訪問的數據,自然是線程安全的。

4.3.2 ThreadLocal的實現原理

ThreadLocal如何保證這些對象只被當前線程訪問呢?下面讓我們一起深入ThreadLocal的內部實現。我們需要關注的自然是ThreadLocal的set()方法和get()方法。先從set()方法說起:

而設置到ThreadLocal中的數據,也正是寫入了threadLocals的這個Map。其中,key爲ThreadLocal當前對象,value就是我們需要的值。而threadLocals本身就保存了當前自己所在線程的所有“局部變量”,也就是一個ThreadLocal變量的集合

     在ThreadLocal類中有一個ThreadLocalMap, 用於存放每一個線程的變量副本,Map中元素的key爲線程對象,value爲對應線程的變量副本。

    另外,說ThreadLocal使得各線程能夠保持各自獨立的一個對象,並不是通過ThreadLocal.set()來實現的,而是通過每個線程中的new 對象 的操作來創建的對象,每個線程創建一個,不是什麼對象的拷貝或副本。通過ThreadLocal.set()將這個新創建的對象的引用保存到各線程的自己的一個map中,每個線程都有這樣一個map,執行ThreadLocal.get()時,各線程從自己的map中取出放進去的對象,因此取出來的是各自自己線程中的對象,ThreadLocal實例是作爲map的key來使用的。 

  如果ThreadLocal.set()進去的東西本來就是多個線程共享的同一個對象,那麼多個線程的ThreadLocal.get()取得的還是這個共享對象本身,還是有併發訪問問題。

在瞭解了ThreadLocal的內部實現後,我們自然會引出一個問題:那就是這些變量是維護在Thread類內部的(ThreadLocalMap定義所在類),這也意味着只要線程不退出,對象的引用將一直存在。當線程退出時,Thread類會進行一些清理工作,其中就包括清理ThreadLocalMap.

因此,使用線程池就意味着當前線程未必會退出(比如固定大小的線程池,線程總是存在)。如果這樣,將一些大的對象設置到ThreadLocal中(它實際保存在線程持有的threadLocals Map內),可能會使系統出現內存泄漏的可能(這裏我的意思是:你設置了對象到ThreadLocal中,但是不清理它,在你使用幾次後,這個對象也不再有用了,但是它卻無法被回收)。

此時,如果你希望及時回收對象,最好使用ThreadLocal.remove()方法將這個變量移除。就像我們習慣性地關閉數據庫連接一樣。如果你確實不需要這個對象了,就應該告訴虛擬機,請把它回收,防止內存泄漏。

另外一種有趣的情況是JDK也可能允許你像釋放普通變量一樣釋放ThreadLocal。比如,我們有時候爲了加速垃圾回收,會特意寫出類似obj=null的代碼。如果這麼做,那麼obj所指向的對象就會更容易地被垃圾回收器發現,從而加速回收。同理,如果對於ThreadLocal的變量,我們也手動將其設置爲null,比如tl=null,那麼這個ThreadLocal對應的所有線程的局部變量都有可能被回收。這裏面的奧祕是什麼呢?先來看一個簡單的例子。

ThreadLocalMap的實現使用了弱引用。弱引用是比強引用弱得多的引用。Java虛擬機在垃圾回收時,如果發現弱引用,就會立即回收.


4.4 無鎖

無鎖的策略使用一種叫作比較交換(CAS,Compare And Swap)的技術來鑑別線程衝突,一旦檢測到衝突產生,就重試當前操作直到沒有衝突爲止

4.4.1 與衆不同的併發策略:比較交換

與鎖相比,使用比較交換會使程序看起來更加複雜一些,但由於其非阻塞性,它對死鎖問題天生免疫,並且線程間的相互影響也遠遠比基於鎖的方式要小。更爲重要的是,使用無鎖的方式完全沒有鎖競爭帶來的系統開銷,也沒有線程間頻繁調度帶來的開銷,因此,它要比基於鎖的方式擁有更優越的性能。\

CAS算法的過程是:它包含三個參數CAS(V,E,N),其中V表示要更新的變量,E表示預期值,N表示新值。僅當V值等於E值時,纔會將V的值設爲N,如果V值和E值不同,說明已經有其他線程做了更新,則當前線程什麼都不做。最後,CAS返回當前V的真實值。CAS操作是抱着樂觀的態度進行的,它總是認爲自己可以成功完成操作。當多個線程同時使用CAS操作一個變量時,只有一個會勝出,併成功更新,其餘均會失敗。失敗的線程不會被掛起,僅是被告知失敗,並且允許再次嘗試,當然也允許失敗的線程放棄操作。基於這樣的原理,CAS操作即使沒有鎖,也可以發現其他線程對當前線程的干擾,並進行恰當的處理。

簡單地說,CAS需要你額外給出一個期望值,也就是你認爲這個變量現在應該是什麼樣子的。如果變量不是你想象的那樣,則說明它已經被別人修改過了。你就重新讀取,再次嘗試修改就好了。

4.4.2 無鎖的線程安全整數:AtomicInteger

爲了讓Java程序員能夠受益於CAS等CPU指令,JDK併發包中有一個atomic包,裏面實現了一些直接使用CAS操作的線程安全的類型。

其中,最常用的一個類就是AtomicInteger,可以把它看作一個整數。與Integer不同,它是可變的,並且是線程安全的。對其進行修改等任何操作都是用CAS指令進行的。這裏簡單列舉一下AtomicInteger的一些主要方法,對於其他原子類,操作也是非常類似的。

第6行的AtomicInteger.incrementAndGet()方法會使用CAS操作將自己加1,同時也會返回當前值(這裏忽略了當前值)。執行這段代碼,你會看到程序輸出了100 000。這說明程序正常執行,沒有錯誤。如果不是線程安全,那麼i的值應該會小於100 000纔對。

使用AtomicInteger會比使用鎖具有更好的性能

這裏讓人印象深刻的應該是incrementAndGet()方法的第2行for循環吧!如果你是初次看到這樣的代碼,可能會覺得很奇怪,爲什麼連設置一個值那麼簡單的操作都需要一個死循環呢?原因就是:CAS操作未必是成功的,因此對於不成功的情況,我們就需要不斷地進行嘗試。第3行的get()取得當前值,接着加1後得到新值next。這裏,我們就得到了CAS必需的兩個參數:期望值及新值。使用compareAndSet()方法將新值next寫入,成功的條件是在寫入的時刻,當前的值應該要等於剛剛取得的current。如果不是這樣,則說明AtomicInteger的值在第3行到第5行代碼之間又被其他線程修改過了。當前線程看到的狀態就是一個過期狀態。因此,compareAndSet返回失敗,需要進行下一次重試,直到成功。

和AtomicInteger類似的類還有:AtomicLong用來代表long型數據;AtomicBoolean表示boolean型數據;AtomicReference表示對象引用。

4.4.3 Java中的指針:Unsafe類

如果你對技術有追求,應該還會特別在意incrementAndGet() 方法中compareAndSet()方法的實現。現在,就讓我們進一步看一下它吧!

這裏就可以看到,雖然Java拋棄了指針,但是在關鍵時刻,類似指針的技術還是必不可少的。這裏底層的Unsafe類實現就是最好的例子。但是很不幸,JDK的開發人員並不希望大家使用這個類。獲得Unsafe類實例的方法是調動其工廠方法getUnsafe(),但是它的實現卻是這樣的:

注意加粗部分的代碼,它會檢查調用getUnsafe()函數的類,如果這個類的ClassLoader不爲null,就直接拋出異常,拒絕工作。因此,這也使得我們自己的應用程序無法直接使用Unsafe類。它是一個在JDK內部使用的專屬類。

4.4.4 無鎖的對象引用:AtomicReference

之前我們說過,線程判斷被修改對象是否可以正確寫入的條件是對象的當前值和期望值是否一致。這個邏輯從一般意義上來說是正確的。但有可能出現一個小小的例外,就是當你獲得對象當前數據後,在準備修改爲新值前,對象的值被其他線程連續修改了兩次,而經過這兩次修改後,對象的值又恢復爲舊值。這樣,當前線程就無法正確判斷這個對象究竟是否被修改過,圖4.2顯示了這種情況。

一般來說,發生這種情況的概率很小,即使發生了,可能也不是什麼大問題。比如,我們只是簡單地要做一個數值加法,即使在取得期望值後,這個數字被不斷地修改,只要它最終改回了我的期望值,我的加法計算就不會出錯。也就是說,當你修改的對象沒有過程的狀態信息時,所有的信息都只保存於對象的數值本身。

但是,在現實中,還可能存在另外一種場景,就是我們是否能修改對象的值,不僅取決於當前值,還和對象的過程變化有關,這時,AtomicReference就無能爲力了。

打一個比方,有一家蛋糕店,爲了挽留客戶,決定爲貴賓卡里餘額小於20元的客戶一次性贈送20元,刺激客戶充值和消費,但條件是,每一位客戶只能被贈送一次。現在,我們就來模擬這個場景,爲了演示AtomicReference,我在這裏使用AtomicReference實現這個功能。首先,我們模擬客戶賬戶餘額。定義客戶賬戶餘額:

如果在贈予金額到賬的同時,客戶進行了一次消費,使得總金額又小於20元,並且正好累計消費了20元。使得消費、贈予後的金額等於消費前、贈予前的金額,那麼後臺的贈予進程就會誤以爲這個賬戶還沒有贈予,所以,存在被多次贈予的可能。模擬這個消費線程:

雖然這種情況出現的概率不大,但是依然是有可能出現的。因此,當業務上確實可能出現這種情況時,我們也必須多加防範。JDK也已經爲我們考慮到了這種情況,使用AtomicStampedReference就可以很好地解決這個問題。

4.4.5 帶有時間戳的對象引用:AtomicStampedReference

AtomicReference無法解決上述問題的根本原因是,對象在修改過程中丟失了狀態信息,對象值本身與狀態被畫上了等號。因此,我們只要能夠記錄對象在修改過程中的狀態值,就可以很好地解決對象被反覆修改導致線程無法正確判斷對象狀態的問題。AtomicStampedReference正是這麼做的。它內部不僅維護了對象值,還維護了一個時間戳(我這裏把它稱爲時間戳,實際上它可以使任何一個整數來表示狀態值)。當AtomicStampedReference對應的數值被修改時,除了更新數據本身外,還必須要更新時間戳。當AtomicStampedReference設置對象值時,對象值及時間戳都必須滿足期望值,寫入纔會成功。因此,即使對象值被反覆讀寫,寫回原值,只要時間戳發生變化,就能防止不恰當的寫入

有了AtomicStampedReference這個法寶,我們再也不用擔心對象被寫壞啦!現在,就讓我們使用AtomicStampedReference來修正那個貴賓卡充值的問題。

在第2行中,我們使用AtomicStampedReference代替原來的AtomicReference。第6行獲得賬戶的時間戳,後續的贈予操作以這個時間戳爲依據。如果贈予成功(第13行),則修改時間戳,使得系統不可能發生二次贈予的情況。消費線程也是類似的,每次操作都使時間戳加1(第36行),使之不可能重複。

4.4.6 數組也能無鎖:AtomicIntegerArray

除提供基本數據類型以外,JDK還爲我們準備了數組等複合結構。當前可用的原子數組有:AtomicIntegerArray、AtomicLongArray和AtomicReferenceArray,分別表示整數數組、long型數組和普通的對象數組。

AtomicIntegerArray本質上是對int[]類型的封裝,使用Unsafe類通過CAS的方式控制int[]在多線程下的安全性。它提供了以下幾個核心API

4.4.7 讓普通變量也享受原子操作:AtomicIntegerFieldUpdater

有時候,由於初期考慮不周,或者後期的需求變化,一些普通變量可能也會有線程安全的需求。如果改動不大,則可以簡單地修改程序中每一個使用或者讀取這個變量的地方。但顯然,這樣並不符合軟件設計中的一條重要原則—開閉原則。也就是系統對功能的增加應該是開放的,而對修改應該是相對保守的。而且,如果系統裏使用到這個變量的地方特別多,一個一個修改也是一件令人厭煩的事情(況且很多使用場景下可能是隻讀的,並無線程安全的強烈要求,完全可以保持原樣)。如果你有這種困擾,在這裏根本不需要擔心,因爲在原子包裏還有一個實用的工具類AtomicIntegerFieldUpdater。它可以讓你在不改動(或者極少改動)原有代碼的基礎上,讓普通的變量也享受CAS操作帶來的線程安全性,這樣你可以通過修改極少的代碼來獲得線程安全的保證。這聽起來是不是讓人很激動呢?

根據數據類型不同,Updater有三種,分別是AtomicIntegerFieldUpdater、AtomicLong-FieldUpdater和AtomicReferenceFieldUpdater。顧名思義,它們分別可以對int、long和普通對象進行CAS修改。

大家如果運行這段程序,不難發現,最終的Candidate.score總是和allScore絕對相等。這說明AtomicIntegerFieldUpdater很好地保證了Candidate.score的線程安全。雖然AtomicIntegerFieldUpdater很好用,但是還是有幾個注意事項。

第一,Updater只能修改它可見範圍內的變量,因爲Updater使用反射得到這個變量。如果變量不可見,就會出錯。比如score聲明爲private,就是不可行的。

第二,爲了確保變量被正確的讀取,它必須是volatile類型的。如果我們原有代碼中未聲明這個類型,那麼簡單地聲明一下就行,這不會引起什麼問題。

第三,由於CAS操作會通過對象實例中的偏移量直接進行賦值,因此,它不支持static字段(Unsafe.objectFieldOffset()方法不支持靜態變量)。

通過AtomicIntegerFieldUpdater,我們可以更加隨心所欲地對系統關鍵數據進行線程安全的保護。

4.4.8 挑戰無鎖算法:無鎖的Vector實現

這裏向大家介紹一種使用無鎖方式實現的Vector。通過這個案例,我們可以更加深刻地認識無鎖的算法,同時也可以學習一下有關Vector實現的細節和算法技巧(本例講述的無鎖Vector來自amino併發包)。

我們將這個無鎖的Vector稱爲LockFreeVector。它的特點是可以根據需求動態擴展其內部空間。在這裏,我們使用二維數組來表示LockFreeVector的內部存儲。


4.5 有關死鎖的問題

在學習了無鎖之後,讓我們重新回到鎖的世界吧!在衆多的應用程序中,使用鎖的情況一般要多於無鎖。因爲對於應用來說,如果業務邏輯很複雜,會極大增加無鎖的編程難度但如果使用鎖,我們就不得不對一個新的問題引起重視—死鎖。

什麼是死鎖呢?通俗地說,死鎖就是兩個或者多個線程相互佔用對方需要的資源,而都不進行釋放,導致彼此之間相互等待對方釋放資源,產生了無限制等待的現象。死鎖一旦發生,如果沒有外力介入,這種等待將永遠存在,從而對程序產生嚴重的影響。

最簡單的情況就是隻有兩個哲學家,假設是A和B,桌面也只有兩個叉子。A左手拿着其中一隻叉子,B也一樣。這樣他們的右手等待對方的叉子,並且這種等待會一直持續,從而導致程序永遠無法正常執行。

在實際環境中,遇到了這種情況,通常的表現就是相關的進程不再工作,並且CPU佔用率爲0(因爲死鎖的線程不佔用CPU),不過這種表面現象只能用來猜測問題。如果想要確認問題,還需要使用JDK提供的一套專業工具。

首先,我們可以使用jps命令得到Java進程的進程ID,接着使用jstack命令得到線程的線程堆棧

如果想避免死鎖,除使用無鎖的函數之外,還有一種有效的做法是使用第3章介紹的重入鎖,通過重入鎖的中斷或者限時等待可以有效規避死鎖帶來的問題。大家可以回顧一下相關內容。

 

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