Java多线程下载文件

JVM是支持多线程程序的,当程序需要同时执行两个或多个任务,实现一些需要等待的任务时,如用户输入、文件读写、网络操作、搜索等多线程程序比单线程程序更具优势,可充分利用CPU资源,完成时间更短,提高应用程序的响应,增强用户体验。因此学会改善程序结构,将即长又复杂的进程分为多个线程,独立去运行,对于开发者来说至关重要。

1、多文件并发下载

以下载多个文件为例,如何使用多线程机制,高效率的完成下载任务?

提出需求:编写一个API,打包下载GitHub的所有用户头像(以zip形式返回所有用户头像)。

在这里插入图片描述

首先文件压缩我们统一使用apache的commons-compress相关类进行压缩,因此需要引入相关的依赖

<!-- https://mvnrepository.com/artifact/org.apache.commons/commons-compress -->
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-compress</artifactId>
    <version>1.24.0</version>
</dependency>

GitHub用户头像访问地址如下

在这里插入图片描述

所以实现的思路也就很简单了,GitHub用户API会返回多个用户信息,我们没有必要一个个下载串行的进行,完全可以采用多线程机制并行下载,这样可以大大提高下载速度,主线程负责查询与分配下载任务,等所有线程池的任务都完成了,再进行最后的压缩,返回给浏览器!完整代码如下

/**
 * TODO
 *
 * @Description
 * @Author laizhenghua
 * @Date 2023/8/31 09:22
 **/
@RestController
@SpringBootApplication
public class TestApplication {
    public static void main(String[] args) {
        SpringApplication.run(TestApplication.class, args);
    }

    @Autowired
    private ServletContext servletContext;

