Bootstrap

java多线程下载器

本文主要介绍一个多线程下载器的实现方法,主要应用技术如下:

  1. Http请求;
  2. 线程池-ThreadExecutorPool;
  3. RandomAccessFile;
  4. CountDownLatch;
  5. 原子类

本文下载器的执行流程如下:

  1. 找到网上一个可供下载的链接;
  2. 发送http请求,获取下载文件信息;
  3. 设置http可分片下载,使用多线程分别对各个分片下载;
  4. 使用countDownLatch统计各个线程是否均已下载完毕;
  5. 合并各个分片成一个完整的下载文件,下载流程结束!

下载链接和主启动类

本文选取qq应用程序的下载链接作为实验对象。可以去qq官网,复制一个下载链接。

主启动类如下,其中 download 是我们要调用的多线程下载方法。

public class Main {
    public static void main(String[] args) {
        String url = "https://dldir1.qq.com/qqfile/qq/PCQQ9.5.9/QQ9.5.9.28650.exe";
        new Downloader().download(url);
    }
}

下载类 Downloader

下载类主要包含如下几种操作:

  • 具体的下载方法: download;
  • 文件分片下载实现:split;
  • 文件合并操作:merge;
  • 移除生成的临时分片文件: removeTmpFile;

包括的操作对象如下:

  • ThreadPoolExecutor poolExecutor: 分片下载任务的线程池;
  • ScheduledExecutorService executorService:下载任务状态信息的线程池;
  • CountDownLatch countDownLatch :保证合并操作之前,下载操作全部完成;
  • RandomAccessFile accessFile:分片文件,同时提供流的读写方法;
  • Callable< T >:为保证线程任务有返回结果,使用此种线程实现方式;
package com.example.testspring.dudemo.core;

import com.example.testspring.dudemo.constant.Constant;
import com.example.testspring.dudemo.util.FileUtils;
import com.example.testspring.dudemo.util.HttpUtils;

import java.io.*;
import java.net.HttpURLConnection;
import java.util.ArrayList;
import java.util.concurrent.*;

/**
 * 下载器
 * @author zjl
 * @date 2022/04/20
 */
public class Downloader {

    // 监听下载信息的线程池
    public ScheduledExecutorService executorService = Executors.newScheduledThreadPool(1);

    // 创建线程池对象
    public ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(Constant.THREAD_NUM,Constant.THREAD_NUM,0,TimeUnit.SECONDS,new ArrayBlockingQueue<>(Constant.THREAD_NUM));

    // CountDownLatch 保证合并之前分片都下载完毕
    CountDownLatch countDownLatch = new CountDownLatch(Constant.THREAD_NUM);

    /**
     * 下载
     * @param url url
     */
    public void download(String url) {
        // 获取文件名
        String fileName = HttpUtils.getHttpFileName(url);
        // 设置文件下载路径
        fileName = Constant.PATH + fileName;
        // 获取文件大小
        long localFileSize = FileUtils.getFileSize(fileName);
        // 下载运行信息线程类
        DownloadInfoThread downloadInfoThread = null;
        // HTTP连接对象
        HttpURLConnection connection = null;
        try{
        	// 建立连接
            connection = HttpUtils.getConnection(url);
            // 获取下载文件的总大小
            int totalLength = connection.getContentLength();
			// 保证本文件未下载过
            if(localFileSize >= totalLength) {
                System.out.println("该文件已经下载过");
                return;
            }

            // 创建获取下载信息的任务对象
            downloadInfoThread = new DownloadInfoThread(totalLength);
            // 获取下载状态,创建一个每秒执行一次的线程,捕获当前下载状态(大小、速度)
            executorService.scheduleAtFixedRate(downloadInfoThread,1,1, TimeUnit.SECONDS);
        }catch (IOException e) {
            e.printStackTrace();
        }
		// 保证连接存在
        if(connection == null) {
            System.out.println("获取连接失败");
            return;
        }
        
        // 括号内代码会自动关闭
        try {
            // 切分任务
            ArrayList<Future> list = new ArrayList<>();
            split(url,list);

			// 保证多个线程的分片数据下载完毕
            countDownLatch.await();

            // 合并文件
            if(merge(fileName)) {
                removeTmpFile(fileName);
            }
        } catch (IOException | InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.print("\r");
            System.out.print("下载完成");
            // 关闭连接
            connection.disconnect();
            executorService.shutdownNow();
            // 关闭线程池
            poolExecutor.shutdown();
        }
    }

