java

java subList memory leak

maruoov 2023. 2. 20. 20:55

java 에서 list 의 일부분을 가져오고 싶을때 흔히 list.subList 를 많이 사용한다
기존 list 의 일부분을 잘라 새로 생성하는 메서드로 생각하고 크게 신경쓰지 않고 사용해왔다.
하지만 잘못 사용하면 메모리 누수의 위험이 있다.

많이 사용하는 ArrayList.subList 의 코드 구현부를 보자

public List<E> subList(int fromIndex, int toIndex) {
    subListRangeCheck(fromIndex, toIndex, this.size);
    return new SubList(this, fromIndex, toIndex);
}

private static class SubList<E> extends AbstractList<E> implements RandomAccess {
    private final ArrayList<E> root;
    private final SubList<E> parent;
    private final int offset;
    private int size;

    public SubList(ArrayList<E> root, int fromIndex, int toIndex) {
        this.root = root;
        this.parent = null;
        this.offset = fromIndex;
        this.size = toIndex - fromIndex;
        this.modCount = root.modCount;
    }
    ...
}

SubList 를 생성하는데, this 를 넘기고 SubList 는 parent 값을 가지고 있다.

List 의 javadoc 을 보면 (https://docs.oracle.com/javase/8/docs/api/java/util/List.html)
subList 에 대해 다음과 같이 써져있다.

subList(int fromIndex, int toIndex)
Returns a view of the portion of this list between the specified fromIndex, inclusive, and toIndex, exclusive.

새로 생성해 주는 것이 아닌 리스트의 fromIndex <= i < toIndex 사이의 view 를 반환해준다고 되어 있다.

SubList 는 자신을 만든 List 를 갖고 있기 때문에, SubList 가 살아있으면 List 는 GC 대상이 되지 않는다.
그래서 원본 리스트의 일부분을 subList 로 만들어 캐싱한다고 했을때, 일부분이 아닌 전체가 메모리를 점유하게 된다.

다음과 같은 코드가 있다고 할때, subList 가 원본 리스트를 갖고 있지 않는다면 4byte * 5 개 리스트이기 때문에
상당히 긴 기간 코드가 실행될것이다.
하지만 이 코드는 40번 루프 이상 돌지 못하고 OOM 이 발생한다 (java memory 2G 환경)
(근데 제대로 테스트 한건진 모르겠음)

@Test
public void cacheTest() throws InterruptedException {

    List<List<Integer>> acc = new ArrayList<>();
    while(true) {
        final List<Integer> test = test();
        acc.add(test);
        System.out.println("current : " + test);
        System.out.println("acc size : " + acc.size());
        System.out.println("current free heap : " + Runtime.getRuntime().freeMemory());
        Thread.sleep(1000L);
    }
}

public List<Integer> test() {
    // 4byte * 10000000 * 40 = 1.6GB
    final List<Integer> list = IntStream.range(0, 10_000_000).boxed().collect(Collectors.toList());
    return list.subList(0, 5);
}

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

다음과 같은 현상도 발생한다.

@Test
public void sublistTest() {
    final List<Integer> list = IntStream.range(0, 10).boxed().collect(Collectors.toList());

    final List<Integer> subList = list.subList(0, 5);

    System.out.println(list);
    System.out.println(subList);

    list.add(9999);
    System.out.println(list);
    System.out.println(subList);
}


[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
[0, 1, 2, 3, 4]
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 9999]
java.util.ConcurrentModificationException
    at java.base/java.util.ArrayList$SubList.checkForComodification(ArrayList.java:1415)

parent 를 변경하는게 subList 에도 영향이가 에러가 발생하게 된다

@Test
public void sublistTest() {
    final List<Integer> list = IntStream.range(0, 10).boxed().collect(Collectors.toList());

    final List<Integer> subList = list.subList(0, 5);

    System.out.println(list);
    System.out.println(subList);

    subList.add(9999);
    System.out.println(list);
    System.out.println(subList);
}


[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
[0, 1, 2, 3, 4]
[0, 1, 2, 3, 4, 9999, 5, 6, 7, 8, 9]
[0, 1, 2, 3, 4, 9999]

반대로, subList를 변경하는게 parent 에도 영향이 간다.

이런 현상을 방지하려면, list.subList 를 새 리스트 생성으로 다시 감싸준다.

new ArrayList<>(list.subList(0, 5))

'java' 카테고리의 다른 글

Spring Webclient queryParam 특수문자 encoding 이슈  (1) 2022.12.20
java mapping library MapStruct  (0) 2022.12.18