개발/Java

자바 메모리 누수

Debin 2023. 12. 9.
반응형

c언어는 free() 함수를 사용해 메모리를 해제한다.

 

자바는 GC가 더 이상 사용하지 않는 힙 영역의 인스턴스 메모리를 알아서 해제해준다.

그렇다면 GC가 메모리를 해제해주므로 자바에서 메모리 누수는 아예 발생하지 않을까??

 

결론부터 말하면 자바에서도 메모리 누수는 발생할 수 있다.

객체가 실제로 사용되지 않으면서 의도치 않게 참조를 가지고 있다면, 이는 메모리 누수의 원인이 된다.

자바에서 메모리 누수는 프로그래머의 실수로 발생하는 것이다.

 

자바 메모리 누수와 관련해서 자주 발생하는 패턴을 살펴보기 전에 먼저 GC 대상에 대한 기초를 잡자.

GC 대상

GC는 힙 내의 객체 중에서 가비지를 찾아내고, 찾아낸 가비지를 처리해서 힙의 메모리를 회수한다.

 

그럼 어떤 객체가 가비지일까??

 

Java GC는 객체가 가비지인지 판별하기 위해서 reachability라는 개념을 사용한다.

어떤 객체에 유효한 참조가 있으면 'reachable'로, 없으면 'unreachable'로 구별하고,

unreachable 객체가비지로 간주해 GC를 수행한다. 

 

한 객체는 여러 다른 객체를 참조하고, 참조된 다른 객체들도 마찬가지로 또 다른 객체들을 참조할 수 있으므로 객체들은 참조 사슬을 이룬다. 이런 상황에서 유효한 참조 여부를 파악하려면 항상 유효한 최초의 참조가 있어야 하는데 이를 객체 참조의 root set이라고 한다.

 

힙에 있는 객체들에 대한 참조는 다음 4가지다.

 

  • 힙 내의 다른 객체에 의한 참조
  • Java 스택, 즉 Java 메서드 실행 시에 사용하는 지역 변수와 파라미터들에 의한 참조
  • 네이티브 스택, 즉 JNI(Java Native Interface)에 의해 생성된 객체에 대한 참조
  • 메서드 정적 변수에 의한 참조
    • 자바 7까지는 메서드 정적 변수는 힙 객체가 아니므로 GC 되지 않음
    • 자바 8부터는 메서드 정적 변수도 힙에 저장되므로 GC의 대상

이들 중 힙 내의 다른 객체에 의한 참조를 제외한 나머지 3개가 root set으로, reachability를 판가름하는 기준이 된다.

 

결론은 유효한 참조가 없어야지 GC의 대상이 된다는 것이다.

 

 

위 그림에서 보듯, root set으로부터 시작한 참조 사슬에 속한 객체들은 reachable 객체이고, 이 참조 사슬과 무관한 객체들이 unreachable 객체로 GC 대상이다.

오른쪽 아래 객체처럼 reachable 객체를 참조하더라도, 다른 reachable 객체가 이 객체를 참조하지 않는다면 이 객체는 unreachable 객체이다.

 

이제 본격적으로 메모리 누수와 관련해서 주의해야할 패턴에 대해 알아보자.

AutoBoxing (래퍼 클래스의 잘못된 사용)

public class Adder {
       public long addIncremental(long l)
       {
              Long sum=0L;
              sum =sum+l;
              return sum;
       }
       public static void main(String[] args) {
              Adder adder = new Adder();
              for(long ;i<1000;i++)
              {
                     adder.addIncremental(i);
              }
       }
}

 

sum 변수로 Long(wrapper 클래스)를 사용한 것이 메모리 누수의 원인이 된다.

auto-boxing으로 인해 각 반복에서 sum = sum + i; 구문이 새로운 객체를 생성하게 되므로 1000개의 불필요한 객체가 생성된다.

최대한 기본 자료형을 사용하도록 하자.

 

캐시 사용

