JavaのURLエンコードとは、URLにそのまま置けない文字(空白・記号・日本語など)を「%E3%81%82」のような形に変換する処理で、URLEncoder.encode(文字列, StandardCharsets.UTF_8) の1行で書けます。 元に戻すデコードは URLDecoder.decode(文字列, StandardCharsets.UTF_8) です。

この記事では、記号・空白・日本語・絵文字を実際に通した結果を、JavaScript の encodeURIComponent と並べた早見表にまとめました。あわせて、空白が + になる理由と %20 に直す置換、クエリ文字列の組み立て、URI クラスを使ったパス部分の扱い、デコードで例外が出る入力とそのエラー文まで、コピーしてそのまま動くコードと実行結果で紹介します。

確認環境:Ubuntu 24.04 / OpenJDK 21.0.10(java ファイル名.java で実行)。JavaScript との比較は Node.js 22.22.2 で実行しています。

URLエンコード・デコードの早見表

やりたいこと別の書き方

やりたいこと 書き方 結果の例(入力は「Java 入門」)
エンコードする(空白は +) URLEncoder.encode(s, StandardCharsets.UTF_8) Java+%E5%85%A5%E9%96%80
空白を %20 にしてエンコードする URLEncoder.encode(s, StandardCharsets.UTF_8).replace("+", "%20") Java%20%E5%85%A5%E9%96%80
デコードする URLDecoder.decode(s, StandardCharsets.UTF_8) Java 入門
クエリ文字列を組み立てる キーと値をそれぞれエンコードし、= と & でつなぐ q=Java%20%E5%85%A5%E9%96%80&page=1
パスに日本語や空白を入れる new URI("https", "example.com", "/files/年間 レポート.pdf", null).toASCIIString() /files/%E5%B9%B4... の形になる
パスの1区切りに / を含める 区切りごとに encode(...).replace("+", "%20") してからつなぐ 10%2F04
デコードできない入力に備える IllegalArgumentException を catch する 例外の代わりに既定値を返す
項目 内容
必要な import java.net.URLEncoder / java.net.URLDecoder / java.nio.charset.StandardCharsets
Charset を渡せる版 Java 10 以降(それより前は文字コード名を文字列で渡す)
そのまま残る文字 英数字と . - * _ の4つの記号
空白の扱い + に変換される(フォーム送信の形式に合わせた動き)
デコードできない入力 IllegalArgumentException が出る(「%」単独、「%zz」など)

文字別の変換結果(Java と JavaScript の比較)

下の表は、同じ入力を Java の URLEncoder.encode(入力, StandardCharsets.UTF_8) と JavaScript の encodeURIComponent(入力) に通した実行結果です。空白を %20 に置換すれば、チルダ・感嘆符・シングルクォート・丸かっこの4種類を除いて両者は一致します。

文字の種類 入力 Java URLEncoder.encode + を %20 に置換した後 JavaScript encodeURIComponent 置換後に一致するか
半角空白 (半角の空白1つ) + %20 %20 一致
プラス + %2B %2B %2B 一致
アンパサンド & %26 %26 %26 一致
イコール = %3D %3D %3D 一致
疑問符 ? %3F %3F %3F 一致
スラッシュ / %2F %2F %2F 一致
コロン : %3A %3A %3A 一致
シャープ # %23 %23 %23 一致
パーセント % %25 %25 %25 一致
アットマーク @ %40 %40 %40 一致
カンマ , %2C %2C %2C 一致
セミコロン ; %3B %3B %3B 一致
ドル $ %24 %24 %24 一致
ハイフン - - - - 一致
アンダースコア _ _ _ _ 一致
ピリオド . . . . 一致
アスタリスク * * * * 一致
チルダ ~ %7E %7E ~ 異なる
感嘆符 ! %21 %21 ! 異なる
シングルクォート ' %27 %27 ' 異なる
丸かっこ () %28%29 %28%29 () 異なる
角かっこ [] %5B%5D %5B%5D %5B%5D 一致
ダブルクォート " %22 %22 %22 一致
全角空白 (全角の空白1つ) %E3%80%80 %E3%80%80 %E3%80%80 一致
ひらがな あ %E3%81%82 %E3%81%82 %E3%81%82 一致
漢字 日本 %E6%97%A5%E6%9C%AC %E6%97%A5%E6%9C%AC %E6%97%A5%E6%9C%AC 一致
アクセント付き é %C3%A9 %C3%A9 %C3%A9 一致
絵文字 😀 %F0%9F%98%80 %F0%9F%98%80 %F0%9F%98%80 一致
英数字 abcXYZ019 abcXYZ019 abcXYZ019 abcXYZ019 一致

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Java URLエンコード・デコード入門|+と%20の違い早見表」

