1. 概要

Scalaは、変数の初期化を延期する lazyvalと呼ばれる優れた言語機能を提供します。 遅延初期化パターンは、Javaプログラムでは一般的です。

魅力的なように見えますが、lazy valの具体的な実装にはいくつかの微妙な問題があります。このクイックチュートリアルでは、基礎となるバイトコードを分析して lazyval機能を調べます。

2. lazy val はどのように機能しますか?

vallazyとして指定するには、変数宣言の前にlazyキーワードを追加するだけです。

lazy val foo = {
  println("Initialized")
  1
}
foo: Int = <lazy>

コンパイラは、 lazyvalのバインドされた式をすぐには評価しません。 最初のアクセスでのみ変数を評価します。最初のアクセス時に、コンパイラは式を評価し、結果を lazyvalに格納します。 後の段階でこの値にアクセスするたびに、実行は行われず、コンパイラは結果を返します。

このプログラムを実行したときに得られる出力を見てみましょう。

Initialized
res0: Int = 1

3. lazy valのデコード

次に、 lazyvalの内部で何が起こっているのかを調べてみましょう。

まず、Personクラス内で単一のlazyvalを宣言します。

class Person {
  lazy val age = 27
}

Person.scalaファイルをコンパイルすると、Person.classファイルが取得されます。 このクラスファイルは、任意のJavaデコンパイラを使用して逆コンパイルできます。 このクラスファイルを逆コンパイルすると、すべてのレイジーvalに対して生成される同等のJavaコードを取得します。

public class Person {
    private int age;
    private volatile boolean bitmap$0;
    
    private int age$lzycompute() {
        synchronized (this) {
            if (!this.bitmap$0) {
                this.age = 27;
                this.bitmap$0 = true;
            }
        }
        return this.age;
    }
    
    public int age() {
        return this.bitmap$0 ? this.age : this.age$lzycompute();
    }
}

私たちが何から始めたかを考えると、これはかなり多くのコードです!

7行目で、コンパイラは同期された(これ){…}モニターを導入します。 これにより、変数が1回だけ初期化されることが保証されます。 コンパイラはブールフラグを導入しますbitmap$0は初期化ステータスを追跡するためのものです。 この変数は、コンパイラが lazyvalに初めてアクセスするときに変更されます。

4. lazy valsのパフォーマンスのボトルネック

4.1. lazy valsにアクセスする際の潜在的なデッドロック

複数のスレッドからインスタンス内の複数の値にアクセスすると、常にデッドロックが発生する可能性があります。

object FirstObj {
  lazy val initialState = 42
  lazy val start = SecondObj.initialState
}

object SecondObj {
  lazy val initialState = FirstObj.initialState
}

object Deadlock extends App {
  def run = {
    val result = Future.sequence(Seq(
      Future {
        FirstObj.start
      },
      Future {
        SecondObj.initialState
      }
    ))
    Await.result(result, 10.second)
  }
  run
}

上記のコードを実行すると、スタックstraceが取得されます。

Exception in thread "main" java.util.concurrent.TimeoutException: Future timed out after [10 seconds]
	at scala.concurrent.impl.Promise$DefaultPromise.tryAwait0(Promise.scala:212)
	at scala.concurrent.impl.Promise$DefaultPromise.result(Promise.scala:225)
	at scala.concurrent.Await$.$anonfun$result$1(package.scala:201)
	at scala.concurrent.BlockContext$DefaultBlockContext$.blockOn(BlockContext.scala:62)

18行目で、FutureFirstObjを初期化し、FirstObjのインスタンスは内部でSecondObjを初期化しようとします。 また、21行目の Future は、SecondObjを初期化しようとします。 これにより、デッドロック状態が発生する可能性があります。

4.2. オブジェクト内のlazy valsの順次評価

オブジェクト内でlazyvals を宣言し、それらに同時にアクセスしてみましょう。 このような場合、遅延値は順番に実行されます。

object LazyValStore {
  lazy val squareOf5 = println(square(5))
  lazy val squareOf6 = println(square(6))

  def square(n: Int): Int = n * n
}

object SequentialLazyVals extends App {
  def run = {
    val result = Future.sequence(Seq(
      Future {
        LazyValStore.squareOf5
      },
      Future {
        LazyValStore.squareOf6
      }
    ))
    Await.result(result, 15.second)
  }

  run
}

コンパイラーは、すべての lazyvalにモニターを導入します。 このため、コンパイラは初期化中にインスタンス全体をロックします。 複数のスレッドがインスタンスにアクセスしようとすると、スレッドはすべての lazyvalsが初期化されるまで待機する必要があります。

5. 結論

このチュートリアルでは、Scalaの lazyvalの落とし穴を探りました。 vallazyval に置き換えることで、コードを最適化したいと考えています。 ただし、 lazyvalがテーブルにもたらす影響を完全に認識している必要があります。

いつものように、完全なコードはGitHubから入手できます。