Java Multi Thread 환경에서 Map과 List 사용 시 주의할 점

@ZungTa · 2024-07-05 금요일 · 7 min read

Error 현상
예제 코드
오류가 발생하는 상황
Collections.synchronizedMap
ConcurrentHashMap
synchronized block
서버에서만 발생하는 특징
Info
References

Error 현상

Java Spring Boot로 개발을 할 때 겪은 오류이다.

내부적으로 Map 데이터를 사용하고 있다.

그 데이터를 다른 Thread에서 수시로 수정을 하고 있다.

또 다른 Thread에서 map.values().stream().toList() 이렇게 List로 만들어서 전송한다.

서버를 실행할 때 가끔 오류가 발생했다.

오류의 내용은 다양했다.

java.lang.IllegalStateException: End size 195 is less than fixed size 196

java.lang.IllegalStateException: Accept exceeded fixed size of 4956

java.lang.IllegalStateException: Begin size -1 is not equal to fixed size 5561

오류가 발생하는 위치를 찾아보니 map.values().stream().toList()를 할 때 java.util.stream.Nodes 에서 발생하였다.

size-error

size-error

nodes-code

nodes-code

size-error-2

size-error-2

nodes-code-2

nodes-code-2

디버깅을 진행하면서 가설을 세워봤다.

ArrayNode 초기화 할 때 첫번째 줄 실행하고 두번째 줄 실행하기 전에 array의 내용이 바뀐다면?

array 넣을 땐 20개짜리 배열이었는데 curSize 넣을 땐 26개가 된다거나.

nodes-code-3

nodes-code-3

그러다가 여러 Thread가 해당 map에 계속 접근을 하여 수정을 하는 부분에 문제가 있을 것이라고 생각했고, 동시성 이슈일 것이라고 생각했다.

동시성 이슈의 해결 방법에는 몇 가지가 있는데 나는 간단하게 Class 내부적으로 동시성 처리를 지원(Thread-safe)하는 ConcurrentHashMap을 사용하여 해결하였다.

방법은 세 가지 정도 있다.

  1. Collections.synchronizedMap

    • 객체 수준 잠금. 오버 헤드 증가
    • 아래에서 설명하겠지만 이걸로는 해결이 안 되었다.
    Map<K, V> synchronizedMap = Collections.synchronizedMap(new HashMap<>());
  2. ConcurrentHashMap

    • 해시맵 버킷 수준 잠금. 빠르고 효율적
    Map<K, V> concurrentMap = new ConcurrentHashMap<>();
  3. synchronized block

    • 직접 동시성 처리
    • 작업 전체를 lock 하기 때문에 성능 저하
    synchronized(map) {
    	// HashMap 수정 작업
    }

예제 코드

오류가 발생하는 상황

thread1 에서는 map을 수정하고 있고, thread2 에서는 map을 List로 변환하고 있다.

toList 하는 과정에서 무언가의 불일치(아마 size)로 인해 오류가 발생하는 것을 확인할 수 있다.

public class Main {

  public static void main(String[] args) {

    final Map<Integer, Integer> map = new HashMap<>();

    Thread thread1 = new Thread(() -> {
      while (true) {
        Integer randomInt = (int) (Math.random() * 10000);
        map.put(randomInt, randomInt);

        try {
          Thread.sleep(20);
        } catch (InterruptedException e) {
          throw new RuntimeException(e);
        }
      }
    });

    Thread thread2 = new Thread(() -> {
      while (true) {
        try {
          List<Integer> randomList = map.values().stream().toList();
        } catch (Exception e) {
          System.out.println("thread2 exception");
          e.printStackTrace();
        }

        try {
          Thread.sleep(40);
        } catch (InterruptedException e) {
          throw new RuntimeException(e);
        }
      }
    });

    thread1.start();
    thread2.start();
  }
}

Collections.synchronizedMap

public class Main {

  public static void main(String[] args) {

    final Map<Integer, Integer> synchronizedMap = Collections.synchronizedMap(new HashMap<>());

    Thread thread1 = new Thread(() -> {
      while (true) {
        Integer randomInt = (int) (Math.random() * 10000);
        synchronizedMap.put(randomInt, randomInt);

        try {
          Thread.sleep(20);
        } catch (InterruptedException e) {
          throw new RuntimeException(e);
        }
      }
    });

    Thread thread2 = new Thread(() -> {
      while (true) {
        try {
          List<Integer> randomList = synchronizedMap.values().stream().toList();
        } catch (Exception e) {
          System.out.println("thread2 exception");
          e.printStackTrace();
        }

        try {
          Thread.sleep(40);
        } catch (InterruptedException e) {
          throw new RuntimeException(e);
        }
      }
    });

    thread1.start();
    thread2.start();
  }
}

