java 線程安全問題以及使用synchronized解決線程安全問題的幾種方式

一、線程安全問題產生的原因

我們使用java多線程的時候,最讓我們頭疼的莫過於多線程引起的線程安全問題,那麼線程安全問題到底是如何產生的呢?究其本質,是因爲多條線程對同一份數據進行讀、寫操作的過程中,不符合原子性。所謂原子性,就是不可再分性(早期沒有發現質子、中子的時候,物理學家們都認爲原子就是組成物質的最小粒子,是不可再分的)。
爲什麼多線程對數據進行讀寫時不符合原子性就會產生的線程安全問題呢?我們用一個非常簡單的例子來說明這個問題:

int i = 1;
int temp; 

while(i < 10){
	//讀取i的值
	temp = i; 
	//對i進行+1操作後再重新賦給i
	i = temp + 1; 
}

你可能已經發現了,這不就是i++做的事情嗎。沒錯,其實i++就是做了上面的兩件事:

  1. 讀取i當前的值。
  2. 對讀取到的值加1然後再賦給i

我們知道,在某一個時間點,系統中只會有一條線程去執行任務,下一時間點有可能又會切換爲其他線程去執行任務,我們無法預測某一時刻究竟是哪條線程被執行,這是由CPU來統一調度的。
因此現在假設我們有t1、t2兩條線程同時去執行這段代碼。假設t1執行完temp = i就被cpu掛起了(需要等待CPU下次調度才能繼續執行),此時t1讀到i的值是1並賦值給了臨時變量temp;然後CPU讓t2執行,注意剛纔t1只執行完了“讀”,也就是說t1並沒有對i進行加1操作然後再賦回給i,因此這時t2讀到的i還是1,然後賦值給臨時變量temp,最後進行加1操作並賦回給i,執行完成後i=2、temp=1。好了,此時CPU又調度t1讓其繼續執行,重點來了,t1該執行i = temp + 1了,由於t1沒能將最新的i=2賦給臨時變量temp,因此temp此時仍然是1,然後對i進行加1得到的結果是2然後賦回給i。
那麼問題來了,我們很清楚此時循環已經進行了兩次。按正常邏輯來說,對i進行兩次加1操作後,此時i應該等於3,但是兩條線程完成兩次加1操作後i的值竟然是2,當進行第三次循環的時候,讀取到i的值將會是2,當這段程序執行完成後得到結果肯定會不符合我們的預期,這就是線程安全問題產生的原因。
那麼引發這個問題的原因是什麼呢?就是我們最開始說的,多條線程對同一份數據進行"讀"、"寫"操作的過程中,不符合原子性。也就是將"讀"和"寫"進行了分割,當"讀"和"寫"分割開後,如果一條線程"讀"完但未"寫"時被CPU停掉,此時其他線程就有可能趁虛而入導致最後產生奇怪的數據,簡單畫個圖描述一下:
在這裏插入圖片描述
我們可以把左邊的圓看成是符合原子性的代碼,而右邊的圓是被分割成了兩步執行的代碼。如果符合原子性,那麼線程只要執行就會把"讀"、“寫”全部執行,其他線程再拿到的數據就是被寫過的最新數據,不會有安全隱患;而如果不符合原子性,將"讀"、“寫”進行了分割,那麼t1,讀取完數據如果停掉的話,t2執行的時候拿到的就是沒有被更新的老數據,接下來t1,t2同時對相同的老數據進行更新勢必會因此數據的異常。

那將上面的"讀"和"寫"兩行代碼改成一行代碼i++是不是就不會產生線程安全問題了呢?
我們知道一條線程被CPU調度執行任務時,至少要執行完一條計算機指令,但是要注意一行java代碼並不一定就是一條指令,一行java代碼有可能轉換成多條計算機指令。而i++就是典型的例子,它在java中雖然是一行代碼,但最終卻會轉成3條計算機指令,有興趣的可以參看一下這篇文章:關於i++是不是原子操作的問題

我們可以使用java.util.concurrent.atomic包下提供的原子操作來使這個讀寫符合原子性:

private static AtomicInteger count = new AtomicInteger(0);

while(count < 10){
   count.incrementAndGet(); 
};

最後,對於線程安全問題,有兩點說明:

  1. 只存在讀數據的時候,不會產生線程安全問題。
  2. 在java中,只有同時操作成員變量的時候纔會產生線程安全問題,局部變量不會。不知道爲什麼的同學,可以先了解一下JMM。

二、代碼演示線程安全問題

基於上面的分析,我們通過經典的賣票例子來進演示線程安全問題:

public class TicketThread implements Runnable{

    private int ticketCount = 100;
    
