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 を返します。エラーも警告も出ません。
| 比べるもの | 使う方法 | 理由 |
|---|---|---|
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 はほぼ比例です。
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 なしでも名前が出ます。