영역 바이너리 옵션 타입

마지막 업데이트: 2022년 7월 10일 | 0개 댓글
  • 네이버 블로그 공유하기
  • 네이버 밴드에 공유하기
  • 페이스북 공유하기
  • 트위터 공유하기
  • 카카오스토리 공유하기
Garbage collection was invented by John McCarthy around 1959 to simplify manual memory management in Lisp. [Wiki] Garbage collection (computer science)

Spark Internal Part 2. Spark의 메모리 관리(1)

Spark 사용 시 여러가지 문제를 겪을 수 있는데, 그 중 가장 많이 겪게 되는 부분이 메모리 관련(주로 GroupBy 계열 Transformation을 사용할 때 발생하는 Out Of Memory Exception)문제입니다.

Spark 공식 문서를 보면 메모리 구조나 옵션 등에 대해 상세히 나와 있지만, 사용하다보면 옵션을 잘 적용했다고 해도 종종 오류가 발생하는 경우가 있습니다. 모든 내용이 Spark 문서에 기재되어 있지 않을 뿐더러(물론 Databricks에서는 많은 강의를 통해 내부 구조에 대해 설명하고 있긴 합니다), 써드파티 라이브러리(Parquet 등)에서의 문제(아래에서 기술할 예정입니다)도 있기 때문입니다.

물론 이 글에서도 전체 내용을 다루지는 못하며(워낙 Spark 코드가 방대하고 현재도 변화하고 있기 때문에), 일부 내용의 경우 잘못 이해하고 작성했을 수도 있습니다.

만일 글 내용 중 틀렸거나 부족한부분이 있다면 피드백 부탁드립니다. 글을 쓰고 공개적인 장소에 공유하는 이유는 이 글을 읽는 다른 분들께 약간이나마 도움을 드릴 수 있고, 저도 피드백을 받아 더 성장할 수 있다고 생각하기 때문입니다.

그럼, 시작하겠습니다.(아래부터는 말투가 바뀌어도 이해해주시기 바랍니다)

Spark의 메모리 관리를 알아보기 전에, JVM Object Memory Layout, Garbage Collection, Java NIO, Netty Library 등에 대한 이해가 필요하다.

첫 두 용어(JVM Object Memory Layout, Garbage Collection)는 Spark의 메모리 구조나 관리가 왜 현재와 같이 설계되었는지에 대한 원인에 가까우며, 나머지 두 용어(Java NIO, Netty Library)의 경우 이러한 원인의 해결책에 가깝다고 할 수 있다.

물론 이러한 주제를 모른다고 해서 Spark의 메모리 구조를 파악하는데 큰 어려움이 있는 것은 아니며, 대부분의 Spark 사용자들은 많은 메모리 관련 오류를 겪으며 어렴풋이 메모리 구조에 대한 이해가 생겼을 수도 있다고 생각한다.

하지만 이 내용들을 이해하고 넘어간다면, 좀 더 확실히 오류의 원인을 파악할 수 있고 빠르게 문제를 해결해 나갈 수 있다고 믿는다.

JVM Object Memory Layout

From Java code to Java heap

This article gives you insight into the memory usage of Java code, covering the memory overhead of putting an int value…

이 내용은 IBM developerWorks의 From Java code to Java Heap을 참고하여 작성하였습니다.

32비트 OS에서 java 명령어를 사용하여 Java Application을 실행 시켰을 때, 점유하는 메모리 공간을 크게 2가지 영역으로 나누어 볼 수 있다.

  • Native Heap 영역(JVM 자체는 Native Heap 영역의 일부를 사용한다)
  • Java Heap 영역

우리가 ‘일반적인’ 자바 프로그램을 작성할 떄 사용하는 부분은 Java Heap 영역이다.

Java에는 원시 타입(Primitive Type | int, boolean 등 기본형)과 참조 타입(Reference Type | Integer, Boolean, 사용자 정의 클래스 등)의 2가지 타입을 제공한다.

기본적으로 참조 타입은 원시 타입에 비해 많은 메모리를 사용한다. 참조 타입은 값에 대한 메모리 공간에 객체메타정보에 대한 메모리 공간도 추가적으로 사용하기 때문이다.

위 글에서는 int와 Integer를 기준으로 비교가 이루어졌는데, Integer의 경우 int보다 약 4배 정도의 메모리 공간을 소모한다고 기술되어 있다.

JVM 벤더에 따라 다를 수 있겠지만 Object 메타데이터는 보통 3가지로 이루어져 있다고 한다.

  • Class 포인터: 객체가 자신이 속한 클래스를 가리키는 포인터. Integer 객체의 경우 java.lang.Integer 클래스를 가리키고 있을 것이다(설명은 나와 있지 않지만, ClassLoader에 의해 PermGen 영역에 로드된 Class 객체를 가리킬 것이라 생각한다)
  • Flags: 객체 상태에 대한 정보(객체의 hashcode나 이 객체가 Array인지 아닌지 등)
  • Lock: 동기화(Synchronization)에 사용되는 정보(Object 기반 Lock을 건다면 사용될 듯 하다)

위 그림을 보면 Integer의 실제 값을 저장하는 공간을 32bit(4byte)인데 메타데이터가 96bit(12byte)를 사용하고 있는 것을 알 수 있다.

Array 객체(여기서는 primitive를 Element로 가진 Array로 설명했다)의 경우 Array Length를 알리는 Size 필드가 추가된다.

String과 같은 객체의 경우 내부적으로 Array[char]를 사용하기 때문에 위와 같이 2개의 메모리 구조를 사용하게 된다.

JVM 메모리 구조를 다루는 글은 아니기 때문에(후에 좀 더 깊이 공부하고싶은 내용이긴 하다) 이 정도까지의 내용을 기술하도록 하겠다.

여기까지 읽으신 분께서는 ‘생각보다 참조 타입이 메모리를 많이 사용하는구나’ 라고 생각하셨을 것이다. 더 나아가, ‘Spark과 같은 대용량 데이터 처리 프레임워크에서는 이러한 JVM Object의 Overhead를 줄이는 방향을 연구하겠구나’ 라는 생각도 하셨을 것이라 생각한다.

Garbage Collection