    @Override
    public void run() {
        while (ticketCount > 0) {
            try {
                Thread.sleep(50);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            sale();
        }
    }
    
    public void sale(){
        if (ticketCount > 0) {
            System.out.println(Thread.currentThread().getName() + "正在出售第" + (100-ticketCount+1) + "張票");
            ticketCount --;
        }
    }
}
public class Main {
    public static void main(String[] args) {

      TicketThread ticketThread = new TicketThread(); 
      Thread t1 = new Thread(ticketThread, "窗口1--");
      Thread t2 = new Thread(ticketThread, "窗口2--");
      t1.start();
      t2.start();
    }
}

運行結果:
在這裏插入圖片描述
我們看到結果中有很多數據都是不符合預期的,很明顯是發生了線程安全問題。大家可以按照上面的思路來分析一下,導致出現問題的代碼在哪裏,哪裏進行了"讀"哪裏進行了"寫"。

三、使用synchronized解決線程安全問題

synchronized的概念

synchronized在英語中翻譯成同步,同步想必大家都不陌生,例如同步調用,有A,B兩個方法,必須要先調用A並且獲得A的返回值才能去調用B,也就是說,想做下一步,必須要拿到上一步的返回值。同樣的道理,使用了synchronized的代碼,當線程t1進入的時候,另一個線程若t2想進入,就必須要得到返回值才能進入,怎麼得到返回值呢?那就要等t1出來了纔會有返回值。這就是多線程中常說的加鎖,加了synchronized的代碼我們可以想象成將他們放到了一個房間,我前邊所說的返回值就相當於這個房間的鑰匙,進入這個房間的線程同時會把鑰匙帶進去,當它出來的時候會將鑰匙仍在地上(釋放資源),然後其他線程過來搶鑰匙(爭奪CPU執行權),以此類推。
被放到"房間"裏代碼,其實就是爲了讓其保持原子性,因爲當線程t1進入被synchronized修飾的代碼當中的時候,其他線程是被鎖在外邊進不來的,直到線程t1執行完裏邊的所有代碼(或拋出異常),纔會釋放資源。我們換個角度想,這不就是讓房間(synchronized)裏面的代碼保持了原子性嗎,某一線程只要進去了,其他線程就只能在外邊等着,執行期間也就不會有其他線程進來干擾它,就像我上面圖中左邊那個圓一樣,也就是相當於將本來分割的讀和寫的操作合併在了一起,讓一個線程要麼不執行,只要執行就得把讀和寫全部執行完(且期間不會受干擾)。
理解了上邊說的,就再也不用糾結到底把什麼代碼放入synchronized中了,只要把"讀"和"寫"分割的代碼,並且分割後會引發線程安全問題的代碼放入讓其保持原子性就可以了。很明顯在上面TicketThread類中,就是下面這三行代碼:

// "讀"ticketCount的值
if (ticketCount > 0) {
	// "讀"ticketCount的值
	System.out.println(Thread.currentThread().getName() + "正在出售第" + (100-ticketCount+1) + "張票");
	// "寫"ticketCount的值
    ticketCount --;
}

synchronized的三種用法

1.synchronized代碼塊

public class SynchronizedBlockThread implements Runnable {

    private Object obj = new Object();
    private int ticketCount = 100;
    
    @Override
    public void run() {
        while (ticketCount > 0) {
            try {
                Thread.sleep(50);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            sale();
        }
    }
    
    public void sale(){
        // 在產生線程安全的地方加上synchronized代碼塊
        synchronized (obj) { 
        if (ticketCount > 0) {
            System.out.println(Thread.currentThread().getName() + "正在出售第" + (100-ticketCount+1) + "張票");
            ticketCount --;
            }
        }
    }
}
public class Main {

    public static void main(String[] args) {
        SynchronizedBlockThread blockThread = new SynchronizedBlockThread();
        Thread t1 = new Thread(blockThread, "窗口1--");
        Thread t2 = new Thread(blockThread, "窗口2--");
        t1.start();
        t2.start();
    }
}

多個線程之間的同步代碼塊中必須使用相同的鎖(體現在代碼中就是同一個對象)才能一個線程進入代碼塊的時候其他線程無法進入。如果使用的不是同一把鎖,那麼一條線程進入synchronized中且未釋放資源前,另一條線程依然可以進入。synchronized代碼塊中使用的鎖要求必須是引用數據類型,最常用的就是傳入一個Object對象,或者使用當前類的對象,即this。

運行結果:
在這裏插入圖片描述

2.同步函數–synchronized作用在方法上

public class SynchronizedMethodThread implements Runnable{

    private int ticketCount = 100;
    
    @Override
    public void run() {
        while (ticketCount > 0) {
            try {
                Thread.sleep(50);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            sale();
        }
    }
    
    public synchronized void sale(){ //使用同步函數使線程間同步
        if (ticketCount > 0) {
            System.out.println(Thread.currentThread().getName()
                    + "正在出售第" + (100-ticketCount+1) + "張票");
            ticketCount --;
            }
    }
}
public class SellTicketMain {

