多线程下载文件
前言
最近项目中导出和下载文件因为数据量太大,导致下载时间过长,甚至出现下载不下来文件(超时,超出nginx的最大时间)
故此使用多线程优化一下
思考
不管是多线程还是单线程,对于下载和导出文件,然后再下载到浏览器,核心就是根据文件类型,将字节写入到浏览器。
了解知识
- 浏览器识别文件的类型
已经生成好字节数组和需要读取文件下载
读取文件的时候,需要随机读(指定跳过多少字节),这里我们使用RandomAccessFile这个类,具体可以百度了解一下。
- 浏览器识别字节的顺序
这里需要注意的是:
单线程下载时候直接不需要顺序,直接向浏览器中写的顺序就是字节顺序、而多线程顺序是不一致的,需要将写入的字节顺序固定,让浏览器自己将字节组装好然后生成文件再展示出来。
http响应体Content-Range去指定字节的顺序
response.setHeader("Content-Range", "bytes=" + finalStart + "-" + finalEnd);
- 选择适合自己的线程池(其中核心线程数,时间,队列大小,策略等)
//IO密集型来说,一般是最佳线程数 =CPU 核数 * [ 1 +(I/O 耗时 / CPU 耗时)]
//private static final int corePoolSize = Runtime.getRuntime().availableProcessors() * (1 + (500/100));
//默认使用拒绝策略
//todo ??? 待确认设置核心线程数
public static final ExecutorService pool = new ThreadPoolExecutor(10,20, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<Runnable>(1000));
- 多线程分别处理每个文件(也就是字节的一部分,如下图)
既然思路已经明白了,那就直接开始展示代码得了。
字节数组多线程下载
针对在内存中生成好的字节数组
大白话就是:将字节数组按照指定规则分为几个,然后分别给不同的线程去写入到浏览器即可。
boolean falg = false;
ArrayList<Byte> arrayList = new ArrayList<>(content.length);
for (int i = 0; i < content.length; i++) {
arrayList.add(content[i]);
}
OutputStream outputStream = new BufferedOutputStream(response.getOutputStream());
int fileLength = content.length;
response.addHeader("Content-Length", "" + fileLength);
// 获取每片大小
List<SubObje> newList = subArrayPey(arrayList, fileLength, 5);
for (SubObje sub : newList) {
loggerHelper.infoLog("start = " + sub.getStart() + " end = " + sub.getEnd() + " size = " + sub.getSubList().size());
try {
int finalStart = sub.getStart();
int finalEnd = sub.getEnd();
List<Byte> finalSubList = sub.subList;
pool.submit(() -> {
response.setHeader("Content-Range", "bytes=" + finalStart + "-" + finalEnd);
try {
byte[] silenArray = new byte[finalSubList.size()];
for (int j = 0; j < finalSubList.size(); j++) {
silenArray[j] = finalSubList.get(j);
}
outputStream.write(silenArray);
outputStream.flush();
response.flushBuffer();
} catch (Exception e) {
loggerHelper.infoLog(Thread.currentThread().getName() + " 下载异常: " + e.getMessage());
e.printStackTrace();
}
}).get(1, TimeUnit.MINUTES);
} catch (InterruptedException e) {
//e.printStackTrace();
loggerHelper.infoLog("多线程导出异常");
falg = true;
} catch (ExecutionException e) {
//e.printStackTrace();
loggerHelper.infoLog("多线程导出异常");
falg = true;
} catch (TimeoutException e) {
//e.printStackTrace();
loggerHelper.infoLog("多线程导出超时");
falg = true;
}
}
if (outputStream!=null){
outputStream.flush();
outputStream.close();
}
response.flushBuffer();
顺序字节实体类
@Data
class SubObje<T> {
private int start;
private int end;
private List<T> subList;
public SubObje() {
}
public SubObje(int start, int end, List<T> subList) {
this.start = start;
this.end = end;
this.subList = subList;
}
}
将字节数组切分几份
/**
*
* @param arrayList 需要转的集合
* @param fileLength 总长度(大小)
* @param poolLength 份数
* @return
*/
private <T> List<SubObje> subArrayPey(List<T> arrayList, int fileLength, int poolLength) {
List<SubObje> newList = new ArrayList<>();
int slice = fileLength / poolLength;
for (int i = 0; i < poolLength; i++) {
int start = i * slice;
int end = (i + 1) * slice - 1;
List<T> subList = arrayList.subList(start, end + 1);
if (i == poolLength - 1) {
start = i * slice;
end = fileLength;
subList = arrayList.subList(start, end);
}
newList.add( new SubObje(start,end,subList));
}
return newList;
}
下载文件
具体代码和上面字节数组下载是一样的,唯一不同的是每个线程读取文件需要读取自己负责的那一段字节,具体请看下段代码中的核心代码片段。
// 3分钟超时
pool.submit(() -> makeFile(vcode, fileList, dirPath, cardNo) ).get(3, TimeUnit.MINUTES);
}
response.addHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode("xxxx" + ".zip", "UTF-8"));
response.setContentType("application/octet-stream");
OutputStream outputStream = new BufferedOutputStream(response.getOutputStream());
//启动多线程开始下载
String path = fileList.get(0).getPath();
String zipPath = path.substring(0, path.lastIndexOf(File.separator));
FileOutputStream fos2 = new FileOutputStream(zipPath + ".zip");
ZipUtils.toZip(fileList, fos2);
//压缩
String zipName = zipPath + ".zip";
//查文件
File file = new File(zipName);
//-------------------- 核心代码 -------------
//"r", "rw", "rws", or "rwd"
RandomAccessFile rf = new RandomAccessFile(file, "rw");//只读
//文件长度
long fileLength = file.length();
//大小 这里流大小表示总的大小
response.addHeader("Content-Length", "" + fileLength);
long poolLength = 5;
// 获取每片大小
long slice = fileLength / poolLength;
for (int i = 0; i < poolLength; i++) {
long start = i * slice;
long end = (i + 1) * slice - 1;
if (i == poolLength - 1) {
start = i * slice;
end = fileLength;
}
//字节范围
response.setHeader("Content-Range", "bytes=" + start + "-" + end);
long finalStart = start;
long finalEnd = end;
RandomAccessFile finalRf = rf;
pool.submit(() -> {
try {
//跳过startPos个字节,表明该线程只下载自己负责哪部分文件。
finalRf.seek(finalStart);
System.out.println("起始位置" + finalStart);
//-------------------- 核心代码 -------------
int lent = Long.valueOf(finalEnd - finalStart).intValue() + 1;
byte[] buffer = new byte[1024];
int read = 0;
int lenth = 0;
//1 这里循环读 循环写
while ((lenth = finalRf.read(buffer)) != -1) {
lenth += buffer.length;
outputStream.write(buffer);
if (lenth == lent) {
break;
}
}
// todo 2 也可以一次性读完
//finalRf.read(buffer, 0, lent);
//outputStream.write(buffer);
outputStream.flush();
response.flushBuffer();
} catch (Exception e) {
logger.info(Thread.currentThread().getName() + " 下载异常: " + e.getMessage());
e.printStackTrace();
}
}).get(1, TimeUnit.MINUTES);
}
if(outputStream!=null){
outputStream.close();
}
response.flushBuffer();
rf.close();
最后
这里我使用分n份,没有按照分多少字节去分。
就是说,如果一个服务器内存只有1g,需要上传10g的文件,此时就需要使用字节去分;意思就是每次写从内存不能超过1g(理论值),需要一边读一边写,等内存释放了再去读再去写,依次循环即可。就是所说的断点上传文件技术。
不管怎样,总之控制好文件字节顺序就可以了,不论是下载还是写文件。