Java基础压缩与解压

本文最后更新于:14 天前

引言

Java 中压缩包的必要性

在企业级应用、数据处理、文件存储、接口交互等场景中,压缩包的处理能力已经成为 Java 后端服务不可或缺的一部分。常见原因包括:

  • 节省带宽与存储:打包压缩减少传输体积,特别适用于大文件传输或归档存储。
  • 批量文件打包:便于文件整体上传、下载、分发与管理。
  • 第三方系统对接:很多政府采购、电商平台、ERP 系统上传资料要求为压缩包格式。
  • 安全合规:通过压缩与加密结合实现敏感数据保护。

Java 提供了较为灵活的文件操作能力,但对非 ZIP 格式(如 7Z、RAR)的原生支持有限,这促使开发者寻求更强大、跨平台、可扩展的解决方案。

Java 对压缩的支持

Java 8 标准库对压缩格式的支持如下:

格式 原生支持 支持程度 是否可写 是否可读 说明
ZIP 中等 java.util.zip 提供压缩与解压 API
JAR JAR 基于 ZIP,支持良好
GZIP 仅支持单文件压缩
7Z 需依赖第三方库
RAR 部分 依赖第三方库,仅支持解压

常用第三方库概览:

库名 支持格式 是否跨平台 说明
Zip4j ZIP(含加密) 支持 AES 加密、分卷
Apache Commons Compress ZIP、7Z、TAR、GZ、AR 等 支持广泛,但功能偏读取
junrar RAR(仅解压 RAR4) 仅兼容 RAR4,更新缓慢
SevenZipJBinding / JNA 7Z、RAR、ZIP ❌(需 native) 支持丰富,但需要平台依赖

典型业务场景与痛点

场景 描述 常见挑战
文件上传解析 用户上传压缩包,系统解压并读取文件内容 格式兼容、编码乱码、压缩炸弹、中文目录名
日志归档与备份 将服务日志、临时文件定期压缩归档 性能、压缩率、多线程处理
报表导出 生成多个文件,打包 ZIP 提供下载 临时文件清理、内存管理
跨系统数据交换 与其他系统通过压缩文件交互数据 RAR/7Z 支持、加密校验、兼容性
安全传输 结合压缩与加密确保数据传输安全 加密算法支持、密码管理、破解防护

压缩基础知识

压缩格式对比

格式 类型 支持多文件 是否支持加密 典型用途 特点
ZIP 容器格式 ✅(部分支持 AES) 通用压缩、上传、下载 Java 原生支持,通用性强
7Z 容器格式 ✅(强加密) 高压缩率需求,如打包安装包 支持多种算法,压缩率高
RAR 容器格式 ✅(较强) 游戏、资源压缩包、数据备份 专利格式,支持分卷、恢复记录
GZ 单文件压缩 Linux 日志压缩、流式传输 极简格式,仅压缩单文件
JAR Java 专用 ZIP ❌(仅支持签名校验) Java 类/资源打包 本质是 ZIP,支持清单与签名

注意: ZIP 与 JAR 可互相解压;但 7Z 和 RAR 对 Java 来说需第三方库解析。

压缩算法

算法 适用格式 压缩率 速度 特点
Deflate ZIP、GZ、JAR Java 内置实现,平衡型
LZMA 7Z 高压缩率,适合超大文件
BZip2 TAR.BZ2、Commons 多线程支持好(仅7-Zip CLI),CPU 占用高
压缩率较佳,适合日志归档
PPMd 7Z 极高 文字类压缩效率极高
Rar压缩专属算法 RAR 中高 专利格式,支持恢复记录

压缩算法通常不可逆使用:压缩率越高,速度越慢;适配场景需平衡性能与存储需求。

内部结构与文件头

压缩包本质上是一种文件容器格式,其结构中包含:

ZIP 文件结构

1
2
3
4
5
[文件1数据块]  
[文件2数据块]
...
[Central Directory(目录项)]
[End of Central Directory Record]
  • 文件头(Local File Header):每个文件前面包含压缩算法、时间戳、文件名等信息
  • 中央目录(Central Directory):用于快速定位各文件,便于随机访问和增量更新
  • EOCD(End of Central Directory):ZIP 的结束标志,存放总文件数、目录偏移等

7Z 文件结构

1
2
3
[Header]  
[Compressed Data Blocks]
[Stream Info + Metadata + CRC校验]
  • 支持多层嵌套压缩、加密数据流、文件名加密、压缩字典自定义等
  • 每个 Block 可以使用不同压缩算法

RAR 文件结构

1
2
3
4
5
6
[Archive Header]  
[File Header 1 + Compressed File Data]
[File Header 2 + Compressed File Data]
...
[Recovery Record(可选)]
[End of Archive]
  • 每个文件块都包含校验码与恢复信息
  • RAR5 引入更强加密机制,但格式解析更复杂

GZ 文件结构

