Kotlin と Java の違いとは、同じ JVM の上で動く 2 つの言語の「書き方」と「コンパイラが確かめてくれる範囲」の違いです。 Java 25 では record・sealed・switch のパターンマッチング・仮想スレッドがそろい、データ保持や分岐の書き方は Kotlin とかなり近づきました。一方で、null を型で区別する仕組み、引数の既定値、拡張関数、プロパティなどは Kotlin 側の特徴として残っています。

この記事では、同じ処理を Java 25 と Kotlin 2.4.20 の両方で書いて実際に動かし、30 項目の対応表にまとめました。そのうえで「今の Java で埋まった差」と「まだ残る差」を分け、Java と Kotlin を混ぜて使うときの @JvmStatic・@JvmOverloads などの注意も、コンパイルエラー文つきで整理します。

確認環境:Ubuntu 24.04、OpenJDK 25.0.4.1(Java 側のコード)、Kotlin 2.4.20(kotlinc-jvm、OpenJDK 21.0.10 上で実行)。一部は OpenJDK 21.0.10 でも動かして、バージョンによる違いを確かめています。

Kotlin と Java の書き方対応表(30項目)

番号は、後半の完成コードの出力 [1-3]〜[29] と対応しています。

変数・null・文字列

No やりたいこと Java 25 Kotlin 2.4
1 再代入しない変数 final String name = "田中"; val name = "田中"
2 再代入する変数(型は推論) var count = 1; var count = 1
3 定数 static final int MAX_RETRY = 3; const val MAX_RETRY = 3
4 null を入れられる変数 String nick = null; val nick: String? = null
5 null なら null のまま呼ぶ nick != null ? nick.length() : null nick?.length
6 null なら既定値 nick != null ? nick : "未設定" nick ?: "未設定"
7 文字列に値を埋め込む "%s の合計は %d 円".formatted(name, price * 2) "$name の合計は ${price * 2} 円"
8 複数行の文字列 """ で囲むテキストブロック """ で囲み .trimIndent()
9 文字列の中身を比べる a.equals(b)(== は参照の比較) a == b(=== が参照の比較)

条件分岐・ループ・関数

No やりたいこと Java 25 Kotlin 2.4
10 条件で値を選ぶ count > 2 ? "多い" : "少ない" if (count > 2) "多い" else "少ない"
11 値で多分岐 switch (count) { case 1 -> "月"; case 2, 3 -> "火か水"; default -> "その他"; } when (count) { 1 -> "月"; 2, 3 -> "火か水"; else -> "その他" }
12 型と条件で分岐 case Integer i when i > 100 -> ... is Int if o > 100 -> ...
13 範囲のループ for (int i = 1; i <= 3; i++) for (i in 1..3)、for (i in 10 downTo 0 step 5)
14 添字つきで回す for (int i = 0; i < fruits.size(); i++) fruits.withIndex()
15 引数の既定値 引数違いのメソッドを複数書く(オーバーロード) fun greet(name: String, word: String = "こんにちは")
16 既存の型に関数を足す static String toYen(int n) を toYen(1234567) で呼ぶ 拡張関数 fun Int.toYen() を 1234567.toYen() で呼ぶ

クラス・データ保持・sealed・static 相当

No やりたいこと Java 25 Kotlin 2.4
17 値を持つクラス フィールド+コンストラクタ+getCount()/setCount() class Counter(var count: Int)
18 データ保持用の型 record Item(String name, int price) {} data class Item(val name: String, val price: Int)
19 一部だけ変えた複製 new Item(pen.name(), 150) pen.copy(price = 150)
20 種類を限定した型と網羅チェック sealed interface Shape permits Circle, Rect {} と switch sealed interface Shape と when
21 シングルトン enum IdGenerator { INSTANCE; ... } object IdGenerator { ... }
22 static 相当 static メソッド・static final フィールド トップレベル関数・const val・companion object

ラムダ・コレクション・例外・並行処理

No やりたいこと Java 25 Kotlin 2.4
23 ラムダを変数に入れて呼ぶ Function<Integer, Integer> twice = x -> x * 2; を twice.apply(21) val twice: (Int) -> Int = { it * 2 } を twice(21)
24 リストを作る List.of(5, 3, 8, 1)、new ArrayList<>(nums) listOf(5, 3, 8, 1)、nums.toMutableList()
25 絞り込み・変換・並べ替え nums.stream().filter(n -> n > 2).map(n -> n * 10).sorted().toList() nums.filter { it > 2 }.map { it * 10 }.sorted()
26 グループ分け words.stream().collect(Collectors.groupingBy(w -> w.charAt(0))) words.groupBy { it.first() }
27 Map の走査と既定値 for (var e : stock.entrySet())、stock.getOrDefault("消しゴム", -1) stock.entries を (k, v) で分解、stock["消しゴム"] ?: -1
28 例外を捕まえて値にする try { parsed = ...; } catch (...) { parsed = -1; } val parsed = try { ... } catch (...) { -1 }
29 使い終わったら閉じる try (var log = new Log()) { ... } Log().use { log -> ... }
30 たくさんの待ち処理を並行に 仮想スレッド(Executors.newVirtualThreadPerTaskExecutor()) コルーチン(async/await)。仮想スレッドも使える

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

