DevSprout/optimizing-java

Chapter 09. JVM의 코드 실행

Opened this issue · 4 comments

Chapter 09. JVM의 코드 실행

정리

  • 바이트코드 해석
    • JVM 인터프리터는 일종의 Stack Machine처럼 작동함
      • 물리적 CPU와는 달리 계산 결과를 바로 보관하는 레지스터는 없음
      • 작업할 값은 모두 평가 스택에 둠
  • JVM에서 데이터를 담아놓는 공간
    • 평가 스택: 메서드별 하나씩 생성
    • 로컬 변수: 결과를 임시 저장
    • 객체 힙: 메서드끼리, 스레드끼리 공유
  • JVM Bytecode
    • Stack Machine 작업 코드(opcode)는 1byte로 나타냄 (그래서 이름도 'byte'code)
    • 0 ~ 255 까지 지정 가능하며, Java 17 Hotspot 기준으로 약 203개

    • Java는 처음부터 이식성을 염두에 두었음
      • Big Endian, Little Endian 하드웨어 아키텍처 모두 바이트코드 변경없이 실행 가능하도록 명세에 규정
    • 자바 1.0 이후 새로 추가된 opcode는 invokedynamic 하나뿐이라고 나와있는데, 찾아보니 더 있음

      • ChatGPT 말로는 더 여러개 있다는데 GPT가 틀린 것 같기도..!

  • Bytecode Categories
    • load / store
    • 산술
    • 흐름 제어
      • tableswitch, lookupswitch는 java 7에 추�가

    • 메서드 호출
  • AOT(Ahead-Of-Time) & JIT(Just-In-Time)
    • AOT: 미리 실행환경에 맞게 Binary code(a.k.a Native code, Machine language)로 컴파일해둠
      • 최적화할 수 있는 순간이 딱 한 번뿐
      • 이식성 안 좋음
      • 대신 실행환경 정해져있으면 그에 최적화할 수 있고, 워밍업이 필요 없어 빨리 구동 가능
    • JIT: 우선 JVM Bytecode로 컴파일한 후, 인터프리터가 매번 번역해서 실행하면서, 프로파일링을 따로 돌리다가, 많이 실행되는 메서드는 통째로 바이너리코드로 컴파일해서 Code Cache라는 메모리 공간에 캐싱해두고 사용함
      • 실행하면서 최적화 가능
      • 이식성 좋음
      • 워밍업을 해야하고, 초기 구동속도가 상대적으로 느림
      • -XX:+PrintCompilation 플래그를 이용해서 stdout으로 컴파일 이벤트 로그 출력 가능
      • Code Cache 공간은 초기 실행 시 max값이 지정되며 더 이상 확장되지 않음
        • 꽉 차면 더 이상 JIT 컴파일 되지 않음
        • Code Cache 단편화 문제가 있을 수 있음
        • Oracle java8 doc - codecache

        • InitialCodeCacheSize – the initial code cache size, 160K default

        • -XX:ReservedCodeCacheSize – the default maximum size is 48MB

          • Java9부터는 240MB로 바뀌었다고 함

          • image
        • CodeCacheExpansionSize – the expansion size of the code cache, 32KB or 64KB

        • –XX:+PrintCodeCache 플래그를 통해 확인 가능

  • 간단한 JIT 튜닝법
    • 컴파일을 원하는 메서드에게 아낌없이 리소스를 베풀라
      1. PrintCompilation 스위치를 켠다
      1. 컴파일 로그를 수집한다
      1. ReservedCodeCacheSize를 통해 코드 캐시를 늘린다
    • 늘렸는데도 컴파일드 메서드 수가 같다면 늘려도 성능 향상 효과 없다는 것

더 알아본 내용

