-
Java Memory Model(자바 메모리 모델)JVM 2020. 5. 27. 19:10반응형
이번에는 Java Memory Model(자바 메모리 모델)에 대해서 공부해보겠습니다.
지난번에 작성한 JVM구조부터 메모리 모델까지는 자바를 사용하는 것에 있어서 굉장히 중요합니다. 단순히 언어의 클래스, 메서드 같은 것들을 많이 아는 것도 중요하지만, 이런 것들은 그때그때 검색하면 됩니다. 하지만 구조적인 것을 모른다면, 검색을 하더라도 이해도가 낮을 수도 있고, 전체적인 프로그램 설계를 하는 것에 있어 어려움을 겪을 수도 있습니다.
혹시나 JVM 구조에 대해서 잘 모르신다면, JVM Architecture란? 이 글을 먼저 보고 오셔야 합니다!
JVM은 OS의 메모리의 사용권한을 할당받게 됩니다. 기본적으로 프로그램을 실행하려면 운영체제(OS)가 제어하고 있는 메모리를 제어할 수 있어야 합니다. C는 OS에 아예 종속되어 실행되게 되어있지만, Java는 JVM을 이용해서 권한을 할당받아서 프로그램을 호출합니다. 아마 컴퓨터공학을 전공했다면 운영체제 수업에서 System Call을 배우셨을 것입니다. 이때 JVM이 Call을 하게 됩니다.
하지만 C처럼 운영체제 종속되어 바로 콜을 하는 것이 아니라, JVM을 거쳐서 콜을 하기 때문에 속도가 C에 비해 느리다는 이야기가 많습니다. 왜냐하면 JVM도 결국에는 C로 만들어졌으니까요. 처음부터 C를 쓰는 게 더 빠르겠죠?
어쨌든 JVM이 메모리의 공간을 할당받아서 한자리를 차지하고 있는데요.
1) Heap Memory
힙 메모리는 Young Generation, Old Generation 2개의 공간으로 나뉠 수 있습니다. "아니 무슨 메모리도 세대갈등이 있나?"라고 생각할 수 있습니다. 천천히 알아가 보겠습니다.
기본적으로 Heap Memory는 JVM이 시작될 때 힙 메모리는 공간을 할당받고, 애플리케이션이 작동 중일 때 사이즈에 있어 자유롭습니다. 여기서 사이즈에 있어 자유롭다는 것은 '동적'으로 할당된다는 의미입니다. 즉 new 연산자를 활용해서 객체를 생성할 때 할당됩니다.
1.1) Young Generation
완전히 틀린 번역이지만, 편의상 "젊은 세대"라고 부르겠습니다. 웬만하면 영어로 쓰세요, 한국말로 하면 아무도 못 알아먹습니다.
여하튼 이름에서 알 수 있듯이, 이 젊은 세대는 비교적 최근에 할당된 객체들을 가지고 있습니다.
그리고 다시 "Eden Memory" 1개, "Survivor Memory spaces" 2개 있습니다.
새로 생성된 객체는 Eden space로 갑니다. Eden 공간이 객체들로 가득 찼을 때는 minor GC가 수행되며, Survivor(살아남은) 객체들은 Survivor 공간으로 이동합니다. Minor GC는 survivor(살아남은) 객체들은 확인하고, 이들을 survivor 공간으로 이동시킵니다. 그때 한 개의 survivor 공간은 항상 비어있습니다. 이유는 아래에서 설명하겠습니다.
여기서 말하는 survivor(살아남은) 객체들이라고 한다면, 계속해서 참조가 되고 있는 객체입니다. 이때 참조가 되고 있는 객체는 "mark" 과정을 거칩니다. 그리고 mark 되지 않는 객체의 영역은 해제합니다.
여하튼 여러 번의 GC 사이클 후 살아남은 객체들은 old generation(늙은 세대)로 옮겨집니다.
1.2) Old Generation (= Tenured space)
이 "늙은 세대"는 Minor GC로부터 살아남고, 오랫동안 사용될 예정의 객체들입니다. 만약에 늙은 세대 공간마저 가득 차게 된다면 Major GC가 작동해서, 최근에 사용되지 않은 객체들부터 가지고 갑니다. 마치 저승사자처럼요.
1.3) Gabage Collector (가비지 컬렉터)
기본적으로 가비지 컬렉터는 메모리 관리 기법입니다. 프로그램이 동적으로 할당했던 메모리를, 필요가 없어지면서 해제하는 것입니다. 즉 사용하지 않는 함수인데도 불구하고 메모리에 계속 남아있다면, 메모리 누수가 일어나는데, 이것을 막아줍니다.
여기서 말하는 메모리 누수는 수도꼭지가 중간에 구멍이 나서 줄줄 샌다는 것보다는, 쓸모없는 (저 같은) 식충이 같은 놈이 방구석에서 한자리 차지하고 있다고 이해하셔야 합니다. 사용되지 않는 객체가 메모리를 차지하고 있는 것입니다.
1.4) Minor GC (마이너 가비지 컬렉터)
Minor GC는 Young Generation space의 가비지(Garbage)를 해제(collect) 합니다. Collect라고 하면 뭔가 수집하는 느낌이 강한데, 해제라고 한국말로 들어야 좀 더 이해가 쉽습니다. 쓰레기 수집해서 어디 팔아먹을 것도 아니니까요. 할당된 객체를 해제하는 것입니다.
편의상 마이너 컬렉터라고 부르겠습니다. 이 마이너 컬렉터는 JVM이 더 이상 새로운 객체를 할당할 공간이 없을 때 수행됩니다. 예를 들어 Eden 공간이 가득 찼을 때입니다. 언제든지 공간이 가득 차면 전체 콘텐츠를 복사해서 Survivor 공간으로 옮깁니다. 그래서 위에서 언급했던 대로 2개의 Survivor 공간 중 하나는 반드시 비어있어야 합니다. 그래야 Eden이 가득 찼을 때 이동할 수 있습니다.
만약 Eden이 가득 차서 옮겨야 하는데, Survivor도 가득 차 버렸다? 그러면 최근에 생성된 객체지만 바로 old generation으로 넘어가게 됩니다. 자주 사용되는 객체인데도 불구하고 옮겨지게 됩니다.
모든 마이너 컬렉터 이벤트가 발생하면, 안타깝게도 애플리케이션의 스레드가 멈추게 됩니다. 물론 이러한 지연은 신경 쓸 정도로 길지는 않습니다. 하지만 그럼에도 불구하고 GC가 사용될 때 스레드가 멈추는 시간을 줄이는 것은 중요합니다.
이러한 GC 동작을 줄이기 위해서 오라클 공식문서에서는 Static 영역을 잘 활용하면, GC 오버헤드를 줄일 수 있다고 합니다. 왜냐하면 Static은 한번 메모리에 올라오면, 프로그램이 종요될 때까지 해제되지 않습니다. 즉 GC가 굳이 관리할 필요가 없어집니다. 메모리를 차지하고 있던 말던, GC가 아얘 신경을 쓰지 않으면서 오버헤드를 줄이는 방식입니다.
하지만 지나치게 사용한다면 메모리 영역이 부족해질 수도 있습니다. 메모리 영역이 넉넉하고, GC의 오버헤드를 줄이고자 한다면 고려해볼 수 있습니다.
마이너 컬렉터는 기본적으로 속도가 빠릅니다. 왜냐하면 Eden은 금방 사용하고 금방 사라질 것들을 주로 저장하기 때문입니다. 근데 그런 객체마저 old로 보내면 비효율적입니다. 1개의 Survivor space를 비워두고, GC가 일어날 때마다 서로 복사를 해주면서 왔다 갔다 합니다. 그렇게 old 혹은 age 되면, 늙은 세대로 이동하는 것이 정상적인 작동 방식입니다. 여기서 age는 노화, 흔히 우리가 피부 안티에이징(anti-aging)이라고 하는 것의 반대말이라고 생각하면 됩니다. 왜냐하면 늙지도 않았는데, Old Generation 공간으로 보낼 수는 없기 때문입니다. 그래서 일부러 2개의 Survivor 공간에서 왔다 갔다 하며 노화를 시킵니다.
여기서 포인터 추적 방식이 사용됩니다. 마이너든 메이저든 가비지 컬렉터에서 사용되는 방식입니다. 이는 한 개 이상의 변수가 접근 가능한 메모리는 앞으로 사용할 수 있는 메모리로 간주하고, 그 밖의 메모리를 해제하는 방식입니다. 접근 가능한 객체는 어떤 변수가 직/간접적으로 가리키는 메모리입니다.
여러 가지 포인터 추적 방식이 있을 수 있는데요. 대표적으로 표시하고 쓸기(mark and sweep)이 있습니다. 메모리 할당 영역에 표시를 위해 1비트의 메모리를 남겨 둡니다. 이렇게 되면 메모리 영역을 표시하지 않는(unmakred) 공간은 접근 불가능한 메모리 영역이 되며, 쓸기 단계에서 모두 해제합니다. 하지만 뭐가 표시되지 않았는지 알기 위해서, 메모리 전체를 검사해야 하므로 프로그램의 성능이 저하될 수 있습니다.
추가적인 방식은 여기를 참고하세요 포인터_추적_방식
1.5) Major GC (메이저 가비지 컬렉터)
여기서는 2개의 GC를 봐야 합니다. Major GC는 Old Generation 공간을 담당하고, Full GC는 Heap 영역의 old, young 양쪽을 다 담당합니다.
Major GC와 Minor GC와 따로 생각할 수 없습니다. 왜냐하면 Minor GC가 Young 공간이 가득 차서, Old 공간으로 옮기려면 Old 가 가득 차면 안 되기 때문입니다. 즉 Major GC도 Minor GC를 위해서 열심히 일을 해야 합니다. 컴퓨터 구조에서 불필요하게 리소스를 사용할 필요는 없지만, 굳이 놀고 있는 리소스를 많이 만드는 것도 효율적인 방식이 아니라고 배웁니다.
2) Non - Heap Memory (힙이 아닌 영역)
힙이 아닌 영역으로써, 이는 모든 스레드가 공유하는 메서드 영역입니다. Heap 영역은 인스턴스, 클래스 그리고 배열을 할당받은 객체의 메모리 공간입니다.
Non - Heap 영역은 Permanent Generation를 포함하고 있습니다. JRE에 포함되어 있지만, Heap 영역과 별도로 메모리에 존재합니다. 자바 8부터 Perm Gen이라고 하는 것이 Metaspace로 대체가 되었습니다. 기본적으로 클래스의 생성자들, 필드, 메서드를 저장합니다. 사이즈 또한 동적으로 변할 수 있습니다.
3) Cache Memory
컴파일러에 의해 컴파일된 기계어가 저장됩니다.
Stack vs Heap
Stack 영역은 Main() 함수를 포함해서 각 함수로부터 생성된 값(지역변수, 매개변수 등) 저장합니다. 각 함수들이 새로운 객체를 생성할 때마다 스택으로 밀어 넣습니다(push). 이때 값들은 임시로 생성되는 값입니다. 사용이 끝나고 나면 공간에서 삭제되고, 그 빈 공간은 사용 가능한 공간으로 바뀌게 됩니다. 위에서도 언급했지만 "삭제"라는 표현보다는 "나가게 되다(poped off)" 라는 표현이 좀 더 어울립니다. 즉 객체들이 push and pop 된다고 생각하시면 됩니다. 스택에 저장되는 값들은 CPU가 관리를 효율적으로 합니다. 읽고, 쓰고 하는 과정이 빠르게 이루어집니다.
Stack은 스레드당 자신의 고유 영역을 가집니다. 즉 한 개의 스레드에서만 사용이 됩니다. Heap처럼 스레드가 영역을 공유하지 않습니다. 즉 Threadsafe(스레드 세이프) 합니다. 이 말을 하면 스택의 변수는 지역 변수가 됩니다. 하지만 힙과 달리 제한된 사이즈를 가집니다. 오직 Primitive(원시의) 값들을 직접 저장하고, Heap에 생성된 객체들을 참조합니다. 참조는 직접 값을 저장하는 것이 아니라 주소 값을 참조하는 것입니다.
Stack은 당연하게도 LIFO(Last - in First - out) 구조입니다. 아래가 막힌 상자라고 생각하시면 됩니다. 차곡차곡 쌓으면, 꺼낼 때는 위에 것부터 꺼내는 것은 당연합니다. Heap 영역은 앞서 봤듯이 각 영역에서 꺼내 쓰면 됩니다.
Heap은 객체가 생성되는 영역입니다. 메모리 사이즈에 제한이 없고, 자동으로 관리가 되지 않습니다. 또한 전역으로 사용되는 객체를 저장하게 됩니다. 만약에 새로운 객체가 할당될 공간이 부족하면, 오래된 순서대로 가비지 컬렉터가 해제합니다.
코드로써 좀 더 자세히 보고 싶다면, 아래 링크를 참고하세요
수정사항
2020년 5월 30일
Stack vs Heap 내용 추가 및 수정, 오타 교정
2020년 5월 31일
Minor Collector 내용 추가
2021년 6월 14일
Heap area 내용 추가
2021년 8월 18일
Mark 내용 추가 및 오타 교정
참고 문헌
medium.com/platform-engineer/understanding-java-memory-model-1d0863f6d973
plumbr.io/blog/garbage-collection/minor-gc-vs-major-gc-vs-full-gc
intergral.atlassian.net/wiki/spaces/FR455/pages/148043389/Heap+and+Non+Heap
https://docs.oracle.com/cd/E19900-01/819-4742/6n6sfgmkr/index.html
반응형'JVM' 카테고리의 다른 글
OOM: unable to create new native thread 추적하기 (0) 2024.09.23 왜 JVM이 필요할까? (8) 2020.06.06 [Java]JVM Architecture란? (6) 2020.05.24