    public static void main(String[] args) {
        SynchronizedMethodThread methodThread = new SynchronizedMethodThread();
        Thread t1 = new Thread(methodThread, "窗口1--");
     Thread t2 = new Thread(methodThread, "窗口2--");  
     t1.start();  
     t2.start();
   } 
}

在synchronized代碼塊中,我們創建了Object對象並將其當做鎖來使用。那synchronized作用在方法上時,是用什麼當作鎖來使用的呢?答案是當前對象,也就是this,我們通過下面的代碼來證明一下:

public class VerifySynchronizedThread implements Runnable {

    private static int trainCount = 100;
    private Object obj = new Object();
    public boolean flag = true;

    @Override
    public void run() {
        if (flag) {
            // flag=ture時,執行synchronized代碼塊this鎖
            while (trainCount > 0) {
                synchronized (this) {
                    if (trainCount > 0) {
                        try {
                            Thread.sleep(50);
                        } catch (Exception e) {
                            e.printStackTrace();
                        }
                        System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - trainCount + 1) + "票");
                        trainCount--;
                    }
                }

            }
        } else {
            // flag=false時,執行synchronized函數
            while (trainCount > 0) {
                sale();
            }
        }

    }

	// synchronized函數
    public synchronized void sale() { 
        if (trainCount > 0) {
            try {
                Thread.sleep(50);
            } catch (Exception e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - trainCount + 1) + "票");
            trainCount--;
        }
    }
}
public class Main {

    public static void main(String[] args) {    
        VerifySynchronizedThread thread = new VerifySynchronizedThread();

        Thread t1 = new Thread(thread, "窗口1--");
        Thread t2 = new Thread(thread, "窗口2--");
        t1.start();
        try {
            Thread.sleep(40);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        thread.flag = false;
        t2.start();
    }
}

我們通過flag控制,讓t1執行時flag=true從而進入synchronized代碼塊進行循環。然後在t2啓動前,將flag改成false,從而讓t2執行synchronized函數。由於兩條線程同時操作trainCount這個成員變量,因此可能會引發線程安全問題,現在t1進入的是synchronized代碼塊,t2進入的是synchronized函數,按照前邊的分析如果他們倆使用的是通一把鎖,就不會有線程安全問題。
我們既然是在驗證synchronized函數使用的是this鎖,因此我們將synchronized代碼塊中也使用this,經過幾次反覆的運行,並沒有發現數據錯誤。當我們將synchronized代碼塊中的鎖換成obj後,就開始出現錯誤數據,因此證明synchronized函數使用的是this鎖。

3.靜態同步函數–synchronized作用在靜態方法上

除了上述兩種用法之外,synchronized還能作用在靜態方法上,但是需要注意的是,此時使用的鎖是什麼呢?肯定不是this,因爲我們知道靜態函數要先於對象加載,也就是說當靜態同步函數被加載的時候,本類的對象即this在內存中還不存在,因此也不可能使用它當作鎖。
它其實是本類的字節碼文件文件來當作鎖,即StaticSynchronizedThread.class。我們依然使用上面的代碼來驗證這一點,只需將之前的this改成StaticSynchronizedThread.class即可:

public class StaticSynchronizedThread implements Runnable {
    private static int ticketCount = 100;
    public boolean flag = true;
    @Override
    public void run() {
        if (flag) {
            while (ticketCount > 0) {
            	// flag=ture時,synchronized代碼塊
                synchronized (StaticSynchronizedThread.class) { 
                    if (ticketCount > 0) {
                        try {
                            Thread.sleep(50);
                        } catch (Exception e) {
                            e.printStackTrace();
                        }
                        System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - ticketCount + 1) + "票");
                        ticketCount--;
                    }
                }

            }
        } else {
            while (ticketCount > 0) {
            	// flag=false時,執行靜態同步函數
                sale();
            }
        }

    }
    //靜態同步函數
    public static synchronized void sale() { 
        if (ticketCount > 0) {
            try {
                Thread.sleep(50);
            } catch (Exception e) {
                e.printStackTrace();
            }
            System.out.println(Thread.currentThread().getName() + ",出售第" + (100 - ticketCount + 1) + "票");
            ticketCount--;
        }
    }
}
public class Main {

    public static void main(String[] args) {
        StaticSynchronizedThread thread = new StaticSynchronizedThread();

        Thread t1 = new Thread(thread, "窗口1--");
        Thread t2 = new Thread(thread, "窗口2--");
        t1.start();
        try {
            Thread.sleep(40);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        thread.flag = false;
        t2.start();
    }
}

synchronized總結

在java中,除了使用synchronized來解決線程安全問題外還可以使用jdk 1.5以後引入的lock鎖機制,本篇着重講解synchronized方式,有機會的話我會再寫一篇通過lock解決線程安全的文章。

最後,對於synchronized的使用,有以下幾點說明和總結:

  • 要使用synchronized,必須要有兩個以上的線程。單線程使用沒有意義,還會使效率降低。
  • 要使用synchronized,線程之間需要發生同步,不需要同步的沒必要使用synchronized,例如只讀數據。
  • 使用synchronized的缺點是效率非常低,因爲加鎖、釋放鎖和釋放鎖後爭搶CPU執行權的操作都很耗費資源。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章