사실 Java의 Garbage Collection은 너무나 많은 국내/외 문서나 블로그에 잘 정리되어 있어, 이 글에 작성하는 것보다 그 글들을 보는 것이 더 효율적일 것이다.(Naver D2의 Java Garbage Collection이 잘 정리되어 있다고 생각하여 공유드린다)

NAVER D2

Java Garbage Collection

이 내용은 Databricks에서 2015년 5월 28일에 작성한 Tuning Java Garbage Collection for Apache Spark Applications의 내용과 내 생각(은 이텔릭체로 표기하겠습니다)을 바탕으로 작성하였다.

Garbage Collection은 ‘weak generational hypothesis’에 기반하여 만들어졌으며, ‘weak generational hypothesis’의 내용은 아래와 같다.

  • Most of the objects become unused quickly
  • The ones that do not usually survive for a (very) long time

대부분의 객체는 생성된 후 얼마가지 않아 사용되지 않으며, 살아남는 객체의 경우 매우 시간을 살아남는다는 것이다.

위의 객체를 RDD에 대응하여 생각해본다면 어떨까?

RDD[T]에 대해 map(f)를 호출하여 RDD[T]를 만든다고 생각해보자. 특정 Executor에 RDD의 파티션들이 할당되고, Executor는 연산을 위해 메모리에 파티션 데이터를 로드하고, 이 데이터들을 우리가 넘긴 함수 f(T->U)에 적용하여 새로운 RDD의 파티션을 만들어 낼 것이다.

함수 f에서 T에 해당하는 이전 데이터들은 U로 변환되고 나면 더이상 참조되어지지 않을 것이다. T에 해당하는 데이터들은 Minor GC가 발생했을 때 Survive하지 못하고 없어질 것이다.(여기까지는 ‘stop the world’가 발생하지 않기 때문에 큰 성능 이슈는 발생하지 않는다)

만일 만들어진 RDD들에 대해 cache를 호출한다면 어떻게 될까?

Spark의 Default Storage Level은 MEMORY_ONLY이기 때문에 cache를 호출하고, 다른 RDD들을 만들어나갈 때마다 cache된 RDD들은 Eden -> Survive -> Old로 이동해나갈 것이다.

결과적으로 메모리 공간이 부족해지면 Full GC가 발생하고, 이에 우리의 Executor JVM은 ‘stop the world’에 직면하여 작업이 지연될 것이다.

JVM Object Memory Layout 특성에 따라 하나의 값을 표현하는데 많은 메모리가 소요되고, 이러한 메모리 소요에 의해 부족해진 공간을 확보하기 위해 Garbage Collection이 발생한다.

대용량 데이터를 처리하는 프로그램에서는 이러한 특징들이 처리 속도를 저하시켰을 것이고, Spark 개발진 또한 이러한 문제를 해결하기 위해 고민했을 것이다. 현재 Spark에 적용되어 있는 다양한 기술(Project Tungsten, Netty)들은 이 고민에 대한 답이라 생각할 수 있을 것이다.

지금부터는 Spark 개발진이 이러한 문제를 해결하기 위하여 적용한 Project Tungsten과 Netty에 대해 알아보려 한다.

Project Tungsten

Project Tungsten은 Spark의 실행 엔진 개선(CPU와 메모리 위주)을 위해 2014년에 실행되었던 Umbrella Project의 코드네임이다.

위의 Project Tungsten 링크로 들어가보면, 7가지 주요 특징에 대해 서술해 놓았는데, 이 중 Software Level에 가까운 3개를 추려보자면 아래와 같다.(사실 Code Generation 또한 Hardware Level에 가깝다고 생각하지만 위 페이지를 들어가 나머지 3개를 보면 왜 Code Generation이 Software Level에 가깝다고 말한지 이해할 것이다)

  • Memory Management and Binary Processing: 메모리를 좀 더 명시적으로 관리하며 JVM 객체와 Garbage Collection의 Overhead를 없애는 것
  • Cache-aware computation: 메모리 계층 구조(L1, L2, Memory, Disk 순으로 느려지니까)를 활용하는 알고리즘과 데이터 구조 도입
  • Code generation: 모던 컴파일러와 CPU를 활용할 수 있는 코드 생성

위 내용을 좀 더 자세히 설명해놓은 자료는 2015년 4월 28일에 올라온

Project Tungsten: Bringing Apache Spark Closer to Bare Metal

Project Tungsten focuses on improving the efficiency of memory and CPU for Spark applications. Motivated by…

이며, 이를 통한 Spark SQL의 성능 개선은

Spark SQL: Another 16x Faster After Tungsten - Databricks

Apache Spark 2.1.0 boosted the performance of Apache Spark SQL due to Project Tungsten software improvements. Another…

첫번째로 링크한 페이지의 내용 중 1. Memory Management and Binary Processing 을 정리해보았다.

처음 내용의 대부분은 이 글의 위에서 기재한 JVM Object Memory Layout과 Garbage Collection에서 오는 성능 저하에 대한 내용을 다루고 있다.

여기서부터 글 내용에 나오는 부분에 대해서는 일반적인 글씨체, 제 생각이 추가된 부분은 이텔릭체로 작성하겠습니다.

Spark의 경우 데이터가 어떤 방식으로 구성(Job, Stage, Task)되어 연산되는지, 데이터의 Life Cycle은 어떻게 되는지에 대한 정보를 가지고 있기 때문에 JVM Garbage Collector보다 더 효율적으로 메모리를 관리할 수 있다.

범용적인 목적의 Application을 가정하고 만들어진 ‘weak generational hypothesis’이 Spark에는 잘 적용되지 않는 모델이라고 생각하는 것 같다.

JVM 객체와 Garbage Collection의 오버헤드를 없애기 위해 JVM Object가 아니라 Binary Data로 직접 연산할 수 있도록 Spark 연산자를 변환하는 메모리 관리자(현재 Spark Code에서 확인할 수 있는 Unified Memory Manager를 가리키는 것 같다)를 도입했다.

사실 이 부분이 가장 애매하게 해석했던 문장인 것 같습니다. 제가 코드를 확인했을 때는(제가 못보거나 잘못 봤을 수도 있겠지만) 연산자를 바꾸는 동작은 확인하지 못했었으니까요. 이 부분에 대해서는 추후 소스 코드 분석 글에서 더 자세히 다루도록 하겠습니다.