    /**
     * 文件切分
     * @param url        url
     * @param futureList 未来的列表
     */
    public void split(String url, ArrayList<Future> futureList) throws IOException {
        // 获取下载文件大小
        long fileSize = HttpUtils.getFileSize(url);
        // 计算切分后的文件大小
        long size = fileSize / Constant.THREAD_NUM;

        for (int i = 0; i < Constant.THREAD_NUM; i++) {
            // 计算下载起始位置
            long startPos = i * size;
            long endPos;

            if(i == Constant.THREAD_NUM - 1) {
                // 下载的最后一块
                endPos = 0;
            }else {
                endPos = startPos + size;
            }

            // 如果不是第一块,那么起始位置+1
            if(startPos != 0) {
                startPos++;
            }

            // 创建任务对象
            DownloadTask downloadTask = new DownloadTask(url, startPos, endPos,i,countDownLatch);
            // 提交任务到线程池
            Future<Boolean> future = poolExecutor.submit(downloadTask);
            // 添加到结果集合中
            futureList.add(future);
        }
    }

    /**
     * 文件合并
     * @param fileName 合并的文件名前缀
     */
    public boolean merge(String fileName) {
        System.out.print("\r");
        System.out.println("开始合并文件");
        byte[] buffer = new byte[Constant.BYTE_SIZE];
        int len = -1;
        try(RandomAccessFile accessFile = new RandomAccessFile(fileName,"rw")){
            for (int i = 0; i < Constant.THREAD_NUM; i++) {
                try (BufferedInputStream bis = new BufferedInputStream(new FileInputStream(fileName + ".temp" + i))){
                    while((len = bis.read(buffer)) != -1) {
                        accessFile.write(buffer,0,len);
                    }
                }
            }
        }catch (Exception e) {
            e.printStackTrace();
            return false;
        }
        System.out.println("文件合并完毕!");
        return true;
    }


    /**
     * 删除临时文件
     * @param fileName 文件名称
     * @return boolean
     */
    public boolean removeTmpFile(String fileName) {
        for (int i = 0; i < Constant.THREAD_NUM; i++) {
            File file = new File(fileName + ".temp" + i);
            file.delete();
        }
        return true;
    }

}

下载任务 DownloadTask实现

有个有趣的点,可能你们会知道,try(*) 的括号里面流或文件的创建后是不需要手动关闭的。
这个任务的主要功能如下:

  1. 发送HTTP请求,请求下载分片后的文件数据;
  2. 将分片结果以临时文件形式保存到本地;
package com.example.testspring.dudemo.core;

import com.example.testspring.dudemo.constant.Constant;
import com.example.testspring.dudemo.util.HttpUtils;

import java.io.BufferedInputStream;
import java.io.FileNotFoundException;
import java.io.InputStream;
import java.io.RandomAccessFile;
import java.net.HttpURLConnection;
import java.util.concurrent.Callable;
import java.util.concurrent.CountDownLatch;

/**
 * 分块下载任务
 *
 * @author zjl
 * @date 2022/04/20
 */
public class DownloadTask implements Callable<Boolean> {

    private String url;
    // 起始位置
    private long startPos;
    // 结束位置
    private long endPos;
    // 标识当前是哪一部分
    private int part;

    private CountDownLatch countDownLatch;

    public DownloadTask(String url, long startPos, long endPos, int part,CountDownLatch countDownLatch) {
        this.url = url;
        this.startPos = startPos;
        this.endPos = endPos;
        this.part = part;
        this.countDownLatch = countDownLatch;
    }

    @Override
    public Boolean call() throws Exception {
        // 获取文件名
        String httpFileName = HttpUtils.getHttpFileName(url);
        // 分块的文件名
        httpFileName = httpFileName + ".temp" + part;
        // 下载路径
        httpFileName = Constant.PATH + httpFileName;
        // 获取分块下载的链接
        HttpURLConnection connection = HttpUtils.getConnection(url, startPos, endPos);
        try (
                InputStream input = connection.getInputStream();
                BufferedInputStream bis = new BufferedInputStream(input);
                RandomAccessFile accessFile = new RandomAccessFile(httpFileName,"rw");
            ){
            byte[] bytes = new byte[Constant.BYTE_SIZE];
            int len = -1;
            while((len = bis.read(bytes)) != -1) {
                accessFile.write(bytes,0,len);
                // 1s内下载数据之和
                DownloadInfoThread.downSize.add(len);
            }
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        } finally {
            countDownLatch.countDown();
            connection.disconnect();
        }
        return true;
    }
}

下载信息任务类:DownloadInfoThread

此处要注意本次下载大小 downSize 和 finishedSize 使用原子类实现。本任务类包含如下信息:

  • 文件总大小;
  • 已下载的文件大小;
  • 本次下载大小;
  • 上一次的下载大小;
  • 下载速度、剩余文件大小和剩余下载时间等。
package com.example.testspring.dudemo.core;

