Java GC 동작 방식과 WeakReference의 이해

mhyun, Park·2024년 4월 10일
1
post-custom-banner

예전에 한번 Java 메모리 관련 내용을 포스팅 ([Java/Android] Memory Leak을 발생시키는 기본 유형) 한적이 있지만, 갑작스럽게 Java WeakReference 동작 방식이 궁금하게 되어 본 포스팅을 작성하게 되었다.

1. GC 동작

다들 알다시피 Java의 가장 큰 특징은 GC(Garbage Collection)이다. GC는 프로그래머가 명시적으로 메모리를 할당하거나 해제하지 않아도 자동으로 메모리 관리를 제공해주는 편의성을 가지게 해준다. GC의 동작 알고리즘은 매우 다양하지만, 아래의 핵심 동작은 모든 GC 알고리즘에서 공통적으로 이뤄지고 있는 내용이다.

(1) Heap 내의 객체 중 사용되지 않는 객체를 찾는다(2) 사용되지 않는 객체의 Memory를 회수한다

이에 좀 더 나아가면, GC는 객체가 가비지인지 아닌지를 판별하기 위해서 reachability라는 개념을 사용한다. 어떤 객체가 참조되고 있는 곳이 한 곳이라도 있을 경우 reachable로 구별되며, 반대로 참조 되고 있는 곳이 하나도 없으면 unreachable로 구별된다. 즉, GC는 unreachable 객체를 가비지로 인식하고 메모리를 해제한다.

하지만, 객체는 여러 곳에서 동시에 참조될 수 있기때문에 객체들은 참조 사슬을 이루는 경우가 많다.
Process의 원할한 Memory 관리를 위해 프로그래머는 사용이 완료 된 객체의 참조를 끊어 unreachable 객체로 만들 수 있도록 유념해야만 한다.

drawing
  1. Heap 내의 다른 객체에 의한 참조
  2. Java Stack, Java 메서드 실행 시에 사용하는 지역 변수와 파라미터들에 의한 참조
  3. Native Stack, JNI(Java Native Interface)에 의해 생성된 객체에 대한 참조
  4. Method 영역의 정적 변수에 의한 참조

2. Memory leak 예제 코드

그렇다면, 다음의 예제 코드를 살펴보도록 하자.
해당 예제 코드의 ContentManager는 생성자에서 ContentObserver를 생성하여 등록하고
사용이 완료되어 GC에 의해 collect 될 때, finalize()에서 ContentObserver를 해제하도록 기대하고 있다.

public class SomeClass {
    private final Context mContext;
    private ContentManager mContentManager;

    public SomeClass(@NonNull Context context) {
        this.mContext = context;
    }
    
    public void initialize() {
        mContentManager = new ContentManager(mContext);
    }

    public void deInitialize() {
        mContentManager = null;
    }
}

public class ContentManager {
    private static final Uri notifyUri = Uri.parse("content://~");
    
    private final Context mContext;
    
    private HandlerThread mObserverHandlerThread;
    private Handler mObserverHandler;
    private ContentObserver mContentObserver;

    public ContentManager(@NonNull Context context) {
        this.mContext = context;

        mObserverHandlerThread = new HandlerThread("ContentManager ContentObserver");
        mObserverHandlerThread.start();
        mObserverHandler = new Handler(mObserverHandlerThread.getLooper());

        mContentObserver = new ContentObserver(mObserverHandler) {
            @Override
            public void onChange(boolean selfChange) {
                 // Do something
            }
        };
        context.getContentResolver().registerContentObserver(notifyUri, true, mContentObserver);
    }

    @Override
    protected void finalize() throws Throwable {
        mObserverHandler.removeCallbacksAndMessages(null);
            
        mObserverHandlerThread.quitSafely();
        try {
            mObserverHandlerThread.join();
        } catch (InterruptedException e) {
            Log.e("ContentManager", "ObserverHandlerThread : interrupted - " + e.getMessage());
        } finally {
            mObserverHandlerThread = null;
            mObserverHandler = null;
        }

        mContext.getContentResolver().unregisterContentObserver(mContentObserver);
        mContentObserver = null;
    }
}

해당 코드를 작성한 프로그래머는 ContentManager의 참조가 끊어져 GC가 동작할 때, 등록된 ContentObserver를 해제하도록 의도했을 것이다. 하지만, SomeClass의 initialize(), deInitialize()를 10번 반복했을 때 adb bugreport를 통해 확인하면, 10개의 HandlerThread가 온전히 살아 있는 것을 확인할 수 있다 (Thread leak)....

[dump dalvik stack 14453: 0.033s elapsed]

