Java多執行緒4:synchronized鎖機制

五月的倉頡發表於2015-10-01

髒讀

一個常見的概念。在多執行緒中,難免會出現在多個執行緒中對同一個物件的例項變數進行併發訪問的情況,如果不做正確的同步處理,那麼產生的後果就是"髒讀",也就是取到的資料其實是被更改過的。

 

多執行緒執行緒安全問題示例

看一段程式碼:

public class ThreadDomain13
{
    private int num = 0;
    
    public void addNum(String userName)
    {
        try
        {
            if ("a".equals(userName))
            {
                num = 100;
                System.out.println("a set over!");
                Thread.sleep(2000);
            }
            else
            {
                num = 200;
                System.out.println("b set over!");
            }
            System.out.println(userName + " num = " + num);
        }
        catch (InterruptedException e)
        {
            e.printStackTrace();
        }
    }
}

寫兩個執行緒分別去add字串"a"和字串"b":

public class MyThread13_0 extends Thread
{
    private ThreadDomain13 td;
    
    public MyThread13_0(ThreadDomain13 td)
    {
        this.td = td;
    }
    
    public void run()
    {
        td.addNum("a");
    }
}
public class MyThread13_1 extends Thread
{
    private ThreadDomain13 td;
    
    public MyThread13_1(ThreadDomain13 td)
    {
        this.td = td;
    }
    
    public void run()
    {
        td.addNum("b");
    }
}

寫一個主函式分別執行這兩個執行緒:

public static void main(String[] args)
{
    ThreadDomain13 td = new ThreadDomain13();
    MyThread13_0 mt0 = new MyThread13_0(td);
    MyThread13_1 mt1 = new MyThread13_1(td);
    mt0.start();
    mt1.start();
}

看一下執行結果:

a set over!
b set over!
b num = 200
a num = 200

按照正常來看應該列印"a num = 100"和"b num = 200"才對,現在卻列印了"b num = 200"和"a num = 200",這就是執行緒安全問題。我們可以想一下是怎麼會有執行緒安全的問題的:

1、mt0先執行,給num賦值100,然後列印出"a set over!",開始睡覺

2、mt0在睡覺的時候,mt1執行了,給num賦值200,然後列印出"b set over!",然後列印"b num = 200"

3、mt1睡完覺了,由於mt0的num和mt1的num是同一個num,所以mt1把num改為了200了,mt0也沒辦法,對於它來說,num只能是100,mt0繼續執行程式碼,列印出"a num = 200"

分析了產生問題的原因,解決就很簡單了,給addNum(String userName)方法加同步即可:

public class ThreadDomain13
{
    private int num = 0;
    
    public synchronized void addNum(String userName)
    {
        try
        {
            if ("a".equals(userName))
            {
                num = 100;
                System.out.println("a set over!");
                Thread.sleep(2000);
            }
            else
            {
                num = 200;
                System.out.println("b set over!");
            }
            System.out.println(userName + " num = " + num);
        }
        catch (InterruptedException e)
        {
            e.printStackTrace();
        }
    }
}

看一下執行結果:

a set over!
a num = 100
b set over!
b num = 200

 

多個物件多個鎖

在同步的情況下,把main函式內的程式碼改一下:

public static void main(String[] args)
{
    ThreadDomain13 td0 = new ThreadDomain13();
    ThreadDomain13 td1 = new ThreadDomain13();
    MyThread13_0 mt0 = new MyThread13_0(td0);
    MyThread13_1 mt1 = new MyThread13_1(td1);
    mt0.start();
    mt1.start();
}

看一下執行結果:

a set over!
b set over!
b num = 200
a num = 100

列印結果的方式變了,列印的順序是交叉的,這又是為什麼呢?

這裡有一個重要的概念。關鍵字synchronized取得的鎖都是物件鎖,而不是把一段程式碼或方法(函式)當作鎖,哪個執行緒先執行帶synchronized關鍵字的方法,哪個執行緒就持有該方法所屬物件的鎖,其他執行緒都只能呈等待狀態。但是這有個前提:既然鎖叫做物件鎖,那麼勢必和物件相關,所以多個執行緒訪問的必須是同一個物件

如果多個執行緒訪問的是多個物件,那麼Java虛擬機器就會建立多個鎖,就像上面的例子一樣,建立了兩個ThreadDomain13物件,就產生了2個鎖。既然兩個執行緒持有的是不同的鎖,自然不會受到"等待釋放鎖"這一行為的制約,可以分別執行addNum(String userName)中的程式碼。

 

synchronized方法與鎖物件

上面我們認識了物件鎖,物件鎖這個概念,比較抽象,確實不太好理解,看一個例子,在一個實體類中定義一個同步方法和一個非同步方法:

public class ThreadDomain14_0
{
    public synchronized void methodA()
    {
        try
        {
            System.out.println("Begin methodA, threadName = " + 
                    Thread.currentThread().getName());
            Thread.sleep(5000);
            System.out.println("End methodA, threadName = " + 
                    Thread.currentThread().getName() + ", end Time = " + 
                    System.currentTimeMillis());
        } 
        catch (InterruptedException e)
        {
            e.printStackTrace();
        }
    }
    
    public void methodB()
    {
        try
        {
            System.out.println("Begin methodB, threadName = " + 
                    Thread.currentThread().getName() + ", begin time = " + 
                    System.currentTimeMillis());
            Thread.sleep(5000);
            System.out.println("End methodB, threadName = " + 
                    Thread.currentThread().getName());
        } 
        catch (InterruptedException e)
        {
            e.printStackTrace();
        }
    }
}

