前言:雖然工作了三年,但是幾乎沒有使用到多線程之類的內(nèi)容。這其實(shí)是工作與學(xué)習(xí)的矛盾。我們在公司上班,很多時(shí)候都只是在處理業(yè)務(wù)代碼,很少接觸底層技術(shù)。
可是你不可能一輩子都寫業(yè)務(wù)代碼,而且跳槽之后新單位很可能有更高的技術(shù)要求。除了干巴巴地翻書,我們可以通過兩個(gè)方式來解決這個(gè)問題:一是做業(yè)余項(xiàng)目,例如在github上傳自己的demo,可以實(shí)際使用;二是把自己的學(xué)習(xí)心得寫成博客,跟同行們互相交流。
3.1 線程的初窺門徑
我們在之前的文章里提到的程序其實(shí)都是單線程程序,也就說啟動(dòng)的程序從main()程序進(jìn)入點(diǎn)開始到結(jié)束只有一個(gè)流程。然而,有時(shí)候我們設(shè)計(jì)的程序需要有多個(gè)流程,也就是所謂的多線程(Multi-thread)程序。
我們可以通過一個(gè)龜兔賽跑的游戲開始學(xué)習(xí)單線程和多線程的區(qū)別。烏龜和兔子賽跑,起點(diǎn)到終點(diǎn)為10米,每經(jīng)過一秒,烏龜會(huì)前進(jìn)0.1米;兔子可能前進(jìn)1米或睡覺。如果使用單線程,我們可以這樣設(shè)計(jì):
1 /** 2 * 線程實(shí)驗(yàn)用例 3 */ 4 public class TortoiseHareRace { 5 public static void main(String[] args) throws InterruptedException { 6 float totalLength = 10; 7 float tortoiseLength = 0; 8 float hareLength = 0; 9 System.out.println("龜兔賽跑大賽,開始!"); 10 while(tortoiseLength < totalLength && hareLength < totalLength) { 11 Thread.sleep(1000); 12 tortoiseLength += 0.1; 13 System.out.println("烏龜跑了 " + tortoiseLength + " 米..."); 14 boolean isHareSleep = Math.random()*10 < 9; 15 if(isHareSleep) { 16 System.out.println("兔子睡著了zzzz"); 17 } else { 18 hareLength ++; 19 System.out.println("兔子跑了 " + hareLength + " 米..."); 20 } 21 } 22 } 23 }
這個(gè)程序里面只有一個(gè)流程,就是從main()方法開始到結(jié)束的流程。要讓目前流程暫停指定時(shí)間,可以使用java.lang.Thread的靜態(tài)sleep()方法,單位是毫秒。調(diào)用這個(gè)方法必須處理java.lang.InterruptedException,在這里直接在main()中聲明 throws,由JVM來處理此異常。
每次暫停一秒后,tortoiseLength遞增0.1,表示烏龜向前爬了0.1米,兔子則可能有百分之90的可能性睡覺。如果不睡覺,hareLength會(huì)遞增1,表示兔子向前蹦了1米。只要他們?nèi)我庖粋€(gè)跑了10米,表示到達(dá)終點(diǎn),分出勝負(fù)比賽結(jié)束。
由于程序只有一個(gè)流程,所以每次只能先讓烏龜先跑再讓兔子跑,這就很不公平(如果倒過來,也是不公平)。如果程序里再有兩個(gè)流程,一個(gè)是烏龜在跑,一個(gè)是兔子在跑,是不是程序邏輯會(huì)更加合理和清晰呢?
在Java中,如果想在main()以外獨(dú)立設(shè)計(jì)流程,可以實(shí)現(xiàn)java.lang.Runnable接口,流程的進(jìn)入點(diǎn)是在run()方法里面。例如,我們可以這樣設(shè)計(jì)烏龜?shù)牧鞒蹋?nbsp;
1 /** 2 * 烏龜線程 3 */ 4 public class Tortoise implements Runnable{ 5 private float totalLength; 6 private float length; 7 8 public Tortoise(float totalLength) { 9 this.totalLength = totalLength; 10 } 11 12 @Override 13 public void run() { 14 try { 15 while(length < totalLength) { 16 Thread.sleep(1000); 17 length += 0.1; 18 System.out.println("烏龜跑了 " + length + " 米..."); 19 } 20 } catch (InterruptedException ex) { 21 throw new RuntimeException(ex); 22 } 23 } 24 }
在Tortoise類中,烏龜?shù)牧鞒虝?huì)從run()開始。在這個(gè)流程里,代碼只需要專心負(fù)責(zé)烏龜每秒爬0.1米就可以了,不用夾雜兔子的動(dòng)作。同樣地,我們可以類似地設(shè)計(jì)兔子的流程:
1 /** 2 * 兔子線程 3 */ 4 public class Hare implements Runnable{ 5 private float totalLength; 6 private float length; 7 8 public Hare(int totalLength) { 9 this.totalLength = totalLength; 10 } 11 12 @Override 13 public void run () { 14 try { 15 while(length < totalLength) { 16 Thread.sleep(1000); 17 boolean isHareSleep = Math.random()*10 < 9; 18 if(isHareSleep) { 19 System.out.println("兔子睡著了zzzz"); 20 } else { 21 length ++; 22 System.out.println("兔子跑了 " + length + " 米..."); 23 } 24 } 25 } catch (InterruptedException ex) { 26 throw new RuntimeException(ex); 27 } 28 } 29 }
在Java中,從main()方法開始的流程會(huì)由主線程(Main Thread)來跑。那么剛才設(shè)計(jì)的兔子線程和烏龜線程,應(yīng)該讓誰來執(zhí)行呢?我們可以通過創(chuàng)建Thread對象的方法來執(zhí)行Runnable對象定義的run()方法,例如:
1 /** 2 * 龜兔賽跑主線程 3 */ 4 public class TortoiseHare2 { 5 public static void main(String[] args) { 6 Tortoise tortoise = new Tortoise(10); 7 Hare hare = new Hare(10); 8 Thread tortoiseThread = new Thread(tortoise); 9 Thread hareThread = new Thread(hare); 10 tortoiseThread.start(); 11 hareThread.start(); 12 } 13 }
要記住,在創(chuàng)建Thread對象之前,必須確保實(shí)例化的參數(shù)實(shí)現(xiàn)了Runnable接口,并且要在之后執(zhí)行start()方法,以下是其中一個(gè)執(zhí)行結(jié)果:
我們都在學(xué)校里學(xué)習(xí)過線程的概念,但這都只是邏輯上的認(rèn)識(shí),并不代表我們已經(jīng)完全掌握其實(shí)際內(nèi)容。通過做實(shí)驗(yàn)的方式,我們寫了代碼,引入了耳熟能詳?shù)膬和适?,加深認(rèn)識(shí)的同時(shí)也增強(qiáng)了記憶。
3.2繼承父類 or 實(shí)現(xiàn)接口
從抽象觀點(diǎn)與開發(fā)者的角度來看,JVM是臺(tái)虛擬計(jì)算機(jī),只安裝了一顆被稱為主線程的CPU,可執(zhí)行main()定義的執(zhí)行流程。如果想要為JVM加裝CPU,就是創(chuàng)建Thread實(shí)例,要啟動(dòng)額外CPU就是調(diào)用Thread實(shí)例的start()方法。額外CPU執(zhí)行流程的進(jìn)入點(diǎn),可以定義在Runnable接口的run()方法里面。
當(dāng)然了,實(shí)際上JVM啟動(dòng)之后并不只有一個(gè)主線程,還有垃圾收集、內(nèi)存管理等線程,不過這是底層機(jī)制。我們暫時(shí)不用理會(huì)。
除了將流程定義在Runnable的run()方法之外,還有另外一個(gè)使用多線程的方法,就是繼承Thread類,重新定義run()方法。例如我們可以這樣的改寫烏龜流程:
1 /** 2 * 烏龜?shù)木€程 3 */ 4 public class Tortoise extends Thread{ //繼承Thread類 5 private float totalLength; 6 private float length; 7 8 public Tortoise(float totalLength) { 9 this.totalLength = totalLength; 10 } 11 12 @Override 13 public void run() { 14 try { 15 while(length < totalLength) { 16 Thread.sleep(1000); 17 length += 0.1; 18 System.out.println("烏龜跑了 " + length + " 米..."); 19 } 20 } catch (InterruptedException ex) { 21 throw new RuntimeException(ex); 22 } 23 } 24 }
由于大部分代碼都一樣,所以就不再把整個(gè)demo重新貼出來一遍了。但還是建議各位朋友,尤其是初學(xué)者們,千萬要自己動(dòng)手改一改,看看兩種寫法到底有什么不同。
在Java中,任何線程可執(zhí)行的流程都要定義在Runnable的run()方法。事實(shí)上,Thread類本身也實(shí)現(xiàn)了Runnable接口,從JDK源碼當(dāng)中我們可以看到:
1 /** 2 * If this thread was constructed using a separate 3 * <code>Runnable</code> run object, then that 4 * <code>Runnable</code> object's <code>run</code> method is called; 5 * otherwise, this method does nothing and returns. 6 * <p> 7 * Subclasses of <code>Thread</code> should override this method. 8 * 9 * @see #start() 10 * @see #stop() 11 * @see #Thread(ThreadGroup, Runnable, String) 12 */ 13 @Override 14 public void run() { 15 if (target != null) { 16 target.run(); 17 } 18 }
了解完兩種多線程的實(shí)現(xiàn)方法之后,我們會(huì)有一個(gè)疑問:到底是實(shí)現(xiàn)Runnable()好呢,還是直接繼承Thread類更好?這就要根據(jù)具體情況具體分析了。
實(shí)現(xiàn)接口的好處是靈活,這個(gè)類還可以繼承其他類。如果你想要直接利用Thread中定義的某些特定方法,那就可以考慮直接繼承Thread類。要想做到靈活應(yīng)用,這就要求開發(fā)者足夠了解這些接口和類。沒有別的捷徑,就是多看API說明文檔和多閱讀JDK源代碼。
3.3 線程的生命周期
有一個(gè)古老的謎語,說是有一種動(dòng)物,早上是四條腿,中午就成了兩條腿,到了傍晚卻是三條腿。我們大家都知道,謎底就是人。
從小孩到長大成人,再到衰老死亡,這就是我們?nèi)祟惖纳芷?。線程也是一樣,從創(chuàng)建到開始,再到最后的結(jié)束。理解人類的生命周期,有助于我們更好地認(rèn)識(shí)自己,規(guī)劃自己的人生。熟悉線程的生命周期,則會(huì)對我們使用線程有莫大的幫助。
線程的生命周期相當(dāng)復(fù)雜,我們將會(huì)從最簡單的開始講起。
3.3.1 Daemon線程
Daemon,本義是守護(hù)神的意思,在希臘神話里是半人半神的精靈。在計(jì)算機(jī)領(lǐng)域,daemon已經(jīng)是一個(gè)專業(yè)術(shù)語,我們可以理解成守護(hù)進(jìn)程或守護(hù)線程。
大人物出現(xiàn)活動(dòng),如果他不離場,安保人員是不可能會(huì)撤離的。Java中的守護(hù)進(jìn)程也一樣,如果主線程中啟動(dòng)了額外線程,默認(rèn)會(huì)等待被啟動(dòng)的所有線程都執(zhí)行完run()方法之后才中止JVM。如果一個(gè)Thread被標(biāo)識(shí)為Daemon線程,那么在所有的非Daemon線程都結(jié)束時(shí),它也會(huì)被JVM自動(dòng)終止。
從main()方法開始的就是一個(gè)非Daemon線程。我們可以使用setDaemon()方法來設(shè)定一個(gè)線程是否屬于Daemon線程。下面是一個(gè)非常簡單的demo,實(shí)驗(yàn)的時(shí)候你可以試著取消setDaemon()那行代碼的注釋,看看會(huì)有什么不同,為什么:
1 /** 2 * Daemon實(shí)驗(yàn)用例 3 */ 4 public class DaemonDemo { 5 public static void main(String[] args) { 6 Thread thread = new Thread() { 7 public void run() { 8 while(true) { 9 System.out.println("run..."); 10 } 11 } 12 }; 13 //thread.setDaemon(true); //把線程設(shè)定為Daemon線程 14 thread.start(); 15 } 16 }
如果沒有使用setDaemon()方法設(shè)定為true,上面這個(gè)程序就會(huì)不會(huì)輸出“run...”,永遠(yuǎn)不會(huì)自動(dòng)終止??墒侨绻坏⑵湓O(shè)置為Daemon線程,在其啟動(dòng)的一瞬間就會(huì)被終止,甚至連一句輸出都沒有。
有趣的是,默認(rèn)所有從Daemon線程產(chǎn)生的線程也是Daemon線程。其實(shí)這也不難理解,因?yàn)榛旧蟻碚f由一個(gè)后臺(tái)服務(wù)線程衍生出來的線程,也應(yīng)該是為了在后臺(tái)服務(wù)而產(chǎn)生的,所以在產(chǎn)生它的線程停止時(shí),也應(yīng)該一并跟著停止。
3.3.2 線程狀態(tài)圖
在看過最簡單的線程生命周期之后,我們再來看復(fù)雜一點(diǎn)的。調(diào)用Thread對象的start()方法之后,該線程的基本狀態(tài)基本可以分為三種:可執(zhí)行(Runnable)、別阻斷(Blocked)、執(zhí)行中(Running)。
三種狀態(tài)之間的轉(zhuǎn)移一兩句話說不清楚,我們可以借助狀態(tài)圖來幫助我們理解:
我們可以一邊對照著上面這幅圖,一邊看接下來的講述。實(shí)例化Thread并執(zhí)行start()方法之后,線程就會(huì)進(jìn)入Runnable狀態(tài)。這個(gè)時(shí)候線程并沒有真正開始執(zhí)行run()方法,必須等待排班器(Scheduler)排入CPU執(zhí)行,線程才會(huì)跑run()方法,這就進(jìn)入了Running狀態(tài)。
線程有優(yōu)先權(quán)重,可以使用Thread的setPriority()方法來設(shè)定優(yōu)先權(quán)。設(shè)定的范圍是1(Thread.MIN_PRIORITY)到 10(Thread.MAX_+PRIORITY),默認(rèn)是5(Thread.NORM_PRIORITY)。數(shù)字越大優(yōu)先權(quán)越高,調(diào)度器越有限排入CPU,也就是越優(yōu)先進(jìn)入Running狀態(tài)。如果優(yōu)先權(quán)相同,則輪流執(zhí)行(Round-robin)。
有幾種情況會(huì)讓線程進(jìn)入Blocked狀態(tài),例如前面調(diào)用過的Thread.sleep()方法,又例如等待輸入輸出等等。我們之所以要運(yùn)用多線程,就是要讓當(dāng)前線程進(jìn)入Blocked狀態(tài)的時(shí)候讓另一線程排入CPU執(zhí)行,即進(jìn)入Running狀態(tài),避免CPU無謂的空閑。這就是我們常用的,用來改進(jìn)性能的方式之一。
下面我們可以用一個(gè)下載網(wǎng)頁的demo來進(jìn)行測試,看看不使用多線程時(shí)需要花費(fèi)多長時(shí)間:
1 import java.net.URL; 2 import java.io.*; 3 import java.util.Date; 4 5 /** 6 * 單線程實(shí)驗(yàn)用例 7 */ 8 public class Download { 9 public static void main(String[] args) throws Exception { 10 URL[] urls = { 11 new URL("http://www.cnblogs.com/levenyes/p/7117559.html"), 12 new URL("http://www.cnblogs.com/levenyes/p/7120267.html"), 13 new URL("http://www.cnblogs.com/levenyes/p/7145214.html"), 14 new URL("http://www.cnblogs.com/levenyes/p/7163843.html") 15 }; 16 17 String[] fileNames = { 18 "file1.html", 19 "file2.html", 20 "file3.html", 21 "file4.html" 22 }; 23 Date begin = new Date(); 24 for(int i = 0 ; i < urls.length; i++) { 25 dump(urls[i].openStream(), new FileOutputStream(fileNames[i])); 26 } 27 Date end = new Date(); 28 System.out.println(end.getTime() - begin.getTime()); 29 } 30 31 private static void dump(InputStream src, OutputStream dest) throws IOException { 32 try (InputStream input = src; OutputStream output = dest) { 33 byte[] data = new byte[1024]; 34 int length = -1; 35 while((length = input.read(data)) != -1) { 36 output.write(data, 0, length); 37 } 38 } 39 } 40 }
每一次for循環(huán)時(shí),會(huì)先開啟網(wǎng)絡(luò)鏈接、進(jìn)行HTTP請求,然后再進(jìn)行文檔寫入等等。在等待網(wǎng)絡(luò)鏈接、HTTP協(xié)議時(shí)很耗時(shí),這就意味著進(jìn)入Blocked的時(shí)間相當(dāng)長。因?yàn)槭菃尉€程,所以必須等第一個(gè)網(wǎng)頁下載完了之后才能下載第二個(gè),如此類推。
因?yàn)槭艿骄W(wǎng)絡(luò)狀態(tài)不穩(wěn)定的影響,有的時(shí)候可能會(huì)比較快,有的時(shí)候會(huì)比較慢。經(jīng)過多次測試,大概的耗時(shí)為400毫秒。
如果我們可以在下載第一個(gè)網(wǎng)頁遇到等待時(shí)就開始下載其他網(wǎng)頁,這樣會(huì)不會(huì)讓速度加快許多呢?例如下面這個(gè)demo:
1 import java.net.URL; 2 import java.io.*; 3 import java.util.Date; 4 5 /** 6 * 多線程實(shí)驗(yàn)用例 7 */ 8 public class Download { 9 public static void main(String[] args) throws Exception { 10 final URL[] urls = { 11 new URL("http://www.cnblogs.com/levenyes/p/7117559.html"), 12 new URL("http://www.cnblogs.com/levenyes/p/7120267.html"), 13 new URL("http://www.cnblogs.com/levenyes/p/7145214.html"), 14 new URL("http://www.cnblogs.com/levenyes/p/7163843.html") 15 }; 16 17 final String[] fileNames = { http://www.cnblogs.com/levenyes/p/7211461.html