はじめに

ここでは JDK 9 から 21 までの変更点を解説します。

経緯

むかしむかし、 JDK 8 の時代に Oracle Certified Java Programmer Gold を取得しました。 このときずいぶんとがんばりました。 ところが月日は流れ、現在の JDK は 21 まで進んでいます。 毎回リリースされた機能については目を通していたものの、次第に量も増えて頭のなかで整理がつかなくなっていきました。 変更点がどこかにまとまっていて、いつでも通読できるような資料が欲しいと思うようになりました。

特にわたしが望んでいるのは、以下の 3 点です。

  • 分からない用語を調べ始めると、気づくと Amazon で何かを買おうとしている自分がいる。前提知識が必要なものは多少遡って説明してほしい。

  • 「 JDK 9 のまとめ」「 JDK 9 ~ 11 のまとめ」といった部分的なものではなく JDK 9 から最新までのすべてのまとめ がほしい。

  • 辞書やリファレンスのように参照するというよりも、体系的な知識を得るために通読できる形式のものがほしい。

ひとことでいって「ぜんぶまとまっているもの」が欲しいです。 そのようなものは見当たらなかったため、作成しました。

すべての JEP を網羅しているわけではありません。 実装者の立場から見て優先度の高い更新を載せています。 API の変更に関してはどこまで載せるべきか現在検討中です。

副次的な効果として、これは初級者にとっても有益なまとめになると気づきました。 新人研修や入門書ではほどほどのところで説明が切り上げられてしまいます。 JDK 9 以降の内容をきっかけにして進んだ文法機能についても理解を深められば、きっとなにかの役に立つのではないでしょうか。

対象読者とゴール

初学者を脱しようとしている学習者に向けて作成しています。 新機能やその前提知識を理解・納得していただけたら幸いです。

例えば、次のような読者を念頭に作成しています:

  • 初学者向けのトレーニングや研修、書籍などで 150 ~ 300 時間の学習を終えた。または、Oracle Certified Java Programmer Silver に書かれている内容をおおむね理解している

  • JDK 9 以降の内容を一通り読んで納得したい。また、これまであまり学習してこなかった、進んだ文法機能についての理解を深めたい

一方で、現時点では以下のような上級者のかたに向けては作成していません:

  • 現在複数のバージョンで開発を進めている。プロジェクトごとに使用できる機能に差異があるため、 各バージョンでどの文法機能を利用できるか、早見表的に利用したい。

  • 共通部品の開発やパフォーマンスチューニングなどの業務を行っている。 一般の開発者が直接使用する機能・ API だけでなく、内部仕様にも踏み込んだ解説を読みたい 。

今後のリリース予定

  • v2.0 …​ 一般編の作成( JShell や main メソッド、モジュールの話など)

  • v3.0 …​ JDK 22 対応

  • v4.0 …​ API 編の作成

  • v5.0 …​ GC 編の作成?

※ v5.0 あたりからは未定。

解説中で用いる用語

解説中では以下の用語を用います。

Table 1. 説明に使用する名称
名称 説明

オペランド

演算子による演算の対象を表します。 1 + 2 における 1 や 2 です。

左/右オペランド

オペランドのうち、演算子の左側・右側にあるものを指定する場合に用います。 1 + 2 において、演算子 + 左にある 1 を左オペランドと表します。

インスタンス/オブジェクト

区別せずに利用します。

メンバ

メソッドとメンバ変数(フィールド)を区別せずに表すために用います。

メンバ変数

フィールドの代わりにメンバ変数を用います。インスタンス変数やクラス変数と一貫した呼称を与えたいためです。

インスタンス変数

メンバ変数のうち、とくに非 static なものを限定して表すために用います。

クラス変数

メンバ変数のうち、とくに static なものを限定して表すために用います。

インスタンスメソッド

メソッドのうち、とくに非 static なものを限定して表すために用います。

クラスメソッド

メソッドのうち、とくに static なものを限定して表すために用います。

サブタイプ

継承関係と実装関係を区別せずに表すために用います。具体的には、一方のクラスが他方のクラスを継承しているか、他方のインタフェースを実装している場合に用います。また、一方のインタフェースが他方のインタフェースを継承している場合にも用います。

シンタックス編

本編では文法機能に関する変更を取り上げます。

識別子

09-213 識別子名への単一アンダースコアの使用禁止

JDK 8 時点で、単一のアンダースコア(アンダーバー、アンダーライン)である _ を識別子に用いることは非推奨とされ、コンパイル時に警告されていました。 また、ラムダ式の引数名に _ を用いることは禁止されていました。

JDK 9, JEP 213 からは識別子名への _ の使用が禁止され、コンパイルエラーとなります。 ただし、アンダースコアを 2 つ以上続けた識別子名は禁止されていません。

UnderScore.java
public class UnderScore {

	public static void main(String[] args) {
		// NG
		// int _ = 42;

		// OK
		int __ = 42;
		int ___ = 42;
	}

}

17-409 シールクラス

JDK 17, JEP 409 の変更により、シールクラス( Sealed Class ) が利用可能になりました。 シール( Seal ) は密閉、密封、封印などをあらわす語です [1] (盾や防御物などを表す shield ではありません)。 シールクラスは他のクラスによる拡張を制限します。 具体的には、継承可能なクラスを明示し、その他のクラスによる継承を禁じることができます。

クラスだけでなくインタフェースも同様です。 シールインタフェースと呼ぶのかな、と思いますが、どうなのでしょう。 JEP では Sealed interfaces という文言があるものの、 🔗別な資料 では シールされたインタフェース と書かれています😥

従来の言語仕様では、クラスの継承関係をコントロールする方法はいくつかつありました。

  • final クラス

  • クラスの可視性

  • モジュール( JDK 9, JEP 200)

  • 内部クラス

  • 列挙型

なかでも列挙型は、要素の集合を表現するために広くつかわれる手法です。 以下の例では一週間に MONDAY, …​ SUNDAY だけが存在し、それ以外の曜日が存在しないことがコードに表現されています。

java.time.DayOfWeek( OpenJDK の実装から抜粋。 🔗全文 )
public enum DayOfWeek implements TemporalAccessor, TemporalAdjuster {

    MONDAY,
    TUESDAY,
    WEDNESDAY,
    THURSDAY,
    FRIDAY,
    SATURDAY,
    SUNDAY;
}

列挙型は各列挙定数が共通して持つべきメンバ変数やメソッドを定義し、具体的な値や処理を列挙定数ごとに指定できます。 各列挙定数は内部的には無名クラスとして実装されています。 その意味で列挙型は、あるスーパークラスを継承できるサブクラスを制限することで、列挙定数と列挙型の関係をコード上で表現する技術だといえます。

列挙型の制約として、ある列挙定数にのみ存在するメンバ変数やメソッドは定義できません。 これを実現するためには通常のクラスの継承関係を用いる必要があります。 従来の文法仕様では継承可能なサブクラスを指定できなかったため、 DayOfWeek を継承できるクラスを Monday, …​ ,Sunday に制限することはできませんでした。 シールクラスの導入により、こうした制限が可能となります。

例えば、 JDK 21, JEP 444 で導入された 🔗仮想スレッド では実際にシールクラスが用いられています。 以下の Builder インタフェースはスレッドのためのビルダーです。 permits によって Builder を継承できるインタフェースを従来のスレッドを扱う Builder.OfPlatform と 仮想スレッドを扱う Builder.OfVirtual のみに制限しています。

標準 API での シールクラスの利用(java.lang.Thread.Builder 🔗GitHub )
public sealed interface Builder
           permits Builder.OfPlatform, Builder.OfVirtual {

クラス定義

シールクラスはクラス宣言時に sealed キーワードを付与し、継承を許可するクラスを permits に続けて指定します(以降、 permits 句 といいます)。 以下の Employee クラスは Manager, Engineer, Sales クラスが継承できます。

シールクラスのクラス定義
public sealed class Employee
    permits Manager, Engineer, Sales {
        int id;
        String name;
}

シールクラスを継承するクラスには 3 種類の

  • final クラス。 以下の Sales クラスが該当します。final クラスである Sales は継承できません。

  • sealed クラス。 以下の Manager クラスが該当します。シールクラスのサブクラスがシールクラスであっても問題ありません。 Manager を継承できるクラスは、( Employee ではなく) Manager の permits 句に指定します。

  • non-sealed クラス(非シールクラス)。 以下の Enginner クラスが該当します。非シールクラスである Enginner は自由に継承できます。なお、 Employee が非シールクラスの定義を禁止することはできません。

@TODO

public sealed class Manager extends Employee permits SeniorManager { Collection<Employee> staffs; }

public final Sales extends Employee {}

public final class SeniorManager extends Manager { String mission; }

パターンマッチ

@TODO

型推論

型推論は前後の文脈からコンパイラがデータ型を推論する技術です。

変数の型推論

10-286 ローカル変数の型推論

JDK 10, JEP 286 の変更によりローカル変数の型推論がサポートされました。

キーワード var を用いてローカル変数を宣言する場合、右辺の値から変数のデータ型を推論します。

LocalVariableTypeInference.java(全文)
public class LocalVariableTypeInference {

	/**
	 * 実行結果
	 * 	class java.lang.String
	 *  class java.lang.Integer
	 * 	class java.lang.Long
	 */
	public static void main(String[] args) {

		var name = "hiroki";
		System.out.println(name.getClass());

		var intNum = 42;
		System.out.println(((Object)intNum).getClass());

		var longNum = 42L;
		System.out.println(((Object)longNum).getClass());
	}

}

var name = "hiroki" について見てみます。 式の右辺 "hiroki" が String 型リテラルのため、左辺の変数 name は暗黙的に String 型で宣言されています。 name.getClass() が class java.lang.String を返すことからも、 name は String 型の変数だと分かります。

基本データ型の変数はリテラルのデータ型に注意が必要です。 Java で 42 のような数値は int 型のリテラルとみなされます。 long 型のリテラルを利用する場合は 42L のように、数値の後に L (Long)を付与する必要があります。

クラス名を取得するメソッド getClass() は java.lang.Object クラスに定義されています。 基本データ型はメソッドを呼び出せないため、いちど Object 型にキャストしてから実行しています。 そのため実行結果ではそれぞれラッパークラス名が取得されています。

11-323 ラムダ式の引数の型推論

JDK 11, JEP 323 の変更によりラムダ式の引数にもローカル変数と同様の型推論がサポートされました。

LambdaParametersTypeInference.java(全文)
import java.util.stream.Stream;

public class LambdaParametersTypeInference {

	/**
	 * 実行結果
	 * 	cherry
	 * 	banana
	 */
	public static void main(String[] args) {
		Stream.of("apple", "banana", "cherry")
				.filter((String fruit) -> {
					return fruit.length() > 5;
				})
				.sorted((f1, f2) -> f1.charAt(2) - f2.charAt(2))
				// 説明用
				// .peek((@NotNull var fruit) -> System.out.println(fruit))
				.forEach((var fruit) -> System.out.println(fruit));

	}
}

ラムダ式は (引数) → {処理} と記載します。 省略構文を用いない基本形は、以下のように記述します。

LambdaParametersTypeInference.java(全文)
.filter((String fruit) -> {
	return fruit.length() > 5;
})

Stream.filter() の引数に String 型の引数を受け取り、真偽値を返す処理 を渡しています。

Stream API と型パラメータの内容を正確に記述すると冗長になるため、ここでは簡単に触れるにとどめています。

ラムダ式にはさまざまな省略構文が用意されています。

LambdaParametersTypeInference.java(全文)
.sorted((f1, f2) -> f1.charAt(2) - f2.charAt(2))

引数 f1, f2 は String f1, String f2 のデータ型を省略したものです。 Stream.of("apple", "banana", "cherry") では各要素を String 型で生成していることから、 f1, f2 のデータ型も String 型だと推論できます。処理中身が戻り値を返すだけの場合、 {} と return は省略して戻り値を直接記載できます。

JDK 11 からはデータ型の記載を省略する代わりに、キーワード var を利用して明示的な型推論が可能になりました。これによりローカル変数の型推論と一貫した記法が可能となります。

LambdaParametersTypeInference.java(全文)
.forEach((var fruit) -> System.out.println(fruit));

また、データ型を記述することで、型推論を行ったままデータ型に対するアノテーション( 型アノテーション )が使用できます。 @NotNull は型アノテーションの一種です。静的解析ツールなどで利用できます。

LambdaParametersTypeInference.java(全文)
// 説明用
// .peek((@NotNull var fruit) -> System.out.println(fruit))
ここでは単に、var による型推論を行うと型アノテーションを指定できることを示しているに過ぎません。Stream 処理において要素が存在しない場合は単に peek メソッドが実行されないため、 @NotNull は必要ありません。また @NotNull はあくまでも例示のために用いたアノテーションです。 標準 API のみの環境で上記のコメントアウトを外すとコンパイルエラーとなります。

型パラメータの推論

JDK 7 では型パラメータの推論がサポートされました。

UseDiamondOperatorWithClass.java
import java.util.ArrayList;
import java.util.List;

public class UseDiamondOperatorWithClass {

	public static void main(String[] args) {
		ArrayList<String> fruits = new ArrayList<>();
		List<String> drinks = new ArrayList<>();
	}

}

fruits を宣言する式の右辺に注目してください。 ArrayList オブジェクトの生成時に要素のデータ型が省略されています。 式の左辺でデータ型 ArrayList<String> を宣言しているため、生成された ArrayList は String 型のオブジェクトを要素に持つことが分かります。 このことからコンパイラが右辺の型パラメータを推論できます。 型パラメータを省略した <> の箇所がダイヤモンドのように見えることから一般に ダイヤモンド演算子 [2] と呼ばれます。

drinks を宣言する式には少し変更が加えられています。 式の左辺には List 、右辺には ArrayList が用いられています。 生成されるオブジェクトのデータ型が宣言されたデータ型のサブタイプとなる場合も、型パラメータは推論できています。

09-213 匿名クラス生成時のダイヤモンド演算子サポート

JDK 9, JEP 213 では Java 言語仕様にいくつかの改善が加えられました。 そのひとつに型推論の強化があります。 この変更により、匿名クラスのオブジェクト生成時、ダイヤモンド演算子が利用可能になりました。

UseDiamondOperatorWithAnonymousClass.java(全文)
import java.util.ArrayList;
import java.util.function.Consumer;

public class UseDiamondOperatorWithAnonymousClass {
	public static void main(String[] args) {

		Consumer<String> printConsumer = new Consumer<>() {
			@Override
			public void accept(String t) {
				System.out.println(t);
			}
		};

		ArrayList<String> fruits = new ArrayList<>() {
			{
				add("apple");
				add("banana");
				add("cherry");
			}
		};

		fruits.forEach(printConsumer);

	}
}
UseDiamondOperatorWithAnonymousClass.java(匿名クラス利用箇所)
Consumer<String> printConsumer = new Consumer<>() {
	@Override
	public void accept(String t) {
		System.out.println(t);
	}
};

代入式の左辺はインタフェース、右辺はインタフェースの実装クラスです(この匿名クラスは関数型インタフェース java.util.function.Consumer を実装しています)。 オブジェクトが宣言型のサブタイプである場合にも型パラメータを省略できるという点で、これは前述の drinks の例と同じ形式です。 JDK 7 の時点では対応が間に合わず型推論がサポートされていませんでしたが、 JDK 9 からはサポートされるようになりました。

インタフェース

インタフェースは通常のクラスと異なり制約が設けられています。 JDK 7 まで、メソッドは抽象メソッドのみが定義できました。この制約は JDK 8 で default メソッド・static メソッド がサポートされたことにより緩和されました。

UseInterfaceAtJdkEight.java
interface InterfaceAtJdkEight {
	void method();

	static void staticMethod() {
		System.out.println("static method");
	}

	default void defaultMethod() {
		System.out.println("default method.");
	}
}

class ImplementsInterfaceAtJdkEight implements InterfaceAtJdkEight {

	@Override
	public void method() {
		System.out.println("override abstract method");
	}

}

public class UseInterfaceAtJdkEight {
	public static void main(String[] args) {
		InterfaceAtJdkEight.staticMethod();

		//NG
		//InterfaceAtJdkEight.defaultMethod();

		ImplementsInterfaceAtJdkEight obj = new ImplementsInterfaceAtJdkEight();

		obj.method();
		obj.defaultMethod();
	}

}

09-213 インタフェースでの private メソッドのサポート

JDK 9, JEP 213 ではインタフェースの private メソッドがサポートされました。 今回のサポートにより、 default メソッド間で共通の処理をまとめるなど、ユーティリティメソッドとして private メソッドに定義することが可能になりました。

UseInterfaceAtJdkNine.java(全文)
interface InterfaceAtJdkNine {

	void method();

	default void defaultMethod() {
		privateMethod("This is default method.");
	}

	private void privateMethod(String message) {
		System.out.println(message);
	}

}

class ImplementsInterfaceAtJdkNine implements InterfaceAtJdkNine {

	@Override
	public void method() {
		// NG
		// privateMethod("Override abstract method");
		System.out.println("Override abstract method");
	}

}

interface InterfaceAtJdkNineUsingStaticMethod {

	static void staticMethod() {
		// NG
		// privateInstanceMethod("This is static method.");
		privateClassMethod("This is static method.");
	}

	private void privateInstanceMethod (String message) {
		System.out.println(message);
	}

	private static void privateClassMethod(String message) {
		System.out.println(message);
	}
}

public class UseInterfaceAtJdkNine {

	public static void main(String[] args) {
		ImplementsInterfaceAtJdkNine obj = new ImplementsInterfaceAtJdkNine();
		obj.method();
		obj.defaultMethod();

		InterfaceAtJdkNineUsingStaticMethod.staticMethod();
	}

}
UseInterfaceAtJdkNine.java(private メソッド)
interface InterfaceAtJdkNine {

	void method();

	default void defaultMethod() {
		privateMethod("This is default method.");
	}

	private void privateMethod(String message) {
		System.out.println(message);
	}

}

修飾子 private が付与されたメンバはこれを定義したクラス・インタフェースでのみ参照できます。 したがって private メソッドは実装クラスから参照できません。

UseInterfaceAtJdkNine.java(実装クラスからは参照できない)
class ImplementsInterfaceAtJdkNine implements InterfaceAtJdkNine {

	@Override
	public void method() {
		// NG
		// privateMethod("Override abstract method");
		System.out.println("Override abstract method");
	}

}

また、通常の private メソッドはインスタンスメソッドです。 インスタンスメンバはクラスメンバから参照できません。 クラスメソッドから参照するためには private なクラスメソッドを定義する必要があります。

UseInterfaceAtJdkNine.java(クラスメソッドの参照)
interface InterfaceAtJdkNineUsingStaticMethod {

	static void staticMethod() {
		// NG
		// privateInstanceMethod("This is static method.");
		privateClassMethod("This is static method.");
	}

	private void privateInstanceMethod (String message) {
		System.out.println(message);
	}

	private static void privateClassMethod(String message) {
		System.out.println(message);
	}
}

パターンマッチング

パターンマッチングとは、値が特定のパターンに合致するかどうかの判定です。 ここでは主に instanceof と switch 文について扱います。

パターンマッチングというと java.util.regex パッケージに代表される正規表現を思い浮かべるかもしれません(私は当初そうでした)。ここで扱うのは正規表現の話ではない点に留意してください。

16-394 演算子 instanceof による比較

JDK 16, JEP 394 では演算子 instanceof によるパターンマッチングが強化されました。

instanceof は左オペランドの値が右オペランドで指定するタイプか、またはそのサブタイプである場合に true と評価される演算子です。

Instanceof.java(全文)
public class Instanceof {

	public static void main(String[] args) {

		String str = "abc";
		useInstanceof(str);

	}

	public static void useInstanceof(Object obj) {

		if (obj instanceof String) {
			String str = (String)obj;
			System.out.println(str.charAt(0));
		}

		if (obj instanceof String str) {
			System.out.println(str.charAt(0));
		}

		if (obj instanceof String str && !str.isEmpty()) {
			System.out.println(str.charAt(0));
		}

		// 煩雑
		if (obj instanceof String str) {
			if (!str.isEmpty()) {
				System.out.println(str.charAt(0));
			}
		}

		// Error: str を解決できません
		// if (obj instanceof String str || !str.isEmpty()) {}

		{
			if (!(obj instanceof String str && !str.isEmpty())) {
				throw new IllegalArgumentException();
			}
			System.out.println(str.charAt(0));
		}

		{
			if (!(obj instanceof String str && !str.isEmpty())) {
				return;
			}

			// Error: 重複ローカル変数 str
			// String str = "hoge";

			System.out.println(str.charAt(0));
		}


		{
			if (!(obj instanceof String str && !str.isEmpty())) {
				System.out.println("something wrong");
			}
			// Error: str を解決できません
			// System.out.println(str.charAt(0));

		}
	}

}
Instanceof.java(従来の用法)
public static void useInstanceof(Object obj) {

	if (obj instanceof String) {
		String str = (String)obj;
		System.out.println(str.charAt(0));
	}

上記の例はキャストを行う際の典型的な記法です。 ここで目的とする処理は変数 obj を String 型にキャストしてメソッドを呼び出すことです。 ただしキャストの際、 obj に格納されたオブジェクトが String 型でない場合は例外 ClassCastException が発生してしまいます。 例外発生を回避するために instanceof による型のチェックを行っています。 このような記法を instanceof-and-cast イディオム といいます。

従来の記法では instanceof による型のチェックとキャストを別々に行っていたため記載が冗長でした。 JDK 16 からは型チェックとキャストを同時に行えるようになり、簡潔な記法が可能となります。

Instanceof.java(JDK 16 以降)
if (obj instanceof String str) {
	System.out.println(str.charAt(0));
}

instanceof に続けて データ型 変数名 と記述します。 この場合、変数 obj が String 型であれば、それ以降は String 型の変数 str として利用できます。 instanceof とともに宣言された変数を パターン変数 といいます。

パターン変数のスコープは instanceof が true の場合にのみプログラムが到達できる箇所 です。 上記は instanceof が true となる場合のみブロック内の処理が実行されるため、ブロック内はパターン変数のスコープといえます。

パターン変数のスコープは通常の変数と異なるルールを持ちます。 以下2点の特徴を例に、パターン変数のスコープについて説明します。

  1. && でつながれた条件内で使用できる

  2. ガード節以降で使用できる

第一の特徴として、パターン変数は && でつながれた条件の右オペランドで使用できます。

Instanceof.java(従来の用法)
if (obj instanceof String str && !str.isEmpty()) {
	System.out.println(str.charAt(0));
}

仮にこのような記述が認められない場合、以下のような記述が必要となります。 このような処理も想定通りに動作するものの、記述が煩雑になってしまいます。

Instanceof.java( && の右オペランドでパターン変数が利用できない場合のデメリット)
// 煩雑
if (obj instanceof String str) {
	if (!str.isEmpty()) {
		System.out.println(str.charAt(0));
	}
}

このような記述が可能なのは && の特性によるものです。 && は、左オペランドの条件式が true と評価される場合にのみ右オペランドの条件式を評価します。 左オペランドの instanceof によってキャスト可能である場合にかぎって右オペランドでパターン変数が利用されます。 これは instanceof が true の場合にのみプログラムが到達できる箇所 という条件を満たします。

反対に、論理演算子 || は使用できません。 || は左オペランドが false の場合、右オペランドの評価を式全体の評価とします(左オペランドの条件式が true の場合は式全体を true と評価します)。 instanceof が false の場合であっても処理が到達してしまうためコンパイルエラーとなります。

Instanceof.java( || は使用不可)
// Error: str を解決できません
// if (obj instanceof String str || !str.isEmpty()) {}
論理演算子 & や | も利用できません。これらの演算子は左オペランドの結果に関わらず右オペランドを評価するためです。 && や || のように左オペランドの結果によっては右オペランドを評価せずに式全体の評価を決定することを 短絡評価(short-circuit evaluation) といいます。 Java では通常短絡評価を行う演算子のみを使用します。

第二の特徴として、パターン変数は早期リターンや例外のスローといったガード節以降で使用できます。 具体的には、以下のような場合においてコンパイルエラーは発生しません。

Instanceof.java(ガード節①例外のスロー)
if (!(obj instanceof String str && !str.isEmpty())) {
	throw new IllegalArgumentException();
}
System.out.println(str.charAt(0));

通常の変数の場合、変数のスコープは宣言されたブロックの内部のみとなります。

通常の変数の場合といっても、そもそも通常 if 文で変数を宣言する例は見慣れないと思います。 for 文で宣言されたカウンタ変数(e.g. int i = 0; )が for 文のなかでのみ利用可能なことと同じだと理解してください。

パターン変数のスコープはあくまでも instanceof が true の場合にのみプログラムが到達できる箇所 です。 この例では inctanceof が false を返す場合に IllegalArgumentException をスローしてメソッドを抜けているため、それ以降の処理が実行されるのは instanceof が true の場合にのみ となります。

例外をスローする場合だけでなく、早期リターンを行う場合も同様です。なお、パターン変数 str はガード節以降も有効なため、同じ名前の変数を再度宣言するとコンパイルエラーになります。

Instanceof.java(ガード節②早期リターン)
if (!(obj instanceof String str && !str.isEmpty())) {
	return;
}

// Error: 重複ローカル変数 str
// String str = "hoge";

System.out.println(str.charAt(0));

なお、以下の例はガード節に似ていますがコンパイルエラーとなります。

Instanceof.java(ガード節①例外のスロー)
if (!(obj instanceof String str && !str.isEmpty())) {
	System.out.println("something wrong");
}
// Error: str を解決できません
// System.out.println(str.charAt(0));

パターン変数を宣言したブロック以降で参照できるかどうかは、instanceof が true の場合にのみプログラムが 到達できる かどうかによって決まります。 上記の例では false の場合であってもプログラムが到達できるため NG となります。

21-440 レコードクラスのパターンマッチング

@TODO

21-441 switch のパターンマッチング

JDK 21, JEP 441 の変更によって、 switch 文/式 で利用可能なパターンマッチが拡張されました。 以前の switch 文は case の表現力が不十分であり、単純な条件しか指定できませんでした。 今回の拡張によってより柔軟な条件を指定できます。

パターンラベル

JDK 16, JEP 394 では演算子 instanceof の強化が行われ、従来 instanceof-and-cast イディオムによって実現していた処理がより簡潔に記述できるようになりました。 JEP 441 の変更では swtich 文に同様の改善が行われます。

case null

case null ラベルは default ラベルとまとめられます。

case null, default → {}

定数ラベル
When 節

switch 文

従来の switch 文にはいくつかの課題がありました。 JDK 9 以降、 switch 文に対する改善が行われています。

  1. case ごとに変数のスコープが分割されない点。 変数のスコープはブロック( { , } )によって分割されます。 従来の swtich 文は case がブロックによって区切られていないため、スコープも分割されません。 そのため case ごとに同じ名前の変数を定義できませんでした。

  2. 意図せぬフォールスルーが発生しうる点。 switch 文において、一般的に case の処理の最後には break を記述します。 しかし break は記載しなくてもコンパイルエラーは発生しません。 break が無い場合、後続の case も続けて実行されます。 これを フォールスルー といいます。 フォールスルーは意図的に行われる場合もありますが、意図せずにフォールスルーが発生することでバグの原因となることがありました。

  3. switch を式として利用できない点。 switch 文は分岐に応じた値を変数に格納するために用いられることがあります。 このような場合において各 case でそれぞれ代入処理を記述する必要があります。 単純な分岐であれば if 文の代わりに三項演算子を用いることで、条件に応じた値を変数に代入できます。 switch 文にはこのような機構が存在しません。

  4. パターンマッチにおける表現力が不足している点。 当初 switch 文で扱えるデータ型は基本データ型と列挙型に限定されていました。 JDK 7 のリリースによって String 型も扱えるようになったものの、柔軟な条件は指定できず、その表現力は十分といえません。

上記の課題感のうち 1. ~ 3. については JDK 14, JEP 361 によって、 4. については JDK 21, JEP 441 によって、解消に向けた機能拡張が行われています。

14-361 switch 文/式

JDK 14, JEP 361 で switch 文が拡張されました。 また、 switch 式が利用可能になりました。

SwitchExpression.java(全文)
public class SwitchExpression {

    enum CardSuit {
        SPADE, CLUB, HEART, DIAMOND
    }

    /**
     * 実行結果
     * 	hei hei!
     * 	super!!
     * 	hei hei!
     * 	super!
     */
    public static void main(String[] args) {

        useLambda(CardSuit.DIAMOND);
        String message = useSwitchExpression(CardSuit.DIAMOND);
        System.out.println(message);

    }

    public static void before(CardSuit suit) {
        switch (suit) {
        case SPADE:
            System.out.println("favorite!!!");
            break;
        case CLUB:
        case HEART:
            System.out.println("good");
            break;
        case DIAMOND:
            System.out.println("hei hei!");
            System.out.println("super!!");
            break;
        default:
            throw new IllegalArgumentException();
        }
    }

    public static void useLambda(CardSuit suit) {
        switch (suit) {
        case SPADE -> System.out.println("favorite!!!");
        case CLUB, HEART -> System.out.println("good");
        case DIAMOND -> {
            System.out.println("hei hei!");
            System.out.println("super!!");
        }
        default -> throw new IllegalArgumentException();
        }
    }

    public static String useSwitchExpression(CardSuit suit) {
        return switch (suit) {
        case SPADE -> "favorite!!!";
        case CLUB, HEART -> "good";
        case DIAMOND -> {
            System.out.println("hei hei!");
            yield "super!!";
        }
        default -> throw new IllegalArgumentException();
        };

    }

    public static String useSwitchExpression2(CardSuit suit) {
        return switch (suit) {
        case SPADE:
            yield "favorite!!!";
        case CLUB:
        case HEART:
            yield "good!!";
        case DIAMOND:
            System.out.println("hei hei!");
            yield "super!!";
        default:
            throw new IllegalArgumentException();
        };
    }
}
SwitchExpression.java(従来のswitch文)
public static void before(CardSuit suit) {
    switch (suit) {
    case SPADE:
        System.out.println("favorite!!!");
        break;
    case CLUB:
    case HEART:
        System.out.println("good");
        break;
    case DIAMOND:
        System.out.println("hei hei!");
        System.out.println("super!!");
        break;
    default:
        throw new IllegalArgumentException();
    }
}
switch 文でのラムダ式

この変更により switch 文においてラムダ式が利用できるようになりました。

SwitchExpression.java(従来のswitch文)
public static void useLambda(CardSuit suit) {
    switch (suit) {
    case SPADE -> System.out.println("favorite!!!");
    case CLUB, HEART -> System.out.println("good");
    case DIAMOND -> {
        System.out.println("hei hei!");
        System.out.println("super!!");
    }
    default -> throw new IllegalArgumentException();
    }
}

この記法が導入されたことにより、従来の case ラベルを コロン case ラベル ( case L: )、 新しいラムダ式によるラベルを 矢印 case ラベル ( case L → {})と呼んで区別します。 ラムダ式の右辺には、式、文を含むブロック、例外のスローを記述できます。また、 case 内で実行する処理がただひとつである場合には、ブロックの記述を省略できます(e.g. SPADE, CLUB, HEART の箇所を参照)。

JDK 13 のプレビュー時点では矢印 case ラベル / コロン case ラベル という文言が使用されていたものの、 JDK 14 では使用されなくなりました。代わりに case L →ラベル という文言が用いられています。しかしこれはなんと読めばいいのか分かりません……。 JEP 361 では Arrow Label という文言が用いられており、これがならしっくりきます。とはいえ、区別が必要なタイミングはあまりないかもしれません。

ラムダ式を利用した switch 文には 2 つの利点があります。

  1. 変数のスコープを分割できる。 コロン case ラベルを使用した場合、変数のスコープはブロックによって分割されませんでした。 矢印 case ラベルを用いることで、変数のスコープを case ごとに分割でき、各 case で同名の変数を宣言できるようになります。

  2. break が不要となり、意図せぬフォールスルーを防げる。 コロン case ラベルを使用した場合、 break の記述が漏れると意図せぬフォールスルーが発生していました。 矢印 case ラベルを用いる場合、各ブロックに break は必要ありません。 各 case は対応する処理のみが実行され、後続の case は実行されません。

矢印 case ラベルのもっとも単純な利用方法は、以下のように処理を矢印の右辺に続けて記載することです。本来は処理部分にブロックが必要ですが、実行する処理がただひとつである場合はブロックの記述を省略できます。 上記で述べた通り、 break は不要です。

SwitchExpression.java(単純な矢印 case ラベル)
case SPADE -> System.out.println("favorite!!!");

複数の値に対して同じ処理を行う場合、ラベルに指定する値をカンマで区切って続けられます。 以下の例では suit の値が CLUB か HEART の場合に処理を行います。

SwitchExpression.java(複数の値に対応する矢印 case ラベル)
case CLUB, HEART -> System.out.println("good");

ラベルに対応する処理が複数ある場合、ブロックを省略せずに記述します。

SwitchExpression.java(複数の処理を持つ矢印 case ラベル)
case DIAMOND -> {
    System.out.println("hei hei!");
    System.out.println("super!!");
}

ラベルに対応する処理では例外をスローできます。 以下の例では default のラベルに例外を指定していますが、 default 以外のラベルで例外をスローすることもできます。

SwitchExpression.java(複数の処理を持つ矢印 case ラベル)
default -> throw new IllegalArgumentException();
switch 式

JEP 361 の変更により、また、 switch 文を式として使えるようになりました。 すなわち switch 式 が利用可能になりました。

SwitchExpression.java(switch 式)
public static String useSwitchExpression(CardSuit suit) {
    return switch (suit) {
    case SPADE -> "favorite!!!";
    case CLUB, HEART -> "good";
    case DIAMOND -> {
        System.out.println("hei hei!");
        yield "super!!";
    }
    default -> throw new IllegalArgumentException();
    };

}

useSwitchExpression() は switch 式の返す値を戻り値とするメソッドです。 引数の値に応じて favorite!!! good super のいずれかを返します。 例外をスローする場合は switch 文と同様です。 以降ではその他の場合について解説します。

SwitchExpression.java(switch 式の評価値 - 処理が複数ある場合)
case DIAMOND -> {
    System.out.println("hei hei!");
    yield "super!!";
}

ラムダ式の右辺に注目してください。 各 case ラベルに対応する処理をブロックのなかに記述します。 yeild によって指定した値 super が、この switch 式の評価値となります。 メソッドにおける return と同等の機能を式で実現するのが yeild だといえます。

SwitchExpression.java(switch 式の評価値 - 処理がひとつの場合)
case SPADE -> "favorite!!!";
case CLUB, HEART -> "good";

上記の case ラベルはラムダ式右辺に記述した値が switch 式の評価値となります。 これは通常のラムダ式における省略のルールと同じように考えられます(式の処理がただひとつであり、かつ、その処理が return 文であるときは、ブロックと return を省略して直接戻り値を記述できます)。

上記の例では矢印 case ラベルを使用していましたが、 switch 式ではコロン case ラベルも同じように使用できます。

SwitchExpression.java(コロン case ラベルで switch 式を利用する)
public static String useSwitchExpression2(CardSuit suit) {
    return switch (suit) {
    case SPADE:
        yield "favorite!!!";
    case CLUB:
    case HEART:
        yield "good!!";
    case DIAMOND:
        System.out.println("hei hei!");
        yield "super!!";
    default:
        throw new IllegalArgumentException();
    };
}
switch 式の注意点( case ラベルの網羅性)

switch 式はあくまでも 式 です。 式は評価値を返す必要があるため、特定の case について評価値が定まらない場合はコンパイルエラーとなります。

  • ブロックを省略しない場合において、 yeild の記載がないとコンパイルエラーとなります。

  • case に網羅性がない場合はコンパイルエラーとなります。

2 点目について解説します。 上記の例において、 CardSuit は以下のような列挙型で定義されています。

enum CardSuit {
    SPADE, CLUB, HEART, DIAMOND
}

switch 式では case DIAMOND ラベルがなく、かつ、 case default ラベルもない場合はコンパイルエラーとなります。 case DIAMOND のみがない場合、 DIAMOND は case default の場合に含まれるためコンパイルエラーとはなりません。また、 case default のみがない場合、 CardSuit のすべての列挙定数と対応する case が存在するためコンパイルエラーとなりません。

case の網羅性といっても、 suit が null の場合は NullPointerException が発生してしまいます。ただし、この点は後述する JEP 441 において case null が可能となりました🎉

21-441 switch のパターンマッチング

JDK 21, JEP 441 の変更によって、 switch 文/式 で利用可能なパターンマッチが拡張されました。 これまでの switch 文は case の表現力が不十分であり、単純な条件しか指定できませんでした。 今回の拡張によってより柔軟な条件を指定できます。

詳細は 🔗パターンマッチの箇所を参照してください。

try-with-resources 文

try-with-resources 文は JDK 1.7 で導入されました。 try-with-resources 文は、 java.lang.AutoClosable の実装クラスを文頭に宣言することで、処理の終了後に AutoClosable#close() メソッドが自動で呼び出されます。 これにより開発者がリソースのクローズを手動で行う必要がなくなりました。

TryWithResourcesStatementAtJdk8.java
import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.IOException;

public class TryWithResourcesStatementAtJdk8 {

	public static void main(String[] args) {

		String fileName = "newfile.txt";
		String content = "hello";

		try (BufferedWriter writer = new BufferedWriter(new FileWriter(fileName))) {

			writer.write(content);

			System.out.println("File written successfully!");
		} catch (IOException e) {
			e.printStackTrace();
		}
	}

}

09-213 try-with-resources 文で effectively-final な変数の宣言をサポート

JDK 9, JEP 213 では try-with-resources 文で effectively-final な変数の宣言がサポートされました。

effectively-final は 実質的 final とも呼ばれます。 修飾子 final を付与した変数は値を再代入できません。 修飾子 final が付与されていない変数であっても、再代入されていなければ実質的に final だとみなすことができます。 このような変数を effectively-final な変数といいます。

以前の tru-with-resources 文では、利用するリソースは文頭で宣言する必要がありました。 すでにリソースが宣言されている場合は再度宣言するするため、記述が冗長になります。

以下の例において writer はすでに宣言済みのリソースです。 try-with-resources 文で利用するためにはリソースを宣言する必要があるため、別名の変数 writer2 を再度宣言しています。

TryWithResourcesStatementCouldNotUseDeclaredVariablesUntilJdk8.java
import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.IOException;

public class TryWithResourcesStatementCouldNotUseDeclaredVariablesUntilJdk8 {

	public static void main(String[] args) throws Exception {

		String fileName = "newfile.txt";
		String content = "hello";

		BufferedWriter writer = new BufferedWriter(new FileWriter(fileName));

		try (BufferedWriter writer2 = writer) {

			writer2.write(content);

			System.out.println("OK");
		} catch (IOException e) {
			e.printStackTrace();
		}

	}

}

JEP 213 の変更によって、リソースが effectively-final であれば文頭での宣言が不要になりました。 すでにリソースが宣言されている場合も再度宣言する必要がなくなり、より簡潔な記述が可能になりました。

TryWithResourcesStatementAtJdk9.java(全文)
import java.io.BufferedWriter;
import java.io.FileWriter;
import java.io.IOException;

public class TryWithResourcesStatementAtJdk9 {
	public static void main(String[] args) throws Exception {

		String fileName = "newfile.txt";
		String content = "hello";

		BufferedWriter writer = new BufferedWriter(new FileWriter(fileName));

		try (writer) {

			writer.write(content);

			System.out.println("OK");
		} catch (IOException e) {
			e.printStackTrace();
		}
	}


	public static void usingDeclaredResouce(BufferedWriter writer) {

		String content = "hello";

		try (writer) {

			writer.write(content);

			System.out.println("OK");
		} catch (IOException e) {
			e.printStackTrace();
		}
	}
}
TryWithResourcesStatementAtJdk9.java(宣言済みのリソースも利用できる)
public static void main(String[] args) throws Exception {

	String fileName = "newfile.txt";
	String content = "hello";

	BufferedWriter writer = new BufferedWriter(new FileWriter(fileName));

	try (writer) {

上記のように、同じメソッド内でリソースを宣言している場合はあまり簡潔になりません。

リソースは多くの場合オブジェクト生成時にコンストラクタが例外をスローしえます(java.io.FileWriter であれば IOException をスローしえます)。 try-with-resources 文の文頭で宣言する場合、オブジェクト生成時に発生した例外も catch ブロックで捕捉できます。 try-with-resources 文よりも前で宣言する場合、例外を catch ブロックで補足できないため、別途例外処理が必要です。 上記の例では main メソッドで Exception のスローを宣言して対処しています。

以下の例は引数で受け取ったリソースを try-with-resources 文で利用する例です。 このような場合はコンストラクタによる例外のスローに対処する必要がなく、処理が簡潔になります。

TryWithResourcesStatementAtJdk9.java(メソッドの引数でリソースを受け取る例)
public static void usingDeclaredResouce(BufferedWriter writer) {

	String content = "hello";

	try (writer) {

		writer.write(content);

		System.out.println("OK");
	} catch (IOException e) {
		e.printStackTrace();
	}
}

21-444 仮想スレッド

JDK 21, JEP 444 の変更によって 仮想スレッド が利用可能になりました。

スレッドはスケジュールできる処理の最小単位です。 バックグランドで実行されるスレッドを除き、プログラムは1つのスレッドで動作を開始します。 プログラム中で他のスレッドを生成することで、複数の処理を並行して実行できます。 スレッドは Java 上で java.lang.Thread のインスタンスとして表現されます。

従来のスレッドは OS のスレッドのラッパーとして実装されていました(仮想スレッドと対比して プラットフォームスレッド といいます)。 利用可能なスレッド数は OS のスレッド数に制限されます。

仮想スレッドは特定の OS スレッドと 1:1 で関連付けられないスレッドです。 少数の OS スレッドに JVM が多数のスレッドを割り当てることで、OS のスレッド数による制限を受けずにスレッドを生成できます。

仮想スレッドは同時接続数の多いアプリケーションにおいて、高いスループットを維持する場合などに用いられます。 特に、各スレッドの処理の大部分が待機に費やされる場合に用いられます。 なお、仮想スレッドは軽量で高速なスレッドではありません。 この点は章の最後に解説します。

Thread クラスを使用した仮想スレッドの生成

仮想スレッドは java.lang.Thread から以下のように利用できます。

VirtualThread.java(全文)
import java.time.Duration;
import java.util.concurrent.Executors;
import java.util.concurrent.locks.ReentrantLock;
import java.util.stream.IntStream;

public class VirtualThread {

    /**
     * 実行結果例
     *  Hello
     *  Thread.sleep() - 従来のスレッド : 15693ミリ秒
     *  Thread.sleep() - 仮想スレッド   : 5000ミリ秒
     *  Thread.sleep() - synchronized   : 7089ミリ秒
     *  Thread.sleep() - ReentrantLock  : 1013ミリ秒
     *  Math.random() -- 従来のスレッド : 15737ミリ秒
     *  Math.random() -- 仮想スレッド   : 17084ミリ秒
     * */
    public static void main(String[] args) throws Exception {

        Thread.Builder builder = Thread.ofVirtual();
        Thread thread = builder.start(() -> System.out.println("Hello"));
        thread.join();


        long startTime, endTime;

        startTime = System.currentTimeMillis();
        try (var executor = Executors.newCachedThreadPool()) {
            IntStream.range(0, 100_000).forEach(i -> {
                executor.submit(() -> {
                    try {
                        Thread.sleep(Duration.ofSeconds(1));
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                });
            });
        }

        endTime = System.currentTimeMillis();
        System.out.println("Thread.sleep() - 従来のスレッド : " + (endTime - startTime) + "ミリ秒");


        startTime = System.currentTimeMillis();
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            IntStream.range(0, 100_000).forEach(i -> {
                executor.submit(() -> {
                    try {
                        Thread.sleep(Duration.ofSeconds(1));
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                });
            });
        }

        endTime = System.currentTimeMillis();
        System.out.println("Thread.sleep() - 仮想スレッド   : " + (endTime - startTime) + "ミリ秒");


        startTime = System.currentTimeMillis();
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            IntStream.range(0, 100).forEach(i -> {
                executor.submit(() -> {
                    Object obj = new Object();
                    synchronized (obj) {
                        try {
                            Thread.sleep(Duration.ofSeconds(1));
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                    }

                });
            });
        }

        endTime = System.currentTimeMillis();
        System.out.println("Thread.sleep() - synchronized   : " + (endTime - startTime) + "ミリ秒");


        startTime = System.currentTimeMillis();
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            IntStream.range(0, 100).forEach(i -> {
                executor.submit(() -> {
                    ReentrantLock lock = new ReentrantLock();
                    lock.lock();
                    try {
                        Thread.sleep(Duration.ofSeconds(1));
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    } finally {
                        lock.unlock();
                    }

                });
            });
        }

        endTime = System.currentTimeMillis();
        System.out.println("Thread.sleep() - ReentrantLock  : " + (endTime - startTime) + "ミリ秒");


        startTime = System.currentTimeMillis();
        try (var executor = Executors.newCachedThreadPool()) {
            IntStream.range(0, 10_000).forEach(i -> {
                executor.submit(() -> {
                    IntStream.range(0, 10_000).forEach(j -> {
                        Math.random();
                    });
                });
            });
        }

        endTime = System.currentTimeMillis();
        System.out.println("Math.random() -- 従来のスレッド : " + (endTime - startTime) + "ミリ秒");


        startTime = System.currentTimeMillis();
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            IntStream.range(0, 10_000).forEach(i -> {
                executor.submit(() -> {
                    IntStream.range(0, 10_000).forEach(j -> {
                        Math.random();
                    });
                });
            });
        }
      endTime = System.currentTimeMillis();
      System.out.println("Math.random() -- 仮想スレッド   : " + (endTime - startTime) + "ミリ秒");

    }

}
VirtualThread(Threadクラスからの仮想スレッド生成)
Thread.Builder builder = Thread.ofVirtual();
Thread thread = builder.start(() -> System.out.println("Hello"));
thread.join();
virtual thread builder class
virtual thread builder sequence

Thread.ofVirtual() は Thread.Builder.OfVirtual インタフェースのインスタンスを返します。 Thread.Builder.start() は Runnnable task を引数に取り、生成したスレッド上で task の処理を実行します。Thread.Builder.OfVirtual は start() 実行時に 仮想 スレッドを生成します(Thread.Builder.OfPlatform は プラットフォーム スレッドを生成します)。

以下に示すように、 Thread.Builder.OfVirtual は Thread クラスの内部インタフェースである Builder インタフェースの内部インタフェースであり、Builder のサブインタフェースです。以下は java.lang.Thread の内部実装です。

java.lang.Thread の内部実装抜粋(OpenJDK, JDK 21 🔗GitHub)
public class Thread implements Runnable {
    public sealed interface Builder
            permits Builder.OfPlatform, Builder.OfVirtual {
        sealed interface OfVirtual extends Builder
                permits ThreadBuilders.VirtualThreadBuilder {/** ... */}
        /** ... */
    }
        /** ... */
}
sealed permits については 🔗シールドクラス にて解説しています。

Executors.newVirtualThreadPerTaskExecutor()メソッド

スレッド API は JDK 1.5 からエグゼキュータが導入され、以降直接 Thread を操作することは少なくなりました。 エグゼキュータによって仮想スレッドを生成する場合 Executors.newVirtualThreadPerTaskExecutor() を使用します。 ExecutorService は 🔗JDK 19 から AutoClosable を継承 しているため、try-with-resouces 文が利用可能です。

以下は newCachedThreadPool() と newVirtualThreadPerTaskExecutor() の比較です。 Thread.sleep() による 1 秒間の待機処理をそれぞれ 100,000 回実行しています。 手元のノートPCの実行時間は、 newCachedThreadPool() が 15,693 ミリ秒、newVirtualThreadPerTaskExecutor() が 5,000ミリ秒でした。

VirtualThread(速度比較①プラットフォームスレッドvs仮想スレッド)
long startTime, endTime;

startTime = System.currentTimeMillis();
try (var executor = Executors.newCachedThreadPool()) {
    IntStream.range(0, 100_000).forEach(i -> {
        executor.submit(() -> {
            try {
                Thread.sleep(Duration.ofSeconds(1));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
    });
}

endTime = System.currentTimeMillis();
System.out.println("Thread.sleep() - 従来のスレッド : " + (endTime - startTime) + "ミリ秒");


startTime = System.currentTimeMillis();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 100_000).forEach(i -> {
        executor.submit(() -> {
            try {
                Thread.sleep(Duration.ofSeconds(1));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });
    });
}

endTime = System.currentTimeMillis();
System.out.println("Thread.sleep() - 仮想スレッド   : " + (endTime - startTime) + "ミリ秒");

仮想スレッドの同期制御

仮想スレッドは synchronized によって同期制御する場合パフォーマンス上の制約が発生します。 この制約は java.util.concurrent.locks.ReentrantLock によって解消できます。

仮想スレッドは JVM によってプラットフォームスレッドと対応付けられています。 少数のプラットフォームスレッドに多数の仮想スレッドが割り当てられるため、仮想スレッドはスケールしやすいという特性を持ちます。 仮想スレッドが対応付けられたプラットフォームスレッドを キャリア といいます。

仮想スレッドがキャリアと対応付けられることを マウント といい、対応付けが解除されることを アンマウント といいます。 アンマウントはI/Oなどのブロッキング操作時に発生します。 アンマウントされたプラットフォームスレッドは JVM によってふたたび他の仮想スレッドのキャリアに割り当てられるため、少数のプラットフォームスレッドによって多数の仮想スレッドを実行できます。

キャリアにピンニング( pinning 。固定、ピン止め)された仮想スレッドは、ブロッキング操作中にアンマウントされません。 実行結果に不整合は生じないものの、ピンニングされたキャリアは他の仮想スレッドの利用を待機させるため、スケーラビリティが低下します。 ピンニングは以下の場合に生じます。

  • synchronized ブロックまたはメソッドの処理の実行時

  • native メソッドまたは外部関数の実行時

JEP 444 では、今後 synchronized によるピンニングは解消される可能性が示唆されています。

In a future release we may be able to remove the first limitation above, namely pinning inside synchronized.

— https://openjdk.org/jeps/444

以下は synchronized と ReentrantLock の比較です。 Thread.sleep() による待機処理を 100 回同期実行しています。

VirtualThread(速度比較②synchronized vs ReentrantLock)
startTime = System.currentTimeMillis();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 100).forEach(i -> {
        executor.submit(() -> {
            Object obj = new Object();
            synchronized (obj) {
                try {
                    Thread.sleep(Duration.ofSeconds(1));
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }

        });
    });
}

endTime = System.currentTimeMillis();
System.out.println("Thread.sleep() - synchronized   : " + (endTime - startTime) + "ミリ秒");


startTime = System.currentTimeMillis();
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    IntStream.range(0, 100).forEach(i -> {
        executor.submit(() -> {
            ReentrantLock lock = new ReentrantLock();
            lock.lock();
            try {
                Thread.sleep(Duration.ofSeconds(1));
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                lock.unlock();
            }

        });
    });
}

endTime = System.currentTimeMillis();
System.out.println("Thread.sleep() - ReentrantLock  : " + (endTime - startTime) + "ミリ秒");

手元のノートPCの実行時間は、 synchronized による同期化が 7,089 ミリ秒、ReentrantLock による同期化が 1,013 ミリ秒でした。 1 秒間のスリープ処理を行っていることを鑑みると、 ReentrantLock を使用する場合は 13 ミリ秒しかオーバーヘッドが生じていません。 これに対して synchronized をする場合は 約 7 倍の時間がかかっています。 ピンニングの発生したキャリアがアンマウントされるのを待機する時間がオーバーヘッドとなり、実行時間が余分にかかっています。

仮想スレッドの速度

これまで見てきたように、仮想スレッドを利用するとプラットフォームスレッドよりも高速に動作する場合があります。 特に、スレッド数が十分に大きく、スレッドが待機状態の発生する処理を行う場合は高速に動作する見込みがあります。

しかし、仮想スレッドはプラットフォームスレッドに比べてオーバーヘッドが小さくて高速というわけでは ありません 。 前述したように仮想スレッドもキャリアとなるプラットフォームスレッドと紐づいて実行されます。 待機状態の少ない処理を行う場合、仮想スレッドがキャリアをアンマウントしないため、プラットフォームスレッドを直接利用する場合と同様の制約が発生します。

以下は newCachedThreadPool() と newVirtualThreadPerTaskExecutor() の比較です。 この比較では Math.random() による生成を 10,000 回行う処理を 10,000 個のスレッドで実行しています。

VirtualThread(速度比較③プラットフォームスレッド vs 仮想スレッド)
  startTime = System.currentTimeMillis();
  try (var executor = Executors.newCachedThreadPool()) {
      IntStream.range(0, 10_000).forEach(i -> {
          executor.submit(() -> {
              IntStream.range(0, 10_000).forEach(j -> {
                  Math.random();
              });
          });
      });
  }

  endTime = System.currentTimeMillis();
  System.out.println("Math.random() -- 従来のスレッド : " + (endTime - startTime) + "ミリ秒");


  startTime = System.currentTimeMillis();
  try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
      IntStream.range(0, 10_000).forEach(i -> {
          executor.submit(() -> {
              IntStream.range(0, 10_000).forEach(j -> {
                  Math.random();
              });
          });
      });
  }
endTime = System.currentTimeMillis();
System.out.println("Math.random() -- 仮想スレッド   : " + (endTime - startTime) + "ミリ秒");

手元のノートPCの実行時間は、 newCachedThreadPool() が 1,5737 ミリ秒、newVirtualThreadPerTaskExecutor() が 17,084 ミリ秒でした。 この例ではスレッドが待機状態に移行しないため、仮想スレッドの処理が実行されているあいだキャリアはアンマウントされません。 他の仮想スレッドがアンマウントされたキャリアをマウントして OS スレッドを使いまわす ―― といった効率のよい使いかたができないため、仮想スレッドを利用する場合であっても実行速度が向上しません。

仮想スレッドを利用したほうが遅くなるのですね。 仮想スレッドをキャリアにマウントしたりアンマウントしたり……といった処理がオーバーヘッドになるのでしょうか? ご存じのかたいらっしゃったら教えてください。

フレームワークの対応

仮想スレッドは各種フレームワークからも利用可能です。 Spirng Framework は 6.1 から、Spring Boot は 3.2 から、それぞれ仮想スレッドをサポートします。 本資料は(作業工数の制約上) Java SE の内容に閉じた解説を行っているため、フレームワークについては各自ご参照ください。

Spring Framework:

Spring Boot:


1. シール「ド」クラスのようにも思えますが、Oracle 公式に合わせました。
2. Oracle 公式では「ダイ『ア』モンド演算子」と記載されています。Google トレンドで比較したところ「ダイ『ヤ』モンド演算子」が優勢のためこちらを採用しました。