Java: parallelStream を使ったアプリが固まるように……原因は何だろう?

週末に、以下のような質問 (問題?) を受けた。

Java 8 で作った Web アプリがある。parallelStream を使って重い処理を並列で行えるよう実装していたが、起動してからしばらくすると (しばらくリクエストを受け付けると) アプリが固まることがわかった。アクセスしても応答が返されなくなり、うんともすんとも言わない状況。原因は何か。なお、parallelStream で並列処理している部分では、DB アクセスを行っている。MyBatis を使ってクエリを投げており、その部分は独立したクラス (以下、DAO) として実装し、Spring で DI している。
原因が不明であるが、試しに parallelStream を使わないように変更したところ、固まる現象は発生しなくなった。

メモリを使いすぎて Linux の OOM Killer に殺されている?

Linux には、空きメモリが少なくなると起動中のプロセスから 1 つ選んで強制終了させるという機能があり、これを OOM Killer と呼ぶ。僕の経験で例を挙げると、Jenkins 上でテストを動かす時にメモリをたっぷり割り当てすぎたら、TestRunner や Jenkins 自体のプロセスが OOM Killer に殺された、ということがあった。
しかし今回の場合は、アプリを動かしている Tomcat のプロセス自体は残っているということだったので、これはなさそう。また、/var/log/messages にも何も残っていないということだった。OOM Killer がプロセスを殺した時には /var/log/messages にメッセージが出るので、その点でも OOM Killer ではなさそうである。

スレッド間でオブジェクトが同じように見えていない?

あるスレッドで行った変更が他のスレッドでも同じように見えるとは限らない。これは、メモリ変更操作のリオーダー (順序入れ替え) が行われることと、マルチコアマシンではあるコアと別のコアから見たメモリ状態が同じとは限らない (同期されない) ことによる。
Java の場合、synchronized ブロックの出入りや volatile 変数の読み書きなど、いくつかのケースで同期が行われることになっている。そうでない場合は、あるスレッドでは初期化完了して値を入れたはずの ArrayList が、別のスレッドからは空に見えるとか、そもそも ArrayList の内部が不整合に見えるとかいったことが起こる。
parallelStream がどうだったか確認していないが、DAO の状態が正しく見えていなくて、そのせいで処理が固まっている可能性はないだろうか。
……とはいうものの、今回の場合、アプリが起動してからしばらくしてから起こるようなので、自分から言っておいて何だけれど、可能性としてはまずないだろう……。

自分が Java で実装した部分でデッドロック?

Java にはスレッド間で同期を行う機能があり、順序が正しくない場合は普通にデッドロックが起きる。Oracleデッドロックが起きるとエラーにしてくれるが、Java はそうではないので、単純に Java のロジック内でデッドロックが起きていれば、Tomcat が無応答になっても当たり前ではある。
もしこれなら、現象が起きたときに jstack コマンドでスタックトレースを採取すれば、そのものずばり止まっている箇所が特定できるだろう。
ただ、今回の場合は実際にどう実装されているかわからないので、これは一旦置いておき、他の可能性を考えてみよう。

コネクションプールにコネクションを返していない?

MyBatis や Hibernate などで ORM する場合、スレッドごとに「セッション」を作成してその中で DB アクセスを行う。
ちょっと雑だが、以下のようなコードがあったとしよう。

@Autowired
private LoginService loginService;

@Autowired
private UserDao userDao;

@Transactional
public void login(String id) {
    User user = userDao.find(id);
    loginService.login(user);
}

MyBatis がどうだったのか忘れてしまったので Hibernate で説明すると、@Transactional の付いたメソッドの中で最初に DB アクセスが行われたとき (ここでは userDao#find が実行されたとき) に、コネクションプールからコネクションが 1 つ取得され、スレッド (ごとに作成されたセッション) にひも付けられる。
そして、@Transactional を付けた login メソッド内で行われた変更はメモリ上にのみ保持されていて、最終的にこの login メソッドを抜ける時点で、それまでに行われた変更から SQL の UPDATE 文が生成され、発行され、そしてコミットされる。また、コネクションもプールに戻される。
厳密には、@Transactional とセッション境界は別にすることもできたり、INSERT や SELECT FOR UPDATE はその場で SQL が発行されたりと、色々なケースがあるが、ここでは割愛する。
さて、ここで login メソッドから @Transactional アノテーションを外してみよう。

@Autowired
private LoginService loginService;

@Autowired
private UserDao userDao;

public void login(String id) {
    User user = userDao.find(id);
    loginService.login(user);
}

この場合も、userDao#find が実行された時点でコネクションプールからコネクションが取得され、スレッドにひも付けられることは変わらない。問題は、それをプールに返されるタイミングがないことだ。この場合、スレッドとコネクションのひも付けはそのままになり、コネクションはプールに返されないままになる。たまたま同じスレッドでもう 1 度 SELECT するだけだと、コネクションが再利用「できてしまう」ので、常に同じくらいのアクセスがあり続ける状況下だと、最初は問題なく動くかもしれず、気づきづらい。
コネクションプールの実装によっては、スレッドが終了して GC されたときに検知してコネクションを取り返す機能があるかもしれないが、そういう機能がなかったら。あるいは、スレッドが終了しなかったら。

@Autowired
private LoginService loginService;

@Autowired
private UserDao userDao;

@Transactional
public void login(String id) {
    User user = userDao.find(id);
    loginService.login(user);
    user.friends().paralellStream().forEach(loginService::notifyLogin);
}

parallelStream は裏側でスレッドプールを使って処理を行う。余談だが、このスレッドプールは毎回新規に作られるわけではなく、JVM 上にひとつだけ存在する共有のスレッドプール (java.util.concurrent.ForkJoinPool.commonPool() で返されるもの) が使われるそうだ。
さて上記のコード例で、LoginService#notifyLogin() で DB アクセスを行っていて、@Transactional が付いていなかったとしたら? 先ほど説明したとおり、コネクションがスレッドにひも付けられるが、そのコネクションはプールに返されないままになる。
共有のスレッドプールがどうなっているか、そしてコネクションプールがスレッドの GC を検知できるかにもよる (そして僕は今どちらも確認していない) が、コネクションがスレッドにひも付いたままになる→スレッドがスレッドプールから解放され GC される→parallelStream が呼ばれて再度スレッドが作られる→コネクションプールからさらにコネクションが持ち出される……というループが起きると、コネクションプールに設定されているコネクション数上限に到達し、それ以上コネクションが取得できなくなる。
この場合にどうなるかはコネクションプールの設定によるが、タイムアウトが設定されていないとすると、新たにコネクションを取得しようとしたスレッドはすべて待機させられることになり、無応答の原因になる。
このケースも、jstack で見ればコネクションプールからコネクションを取得 (borrow) しようとしているところで待たされていることが一目瞭然だが、netstatRDBMS ごとの管理ツールなどを使って「いま Tomcat と DB の間にいくつコネクションが張られているか」を確認できると、さらにはっきりするだろう。

飽きた

寝ます。