Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

CopyOnWriteArrayList vs SynchronizedList #31

Open
ruthetum opened this issue Oct 13, 2024 · 0 comments
Open

CopyOnWriteArrayList vs SynchronizedList #31

ruthetum opened this issue Oct 13, 2024 · 0 comments
Assignees
Labels

Comments

@ruthetum
Copy link
Member

Insight

이번 장에서 동시성 컬렉션을 다루게 되었는데요

관련하여 ArrayList에서 thread safe을 보장하는 CopyOnWriteArrayListSynchronizedList 두 가지 방법 중 정말 CopyOnWriteArrayList가 성능이 더 좋은지 확인해보는 간단한 실험을 진행해봤습니다.

  • SynchronizedList의 의미는 Collections.synchronizedList(new ArrayList<>()를 의미합니다.

추가로 성능을 측정하기 위해 JVM 환경에서 많이 사용하는 JMH도 함께 안내해보고자 합니다.☺️


JMH의 경우 JMH의 모든 부분을 소개하기 보다는 JMH를 왜 사용하는지, 어떻게 사용하면 되는지 정도를 간단하게 설명합니다.

JMH를 사용해본 분들이 계시다면 스터디 시간에 조금 더 추가 설명을 나눠주시면 좋을 것 같습니다.


성능 측정과 JMH

성능 측정

보통 성능 측정을 진행한다고 하면 일반적으로 부하 테스트를 많이 생각하는데요.

  • 대표적으로 nGrinder, jMeter, k6 같은 툴을 이용해서 애플리케이션에 부하를 주고 처리량 및 병목 지점을 확인하게 됩니다.

하지만 위 방법의 경우 애플리케이션을 통째로 실행하고 통합적인 기능에 대한 성능을 측정하는 방법입니다.

단위 기능에 대한 성능 측정 관점에서 고려했을 때에는 너무 비싼 비용을 지불하여 테스트를 수행하게 됩니다.

그렇다면 단위 기능에 대한 성능 측정은 어떻게 하는 게 좋을까요?

간단하게 진행한다면 System.currentTimeMillis() 또는 Spring에서 제공하는 StopWatch를 이용하여 메서드의 수행시간을 측정해본 경험이 있을 것이라 생각됩니다.

  • 하지만 이는 단순히 메서드가 한번 호출됐을 때 시간을 측정을 하는 방법으로 성능에 대해 오인(테스트 순간 디바이스의 CPU에 영향이 있는 경우)할 수 있는 가능성이 존재합니다.
  • 또한 이를 방지하기 위해 여러 번 호출하고 개발자가 직접 호출한 시간들을 집계한다면 생산성이 많이 떨어질 것 입니다.

JMH (Java Microbenchmark Harness)

https://github.com/openjdk/jmh

그래서 이러한 단위 기능에 대한 성능 측정을 위한 도구, 흔히 벤치마크 도구라 불리는 툴들이 존재하고 JVM환경에서는 JMH를 많이 사용합니다.

사용 방법

크게 세 가지 방법이 존재합니다.

  1. 별도 애플리케이션에서 main 함수로 실행하는 방법 (추천 X)
  2. jmh 플러그인과 디펜던시를 추가하여 실행하는 방법 (추천 O)
  3. jmh 디펜던시를 추가하고 테스트 디렉토리 및 코드에서 실행하는 방법 (추천 O)

오늘은 예시는 3번 방법을 통해 간단히 성능 측정을 수행하는 방법을 안내합니다.

1번 방법의 경우 간단한 코드라면 복붙해서 테스트가 가능하겠지만, 일반적으로 여러 클래스를 별도 환경에서 복사해서 가져오는 건 매우 귀찮은 일입니다. 또한 벤치마크 코드를 소스코드 레포지터리에 함께 보관할 수 없기 때문에 추후 구성원들이 재측정 시 접근할 때 제약이 있습니다.

2, 3번의 경우 소스 코드 레포지터리에 함께 보관하고, 정의한 클래스를 바로 임포트해서 사용할 수 있기 때문에 편리하게 사용이 가능합니다.

  • 2번 같은 경우 java-test-fixtures 플러그인을 사용할 때 처럼 src, test 외에 별도 디렉토리(jmh)를 만들고, 그곳에서 실행하는 것을 의미합니다.

디펜던시 추가

dependencies {
    implementation "org.openjdk.jmh:jmh-core:1.37"
    implementation "org.openjdk.jmh:jmh-generator-annprocess:1.37"
}

예시 코드

@State(Scope.Thread)
@BenchmarkMode(Mode.Throughput)
@Warmup(iterations = 3, time = 500, timeUnit = MILLISECONDS)
@Measurement(iterations = 5, time = 1000, timeUnit = MILLISECONDS)
@OutputTimeUnit(MILLISECONDS)
@Fork(1)
public class BenchmarkTest {

     ...
     
     @Benchmark
      public void doBenchmark() {
          doSomething()
      }
    
     ...
}

주요 어노테이션을 아래와 같습니다.

  • @State: 벤치마크에 사용되어지는 Argument의 적용 범위 설정

    • Thread(쓰레드별 각자 상태), Group(쓰레드 그룹별 각자 상태), Benchmark(벤치마크 대상 모든 쓰레드가 동일한 상태 객체 공유)
  • @BenchmarkMode: 처리량(Throughput), 평균 시간(AverageTime) 등 측정할 메트릭 설정

  • @Measurement: 실행 당 측정 횟수 지정

  • @OutputTimeUnit: 출력 시간의 단위

  • @Fork: 실행 횟수

  • @Benchmark: 측정 대상


CopyOnWriteArrayList vs SynchronizedList

전체 코드는 여기서 확인이 가능합니다.

그렇다면 JMH를 이용해서 thread safe한 ArrayList의 성능을 비교해봅시다.


가설 및 방법

일반적으로는 CopyOnWriteArrayList(낙관적 접근)이 SynchronizedList(비관적 접근)보다 성능이 좋을 것 같습니다.

그렇다면 해당 리스트에 읽기/쓰기 작업의 비율을 바꿔가면서 모든 상황에서 CopyOnWriteArrayList의 성능이 좋을지 확인을 해보면 됩니다.

  • 물론 리스트의 사이즈, 동시 접근 수(유압) 등 외부 요인이 존재하지만 본 실험에서는 외부 요인은 배제하도록 하겠습니다.

실험은 size가 1000인 각각의 리스트가 존재하고, 이 리스트를 아래의 읽기/쓰기 비율로 실행한 후 성능을 측정합니다.

  1. 읽기 < 쓰기 - 읽기:쓰기 = 2:8
  2. 읽기 = 쓰기 - 읽기:쓰기 = 5:5
  3. 읽기 > 쓰기 - 읽기:쓰기 = 8:2

실험 결과

급하게 내용을 쓰느라 실제 벤치마크 결과를 남기지는 못 하네요...🥲

메트릭 결과를 요약해보면 아래와 같습니다.

  • 각 클래스 비교 시 부등호에서 '크다'라는 의미는 성능이 좋다라는 의미입니다.

읽기 < 쓰기

CopyOnWriteArrayList < SynchronizedList

읽기 = 쓰기

CopyOnWriteArrayList > SynchronizedList

읽기 < 쓰기

CopyOnWriteArrayList > SynchronizedList


결론

모든 상황에서 CopyOnWriteArrayList가 성능이 우수할 줄 알았지만, 오히려 쓰기 비율이 많은 작업의 경우, 성능이 저하되는 현상이 발생했습니다.

  • 이에 대한 자세한 이유도 깊게 다루면 좋겠지만, 간단히 내용을 정리하면 CopyOnWriteArrayList의 내부 구현에서 쓰기 작업이 발생하는 경우 리스트를 복제해서 데이터를 추가하는 형태로 구현이 되어집니다.

  • 따라서 데이터의 수가 많으면 많을수록 SynchronizedList의 Lock 취득 대기시간보다 리스트 복제 시간이 길어지면서 오히려 성능이 저하되는 것을 유추해볼 수 있습니다.

마지막으로 내용을 정리해보면 대부분의 경우에는 Synchronized를 사용하는 경우보다 동시성 컬렉션을 활용하는 경우가 더 좋은 성능을 기대할 수 있습니다.

하지만 개발이라는 환경에서는 Silver bullet은 없기에 주어진 요구사항 및 비즈니스 상황에 따라 적합한 자료 및 기술을 사용해야 할 것 같습니다.

  • 물론 CopyOnWriteArrayList가 본 실험 상황에서 약간 성능이 떨어졌을 뿐이고, 대부분의 경우에서는 더 좋은 성능이 나올 겁니다.

  • 실험의 요지는 모든 상황을 최적화해서 해결할 수 있는 완벽한 기술은 없고, 개선할 포인트들은 항상 존재하니까 열심히 일하자(?)

@ruthetum ruthetum self-assigned this Oct 13, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests

1 participant