(記載例)出典:株式会社RJC「Kotlin と Java の違い 書き方対応表とコード比較30項目」

同じ処理を両方で動かす:完成コードと出力

対応表の No.1〜29 を、1 ファイルずつにまとめました。どちらもそのまま保存して実行できます。

Java 25 のコード

Java 25 では、クラス宣言を省いた「コンパクトなソースファイル」と void main()、IO.println が使えます。java.base モジュールのクラス(List・ArrayList・Function・Collectors など)は import を書かなくても使えます。

// Compare.java(Java 25)  実行: java Compare.java
sealed interface Shape permits Circle, Rect {}
record Circle(double r) implements Shape {}
record Rect(double w, double h) implements Shape {}
record Item(String name, int price) {}

class Counter {
    private int count;
    Counter(int count) { this.count = count; }
    int getCount() { return count; }
    void setCount(int count) { this.count = count; }
}

enum IdGenerator {
    INSTANCE;
    private int next = 1;
    int nextId() { return next++; }
}

class Log implements AutoCloseable {
    Log() { IO.println("  open"); }
    void write(String s) { IO.println("  write " + s); }
    @Override public void close() { IO.println("  close"); }
}

static final int MAX_RETRY = 3;

static String toYen(int n) { return String.format("%,d円", n); }

String greet(String name) { return greet(name, "こんにちは"); }
String greet(String name, String word) { return word + "、" + name + "さん"; }

String label(Object o) {
    return switch (o) {
        case null -> "null";
        case Integer i when i > 100 -> "大きい数 " + i;
        case Integer i -> "数 " + i;
        case String s -> "文字列 長さ" + s.length();
        default -> "その他";
    };
}

double area(Shape s) {
    return switch (s) {
        case Circle c -> Math.PI * c.r() * c.r();
        case Rect(double w, double h) -> w * h;
    };
}

void main() {
    // 1-3 変数
    final String name = "田中";
    var count = 1;
    count += 2;
    IO.println("[1-3] " + name + " " + count + " " + MAX_RETRY);

    // 4-6 null
    String nick = null;
    Integer len = nick != null ? nick.length() : null;
    String shown = nick != null ? nick : "未設定";
    IO.println("[4-6] " + len + " " + shown + " " + Objects.requireNonNullElse(nick, "未設定"));

    // 7-9 文字列
    int price = 1200;
    IO.println("[7] " + "%s の合計は %d 円".formatted(name, price * 2));
    String text = """
        1行目
          2行目
        """;
    IO.println("[8] " + text.strip().replace("\n", "|"));
    String a = "kotlin";
    String b = new String("kotlin");
    IO.println("[9] 中身=" + a.equals(b) + " 参照=" + (a == b));

    // 10-12 条件分岐
    String size = count > 2 ? "多い" : "少ない";
    String day = switch (count) {
        case 1 -> "月";
        case 2, 3 -> "火か水";
        default -> "その他";
    };
    IO.println("[10-11] " + size + " " + day);
    IO.println("[12] " + label(150) + " / " + label(7) + " / " + label("abc") + " / " + label(null));

    // 13-14 ループ
    var sb = new StringBuilder();
    for (int i = 1; i <= 3; i++) sb.append(i).append(' ');
    for (int i = 10; i >= 0; i -= 5) sb.append(i).append(' ');
    IO.println("[13] " + sb.toString().strip());
    var fruits = List.of("りんご", "みかん");
    sb.setLength(0);
    for (int i = 0; i < fruits.size(); i++) sb.append(i).append('=').append(fruits.get(i)).append(' ');
    IO.println("[14] " + sb.toString().strip());

    // 15-16 関数
    IO.println("[15] " + greet("田中") + " / " + greet("田中", "おはよう"));
    IO.println("[16] " + toYen(1234567));

    // 17-22 クラス
    var counter = new Counter(1);
    counter.setCount(counter.getCount() + 1);
    IO.println("[17] " + counter.getCount());
    var pen = new Item("ペン", 120);
    IO.println("[18] " + pen + " " + pen.equals(new Item("ペン", 120)));
    var pen2 = new Item(pen.name(), 150);
    IO.println("[19] " + pen2 + " " + pen.name());
    IO.println("[20] " + String.format("%.2f %.1f", area(new Circle(1.0)), area(new Rect(2.0, 3.0))));
    IO.println("[21] " + IdGenerator.INSTANCE.nextId() + " " + IdGenerator.INSTANCE.nextId());
    IO.println("[22] " + toYen(MAX_RETRY));

    // 23 ラムダ
    Function<Integer, Integer> twice = x -> x * 2;
    BiFunction<Integer, Integer, Integer> add = (x, y) -> x + y;
    IO.println("[23] " + twice.apply(21) + " " + add.apply(1, 2));

    // 24-27 コレクション
    var nums = List.of(5, 3, 8, 1);
    var mutable = new ArrayList<>(nums);
    mutable.add(9);
    IO.println("[24] " + nums + " " + mutable);
    IO.println("[25] " + nums.stream().filter(n -> n > 2).map(n -> n * 10).sorted().toList());
    var words = List.of("apple", "avocado", "banana");
    IO.println("[26] " + words.stream().collect(Collectors.groupingBy(w -> w.charAt(0))));
    var stock = new LinkedHashMap<String, Integer>();
    stock.put("ペン", 3);
    stock.put("ノート", 0);
    sb.setLength(0);
    for (var e : stock.entrySet()) sb.append(e.getKey()).append(':').append(e.getValue()).append(' ');
    IO.println("[27] " + sb.toString().strip() + " " + stock.getOrDefault("消しゴム", -1));

    // 28-29 例外
    int parsed;
    try {
        parsed = Integer.parseInt("12a");
    } catch (NumberFormatException e) {
        IO.println("[28] " + e.getMessage());
        parsed = -1;
    }
    IO.println("[28] " + parsed);
    IO.println("[29]");
    try (var log = new Log()) {
        log.write("hello");
    }
}

