03 Java多線程

JVM是個多任務的操作系統,可以同時運行多個任務。要理解多線程技術,就應該從理解線程開始。

1. 多線程的概念

我們都知道Windows是個多任務的操作系統,可以同時運行多個應用程序,比如可以在上網的同時,還可以聽音樂,甚至邊玩一些遊戲,而這樣每一個獨立運行的程序又被稱爲進程,同時運行多個程序又叫多進程,它們每個都擁有獨立的內存和代碼,因此進程越多,對內存的消耗就越大。而線程則是包含在進程內的,一個進程裏面可以包含多個線程,一個線程就是一個獨立的程序流,這種稱作爲單線程,初學者接觸的程序大多都是單線程的,比如我們在01篇編程基礎裏面的Hello World例子。然而較大的進程需要同時滿足許多功能,靠單進程是力不從心的,需要在一個進程內開闢多個同時運行的線程,這些線程共享該進程在內存中佔有的資源。

2. 多線程的實現

2.1 兩種方式

Java中實現多線程,有兩種方式,一是繼承Thread類,二是實現Runnable接口。
繼承Thread類的自定義類需要重寫Thread類的public void run()方法,然後調用其start()方法開啓新的線程:
class User extends Thread
{
    @Override
        public void run()
        {
            ...
        }
}
比如:
public class Test
{
    public static void main(String[] args)
    {
        new User().start();     
        new User().start();     //兩個線程隨機交替運行
    }
}

class User extends Thread
{   
    private int count = 0;
    @Override
        public void run()
        {
            for(int i = 0; i < 3; i++)
                //輸出當前線程的編號和count值
                System.out.println(Thread.currentThread().getName() + ", count = " + ++count);
        }
}
實現Runnable接口的自定義類需要重寫Runnable接口的public void run()方法,將該自定義類的對象引用傳給Thread類作爲參數,然後利用這個參數生成Thread類的對象並調用其start()方法開啓新的線程:
class User implements Runnable
{
    public void run()
    {
        .....
    }
}
比如:
public class Test
{
    public static void main(String[] args)
    {
        User usr = new User();
        new Thread(usr).start();     //User類的對象引用作爲參數來生成Thread類的對象
        new Thread(usr).start();     //兩個線程隨機交替運行
    }
}

class User implements Runnable
{
    private int count = 0;
    @Override
        public void run()
        {
            for(int i = 0; i < 3; i++)
                //輸出當前線程的編號和count值
                System.out.println(Thread.currentThread().getName() + ", count = " + ++count);
        }
}
上面兩種方式的Thread - 01和Thread - 02運行順序是不確定的,取決於CPU對線程執行順序的分配。

2.2 資源共享

繼承Thread類和實現Runnable接口的區別,一是能否實現多繼承,二是能否實現資源共享。還是以前面的兩個示例程序來看:
繼承Thread類的線程的輸出:
Thread - 0, count = 1;
Thread - 1, count = 1;
Thread - 0, count = 2;
Thread - 1, count = 2;
Thread - 0, count = 3;
Thread - 1, count = 3;
這說明兩個線程對象的count變量是各自擁有的,各自操作的,不能共享。
實現Runnable接口的線程的輸出:
Thread - 0, count = 1;
Thread - 1, count = 2;
Thread - 0, count = 3;
Thread - 1, count = 4;
Thread - 0, count = 5;
Thread - 1, count = 6;
這說明兩個線程對象的count變量指向同一塊內存,實現了資源共享。
能否共享取決於線程的生成方式:對於繼承Thread類的方法,新Thread類對象的生成調用的是無參構造方法,所以新的線程將獨立生成它自己的內存空間,不知道其它線程的內存信息,也就不能共享數據。而對於實現Runnable接口的方法,新Thread類對象的生成調用的是有參構造方法,如果多個Thread類在構造對象時使用同一個Runnable類型的引用作爲構造參數,則每個新的Thread類對象都有同一個實現了Runnable接口的類的對象的引用,也就獲得了同一個數據地址,實現了數據共享。

2.3 如何中斷一個線程

