gyub's 공부일기/Java

[ JAVA ] JAVA의 동작 방식와 JVM 메모리 구조

gyu__b 2020. 11. 30. 16:11

1. 일반 프로그램과 비교

1) 자바는 JVM 위에서 실행되기 때문에 OS에 독립적입니다.

- 즉, JVM만 설치한다면 OS에 상관없이 돌아갑니다.

 

2) JVM의 특징

- 자바 소스 코드 컴파일 후 생성된 파일이 해석(interpret)과 Link없이 바로 JVM에 적재 되고, OS로부터 메모리를 할당받아 GC(Garbage Collection)를 통해 스스로 메모리 관리를 한다는 특징이 있습니다.

 

2. Java 동작 방식

1) 작성한 자바 소스(.java)를 자바 컴파일러(javac)를 통해 자바 바이트 코드(.class)로 컴파일 합니다.

- 자바 바이트 코드: JVM이 이해할 수 있는 코드로 아직 컴퓨터는 읽을 수 없는 반기계어이다.

자바 바이트 코드의 각 명령어는 1바이트 크리의 Opcode와 추가 피연산자로 이뤄져있다.

 

2) 컴파일 된 바이트 코드를 JVM의 클래스 로더에게 전달한다.

 

3) JVM의 클래스 로더는 동적 로딩을 통해 필요한 클래스들을 로딩 및 링크하여 런타임 데이터 영역 (Runtime Data Area), 즉 JVM의 메모리 영역에 올립니다.

- 런타임 데이터 영역: 메소드 영역, 힙 영역, 스택 영역, PC Register, Native Method Stack

- 로드 타임 동적 로딩: 하나의 클래스를 로딩하는 과정에서 필요한 다른 클래스를 동적으로 로딩하는 것

- 런타임 동적 로딩: 코드를 실행하는 순간에 필요한 클래스를 로딩하는 것

 

4) 실행엔진( Execution Engine)은 JVM 메모리에 올라온 바이트 코드들을 명령어 단위로 하나씩 가져와서 실행합니다. 이때, 실행엔진은 2가지 방식으로 동작할 수 있습니다.

- 자바 인터프리터( Java Interpreter ): 바이트 코드 명령어를 하나씩 읽어서 해석하고 실행합니다. 하나하나의 실행은 빠르나, 전체적인 실행 속도가 느리다는 단점을 가집니다.

 

- JIT 컴파일러( Just In Time Compiler): 인터프리터의 단점을 보완하기 위해 도입된 방식으로 바이트 코드 전체를 컴파일하여 바이너리 코드로 변경하고 이후에는 더 이상 인터프리팅 하지 않고, 바이너리 코드로 직접 실행하는 방식입니다. 하나씩 인터프리팅하여 실행하는 것이 아니라 바이트 코드 전체가 컴파일 된 바이너리 코드를 실행하는 것이기 때문에 전체적인 실행속도는 인터프리팅 방식보다 빠릅니다. => 실행속도가 비교적 느린 자바의 단점을 어느정도 보완.

 

 

3. 런타임 데이터 영역 (= JVM 메모리 구조)

위 그림 참고
런타임 데이터 영역 세부 그림

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

1) 메서드 영역 (= 클래스 영역, 스태틱 영역 )

- JVM에서 오직 하나의 영역만 존재하고 공유할 수 있다.

- 즉, 다른 스레드에서도 접근할 수 있다.

- 하나의 Java파일은 크게 필드(field), 생성자 (constructor), 메서드 (method)로 구성된다.

- 필드 부분에서 선언된 변수(전역 변수)와 정적 멤버변수(static이 붙은 자료형)를 저장한다.

- 타입 정보( 타입의 속성이 클래스인지 인터페이스인지에 대한 정보)도 저장한다.

- final class 변수의 경우 상수로 치환되어 상수 풀에 값 복사

- static 영역의 데이터는 프로그램의 시작부터 종료가 될 때까지 메모리에 남아있다.

- 즉, 전역 변수는 프로그램이 종료될 때까지 어디서든 사용이 가능하다.

- 따라서 전역 변수를 무분별하게 많이 사용하다 보면 메모리가 부족할 우려가 있다.

 

2) 힙 영역

- 모든 객체들의 정보를 저장.( 참조형의 데이터 타입을 갖는 객체, 배열 등 )

- 이때, 변수(객체, 객체변수, 참조변수)는 Stack영역의 공간에서 실제 데이터가 저장된 Heap영역의 참조값(reference value, 해시코드 / 메모리에 저장된 주소를 연결해주는 값)을 new 연산자를 통해 리턴받는다.

- 다시 말하면, 실제 데이터를 갖고 있는 Heap영역의 참조 값을 Stack영역의 객체가 갖고있다.

- 이렇게 리턴 받은 참조 값을 갖고 있는 객체를 통해서만 해당 인스턴스를 핸들할 수 있다.

 

public class HeapAreaEx01 {
    public static void main(String[] args) {
        int[] a = null; // int형 배열 선언 및 Stack 영역 공간 할당
        System.out.println(a); // 결과 null
        a = new int[5];// Heap 영역에 5개의 연속된 공간 할당 및
                       // 변수 a에 참조값 할당
        System.out.println(a); //결과: [I@b4c966a ( 참조값 )

    }
}
class A{}