    @GetMapping("/test")
    public void test() {
        ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletResponse response = servletRequestAttributes.getResponse();

        RestTemplate restTemplate = new RestTemplate();
        String usersUrl = "https://api.github.com/users";

        // 查询github用户信息
        JSONArray userList = restTemplate.getForObject(usersUrl, JSONArray.class);
        if (CollectionUtils.isEmpty(userList)) {
            fallback("下载失败,失败原因: 查询为空", response);
            return;
        }
        // 下载路径准备
        String rootPath = servletContext.getRealPath("/") + "avatars";
        File root = new File(rootPath);
        if (!root.exists()) {
            root.mkdir();
        }

        // 初始化线程池(JDK 5.0新增的线程池API更多知识可自行学习)
        ExecutorService executorService = Executors.newFixedThreadPool(10);

        userList.forEach(item -> {
            JSONObject user = new JSONObject((Map)item);
            String login = user.getString("login"); // github登录名
            String downloadUrl = user.getString("avatar_url"); // 头像下载地址
            String filePath = rootPath + File.separator + login + ".png";
            // 执行下载任务(下载至本地)
            // ****** 一个线程处理一个用户(主线程只负责提交任务尽可能把耗时逻辑都放到多线程任务里如下载、IO操作等) ******
            executorService.execute(() -> {
                try {
                    File file = new File(filePath);
                    boolean newFile = file.createNewFile();
                    if (!newFile) {
                        return;
                    }
                    String name = Thread.currentThread().getName();
                    String log = String.format("[%s] download start --- download path: %s", name, filePath);
                    System.out.println(log);
                    // 调用下载接口获取输入流程
                    ResponseEntity<Resource> responseEntity = restTemplate.getForEntity(downloadUrl, Resource.class);

                    // 将得到的输入流写入文件
                    InputStream inputStream = null;
                    OutputStream outputStream = null;
                    try {
                        inputStream = Objects.requireNonNull(responseEntity.getBody()).getInputStream();
                        outputStream = new FileOutputStream(file);
                        byte[] buffer = new byte[1024];
                        int len;
                        while ((len = inputStream.read(buffer)) != -1) {
                            outputStream.write(buffer, 0, len);
                        }
                    } catch (IOException e) {
                        e.printStackTrace();
                    } finally {
                        IOUtils.close(inputStream);
                        IOUtils.close(outputStream);
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
            });
        });

        // 关闭线程池
        executorService.shutdown();

        // 使用org.apache.commons类压缩下载好的头像
        ZipArchiveOutputStream zipAos = null;
        try {
            // 等待线程池中所有任务执行完成(指定时间内没有执行完则返回false)
            boolean allTaskCompleted = executorService.awaitTermination(30, TimeUnit.MINUTES);
            if (!allTaskCompleted) {
                fallback("下载失败", response);
            }
            // 设置下载信息
            response.setHeader("Content-Disposition", "attachment; filename=\"" + URLEncoder.encode("github_avatar.zip", "utf-8") + "\"");
            response.setContentType("application/zip");

            zipAos = new ZipArchiveOutputStream(response.getOutputStream());
            zipAos.setEncoding("UTF-8");
            zipAos.setUseZip64(Zip64Mode.AsNeeded);

            File[] files = root.listFiles(); // 获取所有下载好的头像
            assert files != null;
            for (File file : files) {
                // 将头像压缩至 github_avatar.zip 文件
                ZipArchiveEntry entry = new ZipArchiveEntry(file, file.getName());
                entry.setLastModifiedTime(FileTime.fromMillis(file.lastModified()));
                zipAos.putArchiveEntry(entry);
                try (InputStream inputStream = new FileInputStream(file)) {
                    byte[] buffer = new byte[1024];
                    int len;
                    while ((len = inputStream.read(buffer)) != -1) {
                        zipAos.write(buffer, 0, len);
                    }
                    file.delete(); // 删除文件
                }
            }
            zipAos.closeArchiveEntry();
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            IOUtils.close(zipAos);
        }
    }

    private void fallback(String message, HttpServletResponse response) {
        response.setCharacterEncoding("UTF-8");
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        PrintWriter writer = null;
        try {
            R error = R.error(500, message);
            JSONObject json = new JSONObject(error);
            writer = response.getWriter();
            writer.append(json.toString());
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (writer != null) {
                writer.close();
            }
        }
    }
}

对于打包下载,我们可以用单线程,也可以用多线程,处理这种任务多线程的优势就体现出来了,可自行对比下单线程和多线程程序响应速度。

在这里插入图片描述

使用多线程需要注意的是:

  1. Executors.newFixedThreadPool()是创建一个可重用固定线程数量的线程池。实际开发中这种创建线程池的方式慎用,巨耗服务器资源,建议使用全局的线程池如tomcat的线程池,而不是每来一个线程都创建一个线程池。
  2. 主线程只负责分配任务,把耗时的逻辑尽可能的写到多线程任务上独立执行。
  3. 使用完线程池必须要关闭,先调用 shutdown() 方法关闭线程池,然后调用 awaitTermination(long timeout, TimeUnit unit) 方法等待线程池中的所有任务执行完成,只有线程池中的所有任务都执行完了,才能把响应信息写到response上。

2、大文件分块下载

除了多文件下载,大文件下载也可以使用多线程机制,这也是优化大文件下载常用的方案,每个线程下载其中一部分,可大大提高下载速率。

我们以下载QQ安装包为例,如在官网上mac版的dmg有324MB,使用单线程下载大概需要52s,例如

@GetMapping("/download")
public void download() throws IOException {
    ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
    HttpServletResponse response = servletRequestAttributes.getResponse();
    StopWatch stopWatch = new StopWatch();
    stopWatch.start();

    String url = "https://dldir1.qq.com/qqfile/qq/QQNT/3a97cdda/QQ_v6.9.21.17412.dmg";

    // 获取文件名称
    String fileName = url.substring(url.lastIndexOf("/") + 1);
    String filePath = "/Users/laizhenghua/Downloads/" + fileName;
    File file = new File(filePath);
    if (!file.exists()) {
        file.createNewFile();
    }
    RestTemplate restTemplate = new RestTemplate();
    // 下载
    ResponseEntity<Resource> entity = restTemplate.getForEntity(url, Resource.class);
    try (InputStream inputStream = Objects.requireNonNull(entity.getBody()).getInputStream();
         BufferedInputStream bufferedInputStream = new BufferedInputStream(inputStream);
         BufferedOutputStream bufferedOutputStream = new BufferedOutputStream(new FileOutputStream(file))) {
        int len;
        while ((len = bufferedInputStream.read()) != -1) {
            bufferedOutputStream.write(len);
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
    stopWatch.stop();
    fallback(String.format("%ss", stopWatch.getTotalTimeMillis() / 1000), "success", response);
}

private void fallback (Object data, String message, HttpServletResponse response){
    response.setCharacterEncoding("UTF-8");
    response.setContentType(MediaType.APPLICATION_JSON_VALUE);
    PrintWriter writer = null;
    try {
        R r = R.success(data, message);
        JSONObject json = new JSONObject(r);
        writer = response.getWriter();
        writer.append(json.toString());
    } catch (IOException e) {
        e.printStackTrace();
    } finally {
        if (writer != null) {
            writer.close();
        }
    }
}

再来看访问api返回的信息

在这里插入图片描述

我们再来看使用多线程的方式下载会花费多长时间?

@GetMapping("blocking/download")
public void blockingDownload() throws IOException, InterruptedException {
    ServletRequestAttributes servletRequestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
    HttpServletResponse response = servletRequestAttributes.getResponse();
    StopWatch stopWatch = new StopWatch();
    stopWatch.start();
    // 准备下载地址
    String url = "https://dldir1.qq.com/qqfile/qq/QQNT/3a97cdda/QQ_v6.9.21.17412.dmg";

    // 获取文件名称
    String fileName = url.substring(url.lastIndexOf("/") + 1);

    // 目标文件创建
    File file = new File("/Users/laizhenghua/Downloads/", fileName);
    if (!file.exists()) {
        file.createNewFile();
    }

    /*
    如果需要计算下载速度,我们需要知道每秒传输的字节数是多少,这时需要借助 SpringMVC 的 ResponseExtractor 接口实现,
    当客户端和服务端连接建立后,会回调这个方法,我们可以在这个方法中监控下载的速度
    */
    int corePoolSize = 10; // 线程池核心线程数量
    // 准备线程池
    ExecutorService executorService = Executors.newFixedThreadPool(corePoolSize);

    RestTemplate restTemplate = new RestTemplate();

    // 获取文件大小
    long contentLength = restTemplate.headForHeaders(url).getContentLength();
    // 换算成MB contentLength / 1024 / 1024
    RandomAccessFile randomAccessFile = null;
    try {
        randomAccessFile = new RandomAccessFile(file, "rw");
        randomAccessFile.setLength(contentLength);
        // 计算每个线程下载的大小
        long length = contentLength / corePoolSize;
        long start, end;
        for (int i = 0; i < corePoolSize; ++i) {
            // 累加
            start = i * length;
            end = i == corePoolSize - 1 ? contentLength: (i + 1) * length;

            // 分配任务
            long finalStart = start;
            long finalEnd = end;
            RandomAccessFile finalRandomAccessFile = randomAccessFile;
            executorService.execute(() -> {
                String name = Thread.currentThread().getName();
                HttpHeaders headers = new HttpHeaders();
                // 使用HTTP协议Range请求头告知需要请求的资源范围格式为 Range: bytes=start-end
                headers.set(HttpHeaders.RANGE, String.format("bytes=%s-%s", finalStart, finalEnd));
                headers.setAccept(Arrays.asList(MediaType.APPLICATION_OCTET_STREAM, MediaType.ALL));
                headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);

                log.info("[{}] download start -- range: {}", name, headers.get(HttpHeaders.RANGE));
                ResponseEntity<byte[]> resp = restTemplate.exchange(url, HttpMethod.GET, new HttpEntity<>(headers),
                        byte[].class);
                try {
                    finalRandomAccessFile.seek(finalStart);
                    byte[] bytes = Objects.requireNonNull(resp.getBody());
                    finalRandomAccessFile.write(bytes);
                } catch (IOException e) {
                    e.printStackTrace();
                }
            });
        }
    } catch (IOException e) {
        e.printStackTrace();
    } finally {
        executorService.shutdown();
        // 等待所有下载任务都完成
        boolean allTaskCompleted = executorService.awaitTermination(10, TimeUnit.MINUTES);
        if (allTaskCompleted) {
            // 所有线程任务
        }
        IOUtils.closeQuietly(randomAccessFile);
    }
    stopWatch.stop();
    fallback(String.format("%ss", stopWatch.getTotalTimeMillis() / 1000), "success", response);
}

再来看访问api返回的信息

在这里插入图片描述
这样对比看似快了20s,实际上多线程请求文件资源,文件在磁盘上一瞬间就写完了,比较耗时的是关闭线程资源,如果换一种写法,速度上肯定还会快很多。

3、线程池的创建优化

不建议使用Executors相关api来创建线程池:

/*
Executors.newFixedThreadPool()和 Executors.newSingleThreadExecutor()

1.创建的队列为无界的阻塞队列(new LinkedBlockingQueue<Runnable>())
2.如果任务过多就会不断添加到队列中,任务越多占用内存和cpu资源就越多,极易导致OOM,cpu使用率飙升
3.无法自定义线程的名字,不利于排查问题
/*

推荐使用ThreadPoolTaskExecutor来创建线程池,如

@Configuration
public class ExecutorConfiguration {
    @Bean
    public Executor executor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        // 核心线程
        executor.setCorePoolSize(5);
        // 最大线程
        executor.setMaxPoolSize(10);
        // 队列容量
        executor.setQueueCapacity(1000);
        // 保持时间(默认秒)
        executor.setKeepAliveSeconds(5);
        // 线程名称前缀
        executor.setThreadNamePrefix("test-thread-");
        // 拒绝策略
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy());
        executor.initialize();
        return executor;
    }
}

使用线程池开启异步任务:

@Component
@EnableAsync
public class AsyncExecutor {
    private final Logger log = LoggerFactory.getLogger(getClass());

    @Async("executor") // executor是线程池bean的name
    public CompletableFuture<String> asyncTest() {
        String name = Thread.currentThread().getName();
        try {
            Thread.sleep(5000);
        } catch (Exception e) {
            e.printStackTrace();
        }
        log.info(name);
        return CompletableFuture.completedFuture(name);
    }
}
  • 1
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

lambda.

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值