Kotlin 2.4 のコード

// Compare.kt(Kotlin 2.4)  実行: kotlinc Compare.kt -include-runtime -d compare.jar && java -jar compare.jar
sealed interface Shape
data class Circle(val r: Double) : Shape
data class Rect(val w: Double, val h: Double) : Shape
data class Item(val name: String, val price: Int)

class Counter(var count: Int)

object IdGenerator {
    private var next = 1
    fun nextId() = next++
}

class Log : AutoCloseable {
    init { println("  open") }
    fun write(s: String) = println("  write $s")
    override fun close() = println("  close")
}

const val MAX_RETRY = 3

fun Int.toYen() = String.format("%,d円", this)

fun greet(name: String, word: String = "こんにちは") = "$word、${name}さん"

fun label(o: Any?) = when (o) {
    null -> "null"
    is Int if o > 100 -> "大きい数 $o"
    is Int -> "数 $o"
    is String -> "文字列 長さ${o.length}"
    else -> "その他"
}

fun area(s: Shape) = when (s) {
    is Circle -> Math.PI * s.r * s.r
    is Rect -> s.w * s.h
}

fun main() {
    // 1-3 変数
    val name = "田中"
    var count = 1
    count += 2
    println("[1-3] $name $count $MAX_RETRY")

    // 4-6 null
    val nick: String? = null
    val len = nick?.length
    val shown = nick ?: "未設定"
    println("[4-6] $len $shown ${nick ?: "未設定"}")

    // 7-9 文字列
    val price = 1200
    println("[7] $name の合計は ${price * 2} 円")
    val text = """
        1行目
          2行目
    """.trimIndent()
    println("[8] " + text.replace("\n", "|"))
    val a = "kotlin"
    val b = String(charArrayOf('k', 'o', 't', 'l', 'i', 'n'))
    println("[9] 中身=${a == b} 参照=${a === b}")

    // 10-12 条件分岐
    val size = if (count > 2) "多い" else "少ない"
    val day = when (count) {
        1 -> "月"
        2, 3 -> "火か水"
        else -> "その他"
    }
    println("[10-11] $size $day")
    println("[12] ${label(150)} / ${label(7)} / ${label("abc")} / ${label(null)}")

    // 13-14 ループ
    val sb = StringBuilder()
    for (i in 1..3) sb.append(i).append(' ')
    for (i in 10 downTo 0 step 5) sb.append(i).append(' ')
    println("[13] " + sb.trim())
    val fruits = listOf("りんご", "みかん")
    println("[14] " + fruits.withIndex().joinToString(" ") { (i, f) -> "$i=$f" })

    // 15-16 関数
    println("[15] ${greet("田中")} / ${greet("田中", word = "おはよう")}")
    println("[16] ${1234567.toYen()}")

    // 17-22 クラス
    val counter = Counter(1)
    counter.count += 1
    println("[17] ${counter.count}")
    val pen = Item("ペン", 120)
    println("[18] $pen ${pen == Item("ペン", 120)}")
    val pen2 = pen.copy(price = 150)
    val (itemName, _) = pen
    println("[19] $pen2 $itemName")
    println("[20] " + String.format("%.2f %.1f", area(Circle(1.0)), area(Rect(2.0, 3.0))))
    println("[21] ${IdGenerator.nextId()} ${IdGenerator.nextId()}")
    println("[22] ${MAX_RETRY.toYen()}")

    // 23 ラムダ
    val twice: (Int) -> Int = { it * 2 }
    val add = { x: Int, y: Int -> x + y }
    println("[23] ${twice(21)} ${add(1, 2)}")

    // 24-27 コレクション
    val nums = listOf(5, 3, 8, 1)
    val mutable = nums.toMutableList()
    mutable.add(9)
    println("[24] $nums $mutable")
    println("[25] " + nums.filter { it > 2 }.map { it * 10 }.sorted())
    val words = listOf("apple", "avocado", "banana")
    println("[26] " + words.groupBy { it.first() })
    val stock = mutableMapOf("ペン" to 3, "ノート" to 0)
    val line = stock.entries.joinToString(" ") { (k, v) -> "$k:$v" }
    println("[27] $line ${stock["消しゴム"] ?: -1}")

    // 28-29 例外
    val parsed = try {
        "12a".toInt()
    } catch (e: NumberFormatException) {
        println("[28] " + e.message)
        -1
    }
    println("[28] $parsed")
    println("[29]")
    Log().use { log ->
        log.write("hello")
    }
}