まず動かす:エンコードとデコードの基本

最初に、エンコードして元に戻すまでの最小のコードです。ファイル名を EncodeBasic.java として保存し、java EncodeBasic.java で実行できます。

import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;

public class EncodeBasic {
    public static void main(String[] args) {
        String keyword = "Java 入門 & 応用";

        // エンコード(Java 10 以降:Charset を渡す。例外の宣言が不要)
        String encoded = URLEncoder.encode(keyword, StandardCharsets.UTF_8);
        System.out.println("エンコード: " + encoded);

        // デコード
        String decoded = URLDecoder.decode(encoded, StandardCharsets.UTF_8);
        System.out.println("デコード  : " + decoded);
        System.out.println("元と同じ  : " + keyword.equals(decoded));

        // 空白を %20 にしたいとき
        String percent20 = encoded.replace("+", "%20");
        System.out.println("%20 版    : " + percent20);
    }
}

実行結果です。

エンコード: Java+%E5%85%A5%E9%96%80+%26+%E5%BF%9C%E7%94%A8
デコード  : Java 入門 & 応用
元と同じ  : true
%20 版    : Java%20%E5%85%A5%E9%96%80%20%26%20%E5%BF%9C%E7%94%A8

読み取れることは3つあります。

  • 日本語は UTF-8 のバイト列になり、1バイトごとに「%」+16進数2桁で表されます。「入」は UTF-8 で3バイトなので %E5%85%A5 です。
  • 空白は + に、もともとの & は %26 に変わります。 区切りとしての & と、値の中の & を見分けられるようになります。
  • 同じ文字コードでデコードすれば、元の文字列に戻ります。

encode と decode の引数は3種類ある

URLEncoder.encode と URLDecoder.decode には、引数の違う3つの形があります。Java 21 の API ドキュメントで確認した内容を表にまとめます。

形 追加された版 特徴 使いどころ
encode(String s, Charset charset) Java 10 StandardCharsets.UTF_8 を渡す。検査例外の宣言が不要 Java 10 以降はこの形が書きやすい
encode(String s, String enc) Java 1.4 文字コード名を文字列で渡す。UnsupportedEncodingException の処理が必要 Java 8 など、Charset 版がない環境
encode(String s) ― 非推奨。実行環境の既定の文字コードで結果が変わる 新しく書くコードでは上の2つを使う

decode も同じ3つの形です(Charset 版は Java 10、文字列版は Java 1.4 で追加)。

文字コード名を文字列で渡す形で出るコンパイルエラー

文字列で渡す形は検査例外(呼び出し側に処理を求める例外)を投げる宣言になっているため、try-catch も throws もないとコンパイルが止まります。

import java.net.URLEncoder;

public class LegacyStyle {
    public static void main(String[] args) {
        String encoded = URLEncoder.encode("Java 入門", "UTF-8");
        System.out.println(encoded);
    }
}
LegacyStyle.java:5: error: unreported exception UnsupportedEncodingException; must be caught or declared to be thrown
        String encoded = URLEncoder.encode("Java 入門", "UTF-8");
                                          ^
1 error
error: compilation failed

try-catch で囲むと動きます。存在しない文字コード名(ここでは UTF8X)を渡したときに、この例外が出ます。

import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;

public class LegacyStyleOk {
    public static void main(String[] args) {
        try {
            System.out.println(URLEncoder.encode("Java 入門", "UTF-8"));
            System.out.println(URLEncoder.encode("Java 入門", "UTF8X"));
        } catch (UnsupportedEncodingException e) {
            System.out.println(e.getClass().getName() + ": " + e.getMessage());
        }
    }
}
Java+%E5%85%A5%E9%96%80
java.io.UnsupportedEncodingException: UTF8X

