線程不安全的問題分析:在小朋友搶氣球的案例中模擬網絡延遲來將問題暴露出來;示例代碼如下:
在線程中的run方法上不能使用throws來聲明拋出異常,所以在run方法中調用有可能出現異常的代碼時,只能使用try-catch將其捕獲來處理。
原因是:子類覆蓋父類方法時不能拋出新的異常,父類的run方法都沒有拋出異常,子類就更加不能拋出異常了。詳情可查看我的另一篇文章 Java基礎之異常處理機制
在上述案例中,通過引入Thread.sleep();來模擬網絡延遲,該方法的作用是讓當前線程進入睡眠狀態10毫秒,此時其他線程就可以去搶佔資源了,方法的參數是睡眠時間,以毫秒為單位。
通過觀察運行結果,發現了問題:
小紅、小強兩個小朋友都搶到了14號氣球
在運行結果中,小紅、小強兩個小朋友都搶到了14號氣球,也就是14號氣球被搶到了2次。我們來梳理線程的運行過程來看看發生了什麼:
小強和小紅兩個線程都拿到了14號氣球,由於線程調度,小強獲得了CPU時間片,打印出了搶到的氣球,而小紅則進入睡眠;小強在打印後對num做了減一操作,此時num為13;小明線程開始運行,搶到了13號氣球,並對num做了減一操作,此時num為12;小紅線程醒來,打印出搶到的14號氣球;此時的num為12,減一後結果為11;由於多個線程是併發操作,所以對num做判斷時可能上一個線程還未對num減一,故都能通過(num > 0)的判斷;
然後再來運行上述代碼,得出如下的結果:
運行結果中出現了本不該出現的0 和 -1
運行結果中出現了本不該出現的0和-1,因為按照正常邏輯,氣球數量到1之後就不應該被打印和減一了。出現這樣的結果是因為出現了以下的執行步驟:
小紅、小強、小明都同時搶到了1號氣球,由於線程調度,小強獲取了cpu時間片,得以執行,而小明和小紅則進入睡眠;小強打印出結果後,對num減一,此時num為0;小明醒來,獲得的num為0,然後小明將num打印出來,再對num減一,此時num為-1;小紅醒來,獲得的num為-1,隨後小紅將num打印出來,再對num減一,此時怒木為-2;由於多個線程是併發操作,所以對num做判斷時可能上一個線程還未對num減一,故都能通過(num > 0)的判斷;
解決方案:
在案例中的搶氣球其實是兩步操作:先搶到氣球,再對氣球總數減一;既然是兩步操作,在併發中就完全有可能會被分開執行,且執行順序無法得到控制;想要解決上述的線程不安全的問題,就必須要將這兩步操作作為一個原子操作,保證其同步運行;也就是當一個線程A進入操作的時候,其他線程只能在操作外等待,只有當線程A執行完畢,其他線程才能有機會進入操作。
原子操作:不能被分割的操作,必須保證其從一而終完全執行,要麼都執行,要麼都不執行。
為解決多線程併發訪問同一個資源的安全性問題,Java 提供如下了幾種不同的同步機制:
同步代碼塊;同步方法;Lock 鎖機制;
同步代碼塊:為了保證線程能夠正常執行原子操作,Java 引入了線程同步機制,其語法如下:
synchronized (同步鎖) { // 需要同步操作的代碼 ... ...}
上述中同步鎖,又稱同步監聽對象、同步監聽器、互斥鎖,同步鎖是一個抽象概念,可以理解為在對象上標記了一把鎖;Java 中可以使用任何對象作為同步監聽對象,但在項目開發中,我們會把當前併發訪問的共享資源對象作為同步監聽對象,在任何時候,最多隻能運行一個線程擁有同步鎖。
衛生間的使用就是一個很好的例子,一個衛生間在一段時間內只能被一個人使用,當一個人進入衛生間後,衛生間會被上鎖,其他只能等待;只有當使用衛生間的人使用完畢,開鎖後才能被下一個人使用。
然後就可以使用同步代碼塊來改寫搶氣球案例,示例代碼如下:
使用同步代碼塊來改寫搶氣球案例
通過查看運行結果,線程同步的問題已經得到解決。
同步方法:使用synchronized修飾的方法稱為同步方法,能夠保證當一個線程進入該方法的時候,其他線程在方法外等待。比如:
public synchronized void doSomething() { // 方法邏輯 }
PS:方法修飾符不分先後順序。
使用同步方法來改寫搶氣球案例,代碼如下:
使用同步方法來改寫搶氣球案例
注意:不能使用synchronized修改線程類中的run方法,因為使用之後,就會出現一個線程執行完了所有功能,多個線程出現串行;原本是多行道,使用synchronized修改線程類中的run方法,多行道變成了單行道。
好:synchronized保證了併發訪問時的同步操作,避免了線程的安全性問題。
壞:使用synchronized的方法、代碼塊的性能會比不用要低一些。
StringBuilder和StringBuffer
StringBuilder和StringBuffer 區別就在於StringBuffer中的方法都使用了synchronized修飾,StringBuilder中的方法沒有使用synchronized修飾;這也是StringBuilder性能比StringBuffer高的主要原因。
Vector和ArrayList
兩者都有同樣的方法,有同樣的實現算法,唯一不同就是Vector中的方法使用了synchronized修飾,所以Vector的性能要比ArrayList低。
Hashtable和HashMap
兩者都有同樣的方法,有同樣的實現算法,唯一不同就是Hashtable中的方法使用了synchronized修飾,所以Hashtable的性能要比HashMap低。
volatile關鍵字的作用在於:被volatile關鍵字修飾的變量的值,將不會被本地線程緩存,所有對該變量的讀寫都是直接操作共享內存,從而可以確保多個線程能正確處理該變量。
需要注意的是,volatile關鍵字可能會屏蔽虛擬機中的一些必要的優化操作,所以運行效率不是很高,因此,沒有特別的需要,不要使用;即便使用,也要避免大量使用。
單例模式--餓漢模式
代碼如下:
單例模式--餓漢模式 示例代碼
單例模式--懶漢模式
代碼如下:
單例模式--懶漢模式 代碼示例
懶漢模式存在線程不安全問題,在對instance對象做判斷時由於併發導致出現和搶氣球案例一樣的問題。為了解決這個問題,使用雙重檢查加鎖機制來解決。
雙重檢查加鎖機制
使用“雙重檢查加鎖”機制實現的程序,既能實現線程安全,有能夠使性能不受較大的影響。那麼何謂“雙重檢查加鎖”機制?其指的是:並不是每次進入getInstance方法都需要同步,而是先不同步,進入方法後,先檢查實例是否存在,如果不存在才執行同步代碼塊,這是第一重檢查;進入同步塊後,再次檢查實例是否存在,如果不存在,就在同步塊中創建一個實例,這是第二重檢查。這樣,就只需要同步一次,減少了多次在同步情況判斷所浪費的時間。
“雙重檢查加鎖”機制的實現需要volatile關鍵字的配合使用,且Java 版本需要在Java 5及以上,雖然該機制可實現線程安全的單例模式,也要根據實際情況酌情使用,不宜大量推廣使用。
使用“雙重檢查加鎖”機制改寫後的懶漢模式,代碼如下:
雙重檢查加鎖”機制改寫後的懶漢示例
Lock 接口
java.util.concurrent.locks包提供了Lock接口,Lock鎖機制提供了比synchronized代碼塊和synchronized方法更廣泛的鎖定操作,而且功能比synchronized代碼塊和synchronized方法更加強大。
官方的提供了參考價值很大的demo,能夠很好的提現Lock機制的功能:
Lock 機制的官方demo
是同Lock 機制改寫的搶氣球案例代碼如下所示:
import java.util.concurrent.locks.*;
public class LockDemo {
public static void main(String []args) {
Balloon balloon = new Balloon();
new Thread(balloon, "小紅").start();
new Thread(balloon, "小強").start();
new Thread(balloon, "小明").start();
}
}
// 氣球
class Balloon implements Runnable {
private int num = 500;
private final Lock lock = new ReentrantLock(); // 創建鎖對象
@Override
public void run() {
for (int i = 0; i < 500; i++) {
grabBalloon();
}
}
// 搶氣球
private void grabBalloon() {
lock.lock(); // 獲取鎖對象
if (num > 0) {
try {
System.out.println(Thread.currentThread().getName() + "搶到了"
+ num + "號氣球");
num--;
} catch (Exception e) {
} finally {
lock.unlock(); // 釋放鎖
}
}
}
}
案例運行正常。
閱讀更多 一名小白人員 的文章