Dev/Java

Garbage Collector 과JVM 메모리 구조 ( OOP를 제대로 알기 )

린네의 2024. 5. 1. 20:56

 

자바를 사용해서 프로그램 코드를 작성하다 보면 간혹 코드 내용에는 문제가 없는 것 같은데 의도와 다르게 실행될 때가 있다. 경험상 이것은 변수가 JVM에서 어떻게 적재되고 읽히는지 제대로 알지 못하고 작성한 경우가 많았다.

 

가장 기초적이지만, 실무를 하다보면 나도 모르게 놓칠 수 있는 JVM 구조의 기초와 OOP에 대한 개념을 정리하고자 한다.

 

OOP 란 ? ( cf. OOPS )

객체지향의 기본 개념은 '실제 세계는 사물로 이루어져 있으며, 발생하는 모든 사건들은 사물 간의 상호작용이다'라는 것이다.

실제 사물의 속성과 기능을 분석한 다음, 데이터와 함수로 정의함으로써 데이터와 실제 세계를 컴퓨터 속에 옮겨 놓은 것과 같은 가상 세계를 구현하고 이 가상세계에서 모의실험을 함으로써 많은 시간과 비용을 절약할 수 있었다.

 

객체지향이론은 상속, 캡슐화, 추상화 개념을 중심으로 점차 구체적으로 발전되었다.

 

객체지향언어의 주요 특징은 다음과 같다.

1. 코드의 재사용성이 높다
2. 코드의 관리가 용이하다 - 유지보수가 용이하다 
3. 신뢰성이 높은 프로그램을 가능하게 만든다 - 제어자와 메서드를 이용해서 데이터를 보호하고 올바른 값을 유지하도록 하며, 코드의 중복을 제거하여 코드의 불일치로 인한 오동작을 방지할 수 있다

 

실제 프로그램 코드를 작성할 때, 차선을 선택해야할 경우에도 재사용성과 유지보수가 용이한지, 그리고 중복된 코드가 잘 제거되었는지에 초점을 맞추면 보다 '객체지향'스러운 코드를 작성할 수 있다.

 

  • 클래스

클래스란 '객체를 정의해놓은 것' 또는 '객체의 설계도 또는 틀'이라고 정의할 수 있다. 클래스는 객체를 생성하는 데 사용되며 객체는 클래스에 정의된 대로 생성된다. 프로그래밍에서의 객체는 클래스에 정의된 내용대로 메모리에 생성된 것을 뜻한다.

 

  • 객체와 인스턴스, 참조변수 

클래스로부터 객체를 만드는 과정을 클래스의 인스턴스화라고하며, 어떤 클래스로부터 만들어진 객체를 그 클래스의 인스턴스라고 한다.

인스턴스는 객체와 같은 의미이지만 객체는 모든 인스턴스를 대표하는 포괄적인 의미를 갖고 있으며, 인스턴스는 어떤 클래스로부터 만들어진 것인지를 강조하는 보다 구체적인 의미를 가지고 있다. ( 사실 객체와 인스턴스는 거의 혼용해서 쓴다 )

 

인스턴스는 참조변수를 통해서만 다룰 수 있으며, 참조변수의 타입은 인스턴스의 타입과 일치해야 한다. 여기서 인스턴스와 참조변수의 관계는  우리가 일상생활에서 사용하는 TV와 TV리모컨의 관계와 같다.  TV리모컨을 사용하여 TV를 다루기 때문이다. 

 

자신을 참조하고 있는 참조변수가 하나도 없는 인스턴스는 더 이상 사용되어질 수 없으므로 '가비지 컬렉터'에 의해서 자동적으로 메모리에서 제거된다.  참조변수에는 하나의 값(주소값)만이 저장될 수 있으므로 둘 이상의 참조변수가 하나의 인스턴스를 가리키는 것은 가능하지만 하나의 참조변수로 여러 개의 인스턴스를 가리키는 것은 가능하지 않다.

 

  • 객체 배열

 객체 배열은 참조변수들을 하나로 묶은 참조변수 배열이다.  따라서 객체의 배열을 생성하는 것은 객체를 다루기 위한 참조변수들이 만들어진 것일 뿐 아직 객체가 저장되지 않았다. 객체를 생성해서 객체 배열의 각 요소에 저장하는 것을 잊으면 안 된다.  모든 배열이 그렇듯이 객체 배열도 기본적으로는 같은 타입의 객체만 저장할 수 있지만, 다형성에 의해 조상 클래스 타입의 참조변수로 자손클래스의 인스턴스를 참조할 수 있다.  다형성에 대한 보다 자세한 글은 아래 링크를 참조 바란다.

 

