[우아한테크코스] 10월 18일 TIL

2 minute read

[Java] JVM Warm up

서버가 실행된 후 처음 시작될 때와 요청이 없는 상태로 1~2시간이 경과하면 응답이 매우 느린 문제가 발생했다.

이유가 무엇인지 찾아보니 아래와 같았다.

jvm이 시작되면

  • Bootstrap Class Loading: JRE\lib \rt.jar java와 Object 같은 필수 클래스 로딩

  • Extension Class Loading: java.ext.dirs 모든 jar 파일 로딩

  • Application Class Loading: application class path 어플리케이션 클래스 경로에 있는 클래스 로딩

이렇게가 로딩되는데 이들은 지연 로딩을 기반으로 한다.

클래스로딩이 완료되면 JVM cache에 클래스들이 푸시되고, 그러면 런타임 중에 더욱 빠르게 접근할 수 있다.

보통의 첫 요청이 느린 것은 Just-In-Time Compile과 lazy class loading 때문이다.

  • JIT Compiler란?

    바이트코드를 cpu로 직접 보낼 수 있는 명령어로 바꾸는 프로그램으로 자바 런타임 환경에서 사용된다.

    (컴파일 시점) java source -> compiler -> bytecode -> (런타임 시점) JIT compiler -> native code

    바이트코드를 로드하고 컴파일 하는 데에 시간이 소요되어 application 실행 시나 캐싱되지 못해 새롭게 컴파일하는 경우 시작 지연이 발생한다.

    아래는 optimized java 책에 소개된 튜닝법 스크린샷 2021-10-19 오전 1 00 05

어플리케이션이 동작하는 동안 메서드는 네이티브 캐시에 로드된다. 중요한 메서드를 Tiered Compilation 이라는 VM 인수를 설정해서 강제로 캐시에 로드시킬 수도 있다. 지연시간이 긴 메서드의 경우에는 사전에 캐시에 올려둘 필요가 있다.

Baeldung 정리

첫 요청 뿐만 아니라 종종 응답이 느린 이유는 준비된 쓰레드 풀보다 요청이 많은 경우거나 jit 컴파일이 완전히 되지 않은 것을 예측!! 해볼 수 있다.(warmup 적용 후에도 발생할 수 있는 문제)

JVM

  • jvm architecture

    image

  • jvm memory 구조

    image

    code cache 영역에 jit compiler가 데이터 저장, 자주 접근하는 컴파일된 코드 블록이 저장된다.

    baeldung code cache

    oracle 공식 문서

    jconsole을 터미널에 입력하면 코드캐치 정보를 볼 수 있다. -Dcom.sun.management.jmxremote.ssl=false 이 옵션 줘야 잘 뜸

    jar 파일 구동 시 -XX:+PrintCodeCache 옵션 주면 코드 캐시 결과가 마지막에 뜸

  • jvm cache

    IBM 문서

개선방법

따라서 개발자가 제어할 수 있는 이벤트 중 가장 마지막에 수행되는 ApplicationReadyEvent 를 이용해 자주 사용되는 메서드를 WarmUp 하도록 할 수 있었다.

SpringApplication.run() 수행 시 발생하는 코드

public ConfigurableApplicationContext run(String... args) { 
  StopWatch stopWatch = new StopWatch(); 
  stopWatch.start(); 
  ConfigurableApplicationContext context = null; 
  Collection<SpringBootExceptionReporter> exceptionReporters = new ArrayList<>(); 
  configureHeadlessProperty(); 
  SpringApplicationRunListeners listeners = getRunListeners(args);//스프링부트가 기본적으로 사용하는 리스너들 등록 
  listeners.starting();//starting과 관련된 이벤트를 호출(ApplicationStartingEvent) 
  try { ApplicationArguments applicationArguments = new DefaultApplicationArguments(args);//arguments 가져오고 
       ConfigurableEnvironment environment = prepareEnvironment(listeners, applicationArguments);//환경정보 가져오고 
       configureIgnoreBeanInfo(environment); 
       Banner printedBanner = printBanner(environment);//배너 찍고 
       context = createApplicationContext();//context 생성하고 
       exceptionReporters = getSpringFactoriesInstances(SpringBootExceptionReporter.class, new Class[] { ConfigurableApplicationContext.class }, context);//스프링 내부에서 사용하는 구성정보를 들고오고 
       prepareContext(context, environment, listeners, applicationArguments, printedBanner);//가져온 것들로 컨텍스트 준비하고 
       refreshContext(context);//준비가 끝나면 리프레시 
       afterRefresh(context, applicationArguments);//리프레시 후에 뭔가 해야할 것들 호출하고 
       stopWatch.stop(); if (this.logStartupInfo) { new StartupInfoLogger(this.mainApplicationClass).logStarted(getApplicationLog(), stopWatch); } listeners.started(context);//ApplicationStartedEvent 발생하는 곳
       callRunners(context, applicationArguments);//ApplicationRunner, CommandLineRunner 실행해주는 곳 
      } catch (Throwable ex) { 
    handleRunFailure(context, ex, exceptionReporters, listeners);//ApplicationFailedEvent 
    throw new IllegalStateException(ex); } 
  try { listeners.running(context);//running과 관련된 이벤트 
      } catch (Throwable ex) { handleRunFailure(context, ex, exceptionReporters, null); throw new IllegalStateException(ex); } return context; }

출처: https://jeong-pro.tistory.com/206 [기본기를 쌓는 정아마추어 코딩블로그]
  1. ApplicationWarmUp Bean 생성

    @Component
    @Slf4j
    public class ApplicationWarmUp {
        @Scheduled(fixedDelay = 60 * 60 * 1000)
        @EventListener(ApplicationReadyEvent.class)
        public void warmUp() {
            log.info("application warm up 시작");
            login();
        }
       
        private void login() {
            LoginRequest loginRequest = new LoginRequest("pobi@woowa.com", "test1234");
            TokenResponse tokenResponse = WebClient.builder()
                    .baseUrl("http://localhost:8080") //"https://k8s.zzimkkong.com")
                    .defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
                    .build()
                    .post()
                    .uri("/api/managers/login/token")
                    .bodyValue(loginRequest)
                    .retrieve()
                    .bodyToMono(TokenResponse.class)
                    .blockOptional()
                    .orElseThrow(IllegalAccessError::new);
            String accessToken = tokenResponse.getAccessToken();
            log.info("로그인 warm up 성공");
       
            createMap(accessToken);
        }
    }
    
  2. Application에 EnabledScheduled 등록

    @SpringBootApplication
    @EnableScheduling
    public class ZzimkkongApplication {
        public static void main(String[] args) {
            TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"));
            SpringApplication.run(ZzimkkongApplication.class, args);
        }
    }
    

결과

기존 로컬에서 400ms -> 200ms 이하로 줄었고, 이후 postman 요청 결과 200ms와 유사한 응답 속도가 보여 성공적으로 개선되었다.

사실 이게 맞는진 모르겠지만 아래 현업에서도 적용한 것 같길래.. 효과적이라고 예상!

참고