정리

  • 인프프리터로 해석하여 구동하는 환경은 대체로 기계어를 직접 실행하는 환경보다 성능이 떨어짐. 그래서 최신 자바 환경은 동적 컴파일 기능을 통해 이 기능을 해결 → JIT 컴파일
  • 바이트 코드
    • JVM 인터프리터는 일종의 스택 머신처럼 작동

    • 바이트코드 해석 - 데이터 저장 공간

      • 평가 스택: 메서드별로 하나씩 생성
      • 로컬 변수: 결과를 임시로 저장
      • 객체 힙: 메서드끼리, 스레드끼리 공유
    • 각 스택 머신 작업 코드(옵코드)는 1 Byte

    • Big endian/Little endian 모두 바이트코드 변경없이 실행 가능하도록 명세에 규정되어 있음.

    • 자바 플랫폼 초창기에는 클래스 파일의 크기를 최대한 압축시키는 문제가 꽤 중요한 설계 결정이었다. 클래스 파일을 14.4Kbps 모뎀을 통해 내려받아야 했기 때문.

    • 메서드 호출 바이트 코드

      • invokevirtual: 인스턴스 메서드 호출
      • invokeinterface: 자바 인터페이스에 선언된 메서드 호출
      • invokespecial: 컴파일 타입에 디스패치할 메서드를 특정할 수 있는 경우 - 즉, private method나 super class
      • invokedynamic: Java 7 부터 지원을 시작했으며, 람다 표현식 등을 더 효율적으로 지원할 수 있게 되었다 (Less bytecode… etc)
    • SafePoint

      • JVM이 어떤 관리 작업을 수행하고 내부 상태를 일관되게 유지하는 데 필요한 지점.
      • 일관된 상태를 유지하려면 JVM이 관리 작업 수행 도중 공유 힙이 변경되지 않게 모든 Application 스레드를 멈추어야 한다.
      • JVM application thread 하나하나가 OS 스레드와 대응됨. Application 스레드가 실행하는 것은 유저 코드가 아니라 JVM 인터프리터 코드. 그렇기에 ‘바이트코드 사이사이’가 Application 스레드를 멈추기에 이상적인 시점.
    • AOT 컴파일 vs JIT 컴파일

      • AOT 컴파일: 실행할 플랫폼과 프로세서 아키텍처에 딱 맞은 실행 코드를 얻는 것
        • 실행할 하드웨어와 똑같은 하드웨어에서 빌드하면 컴파일러가 모든 프로세서의 최적화 기법을 총동원할 수 있을 것.
        • 문제는 확장성. 다양한 아키텍처에서 최대 성능을 내려면 아키텍처마다 특화된 실행 코드가 필요
        • Java 9에 AOT 컴파일을 지원했다고 하는데, Java17에 지워짐 (다른 Article을 보니깐 제한적인 사용이었고, Java16에서 지웠는데 아무도 불평하지 않았다고..)
      • JIT 컴파일: 런타임에 프로그램을 고도로 최적화한 기계어로 변환하는 기법. 프로그램의 런타임 실행 정보를 수집해서 어느 부분이 자주 쓰이고, 최적화해야 효과가 좋은지 프로파일을 만들어 결정 → PGO(Profile Guided Optimization)
        • 바이트 코드를 네이티브 코드로 컴파일 하는 비용은 런타임에 지불된다.
        • Application은 실행 시마다 성능이 편차를 보이기에 그 때 프로파일을 해서 optimize를 하는 것은 효과적일 것이다.
    • 핫스팟 JIT 기초

      • 핫스팟은 멀티스레드 C++ Application. OS 관점에서는 실제로 한 Multi-thread application의 일부

      • JIT 컴파일 서브시스템을 구성하는 스레드는 핫스팟 내부에서 가장 중요한 스레드 - 컴파일 대상 메서드를 찾아내는 프로파일링 스레드, 기계어를 생성하는 컴파일러 스레드도 다 포함

      • JIT 컴파일 로깅

        • 컴파일 이벤트 로그가 표준 출력 스트림에 생성
        -XX:PrintComilation
        • JIT 컴파일러가 어떤 결정을 내렸는지는 아래를 옵션. JITWatch라는 오픈 소스 툴을 이용하면, 로그파일을 파싱해서 더 이해하기 쉬운 형태로 나타낼 수 있음.
      -XX:LogCompilation
      -XX:UnlockDiagnosticVMOptions
      • 코드 캐시
        • JIT 컴파일드 코드는 코드 캐시라는 메모리 영역에 저장
        • VM 시작 시 코드 캐시는 설정된 값으로 최대 크기가 고정되므로 확장이 불가. 이 영역이 꽉 차면, 그 때부터 더 이상 JIT 컴파일은 안 되며, 컴파일되지 않은 코드는 인터프리터에서만 실행.
        • Java 8의 default code cache size: 240MB(with TieredCompilation)
      -XX:ReservedCodeCacheSize=
      • JIT 튜닝 대 원칙 - ‘컴파일을 원하는 메서드에게 아낌없이 리소스를 베풀라’는 것
        • 두 가지 명백한 사실
          • 캐시 크기를 늘리면 컴파일드 메서드 규모가 유의미한 방향으로 커지는가?
          • 주요 트랜잭션 경로상에 위치한 주요 메서드가 모두 컴파일되고 있는가?

개요

  • 표준 자바 구현체가 코드를 실행하는 방법은 보통 VMSpec이라고 부르는 자바 가상 머신 명세에 기술되어있다고 한다.
  • VM Spec에는 인터프리터로 자바 바이트코드를 실행하는 사양이 나오지만, 인터프리터로 해석하여 구동하는 환경은 대체로 기계어를 직접 실행하는 프로그래밍 환경보다 성능이 떨어진다. ( e.q. C, Rust )
  • 이번 장은 코드 실행에 초점이 맞춰져있다.