一個執行緒呼叫其同步方法,一個執行緒呼叫其非同步方法:

public class MyThread14_0 extends Thread
{
    private ThreadDomain14_0 td;
    
    public MyThread14_0(ThreadDomain14_0 td)
    {
        this.td = td;
    }
    
    public void run()
    {
        td.methodA();
    }
}
public class MyThread14_1 extends Thread
{
    private ThreadDomain14_0 td;
    
    public MyThread14_1(ThreadDomain14_0 td)
    {
        this.td = td;
    }
    
    public void run()
    {
        td.methodB();
    }
}

寫一個main函式去呼叫這兩個執行緒:

public static void main(String[] args)
{
    ThreadDomain14_0 td = new ThreadDomain14_0();
    MyThread14_0 mt0 = new MyThread14_0(td);
    mt0.setName("A");
    MyThread14_1 mt1 = new MyThread14_1(td);
    mt1.setName("B");
    mt0.start();
    mt1.start();
}

看一下執行效果:

Begin methodA, threadName = A
Begin methodB, threadName = B, begin time = 1443697780869
End methodB, threadName = B
End methodA, threadName = A, end Time = 1443697785871

從結果看到,第一個執行緒呼叫了實體類的methodA()方法,第二個執行緒完全可以呼叫實體類的methodB()方法。但是我們把methodB()方法改為同步就不一樣了,就不列修改之後的程式碼了,看一下執行結果:

Begin methodA, threadName = A
End methodA, threadName = A, end Time = 1443697913156
Begin methodB, threadName = B, begin time = 1443697913156
End methodB, threadName = B

從這個例子我們得出兩個重要結論:

1、A執行緒持有Object物件的Lock鎖,B執行緒可以以非同步方式呼叫Object物件中的非synchronized型別的方法

2、A執行緒持有Object物件的Lock鎖,B執行緒如果在這時呼叫Object物件中的synchronized型別的方法則需要等待,也就是同步

 

synchronized鎖重入

關鍵字synchronized擁有鎖重入的功能。所謂鎖重入的意思就是:當一個執行緒得到一個物件鎖後,再次請求此物件鎖時時可以再次得到該物件的鎖的。看一個例子:

public class ThreadDomain16
{
    public synchronized void print1()
    {
        System.out.println("ThreadDomain16.print1()");
        print2();
    }
    
    public synchronized void print2()
    {
        System.out.println("ThreadDomain16.print2()");
        print3();
    }
    
    public synchronized void print3()
    {
        System.out.println("ThreadDomain16.print3()");
    }
}
public class MyThread16 extends Thread
{
    public void run()
    {
        ThreadDomain16 td = new ThreadDomain16();
        td.print1();
    }
}
public static void main(String[] args)
{
    MyThread16 mt = new MyThread16();
    mt.start();
}

看一下執行結果:

ThreadDomain16.print1()
ThreadDomain16.print2()
ThreadDomain16.print3()

看到可以直接呼叫ThreadDomain16中的列印語句,這證明了物件可以再次獲取自己的內部鎖。這種鎖重入的機制,也支援在父子類繼承的環境中

 

異常自動釋放鎖

最後一個知識點是異常。當一個執行緒執行的程式碼出現異常時,其所持有的鎖會自動釋放。模擬的是把一個long型數作為除數,從MAX_VALUE開始遞減,直至減為0,從而產生ArithmeticException。看一下例子:

public class ThreadDomain17
{
    public synchronized void testMethod()
    {
        try
        {
            System.out.println("Enter ThreadDomain17.testMethod, currentThread = " + 
                    Thread.currentThread().getName());
            long l = Integer.MAX_VALUE;
            while (true)
            {
                long lo = 2 / l;
                l--;
            }
        } 
        catch (Exception e)
        {
            e.printStackTrace();
        }
    }
}
public class MyThread17 extends Thread
{
    private ThreadDomain17 td;
    
    public MyThread17(ThreadDomain17 td)
    {
        this.td = td;
    }
    
    public void run()
    {
        td.testMethod();
    }
}
public static void main(String[] args)
{
    ThreadDomain17 td = new ThreadDomain17();
    MyThread17 mt0 = new MyThread17(td);
    MyThread17 mt1 = new MyThread17(td);
    mt0.start();
    mt1.start();
}

看一下執行結果:

Enter ThreadDomain17.testMethod, currentThread = Thread-0
Enter ThreadDomain17.testMethod, currentThread = Thread-1
java.lang.ArithmeticException: / by zero
    at com.xrq.example.e17.ThreadDomain17.testMethod(ThreadDomain17.java:14)
    at com.xrq.example.e17.MyThread17.run(MyThread17.java:14)
java.lang.ArithmeticException: / by zero
    at com.xrq.example.e17.ThreadDomain17.testMethod(ThreadDomain17.java:14)
    at com.xrq.example.e17.MyThread17.run(MyThread17.java:14)

因為列印結果是靜態的,所以不是很明顯。在l--前一句加上Thread.sleep(1)結論會更明顯,第一句打出來之後,整個程式都停住了,直到Thread-0丟擲異常後,Thread-1才可以執行,這也證明了我們的結論。

 

後記

文章裡面的這些個結論,記一下都是很快的,但是是否記一下就好了?我認為記住這些結論一點都不重要,重要的應該是學習如何通過程式碼去驗證這些結論。因為只有知道了如何通過程式碼去驗證結論,才可以說真正對於synchronized關鍵字的各種細節有了感性、有了深入的理解,以後碰到其他synchronized的場景就可以以自己的理解去正確分析問題。

相關文章