Java多线程下载器

文章目录

  • 前言
  • 一、多线程下载器
    • 1. 环境搭建
    • 2. 文件下载
  • 二、文件下载器基础代码
  • 三、编写工具类✨
    • 1. 日志工具类
    • 2. 文件工具类
    • 3. 请求工具类
  • 四、文件下载信息
  • 五、线程池简介
    • 线程池工作过程
    • 线程池的状态
    • 线程池的关闭
    • 在Java中实现线程池
    • JDK提供的更便捷的创建线程池的方式
  • 六、文件切片下载
    • 切片工具类
    • 切片任务类
    • 文件切分下载
    • 下载信息改成原子类操作
  • 七、文件合并和清理临时文件
  • 项目代码地址
  • 报错记录
    • Server returned HTTP response code: 416 for URL: https://dldir1.qq.com/qqfile/qq/PCQQ9.7.17/QQ9.7.17.29230.exe
    • 下载信息出现死循环
  • 参考资料


前言

后文有源代码的仓库地址,需要的自取,给个点赞,不过分吧

本项目代码是我照着敲的,加了一些代码解释,之前还是学了一些多线程的知识,一直没有运用,有时间的话可以去支持一下原作者,喜欢本文的话可以点赞+收藏+分享


一、多线程下载器

1. 环境搭建

开发工具:IDEA

Java版本:jdk8

项目编码:UTF-8

Java项目:普通的JavaSE项目

项目结构:

Java多线程下载器_第1张图片

constant:存放常量类

core:下载器核心类包

util:存放工具类

Main:主类

2. 文件下载

从互联网下载文件有点类似于我们将本地某个文件复制到另一个目录下,也会利用 IO 流
进行操作。对于从互联网下载,还需要将本地和下载文件所在的服务器建立连接。

Java多线程下载器_第2张图片

计算机从服务器下载文件的过程可以分为以下几个步骤:

  1. 用户在浏览器中输入要下载的文件的URL地址,例如:http://example.com/file.zip。
  2. 浏览器向服务器发送一个HTTP请求,请求获取该URL地址对应的文件。这个请求包含了一些信息,如请求的方法(GET)、请求头(User-Agent、Accept等)以及请求体(如果需要的话)。
  3. 服务器收到HTTP请求后,会根据请求的信息进行处理。如果服务器允许该请求,它会将文件数据准备好,然后 返回一个HTTP响应给浏览器。这个响应包含了一些信息,如响应的状态码(200表示成功)、响应头(Content-Type、Content-Length等)以及响应体(文件的数据)。
  4. 浏览器收到HTTP响应后,会解析响应头中的Content-Type和Content-Length字段,以确定文件的类型和大小。然后,浏览器会创建一个下载任务,并将文件数据写入到这个任务中。
  5. 当文件数据全部接收完毕后,浏览器会通知用户文件已经下载完成。用户可以在本地找到这个文件,并对其进行查看或解压缩等操作。

二、文件下载器基础代码

创建工具类com.util.Httputils

package com.util;

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

/**
 * @Author YZK
 * @Date 2023/10/18
 */
public class HttpUtils {
    /***
     * 获取HttpURLConnection链接对象
     * @param url 文件地址
     * @return HttpURLConnection
     */
    public static HttpURLConnection getHttpURLConnection(String url) throws IOException {
        URL httpURL = new URL(url);
        HttpURLConnection httpURLConnection = (HttpURLConnection) httpURL.openConnection();
        //向文件所在的服务器发达标识信息
        httpURLConnection.setRequestProperty("User-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36 Edg/118.0.2088.46");
        return httpURLConnection;
    }

    /***
     *  获取文件名
     * @param url 文件地址
     * @return 文件名
     */
    public static String getFileName(String url) {
        //找到URL字符串中最后一个斜杠(/)的位置,将其索引值赋给整型变量index。
        int index = url.lastIndexOf("/");
        //从index+1的位置开始截取字符串,即从最后一个斜杠之后的部分开始截取,直到字符串末尾
        return url.substring(index + 1);
    }

}

编写核心下载器com.core.Downloader

package com.core;

import com.constant.Constant;
import com.util.HttpUtils;

