前言:虽然工作了三年,但是几乎没有使用到多线程之类的内容。这其实是工作与学习的矛盾。我们在公司上班,很多时候都只是在处理业务代码,很少接触底层技术。
可是你不可能一辈子都写业务代码,而且跳槽之后新单位很可能有更高的技术要求。除了干巴巴地翻书,我们可以通过两个方式来解决这个问题:一是做业余项目,例如在github上传自己的demo,可以实际使用;二是把自己的学习心得写成博客,跟同行们互相交流。
3.1 线程的初窥门径
我们在之前的文章里提到的程序其实都是单线程程序,也就说启动的程序从main()程序进入点开始到结束只有一个流程。然而,有时候我们设计的程序需要有多个流程,也就是所谓的多线程(Multi-thread)程序。
我们可以通过一个龟兔赛跑的游戏开始学习单线程和多线程的区别。乌龟和兔子赛跑,起点到终点为10米,每经过一秒,乌龟会前进0.1米;兔子可能前进1米或睡觉。如果使用单线程,我们可以这样设计:
1 /** 2 * 线程实验用例 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 }
这个程序里面只有一个流程,就是从main()方法开始到结束的流程。要让目前流程暂停指定时间,可以使用java.lang.Thread的静态sleep()方法,单位是毫秒。调用这个方法必须处理java.lang.InterruptedException,在这里直接在main()中声明 throws,由JVM来处理此异常。
每次暂停一秒后,tortoiseLength递增0.1,表示乌龟向前爬了0.1米,兔子则可能有百分之90的可能性睡觉。如果不睡觉,hareLength会递增1,表示兔子向前蹦了1米。只要他们任意一个跑了10米,表示到达终点,分出胜负比赛结束。
由于程序只有一个流程,所以每次只能先让乌龟先跑再让兔子跑,这就很不公平(如果倒过来,也是不公平)。如果程序里再有两个流程,一个是乌龟在跑,一个是兔子在跑,是不是程序逻辑会更加合理和清晰呢?
在Java中,如果想在main()以外独立设计流程,可以实现java.lang.Runnable接口,流程的进入点是在run()方法里面。例如,我们可以这样设计乌龟的流程:
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类中,乌龟的流程会从run()开始。在这个流程里,代码只需要专心负责乌龟每秒爬0.1米就可以了,不用夹杂兔子的动作。同样地,我们可以类似地设计兔子的流程:
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()方法开始的流程会由主线程(Main Thread)来跑。那么刚才设计的兔子线程和乌龟线程,应该让谁来执行呢?我们可以通过创建Thread对象的方法来执行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 }
要记住,在创建Thread对象之前,必须确保实例化的参数实现了Runnable接口,并且要在之后执行start()方法,以下是其中一个执行结果:
我们都在学校里学习过线程的概念,但这都只是逻辑上的认识,并不代表我们已经完全掌握其实际内容。通过做实验的方式,我们写了代码,引入了耳熟能详的儿童故事,加深认识的同时也增强了记忆。
3.2继承父类 or 实现接口
从抽象观点与开发者的角度来看,JVM是台虚拟计算机,只安装了一颗被称为主线程的CPU,可执行main()定义的执行流程。如果想要为JVM加装CPU,就是创建Thread实例,要启动额外CPU就是调用Thread实例的start()方法。额外CPU执行流程的进入点,可以定义在Runnable接口的run()方法里面。
当然了,实际上JVM启动之后并不只有一个主线程,还有垃圾收集、内存管理等线程,不过这是底层机制。我们暂时不用理会。
除了将流程定义在Runnable的run()方法之外,还有另外一个使用多线程的方法,就是继承Thread类,重新定义run()方法。例如我们可以这样的改写乌龟流程:
1 /** 2 * 乌龟的线程 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 }
由于大部分代码都一样,所以就不再把整个demo重新贴出来一遍了。但还是建议各位朋友,尤其是初学者们,千万要自己动手改一改,看看两种写法到底有什么不同。
在Java中,任何线程可执行的流程都要定义在Runnable的run()方法。事实上,Thread类本身也实现了Runnable接口,从JDK源码当中我们可以看到:
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 }
了解完两种多线程的实现方法之后,我们会有一个疑问:到底是实现Runnable()好呢,还是直接继承Thread类更好?这就要根据具体情况具体分析了。
实现接口的好处是灵活,这个类还可以继承其他类。如果你想要直接利用Thread中定义的某些特定方法,那就可以考虑直接继承Thread类。要想做到灵活应用,这就要求开发者足够了解这些接口和类。没有别的捷径,就是多看API说明文档和多阅读JDK源代码。
3.3 线程的生命周期
有一个古老的谜语,说是有一种动物,早上是四条腿,中午就成了两条腿,到了傍晚却是三条腿。我们大家都知道,谜底就是人。
从小孩到长大成人,再到衰老死亡,这就是我们人类的生命周期。线程也是一样,从创建到开始,再到最后的结束。理解人类的生命周期,有助于我们更好地认识自己,规划自己的人生。熟悉线程的生命周期,则会对我们使用线程有莫大的帮助。
线程的生命周期相当复杂,我们将会从最简单的开始讲起。
3.3.1 Daemon线程
Daemon,本义是守护神的意思,在希腊神话里是半人半神的精灵。在计算机领域,daemon已经是一个专业术语,我们可以理解成守护进程或守护线程。
大人物出现活动,如果他不离场,安保人员是不可能会撤离的。Java中的守护进程也一样,如果主线程中启动了额外线程,默认会等待被启动的所有线程都执行完run()方法之后才中止JVM。如果一个Thread被标识为Daemon线程,那么在所有的非Daemon线程都结束时,它也会被JVM自动终止。
从main()方法开始的就是一个非Daemon线程。我们可以使用setDaemon()方法来设定一个线程是否属于Daemon线程。下面是一个非常简单的demo,实验的时候你可以试着取消setDaemon()那行代码的注释,看看会有什么不同,为什么:
1 /** 2 * Daemon实验用例 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); //把线程设定为Daemon线程 14 thread.start(); 15 } 16 }
如果没有使用setDaemon()方法设定为true,上面这个程序就会不会输出“run...”,永远不会自动终止。可是如果一旦将其设置为Daemon线程,在其启动的一瞬间就会被终止,甚至连一句输出都没有。
有趣的是,默认所有从Daemon线程产生的线程也是Daemon线程。其实这也不难理解,因为基本上来说由一个后台服务线程衍生出来的线程,也应该是为了在后台服务而产生的,所以在产生它的线程停止时,也应该一并跟着停止。
3.3.2 线程状态图
在看过最简单的线程生命周期之后,我们再来看复杂一点的。调用Thread对象的start()方法之后,该线程的基本状态基本可以分为三种:可执行(Runnable)、别阻断(Blocked)、执行中(Running)。
三种状态之间的转移一两句话说不清楚,我们可以借助状态图来帮助我们理解:
我们可以一边对照着上面这幅图,一边看接下来的讲述。实例化Thread并执行start()方法之后,线程就会进入Runnable状态。这个时候线程并没有真正开始执行run()方法,必须等待排班器(Scheduler)排入CPU执行,线程才会跑run()方法,这就进入了Running状态。
线程有优先权重,可以使用Thread的setPriority()方法来设定优先权。设定的范围是1(Thread.MIN_PRIORITY)到 10(Thread.MAX_+PRIORITY),默认是5(Thread.NORM_PRIORITY)。数字越大优先权越高,调度器越有限排入CPU,也就是越优先进入Running状态。如果优先权相同,则轮流执行(Round-robin)。
有几种情况会让线程进入Blocked状态,例如前面调用过的Thread.sleep()方法,又例如等待输入输出等等。我们之所以要运用多线程,就是要让当前线程进入Blocked状态的时候让另一线程排入CPU执行,即进入Running状态,避免CPU无谓的空闲。这就是我们常用的,用来改进性能的方式之一。
下面我们可以用一个下载网页的demo来进行测试,看看不使用多线程时需要花费多长时间:
1 import java.net.URL; 2 import java.io.*; 3 import java.util.Date; 4 5 /** 6 * 单线程实验用例 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循环时,会先开启网络链接、进行HTTP请求,然后再进行文档写入等等。在等待网络链接、HTTP协议时很耗时,这就意味着进入Blocked的时间相当长。因为是单线程,所以必须等第一个网页下载完了之后才能下载第二个,如此类推。
因为受到网络状态不稳定的影响,有的时候可能会比较快,有的时候会比较慢。经过多次测试,大概的耗时为400毫秒。
如果我们可以在下载第一个网页遇到等待时就开始下载其他网页,这样会不会让速度加快许多呢?例如下面这个demo:
1 import java.net.URL; 2 import java.io.*; 3 import java.util.Date; 4 5 /** 6 * 多线程实验用例 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