본문 바로가기

개발 개발/자바

Java 메모리와 관련하여


1. 객체를 사용하고 나서 null 할당하는건 코드만 더럽히는 짓이다 vs 꼭꼭 null 처리해주는 좋은습관!
2. null 을 할당하는것은 별 의미없다 vs 이건 정말 중요하다
3. null 을 할당한다면 언제 할당해야 하느냐?

이런것들은 자바를 사용하는 개발자들 사이에서 오랫동안 논란이 되어왔고 아직도 논란이 되고 있는 부분이지요. 사실상 이 논란은 자바라는 언어에서 프로그래머가 직접 메모리를 할당/관리 하지 않기때문에 발생하는 것입니다. 하지만 GC 의 원리를 이해하게되면 null 할당이 편집증걸린 프로그래머나 할만한 일은 아니라는것을 알게됩니다. 그리고, 자바라는 언어가 이런 부분들을 해소하기 위해 어떤 노력을 했는지도 알수 있지요. (뒤에 null 할당이 아닌 다른 방법으로 동일한 효과를 얻는 예제를 소개 하겠습니다.) 

위의 3가지 논란에 대해 정리하기에 앞서 한가지 모두가 인정할만한 부분을 짚고 넘어가겠습니다. 

작은 프로그램(혹은 메서드)에서는 null 할당은 큰 의미가 없습니다. 여기서 작은 프로그램이란 
  • 프로그램(혹은 메서드) 자체의 동작시간이 짧고
  • 객체의 생성이 빈번하지 않으며
  • 적은 메모리를 사용하는 
  • 메모리 누수가 문제되지 않는
  • 실행시 지정한(혹은 기본값) 힙사이즈를 채 반도 사용하지 않는
프로그램(메서드)으로 정의해 보겠습니다. (좀더 구체적으로 정의할 방법도 있겠습니다만 당장 떠오르는게 저것밖에 없네요. ^-^;;) 사실 공부하려고 짜보는 프로그램이 아닌 어느정도 규모있는 프로그램이라면 대부분 위의 가지에 정반대되는 경우일 겁니다.  제가 계속 프로그램이라는 단어에 괄호치고 메서드라고 넣는 이유는 프로그램 전체가 위의 항목에 해당 될수도 있지만 어느 클래스의 특정 메서드가 위의 항목에 해당되는 경우도 있기 때문입니다. 이를 테면 작은 프로그램인데 몇개의 클래스에 정의된 특정 메서드들이 프로그램의 실행시간의 많은 부분을 차지한다던가 그 메서드에서만 메모리를 많이 사용한다던가 라는 상황도 있기 때문입니다. 

(프로그램(메서드)의 규모는 개발자마다 체감되는게 다르기 때문에 작다 크다 정의내리면 논란이 되는 경우가 많은데 일단 얘기를 이어나가야 하니 넘어갑시다.)

맨위에서 언급한 논란의 각각에 작은 프로그램(메서드)의 정의를 반대로 적용해 보시면 도움이 될겁니다. 정의를 반대로 적용봤을때 해당한다면 고민하지 마시고 아래의 방법을 추천드립니다.

지금 작성하고 있는 코드가 큰 프로그램(메서드)의 일부라면 항상 가능한한 모든 객체의 사용후 null 을 할당해 주어야 하며, 이것은 정말 중요한 부분입니다.

왜 위와 같이 해야하는지 몇가지 예제을 통해 살펴보도록 하겠습니다. 직접 붙여넣고 실행 해보시기 바랍니다. (실행시 VM 옵션으로 -verbose:gc 를 추가하셔서 실제 GC 가 일어나는 것을 확인해 보세요.)

첫번째 예제 : null 을 안주면 어떻게 되나?

public class GCTest {
private final static int ALLOC_SIZE = (int) (Runtime.getRuntime().maxMemory() * 0.60);

public void allocate() {

System.out.println("Before first allocation");
byte[] b = new byte[ALLOC_SIZE];
System.out.println("After first allocation");

System.out.println("Before second allocation");
byte[] b2 = new byte[ALLOC_SIZE];
System.out.println("After second allocation");

}

public static void main(String[] args) {
new GCTest().allocate();
}
}

먼저 예제를 설명 드리자면 가용한 메모리의 60% 를 allocate 라는 메서드의 첫번째 지역 변수인 b 에서 할당을 합니다. 그리고 이후에는 전혀 사용되지 않고 있지요. 그리고 나서 다시 두번째 지역변수인 b2 에서 또 60% 를 할당을 합니다. 

위의 예제의 결과는 아래 와 같습니다.

Before first allocation
After first allocation
Before second allocation
[GC 555205K->555136K(712576K), 0.0017096 secs]
[GC 555136K->555104K(712576K), 0.0045040 secs]
[Full GC 555104K->555088K(660160K), 0.0052565 secs]
[GC 555088K->555088K(712576K), 0.0071493 secs]
[Full GC 555088K->555066K(665280K), 0.0062704 secs]
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at GCTest.allocate(GCTest.java:12)
at GCTest.main(GCTest.java:18)

첫번째 할당은 정상적으로 이뤄졌지만 두번째 할당을 진행하려고 보니 메모리가 부족한 상황이라 마이너 GC 가 발생하고 연달아 Full GC 까지 이뤄 졌습니다. 하지만 메모리는 여전히 부족합니다. 왜냐면 메서드가 반환되기 이전에는 b 의 참조가 존재하므로 (설사 사용되지 않는다 하더라도) GC 대상에서 제외되었기 때문입니다. 누가봐도 당연히 OOM (Out of memory) 가 나는 상황입니다. 또한 누가봐도 b2 를 할당하는 시점에 GC 가 일어 난다고 예상이 됩니다. (메모리가 부족한 상황에서 GC 는 반드시 일어납니다.

하지만 이 예제는 조건을 극단적으로 단순화 해놨을 뿐이지 규모가 큰 프로그램(메서드) 에서는 언제든 발생할 수 있는 상황입니다. 예를 들어 여러 스레드가 동작하며 각각 어떤 메서드를 실행을 하는데 그 메서드가 이런 구조라면

메서드 호출 -> 객체 잔뜩 생성 -> 객체 마구 사용(이후에는 사용안함) -> 블럭되는 I/O 처리 -> 반환

메서드가 종료되기 전까지 참조를 물고있는 사용되지 않는 지역변수들이 점점 쌓여 갈겁니다. 결국 메모리가 부족해 GC 가 수행되더라도 GC 의 대상이 되어야할 객체들은 참조를 물고 있으니 OOM(버틸수가없다) 으로 직행하는 거지요.

두번째 예제 : null 을 주면 어떻게 되나?

public class GCTest2 {
private final static int ALLOC_SIZE = (int) (Runtime.getRuntime().maxMemory() * 0.60);

public void allocate() {

System.out.println("Before first allocation");
byte[] b = new byte[ALLOC_SIZE];
System.out.println("After first allocation");
b = null;
System.out.println("After assign null to b");
System.out.println("Before second allocation");
byte[] b2 = new byte[ALLOC_SIZE];
System.out.println("After second allocation");
}
public static void main(String[] args) {
new GCTest2().allocate();
}
}

첫번째 예제와 상황은 동일합니다. 하지만 b 를 다쓰고 나서 null 할당 해주었지요.
결과는 이렇습니다.

Before first allocation
After first allocation
After assign null to b
Before second allocation
[GC 555205K->555168K(712576K), 0.0019313 secs]
[GC 555168K->555104K(712576K), 0.0027482 secs]
[Full GC 555104K->208K(105344K), 0.0509913 secs]
After second allocation

예상하셨다시피 OOM 은 일어나지 않았습니다. 앞서 언급했던 

메서드 호출 -> 객체 잔뜩 생성 -> 객체 마구 사용(이후에는 사용안함) -> 블럭되는 I/O 처리 -> 반환

이런 구조의 메서드를 

메서드 호출 -> 객체 잔뜩 생성 -> 객체 마구 사용(이후에는 사용안함) -> 마구사용한 객체 가차없이 null 할당 -> 블럭되는 I/O 처리 -> 반환

이렇식으로 수정한 것과 동일합니다. OOM 으로 직행하려다가 현명한 개발자 덕분에 GC 가 보람있는 일을 했네요. 일례로 많은 사용자를 대상으로 하는 웹어플리케이션에는 아주 딱 들어맞는 상황입니다. 특히. 웹이라는 특성상 서비스의 유형에 따라 피크타임이 존재하기 때문에 더더욱 적합합니다. (개발자의 상황에 적합하지 못한 코드 덕분에 심심하면 죽어나간 서버가 한둘이 아닙니다.)

조건을 단순화한 예제이지만 대부분의 큰 프로그램의 어딘가에서 null 할당을 해줘야 하나 말아야 하나를 고민할때 생각해 볼만한 예제라고 생각이되는군요.

적다보니 내용이 너무 길어 지는 감이 있어 앞에서 얘기한 null 할당 이외의 방법을 소개하고 끝내도록 하겠습니다. 

자바가 처음 나온후 컴퓨터 분야에서는 꽤 오랜 시간이 흘렀습니다.  VM 이 알아서 메모리를 관리해 준다는 장점은 사용하다보니 다른 문제들을 야기하게 되었습니다. 바로 "우린 좀더 GC 와 얘기하고싶다" 라는 건데요.

null 할당은 GC 가 일어나기만 하면 바로 회수해 가버리고 이후에 해당 객체를 더이상 사용할 수 없지만.

1. GC 를 한번 해보고, 메모리가 충분하다면 계속 참조하고 싶다
2. GC 가 일어나기 전까지만 계속 참조하고 싶다.
3. finalize 이후에 뭔가 더 처리 하고 싶다.

라는 요구사항들이 여기저기서 터져나오기 시작한거죠. 결국 1.2 버전에서 새로운 클래스들이 소개되기 에 이르렀는데 그게 바로 

java.lang.ref 

패키지 입니다. 현재 오픈소스나 캐싱 라이브러리 들이 이 패키지를 잘 활용한 예제이지요.  여기에는 딱 5개의 클래스가 존재 합니다.

Reference, PhantomReference, SoftReference, WeakReference, ReferenceQueue

입니다. 여기서 주역들은 

1. GC 를 한번 해보고, 메모리가 충분하다면 계속 참조하고 싶다 (SoftReference)
2. GC 가 일어나기 전까지만 계속 참조하고 싶다. (WeakReference)
3. finalize 이후에 뭔가 더 처리 하고 싶다. (PhantomReference)

이 3가지 클래스 입니다. 저는 WeakReference 와 SoftReference 를 소개 하겠습니다.

WeakReference 예제

import java.lang.ref.WeakReference;

public class GCTest3 {
private final static int ALLOC_SIZE = (int) (Runtime.getRuntime().maxMemory() * 0.60);
public void allocate() {
System.out.println("Before first allocation");
WeakReference<byte[]> b = new WeakReference<byte[]>(new byte[ALLOC_SIZE]);
System.out.println("After first allocation");

System.out.println("b is alive ? " + b.get());
System.out.println("Before second allocation");
byte[] b2 = new byte[ALLOC_SIZE];
System.out.println("After second allocation");
System.out.println("b is alive ? " + b.get());
}
public static void main(String[] args) {
new GCTest3().allocate();
}
}

수행 결과는 

Before first allocation
After first allocation
b is alive ? [B@64c3c749
Before second allocation
[GC 555205K->555168K(712576K), 0.0016671 secs]
[GC 555168K->555104K(712576K), 0.0015086 secs]
[Full GC 555104K->208K(105344K), 0.0562847 secs]
After second allocation
b is alive ? null

GC 한방에 날아가버렸네요.


SoftReference 예제


import java.lang.ref.SoftReference;

public class GCTest3 {
private final static int ALLOC_SIZE = (int) (Runtime.getRuntime().maxMemory() * 0.60);
public void allocate() {
System.out.println("Before first allocation");
SoftReference<byte[]> b = new SoftReference<byte[]>(new byte[ALLOC_SIZE]);
System.out.println("After first allocation");

System.out.println("b is alive ? " + b.get());
System.out.println("Before second allocation");
byte[] b2 = new byte[ALLOC_SIZE];
System.out.println("After second allocation");
System.out.println("b is alive ? " + b.get());
}
public static void main(String[] args) {
new GCTest3().allocate();
}
}


WeakReference 의 예제에서 SoftReference 로 클래스만 바뀌었습니다.

결과를 볼까요?

Before first allocation
After first allocation
b is alive ? [B@7150bd4d
Before second allocation
[GC 555205K->555136K(712576K), 0.0016918 secs]
[GC 555136K->555104K(712576K), 0.0014965 secs]
[Full GC 555104K->555088K(660160K), 0.0045636 secs]
[GC 555088K->555088K(712576K), 0.0068316 secs]
[Full GC 555088K->186K(110208K), 0.0575150 secs]
After second allocation
b is alive ? null

한번 해보고 버틸수가 없다! 이러니까. 결국 이녀석도 날아가 버렸네요.