SSARTEL-10th / JPTS_bookstudy

"개발자가 반드시 알아야 할 자바 성능 튜닝 이야기" 완전 정복
7 stars 0 forks source link

메모리 릭(Memory leak)과 GC #16

Open olrlobt opened 9 months ago

olrlobt commented 9 months ago

👍 문제

메모리 릭(Memory leak)은 무엇이고, 어떻게 대처하거나 예방할 수 있을까? GC와 관련지어서 설명해 주면 좋을 것 같다.

✈️ 선정 배경

메모리 릭에 관하여 의미는 알 것도 같지만, 설명해보라고 하면 말문이 막힐 것 같다. 이 참에 정리해보면 좋을 것 같아서 선정하게 되었다.

📺 관련 챕터 및 레퍼런스

Story.11 JSP와 서블릿,Spring에서 발생할 수 있는 여러 문제점 216p.

🐳 비고

늦어서 미안하당 ..

daminzzi commented 9 months ago

✏️ 책에서의 예시

개발하면서 중요한 일 중 하나는 메모리를 효율적으로 관리하는 일이다. 특히 많은 트래픽이 발생하는 상황에서 메모리 관리는 더욱 강조되어야 한다. 책에서는 스프링에서의 캐시를 예시로 들어 문제점을 이야기했는데, request 요청에 대한 처리에서 문자열 자체를 리턴하면 스프링은 해당 문자열에 해당하는 실제 뷰 객체를 찾는 매커니즘을 사용하며 이 때 동일한 문자열에 대하여 뷰 객체를 캐싱해둬 동일한 문자열이 반환됐을 때 더욱 빠르게 뷰 객체를 찾을 수 있도록 하고 있다. 하지만 매번 다른 문자열이 생성될 가능성이 높은 경우 문자열을 통한 뷰 객체 캐싱이 많은 수의 키 값으로 캐시 값이 생성될 여지가 있어 메모리 릭을 일으킬 수도 있다고 이야기하고 있다.

메모리 릭이란?

위의 책의 예시에서 메모리 릭이라는 단어가 언급되었다. 먼저 CS적 의미로 살펴볼 때, 메모리 릭은 컴퓨터 프로그램이 필요하지 않은 메모리를 계속해서 점유하고 있는 현상이다. 더 이상 불필요한 메모리가 해제되지 않으면서 메모리 할당을 잘못 관리할 때 발생하는데, 이러한 메모리의 낭비를 메모리가 줄줄 샌다는 메모리 릭(누수)라고 이야기할 수 있다.

Java의 메모리 릭

자바에서의 메모리 릭은 더 이상 사용되지 않는 객체들이 GC에 의해서 회수되지 않고 계속 누적되는 현상을 의미한다.

이 때 GC가 객체를 회수하지 않는 경우에 대해서 좀 더 자세히 이야기해보면 young field에서 Minor GC로 객체들을 없애지 못하면 old field가 점점 가득차게 되고, 이로 인해 Major GC(full GC)가 계속해서 일어나지만 정작 heap 메모리의 여유 공간은 늘어나지 않는 현상이 발생한다. 메모리 릭으로 인해 서버가 다운될 수도 있지만 계속해서 Major GC가 일어난다면 Major GC는 속도가 매우 느리고, 발생 순간에 자바 애플리케이션이 멈추기 때문에 성능과 안정성에 큰 영향을 주게 된다.

GC는 참조 변수에서 가리키지 않는 객체들을 소멸 대상으로 판단하는데, 다 쓴 객체에 대해서 참조를 해제하지 않는다면 GC 대상이 되지 않아 메모리 릭이 일어나게 되는 것이다.

GC가 되지 않는 루트 객체 참조

이러한 상황을 피하기 위해서 GC가 되지 않는 루트 참조 객체 유형에 대해서 먼저 알아보자.(혹시 왜 그런지 알고 싶다면 지난 주 이슈 를 참고하자.)

  1. Static 변수에 의한 객체 참조
    • static 변수는 GC의 대상이 되지 않는다. static 변수는 클래스가 생성될 때 메모리를 할당 받고 프로그램 종료 시점에 반환되므로 사용하지 않고 있어도 메모리가 할당되어 있다. 즉 사용하지 않는 객체를 static 변수에 계속 할당되기만 한다면 힙 영역에서 GC가 되지 않아 메모리 릭으로 이어지게 된다.
  2. 현재 자바 스레드 스택 내의 모든 지역 변수 및 매개 변수에 의한 객체 참조
    • 자바에서 현재 실행중인 모든 메소드 내에 선언된 지역 변수와 매개변수에 의해 직/간접적으로 참조되는 모든 객체는 사용될 가능성이 있다. caller 메소드가 여러 단계를 거치게 되면 모든 단계에서 참조되는 객체들은 참조되어 사용될 가능성이 있으므로 Stack을 관리하고 있는 Native Memory에 저장되어 있기 때문에 GC의 대상이 되지 않는다.
  3. JNI 프로그램에 의해 동적으로 만들어지고 제거되는 JNI global 객체 참조
    • JNI(Java Native Interface)는 자바 가상머신(JVM) 위에서 실행되고 있는 자바코드가 네이티브 응용 프로그램(하드웨어와 운영 체제 플랫폼에 종속된 프로그램들) 그리고 C/C++, 어셈블리 같은 다른 언어들로 작성된 라이브러리들을 호출하거나 반대로 호출되는 것을 가능하게 하는 프로그래밍 프레임워크이다.
    • 이러한 내용들은 Native Memory에서 관리하기 때문에 GC의 대상이 되지 않는다.