import java.util.HashMap;
import java.util.Map;
public class Cache {
       private Map<String,String> map= new HashMap<String,String>();
       public void initCache()
       {
              map.put("Anil", "Work as Engineer");
              map.put("Shamik", "Work as Java Engineer");
              map.put("Ram", "Work as Doctor");
       }
       public Map<String,String> getCache()
       {
              return map;
       }
       public void forEachDisplay()
       {
              for(String key : map.keySet())
              {
                String val = map.get(key);                 
                System.out.println(key + " :: "+ val);
              }
       }
       public static void main(String[] args) {            
              Cache cache = new Cache();
              cache.initCache();
              cache.forEachDisplay();
       }
}

 

캐시에서 값을 출력하고 나면 우리는 더 이상 캐시 내부에 데이터를 저장할 필요가 없다.

캐시 내부의 값을 지우는 것을 잊었으므로, 캐시 내부에 있는 데이터가 애플리케이션이 더 이상 필요하지 않아도 GC에서 회수되지 않는다.

캐시가 그 객체들에 대한 Strong Reference(우리가 일반적으로 사용하는 것)를 유지하기 때문이다. 

 

따라서 자체적으로 캐시를 사용할 때, 캐시에 있는 항목이 더 이상 필요하지 않으면 꼭 지워야 한다.

WeakHashMap으로 캐시를 초기화할 수 있다.

WeakHashMap은 키가 다른 객체에 의해 참조되지 않으면 해당 항목이 GC 대상이 된다.

 

커넥션 닫기

try
{
  Connection con = DriverManager.getConnection();
  …………………..
    con.close();
}

Catch(exception ex)
{
}

 

위 코드는 try 블록에서 커넥션 (비용이 많이 드는 자원)을 닫는다.

만약 예외가 발생하는 경우에는 커넥션이 닫히지 않는다.

이로 인해 커넥션이 커넥션 풀로 반환되지 않으므로 메모리 누수가 발생한다.

반드시 닫아야 할 자원은 finally 블록에 위치시켜야 한다.

 

대부분의 커넥션과 관련된 클래스는 AutoCloseable의 구현체이다.

따라서 try-with-resources 구문을 사용하는 것이 제일 깔끔하다.

 

Map 키 클래스에 hashCode()와 equals()를 재정의하지 않은 경우

import java.util.HashMap;
import java.util.Map;
public class CustomKey {
       public CustomKey(String name)
       {
              this.name=name;
       }
       private String name;
       public static void main(String[] args) {
              Map<CustomKey,String> map = new HashMap<CustomKey,String>();
              map.put(new CustomKey("Shamik"), "Shamik Mitra");
              String val = map.get(new CustomKey("Shamik"));
              System.out.println("Missing equals and hascode so value is not accessible from Map " + val);
       }
}

CustomKey 클래스에서 equals() 및 hashCode() 오버라이딩하지 않았으므로 map에 저장된 키와 값은 나중에 가져올 수 없다.

왜냐하면 map의 get() 메서드가 hashCode() 및 equals()를 확인하기 때문이다.

이런 방식은 map에 계속 데이터가 쌓여서 문제가 발생한다. 

따라서 사용자 정의 키를 만들 때 항상 equals() 및 hashCode() 오버라이딩해야 한다.

 

가변 커스텀 키

import java.util.HashMap;
import java.util.Map;
public class MutableCustomKey {
       public MutableCustomKey(String name)
       {
              this.name=name;
       }
       private String name;
       public String getName() {
              return name;
       }
       publicvoid setName(String name) {
              this.name = name;
       }
       @Override
       public int hashCode() {
              final int prime = 31;
              int result = 1;
              result = prime * result + ((name == null) ? 0 : name.hashCode());
              return result;
       }
       @Override
       public boolean equals(Object obj) {
              if (this == obj)
                     return true;
              if (obj == null)
                     return false;
              if (getClass() != obj.getClass())
                     return false;
              MutableCustomKey other = (MutableCustomKey) obj;
              if (name == null) {
                     if (other.name != null)
                           return  false;
              } elseif (!name.equals(other.name))
                     return false;
              return true;
       }
       public static void main(String[] args) {
              MutableCustomKey key = new MutableCustomKey("Shamik");             
              Map<MutableCustomKey,String> map = new HashMap<MutableCustomKey,String>();
              map.put(key, "Shamik Mitra");
              MutableCustomKey refKey = new MutableCustomKey("Shamik");
              String val = map.get(refKey);
              System.out.println("Value Found " + val);
              key.setName("Bubun");
              String val1 = map.get(refKey);
              System.out.println("Due to MutableKey value not found " + val1);
       }
}

 

