Java入門:型・文字列・==とequalsの違いをJDK 26で実行

OpenJDK 26で実行したJava初級講座。intのオーバーフロー、整数除算、doubleとBigDecimal、==とequals、Integerキャッシュ(127と128)、javacの実際のエラー。

シリーズ:Java講座 1/6(初級)。 他の言語でループくらいは書けて、これから Java を書く人向けです。読み終えると、どの比較・数値型・文字列操作が安全か判断でき、初心者が必ず出会う javac のエラーを見てすぐ直せるようになります。

Java を始めたばかりのころのバグは、ロジックの誤りよりも「数値が黙って桁あふれした」「テストでは通った == が本番で外れた」「コンパイルエラーの英語が何を直せと言っているのか分からない」の3つが大半です。この記事ではそれぞれをわざと起こし、OpenJDK 26(Temurin 26+35)、Apple Silicon Mac (M1 Pro) で出た実際の出力をそのまま載せます。

コードはすべて1ファイルで、java ファイル名.java で実行できます。Maven も IDE も要りません。

ビルドなしで動かす:java Hello.java と JShell

Java 11 からは、ソースファイル1つをそのまま実行できます。ランチャーがメモリ上でコンパイルして実行するので、.class ファイルはディスクに残りません。

public class HelloClassic {
    public static void main(String[] args) {
        System.out.println("Hello, " + (args.length > 0 ? args[0] : "world"));
    }
}
$ java HelloClassic.java Duke
Hello, Duke

Java 25 以降はクラス宣言すら省略できる「コンパクトソースファイル」が正式機能になり、IO.println も使えます。

void main() {
    IO.println("Hello, Java " + Runtime.version().feature());
}
$ java Hello.java
Hello, Java 26
$ javac --release 21 Hello.java
Hello.java:1: error: implicitly declared classes are not supported in -source 21
  (use -source 25 or higher to enable implicitly declared classes)

同じフォルダに App.java と Greeter.java を置いて java App.java を実行すると、Greeter も自動で見つけてくれました(複数ファイルのソース起動、Java 22 以降)。このシリーズでは Java 21 でも動くよう、従来の public class の書き方を使います。

1行だけ試したいときは JDK 付属の REPL、JShell が一番早いです。

jshell> int x = Integer.MAX_VALUE + 1
x ==> -2147483648
jshell> "a" + 1 + 2
$5 ==> "a12"
jshell> 1 + 2 + "a"
$6 ==> "3a"

+ は左から評価され、片方が String になった時点で文字列連結に変わります。資格試験(Java Silver など)でもよく出る問題です。

プリミティブと参照型、そして黙って間違える数値

Java の型は、値そのものを持つ8つのプリミティブ型(byte short int long float double char boolean)と、ヒープ上のオブジェクトへの参照を持つ参照型(String、Integer、配列、自作クラスなど)に分かれます。この記事の話はほぼすべてこの違いから来ています。

プリミティブの計算はオーバーフローを検査しません。

int max = Integer.MAX_VALUE;
System.out.println("max + 1 = " + (max + 1));
long bad  = 1_000_000 * 3_000;          // int同士で計算してからlongへ
long good = 1_000_000L * 3_000;
System.out.println("bad  = " + bad + ", good = " + good);
try { Math.addExact(max, 1); }
catch (ArithmeticException e) { System.out.println("addExact: " + e.getMessage()); }
System.out.println("-7 / 2  = " + (-7 / 2) + ", -7 % 2 = " + (-7 % 2));
System.out.println("0.1 + 0.2 = " + (0.1 + 0.2));
double sum = 0;
for (int i = 0; i < 10; i++) sum += 0.1;
System.out.println("0.1 x10 = " + sum + "  == 1.0? " + (sum == 1.0));
System.out.println("new BigDecimal(0.1)   = " + new BigDecimal(0.1));
System.out.println("1.10 equals 1.1? " + new BigDecimal("1.10").equals(new BigDecimal("1.1"))
    + ", compareTo = " + new BigDecimal("1.10").compareTo(new BigDecimal("1.1")));