Collections.synchronizedMap(new HashMap<>()) 을 하면 SynchronizedMap을 생성해서 반환해준다.

collections-code

collections-code

그리고 SynchronizedMap 내부 메서드들은 synchronized가 걸려있다.

그런데 왜 오류가 발생할까?

synchronizedMap.values().stream().toList()를 할 때 values() 까지만 lock이 걸리고 그 이후는 lock이 풀리기 때문에 여전히 toList에서 map의 내용이 바뀔 수 있다.

collections-code-2

collections-code-2

ConcurrentHashMap

public class Main {

  public static void main(String[] args) {

    final ConcurrentMap<Integer, Integer> concurrentMap = new ConcurrentHashMap<>();

    Thread thread1 = new Thread(() -> {
      while (true) {
        Integer randomInt = (int) (Math.random() * 10000);
        concurrentMap.put(randomInt, randomInt);

        try {
          Thread.sleep(20);
        } catch (InterruptedException e) {
          throw new RuntimeException(e);
        }
      }
    });

    Thread thread2 = new Thread(() -> {
      while (true) {
        try {
          List<Integer> randomList = concurrentMap.values().stream().toList();
        } catch (Exception e) {
          System.out.println("thread2 exception");
          e.printStackTrace();
        }

        try {
          Thread.sleep(40);
        } catch (InterruptedException e) {
          throw new RuntimeException(e);
        }
      }
    });

    thread1.start();
    thread2.start();
  }
}

그렇다면 ConcurrentMap은 어떻게 동작하길래 오류가 발생하지 않는 걸까?

내용을 정리하기 전에 알아둬야할 용어가 있다.

Concurrent Modification

  • 다른 thread가 Collection을 순회하는 도중에 collection의 내용을 변경

Fail-Fast

  • Concurrent Modification 발생 시 ConcurrentModificationException을 던진다.

Fail-Safe

  • Collection 순회 시 복사본을 이용하기 때문에 값이 수정되어도 안전하다.

Weak Consistency

  • Stream이 생성된 이후 이뤄진 수정 작업은 Stream 결과에 반영될 수도 있고 아닐 수도 있다.

HashMap의 경우 Fail-Fast iterator로 동작을 하며 데이터 순회 중 데이터가 변경되었을 때 ConcurrentModificationException를 발생시킨다.

ConcurrentHashMap의 경우 Fail-Safe Iterator로 동작을 하며 데이터를 복제하여 순회하기 때문에 동시적 데이터 변경에는 안전하고 Weak Consistency를 가진다.

디버깅을 하면서 내부 소스로 파고 들어가보니 값을 복제해서 사용하는 것 같다.

abstract-pipe-line

abstract-pipe-line

spined-buffer

spined-buffer

synchronized block

public class Main {

  public static void main(String[] args) {

    final Map<Integer, Integer> map = new HashMap<>();

    Thread thread1 = new Thread(() -> {
      while (true) {
        Integer randomInt = (int) (Math.random() * 10000);
        synchronized (map) {
          map.put(randomInt, randomInt);
        }

        try {
          Thread.sleep(20);
        } catch (InterruptedException e) {
          throw new RuntimeException(e);
        }
      }
    });

    Thread thread2 = new Thread(() -> {
      while (true) {
        try {
          synchronized (map) {
            List<Integer> randomList = map.values().stream().toList();
          }
        } catch (Exception e) {
          System.out.println("thread2 exception");
          e.printStackTrace();
        }

        try {
          Thread.sleep(40);
        } catch (InterruptedException e) {
          throw new RuntimeException(e);
        }
      }
    });

    thread1.start();
    thread2.start();
  }
}

서버에서만 발생하는 특징

내가 겪은 이 오류의 특징은 서버가 처음 시작될 때 발생하면 쭉 발생하고, 처음 시작될 때 발생하지 않으면 쭉 발생하지 않는다.

작은 샘플 프로젝트로 에러를 재 구현 했을 때는 그런 현상이 없었다.

이 원인에 대해서 정확히 파악할 순 없지만 ChatGPT를 통해 몇 가지 추측을 알아냈다.

