[Java/Android] Memory Leak을 발생시키는 기본 유형

mhyun, Park·2022년 4월 15일
1

OverView

Android 개발을 하다보면, 이따금씩 Memory Leak 위험에 노출되는 경우가 있다.
필자의 경우도 몇번 마주하고 분석, 개선을 하긴 했지만 장황한 코드에서 정확한 지점을 찾는것은 여간 쉬운 일이 아니다.
이번 포스팅에선 Memory Leak 유형이 크게 어떤 것이 있고 다음 포스팅에선 Android Studio의 Memory Profiler 사용 법을 간단히 알아보려 한다.

Memory Leak 유발하는 코드를 작성하기 전에 Java GC (Garbage Collector)가 대략적으로 어떻게 동작하는지 알 필요가 있는데 기본적으로 Java에서 일반적으로 객체 생성을 하게 되면 Strong Reference 이며, 해당 객체를 한 곳이라도 참조하고 있는 객체가 있을 경우 GC의 정리 대상에서 벗어나게 된다.

즉, Memory Leak은 할당된 Memory를 사용한 다음 반환하지 않을 때 발생하고 누적되면 Memory Overflow를 발생한다.

Memory Leak 유형

1. Auto Boxing

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);
            }
       }
}

위의 예제 코드를 보면, addIncremental 파라미터에 primitive type long을 넘겨주고 있지만
실제 합계를 계산하는 logic안에선 Auto Boxing을 통해 wrapper class인 Long을 생성해 이용한다.
이는, 1000번의 반복마다 불필요한 객체를 생성하고 있기에 코드 로직이 복잡해질 경우 불필요하게 메모리 누수되는 부분이 눈으로 찾기 어렵게 된다. 즉, 가능한 primitive type을 이용하자.

2. Using Cache

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

해당 예제를 보면, Cache class에서 선언 된 전역 변수 map에 데이터를 저장하고 이를 출력하고 있다.
해당 예제에서 forEachDisplay 후에 사용하지 않는다고 가정할 때, map의 element들을 clear하지 않았기에 Cache 객체가 더이상 Application에 필요하지 않지만, map이 안에 put된 객체에 대한 강한 참조를 보유하고 있으므로 GC할 수 없는 문제가 발생한다.
따라서, Cache 항목이 더 이상 필요하지 않는 경우, WeakHashMap을 사용할 것이 아니면 clear 하도록 하자

public class WeakHashMapTest {
    public static void main(String[] args) {
        WeakHashMap<Integer, String> map = new WeakHashMap<>();
        
        Integer key1 = 1000;
        Integer key2 = 2000;
        
        map.put(key1, "test a");
        map.put(key2, "test b");
        
        key1 = null;
        
        System.gc();
        
        map.entrySet().stream().forEach(entry -> System.out.println(entry));
    }
}

> 2000=test b

3. Improper equals() and hashCode() Implementations

public class Fruit {
    public String name;
    
    public Fruit(String name) {
        this.name = name;
    }
}


@Test
public void givenMap_whenEqualsAndHashCodeNotOverridden_thenMemoryLeak() {
    Map<Fruit, Integer> map = new HashMap<>();
    
    for(int i=0; i<100; i++) {
        map.put(new Fruit("apple"), 1);
    }
    
    Assert.assertFalse(map.size() == 1);
}

해당 예제는 매번 apple이라는 Fruit 객체를 HashMap에 삽입하고 있다.
이럴 경우, new Person을 통해 객체가 생성되면 해당 객체에 대한 hashcode는 각기 다르게 되고 HashMap에 다른 키로 삽입되어 Map의 size는 100이 된다.
의도한 것이 아니라면, HashMap에서 이용되어지는 객체는 equals()와 hashCode() 메소드를 재정의해야 한다.

public class Fruit {
    public String name;
    
    public Fruit(String name) {
        this.name = name;
    }
    
    @Override
    public boolean equals(Object o) {
        if (o == this) return true;
        if (!(o instanceof Fruit)) {
            return false;
        }
        Fruit fruit = (Fruit) o;
        return fruit.name.equals(name);
    }
    
    @Override
    public int hashCode() {
        int result = 17;
        result = 31 * result + name.hashCode();
        return result;
    }
}

4. Closing Connections

try {
  	Connection connection = DriverManager.getConnection();
  	//do something
    connection.close();
} catch (Exception e) {
  	//do something
}

해당 예제에선 try block에서 connection 객체를 생성했지만, 만약 Exception이 발생하게 되면 close()로 인한 자원해제가 안되게 되고 memory leck을 유발할 수 있다. 이런 경우라면 finally를 통해 close()를 수행하게끔 보장해주자 하지만, finally에서도 예외가 발생할 수 있으니 Java7 이상이라면 try-with-resource를 kotlin이라면 use scoped function을 이용해 안전하게 자원을 해제하자.

5. Internal Data Structure

public class Stack {
       private int maxSize;
       private Object[] stackArray;
       private int pointer;
       
       public Stack(int s) {
              maxSize = s;
              stackArray = newint[maxSize];
              pointer = -1;
       }
       
       public void push(Object element) {
              stackArray[++pointer] = element;
       }
       
       public Object pop() {
              return stackArray[pointer--];
       }
       
       public Object peek() {
              return stackArray[pointer];
       }
       
       public boolean isEmpty() {
              return (pointer == -1);
       }
       
       public boolean isFull() {
              return (pointer == maxSize - 1);
       }
}

해당 예제를 보면, Stack class 안에서 값들을 Object array로 저장하고 있다.
Stack에서 활성화 되는 부분은 pointer가 가리키는 곳이며, Stack에 먼저 push()됐다가 pop()되는 과정에서 문제에 직면하게 된다.
예제 코드에 따라 1000번의 push 후 1000번의 pop이 되면 pointer의 값은 0이 되지만, 내부 배열에는 pop된 모든 참조가 포함되기 때문이다. 따라서, 모든 원소가 pop되어도 GC의 영향을 받지 않으며 GC의 영향을 받기 위해서 pop에서 null을 넣어주어 참조를 끊어주면 된다.

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

6. Memory Leak Through static Fields

public class StaticTest {
    public static List<Double> list = new ArrayList<>();
  
    public void populateList() {
        for (int i = 0; i < 10000000; i++) {
            list.add(Math.random());
        }
    }
  
    public static void main(String[] args) {
        new StaticTest().populateList();
    }
}

Java에서 static field의 LifeCycle은 Application의 LifeCycle와 같다.
따라서 Application이 종료될 때까지 list의 참조는 풀리지 않기 때문에, GC 대상이 될 수 없다.

7. Using non-static inner class / anonymous class

public class LeakActivity extends Activity {

  private final Handler handler = new Handler() {
    @Override
    public void handleMessage(Message msg) {
      	// do something
    }
  }
  
  @Override
  protected void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    
    handler.postDelayed(new Runnable() {
      	@Override
      	public void run() {
        	// do something
        }
    }, 60000);
    
    finish();
  }
}

해당 예제는 onCreate에서 handler로 1분 뒤에 어떤 작업을 하도록 요청을 한 뒤 Activity를 종료하도록 finish()를 수행한다.

Java에서 non-static inner class의 경우 outer class에 대한 reference를 가지게 된다. 위 코드에서 non-static inner class로 선언된 Handler는 LeakActivity에 대한 reference를 가지고 main thread에서 생성하였기 때문에 main thread의 Looper 및 MessageQueue에 binding 된다.

이 때문에 LeakActivity가 종료가 되었음에도 불구하고 MessageQueue에 delay된 작업이 남아 있고 handler에서는 LeakActivity에 대한 reference가 남아 있기 때문에 LeakActivity는 GC의 대상이 되지 않는다.

그렇다면, 어떻게 수정해야할까?

non-static inner class는 outer class에 대한 reference를 가지지만, static inner class는 outer class에 대한 reference를 가지지 않는다. 추가로 Handler 내부에서 Activity의 메서드나 변수 등 리소스를 참조해야 될 경우 WeakReference를 활용해야 한다.

public class NonLeakActivity extends Activity {
  private NonLeakHandler handler = new NonLeakHandler(this);
  
  private static final class NonLeakHandler extends Handler {
    	private final WeakReference<NonLeakActivity> ref;
    
    	public NonLeakHandler(NonLeakActivity act) {
      		ref = new WeakReference<>(act);  
    	}
    
    	@Override
    	public void handleMessage(Message msg) {
      		NonLeakActivity act = ref.get();
      	 	if (act != null) {
       	       // do something  
      	 	}
    	}
  }
  
  private static final Runnable runnable = new Runnable() {
   		@Override
    	public void run() {
       		// do something  
        }
  }
  
  @Override
  protected void onCreate(Bundle savedInstanceState) {
    	super.onCreate(savedInstanceState);
    
    	handler.postDelayed(runnable, 60000);
    	finish();
  }

Anonymous class도 non-static inner class와 마찬가지로 outer class에 대한 reference를 가지게 되므로 Runnable도 Anonymous class에서 static inner class로 변경했다.

즉, Inner Class 의 경우 Activity의 Lifecycle에 동일하게 생성 및 종료가 보장된다면 non-static inner class로 정의해도 되지만 그렇지 않은 경우 static inner class로 정의해야 된다는 것이다.

8. Using Singletone class

object GlobalSingleton {

	private val listeners = mutableListOf<GlobalSingletonListener>()
    
    fun register(listener: GlobalSingletonListener) {
    	listeners.add(listener)
    }
    
    fun unregister(listener: GlobalSingletonListener) {
    	listeners.remove(listener)
    }
    
    interface GlobalSingletonListener {
    	fun onEvent()
    }
}   
public class LeakActivity extends Activity {

	private val listener = Listener()
    
    override fun onCreate(savedInstanceState: Bundle?) {
    	super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_leaking)
    }
    
    override fun onStart() {
    	super.onStart()
        GlobalSingleton.register(listener)
    }
    
    private inner class Listener : GlobalSingletonListener {
    	override fun onEvent() {
        	// do something
        }
    }
}

해당 예제는 LeakActivity에서 GlobaclSingletonListener를 구현한 Listener를 GlobalSingleton 객체에게 register 하는 예제이다.

Singleton class는 static class이기에 Application과 동일한 Lifecycle을 가지게 된다. 즉, LeakAcitivity가 종료되더라도 listener에 대한 인스턴스는 GlobalSingleton이 갖고 있기에 GC 수거 대상이 되지 않게 된다.

정리

이렇게 쉽지만 자칫하단 Memory Leak을 유발 시키기 쉬운 예제들을 살펴봤다.
반복해서 말했지만, 8개의 예제들의 공통점은 객체 참조가 되는 순간부터 해당 객체의 LifeCycle은 개발자가 알아야 한다는 것이다.
GC reachable 영역에 올라갈 수 있도록, 사용이 완료된 객체는 객체 해제 될 수 있도록 신경을 써주도록 하자.

profile
Android Framework Developer

0개의 댓글