我們通常說的保持同步,其實就是對共享資源的保護。在單線程模型中, 我們永遠不用擔心“多個線程試圖同時使用同一個資源的問題”, 但是有了並發, 就有可能發生多個線程競爭同一個共享資源的問題。
就好比你正在餐廳里吃飯,當你拿起筷子正要夾盤子里的最后一塊肉時, 這片肉突然消失了。因為你的線程被掛起了, 另一個人進入餐廳並吃掉了它。
這就是我們在多線程下需要處理的問題----我們需要某種方式來防止兩個任務同時訪問相同的資源
那么我們很容易想到第一種方法: 加鎖, 好比我們進入衛生間之后要把門關上, 下一個人來到衛生間門口要先敲門,沒人的話他就可以直接使用, 否則就要等到里面的人出來。不管你多么着急,不管外面排了多少人,沒辦法,只要他還在里面,那你就只能等,哪怕他在里面睡覺玩手機。。。 當他終於打開門出來的時候, 離門最近的那么人很有可能會成功進入, 但這一點並不能保證。
同理, 我們給共享資源加一把鎖,任意時刻都只允許一個線程操作共享資源,當某個線程試圖訪問該資源時,需要先檢查鎖的狀態,如果當前沒有其他線程在使用, 則獲取鎖,開始操作資源,操作完成后再釋放資源;否則就要排隊等待。
常見的加鎖方法大致有以下幾種:
1, synchronized關鍵字修飾方法
之前在對HashMap的描述中, 我們說hsahMap是線程不安全的, 但是古老的Hashtable是線程安全的, 就是因為HashTable中對所有操作共享資源的方法都使用了synchronized關鍵字進行了修飾, 如下:

共享資源一般是以對象形式存在的內存片段,也有可能是文件,輸入/輸出端口,打印機等。但是要控制對共享資源的訪問, 得先把它包裝進一個對象,然后把所有要訪問這個資源的方法標記為synchronized.
注意, 這里的描述是“所有”。 為什么呢?
因為java中的對象都自動含有單一的鎖(也叫監視器或者對象鎖), 當某個線程在對象上調用其任意synchronized方法時,此對象會被加鎖(鎖住的是整個對象),所以在該線程釋放鎖之前, 其他線程無法訪問該對象內任一修飾為synchronized的方法。(其他未被synchronized修飾的方法可以被隨意訪問)
一個線程可以多次獲得對象的鎖,JVM負責跟蹤對象被加鎖的次數,如果鎖被釋放,則重置為0, 在線程第一次給對象加鎖的時候,計數為1,每當這個相同的線程在對象上獲得鎖時(從一個synchronized方法到另一個synchronized方法),計數+1,顯然只有首先獲得鎖的線程才可以繼續獲得多個鎖。每離開一個synchronized方法, 計數-1。當計數為0時,鎖完全釋放
注意: 當我們使用synchronized保護共享資源時, 記得聲明該資源為private, 防止其他線程直接訪問域
java中的類也有一個鎖,作為類的class對象的一部分。所以當我們用synchronized關鍵字修飾一個靜態方法時,獲取該方法的鎖也意味着整個類中的static方法都會被加鎖。
2, synchronized關鍵字鎖定代碼塊
上文中我們說到,古老的hashtable是線程安全的,因為它在源碼中對所有操作共享資源的方法都加了鎖。
但是我們在日常開發中,很少會用到它。因為在某些情況下,我們其實只需要保護方法里的核心代碼,為整個方法加鎖會增加多線程訪問下的時間成本
大家可以回顧一下單例模式中的雙重鎖模式

為什么要判空兩次?然后在第二次判空之后才加鎖呢?
如果我們直接給getSingleTon方法加鎖,當然也能實現同步。但帶來的問題是, 如果singleTon已經被創建,應該直接返回就好了,但事實上每次線程執行到這里都要試圖獲取鎖,這是不必要的開銷。
所以第一層判斷如果singleTon已經被創建,則無需獲取鎖直接返回。
第二層判斷的意義是,想象一下,第一個線程來到這里,檢查發現singleTon為空,那么它就會獲得鎖, 並創建了一個singleTon的實例。在它創建singleTon實例的過程中,另一個線程也來到了這里執行了第一層判斷,發現singleTon為null(因為此時第一個線程還沒有完成創建), 於是它排隊等待,然后第一個線程創建完成之后釋放鎖,第二個線程進入同步塊,此時如果沒有第二層判空,那么它就會直接創建一個singleTon實例, 這樣就有了兩個實例
值得一提的是, 當我們使用synchronized同步代碼塊時, 需要傳入一個類或者說對象,如上文中我們傳入了SingleTon.class
因為synchronized快必須指定一個在其上進行同步的對象,通常最合理的方式是使用使用其方法正在被調用的當前對象,如
synchronized(this)
當我們傳入this時, 如果某個線程獲得了同步塊中的鎖, 那么當前對象中其他的synchronized方法和synchronized塊都不能被其他線程調用(其實跟上文說到對象鎖的一樣)
當然, 如果需要的話,我們也可以在在一個對象的同步塊中去同步另一個對象, 比如我們在A對象的某個方法中 synchronized(B), 那么當某個線程獲得鎖時, 它獲得的是B的對象鎖, 這也就意味着與此同時B中的synchronized方法/塊不能被其他線程執行, 而A中的則不受影響。
3, ReentrantLock顯式加鎖
java.util.concurrent類庫中還包含了定義在java.util.concurrent.locks中的顯式互斥機制,如ReentrantLock, 它的簡單用法如下:


可以看到, 我們使用lock時,必須顯式地創建,鎖定和釋放,所以與synchronized相比,代碼缺乏優雅性。但是,對於解決某些類型的問題來說,它更加靈活。
注意, 我們應當使用 try-finally語句, 確保在finally中unlock。 如果該方法有返回值, return語句應放在try中, 以確保unlock不會過早發生
當我們使用synchronized關鍵字時, 某些事物失敗了就會拋出異常, 但我們沒有機會去做一些清理的工作。 顯式lock的優點就體現在這里, 我們可以在finally子句中,將系統維護在正確的狀態。
4,ReentrantReadWriteLock顯式加鎖
ReentrantReadWriteLock實現了ReadWriteLock接口,這種鎖的特點是,允許同時有多個讀取者,只要它們都不試圖寫入就行。如果寫鎖已經被其他線程持有, 那么任何讀取者都不能訪問,直到寫鎖被釋放。
所以,針對那些頻繁讀取,極少寫入的情況, 使用ReentrantReadWriteLock可以提高性能。
ReentrantReadWriteLock的用法大致如下:沒有仔細研究過,不保證正確

5, 使用ThreadLocal進行線程本地存儲
上文說到的4種方式從本質上來說其實都是一樣的, 都是通過對共享資源加鎖的方式來實現同步
接下來我們保護共享資源的的另一個解決思路------根除對變量的共享
線程本地存儲是一種自動化機制,可以為使用相同變量的每個不同線程創建不同的存儲, 通常寫法如下:

注意: ThreadLocal對象通常當作靜態域存儲,在創建ThreadLocal時,只能通過get和set方法來訪問該對象的內容。
其中value.get()方法將返回與當前線程相關聯的對象的副本, 而set會將參數插入到為其線程存儲的對象中, 並返回存儲中原有的對象
6, 利用可見性實現同步-- volatile
volatile關鍵字確保了應用中的可視性,如果你將一個域聲明為volatile, 那么只要對這個域產生了寫操作,那么所有的讀操作都可以看到修改,即使用了本地緩存,情況也是如此,因為volatile域會立即被寫入到主存中,而讀取操作就發生在主存中。
volatile是輕量級的synchronized, 它比 synchronized執行成本低因為它不需要切換上下文以及調度線程。
但是, volatile只適用於靜態域,就是說只有一個線程對共享資源進行寫操作,可以有多個線程執行讀操作。當一個域的值依賴於它之前的值(如遞增一個計數器)或者這個域的值受其他域的值限制時,volatile將無法工作。
同時volatile關鍵字並不保證原子性
7, 使用原子性操作(原子類)來保證同步
在java中,原子的意思就是不可再分,比如 return i 我們可以認為它是原子性的, 但 i++並不是原子性的
原子操作可有線程機制來保證其不可中斷。一旦操作開始, 那么它一定可以在可能發生的線程切換之前被執行完畢。
因此,如果我們確定某個操作時原子性的, 那它就是線程同步的。
所以,在某些情況下,我們可以使用原子類來保證同步。如AtomicInteger, AtomicLong, AtomicReference等
這些類是在機器級別上的原子性,因此使用他們的時候,通常不用擔心同步問題。
8, 使用SingleThreadExecutor
在上一篇文章--啟動線程 中我們提到過,SingleThreadExecutor的調用會產生單線程執行器, 當我們set多個線程時, 她們將按照被提交的順序依次執行
9, 免鎖容器。 如Vector, Hashtable這些早期容易,使用了大量的synchronized方法來保證同步
Java SE5特別添加了一些新的容器,如CopyOnWriteArrayList, CopyOnWriteArraySet, ConcurrentHashMap
這類免鎖容器背后的通用策略是: 對容器的修改可以與讀取操作同時發生, 只要讀取者能看到修改后的內容就行。
修改是在容器數據結構的某個副本中執行的, 並且這個副本在修改過程中不可視,修改完成后,會立即將修改后的數據與主數據結構進行交換。
值得特別說明的是, ConcurrentHashMap還引入了分段鎖,將數據分成多個數據段分別加鎖,從而提高並發性能。
小結:
其實從根本上來說,上面的1,2,3,4都可歸納為加鎖的方式,所以
一, 加鎖 (上面的1,2, 3,4)
二,線程封閉,消除對資源的共享(5)
三, 利用可見性(6)
四,原子類(7)
五,executor框架(8)
六, 使用同步類/免鎖容器(9)