max + 1 = -2147483648
bad  = -1294967296, good = 3000000000
addExact: integer overflow
-7 / 2  = -3, -7 % 2 = -1
0.1 + 0.2 = 0.30000000000000004
0.1 x10 = 0.9999999999999999  == 1.0? false
new BigDecimal(0.1)   = 0.1000000000000000055511151231257827021181583404541015625
1.10 equals 1.1? false, compareTo = 0
  • long bad = 1_000_000 * 3_000 が一番見落としやすい行です。 右辺は int で計算されてあふれ、その後で long に広げられます。片方を 1_000_000L にします。javac -Xlint:all でも警告は出ませんでした。
  • Math.addExact などは桁あふれで例外を投げます。在庫数・ファイルサイズ・金額のように、あふれたらバグになる値に使います。
  • 整数の割り算は0方向への切り捨てで、-7 / 2 は -3 です。整数の 1 / 0 は ArithmeticException ですが、1.0 / 0 は Infinity でした(javac は warning: [divzero] division by zero を出します)。
  • 税込金額のような計算に double は使いません。BigDecimal は文字列から作り、比較は compareTo です。equals はスケール(小数点以下の桁数)まで比べるので、1.10 と 1.1 は等しくありません。円なら long に整数で持つのも確実な方法です。

キャストも切り捨てます。同じ実行で (int) 2.7 は 2、(int) -2.7 は -2、Math.round(2.5) は 3 なのに Math.round(-2.5) は -2、(int) 5_000_000_000L は 705032704 でした。

== と equals:文字列と Integer キャッシュ

参照型の == は「同じオブジェクトか」、equals は「同じ値を表すか」を比べます。文字列やラッパー型では、前者の答えが自分では制御できない事情で変わります。

String a = "hello";
String b = "hello";
String c = new String("hello");
String d = "hel" + "lo";                 // コンパイル時定数
String part = "hel";
String e = part + "lo";                  // 実行時に生成
System.out.println("a == b      : " + (a == b));
System.out.println("a == c      : " + (a == c));
System.out.println("a == d      : " + (a == d));
System.out.println("a == e      : " + (a == e));
System.out.println("a.equals(e) : " + a.equals(e));

Integer i1 = 127, i2 = 127;
Integer j1 = 128, j2 = 128;
System.out.println("127 == 127 : " + (i1 == i2));
System.out.println("128 == 128 : " + (j1 == j2));
Long big = 128L;
System.out.println("Long(128).equals(128) : " + big.equals(128));
a == b      : true
a == c      : false
a == d      : true
a == e      : false
a.equals(e) : true
127 == 127 : true
128 == 128 : false
Long(128).equals(128) : false

リテラルは定数プールで共有され、"hel" + "lo" もコンパイラが1つのリテラルにまとめるので true になります。しかし画面入力・ファイル・JSON から作った文字列は新しいオブジェクトです。テストではリテラル、本番では入力値。== が本番だけで外れる典型的な理由です。

Integer i1 = 127 は Integer.valueOf(127) になり、-128〜127 はキャッシュされた同じオブジェクトが返ります。しかも上限は変更でき、同じクラスを java -XX:AutoBoxCacheMax=1000 で動かすと 128 == 128 : true になりました。JVM オプション次第で結果が変わるコードは、もはや正しいとは言えません。

最後の行は別の罠です。big.equals(128) の 128 は Integer にボクシングされ、Long.equals は Long 以外には常に false を返します。エラーも警告も出ません。

判断図。出発点は2つの値を比べる。問1:どちらも int や double などのプリミティブか。はいなら a == b。いいえなら問2:BigDecimal か。はいなら a.compareTo(b) == 0(equals はスケールまで比べるため)。いいえなら Objects.equals(a, b)。equals を呼び、どちらかが null でも安全。
Java でどの比較を使うか。== が値を比べるのはプリミティブだけで、オブジェクトでは参照(同じ実体か)を比べる。
比べるもの 使う方法 理由
int・long・double・char・boolean == 値そのものを持つ
String・Integer・自作クラス equals / Objects.equals(a, b) == は同一性。Objects.equals は null も安全
BigDecimal compareTo(b) == 0 equals はスケールまで比べる
計算結果の double Math.abs(a - b) < 1e-9 丸め誤差
enum == 定数は1つずつしか存在しない

