Java 애플리케이션은 JVM에서 실행되지만 JVM 기술에 대해 알고 계십니까? 이 기사(이 시리즈의 첫 번째 부분)에서는 Java 1회 쓰기, 크로스 플랫폼 엔진, 가비지 수집 기본 사항, 클래식 GC 알고리즘 및 컴파일 최적화의 장단점과 같은 클래식 Java 가상 머신의 작동 방식을 설명합니다. 후속 기사에서는 최신 JVM 설계를 포함한 JVM 성능 최적화에 대해 설명합니다. 이는 오늘날 동시성 Java 애플리케이션의 성능과 확장성을 지원합니다.
개발자라면 이런 특별한 느낌을 받았을 것이고, 갑자기 영감이 번쩍이고, 모든 아이디어가 연결되고, 새로운 관점에서 이전 아이디어를 떠올릴 수 있을 것입니다. 저는 개인적으로 새로운 지식을 배우는 느낌을 좋아합니다. 저는 JVM 기술, 특히 가비지 수집 및 JVM 성능 최적화 작업을 하면서 이러한 경험을 여러 번 경험했습니다. 이 새로운 Java 세계에서 저는 이러한 영감을 여러분과 공유하고 싶습니다. 내가 이 기사를 쓰고 있는 동안 여러분도 JVM의 성능에 대해 배울 수 있다는 사실에 흥미를 느꼈기를 바랍니다.
이 기사 시리즈는 JVM에 대한 기본 지식과 JVM이 실제로 수행하는 작업에 대해 자세히 알아보는 데 관심이 있는 모든 Java 개발자를 위해 작성되었습니다. 높은 수준에서 가비지 수집과 애플리케이션 작동에 영향을 주지 않으면서 무료 메모리 안전성과 속도를 끊임없이 추구하는 방법에 대해 논의하겠습니다. JVM의 주요 부분인 가비지 수집 및 GC 알고리즘, 컴파일 최적화, 일반적으로 사용되는 일부 최적화에 대해 알아봅니다. 또한 Java 마크업이 왜 그렇게 어려운지에 대해 논의하고 언제 성능 테스트를 고려해야 하는지에 대한 조언을 제공할 것입니다. 마지막으로 Azul의 Zing JVM, IBM JVM, Oracle의 G1(Garbage First) 가비지 수집 초점을 포함하여 JVM 및 GC의 몇 가지 새로운 혁신에 대해 이야기하겠습니다.
Java의 확장성 제약 조건의 특성과 이러한 제약 조건으로 인해 최적의 방식으로 Java 배포를 생성하는 방법에 대한 더 깊은 이해를 바탕으로 이 시리즈를 끝까지 읽어보시기 바랍니다. 깨달음과 좋은 Java 영감을 얻으시기 바랍니다. 이러한 제한 사항을 받아들이지 말고 변경하십시오! 아직 오픈 소스 작업자가 아닌 경우 이 시리즈를 통해 이 분야에서 발전할 수 있습니다.
JVM 성능과 "한 번 컴파일하면 어디서나 실행" 문제
Java 플랫폼은 본질적으로 느리다는 고집스러운 신봉자들을 위한 새로운 소식이 있습니다. Java가 처음 엔터프라이즈급 애플리케이션이 되었을 때 JVM이 비판을 받았던 Java 성능 문제는 이미 10년 이상 전의 문제였지만 이제 이러한 결론은 시대에 뒤떨어진 것입니다. 오늘날 다양한 개발 플랫폼에서 간단한 정적 및 결정적 작업을 실행하는 경우 동일한 JVM에서 가상 환경을 사용하는 것보다 기계에 최적화된 코드를 사용하는 것이 더 나은 성능을 발휘할 가능성이 높다는 것은 사실입니다. 그러나 지난 10년 동안 Java의 성능은 크게 향상되었습니다. Java 산업의 시장 수요와 성장으로 인해 소수의 가비지 수집 알고리즘, 새로운 컴파일 혁신, 고급 JVM 기술을 갖춘 다양한 경험적 방법 및 최적화가 탄생했습니다. 앞으로의 장에서 이들 중 일부를 다룰 것입니다.
JVM의 기술적 아름다움은 또한 가장 큰 과제이기도 합니다. 즉, "한 번 컴파일하면 어디서나 실행되는" 애플리케이션으로 간주할 수 있는 것은 없습니다. 하나의 사용 사례, 하나의 애플리케이션 또는 하나의 특정 사용자 로드에 대해 최적화하는 대신 JVM은 Java 애플리케이션이 현재 수행 중인 작업을 지속적으로 추적하고 그에 따라 최적화합니다. 이러한 동적 작업은 일련의 동적 문제를 야기합니다. JVM에서 작업하는 개발자는 혁신을 설계할 때 정적 컴파일 및 예측 가능한 할당 속도에 의존하지 않습니다(적어도 프로덕션 환경에서 성능이 요구되는 경우는 아닙니다).
JVM 성능의 원인
초기 작업에서 나는 가비지 수집이 "해결"하기가 매우 어렵다는 것을 깨달았고 항상 JVM과 미들웨어 기술에 매료되었습니다. JVM에 대한 나의 열정은 내가 JRockit 팀에 있을 때 시작되어 스스로 가르치고 가비지 수집 알고리즘을 직접 디버그하는 새로운 방법을 코딩했습니다(참고자료 참조). JRockit의 실험적 기능으로 바뀌고 결정적 가비지 수집 알고리즘의 기초가 된 이 프로젝트는 JVM 기술에 대한 나의 여정을 시작했습니다. 저는 BEA Systems, Intel, Sun 및 Oracle에서 근무했습니다(Oracle이 BEA Systems를 인수했기 때문에 잠시 Oracle에서 근무했습니다). 그런 다음 Zing JVM을 관리하기 위해 Azul Systems 팀에 합류했고 지금은 Cloudera에서 일하고 있습니다.
기계에 최적화된 코드는 더 나은 성능을 달성할 수 있지만(그러나 유연성이 희생됨) 이것이 동적 로딩과 빠르게 변화하는 기능을 갖춘 엔터프라이즈 애플리케이션에 대해 무게를 두는 이유는 아닙니다. Java의 장점 때문에 대부분의 회사는 기계에 최적화된 코드가 제공하는 거의 완벽하지 않은 성능을 기꺼이 희생합니다.
1. 코딩 및 기능 개발이 용이함(시장 대응 시간 단축)
2. 지식이 풍부한 프로그래머 확보
3. 더 빠른 개발을 위해 Java API 및 표준 라이브러리를 사용합니다.
4. 이식성 - 새로운 플랫폼을 위해 Java 애플리케이션을 다시 작성할 필요가 없습니다.
Java 코드에서 바이트코드로
Java 프로그래머라면 Java 애플리케이션 코딩, 컴파일, 실행에 익숙할 것입니다. 예: 프로그램(MyApp.java)이 있고 이제 이를 실행하려고 한다고 가정해 보겠습니다. 이 프로그램을 실행하려면 먼저 javac(JDK에 내장된 바이트코드 컴파일러에 대한 정적 Java 언어)를 사용하여 컴파일해야 합니다. Java 코드를 기반으로 javac는 해당 실행 가능 바이트코드를 생성하고 이를 동일한 이름인 MyApp.class로 클래스 파일에 저장합니다. Java 코드를 바이트코드로 컴파일한 후 java 명령(시작 옵션을 사용하지 않고 명령줄이나 시작 스크립트를 통해)을 통해 실행 가능한 클래스 파일을 시작하여 애플리케이션을 실행할 수 있습니다. 이러한 방식으로 클래스가 런타임에 로드되고(Java 가상 머신 실행을 의미) 프로그램이 실행되기 시작합니다.
이는 모든 애플리케이션이 표면적으로 실행하는 것이지만 이제 java 명령을 실행할 때 정확히 어떤 일이 발생하는지 살펴보겠습니다. 자바 가상 머신이란 무엇입니까? 대부분의 개발자는 지속적인 디버깅을 통해 JVM과 상호 작용합니다. 즉, 악명 높은 "메모리 부족" 오류를 방지하면서 Java 프로그램을 더 빠르게 실행하기 위한 시작 옵션 선택 및 값 할당이라고도 합니다. 하지만 처음에 Java 애플리케이션을 실행하기 위해 JVM이 왜 필요한지 궁금한 적이 있습니까?
자바 가상 머신이란 무엇입니까?
간단히 말해서 JVM은 Java 애플리케이션 바이트코드를 실행하고 바이트코드를 하드웨어 및 운영 체제별 명령으로 변환하는 소프트웨어 모듈입니다. 이를 통해 JVM은 Java 프로그램이 처음 작성된 후 원래 코드를 변경할 필요 없이 다른 환경에서 실행될 수 있도록 합니다. Java의 이식성은 엔터프라이즈 애플리케이션 언어의 핵심입니다. JVM이 변환 및 플랫폼 최적화를 처리하므로 개발자는 다양한 플랫폼에 대해 애플리케이션 코드를 다시 작성할 필요가 없습니다.
JVM은 기본적으로 바이트코드 명령 기계 역할을 하며 기본 계층과 상호 작용하여 실행 작업을 할당하고 메모리 작업을 수행하는 데 사용되는 가상 실행 환경입니다.
JVM은 Java 애플리케이션 실행을 위한 동적 리소스 관리도 담당합니다. 이는 메모리 할당 및 해제를 마스터하고, 각 플랫폼에서 일관된 스레딩 모델을 유지하며, CPU 아키텍처에 적합한 방식으로 애플리케이션이 실행되는 실행 명령을 구성한다는 것을 의미합니다. JVM을 사용하면 개발자는 객체에 대한 참조와 객체가 시스템에 존재해야 하는 기간을 추적할 필요가 없습니다. 마찬가지로 C와 같은 비동적 언어의 문제점인 메모리 해제 시기를 관리할 필요가 없습니다.
JVM은 Java를 실행하도록 특별히 설계된 운영 체제로 생각할 수 있습니다. JVM의 임무는 Java 애플리케이션의 실행 환경을 관리하는 것입니다. JVM은 기본적으로 실행 작업을 할당하고 메모리 작업을 수행하기 위한 바이트코드 명령 기계로서 기본 환경과 상호 작용하는 가상 실행 환경입니다.
JVM 구성 요소 개요
JVM 내부 및 성능 최적화에 관해 작성된 기사가 많이 있습니다. 이 시리즈의 기초로서 JVM 구성요소를 요약하고 개관하겠습니다. 이 간략한 개요는 JVM을 처음 접하는 개발자에게 특히 유용하며 이후의 심층 토론에 대해 더 많이 배우고 싶게 만듭니다.
한 언어에서 다른 언어로 - Java 컴파일러 정보
컴파일러는 한 언어를 입력으로 사용하고 다른 실행 가능한 명령문을 출력합니다. Java 컴파일러에는 두 가지 주요 작업이 있습니다.
1. Java 언어의 이식성을 높이고 처음 작성할 때 더 이상 특정 플랫폼에 고정할 필요가 없도록 만듭니다.
2. 특정 플랫폼에 대해 유효한 실행 코드가 생성되었는지 확인하십시오.
컴파일러는 정적이거나 동적일 수 있습니다. 정적 컴파일의 예로는 javac가 있습니다. Java 코드를 입력으로 사용하여 이를 바이트코드(Java 가상 머신에서 실행되는 언어)로 변환합니다. 정적 컴파일러는 입력된 코드를 한 번 해석하고 프로그램이 실행될 때 사용되는 실행 가능한 형식을 출력합니다. 입력이 정적이므로 항상 동일한 결과가 표시됩니다. 원본 코드를 수정하고 다시 컴파일하는 경우에만 다른 출력이 표시됩니다.
JIT(Just-In-Time) 컴파일러와 같은 동적 컴파일러 는 한 언어를 다른 언어로 동적으로 변환합니다. 즉, 코드가 실행되는 동안 이 작업을 수행합니다. JIT 컴파일러를 사용하면 컴파일러의 결정과 환경 데이터를 사용하여 런타임 분석(성능 카운트 삽입)을 수집하거나 생성할 수 있습니다. 동적 컴파일러는 언어로 컴파일하는 과정에서 더 나은 명령 시퀀스를 구현하고 일련의 명령을 보다 효율적인 명령으로 대체하며 중복 작업을 제거할 수도 있습니다. 시간이 지남에 따라 더 많은 코드 구성 데이터를 수집하고 점점 더 나은 컴파일 결정을 내리게 됩니다. 전체 프로세스를 일반적으로 코드 최적화 및 재컴파일이라고 합니다.
동적 컴파일은 동작에 따른 동적 변경 사항에 적응하거나 애플리케이션 로드 수가 증가함에 따라 새로운 최적화를 적용할 수 있는 이점을 제공합니다. 이것이 동적 컴파일러가 Java 작업에 완벽한 이유입니다. 동적 컴파일러가 외부 데이터 구조, 스레드 리소스, CPU 주기 분석 및 최적화를 요청한다는 점은 주목할 가치가 있습니다. 최적화가 깊어질수록 더 많은 리소스가 필요합니다. 그러나 대부분의 환경에서 최상위 계층은 성능에 거의 추가되지 않습니다. 즉, 순수한 해석보다 성능이 5~10배 더 빠릅니다.
할당으로 인해 가비지 수집이 발생함
각 "Java 프로세스 할당 메모리 주소 공간"을 기준으로 각 스레드에 할당되거나 Java 힙이라고 불리거나 직접 힙이라고 불립니다. Java 세계에서는 단일 스레드 할당이 클라이언트 응용 프로그램에서 일반적입니다. 그러나 단일 스레드 할당은 오늘날의 멀티 코어 환경의 병렬성을 활용하지 않기 때문에 엔터프라이즈 애플리케이션 및 워크로드 서버에는 유용하지 않습니다.
또한 병렬 애플리케이션 설계는 JVM이 여러 스레드가 동시에 동일한 주소 공간을 할당하지 않도록 강제합니다. 할당된 전체 공간에 잠금을 설정하여 이를 제어할 수 있습니다. 그러나 이 기술(종종 힙 잠금이라고도 함)은 성능 집약적이며 스레드를 보류하거나 대기열에 추가하면 리소스 활용도와 애플리케이션 최적화 성능에 영향을 미칠 수 있습니다. 멀티 코어 시스템의 좋은 점은 리소스를 할당하고 직렬화하는 동안 단일 스레드 병목 현상을 방지하기 위한 다양한 새로운 방법이 필요하다는 것입니다.
일반적인 접근 방식은 힙을 여러 부분으로 나누는 것입니다. 여기서 각 파티션은 애플리케이션에 적합한 크기입니다. 분명히 조정이 필요하며 할당 속도와 개체 크기는 애플리케이션마다 크게 다르며 동일한 스레드 수도 다릅니다. TLAB(스레드 로컬 할당 버퍼) 또는 때로는 TLA(스레드 로컬 영역)는 전체 힙 잠금을 선언하지 않고도 스레드가 자유롭게 할당할 수 있는 특수 파티션입니다. 영역이 가득 차면 힙이 가득 차게 되는데, 이는 힙에 객체를 배치할 여유 공간이 충분하지 않아 공간을 할당해야 함을 의미합니다. 힙이 가득 차면 가비지 수집이 시작됩니다.
파편
TLAB를 사용하여 예외를 포착하면 힙이 조각화되어 메모리 효율성이 감소합니다. 애플리케이션이 객체를 할당할 때 TLAB 공간을 늘리거나 완전히 할당할 수 없는 경우 해당 공간이 너무 작아서 새 객체를 생성할 위험이 있습니다. 이러한 여유 공간은 "조각화"로 간주됩니다. 응용 프로그램이 개체에 대한 참조를 유지한 다음 남은 공간을 할당하면 결국 해당 공간은 오랫동안 비어 있게 됩니다.
조각화는 조각이 힙 전체에 분산되어 사용되지 않는 메모리 공간의 작은 섹션을 통해 힙 공간을 낭비하는 것입니다. 애플리케이션에 "잘못된" TLAB 공간을 할당하면(객체 크기, 혼합 객체 크기 및 참조 보유 비율과 관련하여) 힙 조각화가 증가하는 원인이 됩니다. 애플리케이션이 실행되면 조각 수가 증가하고 힙 공간을 차지합니다. 조각화로 인해 성능이 저하되고 시스템이 새 응용 프로그램에 충분한 스레드와 개체를 할당할 수 없습니다. 그러면 가비지 수집기가 메모리 부족 예외를 방지하는 데 어려움을 겪게 됩니다.
TLAB 폐기물은 작업 중에 생성됩니다. 조각화를 완전히 또는 일시적으로 방지하는 한 가지 방법은 모든 기본 작업에서 TLAB 공간을 최적화하는 것입니다. 이 접근 방식에 대한 일반적인 접근 방식은 애플리케이션에 할당 동작이 있는 한 이를 다시 조정해야 한다는 것입니다. 이는 복잡한 JVM 알고리즘을 통해 달성할 수 있으며, 또 다른 방법은 힙 파티션을 구성하여 보다 효율적인 메모리 할당을 달성하는 것입니다. 예를 들어, JVM은 특정 크기의 사용 가능한 메모리 블록 목록으로 서로 연결되는 사용 가능 목록을 구현할 수 있습니다. 인접한 여유 메모리 블록은 동일한 크기의 다른 인접한 메모리 블록에 연결되어 각각 고유한 경계를 갖는 소수의 연결된 목록을 생성합니다. 어떤 경우에는 사용 가능 목록이 더 나은 메모리 할당을 가져옵니다. 스레드는 비슷한 크기의 블록에 개체를 할당할 수 있으므로 고정 크기 TLAB에 의존하는 경우보다 잠재적으로 조각화가 덜 발생합니다.
GC 퀴즈
일부 초기 가비지 수집기에는 여러 개의 이전 세대가 있었지만 두 개 이상의 이전 세대가 있으면 오버헤드가 값보다 커집니다. 할당을 최적화하고 조각화를 줄이는 또 다른 방법은 새로운 객체 할당 전용 힙 공간인 젊은 세대를 만드는 것입니다. 나머지 힙은 소위 Old Generation이 됩니다. Old Generation은 수명이 긴 객체를 할당하는 데 사용됩니다. 오랫동안 존재한다고 가정되는 객체에는 가비지 수집되지 않은 객체나 대형 객체가 포함됩니다. 이 할당 방법을 더 잘 이해하려면 가비지 수집에 대한 지식에 대해 이야기해야 합니다.
가비지 수집 및 애플리케이션 성능
가비지 수집은 참조되지 않은 점유된 힙 메모리를 해제하는 JVM의 가비지 수집기입니다. 가비지 수집이 처음으로 트리거되면 모든 개체 참조가 계속 유지되고 이전 참조가 차지한 공간이 해제되거나 재할당됩니다. 회수 가능한 모든 메모리가 수집된 후 해당 공간은 확보되어 새 개체에 다시 할당될 때까지 기다립니다.
가비지 수집기는 참조 개체를 다시 선언할 수 없습니다. 그렇게 하면 JVM 표준 사양이 위반됩니다. 이 규칙의 예외는 가비지 수집기의 메모리가 부족할 경우 포착될 수 있는 소프트 또는 약한 참조입니다. 그러나 약한 참조는 피하는 것이 좋습니다. 왜냐하면 Java 사양의 모호함은 잘못된 해석과 사용 오류로 이어지기 때문입니다. 게다가 Java는 동적 메모리 관리를 위해 설계되었습니다. 메모리를 언제 어디서 해제할지 생각할 필요가 없기 때문입니다.
가비지 수집기의 과제 중 하나는 실행 중인 애플리케이션에 영향을 주지 않는 방식으로 메모리를 할당하는 것입니다. 가비지 수집을 최대한 많이 하지 않으면 애플리케이션이 메모리를 소비하게 됩니다. 너무 자주 수집하면 처리량과 응답 시간이 손실되어 실행 중인 애플리케이션에 나쁜 영향을 미치게 됩니다.
GC 알고리즘
다양한 가비지 수집 알고리즘이 있습니다. 이 시리즈의 뒷부분에서 몇 가지 사항에 대해 자세히 논의할 것입니다. 가장 높은 수준에서 가비지 수집의 두 가지 주요 방법은 참조 계산 및 추적 수집기입니다.
참조 계산 수집기는 개체가 가리키는 참조 수를 추적합니다. 객체의 참조가 0에 도달하면 메모리가 즉시 회수되며 이는 이 접근 방식의 장점 중 하나입니다. 참조 카운팅 접근 방식의 어려움은 순환 데이터 구조와 모든 참조를 실시간으로 업데이트하는 데 있습니다.
추적 수집기는 아직 참조 중인 개체를 표시하고, 표시된 개체를 사용하여 참조된 모든 개체를 반복적으로 추적하고 표시합니다. 여전히 참조되는 모든 개체가 "라이브"로 표시되면 표시되지 않은 모든 공간이 회수됩니다. 이 접근 방식은 링 데이터 구조를 관리하지만 많은 경우 수집기는 참조되지 않은 메모리를 회수하기 전에 모든 표시가 완료될 때까지 기다려야 합니다.
위의 방법을 수행하는 방법은 다양합니다. 가장 유명한 알고리즘은 표시 또는 복사 알고리즘, 병렬 또는 동시 알고리즘입니다. 이에 대해서는 이후 기사에서 논의하겠습니다.
일반적으로 가비지 수집의 의미는 힙의 새 개체와 이전 개체에 주소 공간을 할당하는 것입니다. "오래된 개체"는 많은 가비지 수집에서 살아남은 개체입니다. 새 개체를 할당하려면 새 세대를 사용하고, 메모리를 차지하는 수명이 짧은 개체를 빠르게 재활용하여 조각화를 줄일 수 있습니다. 또한 수명이 긴 개체를 함께 모아 공간의 이전 세대 주소에 배치합니다. 이 모든 것은 수명이 긴 개체 간의 조각화를 줄이고 조각화로부터 힙 메모리를 절약합니다. 새로운 세대의 긍정적인 효과는 이전 세대 개체의 더 비싼 수집을 지연시키고 임시 개체에 대해 동일한 공간을 재사용할 수 있다는 것입니다. (오래된 공간을 수집하는 데는 수명이 긴 개체가 더 많은 참조를 포함하고 더 많은 순회가 필요하기 때문에 비용이 더 많이 듭니다.)
언급할 가치가 있는 마지막 알고리즘은 메모리 조각화를 관리하는 방법인 압축입니다. 압축은 기본적으로 객체를 함께 이동하여 더 큰 연속 메모리 공간을 해제합니다. 디스크 조각화와 이를 처리하는 도구에 익숙하다면 압축이 Java 힙 메모리에서 실행된다는 점을 제외하면 압축이 압축과 매우 유사하다는 것을 알게 될 것입니다. 압축에 대해서는 나중에 시리즈에서 자세히 설명하겠습니다.
요약: 검토 및 하이라이트
JVM은 인기와 생산성 향상에 기여하는 Java 플랫폼의 모든 주요 기능인 이식성(한 번 프로그램하면 어디서나 실행)과 동적 메모리 관리를 허용합니다.
JVM 성능 최적화 시스템에 대한 첫 번째 기사에서는 컴파일러가 바이트코드를 대상 플랫폼의 명령 언어로 변환하고 Java 프로그램 실행을 동적으로 최적화하는 데 도움을 주는 방법을 설명했습니다. 다른 응용 프로그램에는 다른 컴파일러가 필요합니다.
또한 메모리 할당과 가비지 수집, 그리고 이것이 Java 애플리케이션 성능과 어떤 관련이 있는지 간략하게 다루었습니다. 기본적으로 힙을 더 빨리 채우고 가비지 수집을 더 자주 트리거할수록 Java 애플리케이션의 활용률이 높아집니다. 가비지 수집기의 한 가지 과제는 실행 중인 애플리케이션에 영향을 주지 않지만 애플리케이션의 메모리가 부족해지기 전에 메모리를 할당하는 것입니다. 향후 기사에서는 기존 및 새로운 가비지 수집과 JVM 성능 최적화에 대해 더 자세히 논의할 것입니다.