HashSet 코드의 예기치 않은 실행 시간


28

원래, 나는이 코드를 가지고 있었다 :

import java.util.*;

public class sandbox {
    public static void main(String[] args) {
        HashSet<Integer> hashSet = new HashSet<>();
        for (int i = 0; i < 100_000; i++) {
            hashSet.add(i);
        }

        long start = System.currentTimeMillis();

        for (int i = 0; i < 100_000; i++) {
            for (Integer val : hashSet) {
                if (val != -1) break;
            }

            hashSet.remove(i);
        }

        System.out.println("time: " + (System.currentTimeMillis() - start));
    }
}

내 컴퓨터에서 중첩 된 for 루프를 실행하는 데 약 4 초가 걸리며 왜 그렇게 오래 걸 렸는지 이해할 수 없습니다. 외부 루프는 100,000 번 실행되고 내부 for 루프는 1 번 실행되어야합니다 (hashSet의 값이 -1이 아니기 때문에). HashSet에서 항목 제거가 O (1)이므로 약 200,000 개의 작업이 있어야합니다. 일반적으로 초당 100,000,000 개의 작업이있는 경우 코드를 실행하는 데 어떻게 4 초가 걸립니까?

또한 라인 hashSet.remove(i);이 주석 처리 된 경우 코드는 16ms 만 걸립니다. 내부 for 루프가 주석 처리 된 경우 (주석이 아닌 경우 hashSet.remove(i);) 코드는 8ms 만 걸립니다.


4
나는 당신의 발견을 확인합니다. 나는 그 이유에 대해 추측 할 수 있지만, 누군가가 영리한 사람이 매혹적인 설명을 게시 할 수 있기를 바랍니다.
khelwood

1
등이 보이는 for val루프는 시간을 복용 것입니다. 는 remove매우 빠른 아직도있다. 세트가 수정 된 후 새로운 반복자를 설정하는 일종의 오버 헤드 ...?
khelwood

@apangin은 stackoverflow.com/a/59522575/108326 에서 for val루프가 느린 이유에 대한 좋은 설명을 제공했습니다 . 그러나 루프가 전혀 필요하지 않습니다. 세트에 -1과 다른 값이 있는지 확인하려면 검사하는 것이 훨씬 효율적 hashSet.size() > 1 || !hashSet.contains(-1)입니다.
markusk

답변:


32

HashSet알고리즘이 2 차 복잡도로 저하되는 한계 사용 사례를 만들었습니다 .

시간이 오래 걸리는 단순화 된 루프는 다음과 같습니다.

for (int i = 0; i < 100_000; i++) {
    hashSet.iterator().next();
    hashSet.remove(i);
}

async-profiler 는 거의 모든 시간이 java.util.HashMap$HashIterator()생성자 내부에서 소비됨을 보여줍니다 .

    HashIterator() {
        expectedModCount = modCount;
        Node<K,V>[] t = table;
        current = next = null;
        index = 0;
        if (t != null && size > 0) { // advance to first entry
--->        do {} while (index < t.length && (next = t[index++]) == null);
        }
    }

강조 표시된 줄은 해시 테이블에서 비어 있지 않은 첫 번째 버킷을 검색하는 선형 루프입니다.

Integer사소한 것이 있기 때문에 hashCode(즉 hashCode는 숫자 자체와 같습니다) 연속적인 정수는 대부분 해시 테이블에서 연속적인 버킷을 차지합니다. 숫자 0은 첫 번째 버킷, 숫자 1은 두 번째 버킷 등입니다.

이제 0에서 99999까지의 연속 숫자를 제거합니다. 가장 간단한 경우 (버킷에 단일 키가 포함 된 경우) 키 제거는 버킷 배열의 해당 요소를 널링하는 것으로 구현됩니다. 제거 후 테이블이 압축되거나 다시 해시되지 않습니다.

따라서 버킷 배열의 시작 부분에서 키를 많이 제거할수록 HashIterator비어 있지 않은 첫 번째 버킷을 더 오래 찾아야합니다.

다른 쪽 끝에서 키를 제거하십시오.

hashSet.remove(100_000 - i);

알고리즘이 훨씬 빨라집니다!


1
Ahh, 나는 이것을 보았지만 처음 몇 번 실행 한 후에 닫았고 이것이 JIT 최적화 일 것이라고 생각하고 JITWatch를 통해 분석으로 옮겼습니다. 먼저 async-profiler를 실행해야합니다. 제길!
Adwait Kumar

1
꽤 흥미 롭습니다. 루프에서 다음과 같은 작업을 수행하면 내부 맵의 크기를 줄임으로써 속도가 빨라집니다 if (i % 800 == 0) { hashSet = new HashSet<>(hashSet); }.
Grey-그만 악의를 멈춰라
당사 사이트를 사용함과 동시에 당사의 쿠키 정책개인정보 보호정책을 읽고 이해하였음을 인정하는 것으로 간주합니다.
Licensed under cc by-sa 3.0 with attribution required.