【Java】线程池技术(三)ThreadPoolExecutor 状态与运行源码解析

ThreadPoolExecutor 状态

ThreadPoolExecutor 继承了 AbstractExecutorService,并实现了 ExecutorService 接口,用于管理线程。内部使用了原子整型 AtomicInteger ctl 来表示线程池状态和 Worker 数量。前 3 位表示线程池状态,后 29 位表示 Worker 数量。

public class ThreadPoolExecutor extends `AbstractExecutorService` {private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));private static final int COUNT_BITS = Integer.SIZE - 3;private static final int CAPACITY   = (1 << COUNT_BITS) - 1;private static final int RUNNING    = -1 << COUNT_BITS;private static final int SHUTDOWN   =  0 << COUNT_BITS;private static final int STOP       =  1 << COUNT_BITS;private static final int TIDYING    =  2 << COUNT_BITS;private static final int TERMINATED =  3 << COUNT_BITS;private static int runStateOf(int c)     { return c & ~CAPACITY; }private static int workerCountOf(int c)  { return c & CAPACITY; }private static int ctlOf(int rs, int wc) { return rs | wc; }private static boolean runStateLessThan(int c, int s) {return c < s;}private static boolean runStateAtLeast(int c, int s) {return c >= s;}private static boolean isRunning(int c) {return c < SHUTDOWN;}......}

由源码中的常量定义可知,ThreadPoolExecutor 有 5 种线程池状态:

  • RUNNING:线程池接收新任务,会执行任务阻塞队列中的任务,ctl 前三位为 111
  • SHUTDOWN:线程池拒绝接收新任务,会执行任务阻塞队列中的任务,ctl 前三位为 000
  • STOP:线程池拒绝接收新任务,不会执行任务阻塞队列中的任务,尝试中断正在执行的任务,ctl 前三位为 001
  • TIDYING:所有任务被关闭,Worker 数量为 0,ctl 前三位为 010
  • TERMINATED:方法 terminated() 执行完毕,ctl 前三位为 011

通过 ctl 的结构可知,其前三位取值对应的线程池状态满足以下关系:

RUNNING < SHUTDOWN < STOP < TIDYING < TERMINATED

因此 runStateLessThan()runStateAtLeast()isRunning() 方法可以很方便的对线程池状态进行判断,不需要考虑当前 Worker 的具体数量。

img

执行任务源码分析

执行任务的方法为 execute(),其源码如下:

public void execute(Runnable command) {if (command == null)throw new NullPointerException();int c = ctl.get();// 1.如果正在运行的线程数少于核心线程数,则尝试以给定的操作作为其第一个任务以启动一个新线程。addWorker()的调用以原子方式检查了 runState 和 workerCount,从而防止由于误报 false 而导致的线程增加if (workerCountOf(c) < corePoolSize) {if (addWorker(command, true))return;c = ctl.get();}// 2.如果一个任务成功加入了阻塞队列,那么我们仍然需要仔细检查是否应该添加一个线程(因为自上次检查以来原有的线程可能已死亡)或自进入此方法以来,线程池已关闭。所以如果重新检查状态已停止,有必要则直接回滚入队操作,或者如果没有正在运行的线程,则启动一个新线程。if (isRunning(c) && workQueue.offer(command)) {int recheck = ctl.get();if (!isRunning(recheck) && remove(command))reject(command);else if (workerCountOf(recheck) == 0)addWorker(null, false);}// 3.如果无法将任务加入阻塞队列,那么我们尝试添加一个新的线程。 如果失败了,那我们就知道任务线程池已经关闭或饱和,因此拒绝任务。else if (!addWorker(command, false))reject(command);
}

execute() 方法根据 Worker 的数量和线程池状态来决定是新建 Worker 来执行任务,还是将任务添加到任务阻塞队列中。任务无法成功添加到阻塞队列或者新建 Worker 来执行任务失败,则执行任务拒绝策略。