Charset 版なら、文字コード名の打ち間違いはコンパイルの時点で気づけて、try-catch も不要です。 Java 10 以降なら StandardCharsets.UTF_8 を渡す形にそろえると、コードが短くなります。

直す前(文字列で渡す) 直した後(Charset を渡す)
URLEncoder.encode(s, "UTF-8") と try-catch URLEncoder.encode(s, StandardCharsets.UTF_8)
URLDecoder.decode(s, "UTF-8") と try-catch URLDecoder.decode(s, StandardCharsets.UTF_8)

+ と %20 の違いを理解する

空白が + になる理由

URLEncoder の API ドキュメントには、このクラスが「HTML フォームの形式(application/x-www-form-urlencoded)」に変換するためのものだと書かれています。変換の規則は次の4つです。

文字 変換の規則
英字(a〜z、A〜Z)と数字(0〜9) そのまま
. - * _ そのまま
空白 + に変換
それ以外 指定した文字コードでバイト列にし、1バイトごとに %xy(16進数2桁)に変換

つまり + は「フォーム形式での空白の書き方」、%20 は「空白のバイト値 0x20 をそのままパーセント表記にした書き方」 です。どちらも空白を表しますが、通用する場所が違います。

URL の場所 + の意味 %20 の意味 おすすめ
クエリ文字列(? の後ろ) 読む側の道具で変わる(URLDecoder と URLSearchParams は空白、decodeURIComponent は + のまま) 空白 %20 にそろえると受け取り側を選ばない
パス(/ で区切られた部分) + という文字そのもの(URI クラスは + のまま扱う) 空白 %20 を使う
フォーム送信の本文 空白 空白 そのまま(+)でよい

+ を %20 に直す置換

URLEncoder.encode の結果に含まれる + は、必ず「元が空白だったもの」です。元の文字列にあった + は %2B に変換済みだからです。したがって、エンコードした後で + を %20 に置換すれば、安心して空白だけを %20 にできます。

String s = URLEncoder.encode("a b+c", StandardCharsets.UTF_8); // a+b%2Bc
String t = s.replace("+", "%20");                              // a%20b%2Bc

順番が大切で、置換はエンコードの後に行います。エンコードの前に空白を %20 に置換すると、% がさらに %25 に変換されて %2520 になります。

JavaScript の encodeURIComponent・decodeURIComponent との違い

フロント側が JavaScript、サーバー側が Java という組み合わせでは、両者の違いを知っておくと調査が早くなります。Node.js 22.22.2 で次のコードを実行しました。

console.log(encodeURIComponent("It's a (new) Java~ 入門!"));
console.log(decodeURIComponent("Java+%E5%85%A5%E9%96%80"));
console.log(decodeURIComponent("Java%20%E5%85%A5%E9%96%80"));
const u = new URLSearchParams({q:"Java 入門", memo:"1+1=2"});
console.log(u.toString());
console.log(new URLSearchParams("q=Java+%E5%85%A5%E9%96%80").get("q"));
try { decodeURIComponent("%"); } catch (e) { console.log(e.name + ": " + e.message); }
It's%20a%20(new)%20Java~%20%E5%85%A5%E9%96%80!
Java+入門
Java 入門
q=Java+%E5%85%A5%E9%96%80&memo=1%2B1%3D2
Java 入門
URIError: URI malformed
確認したこと 結果
encodeURIComponent は空白をどうするか %20 にする
encodeURIComponent がそのまま残す記号 - _ . ! ~ * ' ( )(MDN の記載どおり)
decodeURIComponent は + を空白に戻すか 戻さない(2行目の出力に + が残っている)
URLSearchParams は空白をどうするか + にする(Java の URLEncoder と同じフォーム形式)
decodeURIComponent("%") URIError: URI malformed

Java で + を含む形のまま返した値を、JavaScript の decodeURIComponent で読むと + が残ります。 Java 側で %20 に置換しておくか、JavaScript 側で URLSearchParams を使って読むと、空白として受け取れます。

コピペで使えるヘルパー:クエリ文字列の組み立てと分解

よく使う処理を1つのクラスにまとめました。フォーム形式、%20 形式、JavaScript と同じ結果にそろえる形、Map からのクエリ文字列の組み立て、その逆の分解が入っています。

import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.stream.Collectors;