메모리 릭의 예제

위에서 GC의 대상이 되지 않는 객체에 의한 메모리 릭에 대해서 알아봤다. 하지만 위의 상황 외에도 메모리 릭이 발생하는 여러 패턴이 있다.

  1. Autoboxing

    • Integer, Long 같은 래퍼 클래스(Wrapper)를 이용하여, 무의미한 객체를 생성하는 경우
    public class Adder {
           publiclong 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);
                  }
           }
    }

    long 대신 Long을 사용함으로써 , 오토 박싱으로 인해 sum=sum+l;에서 매 반복마다 새 개체를 생성하므로 1000개의 불필요한 객체가 생성된다.

  2. Using Cache

    • 맵에 캐쉬 데이터를 선언하고 해제하지 않는 경우
    import java.util.HashMap;
    import java.util.Map;
    public class Cache {
           private Map<String,String> map= new HashMap<String,String>();
           publicvoid 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;
           }
           publicvoid 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();
           }
    }

    캐시(map)에 직원과 직종을 넣었지만, 캐시를 지우지 않았다. 객체가 더 이상 사용되지 않을 때도 Map에 강력한 참조가 있기 때문에 GC가 되지 않는다.

    캐시의 항목이 더 이상 필요하지 않을 때는 캐시를 지워주는 것이 바람직하다.

    또한 WeakHashMap으로 캐시를 초기화 할 수 있다. WeakHashMap의 장점은 키가 다른 객체에서 참조되지 않는 경우 해당 항목이 GC가 된다.

    하지만, 캐시에 저장된 값을 재사용하려면 해당 키가 다른 객체에 의해 참조되지 않을 수 있으므로 항목이 GC되고 해당 값이 사라질 수 있기 때문에 주의하여야 한다.

  3. Closing Connections

    • 스트림 객체를 사용하고 닫지 않는 경우
    try
    {
      Connection con = DriverManager.getConnection();
      …………………..
        con.close();
    }
    
    Catch(exception ex)
    {
    }

    try 블록에서 연결 리소스를 닫으므로 예외가 발생하는 경우 연결이 닫히지 않는다. 따라서 이 연결이 풀로 다시 돌아 오지 않기 때문에 메모리 누수가 발생한다. 또한 닫아지지 않아서 데드락이 발생할 가능 성이 크다.

    항상 finally 블록에 닫는 내용을 넣거나, TryWhitResource를 사용하자.

  4. Using CustiomKey

    • 맵의 키를 사용자 객체로 정의하면서 equals(), hashcode()를 재정의 하지 않아서 같은 키로 착각하여 데이터가 계속 쌓이게 되는 경우
    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);
           }
    }

    equals () 및 hashcode()를 재정의 해주지 않아서, 아래 main에서 get을 부를 때 map에 저장된 키와 value를 검색할 수 없다. 또한 map에서 참조를 하고 있지만 애플리케이션은 액세스를 할 수 없기 때문에 메모리 누수가 발생한다.

  5. Mutable Custiom Key

    • 맵의 키를 사용자 객체로 정의하면서 equals(), hashcode()를 재정의 하였지만, 키값이 불변(Immutable) 데이터가 아니라서 데이터 비교시 계속 변하게 되는 경우
    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;
        }
    
        public void 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;
            } else if (!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);
        }
    }

    4번에서의 경우와 다르게 equals와 hashcode를 만들었지만 setName을 통해서 key 속성이 변경되도록 만들었다. 속성이 변경되면 프로그램에선 찾을 수 없지만, Map에서는 참조가 있으므로 메모리 누수가 발생하게 된다.

  6. Internal Data Structure

    • 자료구조를 생성하여 사용하면서, 구현 오류로 인해 메모리를 해제하지 않는 경우
    public class Stack {
    
           private int maxSize;
           private int[] stackArray;
           private int pointer;
    
           public Stack(int s) {
                  maxSize = s;
                  stackArray = new int[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 = 0; i<1000; i++) {
                         stack.push(i);
                  }
    
                  for(int i = 0; i<1000; i++) {
                         int element = stack.pop();
                         System.out.println("Poped element is "+ element);
                  }
           }
    }

    Stack이 1000으로 커지면서 내부 배열인 stackArray도 값이 채워지게 된다. 그 후 Stack를 pop하면 Stack의 참조된 공간이 비어있지만, stackArray에는 pop된 모든 참조가 포함되게 된다. 자바에선 이를 쓸모없는 참조, 혹은 구식 참조(역 참조 할 수 없는 참조)라고 불린다. 배열에 해당 요소가 포함되어 있으면 GC가 될 수 없지만, pop된 후 에는 불필요하다.

    이 문제를 해결하려면 pop이 실행될 때, null 값을 설정하여 해당 객체가 GC가 되도록 해야 한다.

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