実行結果を並べる

Java 25(java Compare.java)の出力:

[1-3] 田中 3 3
[4-6] null 未設定 未設定
[7] 田中 の合計は 2400 円
[8] 1行目|  2行目
[9] 中身=true 参照=false
[10-11] 多い 火か水
[12] 大きい数 150 / 数 7 / 文字列 長さ3 / null
[13] 1 2 3 10 5 0
[14] 0=りんご 1=みかん
[15] こんにちは、田中さん / おはよう、田中さん
[16] 1,234,567円
[17] 2
[18] Item[name=ペン, price=120] true
[19] Item[name=ペン, price=150] ペン
[20] 3.14 6.0
[21] 1 2
[22] 3円
[23] 42 3
[24] [5, 3, 8, 1] [5, 3, 8, 1, 9]
[25] [30, 50, 80]
[26] {a=[apple, avocado], b=[banana]}
[27] ペン:3 ノート:0 -1
[28] For input string: "12a"
[28] -1
[29]
  open
  write hello
  close

Kotlin 2.4.20(java -Dstdout.encoding=UTF-8 -jar compare.jar、-Dstdout.encoding=UTF-8 は日本語の出力を UTF-8 にそろえる指定)の出力:

[1-3] 田中 3 3
[4-6] null 未設定 未設定
[7] 田中 の合計は 2400 円
[8] 1行目|  2行目
[9] 中身=true 参照=false
[10-11] 多い 火か水
[12] 大きい数 150 / 数 7 / 文字列 長さ3 / null
[13] 1 2 3 10 5 0
[14] 0=りんご 1=みかん
[15] こんにちは、田中さん / おはよう、田中さん
[16] 1,234,567円
[17] 2
[18] Item(name=ペン, price=120) true
[19] Item(name=ペン, price=150) ペン
[20] 3.14 6.0
[21] 1 2
[22] 3円
[23] 42 3
[24] [5, 3, 8, 1] [5, 3, 8, 1, 9]
[25] [30, 50, 80]
[26] {a=[apple, avocado], b=[banana]}
[27] ペン:3 ノート:0 -1
[28] For input string: "12a"
[28] -1
[29]
  open
  write hello
  close

2 つの出力を diff で比べると、違うのは次の 2 行だけでした。

13,14c13,14
< [18] Item[name=ペン, price=120] true
< [19] Item[name=ペン, price=150] ペン
---
> [18] Item(name=ペン, price=120) true
> [19] Item(name=ペン, price=150) ペン

record の toString() は角かっこ、data class は丸かっこで表示されます。それ以外は 1 文字も違いません。同じ JVM の同じ標準ライブラリ(String.format、Integer.parseInt、ArrayList など)を使っているためで、[28] の例外メッセージ For input string: "12a" も同じです。

行数は、空行とコメントを含めて Java が 144 行、Kotlin が 128 行でした。差が出ているのは、Counter クラス(Java 6 行・Kotlin 1 行)、greet の既定値(Java 2 行・Kotlin 1 行)、ループで文字列を組み立てる部分です。record・sealed・switch の部分は、ほぼ同じ行数になっています。

項目ごとの違いを読み解く

null:実行時に気づくか、コンパイル時に気づくか

Java では、どの参照型の変数にも null を入れられます。null のまま呼び出すと、実行時に例外になります。

void main() {
    String nick = null;
    IO.println(nick.length());
}
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.length()" because "<local1>" is null
    at Npe.main(Npe.java:3)

Kotlin では、null を入れられる型(String?)と入れられない型(String)が区別され、次のコードはコンパイルの段階で止まります。

import java.io.File

fun main() {
    val name: String = null
    val nick: String? = null
    println(nick.length)
    val text = File("memo.txt").readText()   // IOException の宣言・catch は不要
    val x = if (text.isEmpty()) 1 else 2
    val y = text.isEmpty() ? 1 : 2
}
NullErr.kt:4:24: error: null cannot be a value of a non-null type 'String'.
    val name: String = null
                       ^^^^
NullErr.kt:6:17: error: only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type 'String?'.
    println(nick.length)
                ^
NullErr.kt:9:28: error: syntax error: Unexpected tokens (use ';' to separate expressions on the same line).
    val y = text.isEmpty() ? 1 : 2
                           ^^^^^^^

3 つ目のエラーは別の違いを示しています。Kotlin には三項演算子 ? : がなく、if が値を返す式なので if (条件) 1 else 2 と書きます。

検査例外:Java は宣言が必要、Kotlin は不要

上の Kotlin コードの File("memo.txt").readText() は、IOException の catch も宣言も書いていませんが、その行はエラーになっていません。Java で同じことを書くと、コンパイルエラーになります。

import java.nio.file.Files;
import java.nio.file.Path;

public class Checked {
    public static void main(String[] args) {
        String text = Files.readString(Path.of("memo.txt"));
        String nick = null;
        System.out.println(nick.length());
    }
}
Checked.java:6: error: unreported exception IOException; must be caught or declared to be thrown
        String text = Files.readString(Path.of("memo.txt"));
                                      ^
1 error

