초심으로 돌아갈 때가 된 것 같다. 오늘 포스팅할 내용은 JVM(Java Virtual Machine)이다. 사실 지금까지는 스킬 베이스의 공부만 해왔었다. 하지만 점점 개발을 하다보니 성능이라는 것에 굉장히 큰 관심이 생겼다. Java의 성능에 핵심인 JVM 튜닝을 다루어 보기 전에 우선 JVM이 무엇인지 알아보자.

 

JVM이란?

JVM(Java Virtual Machine)을 어떻게 정의할 것인가? 우선 원어를 먼저 알아보자. 처음의 "Java"는 프로그램 언어인 Java를 의미한다. 그리고 Virtual Machine은 물리적인 형태가 아닌 소프트웨어로서의 개념을 의미한다. 즉, 소프트웨어적으로 Java 언어를 실행시키기 위한 Machine을 말하는 것이다. 결국 JVM은 정의된 스펙(벤더사마다 다른 JVM 구현을 가진다. 하지만 표준은 존재.)을 구현한 하나의 독자적인 프로세스 형태로 구동되는 Runtime Instance라고 할 수 있다. 따라서 JVM의 역할은 개발자들이 작성한 Java 프로그램을 실행시키는 하나의 데몬이라고 볼 수 있는 것이다.

 

아래그림은 JVM의 내부구조를 보여준다.

 

 

  • Java Source : 사용자가 작성한 Java 코드이다(.java)
  • Java Compiler : Java Source 파일을 JVM이 해석할 수 있는 Java Byte Code로 변경한다.
  • Java Byte Code : Java Compiler에 의해 컴파일된 결과물이다.(.class)
  • Class Loader : JVM 내로 .class 파일들을 로드하여 Runtime Data Areas에 배치한다.
  • Execution Engine : 로드된 클래스의 Bytecode를 해석(Interpret)한다.
  • Runtime Data Area : JVM이라는 프로세스가 프로그램을 수행하기 위해 OS에서 할당 받은 메모리 공간이다.

 

 

Java코드를 JVM으로 실행시키는 과정을 간단하게 설명하면, 자바 컴파일러에 의해 자바코드가 클래스 파일로 컴파일된 후 클래스로더로 해당 클래스 파일들을 로드 시킨다. 이후 로딩된 바이트코드를 실행엔진을 이용하여 해석(Interpret)하여 런타임 데이터 영역에 배치시킨다. 하지만 매번 인터프리터하여 한줄씩 해석하는 데에는 시간이 오래 걸리기 때문에 JIT(Just In Time) 컴파일러를 이용한다. JIT 컴파일러를 이용하지 않는다면 바이트코드를 사용할 때마다 인터프리터해서 사용해야 하기 때문에 실행시간이 굉장히 느리다. 하지만 적절한 시점에 JIT 컴파일러가 바이트코드를 컴파일하여 네이트브 코드로 캐싱해놓는 것이다. 여기서 컴파일한다는 것은 인터프리터처럼 한줄 단위로 컴파일하는 것이 아니라 실행하는 전체 코드를 컴파일하고 캐시하므로 이 또한 시간이 꽤 걸린다. 하지만 한번 컴파일된 이후 캐싱되니 그 이후로는 빠른 실행을 보장할 것이다.(한번 실행되고 말 것들은 인터프리터 하고 주기적으로 자주 수행되는 것들은 체킹을 통해 일정 횟수가 넘으면 JIT 컴파일러로 전체 컴파일한다.) 이렇게 해석된 녀석들은 Runtime Data Areas에 배치한다.

 

그렇다면 Runtime Data Areas는 어떠한 메모리 구조를 갖고 있을까? 한번 살펴보자!

 

 

  • Method Areas : 클래스, 변수, Method, static 변수, 상수 정보 등이 저장되는 영역이다.(모든 Thread가 공유한다.)
  • Heap Area : new 명령어로 생성된 인스턴스와 객체가 저장되는 구역이다.(Garbage Collection 이슈는 이 영역에서 일어나며, 모든 Thread가 공유한다.)
  • Stack Area : Java Method 내에서 사용되는 값들(매개변수, 지역변수, 리턴값 등)이 저장되는 구역으로 메소드가 호출될 때 LIFO로 하나씩 생성되고, 메소드 실행이 완료되면 LIFO로 하나씩 지워진다.(각 Thread별로 하나씩 생성된다.)
  • PC Register : CPU의 Register와 역할이 비슷하다. 현재 수행 중인 JVM 명령의 주소값이 저장된다.(각 Thread별로 하나씩 생성된다.)
  • Native Method Stack : 다른 언어(C/C++ 등)의 메소드 호출을 위해 할당되는 구역으로 언어에 맞게 Stack이 형성되는 구역이다.