2024.03.12 - [개발/java] - 다형성(Polymorphism)과 참조변수 ( with. instanceof 연산자의 필요성)

 

다형성(Polymorphism)과 참조변수 ( with. instanceof 연산자의 필요성)

다형성과 참조변수 객체지향개념에서 다형성이란 '여러 가지 형태를 가질 수 있는 능력'을 의미하며 자바에서 한 타입의 참조변수로 여러 타입의 객체를 참조할 수 있도록 함으로써 다형성을

zigo-autumn.tistory.com

 

 

POJO가 필요한 이유가 뭘까?

 

POJO는 Plain Old Java Object의 약자로 오래된 방식의 간단한 자바 오브젝트라는 말로, 특정 기술에 종속되어 동작하는 것이 아닌 순수한 자바 객체 그자체를 의미한다.  즉 다른 클래스나 인터페이스를 extends/implements 받아 메서드가 추가된 클래스가 아닌 getter/setter 같이 기본적인 기능만 가진 자바 객체라고 볼 수 있다.

 

특정 기술들이 종속성을 띄우게 되면, 객체지향적인 설계가 힘들거나 불가능한 경우가 발생한다.  하나의 로우 레벨의 로직을 구현하기 위해 상속을 받는다고 생각해보자.  로우 레벨을 다시 개발하는 불편함은 줄일 수 있지만, 간단한 서비스조차도 하이 레벨의 서비스까지 모두 끌어오는 바람에 서비스 자체가 무거워지고 코드의 유지보수가 어려워진다는 문제점이 발생한다.   

 

결론적으로,  OOP적이고, 종속적이지 않으며, 편리한 테스트를 위해서는 POJO가 필요하다.  Spring 은 이런 POJO 방식을 기반으로한 웹 프레임워크이다. ( IoC, DI, AOP 사용 등...)

 

JVM 구조를 알아보자 ( feat. 객체,메소드,변수는 어떻게 저장되는가?)

 JVM은 Java Virtual Machine의 약자로 자바 가상 머신이라고 부른다. 자바와 운영체제 사이에서 중개자 역할을 수행하며, 자바가 운영체제에 구애받지 않고 프로그램을 실행할 수 있도록 도와준다.

 

JVM의 구조는 크게 Garbage Collector, Excution Engine, Class Loader, Runtime Data Area로 나뉜다.

 

 

[출처] https://doohong.github.io/2018/03/02/Java-runtime-data-area/

 

 

  • Class Loader(클래스 로더)

자바는 동적으로 클래스를 읽어오므로, 프로그램이 실행 중인 런타임시 동적으로 클래스를 로드하여 모든 코드가 자바 가상 머신과 연관된다. 이렇게 동적으로 클래스를 로딩해 주는 역할을 하는 것이 클래스 로더이다. 즉 JVM 내로 클래스 파일을 로드하고, 링크를 통해 배치하는 작업을 수행하는 모듈이다. 

 

다시 말하면. java 파일이 컴파일되어 생성된. class 파일을 JVM이 운영체제로부터 할당받은 메모리 영역인 Runtime Data Area로 적재하는 역할을 한다.

 

  • Excution Engine(실행 엔진)

클래스 로더에 의해 JVM으로 로드된. class 파일들은 Runtime Data Areas의  Method Area에 배치되는데, 배치된 이후에 JVM은 Method Area의 바이트 코드를 실행 엔진에 제공하여 정의된 내용대로 바이트 코드를 실행시킨다.  이때 로드된 바이트코드들을 실행하는 런타임 모듈이 실행 엔진이다. 실행 엔진은 바이트코드를 명령어 단위로 읽어서 실행한다. 

 

  • Garbage Collector(가비지 컬렉터)