오류가 서버 시작 시 발생하면 계속 발생하고, 처음에 발생하지 않으면 쭉 발생하지 않는 현상은 상태 불일치 또는 경합 조건(race condition) 에 의해 발생하는 전형적인 문제로 보입니다. 이런 문제는 시스템이 초기화될 때 특정한 순서로 작업이 실행될 경우 발생하거나, 특정 상태가 만들어지면 이후로도 계속 문제가 발생할 수 있습니다.

가능한 원인

초기화 시점의 경합 조건:
서버가 시작될 때 여러 스레드가 동시에 HashMap에 접근하여 초기 데이터를 삽입하거나 수정하는 작업을 할 때, 특정 순서로 접근이 이루어지면 HashMap의 상태가 불안정해질 수 있습니다. 예를 들어, 서버 초기화 중 특정 스레드가 HashMap을 변경하고 다른 스레드가 동시에 이 데이터를 사용하려 하면, 데이터 불일치나 상태 불일치가 발생할 수 있습니다.
이 문제는 HashMap의 상태가 초기화 시점에 잘못된 상태로 설정되면 이후에도 계속 그 상태가 유지되기 때문에 발생합니다. 반대로 초기화 시점에 문제없이 설정되면 이후에는 문제가 발생하지 않습니다.

초기 데이터 로드 문제:
서버가 시작될 때, HashMap이 특정 초기 데이터로 채워지거나 외부에서 데이터를 로드할 때 문제가 발생할 수 있습니다. 이 과정에서 HashMap이 여러 스레드에 의해 동시에 수정되면, 이후의 스트림 처리에서도 불일치 상태가 유지됩니다.
만약 초기화 단계에서 이러한 문제가 발생하지 않으면 이후에는 안정적인 상태가 유지될 수 있습니다. 이는 초기화 이후로 HashMap에 대한 접근이 비교적 안정적이기 때문일 수 있습니다.

캐시나 지연된 초기화 문제:
서버 시작 시 HashMap이 특정 서비스나 캐시에 의해 지연 초기화(lazy initialization) 되는 경우, 이 과정에서 동시성이 제대로 처리되지 않으면 문제가 발생할 수 있습니다. 이 경우 초기화가 제대로 되지 않으면 계속 문제가 발생하게 됩니다.

JVM 또는 시스템 환경 문제:
특정 JVM 설정이나 시스템 리소스 상태에 따라 초기화 시점에서만 발생하는 경합 조건이나 리소스 경쟁이 있을 수 있습니다. 이는 서버가 시작될 때 부하가 걸리거나 리소스가 불안정한 상태에서만 문제가 발생하는 것과 유사합니다.

문제 해결 방법

서버 시작 시점 동기화:
서버 초기화 시점에 HashMap을 설정하는 모든 작업을 명시적으로 동기화하여, 한 스레드가 초기화 작업을 독점하도록 설정합니다. 이렇게 하면 초기화 시점의 경합 조건을 방지할 수 있습니다.

스레드 안전한 자료구조 사용:
HashMap 대신 ConcurrentHashMap과 같은 스레드 안전한 자료구조를 사용하여 초기화 시점의 경합 조건을 방지합니다. 이를 통해 초기화 과정에서의 상태 불일치를 최소화할 수 있습니다.

초기화 순서 확인:
서버 시작 시 HashMap을 초기화하는 코드의 순서를 철저히 검토하여, 모든 초기화 작업이 완전하게 끝난 후에만 다른 스레드가 접근할 수 있도록 조치합니다.

지연 초기화 지연 방지:
지연 초기화가 문제가 될 수 있으므로, 필요하다면 서버 시작 시 모든 초기화가 완료되도록 하고, 이후 접근 시 문제가 발생하지 않도록 합니다.

로깅 및 디버깅 강화:
초기화 시점의 동작을 자세히 로깅하여 문제 발생 시 어떤 스레드가 어떤 작업을 하고 있는지 파악할 수 있도록 합니다. 이를 통해 문제의 근본 원인을 정확하게 진단할 수 있습니다.

결론
서버 시작 시 발생하는 문제는 주로 초기화 단계에서의 동기화 문제나 경합 조건 때문에 발생할 가능성이 큽니다. 이를 해결하려면 초기화 과정에서의 동기화를 철저히 관리하고, 스레드 안전한 자료구조를 사용하는 것이 좋습니다. 이를 통해 초기화 시점의 문제가 이후의 지속적인 문제로 이어지는 것을 방지할 수 있습니다.

Info

  • Java 17 (corretto-17.0.12)
  • Spring Boot 3.2.1

References

@ZungTa
I'm a backend developer
© ZungTa Devlog, Built with Gatsby