Thread類內部有一個boolean型的變量叫做中斷狀態,我們無法直接訪問,只能通過interrupt,isInterrupted和interrupted方法來訪問。其中interrupt方法對於非阻塞和阻塞的線程的中斷狀態有不同的作用。
如果一個線程中調用了sleep,join或者wait方法,就會進入阻塞狀態,暫停運行。除此之外都屬於非阻塞狀態,正常運行。
要正確地中斷一個線程,就必須理解interrupt方法的作用。這個方法的字面意思給筆者這樣的初學者以很大誤導,讓筆者誤以爲這個方法可以終止線程,實際上是不能的。
單憑調用interrupt方法不能中斷線程。對於非阻塞的線程,interrupt方法的調用會將其中斷狀態置爲true。對於阻塞的線程,會拋出異常。
真正中斷線程的,是我們編寫的用來響應這兩個事件的代碼。
對非阻塞線程使用interrupt方法,其它程序可以循環檢測其中斷標誌,如果爲true則可以寫對應的處理代碼,比如終止while循環這樣的方式來終止線程。
對於阻塞線程使用interrupt方法,可以在線程的catch塊中(可以進入阻塞狀態的線程必然有異常拋出和捕獲結構)寫對應的處理代碼,比如退出程序的語句。
下面我們來試驗兩種情況下的interrupt()方法:
對非阻塞線程調用interrupt:
public class Test
{
    public static void main(String[] args)
    {
        User usr = new User();
        usr.start();
        Thread.sleep(5);                                        //main線程空轉5ms用於線程初始化
        System.out.println("isInterrupted ? " + usr.isInterrupted());
        usr.interrupt();                                        //中斷usr線程
        System.out.println("isInterrupted ? " + usr.isInterrupted());
    }
}

class User extends Thread
{
    @Override
        public void run()
        {
            try
            {
                long time = System.currentTimeMillis();
                Thread.currentThread().setName("User thread");
                while(System.currentTimeMillis() - time < 20)     //保證User線程運行20ms以上
                {
                    System.out.println(Thread.currentThread().getName() + " is running.");
                }
            }
            catch(Exception e)
            {
                System.out.println(Thread.currentThread().getName() + " is interrupted.");
            }
        }
}
輸出:
User thread is running.
User thread is running.
.....
User thread is running.
isInterrupted ? false
User thread is running.
isInterrupted ? true
User thread is running.
......
很明顯,調用interrupted()方法只是將中斷狀態設置爲true,usr線程並沒中斷,而是繼續運行。
那應當怎樣終止一個非阻塞狀態的線程呢?使用共享的volatile變量作爲終止信號,讓線程週期性地檢查這一變量並據此終止自己。
在2.2中筆者分析Runnable接口實現類的數據共享時,提到過Thread類共享接口實現類的對象引用,從而共享接口實現類對象內的數據,然而這個說法不夠深入。像2.2中一個示例程序要運行,不止需要堆內存來存儲接口實現類的對象,每個Thread類生成的線程都擁有各自的線程棧。線程棧保存了線程運行時的局部變量。當共享數據的兩個線程A,B中的一個,比如A要修改數據時,A會先通過它擁有的接口實現類的對象的引用找到對象本身(在堆內存中),然後把對內存中的數據複製到A自己的線程棧中,然後修改這個複製的數據,在A退出之前將修改後的數據寫回到堆內存的原位置。問題是如果A,B兩個線程沒有同步,B線程可能在A線程剛複製完數據時就改變了堆中數據的值,使得A的副本過時。volatile關鍵字修飾的數據可以保證共享該數據的線程總是得到該數據最新的值,也就是不適用副本而是直接訪問堆。看下面的例子:
public class Test
{
    public static void main(String[] args) throws Exception
    {
        User usr = new User();
        usr.start();
        Thread.sleep(5);           //main線程空轉5ms用於線程初始化
        System.out.println("isAlive ? " + usr.isAlive());
        usr.stop = true;           //中斷usr線程
        Thread.sleep(5);           //留出5ms給usr線程用於終止動作
        System.out.println("isAlive ? " + usr.isAlive());
    }
}

