Skip to content

김수현, 김현준, 윤중진, 홍은기 JVM

김현준 edited this page Jul 10, 2024 · 8 revisions

image

Class Loader

  • 컴파일러를 통해 .class 확장자를 가진 클래스 파일들은 각각의 디렉토리에 존재한다. 기본 라이브러리의 클래스 파일들도 $JAVAHOME 내부의 경로에 존재한다. 이렇게 흩어진 클래스 파일들을 찾아서 Dynamic Loading 방식으로 Runtime Data Area에 올리는 것이 클래스 로더의 역할이다.
  • Loading
    • 컴파일된 클래스 파일을 메모리에 로딩하는 과정이다. 로딩할때 클래스 로더는 3가지의 구현체가 동작하는데, Bootstrap, Extension, Application class loader가 순서대로 동작하게 된다.
      • 그래서 개발자가 직접 클래스로더를 정의해서 로딩 과정에 집어 넣을수도 있고, 이걸 User-Defined Class Loader라 부른다.
      • Bootstrap Class Loader는 lang.Object lang.Class util.* 이런 자바에 필수적인 클래스들을 올리게 된다.
      • Application Class Loader는 개발자가 만든 .class 파일을 로드한다.
        • 즉, class path의 클래스들을 전부 로드한다.
        • 외부 라이브러리 클래스들도 로딩하는지는 모르겠다.
      • 결론은 Bootstrap → extension → application 순으로 자기 역할에 맞는 클래스를 찾게 되고 존재하지 않으면 ClassNotFoundException을 발생시킨다.
    • 자바는 동적 로딩 방식을 사용하는데, 존재하는 모든 .class 파일을 메모리(메서드 영역)에 올리는게 아니라, 필요할 때 올린다는 것이다 → dynamic loading
    • 그래서 클래스가 로딩되면 메서드 영역에 클래스 정보(클래스 바이트 코드 파일)를 저장한다. 그래서 이 메서드 영역의 클래스 정보를 보고 리플렉션이 클래스에 대한 정보를 알 수 있는 것이다.
  • Linking
    • 로딩된 클래스 파일을 검증하고 사용할 수 있게 준비하는 과정이다. Verification, Preparation, Resolution 세 단계로 이루어져 있다.
    • Verificatoin
      • Java의 규칙대로 클래스를 잘 작성했는지 확인한다. 구조에 잘 맞춰서 작성했는지 등을 검사하는 것으로 뭔가 문제가 발생한다면 VerifyException이 발생한다.
      • Bytecode Verifier가 처리를 담당함. 예를 들면 final 메서드, 클래스가 override 됐는가 이런 걸 검사함
    • Preparation
      • 클래스나 인터페이스에 대한 정적 필드를 생성하고 해당 필드를 기본값으로 초기화하는 작업을 한다. 실제 할당된 값이 아닌, int의 경우 0, String의 경우 null과 같이 초기화한다. 실제 값은 이후 initialization 과정에서 바인딩되기 때문에 초기화 블록이나 코드가 실행되지 않는다.
    • Resolution
      • runtime constant pool에 있는 symbolic reference(심볼릭 참조)를 direct reference(직접 참조)로 변경한다.
      • symbolic reference는 코드를 작성하면서 사용한 class, field, method의 이름을 지칭한다. 즉, 정확한 주소 값이 아니다. 이를 정확한 주소 값인 direct reference로 변경하는 것이다.
        • 우선 클래스를 로드하면서 constant pool에 클래스 이름으로 임의의 reference인 symbolic reference를 사용하고(예를 들면 MyClass$myVariable) 이런 식으로? linking과정에서 검증 후, 검증이 완료되었으니 실제 메모리 주소로 매핑해주는 것으로 이해했다.
  • Initialization
    • 클래스의 생성자를 호출하여 초기화를 진행한다.
    • 실제 클래스 파일의 코드를 읽는 것으로, 자바 코드에서의 클래스와 인터페이스의 값들을 지정한 값들로 초기화 및 초기화 메소드를 실행시키는 과정이다. 이때 JVM은 멀티 스레드로 동작하여 같은 시간에 한 번에 초기화를 하는 경우가 있기 때문에 멀티스레드 환경을 고려하지 않을 경우 클래스 초기화 과정 중 오류가 발생할 수 있다.
      • 생성자(또는 초기화 블록 같은 곳)에 멀티스레드 관련된 동기화가 요구되는 코드를 넣지 말라는 뜻이다.
    • 여기서 생성자 호출은 실제로 new 키워드로 생성한 것이 아닌, 로딩을 마친 이후에 힙 영역에 해당 클래스 타입의 객체를 따로 생성한다.
      • 이건 JVM의 필요에 의해 클래스의 인스턴스를 만들어 놓는 것이다.
      • 예시로는 코드를 작성할 때 내가 만든 클래스에 접근할 수 있는 것이 JVM이 객체를 생성했기 때문이다. ClassName.class.getClass() 와 같이 new로 생성하지 않아도 해당 클래스 정보에 접근이 가능한 것이 JVM이 객체를 하나 생성했기 때문으로 이해함