JVM은 가비지 컬렉터를 이용하여 더는 사용하지 않는 메모리를 자동으로 회수해 준다. 따라서 개발자가 따로 메모리를 관리하지 않아도 된다.  Heap 메모리 영역에 생성된 객체들 중에 참조되지 않은 객체들을 탐색 후 제거하는 역할을 하며 해당 역할을 하는 시간은 정확히 언제인지 알 수 없다. GC 역할을 수행하는 스레드를 제외한 나머지 모든 스레드들은 일시정지 상태가 된다. 

 

 

  • Runtime Data Area(런타임 데이터 영역)

JVM의 메모리 영역으로 자바 애플리케이션을 실행할 때 사용되는 데이터들을 적재하는 영역이다.  크게 Method Area, Heap Area, Statck Area, PC Register, Native Method Stack으로 나눌 수 있다.

 

런타임 데이터 영역은 JVM이 운영체제로부터 할당받은 메모리 영역으로 자바 애플리케이션을 실행할 때 사용되는 데이터들을 적재하는 영역이다. 

[출처] https://velog.io/@yerimii11/Java-JVM-%EA%B5%AC%EC%A1%B0

 

 

  • 메서드 영역 ( Method Area )  - static area / 모든 스레드가 공유해서 사용하는 영역

프로그램 실행 중 어떤 클래스가 사용되면, JVM은 해당 클래스의 클래스파일을 읽어서 분석하여 클래스에 대한 정보를 저장한다. 그 클래스의 클래스변수도 이 영역에 함께 저장된다. 모든 스레드가 공유하며 클래스, 인터페이스, 메소드, 필드, static 변수 등의 바이트 코드를 보관한다.

 

  • 힙 영역 ( Heap Area ) / 모든 스레드가 공유해서 사용하는 영역 

인스턴스가 생성되는 공간, 프로그램 실행 중 생성되는 인스턴스는 모두 이곳에 생성된다. 즉, 인스턴스변수들이 생성되는 공간이다. 모든 쓰레드가 공유하며 new 키워드로 생성된 객체와 배열이 생성되는 영역이다.  메서드 영역에 로드된 클래스만 생성이 가능하고 Garbage Collector가 참조되지 않는 메모리를 확인하고 제거하는 영역이다.

 

  • 호출 스택 ( call stack, Excution stack ) - stack area / 스레드마다 하나씩 생성 

호출스택은 메서드의 작업에 필요한 메모리 공간을 제공한다. 메서드가 호출되면, 호출스택에 호출된 메서드를 위한 메모리가 할당되며, 이 메모리는 메서드가 작업을 수행하는 동안 지역변수들과 연산의 중간결과 등을 저장하는 데 사용된다. 그리고 메서드가 작업을 마치면 할당되었던 메모리 공간은 반환되어 비워진다.

 

메서드 호출 시마다 각각의 스택 프레임(해당 메서드만을 위한 공간)을 생성하고, 메서드 수행이 끝나면 프레임 별로 삭제한다.

 

  • PC 레지스터 ( PC Register ) / 스레드마다 하나씩 생성

스레드가 시작하고 생성될 때마다 생성되는 공간으로 스레드별로 하나씩 존재한다. PC는 Program Counter의 준말로 현재 스레드가 실행되는 부분의 주소와 명령을 저장하고 있는 영역이다. 스레드가 어떤 부분을 무슨 명령으로 실행할지에 대한 기록을 하여 현재 수행 중인 JVM 명령의 주소를 가진다

 

  • 네이티브 메서드 스택 ( Native Method Statck ) / 스레드마다 하나씩 생성

자바 이외의 언어로 작성된 네이티브 코드를 실행할 때 사용되는 메모리 영역으로 Java가 아닌 C, C++로 작성된 메서드를 실행하는 스택이다.

 

final와 static

  • static

'static'은 '클래스의' 또는 '공통적인'의 의미를 가지고 있다. 인스턴스변수는 하나의 클래스로부터 생성되었더라도 각기 다른 값을 유지하지만 클래스변수(static 멤버변수)는 인스턴스에 관계없이 같은 값을 갖는다. 하나의 변수를 모든 인스턴스가 공유하기 때문이다.

 

static은 JVM의 메모리영역에서 static  영역에 데이터를 저장한다. 따라서 프로그램의 시작부터 종료까지 메모리에 남아있게 된다.

 

  • final

