网络蚂蚁、FlashGet、迅雷等支持HTTP协议的下载软件无一例外地使用了多线程下载技术。比起单线程下载,多线程下载在同一时间段内发出多个下载请求,每个下载请求负责下载一段内容,充分地利用了网络带宽。
当然多线程下载并非线程数越多越好。试想,一个极端的情况:一个尺寸为1 024个字节的远程文件,动用1 024个线程来下载,每个线程平均只下载一个字节,创建线程的代价和对自身网络出口造成的堵塞远远大于分工下载带来的好处。因此,多线程下载存在一个权衡的问题。一般来说,需要事先根据待下载的远程文件的尺寸来决定启用多少个线程,如果文件很小,则意味着使用单线程下载即可。而且,下载软件允许创建的线程数一般是设置上限的,例如即使下载一个超大文件,也不能开启过多的线程,毕竟创建下载线程是需要耗费客户端资源的,并且线程之间存在着竞争网络带宽的关系。总之,下载线程数往往是在待下载的远程文件尺寸、每个线程分担的字节数任务、线程数三者之间权衡的结果。
实战多线程下载,有几个技术难题有待攻克:
— 如何获取远程文件的尺寸,这关系到开启多少个下载线程。本节例程采用比较简单的线程数决策策略:固定每个线程分担的字节数任务,根据远程文件尺寸来决定需要开启的下载线程,也就是不考虑下载线程数过多带来的负面影响。因此,获取远程文件的尺寸就成为了很关键的一个步骤。
— 如何实现分工下载,即每个线程只下载远程文件的一段。这是多线程HTTP下载的核心技术。
— 如何存储、组织各个线程下载得到的文件碎片,最后将其拼成一个完整的文件。
首先来解决第一个问题:如何获取远程文件的尺寸。我们知道,在HTTP反馈报文的头(Header)部分有一些数据项,其中有一项便是Content-Length,表示的便是HTTP反馈报文的正文部分的字节数。我们经常以Post、Get等方式发起HTTP请求,实际上HTTP协议还支持以Head方式发出HTTP请求。Head方式发出的HTTP请求,表示仅需要HTTP服务器返回头部分、无须返回正文。解决了如何请求报文头的问题之后,在Java程序中如何读取HTTP反馈报文头呢?答案是:java.net.HttpURLConnection可以通过其getHeaderFieldKey(int n)和getHeaderField(int n)方法读取HTTP反馈报文头,其中getHeaderFieldKey(int n)方法返回的是第n个数据项的名称,getHeaderField(int n)方法返回的是第n个数据项的值。之所以只支持根据数据项编号来获取数据项值,是因为不同的HTTP服务器(如Apache和IIS)在返回HTTP反馈报文时,报文头中的数据项顺序是不一致的,而HTTP协议本身也并没有规定标准数据项的顺序。
接着来解决第二个问题:如何实现分工下载,即每个线程只下载文件的一段。HTTP请求报文头有一个不经常为人使用的数据项:RANGE,它代表的是下载的字节范围,如0~1 024代表从文件开始处下载到第1 024个字节处。一般情况下,Web浏览器在下载远程文件时都不使用这个数据项,这就代表无论多大的文件,浏览器都试图用一次HTTP请求来下载。请看下面使用了RANGE数据项的HTTP请求报文头:
GET /webform.html HTTP/1.1
RANGE: bytes=0-860
User-Agent: Java/1.6.0_01
Host: 127.0.0.1:88
Accept: text/html, image/gif, image/jpeg, *; q=.2, */*; q=.2
Connection: keep-alive
来看最后一个问题如何解决:如何存储、组织各个线程下载得到的文件碎片,最后将其拼成一个完整的文件。如果读者们阅读过本书的“文件系统”一章,应该已经知道答案了,那便是使用随机文件存取技术。随机文件存取允许在文件内任意移动指针,从而帮助我们实现一边下载、一边填充文件。当全部线程停止工作时,我们便最终得到了一个完整的文件拷贝。
以下是多线程下载的主程序的源代码。
代码清单7‑16 多线程下载的例程——DownloadManager
1. import java.net.URL;
2. import java.net.HttpURLConnection;
3. import java.io.File;
4. import java.io.RandomAccessFile;
5. import java.io.IOException;
6.
7. public class DownloadManager
8. {
9. static final long unitSize=100*1024; //分配给每个下载线程的字节数
10.
11. public static void main(String[] args) throws IOException
12. {
13. if(args.length!=2)
14. {
15. System.out.println("Usage java DownloadManager URL local_file_name");
16. return;
17. }
18. DownloadManager downloadManager=new DownloadManager();
19. downloadManager.doDownload(args[0],args[1]);
20. }
21.
22. public void doDownload(String remoteFileUrl, String localFileName) throws IOException
23. {
24. long fileSize=this.getRemoteFileSize(remoteFileUrl);
25. this.createFile(localFileName,fileSize);
26. long threadCount=fileSize/unitSize;
27. System.out.println("共启动线程"+(fileSize%unitSize==0?threadCount:threadCount+1)+"个");
28. long offset=0;
29. if(fileSize<=unitSize) //如果远程文件尺寸小于等于unitSize
30. {
31. DownloadThread downloadThread=new DownloadThread(remoteFileUrl, localFileName, offset, fileSize);
32. downloadThread.start();
33. }
34. else //如果远程文件尺寸大于unitSize
35. {
36. for(int i=1;i<=threadCount;i++)
37. {
38. DownloadThread downloadThread=new DownloadThread(remoteFileUrl, localFileName, offset, unitSize);
39. downloadThread.start();
40. offset=offset+unitSize;
41. }
42. if( fileSize % unitSize != 0 ) //如果不能整除,则需要再创建一个线程下载剩余字节
43. {
44. DownloadThread downloadThread=new DownloadThread(remoteFileUrl, localFileName, offset, fileSize-unitSize*threadCount);
45. downloadThread.start();
46. }
47. }
48. }
49.
50. //获取远程文件尺寸
51. private long getRemoteFileSize(String remoteFileUrl) throws IOException
52. {
53. long result=0;
54. HttpURLConnection httpConnection = (HttpURLConnection) new URL(remoteFileUrl).openConnection();
55. httpConnection.setRequestMethod("HEAD");
56. for(int i=1;i<=10;i++)
57. {
58. if( httpConnection.getHeaderFieldKey(i).equalsIgnoreCase ("Content-Length") )
59. {
60. result=Long.parseLong( httpConnection.getHeaderField(i) );
61. break;
62. }
63. }
64. return result;
65. }
66.
67. //创建指定大小的文件
68. private void createFile(String fileName, long fileSize) throws IOException
69. {
70. File newFile=new File(fileName);
71. RandomAccessFile raf=new RandomAccessFile(newFile,"rw");
72. raf.setLength(fileSize);
73. raf.close();
74. }
75. }
DownloadManager类本身不负责下载,而是负责获取远程文件的尺寸,然后决定开启多少个下载线程,进而为每一个下载线程分配下载任务:下载的偏移量、下载字节数。
— 利用getRemoteFileSize()方法获取远程文件的尺寸,获取的根据正是前面所讲的Content-Length数据项。这里使用了一个技巧,以Head方式发出的HTTP请求得到的HTTP反馈报文,其数据项的先后顺序,以及数据项的数量都是不可知的,但是一般的HTTP服务器返回的标准数据项不会超出十项,因此这里只在前面十个数据项中寻找Content-Length数据项。
— 本例程采取固定每线程固定字节数任务的策略来决定开启多少个线程。每个线程分配到的任务为最多为10K字节。
— 在给每个线程分配任务时,需要考虑到3种情况:
Ø 待下载的远程文件的尺寸较小(不超过10KB),只需开启一个线程。
Ø 待下载的远程文件的尺寸是10K的整数倍,那么开启的线程数为“远程文件的尺寸/10KB”。
Ø 待下载的远程文件的尺寸不能被10K整除,那么开启的线程数为“远程文件的尺寸/10KB+1”。
负责实现分段请求的DownloadThread线程的源代码如下。
代码清单7‑17 多线程下载的例程——DownloadThread
1. import java.net.URL;
2. import java.net.HttpURLConnection;
3. import java.io.BufferedInputStream;
4. import java.io.File;
5. import java.io.RandomAccessFile;
6. import java.io.IOException;
7.
8. public class DownloadThread extends Thread
9. {
10. private String url=null; //待下载的文件
11. private String file=null; //本地存储路径
12. private long offset=0; //偏移量
13. private long length=0; //分配给本线程的下载字节数
14.
15. public DownloadThread(String url, String file, long offset, long length)
16. {
17. this.url=url;
18. this.file=file;
19. this.offset=offset;
20. this.length=length;
21. System.out.println("偏移量="+offset+";字节数="+length);
22. }
23.
24. public void run()
25. {
26. try
27. {
28. HttpURLConnection httpConnection = (HttpURLConnection) new URL(this.url).openConnection();
29. httpConnection.setRequestMethod("GET");
30. httpConnection.setRequestProperty("RANGE","bytes="+this.offset+"-"+(this. offset+this.length-1));
31. BufferedInputStream bis=new BufferedInputStream(httpConnection.getInputStream());
32. byte[] buff=new byte[1024];
33. int bytesRead;
34. while(-1 != (bytesRead = bis.read(buff, 0, buff.length)))
35. {
36. this.writeFile( this.file, this.offset, buff, bytesRead );
37. this.offset=this.offset+bytesRead;
38. }
39. }
40. catch(IOException ioe)
41. {
42. ioe.printStackTrace();
43. }
44. }
45.
46. //将字节数组以随机存取方式写入文件
47. //fileName是被写入的文件
48. //offset代表写入文件的位置偏移量
49. //bytes是待写入的字节数组
50. //realLength是实际需要写入的字节数(realLength<=bytes.length)
51. private void writeFile(String fileName, long offset, byte[] bytes, int realLength) throws IOException
52. {
53. File newFile=new File(fileName);
54. RandomAccessFile raf=new RandomAccessFile(newFile,"rw");
55. raf.seek(offset);
56. raf.write( bytes,0,realLength );
57. raf.close();
58. }
59. }
对于DownloadThread线程,需要注意的是第32~38行,这里采取的是分段读取(每次只读取1 024个字节)、分段写入文件的做法。应该说,这是一种值得推荐的读取输入流的方式,因为开辟的读取缓存永远只需要1 024个字节,比起从输入流中全部读取完毕再一次性写入文件的做法,更有利于避免内存泄露。
以下载http://www.ietf.org/rfc/rfc2068.txt文件(该文件是HTTP 1.1协议的RFC文档,RFC是指Request For Comments)为例,运行例程的效果如下:
java DownloadManager
Usage java DownloadManager URL local_file_name
java DownloadManager http://www.ietf.org/rfc/rfc2068.txt rfc2068.txt
共启动线程4个
偏移量=0;字节数=102400
偏移量=102400;字节数=102400
偏移量=204800;字节数=102400
偏移量=307200;字节数=70914
运行完毕,请读者们检查当前目录是否生成了rfc2068.txt文件。不妨通过Web浏览器或者其他手段从相同的远程位置下载得到该文件,比较程序生成的文件和下载得到的文件是否完全相同。如果相同,意味着我们的多线程下载程序成功了!
<!-- page -->