import java.io.*;
import java.net.HttpURLConnection;

/**
 * @Author YZK
 * @Date 2023/10/18
 * 下载器核心类
 */
public class Downloader {
    public void download(String url) {
        //获取文件名
        String httpFileName = HttpUtils.getFileName(url);
        //拼接文件下载路径
        httpFileName = Constant.PATH + httpFileName;
        HttpURLConnection httpURLConnection = null;
        try {
            //获取链接对象
            httpURLConnection = HttpUtils.getHttpURLConnection(url);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
        try (
                //创建输入流
                InputStream inputStream = httpURLConnection.getInputStream();
                //创建缓冲输入流
                BufferedInputStream bis = new BufferedInputStream(inputStream);
                //创建输出流
                FileOutputStream fos = new FileOutputStream(httpFileName);
                //创建缓冲输出流
                BufferedOutputStream bos = new BufferedOutputStream(fos)
        ) {
            int len = -1;
            while ((len = bis.read()) != -1) {
                bos.write(len);
            }
        } catch (FileNotFoundException fileNotFoundException) {
            System.out.println("文件不存在");
        } catch (Exception e) {
            System.out.println("下载失败");
        } finally {
            httpURLConnection.disconnect();
        }
    }
}

给len的初始值为-1是因为,InputStream.read()方法返回一个整数,表示读取到的字节的值。如果已经到达流的末尾,则该方法返回-1。

InputStream.read()源代码

Java多线程下载器_第3张图片

检查当前位置(pos)是否大于等于缓冲区中的字节数(count)。如果是,则执行下一行代码。fill()这个方法会从输入流中读取更多的数据并将其存储在缓冲区中,然后再次检查位置(pos)是否大于等于缓冲区中的字节数(count)。如果是,则返回-1,表示已经到达输入流的末尾。

三、编写工具类✨

1. 日志工具类

日志工具类com.util.LogUtils

package com.util;

import java.time.LocalTime;
import java.time.format.DateTimeFormatter;

/**
 * @Author YZK
 * @Date 2023/10/19
 */
public class LogUtils {

    public static void info(String msg, Object... args) {
        print(msg, "-info-", args);
    }

    public static void error(String msg, Object... args) {
        print(msg, "-error-", args);
    }

    private static void print(String msg, String level, Object... args) {
        if (args != null && args.length > 0) {
            msg = String.format(msg.replace("{}", "%s"), args);
        }
        String name = Thread.currentThread().getName();
        System.out.println(LocalTime.now().format(DateTimeFormatter.ofPattern("hh:mm:ss")) + " " + name + level + msg);
    }
}

2. 文件工具类

文件工具类com.util.FileUtils用于针对文件相关操作,获取文件大小,比较文件MD5

package com.util;

import com.constant.Constant;
import com.sun.org.glassfish.gmbal.ManagedData;

import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.math.BigInteger;
import java.nio.channels.FileChannel;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;

/**
 * @Author YZK
 * @Date 2023/10/19
 */
public class FileUtils {
    /***
     *  获取本地文件的大小
     * @param path 文件路径
     * @return 文件大小
     */
    public static long getFileContentLength(String path) {
        File file = new File(path);
        return file.exists() && file.isFile() ? file.length() : 0;
    }

    /***
     *  获取文件MD5值
     * @param filePath 文件路径
     * @return boolean
     */
    public static String getFileMD5(String filePath) throws IOException, NoSuchAlgorithmException {
        MessageDigest md = MessageDigest.getInstance("MD5");
        FileInputStream fis = new FileInputStream(filePath);
        byte[] buffer = new byte[Constant.BYTE_SIZE];
        int length;
        while ((length = fis.read(buffer)) != -1) {
            md.update(buffer, 0, length);
        }
        fis.close();
        byte[] digest = md.digest();
        BigInteger bigInt = new BigInteger(1, digest);
        return bigInt.toString(16);
    }

