이전 실험에서 왜 "JIT 컴파일"이 문제가 되는지 async-profiler 실험에서 이미 설명한 바가 있기때문에 해당 사실에 대해서는 이미 알고 있다고 전제하겠습니다.
일단 전편에 이어 이번엔 정확히 어디에서 JIT 컴파일이 이루어지는지 찾아야 한다. JIT 컴파일은 내부적으로 메서드 인라이닝, 스칼라 치환, 탈출 분석, 잠금 제거 및 병합 등을 수행하면서 네이티브 코드의 성능을 향상시키는데에 목적이 있다. 이러한 JIT 컴파일이 유리하게 애초에 코드를 작성하는 게 성능향상에 도움이 됨.
다만, 어디에서 주로 JIT 컴파일이 이루어지는 지 정확히 파악하고 해당 부분에 개선을 해야하기 때문에, 이를 파악하기 위해 JMC(JDK Mission Control), JIT Watch를 사용해봤다.
고로 이번 포스팅에선 원인을 분석(어디에서 JIT 컴파일이 수행되는지 찾기)하는데에 집중해보고자 한다. 바로 후속편으로 이에 대한 조치를 취하고 얼만큼의 성능향상을 이룩했는지 작성할 예정이다.
JMC, JIT Watch 등 사용에 미숙한 면이 많습니다. 만약 잘못 파악하고 있다면 첨언 부탁드려요 🙇♀️
Java Mission Control (JMC)은 Java 애플리케이션의 성능 모니터링 및 진단을 위한 도구입니다. Oracle JDK에 포함되어 있으며, JVM의 런타임 동작을 분석하고 최적화하는 데 사용됩니다.
JITWatch는 Java HotSpot JVM의 Just-In-Time (JIT) 컴파일러 동작을 분석하고 시각화하는 오픈소스 도구입니다. AdoptOpenJDK에서 개발되었으며, JVM의 컴파일 로그(hotspot.log)를 처리하여 개발자들이 JIT 최적화 과정을 이해하고 성능을 개선할 수 있도록 돕습니다.
Gatling은 오픈소스 부하 테스트 도구로, 주로 웹 애플리케이션의 성능 및 부하 테스트를 위해 사용됩니다. Scala로 작성되었으며, Akka와 Netty를 기반으로 비동기 방식으로 높은 성능을 제공합니다.
지난번처럼 부하 테스트 자체는 Gatling을 사용했습니다.
우선, hotspot.log와 jfr을 수집해야하기 때문에 인텔리제이에 다음과 같은 vm 옵션을 추가해야합니다.
-XX:StartFlightRecording=filename=jit-profile/recording.jfr,duration=300s,settings=profile
-XX:+UnlockDiagnosticVMOptions
-XX:+LogCompilation
-XX:LogFile=jit-profile/hotspot_%p.log
-XX:+PrintInlining
-XX:+PrintCompilation
-XX:+PrintCodeCache
-XX:+PrintAssembly
-XX:PrintAssemblyOptions=intel
-XX:CompileCommand=print,*benchmark*
-XX:+DebugNonSafepoints
-XX:StartFlightRecording=filename=jit-profile/recording.jfr,duration=300s,settings=profile
filename=jit-profile/recording.jfr: 기록된 데이터를 jit-profile/recording.jfr 파일에 저장duration=300s: 기록을 300초 동안 진행settings=profile: JFR의 'profile' 설정 템플릿을 사용해 상세한 성능 데이터를 수집(CPU, 메모리, 스레드 등). -XX:+UnlockDiagnosticVMOptions
LogCompilation, PrintInlining)을 사용 가능하게 함-XX:+LogCompilation
-XX:LogFile로 지정한 파일로 저장됨-XX:LogFile=jit-profile/hotspot_%p.log
LogCompilation 출력)의 저장 경로를 지정%p는 프로세스 ID를 나타내며, 로그 파일은 jit-profile/hotspot_<PID>.log로 생성-XX:+PrintInlining
-XX:+PrintCompilation
-XX:+PrintCodeCache
-XX:+PrintAssembly
-XX:PrintAssemblyOptions=intel
intel 설정으로 더 읽기 쉬운 Intel 구문 사용-XX:CompileCommand=print,benchmark
*benchmark* 패턴에 매칭되는 메서드)에 대해 컴파일 정보를 출력하도록 지정print는 해당 메서드의 컴파일 과정을 상세히 로그로 기록-XX:+DebugNonSafepoints
어셈블리 수준의 최적화를 진행할 것이 아니라면 당연히 해당 옵션은 필요없습니다.. 저는 그냥 궁금해서 열어봤어요.
기존의 아주 간단한 Hello 시나리오는 모자랄 것 같아, 더 큰 부하를 가하는 시나리오를 새로 작성했습니다. 우선은 warm-up에 대한 문제를 해결하기 위함이고, 초반에 JIT 컴파일이 어디서 나타나는지 찾기 위함이니 요청 수를 높게 설정했습니다.
package benchmark;
import io.gatling.javaapi.core.*;
import io.gatling.javaapi.http.*;
import static io.gatling.javaapi.core.CoreDsl.*;
import static io.gatling.javaapi.http.HttpDsl.*;
import java.time.Duration;
/**
* Heavy Load Simulation for JIT Profiling
* - Warm-up: ~20,000 requests
* - Load test: ~80,000 requests
* - Total: ~100,000 requests
*/
public class HeavyLoadSimulation extends Simulation {
HttpProtocolBuilder httpProtocol = http
.baseUrl("http://localhost:8080")
.acceptHeader("text/plain,application/json")
.userAgentHeader("Gatling Heavy Load Test")
.shareConnections() // 커넥션 재사용
.disableFollowRedirect()
.disableWarmUp() // Gatling 기본 warm-up 비활성화
.maxConnectionsPerHost(500)
.connectionHeader("keep-alive")
.header("Keep-Alive", "timeout=5, max=1000");
// Warm-up 시나리오: 약 20,000 요청
// 목적: 서버 JVM JIT 컴파일, 스레드풀, 커넥션 풀 예열
ScenarioBuilder warmUp = scenario("Warm-up Phase")
.exec(
http("Warm-up request")
.get("/benchmark/hello")
.check(status().is(200))
);
// Heavy Load 시나리오: 약 80,000 요청
// 목적: JIT 컴파일 완료 후 steady-state 성능 측정
ScenarioBuilder heavyLoad = scenario("Heavy Load Test")
.exec(
http("GET /benchmark/hello")
.get("/benchmark/hello")
.check(status().is(200))
);
{
setUp(
// ========================================
// Phase 1: Warm-up (~20,000 requests)
// ========================================
warmUp.injectOpen(
// 점진적 증가: 0 -> 100 RPS (5초)
rampUsersPerSec(0).to(100).during(Duration.ofSeconds(5)),
// 안정적 부하: 100 RPS (10초) = 1,000 requests
constantUsersPerSec(100).during(Duration.ofSeconds(10)),
// 중간 부하: 300 RPS (20초) = 6,000 requests
constantUsersPerSec(300).during(Duration.ofSeconds(20)),
// 고부하: 500 RPS (25초) = 12,500 requests
constantUsersPerSec(500).during(Duration.ofSeconds(25))
// Total: ~19,500 requests
).protocols(httpProtocol),
// ========================================
// Phase 2: Heavy Load (~80,000 requests)
// ========================================
heavyLoad.injectOpen(
// warm-up 완료 대기
nothingFor(Duration.ofSeconds(60)),
// 점진적 램프업: 100 -> 1000 RPS (10초)
rampUsersPerSec(100).to(1000).during(Duration.ofSeconds(10)),
// 고부하 구간 1: 1000 RPS (30초) = 30,000 requests
constantUsersPerSec(1000).during(Duration.ofSeconds(30)),
// 최대 부하: 1000 -> 1500 RPS (10초)
rampUsersPerSec(1000).to(1500).during(Duration.ofSeconds(10)),
// 고부하 구간 2: 1500 RPS (30초) = 45,000 requests
constantUsersPerSec(1500).during(Duration.ofSeconds(30)),
// 점진적 감소: 1500 -> 500 RPS (10초)
rampUsersPerSec(1500).to(500).during(Duration.ofSeconds(10))
// Total: ~80,000 requests
).protocols(httpProtocol)
)
.assertions(
// 최대 응답시간 1초 이하
global().responseTime().max().lt(1000),
// 95 percentile 응답시간 500ms 이하
global().responseTime().percentile3().lt(500),
// 성공률 99% 이상
global().successfulRequests().percent().gt(99.0),
// 초당 요청 처리량 (throughput) 체크
global().requestsPerSec().gt(500.0)
);
}
}
HeavyLoadSimulation은 Gatling을 사용한 Java 애플리케이션 부하 테스트 시나리오로, JIT 컴파일 프로파일링을 위해 설계했습니다. 약 100,000개의 HTTP 요청을 생성하며, 두 단계로 구성됩니다.
Warm-up Phase (~20,000 요청)
/benchmark/hello 엔드포인트에 GET 요청Heavy Load Phase (~80,000 요청)
/benchmark/hello)에 GET 요청HTTP 설정
http://localhost:8080.검증 조건
이 스크립트는 JVM의 JIT 최적화 동작을 분석하기 위해 초기 예열 후 고부하 테스트를 수행하며, 성능 지표를 엄격히 검증하고자 했습니다.
지금까지 제가 직접 만들었던 프레임워크이자 서버가 모델입니다.
이번에 선정한 모델은 hybrid + VT 조합이고 가장 안정적일 것이라 기대되는 모델을 골랐습니다. 해당 조합에서도 JIT 컴파일 관련 문제는 동일하게 발생했습니다.
톰캣과 비슷한 구조로 동작하는 모델입니다.
async-profiler 를 사용하여 메모리 할당에 큰 문제가 있던 부분을 개선
byteBuffer 할당이 잦아 해당 부분에 ByteBufferPool을 도입하여 GC 압박 감소 -> JMX 부하(CPU 작업, 메트릭 수집을 위한 작업입니다) 감소 -> 결론적으로 CPU에 비교적 여유가 발생하여 성능 향상해당 성능 개선 이후, controller를 찾는 부분에 있어 Pattern 매칭과 관련하여 메모리 할당이 잦아 해당 부분에 간단한 캐싱 계층 추가.
// 기존에 사용하던 캐시
private final Map<PathPattern, Map<HttpMethod, RequestMappingInfo>> mappings = new ConcurrentHashMap<>();
// 개선을 위해 추가한 캐시
private final Map<String, PathPattern> pathPatterns = new ConcurrentHashMap<>();
로직 초반에 다음과 같은 추가 리턴문을 두어 캐싱하도록 하였습니다.
if (pathPatterns.containsKey(path)) {
return mappings.get(pathPatterns.get(path)).get(httpMethod);
}
// 생략
pathPatterns.put(path, matchingHandlers.getFirst().pattern());
약간의 메모리 개선, 이로 인한 GC 압력이 어느정도 감소했을 것이라 추측됩니다.
#!/bin/bash
OUTPUT_DIR="./jit-profile"
PORT=8080
# ================================================
# 준비
# ================================================
mkdir -p "$OUTPUT_DIR"
echo "════════════════════════════════════════════════════════════════"
echo " 🌱 Sprout JIT Profiling with Gatling"
echo "════════════════════════════════════════════════════════════════"
echo ""
# ================================================
# 서버 구동 확인
# ================================================
echo "[1/3] Checking if Sprout server is running..."
if ! curl -s "http://localhost:$PORT/benchmark/hello" > /dev/null 2>&1; then
echo ""
echo "Server is NOT running on port $PORT"
echo ""
echo "Start the server from IntelliJ with these VM options:"
echo "────────────────────────────────────────────────────────────────"
cat << 'VMEOF'
--add-opens=java.base/java.lang=ALL-UNNAMED
--add-opens=java.base/java.lang.reflect=ALL-UNNAMED
--add-opens=java.base/java.io=ALL-UNNAMED
--add-opens=java.base/java.util=ALL-UNNAMED
-XX:StartFlightRecording=filename=jit-profile/recording.jfr,duration=300s,settings=profile
-XX:+UnlockDiagnosticVMOptions
-XX:+LogCompilation
-XX:LogFile=jit-profile/hotspot_%p.log
-XX:+PrintInlining
-XX:+PrintCompilation
-XX:+PrintCodeCache
-XX:+PrintAssembly
-XX:PrintAssemblyOptions=intel
-XX:CompileCommand=print,*benchmark*
-XX:+DebugNonSafepoints
VMEOF
echo "────────────────────────────────────────────────────────────────"
echo ""
exit 1
fi
echo "✓ Server is running on port $PORT"
echo ""
# ================================================
# Gatling 부하 테스트
# ================================================
echo "[2/3] Running Gatling Heavy Load test..."
echo "────────────────────────────────────────────────────────────────"
echo " Simulation: HeavyLoadSimulation"
echo " Warm-up: ~20,000 requests"
echo " Load test: ~80,000 requests"
echo " Total: ~100,000 requests"
echo "────────────────────────────────────────────────────────────────"
echo ""
# Gatling 실행
./gradlew gatlingRun --simulation=benchmark.HeavyLoadSimulation
GATLING_EXIT_CODE=$?
if [ $GATLING_EXIT_CODE -ne 0 ]; then
echo ""
echo "Gatling test failed with exit code: $GATLING_EXIT_CODE"
exit $GATLING_EXIT_CODE
fi
echo ""
echo "✓ Gatling test completed"
echo ""
# ================================================
# 결과 정리
# ================================================
echo "[3/3] Collecting profiling results..."
echo ""
# Gatling 리포트 경로 찾기
LATEST_GATLING_REPORT=$(find build/reports/gatling -type d -name "*HeavyLoadSimulation*" 2>/dev/null | sort -r | head -n 1)
echo "════════════════════════════════════════════════════════════════"
echo " Profiling Complete!"
echo "════════════════════════════════════════════════════════════════"
echo ""
echo "Profiling Data:"
echo " • JIT Compilation Log: $OUTPUT_DIR/hotspot_*.log"
echo " • JFR Recording: $OUTPUT_DIR/recording.jfr"
echo " • Assembly Output: $OUTPUT_DIR/hotspot_*.log (if hsdis available)"
echo ""
echo "Gatling Report:"
if [ -n "$LATEST_GATLING_REPORT" ]; then
echo " • HTML Report: $LATEST_GATLING_REPORT/index.html"
echo ""
echo " Open report:"
echo " open $LATEST_GATLING_REPORT/index.html"
else
echo " • Check: build/reports/gatling/"
fi
echo ""
echo "Analysis Tools"
echo " 1. JITWatch: Analyze hotspot_*.log for JIT compilation details"
echo " 2. JDK Mission Control: Open recording.jfr for performance profiling"
echo " 3. Gatling: View HTML report for load test metrics"
echo ""
echo "════════════════════════════════════════════════════════════════"
이 스크립트는 Gatling을 사용한 부하 테스트와 JVM의 JIT 프로파일링을 자동화하여 Java 애플리케이션의 성능을 분석하도록 작성한 스크립트입니다.
준비 단계
./jit-profile 디렉토리를 생성하여 결과 파일을 저장localhost:8080에서 실행 중인지 확인Gatling 부하 테스트
HeavyLoadSimulation 클래스를 실행하여 약 100,000개의 요청 생성결과 정리
recording.jfr), JIT 컴파일 로그(hotspot_*.log), Gatling HTML 리포트를 수집주요 출력
jit-profile/hotspot_*.log (JITWatch로 분석)jit-profile/recording.jfr (JMC로 분석)build/reports/gatling/*/index.html (성능 메트릭 확인)그냥 서버 실행하고 해당 스크립트 실행하면 됩니다. 위에서 설명했던 Gatling 시나리오 실행하고 리포트 위치를 알려줍니다.
솔직히 십만요청..? 걍 서버 뻗을까봐 굉장히 쫄렸음..




테스트 자체는 2분 29초 정도 소요되었구요, 요청을 생각보단 그래도 잘 처리하긴 하네요...?(99.73%) 역시 마찬가지로 초기 warm-up이 문제이고, JIT 컴파일이 잘 이루어진 경우엔 KO가 발생하지 않습니다.
KO 없이 요청이 잘 이루어지는 시간 분기점은 서버 실행 후 약 1분 뒤임을 그래프에서 확인할 수 있었습니다.
이제 정확히 어디서 컴파일이 발생하는지 찾아보면 됩니다.

JDK Mission Control을 열고 .jfr 파일을 Import 해줍니다.
JMC에서 JIT 컴파일에 대한 정보는 왼쪽 Compliations 탭에 위치합니다.

해당 그래프는 JVM의 JIT 컴파일러가 메서드를 컴파일하는 데 걸리는 시간(Compilation Duration)을 시각적으로 보여주고 있다.
컴파일 시간 분포
피크 분석
이제 해당하는 작업이 뭔지 보면 된다. 가장 피크인 부분부터 보자.

이게 가장 긴(4.75ms)에 달하던 로직들이다. JIT는 해당 부분이 코드적으로 빨라져야 한다고 생각했던 것임..
aseLocale sun.util.locale.BaseLocale.getInstance(String, String, String, String) : Java의 로케일(Locale) 정보를 생성하는 메서드인데, 내가 작성한 애플리케이션에는 로케일 관련 작업은 따로 없음. 인스턴스 생성 자체에 필요한 로직
Resource jdk.internal.loader.URLClassPath$JarLoader.getResource(String, boolean): 이 메서드는 클래스 로더가 동적으로 리소스(설정 파일, 라이브러리 등)를 로드할 때 호출된다. 성능 피크는 클래스 로드 빈도가 높다는 것을 의미함.
String sprout.server.HttpUtils.readRawRequest(ByteBuffer, InputStream): 제가 작성한 HttpUtils 클래스 내 readRawRequest 메서드. ByteBuffer와 InputStream을 사용해 HTTP 요청 데이터를 읽는 역할을 함. 실행 시간이 길다면 I/O 처리나 데이터 변환에서 병목이 있을 것이다..
boolean jdk.internal.org.objectweb.asm.Frame.merge(SymbolTable, Frame, int): 이는 동적 바이트코드 생성이나 변환(예: JIT 컴파일, 프록시 생성 등)이 발생할 때 호출된다.
void java.io.ObjectOutputStream.writeOrdinaryObject(Object, ObjectStreamClass, boolean): 객체를 직렬화하여 네트워크나 파일로 전송할 때 호출, 근데 당연히 십만번 호출하면.. 컴파일 하고 싶을듯?
또 다른 병목 지점들을 살펴보자. JIT 컴파일의 이유에 대해서 정확히 유추할 필요는 없다. 조금이따 JIT Watch로 확인할 수 있음.

이 부분은 약 3ms정도 소요됨.

이 스레드에서는 약 2ms 정도 소모,

디스패처에 요청 할당시키는 부분에서도 컴파일에 2ms정도 소모됨.
내가 작성한 로직에 대해서는 이정도 였고 나머진 또 바이트코드 조작 부분이 대부분이었음. java assist 같은..ㅇㅇ
사실 전편의 async-profiler에서 파악했던 부분들과 상당히 똑같다.. ㅋㅋㅋ
우선 GC는 G1을 사용함. 물론 저지연 GC인 셰넌도어나 ZGC도 굉장히 훌륭하지만.. 저지연을 위해 애플리케이션 스레드랑 같이 활동하는 GC 스레드를 사용하기 때문에, 지금 JIT 컴파일 스레드 때문에 초반 부하가 감당이 안되는 상황에서는 ㅋㅋ CPU를 더 쓰느니 차라리 멈추고 실행하는 G1을 쓰는게 낫지 않을까 합니다.. 별 관측치는 없고 제 생각임..
아무튼, 현재 구조에서의 GC를 추가로 판단해볼 수 있기 때문에, 이에 대한 데이터도 보면 좋을 것 같아요.

실제로 요청은 2분 30초 발생했으니 36분 30초부터 39분까지의 내역을 보면 될 것 같음. 애초에 저 구간에서 요청-응답 파이프라인이 먹통임. JIT 컴파일하느라.
그 이후부터 GC가 실제로 일을 하기 시작하는 모습인 듯 합니다. 초반에 아예 처리를 하지 않는건 아닌듯 합니다.
GC 스레드가 중후반에 저렇게 활동이 가능하려면, 해당 부분 전엔 C2 컴파일까지 종료되었을 확률이 높을 것 같다.
현재 응답을 읽어들이는 쪽에서는 "바이트 버퍼풀"을 사용하는데, 내려주는 쪽은 응답을 새로 생성함. 그리고 응답처리 부분에서 HttpRequest와 HttpResponse를 계속 만드는데, 이에 대한 쓰레기들을 줍는 것으로 예측됩니다.
해당 객체들도 전부 풀로 만들면 더 나아질 것이라 생각됨.
GC 언어에서는 그나마 풀링이 메모리 주도권을 잡을 어쩌면 유일한 방법이 아닐까..

위의 이미지에서 볼 수 있겠지만 제가 측정을 잘못해서, 요청은 2분 30초정도 발생하는데, 관측은 5분했음. 감안해서 2분 30초로 계산할게요.
이 정도는 GC 부하가 거의 없는 수준이라고 보통은 평가됩니다.
Young Generation GC
Old Generation GC
전체 정지 시간(Pause Time)
GC 빈도
원래 더 안좋았겠지만, 최근 진행한 개선이 영향을 준 것 같음.
근데 만약 JIT 컴파일링 문제가 해결되면, 이 부분은 다시 살펴봐야함 초반부터 GC가 일어나게 될테니 지표가 더 튈 가능성이 높음.
이거 사용이 너무 서툴러서; 비교적 더 짧은 로그로 만들어서 사용했습니다.

JIT Watch의 UI는 이런식이다. Open Log에서 핫스팟 로그를 열어주고, Config에서 다음과 같이 폴더를 위치시켜주면 된다.
| 항목 | 의미 | 넣어야 하는 경로 |
|---|---|---|
| Class Locations | JITWatch가 로그에 기록된 클래스(.class)를 찾아 디컴파일하거나 바이트코드 보기용으로 사용 | 컴파일 결과 폴더 (예: build/classes/java/main) 또는 .jar 파일 경로 |
| Source Locations | Java 원본 코드(.java)를 찾아 TriView 왼쪽 패널에 표시 | src/main/java 같은 소스 루트 디렉터리 |
이제 JMC에서 찾았던 병목 지점들을 확인해보자.
로그도 찾아서 넣어주고 Start를 누르면됨.

하단에 이런 로그가 보임.
Finished reading log file.
Finding code suggestions.
Found 2196 code suggestions.
Finding eliminated allocations
Found 33 eliminated allocations.
Finding optimised locks
Found 0 optimised locks.
HotSpot이 컴파일했거나 인라이닝을 고려했지만 최적화 여지가 있는 메서드 2,196개를 발견했다는 뜻
탈출 분석 결과, 힙 할당을 스택으로 전환해 없앤 최적화가 33번 발생함
락 제거(lock elision) 최적화는 일어나지 않음 -> 락 경쟁이 없거나, 코드가 락을 사용하지 않음 (이건 문제없다..)

이와 같이 제안을 해준다.

실제로 JIT 컴파일이 이루어졌다고 보여짐 ㅋㅋㅋ JMC에서 확인했던 readRawRequest도 포함됨을 알 수 있다. 그리고 다른 메서드들도 컴파일이 되었음을 확인할 수 있다.
여기에서 상단의 TriView를 누르면 더 상세하게 확인할 수 있다.

이렇게 두면 소스코드 / 자바 바이트코드 / 어셈블리까지 확인할 수 있다.
실제로 C2 컴파일까지 이루어진걸 확인할 수 있다.
일단 이 부분에서 뭐라고 제안해주는지도 보자.
일단 8개 검출해줌..

전부 rawRequest 관련 제안이다.
해당 메서드에 대한 조언들을 살펴보니 다음과 같았다.
String(byte[], Charset) 생성자의 스택 프레임 크기가 C1 인라이너 한계를 초과한 것new String(bytes, UTF_8)을 빈번하게 생성하는 경우라면 이또한 풀링으로 재사용한다거나 해야할 것 같다일단 이러한 코드 개선점은 받아서 리팩토링에 활용하는 것으로 하자.
이러한 부분이 당장의 메모리 할당 등에서도 효과가 있겠지만, JIT 친화적으로 코드를 작성할 경우, JIT 컴파일이 “더 빠르게”, “더 많이”, 그리고 “더 효과적으로” 일어난다
하지만 지금 코드처럼
이런 요소가 있으면 JIT이 더 프로파일링 데이터가 쌓이고 시도하려고 한다. 그래서 컴파일 자체가 지연되거나 부분 컴파일만 일어남. 코드를 더 단순화 하면 이런 "조건 충족 지연"이 사라져 초기 워밍업 (인터프리터 -> C1 -> C2) 구간이 짧아진다.

이 이미지는 대표적인 인라이닝 실패 이유들을 보여줌.
실제로 대부분의 인라이닝 실패 이유가 "너무 커서" 임

바이트코드 58번 위치에 브랜치(조건문, 예: if-else)가 있으며,
이는 10,412번 관찰되었고 실행 확률이 50%입니다.
이는 CPU의 브랜치 예측기(Branch Predictor)가 실패할 확률이 높아 성능 저하를 초래합니다.
JVM이 예측 실패 시 파이프라인을 플러시해야 하므로 지연이 발생.
바이트코드 349번 위치에서 InterceptorChain.applyAfterCompletion 메서드를 호출하지만,
인라이닝(메서드 코드를 호출자에 직접 삽입)되지 않았습니다.
이유는 호출 대상 메서드 크기(49바이트)가 너무 커서입니다.
인라이닝 실패 시 메서드 호출 오버헤드(스택 프레임 생성 등)가 증가.
바이트코드 61번 위치에서 FilterChain.doFilter 메서드를 호출하지만, 인라이닝되지 않았습니다.
이유는 메서드 크기(60바이트)가 C1 컴파일러의 최대 인라이닝 크기를 초과했기 때문.
호출 횟수는 3,069번으로 빈번.
C1 컴파일러는 C2보다 인라이닝 기준이 엄격하다, 이는 초기 컴파일 단계에서 발생한 것이다.
Tiered Compilation에서 C1 후 C2로 승격되지만, 여전히 오버헤드가 존재함.
doFilter 메서드를 더 작게 분할하자다른 부분에 대해서도 추가적인 조언들이 많지만 너무 포스팅이 길어져서 여기에서 마무리 하려고 한다. 깨닫게 된건 의외로 async-profiler 정도로도 문제 원인은 대부분 동일하게 잡아낸다는 것.
JMC, JIT Watch도 너무 다루는게 서툴러서.. 좀 더 해봐야할 듯?
최대한 코드를, 특히 서버 I/O가 엮인 쪽은 코드를 JIT 친화적으로 개선해야함을 깨달았다. 객체 생성 부분에 있어서도, 최대한 재사용하는 방안으로 개선하면 좋을 것 같다. 요청객체/응답객체도 풀링하고, 메서드 쪼개고, 분기예측 쉽게 early return 으로 전반적 코드 개선이 필요할 듯? 솔직히 스칼라 대체같은거 생각하면 보기 싫어도 변수들도 오히려 정말 잘게 쪼개놓아야 하지만, 메서드 스택이 또 너무 많으면 컴파일이 어려워져서 적절하게해야 하는데 이게 어려울 듯
추가로 톰캣이나 네티나 Undertow 같은 곳에서는 JIT 컴파일링 문제를 안겪는지, 만약 겪는다면 어떻게 해결하는지 찾아봤는데,
톰캣에서 “10 Tips for performance tuning” 같은 문서엔 JIT 직접 언급은 적지만, JVM 옵션(-XX:+TieredCompilation, -XX:CompileThreshold, -XX:+UseJITCompilationMonitoring 등) 설정이 간접적으로 영향을 준다는 내용이 있었음.
그리고 다들 부하테스트시에는, 워밍업을 무조건 거침. JIT 특성상 어쩔수가 없는 듯 하다.
실제로 Netty 팀의 마이크로벤치마크(netty-microbench)나 CI용 성능 테스트를 보면, 항상 초반 수천~수만 번 요청을 워밍업으로 버린다.
Undertow의 벤치마크 문서나 RedHat 퍼포먼스 팀의 실험을 보면, 초반 수십 초~1분간의 워밍업을 두고 이후부터 지표를 수집한다. (일반적으로 1분 이상 워밍업을 하지 않으면 Throughput이 매 측정마다 다르게 나온다..)
그래서 운영이나 벤치마크 팀에서도 트래픽을 일부러 만들거나, 리버스 프록시에서 받은 요청을 재시도하도록 해두기도 함.
그냥 나도 내부적으로 더미 데이터를 자체적으로 보내서 자체 워밍업을 시켜버릴까 싶기도 하다.. 최근 진행한 성능 관측에서는 플랫폼 스레드(스레드 풀) 모델을 사용하지 않았지만, 스레드 풀 생성을 eager로 생성하도록 해야할듯?