public class HeapArea {
    public static void main(String[] args) {
        A a = null; // 클래스 A타입의 a객체 선언 및 Stack 영역 공간 할당
        System.out.println(a); // 결과 null
        a = new A(); // Heap 메모리에 공간 할당 및 객체(a)에 참조값 할당
        System.out.println(a); // 결과 A@2f4d3709
    }
}

 

- JVM에서 오직 하나의 영역만 존재하고, 공유할 수 있다.

- 즉, 다른 스레드에서 접근 가능하다.

- 힙 영역에 저장된 데이터가 더이상 사용이 불필요하다면 메모리 관리를 위해 JVM의 GC(가비지컬렉터)에 의해 알아서 해제된다.

 

3) 스택 영역

- 메서드 내에서 정의하는 기본 자료형 (int, double, byte 등)에 해당되는 지역변수 (매개 변수 및 블럭문 내 변수 포함)의 데이터 값이 저장되는 공간이다.

- 메서드가 호출될 때 메모리에 할당되고 종료되면 메모리가 해제된다.

- 모든 스레드에 대해 JVM은 여기에 저장된 하나의 런타임 스택을 만든다.

- 즉, 메서드 호출 시 마다 각각의 스택 프레임(해당 메서드만을 위한공간)이 생성된다.

- 이 스택의 모든 블럭은 메서드 호출이 저장되는 활성화 기록/스택 프레임이라 불린다.

- 스레드가 끝나면 런타임 스택은 JVM에 의해 소멸된다.

- 즉, 메서드 수행이 끝나면 프레임별로 삭제된다.

- 위에서도 스레드 얘기를 했지만, 스레드도 stack 영역에 생긴다.

- 이러한 이유로 하나의 스레드는 다른 스레드로의 접근이 불가하지만 static영역과 heap영역은 공유해서 사용할 수 있다.

 

4) PC Registers

- 스레드의 현재 실행 멸영의 주소를 저장한다.

- 각 스레드에는 별도의 PC레지스터를 가지고 있다.

 

5) Native method stacks

- 모든 스레드에 대해 별도의 네이티브 스택을 생성한 후 네이티브 메서드 정보를 저장한다.

- 일반적인 메서드를 실행하는 경우 JVM 스택에 쌓이다가 해당 메서드 내부에 네이티브 방식을 사용하는 메서드(C,C++로 작성된 메서드) 가 있다면 해당 메서드는 Native method stack에 쌓입니다.

 

힙영역과 가비지컬렉터

 

힙 영역은 따로 더 살펴보겠습니다. 왜냐? GC의 주요 대상이기 때문입니다.

힙 영역은 우선 5개의 영역(eden, survivor1, survivor2, old, permanent)로 나뉩니다.

크게 보면 Young영역( eden, survivor1,survivor2), Old영역(old), Perm영역(permanent)으로 볼 수 있습니다.

※ JDK7까지는 permanent영역이 heap에 존재했지만, JKD8부터는 permanent영역은 사라지고 일부가 "meta space 영역"으로 변경되었습니다.( meta space영역은 Native stack 영역에 포함되었습니다.)

※ survivor영역의 숫자는 별다른 의미는 없고 두 개로 나뉜다는 것이 중요합니다.

이렇게 힙 영역을 5개로 나눈 이유는 효율적으로 GC가 일어나게 위함인데요. GC가 일어나는 프로세스를 보겠습니다.

 

- 마이너 GC: Young영역(Eden, Survivor1, Survivor2)에서 일어나는 GC

1) 최초에 객체가생성되면 eden영역에 생성된다.

2) Eden영역에 객체가 어느정도 차게 되면 이 영역에 있던 객체가 어디론가 옮겨지거나 삭제된다.

3) 이때 옮겨지는 위치가 survivor영역이다.

4) 두 개의 Survivor영역 사이에 우선 순위가 있는 것은 아니지만, 두 개의 영역 중 한 영역은 반드시 비어 있어야 한다.

5) 그 비어있는 영역에 Eden 영역에 있던 객체가 할당된다.

6) 할당된 survivor 영역이 차면 GC가 되면서 Eden영역에 있는 객체와 꽉 찬 survivor 영역에 있는 객체가 비어 있는 survivor 영역으로 이동합니다.

7) 그러다 더 큰 객체가 생성되거나, 더 이상 Young영역에 공간이 남지 않으면 객체들은 Old 영역으로 이동하게 됩니다.

 

- 메이저 GC(Full GC): Old영역이나 Perm영역에서 발생하는 GC

1) old 영역에 있는 모든 객체들을 검사하며 참조되고 있는지 확인한다.

2) 참조되지 않은 객체들을 모아 한 번에 제거한다.

- Minor GC보다 시간이 훨씬 많이 걸리고 실행중에 GC를 제외한 모든 스레드가 중지한다.

이때 old영역에 있는 참조가 없는 객체들을 표시하고 그 해당 객체들을 모두 제거하게 되면, Heap 메로리 영역 중간 중간에 구멍(제거되고 빈 메모리 공간)이 생깁니다.

이 부분을 없애기 위해 재구성을 합니다.

 

 

반응형