
필자는 C언어와 Java의 차이점으로 들었던 부분 중 하나가
C언어는 메모리 할당 후 free()를 통해 메모리 해제를 진행해야 하지만,
Java는 Garbage Collector가 직접 불필요한 메모리를 알아서 정리 해준다는 것이였다.
이번에 Back-end 파트를 공부하면서 Java를 사용하게 됐는데, 이참에 전부터 궁금했던 이 Garbage Collection에 대한 원리를 알아보고자 한다.
먼저 이번 포스팅에서는 Java에서 Stack과 Heap영역이 어떻게 이용되는지 알아보자.
메서드가 호출되기 직전 스택 프레임(Stack Frame)이 Stack에 할당(push)되고, 메서드가 호출된다. 메서드의 호출이 끝나면 자동으로 해당 Stack Frame은 제거(pop)된다.
(Stack Frame에는 매개 변수, 지역 변수, Return address와 Return value가 포함된다.)
Stack 영역의 top에 존재하는 하나의 Stack Frame만 활성화되며, 그 이전에 존재하는 Stack Frame은 모두 비활성화되며, 비활성화된 Stack Frame의 지역 변수에는 접근이 불가능하다.
Primitive Type(원시 타입)의 데이터가 값과 함께 할당된다.
Heap 영역에 생성된 Object 타입 데이터의 참조값이 할당된다.
각 Thread는 자신만의 Stack영역을 가진다.
Stack에는 Primitive Type(원시 타입 - byte, short, int, long, double, float, char, boolean)의 데이터들이 할당되는데, 여기서 참조값을 저장하는 것이 아닌 실제 값을 Stack에 저장한다.
또한, Stack 메모리는 Thread가 새롭게 생성되는 순간 해당 Thread의 Stack도 함께 생성되며, 각 Thread에서 다른 Thread의 Stack 영역으로 접근할 수 없다.
다음 코드를 통해 Java에서 Stack 영역이 어떻게 활용되는지 알아보자.
class Main {
public static void main(String[] args) {
int num = 7;
num = addInteger(num);
}
private static int addInteger(int param) {
int sum = param + 7;
int result = sum + 7;
return result;
}
먼저 main 메소드를 살펴보면,
int num = 7;
에 의해 Stack에 변수명 num이라는 공간이 할당되고, 여기서 num의 타입은 원시 타입(int)로 선언되었으므로 실제 값인 7이 할당된다. 이에 대한 Stack의 상태는 다음과 같다.

이후,
num = addInteger(num);
에 의해 addInteger 메소드가 호출되며, 이에 해당하는 Stack Frame이 생성된다.
이때, addInteger의 파라미터로 num의 값을 넘겨주며 addInteger의 파라미터인 param에 num값인 7이 저장된다. 또한 param의 타입은 원시 타입(int)으로, Stack에 값이 할당된다.
마지막으로, 현재 Stack의 top은 addInteger의 Stack Frame이므로 기존 Stack Frame의 지역 변수(num)에는 접근할 수 없다. 이에 대한 Stack의 상태는 다음과 같다.

다음으로,
int sum = param + 7;
int result = sum + 7;
에 의해 Stack에 값이 할당(push)된다.

이후,
private static int addInteger(int param) {
int sum = param + 7;
int result = sum + 7;
return result;
}
num = addInteger(num);
addInteger 메소드의 Stack Frame에는 Return value가 21로 저장될 것이며, 따라서 21이 num에 재할당되고, addInteger에 사용되었던 모든 지역변수들이 제거(pop)될 것이다.

그렇게 main 메소드도 종료되는 순간 Stack에 있던 모든 데이터들은 제거(pop)되며, 프로그램은 종료된다.
애플리케이션의 모든 메모리 중 Stack에 있는 데이터를 제외한 부분이라고 볼 수 있다.
Heap 메모리 영역은 단 하나만 존재한다.
(실행 중인 스레드 수에 관계없이 Heap 영역에 존재하는 메모리는 공유된다)
모든 Object 타입(Integer, String, ArrayList 등)은 Heap 영역에 생성된다.
실제 객체가 저장되는 공간이다. Heap 영역에 존재하는 객체들은 Stack 영역의 변수들에 의해 참조된다.
다음 코드를 통해 Java에서 Heap 영역이 어떻게 활용되는지 알아보자.
class Main {
public static void main(String[] args) {
String name = "platinouss";
}
}
main 메소드를 살펴보면,
String은 Object를 상속받아 구현되었으므로 Heap 영역에 할당되고, Stack에 name이라는 변수는 Heap에 있는 "platinouss" 문자열을 참조하게 된다.

다음 예시를 통해 결과가 어떻게 예측될지 생각해보자.
public class Main {
public static void main(String[] args) {
String s = "Hello ";
addString(s);
System.out.println(s);
}
public static void addString(String name) {
name += "platinouss!";
}
}
위에서 소개한대로라면,
String s = "Hello ";
라는 부분에서 문자열 "Hello"는 Heap 영역에 할당되고, "Hello "를 가르키는 참조변수 s는 Stack에 할당될 것이다.
또한,
addString(s)
에 의해 name이라는 레퍼런스 변수가 Stack에 할당되고, 이 name이라는 변수는 main메소드의 s변수와 같은 값(Heap 영역에 생성된 "Hello " 문자열을 가진 인스턴스 주소 값)을 가르키고 있을 것이다.
그리고 addString( ) 메서드 내부인,
name += "platinouss!";
위의 연산을 처리하고, addString( ) 메서드는 종료될 것이다.
여기서 의문점은 "Hello " 문자열이 포함된 s라는 객체에 "platinouss!" 문자열이 추가되었을지이다.
힌트는 String은 불변 객체라는 것이다.
먼저 불변 객체가 어떤 의미를 가지는지 알아보자.
위의 질문에 대한 답변을 하기전에, 불변 객체란 어떤 것을 의미하는지 알아보자.
불변(Immutable) 객체란 생성 후, 그 객체의 상태를 바꿀 수 없는 객체를 의미한다.
즉, 객체가 할당이 되고 나면 이후에는 내부 데이터를 변경할 수 없다는 뜻이다.
실제로 Java에서 구현된 String 클래스의 일부분을 살펴보면 다음과 같다.
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence {
@Stable
private final byte[] value;
private final byte coder;
...
public String() {
this.value = "".value;
this.coder = "".coder;
}
...
}
위에서 변수 value는 final로 선언되어, 수정할 수 없게된다.
따라서 자료형이 String인 객체는 값을 수정할 수 없고, 수정이 필요한 경우 기존에 사용된 인스턴스가 아닌 새로운 인스턴스가 생성되고 수정된 값으로 적용되는 과정을 거친다.
또한, String 뿐만 아니라 Java에서 말하는 Wrapper class에 해당하는 Integer, Character, Byte, Boolean, Long, Double, Float, Short 클래스 모두 불변 객체이다.
그럼 불변 객체를 사용하는 이유는 뭘까? String을 예시로 들어보겠다.
String Pool이란 문자열이 JVM에 저장되는 특수한 메모리 영역이다.
String Pool에 String Literal을 캐싱하고 공유하여 재사용함으로써, 할당된 메모리를 최적화하는 역할을 가진다.
String s1 = "Hello World";
String s2 = "Hello World";
assertThat(s1 == s2).isTrue();

위의 예시에서 String Pool에 참조하려는 문자열("Hello World")이 이미 존재하므로, 새로 생성하지 않고 s1과 s2는 String pool의 동일한 위치를 가르켜, 메모리 리소스가 절약되고 새로 생성하는 동작이 줄어들기 때문에 성능이 향상되는 결과를 가져온다.
String은 user name과 password, URL주소 등을 표현하기 위해 사용되기도 하며, JVM 클래스 로더 등에서도 사용된다.
따라서 String 클래스는 보안이라는 부분에서 중요하다는 것을 알 수 있는데, 다음 예시를 통해 String이 불변 객체로 보안에 유리한 점을 알아보자.
void criticalMethod(String userName) {
// perform security checks
if (!isAlphaNumeric(userName)) {
throw new SecurityException();
}
// do some secondary tasks
initializeDatabase();
// critical task
connection.executeUpdate("UPDATE Customers SET Status = 'Active' " +
" WHERE UserName = '" + userName + "'");
}
위에서 신뢰할 수 없는 파라미터를 받아 userName이라는 변수에 할당하고, 무결성 검사인 isAlphaNumberic( ) 메서드를 통해 문자열이 영숫자인지 확인한다.
만약 여기서 String이 불변 객체가 아니라면,
connection.executeUpdate("UPDATE Customers SET Status = 'Active' " +
" WHERE UserName = '" + userName + "'");
위의 코드를 통해 update를 진행할 때, 메서드를 호출한 클라이언트가 userName의 레퍼런스 값을 가지고 있기 때문에 무결성 검사를 진행 후 문자열을 변경해버리면 userName에 할당된 문자열이 영숫자인지 확신할 수 없게되어 결국 SQL Injection 공격에 취약해진다.
불변 객체에서 값이 변경될 경우 수정된 문자열이 String Pool에 새로 생성되기 때문에, 여러 스레드에서 특정 불변 객체에 액세스 할 때 변경될 염려가 없으므로 Thread safe하다.
HashSet이나 HashMap같은 Hash 구현에서 HashCode( ) 메서드는 자주 호출된다.
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
hash = h = isLatin1() ? StringLatin1.hashCode(value)
: StringUTF16.hashCode(value);
}
return h;
}
위의 코드는 String 클래스에서 선언된 hashCode( ) 메서드이다.
위에서 보면 알 수 있듯이 hashCode( ) 메서드는 캐싱을 용이하게 하기 위해, 처음에만 hashCode( ) 호출 시 해시가 계산되고, 그 이후로 동일한 값이 반환되도록 오버라이딩 되어있다.
따라서 String이 값이 변하지 않는 불변이라는 점을 이용하여, 해시 값을 캐싱하여 사용할 수 있고, 이후에 캐싱된 해시값을 사용하여, 해시를 구현하는 Collection의 성능을 향상시킨다.
https://ttl-blog.tistory.com/m/368
https://yaboong.github.io/java/2018/05/26/java-memory-management/
https://www.baeldung.com/java-string-immutable