들어가면서
C, C++처럼 메모리를 직접 관리해야 하는 언어를 쓰다가 자바처럼 가비지 컬렉터를 갖춘 언어로 넘어오면 프로그래머의 삶이 훨씬 평안해진다. 다 쓴 객체를 알아서 회수해가니 말이다. 처음 경험할 때는 마법을 보는 듯했다. 그래서 자칫 메모리 관리에 더 이상 신경 쓰지 않아도 된다고 오해할 수 있는데, 절대 사실이 아니다.
- 이펙티브 자바 아이템7: 다 쓴 객체 참조를 해제하라
객체 참조
자바에서 객체는
new
키워드를 사용해 생성된다. 객체 생성 시, JVM은 힙(heap) 영역에 해당 객체의 메모리를 할당한다. 그리고 객체를 가리키는 참조(reference)는 스택(stack) 영역의 변수에 저장된다.
public class Person {
String name;
public Person(String name) {
this.name = name;
}
public static void main(String[] args) {
// 객체 생성: 힙에 메모리 할당
Person person1 = new Person("Alice");
// person1 변수는 힙에 할당된 Person 객체를 참조(가리킴)
System.out.println(person1.name); // "Alice" 출력
}
}
여기서 new Person("Alice")
를 호출하면, JVM은 힙에 Person
객체를 위한 메모리를 할당하고, 그 주소값(참조값)을 person1
변수에 저장한다. 이처럼 person1
은 객체에 대한 참조를 유지하게 된다.
메모리 할당
자바에서 객체를 생성하는 과정은 다음과 같이 진행된다. 먼저, 개발자가 코드 내에서 new
키워드를 사용하여 객체 생성 요청을 하면, JVM은 해당 요청을 받아들여 지정된 클래스의 인스턴스를 만들기 위한 절차를 시작한다.
이때, JVM은 먼저 힙(heap) 영역에서 해당 객체를 저장할 수 있을 만큼의 충분한 크기의 메모리 블록을 검색하고 할당한다. 할당된 메모리 공간은 객체가 가질 멤버 변수나 메서드의 정보, 그리고 객체 상태를 저장할 수 있도록 구조화된다.
이후, 생성자(Constructor)가 호출되어 객체의 필드들이 적절한 초기값으로 설정되며, 객체가 올바른 초기 상태를 갖도록 초기화 작업이 수행된다. 마지막으로, 초기화가 완료된 객체의 메모리 주소(참조값)가 변수에 할당되어, 해당 변수는 스택(stack) 영역에 위치하면서 힙에 생성된 객체를 가리키게 된다.
메모리 해제
자바에서는 C나 C++에서처럼 프로그래머가 직접 free()
나 delete
와 같은 함수를 호출하여 메모리를 해제하지 않는다. 대신, 자바의 JVM은 **가비지 컬렉터(Garbage Collector, GC)**를 통해 메모리 관리를 자동으로 수행한다.
public class Main {
public static void main(String[] args) {
Person person1 = new Person("Alice"); // 객체 생성 및 참조
person1 = null; // person1 참조를 null로 설정하여 객체와의 연결을 끊음
// 이제 "Alice" 객체는 다른 참조가 없으므로 가비지 컬렉션 대상이 됨.
System.gc(); // GC를 호출할 수 있으나, 실제 해제 시점은 JVM에 따라 결정됨.
}
}
프로그램 실행 중 객체에 대한 참조가 더 이상 존재하지 않는 경우, 즉 해당 객체를 가리키는 변수가 없거나 모두 다른 객체로 바뀌어 “도달 불가능(unreachable)” 상태가 되면, 그 객체는 가비지 컬렉션의 대상이 된다. 예를 들어, 변수 person1
이 특정 객체를 참조하고 있을 때, person1 = null;
과 같이 참조를 제거하면, 그 객체는 프로그램 내에서 더 이상 접근할 수 없게 되어 GC에 의해 회수될 수 있는 상태가 된다.
이후, JVM의 가비지 컬렉터는 주기적으로 힙 영역을 검사하며 도달 불가능한 객체들을 찾아내고, 해당 객체들이 점유하고 있던 메모리 공간을 자동으로 해제한다. 단, 가비지 컬렉션이 언제 실행될지, 그리고 특정 객체가 정확히 언제 메모리에서 제거될지는 JVM 내부의 스케줄링과 최적화 로직에 따라 결정되므로, 개발자가 이를 직접 통제할 수는 없다.
GC에 관한 더 자세한 내용은 조만간 정리를 해서 포스팅을 할 예정이다.(아직 없다. 시간이 오래 걸릴듯 하다. 한 2주?)
어떤 상황에 메모리 누수가 일어나는가?
메모리 누수가 일어날 수 있는 경우는 총 3가지가 있는데, 모두 공통적인 특징이 있다. 객체를 쌓아둘 수 있는 공간이 있는 경우이다. 각각의 경우를 알아보고, 객체 참조를 해제하는 방법을 알아보자.
클래스가 자신의 메모리를 직접 관리하는 경우
첫번째는 클래스가 자신의 메모리를 직접 관리하는 경우이다. 아래 코드를 보자.
public class Stack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(Object e) {
ensureCapacity();
elements[size++] = e;
}
public Object pop() {
if (size == 0)
throw new EmptyStackException();
return elements[--size];
}
/**
* 원소를 위한 공간을 적어도 하나 이상 확보한다.
* 배열 크기를 늘려야 할 때마다 대략 두 배씩 늘린다.
*/
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size + 1);
}
public static void main(String[] args) {
Stack stack = new Stack();
for (String arg : args)
stack.push(arg);
while (true)
System.err.println(stack.pop());
}
}
위 코드에서 push()
는 용량을 확인하고, element
객체배열에 하나씩 추가를 하니 문제가 없다. 하지만 pop()
은 기능적으로는 동작을 잘 하지만, 객체를 반환하기만 하고 객체 배열에서 사라지지 않는다. 그래서 아무리 pop()
을 많이 하더라도, 결국 이 배열은 꽉 차 메모리 누수가 일어날 것이다.
그래서 이런 경우에는 참조를 해제해 주어야 한다.
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // 다 쓴 참조 해제
return result;
}
다음과 같이 짜게 되면, 다 쓴 참조를 null로 해제를 하게 되면서, 메모리를 직접 정리가 된다.
캐시
public class CacheKey {
private Integer value;
private LocalDateTime created;
public CacheKey(Integer value) {
this.value = value;
this.created = LocalDateTime.now();
}
@Override
public boolean equals(Object o) {
return this.value.equals(o);
}
@Override
public int hashCode() {
return this.value.hashCode();
}
public LocalDateTime getCreated() {
return created;
}
@Override
public String toString() {
return "CacheKey{" +
"value=" + value +
", created=" + created +
'}';
}
}
이 클래스는 캐시의 키로 사용되는 객체로, 정수형 값과 생성 시간을 가지고 있다. 이 클래스는 equals()
와 hashCode()
메서드를 오버라이딩하여 값 기반의 비교를 가능하게 한다. equals()
메서드는 단순히 value 값을 비교하므로, 서로 다른 생성 시간을 가진 두 CacheKey
객체도 동일한 value 값을 가지면 같은 객체로 간주된다.
public class PostRepository {
private Map<CacheKey, Post> cache;
public PostRepository() {
this.cache = new HashMap<>();
}
public Post getPostById(CacheKey key) {
if (cache.containsKey(key)) {
return cache.get(key);
} else {
// TODO DB에서 읽어오거나 REST API를 통해 읽어올 수 있습니다.
Post post = new Post();
cache.put(key, post);
return post;
}
}
public Map<CacheKey, Post> getCache() {
return cache;
}
}
이 클래스는 Post 객체를 캐싱하기 위한 저장소로, HashMap을 사용하여 CacheKey
를 기반으로 Post
객체를 저장하고 검색한다. getPostById()
메서드는 주어진 키에 해당하는 Post
가 캐시에 있으면 즉시 반환하고, 없다면 새 Post
객체를 생성하여 캐시에 저장한 후 반환한다.
class PostRepositoryTest {
@Test
void cache() throws InterruptedException {
PostRepository postRepository = new PostRepository();
CacheKey key1 = new CacheKey(1);
Post postsById = postRepository.getPostById(key1);
assertFalse(postRepository.getCache().isEmpty());
key1 = null;
// TODO run gc
System.out.println("run gc");
System.gc();
System.out.println("wait");
Thread.sleep(3000L);
assertTrue(postRepository.getCache().isEmpty());
}
}
테스트 코드를 보면 postRepository.getPostById(key1);
를 통해 Post를 가지고 온다. 이후 PostRepository
에서는 cache
에 저장하게 된다. 이 상태로 테스트 코드를 작동시켜보자.
결과를 보면 테스트를 실패한다. 캐시가 비어있지 않다는 이야기이다.(
key1
을 null로 설정해도 그렇다.)
이 경우는 어떻게 해결하는 것이 좋을까?
자료구조를 HashMap
이 아닌 WeekHashMap
을 사용하면 된다.
public PostRepository() {
this.cache = new WeekHashMap<>();
}
WeekHashMap
WeekHashMap은 더 이상 사용되지 않는 객체가 가비지 컬렉션(GC) 시 자동으로 삭제되는 Map이다. 이 맵은 키가 강하게 참조되지 않으면 해당 엔트리를 자동으로 제거하며, 이때 사용되는 Reference는 Strong, Soft, Weak, Phantom 등 여러 종류가 있다. 이러한 특성 덕분에 맵의 엔트리를 값(Value)이 아닌 키(Key)에 의존하여 관리할 수 있으며, 캐시를 구현하는 데에도 활용할 수 있지만 직접 캐시를 구현하는 것은 권장되지 않는다.
특히 주의해야 하는 점이 있다. key 타입으로 Integer
나 String
같은 원시 타입 래퍼 클래스를 사용하면 안된다.
Integer
나 String
같은 원시 타입 래퍼 클래스의 경우, 특히 문자열 리터럴은 JVM의 문자열 상수 풀에 의해 intern 되어 강한 참조가 유지되고, autoboxing을 통해 생성된 Integer
의 경우 -128~127 범위의 캐시로 인해 강하게 참조될 가능성이 있기 때문에, 이들 키는 GC 대상이 되지 않는다.(레퍼런스 Strong)
Listener
아래 코드는 사용자가 채팅방에 참여할 때 발생할 수 있는 메모리 누수를 방지하기 위해, 등록된 리스너를 약한 참조(weak reference)로 관리하는 방식을 보여준다. 여기서 callback은 메시지가 도착했을 때 실행되는 메서드이고, listener는 이 callback을 구현한 객체이다.
public class User {
// callback 역할: 메시지를 받아 출력하는 메서드
public void receive(String message) {
System.out.println(message);
}
}
위의 User
클래스에서 receive
메서드는 메시지를 처리하는 콜백(callback)이다. 이 메서드는 특정 이벤트(예를 들어, 메시지 전송)가 발생하면 실행되며, 사용자가 메시지를 받을 수 있도록 도와준다.
public class ChatRoom {
/**
* listener들을 저장하는 리스트.
*/ private List<WeakReference<User>> users;
public ChatRoom() {
this.users = new ArrayList<>();
}
/**
* listener 등록: 클라이언트가 채팅방에 참여할 때 User 객체를 등록한다.
* @param user listener 역할
*/
public void addUser(User user) {
this.users.add(new WeakReference<>(user));
}
// 메시지 전송 시, 등록된 모든 listener의 callback(여기서는 receive 메서드)을 호출한다.
public void sendMessage(String message) {
users.forEach(wr -> Objects.requireNonNull(wr.get()).receive(message));
}
public List<WeakReference<User>> getUsers() {
return users;
}
}
위 코드를 보면, 채팅에 들어오는 유저는 있지만, 나가는 유저는 없다. 만약 유저가 들어와서 대화를 하다 사라지면 어떻게 될까? 리스트에 유저 객체가 남아 있지만, GC는 이걸 삭제하지 않는다. 메모리 누수가 나는 것이다. 하지만 WeakReference
를 사용했으니, 문제가 없을 것이다. 정말 없을까? 테스트를 해보면서 알아보자.
class ChatRoomTest {
@Test
void charRoom() throws InterruptedException {
ChatRoom chatRoom = new ChatRoom();
User user1 = new User();
User user2 = new User();
// 두 명의 listener(User 객체)를 채팅방에 등록한다.
chatRoom.addUser(user1);
chatRoom.addUser(user2);
// 메시지 전송 시, 두 listener의 receive(callback) 메서드가 호출된다.
chatRoom.sendMessage("hello");
user1 = null;
System.gc();
Thread.sleep(5000L);
// GC에 의해 user1이 수거되었으므로, 남은 listener는 user2 하나뿐이다.
List<WeakReference<User>> users = chatRoom.getUsers();
assertTrue(users.size() == 1);
}
}
다음 테스트는 2명의 유저를 등록하고, 메세지를 보낸 뒤 user
의 강한 참조를 없애준다. 이렇게 되면 User
를 WeakReference
에 감싼 뒤 리스트에 저장했었으니 생성된 객체는 소멸할 것이다. 그러면 “유저가 2명에서 1명으로 줄었으니 테스트는 통과하겠지?” 라고 생각하고 테스트를 돌려보면,
어 실패가 떴다. 왜 그럴까? 디버깅을 해보자.
user1을 null로 지정하였지만, users에서는 아직 2명 모두 존재하는 것으로 나온다. 하지만, 0번 유저를 보면 referent
가 null인 것을 볼 수 있다. 다시 말해, null값 처리가 된건 맞지만 리스트에서 직접 제거를 한번 더 해줘야 한다는 말이다.
정리
지금까지의 내용을 정리해보자. 객체 참조를 제대로 해제하지 않거나, 적절하지 않은 자료구조를 사용하면 메모리 누수와 같은 문제가 발생할 수 있다. 메모리 누수의 원인인 Stack, 캐시, 그리고 Listener를 예시와 함께 알아보았고, 메모리 누수를 해결하기 위해 어떻게 해야 하는지도 알아보았다.
Stack의 경우에는 pop() 메서드에서 사용이 끝난 참조를 null로 명시적으로 해제해야 메모리 누수를 방지할 수 있다. 캐시에서는 강한 참조를 유지하는 HashMap 대신, 약한 참조를 활용한 WeakHashMap을 사용함으로써 더 이상 사용되지 않는 객체를 자동으로 제거할 수 있다. 다만, 기본 자료형의 래퍼 클래스는 강한 참조로 남아 있을 수 있으므로 주의가 필요하다. 클라이언트가 콜백을 등록만 하고 명확히 해지하지 않는다면, 뭔가 조치해주지 않는 한 콜백은 계속 쌓여갈 것이다. 이럴 때 콜백을 약한 참조(weak reference)로 저장하면 가비지 컬렉터가 즉시 수거해간다.
알아야 할 배경지식이 많고, 쉽지 않은 장이었다. 아직 보완할 내용이 많아보이지만, 추후에 수정과 추가를 할 예정이다.(Reference, GC)
참조
- 그림으로 보는 자바 코드의 메모리 영역(스택 & 힙) - 인파
- 이펙티브 자바 7: 다 쓴 객체 참조를 해제하라
- 백기선의 이펙티브 자바(인프런)