처음부터 자바는 개발자가 플랫폼을 저수준에서 다 알 필요가 없도록 설계했다. 그래서 고객이 성능이 느리다고 얘기해도 개발자는 내가 할 일은 다 했노라 항변할 수 밖에 없는 상황이 연출되곤 한다고 한다.
그러나 성능에 관심 있는 개발자라면 기본적인 JVM 기술 스택의 구조를 이해애야한다. JVM 을 이해하면 더 좋은 소프트웨어를 개발할 수 있고, 성능 이슈를 탐구할 때 필요한 이론적 배겨지식을 갖추게 된다.
인터프리팅과 클래스로딩
자바 가상머신을 규정한 명세서(VM 스펙이라고함)에 따르면 jvm은 스택 기반의 해석 머신이다. 레지스터는 없지만 일부 결과를 실행스택에 보관하며, 이 스택의 맨 위에 쌓인 값(들)을 가져와 계산한다.
JVM 인터프리터의 기본 로직은 평가 스택을 이용해 중간 값들을 담아두고 가장 마지막에 실행된 명령어와 독립적으로 프로그램을 구성하는 *옵코드를 하나씩 순서대로 처리하는 while 루프 안의 switch문이라고 떠올리면 된다.(오라클/Open JDK VM 같은 실제 *제품급 자바 인터프리터는 더 복잡) -> while로 스택 값 꺼내서 옵코드를 switch문에 맞춰서 실행
*옵코드:operation code의 줄임말로, 기계어의 일부로서 수행할 명령어를 나타내는 부호
* 제품급: 실제 비즈니스 업무와 기업 컴퓨팅 환경에서 사용해도 될 정도로 완성도를 갖춘
Java 파일 실행 과정
java HelloWorld 명령을 내려 자바 애플리케이션 실행 -> os는 가상 머신 프로세스(자바 바이너리)를 구동 -> 자바 가상환경 구성 + 스택 머신 초기화 -> 유저가 작성한 HelloWorld 클래스 파일 실행
애플리케이션 진입점은 HelloWorld.class 에 있는 main() 메서드이다. 제어권을 이 클래스로 넘기려면 가상 머신이 실행되기 전에 이 클래스를 로드해야한다. (실행하려는데 해당 클래스가 jvm에 없으면 실행할 수 가 없음)
-> 여기에 클래스 로딩 메커니즘이 관여한다.
클래스로딩 매커니즘
자바 프로세스 초기화되면서 클래스로더 작동, 제일 먼저 부트스트램 클래스가 자바 런타임 코어 클래스를 로드한다. 런타임 코어 크래스는 자바 8이전까지는 rt.jar 파일에서 가져오지만, 자바 9이후 부터는 런타임이 모듈화되고 클래스로딩 개념 자체가 달라졌다.
부트스트랩 클래스로더(C/C++로 구현된 jvm 내부 로더) 주입무는 다른 클래스 로더가 나머지 시스템에 필요한 클래스를 로드할 수 있게 최소한의 필수 클래스(java.lang.Object, Class, ClassLoader)만 로드하는 것이다.
JVM이 프로그램을 시작할 때 아무 클래스도 로딩되어 있지 않다면, 최초로 실행할 클래스(예: `Main.class`)와 의존 클래스들을 어떻게 정할 것인가?
클래스 초기 세트 설정하지 않으면 클래스 로더를 정의하는 과정에서 순환성이 문제될 수 있다.
*순환성: 어떤 클래스나 인터페이스가 자신의 슈퍼클래스가 될 수도 있는 역설적인 상황 ->클래스 A가 로드되면서 클래스 B를 필요로 하고, B가 다시 A를 필요로 하면서 무한 루프에 빠지는 상황
자바에서 클래스로더는 런타임과 타입 체계 내부에 있는 하나의 객체에 불과. 따라서 클래스 초기 세트를 존재하게할 방법이 필요.
- 클래스 로더(ClassLoader): 자바에서 클래스 바이트코드를 로드해 JVM 메모리에 올리는 역할. 하지만 이것도 결국 하나의 객체(→ 인스턴스)일 뿐이고, 단독으로는 어떤 클래스도 “자동”으로 미리 로드하지 않습니다.
우리가 말하는 "클래스 초기 세트"는 사용자가 만든 프로그램 중에서,
- JVM이 어떤 클래스부터 로딩해야 할지
- 어떤 클래스들이 초기 실행에 반드시 필요할지 를 지정하는 개념입니다.
부트스트랩 로더가 최소한의 필수 클래스만 로드한 후, 확장 클래스로더가 생긴다. 부트스트랩 클래스 로더를 자기 부모로 설정하고 필요할 클래스로딩 작업을 부모에게 넘긴다.
끝으로, 애플리케이션 클래스로더가 생성되고 지정된 클래스에 위치한 유저 클래스를 로드한다. 확장 클래스로더의 자식인 애플리케이션 클래스 로더는 아주 자주 쓰인다.
애플리케이션 클래스로더를 '시스템' 클래스로더라고 부르는 글도 있다. (부트스트램 클래스로더가 로드하느) 시스템 클래스는 로드하지 않기 때문에 이런 말은 가급적 쓰지 말라고한다.
런타임에 클래스 메타정보를 확인할 수 있는 이유 (리플렉션이 가능한 이유)
보통 환경에서 자바는 클래스를 로드할 때 런타임 환경에서 해당 클래스의 메타정보를 포함하는 Class 객체를 만든다. 근데 똑같은 클래스를 상이한 클래스로더가 두 번 로드할 가능성도 있으므로 주의해야한다. 한 시스템에서 클래스는 (패키지명을 포함한) 풀 클래스명과 자신을 로드한 클래스로더, 두 가지 정보로 식별된다.
클래스로딩 매커니즘 정리
부트스트랩 로더(시스템에 필요한 최소한의 클래스를 로드할 수 있도록 필수 클래스만 로드) -> 확장 클래스로더(현재 deprecated) -> 애플리케이션 클래스로더(지정된 클래스패스에 위치한 클래스를 로드)
자바는 실행중 처음 보는 새 클래스를 dependency(의존체)에 로드한다. 클래스를 찾지 못한 클래스로더는 기본적으로 자신의 부모 클래스로더에게 대신 룩업(찾아보기)를 넘긴다. 계속 거슬러 올라가서 부트스트랩도 룩업하지 못하면` ClassNotFoundException` 예외 발생 -> 빌드 프로세스 수립 시 운영 환경과 동일한 클래스패스로 컴파일하는 것이 좋음(클래스패스가 다르면? 애플리케이션 코드에 객체를 만들었는데, 애플리케이션 클래스 로더가 못찾는 클래스가 있을 수 있음 -> 오류)
바이트코드
javac로 자바소스코드파일을 바이트코드로 가득찬 .class로 변환 -> 최적화를 거의하지 않아서 javap같은 표준 역어셈블리 툴로 열어보면 원래 자바 코드를 볼 수 있다.
바이트코드는 특정 컴퓨터 아키텍처에 특정하지 않은 중간 표현형(Intermediate Represetation)(IR)이다.
특정 플랫폼에 종속되지 않아 이식성이 좋아 개발을 마친 소프트웨어는 jvm 지원 플랫폼 어디서건 실행 가능하다.
jvm 규격에 따라 클래스파일로 컴파일 되는 jvm언어는 다 실행할 수 있다. 스칼라 컴파일러 scalac로 컴파일한 바이트코드도 jvm에서 작동한다.
jvm은 클래스를 로드할 때 올바른 형식을 준수하고 있는지 빠짐없이 검사한다. -> 클래스파일 해부도 참고
실행하는 jvm 버전과 컴파일한 jvm 버전
모든 클래스파일은 `0xCAFEBABE` 라는 매직 넘버, 즉 이 파일이 클래스 파일임을 나타내는 4바이트 16진수로 시작하고, 그 다음 4바이트는 클래스 파일을 컴파일할 때 꼭 필요한 메이저/마이너 버전 숫자이다. 클래스를 파일을 실행하는 대상JVM이 컴파일한 JVM버전보다 낮으면 안된다(런타임 버전이 컴파일된 클래스 버전보다 낮으면 안되니까). 이처럼 클래스로더의 호환성 보장을 위해 검사하고 호환되지 않는 버전의 클래스 파일을 만나면 런타임에 UnsuppeortedClassVersionError 예외를 던진다.
(자바 9부터 모듈 파일에 `0xCAFEDADA` 매직 넘버 사용)
핫스팟 입문
제로-오버헤드 원칙을 준수하는 언어: C, 어셈블리어 같이 저수준 언어들은 제로 코스트 추상화 사상에 근거한 기계에 가까운 언어
일을 대행하는 언어: 개발자의 생산성에 무게를 두고 엄격한 저수준 제어하는 언어
위 두개를 저울질하며 결정을 해야했다.
제로-오버헤드 원칙은 개발자가 os와 컴퓨터의 동작 원리를 제대로 알고 개발해야한다. -> 학습 부담이 많음
또한 소스코드를 빌드하면 사전 컴파일(Ahead Of Time(AOT)되어 특정 플랫폼에 종속된다.
핫스팟은 프로그램의 런타임 동작을 분석하고 성능에 가장 유리한 방향으로 영리한 최적화를 적용하는 가상머신이다. 핫스팟 VM의 목표는 개발자가 억지로 VM 틀레 맞게 프로그램을 욱여 넣는 대신, 자연스럽게 자바 코드를 작성하고 바람직한 설계 원리를 따르도록 하는 것이다.
JIT 컴파일러란?
자바 프로그램(JVM)은 바이트코드 인터프리터가 가상화한 스택 머신에서 명령어를 실행하며 시작. 프로그램 성능을 최대로 내려면 네이티브 기능을 활용해 CPU 에서 직접 프로그램을 실행시켜야한다.
이를 위해 핫스팟은 프로그램 단위(메서드와 루프)를 인터프리티드 바이트코드에서 네이티브 코드로 컴파일한다. -> 이게 JIT(Just In Time) (그때그때 하는) 컴파일이라고 알려진 기술이다.
핫스팟은 인터프리티드 모드로 실행하는 동안 자주 실행되는 코드 파트를 발견하고 JIT 컴파일을 수행한다 -> 네이트브 코드로 컴파일하면 더 빠르게 실행되므로 자주 실행되는 코드를 JIT 컴파일을 하는 것이다. (애플리케이션 모니터링하면서 (그때그때) 자주 사용되는 코드 파트를 네이트 코드로 컴파일한다고 하여 Just In Time 컴파일 기술이라고 한다고 생각)
JIT로 인해 네이티브 코드로 변환하면 jvm에 의해 인터프리티드 되지 않아도 된다. -> GC를 사용하지 않는다?
특정 메서드가 어느 한계치(임계점, threshold)을 넘어가면 프로파일러가 특정 코드 섹션을 컴파일/최적화한다.
JIT 컴파일 방식의 이점
컴파일러가 해석단계에서 수집한 추적 정보를 근거로 최적화를 결정한다는 게 가장 큰 장점. -> 상황별로 수집한 다양한 정보를 토대로 핫스팟이 더 올바른 방향으로 최적화할 수 있다.
핫스팟(런타임에 성능에 유리하도록 최적화를 적용한 가상머신)은 개발에 공들인 시간만도 수백년(또는 그 이상)에 이르고 새 버전이 나올 때마다 최신 최적화 기법과 혜택을 추가해왔다.
최신 성능 최적화의 덕을 보려면 핫스팟 새 버전에서 자바 애플리케이션을 실행하는 것이 좋다.(물론 다시 컴파일 할 필요는 없다!! 플랫폼에 비종속적인 바이트코드만 있으면 상위 JVM에서 실행 가능하므로)
자바 소스 코드가 바이트 코드로 바뀌고 또 다른 컴파일 단계(JIT)를 거친 후 실제로 실행되는 코드는 처음에 개발자가 작성한 소스 코드와 다르다. 성능 관련 탐구를 할때 중요한 사실이므로 기억하자. JVM에서 JIT 컴파일 후 실행되는 코드는 원본 자바 소스코드와 전혀 딴판일 가능성이 크다.
AOT 컴파일러와 PGO
AOT컴파일러는 여러 기종의 프로세서에서 실행 가능한 코드를 만들지만, 프로세서에 특정한 기능은 어쩔 도리가 없다.
자바처럼 프로필 기반 최적화(PGO(Profile-Guided Optimization))를 응용하는 환경에서는 대부분의 AOT플랫폼에서 불가능한 방식으로 런타임 정보를 활용할 수 있어(JIT처럼 런타임에 메서드 최적화) 동적 인라이닝 또는 가상호출등으로 성능을 개선할 수 있다. 또한 핫스팟 VM은 시동시 CPU 타입을 정확히 감지해 가능하면 특정 프로세서의 기능에 맞게 최적화를 적용할 수 있다. (PGO와 JIT 컴파일러는 9,10 장에서 자세히 다룬다.)
* AOT(Ahead-Of-Time complie) 컴파일은 목표 시스템의 기계어와 무관하게 중간 언어 형태로 배포된 후 목표 시스템에서 인터프리터나 JIT 컴파일 등 기계어 변역을 통해 실행되는 중간 언어를 미리 목표 시스템(특정 플랫폼, OS)에 맞는 기계어로 번역하는 방식을 지칭 -> 인터프리팅 과정이 생략(미리 기계어로 번역해 놓은 바이트코드 파일을 만들어놓으므로)
JVM 메모리 관리
자바 초창기부터 독보적인 기능인 자동 메모리관리
C/C++ 메모리 할당/해제 작업을 직접 수행 -> 관리가 번거로움, 개발자가 메모리를 정확하게 계산해서 처리해야하는 막중한 책임 수반
가비지 컬렉터
자바는 가비지 컬렉터라는 프로세스를 이용해 힙 메모리를 자동 관리하는 방식으로 해결
JVM이 더 많은 메모리를 할당해야할 때 불 필요한 메모리를 회수하거나 재사용하는 불확정적 프로세스
GC가 실행되면 그동안 다른 애플리케이션은 모두 중단 (STW) 되고 하던 일은 멈춰야한다. 애플리케이션 부하가 늘 수록 이 시간도 무시할 수 없다. -> 가비지 수집은 자바 성능 최적화의 중심 주제(6,7,8 장에 걸처 다룸)
스레딩과 자바 메모리 모델(JMM)
자바 1.0부터 멀티 스레드 프로그램을 기본 지원. 자바8기준 아래 코드로 스레드를 새로 만들 수 있음
Thread t = new Thread(() -> {System.out.println("HelloWorld");});
t.start();
자바 환경 자체가 JVM처럼 멀티스레드 기반일 까닭에 자바 프로그램이 동작하는 방식은 한층 더 복잡 + 성능 분석가도 작업하기 힘듦
위처럼 생성한 스레드(자바 애플리케이션 스레드)는 JVM 구현체에서 각각 정확히 하나의 전용 OS 스레드에 대응된다. 공유 스레드 풀을 이용해 전체 자바 애플리케이션 스레드를 실행하는 방안(그린 스레드)도 있지만, 쓸데없이 복잡도만 증가하고 만족할 만한 수준의 성능이 나오지 않은 것으로 밝혀졌다.
모든 JVM 애플리케이션의 Thread 객체의 start() 메서드가 호출될 때 생성되는 유일한 OS 스레드가 있다고 보면 된다.
1990년대 후반부터 자바의 멀티스레드 방식은 다음 세가지고 기본 설계원칙에 기반한다.
1. 자바 프로세스의 모든 스레드는 가비지가 수집되는 하나의 공용 힙을 가진다.
2. 한 스레드가 생성한 객체는 그 객체를 참조하는 다른 스레드가 액세스할 수 있다. -> 공용 힙을 사용하므로(힙은 스레드의 공유 영역이다.)
3. 기본적으로 객체는 변경 가능하다.(mutable) . 즉, 객체 필드에 할당된 값은 프로그래머가 애써 final 키워드로 불변(immutable) 표시하지 않는 한 바뀔 수 있다
JMM은 서로 다른 실행 스레드가 객체 안에 변경되는 값을 어떻게 바라보는지를 기술한 공식 메모리 모델이다.
A 스레드와 B 스레드 둘다 객체 obj를 참조할 때 스레드A가 obj값을 바꾸면 스레드 B는 무슨값을 참조할까? os 스케줄러가 언제라도 CPU 코어에서 강제로 스레드를 방출할 수 있다.(선점형 스케줄링..), 스레드 A가 처리중인 객체를 스레드 B가 시작되면서 참조할때 잘못된, 무효 상태릐 객체를 바라보게 될 가능성이 도사리고 있다.
상호베타락(mutual exclusion lock)은 코드가 동시에 실행되는 도중 객체가 손상되는 현상을 막을 수 있는 자바의 유일한 방어 장치지만 실제로 애플리케이션에 사용하려면 상당히 복잡해질 수 있다. -> JMM작동 원리와 실제로 스레드/락은 12장에서 다룬다.
'언어 > JAVA' 카테고리의 다른 글
[자바 최적화] 1장 성능과 최적화 (4) | 2025.08.01 |
---|---|
자바에서 비동기 처리로 성능 개선하기 - CompletableFuture (7) | 2025.07.30 |
[Java] Java Collection Framework (JCF) (1) | 2025.03.23 |
[JAVA] EOF 사용법 (0) | 2024.04.10 |
Optional 올바르게 사용하자 (0) | 2024.03.30 |