Java Heap(Hotspot JVM의 Heap)

Java의 메모리 구조는 Heap이 전부는 아니다. Thread 공유의 정보는 Stack에 저장이 되고 Class나 Method 정보, Bytecode등은 Method Areas에 저장된다. Java Heap은 Instance, Array 객체 두 가지 종류만 저장되는 공간이다. 모든 Thread들에 의해 공유되는 영역이다. 같은 애플리케이션을 사용하는 Thread 사이에서는 공유된 Heap Data를 이용할 때 동기화 이슈가 발생할 수 있다.

JVM은 Java Heap에 메모리를 할당하는 메소드(ex.new 연산)만 존재하고 메모리 해제를 위한 어떤 Java Code를 직접적으로 프로그래머가 명시하지 않는다.(물론 있긴하지만 사용하지 않는 편이 낫다.) Java Heap의 메모리 해제는 오로지 Garbage Collection을 통해서만 수행한다.

 

지금 다루어볼 Heap의 구조는 Hotspot JVM의 Heap 구조를 다루어 볼 것이다. 하지만 Hotspot JVM Heap 구조는 Java 8 버전 전후로 구조가 바뀌었다. 해당 내용은 기술 면접에서도 나올 만한 내용이다. 왜냐? 지금은 대부분 Java 1.8 이상을 사용하고 있기 때문이다. 아래 그림을 참조해보자. 

 

Java 1.7 이전

 

Java 1.8 이후

 

달라진 부분이 Perm 영역이다. Metaspace 영역으로 대체된 것이 보인다. 이것은 뒤에서 설명할 것이다.

 

Hotspot JVM은 크게 Young Generation과 Old Generation으로 나누어져 있다. Young Generation은 Eden 영역과 Suvivor 영역으로 구성되는데 Eden 영역은  Object가 Heap에 최초로 할당되는 장소이며 Eden 영역이 꽉 차게 되면 Object의 참조 여부를 따져 만약 참조가 되어 있는 Live Object이면 Suvivor 영역으로 넘기고, 참조가 끊어진 Garbage Object 이면 그냥 남겨 놓는다. 모든 Live Object가 Survivor 영역으로 넘어가면 Eden 영역을 모두 청소한다.

 

Suvivor 영역은 말 그대로 Eden 영역에서 살아남은 Object들이 잠시 머무르는 곳이다. 이 Suvivor 영역은 두 개로 구성되는데 Live Object를 대피시킬 때는 하나의 Survivor 영역만 사용하게 된다. 이러한 전반의 과정을 Minor GC라고 한다.

 

Young Generation에서 Live Object로 오래 살아남아 성숙된 Object는 Old Generation으로 이동하게 된다. 여기서 성숙된 Object란 의미는 애플리케이션에서 특정 회수 이상 참조되어 기준 Age를 초과한 Object를 말한다. 즉, Old Generation 영역은 새로 Heap에 할당되는 Object가 들어오는 것이 아니라, 비교적 오랫동안 참조되어 이용되고 있고 앞으로도 계속 사용될 확률이 높은 Object들이 저장되는 영역이다. 이러한 Promotion 과정 중 Old Generation의 메모리도 충분하지 않으면 해당 영역에서도 GC가 발생하는데 이를 Full GC(Major GC)라고 한다.

 

Java 1.7 기준의 Perm 영역은 보통 Class의 Meta 정보나 Method의 Meta 정보, Static 변수와 상수 정보들이 저장되는 공간이다. 이 영역은 Java 1.8부터는 Native 영역으로 이동하여 Metaspace 영역으로 변경되었다.(다만, 기존 Perm 영역에 존재하던 Static Object는 Heap 영역으로 옮겨져 최대한 GC 대상이 될 수 있도록 하였다.)

 

조금 더 1.7과 1.8 메모리 구조의 변경에 대해 설명하자면, Java 1.8에서 JVM 메모리의 구조적인 개선 사항으로 Perm 영역이 Metaspace 영역으로 전환되고 기존 Perm 영역은 사라지게 되었다. Metaspace 영역은 Heap이 아닌 Native 메모리 영역으로 취급하게 된다. (이 말은 기존의 Perm은 Heap 안에 존재하는 것으로 알 수 있다.) Native 메모리는 Heap과 달리 OS레벨에서 관리 하는 영역이다.

 