String は不変。ループでは StringBuilder

String は一度作ると変わりません。toUpperCase や replace は新しい文字列を返します。戻り値を捨てるのは初心者によくあるミスです。

String s = "java";
s.toUpperCase();
System.out.println(s);        // java
s = s.toUpperCase();
System.out.println(s);        // JAVA

不変だからこそ共有や HashMap のキーに安全に使えますが、ループで s += x を繰り返すと毎回全体をコピーするので、仕事量は長さの2乗で増えます。実測しました(コードは英語版と共通の Concat.java。JIT ウォームアップ後、各サイズ5回の最速値)。

static String plus(int n) {
    String s = "";
    for (int i = 0; i < n; i++) s += i % 10;
    return s;
}
static String builder(int n) {
    StringBuilder sb = new StringBuilder();
    for (int i = 0; i < n; i++) sb.append(i % 10);
    return sb.toString();
}
n= 10,000  +=:      2.4 ms   StringBuilder:   0.14 ms
n= 50,000  +=:     64.6 ms   StringBuilder:   0.15 ms
n=100,000  +=:    248.7 ms   StringBuilder:   0.32 ms
n=200,000  +=:    924.2 ms   StringBuilder:   0.56 ms

javac でコンパイルし java -cp で実行、OpenJDK 26、M1 Pro。3回実行してもぶれは5%程度でした(200,000 の行は 924〜976ms)。n が2倍になると += は約4倍、StringBuilder はほぼ比例です。

ミリ秒の棒グラフ。+= で n=10,000:2.4、n=50,000:64.6、n=100,000:248.7、n=200,000:924.2。StringBuilder で n=200,000:0.56。n を2倍にすると += の時間はほぼ4倍になった。
ループ内の += と StringBuilder で n 桁の文字列を作る時間。OpenJDK 26、Apple Silicon Mac (M1 Pro)、JIT ウォームアップ後 5 回の最速値。+= はほぼ n² で伸び、StringBuilder は 1ms 未満のまま。

1行の "こんにちは、" + name + "さん" まで StringBuilder にする必要はありません。コンパイラが1つの式として最適化します。目安は「何度も回るループの中での連結だけ StringBuilder」、すでにコレクションがあるなら String.join で十分です。

var・テキストブロック・switch 式

新しめの3つの機能は、それぞれ --release を変えてコンパイルし、標準になったバージョンを確認しました。

var list = new ArrayList<String>();      // ArrayList<String> と推論
var ratio = 10 / 4;                      // int。値は 2

String json = """
    {
      "name": "t.laboratory",
      "path": "C:\\temp"
    }
    """;