    /***
     * 比较文件MD5
     * @param filePath1 文件1的路径
     * @param filePath2 文件2的路径
     * @return boolean
     */
    public static boolean verifyFileMD5(String filePath1, String filePath2) throws IOException, NoSuchAlgorithmException {
        String file1MD5 = getFileMD5(filePath1);
        String file2MD5 = getFileMD5(filePath2);
        return file1MD5.equals(file2MD5);
    }
}

3. 请求工具类

Http请求工具类com.util.HttpUtils用于处理http请求

package com.util;

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

/**
 * @Author YZK
 * @Date 2023/10/18
 */
public class HttpUtils {
    /***
     * 获取HttpURLConnection链接对象
     * @param url 文件地址
     * @return HttpURLConnection
     */
    public static HttpURLConnection getHttpURLConnection(String url) throws IOException {
        URL httpURL = new URL(url);
        HttpURLConnection httpURLConnection = (HttpURLConnection) httpURL.openConnection();
        //向文件所在的服务器发达标识信息
        httpURLConnection.setRequestProperty("User-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36 Edg/118.0.2088.46");
        return httpURLConnection;
    }

    /***
     *  获取文件名
     * @param url 文件地址
     * @return 文件名
     */
    public static String getFileName(String url) {
        //找到URL字符串中最后一个斜杠(/)的位置,将其索引值赋给整型变量index。
        int index = url.lastIndexOf("/");
        //从index+1的位置开始截取字符串,即从最后一个斜杠之后的部分开始截取,直到字符串末尾
        return url.substring(index + 1);
    }

}

四、文件下载信息

文件下载的时候最好能够展示出下载的速度,已下载文件大小等信息。这里可以每隔一段时间来获取文件的下载信息,比如间隔 1 秒获取一次,然后将信息打印到控制台。文件下载是个独立的线程,另外还需要再开启一个线程来间隔获取文件的信息。java,util.concurrent.ScheduledExecutorService,这个类可以帮助我们来实现此功能。

package com.core;

import com.constant.Constant;

/**
 * @Author YZK
 * @Date 2023/10/19
 * 展示下载信息
 */
public class DownloadInfoThread implements Runnable {

    //下载文件总大小
    private long httpFileContentLength;

    //本地已经下载文件的大小
    public double finishedSize;

    //本次累计下载的大小
    public volatile double downSize;
    
    //前一次下载的大小
    public double prevSize;

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

    @Override
    public void run() {
        //计算文件总大小,mb
        String httpFileSize = String.format("%.2f", httpFileContentLength / Constant.MB);
        //计算每秒下载速度,kb
        int speed = (int) ((downSize - prevSize) / 1024d);
        prevSize = downSize;
        //计算剩余文件大小
        double remainSize = httpFileContentLength - finishedSize - downSize;
        //计算剩余时间
        String remainTime = String.format("%.1f", remainSize / 1024d / speed);
        if ("Infinity".equalsIgnoreCase(remainTime)) {
            remainTime = "-";
        }
        //已下载文件大小
        String currentFileSize = String.format("%.2f", (downSize - finishedSize) / Constant.MB);
        String downInfo = String.format("已下载%smb/%smb,速度%skb/s,剩余时间%ss", currentFileSize, httpFileSize, speed, remainTime);
        System.out.print("\r");
        System.out.print(downInfo);
    }
}

五、线程池简介

本节与下载器关系不大,只是本人学习时的记录,可跳过

线程池,顾名思义,是一种用来存放和管理线程的机制。它的主要实现思想是基于池化技术,预先创建一定数量的线程并将它们存储在一个“池子”内。当有新的任务需要处理时,线程池内部会复用这些预先创建的线程来执行任务,避免了频繁地创建和销毁线程所带来的性能开销。

在具体的工作过程中,当一个并发任务提交给线程池时,线程池会进行一系列的判断与分配。首先,它会判断核心线程池中的所有线程是否都在执行任务,如果不是,就会新创建一个线程来执行刚提交的任务;如果核心线程池中所有的线程都在执行任务,那么新提交的任务就会被放置在阻塞队列中等待。

我们为什么需要使用线程池呢?首先,线程的创建和销毁是要占用一定的资源的。具体来说,创建线程会直接向系统申请资源,包括分配内存、列入调度等,同时线程还要进行上下文的切换。再者,对于Java来说,每个线程都需要一定的内存来存储上下文信息,并且这个内存是在Java堆外,不受我们的程序控制,只受系统资源限制。因此,如果我们为每个请求都新建一个线程,那么系统的内存资源很快就会被耗尽。通过使用线程池,我们可以降低频繁创建和销毁线程所带来的资源消耗,提高系统的性能和稳定性。

线程池工作过程

线程池的工作过程主要包括以下几个步骤:

  1. 当新任务提交到线程池时,会首先调用execute()方法。
  2. 接着,线程池会判断当前正在运行的线程数量是否小于核心线程数(corePoolSize)。如果小于,那么无论线程池中是否有空闲的线程,都会创建一个新的线程来执行这个新的任务。
  3. 如果线程池中所有的线程都在执行任务,即已达到核心线程数,那么新提交的任务会被放入阻塞队列中等待执行。
  4. 当线程池中总的线程数量超过最大线程数(maximumPoolSize)时,如果阻塞队列也已满,那么根据饱和策略来处理这个任务。常见的饱和策略有拒绝策略和抛弃旧任务策略等。
  5. 当线程池中所有的任务都执行完毕后,线程池将进入终止状态。

线程池的状态

线程池具有五种主要状态,分别是:Running、ShutDown、Stop、Tidying和Terminated。

  1. RUNNING:这是线程池的正常运行状态。在此状态下,线程池能够接收新任务,并对已添加的任务进行处理。

  2. SHUTDOWN:当线程池进入此状态时,它不再接收新任务。但是,所有已提交的任务将被执行完毕,同时线程池中已存在的线程将继续执行完它们的任务。

  3. STOP:与SHUTDOWN状态不同,当线程池处于STOP状态时,不仅不会接收新任务,而且会停止所有已存在的线程。

  4. TIDYING:在线程池关闭的过程中,线程池首先会进入Tidying状态。在此状态下,所有已接收但还未处理的任务都会被丢弃。

  5. TERMINATED:这是线程池的最终状态。当线程池中所有的任务都执行完毕后,线程池将进入此状态。

线程池的关闭

线程池的关闭主要涉及到shutdownshutdownNow两种方法。shutdown方法是用于拒绝新任务提交并等待已提交的任务执行完毕后再关闭线程池,这样可以避免正在执行的任务被突然中断。而shutdownNow方法则是用来立即停止接收新任务,并且尝试停止所有正在执行的任务,这种方式相对强硬,可能会导致正在执行的任务被中断。

在具体使用时,需要根据实际需求来选择使用哪种关闭方式。比如,如果希望尽快关闭线程池并且不关心已提交任务的执行情况,可以选择使用shutdownNow方法。反之,如果希望尽可能完成已提交任务的执行,然后再关闭线程池,那么shutdown方法是更合适的选择。

此外,需要注意的是,线程池的关闭过程是一个温和且安全的过程。在实际应用中,我们需要确保在适当的时候调用这些关闭方法,避免因为不正确的使用导致线程池无法正常关闭或者出现其他问题。

在Java中实现线程池

java.util.concurrent.ThreadPooTExecutor类构造方法参数详解

Java多线程下载器_第4张图片

corePoolSize:线程池中核心线程的数量

maximumPoolSize:线程池中最大线程数,是核心线程数和非核心线程数之和

keepAliveTime:非核心线程存活时间(非核心线程会被销毁,核心线程不会)

unit:存活时间单位

workQueue:当没有空闲的线程时,新的任务会加入到 workQueue 中排队等待

threadFactory:线程工厂,用于创造线程

handler:当线程池中的线程数量已经达到最大值,且队列中的任务数量也达到了最大值时,新提交的任务就会被拒绝执行。此时,如果handler不为空,就会调用它的rejectedExecution方法来处理被拒绝的任务。

public class PoolTest {
    public static void main(String[] args) {
        ThreadPoolExecutor threadPool = new ThreadPoolExecutor(2, 3, 1, TimeUnit.MINUTES,
                new ArrayBlockingQueue<>(2));
        Runnable r = () -> System.out.println(Thread.currentThread().getName());
        threadPool.execute(r);
    }
}

//运行结果
//pool-1-thread-1

JDK提供的更便捷的创建线程池的方式

Executors 是 Java 中的一个工厂类,专门用于创建和管理线程池。它提供了一系列静态工厂方法,能够创建不同类型的线程池,如 newCachedThreadPool()、newFixedThreadPool()、newSingleThreadExecutor() 和 newScheduledThreadPool() 等。这些工厂方法极大地简化了线程池的创建过程,隐藏了线程池的复杂性。

以下是 Executors 提供的几种主要线程池类型:

  • newCachedThreadPool():创建一个可缓存的线程池。这种类型的线程池可以重复使用已经创建的线程,如果当前任务结束,则会回收线程,并在下次需要时再次使用。
  • newFixedThreadPool():创建一个定长线程池,可以控制线程的最大并发数,以固定大小的线程池执行任务。
  • newSingleThreadExecutor():创建一个单线程化的线程池,它只会用唯一的工作线程来执行任务。
  • newScheduledThreadPool():创建一个定时任务线程池,它可以在给定延迟后运行命令,或者定期执行命令。

总的来说,Executors 提供了一种简单有效的方式来创建和管理线程池,是 Java 多线程编程中的重要工具类。

//这是一个固定线程池,它会预先创建5个线程并保持这些线程的可用状态。当提交任务时,如果有空闲线程,任务将立即执行;如果没有空闲线程,任务将在等待队列中等待,直到有线程可用。
ExecutorService executorService1 = Executors.newFixedThreadPool(5);
//这是一个可缓存线程池,它可以缓存最多127个线程。当提交任务时,如果有空闲线程,任务将立即执行;如果没有空闲线程,线程池将创建一个新线程来执行任务。当线程空闲超过60秒时,它们将被终止并从缓存中移除。
ExecutorService executorService2 = Executors.newCachedThreadPool();
//这是一个单线程执行器,它只使用一个线程来执行任务。这意味着提交的任务将按顺序执行,而不是并发执行。
ExecutorService executorService3 = Executors.newSingleThreadExecutor();
//这是一个定时线程池,它可以定时或周期性地执行任务。在这个例子中,它创建了一个包含5个线程的线程池,可以用来执行定时任务。
ScheduledExecutorService executorService4 = Executors.newScheduledThreadPool(5);

其内部实现,也是使用了上一节写到的java.util.concurrent.ThreadPooTExecutor类;但是!!!阿里巴巴开发文档中并不建议使用以上4中方式创建线程池,建议还是使用原生的创建方式

Java多线程下载器_第5张图片

六、文件切片下载

切片工具类

com.util.HttpUtils中添加以下方法

    /***
     *分块下载
     * @param url 文件地址
     * @param startPos 下载文件起始位置
     * @param endPos 下载文件结束位置
     * @return HttpURLConnection
     */
    public static HttpURLConnection getHttpURLConnection(String url, long startPos, long endPos) throws IOException {
        HttpURLConnection httpURLConnection = getHttpURLConnection(url);
        LogUtils.info("下载的区间是:{}-{}", startPos, endPos);
        if (endPos != 0) {
            httpURLConnection.setRequestProperty("Range", "bytes=" + startPos + "-" + endPos);
        }else {

            httpURLConnection.setRequestProperty("Range", "bytes=" + startPos);
        }
        return httpURLConnection;
    }

代码中判断endPos是否不等于0。这段代码是用于设置HTTP请求头的"Range"属性,以便在下载文件时指定要获取的字节范围。

  1. 首先判断endPos是否不等于0,如果不等于0,说明需要指定一个具体的字节范围;
  2. 如果endPos等于0,说明只需要指定起始字节位置startPos
  3. 根据判断结果,设置"Range"属性值为"bytes=" + startPos + “-” + endPos(如果endPos不等于0)或"bytes=" + startPos(如果endPos等于0)。

这样设置后,服务器会返回从startPosendPos之间的字节数据。

切片任务类

com.core.DownloadTask

package com.core;

import com.constant.Constant;
import com.util.HttpUtils;
import com.util.LogUtils;

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;

/**
 * @Author YZK
 * @Date 2023/10/25
 */
public class DownloadTask implements Callable<Boolean> {
    private String url;
    //下载的起始位置
    private long startPos;
    //下载的结束位置
    private long endPos;
    //标识当前文件是第几部分
    private int part;

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