Java は「呼び出し側が例外を扱うこと」をコンパイラが確かめます。Kotlin はすべての例外を非検査として扱い、どこで捕まえるかを書き手に任せます。

データ保持:record と data class

観点 Java の record Kotlin の data class
宣言 record Item(String name, int price) {} data class Item(val name: String, val price: Int)
値の取り出し pen.name() pen.name
equals・hashCode・toString 自動生成 自動生成
toString の形 Item[name=ペン, price=120] Item(name=ペン, price=120)
一部だけ変えた複製 new Item(pen.name(), 150) と全成分を書く pen.copy(price = 150)
分解 パターンで分解:case Rect(double w, double h) 分解宣言:val (itemName, _) = pen
書き換え可能なプロパティ 持てない(成分はすべて final) var で宣言すれば持てる

sealed と網羅チェック

どちらも「この型の種類はここに書いたものだけ」とコンパイラに伝えられ、switch/when に default/else を書かなくても全種類を扱ったことを確かめてくれます。Java は permits で種類を並べ、record パターン case Rect(double w, double h) で中身を取り出せます。Kotlin は is Rect -> と書いた先で s.w のように型が自動で切り替わります(スマートキャスト)。

static 相当:companion object とトップレベル

Kotlin に static キーワードはありません。代わりに次の 3 つを使い分けます。

Java での書き方 Kotlin での置き場所
static なユーティリティメソッド ファイル直下のトップレベル関数、または拡張関数
static final の定数 const val(トップレベル、object、companion object の中)
クラスに属する static メソッド(ファクトリなど) クラス内の companion object
1 つだけ存在するインスタンス object 宣言

並行処理:仮想スレッドとコルーチン

Java は仮想スレッドで、これまでと同じ書き方(Thread.sleep や Future)のまま大量の待ち処理を並行に走らせられます。

// Virtual.java(Java 25)  実行: java Virtual.java
void main() throws Exception {
    long start = System.nanoTime();
    try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
        var futures = new ArrayList<Future<Integer>>();
        for (int i = 1; i <= 3; i++) {
            final int n = i;
            futures.add(executor.submit(() -> {
                Thread.sleep(200);          // 待つ間、OS スレッドを手放す
                return n * 10;
            }));
        }
        var results = new ArrayList<Integer>();
        for (var f : futures) results.add(f.get());
        IO.println(results);
    }
    long ms = (System.nanoTime() - start) / 1_000_000;
    IO.println("約200msで終わったか=" + (ms < 500));
    Thread t = Thread.ofVirtual().start(() -> IO.println("virtual=" + Thread.currentThread().isVirtual()));
    t.join();
}
[10, 20, 30]
約200msで終わったか=true
virtual=true

Kotlin はコルーチンで、suspend を付けた関数と async/await で書きます。

// Coro.kt(Kotlin 2.4 + kotlinx-coroutines)
import kotlinx.coroutines.*
import kotlin.time.measureTime

suspend fun load(n: Int): Int {
    delay(200)            // 待つ間、スレッドを手放す
    return n * 10
}

fun main() = runBlocking {
    val elapsed = measureTime {
        val results = (1..3).map { n -> async { load(n) } }.awaitAll()
        println(results)
    }
    println("約200msで終わったか=${elapsed.inWholeMilliseconds < 500}")

    // Kotlin からも仮想スレッドをそのまま使える
    val t = Thread.ofVirtual().start { println("virtual=${Thread.currentThread().isVirtual}") }
    t.join()
}
[10, 20, 30]
約200msで終わったか=true
virtual=true

200 ミリ秒待つ処理を 3 つ並べても、どちらも約 200 ミリ秒で終わります。Kotlin のコードの最後のとおり、Kotlin からも Thread.ofVirtual() で仮想スレッドをそのまま使えます(OpenJDK 21 上で実行)。

観点 Java の仮想スレッド Kotlin のコルーチン
提供のされ方 JDK の標準機能 ライブラリ kotlinx.coroutines を依存に追加
待つ処理の書き方 Thread.sleep など通常のブロッキング API delay など suspend 関数
関数の印 なし(通常のメソッド) suspend を付ける
結果の受け取り Future.get() await()/awaitAll()

コルーチンは標準ライブラリとは別の配布物です。Gradle(Kotlin DSL)では、kotlinx.coroutines の README に次の依存の書き方が載っています(今回開いた時点の README では 1.11.0、Kotlin 2.2.20 向けと記載)。

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0")
}

なお、OpenJDK の JDK 25 のページでは、タスクの親子関係をまとめて扱う Structured Concurrency(JEP 505)は「Fifth Preview」とされています。

今の Java で埋まった差

「Kotlin なら短く書ける」と言われてきた点のうち、Java の言語機能として正式に入ったものを、Oracle の「Java Language Changes」ページで確認した正式化のバージョンとともにまとめます。

機能 正式化された Java Kotlin で対応するもの
ローカル変数の型推論 var Java 10 val/var
switch 式(値を返す switch、->) Java 14 when 式
テキストブロック """ Java 15 生文字列 """
record Java 16 data class
instanceof のパターンマッチング Java 16 is とスマートキャスト
sealed クラス・インターフェース Java 17 sealed
record パターン(分解) Java 21 分解宣言
switch のパターンマッチング(case 型 変数 when 条件) Java 21 when の is とガード条件
使わない変数・成分の _ Java 22 _
モジュール単位の import(import module java.base;) Java 25 既定の import
コンパクトなソースファイルとインスタンス main(void main()) Java 25 トップレベルの fun main()
super(...) の前に文を書けるコンストラクタ Java 25 init ブロックや引数の式