Execution Engine

  • Execution Engine은 Java Virtual Machine (JVM)의 핵심 구성 요소 중 하나로, 바이트코드를 실제 실행하는 역할을 합니다
  • Integerpreter와 Jit compiler, Garbage Collector로 구성됨.

JIT Compiler와 인터프리터의 차이점은

  • 인터프리터는 바이트코드를 한 줄씩 해석하여 실행하지만, JIT 컴파일러는 바이트코드를 네이티브 머신 코드로 변환하여 실행 성능을 향상시킵니다.
  • 프로그램이 처음 실행될때는 바이트코드를 인터프리터를 통해 한 줄씩 해석해서 실행. 빠른 시작 시간. 실행 성능은 최적화되지 않음
  • 프로그램 실행되고나서 프로파일러에 의해 식별된 핫스팟은 네이티브코드(기계어)로 변환되어서 CPU에 의해 실행됨.

JIT 컴파일러의 동작 과정

  • JIT 컴파일러는 런타임에 바이트코드를 네이티브 머신 코드로 변환하여 성능을 향상시키는 역할을 합니다.
  • JIT 컴파일링은 전체 코드가 아니라 클래스 로더에 의해 로드된 클래스에 대해서 수행됨.
  • 프로파일링 : 프로그램이 실행되면서 JVM은 프로파일링을 수행하여 자주 실행되는 경로 (”핫스팟”)을 식별.
  • 프로파일링 결과에 따라 핫스팟에 대해서 타겟 코드 생성 및 코드 최적화.
  • 중간 코드 생성 → 코드 최적화 → 타겟 코드 생성 세단계로 나뉨.
  • 중간 코드 생성: 바이트코드는 중간 표현으로 변환. 바이트코드를 최적화하기 쉬운 형태로 추상화. 이 중간 코드는 JIT 컴파일러의 내부 메모리에서 사용됩니다.
  • 코드 최적화: 중간 표현을 분석하여 성능을 향상 시키기 위한 다양한 작업을 수행. 실행 속도를 높이거나, 메모리 사용률을 줄이거나.
  • 타겟 코드 생성: 최적화된 중간 표현을 실제 실행 가능한 네이티브 머신 코드로 변환. 하드웨어와 아키텍처에 종속된 코드를 생성함. 대신, 이 네이티브 코드는 직접 CPU에서 실행됩니다. 생성된 네이티브 코드는 JVM의 코드 캐시(Code Cache)에 저장됩니다 이후 동일한 코드가 실행될 때, JVM은 인터프리터 대신 코드 캐시에서 네이티브 코드를 찾아 직접 실행합니다. 이는 CPU에서 직접 실행되므로, 인터프리터에 의해 해석되는 것보다 훨씬 빠릅니다.
  • 코드 캐시:
    • 코드 캐시는 제한된 크기를 가지며, 가비지 컬렉터와 유사한 메커니즘을 사용하여 오래되거나 사용되지 않는 코드를 제거합니다.
    • 코드 캐시는 JVM의 런타임 데이터 영역에 포함되어 있으며, 자주 실행되는 코드를 저장하고 빠르게 접근할 수 있도록 합니다.
  • 다음에 동일한 메서드가 호출되면, JVM은 먼저 코드 캐시에서 해당 메서드의 네이티브 코드가 존재하는지 확인합니다. 메서드가 호출될 때, JVM은 메서드 객체를 키로 코드 캐시를 탐색하여 네이티브 코드의 존재 여부를 확인합니다. JDK 공급업체에 따라 코드 캐시에 사용되는 자료구조와 구현 세부 사항이 다를 수 있습니다.