이 기능은 C 스타일의 메모리 접근(malloc, dealloc 등)을 허용하는 sun.misc.UnSafe 기반으로 구현되었습니다.

대부분의 Java Class들은 구글에 검색해보면 Oracle JavaDoc이 나오는데, sun.misc.UnSafe의 경우에는 문서가 나오지 않는다. OpenJDK7 JavaDoc을 Hosting하는 http://www.docjar.com/html/api/sun/misc/Unsafe.java.html 페이지에서 겨우 발견할 수 있었다. 클래스 주석을 보면 다음과 같이 적혀 있다.

Low-Level의 Unsafe한 기능을 수행하는 함수들을 모아놓은 클래스이며, 이 클래스와 클래스에 속한 모든 methods는 Public 접근자이지만 Trusted Code에서만 이 객체를 얻을 수 있는 제한이 있습니다.

그렇다면 JVM Object를 사용하는 것과 Unsafe를 사용하는 것이 얼마나 차이가 있을까? 직접 테스트를 수행해 보았다.

  1. JVM Object를 사용하는 버전(Java로 작성)

Total Memory: 128974848
Max Memory: 1908932608
Free Memory: 127611672

Elapsed: 30192
Total Memory: 1547698176
Max Memory: 1908932608
Free Memory: 547102752

2. Unsafe를 사용하는 버전(Scala로 작성, 왜인지 모르겠지만 Java 환경에서 sun.misc.Unsafe가 import되지 않음…)

Total Memory: 128974848
Max Memory: 1908932608
Free Memory: 127611672
Elapsed: 200
Total Memory: 128974848
Max Memory: 1908932608
Free Memory: 126929656

눈여겨 보아야 할 점은 2가지이다.

Unsafe를 사용할 경우 200ms, Java Object를 사용했을 경우 30192ms가 소요되었다.(맥북에어 2014 기준)

언어의 차이를 배제하고도 거의 15배 정도의 차이가 발생하는 것을 확인할 수 있다.

2. Memory 사용량의 변화

위 두 코드 모두 Xmx, Xms 등의 Heap 영역 설정을 주지 않고 실행하였으며, Java의 경우 힙 사용량이 거의 0MB에서 800MB 정도로 늘어난 것을 확인할 수 있고, Unsafe를 사용한 Scala의 경우 힙 공간에 변동이 거의 없는 것을 확인할 수 있다. 이는 Unsafe가 위의 JVM Object Memory Layout에서 언급했던 Native Heap 영역을 사용하기 때문이다. Native Heap 영역의 경우 Garbage Collection 의 영향을 받지 않기 때문에 ‘stop the world’ 또한 발생하지 않는다.

이렇게 sun.misc.Unsafe를 사용하게 되면 JVM 에서 연산하는 것보다 속도/메모리 관리 면에서 더 많은 이점을 가지게 된다.

다시 Spark의 Tungsten 글로 돌아와서, Spark에서 java.util.HashMap을 UnSafe를 이용한 Binary Map으로 변경하여 성능 최적화 한 부분을 보여준다.

Java HashMap(LinkedHashMap인 것으로 추정된다)을 도식화 해놓았는데, 사실 좀 더 자세히 그렸다면 HashMap 자체의 Object 메타데이터, 각 Entry의 Object 메타데이터, Key의 Object 메타데이터, Value의 Object 메타데이터 등이 포함되어 매우 많은 메모리를 소모하는 것을 예상할 수 있다.

이에 반해 Spark Binary Map의 경우 선형적 메모리 공간에 Key,Value를 연속적으로 할당하였으며(mmap과 비슷한…), Java Object가 영역 바이너리 옵션 타입 아니기 때문에 메타데이터 공간도 절약되고 Garbage Collection의 영향도 안 받게 된다.

따라서 아래와 같은 그래프에서 극적인 성능 개선이 이루어지는 것이다.

개인적으로 위의 도식과 비교 방식이 좀 아쉽다고 생각한다. 일단 HashMap의 경우 Linked List기반의 Linked HashMap이고 Binary Map의 경우 Array에 더 가까운 모습이다. 물론 이를 제외하고라도 성능 차이가 극명한 것은 눈에 보이긴 하지만…

이 기능은 Spark 1.4 부터 적용되어 있고 Spark SQL의 엔진에서도 사용하는 것으로 알고 있다.

Spark의 경우 UnifiedMemoryManager를 통해 Heap, Off-Heap 메모리의 비율이나 사용량을 측정/조절한다.

여기까지는 좋은데, Third Party Library에서 사용하는 Off-Heap 공간이 문제다. Netty, Parquet 등의 Third Party Library에서도 성능 향상을 위해 Unsafe를 사용하고 있는데, 이러한 Library들에서 사용하는 메모리 공간에 대해서는 Spark에서 관여하지 않는 듯 하다.