public class UrlCodec {

    /** フォーム形式(空白は +)。クエリの値にそのまま使える */
    static String encodeForm(String s) {
        return URLEncoder.encode(s, StandardCharsets.UTF_8);
    }

    /** 空白を %20 にした形。パスの1区切り分にも使える */
    static String encodePercent(String s) {
        return URLEncoder.encode(s, StandardCharsets.UTF_8).replace("+", "%20");
    }

    /** JavaScript の encodeURIComponent と同じ結果にそろえる */
    static String encodeLikeJs(String s) {
        return URLEncoder.encode(s, StandardCharsets.UTF_8)
                .replace("+", "%20")
                .replace("%21", "!")
                .replace("%27", "'")
                .replace("%28", "(")
                .replace("%29", ")")
                .replace("%7E", "~");
    }

    /** Map からクエリ文字列を組み立てる */
    static String buildQuery(Map<String, String> params) {
        return params.entrySet().stream()
                .map(e -> encodePercent(e.getKey()) + "=" + encodePercent(e.getValue()))
                .collect(Collectors.joining("&"));
    }

    /** クエリ文字列を Map に戻す */
    static Map<String, String> parseQuery(String query) {
        Map<String, String> map = new LinkedHashMap<>();
        for (String pair : query.split("&")) {
            if (pair.isEmpty()) continue;
            int i = pair.indexOf('=');
            String k = i < 0 ? pair : pair.substring(0, i);
            String v = i < 0 ? "" : pair.substring(i + 1);
            map.put(URLDecoder.decode(k, StandardCharsets.UTF_8),
                    URLDecoder.decode(v, StandardCharsets.UTF_8));
        }
        return map;
    }

    public static void main(String[] args) {
        String s = "It's a (new) Java~ 入門!";
        System.out.println("encodeForm   : " + encodeForm(s));
        System.out.println("encodePercent: " + encodePercent(s));
        System.out.println("encodeLikeJs : " + encodeLikeJs(s));

        Map<String, String> params = new LinkedHashMap<>();
        params.put("q", "Java 入門");
        params.put("tag", "a&b=c");
        params.put("並び順", "新着");
        params.put("memo", "1+1=2");
        String query = buildQuery(params);
        String url = "https://example.com/search?" + query;
        System.out.println(url);

        System.out.println(parseQuery(query));
    }
}

実行結果です。

