JVM은 OS의 메모리 영역에 접근을 해서 Java의 메모리를 관리하는 가상 프로그램을 의미한다. 이 메모리를 그냥 사용해서는 안되고 누군가 쓸 때 할당을 해주고 또 다쓰고 나면 해제를 해줘야 한다. 메모리 관리를 C나 C++을 개발할 때는 사용자가 직접 관리를 해줘야 했지만 자바에서는 GC가 직접 해준다.
이러한 JVM의 Stack과 Heap 영역에 대해서 자세히 살펴보자.
JVM
Java의 Stack과 Heap에 대해서 이해하기 위해서는 먼저 JVM에 대한 이해가 필요하다. 간단하게 자바에서 메모리 관리가 어떻게 이루어지는지 살펴보자.
JVM 이전 C/C++ 문제점
위 그림처럼 리눅스에서 컴파일해서 나온 실행파일을 윈도우에서 돌리게 되면 안돌아간다. C/C++는 컴파일 플랫폼과 타겟 플랫폼(= 운영체제 + CPU 아키텍쳐)이 다를 경우, 프로그램이 동작하지 않는다는 문제가 있었다.
이를 해결하기 위해서 크로스 컴파일이라고 하는 것이 나왔다. 이 '크로스 컴파일'은 타겟 플랫폼에 맞춰 컴파일하는 것을 말한다. 이 덕분에 리눅스에서 윈도우를 타겟으로 잡고 컴파일이 가능하다. 당연히 컴파일해서 나온 실행파일은 윈도우에서 동작을 한다. 이러한 방식으로 C/C++에서 플랫폼에 의존적이였던 문제점을 해결하였다.
JVM으로 문제를 해결
JVM은 위 문제를 근본적으로 해결했다. Java 소스코드가 컴파일을 거치고 나면 Java 바이트코드가 된다. 이 Java 바이트 코드는 JVM이 설치된 플랫폼이라면 어떤 플랫폼이던지 상관없이 잘 동작한다. 물론 JVM이 플랫폼과 관련된 기계어 지저분한 작업들을 대신해주기 때문에 가능하다. 따라서 Windows면 Windows용 JVM을 설치해도 Linux에서 작업하고 컴파일한 Java 바이트코드를 실행해도 아무런 문제가 없다.
WORA(Write Once, Run Anywhere)처럼 자바 코드를 컴파일해서 배포하면 어떤 플랫폼이든 다시 컴파일할 필요 없이 실행시킬 수 있고 실행하려면 그 플랫폼에 맞는 JVM이 설치되어 있어야 한다.
C/C++도 크로스 컴파일해서 배포면 되는데, 굳이 JVM을 사용해야 했을까? Java가 나오던 시절에는 네트워크가 막 발전한 시대였다. 네트워크로 모든 것들이 연결되고, 디바이스마다 실행하기 위한 프로그램이 필요했다. 이때, C/C++는 플랫폼마다 컴파일을 해줘야하는데 많은 기종의 디바이스들에 맞게 다컴파일하기에는 한계가 있었다. 그래서 Java는 네트워크에 연결된 모든 디바이스에서 작동하는 것이 목적이었다. 디바이스마다 운영체제나 하드웨어가 다르기 때문에, 자연스럽게 플랫폼에 의존하지 않도록 언어를 설계했다. 그리고 그 결과가 Java Bytecode, JVM이다. 바이트코드를 담은 .class 파일을 네트워크를 통해 전달해주면 웹 브라우저에 JVM이 설치되어 있어서 이것을 실행하기만 하면 된다. 자바스크립트 파일을 웹 브라우저에 넘겨주면 웹 브라우저에 설치되어있는 자바스크립트 런타임이 즉석에서 interpret해서 실행한다는 것과 같다.
JVM의 내부 구조
JVM(Java Virtual Machine)의 동작 방식
- Java로 개발된 프로그램을 실행하면 JVM은 OS로부터 메모리를 할당한다.
- 자바 컴파일러(javac)가 자바 소스코드(.java)를 자바 바이트코드(.class)로 컴파일한다.
- Class Loader를 통해 JVM Runtime Data Area로 로딩한다.
- Runtime Data Area에 로딩된 .class들은 Execution Engine을 통해 해석한다.
- 해석된 바이트 코드는 Runtime Data Area의 각 영역에 배치되어 수행하며 이 과정에서 Execution Engine에 의해 GC의 작동과 스레드 동기화가 이루어진다.
JVM은 크게 3가지 영역으로 구성된다. 바이트코드를 읽고, 클래스 정보를 메모리의 Heap/Method Area에 저장하는 클래스 로더, 실행 중인 프로그램의 정보가 올라가 있는 Runtime Data Area, 바이트 코드를 네이티브 코드로 변환시켜주고, GC를 실행하는 실행 엔진이 있다.
JVM 내부 구조에서 우리가 주목해야할 부분은 Runtime Data Area으로 JVM은 OS로부터 메모리를 할당받은 후, Java Bytecode를 실행하기 위해 여러 가지 종류의 메모리가 필요한데 그 때 사용하는 메모리 공간이다. 이 때 스레드가 공유하는 것이 있고, 공유하지 않는 것이 있다. Method Area와 Heap Area이 모든 스레드가 공유하는 Data Area이고 Heap Area GC의 대상이 된다. 그리고 나머지는 스레드마다 고유하게 생성하면, 스레드 종료 시 소멸된다.
Method Area(Static Area)
Method Area는 보통 정적(Static) 영역이라고 부르는 메모리이다. 프로그램 실행 중 클래스나 인터페이스를 사용하게 되면, Class Loader를 이용해 클래스와 인터페이스의 메타 데이터를 Method Area에 저장한다. 즉, 클래스가 로드되는 시점은 해당 클래스가 되기 위해 호출되는 시점이다. 메타 데이터는 변수, 메서드 정적 변수 등을 가리키는데 Method Area에 저장되는 목록에 대해 살펴보자.
1. Type Information: 클래스와 인터페이스의 정보
- Type명: Package name + Class name
- Type의 종류: Type이 Class인지 Interface인지에 대한 정보
- Type의 제어자: 접근 제어자(public, private, default 등), 그 외 제어(abstract, final 등)
- 연관된 Interface 정보: 사용된 Interface의 정보
2. Runtime Constant Pool: Type의 상수 정보를 저장하는 Pool
3. Field Information: 인스턴스 변수의 정보를 저장
- Type명: 인스턴스 변수의 타입
- 제어자: 접근 제어자(public, private 등), 그 외 제어자(static, final, volatile 등)
4. Method Information: 메서드의 모든 정보 저장
- Method 명, Method 반환 타입, Method parameter 수와, parameter의 타입 정보
- 외 필요한 메서드에 대한 정보
5. Class Variable: static 키워드로 선언된 변수 저장
PC(Program Counter) register
각 스레드는 항상 어떤 메서드를 실행하고 있다. 그때 PC는 그 메서드 안에서 바이트코드 몇 번째 줄을 실행해야 하고 있는지를 나타내는 역할을 한다.
Stack Area
Stack 같은 경우에는 정적으로 할당된 메모리 영역이다. Stack에서는 boolean, char, short, int, long, float, double 같은 원시 타입의 데이터가 값이랑 같이 Stack에 할당이 되고, Heap 영역에서 오브젝트 타입 데이터의 참조값도 Stack에 할당이 된다. 다른 특징으로 새로운 Thread가 생성이 되면 그 해당 Thread에 대한 Stack이 새롭게 생성이 되고, 각 Thread끼리는 Stack 영역을 접근할 수가 없다. 즉, Stack의 메모리는 Thread당 하나씩 할당된다.
그리고 스택 프레임이라는 것이 있는데 이 스택 프레임은 메서드가 호출될 때마다 생성되고, 메서드 실행이 끝나면 스택 프레임은 pop되어 스택에서 제거된다. 따라서 제일 위에 있는 stack frame은 main 메서드이고, 그 밑에 있는 stack frame들은 main 메서드에서부터 계속 호출된 어떤 메서드들이라 할 수 있다.
참고: 정적할당
변수 선언을 통해 필요한 메모리를 확보하는 방법
참고: Stack Frame
스택 프레임은 메서드가 호출될 때마다 새로 생겨 스택에 push된다. 스택 프레임은 Local variables array, Operand stack, Frame Data를 갖는다. Frame Data는 Constant Pool, 이전 스택 프레임에 대한 정보, 현재 메서드가 속한 클래스/객체에 대한 참조 등의 정보를 갖는다. 쉽게 생각하면 바이트코드를 실행하기 위해 당연히 필요한 정보들이라 생각하면 좋다.
Native Method Stack
Native Method는 Java Bytecode가 아닌 다른 언어로 작성된 메서드를 의미한다. JVM이 성능 향상을 목적으로 C/C++로 작성된 언어로 코드를 컴파일해서 사용하는 경우가 있는데 그때 사용되는 Method이다.
Heap Area
Heap 영역 같은 경우에는 동적으로 할당된 메모리 영역이다. 프로그램을 실행하면서 생성한 모든 객체(오브젝트 타입의 데이터)를 Heap에 저장한다. 참고로 모든 객체는 오브젝트 타입을 상속받는다. Heap 영역에 있는 데이터 들은 대부분 오브젝트 크기는 크고 서로 다른 코드 블럭에서 참조하다 보니 계속 사용하기 때문에 상대적으로 생명주기가 길다고 할 수 있다. 그리고 이 Heap은 Stack처럼 Thread마다 하나씩 있는 것이 아니라 여러 개의 Thread가 있더라도 이 Heap 영역은 단 하나의 영역만 존재하기 때문에 절대 헷갈리면 안된다.
참고: 동적할당
프로그램 실행 도중, 필요한 메모리를 확보하는 방법
코드 예시
import java.util.ArrayList;
import java.util.List;
public class StackHeap {
public static void main(String[] args) {
int age = 32;
String name = "oneny";
List<String> skills = new ArrayList<>();
skills.add("java");
skills.add("js");
skills.add("c++");
test(skills);
for (String skill : skills) {
System.out.println(skill);
}
}
public static void test(List<String> list) {
String mySkill = list.get(0);
list.add("python");
}
}
위에서 계속 언급한 메모리가 실제로 Heap과 Stack에서 어떻게 쌓이는지를 쉽게 알 수 있도록 다음과 같은 코드를 작성했다.
가장 먼저 int age = 32; 코드가 실행되면 age에 32 int 원시 타입이 할당된다. 위에서 살펴본 것처럼 원시 타입이기 때문에 Stack에 값과 같이 올라가게 된다. 그리고 그 다음 줄의 String name = "oneny";가 실행되면 name 변수만 Stack에 쌓이게 되고, Heap 영역에 있는 실제 String 값을 참조하게 되어있다.
그 다음 줄인 skills 라는 List를 ArrayList로 하나 생성했는데 위 과정과 마찬가지로 Stack에는 skills라는 변수만 올라가고 빈 List가 Heap 영역에 올라가게 된다. 그리고 skills에 add 메서드를 사용하여 java, js, c++ 값을 추가를 하면 리스트 안에 딱 값이 들어가는 것이 아니라 위 그림처럼 Heap 영역의 메모리로 만들어져 java, c++, js들이 List의 인덱스에 참조한다.
그 다음 test 메서드를 실행하는데 test메서드에 대해 살펴보자면 메서드 내부가 파라미터로 List를 하나 받고, 안에서는 mySkill에는 인덱스가 0번에 있는 데이터를 가져오고, 해당 List에 python이라는 값을 추가한다. 그 결과로 위 그림처럼 Stack과 Heap 영역에 메모리가 생성된 것을 확인할 수 있다.
이를 좀 더 자세히 들여보다보면, test 메서드에 파라미터로 들어온 list도 Stack 영역에 쌓이게 되고 skills를 인자로 넘겨줬기 때문에 skills가 가리키고 있는 Heap 영역의 List를 참조한다. 그리고 mySkill도 마찬가지로 변수가 Stack에 올라가고 String이기 때문에 당연히 Heap 영역에 있는 것을 참조해줘야 하는데 list.get(0)이 참조하고 있는 것을 참조해서 "java" 값을 가리키게 된다. 그리고 add 메서드를 실행하면 3번 인덱스는 "python"이란 값을 가리키고 있는 것을 확인할 수 있다.
그러면 test 메서드가 끝나면 어떻게 될까? 메서드가 끝나면 Stack 영역에 있던 변수들은 다 사용한 것이기 때문에 위 그림처럼 날려보내고(pop) 메모리에 남아있는 것을 확인할 수 있다.
코드 예시 2
public class Main {
public static void main(String[] args) {
String name = "oneny";
System.out.println("Before Name : " + name);
changeName(name);
System.out.println("After Name 1 : " + name);
name += " babo";
System.out.println("After Name 2 : " + name);
}
private static void changeName(String s) {
s += " babo";
}
}
위 코드를 보면 두 번째 출력할 때의 name은 "oneny babo"가 출력되고, 세 번째에는 "oneny babo babo"가 출력되어야 할 것 같다고 생각할 수 있다. 하지만 실행 결과를 보면 "oneny", "oneny babo"가 출력되는 것을 확인할 수 있다. 왜 이런 결과가 나오는지 코드 한줄 한줄 살펴보면서 알아가보자.
String name = "oneny";가 실행되면 위에서 살펴봤듯이 Stack 영역에는 name 변수가, Heap 영역에는 "oneny"라는 값이 메모리에 만들어지고, name은 Heap 영역의 "oneny"라는 값을 가리키게 된다. 그래서 다음 줄에서 name은 당연히 "oneny"가 출력되는 것을 확인할 수 있다.
그 다음 changeName 메서드가 실행되면 s라는 변수가 Stack 영역에 올라가게 되는데 위 원리대로라면 name이 가리키고 있는 "oneny"라는 값을 가리켜야 한다. 하지만 그렇지 않고 위 그림처럼 "oneny"이라는 값을 Heap 영역에서 딱 복사를 해버린다! 그리고 s라는 변수는 이 복사된 값을 가리킨다. 왜 이전과 달리 복사한 값을 가리키는 것일까?
바로 String이 immutable한 클래스이기 때문이다. immutable은 말 그대로 '변경할 수 없는', '불변의'를 의미하는 String은 이런 불변의 객체이기 때문이다. String 이외에도 immutable한 클래스는 Boolean, Integer, Float, Long, Double 같은 클래스 즉, Wrapper 클래스들은 이렇게 immutable한 성격을 가지고 있다.
반대로, 위에서 살펴본 List와 같은 클래스는 mutable한 객체이다. 이외에도 ArrayList, HashMap 등의 컬렉션들이 대표적인 mutable한 객체이다.
하지만 여기서도 조심해야 할 것이 있다. s += " babo"; 를 실행시키면 += 연산자는 그냥 그대로 갖다 붙이는 것이 아닌 새롭게 생성하기 때문에 복사된 값에 " babo"가 그대로 붙는 것이 아니다. 그래서 위 그림처럼 s 변수가 가리키는 값이 "oneny"에서 "oneny babo"로 바뀌게 된다.
그리고 메서드가 끝나면 s는 pop이 되고, name += " babo"가 실행되는데 name 변수가 가리키는 값은 s가 가리키는 "oneny babo"가 아닌 새로운 "oneny babo"라는 값을 생성하여 가리키게 된다. 그리고 나머지 연결이 끊긴 세 값 즉, Garbage들은 GC(Garbage Collector)가 청소를 해준다.
참고: GC(Garbage Collector)
GC는 프로그램에서 더 이상 사용되지 않는 객체를 분별해낸다. 어떤 객체에 유효한 참조가 있다면 'reachable', 없으면 'unreachable'로 구별한다. 그리고 참조가 없는 unreachable object에 대해서 Heap 메모리에서 정리한다.
mutable한 String 객체
public class Main {
public static void main(String[] args) {
StringBuilder sb = new StringBuilder("oneny");
System.out.println("sb = " + sb);
changeName(sb);
System.out.println("sb = " + sb);
sb.append(" babo");
System.out.println("sb = " + sb);
}
private static void changeName(StringBuilder sb) {
sb.append(" babo");
}
}
그러면 문자열을 mutable하게 쓸 수 없는 것일까? mutable하게 쓰고 싶은 경우에는 StringBuilder를 사용하면 된다. StringBuilder의 append 메서드를 이용해서 값을 더해주면 같은 데이터에 그대로 참조하기 때문에 위 코드를 실행시키면 결과가 babo가 두 번 나오는 것을 확인할 수 있다.
출처
[10분 테코톡] 무민의 JVM Stack & Heap
Java 알려주는 은행원 - Stack & Heap 이해하기
[메모리] JVM의 메모리 사용 방식: JVM의 Static Area와 Heap Area를 중심으로 Kotlin/JVM의 메모리 사용방식을 이해하기
'Java > Java' 카테고리의 다른 글
Java의 hashCode, equals와 hashCode 같이 써야하는 이유 (0) | 2023.07.10 |
---|---|
String Literal vs new String (0) | 2023.07.09 |
정적 팩토리 메서드, 인스턴스 캐싱 (0) | 2023.04.30 |
함수형 인터페이스와 람다식 + 전략패턴 (0) | 2023.04.19 |
익명 클래스, 인터페이스 익명 구현 객체 (0) | 2023.04.14 |