目录
一,速度对比
单线程下载,耗时160s
多线程下载,耗时54s
二,HTTP协议Range请求头
Range主要是针对只需要获取部分资源的范围请求,通过指定Range即可告知服务器资源的指定范围,格式:Range: bytes=start-end
比如:获取字节范围 5001-10000
Range: bytes=5001-10000
也可以指定开始位置不指定结束位置,表示获取开始位置之后的全部数据
Range: bytes=5001-
服务器接收到带有Range
的请求,会在处理请求后的返回状态码为206 Partial Content
的响应。
基于Range的特性,我们就可以实现文件的多线程下载,文件的断点续传。
三,准备工作
1,RestTemplate绕过SSL验证
本文我们使用的是springmvc的RestTemplate,由于链接是Https,所以我们需要设置RestTemplate绕过证书验证。
public class RestTemplateBuilder {
public static RestTemplateBuilder builder(){
return new RestTemplateBuilder();
}
public RestTemplate build() throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
SSL factory = new SSL();
factory.setReadTimeout(5000);
factory.setConnectTimeout(15000);
return new RestTemplate(factory);
}
public static class SSL extends SimpleClientHttpRequestFactory {
@Override
protected void prepareConnection(HttpURLConnection connection, String httpMethod)
throws IOException {
if (connection instanceof HttpsURLConnection) {
prepareHttpsConnection((HttpsURLConnection) connection);
}
super.prepareConnection(connection, httpMethod);
}
private void prepareHttpsConnection(HttpsURLConnection connection) {
connection.setHostnameVerifier(new SkipHostnameVerifier());
try {
connection.setSSLSocketFactory(createSslSocketFactory());
}
catch (Exception ex) {
// Ignore
}
}
private SSLSocketFactory createSslSocketFactory() throws Exception {
SSLContext context = SSLContext.getInstance("TLS");
context.init(null, new TrustManager[] { new SkipX509TrustManager() },
new SecureRandom());
return context.getSocketFactory();
}
private class SkipHostnameVerifier implements HostnameVerifier {
@Override
public boolean verify(String s, SSLSession sslSession) {
return true;
}
}
private static class SkipX509TrustManager implements X509TrustManager {
@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[0];
}
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) {
}
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) {
}
}
}
}
2,定义DisplayDownloadSpeed下载速度接口
在下载的过程中,我们需要知道当前下载的速度是多少,所以需要定义一个显示下载速度的接口
public interface DisplayDownloadSpeed {
/**
* 显示下载速度
*/
void displaySpeed(String task,long contentLength);
}
3,了解ResponseExtractor接口
因为计算下载速度,我们需要知道每秒传输的字节数是多少,为了监控传输数据的过程,我们需要了解SpringMVC中的接口ResponseExtractor
@FunctionalInterface
public interface ResponseExtractor<T> {
/**
* Extract data from the given {@code ClientHttpResponse} and return it.
* @param response the HTTP response
* @return the extracted data
* @throws IOException in case of I/O errors
*/
@Nullable
T extractData(ClientHttpResponse response) throws IOException;
}
该接口只有一个方法,当客户端和服务端连接建立之后,会调用这个方法,我们可以在这个方法中监控下载的速度。
4,DisplayDownloadSpeed接口的抽象实现AbstractDisplayDownloadSpeedResponseExtractor
public abstract class AbstractDisplayDownloadSpeedResponseExtractor<T> implements ResponseExtractor<T>,DisplayDownloadSpeed {
/**
* 显示下载速度
*/
@Override
public void displaySpeed(String task, long contentLength) {
long totalSize = contentLength / 1024;
CompletableFuture.runAsync(()->{
long temp = 0;
long speed;
StringBuilder stringBuilder = new StringBuilder();
while (contentLength - temp > 0){
speed = getAlreadyDownloadLength() - temp;
temp = getAlreadyDownloadLength();
stringBuilder.append("\r");//不换行进行覆盖
stringBuilder.append(task + " 文件总大小: " + totalSize + " KB,已下载:" + (temp / 1024) + "KB,下载速度:"+ (speed/1000)+"KB/S");
System.out.print(stringBuilder.toString());
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
});
}
@Override
public T extractData(ClientHttpResponse clientHttpResponse) throws IOException {
long contentLength = clientHttpResponse.getHeaders().getContentLength();
this.displaySpeed(Thread.currentThread().getName(),contentLength);
return this.doExtractData(clientHttpResponse);
}
public abstract T doExtractData(ClientHttpResponse clientHttpResponse) throws IOException;
/**
* 获取已经下载了多少字节
*/
protected abstract long getAlreadyDownloadLength();
}
四,简单的文件下载器
这里使用的RestTemplate调用execute,先文件获取到字节数组,再将字节数组直接写到目标文件。
这里我们需要注意的点是:这种方式会将文件的字节数组全部放入内存中,及其消耗资源,我们来看看如何实现。
1,创建ByteArrayResponseExtractor类继承AbstractDisplayDownloadSpeedResponseExtractor
/**
* 这种方式会将文件的字节数组全部放入内存中,及其消耗资源,只适用于小文件的下载,如果下载几个G的文件,内存肯定是不够用的
*/
@Deprecated
public class ByteArrayResponseExtractor extends AbstractDisplayDownloadSpeedResponseExtractor<byte[]> {
//保存已经下载的字节数
private long byteCount;
@Override
public byte[] doExtractData(ClientHttpResponse clientHttpResponse) throws IOException {
long contentLength = clientHttpResponse.getHeaders().getContentLength();
ByteArrayOutputStream out = new ByteArrayOutputStream(contentLength >= 0 ? (int) contentLength : StreamUtils.BUFFER_SIZE);
InputStream in = clientHttpResponse.getBody();
int byteRead;
for (byte[] buffer = new byte[4096]; (byteRead = in.read(buffer)) != -1; byteCount += byteRead) {
out.write(buffer,0,byteRead);
}
out.flush();
return out.toByteArray();
}
@Override
protected long getAlreadyDownloadLength() {
return byteCount;
}
}
2,调用RestTemplate.execute()执行下载,保存字节数据到文件中
/**
* 单线程基于内存下载
*
* @param fileURL
* @param filePath
* @throws NoSuchAlgorithmException
* @throws KeyStoreException
* @throws KeyManagementException
*/
public void downloadToMemory(String fileURL, String filePath) throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
long start = System.currentTimeMillis();
RestTemplate restTemplate = RestTemplateBuilder.builder().build();
byte[] bytes = restTemplate.execute(fileURL, HttpMethod.GET, null, new ByteArrayResponseExtractor());
try {
Files.write(Paths.get(filePath), Objects.requireNonNull(bytes));
System.out.println("总共文件下载耗时:" + (System.currentTimeMillis() - start) / 1000 + "s");
} catch (IOException e) {
throw new RuntimeException(e);
}
}
测试下载
public static void main(String[] args) throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException, IOException, InterruptedException {
FileDownloader fileDownloader = new FileDownloader();
String fileURL = "https://mirrors.tuna.tsinghua.edu.cn/eclipse/4diac/releases/2.0/4diac-ide/4diac-ide_2.0.0-linux.gtk.x86_64.tar.gz";
fileDownloader.downloadToMemory(fileURL,"C:\\Users\\fanto\\Downloads\test.tar.gz");
}
执行一段时间后,我们可以看到内存已经使用了800M左右,所以这种方式会将文件的字节数组全部放入内存中,及其消耗资源,只适用于小文件的下载,如果下载几个G的文件,内存肯定是不够用的。
五,单线程大文件下载
上面的方式只能下载小的文件,那大文件的下载该用什么方式呢?我们可以把流输出到文件而不是内存中,接下来我们实现大文件的下载。
1,创建FileResponseExtractor类继承AbstractDisplayDownloadSpeedResponseExtractor
把流输出到文件中
public class FileResponseExtractor extends AbstractDisplayDownloadSpeedResponseExtractor<File> {
//已下载的字节数
private long byteCount;
//文件的路径
private String filePath;
public FileResponseExtractor(String filePath) {
this.filePath = filePath;
}
@Override
public File doExtractData(ClientHttpResponse clientHttpResponse) throws IOException {
long contentLength = clientHttpResponse.getHeaders().getContentLength();
InputStream in = clientHttpResponse.getBody();
File file = new File(filePath);
FileOutputStream out = new FileOutputStream(file);
int byteRead;
for (byte[] buffer = new byte[4096]; (byteRead = in.read(buffer)) != -1; byteCount += byteRead) {
out.write(buffer,0,byteRead);
}
out.flush();
out.close();
return file;
}
@Override
protected long getAlreadyDownloadLength() {
return byteCount;
}
}
2,文件下载器,先把流输出到临时文件(xxxx.download),下载完成再重命名文件
/**
* 单线程基于文件下载
*
* @param fileURL
* @param filePath
* @throws NoSuchAlgorithmException
* @throws KeyStoreException
* @throws KeyManagementException
*/
public void downloadToFile(String fileURL, String filePath) throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException {
long start = System.currentTimeMillis();
RestTemplate restTemplate = RestTemplateBuilder.builder().build();
FileResponseExtractor fileResponseExtractor = new FileResponseExtractor(filePath + ".download");
File tempFile = restTemplate.execute(fileURL, HttpMethod.GET, null, fileResponseExtractor);
tempFile.renameTo(new File(filePath));
System.out.println("总共文件下载耗时:" + (System.currentTimeMillis() - start) / 1000 + "s");
}
测试下载
public static void main(String[] args) throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException, IOException, InterruptedException {
FileDownloader fileDownloader = new FileDownloader();
String fileURL = "https://mirrors.tuna.tsinghua.edu.cn/eclipse/4diac/releases/2.0/4diac-ide/4diac-ide_2.0.0-linux.gtk.x86_64.tar.gz";
fileDownloader.downloadToFile(fileURL, "C:\\Users\\fanto\\Downloads\\test.tar.gz");
}
执行一段时间后,我们再看看内存的使用情况,发现这种方式内存消耗较少,效果比较理想。
六,多线程大文件下载
思路
- 先请求文件,返回文件大小
- 根据文件大小,给多个线程分配要请求的部分文件大小(均匀分配,最后一个线程或多或少)
- 开启多线程,每个线程去调用上面单线程的逻辑,请求头Range指定文件大小的范围
1,提供多线程下载的方法downloadToFileMultiThread()
public void downloadToFileMultiThread(String fileURL, String filePath, int threadNumber) throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException, IOException, InterruptedException {
long start = System.currentTimeMillis();
//先请求文件,得到文件总大小
URL url = new URL(fileURL);
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
conn.setConnectTimeout(10000);
conn.setRequestMethod("GET");
// 得到需要下载的文件大小
long fileLength = conn.getContentLengthLong();
conn.disconnect();
/*
* 计算每条线程下载的字节数,以及每条线程起始下载位置与结束的下载位置,
* 因为不一定平均分,所以最后一条线程下载剩余的字节
* 然后创建线程任务并启动
* Main线程等待每条线程结束(join()方法)
*/
long oneThreadReadByteLength = fileLength / threadNumber;
CountDownLatch countDownLatch = new CountDownLatch(threadNumber);
for (int i = 0; i < threadNumber; i++) {
long startPosition = i * oneThreadReadByteLength;
long endPosition = i == threadNumber - 1 ? fileLength : (i + 1) * oneThreadReadByteLength - 1;
Thread t = new Thread(new Task(startPosition, endPosition, countDownLatch, filePath, fileURL, "download" + i));
t.start();
}
countDownLatch.await();
mergeTempFiles(filePath, threadNumber);
System.out.println("总共文件下载耗时:" + (System.currentTimeMillis() - start) / 1000 + "s");
}
public void mergeTempFiles(String filePath, int threadNumber) throws IOException, InterruptedException {
System.out.println("开始合并");
//开始合并
OutputStream os = new BufferedOutputStream(new FileOutputStream(filePath));
//对临时目录的所有文件分片进行遍历,进行合并
for (int i = 0; i < threadNumber; i++) {
File tempFile = new File(filePath + ".download" + String.valueOf(i));
System.out.println("文件名称:" + tempFile.getAbsolutePath());
while (!tempFile.exists()) {
Thread.sleep(100);
}
byte[] bytes = FileUtils.readFileToByteArray(tempFile);
os.write(bytes);
os.flush();
tempFile.delete();
}
os.close();
}
2,线程类
class Task implements Runnable {
private long startPosition;
private long endPosition;
private CountDownLatch countDownLatch;
// private RestTemplate restTemplate;
private String filePath;
private String fileURL;
private String tempName;
Task(long startPosition,
long endPosition,
CountDownLatch countDownLatch,
String filePath,
String fileURL,
String tempName) {
this.startPosition = startPosition;
this.endPosition = endPosition;
this.countDownLatch = countDownLatch;
this.filePath = filePath;
this.fileURL = fileURL;
this.tempName = tempName;
}
@Override
public void run() {
try {
RestTemplate restTemplate = RestTemplateBuilder.builder().build();
FileResponseExtractor fileResponseExtractor = new FileResponseExtractor(filePath + "." + tempName);
// 借助拦截器的方式来实现塞统一的请求头
ClientHttpRequestInterceptor interceptor = (httpRequest, bytes, execution) -> {
httpRequest.getHeaders().set("Range", "bytes=" + startPosition + "-" + endPosition);
return execution.execute(httpRequest, bytes);
};
restTemplate.getInterceptors().add(interceptor);
File tempFile = restTemplate.execute(fileURL, HttpMethod.GET, null, fileResponseExtractor);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException(e);
} catch (KeyStoreException e) {
throw new RuntimeException(e);
} catch (KeyManagementException e) {
throw new RuntimeException(e);
} finally {
countDownLatch.countDown();
}
}
}
3,下载测试
public static void main(String[] args) throws NoSuchAlgorithmException, KeyStoreException, KeyManagementException, IOException, InterruptedException {
FileDownloader fileDownloader = new FileDownloader();
String fileURL = "https://mirrors.tuna.tsinghua.edu.cn/eclipse/4diac/releases/2.0/4diac-ide/4diac-ide_2.0.0-linux.gtk.x86_64.tar.gz";
fileDownloader.downloadToFileMultiThread(fileURL, "C:\\Users\\fanto\\Downloads\\test.tar.gz", 10);
}
七,扩展
1,下载使用进度条展示
进度条工具类
package com.example.demo.web;
import java.io.IOException;
public class PrintProgressBar {
//总大小
private long size;
//必须设置总大小
public PrintProgressBar(long size) {
this.size = size;
}
//配置
//是否打印进度条
private boolean printProgressBar = true;
//是否打印速度
private boolean printSpeed = true;
//是否打印百分比
private boolean printPercentage = true;
//是否打印总大小
private boolean printSize = true;
//是否开启单位换算
private boolean byteConversion = true;
//进度条长度
private int percentageLength = 100;
//是否在结束时自动打印信息
private boolean autoPrintTime = true;
//减少打印次数
private boolean print100 = true;
//单位
private String conversion = "个";
public PrintProgressBar setPrintProgressBar(boolean printProgressBar) {
this.printProgressBar = printProgressBar;
return this;
}
public PrintProgressBar setPrintSpeed(boolean printSpeed) {
this.printSpeed = printSpeed;
return this;
}
public PrintProgressBar setPrintPercentage(boolean printPercentage) {
this.printPercentage = printPercentage;
return this;
}
public PrintProgressBar setPrintSize(boolean printSize) {
this.printSize = printSize;
return this;
}
public PrintProgressBar setByteConversion(boolean byteConversion) {
this.byteConversion = byteConversion;
return this;
}
public PrintProgressBar setPercentageLength(int percentageLength) {
this.percentageLength = percentageLength;
return this;
}
public PrintProgressBar setAutoPrintTime(boolean autoPrintTime) {
this.autoPrintTime = autoPrintTime;
return this;
}
public PrintProgressBar setPrint100(boolean print100) {
this.print100 = print100;
return this;
}
public PrintProgressBar setConversion(String conversion) {
this.conversion = conversion;
return this;
}
//时间
private long timeStart;//最开始的时间
private long timeEnd;//完全结束的时间
private double progress;//已完成进度(百分比)
private long count;//已完成进度(数量)
//速度
private long speedStart;//记录每秒速度的开始时间
//记录单位时间内执行的数据
private long speedNum;
//记录速度值, 放在这里是因为不一定每一次打印都要刷新速度, 中间的间隔可以用记录在这里的旧数据
private long speed;
//记录完成百分比, 用于减少打印次数
private int flag;
//内部计算总完成量
public void printAppend(long count) {
this.count += count;
print(this.count);
}
/**
* 核心方法
* @param count 当前完成的数量
*/
public void print(long count) {
//开始计时
if (timeStart == 0) timeStart = speedStart = System.currentTimeMillis();
//如果需要在下载完成后自动打印总耗时和平均速度, 需要每次都进行计算完成度, 当这个值不小于100则代表完成
if (autoPrintTime) progress = count * 100 / (size + 0.0);
double percentage = 0;//当前完成百分比
//获取当前完成百分比
{
if (percentageLength != 100) {//自定义进度条长度后要根据进度条长度进行计算
percentage = count * percentageLength / (size + 0.0);
} else if (!autoPrintTime) {//默认的进度条长度并且没有开启结束自动打印, 需要在这里计算完成百分比
percentage = count * 100 / (size + 0.0);
} else {//默认的进度条长度并且 开启 了结束自动打印, 计算步骤以在上面完成, 无需再次计算
percentage = progress;
}
}
if (print100 && percentage < flag) {//当前进度还满足打印条件
return;
} else {
flag++;
}
//准备打印
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append("\r");//不换行进行覆盖
//打印进度条
if (printProgressBar){
stringBuilder.append("[");
for (int i = 0; i < percentage; i++) {
stringBuilder.append("=");
}
stringBuilder.append(">");
for (int i = 0; i < percentageLength - percentage; i++) {
stringBuilder.append(" ");
}
stringBuilder.append("]");
}
//打印百分比
if (printPercentage){
if (percentageLength == 100) {//进度条长度默认100, 不用重新计算
stringBuilder.append(String.format("%.2f", percentage));
} else if(autoPrintTime) {//开启了结束后自动打印, 百分比已经计算, 不用重新计算
stringBuilder.append(String.format("%.2f", progress));
} else {//自定义了进度条长度并且关闭了结束后自动打印, 打印百分比要计算值
stringBuilder.append(String.format("%.2f", count * 100 / (size + 0.0)));
}
stringBuilder.append("%");
}
//打印总大小
if (printSize) {
stringBuilder.append(" 总大小: ");
if (byteConversion) {
getByteConversion(stringBuilder, size, false);
} else {
stringBuilder.append(size);
stringBuilder.append(conversion);
}
}
//打印速度
if (printSpeed){
//获取当前时间
long speedEnd = System.currentTimeMillis();
//计算当前时间 减去 上次打印速度的时间
long time = speedEnd - speedStart;
if (time >= 1000 || //距离上次打印时间超过1秒才会更新速度数据
(time != 0 && speedEnd - timeStart < 1000)) {//或者程序总执行时间还不到1秒也可以计算
//当前进度减去上次记录的进度, 从毫秒转换到秒
speed = (count - speedNum) * 1000 / time;
speedNum = count;//记录这次的进度, 给下次计算速度的时候提供数据
speedStart = speedEnd;//记录这次的时间, 给下次计算速度的时候提供数据
}
stringBuilder.append(" 速度: ");
//开启单位换算
{
if (byteConversion) {//进制转换
getByteConversion(stringBuilder, speed, true);
} else {//不需要进制转换
stringBuilder.append(speed);
stringBuilder.append(conversion);
stringBuilder.append("/s");
}
}
}
System.out.print(stringBuilder.toString());
if (autoPrintTime) {
//完成进度大于等于100则打印总耗时和平均速度
if (progress >= 100) {
printTime();
}
}
}
/**
* 打印总耗时和平均每秒速度
*/
public void printTime() {
//设置结束时间
if (timeEnd == 0) timeEnd = System.currentTimeMillis();
//获取总时间
long time = timeEnd - timeStart;
//时间转换倍率
int conversion = 1;
//打印时间单位
String timeConversion = "";
//获取时间单位和转换倍率
{
if (time / 1000 >= 60 && time / 1000 < 60 * 60) {//大于等于一分钟, 小于一小时
conversion = 60;
timeConversion = "分钟";
} else if (time / 1000 >= 60 * 60) {//大于等于一小时
conversion = 60 * 60;
timeConversion = "小时";
} else {
timeConversion = "秒";
}
}
//准备打印
StringBuilder stringBuilder = new StringBuilder();
//打印时间
{
stringBuilder.append("\n");//刚刚打印完进度条, 需要换行
stringBuilder.append("总共耗时: ");
//总毫秒 转换成秒在 除 转换倍率 ---> 保留两位小数点
stringBuilder.append(String.format("%.2f", (time + 0.0) / conversion / 1000));
stringBuilder.append(timeConversion);
}
//打印平均速度
{
//time小于1必然发生 除0 异常
if (time > 1) {
stringBuilder.append("\n");
stringBuilder.append("平均速度: ");
//总大小 除 总时间(秒)
double byteConversionCount = size / ((time + 0.0) / 1000);
if (byteConversion) {//进制转换
getByteConversion(stringBuilder, byteConversionCount, true);
} else {
//不进制转换
stringBuilder.append(String.format("%.2f", byteConversionCount));
stringBuilder.append(this.conversion);
stringBuilder.append("/s");
}
}
}
//打印
System.out.println(stringBuilder.toString());
}
/**
* 进制转换
* @param stringBuilder 将转换后的数据放在这个StringBuilder中
* @param num 需要转换的数据
* @param printConversion 是否打印 “/s”
*/
public void getByteConversion(StringBuilder stringBuilder, double num, boolean printConversion) {
if (num < 1024) {
stringBuilder.append(String.format("%.2f", num));
stringBuilder.append("B");
} else if (num < 1024 * 1024) {
stringBuilder.append(String.format("%.2f", num / 1024));
stringBuilder.append("KB");
} else {
stringBuilder.append(String.format("%.2f", num / 1024 / 1024));
stringBuilder.append("MB");
}
if (printConversion) stringBuilder.append("/s");
}
public PrintProgressBar noPrintProgressBar() {
printProgressBar = false;
return this;
}
public PrintProgressBar noPrintSpeed() {
printSpeed = false;
return this;
}
public PrintProgressBar noPrintPercentage() {
printPercentage = false;
return this;
}
public PrintProgressBar noPrintSize() {
printSize = false;
return this;
}
public PrintProgressBar noByteConversion() {
byteConversion = false;
return this;
}
public PrintProgressBar noAutoPrintTime() {
autoPrintTime = false;
return this;
}
public PrintProgressBar noPrint100() {
print100 = false;
return this;
}
public static void main(String[] args) throws InterruptedException, IOException {
long size = 1003;
//创建对象并且赋值总大小
PrintProgressBar printProgressBar = new PrintProgressBar(size)
//自定义配置
// .noPrintProgressBar()//取消打印进度条
// .noPrintSpeed()//取消打印速度
// .noPrintPercentage()//取消打印百分比
// .noPrintSize()//取消打印总大小
.noByteConversion()//取消字节转换
.setPercentageLength(50)//设置进度条长度
// .noAutoPrintTime()//取消完成后自动打印总耗时和平均每秒速度
// .noPrint100()//增加打印次数, 实时监控, 对性能有略微影响(在我的渣渣机子上打印20亿次仅影响10秒)
.setConversion("只")//自定义单位(此配置需要关闭字节转换才有效果)
;
for (long i = 0; i <= size; i++) {
printProgressBar.print(i);//打印进度条
Thread.sleep(1);//程序执行太快, 需要睡一会
}
}
}
2,运行效果
3,Java控制台打印如何覆盖打印(原行更新)
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append("\r");//不换行进行覆盖
stringBuilder.append(task + " 文件总大小: " + totalSize + " KB,已下载:" + (temp / 1024) + "KB,下载速度:"+ (speed/1000)+"KB/S");
System.out.print(stringBuilder.toString());
通过\r
并且打印不换行就可以实现覆盖(原行更新)的效果啦!
文章主要部分转载自:https://www.xiaohongshu.com/explore/632de36c000000001801b9c5
标签:Java,String,stringBuilder,30,long,new,多线程,public,append From: https://www.cnblogs.com/fantongxue/p/17150777.html