JNI

  • 자바는 JVM이라는 가상 머신위에서 실행되고 각 운영체제에 맞는 JVM이 존재하기 때문에
    JAVA 파일만으로도 운영체제 상관없이 원하는 결과를 얻을 수 있다는 장점이 있습니다.

  • 하지만 JVM이 운영체제가 제공하는 모든 기능을 담지 못한다는 단점도 존재합니다 예를 들어 리눅스 커널이 업데이트 되면서.
    새로운 시스템 콜이 추가되거나 특정 유닉스의 시스템 콜을 호출하고 싶지만 JVM이 지원하지 않으면 자바에서 이를 사용하기 어려울 수 있습니다.

  • 이 문제를 JNI라는 것을 사용하면 해결할 수 있는데 JNI는 JVM 위에서 실행되고 있는 자바 코드가 네이티브 프로그램(하드웨어와 운영 체제 플랫폼에 종속된 프로그램들),
    C, C++ 그리고 어셈블리 같은 다른 언어들로 작성된 라이브러리들을 호출하거나 반대로 호출되는 것을 가능하게 하는 프로그래밍 프레임워크입니다.
    다시말해 JVM 위에서 실행되는 바이트 코드와 기본 코드 사이에 브리지 역할을 합니다.

JNI를 사용하는 이유로는 3가지를 꼽을 수 있습니다.

  1. 일부 하드웨어를 처리해야 하는 경우가 필요합니다.
  2. 까다로운 프로세스의 성능 개선을 위해 필요합니다.
    구현하고 싶은 기능들을 자바 언어로 작성할 경우 매우 비효율적일 수 있습니다.
    이 경우 C, C++을 이용해서 컴파일한 모듈을 자바에서 호출해 사용하는 방법을 고려할 수 있습니다.
  3. 기존에 존재하는 리이브러리를 JAVA로 재작성하지 않고 재사용하고 싶을 때 JNI를 사용할 수 있습니다.

Native Method Stack?

JVM이 할당 받는 메모리 영역인 Runtime Data Area(Method, Heap, Stack, PC Register, Native Method Stack) 중에 하나로,
Native method를 호출할 시에는 JVM Stack이 동작하는 것이 아닌 Native Method Stack을 활용하여 Frame을 push, pop합니다.

출처
https://www.baeldung.com/jni
https://velog.io/@vrooming13/JNI-JAVA-Native-Interface
https://mommoo.tistory.com/71

https://ram-bak.tistory.com/5
https://velog.io/@boo105/JVM%EC%9D%84-%EA%B3%B5%EB%B6%80%ED%95%B4%EB%B3%B4%EC%9E%90

GC

GC란?

프로그램을 개발 하다 보면 유효하지 않은 메모리인 가비지가 발생한다. C언어는 free()라는 함수를 통해 직접 메모리를 해제 해주어야 하지만, JVM을 이용해 개발을 하다 보면 개발자가 직접 메모리를 해제해주는 일이 없다.

따라서 참조 하지 않고 사용 되지 않는 가비지에 따른 메모리 누수가 생기는데 이를 방지하기 위해 가비지 컬렉터가 주기적으로 검사하여 메모리를 청소해준다.

System.gc()로도 호출이 가능 → JVM에 따라 다르지만 일반적으로 Full GC, 즉 영역에 따라 수집하는게 아니라 모든 영역을 바탕으로 수집해 오래 걸릴 수 있다.

‘stop-the-world’라는 용어는 GC를 실행하기 위해 JVM이 애플리케이션 실행을 멈추는 것이다. 해당 작업이 발생하면 GC를 실행하는 쓰레드를 제외 나머지 쓰레드는 모두 작업을 멈춘다. GC의 작업이 완료되면 중단했던 작업을 실행하는데, 대개의 GC 튜닝은 stop-the-world의 시간을 줄이는 것이다.

GC의 과정

GC는 두 가지 가설을 따른다. 이러한 가설을 ‘weak generational hypothesis’라고 한다.

  • 대부분 객체는 금방 접근 불가능 상태가 된다.
  • 오래된 객체에서 젊은 객체로의 참조는 아주 적게 존재한다.

HotSpot VM에서는 크게 2개로 물리적 공간을 나누었다. 이것이 Young, Old 영역이다.

image

Permanent Generation 영역(이하 Perm 영역)은 Method Area라고도 한다. 객체나 억류된 문자열 정보를 저장하는 곳이며, Old영역에서 살아남은 객체가 영원히 남아 있는 곳은 절대 아니다.

이 영역도 GC가 발생할 수도 있는데 Major GC 횟수에 포함된다.

Old 영역에 있는 객체가 Young 영역의 객체를 참조하는 경우에는 Old 영역에 512바이트 청크로 되어 있는 카드 테이블을 통해 Young 영역에 GC를 실행할 때 Old 영역을 뒤지지 않고 테이블을 뒤져 GC 대상을 식별한다.

카드 테이블은 write barrier를 사용하여 관리한다. write barrier는 Minor GC를 빠르게 할 수 있도록 하는 장치이다. write barrirer때문에 약간의 오버헤드는 발생하지만 전반적인 GC 시간은 줄어들게 된다.

write barrier란?