'final'은 '마지막의' 또는 '변경될 수 없는'의 의미를 가지고 있으며 거의 모든 대상에 사용될 수 있다. 변수에 사용되면 값을 변경할 수 없는 상수가 되며, 메서드에 사용되면 오버라이딩을 할 수 없게 되고 클래스에 사용되면 자신을 확장하는 자손클래스를 정의하지 못하게 한다.

 

final 또한 JVM의 메모리영역에서 static 영역에 데이터를 저장한다. 따라서 프로그램의 시작부터 종료까지 메모리에 남아있게 된다.

 

최신 자바 메모리 모델

 

사실 자바 메모리 모델이 크게 변경된 것은 java7에서 java8로 바뀌면서 삭제된 permSize라고 할 수 있다.

perm 영역은 Class의 Meta정보, Method의 Meta 정보, Static의 변수와 상수 정보 등이 저장되는 공간으로 흔히 메타데이터 저장 영역이라고 표현했다. 이 영역은 java 8부터는 Native Memory 영역으로 이동하여 Metaspace 영역으로 명명되었다. 메모리가 부족할 경우 따로 permSize, maxPermSize를 설정할 필요 없이 메모리를 자동으로 늘려줄 수 있게 바뀌었다.  ( permSize, maxPermSize 대신에 MetaSpaceSize와 MaxMetaspaceSize를 사용하게 되었는데 default MaxMetaspaceSize는 Native Memory 자원과 동일하다 ) 

다만 static Object는 Heap 영역으로 옮겨져서 GC의 대상이 될 수 있도록 바뀌었다.

 

 

 

 

 

 

 

 

여기서 heap 영역은 young과 old로 나뉜 게 된다. 각각이 의미하는 바는 아래와 같다

 

  • young 영역

새롭게 생성된 객체가 할당되는 영역으로 대부분의 객체가 금방 참조하지 않는 객체 상태(Unreachable)가 되기 때문에, 많은 객체가 Young 영역에 생성되었다가 사라진다. Young에 대한 가비지 컬렉션을 Minor GC라고 부른다.

 

 

  • old 영역

young 영역에서 참조상태(Reachable)를 유지하여 살아남은 객체가 복사되는 영역으로 Young 영역보다 크게 할당되며 영역의 크기가 큰 만큼 가비지는 적게 발생한다. Old에 대한 가비지 컬렉션을 Major GC 또는 Full GC라고 부른다.

 

여기서 힙 영역은 더욱 효율적인 GC를 통해 Young 영역을 (Eden, Survivor 0, Survivor 1)로 나눈다

Eden은 new를 통해 새로 생성된 객체가 위치하며, 정기적인 쓰레기 수집 후 살아남은 객체들은 Survivor 영역으로 보낸다.

 

Survivor0/1은 최소 1번의 GC에서 살아남은 객체가 존재하는 영역으로, Survivor 영역에서 Survivor0 또는 Survivor1 중에 하나는 꼭 비어 있어야 한다.

 

  • stop the world

GC 를 실행하기 위해 JVM이 어플리케이션 실행을 멈추는 것이다. stop-the-world가 발생하면 GC를 실행하는 쓰레드를 제외한 나머지 쓰레드는 모두 작업을 멈춘다. 대개 GC 튜닝이라 하면 stop-the-world시간을 줄이는 것이다.

 

 

GC 란?

GC, Garbage Collector은 Heap 메모리 영역에서 더는 사용하지 않는 메모리를 자동으로 회수해 주는 역할을 한다. 

GC의 실행 시점은 JVM에 의해 결정되며 개발자는 System.gc() 메서드를 호출하여 GC 실행을 제안할 수 있지만 실제 실행 여부와 시점은 JVM의 결정에 따른다. 

 

가비지 컬렉션은 자바 애플리케이션의 메모리 관리를 단순화시켜주는 중요한 기능이다.

아 참고로 System.gc()는 절대 사용하면 안된다.

 

GC의 객체 수집 방법

Heap 영역에 참조하고 있지 않은 객체, 즉 더 이상 사용하지 않아 메모리에서 삭제되어야 하는 객체를 수집할 때  GC는 Mark-Sweep이라는 알고리즘을 사용한다.

 

