多线程下载文件提升性能

428 阅读4分钟

多线程下载文件

前言

最近项目中导出和下载文件因为数据量太大,导致下载时间过长,甚至出现下载不下来文件(超时,超出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(理论值),需要一边读一边写,等内存释放了再去读再去写,依次循环即可。就是所说的断点上传文件技术。

不管怎样,总之控制好文件字节顺序就可以了,不论是下载还是写文件。