위 코드는 사용자 정의 키에 대해 equals()와 hashCode()를 오버라이딩했지만, 이를 맵에 저장한 후에  변경 가능하게 만들었다.

만약 인스턴스 변수가 변경된다면, 해당 entry는 애플리케이션에 의해 더 이상 찾을 수 없게 된다.

그러나 맵은 여전히 해당 entry에 대한 참조를 가지고 있으므로 메모리 누수가 발생한다.

언제나 사용자 정의 키를 불변(immutable)하게 만드는 것이 좋다.

내부 자료 구조

public class Stack {
       private int maxSize;
       private int[] stackArray;
       private int pointer;
       public Stack(int s) {
              maxSize = s;
              stackArray = newint[maxSize];
              pointer = -1;
       }
       public void push(int j) {
              stackArray[++pointer] = j;
       }
       public int pop() {
              return stackArray[pointer--];
       }
       public int peek() {
              return stackArray[pointer];
       }
       public boolean isEmpty() {
              return (pointer == -1);
       }
       public boolean isFull() {
              return (pointer == maxSize - 1);
       }
       public static void main(String[] args) {
              Stack stack = new Stack(1000);
              for(int ;i<1000;i++)
              {
                     stack.push(i);
              }
              for(int ;i<1000;i++)
              {
                     int element = stack.pop();
                     System.out.println("Poped element is "+ element);
              }
       }
}

 

스택은 내부적으로 배열을 유지하지만 프로그램 관점에서 스택의 활성 부분은 포인터가 가리키는 곳이다.

 

스택이 1000까지 확장될 때 내부적으로 배열이 값으로 채워지지만, 그 이후에 모든 요소를 pop()하면 포인터가 0으로 돌아오므로 스택은 비었다고 생각할 수 있다.

그러나 배열은 모든 pop()을 통해 리턴된 객체의 참조를 가지고 있다.

 

자바에서는 이를 "오래된 참조(obsolete reference)"라고 부른다.오래된 참조는 역참조될 수 없는 참조다,

 

이 참조는 배열이 해당 요소를 보유하기 때문에 GC(Garbage Collection)가 수행되지 않는다.

그러나 이들은 pop() 메서드가 실행된 이후에는 불필요하다.

 

이를 해결하려면 pop()이 발생할 때 null 값을 넣어서 객체가 GC될 수 있도록 해야 한다.

public int pop() {
    int size = pointer--;
    int element = stackArray[size];
    stackArray[size] = null;
    return element;
}

 

 

사실 null 값을 넣어서 객체 참조를 처리하는 일은 예외적인 경우여야 한다고 한다.

다 쓴 참조를 해제하는 가장 좋은 방법은 그 참조를 담은 변수를 유효 범위 밖으로 밀어내는 것이다.

즉 변수의 범위를 잘 정해주는 것이다.

 

개인적으로 메모리 누수를 막기 위해서는 적절한 변수 스코프 설정이 제일 중요하다고 생각한다.

 

리스너 혹은 콜백

클라이언트가 콜백을 등록만 하고 명확히 해지하지 않는다면, 뭔가 조치해주지 않는 한 콜백은 계속 쌓여갈 것이다.

이럴 때 콜백을 약한 참조로 저장하면 가비지 컬렉터가 즉시 수거해간다. 

예를들어 WeakHashMap에 저장하면 된다.

 

 

참고자료

https://dzone.com/articles/memory-leak-andjava-code

https://d2.naver.com/helloworld/329631

이펙티브 자바

반응형

댓글