
Java 解压 RAR 文件这件事我前后折腾了差不多三年第一版代码用纯 Java 库跑得好好的某天业务方传上来一个用新版压缩工具打出来的包直接抛异常换命令行方案之后又遇上容器镜像里没有解压程序再后来做跨境文件处理文件名全是中文乱码排查了大半天才发现问题出在编码标志位上。这篇文章就把我用过的三种 Java 解压 RAR 文件的方案完整摊开讲——调系统命令、用纯 Java 库、走 JNI 绑定本地库。每一种我都会给出可以照着抄的实现代码、参数含义、能力边界还有那些文档里不会写、只有跑挂了才会知道的细节。适合正在做文件批量导入、附件解包、数据归档处理的朋友不管你是刚接手一个老项目还是正准备从零搭一套文件处理服务都能直接从里面挑一个方案落地。1. 先弄清 RAR 在 Java 里为什么这么别扭1.1 RAR 和 ZIP 的底层差异决定了工具选型很多人第一次写解压代码时会有个直觉ZIP 用java.util.zip一句话搞定RAR 应该也差不多。真上手才发现 JDK 标准库里压根就没有 RAR 的影子连java.util.zip下面的类名里都找不到相关字眼。原因不复杂ZIP 是公开规范、格式文档齐全、多家实现互相兼容RAR 是私有格式压缩算法和文件头结构由一方掌控、随版本演进规范文档并不完整公开JDK 这种要求实现完全自主可控的库自然不会有它。这个差异会一路影响后面的所有决策。ZIP 你可以纯内存流式读写RAR 你多半得依赖外部实现ZIP 的每个条目独立压缩、随机读取很方便RAR 有固实压缩模式前一个文件的数据会影响后一个文件的解压必须顺序解码。理解了这一层你就明白为什么网上搜到的 RAR 解压代码翻来覆去就那么几种套路——不是大家不想创新是可选的路本来就这么窄。还有一个容易被忽略的点RAR 格式本身在演进。RAR4 的文件头魔术字节是52 61 72 21 1A 07 00RAR5 变成了52 61 72 21 1A 07 01 00多了一个字节。头结构、CRC 校验方式、时间戳存储、文件名编码标志位全都变了。这意味着一个只认 RAR4 的库遇到 RAR5 的包会直接报不是有效的压缩文件。我见过太多项目在测试环境跑得好好的上线后用户传了新版本压缩工具打的包全线崩掉根因就是这一行魔术字节。所以选方案之前先问自己三个问题压缩包是谁生产的、格式版本能不能控制运行环境能不能装外部程序要不要支持密码、分卷、固实压缩。这三个答案基本就把方案定死了。1.2 三种方案的取舍地图先把全景摆出来后面再逐个拆。维度命令行外部进程纯 Java 库junrar 系JNI 绑定7-Zip 绑定库是否需要外部程序需要且必须预装不需要不需要外部程序但需要平台原生库RAR4 支持完整完整完整RAR5 支持取决于外部程序版本新版本支持较好老版本不行完整加密压缩包支持支持基础加密支持含加密头分卷压缩包支持部分支持支持部署复杂度低但要管环境最低中高多平台原生库可控性超时、资源隔离好进程级隔离一般同进程内存一般典型故障模式程序找不到、路径空格、输出阻塞格式不支持、乱码原生库加载失败、架构不匹配我个人的经验是内部可控的文件处理走纯 Java 库面向不确定用户的入口走命令行或 JNI 绑定做产品级通用解压能力就直接上 JNI 绑定。原因后面每一节都会对应讲清楚。2. 方式一调用系统解压命令落地最快的一条路2.1 为什么这个土办法在生产环境里一直活着命令行方案说白了就是把解压这件事外包给一个成熟的本地程序Java 只负责调度和收尾。它的优势不在技术优雅而在于能力上限等于那个外部程序的能力上限。你装的是最新版解压工具它就认识最新的格式它支持分卷、支持加密头、支持固实压缩你什么都不用做。反过来纯 Java 库每多支持一个特性都要等库作者去逆向、去实现、去修 bug。另一个隐性好处是故障隔离。解压畸形压缩包的时候某些库可能出现内存暴涨甚至把 JVM 带崩的情况而外部进程再怎么炸最多是这个子进程挂了你的主服务读一下退出码就知道结果。做大文件批量处理时这一点非常关键我做过一个每天要处理上千个压缩包的导入服务早期用同进程库遇到一个构造异常的包直接把整个 worker 拖死迁到进程方案之后再没出现过雪崩。当然代价也明显你得管环境。开发机上有测试环境可能没有容器镜像是精简版可能连解压程序都没装Windows 上的程序路径带空格Program Files这种目录一不小心就踩雷。这些后面都会给具体解法。2.2 完整实现进程封装、输出消费与超时控制先看核心代码。这里我用ProcessBuilder而不是老掉牙的Runtime.exec(String)原因很实在Runtime.exec(String)会把命令按空格切分路径里只要有空格就崩而且没法处理带引号的参数。ProcessBuilder用变长参数列表路径里有空格、有中文原样传进去就行。import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.nio.charset.Charset; import java.nio.file.Path; import java.util.ArrayList; import java.util.List; import java.util.concurrent.TimeUnit; public class CommandRarExtractor { public static void extract(Path rarFile, Path destDir, String password, long timeoutSeconds) throws IOException, InterruptedException { ListString cmd new ArrayList(); cmd.add(unrar); // 或绝对路径 /opt/tools/unrar cmd.add(x); // x 保留目录结构解压 cmd.add(-o); // 覆盖已存在文件 cmd.add(-y); // 所有询问默认 yes cmd.add(-p (password null ? - : password)); // -p- 表示不询问密码 cmd.add(rarFile.toAbsolutePath().toString()); cmd.add(destDir.toAbsolutePath().toString() java.io.File.separator); ProcessBuilder pb new ProcessBuilder(cmd); pb.redirectErrorStream(true); // 合并错误流避免双流互相阻塞 Process process pb.start(); // 必须消费输出否则管道缓冲区写满后子进程会卡住 StringBuilder log new StringBuilder(); Thread reader new Thread(() - { try (BufferedReader br new BufferedReader( new InputStreamReader(process.getInputStream(), Charset.defaultCharset()))) { String line; while ((line br.readLine()) ! null) { log.append(line).append(\n); } } catch (IOException ignored) { } }); reader.setDaemon(true); reader.start(); boolean finished process.waitFor(timeoutSeconds, TimeUnit.SECONDS); if (!finished) { process.destroyForcibly(); throw new IOException(解压超时已强制终止 rarFile); } int exitCode process.exitValue(); reader.join(2000); // unrar 的退出码 0 成功1 警告部分文件失败其余为错误 if (exitCode ! 0) { throw new IOException(解压失败退出码 exitCode 输出\n log); } } }这段代码里有两个细节值得单独说。第一redirectErrorStream(true)配合独立线程消费输出是命令行方案的生死线。很多人在本地测试没问题一上生产就解压卡住不动排查半天发现是子进程往标准输出写了大量进度信息管道缓冲区Linux 上通常 64KB写满后子进程阻塞在写操作上而 Java 这边在waitFor()上死等双方互相僵持。这个坑我踩过一次排查了整整一个下午。第二waitFor必须带超时。不带超时的waitFor()是无限等待一旦外部程序因为权限、磁盘满、损坏包等原因挂在那里你的线程就永久占用了。2.3 参数详解和几个必踩的坑unrar和7z两套命令行工具的参数不完全一样我把常用参数对照整理出来需求unrar 参数7z 参数备注保留目录结构xx用e会把所有文件平铺到同一层指定输出目录最后追加目录参数-odir7z 的-o后面不能有空格覆盖已有文件-o-yunrar 默认会询问必须显式指定不覆盖-o-配合脚本判断增量处理常用指定密码-ppwd-ppwd密码为空写-p-全部确认-y-y自动化必备静默输出-inul-bso0 -bsp0减少输出量注意Windows 上如果调用的是图形界面压缩工具目录里的命令行程序路径带空格是常态务必用绝对路径并交给ProcessBuilder的列表参数处理不要自己拼字符串加引号。自己拼引号在不同 shell 下行为不一致是最容易埋雷的写法。再补充三个实操心得。第一个是不要用Runtime.getRuntime().exec()的字符串重载这个 API 的分词逻辑简单粗暴路径里带空格、参数里带中文统统会出问题。第二个是临时目录要独立每次解压分配一个独立子目录解压成功后再原子性移动到目标位置避免解压失败留下半截文件污染数据。第三个是退出码要按工具区分unrar返回 1 表示有警告但完成了比如部分文件损坏如果你把 1 也当失败就会误判大量其实可用的包。我在一个项目里就因为这个问题把 30% 的可用数据判成了坏包白白重跑了一晚上。环境检测这块也建议提前做。服务启动时执行一次unrar或7z的无参调用能起来就记个标记起不来就在健康检查里直接报出来别等到用户上传文件了才发现环境缺东西。容器镜像里记得把这个程序一起装进去精简镜像默认是不带的。3. 方式二纯 Java 库零外部依赖的干净方案3.1 junrar 的能力边界尤其是 RAR4 和 RAR5 的分水岭junrar 是 Java 生态里用得最多的 RAR 解压库纯 Java 实现pom.xml里加一行依赖就能用不需要在服务器上装任何东西。这对容器化部署特别友好——镜像可以做到最小也不会因为宿主机环境不同出现我这能跑你那不能跑的问题。但它的能力边界必须提前搞清楚否则一定会在某次上线时翻车。最核心的问题就是 RAR5 支持。junrar 的 4.x 系列网上教程最常引用的那批只认 RAR4你拿一个 RAR5 的包喂进去报错信息往往是corrupt或者not a rar file之类看不出任何有价值的信息。7.x 系列对 RAR5 的支持明显改善常规的 RAR5 单卷包能正常解。但即便如此遇到加密的 RAR5、特殊的分卷命名、固实压缩的极端组合仍然可能出现解压中途失败。所以我的建议是如果你能控制压缩包的生产端就明确约定用 RAR4 或者干脆让业务方改用 ZIP如果控制不了一定要做版本探测和失败兜底探测到 RAR5 就切换到另外两种方案之一。探测方法很简单读文件前 8 个字节判断魔术字节就行public static int detectRarVersion(Path file) throws IOException { byte[] magic new byte[8]; try (java.io.InputStream in java.nio.file.Files.newInputStream(file)) { int read in.read(magic); if (read 7) return -1; } // RAR4: 52 61 72 21 1A 07 00 // RAR5: 52 61 72 21 1A 07 01 00 if (magic[0] 0x52 magic[1] 0x61 magic[2] 0x72 magic[3] 0x21 magic[4] 0x1A magic[5] 0x07) { if (magic[6] 0x00) return 4; if (magic[6] 0x01) return 5; } return -1; }这十几行代码能帮你省掉大量排查时间。把它放在解压入口处日志里打出来出问题时一眼就知道是格式问题还是别的问题。3.2 完整代码逐条目解压和路径穿越防护直接调extractAll很省事但生产代码我建议逐条目处理因为这样你才能做路径校验、才能跳过不需要的条目、才能统计进度。下面是 7.x API 的写法import com.github.junrar.Archive; import com.github.junrar.rarfile.FileHeader; import com.github.junrar.volume.FileVolumeManager; import java.io.OutputStream; import java.nio.file.Files; import java.nio.file.Path; public class JunrarExtractor { public static void extract(Path rarFile, Path destDir, String password) throws Exception { Path base destDir.toAbsolutePath().normalize(); Files.createDirectories(base); // 7.xArchive(VolumeManager, password)实现了 Closeable try (Archive archive new Archive(new FileVolumeManager(rarFile.toFile()), password)) { if (archive.isEncrypted() password null) { throw new IllegalStateException(压缩包已加密但未提供密码); } for (FileHeader header : archive.getFileHeaders()) { String name header.getFileName(); Path target base.resolve(name).normalize(); // 关键防护阻止 ../ 形式的路径穿越 if (!target.startsWith(base)) { throw new SecurityException(非法条目路径 name); } if (header.isDirectory()) { Files.createDirectories(target); continue; } Files.createDirectories(target.getParent()); try (OutputStream out Files.newOutputStream(target)) { archive.extractFile(header, out); } } } } }如果你手上是 4.x 版本写法会略有差异new Archive(File)、archive.getHeaders()、archive.extractFile(header, out)。升级版本时这几个 API 名字都变过建议先写个最小用例跑通再集成到业务里。target.startsWith(base)这一行千万别省。压缩包里的条目名是可以随便写的攻击者完全可以构造一个含../../../etc/xxx的条目你的程序老老实实按路径写出去后果不用我多说。这在业内叫 Zip Slip虽然名字里带 Zip但所有压缩格式都适用。我是吃过教训之后才养成这个习惯的——早年间一个内部工具就有这个漏洞虽然没造成实际损失但安全扫描报出来的时候脸上确实挂不住。3.3 中文文件名乱码的处理思路junrar 里最让人抓狂的问题就是中文文件名。压缩包内文件名的存储方式跟压缩时的操作系统区域设置强相关在中文环境下用某些工具压缩文件名可能是按本地编码存的在 Unix 环境下压缩可能是 UTF-8而 RAR 格式里有一个 unicode 标志位设置了这个标志的条目文件名会以 UTF-16 形式额外存一份。处理顺序建议是这样的先调header.getFileName()看结果对不对如果能正常显示中文就不用管如果显示成问号或乱码说明编码解析没对上需要看header.isUnicode()标志或者退回到原始字节自己用指定字符集解码。有些版本提供了返回原始字节的接口可以new String(rawBytes, GBK)或者new String(rawBytes, UTF-8)试一下。这个问题的本质在于一旦压缩包里存的是非 Unicode 的本地编码你能依靠的只有猜。所以我一般会做一层尝试解出来的文件名如果包含大量不可打印字符或者典型的乱码特征比如连续的问号、半个汉字对应的方块就换成 GBK 再解一次。听着不优雅但实际很有效。更根本的解法是在业务协议层面约定压缩包必须用 UTF-8 文件名从源头消灭这个问题。我在做文件交换类项目时都会在对接文档里明确写这一条比事后修补省太多事。4. 方式三JNI 绑定本地解压库能力最全的方案4.1 为什么要为它忍受原生库的麻烦7-Zip-JBinding 这个名字有点误导它不是 7z 格式的库实质是把一个成熟本地解压引擎通过 JNI 封装成了 Java 接口因此能力上限非常高RAR4、RAR5 都能读加密压缩包包括加密文件名头、多卷分卷、固实压缩基本上本地引擎能的它都能。接口设计也算合理提供ISevenZipInArchive这种面向条目的 API可以逐条目回调解压。选它的典型场景是你对外提供一个通用的文件上传解压能力用户的压缩包怎么来的你完全不知道格式版本、加密方式、分卷方式都可能出现。这种什么都要能吃下的需求纯 Java 库很难做到 100% 覆盖命令行方案又要求每个节点都装好程序JNI 绑定就成了折中选择——不依赖外部程序能力又足够全。代价是原生库加载。JNI 需要针对不同操作系统、不同 CPU 架构加载对应的本地动态库这些库被打包在单独的 jar 里。如果你的部署环境是 x86_64 的常规服务器直接引入全平台包最省事但如果要跑在 ARM 架构的机器上就得确认对应平台的库是否存在。这是这个方案最大的不确定性来源一定要在正式环境验证过再推广。4.2 依赖引入和原生库加载的稳妥做法依赖通常需要两个坐标一个是 Java API 部分一个是本地库部分。全平台包体积会大一些好处是不用挑平台dependency groupIdnet.sf.sevenzipjbinding/groupId artifactIdsevenzipjbinding/artifactId version16.02-2.01/version /dependency dependency groupIdnet.sf.sevenzipjbinding/groupId artifactIdsevenzipjbinding-all-platforms/artifactId version16.02-2.01/version /dependency版本号建议选你所在团队验证过的不要盲目追新。本地库加载失败是这类方案最常见的启动期故障典型报错是no native library found或Unsupported platform。摆平这个问题的思路有三个层次第一层确认 jar 里有你要的平台。全平台包会按Linux-amd64、Linux-aarch64、Windows-amd64这类目录组织用jar tf看一眼就知道有没有你的目标平台。第二层运行环境可能是精简镜像。有些基础镜像裁掉了 C 运行库的某些组件本地库虽然找到了但加载时报UnsatisfiedLinkError。这时需要补装基础运行库具体依赖看报错的符号名。第三层实在搞不定就走降级链。我在一个项目里的做法是定义一个解压器接口实现三个版本JNI 优先、纯 Java 兜底、命令行最后兜底。启动时挨个探测可用性记到配置里。这样即使某个环境原生库加载不了服务还能降级跑不至于整个功能不可用。这个设计后来救过至少两次线上事故。4.3 完整代码和逐条目回调的写法核心代码大概是这个结构用RandomAccessFileInStream打开文件流遍历条目把每个条目解到目标路径import net.sf.sevenzipjbinding.ExtractOperationResult; import net.sf.sevenzipjbinding.ISequentialOutStream; import net.sf.sevenzipjbinding.ISevenZipInArchive; import net.sf.sevenzipjbinding.SevenZip; import net.sf.sevenzipjbinding.impl.RandomAccessFileInStream; import java.io.OutputStream; import java.io.RandomAccessFile; import java.nio.file.Files; import java.nio.file.Path; public class SevenZipExtractor { public static void extract(Path rarFile, Path destDir, String password) throws Exception { Path base destDir.toAbsolutePath().normalize(); Files.createDirectories(base); try (RandomAccessFile raf new RandomAccessFile(rarFile.toFile(), r); ISevenZipInArchive inArchive SevenZip.openInArchive(null, new RandomAccessFileInStream(raf))) { int count inArchive.getNumberOfItems(); for (int i 0; i count; i) { String path String.valueOf(inArchive.getProperty(i, net.sf.sevenzipjbinding.PropID.PATH)); Boolean isFolder (Boolean) inArchive.getProperty(i, net.sf.sevenzipjbinding.PropID.IS_FOLDER); Path target base.resolve(path).normalize(); if (!target.startsWith(base)) { throw new SecurityException(非法条目路径 path); } if (Boolean.TRUE.equals(isFolder)) { Files.createDirectories(target); continue; } Files.createDirectories(target.getParent()); try (OutputStream out Files.newOutputStream(target)) { ISequentialOutStream outStream data - { out.write(data); return data.length; // 必须返回写入长度并声明继续 }; ExtractOperationResult result inArchive.extractSlow(i, outStream, password); if (result ! ExtractOperationResult.OK) { throw new IllegalStateException(条目解压失败 path 原因 result); } } } } } }几个要点值得强调。extractSlow的第三个参数是密码整个压缩包如果用的是加密文件名头密码需要在打开归档时通过回调提供而不是在extractSlow里这一点很多人会混淆导致明明密码正确却报错。ISequentialOutStream.write必须返回实际接收的字节数返回 0 或者负数会被引擎判定为下游不愿意接收,直接中止。RandomAccessFileInStream背后是一个随机访问的文件句柄所以能高效定位条目。分卷压缩包的处理需要额外实现一个提供卷列表的回调接口告诉引擎去哪儿找下一个分卷。如果业务里确实有分卷需求这部分要单独测别指望它自动找到同目录下的分卷文件——默认行为不一定符合你的命名习惯。5. 三种方案横向对比与选型决策5.1 性能和资源占用的实际感受我做过的粗略对比同一台机器、同一批约 200MB 的压缩包、常规 4 核 8G 环境指标命令行外部进程纯 Java 库JNI 绑定单包耗时相对值1.01.3 ~ 1.61.0 ~ 1.1峰值内存JVM 内低独立进程中高同进程堆内中原生内存为主并发能力好天然进程隔离一般受堆限制一般失败对主服务影响几乎无有一定风险有一定风险单元测试友好度差依赖环境最好中依赖原生库纯 Java 库慢一些是合理的因为它要在 JVM 里做解码计算还要频繁分配缓冲区。JNI 快的核心原因是解压计算跑在原生代码里。命令行方案耗时看起来正常但它有进程创建开销包很小的时候这个开销占比会很明显——如果你要处理成千上万个几十 KB 的小包进程创建的成本可能比解压本身还高这时候应该优先考虑前面两种。5.2 按场景对号入座光列指标没用实际选型看的是场景。我按常见情况整理了一份对照场景特征推荐方案理由内部系统压缩包由自家程序生成格式可控纯 Java 库零部署成本测试方便面向 C 端用户的上传解压JNI 绑定格式五花八门能力要全服务器容器化禁止额外安装程序纯 Java 库 / JNI 绑定都不依赖外部程序超大文件、批量处理、追求稳定性命令行外部进程进程级隔离崩了不影响主服务已有成熟解压命令行工具链命令行外部进程复用现有运维能力需要 RAR5 加密 分卷全覆盖JNI 绑定纯 Java 库边界较多快速验证、临时脚本、数据一次性迁移命令行外部进程十分钟就能写出来最后一个场景我想多说一句。很多人一上来就想搞个通用方案,结果在选型上纠结很久。其实如果你的需求是一次性数据迁移写个 shell 循环调命令行就够了根本不用进 Java 代码。工程上最忌讳的就是为了通用性付出过高的复杂度而实际需求一辈子只用一次。6. 常见问题与排查实录6.1 问题速查表这张表是这几年被问得最多、自己也踩过的问题汇总遇到故障先来这里对照一遍现象最可能原因排查动作与解法纯 Java 库报不是有效压缩文件压缩包是 RAR5库版本太老读魔术字节确认版本升级库版本或换方案解压卡住不返回子进程输出未被消费、管道写满加redirectErrorStream 独立线程读输出解压卡住且磁盘已满磁盘空间不足子进程等待解压前检查剩余空间设置超时中文文件名全是问号压缩包内文件名用本地编码存储判断 unicode 标志用 GBK/UTF-8 重新解码报找不到命令运行环境没装解压程序启动时做环境探测容器镜像补装原生命库加载失败平台 jar 缺失或架构不匹配用全平台包检查基础镜像的运行库解压出来了但文件是空的密码错误或条目被跳过检查密码核对条目解压返回值部分文件缺失使用了平铺模式或路径处理不当确认用x而非e检查路径拼接大压缩包解压时 OOM用了全量读取到内存的写法改为流式写入缓冲区 8KB 左右即可6.2 几个不容易被想到的坑缓冲区大小不是越大越好。很多人喜欢把缓冲区设成 1MB、4MB觉得越大越快。实测下来 8KB 到 64KB 之间差别不大超过之后反而因为缓存不友好导致性能下降而且并发一多内存占用是线性放大的。我在一个并发 20 的服务里把缓冲区从 8KB 改成 1MB内存直接多占了 200MB性能却没提升。现在我的默认值是 32KB。密码不要拼在命令行参数里。命令行方案中-p密码这种写法会在系统的进程列表里明文暴露同机器上的其他用户ps一下就能看到。临时文件、日志里也别打密码。真要上生产密码应该从配置中心或环境变量取用完即弃并且避免在异常堆栈里带出完整命令行。并发解压要限流。解压是 CPU 和磁盘双密集型操作无限制并发会让你所有请求都变慢。我一般的做法是给解压专门开一个固定大小的线程池大小取 CPU 核数的 1 到 2 倍超出的任务排队。别用公共线程池否则解压任务会把其他业务任务全堵住。解压后一定要清理。临时目录、半成品文件、失败残留这些不清理跑几个月磁盘就满了。我的做法是每次解压用独立的临时目录try-finally里保证删除只有在最终成功移动之后才不清理。提示如果业务允许最省事的方案其实是从源头推动格式统一。RAR 在很多场景下只是因为历史习惯才被使用改成 ZIP 之后Java 侧用标准库就能解决依赖、性能、稳定性问题一次性消失。我在两个项目里推动过这件事投入的沟通成本远低于长期维护三套解压方案的成本。最后说说我个人的体会。这三种方案我都在生产环境长期跑过没有哪个是最优解,真正决定成败的往往不是选哪个库而是你有没有把边界想清楚格式版本会不会变、运行环境能不能保证、失败之后怎么兜底。我在现在这套服务里的做法是三个实现都留着用一个统一的解压入口按文件特征和运行环境动态选择配置能改、日志能查、降级能跑。多写的那两百行代码换来的是半夜不用被报警电话叫起来——这笔账怎么算都值。