private boolean addWorker(Runnable firstTask, boolean core) {retry:for (int c = ctl.get();;) {// 线程池不为 RUNNING 状态已停止工作,或为 SHUTDOWN 同时阻塞队列为空,则 Worker 创建失败if (runStateAtLeast(c, SHUTDOWN)&& (runStateAtLeast(c, STOP)|| firstTask != null|| workQueue.isEmpty()))return false;for (;;) {// 当前 Worker 数量大于 size,则拒绝创建 Worker。其中 core 为 true 对比核心线程数,core 为 false 则比对最大线程数。if (workerCountOf(c)>= ((core ? corePoolSize : maximumPoolSize) & COUNT_MASK))return false;// 以 CAS 方式将 Worker 数量加1,成功则跳出 retry 循环if (compareAndIncrementWorkerCount(c))break retry;// CAS 追加 Worker 数量失败,重新获取 ctl。c = ctl.get();  // 线程池状态改变,则需要基于新的线程池状态,重新执行外层循环来判断是否允许创建 Worker// 线程池状态不变,则继续执行内层循环,再次尝试以 CAS 方式增加 Worker 数量if (runStateAtLeast(c, SHUTDOWN))continue retry;}}boolean workerStarted = false;boolean workerAdded = false;Worker w = null;try {w = new Worker(firstTask);final Thread t = w.thread;if (t != null) {// 存储 Worker 的 HashSet 需要获取全局锁来保证添加 Worker 时的线程安全final ReentrantLock mainLock = this.mainLock;mainLock.lock();try {// 在获取全局锁之后,再次获取线程池最新的状态int c = ctl.get();// 线程池状态为 RUNNING 或 SHUTDOWN 同时创建 Worker 的初始任务为 nullif (isRunning(c) ||(runStateLessThan(c, STOP) && firstTask == null)) {// 避免 Worker 的线程被重复启动(处于活动状态的线程无法再次启动)if (t.isAlive())throw new IllegalThreadStateException();workers.add(w);// 记录线程池存在过的最大 Worker 数int s = workers.size();if (s > largestPoolSize)largestPoolSize = s;workerAdded = true;}} finally {mainLock.unlock();}if (workerAdded) {t.start();workerStarted = true;}}} finally {if (! workerStarted)addWorkerFailed(w);}return workerStarted;
}

addWorker() 方法中只有以下两种情况下可以创建 Worker:

  • 线程池状态为 RUNNIGNG
  • 线程池状态为 SHUTDOWN,且任务阻塞队列不为空,可以创建初始任务为 null 的 Worker

当 Worker 创建成功后,其线程就会启动,如果 Worker 创建失败或者线程启动失败,则会调用回滚方法 addWorkerFailed(),源码实现如下:

private void addWorkerFailed(Worker w) {final ReentrantLock mainLock = this.mainLock;mainLock.lock();try {if (w != null)workers.remove(w);decrementWorkerCount();tryTerminate();} finally {mainLock.unlock();}
}

在这个过程中所添加的 Worker 实体,实现了 Runnable 接口,因此它实际上就是一个任务,其线程执行的任务就是它本身。所以在 addWorker() 方法中启动线程时,会调用 Worker 的 run() 方法,实际上内部就是调用了 runWorker() 方法,源码实现如下:

final void runWorker(Worker w) {Thread wt = Thread.currentThread();Runnable task = w.firstTask;w.firstTask = null;// 释放了锁,以支持外部中断任务w.unlock();// 是否属于被中断的状态标识boolean completedAbruptly = true;try {// 获取 Worker 当前任务,或者从阻塞队列中取出下一个任务while (task != null || (task = getTask()) != null) {w.lock();// 当线程池为 STOPPING 状态时,要确保当前线程是被中断的(为了确保 shutdownNow() 方法在中断任务时能正确处理)if ((runStateAtLeast(ctl.get(), STOP) ||(Thread.interrupted() &&runStateAtLeast(ctl.get(), STOP))) &&!wt.isInterrupted())wt.interrupt();try {beforeExecute(wt, task);try {task.run();afterExecute(task, null);} catch (Throwable ex) {afterExecute(task, ex);throw ex;}} finally {task = null;w.completedTasks++;w.unlock();}}completedAbruptly = false;} finally {processWorkerExit(w, completedAbruptly);}
}

从循环的条件可以看出,Worker 启动后,会首先执行自己的初始任务,然后再去任务阻塞队列中获取任务。任务流程执行结束或被中断后,则会调用 processWorkerExit() 方法。当 Worker 任务执行异常,或获取的任务最终为 null 时,该方法会将当前 Worker 从集合中删除,并尝试终止线程池。processWorkerExit() 方法的源码实现如下:

private void processWorkerExit(Worker w, boolean completedAbruptly) {// 异常中断的任务,Worker 数量没有及时调整,需要修正if (completedAbruptly)decrementWorkerCount();final ReentrantLock mainLock = this.mainLock;mainLock.lock();try {completedTaskCount += w.completedTasks;workers.remove(w);} finally {mainLock.unlock();}// 尝试终止线程池tryTerminate();int c = ctl.get();if (runStateLessThan(c, STOP)) {// 如果线程池仍在可运行状态,由异常中断的导致被移除的 Worker 需要新增一个 null 任务来替换,以保持核心线程池数目或者阻塞队列的状态一致if (!completedAbruptly) {          int min = allowCoreThreadTimeOut ? 0 : corePoolSize;if (min == 0 && ! workQueue.isEmpty())min = 1;if (workerCountOf(c) >= min)return; // replacement not needed}addWorker(null, false);}
}

总结下来,ThreadPoolExecutor 执行任务主要由以下几步:

  1. 判断当前线程池状态是否允许创建 Worker

    • 线程池状态为 RUNNIGNG,可以创建 Worker
    • 线程池状态为 SHUTDOWN,且任务阻塞队列不为空,可以创建初始任务为 null 的 Worker
  2. 按照以下规则判断如何添加 Worker(执行任务):

    • 如果 Worker 数量小于核心线程数,则创建 Worker 来执行任务
    • 如果 Worker 数量大于等于核心线程数,则将任务添加到任务阻塞队列
    • 如果任务阻塞队列已满,则创建 Worker 来执行任务
    • 如果 Worker 数量已经达到最大线程数,此时执行任务拒绝策略
  3. Worker启动自身持有的线程,并执行自身实现的任务。启动后先执行自己的初始任务,然后再取任务阻塞队列中的任务。

关闭线程池源码分析

关闭 ThreadPoolExecutor 的方法主要有 shutdown()shutdownNow()

shutdown()

停止接收新任务,会把阻塞队列中的任务执行完毕。

public void shutdown() {final ReentrantLock mainLock = this.mainLock;mainLock.lock();try {// 检查相关的线程修改权限checkShutdownAccess();// 通过CAS将线程池状态修改为SHUTDOWNadvanceRunState(SHUTDOWN);// 中断空闲的WorkerinterruptIdleWorkers();// 为 ScheduledThreadPoolExecutor 提供的方法入口,根据指定的终止策略,中断和清除队列中所有任务onShutdown();} finally {mainLock.unlock();}// 尝试终止线程池tryTerminate();
}

空闲 Worker 的中断涉及到锁的获取,具体处理过程可以参考以下的源码:

private void interruptIdleWorkers(boolean onlyOne) {final ReentrantLock mainLock = this.mainLock;mainLock.lock();try {for (Worker w : workers) {Thread t = w.thread;// 中断线程前,尝试获取Worker的锁if (!t.isInterrupted() && w.tryLock()) {try {t.interrupt();} catch (SecurityException ignore) {} finally {w.unlock();}}if (onlyOne)break;}} finally {mainLock.unlock();}
}private void interruptIdleWorkers() {interruptIdleWorkers(false);
}

这里需要注意的是,Worker 除了实现了 Runnable 接口外,还继承了 AbstractQueuedSynchronizer,因此 Worker 本身还是一把锁,故有其自身实现锁的相关方法。以下是相关方法的实现:

public void lock()        { acquire(1); }
public boolean tryLock()  { return tryAcquire(1); }
public void unlock()      { release(1); }protected boolean tryAcquire(int unused) {if (compareAndSetState(0, 1)) {setExclusiveOwnerThread(Thread.currentThread());return true;}return false;
}
protected boolean tryRelease(int unused) {setExclusiveOwnerThread(null);setState(0);return true;
}

从源码中可以看出,Worker 的 lock() 方法调用了 AbstractQueuedSynchronizer 抽象类的 acquire() 方法,该方法内部又调用了 Worker 实现的 tryAcquire() 方法。tryAcquire() 方法通过 CAS 将锁的状态 STATE 从 0 设置为 1,由此可知 Worker 是一把不可重入锁。

正由于 Worker 是一把不可重入锁,正在执行任务的 Worker 是无法获取到锁的,只有线程池中没有执行任务的 Worker 才能被获取到锁,所以关闭线程池时对空闲 Worker 执行中断指令,实际上就是中断没有执行任务的空闲 Worker。正在执行任务的 Worker 在 shutdown() 方法被调用时不会被中断,而是继续执行完当前任务,随后再从任务阻塞队列中获取任务来执行,直到任务阻塞队列为空,紧接着当前 Worker 也会被删除。等到线程池中所有的 Worker 都被删除,以及任务阻塞队列任务清空后,线程池才会被终止掉。

总结下来,shutdown() 方法实现的内容就是:将线程池的状态设置为 SHUTDOWN,拒绝接收新的任务,等到线程池 Worker 数为 0,且任务阻塞队列为空时,关闭线程池。

shutdownNow()

停止接收新任务,中断当前所有的任务,并将线程池的状态置为 STOP。

public List<Runnable> shutdownNow() {List<Runnable> tasks;final ReentrantLock mainLock = this.mainLock;mainLock.lock();try {checkShutdownAccess();// 通过CAS方式将线程池状态设置为STOPadvanceRunState(STOP);// 中断所有WorkerinterruptWorkers();// 将任务阻塞队列中所有任务获取出来并返回tasks = drainQueue();} finally {mainLock.unlock();}// 尝试终止线程池tryTerminate();return tasks;
}private void interruptWorkers() {// 需要确保当前线程获取到了全局锁 this.mainLockfor (Worker w : workers)w.interruptIfStarted();
}

shutdownNow() 方法跟 shutdown() 方法的过程基本一致,在一些细节的具体实现上有所区别。首先会将线程池状态设置为 STOP,然后调用 interruptWorkers() 方法中断线程池中的所有 Worker,接着调用 tryTerminate() 方法尝试终止线程池,最后将任务阻塞队列中未被执行的任务返回。

shutdownNow() 方法调用后,线程池中的所有 Worker 都会被中断,包括正在执行任务的 Worker。也就是说 shutdownNow() 方法不会保证正在执行的任务会被安全的执行完毕,同时还会放弃任务阻塞队列中的所有任务。

tryTerminate()

前面多次提及的 tryTerminate() 方法可以确保线程池被正确的关闭,这里可以看看源码的具体实现:

final void tryTerminate() {for (;;) {int c = ctl.get();// 满足以下三种状态的线程池,不能被终止if (isRunning(c) ||runStateAtLeast(c, TIDYING) ||(runStateLessThan(c, STOP) && ! workQueue.isEmpty()))return;// Worker数量不为0时,中断一个空闲的Worker;内部传播了shudown信号if (workerCountOf(c) != 0) {interruptIdleWorkers(ONLY_ONE);return;}final ReentrantLock mainLock = this.mainLock;mainLock.lock();try {// 将线程池状态设置为TIDYINGif (ctl.compareAndSet(c, ctlOf(TIDYING, 0))) {try {// 终止线程池terminated();} finally {// 将线程池状态设置为TERMINATEDctl.set(ctlOf(TERMINATED, 0));termination.signalAll();}return;}} finally {mainLock.unlock();}// else retry on failed CAS}
}

线程池不能被终止的三种状态:

  • RUNNING - 正在执行任务
  • 大于或等于TIDYING - 正在进行终止流程或已经被终止
  • SHUTDOWN,但任务阻塞队列不为空 - 正在等待任务阻塞队列的任务被执行完

如果线程池不属于以上三种状态,此时可以通过中断一个空闲的 Worker,被中断的空闲 Worker 会在 getTask() 方法中返回 null,从而执行 processWorkerExit() 方法。在 processWorkerExit() 方法中,会删除当前的 Worker,又会再调用 tryTerminate() 方法,从而实现在所有空闲 Worker 之前传播 shutdown 信号。

以下是源码中提供的官方注释内容:

Transitions to TERMINATED state if either (SHUTDOWN and pool and queue empty) or (STOP and pool empty). If otherwise eligible to terminate but workerCount is nonzero, interrupts an idle worker to ensure that shutdown signals propagate. This method must be called following any action that might make termination possible – reducing worker count or removing tasks from the queue during shutdown. The method is non-private to allow access from ScheduledThreadPoolExecutor.

通过官方注释可知,只有在以下两种状态下,线程池能够被终止:

  • 线程池状态为 SHUTDOWN,Worker 数量为 0,任务阻塞队列为空
  • 线程池状态为 STOP,Worker 数量为 0

同时,在所有可能导致线程池终止的操作中都应该调用 tryTerminate() 方法来尝试终止线程池,因此线程池中 Worker 被删除时和任务阻塞队列中任务被删除时会调用 tryTerminate(),以达到在线程池符合终止条件时及时终止线程池。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/857012.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

python代码生成可执行文件

以下面转换图片尺寸的代码resize_images.py为例&#xff1a; 代码功能&#xff1a;原始图片放在img文件夹中&#xff0c;然后运行代码可以转换成指定分辨率&#xff0c;保存在同一目录下的新生成的文件夹中 import os import sys import cv2 from datetime import datetime f…

在React中,如何利用React.memo函数对函数组件进行优化?

React.memo 是 React 的一个高阶组件&#xff0c;用于对函数组件进行性能优化。它通过记忆化&#xff08;memoization&#xff09;来避免不必要的重新渲染。当组件的 props 没有变化时&#xff0c;React.memo 可以防止组件重新渲染&#xff0c;从而提高应用的性能。 使用 Reac…

wireshark抓包ssl数据出现ignored unknown record的原因

文章目录 前言一、出现原因二、wireshark抓包分析Ignored Unknown RecordTCP segment of a reassembled PDU 总结 前言 使用下面这个例子来观察记录层数据大于TCP MSS时用wireshark抓包出现ignored unknown record的情况并分析原因。 c语言利用openssl实现简单客户端和服务端&…

Rich Human Feedback for Text-to-Image Generation 读论文笔记

Rich Human Feedback for Text-to-Image Generation 读论文笔记 摘要方法细节收集数据的过程人类反馈确认 数据集VQA使用方法数据分析分数统计评价一致性&#xff08;pair alignment&#xff09; 实验模型模型架构模型变体模型其他优化 实验MetricsScoreHeatmapMisalignment 量…

什么是NLP-自然语言处理

什么是NLP-自然语言处理 什么是NLP开通NLP新建项目创建模型 什么是NLP NPL是面向算法小白用户的行业自适应标注、训练和服务平台。该产品支持文本实体抽取、文本分类、关键短语抽取、情感分析、关系抽取、短文本匹配、商品评价解析等 NLP 定制化算法能力&#xff0c;用户无需拥…

【变量与常量】

1.基本程序 容器用来存放物品 声明变量 num 是个用来装整数的变量 &#xff01; 一 个整数变量需要 4 个 byte 存储&#xff0c; 计算机 在内存里为你分配了 4 个 byte 。int num;在代码中&#xff0c;声明一个变量。 常用变量类型 存储不同类型的数据需要声明不同类型的变…

Steam怎么购买黄金树之影 购买了黄金树之影怎么下载DLC教程

《艾尔登法环》大型DLC“黄金树幽影”将于6月21日正式上线&#xff0c;为广大玩家带来全新的冒险与挑战。在“黄金树幽影”中&#xff0c;玩家将拥有专属的强化系统。通过收集探索幽影之地获得的“幽影树的碎片”和“灵灰的加护”&#xff0c;不仅可以大幅度提升玩家的攻击力与…

【从零到一】电子元器件网站建设/开发方案、流程及搭建要点全解

电子元器件行业在数字化转型的大潮下也迎来了前所未有的发展机遇。一个高效、专业、用户友好的电子元器件网站&#xff0c;不仅能够提升品牌形象&#xff0c;还能显著提高销售转化率&#xff0c;增强客户粘性。道合顺芯站点将详细阐述电子元器件开发方案、实施流程&#xff0c;…

从混乱到有序:SRM系统如何优化工厂采购流程

一、工厂采购管理的重要性 工厂采购管理是企业运营中的关键环节&#xff0c;它直接影响到生产成本、产品质量和市场响应速度。有效的采购管理能够降低成本、提升供应链的灵活性和响应市场变化的能力。在竞争激烈的市场环境中&#xff0c;采购管理的优劣直接关系到企业的竞争力…

HarmonyOS角落里的知识—Stage模型应用程序

开发态包结构 在DevEco Studio上创建一个项目工程&#xff0c;并尝试创建多个不同类型的Module。根据实际工程中的目录对照本章节进行学习&#xff0c;可以有助于理解开发态的应用程序结构。 图1 项目工程结构示意图&#xff08;以实际为准&#xff09; 工程结构主要包含的文…

解释React中的“端口(Portals)”是什么,以及如何使用它来渲染子节点到DOM树以外的部分。

React中的“端口&#xff08;Portals&#xff09;”是一种将子节点渲染到DOM****树以外的部分的技术。在React应用中&#xff0c;通常情况下组件的渲染是遵循DOM的层次结构&#xff0c;即子组件会渲染在父组件的DOM节点内部。然而&#xff0c;有些情况下&#xff0c;开发者可能…

4K高质量视频素材库,剪辑必备

找免费4K 高质量视频素材&#xff0c;就上这8个网站&#xff0c;剪辑必备&#xff0c;赶紧收藏吧&#xff01; 1、baotu 【实拍视频】免费下载_实拍视频素材​ibaotu.com/shipin/7-5026-0-0-0-1.html?spmzhkolws​编辑 国内高质量素材网站&#xff0c;涵盖设计、新媒体、视频…

有效提升智能会议系统语音识别准确性案例分享

语音识别技术是在智能会议系统中至关重要&#xff0c;准确的智能会议语音识别能力&#xff0c;意味着会议参与者可以通过语音命令来控制会议设备&#xff0c;如开启投影仪、调整音量、切换幻灯片或者记录会议纪要&#xff0c;节省时间并提高会议效率。多语言支持的语音识别技术…

【C++LeetCode】【热题100】字母异位词分组【中等】-不同效率的题解【3】

题目&#xff1a; 暴力方法&#xff1a; class Solution { public:vector<vector<string>> groupAnagrams(vector<string>& strs) {std::unordered_set<std::string> uniqueWord;//单词字符唯一化集合vector<vector<std::string>>…

深入Android S (12.0) 探索Framework之输入子系统InputDispatcher的流程

Framework层之输入系统 第一篇 深入Android S (12.0) 探索Framework之输入系统IMS的构成与启动 第二篇 深入Android S (12.0) 探索Framework之输入子系统InputReader的流程 第三篇 深入Android S (12.0) 探索Framework之输入子系统InputDispatcher的流程 文章目录 Framework层…

微信小程序-界面提示框和消息

一.Loading加载框 小程序提供了wx.showLoading用来在加载界面的时候使用&#xff0c;比如加载图片和数据的时候可以使用。 常常和wx.hideLoading()配合使用&#xff0c;否则加载框一直存在。 其效果如下&#xff1a; 代码如下&#xff1a; //显示加载消息wx.showLoading({//提…

【机器学习】Lasso回归:稀疏建模与特征选择的艺术

&#x1f308;个人主页: 鑫宝Code &#x1f525;热门专栏: 闲话杂谈&#xff5c; 炫酷HTML | JavaScript基础 ​&#x1f4ab;个人格言: "如无必要&#xff0c;勿增实体" 文章目录 Lasso回归&#xff1a;稀疏建模与特征选择的艺术引言一、Lasso回归简介1.1 基本…

丰臣秀吉-读书笔记五

如今直面自己一生中的最高点&#xff0c;加之平日里的觉悟与希冀&#xff0c;此时此地他“一定要死得其所”。 “武士之道&#xff0c;便是在死的瞬间决定一生或华或实。一生谨慎、千锤百炼&#xff0c;如果在死亡这条路上一步走错&#xff0c;那么一生的言行便全部失去真意&am…

帕金森的锻炼方式

帕金森病&#xff0c;这个看似陌生的名词&#xff0c;其实离我们并不遥远。它是一种常见的神经系统疾病&#xff0c;影响着许多中老年人的生活质量。虽然帕金森病目前尚无根治之法&#xff0c;但通过科学合理的日常锻炼&#xff0c;可以有效缓解病情&#xff0c;提高生活质量。…

录的视频太大怎么压缩?这几款软件真的很不错!

在数字化时代&#xff0c;视频已成为我们日常生活和工作中不可或缺的一部分。无论是记录生活点滴&#xff0c;还是制作工作汇报&#xff0c;视频都以其直观、生动的特点赢得了我们的青睐。然而&#xff0c;随着视频质量的提升&#xff0c;视频文件的大小也在不断增加&#xff0…