Spring Bootでprototypeスコープを使う方法あれこれ&ベンチマーク

今更感はありますが…。

Spring Boot(というよりSpring)でインジェクションされるコンポーネントのデフォルトのスコープはSingletonになります。
そのため、Singletonスコープのコンポーネントに対して、Singletonより短いスコープであるPrototypeコンポーネントをインジェクションしようと思った場合、@Scope("prototype") をつけてコンポーネントを定義してインジェクションさせるだけではうまくいきません。

prototypeスコープを使うには、いくつか方法があります。今回は4つの方法を試してみました。

なお、以下の本エントリでは、動作の説明のため、カウンタコンポーネントを利用します。

方法1: proxyMode = ScopedProxyMode.TARGET_CLASS を指定する

カウンタの定義。proxyMode を指定しているところがポイント。

@Component
@Scope(value = "prototype", proxyMode = ScopedProxyMode.TARGET_CLASS)
public class Counter1 {

    private int count;

    public int increment() {
        return count++;
    }
}

ロジックそのものは説明するまでもないですが、同じインスタンスに対して increment メソッドを呼ぶと、そのたびに、戻り値が0, 1, 2, …と1ずつ増えていくものです。

利用例(spring-boot-starter-webを使ったWebアプリを想定しています)

@RestController
public class Counter1Controller {

    @Autowired
    Counter1 counter1;

    @RequestMapping("counter1")
    public int counter1() {
        return counter1.increment();
    }
}

8080ポートでアプリを起動して、 http://localhost:8080/counter1 にアクセスすると、何度ブラウザをリロードしても、常に0が表示されます。 このことから、毎回新しいCounter1のインスタンスが参照されていることが分かります。

方法2: ApplicationContextからgetBeanで取得する

カウンタ。

@Component
@Scope(value = "prototype")
public class Counter2 {

    private int count;

    public int increment() {
        return count++;
    }
}

利用例。Counter2を直接@Autowiredでインジェクションせずに、 ApplicationContext#getBean で取得します。

@RestController
public class Counter2Controller {

    @Autowired
    ApplicationContext context;

    @RequestMapping("counter2")
    public int counter2() {
        Counter2 counter = context.getBean(Counter2.class);
        return counter.increment();
    }
}

アプリの動きは方法1と同じです。

方法3: @Lookupでnullを返す

カウンタ(方法2とまったく同じ)

@Component
@Scope(value = "prototype")
public class Counter3 {

    private int count;

    public int increment() {
        return count++;
    }
}

この方法の場合、新しくLookup用のサービスクラスを作ります。

@Component
public class Counter3Lookup {

    @Lookup
    public Counter3 lookup() {
        return null;
    }
}

Lookupメソッド経由で取得します。

@RestController
public class Counter3Controller {

    @Autowired
    Counter3Lookup lookup;

    @RequestMapping("counter3")
    public int counter3() {
        Counter3 counter = lookup.lookup();
        return counter.increment();
    }
}

方法4: JSR 330のjavax.inject.Providerを使う

ちょっと蛇足的ですが、一部Java EEAPIが利用できるので、それを利用します。 カウンタは方法2、方法3と同様。

@Component
@Scope(value = "prototype")
public class Counter4 {

    private int count;

    public int increment() {
        return count++;
    }
}

使う側は、 javax.inject.Provider#get メソッド経由で取得します。

@RestController
public class Counter4Controller {

    @Autowired
    Provider<Counter4> counterProvider;

    @RequestMapping("counter4")
    public int counter4() {
        Counter4 counter = counterProvider.get();
        return counter.increment();
    }
}

性能を比較

今回の記事のメイン。それぞれの方法について、実行時間的にどれくらいの差が現れるのかを簡単に計測してみました。

計測の条件をなるべく揃えるために、以下のように、方法1〜方法4までのカウンタを取得するためのコンポーネントを1枚かませてみます。

@Component
public class CounterProvider {

    @Autowired
    Counter1 counter1;

    @Autowired
    ApplicationContext context;

    @Autowired
    Counter3Lookup lookup;

    @Autowired
    Provider<Counter4> counterProvider;

    public Counter1 getCounter1() {
        return counter1;
    }

    public Counter2 getCounter2() {
        return context.getBean(Counter2.class);
    }

    public Counter3 getCounter3() {
        return lookup.counter3();
    }

    public Counter4 getCounter4() {
        return counterProvider.get();
    }
}

その上で、コンポーネントの取得&incrementメソッドをの呼び出しのセットを1,000,000呼び出すのに、どれくらいの時間がかかるのかを測ってみました。 以下のメソッドは方法1の場合ですが、他の方法についても同様に実施します。

    public int benchmarkCounter1() {
        long start = System.currentTimeMillis();

        int sum = 0;
        for (int i = 0; i < LIMIT; i++) {
            Counter1 c = counterProvider.getCounter1();
            sum += c.increment();
        }

        log.info("sum = {}, total = {} ms", sum, (System.currentTimeMillis() - start)); // sum はどれも 0

        return sum;
    }

結果

方法 実行時間
1: @ScopeproxyMode = ScopedProxyMode.TARGET_CLASS をつける 4,430ms
2: ApplicationContext#getBean 4,105ms
3: @Lookup 4,284ms
4: Provider#get 7,908ms

方法4だけが突出して遅いですね。
何度か試しても、この傾向は同じでした。

それ以外の方法はどれも大差なく proxyMode を使っても、顕著に遅くなる、ということはないようです。

まとめ

今回実行時間を比較してみることにした動機は、 方法1の場合に極端に処理が遅くなることがない、という裏付けをとることだったので、確認できてよかったです。

Spring Bootのアプリを作る上で、極力プロトタイプスコープのコンポーネントが必要ないようにはしているものの、どうしても必要な時は、1を使う、という方針でいこうかなーと思いました。