    @Override
    public Boolean call() throws Exception {
        //获取文件名
        String httpFileName = HttpUtils.getFileName(url);
        //分块文件名
        httpFileName = httpFileName + ".temp" + part;
        //下载路径
        httpFileName = Constant.PATH;
        //获取分块下载链接
        HttpURLConnection httpURLConnection = HttpUtils.getHttpURLConnection(url, startPos, endPos);
        try (
                InputStream inputStream = httpURLConnection.getInputStream();
                BufferedInputStream bis = new BufferedInputStream(inputStream);
                RandomAccessFile accessFile = new RandomAccessFile(httpFileName, "rw");
        ) {
            byte[] buffer = new byte[Constant.BYTE_SIZE];
            int len = -1;
            while ((len = bis.read()) != -1) {
                accessFile.write(buffer, 0, len);
            }
        } catch (FileNotFoundException e) {
            LogUtils.error("下载的文件不存在{}",url);
            return false;
        } catch (Exception e) {
            LogUtils.error("文件下载异常");
            return false;
        }finally {
            httpURLConnection.disconnect();
        }

        return true;
    }
}

DownloadTask类实现了Callable接口,因此它可以被多个线程并发调用。在多线程环境下,当需要下载文件的一部分时,可以创建一个DownloadTask对象,并将其提交给线程池执行。线程池会自动分配一个线程来执行该任务,并返回一个Future对象,用于获取任务执行的结果。

文件切分下载