encodeForm   : It%27s+a+%28new%29+Java%7E+%E5%85%A5%E9%96%80%21
encodePercent: It%27s%20a%20%28new%29%20Java%7E%20%E5%85%A5%E9%96%80%21
encodeLikeJs : It's%20a%20(new)%20Java~%20%E5%85%A5%E9%96%80!
https://example.com/search?q=Java%20%E5%85%A5%E9%96%80&tag=a%26b%3Dc&%E4%B8%A6%E3%81%B3%E9%A0%86=%E6%96%B0%E7%9D%80&memo=1%2B1%3D2
{q=Java 入門, tag=a&b=c, 並び順=新着, memo=1+1=2}
  • 3行目の encodeLikeJs の結果は、前の章で Node.js が出力した encodeURIComponent の結果(It's%20a%20(new)%20Java~%20%E5%85%A5%E9%96%80!)と一致しています。
  • 値の中の & と = は %26 と %3D に変わるため、tag=a%26b%3Dc は1組のキーと値として扱われます。
  • memo の値「1+1=2」は 1%2B1%3D2 になり、分解すると元の「1+1=2」に戻っています。
  • キーに日本語(並び順)を使っても、値と同じようにエンコードすれば問題なく往復できます。

クエリ文字列を組み立てるときの3つの約束

約束 理由
キーと値を別々にエンコードしてから、= と & でつなぐ 全体をまとめてエンコードすると、区切りの = と & まで変換されてしまう
URL 全体(https://...)は URLEncoder に通さない : や / が %3A %2F になり、URL として読めなくなる
分解するときは、& と = で分けてからデコードする 先にデコードすると、値の中の & と区切りの & を見分けられなくなる

パス部分は URI クラスで扱う

URLEncoder はクエリの値に向いた道具です。パス(/ で区切られた部分)に日本語や空白を入れるときは、java.net.URI を使うと場所に合った変換になります。

import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;

public class UriPath {
    public static void main(String[] args) throws URISyntaxException {
        // 1) 複数引数のコンストラクタにパスを渡すと、パスに置けない文字を変換してくれる
        URI uri = new URI("https", "example.com", "/files/2026 年間レポート.pdf", null);
        System.out.println("toString      : " + uri);
        System.out.println("toASCIIString : " + uri.toASCIIString());
        System.out.println("getRawPath    : " + uri.getRawPath());
        System.out.println("getPath       : " + uri.getPath());

        // 2) パスの中の + はそのまま残る(パスでは + は空白の意味を持たない)
        URI plus = new URI("https", "example.com", "/files/a+b c.txt", null);
        System.out.println("plus          : " + plus.toASCIIString());

        // 3) ファイル名に / や ? を含むときは、1区切りずつ自分でエンコードしてつなぐ
        String name = "議事録 10/04 Q&A?.txt";
        String segment = URLEncoder.encode(name, StandardCharsets.UTF_8).replace("+", "%20");
        URI built = URI.create("https://example.com/files/" + segment);
        System.out.println("segment       : " + built);
        System.out.println("getPath       : " + built.getPath());

        // 4) クエリつきで組み立てる(値は先にエンコードしておく)
        String q = "q=" + URLEncoder.encode("Java 入門 & 応用", StandardCharsets.UTF_8);
        URI search = URI.create("https://example.com/search?" + q);
        System.out.println("search        : " + search);
        System.out.println("getRawQuery   : " + search.getRawQuery());
        System.out.println("getQuery      : " + search.getQuery());

        // 5) エンコードせずに URI.create へ渡すと例外になる
        try {
            URI.create("https://example.com/files/a b.txt");
        } catch (IllegalArgumentException e) {
            System.out.println(e.getMessage());
        }

        // 6) 複数引数のコンストラクタは % を必ず変換するので、エンコード済みの値を渡すと二重になる
        URI twice = new URI("https", "example.com", "/files/a%20b.txt", null);
        System.out.println("twice         : " + twice);

        // 7) クエリを複数引数のコンストラクタに渡すと & や = は変換されない
        URI rawQuery = new URI("https", "example.com", "/search", "q=a&b=c d", null);
        System.out.println("query arg     : " + rawQuery.toASCIIString());
    }
}

実行結果です。

toString      : https://example.com/files/2026%20年間レポート.pdf
toASCIIString : https://example.com/files/2026%20%E5%B9%B4%E9%96%93%E3%83%AC%E3%83%9D%E3%83%BC%E3%83%88.pdf
getRawPath    : /files/2026%20年間レポート.pdf
getPath       : /files/2026 年間レポート.pdf
plus          : https://example.com/files/a+b%20c.txt
segment       : https://example.com/files/%E8%AD%B0%E4%BA%8B%E9%8C%B2%2010%2F04%20Q%26A%3F.txt
getPath       : /files/議事録 10/04 Q&A?.txt
search        : https://example.com/search?q=Java+%E5%85%A5%E9%96%80+%26+%E5%BF%9C%E7%94%A8
getRawQuery   : q=Java+%E5%85%A5%E9%96%80+%26+%E5%BF%9C%E7%94%A8
getQuery      : q=Java+入門+&+応用
Illegal character in path at index 27: https://example.com/files/a b.txt
twice         : https://example.com/files/a%2520b.txt
query arg     : https://example.com/search?q=a&b=c%20d

出力からわかることを整理します。

番号 わかること
1 複数引数のコンストラクタは空白を %20 にする。日本語は toString ではそのまま、toASCIIString で %XX の形になる
1 getRawPath は変換後のまま、getPath はデコードした形を返す
2 パスの中の + は + のまま残る
3 / や ? を1区切りの中に含めたいときは、区切りごとに自分でエンコードしてからつなぐ
4 getRawQuery はエンコードされたまま、getQuery は %XX をデコードする(+ は + のまま)
5 空白を含む文字列を URI.create に渡すと IllegalArgumentException(Illegal character in path)
6 エンコード済みの値を複数引数のコンストラクタに渡すと、% が %25 になり二重になる
7 クエリを引数で渡した場合、& と = は区切りとみなされて変換されない

API ドキュメントにも、複数引数のコンストラクタは「その場所に置けない文字を変換する」「% は常に変換する」、引数1つのコンストラクタは「置けない文字が変換済みであることを求める」と書かれています。使い分けは次のとおりです。

手元にある値 使うもの
まだエンコードしていないパス(/ は区切りとして使う) new URI(scheme, host, path, fragment) と toASCIIString()
/ や ? を含むファイル名など、1区切り分の値 URLEncoder.encode(...).replace("+", "%20") で区切りごとに変換
すでにエンコード済みの URL 文字列 URI.create(文字列)
クエリの値 URLEncoder.encode(...)(必要なら %20 に置換)

デコードで IllegalArgumentException が出る入力

URLDecoder.decode は、% の後ろに16進数2桁が続いていない入力を受け取ると IllegalArgumentException を投げます。API ドキュメントには「この実装は、不正な文字列に出会うと IllegalArgumentException を投げる」と書かれています。どんな入力で、どんなエラー文になるかを一覧で確かめます。

import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;

public class DecodeErrors {
    public static void main(String[] args) {
        String[] inputs = {
            "%",            // % だけ
            "100%",         // 末尾が %
            "%2",           // 16進数が1桁しかない
            "%zz",          // 16進数ではない
            "50%OFF",       // % の後ろが 16進数ではない
            "%-1",          // 符号つき
            "%E3%81",       // UTF-8 として途中で切れている
            "%82%A0",       // Shift_JIS のバイト列を UTF-8 で読む
            "1+1=2",        // + を含む
            "%2B",          // + をエンコードしたもの
            "%2520",        // 二重エンコード
        };
        for (String in : inputs) {
            try {
                String out = URLDecoder.decode(in, StandardCharsets.UTF_8)
                        .replace(String.valueOf((char) 0xFFFD), "(置換文字)");
                System.out.printf("%-8s -> [%s]%n", in, out);
            } catch (IllegalArgumentException e) {
                System.out.printf("%-8s -> %s: %s%n", in, e.getClass().getSimpleName(), e.getMessage());
            }
        }
    }
}

実行結果です(置換文字は、読めないバイト列の代わりに入る U+FFFD の文字で、見やすいよう文字列に置き換えて表示しています)。

%        -> IllegalArgumentException: URLDecoder: Incomplete trailing escape (%) pattern
100%     -> IllegalArgumentException: URLDecoder: Incomplete trailing escape (%) pattern
%2       -> IllegalArgumentException: URLDecoder: Incomplete trailing escape (%) pattern
%zz      -> IllegalArgumentException: URLDecoder: Illegal hex characters in escape (%) pattern - Error at index 0 in: "zz"
50%OFF   -> IllegalArgumentException: URLDecoder: Illegal hex characters in escape (%) pattern - Error at index 0 in: "OF"
%-1      -> IllegalArgumentException: URLDecoder: Illegal hex characters in escape (%) pattern - negative value
%E3%81   -> [(置換文字)]
%82%A0   -> [(置換文字)(置換文字)]
1+1=2    -> [1 1=2]
%2B      -> [+]
%2520    -> [%20]
入力 結果 エラー文・戻り値
%、100%、%2 例外 URLDecoder: Incomplete trailing escape (%) pattern
%zz、50%OFF 例外 URLDecoder: Illegal hex characters in escape (%) pattern - Error at index 0 in: "zz"
%-1 例外 URLDecoder: Illegal hex characters in escape (%) pattern - negative value
%E3%81(UTF-8 として途中で切れている) 例外にならない 置換文字1つ
%82%A0(別の文字コードのバイト列) 例外にならない 置換文字2つ
1+1=2 例外にならない 1 1=2(+ が空白になる)
%2520 例外にならない %20(1回分だけ戻る)

「% の形が崩れている」と例外、「形は正しいが文字コードとして読めない」と置換文字、という2段階になっている点がポイントです。

例外をそのまま出したときのスタックトレース

try-catch なしで「割引率は50%」のような、% で終わる文字列をデコードした場合です。

import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;

public class DecodeTrace {
    public static void main(String[] args) {
        String value = URLDecoder.decode("割引率は50%", StandardCharsets.UTF_8);
        System.out.println(value);
    }
}
Exception in thread "main" java.lang.IllegalArgumentException: URLDecoder: Incomplete trailing escape (%) pattern
    at java.base/java.net.URLDecoder.decode(URLDecoder.java:236)
    at DecodeTrace.main(DecodeTrace.java:6)

画面に入力された値やログの文字列など、エンコードされていない普通の文章をデコードに通すと、% を含むときにこの例外が出ます。 「エンコードされた値だけをデコードする」と決めておくと、この例外に出会う場面が減ります。

例外に備える書き方と、+ を空白にしないデコード

外から届いた値をデコードするときは、例外を受け止めて既定値にする小さなメソッドを用意しておくと扱いやすくなります。

import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Optional;

public class SafeDecode {

    /** デコードできない入力は空の Optional にする */
    static Optional<String> tryDecode(String s) {
        try {
            return Optional.of(URLDecoder.decode(s, StandardCharsets.UTF_8));
        } catch (IllegalArgumentException e) {
            return Optional.empty();
        }
    }

    /** + を空白にせず、%XX だけを戻す(パス部分向け) */
    static String decodePath(String s) {
        return URLDecoder.decode(s.replace("+", "%2B"), StandardCharsets.UTF_8);
    }

    public static void main(String[] args) {
        System.out.println(tryDecode("Java%20%E5%85%A5%E9%96%80"));
        System.out.println(tryDecode("50%OFF"));
        System.out.println(tryDecode("50%OFF").orElse("(読み取れない値)"));
        System.out.println(decodePath("a+b%20c.txt"));
        System.out.println(URLDecoder.decode("a+b%20c.txt", StandardCharsets.UTF_8));
    }
}
Optional[Java 入門]
Optional.empty
(読み取れない値)
a+b c.txt
a b c.txt

decodePath は、デコードの前に + を %2B に置換しておくことで、+ を空白にせず + のまま残します。パス部分の値をデコードするときに使える形です(最後の行は置換なしの結果で、+ が空白になっています)。

文字コードの不一致と二重エンコード

エンコードとデコードで文字コードがそろっていない場合と、エンコードを2回かけた場合の動きも確かめておきます。

import java.net.URLDecoder;
import java.net.URLEncoder;
import java.nio.charset.Charset;
import java.nio.charset.StandardCharsets;

public class Charsets {
    public static void main(String[] args) {
        Charset sjis = Charset.forName("Shift_JIS");
        String utf8 = URLEncoder.encode("日本", StandardCharsets.UTF_8);
        String s = URLEncoder.encode("日本", sjis);
        System.out.println("UTF-8     : " + utf8);
        System.out.println("Shift_JIS : " + s);
        System.out.println("UTF-8 で書いて Shift_JIS で読む: " + URLDecoder.decode(utf8, sjis));
        System.out.println("同じ文字コードで読む            : " + URLDecoder.decode(utf8, StandardCharsets.UTF_8));
        System.out.println("二重エンコード: " + URLEncoder.encode(utf8, StandardCharsets.UTF_8));
    }
}
UTF-8     : %E6%97%A5%E6%9C%AC
Shift_JIS : %93%FA%96%7B
UTF-8 で書いて Shift_JIS で読む: 譌・譛ャ
同じ文字コードで読む            : 日本
二重エンコード: %25E6%2597%25A5%25E6%259C%25AC
  • 同じ「日本」でも、UTF-8 と Shift_JIS では %XX の並びが変わります。 送る側と受け取る側で文字コードをそろえることが前提です。
  • UTF-8 でエンコードしたものを Shift_JIS でデコードしても例外は出ず、別の文字(譌・譛ャ)になります。文字化けを見たら、まず両側の文字コードを見比べます。
  • エンコード済みの文字列をもう一度エンコードすると、% が %25 になります。%25E6 のように %25 が並んでいたら二重エンコードのサインです。

つまずきやすい点と直し方

症状 原因 直し方
受け取り側で空白が + のまま表示される 受け取り側が + を空白として扱わない(JavaScript の decodeURIComponent など) エンコード後に .replace("+", "%20")
値の中の + が空白に変わる エンコードせずに + を送り、受け取り側がフォーム形式でデコードした 送る前に必ずエンコードする(+ は %2B になる)
%2520 や %25E3 が出てくる エンコードを2回かけている エンコードは「URL を組み立てる直前に1回」と決める
unreported exception UnsupportedEncodingException 文字コード名を文字列で渡す形を使っている StandardCharsets.UTF_8 を渡す形に変える
Incomplete trailing escape (%) pattern % で終わる、または % の後ろが1桁しかない エンコード済みの値だけをデコードする。外から届いた値は例外に備える
Illegal hex characters in escape (%) pattern % の後ろが16進数ではない 同上
Illegal character in path at index ... エンコードしていない文字列を URI.create に渡した 複数引数の new URI(...) を使うか、先にエンコードする
URL 全体が https%3A%2F%2F... になる URL 全体を URLEncoder に通した 値の部分だけをエンコードする
文字化けする エンコードとデコードで文字コードが違う 両方 UTF-8 にそろえる

どの方法を選ぶかの判断表

値を置く場所 選ぶ方法
クエリの値・キー(相手が Java のフォーム処理) URLEncoder.encode(s, UTF_8) のまま
クエリの値・キー(相手が不明、または JavaScript で読む) URLEncoder.encode(s, UTF_8).replace("+", "%20")
JavaScript の encodeURIComponent と完全に同じ文字列が必要(署名の計算など) この記事の encodeLikeJs
パス全体(/ は区切り) new URI(...).toASCIIString()
パスの1区切り URLEncoder.encode(s, UTF_8).replace("+", "%20")
フォーム送信の本文 URLEncoder.encode(s, UTF_8) のまま

よくある質問

Q. + と %20 は、結局どちらを使えばよいですか? A. 迷ったら %20 です。%20 はクエリでもパスでも空白として読まれます。+ が空白として読まれるのはフォーム形式(クエリやフォーム送信の本文)に限られます。URLEncoder.encode の結果に .replace("+", "%20") を付ければ切り替えられます。

Q. Java 8 でも StandardCharsets.UTF_8 を渡せますか? A. encode(String, Charset) と decode(String, Charset) は Java 10 で追加された形です。Java 8 では URLEncoder.encode(s, "UTF-8") のように文字コード名を文字列で渡し、UnsupportedEncodingException を try-catch で受けます。StandardCharsets.UTF_8.name() を渡すと、文字コード名の打ち間違いを防げます。

Q. URL 全体をまとめてエンコードしてもよいですか? A. 値の部分だけをエンコードします。URL 全体を通すと : や /、?、& まで変換されて、URL として読めない文字列になります。クエリはキーと値を別々に、パスは URI クラスか区切りごとのエンコードで組み立てます。

Q. デコードで例外が出るのを防ぐにはどうすればよいですか? A. 「エンコードされた値だけをデコードする」ことが第一です。外から届く値は形が崩れていることもあるので、IllegalArgumentException を catch して既定値を返す tryDecode のようなメソッドを通すと、処理を続けられます。

まとめ

  • エンコードは URLEncoder.encode(s, StandardCharsets.UTF_8)、デコードは URLDecoder.decode(s, StandardCharsets.UTF_8)(Java 10 以降)。
  • URLEncoder はフォーム形式なので、空白は + になる。 %20 にしたいときはエンコード後に .replace("+", "%20")。
  • %20 に置換すれば、チルダ・感嘆符・シングルクォート・丸かっこを除いて encodeURIComponent と同じ結果になる。
  • クエリ文字列は、キーと値を別々にエンコードしてから = と & でつなぐ。
  • パス部分は URI の複数引数コンストラクタと toASCIIString()、/ を含む1区切りは個別にエンコードする。
  • 「%」単独や「%zz」をデコードすると IllegalArgumentException。 エラー文は Incomplete trailing escape (%) pattern と Illegal hex characters in escape (%) pattern の2系統。
  • %25 が並んでいたら二重エンコード、文字化けは文字コードの不一致を先に確かめる。

参考・出典

  • Java SE 21 API ドキュメント:java.net.URLEncoder(https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/net/URLEncoder.html)
  • Java SE 21 API ドキュメント:java.net.URLDecoder(https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/net/URLDecoder.html)
  • Java SE 21 API ドキュメント:java.net.URI(https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/net/URI.html)
  • MDN Web Docs:encodeURIComponent()(https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent)

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Java URLエンコード・デコード入門|+と%20の違い早見表」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?