摘要:大多數(shù)都是線程安全的,所以極大降低了在實現(xiàn)線程安全性的復(fù)雜性。只有在處理請求需要保存一些信息的情況下,線程安全性才會成為一個問題。雖然這種方式可以保證線程安全,但是性能方面會有些問題。
本文是作者在閱讀JCIP過程中的部分筆記和思考,純手敲,如有誤處,請指正,非常感謝~
可能會有人對書中代碼示例中的注解有疑問,這里說一下,JCIP中示例代碼的注解都是自定義的,并非官方JDK的注解,因此如果想要在自己的代碼中使用,需要添加依賴。移步:jcip.net
一、什么是線程安全性?當(dāng)多個線程訪問某個類時,這個類始終都能表現(xiàn)出正確的行為,那么這個類就是線程安全的。
示例:一個無狀態(tài)的Servlet
從request中獲取數(shù)值,然后因數(shù)分解,最后將結(jié)果封裝到response中
@ThreadSafe public class StatelessFactorizer implements Servlet { public void service(ServletRequest req, ServletResponse resp) { BigInteger i = extractFromRequest(req); BigInteger[] factors = factor(i); encodeIntoResponse(resp, factors); } }
這是一個無狀態(tài)的Servlet,什么是無狀態(tài)的?不包含任何域或者對其他類的域的引用。service里僅僅是用到了存在線程棧上的局部變量的臨時狀態(tài),并且只能由正在執(zhí)行的線程訪問。
所以,如果有一個線程A正在訪問StatelessFactorizer類,線程B也在訪問StatelessFactorizer類,但是二者不會相互影響,最后的計算結(jié)果仍然是正確的,為什么呢?因為這兩個線程并沒有共享狀態(tài),他們各自訪問的都是自己的局部變量,所以像這樣 無狀態(tài)的對象都是線程安全的 。
大多數(shù)Servlet都是線程安全的,所以極大降低了在實現(xiàn)Servlet線程安全性的復(fù)雜性。只有在Servlet處理請求需要保存一些信息的情況下,線程安全性才會成為一個問題。
二、原子性我理解的原子性就是指一個操作是最小范圍的操作,這個操作要么完整的做要么不做,是一個不可分割的操作。比如一個簡單的賦值語句 x = 1,就是一個原子操作,但是像復(fù)雜的運算符比如++, --這樣的不是原子操作,因為這涉及到“讀取-修改-寫入”的一個操作序列,并且結(jié)果依賴于之前的狀態(tài)。
示例:在沒有同步的情況下統(tǒng)計已處理請求數(shù)量的Servlet(非線程安全)
@NotThreadSafe public class UnsafeCountingFactorizer implements Servlet { private long count = 0; public long getCount() { return count; } public void service(ServletRequest req, ServletResponse resp) { BigInteger i = extractFromRequest(req); BigInteger[] factors = factor(i); count++; // *1 encodeIntoResponse(resp, factors); } }
在上面這段代碼中,count是一個公共的資源,如果有多個線程,比如線程A, B同時進(jìn)入到 *1 這行,那么他們都讀取到count = 0,然后進(jìn)行自增,那么count就會變成1,很明顯這不是我們想要的結(jié)果,因為我們丟失了一次自增。
1. 競態(tài)條件這里有一個概念:競態(tài)條件(Race Condition),指的是,在并發(fā)編程中,由于不恰當(dāng)?shù)膱?zhí)行時序而出現(xiàn)不正確的結(jié)果。
在count自增的這個計算過程中,他的正確性取決于線程交替執(zhí)行的時序,那么就會發(fā)生競態(tài)條件。
大多數(shù)競態(tài)條件的本質(zhì)是,基于一種可能失效的觀察結(jié)果來做出判斷 or 執(zhí)行某個計算,即“先檢查后執(zhí)行”。
還是拿這個count自增的計算過程舉例:
count++大致包含三步:
取當(dāng)前count值 *1
count加一 *2
寫回count *3
那么在這個過程中,線程A首先去獲取當(dāng)前count,然后很不幸,線程A被掛起了,線程B此時進(jìn)入到 1,他取得的count仍然為0,然后繼續(xù) 2,count = 1,現(xiàn)在線程B又被掛起了,線程A被喚醒繼續(xù) 2,此時線程A觀察到的仍然是自己被掛起之前count = 0的結(jié)果,實際上是已經(jīng)失效的結(jié)果,線程A再繼續(xù) 2,count = 1,然后 *3,最后得到結(jié)果是count = 1,然后線程B被喚醒后繼續(xù)執(zhí)行,得到的結(jié)果也是count = 1。
這就是一個典型的由于不恰當(dāng)?shù)膱?zhí)行時序而產(chǎn)生不正確的結(jié)果的例子,即發(fā)生競態(tài)條件。
2. 延遲初始化中的競態(tài)條件這是一個典型的懶漢式的單例模式的實現(xiàn)(非線程安全)
@NotThreadSafe public class Singleton { private static Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance == null) { // *1 instance = new Singleton(); } return instance; } }
在 *1 判空后,即實際需要使用時才初始化對象,也就是延遲初始化。這種方式首先判斷 instance 是否已經(jīng)被初始化,如果已經(jīng)初始化過則返回現(xiàn)有的instance,否則再創(chuàng)建新的instance,然后再返回,這樣就可以避免在后來的調(diào)用中執(zhí)行這段高開銷的代碼路徑。
在這段代碼中包含一個競態(tài)條件,可能會破壞該類的正確性。假設(shè)有兩個線程A, B,同時進(jìn)入到了getInstance()方法,線程A在 *1 判斷為true,然后開始創(chuàng)建Singleton實例,但是A會花費多久能創(chuàng)建完,以及線程的調(diào)度方式都是不確定的,所以有可能A還沒創(chuàng)建完實例,B已經(jīng)判空返回true,最終結(jié)果就是創(chuàng)建了兩個實例對象,沒有達(dá)到單例模式想要達(dá)到的效果。
當(dāng)然,單例模式有很多其他經(jīng)典的線程安全的實現(xiàn)方式,像DCL、靜態(tài)內(nèi)部類、枚舉都可以保證線程安全,在這里就不贅述了。
三、加鎖機制還是回到因數(shù)分解那個例子,如果希望提升Servlet的性能,將剛計算的結(jié)果緩存起來,當(dāng)兩個連續(xù)的請求對相同的值進(jìn)行因數(shù)分解時,可以直接用上一次的結(jié)果,無需重新計算。
具體實現(xiàn)如下:
該Servlet在沒有足夠原子性保證的情況下對其最近計算結(jié)果進(jìn)行緩存(非線程安全)
@NotThreadSafe public class UnsafeCachingFactorizer implements Servlet { private final AtomicReferencelastNumber = new AtomicReference<>(); private final AtomicReference lastFactors = new AtomicReference<>(); public void service (ServletRequest req, ServletResponse resp) { BigInteger i = extractFromRequest(req); if (i.equals(lastNumber.get())) // *2 encodeIntoResponse(resp, lastFactors.get()); // *3 else { BigInteger[] factors = factor(i); lastNumber.set(i); // *1 lastFactors.set(factors); encodeIntoResponse(resp, factors); } } }
很明顯這個Servlet不是線程安全的,盡管使用了AtomicReference(替代對象引用的線程安全類)來保證每個操作的原子性,但是整個過程仍然存在競態(tài)條件,我們無法同時更新lastNumber和lastFactors,比如線程A執(zhí)行到 1之后set了新的lastNumber,但此時還沒有更新lastFactors,然后線程B進(jìn)入到了 2,發(fā)現(xiàn)已經(jīng)該數(shù)字已經(jīng)有緩存,便進(jìn)入 *3,但此時線程A并沒有同時更新lastFactors,所以線程B現(xiàn)在get的i的因數(shù)分解結(jié)果是錯誤的。
Java提供了一些鎖的機制來解決這樣的問題。
1. 內(nèi)置鎖synchronized (lock) { // 訪問或修改由鎖保護(hù)的共享狀態(tài) }
在Java中,最基本的互斥同步手段就是synchronized關(guān)鍵字了
比如,我們對一個計數(shù)操作進(jìn)行同步
public class Test implements Runnable { private static int count; public Test() { count = 0; } @Override public void run() { synchronized (this) { for (int i = 0; i < 5; i++) { try { System.out.println(Thread.currentThread().getName() + ":" + (count++)); Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } } } public int getCount() { return count; } public static void main(String[] args) { Test test = new Test(); Thread thread1 = new Thread(test, "thread1"); Thread thread2 = new Thread(test, "thread2"); thread1.start(); thread2.start(); } }
最后輸出的結(jié)果是:
thread1:0 thread1:1 thread1:2 thread1:3 thread1:4 thread2:5 thread2:6 thread2:7 thread2:8 thread2:9
synchronized關(guān)鍵字編譯后會在同步塊前后形成 monitorenter 和 monitorexit 這兩個字節(jié)碼指令
public void run(); Code: 0: aload_0 1: dup 2: astore_1 3: monitorenter 4: iconst_0 5: istore_2 6: iload_2 // ...... 67: iinc 2, 1 70: goto 6 73: aload_1 74: monitorexit 75: goto 85 78: astore 4 80: aload_1 81: monitorexit 82: aload 4 84: athrow 85: return
在執(zhí)行monitorenter時會嘗試去獲取對象的鎖,如果這個對象沒被鎖定 or 當(dāng)前線程已擁有了這個對象的鎖,則計數(shù)器 +1 ,相應(yīng)地,執(zhí)行monitorexit時計數(shù)器 -1 ,計數(shù)器為0,則釋放鎖。如果獲取對象失敗,需要阻塞等待。
雖然這種方式可以保證線程安全,但是性能方面會有些問題。
因為Java的線程是映射到操作系統(tǒng)的原聲線程上的,所以如果要阻塞 or 喚醒一個線程,需要操作系統(tǒng)在系統(tǒng)態(tài)和用戶態(tài)之間轉(zhuǎn)換,而這種轉(zhuǎn)換會耗費很多處理器時間。
除此之外,這種同步機制在某些情況下有些極端,如果我們用synchronized關(guān)鍵字修飾前面提到的因式分解的service方法,那么在同一時刻就只有一個線程能執(zhí)行該方法,也就意味著多個客戶端無法同時使用因式分解Servlet,服務(wù)的響應(yīng)性非常低。
不過,虛擬機本身也在對其不斷地進(jìn)行一些優(yōu)化。
2. 重入什么是重入?
舉個例子,一個加了X鎖的方法A,這個方法內(nèi)調(diào)用了方法B,方法B也加了X鎖,那么,如果一個線程拿到了方法A的X鎖,再調(diào)用方法B時,就會嘗試獲取一個自己已經(jīng)擁有的X鎖,這就是重入。
重入的一種實現(xiàn)方法是:每個鎖有一個計數(shù)值,若計數(shù)值為0,則該鎖沒被任何線程擁有。當(dāng)一個線程想拿這個鎖時,計數(shù)值加1;當(dāng)一個線程退出同步塊時,計數(shù)值減1。計數(shù)值為0時鎖被釋放。
synchronized就是一個可重入的鎖,我們可以用以下代碼證明一下看看:
Parent.java
public class Parent { public synchronized void doSomething() { System.out.println("Parent: calling doSomething"); } }
Child.java
public class Child extends Parent { public synchronized void doSomething() { System.out.println("Child: calling doSomething"); super.doSomething(); // 獲取父類的鎖 } public static void main(String[] args) { Child child = new Child(); child.doSomething(); } }
輸出:
Child: calling doSomething Parent: calling doSomething
如果synchronized不是一個可重入鎖,那么上面代碼必將產(chǎn)生死鎖。Child和Parent類中doSomething方法都被synchronized修飾,我們在調(diào)用子類的重載的方法時,已經(jīng)獲取到了synchronized鎖,而該方法內(nèi)又調(diào)用了父類的doSomething,會再次嘗試獲取該synchronized鎖,如果synchronized不是可重入的鎖,那么在調(diào)用super.doSomething()時將無法獲取父類的鎖,線程會永遠(yuǎn)停頓,等待一個永遠(yuǎn)也無法獲得的鎖,即發(fā)生了死鎖。
四、活躍性與性能前面在內(nèi)置鎖部分提到過,如果用synchronized關(guān)鍵字修飾因式分解的service方法,那么每次只有一個線程可以執(zhí)行,程序的性能將會非常低下,當(dāng)多個請求同時到達(dá)因式分解Servlet時,這個應(yīng)用便會成為 Poor Concurrency。
那么,難道我們就不能使用synchronized了嗎?
當(dāng)然不是的,只是我們需要恰當(dāng)且小心地使用。
我們可以通過縮小同步塊,來做到既能確保Servlet的并發(fā)性,又能保證線程安全性。我們應(yīng)該盡量將不影響共享狀態(tài)且執(zhí)行時間較長的操作從同步塊中分離,從而縮小同步塊的范圍。
下面來看在JCIP中,作者是怎么實現(xiàn)在簡單性和并發(fā)性之間的平衡的:
緩存最近執(zhí)行因數(shù)分解的數(shù)值及其計算結(jié)果的Servlet(線程安全且高效的)
@ThreadSafe public class CachedFactorizer implements Servlet { @GuardedBy("this") private BigInteger lastNumber; @GuardedBy("this") private BigInteger[] lastFactors; @GuardedBy("this") private long hits; @GuardedBy("this") private long cacheHits; // 因為hits和cacheHits也是共享變量,所以需要使用同步 *3 public synchronized long gethits() { return hits; } public synchronized double getCacheHitRatio() { return (double) cacheHits / (double) hits; } public void service(ServletRequest req, ServletResponse resp) { // 局部變量,不會共享,無需同步 BigInteger i = extractFromRequest(req); BigInteger[] factors = null; synchronized (this) { // *2 ++hits; // 命中緩存 if (i.equals(lastNumber)) { ++cacheHits; factors = lastFactors.clone(); } } // 沒命中,則進(jìn)行計算 if (factors == null) { factors = factor(i); // *3 // 同步更新兩個共享變量 synchronized (this) { // *1 lastNumber = i; lastFactors = factors.clone(); } } encodeIntoResponse(resp, factors); } }
首先,lastNumber和lastFactors作為兩個共享變量是肯定需要同步更新的,因此在 1 處進(jìn)行了同步。然后,在 2 處,判斷是否命中緩存的操作序列也必須同步。此外,在 *3 處,緩存命中計數(shù)器的實現(xiàn)也需要實現(xiàn)同步,因為計數(shù)器是共享的。
安全性是實現(xiàn)了,那么性能上呢?
前面我們說過,應(yīng)該盡量將 不影響共享狀態(tài) 且 執(zhí)行時間較長 的操作從同步塊中分離,從而縮小同步塊的范圍。那么這個Servlet里不影響共享狀態(tài)的就是i和factos這兩個局部變量,可以看到作者已經(jīng)將其分離出;執(zhí)行時間較長的操作就是因式分解了,在 *3 處,CachedFactorizer已經(jīng)釋放了前面獲得的鎖,在執(zhí)行因式分解時不需要持有鎖。
因此,這樣既確保了線程安全,又不會過多影響并發(fā)性,并且在每個同步塊內(nèi)的代碼都“足夠短”。
總之,在并發(fā)代碼的設(shè)計中,我們要盡量設(shè)計好每個同步塊的大小,在并發(fā)性和安全性上做好平衡。
參考自:
《Java Concurrency in Practice》
以及其他網(wǎng)絡(luò)資源
文章版權(quán)歸作者所有,未經(jīng)允許請勿轉(zhuǎn)載,若此文章存在違規(guī)行為,您可以聯(lián)系管理員刪除。
轉(zhuǎn)載請注明本文地址:http://specialneedsforspecialkids.com/yun/69370.html
摘要:在之前的文章中學(xué)習(xí)了關(guān)鍵字,可以保證變量在線程間的可見性,但他不能真正的保證線程安全。線程執(zhí)行到指令時,將會嘗試獲取對象所對應(yīng)的的所有權(quán),即嘗試獲得對象的鎖。從可見性上來說,線程通過持有鎖的方式獲取變量的最新值。 在之前的文章中學(xué)習(xí)了volatile關(guān)鍵字,volatile可以保證變量在線程間的可見性,但他不能真正的保證線程安全。 /** * @author cenkailun *...
摘要:從使用到原理學(xué)習(xí)線程池關(guān)于線程池的使用,及原理分析分析角度新穎面向切面編程的基本用法基于注解的實現(xiàn)在軟件開發(fā)中,分散于應(yīng)用中多出的功能被稱為橫切關(guān)注點如事務(wù)安全緩存等。 Java 程序媛手把手教你設(shè)計模式中的撩妹神技 -- 上篇 遇一人白首,擇一城終老,是多么美好的人生境界,她和他歷經(jīng)風(fēng)雨慢慢變老,回首走過的點點滴滴,依然清楚的記得當(dāng)初愛情萌芽的模樣…… Java 進(jìn)階面試問題列表 -...
摘要:首發(fā)于我的博客線程池進(jìn)程池網(wǎng)絡(luò)編程之同步異步阻塞非阻塞后端掘金本文為作者原創(chuàng),轉(zhuǎn)載請先與作者聯(lián)系。在了解的數(shù)據(jù)結(jié)構(gòu)時,容器可迭代對象迭代器使用進(jìn)行并發(fā)編程篇二掘金我們今天繼續(xù)深入學(xué)習(xí)。 Python 算法實戰(zhàn)系列之棧 - 后端 - 掘金原文出處: 安生??? 棧(stack)又稱之為堆棧是一個特殊的有序表,其插入和刪除操作都在棧頂進(jìn)行操作,并且按照先進(jìn)后出,后進(jìn)先出的規(guī)則進(jìn)行運作。 如...
摘要:堆和方法區(qū)只有在程序運行時才能確定內(nèi)存的使用情況,垃圾回收器所關(guān)注的主要就是這部分內(nèi)存。虛擬機會根據(jù)當(dāng)前系統(tǒng)的運行情況收集性能監(jiān)控信息,動態(tài)調(diào)整比率參數(shù)以提供最合適的停頓時間或最大的吞吐量。 Tip:內(nèi)容為對《深入理解Java虛擬機》(周志明 著)第三章內(nèi)容的總結(jié)和筆記。這是第一次拜讀時讀到的一些重點,做個分享,也為后面再次閱讀和實踐做保障。 3.1 概述 程序計數(shù)器、虛擬機棧、本地...
閱讀 1776·2021-10-27 14:15
閱讀 3835·2021-10-08 10:12
閱讀 1168·2021-09-22 15:55
閱讀 3230·2021-09-22 15:17
閱讀 834·2021-09-02 15:40
閱讀 1748·2019-08-29 18:33
閱讀 1099·2019-08-29 15:22
閱讀 2355·2019-08-29 11:08