    /**
     * 文件切片
     *
     * @param url        文件地址
     * @param futureList 切片文件的列表
     */
    public void split(String url, List<Future> futureList) {
        //TODO 动态计算文件块的数量,过后以64mb为一块
        try {
            //获取文件下载大小
            long contentLength = HttpUtils.getHttpFileContentLength(url);
            //计算切分后的文件大小
            long size = contentLength / 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);
                //提交到线程池
                Future<Boolean> future = poolExecutor.submit(downloadTask);
                futureList.add(future);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

代码解释:

  1. 首先,通过HttpUtils.getHttpFileContentLength(url)获取文件的大小。
  2. 然后,计算每个线程需要下载的文件大小,这里使用contentLength除以线程数量(Constant.THREAD_NUM)。
  3. 接下来,使用for循环创建线程池中的线程,循环次数为线程数量。
  4. 在循环中,计算每个线程需要下载的起始位置和结束位置。如果是最后一个线程,则结束位置为0;否则,结束位置为起始位置加上每个线程需要下载的文件大小。
  5. 如果不是第一个线程,起始位置需要加1。
  6. 创建一个DownloadTask对象,传入URL、起始位置、结束位置和线程索引。
  7. 将DownloadTask对象提交到线程池执行,并将返回的Future对象添加到futureList中。
  8. 如果在执行过程中发生异常,捕获IOException并打印堆栈信息。

下载信息改成原子类操作

原子类(Atomic)是一种特殊的数据类型,它可以保证在多线程环境下对共享数据的访问是原子性的。这意味着在一个线程修改一个原子变量时,其他线程无法同时访问该变量,从而避免了数据不一致的问题。

修改成原子类的原因主要有以下几点:

  1. 线程安全:在多线程环境下,如果不使用原子类,可能会导致数据不一致的问题,例如多个线程同时修改同一个变量的值,最后得到的结果可能是错误的。使用原子类可以确保在任何时刻只有一个线程能够访问和修改共享数据,从而保证线程安全。
  2. 性能优化:在某些情况下,使用原子类可以提高程序的性能。例如,当需要对一个整数进行加法操作时,可以使用原子类的addAndGet()方法,这样可以避免使用synchronized关键字或者Lock对象来实现同步,从而提高性能。
  3. 简化代码:使用原子类可以简化代码,避免使用复杂的同步机制。例如,使用AtomicInteger代替int,使用AtomicLong代替long,可以让代码更加简洁易懂。
  4. 跨平台支持:许多编程语言都提供了原子类的支持,例如Java中的java.util.concurrent.atomic包。这使得在不同的平台上开发和维护代码变得更加容易。

修改上文文件下载信息类com.core.DownloadInfoThread

将finishedSize,downSize两个字段从double类型修改为LongAdder类型并初始化,run()方法中两个字段的值使用doubleValue()进行调用

package com.core;

import com.constant.Constant;

import java.util.concurrent.atomic.LongAdder;

/**
 * @Author YZK
 * @Date 2023/10/19
 * 展示下载信息
 */
public class DownloadInfoThread implements Runnable {

    //下载文件总大小
    private long httpFileContentLength;

    //本地已经下载文件的大小
    public static LongAdder finishedSize=new LongAdder();

    //本次累计下载的大小
    public static volatile LongAdder downSize=new LongAdder();
    //前一次下载的大小
    public double prevSize;

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

    @Override
    public void run() {
        //计算文件总大小,mb
        String httpFileSize = String.format("%.2f", httpFileContentLength / Constant.MB);
        //计算每秒下载速度,kb
        int speed = (int) ((downSize.doubleValue() - prevSize) / 1024d);
        prevSize = downSize.doubleValue();
        //计算剩余文件大小
        double remainSize = httpFileContentLength - finishedSize.doubleValue() - downSize.floatValue();
        //计算剩余时间
        String remainTime = String.format("%.1f", remainSize / 1024d / speed);
        if ("Infinity".equalsIgnoreCase(remainTime)) {
            remainTime = "-";
        }
        //已下载文件大小
        String currentFileSize = String.format("%.2f", (downSize.doubleValue() - finishedSize.doubleValue()) / Constant.MB);
        String downInfo = String.format("已下载%smb/%smb,速度%skb/s,剩余时间%ss", currentFileSize, httpFileSize, speed, remainTime);
        System.out.print("\r");
        System.out.print(downInfo);
    }
}

七、文件合并和清理临时文件

修改com.core.Downloader,在里面添加两个方法merge,clearTemp,并增加CountDownLatch来阻塞线程

package com.core;

import com.constant.Constant;
import com.util.FileUtils;
import com.util.HttpUtils;
import com.util.LogUtils;

import java.io.*;
import java.net.HttpURLConnection;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.*;

/**
 * @Author YZK
 * @Date 2023/10/18
 * 下载器核心类
 */
public class Downloader {

    private ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(1);

    private ThreadPoolExecutor poolExecutor = new ThreadPoolExecutor(
            Constant.THREAD_NUM,
            Constant.THREAD_NUM,
            0,
            TimeUnit.SECONDS,
            new ArrayBlockingQueue<>(Constant.THREAD_NUM)
    );

    private CountDownLatch countDownLatch = new CountDownLatch(Constant.THREAD_NUM);

    public void download(String url) {
        //获取文件名
        String httpFileName = HttpUtils.getFileName(url);
        //拼接文件下载路径
        httpFileName = Constant.PATH + httpFileName;
        //获取本地文件大小
        long localFileLength = FileUtils.getFileContentLength(httpFileName);
        //获取链接对象
        HttpURLConnection httpURLConnection = null;
        DownloadInfoThread downloadInfoThread = null;
        try {
            httpURLConnection = HttpUtils.getHttpURLConnection(url);
            //获取下载文件的总大小
            int contentLength = httpURLConnection.getContentLength();
            //判断文件是否已经下载过
            //Todo 过后使用MD5进行校验
            if (localFileLength >= contentLength) {
                LogUtils.info("{}文件已经下载完毕,无需重新下载", httpFileName);
                return;
            }
            downloadInfoThread = new DownloadInfoThread(contentLength);
            //将任务交给线程执行,延迟一秒执行,然后每隔一秒执行一次
            scheduledExecutorService.scheduleAtFixedRate(downloadInfoThread, 1, 1, TimeUnit.SECONDS);
            //切分任务
            ArrayList<Future> list = new ArrayList<>();
            split(url, list);
            countDownLatch.await();
            //合并文件
            if (merge(httpFileName)) {
                clearTemp(httpFileName);
            }

        } catch (IOException e) {
            e.printStackTrace();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        } finally {
            System.out.println("\r");
            System.out.print("下载完成");
            //关闭连接对象
            if (httpURLConnection != null) {
                httpURLConnection.disconnect();
            }

            //关闭
            scheduledExecutorService.shutdownNow();
            //关闭线程池
            poolExecutor.shutdown();
        }
    }

    /**
     * 文件切片
     *
     * @param url        文件地址
     * @param futureList 切片文件的列表
     */
    public void split(String url, List<Future> futureList) {
        //TODO 动态计算文件块的数量,过后以64mb为一块
        try {
            //获取文件下载大小
            long contentLength = HttpUtils.getHttpFileContentLength(url);
            //计算切分后的文件大小
            long size = contentLength / 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);
            }

        } catch (IOException e) {
            e.printStackTrace();
        }
    }