class User extends Thread
{
    volatile boolean stop = false;
    @Override
        public void run()
        {
            try
            {
                long time = System.currentTimeMillis();
                Thread.currentThread().setName("User thread");
                while(System.currentTimeMillis() - time < 20 && stop == false)     //保證User線程運行20ms以上
                {
                    System.out.println(Thread.currentThread().getName() + " is running.");
                }
            }
            catch(Exception e)
            {
                System.out.println(Thread.currentThread().getName() + " is interrupted.");
            }
        }
}
輸出:
User thread is running.
User thread is running.
.....
isAlive ? true
User thread is running.
isAlive ? false
很明顯,usr.stop標誌改爲true時usr線程立刻終止並退出。
下面來考察阻塞狀態的線程如何中斷。阻塞狀態的線程停止在阻塞它的語句處,無法循環檢查用戶設定的停止標誌。這時應當使用interrupt()方法中斷線程,拋出異常,並在異常處理catch塊中編碼決定該線程中斷後的命運。先看休眠狀態的線程:
public class Test
{
    public static void main(String[] args) throws Exception
    {
        User usr = new User();
        usr.start();
        Thread.sleep(1000);             //等待usr線程進入休眠狀態
        System.out.println("isInterrupted ? " + usr.isInterrupted());
        usr.interrupt();                //中斷usr線程
        Thread.sleep(10);               //留出10ms給usr線程用於終止動作
        System.out.println("isInterrupted ? " + usr.isInterrupted());
        System.out.println("isAlive ? " + usr.isAlive());
    }
}

class User extends Thread
{
    @Override
        public void run()
        {
            try
            {
                Thread.currentThread().setName("User thread");
                Thread.sleep(3000);     //usr線程休眠3000ms
            }
            catch(Exception e)
            {
                System.out.println(Thread.currentThread().getName() + " is interrupted.");
            }
        }
}
輸出:
isInterrupted ? false
User thread is interrupted.
isInterrupted ? false
isAlive ? false
可以看到對阻塞狀態的usr線程使用interrupt方法後,usr線程結束休眠,拋出異常,顯示中斷狀態爲false,隨後退出。
上面的例子說明對阻塞狀態的線程使用interrupt方法,中斷狀態仍爲false,只是會立刻拋出異常。那麼中斷狀態只能由interrupt方法來控制嗎?看下面的例子:
public class Test
{
    public static void main(String[] args) throws Exception
    {
        User usr = new User();
        usr.start();
        System.out.println("isInterrupted ? " + usr.isInterrupted());
        usr.interrupt();                //中斷usr線程
        System.out.println("isInterrupted ? " + usr.isInterrupted());
    }
}

class User extends Thread
{
    @Override
        public void run()
        {
            try
            {
                long time = System.currentTimeMillis();
                Thread.currentThread().setName("User thread");
                while(System.currentTimeMillis() - time < 100);   //usr線程空轉100ms等待中斷狀態置爲true
                Thread.sleep(300);                                //usr線程休眠300ms
            }
            catch(Exception e)
            {
                System.out.println(Thread.currentThread().getName() + " isInterrupted ? " + this.isInterrupted());
            }
        }
}
輸出:
isInterrupted ? false
isInterrupted ? true
User thread isInterrupted ? false
這說明usr線程先被interrupt方法將中斷狀態置爲true,然後再調用sleep時就會拋出異常,同時將中斷狀態置爲false。
綜上,中斷狀態跟interrupt和sleep方法的調用順序有關,可以歸納爲:
false -  > interrupt -  > true;
false -  > sleep -  > false -  > interrupt -  > Exception thrown -  > false;
false -  > interrupt -  > true - > sleep -  > Exception thrown -  > false
另一種阻塞狀態的線程是調用wait方法後出現的,但這隻能出現在使用同步方法的線程中。

3. 線程同步

爲了保證單個線程中的多個步驟按順序進行,不被其他線程打擾,需要進行線程同步,使得線程中的代碼獲得其所操作的對象的鎖,這些代碼得以按順序執行完畢後再釋放鎖。

3.1 理解synchronized方法和synchronized塊

一個沒有線程同步的例子:
public class Test
{
    public static void main(String[] args) throws Exception
    {
        Resource r = new Resource();
        User usr = new User(r);
        new Thread(usr).start();
        new Thread(usr).start();
    }
}

class Resource
{
    private int count = 0;
    public void add()
    {
        count++;
        System.out.println(Thread.currentThread().getName() + " does step 1. count = " + count);
        count++;
        System.out.println(Thread.currentThread().getName() + " does step 2. count = " + count);
    }
}