메모리 릭 대처에 대한 흥미로운 실제 사례

해당 자료를 검색하다가 우아한 기술 블로그의 글(https://techblog.woowahan.com/2628/) 을 보게 되었다.

해당 사례에서는 응답 지연이 발생한 상황에서 문제 해결을 위해 메모리 누수가 어디에서 발생했는지 추적하는 과정과 해결 방법에 대해서 이야기하고 있다. 문제가 발생한 class의 문제를 확실히 이해하기는 어려웠지만 그 추적 방법에 대해서 실제 사례를 통해 알아보면서 메모리 관리의 중요성을 다시 한 번 알 수 있었다. 또한 사례에서 발생한 문제의 경우 트래픽 증가에 따라 발생한 문제이기 때문에 이를 미리 예측하기가 어려웠다는 문제점이 있는데 이러한 문제를 성능 테스트를 통해서 예방할 수 있기 때문에 이를 꼭 생각하면서 개발을 해야 한다는 교훈을 얻을 수 있었다.

정리하며

메모리 릭은 발생 시 서버가 다운되며 서비스가 마비될 수 있는 문제를 일으킬 수 있다. 또한 단순히 에러 메세지만으로는 그 문제를 파악하기가 어려운 상황이 많기 때문에 jmap을 이용한 메모리 덤프 확인 및 visual VM을 활용한 분석을 통해 해당 문제에 대해서 빠르게 접근하고 해결할 수 있도록 하는 것이 중요하다고 생각한다.

참고자료

https://inpa.tistory.com/entry/JAVA-☕-가비지-컬렉션GC-동작-원리-알고리즘-💯-총정리

https://118k.tistory.com/608

https://junghyungil.tistory.com/133

https://techblog.woowahan.com/2628/

https://internet-craft.tistory.com/1

https://velog.io/@vrooming13/JNI-JAVA-Native-Interface

olrlobt commented 9 months ago

finally 블록과 try-with-resources는 자원을 닫을 때 사용되는 두 가지 주요 방법입니다. Java 7부터 도입된 try-with-resources는 자동 리소스 관리(Automatic Resource Management, ARM)를 위한 특징입니다. 두 방식 모두 장단점이 있지만, 권장되는 방식은 try-with-resources입니다.

try-with-resources의 장점:

간결성: 코드가 더 간결하고 명확해집니다. 불필요한 finally 블록 없이도 자원을 닫을 수 있습니다. 자동화: try 블록 내에서 선언된 모든 자원이 자동으로 닫힙니다. 따라서 개발자는 close() 메소드 호출을 실수로 빼먹을 위험이 없습니다. 예외 처리: try-with-resources는 자원 닫기 중에 발생하는 예외도 적절하게 처리합니다. 만약 try 블록과 close() 메소드 호출 모두에서 예외가 발생하면, try 블록의 예외가 기본적으로 던져지며 close()에서 발생한 예외는 suppressed 예외로 처리됩니다. finally 블록의 장점:

호환성: try-with-resources는 Java 7 이후에만 사용 가능합니다. 이전 버전의 Java에서 코드를 작성하거나 유지해야 하는 경우에는 finally 블록을 사용해야 합니다. 유연성: 특정 상황에서 자원을 닫는 방법을 더 세밀하게 제어해야 하는 경우에는 finally 블록이 더 유연할 수 있습니다. 결론:

대부분의 상황에서, try-with-resources를 사용하는 것이 더 안전하고 명확합니다. 코드가 간결하며 자원의 누수 위험이 줄어듭니다. 그러나 Java 7 이전 버전을 사용하거나 특정한 요구사항이 있는 경우에는 finally 블록을 사용할 수도 있습니다.