[이해하기] Lock 성능 테스트
개요
- S/W 개발자로서 성능 최적화의 핵심 요소인 스레드에 대해 정리한다
- JVM 환경에서의 스레드 v2 [Lock]의 후속 글로, 이 문서에서는 주로 Lock 성능 테스트에 대해 다룬다
JVM 환경에서의 스레드 v2 [Lock]
개요S/W 개발자로서 성능 최적화의 핵심 요소인 스레드에 대해 정리한다JVM 환경에서의 스레드 v1 [스레드에 대한 이해]의 후속 글로, 이 문서에서는 주로 Lock에 대해 다룬다 JVM 환경에서의 스
pablo7.tistory.com
목적
- Lock 성능 테스트를 통해 멀티 스레드 환경에서 여러 Lock에 대한 개념을 구체화한다
- 지난 글로 Lock에 대해 학습하였으나, 이는 추상적인 느낌인 것이지, 정말 그런 한지 경험이 부족하다
- 예를들면 지난 글에서 synchronized과 ReentrantLock의 차이를 이론적으로만 학습했었는데,
직접 실험해보고 진짜 차이를 확인해보고 싶었다.
- 지난 글로 Lock에 대해 학습하였으나, 이는 추상적인 느낌인 것이지, 정말 그런 한지 경험이 부족하다
🤔 "성능 테스트"란 무엇인가?
- 성능 테스트에 대한 사고방식을 먼저 정립해야 한다
- 성능 테스트란 애플리케이션의 성능에 대한 가설을 세우고,
이를 실험(테스트)을 통해 실제 데이터를 얻어 그 가설을 검증하는 행위라고 볼 수 있다- 막연하게 "빠르다" 또는 "느리다"가 아니라, 반드시 측정 가능한 지표(메트릭)를 정해야 한다
- 또한, 어떠한 환경에서 수행 됐는지도 함께 공유해야 한다
- 여기서 환경이란 컴퓨터 하드웨어 자원인 물리적 자원을 포함하여,
OS, JVM 버전 등 주요한 소프트 웨어 구성 환경까지 포함된다
- 여기서 환경이란 컴퓨터 하드웨어 자원인 물리적 자원을 포함하여,
❗ 꼭 알아야 하는 주요 성능 지표 (메트릭 metric)
- Throughput(처리량): 단위 시간당 처리되는 작업 수
- Latency(지연 시간): 작업 요청부터 응답까지 걸린 시간(평균, 최대, 최소, 95th percentile)
- CPU 사용률: 자원의 효율적 사용을 나타냄
- Memory 사용률: 메모리 누수나 과도한 메모리 할당 확인
- 컨텍스트 스위칭 수: 멀티스레드 환경에서 중요한 지표
- 지난 Lock에 대한 정리 글을 통해, CPU 컨텍스트 스위칭 발생이 성능 저하의 주요 원인이었다
- GC 횟수 및 시간: Java 환경에서 매우 중요한 지표
그렇다면 성능 지표를 어떻게 측정할 것 인가?
- 주요 성능 지표에 대해서도 학습이 필요하지만, 측정하는 방법을 알아야 테스트를 시작할 수 있다
- 따라서 먼저 성능 측정하는 도구를 알아보았다
1️⃣ 생각보다 다양한 성능 측정 도구들
- 멀티 스레드 환경에서 성능을 측정할 때 사용할 수 있는 도구들은 생각 이상으로 다양하게 존재했다
- 그중 대표적인 도구들의 주요 특징은 다음과 같았다
도구명 | 주요 특징 |
JMH (Java Microbenchmark Harness) |
Java 공식 성능 측정 도구, 마이크로 벤치마크에 최적화 |
JProfiler | GUI 기반의 강력한 Java 프로파일러, Lock contention(경합) 분석 가능 (유료) |
VisualVM | 무료 GUI 기반 프로파일러, 스레드 상태 분석 가능 (참고용) |
perf (Linux) | 리눅스 기본 성능 분석 도구, 시스템 레벨에서 성능 분석 (저수준 분석용) |
Java Flight Recorder (JFR) | JVM 내장 성능 분석 도구, JDK 11부터 기본 제공 (참고용) |
System.nanoTime() | 가장 간단한 방식, 실행 시간 측정 가능 (가장 신뢰도가 낮다) |
2️⃣ 성능 측정 도구 선택 : JMH
- 다양한 성능 측정 도구가 있었으나, JMH를 활용하기로 했다
💡 왜 JMH를 선택했는가?
- 자바 공식으로 제공하는 마이크로 벤치마킹 라이브러리라고 하기에 (무료인 점!)
- Lock 성능 측정을 위해 필요한 멀티 스레드 환경 지원한다는 점 (Threads 옵션 제공)
- Lock 성능 비교를 위한 Throughput, Latency 등 다양한 측정 방식 지원한다고 해서
- 실행 환경과 JVM의 영향을 최소화하여 정확한 벤치마킹 가능하다고 하니까
추가로
- JIT(Just-In-Time) 컴파일러 최적화를 방지하는 기능 내장되어 있다 하며
- GC 영향 제거도 가능하다 한다
사실 다른 학습할 것들이 많이 있기 때문에,
현재로선 가장 쉽고 빠르게 성능을 측정할 수 있는 도구를 선택하자는 게 최우선이었고,
JMH가 학습하기 제일 쉽지 않을까 판단했다
그러므로 위와 같은 선택 이유를 정리한 것은, JMH에 대해서만 알아보면서 정리한 것으로
상세한 비교를 해보진 못했다
3️⃣ JMH 기본 사용법
- JMH를 사용하려면 먼저 Maven 또는 Gradle에서 라이브러리를 추가해야 한다
- 아래는 Gradle로 라이브러리를 추가하는 예시이다
plugins {
id 'java'
}
group = 'org.example'
version = '1.0-SNAPSHOT'
repositories {
mavenCentral()
gradlePluginPortal() // 추가
}
dependencies {
implementation 'org.openjdk.jmh:jmh-core:1.37' // 추가
annotationProcessor 'org.openjdk.jmh:jmh-generator-annprocess:1.37' // 추가
testImplementation platform('org.junit:junit-bom:5.10.0')
testImplementation 'org.junit.jupiter:junit-jupiter'
}
test {
useJUnitPlatform()
}
🖖🏻 JMH 기본 코드 예제
- 매우 간단한 count 변수 증가 메서드에 [synchronized VS ReetrantLock]를 성능 측정해 보았다
- JMH에서 지원해 주는 어노테이션들이 어떤 의미가 있는지는 주석으로 정리한다
@BenchmarkMode(Mode.AverageTime) // 평균 실행 시간 측정
@State(Scope.Thread) // 각 스레드마다 독립적인 상태 유지
@OutputTimeUnit(TimeUnit.NANOSECONDS) // 실행 시간 단위를 나노초로 설정
public class BenchmarkTest {
private int count = 0;
private final Lock reentrantLock = new ReentrantLock();
@Benchmark // 측정할 메서드에 어노테이션 추가
public synchronized void synchronizedIncrement() {
count++;
}
@Benchmark // 측정할 메서드에 어노테이션 추가
public void reentrantLockIncrement() {
reentrantLock.lock();
try {
count++;
} finally {
reentrantLock.unlock();
}
}
}
- 어노테이션 @BenchmarkMode 를 활용해, 성능 측정하는 기준을 "평균 실행 시간"으로 두었다
- 어노테이션 @State 를 활용해 각 스레드마다 독립적인 상태를 유지한다
- 어노테이션 @OutputTimeUnit 을 활용해 측정 결과에 대한 단위를 설정했다
- 어노테이션 @Benchmark 를 활용해 측정할 메서드를 결정한다
public class Main {
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(BenchmarkTest.class.getSimpleName()) // 실행할 벤치마크 클래스 지정
.forks(1) // 프로세스를 몇 번 실행할지 (1이면 한 번 실행)
.warmupIterations(3) // 워밍업 실행 횟수
.measurementIterations(5) // 실제 측정 실행 횟수
.build();
new Runner(opt).run();
}
}
- 위와 같이 JMH에서 제공하는 OptionsBuilder 객체를 활용해 Options 객체의 초기 값을 세팅한다
- 성능 측정할 클래스가 어떤 클래스 인지 include()로 포함시키고,
- forks()에는 프로세스를 몇 번 실행할 것 인지 횟수를 세팅한다
- warmupIterations()에는 워밍업 횟수를 지정하는 것인데, 이 이유는 JVM 내부에 JIT를 미리 활성화시키기 위해서다
- JIT 덕에 바이트 코트가 미리 네이티브로 컴파일되어 JVM 인터프리터가 읽을 필요 없어 성능이 빨라질 수 있으니,
이를 미리 워밍업 처리로 성능 측정에 대한 정확성을 높이자는 것이다.
WHY? JIT는 자주 실행되는 바이트 코드를 모니터링할 테니까
- JIT 덕에 바이트 코트가 미리 네이티브로 컴파일되어 JVM 인터프리터가 읽을 필요 없어 성능이 빨라질 수 있으니,
- measurementIterations()에 측정 횟수를 지정한다,
측정 횟수가 많아질수록 정확도가 올라갈 확률이 높아지기 때문이다
- 최종적으로JMH에서 제공하는 Runner 객체를 활용하여 run() 메서드를 실행시키면 세팅해 둔 것과 같이 성능 측정을 시작한다
👽 성능 측정 결과 해석 보자
- main 스레드로 JMH 기능을 수행하여 콘솔에서 확인할 수 있는 성능 측정 결과는 다음과 같다
> Task :compileJava
> Task :processResources NO-SOURCE
> Task :classes
> Task :org.example.Main.main()
# JMH version: 1.37
# VM version: JDK 21.0.6, OpenJDK 64-Bit Server VM, 21.0.6+7-LTS
# VM invoker: /Users/x/Library/Java/JavaVirtualMachines/temurin-21.0.6/Contents/Home/bin/java
# VM options: -Dfile.encoding=UTF-8 -Duser.country=KR -Duser.language=ko -Duser.variant
# Blackhole mode: compiler (auto-detected, use -Djmh.blackhole.autoDetect=false to disable)
# Warmup: 3 iterations, 10 s each
# Measurement: 5 iterations, 10 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Average time, time/op
# Benchmark: org.example.BenchmarkTest.reentrantLockIncrement
# Run progress: 0.00% complete, ETA 00:02:40
# Fork: 1 of 1
# Warmup Iteration 1: 12.698 ns/op
# Warmup Iteration 2: 12.628 ns/op
# Warmup Iteration 3: 12.673 ns/op
Iteration 1: 12.611 ns/op
Iteration 2: 12.631 ns/op
Iteration 3: 12.559 ns/op
Iteration 4: 12.566 ns/op
Iteration 5: 12.582 ns/op
Result "org.example.BenchmarkTest.reentrantLockIncrement":
12.590 ±(99.9%) 0.118 ns/op [Average]
(min, avg, max) = (12.559, 12.590, 12.631), stdev = 0.031
CI (99.9%): [12.472, 12.708] (assumes normal distribution)
# JMH version: 1.37
# VM version: JDK 21.0.6, OpenJDK 64-Bit Server VM, 21.0.6+7-LTS
# VM invoker: /Users/x/Library/Java/JavaVirtualMachines/temurin-21.0.6/Contents/Home/bin/java
# VM options: -Dfile.encoding=UTF-8 -Duser.country=KR -Duser.language=ko -Duser.variant
# Blackhole mode: compiler (auto-detected, use -Djmh.blackhole.autoDetect=false to disable)
# Warmup: 3 iterations, 10 s each
# Measurement: 5 iterations, 10 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Average time, time/op
# Benchmark: org.example.BenchmarkTest.synchronizedIncrement
# Run progress: 50.00% complete, ETA 00:01:20
# Fork: 1 of 1
# Warmup Iteration 1: 9.376 ns/op
# Warmup Iteration 2: 9.372 ns/op
# Warmup Iteration 3: 9.424 ns/op
Iteration 1: 9.480 ns/op
Iteration 2: 9.461 ns/op
Iteration 3: 9.427 ns/op
Iteration 4: 9.425 ns/op
Iteration 5: 9.421 ns/op
Result "org.example.BenchmarkTest.synchronizedIncrement":
9.443 ±(99.9%) 0.101 ns/op [Average]
(min, avg, max) = (9.421, 9.443, 9.480), stdev = 0.026
CI (99.9%): [9.341, 9.544] (assumes normal distribution)
# Run complete. Total time: 00:02:40
REMEMBER: The numbers below are just data. To gain reusable insights, you need to follow up on
why the numbers are the way they are. Use profilers (see -prof, -lprof), design factorial
experiments, perform baseline and negative tests that provide experimental control, make sure
the benchmarking environment is safe on JVM/OS/HW level, ask for reviews from the domain experts.
Do not assume the numbers tell you what you want them to tell.
NOTE: Current JVM experimentally supports Compiler Blackholes, and they are in use. Please exercise
extra caution when trusting the results, look into the generated code to check the benchmark still
works, and factor in a small probability of new VM bugs. Additionally, while comparisons between
different JVMs are already problematic, the performance difference caused by different Blackhole
modes can be very significant. Please make sure you use the consistent Blackhole mode for comparisons.
Benchmark Mode Cnt Score Error Units
BenchmarkTest.reentrantLockIncrement avgt 5 12.590 ± 0.118 ns/op
BenchmarkTest.synchronizedIncrement avgt 5 9.443 ± 0.101 ns/op
BUILD SUCCESSFUL in 2m 40s
2 actionable tasks: 2 executed
- 위 성능 측정 결과에 중요한 부분을 순서대로 해석해 본다
1. 실행 환경 확인
# JMH version: 1.37
# VM version: JDK 21.0.6, OpenJDK 64-Bit Server VM, 21.0.6+7-LTS
# VM invoker: /Users/x/Library/Java/JavaVirtualMachines/temurin-21.0.6/Contents/Home/bin/java
- JMH 버전: 1.37
- JDK 버전: Java 21.0.6 (LTS)
- 실행된 JVM: OpenJDK 64-bit Server VM
JMH는 실행될 때 JVM 환경을 매우 중요하게 생각한다
왜냐하면 JVM 내부 최적화(JIT 컴파일, Escape Analysis 등)에 따라 결과가 크게 달라질 수 있기 때문이다
2. 벤치마크 설정
# Warmup: 3 iterations, 10 s each
# Measurement: 5 iterations, 10 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Average time, time/op
- 여기서 중요한 설정을 살펴보자
- `# Warmup: 3 iterations, 10 s each` (Warmup (워밍업))
- JMH는 실행하기 전에 3회(10초씩) 워밍업을 수행했다
- WHY? JVM이 처음 실행되면 JIT(Just-In-Time) 컴파일러가 최적화를 하지 않은 상태이니까
- JIT이 안정화될 때까지 충분한 실행을 거친 후 실제 측정을 시작한다
- JMH는 실행하기 전에 3회(10초씩) 워밍업을 수행했다
- `# Measurement: 5 iterations, 10 s each` (Measurement (측정))
- 본격적인 벤치마킹은 5회 실행(각각 10초씩) 진행되었다
- 즉, 50초 동안 성능 데이터를 수집한 것
- 본격적인 벤치마킹은 5회 실행(각각 10초씩) 진행되었다
- `# Benchmark mode: Average time, time/op` (Benchmark Mode (측정 방식))
- 평균 실행 시간(Average Time, avgt)을 측정하는 방식이야.
- 즉, 1번 실행하는 데 걸리는 평균 시간을 나노초(ns/op) 단위로 측정한 것
- 다른 모드로는 Throughput(초당 실행 횟수), SampleTime(샘플링 기반 시간 측정) 등이 있다
- 평균 실행 시간(Average Time, avgt)을 측정하는 방식이야.
3. ReentrantLock 성능 측정 결과
# Benchmark: org.example.BenchmarkTest.reentrantLockIncrement
Iteration 1: 12.611 ns/op
Iteration 2: 12.631 ns/op
Iteration 3: 12.559 ns/op
Iteration 4: 12.566 ns/op
Iteration 5: 12.582 ns/op
Result "org.example.BenchmarkTest.reentrantLockIncrement":
12.590 ±(99.9%) 0.118 ns/op [Average]
(min, avg, max) = (12.559, 12.590, 12.631), stdev = 0.031
CI (99.9%): [12.472, 12.708]
- 5번 반복 측정된 결과는 다음과 같다
- 평균 실행 시간: 12.590ns (나노초)
- 최소/최대 시간: 12.559ns ~ 12.631ns
- 오차 범위(99.9% 신뢰 구간, CI): ± 0.118 ns
👉 ReentrantLock을 사용한 경우, count++ 연산을 실행하는 데 평균적으로 약 12.6ns가 걸렸다
4. synchronized 성능 측정 결과
# Benchmark: org.example.BenchmarkTest.synchronizedIncrement
Iteration 1: 9.480 ns/op
Iteration 2: 9.461 ns/op
Iteration 3: 9.427 ns/op
Iteration 4: 9.425 ns/op
Iteration 5: 9.421 ns/op
Result "org.example.BenchmarkTest.synchronizedIncrement":
9.443 ±(99.9%) 0.101 ns/op [Average]
(min, avg, max) = (9.421, 9.443, 9.480), stdev = 0.026
CI (99.9%): [9.341, 9.544]
- 5번 반복 측정된 결과는 다음과 같다
- 평균 실행 시간: 9.443ns
- 최소/최대 시간: 9.421ns ~ 9.480ns
- 오차 범위(99.9% 신뢰 구간, CI): ± 0.101 ns
👉 synchronized를 사용한 경우, 같은 count++ 연산이 평균적으로 약 9.4ns 걸렸음!
5. 최종 비교 및 분석
Benchmark Mode Cnt Score Error Units
BenchmarkTest.reentrantLockIncrement avgt 5 12.590 ± 0.118 ns/op
BenchmarkTest.synchronizedIncrement avgt 5 9.443 ± 0.101 ns/op
- 결과를 표로 요약하면 아래와 같다
Lock 방식 | 평균 실행 시간 (ns/op) | 오차 범위 (99.9%) |
ReentrantLock | 12.590 ns | ±0.118 ns |
synchronized | 9.443 ns | ±0.101 ns |
- 세팅된 환경에서 synchronized가 ReentrantLock보다 약 25% 더 빠르게 실행됐다
- 9.4ns vs 12.6ns → synchronized가 더 효율적임
- ReentrantLock이 오버헤드가 더 크다
- WHY? lock() & unlock() 메서드 호출이 추가되면서 비용이 더 큼
- 하지만 멀티스레드 환경에서는 결과가 다를 수 있음
- synchronized는 JVM 내부 최적화(Lightweight Locking, Biased Locking 등)의 영향을 많이 받은 것 같다
synchronized가 더 효율적이었다, 이는 JVM이 내부적으로 빠르게 최적화 해주기 때문으로 판단된다
Lock 성능 테스트 설계
- 위에는 JMH라는 성능 테스트 도구를 학습하기 위한 과정이었고, 본격적으로 Lock 성능 테스트를 해보고자 한다
- 우선 Lock 성능 테스트를 위해 어떠한 Lock을 어떠한 상황에서 활용한건지 명확한 시나리오가 있어야 한다
- 성능 테스트를 위해 유의미한 시나리오를 잘 짜는 것 또한,
역시 측정하고자 하는 기술에 대한 기본 이해가 뒷받침되어야 하는 것 아닐까 생각한다
- 성능 테스트를 위해 유의미한 시나리오를 잘 짜는 것 또한,
- 그 후 측정하고자 하는 지표를 정리해야 한다
- GPT 형님의 도움으로 Lock 성능을 측정하는 주요 지표가 무엇인지 도움을 얻을 수 있었다
- 아래 3개의 지표를 Lock 성능 테스트를 하는 주요 지표로 삼았다
- Throughput 및 Latency 변화
- Lock으로 인해 전체적으로 애플리케이션 성능이 어느 정도 영향을 받는지 평가
- 지표: 초당 처리된 작업 수, 평균 응답시간, 지연시간의 변화
- 컨텐션(경합) 정도
- Lock을 얻기 위해 대기하는 스레드가 많아지면 성능이 떨어질 수 있음
- 지표: Lock을 얻는 데 걸리는 평균 시간, 최대 대기시간, 큐에 대기하는 스레드 수
- 컨텍스트 스위칭
- Lock이 스레드를 얼마나 자주 멈추게 하는지 확인해야 함
- 지표: vmstat, pidstat, top, Java VisualVM 등의 툴을 이용한 컨텍스트 스위칭 모니터링
테스트 환경 체크
- 현재로선 내 Local 환경인 맥북에서 성능 테스트를 하고자 한다
하드웨어 개요:
모델명: MacBook Pro
모델 식별자: MacBookPro18,3
모델 번호: Z15K000RMKH/A
칩: Apple M1 Pro
총 코어 개수: 10(8 성능 및 2 효율)
메모리: 32 GB
시스템 펌웨어 버전: 11881.81.4
OS 로더 버전: 11881.81.4
시나리오: 멀티 스레드 환경에서 synchronized와 ReetrantLock 비교
- 필자가 학습한 개념에 따르면 JVM으로 관리되는 Lock은 Lock 경쟁이 심해질수록, 극격한 성능 저하가 발생해야 한다
- 따라서 여러 스레드가 Lock을 획득해야 하는 멀티스레드 조건에서는,
synchronized를 활용한 Lock이 ReetrantLock보다 처리 속도가 느리다는 가설을 세웠다 - 그러므로 JMH 도구를 학습하고자 구성했던 예제 코드에서 `단일 -> 멀티 스레드`환경으로 수정하고,
벤치마크 모드를 단순히 평균 처리 시간만 측정하는 게 아닌, 측정할 수 있는 모든 지표를 측정하도록 수정했다
@BenchmarkMode(Mode.All) // All 벤치마크 모드를 실행
@OutputTimeUnit(TimeUnit.NANOSECONDS) // 실행 시간 단위를 나노초로 설정
@State(Scope.Benchmark) // 모든 스레드가 동일한 자원 공유
public class MultiThreadLockBenchmark {
private int count = 0;
private final Lock reentrantLock = new ReentrantLock();
@Benchmark
@Threads(20)
public synchronized void synchronizedIncrement() {
count++;
}
@Benchmark
@Threads(20)
public void reentrantLockIncrement() {
reentrantLock.lock();
try {
count++;
} finally {
reentrantLock.unlock();
}
}
}
- JMH Options은 동일하다
public class Main {
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(BenchmarkTest.class.getSimpleName()) // 실행할 벤치마크 클래스 지정
.forks(1) // 프로세스를 몇 번 실행할지 (1이면 한 번 실행)
.warmupIterations(3) // 워밍업 실행 횟수
.measurementIterations(5) // 실제 측정 실행 횟수
.build();
new Runner(opt).run();
}
}
- 위에 대한 결과는 다음과 같았다
Benchmark Mode Cnt Score Error Units
MultiThreadLockBenchmark.reentrantLockIncrement thrpt 5 0.051 ± 0.002 ops/ns
MultiThreadLockBenchmark.synchronizedIncrement thrpt 5 0.033 ± 0.002 ops/ns
MultiThreadLockBenchmark.reentrantLockIncrement avgt 5 391.218 ± 14.530 ns/op
MultiThreadLockBenchmark.synchronizedIncrement avgt 5 560.223 ± 35.829 ns/op
MultiThreadLockBenchmark.reentrantLockIncrement sample 34324041 1193.379 ± 10.973 ns/op
MultiThreadLockBenchmark.reentrantLockIncrement:p0.00 sample ≈ 0 ns/op
MultiThreadLockBenchmark.reentrantLockIncrement:p0.50 sample 41.000 ns/op
MultiThreadLockBenchmark.reentrantLockIncrement:p0.90 sample 42.000 ns/op
MultiThreadLockBenchmark.reentrantLockIncrement:p0.95 sample 83.000 ns/op
MultiThreadLockBenchmark.reentrantLockIncrement:p0.99 sample 45120.000 ns/op
MultiThreadLockBenchmark.reentrantLockIncrement:p0.999 sample 229888.000 ns/op
MultiThreadLockBenchmark.reentrantLockIncrement:p0.9999 sample 429568.000 ns/op
MultiThreadLockBenchmark.reentrantLockIncrement:p1.00 sample 10387456.000 ns/op
MultiThreadLockBenchmark.synchronizedIncrement sample 32009474 3710.675 ± 30.635 ns/op
MultiThreadLockBenchmark.synchronizedIncrement:p0.00 sample ≈ 0 ns/op
MultiThreadLockBenchmark.synchronizedIncrement:p0.50 sample 42.000 ns/op
MultiThreadLockBenchmark.synchronizedIncrement:p0.90 sample 541.000 ns/op
MultiThreadLockBenchmark.synchronizedIncrement:p0.95 sample 1250.000 ns/op
MultiThreadLockBenchmark.synchronizedIncrement:p0.99 sample 82944.000 ns/op
MultiThreadLockBenchmark.synchronizedIncrement:p0.999 sample 545792.000 ns/op
MultiThreadLockBenchmark.synchronizedIncrement:p0.9999 sample 1763328.000 ns/op
MultiThreadLockBenchmark.synchronizedIncrement:p1.00 sample 26181632.000 ns/op
MultiThreadLockBenchmark.reentrantLockIncrement ss 5 312.910 ± 499.198 ns/op
MultiThreadLockBenchmark.synchronizedIncrement ss 5 128.370 ± 85.028 ns/op
- 위 벤치 마크 결과를 이제 하나씩 해석해 보자
1️⃣ Throughput (thrpt) - 초당 실행 횟수 (ops/ns)
Benchmark | Score (ops/ns) | 의미 |
reentrantLockIncrement | 0.051 | ReentrantLock이 초당 약 0.051번 실행됨 |
synchronizedIncrement | 0.033 | synchronized가 초당 약 0.033번 실행됨 |
👉 ReentrantLock이 synchronized보다 약 54% 높은 처리량을 보인다
즉, ReentrantLock이 동시 실행 환경에서 더 많은 연산을 수행할 수 있다는 의미로 볼 수 있다
2️⃣ Average Time (avgt) - 평균 실행 시간 (ns/op)
Benchmark | Score (ns/op) | 의미 |
reentrantLockIncrement | 391.218 ns | ReentrantLock을 사용했을 때 평균적으로 391.2ns가 걸림 |
synchronizedIncrement | 560.223 ns | synchronized를 사용했을 때 평균적으로 560.2ns가 걸림 |
👉 ReentrantLock이 synchronized보다 약 30% 더 빨랐다
동기화 방식에 따른 성능 차이가 확연히 나타난다고 추측된다
3️⃣ Sample (sample) - 다양한 실행 시간 분포
- Percentile(백분위수) 개념에 대해 먼저 정립한다
- Percentile(백분위수)은 데이터가 전체 분포에서 어느 위치에 있는지를 나타내는 지표로,
"N% Percentile"은 해당 값이 데이터의 N% 이하에 속한다는 의미이다
- 예를 들어, p90 = 42ns라면 전체 실행 중 90%의 요청이 42ns 이하의 실행 시간을 가졌다는 뜻이다
- Percentile이 왜 중요한가?
- 평균 처리 시간만 봐서는 특이 상황에 대한 케이스를 놓칠 수 있기 때문이다
(필자가 보기엔 평균 처리량을 보는 것보다 이러한 케이스를 확인하는 게 매우 중요하다고 판단된다)
- 평균 처리 시간만 봐서는 특이 상황에 대한 케이스를 놓칠 수 있기 때문이다
- 예를 들어 아래와 같은 상황이 존재한다고 가정하자
- Case 1: 평균만 보고 성능을 판단하면 안 되는 경우
- 평균 응답 시간: 100ms
- p99 (99% Percentile): 1,500ms
- p99.9 (99.9% Percentile): 5,000ms
- 평균(100ms)만 보면 빠른 것처럼 보이지만,
실제로 상위 1%의 요청(p99)은 1.5초, 상위 0.1%는 5초나 걸렸다!
🚨이런 경우, 일부 사용자에게는 서비스가 엄청 느리게 보일 수도 있다! - Case 2: Percentile을 보고 성능 튜닝하는 방법
- 평균 응답 시간: 100ms
- p95: 120ms
- p99: 150ms
- p99.9: 200ms
- 극단적인 경우(p99, p99.9)에서도 응답 시간이 일정하게 유지됨 → 안정적인 서비스 제공 가능!
- Case 1: 평균만 보고 성능을 판단하면 안 되는 경우
- 즉, 평균값만 보면 성능이 좋아 보여도, 일부 요청이 매우 느려질 수 있기 때문이다
- Percentile을 보면 극단적인 경우에서 성능이 어떻게 나오는지 확인할 수 있으며,
특히 p99, p99.9 값이 크다면, 일부 요청이 심각하게 느릴 수 있으므로 최적화 필요하다!
실무에서 퍼센타일을 활용하는 팁
- 📌 p50, p90 → 일반적인 성능 확인
- 📌 p99, p99.9 → 최악의 경우를 분석하는 핵심 지표!
- 📌 p100 (최댓값) → 정말 극단적인 경우 (Outlier) 확인
- 이제 각 percentile(백분위) 별로 실행 시간이 어떻게 분포하는지 확인해 본다
🔹 ReentrantLock 샘플 결과
Percentile | 실행 시간 (ns/op) |
p0.50 (50%) | 41 ns |
p0.90 (90%) | 42 ns |
p0.95 (95%) | 83 ns |
p0.99 (99%) | 45120 ns |
p0.999 (99.9%) | 229888 ns |
p1.00 (최대값) | 10387456 ns (10ms) |
🔹 synchronized 샘플 결과
Percentile | 실행 시간 (ns/op) |
p0.50 (50%) | 42 ns |
p0.90 (90%) | 541 ns |
p0.95 (95%) | 1250 ns |
p0.99 (99%) | 82944 ns |
p0.999 (99.9%) | 545792 ns |
p1.00 (최대값) | 26181632 ns (26ms) |
👉 분석
- ReentrantLock의 p99 (99%) 값이 45120 ns, p999 (99.9%) 값이 229888 ns
- synchronized의 p99 값이 82944 ns, p999 값이 545792 ns
- 즉, ReentrantLock이 평균적으로 더 빠르고, 높은 퍼센타일(극단적인 경우)에서도 더 안정적인 성능을 보인다
- 하지만 100% 퍼센타일(p1.00)에서는 synchronized가 최악의 경우 26ms까지 걸리는 반면,
ReentrantLock도 10ms까지 걸리는 경우가 있다
- 즉, ReentrantLock이 평균적으로 더 빠르고, 높은 퍼센타일(극단적인 경우)에서도 더 안정적인 성능을 보인다
- 극단적인 상황에서는 ReentrantLock도 느려질 수 있지만, 전체적으로는 synchronized보다 더 나은 성능을 보인다
4️⃣ Single Shot (ss) - 단일 실행 성능
Benchmark | Score (ns/op) | 의미 |
reentrantLockIncrement | 312.910 ns (±499.198) | 단일 실행 시 약 312.9ns 소요 (편차가 크다는 점 주목) |
synchronizedIncrement | 128.370 ns (±85.028) | 단일 실행 시 약 128.4ns 소요 |
👉 단일 실행에서는 synchronized가 ReentrantLock보다 더 빨랐다
- 즉, 한 번만 실행하는 경우라면 synchronized가 오버헤드가 적어서 더 빠를 수 있다
📌 결론
- 멀티스레드 환경에서 Throughput & Average Time → ReentrantLock이 synchronized보다 더 좋은 성능을 보였다
- 샘플 분석 (Percentile) → ReentrantLock이 대부분 더 안정적
- 단일 실행 (Single Shot) → synchronized가 더 빠름 (오버헤드가 적기 때문에)
✅ Lock 획득 경쟁이 심해지는 멀티스레드 환경에서
성능 최적화를 원한다면 ReentrantLock을 선택하는 것이 더 유리하다
✅ 하지만 Lock 획득 경쟁이 단순한 경우에는 synchronized가 더 적합할 수 있다
성능 측정 결과와 별개로 중요하게 측정해야 할 사항이 빠졌다?
- 위와 같이 JHM를 활용해서 Lock 경쟁이 심해지는 멀티스레드 환경에선 JVM에서 관리하는 Lock 보다,
자바 라이브러리에서 관리해 주는 Lock이성능이 더 좋을 수 있다는 것을 확인할 수 있었다 - 그러나, 이는 성능 측정 결과에 불과하지 않나?
멀티스레드 환경에서 JVM이 관리해주는 Lock이 성능 저하가 심각해지는 이유는
OS 스레드가 Lock이 걸리면서, 결과적으로는 더 많은 컨텍스트 스위칭이 발생하기 때문이라고 학습하지 않았나?
- 즉, 가설에 대한 완벽한 검증을 위해서는 정말 JVM의 Lock이 OS 스레드 Lock이 걸리게 했고,
이로 인해 CPU 콘텍스트 스위칭이 더 많이 발생했다는 점이 확인되어야한다
- 즉, 가설에 대한 완벽한 검증을 위해서는 정말 JVM의 Lock이 OS 스레드 Lock이 걸리게 했고,
- 이러한 검증을 통해, 추상화된 지식을 좀 더 구체화 시켜보자
CPU 컨텍스트 스위칭 검증을 위한 별도 코드 작성
- 안타깝게도 JMH로도 CPU 턴텍스트 스위칭이 얼마나 발생됐는지 확인하기 어려웠다
- 따라서, JMH이 아닌, OS에서 제공하는 CLI 명령어를 통해 검증하는 방법을 택했다
- 현재로선 이 방법이 가장 간단하고 쉬울 것 같았기 때문이다
JVM에서 관리해 주는 Lock 성능 테스트 코드 (Synchronized)
public class SynchronizedTest {
private static int count = 0;
public static synchronized void increment() {
count++;
}
public static void main(String[] args) {
int numThreads = 20;
Thread[] threads = new Thread[numThreads];
for (int i = 0; i < numThreads; i++) {
threads[i] = new Thread(() -> {
while (true) {
increment();
}
});
threads[i].start();
}
}
}
- 총 20개의 스레드에 무한 루프를 돌려 JVM에서 관리하는 Lock 경쟁이 지속되도록 설정했다
자바 라이브러리 Lock 성능 테스트 코드 (ReetrantLock)
public class ReentrantLockTest {
private static int count = 0;
private static final Lock lock = new ReentrantLock();
public static void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
public static void main(String[] args) {
int numThreads = 20;
Thread[] threads = new Thread[numThreads];
for (int i = 0; i < numThreads; i++) {
threads[i] = new Thread(() -> {
while (true) {
increment();
}
});
threads[i].start();
}
}
}
- 총 20개의 스레드에 무한 루프를 돌려 자바 라이브러리로 관리하는 Lock 경쟁이 지속되도록 설정했다
측정 방법
- 먼저 JVM에서 관리하는 Lock 성능 테스트 코드만 실행시킨 뒤, 해당 프로세스 ID를 찾는다
- 프로세스 ID를 찾는 방법은 아래와 같다
ps aux | grep java # PID 확인
- 프로세스 ID를 찾은 후, 해당 프로세스에서 얼마나 CPU 컨텍스트 스위칭이 발생되는지 알아보기 위해 아래와 같이 명령어를 실행한다
top -stats csw -pid [프로세스 ID] # csw (context switch per second)
측정 결과
- 위 절차대로 수행하여 측정된 결과는 다음과 같다
synchronized Lock 성능 테스트 결과
Processes: 739 total, 3 running, 736 sleeping, 3688 threads
Load Avg: 3.08, 3.73, 3.83 CPU usage: 22.28% user, 11.4% sys, 66.66% idle
SharedLibs: 1027M resident, 198M data, 409M linkedit.
MemRegions: 0 total, 0B resident, 0B private, 3639M shared.
PhysMem: 29G used (1909M wired, 2121M compressor), 2449M unused.
VM: 307T vsize, 5536M framework vsize, 0(0) swapins, 0(0) swapouts.
Networks: packets: 16561455/15G in, 6369143/1568M out.
CSW
18404336+
- 실행 후 약 2분 뒤 측정한 값이며 CSW 값이 무서운 속도로 끝없이 올라가고 있었다
.. 실제로 20000000 수치를 넘어섰었다
Reetrant Lock 성능 테스트 결과
Processes: 730 total, 3 running, 727 sleeping, 3565 threads
Load Avg: 3.19, 4.03, 4.11 CPU usage: 17.33% user, 13.33% sys, 69.32% idle
SharedLibs: 1027M resident, 198M data, 409M linkedit.
MemRegions: 0 total, 0B resident, 0B private, 3678M shared.
PhysMem: 29G used (1891M wired, 2110M compressor), 1902M unused.
VM: 302T vsize, 5536M framework vsize, 0(0) swapins, 0(0) swapouts.
Networks: packets: 16532977/15G in, 6361235/1565M out.
Disks: 4144272/75G read, 7095031/100G written.
CSW
13082
- 실행 후 약 5분 뒤 측정한 값이며 CSW 값이 13000쯤에서 더 이상 올라가지 않고 유지되고 있었다
측정 결과 해석
- 주요한 CSW(컨텍스트 스위칭 값) 값에 대해 논하기 전에, top 명령어를 통해 확인할 수 있는 측정값에 대해 정리해 보자
- 가장 상단의 Processes 개수
Processes: 730 total, 4 running, 727 sleeping, 3535 threads
✔ 현재 총 730개의 프로세스가 실행 중이고, 이 중 4개가 실제로 CPU를 사용 중이며, 727개는 대기 상라는 뜻
✔ 전체적으로 3535개의 스레드가 실행 중.
✔ 실행한 Java 프로그램(멀티스레드 Lock 테스트)도 이 중 하나로 포함된다
- Load (CPU 사용량)
Load Avg: 4.53, 4.47, 4.25 CPU usage: 17.73% user, 12.67% sys, 69.59% idle
✔ Load Avg: 4.53, 4.47, 4.25 → 1, 5, 15분 동안의 평균 CPU 부하 (높을수록 CPU 사용량이 많다는 의미)
✔ CPU 사용량 분석
- 17.73% user → 사용자 코드(실행한 Java 프로그램 포함)에서 사용한 CPU 비율
- 12.67% sys → 커널(OS 내부 동작, 컨텍스트 스위칭 포함)에서 사용한 CPU 비율
- 69.59% idle → CPU가 놀고 있는 비율 (아직 여유가 있음)
🔥 Lock을 테스트하는 프로그램이 CPU에서 실행 중이며, 커널 영역에서도 약 12.67%의 사용량을 보인다는 것
🔥 이 값이 컨텍스트 스위칭이 얼마나 영향을 주는지 알 수 있는 지표가 될 수 있다
- 메모리 사용량 (PhysMem)
PhysMem: 29G used (1888M wired, 2110M compressor), 1953M unused.
✔ 여기서 중요한 건 Lock이 테스트 프로그램이 메모리를 얼마나 잡아먹고 있는가? 이겠지만
Lock 테스트는 메모리를 많이 쓰는 작업이 아니라서 여기선 큰 의미 없을 것 같다
이제 처음부터 파악하고자 했던 컨텍스트 스위칭 발생 횟수(CSW)에 대한 비교를 시작해 보자
📌 최종 분석: synchronized vs ReentrantLock 컨텍스트 스위칭 비교
Lock 방식 | CSW (Context Switches) 값 |
ReentrantLock | 13,082 |
synchronized | 18,404,336+ (계속 증가 중.. 😱) |
💡 결론: synchronized를 사용할 때 컨텍스트 스위칭(CSW)이 엄청나게 많이 발생하게 됐었다
(🚨 Lock 경쟁을 2~3분 유지했을 때 ReentrantLock보다 1,400배 이상 많음;)
📌 왜 synchronized에서 CSW가 이렇게 폭증할까?
1️⃣ synchronized는 OS 수준에서 강제적인 컨텍스트 스위칭이 많다
- synchronized는 기본적으로 JVM이 관리하는 모니터 락을 사용한다
- 여러 개의 스레드가 동시에 진입하려고 하면, 대기하는 스레드가 OS에 의해 블로킹 상태가 된다
- 즉, OS 스케줄러가 직접 CPU를 다른 스레드에게 넘겨줘야 하기 때문에, CSW가 폭발적으로 증가한다
2️⃣ ReentrantLock은 OS 개입을 줄이는 방식으로 동작
- ReentrantLock은 내부적으로 "스핀 락(Spin Lock)" 기법을 활용해서,
일정 시간 동안 CPU에서 기다렸다가 직접 Lock을 획득한다- 즉, OS에 의해 컨텍스트 스위칭이 강제되지 않도록 설계됐다
- 결과적으로 CSW가 크게 줄어듦!
📌 결론: 어떻게 적용해야 할까?
✅ 멀티스레드 환경에서 Lock 경합이 심하다면? → ReentrantLock을 고려하자!
- synchronized는 스레드가 Lock을 잡지 못하면 무조건 블로킹되면서, OS에서 컨텍스트 스위칭이 과도하게 발생한다
- ReentrantLock은 OS가 개입하는 컨텍스트 스위칭을 줄이고, 자체적으로 스핀락을 통해 효율적으로 Lock을 관리한다
- 컨텍스트 스위칭이 많아질수록 CPU 오버헤드가 커지고, 성능이 급격히 저하됨
- 특히, 고성능 애플리케이션에서는 ReentrantLock이 더 적절할 것 같다
✅ Lock 경합이 적거나, 간단한 Lock만 필요하다면? → synchronized를 유지
- synchronized는 JVM의 Biased Locking & Lightweight Locking 최적화를 받을 수 있다
- 즉, Lock 경합이 적은 경우에는 unlock()도 JVM이 알아서 해주는 synchronized가 더 빠를 수도 있음
Github Code
- 다양한 환경 테스트하지 못하고 내 로컬에서만 테스트했으므로,
다른 환경에서 쉽게 테스트해보기 위해 Github에 실습 코드를 올려 두었다 - 그리고 혹시 누군가 기본적인 Lock 성능 테스트를 하고자 할 때, 이 글이 작은 도움이 될지도 모르기 때문이다
https://github.com/Pablo730/java-lock-benchmark
GitHub - Pablo730/java-lock-benchmark: synchronized, ReetrantLock 성능 테스트
synchronized, ReetrantLock 성능 테스트. Contribute to Pablo730/java-lock-benchmark development by creating an account on GitHub.
github.com
여기까지 Lock에 대한 성능 측정을 통해,
머릿속에 추상적인 이론이었던 Lock에 대한 개념이 좀 더 구체화됨을 느꼈다
지난 글과 같이 생각 정리를 통해,
앞으로 어떠한 의식을 갖고 코드를 작성해야 하는지 점검해 보자
생각 정리
1. 첫 멘토링에서 static 키워드에 대한 설명을 해보라 하셨을 때, 아래와 같은 답변을 했었던 것 같다
"static 키워드를 활용하면 전역적으로 사용할 수 있다"
"어플리케이션 실행과 동시에 메모리에 적재되는 것으로 알고 있다"
"값이 고정적인 상수 혹은 클래스를 생성하지 않아도 되는 유틸리성 메서드에 static 키워드를 활용했다"
이제는 이 중에 완벽히 틀린 말도 있고,
완벽히 틀리진 않았지만 딱 봐도 static에 대한 개념이 없구나라고 느낄만한 답변에 가깝다
이제는 적어도 아래와 같은 사고를 갖고 답변을 할 것 같다
"static? 일단 해당 변수의 값(데이터)은 메모리에 고정적인 주소를 갖게 될 수 있는 거잖아?"
"따라서 '정적'이라는 워딩을 잘 표현해야겠네"
즉, 메모리 주소 값이 고정되어 활용될 테니, static 키워드가 붙어있으면
컴파일러도 "아, 이 변수나 메서드는 클래스가 생성될 필요도 없구나"라고 사고할 수 있어야 한다
이러한 사고를 기반으로 static 키워드에 대한 설명이 나와야 한다
또한 "어플리케이션 실행과 동시에 메모리에 올라가는 것"이 아니라
"런타임 환경에서 클래스 로더에 의해 클래스가 처음 로딩 될 때 메모리 주소 값을 얻게 될 거다"라고
정확히 표현해야겠다
2. JIT 컴파일러도 단순하게 "JVM이 성능 최적화를 위해 존재한다" 정도로 파악할게 아니라,
"기본적으로는 런타임 환경에서 JVM 인터프리터에 의해 바이트 코드가 실행되겠지만,
JIT 컴파일러가 자주 실행되는 바이트 코드를 모니터링하여,
인터프리터가 실행되지 않아도 빠르게 실행될 수 있는 네이티브 코드로 변환해 주고,
다시 같은 코드가 재실행되어야 할 때, 바이트 코드가 아닌 네이티브 코드로 빠르게 실행시켜 준다"
그리고 왜 처음부터 미리 동작이 빠른 네이티브 코드로 변환해놓지 않는지도 알고 있어야 하겠다
"바이트 코드보다 네이티브 코드가 훨씬 더 길기 때문에 모든 바이트 코드를 네이티브 코드로 변환해 놓으면,
메모리를 많이 차지한다는 점과, 네이티브 코드는 물리적인 사양이나 OS에 따라 달라질 수 있으므로,
어떠한 환경에서도 코드를 실행시켜 주는 JVM의 플랫폼 역할을 이룰 수 없게 된다"는 점을 자연스럽게 이해하자
3. 또한 객체를 생성하는 단 한 줄의 코드도 CPU 입장에서는 여러 연산 과정이 이뤄지는 것이며,
JVM은 이 부분 또한 성능 최적화를 이뤄내기 위해 병렬 처리하는 부분이 존재한다는 점을 기억하자
그래서 '메모리 가시성 문제'도 존재하는 것이다..
문제와 해결 방식을 외울게 아니라, 문제와 근본 원인을 계속 이해해야 한다
지금처럼 근본 원인을 이해하면, 해결 방식은 굳이 외우지 않아도 답변이 가능하게 된다
4. 두 번째 멘토링 시간에는 Thread와 Lock에 대해 어느 정도 알고 있는지 여쭤보셨고,
Thread는 "프로세스의 작은 작업 단위"라던가, "경량 프로세스"라고 표현했던 것 같다
Lock에 대해서는 뭐라고 표현했는지 기억도 잘 안나는 것 보면 제대로 된 답변을 못 했던 것 같다
스스로는 잘 알고 있는 개념이라고 생각했는데, 역시나 알고 있다고 착각했던 것에 불과했다.
처음으로 되돌아간다는 마음을 먹고 동시성 문제에 대한 기본 개념 이해부터,
Lock 성능 테스트까지 직접 눈으로 확인하게 되었고,
덕분에 추상적인 느낌이 있거나 논리적으로 앞뒤가 맞지 않았던 많은 의문점을 해소할 수 있었다
5. 성능 테스트를 하면서 OS 명령어 또는 JMH 같은 성능 측정 도구에 대한 사용법을 알게 된 이점도 있으나,
그보다 더욱 중요한 건, 학습을 통해 어떠한 상황에서 성능적으로 부하가 생길지 가설을 세울 수 있었고,
그러한 성능 부하를 어떠한 지표로 측정할 것인가를 판단하는 과정을 통해,
CPU CSW까지 측정하면서 S/W 개발자로서 "성능이 좋다", "나쁘다"가 같은 추상적인 사고가 아닌,
구체적인 수치를 제시하는 사고가 체득되기 시작했다는 것이다
6. Thread와 Lock에 대해 알아보면서 얻은 가장 큰 수확은 점점 내 코드 한 줄을 바라보는 시야가
JVM에서만 머물지 않고, CPU, OS, 캐시 메모리, 메인 메모리로 확장 됐다는 것이다
그렇기에 그냥 단순히 "아, 동시성 문제가 발생되기 전에 Lock을 걸어야겠구나"라는 관점에서
"동시성 문제를 해결하는 데 있어 성능 저하가 최소화되도록 적절한 조치를 고려해야겠구나"라는 관점으로 바뀌었으며,
덤으로 1주 차 때 부족한 기본기로 인해, 잘못 학습한 내용들을 바로 잡을 수 있었다
7. 지난 2주간의 학습을 토대로 앞으로 개념과 사고를 계속 확장시켜나가야 한다