import com.example.testspring.dudemo.constant.Constant;

import java.util.concurrent.atomic.LongAdder;

public class DownloadInfoThread implements Runnable{

    /**
     * 文件总大小
     */
    private long fileSize;

    public DownloadInfoThread(long fileSize) {
        this.fileSize = fileSize;
    }

    /**
     * 已下载的文件大小
     */
    private static LongAdder finishedSize = new LongAdder();

    /**
     * 本次下载大小
     */
    public static volatile LongAdder downSize = new LongAdder();

    /**
     * 上一次下载大小
     */
    private double preSize;


    @Override
    public void run() {
        // 计算文件总大小 单位mb
        String httpFileSize = String.format("%.2f",fileSize/ Constant.MB);

        // 计算每秒下载速度 单位kb/s
        int speed = (int) ((downSize.longValue() - preSize) / 1024d);
        preSize = downSize.longValue();

        // 剩余文件大小
        double remainSize = fileSize - finishedSize.longValue() - downSize.longValue();

        // 计算剩余时间
        String remainTime = String.format("%.1f",remainSize / 1024d / speed);
        if ("Infinity".equalsIgnoreCase(remainTime)) {
            remainTime = "-";
        }

        // 计算已经下载大小
        String currentFileSize = String.format("%.2f",(downSize.longValue() - finishedSize.longValue()) / Constant.MB);

        String downloadInfo = String.format("已下载 %smb/%smb,速度 %skb/s,剩余时间 %ss", currentFileSize, httpFileSize, speed, remainTime);

        System.out.print("\r");
        System.out.print(downloadInfo);

    }
}

常量类

主要记录了一些下载相关的信息

/**
 * 常量类
 * @author zjl
 * @date 2022/04/20
 */
public class Constant {
    public static final double MB = 1024d * 1024d;
    public static final String PATH = "C:\\Users\\DELL\\Desktop\\";
    public static final int BYTE_SIZE = 100*1024;
    public static final int THREAD_NUM = 5;
}

工具类

主要获取文件信息和建立http连接信息。

HttpUtils:

package com.example.testspring.dudemo.util;

import java.io.IOException;
import java.net.HttpURLConnection;
import java.net.URL;

/**
 * http工具包
 * @author zjl
 * @date 2022/04/20
 */
public class HttpUtils {

    /**
     * 获得连接
     * @param url url
     * @return {@link HttpURLConnection}
     * @throws IOException ioexception
     */
    public static HttpURLConnection getConnection(String url) throws IOException {
        URL httpUrl = new URL(url);
        HttpURLConnection connection = (HttpURLConnection)httpUrl.openConnection();
        // 向文件所在服务器发送标识信息
        connection.setRequestProperty("User-Agent","Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/535.1 (KHTML, like Gecko)Chrome/14.0.835.163 Safari/535.1");
        return connection;
    }


    /**
     * 得到http文件名称
     * @param url url
     * @return {@link String}
     */
    public static String getHttpFileName(String url) {
        int index = url.lastIndexOf("/");
        return url.substring(index+1);
    }


    /**
     * 获得分片连接
     * @param url      url
     * @param startPos 开始pos
     * @param endPos   终端pos
     * @return {@link HttpURLConnection}
     * @throws IOException ioexception
     */
    public static HttpURLConnection getConnection(String url, long startPos,long endPos) throws IOException {
        HttpURLConnection connection = getConnection(url);
        System.out.println("下载的分片区间是" + startPos + "-" + endPos);
        if(endPos != 0) {
            // bytes = 100-200
            connection.setRequestProperty("RANGE","bytes="+startPos+"-"+endPos);
        }else {
            // 只有 - 会下载到结尾的所有数据
            connection.setRequestProperty("RANGE","bytes="+startPos+"-");
        }
        return connection;
    }

    /**
     * 获取下载文件大小
     * @param url
     * @return long
     */
    public static long getFileSize(String url) throws IOException {
        return getConnection(url).getContentLength();
    }

}

FileUtils:

package com.example.testspring.dudemo.util;

import java.io.File;

public class FileUtils {
    public static long getFileSize(String path) {
        File file = new File(path);
        return file.exists() && file.isFile() ? file.length() : 0;
    }
}

运行结果

在这里插入图片描述
在这里插入图片描述

代码有很多改进和值得思考的地方,案例场景虽然并不复杂,但是胜在应用技术全面,可作为其他场景下的基础demo!

参考链接:
https://www.iqiyi.com/v_1ykiuvgozfw.html?vfrm=pcw_playpage&vfrmblk=D&vfrmrst=80521_listbox_positive#curid=7040308833907200_e5e0f6c7a8a786310017870b9526bacd

;