----- pid 14556 at 2024-03-23 20:35:21.311828192+0900 -----
"ContentManager ContentObserver" prio=5 tid=64 Native
"ContentManager ContentObserver" prio=5 tid=77 Native
"ContentManager ContentObserver" prio=5 tid=81 Native
"ContentManager ContentObserver" prio=5 tid=83 Native
"ContentManager ContentObserver" prio=5 tid=85 Native
"ContentManager ContentObserver" prio=5 tid=91 Native
"ContentManager ContentObserver" prio=5 tid=94 Native
"ContentManager ContentObserver" prio=5 tid=97 Native
"ContentManager ContentObserver" prio=5 tid=101 Native
"ContentManager ContentObserver" prio=5 tid=104 Native

이미 문제를 단박에 파악한 분들은 많겠지만, 해당 Thread leak의 이유는 단순히 GC가 ContentManager를 가비지로 인식하지 하지 못했고 이로인해 fianlize()가 수행되지 않아 ContentObserver를 unregister 하지 못 했기 때문이다.

이를 해결하기 위해선, 다음과 같이 ContentObserver를 unregister 하기위한 명시적인 API를 open하고 호출해줘야만 ContentObserver의 참조 사슬이 끊기게되고 GC가 unreachable 상태가 된 mObserverHandler 및 mObserverHandlerThread를 가바지로 인식하여 메모리를 회수한다.

public class SomeClass {
    // ... (위 예제와 동일)

    public void deInitialize() {
        if (mContentManager != null) {
            mContentManager.release();
            mContentManager = null;
        }
    }
}

public class ContentManager {
    // ... (위 예제와 동일)

    public void release() {
        mObserverHandler.removeCallbacksAndMessages(null);
            
        mObserverHandlerThread.quitSafely();
        try {
            mObserverHandlerThread.join();
        } catch (InterruptedException e) {
            Log.e("ContentManager", "ObserverHandlerThread : interrupted - " + e.getMessage());
        } finally {
            mObserverHandlerThread = null;
            mObserverHandler = null;
        }

        // ContentProvider에 연결되어있는 mContentObserver의 참조를 끊는다.
        // mContentObserver를 참조하는 객체는 더이상 없기 때문에 
        // mObserverHandler 및 mObserverHandlerThread는 GC에 의해 수거된다.
        mContext.getContentResolver().unregisterContentObserver(mContentObserver);
        mContentObserver = null;
    }
}

3. WeakReference

java.lang.ref 패키지가 출시된 Java 1.2부터 프로그래머들은 객체들을 strong, soft, weak, phantom reachable 객체로 더 자세히 구별하여 GC 때의 동작을 다르게 지정할 수 있게 되었다. 즉, GC 대상 여부를 판별하는 부분에 프로그래머의 기준을 개입하여 GC되는 조건을 셋팅할 수 있게 된 것이다.

여기서 weak reachable 객체, WeakReference는 GC가 동작할 때 무조건 수거되는 성격을 갖고 있기때문에 프로그래머가 참조 끊기에 대한 부담을 덜어줄 수 있어 많은 곳에서 다양하게 활용되고 있다. WeakReference 객체는 다음과 같이 실 객체를 Wrapping 함으로써 생성된다.

  • 생성 : WeakReference<SomeObject> someObj = new WeakReference<SomeObject>(new SomeObject());
  • 이용 : someObj.get(); // @Nullable return

그렇다면, 예제 코드를 통해 WeakReference 동작에 대해 알아보자.
아래 Image 객체와 Image 객체를 소유하고 있는 ImageViewer 객체가 있다.

    public class Image {
        public final String name;

        public Image(String name) {
            this.name = name;
        }

        @Override
        protected void finalize() throws Throwable {
            super.finalize();
            System.out.println("finalize " + name);
        }
    }

    public class ImageViewer {
        public final Image image;

        public ImageViewer(Image image) {
            this.image = image;
        }
    }

그리고 다음과 같이 ImageViewerImage 객체를 주입한 후 Image를 null로 만들어 참조를 해제했다고 할 때
System.gc()를 호출했음에도 불구하고 Image 객체의 finalize()는 호출되지 않는 결과가 나타난다.

    public static void main(String[] args) {
        Image image = new Image("image1");
        ImageViewer imageViewer = new ImageViewer(image);

        System.out.println("test start");
        
        image = null;
        System.gc();
        Thread.sleep(1000);
        
        System.out.println("test end");
    }
    
  > test start
  > test end

이유는 당연하게도 ImageViewerImage 객체를 들고 있기 때문에 Image 객체가 아직 reachable 상태이기 때문이다.
하지만, ImageViewer가 소유하고 있는 Image에 대해 WeakReference를 사용하면, 사뭇 다른 결과를 확인할 수 있다.

    public class ImageViewer {
        public final WeakReference<Image> image;

        public ImageViewer(Image image) {
            this.image = new WeakReference<>(image);
        }
    }
    
    // 이전 예제 main과 동일
    public static void main(String[] args) {
        Image image = new Image("image1");
        ImageViewer imageViewer = new ImageViewer(image);

        System.out.println("test start");
        
        image = null;
        System.gc();
        Thread.sleep(1000);
        
        System.out.println("test end");
    }
    
  > test start
  > finalize image1
  > test end

결과는, 이전과 달리 Image 객체의 참조를 해제하여 현재 Image 객체가 참조되고 있는 곳이 ImageViewerWeakReference 뿐이라면
GC 수행시에 Image 객체의 메모리는 해제되게 된다.

그 이유는, GC가 실행될 때 unreachable 객체뿐만 아니라 weakly reachable 객체도 가비지 객체로 간주되어 메모리에서 회수되기 때문이다.
이는 Root set으로부터 시작된 참조 사슬에 속해 있더라도, 개발자 요구에따라 객체가 GC의 대상이 될 수 있다는 것을 의미한다.
(*cf. SofrReference는 참조가 끊기고 GC가 동작되도 heap 메모리가 부족한 경우에만 GC에서 수거한다.)

drawing

4. ReferenceQueue

그렇다면, 내부적으로 어떤 동작이 이뤄지길래 WeakReference가 위와 같은 역할을 할 수 있었던 것일까?
그것은 WeakReference가 상속하고 있는 Reference class의 ReferenceQueue 때문이다.

ReferenceQueue의 동작을 간단히 설명하면 다음과 같다.

  • GC가 참조된 객체를 수거하면, enqueue 메서드를 호출하여 ReferenceQueue에 해당 객체를 추가한다.
  • ReferenceQueue에 enqueue된 객체는 GC 내부적으로 객체의 수명을 추적하고 이벤트를 처리할 수 있게 한다.
public class WeakReference<T> extends Reference<T>

public abstract	class Reference<T> extends Object {
	private static final int STATE_INITIAL = 0;
	private static final int STATE_CLEARED = 1;
	private static final int STATE_ENQUEUED = 2;

	private T referent;
	private ReferenceQueue queue;
	private int state;
    
    ...
    
    public boolean enqueue() {
		if (ClearBeforeEnqueue.ENABLED) {
			clearImpl();
		}
		return enqueueImpl();
	}
    
    boolean enqueueImpl() {
		final ReferenceQueue tempQueue;
		boolean result;
		T tempReferent = referent;
		synchronized(this) {
			/* Static order for the following code (DO NOT CHANGE) */
			tempQueue = queue;
			queue = null;
			if (state == STATE_ENQUEUED || tempQueue == null) {
				return false;
			}
			result = tempQueue.enqueue(this);
			if (result) {
				state = STATE_ENQUEUED;
				if (null != tempReferent) {
					reprocess();
				}
			}
			return result;
		}
	}
    
	private native void reprocess();
}

이렇게, 지금까지 설명한 WeakReference 성격으로 인해 WeakReference의 참조된 객체를 이용할 때 항상 null-checking 후 사용되어야만 하며
주로 다음과 같은 상황에서 활용될 수 있다.

  • Cache
    Cache된 객체가 다른 곳에서 참조되지 않을 때, GC의 대상이 되게끔 만들어 메모리 Retain/Leak을 방지한다. ex) WeakHashMap
    public static void main(String[] args) {
    	final WeakHashMap<String, Inteager> cacheMap = new WeakHashMap<>();
    	String name1 = "Park";
    	String name2 = "Lee";
    	map.put(name1, 30);
    	map.put(name2, 25);
    
    	name1 = null;
    	System.gc();
    
    	map.entrySet().stream().forEach(entry -> System.out.println(entry));
    }
    
  > Lee=25
  • Callback/Listener
    Callback/Listener를 소유하고 있는 객체가 있을 때, Callback/Listener 참조 해제시에도 GC가 Callback/Listener 객체의 메모리를 수거 가능하게끔 한다.
    public class SomeHandler extends Handler {
        private final WeakReference<SomeManager> mSomeManager;

        public SomeHandler(SomeManager someManager, Looper looper) {
            super(looper);
            someManager = new WeakReference<>(someManager);
        }

        @Override
        public void handleMessage(@NonNull Message msg) {
            final SomeManager someManager = someManager.get();
            if (someManager == null) {
                Log.w(TAG, "handleMessage :: SomeManager garbage collected, return.");
                return;
            }
            someManager.processForSomething((byte[]) msg.obj, msg.arg1, msg.arg2);
        }
    }
  • 순환 참조 방지
    객체 간의 순환 참조가 발생할 경우, 순환 참조되는 객체들은 가비지 컬렉션 대상이 될 수 없다. 순환 참조 형태는 최대한 지양해야하지만, 불가피한 경우 WeakReference를 사용하여 GC가 동작될 때, 순환 참조를 해제할 수 있도록 한다.

이렇게 Java에서 메모리 누수를 피하기 위해 명시적인 참조 해제나 WeakReference와 같은 메커니즘을 통해 객체를 관리할 수 있는 방법에 대해 알아 보았다. 이를 통해 안정적인 애플리케이션을 개발하고 성능을 최적화해보도록 하자.

profile
Android Framework Developer
post-custom-banner

0개의 댓글