static String kind(int day) {
    return switch (day) {
        case 6, 7 -> "weekend";
        case 1, 2, 3, 4, 5 -> {
            String s = "week";
            yield s + "day";
        }
        default -> throw new IllegalArgumentException("day " + day);
    };
}
  • var も静的型付けです。var ratio = 10 / 4 は int の 2 です。var a;、var b = null;、var c = {1, 2}; はすべて cannot infer type for local variable エラーになりました。
  • テキストブロックは閉じ """ の位置で先頭の空白の削り方が決まります。Windows パス風の \\ は1文字になります。
  • 矢印形式の switch はフォールスルーしません。従来のコロン形式で day = 1 にすると Monday、Tuesday、other が全部出ました。javac -Xlint:fallthrough で warning: [fallthrough] possible fall-through into case が出ます。
  • switch 式はすべての値を網羅する必要があり、int で default がないと error: the switch expression does not cover all possible input values です。

よくあるミス:初心者が実際に出会う javac エラー

javac 26 の出力をそのまま載せます。javac は型のエラーを先に、到達性(missing return など)のエラーを後で報告します。最初のエラーを直したら別のエラーが出てきた、というのはこのためで、壊したわけではありません。

int count = "42";
long big = 5_000_000_000L;
int small = big;
double avg = 2.5;
int rounded = avg;
String s = null;
if (s = null) { }
Mistakes.java:11: error: incompatible types: String cannot be converted to int
Mistakes.java:13: error: incompatible types: possible lossy conversion from long to int
Mistakes.java:15: error: incompatible types: possible lossy conversion from double to int
Mistakes.java:17: error: incompatible types: String cannot be converted to boolean
エラー 実際に起きていること 直し方
String cannot be converted to int 文字列は数値に自動変換されない Integer.parseInt("42")(不正な入力は NumberFormatException)
possible lossy conversion from long to int 上位ビットが消える可能性 long のまま使うか Math.toIntExact。(int) キャストだと黙って 705032704
possible lossy conversion from double to int 小数部が消える (int) は切り捨て、Math.round は四捨五入(負数に注意)
String cannot be converted to boolean = は代入 if (s == null)

到達性のエラーはこうなりました。

Flow.java:5: error: missing return statement
Flow.java:8: error: unreachable statement
Flow.java:17: error: variable total might not have been initialized

1つめは if (x > 0) return 1; else if (x < 0) return -1; で終わるメソッドで、x == 0 のとき値を返さずに抜けるため、閉じ括弧 } の位置が指されます。2つめは return 0; の後の文、3つめは if の中でしか代入しないローカル変数です。

NullPointerException の詳細メッセージを読む

Java 14 で追加され 15 から既定で有効になった詳細メッセージは、どの式が null だったかを教えてくれます。次は . が1つもない行で起きる、在庫マップのアンボクシングの NPE です。

Map<String, Integer> stock = new HashMap<>();
stock.put("apple", 3);
int n = stock.get("banana");
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because the return value of "java.util.Map.get(Object)" is null
    at Npe.main(Npe.java:7)

意外だったのはローカル変数です。java Npe2.java でも普通の javac でも because "<local1>" is null と出て、javac -g でコンパイルしたときだけ because "name" is null になりました。Maven や Gradle は通常 -g 付きでビルドするので名前が出ますが、手でコンパイルしたときは出ません。

正直な限界とバージョンについて

  • すべて OpenJDK 26(Temurin 26+35)、Apple Silicon Mac (M1 Pro) で実行しました。時間は1台での best-of-N で、JMH ではありません。比率として読んでください。
  • javac --release N で確認したもの:var は 9 で失敗し 10 で成功、switch 式は 13 で失敗し 14 で成功、テキストブロックは 14 で失敗し 15 で成功、コンパクトソースファイルは 24 で失敗し 25 で成功。--release 8 は JDK 26 でもまだ使えますが「obsolete」の警告が出ました。
  • Java 11 のソース起動や Java 14 の NPE メッセージを当時の JDK では動かしていません。確認したのは 26 だけです。-XX:AutoBoxCacheMax は HotSpot のオプションです。

次回: Java講座 2/6:クラス・record・インターフェース。同じレベルの他言語:Python 講座 1、VB.NET 講座 1。C# 経験者は C# の変数とデータ型 と比べてみてください。

よくある質問

Integer の 127 == 127 は true なのに 128 == 128 が false になるのはなぜですか?

オートボクシングは Integer.valueOf を通り、-128〜127 はキャッシュ済みの同じオブジェクトが返ります。128 は毎回別のオブジェクトなので、参照を比べる == は false です。上限は -XX:AutoBoxCacheMax で変えられ、実際に 128 == 128 が true になりました。ラッパー型の比較は equals を使います。

金額の計算は double と BigDecimal のどちらを使えばいいですか?

double で 0.1 を10回足すと 0.9999999999999999 になりました。BigDecimal は必ず文字列から作り(new BigDecimal("0.1"))、比較は compareTo を使います。1.10 と 1.1 は equals では false です。円のように小数がない通貨なら long に整数で持つのも確実です。

NullPointerException のメッセージに変数名ではなく <local1> と出るのはなぜですか?

ローカル変数名はデバッグ情報(javac -g)付きでコンパイルしたときだけ表示されます。-g なしでは because "<local1>" is null、-g ありでは because "name" is null と出ました。フィールドやメソッドの戻り値は -g なしでも名前が出ます。

  • #Java
  • #初級
  • #OpenJDK 26
  • #String
  • #equals
  • #BigDecimal
  • #javac

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