바이트코드 해석

  • JVM은 주로 스택 머신처럼 작동한다. 다만 CPU와 다르게 계산 결과를 보관하는 레지스터는 따로 없다.

  • 평가스택 : 메서드별로 하나씩 생성된다.

  • 로컬 변수 : 결과를 임시 저장한다. ( 특정 메서드별로 저장한다. )

  • 객체 힙 : 메서드끼리, 쓰레드끼리 공유한다.

  • JVM은 빅 엔디언이든 리틀 엔디언이든 하드웨어 아키텍처 모두 바이트코드 변경없이 실행하도록 명세에 규정되어있다고 한다.

    • 빅 엔디언 : 빅 엔디안 방식은 낮은 주소에 데이터의 높은 바이트(MSB, Most Significant Bit)부터 저장하는 방식입니다.
      image

    • 리틀 엔디언 : 리틀 엔디안 방식은 낮은 주소에 데이터의 낮은 바이트(LSB, Least Significant Bit)부터 저장하는 방식입니다.

image

 - 인텔같은 최신 CPU들은 대부분 리틀엔디언을 사용하는데, 항상 최하위 주소에 최하위 바이트가 저장되므로 프로세스가 더 쉽게 엑세스할 수 있다고 합니다. ( 수학적 연산이 쉬워짐)
  • 실제 핫스팟은 단순한 VM 작업을 구현하고 네이티브 플랫폼의 스택 프레임 레이아웃을 최대한 활용해서 성능을 높이기 위해 상당히 많은 어셈블리 코드를 작성되어있다고 한다.

AOT & JIT

  • AOT는 프로그램을 실행할 플랫폼과 프로세서 아키텍처에 딱 맞는 실행 코드를 얻는 것!
  • 결국 AOT는 CPU 기능을 최대한으로 활용하지 못하는 경우가 많아서 성능 향상의 여지가 많지만, AOT 컴파일 자체의 속도는 빠를 수 밖에 없다. ( 일단 코드 -> 기계어로 만들어버리니깐)
  • JIT는 런타임에 프로그램을 기계어로 변환하는 기법이다.
  • 최신 버전의 javac는 일부러 dumb bytecode를 지향한다고 한다. 한정된 최적화만 수행하는 대신 JIT 컴파일러가 이해하기 쉬운 형태로 프로그램을 만들기 때문이다.

AOT VS JIT

  • AOT는 결국 아키텍처에서 최대한의 성능을 내려면 특화된 코드가 필요로 하게 된다.
  • 반면 핫스팟은 새로운 프로세서 기능에 대한 최적화 코드를 추가할 수 있고, 애플리케이션은 기존 클래스나 JAR를 다시 컴파일하지 않더라도 신기능을 계속 사용할 수 있다고 한다.
  • 자바 역시 계속해서 AOT에 대한 지원을 밀고 있는데, 이는 결국 JIT가 런타임에 코드를 해석하는 과정이므로, 이를 더 빠르게 하려는 자바 개발자의 노오력이 아닌가 싶었음.

핫스팟 JIT 기초

  • 핫스팟은 결국 멀티스레드 C++ 애플리케이션이다.
  • 핫스팟에는 C1, C2가 존재하는데 C1은 GUI, C2는 실행 시간이 긴 서버에서 사용한다고 하는데, 요즘은 그냥 메서드 호출 횟수에 따라 컴파일러가 트리거링 된다고 한다. https://www.youtube.com/watch?v=CQi3SS2YspY&ab_channel=kakaotech ( C1, C2는 여기서 그 내용이 나온다 )
  • JIT 컴파일된 코드는 코드 캐시라는 영역에 저장된다.
  • 코드 캐시가 꽉 차면 JIT가 동작 안하고, 인터프리터에서만 동작할 수 있음.
  • 메서드를 지닌 클래스가 언로딩 되거나 역최적화 ( 최적화하려고 해놨는데 실제로 코드가 그렇게 실행 안될 경우 이전 코드로 돌려놓는 행위), 다른 컴파일 버전으로 교체될 때 네이티브 코드는 캐시에서 제거된다고 함.

바이트 코드 해석

  • 스택 기반 evaluation
  • 평가 스택에서 값을 pop해서 계산 후 결과를 다시 스택에 push하는 방식
  • 스택 머신 작업은 op code(1바이트)로 나타냄
  • iadd, dadd 같이 명령코드에서 사용되는 오퍼랜드의 기본형을 구분할 수 있게함
    • 자바는 이식성을 염두에 두고 설계된 언어이기 때문에
    • 하드웨어 아키텍처에 관계없이 바이트코드를 작성하기 위함
  • 메서드 호출 시 아래 opcode가 실행됨
    • invokevirtual : 기본적인 가상 디스패치를 통해 호출함. (인스턴스 호출은 보통 이걸로 변환됨)
    • invokespecial : private 메서드나 슈퍼클래스를 호출하는 경우
    • invokeinterface : 인터페이스를 호출하는 경우
    • invokedynamic : 람다를 사용하는 경우

AOT vs JIT compiler

  • AOT : 프로그램을 실행할 플랫폼과 프로세서 아키텍처에 딱 맞은 실행 코드를 얻는 것. (이식성 포기)
  • JIT : 이식성을 위해 바이트코드를 만들어서 네이티브 코드로 컴파일하는 비용은 런타임에 지불함