class User implements Runnable
{
    Resource r;
    User(Resource r)
    {
        this.r = r;     //線程生成時獲取其要操作的對象r
    }
    @Override
        public void run()
        {
            for(int i = 0; i < 3; i++) 
            {
                 r.add();
            }
        }
}
輸出:
Thread - 1 does step 1. count = 2
Thread - 0 does step 1. count = 2
Thread - 1 does step 2. count = 3
Thread - 0 does step 2. count = 4
Thread - 1 does step 1. count = 5
Thread - 0 does step 1. count = 6
Thread - 1 does step 2. count = 7
Thread - 0 does step 2. count = 8
...
兩個線程共享對象r。add方法中的兩步count++應當是由同一個線程連續完成的。但由於這裏沒有線程同步,兩個線程隨機調用同一個r對象的add方法,不管另一個線程是否完成了add方法的兩步,造成了執行順序的混亂,甚至打印命令也被視作單獨的一步,也不按代碼中的順序執行了,顯示的count值出現錯誤。
如果將add方法用synchronized關鍵字修飾,或者將r.add()語句加上對象鎖如下:
class Resource
{
    private int count = 0;
    public void synchronized add()      //synchronized關鍵字
    {
        count++;
        System.out.println(Thread.currentThread().getName() + " does step 1. count = " + count);
        count++;
        System.out.println(Thread.currentThread().getName() + " does step 2. count = " + count);
    }
}

class User implements Runnable
{
    Resource r;
    User(Resource r)
    {
        this.r = r;     //線程生成時獲取其要操作的對象r
    }
    @Override
        public void run()
        {
            for(int i = 0; i < 3; i++) 
            {
                //synchronized(r)    //對象鎖讓線程鎖住對象r, 將r換成this也可以
                {
                     r.add();
                }
            }
        }
}
輸出:
Thread - 1 does step 1. count = 1
Thread - 1 does step 2. count = 2
Thread - 0 does step 1. count = 3
Thread - 0 does step 2. count = 4
Thread - 0 does step 1. count = 5
Thread - 0 does step 2. count = 6
Thread - 1 does step 1. count = 7
Thread - 1 does step 2. count = 8
...
這時執行順序和顯示都正確了。這裏兩種synchronized關鍵字的兩種使用方法都能奏效,一種是synchronized方法,一種是synchronized塊。當add()方法被synchronized關鍵字修飾,成爲synchronized方法後,add方法就被鎖住了,其中所有代碼必須被一個線程執行完畢後才能被另一個線程執行。或者當r.add()語句被包括進synchronized塊中,如果參數是r,表示塊中的代碼必須由獲得了r對象鎖的線程執行,而一個線程如果不用wait方法,在運行完之前是不會釋放其所擁有的鎖的。這就保證了塊中的代碼是由一個線程運行完畢後才交給另一個線程運行的。如果參數是this,表示塊中代碼必須由獲得了本對象鎖的線程執行。這裏的本對象就是usr這個線程對象本身,而因爲多個線程共享usr,而且獲得usr鎖的Thread線程執行完塊中代碼纔會釋放鎖,就保證了塊中代碼也是由一個線程運行完畢後才交給另一個線程運行的。
從上面的分析可以看出,synchronized塊必須至少鎖住一個完整的對象,該對象的所有方法,無論是同步還是非同步方法,都被鎖住了。而synchronized方法只會鎖住一個完整對象中的某個方法,不妨礙其他線程訪問未上鎖的方法。所以synchronized塊要求線程獲得對象鎖,而synchronized方法要求線程獲得方法鎖。

3.2 通過單個鎖和標誌位控制多個線程的運行順序

畢老師的生產者消費者代碼很好地解釋了線程通過wait方法釋放鎖進入等待,通過notify方法喚醒等待的線程使其從新得到鎖從而運行來控制生產線程和消費線程輪流運行的思路。
下面我們來考慮一種更復雜的情況,如何要求三個線程A, B, C按照ABCABCA的順序輪流運行?按照視頻的思路,比較簡單的情況是設置一個標誌位,不過不是boolean類型,而是一個字符串,用來標記下一個應當運行的線程:
public class Test
{
    public static void main(String[] args)
    {
        Tag tag = new Tag();
        new A(tag).start();
        new B(tag).start();
        new C(tag).start();
    }
}

class Tag
{
    String next = new String("A");
}

class A extends Thread 
{
    Tag tag = null;

    A(Tag tag)   
    {
        this.tag = tag;
    }

    @Override
        public void run()
        {
            for(int i = 0; i < 5; i++) 
            {
                synchronized(tag)
                {
                    //wait()一定要寫在while循環裏,否則會出現假喚醒的狀況,即滿足wait條件時線程繼續運行,因爲已經判斷過。
                    //while循環使得線程在繼續運行前一定再檢測一遍wait條件是否滿足
                    while(! tag.next.equals("A"))
                    {
                        try
                        {
                            tag.wait();
                        }
                        catch(Exception e){}
                    }
                    System.out.println("A");
                    tag.next = "B";
                    tag.notifyAll();
                }
            }
        }
}

