ZIP是Java应用里最常见的归档格式,JDK自带的java.util.zip包无需引入任何第三方依赖就能完成压缩和解压。本文从核心类的原理讲起,一步步实现单文件压缩、文件夹递归压缩、解压还原,并解决中文文件名乱码、大文件性能优化等实际开发中容易踩的坑。

一、ZIP压缩的核心原理与ZipOutputStream用法
理解ZIP的结构对正确使用API非常重要。一个ZIP文件由多个Entry组成,每个Entry对应一个文件或目录,包含文件名、原始大小、压缩后大小、CRC32校验值等信息,Entry的数据依次排列在文件末尾还有一个中央目录记录所有Entry的索引。解压时程序先读中央目录,再定位到具体Entry,这也是为什么损坏的ZIP文件常常报“central directory is empty”这类错误。
ZipOutputStream是OutputStream的装饰器,负责把数据压缩成Deflate格式并按ZIP结构写入。它的核心流程分三步:先调用putNextEntry写入一个Entry头信息,然后往流里write数据,最后调用closeEntry结束当前Entry。注意Entry必须按顺序处理,不能同时打开两个Entry。写文件时建议包装一层BufferedInputStream,可以显著减少底层IO调用次数。
import java.io.*;
import java.util.zip.*;
public class ZipCompressor {
/**
* 压缩单个文件
* @param srcFile 源文件路径
* @param zipFile 生成的zip文件路径
*/
public static void compressFile(String srcFile, String zipFile) throws IOException {
try (ZipOutputStream zos = new ZipOutputStream(
new BufferedOutputStream(new FileOutputStream(zipFile)))) {
// 设置压缩级别:0-9,0不压缩,9最高压缩率但最慢
zos.setLevel(Deflater.BEST_COMPRESSION);
File file = new File(srcFile);
try (BufferedInputStream bis = new BufferedInputStream(
new FileInputStream(file))) {
// 创建entry,名称为文件名本身
zos.putNextEntry(new ZipEntry(file.getName()));
byte[] buffer = new byte[8192];
int len;
while ((len = bis.read(buffer)) != -1) {
zos.write(buffer, 0, len);
}
zos.closeEntry();
}
}
System.out.println("压缩完成: " + zipFile);
}
public static void main(String[] args) throws IOException {
compressFile("D:\\logs\\app.log", "D:\\logs\\app.zip");
}
}</code>上面代码有三点值得注意:一是使用try-with-resources保证流一定被关闭,ZIP文件的中央目录是在close时写入的,如果流没有正常关闭,得到的ZIP文件是损坏的;二是缓冲区8192字节是比较通用的选择,太小会频繁系统调用,太大浪费内存;三是setLevel可以控制压缩率,日常场景用默认的Deflater.DEFAULT_COMPRESSION即可,对文本日志类文件开启BEST_COMPRESSION往往能再省一半空间。
二、递归压缩整个文件夹
实际需求中更常见的是把一个目录连同子目录整体打包。ZIP没有真正的目录概念,目录层级靠Entry名称中的斜杠表达,例如doc/img/a.png表示doc目录下img子目录里的文件。递归遍历时要把相对路径拼进Entry名称,JDK会自动处理斜杠,用File.separator拼接的话在Windows上生成的ZIP会有兼容性问题。
另外建议为每个空目录单独写一个以斜杠结尾的Entry,这样解压后目录结构才完整。下面是完整的递归压缩实现:
import java.io.*;
import java.util.zip.*;
public class DirectoryZip {
/**
* 递归压缩目录
* @param srcDir 要压缩的目录
* @param zos ZipOutputStream
* @param base entry名称前缀
*/
public static void zipDirectory(File srcDir, ZipOutputStream zos, String base)
throws IOException {
File[] files = srcDir.listFiles();
if (files == null) return;
for (File file : files) {
String entryName = base + file.getName();
if (file.isDirectory()) {
// 目录entry以斜杠结尾
zos.putNextEntry(new ZipEntry(entryName + "/"));
zos.closeEntry();
zipDirectory(file, zos, entryName + "/");
} else {
zos.putNextEntry(new ZipEntry(entryName));
try (BufferedInputStream bis = new BufferedInputStream(
new FileInputStream(file))) {
byte[] buffer = new byte[8192];
int len;
while ((len = bis.read(buffer)) != -1) {
zos.write(buffer, 0, len);
}
}
zos.closeEntry();
}
}
}
public static void main(String[] args) throws IOException {
File srcDir = new File("D:\\project\\docs");
try (ZipOutputStream zos = new ZipOutputStream(
new BufferedOutputStream(new FileOutputStream("D:\\docs.zip")))) {
zipDirectory(srcDir, zos, "");
}
}
}这里有个容易被忽视的细节:如果目录里存在同名Entry(比如两个大小写不同的文件在Windows上正常但在ZIP里会产生重复Entry),某些解压工具会报错。压缩前最好对Entry名称做一次去重校验。此外,SymbolicLink(符号链接)如果不加判断直接压缩,可能导致死循环递归,生产代码中应该用Files.readAttributes判断文件类型再处理。
三、使用ZipInputStream解压文件
解压的流程和压缩正好相反:通过getNextEntry依次读取每个Entry,返回null表示遍历结束。读到文件Entry时创建对应的输出流写出数据,读到目录Entry(名称以斜杠结尾)时创建目录即可。下面的实现同时处理了目录Entry和文件Entry,并确保中文文件名正常显示。
import java.io.*;
import java.util.zip.*;
public class ZipExtractor {
public static void unzip(String zipPath, String destDir) throws IOException {
File destFolder = new File(destDir);
if (!destFolder.exists()) {
destFolder.mkdirs();
}
try (ZipInputStream zis = new ZipInputStream(
new BufferedInputStream(new FileInputStream(zipPath)),
java.nio.charset.StandardCharsets.UTF_8)) {
ZipEntry entry;
byte[] buffer = new byte[8192];
while ((entry = zis.getNextEntry()) != null) {
File newFile = new File(destFolder, entry.getName());
// 安全校验:防止Zip Slip路径穿越攻击
String canonicalPath = newFile.getCanonicalPath();
if (!canonicalPath.startsWith(destFolder.getCanonicalPath())) {
throw new IOException("检测到非法的entry路径: " + entry.getName());
}
if (entry.isDirectory()) {
newFile.mkdirs();
} else {
// 先确保父目录存在
File parent = newFile.getParentFile();
if (parent != null && !parent.exists()) {
parent.mkdirs();
}
try (BufferedOutputStream bos = new BufferedOutputStream(
new FileOutputStream(newFile))) {
int len;
while ((len = zis.read(buffer)) != -1) {
bos.write(buffer, 0, len);
}
}
}
zis.closeEntry();
}
}
}
public static void main(String[] args) throws IOException {
unzip("D:\\docs.zip", "D:\\output");
}
}代码里有一段安全校验必须重点说明。恶意构造的ZIP包里Entry名称可能形如../../../etc/passwd,如果直接用名称拼接路径,解压时会覆盖压缩包之外的系统文件,这就是著名的Zip Slip漏洞(CVE-2018-1263相关攻击中大量出现过)。通过canonicalPath比对目标目录前缀,可以把这类路径穿越直接拦截掉,任何解压代码都不应省略这段校验。
四、中文乱码与常见异常排查
中文乱码是ZIP处理中最高频的问题,根源在于历史ZIP规范没有统一的编码标准。Windows的压缩软件通常用GBK编码文件名,而JDK 7之前的ZipInputStream构造方法只支持UTF-8,结果就是解压Windows生成的压缩包时文件名变成乱码。解决办法是使用JDK 7新增的带Charset参数的构造方法,明确指定编码。如果包内文件名是GBK就用GBK解,是UTF-8就用UTF-8解。
反过来,如果你的Java程序生成的ZIP要给Windows用户解压,同样可以把ZipOutputStream的编码设为GBK,这样资源管理器里就不会乱码。不过如果压缩包面向跨平台场景,建议统一UTF-8,主流工具如7-Zip、WinRAR新版都能正确识别。
除了乱码,还有几类异常值得了解。ZipException: error opening zip file通常是文件本身损坏或者根本不是ZIP格式,可以先看文件头魔数是否为PK两个字符;java.util.zip.ZipException: duplicate entry说明压缩时产生了重复的Entry名称;而EOFException多半是流没写完就关闭导致中央目录缺失。遇到疑难杂症时,可以换用ZipFile类(基于随机访问文件实现)打开,它在读取Entry列表时对损坏文件的诊断信息更详细,而且ZipFile天然带文件名编码参数,批量读取场景性能比ZipInputStream更好。
五、性能优化与替代方案
处理大文件时,除了前面提到的缓冲区优化,还可以从压缩算法入手。Deflater支持多种策略,setStrategy配合Deflater.HUFFMAN_ONLY可以加快压缩速度,适合对压缩率要求不高的实时场景。对于多核机器,可以自己拆分文件用多线程压缩后合并,不过ZIP单流压缩本身是串行的,多线程优化需要借助第三方库。
如果需求更复杂,建议考虑两个流行库。Apache Commons Compress提供了更完善的格式支持(含tar、7z)和更健壮的实现;Zip4j则原生支持ZIP加密(AES、ZIP标准加密)、分卷压缩和中文处理,API设计也更友好。简单场景直接用JDK自带API零依赖是最优解,涉及加密、超大文件或特殊格式时再引入第三方库,避免不必要的依赖膨胀。
最后一点实践建议:解压前先检查磁盘剩余空间,压缩后可以用ZipFile校验Entry数量和CRC是否完整。对于用户上传的压缩包,务必限制Entry数量和单个文件解压后的最大尺寸,否则一个几MB的“ZIP炸弹”(高压缩比恶意文件)解压后可能撑爆整个磁盘,这类防护在生产环境中不可省略。
Java ZIP压缩ZipOutputStreamZipInputStream修改时间:2026-09-09 06:36:45