Skip to content

パフォーマンスの深い理解

シニアエンジニアとして、パフォーマンスの問題を解決するには、表面的な最適化ではなく、システムの根本的な動作を理解する必要があります。この章では、Javaアプリケーションのパフォーマンスを深く理解するための知識を解説します。

ガベージコレクションの動作原理

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の頻度が低下
}
}

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非常に高バッチ処理
G1GC4GB - 32GB一般的なWebアプリ
ZGC8GB+非常に低低レイテンシが必要

データベースアクセスの最適化

Section titled “データベースアクセスの最適化”

問題の本質:

// N+1問題の発生メカニズム
@Service
public 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: バッチフェッチ(複数の親エンティティがある場合)
@Entity
public class Order {
@OneToMany(fetch = FetchType.LAZY)
@BatchSize(size = 10) // 10件ずつバッチで取得
private List<OrderItem> items;
}
// 動作:
// 1. 注文を10件取得
// 2. 10件の注文IDで1回のクエリでアイテムを取得
// 合計: 2回のクエリ(1 + 1 = 2)

インデックスの効果を理解する:

// インデックスなし: フルテーブルスキャン
// 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") // 追加
})

クエリプランの理解:

application.properties
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.use_sql_comments=true
// クエリプランを確認
@Repository
public 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)
// → インデックススキャン(効率的)
}

マルチレベルキャッシング:

// L1: アプリケーションレベル(ローカルキャッシュ)
@Service
public 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;
}
}

キャッシュの無効化戦略:

// 問題: キャッシュの無効化が複雑
@Service
public 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 “スレッドプールサイズの最適化”

適切なスレッドプールサイズの計算:

// 経験則: スレッド数 = CPUコア数 + 1
int 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
@Service
public 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 “パフォーマンス計測とプロファイリング”
// マイクロベンチマークの落とし穴
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());
}
}
}
}

パフォーマンスの深い理解において重要なポイント:

  1. 計測が最優先: 推測ではなく、データに基づいて判断
  2. ボトルネックの特定: 全体の20%が80%の時間を消費する(パレートの法則)
  3. トレードオフの理解: メモリ vs CPU、レイテンシ vs スループット
  4. システム全体の視点: 単一のコンポーネントではなく、システム全体を考慮
  5. 継続的な改善: 一度の最適化ではなく、継続的な監視と改善

パフォーマンスの問題は、表面的な症状ではなく、根本原因を理解することで解決できます。