    /***
     * 文件合并
     * @param fileName 文件名
     * @return boolean
     */
    public boolean merge(String fileName) {
        LogUtils.info("开始合并文件{}", fileName);
        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(
                        Files.newInputStream(Paths.get(fileName + ".temp" + i)))) {
                    while ((len = bis.read(buffer)) != -1) {
                        accessFile.write(buffer, 0, len);
                    }
                }
            }
            LogUtils.info("文件合并完毕");
        } catch (Exception e) {
            e.printStackTrace();
            return false;
        }
        return true;
    }

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

}

修改com.core.DownloadTask

增加一个CountDownLatch字段,并修改构造方法,最后在finaly中添加 countDownLatch.countDown();

package com.core;

import com.constant.Constant;
import com.util.HttpUtils;
import com.util.LogUtils;

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 YZK
 * @Date 2023/10/25
 */
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.getFileName(url);
        //分块文件名
        httpFileName = httpFileName + ".temp" + part;
        //下载路径
        httpFileName = Constant.PATH + httpFileName;
        //获取分块下载链接
        HttpURLConnection httpURLConnection = HttpUtils.getHttpURLConnection(url, startPos, endPos);
        try (
                InputStream inputStream = httpURLConnection.getInputStream();
                BufferedInputStream bis = new BufferedInputStream(inputStream);
                RandomAccessFile accessFile = new RandomAccessFile(httpFileName, "rw");
        ) {
            byte[] buffer = new byte[Constant.BYTE_SIZE];
            int len = -1;
            while ((len = bis.read(buffer)) != -1) {
                //1秒内下载数据之和通过原子类进行操作
                DownloadInfoThread.downSize.add(len);
                accessFile.write(buffer, 0, len);
            }
        } catch (FileNotFoundException e) {
            LogUtils.error("下载的文件不存在{}", url);
            return false;
        } catch (Exception e) {
            LogUtils.error("文件下载异常");
            return false;
        } finally {
            httpURLConnection.disconnect();
            countDownLatch.countDown();
        }
        return true;
    }
}

项目代码地址

笑的像个两百斤的孩子-Gitee仓库-Java多线程下载器

报错记录

Server returned HTTP response code: 416 for URL: https://dldir1.qq.com/qqfile/qq/PCQQ9.7.17/QQ9.7.17.29230.exe

**问题:**写这个方法时,else的判断语句中,最后在setRequestProperty方法中,传第二个参数时,少拼接了一个"-",就导致文件切片不能下载完全,只能下载前面的几个部分

**原因:**因为没有拼接最后的"-",导致文件的size超出了RANGE。

Java多线程下载器_第6张图片

下载信息出现死循环

**问题:**输入链接地址开始下载后,下载区间的信息没有问题,文件也能正常的下载,一直执行不到finaly中,导致文件下载完成后,下载信息一直重复累加,出现下图情况。

Java多线程下载器_第7张图片

原因:

Java多线程下载器_第8张图片

如果在调用 read() 方法时不传入缓冲区,那么该方法将直接从输入流中读取数据,并将其转换为字节数组。这意味着每次调用 read() 方法都会返回实际读取到的字节数,而不会使用缓冲区来暂存数据。

参考资料

【动力节点】Java多线程下载器项目实战

你可能感兴趣的:(开发,java)