▸JAVA/기본 상식

Java의 메모리 구조_기본 구조[1/3]

코데방 2019. 12. 9.
728x90

개발자 입장에서 프로그램이 어떻게 돌아가는지와 효율적인 메모리 관리를 어떻게 해야하는지에 대해 큰 줄기만 정리해보겠습니다.

가장 기본 언어이자 기본 구조인 C언어의 메모리 구조와 비교하면서 보시면 이해가 더 잘될 것 같습니다. 혹시 C언어의 메모리 구조 또는 스택/힙 메모리에 대해 잘 모르시는 분은 아래 두 개 글을 참고하시면 됩니다.

 

2019/12/05 - [C언어/기본상식] - C언어의 메모리_기본 구조 [1/2]

2019/12/05 - [C언어/기본상식] - C언어의 메모리_스택 메모리 [2/2]

 


 

먼저 소스코드를 작성하고 컴파일하면 JDK에 들어 있는 컴파일러가 바이트 코드로 번역해서 .class 파일을 만들어 줍니다. 바이트 코드는 기계가 못 읽고 Java만 알 수 있는 중간 언어입니다. 그리고 프로그램을 실행하면 JRE에서 우리가 만든 .class파일을 가져가서 이리저리 프로그램 구동 준비를 하고 JVM을 띄워줍니다. C언어에서 하나의 컴파일 된 기계어(바이너리) 코드를 바로 메모리에 올리는 것과 대조적입니다. 절차만 봐도 알 수 있듯이 상대적으로 구동 속도가 느립니다.

최신 Java 버전에서는 JRE와 JVM이 통합됐다는 내용을 어디선가 얼핏 봤는데 역할별로 구조화를 쉽게 하기 위해 일단 구역을 나눠뒀습니다.

 

 


 

제반 준비가 끝나면 JRE의 클래스 로더가 프로그램 실행에 필요한 클래스의 바이트코드를 JVM의 코드 메모리 영역에 올려줍니다. 필요할 때마다 동적으로 올려주기 때문에 가장 먼저 메인 클래스의 메인 메소드가 메모리에 올라가게 됩니다. JVM에는 여러 가지 기능이 있지만 가장 핵심은 메모리 영역을 가지고 있다는 것과 코드 영역에 올라온 바이트코드를 기계어로 해석해서 실행시켜 준다는 것입니다.

코드 메모리에 올라온 코드는 실행 엔진(Excution Engine)이 한줄씩 기계어로 해석해서 실행해 줍니다. 바이트 코드를 한줄씩 기계어로 번역해서 실행하는 방식을 "인터프리터(Interpreter)"라고 하고 파일 전체를 한번에 기계어로 번역하는 방식을 "JIT(Just In Time)"이라고 하는데 두 방식을 적절하게 섞어서 실행시켜 줍니다.

 

 

 


 

이제 아래 코드를 예시로 구조를 살펴보겠습니다.

 

package study.first;

/* Public class */
public class Main {

	static int a = 3;
	int b = 5;
    static String str = "abc";	
	
	/* static 메소드 */
	public static void main(String[] args) {	
		Example e1 = new Example();
		Main m1 = new Main();
		e1.sum();
		m1.print();				
	}

	
	/* 일반 메소드 */
	public void print() {		
		int val = 10;
		String val2 = "abc";
		System.out.println("print 메소드 실행");		
	}	
}

/* Default Class */
class Example {

	char c = 'a';
	String arr = "abc";
	
	void sum() {
		String val3 = "abc";
		System.out.println("sum 메소드 실행");	
	}
}

 


 

메인 메소드와 같이 Static으로 지정된 메소드의 코드는 해당 클래스 파일이 최초 호출될 때 메모리에 올라간 후 프로그램 종료 시까지 계속 남아 있습니다. 그 외의 코드들은 필요할 때만 메모리에 올라왔다가 다 쓰고 나면 가비지 컬렉터(GC)가 없애 버립니다. 이러한 이유로 Static 메소드는 별도의 객체 생성을 하지 않고도 언제 어디서나 접근해서 사용할 수 있게 됩니다. 일반 메소드의 코드는 평소 메모리에 없는 상태이기 때문에 객체를 생성해서 해당 클래스의 바이트코드를 메모리에 올려주는 작업을 해줘야 합니다.

처음 프로그램을 실행하면 아래와 같이 셋팅됩니다. Main.class 파일에 있는 static 메소드와 필드를 찾아서 전부 메모리에 올려줍니다. ⓢ표시는 static 입니다.