class B extends Thread 
{
    Tag tag = null;

    B(Tag tag)   
    {
        this.tag = tag;
    }

    @Override
        public void run()
        {
            for(int i = 0; i < 5; i++) 
            {
                synchronized(tag)
                {
                    while(! tag.next.equals("B"))
                    {
                        try
                        {
                            tag.wait();
                        }
                        catch(Exception e){}
                    }
                    System.out.println("B");
                    tag.next = "C";
                    tag.notifyAll();
                }
            }
        }
}

class C extends Thread 
{
    Tag tag = null;

    C(Tag tag)   
    {
        this.tag = tag;
    }

    @Override
        public void run()
        {
            for(int i = 0; i < 5; i++) 
            {
                synchronized(tag)
                {
                    while(! tag.next.equals("C"))
                    {
                        try
                        {
                            tag.wait();
                        }
                        catch(Exception e){}
                    }
                    System.out.println("C");
                    tag.next = "A";
                    tag.notifyAll();
                }
            }
        }
}
輸出:
A
B
C
A
B
C
A
...
如果被選中運行的線程不是符合順序的,則調用wait方法放棄鎖並進入等待隊列,等其它未在等待且順序正確的線程執行完後將其喚醒,再試試運氣是否符合順序可以完整運行。

3.3 通過多個鎖控制多個線程的運行順序

上面的例子裏線程依靠一個鎖和標誌位來決定運行順序。然而在只有一個鎖卻有3個線程的情況下,如果A線程進入wait放棄了鎖,則獲得鎖的線程可能是C(如果B也處於wait狀態),也可能是B、C中的一個(如果B不在wait狀態)。也就是說單靠一把鎖不能確定下一個線程是B還是C,故添加了標誌位。這種方法的本質是試錯,如果正確則運行,錯誤則阻塞。獲得鎖的線程不一定是正確的。想象一下,對於3個線程,僅僅通過獲得鎖能不能確定一個線程是正確的呢?答案是可以的,不過它要同時獲得兩把鎖:
public class Test
{
    public static Object a = new Object();
    public static Object b = new Object();
    public static Object c = new Object();

    public static void main(String[] args) throws Exception
    {
		A tA = new A();
		B tB = new B();
		C tC = new C();

		tA.start();
       
        Thread.sleep(1);

		tB.start();

        Thread.sleep(1);

		tC.start();
    }
}

class A extends Thread    //A線程要同時獲得a,b兩把鎖才能運行
{
    public void run() 
    {
        for (int i = 0; i < 5; i++) 
        {
            try 
            {
                synchronized (Test.a) 
                {
                    synchronized (Test.b) 
                    {
                        System.out.println("A");
                        Test.b.notify();        
                    }
                    if (i < 4) 
                    {
                        Test.a.wait();
                    }
                }
            } 
            catch (Exception e){} 
        }
    }
}

class B extends Thread    //B線程要同時獲得b,c兩把鎖才能運行
{
    public void run() 
    {
        for (int i = 0; i < 5; i++) 
        {
            try 
            {
                synchronized (Test.b) 
                {
                    synchronized (Test.c) 
                    {
                        System.out.println("B");
                        Test.c.notify();
                    }
                    if (i < 4) 
                    {
                        Test.b.wait();
                    }
                }
            } 
            catch (Exception e){} 
        }
    }
}

class C extends Thread    //C線程要同時獲得c,a兩把鎖才能運行 
{
    public void run() 
    {
        for (int i = 0; i < 5; i++) 
        {
            try 
            {
                synchronized (Test.c) 
                {
                    synchronized (Test.a) 
                    {
                        System.out.println("C");
                        Test.a.notify();
                    }
                    if (i < 4) 
                    {
                        Test.c.wait();
                    }
                }
            } 
            catch (Exception e) {}
        }
    }
}
輸出:
A
B
C
A
B
C
A
...
當一個線程獲得三把鎖中指定的兩把,就可以運行。A線程需獲得a, b鎖。B線程需獲得b, c鎖。C線程需獲得c, a鎖。三個線程中A最先啓動,B其次,C最後啓動,這樣確保了從A線程開始運行。

4. 總結

多線程算是java基礎中最難的一部分了。考慮多線程程序時要能想象多個線程同時運行,還要容忍它們彼此間的不確定性。唯一的辦法就是多寫代碼做試驗,並且熟悉一些定時啓動,停止線程的方法以便在一定程度上定量分析。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章