多线程下载技术是很常见的一种下载方案,这种方式充分利用了多线程的优势,在同一时间段内通过多个线程发起下载请求,将需要下载的数据分割成多个部分,每一个线程只负责下载其中一个部分,然后将下载后的数据组装成完整的数据文件,这样便大大加快了下载效率。常见的下载器,迅雷,QQ旋风等都采用了这种技术。
原理很清楚,但是其中涉及到两个关键问题:
要解决这两个问题,需要掌握下面两个知识点。
Range,是在 HTTP/1.1里新增的一个 header field,它允许客户端实际上只请求文档的一部分,或者说某个范围。
有了范围请求,HTTP 客户端可以通过请求曾获取失败的实体的一个范围(或者说一部分),来恢复下载该实体。当然这有一个前提,那就是从客户端上一次请求该实体到这次发出范围请求的时段内,该对象没有改变过。例如:
GET /bigfile.html HTTP/1.1
Host: www.joes-hardware.com
Range: bytes=4000-
User-Agent: Mozilla/4.61 [en] (WinNT; I)
上述请求中,客户端请求的是文档开头 4000 字节之后的部分(不必给出结尾字节数,因为请求方可能不知道文档的大小)。在客户端收到了开头的 4000 字节之后就失败的情况下,可以使用这种形式的范围请求。还可以用 Range 首部来请求多个范围(这些范围可以按任意顺序给出,也可以相互重叠)。例如,假设客户端同时连接到多个服务器,为了加速下载文档而从不同的服务器下载同一个文档的不同部分。对于客户端在一个请求内请求多个不同范围的情况,返回的响应也是单个实体,它有一个多部分主体及 Content-Type: multipart/byteranges
首部。
Range头域使用形式如下。例如:
表示头500个字节:bytes=0-499
表示第二个500字节:bytes=500-999
表示最后500个字节:bytes=-500
表示500字节以后的范围:bytes=500-
第一个和最后一个字节:bytes=0-0,-1
如果客户端发送的请求中Range这个值存在而且有效,则服务端只发回请求的那部分文件内容,响应的状态码变成206,表示Partial Content
,并设置Content-Range
。如果无效,则返回416状态码,表明Request Range Not Satisfiable
如果不包含Range的请求头,则继续通过常规的方式响应。
比如某文件的大小是 1000 字节,client 请求这个文件时用了 Range: bytes=0-500
,那么 server 应该把这个文件开头的 501 个字节发回给 client,同时回应头要有如下内容:Content-Range: bytes 0-500/1000
,并返回206状态码。
并不是所有服务器都接受范围请求,但很多服务器可以。服务器可以通过在响应中包含 Accept-Ranges 首部的形式向客户端说明可以接受的范围请求。这个首部的值是计算范围的单位,通常是以字节计算的。
RandomAccessFile适用于由大小已知的记录组成的文件,所以我们可以使用seek()将记录从一处转移到另一处,然后读取或修改记录。
随机访问文件的行为类似存储在文件系统中的一个大型 byte 数组。存在指向该隐含数组的光标或索引,称为文件指针;输入操作从文件指针开始读取字节,并随着对字节的读取而前移此文件指针。如果随机访问文件以读取/写入模式创建,则输出操作也可用;输出操作从文件指针开始写入字节,并随着对字节的写入而前移此文件指针。写入隐含数组的当前末尾之后的输出操作导致该数组扩展。该文件指针可以通过 getFilePointer 方法读取,并通过 seek 方法设置。
RandomAccessFile虽然位于Java.io包中,但从RandomAccessFile类的层级结构来看,它并不是InputStream或者OutputStream继承层次结构中的一部分。除了实现了DataInput和DataOutput接口(DataInputStream和DataOutputStream也实现了这两个接口),它和这个两个继承层次结构没有任何关联。它甚至不适用InputStream和OutputStream类中已有的任何功能。它是一个完全独立的类,从头开始编写其所有的方法(大多数都是native的)。这么做是因为RandomAccessFile拥有和别的I/O类型本质不同的行为,我们可以通过它在一个文件内向前和向后移动。在任何情况下,它都是自我独立的,直接派生自Object类。
本质上来说,RandomAccessFile的工作方式类似于把DataInputStream和DataOutStream组合起来使用,还添加了一些方法。
以下是一些比较重要的方法。
构造方法RandomAccessFile
public RandomAccessFile(File file, String mode) throws FileNotFoundException
创建从中读取和向其中写入(可选)的随机访问文件流,该文件由 File 参数指定。将创建一个新的 FileDescriptor 对象来表示此文件的连接。
mode 参数指定用以打开文件的访问模式。允许的值及其含意为:
getFilePointer
public native long getFilePointer() throws IOException;
返回此文件中的当前偏移量,以字节为单位。
length
public native long length() throws IOException;
返回此文件的长度。
setLength
public native void setLength(long newLength)
throws IOException
设置此文件的长度。
seek
public native void seek(long pos)
throws IOException
设置到此文件开头测量到的文件指针偏移量,在该位置发生下一个读取或写入操作。
write
public void write(byte[] b,int off,int len) throws IOException
将 len 个字节从指定 byte 数组写入到此文件,并从偏移量 off 处开始。
RandomAccessFile类特殊之处在于支持搜寻方法,并且只适用于文件,这种随机访问特性,为多线程下载提供了文件分段写的支持。
需要注意的是,在RandomAccessFile的大多函数均是native的,在JDK1.4之后,RandomAccessFile大多数功能由nio存储映射文件所取代。所谓存储映射文件,简单来说 是由一个文件到一块内存的映射。内存映射文件与虚拟内存有些类似,通过内存映射文件可以保留一个地址空间的区域,同时将物理存储器提交给此区域,内存文件映射的物理存储器来自一个已经存在于磁盘上的文件,而且在对该文件进行操作之前必须首先对文件进行映射。使用内存映射文件处理存储于磁盘上的文件时,将不必再对文件执行I/O操作,使得内存映射文件在处理大数据量的文件时能起到相当重要的作用。有了内存映射文件,我们就可以假定整个文件都放在内存中,而且可以完全把它当做非常大的数组来访问。
了解了上面两个知识点,下面看一下多线程下载的具体实现。
import java.io.BufferedReader;
import java.io.File;
import java.io.FileInputStream;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.RandomAccessFile;
import java.net.HttpURLConnection;
import java.net.MalformedURLException;
import java.net.URL;
import java.security.acl.LastOwnerException;
import javax.print.attribute.standard.Finishings;
public class Main {
//此处下载路径可以更改
static String path = "http://172.28.21.98:8080/droid4.exe";
//开启的线程个数
static int threadCount = 3;
static int finishedThread = 0;
public static void main(String[] args) {
//发送http请求,拿到目标文件长度
try {
URL url = new URL(path);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(8000);
conn.setReadTimeout(8000);
if(conn.getResponseCode() == 200){
//获取长度
int length = conn.getContentLength();
//创建临时文件
File file = new File(getNameFromPath(path));
RandomAccessFile raf = new RandomAccessFile(file, "rwd");
//设置临时文件大小与目标文件一致
raf.setLength(length);
raf.close();
//计算每个线程下载区间
int size = length / threadCount;
for (int id = 0; id < threadCount; id++) {
//计算每个线程下载的开始位置和结束位置
int startIndex = id * size;
int endIndex = (id + 1) * size - 1;
if(id == threadCount - 1){
endIndex = length - 1;
}
System.out.println("线程" + id + "下载的区间:" + startIndex + " ~ " + endIndex);
new DownLoadThread(id, startIndex, endIndex).start();
}
}
} catch (Exception e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
public static String getNameFromPath(String path){
int index = path.lastIndexOf("/");
return path.substring(index + 1);
}
}
class DownLoadThread extends Thread{
int threadId;
int startIndex;
int endIndex;
public DownLoadThread(int threadId, int startIndex, int endIndex) {
super();
this.threadId = threadId;
this.startIndex = startIndex;
this.endIndex = endIndex;
}
@Override
public void run() {
try {
File fileProgress = new File(threadId + ".txt");
int lastProgress = 0;
if(fileProgress.exists()){
//读取进度临时文件中的内容
FileInputStream fis = new FileInputStream(fileProgress);
BufferedReader br = new BufferedReader(new InputStreamReader(fis));
//得到上一次下载进度
lastProgress = Integer.parseInt(br.readLine());
//改变下载的开始位置,上一次下过的,这次就不请求了
startIndex += lastProgress;
fis.close();
}
//发送http请求,请求要下载的数据
URL url = new URL(Main.path);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(8000);
conn.setReadTimeout(8000);
//设置请求数据的区间
conn.setRequestProperty("Range", "bytes=" + startIndex + "-" + endIndex);
//请求部分数据,成功的响应码是206
if(conn.getResponseCode() == 206){
InputStream is = conn.getInputStream();
byte[] b = new byte[1024];
int len = 0;
//当前线程下载的总进度
int total = lastProgress;
File file = new File(Main.getNameFromPath(Main.path));
RandomAccessFile raf = new RandomAccessFile(file, "rwd");
//设置写入的开始位置
raf.seek(startIndex);
while((len = is.read(b)) != -1){
raf.write(b, 0, len);
total += len;
System.out.println("线程" + threadId + "下载了:" + total);
//创建一个进度临时文件,保存下载进度
RandomAccessFile rafProgress = new RandomAccessFile(fileProgress, "rwd");
//每次下载1024个字节,就,就马上把1024写入进度临时文件
rafProgress.write((total + "").getBytes());
rafProgress.close();
}
raf.close();
System.out.println("线程" + threadId + "下载完毕------------------");
//3条线程全部下载完毕,才去删除进度临时文件
Main.finishedThread++;
synchronized (Main.path) {
if(Main.finishedThread == Main.threadCount){
for (int i = 0; i < Main.threadCount; i++) {
File f = new File(i + ".txt");
System.out.println(f.getAbsolutePath());
f.delete();
}
Main.finishedThread = 0;
}
}
}
}
catch (Exception e) {
e.printStackTrace();
}
}
}