이것은 GC가 동작하는 기초적인 청소 과정에 해당한다.

 

  • 마킹 ( Marking ) - Mark  : 가비지 컬렉터가 사용되거나 사용하고 있지 않는 객체를 식별함
  • 일반 삭제 ( Normal Deletion ) - Sweep  :  가비지 컬렉터가 미사용 객체를 삭제하고 다른 객체가 할당될 자유 공간을 반환함
  • 압축 삭제 ( Delection with compacting ) - Compact  : 더 나은 성능을 위해 미사용 객체가 삭제된 뒤 살아남은 모든 객체가 한 곳으로 이동하게 되며 이것은 새로운 객체를 위한 메모리 할당의 성능을 향상해줌, Sweep 후에 분산된 객체들을 Heap의 시작 주소로 모아 메모리가 할당된 부분과 그렇지 않은 부분으로 압축한다. ( GC 알고리즘에 따라 사용할 수도 있고 사용하지 않을 수도 있다 ) 

 

GC의 대표 알고리즘 소개

  • Serial  GC

서버의 CPU 코어가 1개 일 때 사용하기 위해 개발된 가장 단순한 GC로 GC를 처리하는 스레드가 1개 여서 가장 stop-the-world 시간이 길다. Minor GC 에는 Mark-Sweep을 사용하고 Major GC 에는 Mark-Sweep-Compact를 사용한다. 보통 실무에서는 거의 사용하지 않는다. ( 요즘에는 거의 다 멀티코어이기 때문이다. )

 

  • Parallel GC

Java 8의 기본 GC이다. Serial GC와 기본 알고리즘은 동일하지만 Young 영역의 Minor GC를 멀티 스레드로 수행한다 ( Old 영역은 여전히 싱글 쓰레드 이다. Serial GC에 비해 stop-the-world 시간이 감소한다.

 

  • Parallel old GC

Parallel GC를 개선한 버전으로 Young과 Old 영역에서도 멀티 쓰레드로 GC를 수행한다. 새로운 가비지 컬렉션 청소 방식인 Mark-Summary-Compack 방식을 이용한다.

 

  • CMS GC ( Concurrent Mark & Sweep ) 

애플리케이션의 스레드와 GC 스레드가 동시에 실행되어 stop-the-world 시간을 최대한 줄이기 위해 고안된 GC로 GC 과정이 매우 복잡해진다. GC 대상을 파악하는 과정이 복잡한 여러 단계로 수행되기 때문에 다른 GC 사용대비 CPU사용랴잉 높고, 메모리 파편화 문제가 발생한다. Java9부터 deprecated 되었고 Java14부터는 사용이 중지되었다.

 

  • G1 GC ( Garbage First )

CMS GC 모드를 대체하기 위해 jdk 7 버전에서 최초로 release 된 GC로 java 9+ 버전의 디폴트 GC로 지정되었다.

4GB 이상의 힙 메모리, Stop the World 시간이 0.5초 정도 필요한 상황에 사용된다. 기존 GC 알고리즘에서는 Heap 영역을 물리적으로 고정된 Young / Old 영역으로 나누었지만 G1 gc는  Region 개념을 새로 도입해서 사용함. 

 

전체 Heap 영역을 Region이라는 영역으로 체스같이 분할하여 상황에 따라 Eden, Survivor, Old 등 역할을 고정이 아닌 동적으로 부여한다. Garbage로 가득 찬 영역을 빠르게 회수하여 빈 공간을 확보하므로 결국 GC 빈도가 줄어드는 효과를 얻을 수 있다.

 

 

 

  • Shenandoah GC 

java 12에서 release 되었다. 레드햇에서 개발한 GC로 기존 CMS가 가진 단편화와 G1이 가진 pause 이슈를 해결했다. 강력한 Concurrency와 가벼운 GC 로직으로 heap 사이즈에 영향을 받지 않고 일정한 pause 시간 소요가 특징이다

 

 

  • Z GC ( Z Garbage Collector )

java 15에서 release 되었다. 대량의 메모리 ( 8 MB ~ 16TB )를 low-latency로 잘 처리하기 위해 디자인된 GC로 G1의 Region처럼 ZPage라는 영역을 사용하며 고정크기인 Region에 비해 2mb의 배수로 동적으로 운영한다.  ZGC가 내세우는 최대 장점 중 하나는 힙 크기가 증가하더라도 stop-the-world 시간이 절대 10ms를 넘지 않는다는 것이다. java 21 에서 기본 GC로 선정되었다.