Perm과 Metaspace를 표로 구분해 보았다.

구분 상세 구분 Perm Metaspace
저장 정보 클래스의 메타 정보 저장 저장
메소드의 메타 정보 저장 저장
Static 변수, 상수 저장 Heap 영역으로 이동
관리 포인트 메모리 관리(튜닝)

Heap 영역 튜닝

Perm 영역도 별도로 튜닝해야함

Heap 영역 튜닝

Native 영역 동적 조정

(별도 옵션으로 조절 가능)

GC 측면 GC 수행 대상 Full GC 수행 대상 Full GC 수행 대상
메모리 측면 메모리 크기(옵션)

-XX:PermSize

-XX:MaxPermSize

-XX:MetaspaceSize

-XX:MaxMetaspaceSize

 

Java 1.8로 넘어오면서 주의해야 할 점이 있다. 아래 명령어 결과를 보자.

Metaspace가 잡고 있는 Max Size가 약 16Exabyte이다. 이 수치는 64bit 프로세서가 취급할 수 있는 메모리 상한치라 할 수 있다. Metaspace 영역은 Native 메모리로 다루기 때문에 기본적으로 JVM에 의해 크기가 강제되지 않고 프로세스가 이용할 수 있는 메모리 자원을 최대한 활용할 수 있다고 본다. 만약 Classloader에 메모리 누수가 의심되는 경우 -XX:MaxMetaspaceSize를 지정할 필요성이 있다. 왜냐하면  최대 값을 정해놓지 않는다면 Metaspace 영역의 메모리가 계속해서 커질 것이고 결국은 서버가 뻑나는 경우가 생길 수 있다.

 

오늘은 여기까지 간단히 JVM 구조에 대해 다루어보았다. 다음 포스팅은 Heap에서 이루어지는 GC에 대한 포스팅을 할 예정이다. 자바 성능에 아주 중요한 요인중 하나인 GC는 튜닝 관점에서 아주 중요한 관리 포인트이기도 하다.

posted by 여성게
:

엘라스틱서치는 JVM 위에서 동작하는 자바 애플리케이션이다. 그렇기 때문에 엘라스틱서치는 JVM 튜닝옵션들을 제공한다. 하지만 수년간 엘라스틱서치의 경험으로 최적화된 JVM옵션을 거의 적용하고 있기 때문에 변경할 필요는 없다고 한다. 하지만 Heap Memory 사이즈 같은 경우는 실 운영환경에서는 기본으로 제공하는 1기가보다는 높혀서 사용할 필요성이 있다.

 

$ELASTIC_PATH/config/jvm.options 파일에 들어가면 Xms,Xmx 옵션으로 최소,최대 JVM 힙 메모리 사이즈 조정이 가능하며 기타 다른 JVM옵션 변경이 가능하다. 다시 한번 강조하자면 왠만하면 다른 옵션들은 디폴트 값으로 가져가 사용하는 것이 좋다.

 

그리고 보통 JVM에서 Xms 크기의 메모리를 사용하다가 메모리가 더 필요하면 Xmx 크기만큼 메모리를 늘려서 사용하게 된다. 하지만 이렇게 Xmx크기의 메모리를 사용하려면 그 순간 갑자기 성능이 나빠질 수 있다. 그렇기 때문에 왠만하면 Xms,Xmx의 크기를 같게 주는 것이 여러모로 유리하다. 그리고 힙사이즈를 너무 작게하면 OOM(Out Of Memory) 오류가 날 수 있고 그렇다고 힙사이즈를 너무 크게하면 FullGC 발생시 STW가(Stop The World) 발생해 애플리케이션이 프리징되는 시간이 길어지기 때문에 사용자에게 애플리케이션이 멈춰보이는 현상을 줄 수 있기에 무작정 큰 메모리 사이즈를 할당하는 것도 좋지 않다.(보통 엘라스틱서치의 힙사이즈는 데몬 서버당 32기가 이하로 설정하길 권장한다.)

 

 

운영체제가 사용할 메모리 공간을 확보