상수영역은 static 필드나 코드 안에 포함된 변수들의 값을 저장하는 영역입니다. 코드/힙/스택 어느 영역에서나 원시타입 변수는 그 자리에 저장하고, 그 외 참조변수(객체)는 포인터(레퍼런스) 변수입니다. 참조변수의 실제 데이터는 항상 힙(Heap)영역에 생성됩니다. C언어에서는 개발자가 직접 생성하지 않는 한 힙 영역을 사용하지 않지만 Java는 모든 참조변수의 값을 힙 영역에 저장합니다.

 

 

 


 

이제 메인 메소드를 실행합니다. 메소드 실행이므로 새로운 스택 프레임이 하나 생성되고, 다른 클래스의 메소드를 실행하려고 합니다. static 메소드가 아닌 이상 코드가 메모리에 없기 때문에 먼저 메모리에 올려주는 작업이 필요합니다. 이 작업이 바로 new를 사용한 인스턴스 및 객체 생성 코드입니다.

e1 인스턴스를 하나 만들어주면 힙 영역에 해당 클래스의 설계도에 맞춰서 공간이 할당되고 초기값이 입력됩니다. 여기에서도 기본 변수는 본인이 값을 가지고 있지만 참조변수인 String arr은 그 값을 다른 곳에 생성한 뒤 주소만 가지고 있습니다. 코드 내에서 이미 "abc"라는 문자열을 저장해두었기 때문에 지금 코드에서는 기존의 문자열을 가리키기만 합니다.

 

 

 


 

그리고 이번엔 Main 클래스의 인스턴스 m1을 하나 추가합니다. 역시 과정은 위와 동일합니다. 하지만 Main클래스에서는 static 필드와 메소드가 이미 올라가 있습니다. 따라서 해당 static 필드의 값들은 주소만 가리키게 되고 나머지 필드들의 값만 저장됩니다. static은 딱 하나만 있어야 하기 때문입니다. 그리고 print 메소드의 코드도 코드영역에 올라옵니다. 이 부분은 클래스 로더가 알아서 해줍니다.

 

 

 


 

이제 e1.sum() 메소드가 실행될 차례입니다. 하나의 스택 프레임이 다시 생성됩니다. sum 메소드 안에도 참조 변수인 String val3이 있습니다. 지역 변수 자체는 스택 프레임에 저장되지만 참조변수이기 때문에 값 자체는 역시 힙 영역에 저장됩니다. 코드 상에 있는 초기값 문자열이므로 이미 있는 문자열이라면 해당 문자열 주소를 가리킵니다. 없으면 힙 영역에 새로 만들어 줍니다.

 

 

 


 

셋팅이 끝났으니 이제 system.out.println("sum 메소드 실행")을 출력하고 함수가 종료됩니다. 함수가 종료되었으므로 sum의 스택프레임이 제거됩니다. 다음으로 m1.print() 메소드를 실행합니다. sum함수 실행과 동일하므로 설명은 생략하겠습니다.

 

 

 


 

main 메소드가 끝났으니 프로그램 실행이 종료됩니다. 하지만 만약 지금 예로 든 것이 메인 클래스의 메인 메소드가 아닌 다른 메소드라고 가정해보겠습니다.

메소드가 종료되었으므로 역시 해당 메소드의 스택 프레임이 제거됩니다. 인스턴스 e1과 m1이 사라졌으므로 이제 힙 영역에 있는 e1과 m1의 공간을 가리키는 레퍼런스(참조, 포인터)가 하나도 없습니다. 즉, 이 데이터는 이제 쓰레기(Garbage)가 됩니다. 인스턴스와 함께 묶여있는 메소드 코드 또한 마찬가지입니다. 이 때 가비지 컬렉터(Garbage Collector)가 등장해서 쓰레기 처리를 합니다. C언어에서 동적할당(malloc)한 메모리를 직접 Free시켜주는 것과 마찬가지로, Java에서는 GC(가비지 컬렉터)가 그 역할을 자동 수행해줍니다.

 

 

 


 

GC(가비지 컬렉터)는 static이 붙어있는 요소는 건드리지 않습니다. 물론 static 메소드에서 쓰고 남은 지역변수나 인스턴스는 그 자체로 static이 아니기 때문에 치워집니다. static 메소드는 코드만 코드영역에 계속 남아있게 됩니다. 그리고 클래스의 static 필드는 코드영역 내 상수영역(Constant Pool)에 계속 남아 있기 때문에 별도로 null 처리 해주지 않는 한 아무리 많아도 지워지지 않습니다.

 

728x90

댓글

💲 추천 글