仮想スレッド(Thread.ofVirtual())と、getFirst()・reversed() などの順序つきコレクションのメソッドは、OpenJDK 21.0.10 で動くことを確かめました。

Java 22 以降の機能を 1 ファイルで確かめたコードです。

// New25.java(Java 25)  実行: java New25.java
import module java.base;                       // モジュール単位の import(Java 25)

public class New25 {
    record Point(int x, int y) {}

    static class Base {
        Base(int size) { IO.println("Base size=" + size); }
    }
    static class Sized extends Base {
        Sized(int size) {
            if (size < 0) throw new IllegalArgumentException("size は 0 以上");
            super(size);                       // super の前に検証を書ける(Java 25)
        }
    }

    public static void main(String[] args) {
        Object o = new Point(3, 4);
        if (o instanceof Point(int x, _)) {    // 使わない成分は _(Java 22)
            IO.println("x=" + x);
        }
        try {
            Integer.parseInt("x");
        } catch (NumberFormatException _) {    // 使わない例外変数も _
            IO.println("数値ではない");
        }
        var list = new ArrayList<>(List.of(1, 2, 3));
        IO.println(list.getFirst() + " " + list.getLast() + " " + list.reversed());
        new Sized(3);
    }
}

OpenJDK 25.0.4.1 での実行結果:

x=3
数値ではない
1 3 [3, 2, 1]
Base size=3

同じファイルを OpenJDK 21.0.10 で実行すると、次のように止まります。

New25.java:2: error: '.' expected
import module java.base;                       // モジュール単位の import(Java 25)
             ^
