- JAVA
Java ZIP圧縮・解凍の書き方|ディレクトリごと圧縮と日本語名の文字化け対策
JavaでZIPを扱うには、標準ライブラリの java.util.zip パッケージを使い、圧縮は ZipOutputStream、解凍は ZipInputStream で行います。 追加のライブラリは要りません。ディレクトリを丸ごと圧縮するときは Files.walk() でファイルをたどって1つずつエントリとして書き込み、日本語のファイル名を含む ZIP は、作られたときの文字コードを Charset で指定して開きます。
この記事では、そのまま貼り付けて使える圧縮・解凍のメソッドを用意し、できた ZIP の中身を unzip -l で確かめます。あわせて、MS932(Windows の Shift_JIS)で作られた ZIP を UTF-8 で開いたときに出る例外、展開先の外へ書き込ませない Zip Slip 対策、duplicate entry の例外、圧縮レベルごとのサイズ比較まで、すべて実行結果つきで紹介します。
確認環境:Ubuntu 24.04 / OpenJDK 21.0.10(ロケールは C.UTF-8)、中身の確認には UnZip 6.00 を使用。掲載している出力はすべてこの環境で実行したものです。
ZIP圧縮・解凍の早見表
| やりたいこと | 使うクラス・メソッド | ポイント |
|---|---|---|
| ファイルを ZIP に圧縮する | ZipOutputStream + putNextEntry(new ZipEntry(名前)) |
中身を書いたら closeEntry() |
| ディレクトリごと圧縮する | Files.walk(dir) でたどり、相対パスをエントリ名にする |
区切りは / にそろえる |
| 空のディレクトリも入れる | 名前の末尾に / を付けたエントリを作る |
中身は書かない |
| ZIP を解凍する | ZipInputStream + getNextEntry() |
展開先の中かを必ず確かめる |
| 一覧を見て一部だけ取り出す | ZipFile + getEntry(名前) |
サイズなどの情報も取れる |
| ZIP の中身を書き換える | zip FileSystem(FileSystems.newFileSystem(zipのパス)) |
Files の API がそのまま使える |
| 日本語名の ZIP を開く | 第2引数に Charset.forName("MS932") |
Windows で作られた ZIP 向け |
| 圧縮率を変える | zos.setLevel(0〜9) |
既定は速度とサイズの中間 |
読み込みに使える3つの方法の違いは次のとおりです。
| 観点 | ZipInputStream | ZipFile | zip FileSystem |
|---|---|---|---|
| 読み方 | 先頭から順に読む | 一覧を取得し、名前を指定して読む | ふつうのファイルと同じ API |
| 入力元 | 任意の InputStream(アップロードされたデータなど) | ディスク上のファイル | ディスク上のファイル |
| エントリのサイズ取得 | 読む前は -1 になることがある | 取得できる | Files.size() で取得できる |
| 書き込み・更新 | 不可(書き込みは ZipOutputStream) | 不可 | 追加・削除・上書きができる |
| 文字コード指定 | コンストラクタの第2引数 | コンストラクタの第2引数 | encoding プロパティ |
| 合わない文字コードで開いたとき | IllegalArgumentException | ZipException | ZipException |
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Java ZIP圧縮・解凍の書き方|ディレクトリごと圧縮と日本語名の文字化け対策」
コピペで使える圧縮・解凍メソッド
「ファイル単体の圧縮」「ディレクトリ丸ごとの圧縮(空ディレクトリ込み)」「解凍」の3つを1つのクラスにまとめました。main メソッドでサンプルのフォルダを作り、圧縮して解凍するところまで動かします。
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
public class ZipUtil {
/** ファイル1つを ZIP にする */
public static void zipFile(Path file, Path zip) throws IOException {
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zip), StandardCharsets.UTF_8)) {
zos.putNextEntry(new ZipEntry(file.getFileName().toString()));
Files.copy(file, zos);
zos.closeEntry();
}
}
/** ディレクトリを丸ごと ZIP にする(空のディレクトリも含める) */
public static void zipDirectory(Path dir, Path zip) throws IOException {
List<Path> paths;
try (Stream<Path> walk = Files.walk(dir)) {
paths = walk.filter(p -> !p.equals(dir)).sorted().toList();
}
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zip), StandardCharsets.UTF_8)) {
for (Path p : paths) {
// ZIP の中の名前は、区切りを必ず "/" にした相対パス
String name = dir.relativize(p).toString().replace('\\', '/');
if (Files.isDirectory(p)) {
zos.putNextEntry(new ZipEntry(name + "/")); // 末尾 "/" でディレクトリ
zos.closeEntry();
} else {
ZipEntry entry = new ZipEntry(name);
entry.setLastModifiedTime(Files.getLastModifiedTime(p));
zos.putNextEntry(entry);
Files.copy(p, zos);
zos.closeEntry();
}
}
}
}
/** ZIP を解凍する(展開先の外へ出るエントリは止める) */
public static void unzip(Path zip, Path destDir, Charset charset) throws IOException {
Path base = destDir.toAbsolutePath().normalize();
Files.createDirectories(base);
try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(zip), charset)) {
ZipEntry entry;
while ((entry = zis.getNextEntry()) != null) {
Path target = base.resolve(entry.getName()).normalize();
if (!target.startsWith(base)) {
throw new IOException("展開先の外を指すエントリです: " + entry.getName());
}
if (entry.isDirectory()) {
Files.createDirectories(target);
} else {
Files.createDirectories(target.getParent());
Files.copy(zis, target, StandardCopyOption.REPLACE_EXISTING);
}
}
}
}
public static void main(String[] args) throws IOException {
// サンプルのディレクトリを用意
Path src = Path.of("docs");
Files.createDirectories(src.resolve("img"));
Files.createDirectories(src.resolve("empty"));
Files.writeString(src.resolve("readme.txt"), "ZIP のサンプルです。\n".repeat(50));
Files.writeString(src.resolve("img/memo.txt"), "画像フォルダのメモ\n");
Files.writeString(src.resolve("日本語メモ.txt"), "日本語の名前のファイル\n");
zipFile(src.resolve("readme.txt"), Path.of("single.zip"));
zipDirectory(src, Path.of("docs.zip"));
unzip(Path.of("docs.zip"), Path.of("restored"), StandardCharsets.UTF_8);
try (Stream<Path> walk = Files.walk(Path.of("restored"))) {
walk.sorted().forEach(p -> System.out.println(
(Files.isDirectory(p) ? "[dir] " : "[file] ") + p));
}
}
}
$ java ZipUtil.java
[dir] restored
[dir] restored/empty
[dir] restored/img
[file] restored/img/memo.txt
[file] restored/readme.txt
[file] restored/日本語メモ.txt
できた ZIP の中身を、Java とは別のツール(unzip -l)で確かめます。
$ unzip -l docs.zip
Archive: docs.zip
Length Date Time Name
--------- ---------- ----- ----
0 2026-10-03 23:51 empty/
0 2026-10-03 23:51 img/
28 2026-10-03 23:51 img/memo.txt
1450 2026-10-03 23:51 readme.txt
34 2026-10-03 23:51 日本語メモ.txt
--------- -------
1512 5 files
空のディレクトリ empty/ も、日本語名のファイルも入っています。解凍したフォルダを元のフォルダと diff -r docs restored で比べ、差分がないこと(中身が完全に一致すること)も確認しました。
ファイル単体を圧縮する:zipFile
ZIP への書き込みは、次の3手順の繰り返しです。
| 手順 | メソッド | 役割 |
|---|---|---|
| 1 | zos.putNextEntry(new ZipEntry(名前)) |
「これからこの名前のファイルを書く」と宣言する |
| 2 | Files.copy(file, zos) や zos.write(バイト配列) |
中身を書く(書きながら圧縮される) |
| 3 | zos.closeEntry() |
そのエントリを書き終える |
ZipEntry に渡す名前が、ZIP の中でのパスになります。file.toString() をそのまま渡すと、手元の絶対パスが丸ごと ZIP の中に入ってしまうので、ファイル名だけ(getFileName())か、基準フォルダからの相対パスを渡します。
ZipOutputStream は try-with-resources で閉じます。ZIP は末尾に目次にあたる情報を書き込んで完成する形式なので、閉じることで初めて正しい ZIP になります。
ディレクトリごと圧縮する:zipDirectory
Files.walk(dir) は、フォルダの中を下の階層までたどって、すべてのパスを順に返します。ポイントは3つです。
- エントリ名は
dir.relativize(p)で相対パスにする。docs/img/memo.txtならimg/memo.txtになります。 - 区切りは
/にそろえる。パスの区切り文字が OS によって異なる場合に備えて、replace('\\', '/')を入れています。 - ディレクトリは、名前の末尾に
/を付けたエントリとして入れる。中身は書かずにすぐcloseEntry()します。これを省くと、空のディレクトリは ZIP に含まれません。
entry.setLastModifiedTime(...) は、元のファイルの更新日時を ZIP に記録するための指定です。省くと、圧縮した時刻が入ります。
解凍する:unzip
zis.getNextEntry() を呼ぶたびに次のエントリへ進み、最後まで読むと null が返ります。エントリに進んだ状態で zis を読むと、そのエントリの中身(解凍済みのバイト列)が取り出せるので、Files.copy(zis, target) でそのままファイルに書き出せます。
entry.isDirectory()は、名前が/で終わるエントリのときに true になります。Files.createDirectories(target.getParent())は、ディレクトリのエントリが入っていない ZIP(ファイルのエントリだけの ZIP)でも解凍できるようにするためのものです。StandardCopyOption.REPLACE_EXISTINGを付けているので、同じ場所に2回解凍しても上書きされます。付けない場合、2回目の解凍はFileAlreadyExistsExceptionで止まりました。上書きを避けたい用途では外してください。target.startsWith(base)の確認は、後述する Zip Slip への対策です。解凍のコードには必ず入れておきます。
ZIPを読む3つの方法を比べる
同じ docs.zip を、3つの方法で読んでみます。
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Stream;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipInputStream;
public class Readers {
public static void main(String[] args) throws IOException {
Path zip = Path.of("docs.zip");
System.out.println("--- ZipInputStream(先頭から順に読む)");
try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(zip), StandardCharsets.UTF_8)) {
ZipEntry e;
while ((e = zis.getNextEntry()) != null) {
System.out.println(e.getName() + " size=" + e.getSize());
}
}
System.out.println("--- ZipFile(一覧を見て、必要なものだけ読む)");
try (ZipFile zf = new ZipFile(zip.toFile(), StandardCharsets.UTF_8)) {
System.out.println("エントリ数: " + zf.size());
zf.stream().forEach(e -> System.out.println(
e.getName() + " size=" + e.getSize() + " 圧縮後=" + e.getCompressedSize()));
ZipEntry memo = zf.getEntry("img/memo.txt");
try (InputStream in = zf.getInputStream(memo)) {
System.out.print("img/memo.txt の中身: " + new String(in.readAllBytes(), StandardCharsets.UTF_8));
}
}
System.out.println("--- zip FileSystem(ふつうのファイルと同じ API で扱う)");
try (FileSystem fs = FileSystems.newFileSystem(zip)) {
try (Stream<Path> walk = Files.walk(fs.getPath("/"))) {
walk.forEach(p -> System.out.println(p));
}
System.out.print("readString: " + Files.readString(fs.getPath("/img/memo.txt")));
}
}
}
$ java Readers.java
--- ZipInputStream(先頭から順に読む)
empty/ size=-1
img/ size=-1
img/memo.txt size=-1
readme.txt size=-1
日本語メモ.txt size=-1
--- ZipFile(一覧を見て、必要なものだけ読む)
エントリ数: 5
empty/ size=0 圧縮後=2
img/ size=0 圧縮後=2
img/memo.txt size=28 圧縮後=33
readme.txt size=1450 圧縮後=46
日本語メモ.txt size=34 圧縮後=37
img/memo.txt の中身: 画像フォルダのメモ
--- zip FileSystem(ふつうのファイルと同じ API で扱う)
/
/日本語メモ.txt
/readme.txt
/img
/img/memo.txt
/empty
readString: 画像フォルダのメモ
この結果から、使い分けの目安が見えてきます。
- ZipInputStream の
getSize()は -1 になっています。ZipOutputStream で作った ZIP は、サイズの情報が各エントリの中身の後ろに書かれるため、先頭から順に読む ZipInputStream では、読み始める時点でサイズがわかりません。エントリのサイズを事前に知りたいときは ZipFile を使います。 - ZipFile は、名前を指定して1つだけ取り出せます。全体を読まずに済むので、大きな ZIP から一部だけ使う場合に向いています。
readme.txtは 1450 バイトが 46 バイトに圧縮され、短いimg/memo.txtは圧縮後のほうが少し大きくなっている、といった情報も取れます。 - zip FileSystem は、
Files.walk、Files.readStringなど、いつもの API がそのまま使えます。
| 状況 | 選ぶ方法 |
|---|---|
| アップロードされたデータなど、InputStream として受け取った ZIP を読む | ZipInputStream |
| ディスク上の ZIP を全部解凍する | ZipInputStream(または ZipFile) |
| ディスク上の ZIP から一部だけ取り出す、サイズを先に知りたい | ZipFile |
| ZIP の中のファイルを追加・削除・上書きする | zip FileSystem |
| 新しく ZIP を作る(大量のファイル) | ZipOutputStream |
公式の API ドキュメント(Java SE 21)は、ZipFile が使うリソースを解放するために close() を明示的に呼ぶか try-with-resources を使うこと、と注意書きを添えています。3つの方法とも try-with-resources で開くと覚えておけば足ります。
zip FileSystemでZIPの中身を追加・削除する
ZipOutputStream は「新しく作る」ためのクラスで、すでにある ZIP に1ファイルだけ足す、という操作は用意されていません。その用途には zip FileSystem が便利です。
import java.io.IOException;
import java.nio.file.FileSystem;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
public class ZipFs {
public static void main(String[] args) throws IOException {
Path zip = Path.of("fs.zip");
Files.deleteIfExists(zip);
// 新しい ZIP を作って、ファイルを入れる
try (FileSystem fs = FileSystems.newFileSystem(zip, Map.of("create", "true"))) {
Files.createDirectories(fs.getPath("/reports/2026"));
Files.writeString(fs.getPath("/reports/2026/10月.txt"), "10月のレポート\n");
Files.copy(Path.of("docs/readme.txt"), fs.getPath("/readme.txt"));
}
// 既存の ZIP に1ファイル足し、1ファイル消す
try (FileSystem fs = FileSystems.newFileSystem(zip)) {
Files.writeString(fs.getPath("/added.txt"), "あとから追加\n");
Files.delete(fs.getPath("/readme.txt"));
}
}
}
$ java ZipFs.java
$ unzip -l fs.zip
Archive: fs.zip
Length Date Time Name
--------- ---------- ----- ----
0 2026-10-03 23:52 reports/
0 2026-10-03 23:52 reports/2026/
21 2026-10-03 23:52 reports/2026/10月.txt
19 2026-10-03 23:52 added.txt
--------- -------
40 4 files
readme.txt が消え、added.txt が増えています。変更は FileSystem を閉じたときに ZIP へ反映されるので、ここでも try-with-resources が欠かせません。公式ドキュメント(jdk.zipfs モジュール)に載っている主なプロパティは次のとおりです。
| プロパティ | 既定値 | 意味 |
|---|---|---|
create |
false | true にすると、ZIP がなければ新しく作る |
encoding |
UTF-8 | エントリ名の文字コード |
compressionMethod |
DEFLATED | 圧縮方式。STORED を指定すると無圧縮 |
日本語ファイル名の文字化けと直し方
MS932のZIPをUTF-8で開くとどうなるか
Java の ZIP 関連クラスは、文字コードを指定しないとエントリ名を UTF-8 として扱います。一方、Windows の標準機能などで作られた ZIP には、日本語のファイル名が MS932(Shift_JIS 系)で記録されているものがあります。この組み合わせで何が起きるかを確かめます。
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.zip.ZipEntry;
import java.util.zip.ZipFile;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
public class Mojibake {
static final Charset MS932 = Charset.forName("MS932");
public static void main(String[] args) throws IOException {
// 日本語名のエントリを、MS932 と UTF-8 のそれぞれで作る
make(Path.of("ms932.zip"), MS932);
make(Path.of("utf8.zip"), StandardCharsets.UTF_8);
System.out.println("1) MS932 の ZIP を ZipInputStream(UTF-8)で読む");
list(Path.of("ms932.zip"), StandardCharsets.UTF_8);
System.out.println("2) MS932 の ZIP を ZipFile(UTF-8)で開く");
open(Path.of("ms932.zip"), StandardCharsets.UTF_8);
System.out.println("3) MS932 の ZIP を ZipInputStream(MS932)で読む");
list(Path.of("ms932.zip"), MS932);
System.out.println("4) MS932 の ZIP を ZipFile(MS932)で開く");
open(Path.of("ms932.zip"), MS932);
System.out.println("5) UTF-8 の ZIP を ZipInputStream(MS932)で読む");
list(Path.of("utf8.zip"), MS932);
System.out.println("6) UTF-8 の ZIP を ZipFile(MS932)で開く");
open(Path.of("utf8.zip"), MS932);
}
static void make(Path zip, Charset cs) throws IOException {
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zip), cs)) {
zos.putNextEntry(new ZipEntry("見積書.txt"));
zos.write("本文".getBytes(StandardCharsets.UTF_8));
zos.closeEntry();
}
}
static void list(Path zip, Charset cs) throws IOException {
try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(zip), cs)) {
ZipEntry e;
while ((e = zis.getNextEntry()) != null) {
System.out.println(" " + e.getName());
}
} catch (Exception ex) {
System.out.println(" " + ex);
}
}
static void open(Path zip, Charset cs) {
try (ZipFile zf = new ZipFile(zip.toFile(), cs)) {
zf.stream().forEach(e -> System.out.println(" " + e.getName()));
} catch (Exception ex) {
System.out.println(" " + ex);
}
}
}
$ java Mojibake.java
1) MS932 の ZIP を ZipInputStream(UTF-8)で読む
java.lang.IllegalArgumentException: malformed input off : 0, length : 1
2) MS932 の ZIP を ZipFile(UTF-8)で開く
java.util.zip.ZipException: invalid CEN header (bad entry name or comment)
3) MS932 の ZIP を ZipInputStream(MS932)で読む
見積書.txt
4) MS932 の ZIP を ZipFile(MS932)で開く
見積書.txt
5) UTF-8 の ZIP を ZipInputStream(MS932)で読む
見積書.txt
6) UTF-8 の ZIP を ZipFile(MS932)で開く
見積書.txt
結果を整理します。
| ZIP の文字コード | 開くときの指定 | ZipInputStream | ZipFile |
|---|---|---|---|
| MS932 | UTF-8(指定なしも同じ) | IllegalArgumentException: malformed input | ZipException: invalid CEN header |
| MS932 | MS932 | 正しく読める | 正しく読める |
| UTF-8 | MS932 | 正しく読める | 正しく読める |
1) と 2):MS932 の ZIP を UTF-8 で開くと、例外で止まります。 ZipInputStream では IllegalArgumentException: malformed input off : 0, length : 1、ZipFile では ZipException: invalid CEN header (bad entry name or comment) です。検査例外の IOException ではなく IllegalArgumentException が飛んでくる点は、catch (IOException e) だけでは受けられないので覚えておきたいところです。3) と 4):直し方は、コンストラクタの第2引数に Charset.forName("MS932") を渡すだけです。 先ほどの ZipUtil.unzip も第3引数で文字コードを受け取れるようにしてあるので、ZipUtil.unzip(Path.of("ms932.zip"), Path.of("out_ms932"), Charset.forName("MS932")) と呼ぶと、out_ms932/見積書.txt が正しい名前で展開されました。
5) と 6):UTF-8 の ZIP は、MS932 を指定して開いても正しく読めます。 ZIP には「このエントリ名は UTF-8 で書かれている」ことを示すフラグがあり、Java の ZipOutputStream は UTF-8 で書くときにこのフラグを立てます。公式ドキュメントでも、ZipFile のコンストラクタに渡す charset は「UTF-8 でエンコードされていない(エントリの汎用フラグで示される)エントリ名とコメントをデコードするために使う」と説明されています。つまり、渡した Charset は、UTF-8 のフラグが立っていないエントリにだけ使われます。
このしくみのおかげで、「Windows で作られた ZIP と、Java で作った ZIP のどちらが来るかわからない」場合は、MS932 を指定して開いておけば両方に対応できます。
zip FileSystem でも同じで、指定なしで開くと ZipException: invalid CEN header (bad entry name) になり、FileSystems.newFileSystem(path, Map.of("encoding", "MS932")) と指定すると /見積書.txt が正しく表示されました。
ほかのツールで文字化けして見える例
Java 以外のツールでも、文字コードの取り違えは起こります。MS932 で作った ms932.zip を unzip -l で見ると、名前が別の文字に化けて表示されました。
$ unzip -l ms932.zip
Length Date Time Name
--------- ---------- ----- ----
6 2026-10-03 23:52 МйР╧ПС.txt
$ unzip -O cp932 -l ms932.zip
Length Date Time Name
--------- ---------- ----- ----
6 2026-10-03 23:52 見積書.txt
-O cp932 で文字コードを指定すると、正しい名前になります。「例外にはならないが名前が化ける」ケースも、原因は同じく文字コードの取り違えです。
文字コードが影響する範囲
文字コードが影響するのはエントリ名(ファイル名)とコメントだけです。ファイルの中身はバイト列としてそのまま圧縮されるので、ZIP の文字コード指定を変えても中身は変わりません。
Zip Slipを防ぐ:展開先の外に書かせない
Zip Slip は、エントリ名に ../ を含む ZIP を解凍したときに、展開先として指定したフォルダの外にファイルが書き込まれてしまう問題です。外部から受け取った ZIP を解凍するプログラムでは、必ず対策を入れます。
検証用に、../outside.txt という名前のエントリを含む ZIP を作り、対策なしの解凍と、ZipUtil.unzip での解凍を比べます。
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
public class Slip {
public static void main(String[] args) throws IOException {
// 検証用: 名前に "../" を含むエントリを持つ ZIP を作る
Path zip = Path.of("slip.zip");
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zip))) {
zos.putNextEntry(new ZipEntry("ok.txt"));
zos.write("通常のファイル\n".getBytes(StandardCharsets.UTF_8));
zos.closeEntry();
zos.putNextEntry(new ZipEntry("../outside.txt"));
zos.write("展開先の外に書かれた\n".getBytes(StandardCharsets.UTF_8));
zos.closeEntry();
}
// 1) エントリ名をそのまま使う解凍
Path dest1 = Path.of("work/naive");
Files.createDirectories(dest1);
try (ZipInputStream zis = new ZipInputStream(Files.newInputStream(zip))) {
ZipEntry e;
while ((e = zis.getNextEntry()) != null) {
Path target = dest1.resolve(e.getName());
Files.copy(zis, target);
System.out.println("書き込み先: " + target + " → 実際の場所: " + target.normalize());
}
}
System.out.println("work/outside.txt がある: " + Files.exists(Path.of("work/outside.txt")));
Files.delete(Path.of("work/outside.txt"));
// 2) 展開先の中かどうかを確かめる解凍
try {
ZipUtil.unzip(zip, Path.of("work/safe"), StandardCharsets.UTF_8);
} catch (IOException ex) {
System.out.println("止めた: " + ex.getMessage());
}
System.out.println("work/outside.txt がある: " + Files.exists(Path.of("work/outside.txt")));
}
}
ZipUtil を使うので、先にコンパイルしてから実行します。
$ javac -d cls ZipUtil.java
$ javac -cp cls -d cls Slip.java
$ java -cp cls Slip
書き込み先: work/naive/ok.txt → 実際の場所: work/naive/ok.txt
書き込み先: work/naive/../outside.txt → 実際の場所: work/outside.txt
work/outside.txt がある: true
止めた: 展開先の外を指すエントリです: ../outside.txt
work/outside.txt がある: false
対策なしの解凍では、展開先 work/naive の1つ上の work/outside.txt にファイルが作られました。../ を重ねれば、さらに上の階層にも届いてしまいます。ZipUtil.unzip では、書き込む前に例外で止まっています。
対策の手順は次のとおりです。
| 手順 | 内容 |
|---|---|
| 1 | 展開先を絶対パスにして normalize() で整える |
| 2 | エントリ名を結合し、normalize() で ../ を解決した実際の場所を求める |
| 3 | Path.startsWith() で、展開先の中に収まっているかを確かめる |
文字列の String.startsWith() ではなく、Path.startsWith() を使うのがポイントです。Path の比較はフォルダ名の単位で行われるので、/data/out と /data/out2 のような似た名前を取り違えません。
なお、同じ ZIP を unzip コマンドで解凍すると、warning: skipped "../" path component(s) in ../outside.txt と表示され、../ を取り除いたうえで展開先の中に書き出されました。解凍ツールも同じ考え方で対策していることがわかります。
duplicate entryのZipExceptionと直し方
1つの ZIP に同じ名前のエントリを2回入れようとすると、ZipException: duplicate entry になります。フォルダの違う同名ファイルを、ファイル名だけでエントリにしたときによく起こります。
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipException;
import java.util.zip.ZipOutputStream;
public class Dup {
public static void main(String[] args) throws IOException {
// 別々のフォルダにある同じ名前のファイルを、名前だけで入れようとする例
String[] files = {"2025/report.txt", "2026/report.txt"};
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(Path.of("dup.zip")))) {
for (String f : files) {
String name = Path.of(f).getFileName().toString(); // どちらも report.txt
zos.putNextEntry(new ZipEntry(name));
zos.write(f.getBytes());
zos.closeEntry();
}
} catch (ZipException e) {
System.out.println("1) " + e);
}
// 直し方: 相対パスごと入れる。念のため、使った名前を Set で確かめる
Set<String> used = new HashSet<>();
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(Path.of("dup.zip")))) {
for (String f : files) {
if (!used.add(f)) {
continue; // 同じ名前は2回入れない
}
zos.putNextEntry(new ZipEntry(f));
zos.write(f.getBytes());
zos.closeEntry();
}
}
System.out.println("2) 入れた名前: " + used.stream().sorted().toList());
}
}
$ java Dup.java
1) java.util.zip.ZipException: duplicate entry: report.txt
2) 入れた名前: [2025/report.txt, 2026/report.txt]
| 原因 | 直し方 |
|---|---|
| フォルダ違いの同名ファイルを、ファイル名だけで入れている | 基準フォルダからの相対パスをエントリ名にする |
| 同じファイルを2回処理している | 入れた名前を Set で管理し、add() が false なら読み飛ばす |
| 複数の場所から集めたファイルの名前が重なる | report_2.txt のように連番を付けて別名にする |
ZipUtil.zipDirectory は相対パスをエントリ名にしているので、1つのフォルダを圧縮する限り、名前が重なることはありません。
圧縮レベルでサイズと時間はどう変わるか
zos.setLevel() で、圧縮の強さを 0〜9 で指定できます。レベルごとのサイズと時間を、性質の違う2種類のデータ(約 10 MB)で比べます。
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Random;
import java.util.zip.Deflater;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;
public class Level {
public static void main(String[] args) throws IOException {
// 1) 繰り返しの多いテキスト(CSV 風)約 10 MB
StringBuilder sb = new StringBuilder();
Random r = new Random(1);
for (int i = 0; i < 300_000; i++) {
sb.append(i).append(",備品").append(r.nextInt(100)).append(',').append(r.nextInt(100000)).append(",2026-10-03\n");
}
byte[] text = sb.toString().getBytes(StandardCharsets.UTF_8);
// 2) 乱数のバイト列(すでに圧縮済みのデータに近い)10 MB
byte[] random = new byte[10_000_000];
new Random(2).nextBytes(random);
measure("テキスト", text);
measure("乱数", random);
}
static void measure(String label, byte[] data) throws IOException {
System.out.println("--- " + label + "(元のサイズ " + data.length + " バイト)");
int[] levels = {Deflater.NO_COMPRESSION, Deflater.BEST_SPEED, Deflater.DEFAULT_COMPRESSION, Deflater.BEST_COMPRESSION};
String[] names = {"0 (NO_COMPRESSION)", "1 (BEST_SPEED)", "-1 (DEFAULT)", "9 (BEST_COMPRESSION)"};
for (int i = 0; i < levels.length; i++) {
Path zip = Path.of("level.zip");
long t0 = System.nanoTime();
try (ZipOutputStream zos = new ZipOutputStream(Files.newOutputStream(zip))) {
zos.setLevel(levels[i]);
zos.putNextEntry(new ZipEntry("data.bin"));
zos.write(data);
zos.closeEntry();
}
long ms = (System.nanoTime() - t0) / 1_000_000;
long size = Files.size(zip);
System.out.printf("%-22s %,11d バイト (%5.1f%%) %5d ms%n", names[i], size, size * 100.0 / data.length, ms);
Files.delete(zip);
}
}
}
$ java Level.java
--- テキスト(元のサイズ 9725391 バイト)
0 (NO_COMPRESSION) 9,727,001 バイト (100.0%) 66 ms
1 (BEST_SPEED) 2,665,344 バイト ( 27.4%) 80 ms
-1 (DEFAULT) 2,281,872 バイト ( 23.5%) 298 ms
9 (BEST_COMPRESSION) 2,172,372 バイト ( 22.3%) 1701 ms
--- 乱数(元のサイズ 10000000 バイト)
0 (NO_COMPRESSION) 10,001,655 バイト (100.0%) 24 ms
1 (BEST_SPEED) 10,003,175 バイト (100.0%) 219 ms
-1 (DEFAULT) 10,003,185 バイト (100.0%) 224 ms
9 (BEST_COMPRESSION) 10,003,185 バイト (100.0%) 230 ms
この計測から言えることは次のとおりです(時間は計測した環境での値で、マシンによって変わります)。
- テキストは、レベル 1 でも元の 27% まで小さくなります。 既定との差は約4ポイント、レベル 9 と既定の差は約1ポイントでした。
- レベル 9 は、既定の5倍以上の時間がかかりました。 サイズの差はわずかなので、時間をかけてでも小さくしたい場合に限って選ぶのがよさそうです。
- 乱数のデータは、どのレベルでも小さくなりません。 画像や動画、すでに圧縮されたファイルも同じ傾向になるデータです。こうしたファイルが中心なら、レベル 0 にすると圧縮にかかる時間を省けます(この計測では約10分の1)。
| 状況 | 選ぶレベル |
|---|---|
| 特に理由がない | 指定しない(既定のまま) |
| 大量のテキストを速く固めたい | Deflater.BEST_SPEED(1) |
| 配布物を少しでも小さくしたい | Deflater.BEST_COMPRESSION(9) |
| 圧縮済みのファイルをまとめるだけ | Deflater.NO_COMPRESSION(0) |
つまずきやすいエラーの逆引き表
| 出たエラー・症状 | 原因 | 直し方 |
|---|---|---|
IllegalArgumentException: malformed input off : 0, length : 1 |
エントリ名が UTF-8 ではない ZIP を、UTF-8 で読んだ(ZipInputStream) | 第2引数に Charset.forName("MS932") を渡す |
ZipException: invalid CEN header (bad entry name or comment) |
同上(ZipFile、zip FileSystem) | 文字コードを指定して開く |
ZipException: duplicate entry: 名前 |
同じ名前のエントリを2回入れた | 相対パスをエントリ名にする、Set で重複を確かめる |
FileAlreadyExistsException(解凍時) |
展開先に同じ名前のファイルがある | StandardCopyOption.REPLACE_EXISTING を付ける、または展開先を空にする |
| 空のディレクトリが ZIP に入らない | ファイルのエントリしか作っていない | 末尾 / のエントリを追加する |
| ZIP の中に手元の絶対パスが入る | file.toString() をエントリ名にした |
relativize() で相対パスにする |
| エントリのサイズが -1 になる | ZipInputStream で読んでいる | ZipFile を使う |
| できた ZIP が開けない | ZipOutputStream を閉じていない | try-with-resources で開く |
よくある質問
Q. パスワード付きの ZIP は作れますか?
A. 標準ライブラリの java.util.zip には、パスワードを設定する機能がありません。必要な場合は、対応した外部ライブラリを使うか、ZIP とは別のしくみでファイルを保護する方法を検討します。
Q. ZipOutputStream で、すでにある ZIP にファイルを追加できますか?
A. ZipOutputStream は新しく作るためのクラスです。既存の ZIP に足したいときは、本文で紹介した zip FileSystem を使うと、Files.writeString や Files.copy で追加できます。
Q. 解凍したら日本語のファイル名でエラーになります。どの文字コードを指定すればよいですか?
A. Windows で作られた ZIP なら、まず Charset.forName("MS932") を試します。UTF-8 のフラグが立ったエントリは、MS932 を指定しても UTF-8 として読まれるので、Java で作った ZIP も同じコードで開けます。
まとめ
- 圧縮は
ZipOutputStream、解凍はZipInputStream。putNextEntry→ 書き込み →closeEntryを繰り返し、try-with-resources で閉じる - ディレクトリごとの圧縮は
Files.walk()+相対パス。空のディレクトリは末尾/のエントリで入れる - MS932 の ZIP を UTF-8 で開くと、ZipInputStream は IllegalArgumentException、ZipFile は ZipException。第2引数に
Charset.forName("MS932")を渡せば読める - UTF-8 のフラグが立ったエントリは、MS932 を指定しても正しく読めるので、MS932 指定で両方の ZIP に対応できる
- 解凍では
normalize()とPath.startsWith()で、展開先の中かを必ず確かめる(Zip Slip 対策) - 一部だけ読むなら ZipFile、中身の追加・削除は zip FileSystem
- 圧縮レベルは既定のままで十分なことが多い。テキストはレベル 1 でも 27% まで小さくなり、圧縮済みのデータはどのレベルでも小さくならない
参考・出典
- Java SE 21 API ドキュメント:
java.util.zip.ZipFile(Oracle) - Java SE 21 API ドキュメント:jdk.zipfs モジュール(Oracle)
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「Java ZIP圧縮・解凍の書き方|ディレクトリごと圧縮と日本語名の文字化け対策」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。