パフォーマンスの深い理解
パフォーマンスの深い理解
Section titled “パフォーマンスの深い理解”シニアエンジニアとして、パフォーマンスの問題を解決するには、表面的な最適化ではなく、システムの根本的な動作を理解する必要があります。この章では、Javaアプリケーションのパフォーマンスを深く理解するための知識を解説します。
JVMのメモリ管理の深い理解
Section titled “JVMのメモリ管理の深い理解”ガベージコレクションの動作原理
Section titled “ガベージコレクションの動作原理”ヒープメモリの構造:
┌─────────────────────────────────┐│ Young Generation ││ ┌──────────┐ ┌──────────┐ ││ │ Eden │ │ Survivor│ ││ │ │ │ (S0/S1) │ ││ └──────────┘ └──────────┘ │├─────────────────────────────────┤│ Old Generation ││ ┌──────────────────────────┐ ││ │ │ ││ └──────────────────────────┘ │└─────────────────────────────────┘GCの動作を理解する:
// オブジェクトのライフサイクルpublic class ObjectLifecycle {
public void demonstrateLifecycle() { // 1. オブジェクトはEden領域に作成される User user1 = new User("Alice");
// 2. Minor GCが発生すると、生きているオブジェクトはSurvivor領域へ // 3. 複数回のMinor GCを生き延びたオブジェクトはOld Generationへ
// 4. Old GenerationがいっぱいになるとMajor GC(Full GC)が発生 // - アプリケーションが一時停止(Stop-the-World) // - パフォーマンスに大きな影響
// 問題のあるコード: 大量のオブジェクトを短期間で作成 for (int i = 0; i < 1000000; i++) { User user = new User("User" + i); // 毎回新しいオブジェクト processUser(user); } // 結果: Minor GCが頻繁に発生し、パフォーマンスが低下 }
// 最適化: オブジェクトの再利用 public void optimizedLifecycle() { User reusableUser = new User(); for (int i = 0; i < 1000000; i++) { reusableUser.setName("User" + i); // 同じオブジェクトを再利用 processUser(reusableUser); } // 結果: オブジェクト作成が減り、GCの頻度が低下 }}GCアルゴリズムの選択
Section titled “GCアルゴリズムの選択”G1GCの動作理解:
// G1GCの設定例// -XX:+UseG1GC// -XX:MaxGCPauseMillis=200 // 目標: 200ms以内にGCを完了// -XX:G1HeapRegionSize=16m // リージョンサイズ
// G1GCが適している場合:// 1. ヒープサイズが大きい(4GB以上)// 2. 低レイテンシが重要// 3. ヒープの一部が空いている
// G1GCの動作:// 1. ヒープを複数のリージョンに分割// 2. 各リージョンはEden、Survivor、Oldのいずれか// 3. 最もゴミの多いリージョンから回収(Garbage First)ZGCの動作理解(Java 11+):
// ZGCの設定例// -XX:+UseZGC// -XX:+UnlockExperimentalVMOptions // Java 11-14で必要
// ZGCが適している場合:// 1. 非常に大きなヒープ(8GB以上)// 2. 超低レイテンシが必要(< 10ms)// 3. スループットよりもレイテンシを優先
// ZGCの特徴:// 1. 並行GC(アプリケーションの停止時間が非常に短い)// 2. マルチマッピング技術を使用// 3. レイテンシがヒープサイズに依存しないGC選択の意思決定:
| GC | ヒープサイズ | レイテンシ目標 | スループット | 適用範囲 |
|---|---|---|---|---|
| Serial GC | < 100MB | 低 | 高 | シングルコア、小規模 |
| Parallel GC | < 8GB | 中 | 非常に高 | バッチ処理 |
| G1GC | 4GB - 32GB | 低 | 高 | 一般的なWebアプリ |
| ZGC | 8GB+ | 非常に低 | 中 | 低レイテンシが必要 |
データベースアクセスの最適化
Section titled “データベースアクセスの最適化”N+1問題の根本的な理解
Section titled “N+1問題の根本的な理解”問題の本質:
// N+1問題の発生メカニズム@Servicepublic class OrderService {
// 問題のあるコード public List<OrderDTO> getOrdersWithItems(Long customerId) { // 1回のクエリ: 注文を取得 List<Order> orders = orderRepository.findByCustomerId(customerId); // 結果: 10件の注文
List<OrderDTO> dtos = new ArrayList<>(); for (Order order : orders) { // N回のクエリ: 各注文のアイテムを取得 // 問題: 10件の注文に対して10回のクエリが実行される List<OrderItem> items = orderItemRepository.findByOrderId(order.getId());
dtos.add(convertToDTO(order, items)); } // 合計: 1 + 10 = 11回のクエリ(N+1問題) }}解決方法の深い理解:
// 解決方法1: JOIN FETCH(最も効率的)@Query("SELECT DISTINCT o FROM Order o " + "LEFT JOIN FETCH o.items " + "WHERE o.customerId = :customerId")List<Order> findByCustomerIdWithItems(@Param("customerId") Long customerId);
// 1回のクエリで注文とアイテムを取得// SQL: SELECT o.*, i.* FROM orders o// LEFT JOIN order_items i ON o.id = i.order_id// WHERE o.customer_id = ?
// 解決方法2: Entity Graph@Entity@NamedEntityGraph( name = "Order.withItems", attributeNodes = @NamedAttributeNode("items"))public class Order { // ...}
// 使用@EntityGraph("Order.withItems")List<Order> findByCustomerId(Long customerId);
// 解決方法3: バッチフェッチ(複数の親エンティティがある場合)@Entitypublic class Order { @OneToMany(fetch = FetchType.LAZY) @BatchSize(size = 10) // 10件ずつバッチで取得 private List<OrderItem> items;}
// 動作:// 1. 注文を10件取得// 2. 10件の注文IDで1回のクエリでアイテムを取得// 合計: 2回のクエリ(1 + 1 = 2)クエリ最適化の深い理解
Section titled “クエリ最適化の深い理解”インデックスの効果を理解する:
// インデックスなし: フルテーブルスキャン// SELECT * FROM users WHERE email = 'alice@example.com'// 実行時間: O(n) - テーブル全体をスキャン
// インデックスあり: インデックススキャン// CREATE INDEX idx_users_email ON users(email);// 実行時間: O(log n) - B-Treeインデックスを使用
// 複合インデックスの最適化@Entity@Table(indexes = { @Index(name = "idx_user_status_created", columnList = "status, created_at")})public class Order { private OrderStatus status; private LocalDateTime createdAt;}
// クエリ1: インデックスが使用される// WHERE status = 'PENDING' AND created_at > '2024-01-01'// → インデックスが使用される
// クエリ2: インデックスが使用されない// WHERE created_at > '2024-01-01'// → インデックスの最初のカラム(status)が条件にないため使用されない
// 最適化: クエリパターンに合わせてインデックスを設計@Table(indexes = { @Index(name = "idx_user_status_created", columnList = "status, created_at"), @Index(name = "idx_user_created", columnList = "created_at") // 追加})クエリプランの理解:
spring.jpa.properties.hibernate.format_sql=truespring.jpa.properties.hibernate.use_sql_comments=true
// クエリプランを確認@Repositorypublic class OrderRepository {
@Query(value = "EXPLAIN SELECT * FROM orders WHERE customer_id = ?", nativeQuery = true) String explainQuery(Long customerId);
// 結果の例: // Seq Scan on orders (cost=0.00..1000.00 rows=1000 width=100) // Filter: (customer_id = 123) // → フルテーブルスキャン(非効率)
// インデックス追加後: // Index Scan using idx_orders_customer_id on orders // (cost=0.00..10.00 rows=1000 width=100) // Index Cond: (customer_id = 123) // → インデックススキャン(効率的)}キャッシングの深い理解
Section titled “キャッシングの深い理解”キャッシュの階層と戦略
Section titled “キャッシュの階層と戦略”マルチレベルキャッシング:
// L1: アプリケーションレベル(ローカルキャッシュ)@Servicepublic class UserService { // Caffeine: 高速なローカルキャッシュ private final Cache<Long, User> localCache = Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(5, TimeUnit.MINUTES) .recordStats() // 統計情報を記録 .build();
// L2: 分散キャッシュ(Redis) @Autowired private RedisTemplate<String, User> redisTemplate;
public User findById(Long id) { // 1. L1キャッシュから取得 User cached = localCache.getIfPresent(id); if (cached != null) { return cached; }
// 2. L2キャッシュから取得 String key = "user:" + id; cached = redisTemplate.opsForValue().get(key); if (cached != null) { localCache.put(id, cached); // L1にも保存 return cached; }
// 3. データベースから取得 User user = userRepository.findById(id) .orElseThrow(() -> new UserNotFoundException(id));
// 両方のキャッシュに保存 localCache.put(id, user); redisTemplate.opsForValue().set(key, user, 10, TimeUnit.MINUTES);
return user; }}キャッシュの無効化戦略:
// 問題: キャッシュの無効化が複雑@Servicepublic class UserService {
// 問題のあるコード: キャッシュの無効化が不完全 @CacheEvict(value = "users", key = "#id") public void updateUser(Long id, UserUpdateRequest request) { userRepository.save(convertToEntity(request)); // 問題: 関連するキャッシュが無効化されない // 例: ユーザーリストのキャッシュが古いまま }
// 解決: キャッシュキーの設計を改善 @CacheEvict(value = "users", allEntries = true) // すべてのエントリを無効化 public void updateUser(Long id, UserUpdateRequest request) { userRepository.save(convertToEntity(request)); }
// より良い解決: イベントベースの無効化 @Transactional public void updateUser(Long id, UserUpdateRequest request) { User user = userRepository.save(convertToEntity(request));
// イベントを発行 eventPublisher.publishEvent(new UserUpdatedEvent(user.getId())); }
@EventListener public void handleUserUpdated(UserUpdatedEvent event) { // 関連するすべてのキャッシュを無効化 cacheManager.evict("users", "user:" + event.getUserId()); cacheManager.evict("users", "userList"); cacheManager.evict("users", "userStats"); }}並行処理のパフォーマンス
Section titled “並行処理のパフォーマンス”スレッドプールサイズの最適化
Section titled “スレッドプールサイズの最適化”適切なスレッドプールサイズの計算:
// 経験則: スレッド数 = CPUコア数 + 1int cores = Runtime.getRuntime().availableProcessors();int threadPoolSize = cores + 1;
// しかし、これはI/O待機がある場合には不適切
// より正確な計算:// スレッド数 = CPUコア数 × (1 + 待機時間 / 処理時間)//// 例:// - CPUコア数: 4// - 処理時間: 10ms// - I/O待機時間: 90ms// スレッド数 = 4 × (1 + 90/10) = 4 × 10 = 40
@Servicepublic class OptimizedThreadPoolConfig {
@Bean public ExecutorService ioBoundExecutor() { int cores = Runtime.getRuntime().availableProcessors(); // I/O待機が多い場合: コア数の2-4倍 int poolSize = cores * 4;
return new ThreadPoolExecutor( poolSize, poolSize, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(1000), new ThreadFactoryBuilder() .setNameFormat("io-pool-%d") .build(), new ThreadPoolExecutor.CallerRunsPolicy() // キューが満杯の場合の処理 ); }
@Bean public ExecutorService cpuBoundExecutor() { int cores = Runtime.getRuntime().availableProcessors(); // CPUバウンドな処理: コア数 + 1 int poolSize = cores + 1;
return new ThreadPoolExecutor( poolSize, poolSize, 60L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(), new ThreadFactoryBuilder() .setNameFormat("cpu-pool-%d") .build() ); }}パフォーマンス計測とプロファイリング
Section titled “パフォーマンス計測とプロファイリング”適切な計測方法
Section titled “適切な計測方法”// マイクロベンチマークの落とし穴public class BadBenchmark { public void badBenchmark() { long start = System.currentTimeMillis();
for (int i = 0; i < 1000; i++) { processData(); }
long duration = System.currentTimeMillis() - start; System.out.println("Duration: " + duration + "ms");
// 問題: // 1. JITコンパイラのウォームアップがない // 2. GCの影響を考慮していない // 3. システムの負荷を考慮していない }}
// 適切なベンチマーク(JMH使用)@BenchmarkMode(Mode.AverageTime)@OutputTimeUnit(TimeUnit.MILLISECONDS)@State(Scope.Benchmark)public class GoodBenchmark {
private List<String> data;
@Setup public void setup() { data = generateTestData(10000); }
@Benchmark public void benchmarkStream() { data.stream() .filter(s -> s.length() > 5) .map(String::toUpperCase) .collect(Collectors.toList()); }
@Benchmark public void benchmarkLoop() { List<String> result = new ArrayList<>(); for (String s : data) { if (s.length() > 5) { result.add(s.toUpperCase()); } } }}パフォーマンスの深い理解において重要なポイント:
- 計測が最優先: 推測ではなく、データに基づいて判断
- ボトルネックの特定: 全体の20%が80%の時間を消費する(パレートの法則)
- トレードオフの理解: メモリ vs CPU、レイテンシ vs スループット
- システム全体の視点: 単一のコンポーネントではなく、システム全体を考慮
- 継続的な改善: 一度の最適化ではなく、継続的な監視と改善
パフォーマンスの問題は、表面的な症状ではなく、根本原因を理解することで解決できます。