New25.java:19: error: unnamed variables are a preview feature and are disabled by default.
        if (o instanceof Point(int x, _)) {    // 使わない成分は _(Java 22)
                                       ^
  (use --enable-preview to enable unnamed variables)
2 errors
error: compilation failed

先ほどの Compare.java も、OpenJDK 21.0.10 では unnamed classes are a preview feature and are disabled by default. と表示されて止まります。Java 側の書き方は JDK のバージョンで大きく変わるので、比較するときは「どの Java と比べているか」をそろえることが大切です。

まだ残る差

Java 25 と Kotlin 2.4.20 で動かして確かめた範囲で、言語の仕組みとして違いが残っている点です。

観点 Java 25 Kotlin 2.4 この記事での確認
null の扱い 参照型はすべて null を入れられる。実行時に NullPointerException String と String? を型で区別。コンパイル時にエラー 両方のエラー文を掲載
引数の既定値・名前付き引数 なし(オーバーロードで書く) word: String = "..."、greet("田中", word = "おはよう") No.15
拡張関数 なし(static メソッドに引数で渡す) fun Int.toYen() No.16
プロパティ フィールド+getter/setter を書く(record は読み取りのみ) var count: Int で読み書き No.17
一部だけ変えた複製 全成分を書いて new copy(price = 150) No.19
文字列への埋め込み formatted・String.format・+ "$name"・"${式}" No.7
if・try が値を返す if と try は文(値は三項演算子や switch 式で) if・when・try が式 No.10・28
三項演算子 ? : ある ない(if 式で書く) エラー文を掲載
検査例外 ある(catch か throws が必要) ない エラー文を掲載
== の意味 参照の比較(中身は equals) 中身の比較(参照は ===) No.9
並行処理の書き方 仮想スレッド(JDK 標準) コルーチン(ライブラリ)+仮想スレッドも可 No.30

文字列への埋め込みについて、Oracle の「Java Language Changes」ページでは、String Templates は Java 21・22 でプレビューとして入り、Java 23 で取り下げ(Withdrawn)と記載されています。

Java から Kotlin へ移行するときのメリットと進め方

Kotlin のコードは JVM のクラスファイルにコンパイルされ、Java のクラスをそのまま呼べます。逆に Java から Kotlin のクラスも呼べます。そのため、全部を一度に書き換えずに、ファイル単位で少しずつ移していけることが移行の大きなメリットです。

移行で得られること 根拠(この記事で確かめた内容)
null の取り違えをコンパイル時に見つけられる null cannot be a value of a non-null type 'String'.
getter/setter・オーバーロードの記述が減る Counter が 6 行から 1 行、greet が 2 行から 1 行
Java の標準ライブラリと既存のクラスをそのまま使える 出力が record の表示以外すべて一致
コレクション操作を Stream なしで書ける No.25・26
Java と混ぜて段階的に移せる 次の章の相互運用のコード

Java のままが向いている場面もあります。チームの全員が Java に慣れている、Kotlin のコンパイラやライブラリを追加せずに JDK だけで完結させたい、といった場合です。Java 25 で record・sealed・パターンマッチングを使えば、データの定義と分岐は Kotlin に近い形で書けます。

Java と Kotlin を混ぜるときの相互運用の注意

Java から Kotlin を呼ぶ:@JvmStatic・@JvmOverloads・@JvmField・@Throws

Kotlin 側のライブラリです。

// Lib.kt(Kotlin 側のライブラリ)
@file:JvmName("TextUtil")
package lib

import java.io.IOException

fun shout(s: String) = s.uppercase() + "!"

class Mailer {
    companion object {
        const val VERSION = "1.0"
        @JvmField val defaultSubject = "(件名なし)"
        @JvmStatic fun create() = Mailer()
        fun plain() = Mailer()
    }

    @JvmOverloads
    fun send(to: String, subject: String = defaultSubject, retry: Int = 1) =
        "to=$to subject=$subject retry=$retry"

    fun sendPlain(to: String, subject: String = defaultSubject) = "to=$to subject=$subject"

    @Throws(IOException::class)
    fun load(path: String): String = throw IOException("not found: $path")

    fun loadPlain(path: String): String = throw IOException("not found: $path")
}

class User(val name: String, var age: Int, @JvmField val id: Int)

Java 側の呼び出しです。

// Caller.java(Java から Kotlin を呼ぶ)
import lib.Mailer;
import lib.TextUtil;
import lib.User;
import java.io.IOException;

public class Caller {
    public static void main(String[] args) {
        System.out.println(TextUtil.shout("hello"));             // トップレベル関数
        System.out.println(Mailer.VERSION);                      // const val
        System.out.println(Mailer.defaultSubject);               // @JvmField
        Mailer m1 = Mailer.create();                             // @JvmStatic
        Mailer m2 = Mailer.Companion.plain();                    // 注釈なしは Companion 経由
        System.out.println(m1.send("a@example.com"));            // @JvmOverloads
        System.out.println(m1.send("a@example.com", "連絡"));
        System.out.println(m2.sendPlain("a@example.com", "連絡")); // 注釈なしは全引数が必要
        try {
            m1.load("memo.txt");                                 // @Throws
        } catch (IOException e) {
            System.out.println("catch: " + e.getMessage());
        }
        User u = new User("田中", 30, 7);
        u.setAge(u.getAge() + 1);                                // var は getter / setter
        System.out.println(u.getName() + " " + u.getAge() + " " + u.id);
        try {
            TextUtil.shout(null);                                // null 不可の引数に null
        } catch (NullPointerException e) {
            System.out.println(e.getMessage());
        }
    }
}

実行結果(javac -cp lib.jar でコンパイルし、実行時のクラスパスに kotlin-stdlib.jar を加えています):

HELLO!
1.0
(件名なし)
to=a@example.com subject=(件名なし) retry=1
to=a@example.com subject=連絡 retry=1
to=a@example.com subject=連絡
catch: not found: memo.txt
田中 31 7
Parameter specified as non-null is null: method lib.TextUtil.shout, parameter s

注釈を付けなかったメンバーを、付けたときと同じ書き方で Java から呼ぶと、次のコンパイルエラーになります。

import lib.Mailer;
import java.io.IOException;

public class Bad {
    public static void main(String[] args) {
        Mailer m = Mailer.plain();
        m.sendPlain("a@example.com");
        try {
            m.loadPlain("memo.txt");
        } catch (IOException e) {
            System.out.println(e.getMessage());
        }
    }
}
Bad.java:6: error: cannot find symbol
        Mailer m = Mailer.plain();
                         ^
  symbol:   method plain()
  location: class Mailer
Bad.java:7: error: method sendPlain in class Mailer cannot be applied to given types;
        m.sendPlain("a@example.com");
         ^
  required: String,String
  found:    String
  reason: actual and formal argument lists differ in length
2 errors

上の 2 か所を直しても、@Throws のない loadPlain を catch (IOException e) で囲んだ部分で、次のエラーになります。

Bad2.java:9: error: exception IOException is never thrown in body of corresponding try statement
        } catch (IOException e) {
          ^
1 error
Kotlin 側の書き方 Java からの見え方 注釈なしの場合
@file:JvmName("TextUtil") トップレベル関数を TextUtil.shout() で呼べる ファイル名から作られるクラス名(Lib.kt なら LibKt)になる
@JvmStatic(companion object 内) Mailer.create() Mailer.Companion.plain() と書く
@JvmOverloads 既定値のある引数を省いたオーバーロードが作られる 全引数を渡す
@JvmField Mailer.defaultSubject、u.id とフィールドで読める getName() のような getter 経由
const val Mailer.VERSION と static フィールドで読める ―
@Throws(IOException::class) catch (IOException e) を書ける 「never thrown」のコンパイルエラー
val name / var age getName() / getAge()・setAge() ―
null を受け付けない引数 null を渡すと実行時に NullPointerException ―

Kotlin 公式ドキュメント「Calling Kotlin from Java」によると、@JvmOverloads は既定値を持つ引数ごとに、その引数と右側の引数を除いたオーバーロードを 1 つずつ生成します。また、Kotlin は null を受け付けない引数を持つ公開関数に実行時チェックを入れるため、Java から null を渡すと出力の最後の行のようなメッセージで止まります。

Kotlin から Java を呼ぶ:getter/setter・SAM 変換・null の扱い

Java 側のクラスです。

// Legacy.java(Java 側のクラス)
import java.util.List;

public class Legacy {
    private String title = "無題";
    public String getTitle() { return title; }
    public void setTitle(String title) { this.title = title; }
    public static String find(String key) { return key.equals("a") ? "apple" : null; }
    public static List<String> names() { return List.of("x", "y"); }
    public static void run(Runnable r) { r.run(); }
}

Kotlin からの呼び出しです。

// UseJava.kt(Kotlin から Java を呼ぶ)
fun main() {
    val legacy = Legacy()
    legacy.title = "議事録"                 // setTitle がプロパティとして見える
    println(legacy.title)                  // getTitle

    val hit: String? = Legacy.find("a")    // null が返る可能性を型に書く
    val miss: String? = Legacy.find("z")
    println("${hit?.length} ${miss?.length}")

    Legacy.run { println("SAM 変換でラムダを渡せる") }

    val names: List<String> = Legacy.names()
    println(names.map { it.uppercase() })

    val s: String = Legacy.find("z")       // null 不可の型で受ける
    println(s.length)
}
議事録
5 null
SAM 変換でラムダを渡せる
[X, Y]
Exception in thread "main" java.lang.NullPointerException: find(...) must not be null
    at UseJavaKt.main(UseJava.kt:16)
    at UseJavaKt.main(UseJava.kt)
  • getTitle()/setTitle() は legacy.title というプロパティとして読み書きできます。
  • Runnable のようにメソッドが 1 つのインターフェースには、ラムダをそのまま渡せます(SAM 変換)。
  • Java のメソッドの戻り値は、null かどうかが Kotlin からは分かりません。 String? で受ければ ?. で安全に扱えます。String で受けるとコンパイルは通り、実際に null が返ったときに最後の行のように find(...) must not be null で止まります。null が返る可能性のある Java のメソッドは、String? で受けるのが確実です。

よくある質問

Q. Java 25 があれば Kotlin は不要ですか? A. 書き方の差は縮まりましたが、null を型で区別する仕組み、引数の既定値、拡張関数、プロパティ、copy は Kotlin 側にだけあります。この記事の例では、Java 144 行に対して Kotlin 128 行でした。どちらも同じ JVM と標準ライブラリを使うので、チームの経験と使いたい機能で選べます。

Q. Kotlin と Java は 1 つのプロジェクトで混ぜられますか? A. 混ぜられます。この記事では、Kotlin でコンパイルした jar を Java 25 の javac から参照する形と、Java でコンパイルしたクラスを Kotlin から参照する形の両方を動かしました。Java から呼びやすくするには @JvmStatic・@JvmOverloads・@Throws を付けます。

Q. record と data class はどちらが高機能ですか? A. 役割はほぼ同じで、equals・hashCode・toString が自動で作られます。data class には copy と var のプロパティがあり、record にはパターンでの分解(case Rect(double w, double h))があります。toString の表示は角かっこと丸かっこで異なります。

Q. Kotlin から Java の仮想スレッドは使えますか? A. 使えます。OpenJDK 21 上で Thread.ofVirtual().start { ... } を Kotlin から呼び、isVirtual が true になることを確かめました。コルーチンと仮想スレッドは、同じプログラムの中で併用できます。

まとめ

  • Kotlin と Java は同じ JVM・同じ標準ライブラリで動く。30 項目を両方で実行した出力の違いは、record と data class の toString の 2 行だけ
  • 今の Java で埋まった差:var(10)、switch 式(14)、テキストブロック(15)、record・instanceof パターン(16)、sealed(17)、record パターン・switch のパターンマッチング(21)、_(22)、void main()(25)
  • まだ残る差:null を型で区別、引数の既定値と名前付き引数、拡張関数、プロパティ、copy、文字列テンプレート、if・try が式
  • Java にあって Kotlin にないもの:検査例外、三項演算子、static キーワード(Kotlin はトップレベル・const val・companion object で表す)
  • 並行処理は、Java が JDK 標準の仮想スレッド、Kotlin がライブラリのコルーチン。Kotlin から仮想スレッドも使える
  • Java から Kotlin を呼ぶときは @JvmStatic・@JvmOverloads・@JvmField・@Throws・@file:JvmName で Java らしい呼び方にできる
  • Kotlin から Java を呼ぶときは、null が返りうる戻り値を String? で受ける

参考・出典

  • Oracle「Java Language Changes」(Java SE 25) https://docs.oracle.com/en/java/javase/25/language/java-language-changes-release.html (各言語機能が正式化されたバージョン、String Templates の取り下げ)
  • OpenJDK「JDK 25」 https://openjdk.org/projects/jdk/25/ (JEP 511・512・513、JEP 505 が Fifth Preview であること)
  • Kotlin 公式ドキュメント「Calling Kotlin from Java」 https://kotlinlang.org/docs/java-to-kotlin-interop.html (@JvmStatic・@JvmOverloads・@JvmField・@JvmName・@Throws、プロパティの getter/setter、null チェック)
  • kotlinx.coroutines README https://github.com/Kotlin/kotlinx.coroutines/blob/master/README.md (Gradle の依存の書き方)

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

(記載例)出典:株式会社RJC「Kotlin と Java の違い 書き方対応表とコード比較30項目」

株式会社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で一緒に開発しながら、 成長を楽しみませんか?