가끔 Parquet 포맷의 파일을 사용할 때 “java.lang.OutOfMemoryError: Direct buffer memory”와 같은 오류 메시지를 볼 수 있는데, Parquet Library에서 허용하는 Direct Memory 이상의 메모리를 사용했기 때문에 발생하게 된다.(https://issues.apache.org/jira/browse/SPARK-4073)

여기까지 Spark Tungsten Project에 관련된 내용을 정리하였다. Part 1에 비해 약간은 코드가 적고 이론적인 설명이 많다.

원래 Spark의 BlockManager, UnifiedMemoryManager, NettyUtils, PooledByteBufAllocator에 대한 코드 분석과 Java NIO, Netty를 연계한 설명도 이 글에 포함할 계획이었다.

하지만 워낙 양이 많아 하나의 글로 풀어 내기에는 방대할 것 같아 여기까지의 내용을 1편으로 정리하고 이어지는 2편에서 다룰 예정이다.

자바 메모리 관리 - 가비지 컬렉션

C 나 C++ 에서는 OS 레벨의 메모리에 직접 접근하기 때문에 free() 라는 메소드를 호출하여 할당받았던 메모리를 명시적으로 해제해주어야 한다. 그렇지 않으면 memory leak 이 발생하게 되고, 현재 실행중인 프로그램에서 memory leak 이 발생하면 다른 프로그램에도 영향을 끼칠 수 있다.

반면, 자바는 OS 의 메모리 영역에 직접적으로 접근하지 않고 JVM 이라는 가상머신을 이용해서 간접적으로 접근한다. JVM 은 C 로 쓰여진 또 다른 프로그램인데, 오브젝트가 필요해지지 않는 시점에서 알아서 free() 를 수행하여 메모리를 확보한다. 웹 애플리케이션을 만들 때 모든 것을 다 직접 개발하여 쓰기보다 검증된 라이브러리나 프레임워크를 이용하는 것이 더 안전하고 편리한 것 처럼, 메모리 관리라는 까다로운 부분을 자바 가상머신에 모두 맡겨버리는 것이다.

프로그램 실행시 JVM 옵션을 주어서 OS에 요청한 사이즈 만큼의 메모리를 할당 받아서 실행하게된다. 할당받은 이상의 메모리를 사용하게 되면 에러가 나면서 자동으로 프로그램이 종료된다. 그러므로 현재 프로세스에서 메모리 누수가 발생하더라도 현재 실행중인 것만 죽고, 다른 것에는 영향을 주지 않는다.

이렇게 자바는 가상머신을 사용함으로써 (운영체제로 부터 독립적이라는 장점 외에도) OS 레벨에서의 memory leak 은 불가능하게 된다는 장점이 있다.

자바가 메모리 누수현상을 방지하는 또 다른 방법이 가비지 컬렉션이다.

Garbage Collection

Garbage collection was invented by John McCarthy around 1959 to simplify manual memory management in Lisp. [Wiki] Garbage collection (computer science)

가비지 컬렉션이라는 개념은 자바에서 처음 사용된 것이 아니다. LISP 라는 언어에서 처음 도입된 개념이다. 하지만, 자바가 가비지 컬렉션이란 개념을 더욱 대중화 시킨데 기여한 부분은 있다.

프로그래머는 힙을 사용할 수 있는 만큼 자유롭게 사용하고, 더 이상 사용되지 않는 오브젝트들은 가비지 컬렉션을 담당하는 프로세스가 자동으로 메모리에서 제거하도록 하는 것이 가비지 컬렉션의 기본 개념이다.

자바는 가비지 컬렉션에 아주 단순한 규칙을 적용한다.

Heap 영역의 오브젝트 중 stack 에서 도달 불가능한 (Unreachable) 오브젝트들은 가비지 컬렉션의 대상이 된다.

무슨말인지 정확히 이해되지 않는다면, Java Memory Management - Stack and Heap 를 먼저 읽는 것을 추천한다.

Garbage Collection 살짝 겉핥아보기

이제 간단한 코드를 살펴보면서 garbage collection 이 뭔지 살짝만 알아보자.

구문이 실행된 뒤 스택과 힙은 아래와 같다.

stack and heap

문자열 더하기 연산이 수행되는 과정에서, (String 은 불변객체이므로) 기존에 있던 "https://" 스트링에 "yaboong.github.io" 를 덧붙이는 것이 아니라, 문자열에 대한 더하기 연산이 수행된 결과가 새롭게 heap 영역에 할당된다. 그 결과를 그림으로 표현하면 아래와 같다.

stack and heap

Stack 에는 새로운 변수가 할당되지 않는다. 문자열 더하기 연산의 결과인 "https://yaboong.github.io" 가 새롭게 heap 영역에 생성되고, 기존에 "https://" 를 레퍼런스 하고 있던 url 변수는 새롭게 생성된 문자열을 레퍼런스 하게 된다.

기존의 "https://" 라는 문자열을 레퍼런스 하고 있는 변수는 아무것도 없으므로 Unreachable 오브젝트가 된다.

JVM 의 Garbage Collector 는 Unreachable Object 를 우선적으로 메모리에서 제거하여 메모리 공간을 확보한다. Unreachable Object 란 Stack 에서 도달할 수 없는 Heap 영역의 객체를 말하는데, 지금의 예제에서 "https://" 문자열과 같은 경우가 되겠다. 아주 간단하게 이야기해서 이런 경우에 Garbage Collection 이 일어나면 Unreachable 오브젝트들은 메모리에서 제거된다.

Garbage Collection 과정은 Mark and Sweep 이라고도 한다. JVM의 Garbage Collector 가 스택의 모든 변수를 스캔하면서 각각 어떤 오브젝트를 레퍼런스 하고 있는지 찾는과정이 Mark 다. Reachable 오브젝트가 레퍼런스하고 있는 오브젝트 또한 marking 한다. 첫번째 단계인 marking 작업을 위해 모든 스레드는 중단되는데 이를 stop the world 라고 부르기도 한다. (System.gc() 를 생각없이 호출하면 안되는 이유이기도 하다)

그리고 나서 mark 되어있지 않은 모든 오브젝트들을 힙에서 제거하는 과정이 Sweep 이다.

Garbage Collection 이라고 하면 garbage 들을 수집할 것 같지만 실제로는 garbage 를 수집하여 제거하는 것이 아니라, garbage 가 아닌 것을 따로 mark 하고 그 외의 것은 모두 지우는 것이다. 만약 힙에 garbage 만 가득하다면 제거 과정은 즉각적으로 이루어진다.

Garbage Collection 영역 바이너리 옵션 타입 이 일어난 후의 메모리 상태는 아래와 같을 것이다.

stack and heap

System.gc()

System.gc() 를 호출하여 명시적으로 가비지 컬렉션이 일어나도록 코드를 삽입할 수 있지만, 모든 스레드가 중단되기 때문에 코드단에서 호출하는 짓은 하면 안된다. 자바 도큐먼트를 보면 gc() 메소드에 대한 설명은 아래와 같다.

System.gc()

https://docs.oracle.com/javase/8/docs/api/

System.gc() 호출하는게 하면 안되는 짓이라는데 한번 해보자.

아무것도 하지 않고 시간만 측정하는 코드다.

내 피씨에서는 275ns 가 나온다. 275 나노초면 0.000000275초의 시간이다. 아무것도 하는게 없는 코드니까 당연히 엄청 빠르다. 이제 startTime, endTime 사이에 System.gc() 를 심어보자.

  • 가비지 컬렉션이 수행되는지 보려면 jvm 옵션으로 -verbose:gc 를 주면 된다.
  • 어떤 가비지 컬렉터를 사용하고 있는지 보기위해 jvm 옵션으로 -XX:+PrintCommandLineFlags 도 주고 시작하자.
  • IntelliJ 라면 Edit Configurations -> VM options -> -verbose:gc -XX:+PrintCommandLineFlags 를 입력하면 된다.

첫번째 라인은 -XX:+PrintCommandLineFlag 에 의해 출력된 값들이고, 두번째 세번째 라인은 -verbose:gc 옵션을 주어 가비지 컬렉션이 일어날때 자동출력된 부분이다.

첫번째 라인의 마지막을 보면 -XX:+UseParallelGC 옵션이 있는데 ParallelGC 라는 가비지 컬렉터를 사용하고 있다는 것이다.

어쨌든 실행시간에 대한 결과를 보면 6959381 나노초가 나온다. 0.006959381 초의 시간이다. JVM 옵션을 모두 제거하고 돌려도 비슷한 시간이 나온다. System.gc() 를 호출하기 전에 275 나노초, 호출하면 6959381 나노초. 아무역할도 하지 않는 코드로 단순히 산술적 비교를 하는 것이 무의미할 수도 있지만, 호출하면 안될 것 같다는 위험성은 느껴진다.

System.gc() 소스까보기

System.gc() 를 타고 들어가면 아래와 같이 생겼다.

Runtime.getRuntime().gc() 를 타고 들어가면,

native 라는 키워드가 붙은 메소드는 자바가 아닌 다른 프로그래밍 언어로 쓰여진 메소드를 말한다. JVM 이 C 언어 로 쓰여졌으니까 아마 C 언어로 작성되었을 것 같다. 어쨌든 자바의 영역을 벗어나니까 일단 넘어가자.

Garbage Collection 코드로 확인하기

1. 프로그램이 메모리 부족으로 죽는 경우

OutOfMemoryError 를 빨리내고, GC 를 확인하기 위해서 jvm 옵션으로 -Xmx16m -verbose:gc 를 주고 시작하자. -Xmx 는 힙영역의 최대 사이즈 를 설정하는 것이다. 16MB 로 설정했다.

실행결과를 보면 가비지 컬렉션 작업을 몇번 반복하다가 결국 OutOfMemoryError 를 뱉으며 프로그램이 죽어버린다. 위에서, 가비지 컬렉션의 대상이 되는 오브젝트는 Unreachable 오브젝트라고 했다. 그런데 무한루프의 외부에서 선언한 ArrayList 는 무한루프가 도는 동안에도 계속해서 Reachable 하기 때문에 (레퍼런스가 끊이지 않기 때문에), 가비지 컬렉션 작업이 진행되어도 힙에 모든 데이터가 계속 남아있게 된다.

즉, 무한루프를 돌기 때문에 프로그램이 죽은 것이 아니라, ( Unreachable 오브젝트가 없으므로) 가비지 컬렉션이 일어나도 모든 오브젝트가 살아있기 때문에 OutOfMemoryError 가 발생한 것이다.

똑같이 무한루프를 돌지만, Unreachable 오브젝트를 만들어 내는 코드를 살펴보자.

2. 가비지 컬렉터가 열일하여 프로그램이 죽지 않는 경우

JVM 옵션으로 똑같이 -Xmx16m -verbose:gc 를 주고 실행했다.

Thread.sleep() 하는 부분에서 li 변수에 새로운 ArrayList 를 생성하도록 해보자. 그리고 몇번째 루프에서 가비지 컬렉션이 수행되는지 확인하기 위해 프린트도 하나 찍어보자. 무한루프를 돌면서 중간중간에 List 를 가비지가 되도록 만들어서 가비지 컬렉션이 수행되면 프로그램은 죽지않고 계속해서 돌아갈 것이다. 코드는 아래와 같다.

실행결과는 아래와 같다. 루프 횟수는 . 으로 표시했다.

if (i % 100 == 0) 구문으로 100 번째 단위로 루프를 돌때마다 (새로운 리스트를 할당하여) 기존에 있던 리스트를 가비지로 만들어주니 프로그램이 죽지 않고 계속 돌아가는 것을 보면, 가비지 컬렉터가 열일하고 있다는 것을 알 수 있다.

첫 번째 코드예제에서, 스택에 한개의 리스트 레퍼런스 변수를 두고 같은 리스트에 계속해서 데이터를 추가하면, 가비지 컬렉션이 이루어져도 가비지로 분류되는 Unreachable 오브젝트가 없기 때문에 프로그램이 죽는 것을 확인했다.

두 번째 코드예제에서는, 똑같이 스택에 한개의 리스트 레퍼런스 변수를 두더라도, 주기적으로 새로운 리스트를 생성해서 새롭게 생성한 리스트를 레퍼런스 하도록 만들었다. 그 결과, Unreachable 오브젝트 가 되어버린 기존 리스트들을 가비지 컬렉터가 메모리에서 제거함으로써 프로그램이 죽지않고 돌아가는 것을 확인했다.

VisualVM 으로 모니터링하기

시각화된 그래프를 보면서 프로그램을 모니터링 할 수 있다. 본인의 java 설치경로 (아마 $JAVA_HOME) 의 bin 디렉토리로 가서 jvisualvm 을 실행시킨다.

힙의 세부적인 모니터링을 위해 VisualGC 라는 플러그인을 설치할 것인데 이렇게 실행하니까 잘 안돼서 그냥 툴을 다운 받았다. [Go to VisualVM Download Page]

Java 9 부터는 Graal Visual VM 으로 바뀌었다고 한다. 이 글은 Java 8 을 기준으로 작성되었으므로 Java 9 을 사용한다면 Graal Visual VM 을 사용할 수 있다.

Visual VM

Visual VM 을 실행하면 위와같은 화면을 볼 수 있다. 왼쪽에 있는 프로세스들에 대한 모니터링이 가능하다. 하지만 지금은 가비지 컬렉션이 어떻게 이루어지는지 보기 위해 heap 영역을 좀더 세부적으로 모니터링 하고 싶다. 그러기 위해서 VisualGC 라는 플러그인이 필요한데, 상단 메뉴에서 Tool > Plugins > Available Plugins 로 가서 Visual GC 를 체크한 후 Install 을 클릭한다.

Visual GC Plugin

Visual GC 플러그인을 설치하면 힙의 각 영역별로 세부적인 모니터링이 가능하다.

메모리 구성 - Metaspace & Heap

Visual VM 과 Visual GC 를 이용하여 프로그램을 실행했을 때 메모리 변화에 대한 모니터링을 해보자.

1. Metaspace

먼저 metaspace 에 대해 알아보자. 자바 8 에 적용된 변화로 람다, 스트림, 인터페이스의 default 지시자가 대표적이다. 하지만, 메모리의 관점에서 가장 큰 변화로는 PermGen 이 사라지고 Metaspace 가 이를 대체하게 되었다는 것이다.

PermGen 은 자바 7까지 클래스의 메타데이터를 저장하던 영역이었고 Heap 의 일부였다. 주요 내용만 뽑아보면,

  • Permanent Generation은 힙 메모리 영역중에 하나로 자바 애플리케이션을 실행할때 클래스의 메타데이터를 저장하는 영역이다.(Java 7기준)
  • 아래와 같은 것들이 Java Heap 이나 native heap 영역으로 이동했다.
    • Symbols -> native heap
    • Interned String -> Java Heap
    • Class statics -> Java Heap

    자바 8 부터 클래스들은 모두 힙이 아닌 네이티브 메모리를 사용하는 Metaspace 에 할당된다고 하는데, 한번 확인해보자.

    위 코드는 metaspace 를 사용하게 될 class 들을 런타임시에 생성한다. 코드를 실행하기 전에 javassist 라이브러리를 추가하고, 아래코드를 실행시킨 뒤 VisualVM 에서 모니터링 해보자.
    Metaspace 를 제한하기 위해 vm 옵션으로 -XX:MaxMetaspaceSize=128m 을 주고 실행시킨다. 최대 Metaspace 의 크기를 128 MB 로 제한하는 옵션이다.

    VisualVM 의 VisualGC 탭으로 들어가면 아래와 같은 화면을 볼 수 있다. java.lang.OutOfMemoryError: Metaspace 에러로 인해 프로그램이 죽은 상태의 캡처본이다.

    java.lang.OutOfMemoryError: Metaspace

    Metaspace 의 크기가 128MB 에 도달하면 OutOfMemoryError: Metaspace 오류를 뱉으며 죽는 것을 확인할 수 있다.

    서론이 너무 길었는데, 어쨌든 Metaspace 는 Heap 과는 상관없는 네이티브 메모리 영역이다.

    Metaspace 를 제외한, Heap 에 해당하는 Old, Eden, S0, S1 에 대해서 알아보자.

    2. Heap - Old & Young (Eden, Survivor)

    위 모니터링 화면에서 Spaces 부분을 보면 다섯개의 영역으로 나누어져 있는 것을 볼 수 있다. Metaspace, Old, Eden, S0, S1 총 다섯개 영역이다.

    http://www.waitingforcode.com/off-heap/on-heap-off-heap-storage/read

    Heap 은 Young Generation, Old Generation 으로 크게 두개의 영역으로 나누어 지고, Young Generation 은 또다시 Eden, Survivor Space 0, 1 로 세분화 되어진다. S0, S1 으로 표시되는 영역이 Survivor Space 0, 1 이다. 각 영역의 역할은 가비지 컬렉션 프로세스를 알면 알 수 있다.

    가비지 컬렉션 프로세스

    1. 새로운 오브젝트는 Eden 영역에 할당된다. 두개의 Survivor Space 는 비워진 상태로 시작한다.
    2. Eden 영역이 가득차면, MinorGC 가 발생한다.
    3. MinorGC 가 발생하면, Reachable 오브젝트들은 S0 으로 옮겨진다. Unreachable 오브젝트들은 Eden 영역이 클리어 될때 함께 메모리에서 사라진다.
    4. 다음 MinorGC 가 발생할때, Eden 영역에는 3번과 같은 과정이 발생한다. Unreachable 오브젝트들은 지워지고, Reachable 오브젝트들은 Survivor Space 로 이동한다. 기존에 S0 에 있었던 Reachable 오브젝트들은 S1 으로 옮겨지는데, 이때, age 값이 증가되어 옮겨진다. 살아남은 모든 오브젝트들이 S1 으로 모두 옮겨지면, S0 와 Eden 은 클리어 된다. Survivor Space 에서 Survivor Space 로의 이동은 이동할때마다 age 값이 증가한다.
    5. 다음 MinorGC 가 발생하면, 4번 과정이 반복되는데, S1 이 가득차 있었으므로 S1 에서 살아남은 오브젝트들은 S0 로 옮겨지면서 Eden 과 S1 은 클리어 된다. 이때에도, age 값이 증가되어 옮겨진다. Survivor Space 에서 Survivor Space 로의 이동은 이동할때마다 age 값이 증가한다.
    6. Young Generation 에서 계속해서 살아남으며 age 값이 증가하는 오브젝트들은 age 값이 특정값 이상이 되면 Old Generation 으로 옮겨지는데 이 단계를 Promotion 이라고 한다.
    7. MinorGC 가 계속해서 반복되면, Promotion 작업도 꾸준히 발생하게 된다.
    8. Promotion 작업이 계속해서 반복되면서 Old Generation 이 가득차게 되면 MajorGC 가 발생하게 된다.

    위 과정의 반복이 가비지 컬렉션이다.

    • MinorGC : Young Generation 에서 발생하는 GC
    • MajorGC : Old Generation (Tenured Space) 에서 발생하는 GC
    • FullGC : Heap 전체를 clear 하는 작업 (Young/Old 공간 모두)

    Garbage Collection 눈으로 확인하기

    1. 프로그램이 메모리 부족으로 죽는 경우

    실행시 VisualVM 으로 모니터링 한 화면을 녹화해 보았다. (유튜브 영상 전체화면 기능이 이상하게 먹힙니다… 유튜브 페이지로 가서 전체화면으로 봐주세요… )

    VisualVM 그래프를 보면, Eden 영역이 활발하게 생성되는 것이 보인다. 또한, Eden 이 가득차면 Survivor Space 로 이동하고, 기존에 Survivor Space 에서 오랫동안 살아남은 오브젝트들이 Old Generation 으로 이동하게 되는 것도 보인다. 심지어 Eden 이 차기도 전에 Old Generation 으로 이동하는 경우도 보인다. 실행 로그를 보면 FullGC 가 발생하는 것도 볼 수 있는데, FullGC 가 실행되었음에도 불구하고 힙에 더 이상 할당할 수 있는 공간이 없어서 OutOfMemoryError 를 뱉으면서 죽는다.

    2. 가비지 컬렉터가 열일하여 프로그램이 죽지 않는 경우

    아래 영상 역시 VisualVM 으로 모니터링 한 화면을 녹화한 것이다. (유튜브 영상 전체화면 기능이 이상하게 먹힙니다… 유튜브 페이지로 가서 전체화면으로 봐주세요… )

    VisualVM 그래프를 보면, 첫 번째 경우와 동일하게 Eden 은 활발하게 움직이는 것을 볼 수 있다. 또한, 처음에는 Eden 에서 Survivor Space 로 이동하는 오브젝트가 많지만, 시간이 지나면서 살아남는 오브젝트들이 점점 줄어드는 것이 보인다. 이는 생성되는 오브젝트들의 생명주기가 굉장히 짧은 것을 의미한다. Survivor Space 에서 Old Generation 으로의 Promotion 과정도 미미하게 발생하지만 첫 번째 경우보다 Old Generation 이 증가하는 속도는 굉장히 더딘 것을 확인할 수 있다.

    Garbage Collector 종류 겉핥기

    이제 가비지 컬렉션은 어느정도 알게 된 것 같다. 그러면 가비지 컬렉션을 담당하는 가비지 컬렉터에는 어떤 것들이 있는지 간략하게 살펴보자.

    Serial GC

    -XX:+UseSerialGC 옵션을 줘서 사용할 수 있는 Serial GC 는 Java SE 5, 6 에서 사용되는 디폴트 가비지 컬렉터이다.

    • MinorGC, MajorGC 모두 순차적으로 시행된다.
    • Mark-Compact collection method 를 사용한다.

    Mark-Compact collection method 란, 새로운 메모리 할당을 빠르게 하기 위해서 기존의 메모리에 있던 오브젝트들을 힙의 시작위치로 옮겨 놓는 방법이다. 창고에서 필요없는 물건들을 버린 후, 창고에 물건을 차곡차곡 쌓기위해 창고안을 정리하는 것이라 생각할 수 있다. 아래 그림을 참고하면 이해가 쉽다.

    http://www.oracle.com/webfolder/technetwork/tutorials/obe/java/gc01/index.영역 바이너리 옵션 타입 html

    Parallel GC

    -XX:+UseParallelGC 옵션으로 사용 가능한 Parallel 가비지 컬렉터는 young generation 에 대한 가비지 컬렉션 수행시 멀티스레드를 사용한다. 멀티스레딩을 할 수 있는 ParallelGC 를 사용하도록 옵션을 주었더라도, 호스트 머신이 싱글 CPU 라면 디폴트 가비지 컬렉터(Serial GC)가 사용된다. 하지만, 호스트의 CPU 가 두개 이상이라면 young 영역 바이너리 옵션 타입 generation 의 가비지 컬렉션 시간을 줄일 수 있다.

    가비지 컬렉터 스레드 개수는 디폴트로 CPU 개수만큼이 할당되는데 -XX:ParallelGCThread= 옵션으로 조절가능하다. 또한, -XX:+UseParallelOldGC 옵션을 사용한다면, old generation 의 가비지 컬렉션에서도 멀티스레딩을 활용할 수 있다.

    Concurrent Mark Sweep (CMS) Collector

    Concurrent Low Pause Collector 라고도 불리는 CMS 컬렉터는 -XX:+UseConcMarkSweepGC 옵션으로 사용할 수 있다. 대부분의 가비지 컬렉션 작업을 애플리케이션 스레드와 동시에 수행함으로써 가비지 컬렉션으로 인한 stop-the-world 시간을 최소화하는 GC이다.

    CMS 컬렉터는 young generation 에 대한 가비지 컬렉션시 Parallel GC 와 같은 알고리즘을 사용하는데, -XX:ParallelCMSThreads= 옵션으로 스레드 개수를 설정할 수 있다.

    일반적으로 CMS 컬렉터는 살아있는 오브젝트들에 대한 compact 작업을 수행하지 않으므로, 메모리의 파편화(Fragmentation) 가 문제가 된다면 더 큰 힙사이즈를 할당해야 한다.

    G1 Garbage Collector

    Garbage First 라는 의미의 G1 가비지 컬렉터는 Java 7 부터 사용가능하며, 장기적으로 CMS 컬렉터를 대체하기 위해 만들어졌다. -XX:+UseG1GC 옵션으로 사용가능하다. G1 가비지 컬렉터는 이전까지 이야기한 것들과는 다른 방식으로 동작한다. G1 GC 에 대한 자세한 내용은 [Oracle] Getting Started with the G1 Garbage Collector 를 참고하기 바란다.

    벼르고 벼르고 벼르고 벼르던 가비지 컬렉션에 대한 글을 드디어 마무리했다.

    아래 참고한 자료를 보면 알 수 있겠지만, 가비지 컬렉션을 공부하기 위해 여기저기 정보를 열심히 뒤졌다. 뒤지다 보니까 가비지 컬렉션에 대한 거의 모든 블로그나 강의 내용들이 [Oracle] Java Garbage Collection Basics 이걸 기반으로 작성된 것 같다.

    kubectl 치트 시트

    --all-namespaces 를 붙여야 하는 상황이 자주 발생하므로, --all-namespaces 의 축약형을 알아 두는 것이 좋다.

    Kubectl 컨텍스트와 설정

    kubectl 이 통신하고 설정 정보를 수정하는 쿠버네티스 클러스터를 지정한다. 설정 파일에 대한 자세한 정보는 kubeconfig를 이용한 클러스터 간 인증 문서를 참고한다.

    Kubectl apply

    apply 는 쿠버네티스 리소스를 정의하는 파일을 통해 애플리케이션을 관리한다. kubectl apply 를 실행하여 클러스터에 리소스를 생성하고 업데이트한다. 이것은 프로덕션 환경에서 쿠버네티스 애플리케이션을 관리할 때 권장된다. Kubectl Book을 참고한다.

    오브젝트 생성

    쿠버네티스 매니페스트는 JSON이나 YAML로 정의된다. 파일 확장자는 .yaml , .yml , .json 이 사용된다.

    리소스 조회 및 찾기

    리소스 업데이트

    리소스 패치

    리소스 편집

    선호하는 편집기로 모든 API 리소스를 편집할 수 있다.

    리소스 스케일링

    리소스 삭제

    실행 중인 파드와 상호 작용

    컨테이너로/컨테이너에서 파일과 디렉터리 복사

    참고: kubectl cp 명령을 사용하려면 컨테이너 이미지에 'tar' 바이너리가 포함되어 있어야 한다. 'tar'가 없으면, kubectl cp 는 실패할 것이다. 심볼릭 링크, 와일드카드 확장, 파일 모드 보존과 같은 고급 사용 사례에 대해서는 kubectl exec 를 고려해 볼 수 있다.

    디플로이먼트, 서비스와 상호 작용

    노드, 클러스터와 상호 작용

    리소스 타입

    단축명, API 그룹과 함께 지원되는 모든 리소스 유형들, 그것들의 네임스페이스와 종류(Kind)를 나열:

    API 리소스를 탐색하기 위한 다른 작업:

    출력 형식 지정

    특정 형식으로 터미널 창에 세부 사항을 출력하려면, 지원되는 kubectl 명령에 -o (또는 --output ) 플래그를 추가한다.

    출력 형식세부 사항
    -o=custom-columns= 쉼표로 구분된 사용자 정의 열 목록을 사용하여 테이블 출력
    -o=custom-columns-file= 파일에서 사용자 정의 열 템플릿을 사용하여 테이블 출력
    -o=json JSON 형식의 API 오브젝트 출력
    -o=jsonpath= jsonpath 표현식에 정의된 필드 출력
    -o=jsonpath-file= 파일에서 jsonpath 표현식에 정의된 필드 출력
    -o=name 리소스 명만 출력하고 그 외에는 출력하지 않음
    -o=wide 추가 정보가 포함된 일반-텍스트 형식으로 출력하고, 파드의 경우 노드 명이 포함
    -o=yaml YAML 형식의 API 오브젝트 출력

    -o=custom-columns 의 사용 예시:

    더 많은 예제는 kubectl 참조 문서를 참고한다.

    Kubectl 출력 로그 상세 레벨(verbosity)과 디버깅

    Kubectl 로그 상세 레벨(verbosity)은 -v 또는 --v 플래그와 로그 레벨을 나타내는 정수로 제어된다. 일반적인 쿠버네티스 로깅 규칙과 관련 로그 레벨이 여기에 설명되어 있다.

    로그 레벨세부 사항
    --v=0 일반적으로 클러스터 운영자(operator)에게 항상 보여지게 하기에는 유용함.
    --v=1 자세한 정보를 원하지 않는 경우, 적절한 기본 로그 수준.
    --v=2 서비스와 시스템의 중요한 변화와 관련이있는 중요한 로그 메시지에 대한 유용한 정상 상태 정보. 이는 대부분의 시스템에서 권장되는 기본 로그 수준이다.
    --v=3 변경 사항에 대한 확장 정보.
    --v=4 디버그 수준 상세화.
    --v=5 트레이스 수준 상세화.
    --v=6 요청한 리소스를 표시.
    --v=7 HTTP 요청 헤더를 표시.
    --v=8 HTTP 요청 내용을 표시.
    --v=9 내용을 잘라 내지 않고 HTTP 요청 내용을 표시.

    다음 내용

    재사용 스크립트에서 kubectl 사용 방법을 이해하기 위해 kubectl 사용 규칙을 참고한다.

    더 많은 커뮤니티 kubectl 치트시트를 확인한다.

    이 페이지가 도움이 되었나요?

    피드백 감사합니다. 쿠버네티스 사용 방법에 대해서 구체적이고 답변 가능한 질문이 있다면, 다음 링크에서 질문하십시오. Stack Overflow. 원한다면 GitHub 리포지터리에 이슈를 열어서 문제 리포트 또는 개선 제안이 가능합니다..

    JVM이란

    JVM 구조

    Class Loader

    • 사용자가 코드를 작성하면 위의 그림과 같이 Stock.java 파일이 작성
    • 이 .java 소스를 Java Compiler를 통해 Stock.class(바이트코드)로 생성
    • 이렇게 생성된 클래스 파일들을 운영체제로 부터 할당 받은 메모리 영역인 Runtime Data Area로 적재하는 역할 수행
    • 자바 어플리케이션이 실행중일 때 수행

    개발자가 작성한 소스 코드를 바이너리 코드로 변환하는 과정 👉 목적 파일이 생성
    이러한 작업을 해주는 프로그램 : 컴파일러(Compiler)

    Execution Engine

    • Class Loader에 의해 메모리에 적재된 클래스(바이트코드)들을 기계어로 변경해 명령어 단위로 실행
      • 인터프리터(interpreter) 방식 : 명령어를 하나하나 실행
      • JIT(Just-In-Time) 컴파일러를 이용하는 방식 : 적절한 시간에 전체 바이트 코드를 네이티브 코드로 변경해 Execution Engine이 네이티브 코드를 실행하는 것으로, 성능을 높이는 방식.

      출처 : https://aboullaite.me/understanding-jit-compiler-just-in-time-compiler/

      Native Code(=Unmanaged Code) : 컴파일하면 OS에서 해석가능한 기계어로 번역된 코드
      Managed Code : OS가 바로 이해할 수 있는 바이너리가 아닌, 임시 코드(IL, Intermediate Language)로 변환시키는 것으로, IL 코드가 실행되기 위해 JIT 컴파일러가 OS용 코드로 변환. (JIT 컴파일러는 OS마다 다름)


0 개 댓글

답장을 남겨주세요