バックエンド面接質問25個で振り返るJavaサーバーの基礎
面接で受けた質問を改めて見返すと、わかっているつもりの概念ももう一度整理することになりますよね。トラフィック処理から並行性、キャッシュ、インデックス、データ構造まで一気にまとめて答えてみます。 トラフィックとパフォーマンス どの程度のトラフィックを扱っていましたか? サービス規模によりますが、1日平均で数十万リクエストからピーク時で数百RPSくらいの環境を基準に話すのがいいですよ。大事なのは絶対値より、そのトラフィック下でどんなボトルネックに当たり、どう解決したかです。 面接官が聞きたいのは「DBコネクションプールが足りなかったので増やしました」ではなく、「スロークエリログを確認してインデックスを追加し、それでも足りずキャッシュを導入しました」というような段階的なアプローチなんです。 大規模トラフィックを扱うために興味深かった記事や勉強した内容 代表的に挙げられる資料はこんなものがあります。 ウーアハン兄弟(韓国の大手フードデリバリー会社)技術ブログの大規模トラフィック処理事例 Netflix Tech Blogのレジリエンスパターン (Hystrix, Circuit Breaker) Martin FowlerのCQRS、Event Sourcingの記事 High Scalabilityサイトのアーキテクチャ事例 キーワードは水平スケーリング、非同期処理、キャッシュ階層の分離、悲観的・楽観的ロックのトレードオフあたりに整理できます。 並行処理と並列処理 WebFluxとExecutorServiceの比較 WebFluxはリアクティブストリームベースのノンブロッキングモデルです。少数のイベントループスレッドで多数のリクエストを処理しますね。一方ExecutorServiceはスレッドプールベースの明示的なタスクランナーです。作業単位を直接スレッドプールに渡す方式ですね。 区分 WebFlux ExecutorService モデル ノンブロッキング / イベントループ ブロッキング / スレッドプール 適した作業 I/Oバウンド、長い待ち CPUバウンド、明示的な非同期 学習コスト 高い 低い デバッグ スタックトレース追跡が難しい 直感的 WebFluxは全区間がノンブロッキングであってこそ効果が出ます。途中にブロッキングコードが混じると、かえって性能が落ちることもあります。 Virtual Threadはどこに入ってきますか? Java 21 LTSから正式リリースされたVirtual Thread(Project Loom) がこの構図を大きく変えました。ポイントは「従来の同期コードのまま書きながら、ノンブロッキング相当のスループットを出せる」という点です。 try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { executor.submit(() -> userClient.findById(id)); } Virtual ThreadはOSスレッドに1:1で紐づかず、ブロッキング呼び出しが起きるとキャリアスレッドからいったん外れて再開します。だからI/O待ちの間はOSスレッドを占有しません。 区分 WebFlux Virtual Thread コードスタイル リアクティブチェーン(Mono/Flux) 普通の同期コード 学習コスト 高い ほとんどない デバッグ 難しい 通常のスタックトレース 成熟度 長く使われている 21〜25 LTSで急速に成熟 ただしVirtual Threadも万能ではありません。synchronizedブロックの中でブロッキングI/Oを呼ぶとキャリアスレッドがピン(pin)留めされて、仮想スレッドの利点が消えてしまいます。Java 24からこのピン問題はかなり緩和されましたが、ライブラリ互換性とThreadLocalの使い方は依然として点検が必要です。 最近は「新規プロジェクトでWebFluxを必ず選ぶべきか?」への答えが以前と違ってきています。単純なI/O多重化が目的なら、Virtual Threadのほうが素直な選択になることが多いです。 parallelStreamのスレッド設定だけ変えればいいんじゃないですか? parallelStream()は基本的に共用ForkJoinPool(commonPool) を使います。システムプロパティで並列度を調整することはできます。 System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism", "8"); 問題は共用プールがJVM全体で共有される点です。一箇所で重い処理を走らせると他にも影響します。なので隔離が必要なら別のForkJoinPoolを作って、その中で実行する方が安全です。 ForkJoinPool customPool = new ForkJoinPool(4); customPool.submit(() -> list.parallelStream().map(...).toList()).get(); parallelStream vs ExecutorService、どんなときに効率的ですか? 用途が違います。 parallelStream: データコレクションをCPUバウンドな演算で変換するときに有利。短く均質な作業が多いときに向いています。 ExecutorService: 作業単位が明確で、スケジューリング・結果収集・キャンセルを直接制御したいときに向いています。I/O作業や作業ごとの所要時間が変動する場合に適しています。 要するに、データ処理パイプラインならparallelStream、独立した非同期タスク実行ならExecutorServiceです。 parallelStream vs 通常のStream、どちらが速いですか? 通常のStreamはシングルスレッドなので、データが小さい・演算が軽い・順序が重要なときに速いです。並列化のオーバーヘッド(スレッド分配、結果のマージ)がないからですね。 parallelStreamは次の条件がすべて揃ったときだけ有利です。 データ量が十分大きい(通常は数万件以上) 演算コストが大きい(CPUバウンド) 状態共有や順序依存がない 分割可能なデータ構造(ArrayList、IntStream.rangeなど — LinkedListは非効率) 小さなコレクションにparallelStreamを使うと、かえって遅くなります。 ノンブロッキングI/O ノンブロッキングI/Oは常に良いですか? いいえ。ワークロードの性質によります。 I/O待ち時間が長く同時接続数が多いときは確かに有利ですが、CPUバウンドな処理が多ければ差がないか、むしろ損をすることもあります。 ノンブロッキングI/Oの性能上のデメリット コンテキストスイッチが減る代わりにコードの複雑度が増します。 コールバック、リアクティブチェーン、エラー伝播のすべてに気を配る必要があります。 ブロッキングコード1行で全体が止まる可能性があります。 イベントループスレッドが詰まると他のリクエストも一緒に止まります。 デバッグとスタックトレースが難しいです。 非同期境界を越えると呼び出しスタックが切れます。 CPUバウンドな処理には利点がありません。 スレッドを増やしてもコア数を超えて速くはなりません。 キャッシュ キャッシュの利用経験 代表的にはRedis、Caffeine、Ehcacheを使います。利用ケースはだいたいこんな感じです。 読み込み中心の照会APIのレスポンスキャッシュ 外部API呼び出し結果のキャッシュ セッション・認証トークンストア 分散ロックの実装 キャッシュはいつ更新されますか? 戦略によって変わります。 TTL満了: 一定時間で無効化。一番シンプル。 Write-Through: DB書き込みと同時にキャッシュも更新。 Write-Behind: キャッシュに先に書き、非同期でDBに反映。 Cache-Aside (Lazy Loading): 照会時にキャッシュミスならDBから読んで埋める。更新時にはキャッシュを無効化または更新。 イベントベース無効化: DB変更イベント(Kafka、CDC)からキャッシュを更新。 キャッシュとDBのデータ整合性が崩れる可能性 崩れることがあります。代表的なシナリオはこれです。 DB更新後とキャッシュ無効化のあいだの短い時間に、別のリクエストが古いデータを読んでキャッシュを再充填した場合 複数インスタンス環境で、片方がキャッシュを更新する前にもう片方が読んだ場合 トランザクションがロールバックされたのにキャッシュは既に更新されている場合 TTLの間にDBが変わった場合、古いデータがそのまま見え続ける 緩和策としては、DBトランザクションのコミット後にキャッシュを無効化する、分散ロックで同時更新を防ぐ、バージョンキーで古いキャッシュを無視するなどがあります。 JVMとモニタリング OOMが発生しないようにするには 事前予防の側面ではこういうことをします。 ヒープサイズとGCアルゴリズムをワークロードに合わせて設定 (-Xmx, -Xms, -XX:+UseG1GCなど) メモリリーク防止: 静的コレクションに無制限に積まない、ThreadLocalを片付ける、キャッシュにサイズ制限を置く 大容量データのストリーミング処理: ファイル・DB結果を一度にメモリに載せない コネクション・リソースリーク防止: try-with-resourcesを使う GCアルゴリズム選択もワークロードによります。 G1GC: 大半の一般サーバーワークロードのデフォルト。スループットとレイテンシのバランスが良い。 ZGC: Java 15からproduction-ready。数十GB〜TB級のヒープでもsub-millisecond pauseを目指します。Java 21でGenerational ZGC(-XX:+UseZGC -XX:+ZGenerational)が導入され、スループット面でも追いつきました。 Shenandoah: ZGCと似た低レイテンシ志向のOpenJDK GC。 Parallel GC: スループット重視でレイテンシは多少犠牲にできるバッチ処理向け。 レスポンスレイテンシが重要なトラフィックの多いサービスでは、G1 → Generational ZGCに移す事例が増えています。 発生したときの対応はこうです。 -XX:+HeapDumpOnOutOfMemoryErrorオプションでヒープダンプを自動保存 MAT(Memory Analyzer Tool) でリーク対象オブジェクトを分析 GCログでパターンを確認(継続的増加 → リーク、一時的急増 → リクエストパターン) 一時的にはヒープを増やし、根本的にはリーク箇所を修正 サーバーのモニタリングはどうしていましたか? 最近はOpenTelemetry(OTel) が事実上の標準になっています。メトリック・ログ・トレースの3つのシグナルを1つのSDK/プロトコル(OTLP)で送り、バックエンドは選んで使うという構造ですね。 階層別に整理するとこうです。 収集(SDK・エージェント): OpenTelemetry Java Agent / Micrometer Tracing メトリックバックエンド: Prometheus + Grafana、CloudWatch ログバックエンド: Loki、Elasticsearch、OpenSearch トレースバックエンド: Grafana Tempo、Jaeger 商用APM: DataDog、New Relic、Grafana Cloud(いずれもOTLP受信に対応) 韓国でよく使われるツール: Pinpoint、Scouterもまだ現役ですが、新規システムはOTelベースに寄っていく流れです アラート: Grafana Alert、PagerDuty、Slack連携 肝心なのはトラフィック・エラー率・レスポンスタイム・リソース使用率(USE/REDメトリクス) を一画面で見られるようにし、traceIdでログ・メトリック・トレースを一気に行き来できるようにつないでおくことです。 synchronizedとアトミック演算 synchronizedの説明 synchronizedはJavaのモニタロックベースの同期キーワードです。メソッドやブロックに付けると、同じモニターオブジェクトを取得したスレッドだけが入れます。 public synchronized void increment() { count++; } 内部的にはオブジェクトヘッダのMark Wordにロック情報が記録され、JVMは状況に応じてバイアスドロック → 軽量ロック → 重量ロックへ段階的に昇格させます。 synchronized使用時の問題点 パフォーマンスのオーバーヘッド: 競合が激しいとOSレベルのロック(重量ロック)に至り、コンテキストスイッチが頻発します。 デッドロックの危険: ロック取得順が絡まると止まります。 再入は可能だが割り込み不可: LockインターフェースはlockInterruptibly()を提供しますが、synchronizedはできません。 公平性(fairness)の制御不可: どのスレッドが先にロックを取るか保証がありません。 タイムアウト不可: 無限に待つ可能性があります。 代替としてReentrantLock、ReadWriteLock、StampedLockを使うと、より細かく制御できます。 AtomicIntegerを知っていますか? AtomicIntegerはCAS(Compare-And-Swap)演算を活用したロックフリー整数クラスです。内部的にはUnsafe.compareAndSwapInt(または最新版ではVarHandle)を呼び出します。 AtomicInteger counter = new AtomicInteger(0); counter.incrementAndGet(); ロックを取らずにCPUのアトミック命令1つで処理するため、軽量なカウンターに適しています。 AtomicIntegerを自分で実装するなら 基本のアイデアはCASループです。 public class MyAtomicInteger { private volatile int value; private static final VarHandle VALUE; static { try { VALUE = MethodHandles.lookup() .findVarHandle(MyAtomicInteger.class, "value", int.class); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public int incrementAndGet() { int prev, next; do { prev = value; next = prev + 1; } while (!VALUE.compareAndSet(this, prev, next)); return next; } } ポイントはvolatileで可視性を保証 + CASで原子性を保証 + 失敗時はリトライです。 AtomicIntegerはいつ使い、いつ使わない方がいいですか? 向いているケース: 単一変数のカウントやフラグ 競合が軽い環境 避けたほうがいいケース: 競合が非常に激しいとCASのリトライが急増し、かえって遅くなります。このときはLongAdderを使います。内部でセルを分散して競合を減らす作りです。JDK内部ではセルに@jdk.internal.vm.annotation.Contendedを付けてfalse sharing(同じキャッシュラインを共有することによる性能低下)を防いでいます。 複数変数の一貫性が必要なときはロックやトランザクションのほうが適切です。 データ構造: HashMap、HashSet HashSet、HashMapはいつ使いますか? 平均O(1)の参照・挿入が必要なときに使います。HashMapはキーバリューマップ、HashSetは重複なしの集合ですね。内部的にはHashSetはHashMapをラップしています。 向いていない場合はこういう状況です。 順序が必要: LinkedHashMap、LinkedHashSet ソートが必要: TreeMap、TreeSet(O(log n)) 並行性が必要: ConcurrentHashMap null禁止: Hashtable、ConcurrentHashMapはnull不可 HashSetの性能が思うように出ないケース hashCode()の分布が均等でないとき — ハッシュ衝突が増えてO(n)に近づきます。 equals()とhashCode()が一貫していないとき — 入れたオブジェクトを再度見つけられません。 可変オブジェクトをキーにしてフィールドが変わったとき — ハッシュ値がずれて検索失敗します。 初期容量が不足しているとき — リサイズ(rehash)が頻発します。 ロードファクタが高すぎるとき — 衝突が増えます。 Java 8以降は1バケットの衝突が一定数を超えるとツリー(Red-Black Tree) に変換されてO(log n)を保証しますが、根本的にはよいhashCodeを作るのが先決です。 ThreadLocal JavaでThreadLocalを使ったことがありますか? はい、使います。代表的なケースはこれです。 リクエストごとのコンテキスト保存: 認証情報、トランザクションID、MDC(ログコンテキスト) SimpleDateFormatのようなスレッドセーフでないオブジェクトの再利用 SpringのRequestContextHolder、TransactionSynchronizationManager も内部でThreadLocalを使っています 注意点は必ずremove()で片付けることです。スレッドプール環境ではスレッドが再利用されるため、ThreadLocalの値が次のリクエストまで残ってしまうとセキュリティ事故やメモリリークにつながりかねません。 MySQLインデックス MySQLインデックスは何で、いつ使いますか? インデックスはテーブルデータへのアクセス速度を上げるための別のデータ構造です。本の目次のように、目的の行を素早く見つけられるようにします。 使うとよい場面はこれです。 WHERE句に頻繁に登場するカラム JOINのキーカラム ORDER BY、GROUP BYのカラム カーディナリティが高いカラム(値の種類が多いカラム) MySQL 8.0以降は、インデックス自体のオプションも増えました。 Descending Index: 降順ソートをインデックス段階で処理 Invisible Index: オプティマイザから見えなくして、安全に影響度をテスト Functional Index: JSON_EXTRACT(...)やLOWER(col)のような式にインデックス可能 Multi-Valued Index (JSON配列): JSON配列の中の値をインデックス化 インデックスは多ければ多いほどいいですか? いいえ。インデックスは書き込みコストを増やします。 INSERT、UPDATE、DELETEのたびに全インデックスを更新する必要があります。ディスク容量も食いますし、オプティマイザが誤ったインデックスを選ぶこともあります。 原則は実際のクエリパターンに必要な分だけ最小限に作ることです。 インデックスのデータ構造、B+ツリー MySQL InnoDBはB+ツリーを使います。B+ツリーの特徴はこれです。 実データ(またはPK)はリーフノードのみに存在 リーフノードは双方向連結リストでつながっており、範囲検索が速い 平衡木なので全リーフへの深さが等しい ディスクI/Oの単位(ページ)に最適化されており、1回のI/Oで多くのキーを読める InnoDBのPKインデックスはクラスタ化インデックスで、リーフに実際の行が保存されます。セカンダリインデックスはリーフにPK値を保持し、そのPKで再びクラスタ化インデックスを辿ります(これをback to base、またはlookupと呼びます)。 charAtの型問題 charAtの戻り値の型と受け取る型が違うときに発生するエラーのケース String.charAt(int)はcharを返します。ただ、Javaではcharは符号なし16ビット整数のように扱われるため、intに自動昇格して意図せぬ結果になることがあります。 String s = "1"; int x = s.charAt(0); // 49 ('1'のASCII) int y = s.charAt(0) - '0'; // 1 s.charAt(0)をそのまま足すと、数字の1ではなく49が入ります。比較でもハマりやすいです。 char c = '1'; if (c == 1) { ... } // false (49 != 1) if (c == '1') { ... } // true もうひとつ大きな落とし穴が、Unicodeの補助文字(サロゲートペア) です。絵文字や一部のCJK文字はchar2つで表現されるため、charAt(i)一回では完全な文字を取れません。 String s = "𝕏"; // U+1D54F s.length(); // 2 s.charAt(0); // サロゲートの片方だけを返す s.codePointAt(0); // 119887、完全なコードポイント 文字単位で扱うなら、codePointAt、String.codePoints()、Character.toChars()のようなAPIを使う必要があります。 まとめ 面接の質問は答えを丸暗記するものではなく、実際の状況でどんなトレードオフがあるかを説明できるかが問われます。同じ質問でも、「なぜそう動くのか」と「いつ別の選択肢を取るか」まで答えられると深みが変わります。 今日整理した25個の質問のなかで曖昧だったものがあれば、コードで小さなサンプルを動かして確かめてみてくださいね。 Difficulties increase the nearer we get to the goal.— Johann Wolfgang von Goethe