-
Java ArrayList 동시성 문제와 성능Java & Kotlin 2025. 1. 26. 22:31반응형
1) 서론
이번에는 Java ArrayList를 사용하며 동시성 문제가 발생했고, 이것을 해결하기 위해서 확인해본 다양한 선택들의 과정을 기록합니다.
2) 문제
전체적인 흐름은 이렇습니다.
- 외부 서버를 병렬로 요청
- 외부 서버로부터의 응답들을 List 객체에 담음
- List 객체에 담겨진 전체 응답들을 처리
간단하게 테스트 코드를 작성해 봅니다.
JDK 21을 사용했고, 가상스레드를 이용해서 병렬처리 합니다.
메서드 상단에 정의한 @ValuSource에서 호출할 횟수를 정의하고 있습니다. 호출한 횟수만큼 응답이 있을 것이고, 응답을 저장한 List 객체의 사이즈는 호출한 횟수와 동일해야 합니다.
결과는 어떻게 될까요?// 기대한 List에 담긴 원소의 수가 불일치 // 1_000번 Expected :1000 Actual :965 // 10_000번 Expected :10000 Actual :9291 // 100_000번 Expected :100000 Actual :99649
10번의 요청에서만 저장된 응답 개수가 일치하고, 나머지는 실제로 저장되어야 할 갯수만큼 List에 저장되지 않았습니다.
왜 그런 걸까요?
3) ArrayList 내부 구현을 보자
- List를 조회하기 위해서 next 등의 연산으로 순회할 때 modCount를 내부의 expectedModCount 변수에 할당하고 비교합니다. 이때 순회 중에 리스트에 변형이 생겨서 modCount와 expectedModCount 달라지게 되면 ConcurrentModificationException이 발생합니다.
- List 객체가 가지고 있는 원소들은 elementData 변수에 담겨있고, 이것을 add 연산을 위해서 전달합니다
- add 연산 시 elementData 변수에 index를 지정하여 원소를 넣고 있습니다
elementData, size는 ArrayList의 멤버변수입니다. new ArrayList() 형태로 객체가 초기화된 후 add 연산은 모두 동일한 size, elementData를 바라봅니다. 즉 add를 멀티스레드 환경에서 사용하게 되면 동일한 size 필드를 조회하고, elementData의 index로 사용됩니다.
당연히 동시성 문제가 발생할 수밖에 없습니다.
이러한 문제는 자바에서 제공하는 HashMap, HashSet 객체들에서 모두 동일한 문제가 있습니다.
4) 어떻게 동시성 문제를 해결할 수 있을까요?
기존의 List 객체를 아래와 같이 변경합니다.
// 기존 new ArrayList<Boolean>(); new ArrayList<Future<Boolean>>() // 변경 Collections.synchronizedList(new ArrayList<Boolean>()); Collections.synchronizedList(new ArrayList<Future<Boolean>>());
Collections.synchronizedList에게 생성할 List 객체를 전달하고, 해당 객체를 다시 SynchronizedList 객체로 전달해서 초기화합니다. 이름에서부터 유추할 수 있듯이 동기화된 리스트 객체를 생성하는 것을 유추해 볼 수 있습니다.
SynchronizedList에서 add 연산을 하게 되면 내부적으로 mutex를 이용해서 critical section을 만들고 lock을 획득한 스레드만 add 연산하도록 처리합니다.
테스트도 잘 통과하는 것을 볼 수 있습니다.
Write(add) 연산에서 critical section을 생성하고 있기 때문에 write(add) 연산 성능에서 손해를 볼 수밖에 없습니다. 멀티 스레드 환경이지만 실제로 연산은 순차적으로 처리되고, 그동안 다른 스레드는 대기할 수밖에 없습니다. 하지만 위의 경우와 같이 외부(다른 서버)로의 통신이 있고 응답이 오래 걸려서 병렬처리가 필요하고, 결과를 저장해야 할 때에는 성능 향상을 얻을 수 있습니다. 왜냐하면 List 객체에 담는 것의 지연보다 외부 통신의 지연이 더 크다고 판단할 수 있기 때문입니다.
하지만 SynchronizedList는 조회(get) 연산에서도 critical section을 생성하기 때문에 위의 경우에는 불필요합니다. 어차피 for문이 동기적으로 돌면서, 대외 통신에 대한 응답을 반드시 기다려야 하기 때문입니다. 이런 경우에는 CopyOnWriteArrayList 클래스를 사용해서 조회 연산에서는 critical section을 사용하지 않는 것도 방법입니다.
5) CopyOnWriteArrayList도 가능하지만... 성능이 문제
조회에 있어서 동시성 문제가 없음을 보장하는 것이 필요 없다면 CopyOnWriteArrayList도 사용 가능합니다. 하지만 쓰기에서 성능적으로 큰 단점이 있습니다.
순서대로 SynchronizedList, CopyOnWriteArrayList write(add) 연산 시 메모리 할당에 대한 profiling 결과인데요.
CopyOnWriteArrayList는 wrtie(add) 연산 시 단순히 기존 원소 배열에 값을 추가하는 것이 아니라, copy 연산으로 Object 배열 객체를 지속적으로 생성하고 있습니다. 생성된 Object 배열은 20GB이고 약 1백만 배 차이입니다.
또한 CopyOnWriteArrayList에서 생성한 객체들을 해제해 주기 위해서 GC가 훨씬 더 자주 동작한 것을 볼 수 있습니다.
이렇게 메모리를 많이 생성하는데에는 CopyOnWriteArrayList의 아래 로직이 문제인데요. Write를 할 때에 copyOf()를 통해서 객체를 생성하고, 이것을 다시 배열에 할당해주고 있습니다. Write, Read 간 참조하는 객체의 reference가 다르기 때문에 read(get) 로직에서는 한번 읽은 객체는 항상 같은 값임을 보장할 수 있습니다. 메모리 소비와 GC 수행이 늘어난다는 단점이 있지만 읽기 동시성 보장을 위해서 이렇게 처리하고 있는것을 알 수 있습니다.
반면에 read(get) 연산 시에는 곧바로 배열의 reference를 반환하기 때문에 critical section을 생성하지 않기 때문에 속도의 이점이 있습니다. 하지만 이 글의 예시는 for문을 싱글스레드가 동기적으로 반복하기 때문에 이점이 없습니다.
즉 두 클래스간 어떤 것을 선택해야 할지는 메모리 소비 관점, 속도에 대한 관점으로 비교해서 선택하면 된다고 생각합니다.반응형'Java & Kotlin' 카테고리의 다른 글
인터페이스와 제네릭을 이용한 공통 로직 관리 (1) 2024.01.01 ExecutorService 그리고 maxThreadPool (1) 2023.11.15 JVM OOM 발생 및 원인 분석하기 (0) 2023.09.22 UTF-8, EUC-KR 인코딩 파일 읽어들이기 (0) 2023.09.03 심심해서 살펴본 Querydsl fetchOne() 구현 (0) 2023.08.27