1
2
3
4
5
[Magic Number (1F 8B)]  
[Compression Method (08 for Deflate)]
[Flags + Timestamp + Optional Fields]
[Compressed Data]
[CRC32 + Input Size]
  • 单文件压缩格式,通常配合 .tar 打包使用(如 .tar.gz

Zip 处理

java.util.zip

说明
ZipOutputStream 基于 DeflaterOutputStream,负责向输出流写入 ZIP 条目与压缩数据。支持设置压缩级别。
ZipEntry 表示 ZIP 中的一个“条目”(文件或目录),包含文件名、时间戳、压缩方法等元数据。
ZipInputStream 基于 InflaterInputStream,按顺序读取 ZIP 条目并解压数据,适合流式处理。
ZipFile 随机访问 ZIP,支持按条目索引读取,更能指定字符集(new ZipFile(file, charset))。
Deflater / Inflater 底层压缩/解压算法引擎,可通过 ZipOutputStream#setLevel(int) 调整压缩级别。
CRC32
CheckedOutputStream
CheckedInputStream
提供 CRC 校验支持,可对流做一致性校验。

Tip:当文件名包含非 ASCII(如中文)时,ZipFile 的 charset 构造器可避免乱码;而 ZipInputStream 支持使用 UTF-8,当条目未置 EFS 标志时,它退回 CP437

创建压缩包

核心流程

  1. 打开目标输出流:FileOutputStream → BufferedOutputStream → ZipOutputStream
  2. 设置压缩级别:zipOut.setLevel(Deflater.BEST_SPEED / DEFAULT_COMPRESSION / BEST_COMPRESSION)
  3. 遍历源文件,对每个文件/目录:
    • 创建 ZipEntry entry = new ZipEntry(relativePath)
    • zipOut.putNextEntry(entry)
    • 读取源文件字节,写入 zipOut
    • zipOut.closeEntry()
  4. 结束:zipOut.finish()(可选),关闭流。

代码实例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
public class ZipCreator {

// UTF-8 编码,适用于 Java 8 默认
public static void createZip(Path sourceDir, Path targetZip, int compressionLevel) throws IOException {
// 缓冲区大小:8KB,对100MB以下文件足够
byte[] buffer = new byte[8 * 1024];
// 父流 → 缓冲 → ZIP
try (ZipOutputStream zipOut = new ZipOutputStream(
new BufferedOutputStream(Files.newOutputStream(targetZip)))) {

// 设置压缩级别(0-9)
zipOut.setLevel(compressionLevel);

// 递归遍历目录
Files.walk(sourceDir)
.filter(path -> !Files.isDirectory(path))
.forEach(path -> {
// 计算相对路径,作为 ZipEntry 名称
Path relPath = sourceDir.relativize(path);
ZipEntry entry = new ZipEntry(relPath.toString().replace("\\", "/"));
try {
zipOut.putNextEntry(entry);
// 数据写入
try (InputStream in = Files.newInputStream(path)) {
int len;
while ((len = in.read(buffer)) > 0) {
zipOut.write(buffer, 0, len);
}
}
zipOut.closeEntry();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
// 可选:确保所有数据写入
zipOut.finish();
}
}

public static void main(String[] args) throws IOException, URISyntaxException {
Path src = Paths.get("data目录"); // 待压缩目录
Path dest = Paths.get("archive.zip"); // 输出 ZIP 文件
// 介于压缩率与速度的平衡:DEFAULT_COMPRESSION(级别6)
createZip(src, dest, Deflater.DEFAULT_COMPRESSION);
System.out.println("压缩完成:" + dest.toAbsolutePath());
}

}

编码与效率考量

  • 文件名编码:Java 8 默认对 ZipEntry 名称采用 UTF-8 并设置 EFS 标志;在 Windows Explorer(CP437)可能出现乱码。
    • 建议:如果需兼容 GBK/CP437,可后续使用第三方库(如 Zip4j)或在解压时用 new ZipFile(file, Charset.forName("GBK"))
  • 压缩效率(100MB 以下)
    • 默认 Deflater.DEFAULT_COMPRESSION(级别6)在中等速度与中等压缩率间平衡,100MB 文件常见场景耗时 <1s(现代 CPU)。
    • 对实时场景可降至 BEST_SPEED(级别1),对归档场景可提升至 BEST_COMPRESSION(级别9)。
  • I/O 缓冲:8KB–16KB 缓冲区已足够,过大并不会显著提高吞吐。

解压缩

核心流程

  1. 打开源 ZIP 流:FileInputStream → BufferedInputStream → ZipInputStream
  2. 迭代 ZipEntrywhile ((entry = zipIn.getNextEntry()) != null)
  3. 构建目标文件(保留目录结构)
  4. 读取压缩数据:将 zipIn 中的字节写入目标文件
  5. 结束条目:zipIn.closeEntry()
  6. 关闭流

代码示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
public class ZipExtractor {

// 处理UTF8编码的Zip文件
public static void unzipForUtf8(Path zipPath, Path targetDir) throws IOException {
byte[] buffer = new byte[8 * 1024];
try (ZipInputStream zipIn = new ZipInputStream(
new BufferedInputStream(Files.newInputStream(zipPath)))) {
ZipEntry entry;
while ((entry = zipIn.getNextEntry()) != null) {
Path outPath = targetDir.resolve(entry.getName());
if (entry.isDirectory()) {
Files.createDirectories(outPath);
} else {
Files.createDirectories(outPath.getParent());
try (OutputStream out = Files.newOutputStream(outPath)) {
int len;
while ((len = zipIn.read(buffer)) > 0) {
out.write(buffer, 0, len);
}
}
}
zipIn.closeEntry();
}
}
}

public static void main(String[] args) throws IOException {
Path zipFile = Paths.get("data目录.zip");
Path outDir = Paths.get("output");
unzipAutoDetect(zipFile, outDir);
System.out.println("解压完成:" + outDir.toAbsolutePath());
}
}

改进

在以上代码示例中,如果压缩包是通过非 UTF-8 / 非标准工具(如 7-Zip、WinRAR) 生成的 zip 文件,那么在解压缩时可能会抛出异常:

1
2
java.lang.IllegalArgumentException: MALFORMED
at java.util.zip.ZipCoder.toString(ZipCoder.java:58)

Windows默认使用 GBK 编码,可以在创建 ZipInputStream 时使用 GBK 编码,如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public static void unzipForGbk(Path zipPath, Path targetDir) throws IOException {
byte[] buffer = new byte[8 * 1024];
try (ZipInputStream zipIn = new ZipInputStream(
new BufferedInputStream(Files.newInputStream(zipPath)), Charset.forName("GBK"))) {
ZipEntry entry;
while ((entry = zipIn.getNextEntry()) != null) {
Path outPath = targetDir.resolve(entry.getName());
if (entry.isDirectory()) {
Files.createDirectories(outPath);
} else {
Files.createDirectories(outPath.getParent());
try (OutputStream out = Files.newOutputStream(outPath)) {
int len;
while ((len = zipIn.read(buffer)) > 0) {
out.write(buffer, 0, len);
}
}
}
zipIn.closeEntry();
}
}
}

综上:为了适应多编码(UTF-8、GBK、CP437、ISO-8859-1),需要对方法进行改进,自动检测多编码,思想就是按照优先级尝试多种编码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
public static void unzipAutoDetect(Path zipPath, Path targetDir) throws IOException {
Charset[] charsets = {
StandardCharsets.UTF_8,
Charset.forName("GBK"),
Charset.forName("Cp437"),
StandardCharsets.ISO_8859_1,
Charset.forName("MacRoman") // MacOS 早期默认
};

IOException lastIOException = null;
RuntimeException lastRuntimeException = null;

for (Charset cs : charsets) {
System.out.println("尝试使用编码:" + cs.name());
try (ZipFile zipFile = new ZipFile(zipPath.toFile(), cs)) {
Enumeration<? extends ZipEntry> entries = zipFile.entries();
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
// 同前:防 Zip Slip、创建目录或文件、写出流……
extractEntry(zipFile, entry, targetDir);
}
return; // 解压成功,结束
} catch (IOException ioe) {
lastIOException = ioe;
} catch (IllegalArgumentException iae) {
lastRuntimeException = iae;
}
// 重试下一个 charset
}

// 全部编码尝试失败,抛出一个综合异常
String msg = "无法识别 ZIP 文件名编码,尝试过的编码:" +
Arrays.toString(charsets);
IOException ex = new IOException(msg, lastIOException);
if (lastRuntimeException != null) {
ex.addSuppressed(lastRuntimeException);
}
throw ex;
}

// 抽取的写出逻辑
private static void extractEntry(ZipFile zipFile, ZipEntry entry, Path targetDir) {
try (InputStream in = zipFile.getInputStream(entry)) {
Path out = targetDir.resolve(entry.getName()).normalize();
if (!out.startsWith(targetDir)) {
throw new IOException("非法的 ZIP 路径:" + entry.getName());
}
if (entry.isDirectory()) {
Files.createDirectories(out);
} else {
Files.createDirectories(out.getParent());
try (OutputStream outStream = Files.newOutputStream(out)) {
byte[] buf = new byte[8 * 1024];
int len;
while ((len = in.read(buf)) > 0) {
outStream.write(buf, 0, len);
}
}
}
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}

通用解决方案

下面使用 Apache Commons Compress 结合 Zip4j,提供一个通用的 Java 工具类 CompressUtil

  • Apache Commons Compress:格式全,API 统一;
  • Zip4j:ZIP 密码保护(ZipCrypto / AES)。

工具类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
public final class CompressUtil {

/* ---------------- 创建压缩包 ---------------- */

/**
* 将目录或单文件压缩为 ZIP;可选密码 (ZipCrypto / AES)。
*
* @param srcPath 待压缩目录或文件
* @param zipPath 输出 zip
* @param password null 表示无密码
* @param aes256 true -> AES-256;false -> ZipCrypto
* @throws IOException if I/O error
*/
public static void createZip(Path srcPath,
Path zipPath,
String password,
boolean aes256) throws IOException {

try (ZipFile zip = (password == null)
? new ZipFile(zipPath.toFile())
: new ZipFile(zipPath.toFile(), password.toCharArray())) {

ZipParameters baseParams = new ZipParameters();
baseParams.setCompressionMethod(CompressionMethod.DEFLATE);
baseParams.setCompressionLevel(CompressionLevel.NORMAL);
if (password != null) {
baseParams.setEncryptFiles(true);
baseParams.setEncryptionMethod(
aes256
? EncryptionMethod.AES
: EncryptionMethod.ZIP_STANDARD
);
if (aes256) {
baseParams.setAesKeyStrength(AesKeyStrength.KEY_STRENGTH_256);
}
}

if (Files.isDirectory(srcPath)) {
// 递归遍历所有文件/目录(包括空目录)
try (Stream<Path> stream = Files.walk(srcPath)) {
stream.forEach(path -> {
String entry = srcPath.relativize(path)
.toString()
.replace("\\", "/");
try {
ZipParameters params = new ZipParameters(baseParams);
// 如果是目录,确保以 “/” 结尾,让 ZIP 记录目录
if (Files.isDirectory(path)) {
if (!entry.endsWith("/")) {
entry = entry + "/";
}
params.setFileNameInZip(entry);
zip.addFolder(path.toFile(), params);
} else {
params.setFileNameInZip(entry);
zip.addFile(path.toFile(), params);
}
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
}
} else {
// 单文件
ZipParameters params = new ZipParameters(baseParams);
params.setFileNameInZip(srcPath.getFileName().toString());
zip.addFile(srcPath.toFile(), params);
}
}
}

/**
* 7z 压缩:Commons Compress 的 SevenZFile 只支持 LZMA/LZMA2。
*/
public static void create7z(Path srcDir, Path out7z) throws IOException {
try (SevenZOutputFile out = new SevenZOutputFile(out7z.toFile())) {
addPathTo7z(out, srcDir, srcDir);
}
}

private static void addPathTo7z(SevenZOutputFile out,
Path root, Path src) throws IOException {
if (Files.isDirectory(src)) {
try (DirectoryStream<Path> ds = Files.newDirectoryStream(src)) {
for (Path p : ds) {
addPathTo7z(out, root, p);
}
}
} else {
String entryName = root.relativize(src)
.toString().replace("\\", "/");
SevenZArchiveEntry entry =
out.createArchiveEntry(src.toFile(), entryName);
out.putArchiveEntry(entry);
try (InputStream is = Files.newInputStream(src)) {
byte[] buf = new byte[8 * 1024];
int len;
while ((len = is.read(buf)) != -1) {
out.write(buf, 0, len);
}
}
out.closeArchiveEntry();
}
}

/**
* 将目录压成 tar.gz(Linux 生态常用)。
*/
public static void createTarGz(Path srcDir, Path outTarGz) throws IOException {
try (TarArchiveOutputStream taos = new TarArchiveOutputStream(
new GzipCompressorOutputStream(
Files.newOutputStream(outTarGz)))) {

taos.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX);
try (Stream<Path> stream = Files.walk(srcDir)) {
stream
.filter(p -> !p.equals(srcDir)) // 不把根目录写入
.forEach(p -> {
try {
TarArchiveEntry entry = new TarArchiveEntry(
p.toFile(),
srcDir.relativize(p).toString().replace("\\", "/"));
taos.putArchiveEntry(entry);
if (Files.isRegularFile(p)) {
Files.copy(p, taos);
}
taos.closeArchiveEntry();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
}
}
}

/* ---------------- 解压 ---------------- */

/**
* 自动按扩展名解压:zip/7z/tar.gz
* 若 zip 带密码请传 password,否则 null。
*/
public static void extract(Path archive, Path targetDir, String password)
throws IOException, RarException {

String name = archive.getFileName().toString().toLowerCase();
if (name.endsWith(".zip")) {
extractZip(archive, targetDir, password);
} else if (name.endsWith(".7z")) {
extract7z(archive, targetDir, password);
} else if (name.endsWith(".tar.gz") || name.endsWith(".tgz")) {
extractTarGz(archive, targetDir);
} else {
throw new IllegalArgumentException("不支持的格式: " + name);
}
}

/* --- zip --- */
private static void extractZip(Path zip, Path target, String password) throws IOException {
// 1. 先用临时ZipFile只做“投票”检查,不做 setCharset
try (ZipFile probe = password == null
? new ZipFile(zip.toFile())
: new ZipFile(zip.toFile(), password.toCharArray())) {

long utf8Count = probe.getFileHeaders() // 会立刻把所有条目的文件名解析进内存,导致字符集错误,因此探测后要重新开实例(见后续)
.stream()
.filter(FileHeader::isFileNameUTF8Encoded)
.count();
boolean useUtf8 = utf8Count >= (probe.getFileHeaders().size() + 1) / 2;

// 2. 再重新创建一个真正用来解压的 ZipFile,直接带正确字符集
try (ZipFile real = password == null
? new ZipFile(zip.toFile())
: new ZipFile(zip.toFile(), password.toCharArray())) {

if (!useUtf8) {
real.setCharset(Charset.forName("GBK"));
}
real.extractAll(target.toString());
}
}
}

/* --- 7z --- */
private static void extract7z(Path sevenZ, Path target, String password)
throws IOException {
try (SevenZFile zf = password == null
? new SevenZFile(sevenZ.toFile())
: new SevenZFile(sevenZ.toFile(), password.toCharArray())) {

SevenZArchiveEntry entry;
byte[] buf = new byte[8 * 1024];
while ((entry = zf.getNextEntry()) != null) {
Path out = target.resolve(entry.getName()).normalize();
if (!out.startsWith(target))
throw new IOException("Zip-Slip: " + entry.getName());

if (entry.isDirectory()) {
Files.createDirectories(out);
} else {
Files.createDirectories(out.getParent());
try (OutputStream os = Files.newOutputStream(out)) {
int len;
while ((len = zf.read(buf)) > 0) {
os.write(buf, 0, len);
}
}
}
}
}
}

/* --- tar.gz --- */
private static void extractTarGz(Path tarGz, Path target) throws IOException {
try (GzipCompressorInputStream gis =
new GzipCompressorInputStream(Files.newInputStream(tarGz));
TarArchiveInputStream tis = new TarArchiveInputStream(gis)) {

TarArchiveEntry entry;
while ((entry = tis.getNextEntry()) != null) {
Path out = target.resolve(entry.getName()).normalize();
if (!out.startsWith(target)) throw new IOException("Zip-Slip: " + entry.getName());
if (entry.isDirectory()) {
Files.createDirectories(out);
} else {
Files.createDirectories(out.getParent());
try (OutputStream os = Files.newOutputStream(out)) {
IOUtils.copy(tis, os);
}
}
}
}
}

/* ---------------- 小工具 ---------------- */
private CompressUtil() {
}
}

测试类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
public class CompressUtilTest {

/**
* 测试 CompressUtil.createZip
*/
@Test
public void testCreateZip() throws IOException {
Path src = Paths.get(".gitignore"); // 待压缩目录
Path dest = Paths.get("data目录压缩包.zip"); // 输出 ZIP 文件
CompressUtil.createZip(src, dest, "123456", true);
// CompressUtil.createZip(src, dest, null, false);
System.out.println("压缩成功:" + dest);
}

/**
* 测试 CompressUtil.create7z
*/
@Test
public void testCreate7z() throws IOException {
Path src = Paths.get("data目录"); // 待压缩目录
Path dest = Paths.get("data目录压缩包.7z"); // 输出 7z 文件
CompressUtil.create7z(src, dest);
System.out.println("7z 压缩成功:" + dest);
}

/**
* 测试 CompressUtil.createTarGz
*/
@Test
public void testCreateTarGz() throws IOException {
Path src = Paths.get("data目录"); // 待压缩目录
Path dest = Paths.get("data目录压缩包.tar.gz"); // 输出 tar.gz 文件
CompressUtil.createTarGz(src, dest);
System.out.println("tar.gz 压缩成功:" + dest);
}

/**
* 测试 CompressUtil.extractZip
*/
@Test
public void testExtractZip() throws IOException, RarException {
Path zipPath = Paths.get("data目录压缩包.zip"); // 待解压 ZIP 文件
Path extractDir = Paths.get("解压zip压缩包目录"); // 解压目标目录
CompressUtil.extract(zipPath, extractDir, "123456");
System.out.println("ZIP 解压成功:" + extractDir);
}

/**
* 测试 CompressUtil.extract7z
*/
@Test
public void testExtract7z() throws IOException, RarException {
Path sevenZPath = Paths.get("data目录压缩包.7z"); // 待解压 7z 文件
Path extractDir = Paths.get("解压7z压缩包目录"); // 解压目标目录
CompressUtil.extract(sevenZPath, extractDir, "123");
System.out.println("7z 解压成功:" + extractDir);
}

/**
* 测试 CompressUtil.extractTarGz
*/
@Test
public void testExtractTarGz() throws RarException, IOException {
Path tarGzPath = Paths.get("data目录压缩包.tar.gz");
Path extractDir = Paths.get("解压tar.gz压缩包目录");
CompressUtil.extract(tarGzPath, extractDir, null);
System.out.println("tar.gz 解压成功:" + extractDir);
}

}

安全防护

Zip Bomb

定义

Zip Bomb(压缩炸弹)是一种恶意构造的压缩包,体积极小(如几 KB),解压后却膨胀到极大的体积(如几 TB),以耗尽磁盘空间、内存或 CPU 资源,造成系统 DoS。

原理

  • 多层嵌套压缩:将同一份数据重复压缩数十层
  • 超高冗余率:利用极高压缩率算法(如重复单字符)

检测策略

  1. 设定阈值

    • 读取所有条目的 “压缩大小” 与 “原始大小”
    • 计算总压缩比:sum(uncompressed) / sum(compressed)
    • 若压缩比 > 100(具体根据业务可调,常见 50–200)时拒绝解压
    • 或单个条目原始大小超过阈值(如 >1 GB)时拒绝解压
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    try (ZipFile probe = (password == null)
    ? new ZipFile(zip.toFile())
    : new ZipFile(zip.toFile(), password.toCharArray())) {

    long totalC = 0, totalU = 0, utf8Count = 0;
    for (FileHeader fh : probe.getFileHeaders()) {
    totalC += fh.getCompressedSize();
    totalU += fh.getUncompressedSize();
    if (fh.isFileNameUTF8Encoded()) utf8Count++;
    }
    if (totalC > 0 && (double) totalU / totalC > 100) {
    throw new IOException(String.format(
    "检测到压缩比过高(%.1f),疑似 Zip Bomb,终止解压", (double) totalU / totalC));
    }
    }
  2. 限制嵌套深度

    • 禁止多层嵌套(如 Entry 名称中出现超过 N 个 “.zip” 后缀)
  3. 限制总条目数

    • 若压缩包包含文件数 >100 000,则可认为可疑
  4. 流式监控

    • 在解压过程中累加已写出字节数
    • 若超出预先计算的最大“安全”解压大小,立刻中断

Zip-Slip 防护

漏洞成因

压缩包条目名中含有 ../、绝对路径(如 /etc/passwd)或 Windows 驱动器前缀(如 C:\secret.txt),直接拼接后会写出到项目目录之外。

防护原则

  1. 归一化

    1
    Path out = targetDir.resolve(entryName).normalize();
  2. 前缀校验

    1
    2
    3
    if (!out.startsWith(targetDir)) {
    throw new IOException("非法路径:" + entryName);
    }
  3. 过滤黑名单(可选)

    • 拒绝包含 ..、以 /\ 开头的名称
    • 拒绝包含控制字符(\0\r\n
  4. 字符集安全

    • 对文件名先按预期字符集解码,再检查是否含有非法 Unicode 码点(如 U+202E “RTL Override”)
  5. 只读沙箱(高级)

    • 在专门的临时目录中解压,解压后再移动至生产目录

密码暴力破解限制

风险场景

对于带密码的 ZIP(ZipCrypto/AES),攻击者可不断尝试不同密码,若没有限制则可高速穷举,开发者需要在自己系统的接口层面进行限制,当然对于破解者本地运行程序进行破解,则无法进行限制,只能通过选择更不容易破解的加密算法。

限制策略

  1. 尝试次数限制
    • 每个压缩包最多允许尝试 3–5 次解密失败,超过则拒绝服务
  2. 时间窗口与锁定
    • 在 1 分钟内多次失败,锁定该用户或该压缩包 10 分钟
  3. 延时退避
    • 每次失败后增加延时:初始 500 ms,之后 1 s、2 s、4 s…
  4. 验证码或二次验证
    • 超过 N 次失败后,要求用户输入 CAPTCHA 或二次校验(如手机短信)
  5. 统一入口限流
    • 对所有解压接口统一限流:每秒不超过 M 次请求
  6. 日志告警
    • 记录失败密码尝试日志,配合 SIEM 监控异常行为
  7. 加密算法选择
    • 推荐使用 AES/GCM 而非传统 ZipCrypto,因后者更易被暴力破解

最终代码呈现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
/**
* 通用压缩/解压工具 —— Apache Commons Compress + Zip4j + junrar
* 支持:ZIP(含加密) / 7Z / TAR.GZ
*
* @author yt
*/
public final class CompressUtil {

// 最大允许压缩比
private static final double MAX_COMPRESSION_RATIO = 10.0;

// 解压出来大小限制
private static final long MAX_UNCOMPRESSED_SIZE = 1024 * 1024 * 1024; // 1024 MB

/* ---------------- 创建压缩包 ---------------- */

/**
* 将目录或单文件压缩为 ZIP;可选密码 (ZipCrypto / AES)。
*
* @param srcPath 待压缩目录或文件
* @param zipPath 输出 zip
* @param password null 表示无密码
* @param aes256 true -> AES-256;false -> ZipCrypto
* @throws IOException if I/O error
*/
public static void createZip(Path srcPath,
Path zipPath,
String password,
boolean aes256) throws IOException {
// 规范为绝对路径
srcPath = srcPath.toAbsolutePath().normalize();
try (ZipFile zip = (password == null)
? new ZipFile(zipPath.toFile())
: new ZipFile(zipPath.toFile(), password.toCharArray())) {

ZipParameters baseParams = new ZipParameters();
baseParams.setCompressionMethod(CompressionMethod.DEFLATE);
baseParams.setCompressionLevel(CompressionLevel.NORMAL);
if (password != null) {
baseParams.setEncryptFiles(true);
baseParams.setEncryptionMethod(
aes256
? EncryptionMethod.AES
: EncryptionMethod.ZIP_STANDARD
);
if (aes256) {
baseParams.setAesKeyStrength(AesKeyStrength.KEY_STRENGTH_256);
}
}

if (Files.isDirectory(srcPath)) {
// 递归遍历所有文件/目录(包括空目录)
try (Stream<Path> stream = Files.walk(srcPath)) {
Path finalSrcPath = srcPath;
stream.filter(path -> !path.equals(finalSrcPath))
.forEach(path -> {
String entry = finalSrcPath.relativize(path)
.toString()
.replace("\\", "/");
// 清理多余前缀
if (entry.startsWith("/")) {
entry = entry.substring(1);
}
if (entry.startsWith("./")) {
entry = entry.substring(2);
}
try {
ZipParameters params = new ZipParameters(baseParams);
// 如果是目录,确保以 “/” 结尾,让 ZIP 记录目录
if (Files.isDirectory(path)) {
if (!entry.endsWith("/")) {
entry = entry + "/";
}
params.setFileNameInZip(entry);
zip.addFolder(path.toFile(), params);
} else {
params.setFileNameInZip(entry);
zip.addFile(path.toFile(), params);
}
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
}
} else {
// 单文件
ZipParameters params = new ZipParameters(baseParams);
params.setFileNameInZip(srcPath.getFileName().toString());
zip.addFile(srcPath.toFile(), params);
}
}
}

/**
* 7z 压缩:Commons Compress 的 SevenZFile 只支持 LZMA/LZMA2。
*/
public static void create7z(Path srcDir, Path out7z) throws IOException {
try (SevenZOutputFile out = new SevenZOutputFile(out7z.toFile())) {
addPathTo7z(out, srcDir, srcDir);
}
}

private static void addPathTo7z(SevenZOutputFile out,
Path root, Path src) throws IOException {
if (Files.isDirectory(src)) {
try (DirectoryStream<Path> ds = Files.newDirectoryStream(src)) {
for (Path p : ds) {
addPathTo7z(out, root, p);
}
}
} else {
String entryName = root.relativize(src)
.toString().replace("\\", "/");
SevenZArchiveEntry entry =
out.createArchiveEntry(src.toFile(), entryName);
out.putArchiveEntry(entry);
try (InputStream is = Files.newInputStream(src)) {
byte[] buf = new byte[8 * 1024];
int len;
while ((len = is.read(buf)) != -1) {
out.write(buf, 0, len);
}
}
out.closeArchiveEntry();
}
}

/**
* 将目录压成 tar.gz(Linux 生态常用)。
*/
public static void createTarGz(Path srcDir, Path outTarGz) throws IOException {
try (TarArchiveOutputStream taos = new TarArchiveOutputStream(
new GzipCompressorOutputStream(
Files.newOutputStream(outTarGz)))) {

taos.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX);
try (Stream<Path> stream = Files.walk(srcDir)) {
stream
.filter(p -> !p.equals(srcDir)) // 不把根目录写入
.forEach(p -> {
try {
TarArchiveEntry entry = new TarArchiveEntry(
p.toFile(),
srcDir.relativize(p).toString().replace("\\", "/"));
taos.putArchiveEntry(entry);
if (Files.isRegularFile(p)) {
Files.copy(p, taos);
}
taos.closeArchiveEntry();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
}
}
}

/* ---------------- 解压 ---------------- */

/**
* 自动按扩展名解压:zip/7z/tar.gz
* 若 zip 带密码请传 password,否则 null。
*/
public static void extract(Path archive, Path targetDir, String password)
throws IOException, RarException {

String name = archive.getFileName().toString().toLowerCase();
if (name.endsWith(".zip")) {
extractZip(archive, targetDir, password);
} else if (name.endsWith(".7z")) {
extract7z(archive, targetDir, password);
} else if (name.endsWith(".tar.gz") || name.endsWith(".tgz")) {
extractTarGz(archive, targetDir);
} else {
throw new IllegalArgumentException("不支持的格式: " + name);
}
}

/* --- ZIP 解压(含压缩比 & Zip-Slip & 字符集探测) --- */
private static void extractZip(Path zip, Path target, String password) throws IOException {
// 1. 探针模式:统计压缩比 & UTF-8 标志投票
boolean useUtf8;
try (ZipFile probe = (password == null)
? new ZipFile(zip.toFile())
: new ZipFile(zip.toFile(), password.toCharArray())) {

long totalC = 0, totalU = 0, utf8Count = 0;
for (FileHeader fh : probe.getFileHeaders()) {
totalC += fh.getCompressedSize();
totalU += fh.getUncompressedSize();
if (fh.isFileNameUTF8Encoded()) utf8Count++;
}
if (totalC > 0 && (double) totalU / totalC > MAX_COMPRESSION_RATIO) {
throw new IOException(String.format(
"检测到压缩比过高(%.1f),疑似 Zip Bomb,终止解压", (double) totalU / totalC));
}
useUtf8 = utf8Count >= (probe.getFileHeaders().size() + 1) / 2;
}

// 2. 真正解压:带字符集 & Zip-Slip 检查
try (ZipFile real = (password == null)
? new ZipFile(zip.toFile())
: new ZipFile(zip.toFile(), password.toCharArray())) {

if (!useUtf8) {
real.setCharset(Charset.forName("GBK"));
}
for (FileHeader fh : real.getFileHeaders()) {
String name = fh.getFileName();
// 过滤 ../、绝对路径等
if (name.contains("..") || name.startsWith("/") || name.startsWith("~")) {
throw new IOException("拒绝可疑条目路径:" + name);
}
Path out = target.resolve(name).normalize();
if (!out.startsWith(target)) {
throw new IOException("Zip-Slip 安全风险:" + name);
}
if (fh.isDirectory()) {
Files.createDirectories(out);
} else {
Files.createDirectories(out.getParent());
try (InputStream in = real.getInputStream(fh);
OutputStream os = Files.newOutputStream(out)) {
byte[] buf = new byte[8 * 1024];
int len;
while ((len = in.read(buf)) > 0) {
os.write(buf, 0, len);
}
}
}
}
}
}

/* --- 7z 解压(含大小阈值 & Zip-Slip) --- */
private static void extract7z(Path sevenZ, Path target, String password) throws IOException {
// 1. 探针模式:累计 uncompressedSize
long totalU = 0;
try (SevenZFile probe = (password == null)
? new SevenZFile(sevenZ.toFile())
: new SevenZFile(sevenZ.toFile(), password.toCharArray())) {
SevenZArchiveEntry e;
while ((e = probe.getNextEntry()) != null) {
totalU += e.getSize();
}
}
if (totalU > MAX_UNCOMPRESSED_SIZE) {
throw new IOException(String.format(
"检测到解压后总大小 %d bytes,超过 %d bytes,终止解压",
totalU, MAX_UNCOMPRESSED_SIZE));
}

// 2. 真正解压:Zip-Slip 检查
try (SevenZFile zf = (password == null)
? new SevenZFile(sevenZ.toFile())
: new SevenZFile(sevenZ.toFile(), password.toCharArray())) {

SevenZArchiveEntry entry;
byte[] buf = new byte[8 * 1024];
while ((entry = zf.getNextEntry()) != null) {
String name = entry.getName();
if (name.contains("..") || name.startsWith("/") || name.startsWith("~")) {
throw new IOException("拒绝可疑条目路径:" + name);
}
Path out = target.resolve(name).normalize();
if (!out.startsWith(target)) {
throw new IOException("Zip-Slip 安全风险:" + name);
}
if (entry.isDirectory()) {
Files.createDirectories(out);
} else {
Files.createDirectories(out.getParent());
try (OutputStream os = Files.newOutputStream(out)) {
int len;
while ((len = zf.read(buf)) > 0) {
os.write(buf, 0, len);
}
}
}
}
}
}

/* --- tar.gz 解压(含压缩比 & Zip-Slip) --- */
private static void extractTarGz(Path tarGz, Path target) throws IOException {
// 1. 探针模式:压缩比检测
long totalC = Files.size(tarGz), totalU = 0;
try (GzipCompressorInputStream gis =
new GzipCompressorInputStream(Files.newInputStream(tarGz));
TarArchiveInputStream tis = new TarArchiveInputStream(gis)) {
TarArchiveEntry e;
while ((e = (TarArchiveEntry) tis.getNextEntry()) != null) {
if (!e.isDirectory()) {
totalU += e.getSize();
}
}
}
if (totalC > 0 && (double) totalU / totalC > MAX_COMPRESSION_RATIO) {
throw new IOException(String.format(
"检测到压缩比过高(%.1f),疑似 Zip Bomb,终止解压", (double) totalU / totalC));
}

// 2. 真正解压:Zip-Slip 检查
try (GzipCompressorInputStream gis =
new GzipCompressorInputStream(Files.newInputStream(tarGz));
TarArchiveInputStream tis = new TarArchiveInputStream(gis)) {

TarArchiveEntry entry;
while ((entry = (TarArchiveEntry) tis.getNextEntry()) != null) {
String name = entry.getName();
if (name.contains("..") || name.startsWith("/") || name.startsWith("~")) {
throw new IOException("拒绝可疑条目路径:" + name);
}
Path out = target.resolve(name).normalize();
if (!out.startsWith(target)) {
throw new IOException("Zip-Slip 安全风险:" + name);
}
if (entry.isDirectory()) {
Files.createDirectories(out);
} else {
Files.createDirectories(out.getParent());
try (OutputStream os = Files.newOutputStream(out)) {
IOUtils.copy(tis, os);
}
}
}
}
}

/* ---------------- 小工具 ---------------- */
private CompressUtil() {
}
}

总结

本文系统梳理了 Java 压缩处理的动机、常见格式差异、核心算法和内部结构,并给出 Zip4j 与 Commons-Compress 融合的压缩解压解决方案,满足设置密码、多算法压缩解压、Zip-Bomb 检测与Zip-Slip 防护,整体上已经能满足 Java 中的压缩解压处理。