Write Barrier는 늙은 객체와 젊은 객체의 관계가 맺어지면 카드 테이블 엔트리를 더티 값으로 세팅하고, 반대로 관계가 해제되면 더티 값을 지우는 실행 엔진에 포함된 작은 코드 조각이다. 

Young 영역의 구성

  • Eden 영역
  • Survivor 영역(2개)

각 영역의 처리 절차를 순서에 따라서 기술하면 다음과 같다.

  • Eden 영역에서 GC가 한 번 발생한 후 살아남은 객체는 Survivor 영역 중 하나로 이동된다.
  • Eden 영역에서 GC가 발생하면 이미 살아남은 객체가 존재하는 Survivor 영역으로 객체가 계속 쌓인다.
  • 하나의 Survivor 영역이 가득 차게 되면 그 중에서 살아남은 객체를 다른 Survivor 영역으로 이동한다. 그리고 가득 찬 Survivor 영역은 아무 데이터도 없는 상태로 된다.
  • 이 과정을 반복하다가 계속해서 살아남아 있는 객체는 Old 영역으로 이동하게 된다.

이 절차를 확인해 보면 알겠지만 Survivor 영역 중 하나는 반드시 비어 있는 상태로 남아 있어야 한다. 만약 두 Survivor 영역에 모두 데이터가 존재하거나, 두 영역 모두 사용량이 0이라면 여러분의 시스템은 정상적인 상황이 아니라고 생각하면 된다. → 가득찬 다른 Survivor 객체들을 GC하면서 복사해야되기 때문에

Minor GC 과정 그림

image

참고로, HotSpot VM에서는 보다 빠른 메모리 할당을 위해서 두 가지 기술을 사용한다. 하나는 bump-the-pointer라는 기술이며, 다른 하나는 TLABs(Thread-Local Allocation Buffers)라는 기술이다.

bump-the-pointer는 Eden 영역에 할당된 마지막 객체를 추적한다. 마지막 객체는 Eden 영역의 맨 위(top)에 있다. 그리고 그 다음에 생성되는 객체가 있으면, 해당 객체의 크기가 Eden 영역에 넣기 적당한지만 확인한다. 만약 해당 객체의 크기가 적당하다고 판정되면 Eden 영역에 넣게 되고, 새로 생성된 객체가 맨 위에 있게 된다. 따라서, 새로운 객체를 생성할 때 마지막에 추가된 객체만 점검하면 되므로 매우 빠르게 메모리 할당이 이루어진다.

그러나 멀티 스레드 환경을 고려하면 이야기가 달라진다. Thread-Safe하기 위해서 만약 여러 스레드에서 사용하는 객체를 Eden 영역에 저장하려면 락(lock)이 발생할 수 밖에 없고, lock-contention 때문에 성능은 매우 떨어지게 될 것이다. HotSpot VM에서 이를 해결한 것이 TLABs이다.

각각의 스레드가 각각의 몫에 해당하는 Eden 영역의 작은 덩어리를 가질 수 있도록 하는 것이다. 각 쓰레드에는 자기가 갖고 있는 TLAB에만 접근할 수 있기 때문에, bump-the-pointer라는 기술을 사용하더라도 아무런 락이 없이 메모리 할당이 가능하다.

Old 영역에 대한 GC

Old 영역은 기본적으로 데이터가 가득 차면 GC를 실행한다. GC 방식에 따라서 처리 절차가 달라지므로, 어떤 GC 방식이 있는지 살펴보면 이해가 쉬울 것이다. GC 방식은 JDK 7을 기준으로 5가지 방식이 있다.

  • Serial GC
  • Parallel GC
  • Parallel Old GC(Parallel Compacting GC)
  • Concurrent Mark & Sweep GC(이하 CMS)
  • G1(Garbage First) GC

이 중에서 운영 서버에서 절대 사용하면 안 되는 방식이 Serial GC다. Serial GC는 데스크톱의 CPU 코어가 하나만 있을 때 사용하기 위해서 만든 방식이다. Serial GC를 사용하면 애플리케이션의 성능이 많이 떨어진다.

각 GC에 대한 것은 아래의 링크를 참고하자 ^^

https://d2.naver.com/helloworld/1329?source=post_page-----2d046f73da4f----------------------

출처

👼 개인 활동을 기록합시다.

개인 활동 페이지

🧑‍🧑‍🧒‍🧒 그룹 활동을 기록합시다.

그룹 활동 페이지

🎤 미니 세미나

미니 세미나

🤔 기술 블로그 활동

기술 블로그 활동

📚 도서를 추천해주세요

추천 도서 목록

🎸 기타

기타 유용한 학습 링크

Clone this wiki locally