엘라스틱서치 샤드는 내부적으로 루씬을 가지고 있으며 루씬은 세그먼트 생성 및 관리를 위해 커널 시스템 캐시를 많이 사용한다. 하지만 이렇게 시스템 캐시는 운영체제가 가지고 있는 메모리 공간으로 커널 내부에 존재하게 된다. 즉, 운영체제가 사용할 메모리를 대략 전체 스펙에 50%정도를 남겨놔야 좋다.

 

자바 8 기반에서는 힙 크기를 32기가 이상 사용하지 않는 것이 좋다

예) 128기가의 물리 머신에서 64기가를 운영체제에게 나머지 64기가를 엘라스틱서치가 할당받는 다면 밑에 스펙중 무엇을 선택할것인가?

  • 1)64기가 운영체제, 64기가 엘라스틱서치 노드1개
  • 2)64기가 운영체제,32기가 엘라스틱서치 노드2개

위의 두가지중 엘라스틱서치에서 안내하는 권장사항을 따른다면 2번 스펙을 따를 것이다. 이 말은 엘라스틱서치 노드 데몬서버 하나당 힙메모리를 32기가이상 잡지 않는것이다. 엘라스틱서치에서 이러한 가이드를 제공하는 이유는 핫스팟(Hot-Spot) JVM의 Object Pointer 정책때문이다. 즉, 엘라스틱서치 뿐만 아니라 모든 자바 기반 애플리케이션에도 동일하게 32기가 이상 잡지 않는 것을 권장한다. Object Pointer는 간단히 객체의 메모리 번지를 표현하는 주소값이다. 그리고 32비트,64비트 JVM은 기본적으로 모두 32비트 주솟값을 가지고 동작한다. 이유는 기본적으로 JVM은 32비트 Object Pointer를 사용하고 있기 때문이다. 여기서 너무 자세한 내용을 설명하는 것은 주제와 맞지 않을 것같아서 간단히 이야기하면 64비트 주솟값을 사용하면 주솟값을 위해 낭비되는 메모리값이 너무 많아 진다. 그렇기 때문에 JVM은 기존 Object Pointer를 개선한 Compressed Ordinary Object Pointer를 사용하는데 이 포인터가 기본적으로 32비트 Object Pointer한다. 이렇게 64비트 환경의 서버에서 32비트의 주소값을 사용하여 메모리 낭비를 줄이며 효율적으로 사용되는데, 만약 JVM 힙메모리 옵션이 32기가 이상 넘어가게되면 COOP에서 일반적인 64비트 주소값을 사용하는 OOP로 바뀌도록 되어 있다. 이렇게 64비트 주솟값 OOP 사용하게 되면 주솟값을 위하여 낭비되는 메모리의 값이 동일하게 증가하기 때문에 효율성이 떨어지게 되는 것이다.

 

 

상황에 따른 엘라스틱서치 힙크기 설정하기

 

  • 1)적절한 성능의 서버 : 32기가 힙메모리를 할당하여 엘라스틱서치 노드를 사용한다.
  • 2)고성능 서버 : 적절히 엘라스틱서치 노드를 나누어서 32기가씩 할당하여 사용한다.
  • 3)전문(Full Text) 검색을 주목적으로 엘라스틱서치를 사용하는 경우 : 엘라스틱서치 힙에 32기가를 할당하고 나머지를 운영체제에 남겨둬서 루씬이 시스템 캐시를 통해 메모리를 최대한 사용할 수 있게 한다. 전문 검색의 경우 메모리 연산보다는 루씬의 역색인 구조를 이용하기 때문에 시스템 캐시를 많이 이용한다.
  • 4)Not Analyzed 필드의 정렬/집계 작업이 많이 수행되는 경우 : 분석되지 않은 필드들의 정렬/집계는 루씬의 DocValues(루씬 캐시,기본적으로 not analyzed한 필드들은 기본적으로 DocValues가 생성됨)를 사용하기 때문에 힙 공간을 거의 사용하지 않고 시스템캐시를 이용하기 때문에 루씬에게 메모리를 많이 할당 될 수 있게 한다.
  • 5)전문(Full Text) 필드에서 정렬/집계 작업을 많이 수행하는 경우 : 전문(analyzed fleld)같은 경우는 루씬의 DocValues를 이용하지 않기 때문에 fielddata라는 힙 기반의 캐시를 이용하기 때문에 전문 필드 정렬/집계가 많은 경우 32기가로 엘라스틱서치 노드를 나누어서 여러개 생성하는 방식이 효율적이다.

 

posted by 여성게
: