하나의 프로세스에서 JVM heap memory는 할당량이 제한된다. 할당할 수 있는 데이터의 크기가 한정적이므로 사용하지 않을 데이터를 계속 메모리에 올려서 사용하는 것은 비효율적이고, 만약 데이터를 무한정 할당하게 된다면 메모리 공간이 부족해져 OOME가 발생할 것이다. 이를 관리해주는 것이 Garbage Collector다. 가비지컬렉터가 사용하지 않는 메모리 참조를 해제함으로써 메모리의 여유 공간을 확보하는 것이다.
GC는 현재 참조하고 있지 않은 대상을 수집하여 메모리 공간을 확보하는데, 만약 크기가 큰 객체를 지속적으로 참조해야 상황이 생긴다면 어떻게 될까? 모두 GC 대상에서 제외될까? 만약 현재 참조하여 활용되고 있는 데이터는 모두 GC의 대상이 되지 못한다는 가설이 맞다면 다음의 문제가 발생할 수 있다.
약 1GB 데이터를 갖는 BigObject를 만들고 static List에 초당 1개씩 데이터를 넣는다. heap 메모리는 4GB를 할당하여 실행시킨다.
java -Xms4G -Xmx4G ReferenceTest
public class ReferenceTest {
private static final List<BigObject> datas = new ArrayList<>();
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 4; i++) {
Thread.sleep(1000);
datas.add(new BigObject());
}
}
}
class BigObject {
private final byte[] gigabyte = new byte[1024 * 1024 * 1024];
}
OutOfMemoryError가 발생한다. datas 리스트가 1기가 데이터를 4~5개를 계속 참조하려고 하다보니 GC가 동작하기도 전에 메모리가 부족하다는 에러가 난 것이다. 이는 datas 리스트가 BigObject를 강하게 참조하고 있기 때문이다.
참조하고는 있지만 우리가 불필요하다고 판단하는 데이터는 GC가 수거할 수 있게 하면 어떨까? 모든 데이터가 강하게 참조되어 메모리 문제를 발생시키는 것보다는 상대적으로 중요도가 낮은 데이터를 선별하여 해당 데이터는 메모리에서 해제시키는 것이다. 이제 Reference Type에 대한 이해가 필요해졌다.
Strong Reference vs non-Strong Reference
일반적으로 new를 통해 인스턴스를 생성하거나 데이터를 할당하면 유효한 scope 내에서 살아 있는 데이터가 된다. 별다른 처리를 하지 않는 이상 GC는 해당 데이터가 현재 필요하다고 판단하여 수거 대상에 포함시키지 않는다. 이를 Strong Reference라고 한다.
반면에 GC 대상이 될 수 있도록 참조 수준을 변경할 수도 있는데 이를 non-Strong Reference라고 한다. Phantom Reference도 있지만, 이번 글에서는 Soft와 Weak Reference에 대해서만 다루고자 한다.
Soft Reference
Soft Reference는 JVM이 관리하는 heap memory 공간이 부족할 것으로 판단되기 전에는 해당 객체를 GC하지 않는다. 가비지 컬렉터는 할당된 메모리에 근접하게 되어 메모리가 부족할 것이 예상되면 여유 메모리를 확보하기 위해 GC를 시도해본다. 이 때 soft referenced 데이터들이 메모리에서 해제된다.
우선은 메모리가 여유 있는 상황을 가정하여 SoftReference 객체를 확인해본다. 100MB 객체를 4개 할당하고 마지막에 reference를 확인한다.
public class ReferenceTest {
private static final List<SoftBigObject> datas = new ArrayList<>();
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 4; i++) {
Thread.sleep(1000);
datas.add(new SoftBigObject());
}
datas.stream()
.map(data -> data.soft.get())
.forEach(System.out::println);
}
}
class BigObject {
private final byte[] gigabyte = new byte[1024 * 1024 * 100];
}
class SoftBigObject {
final SoftReference<BigObject> soft = new SoftReference<>(new BigObject());
}
BigObject@626b2d4a
BigObject@5e91993f
BigObject@1c4af82c
BigObject@379619aa
OutOfMemoryError도 나지 않고 SoftReference가 관리하는 BigObject 객체도 참조 해제되지 않았다.
이제 다시 OOME가 발생할만큼 큰 데이터로 바꿔준다. 동일한 조건에서 실행한다. 객체 참조가 null로 바뀌며 GC가 동작하여 heap memory도 여유 공간을 확보하는 것을 확인할 수 있다.
private final byte[] gigabyte = new byte[1024 * 1024 * 1024];
null
null
null
BigObject@379619aa
Weak Reference
Weak Reference가 Soft Reference와 갖는 가장 큰 차이점은 Reference 객체만이 관리 객체인 'referent'를 참조하고 있느냐의 여부다. Soft Reference의 경우에는 Reference 객체만이 참조를 유지하고 있더라도 메모리 여유 공간이 있으면 강제로 GC 수거 대상으로 포함시키진 않는다. 하지만 Weak Reference 같은 경우에는 그렇지 않다.
아래 코드를 실행시키면 메모리 공간이 충분한 상태에서도 GC를 강제로 실행시킬 경우 SoftReference 객체는 참조 해제 되지 않았지만, WeakReference 객체는 참조 해제되어 null이 되었다.
public class ReferenceTest {
private static final List<SoftBigObject> datas = new ArrayList<>();
private static final List<WeakBigObject> weakDatas = new ArrayList<>();
public static void main(String[] args) throws InterruptedException {
for (int i = 0; i < 4; i++) {
Thread.sleep(1000);
datas.add(new SoftBigObject());
weakDatas.add(new WeakBigObject());
}
System.gc(); // 강제 GC 실행
datas.stream()
.map(data -> data.soft.get())
.forEach(System.out::println);
weakDatas.stream()
.map(data -> data.weak.get())
.forEach(System.out::println);
}
}
class BigObject {
private final byte[] gigabyte = new byte[1024 * 1024 * 100];
}
class SoftBigObject {
final SoftReference<BigObject> soft = new SoftReference<>(new BigObject());
}
class WeakBigObject {
final WeakReference<BigObject> weak = new WeakReference<>(new BigObject());
}
BigObject@7c37508a
BigObject@1033576a
BigObject@303cf2ba
BigObject@76494737
BigObject@4a003cbe
null
null
null
null
null
마치며
그런데 위 Soft/Weak reference 객체를 그대로 사용하는 것은 문제가 있어 보인다. 무조건 참조 해제 시키면 해당 데이터가 금방 다시 필요할 경우 메모리에 다시 올리는 작업이 필요해져 비용이 증가한다. 개중에는 필요한 데이터도 있을텐데, GC 내부 로직에 의해서 FIFO 방식으로 이전 데이터부터 참조 해제를 시킨다.
자주 사용하는 데이터는 유지시키고 싶거나, 오랫동안 사용하지 않는 데이터부터 해제시키는 등의 필요가 있다면 이야기는 달라진다. 프로그램 로직상 필요에 따라 최적화가 필요한 부분이다.
'java' 카테고리의 다른 글
[Java] http 요청과 응답 구현하기 - 2(apache tomcat의 http 처리) (0) | 2023.04.17 |
---|---|
[Java] http 요청과 응답 구현하기 - 1(socket으로 http 요청하기) (0) | 2023.04.17 |
[Java] Dynamic Proxy란? (0) | 2023.03.21 |
[Java] Proxy란? (0) | 2023.03.20 |
[Java] JNI(Java Native Interface)란? (0) | 2023.03.06 |