Synchronized vs CAS가 속도 차이가 나는 이유가 뭘까?

uncle.ra·2023년 8월 20일
2
post-thumbnail

주의 사항

주의!) 많이 고민하고 적은 내용이지만 제 의견도 포함되어 있는 글입니다! 혹시나 잘못된 부분이 있으면 지적 부탁드릴게요🙏

주의2!) 테스트는 Counter를 증가시키는 단순한 연산으로 진행했어요. 복잡한 연산으로 진행했을 때의 결과는 다를 수 있으니 이 점 참고 부탁드릴게요!

시작하기 전에

여러 스레드가 하나의 공유 자원에 접근할 때 어떻게 처리할 것인가? 🤔

최근에 Java에 대해 알아가면서 위 문장에 대한 중요성을 깨닫고 있다.

Immutable Object, volatile, synchronized, CAS Algorithm, 동기화, 동시성, 가시성, 원자성, ConcurrentHashMap 등 계속해서 새로운 keyword들이 튀어 나온다.

조바심이 나더라도 깊이 있게 가자는 생각으로..!
동기화를 보장하기 위한 방법 중 대표적인 두 가지, synchronized keywordCAS Algorithm에 대해서 성능 비교를 해보고자 한다.

성능 비교는 아래와 같이 진행했다.

synchronized keyword와 CAS Algorithm을 활용한 Class인 AtomicInteger로 테스트를 진행했다. 각각 100만 번을 순회하면서 Counter를 증가시키는 연산을 수행하는데 걸리는 시간을 테스트 했다. thread 갯수는 2개, 4개, 10개, 50개로 나눠서 테스트를 진행해 보았다 🧐

import java.util.concurrent.atomic.AtomicInteger;

public class SynchronizedVsCASPerformance {
	
	private static final int NUM_THREADS = 4;
	private static final int NUM_ITERATIONS = 1_000_000;
	
	private static int synchronizedCounter = 0;
	private static AtomicInteger casCounter = new AtomicInteger(0);
	
	public static void main(String[] args) throws InterruptedException {
		Thread[] synchronizedThreads = new Thread[NUM_THREADS];
		Thread[] casThreads = new Thread[NUM_THREADS];
		
		// Synchronized Test
		long beforeTestWithSynchronized = System.nanoTime();
		for (int i = 0; i < NUM_THREADS; i++) {
			synchronizedThreads[i] = new Thread(() -> {
				for (int j = 0; j < NUM_ITERATIONS; j++) {
					synchronized (SynchronizedVsCASPerformance.class) {
						synchronizedCounter++;
					}
				}
			});
			synchronizedThreads[i].start();
		}
		
		for (Thread thread : synchronizedThreads) {
			thread.join();
		}
		
		// System.out.println("Synchronized Counter: " + synchronizedCounter);
		long afterTestWithSynchronized = System.nanoTime();
		long performanceTimeWithSynchronized = afterTestWithSynchronized - beforeTestWithSynchronized;
		
		// CAS Test
		long beforeTestWithCAS = System.nanoTime();
		for (int i = 0; i < NUM_THREADS; i++) {
			casThreads[i] = new Thread(() -> {
				for (int j = 0; j < NUM_ITERATIONS; j++) {
					casCounter.getAndIncrement();
				}
			});
			casThreads[i].start();
		}
		
		for (Thread thread : casThreads) {
			thread.join();
		}
		
		// System.out.println("CAS Counter: " + casCounter.get());
		long afterTestWithCAS = System.nanoTime();
		long performanceTimeWithCAS = afterTestWithCAS - beforeTestWithCAS;
		
		System.out.println("synchronized performance : " + performanceTimeWithSynchronized);
		System.out.println("CAS performance : " + performanceTimeWithCAS);
		System.out.print("Who is Faster : ");
		if (performanceTimeWithCAS < performanceTimeWithSynchronized) {
			System.out.println("CAS");
		} else if (performanceTimeWithCAS > performanceTimeWithSynchronized) {
			System.out.println("synchronized");
		} else {
			System.out.println("Same");
		}
	}
}

테스트 결과는?!

Threadsynchronized(s)CAS(s)Faster
20.128s0.059sCAS
40.666s0.147sCAS
101.790s0.533sCAS
507.935s2.524sCAS

테스트 결과를 표로 정리해보았다. CAS Algorithm이 더 빠르다고 나타난다. 보통 2.5배 ~ 3배 정도의 성능 차이를 보이는데 이렇게 차이가 나는 이유가 뭘까?

synchronized keyword와 CAS Algorithm이 속도 차이가 나는 이유가 뭘까?

1) synchronized method/block은 lock을 취득/해제하는 과정에서 Context Switching이 발생한다.

객체마다 JVM 내부에서 관리하는 header가 존재한다. header 내에는 is_locked/is_unlocked 변수가 존재하고 해당 객체에 대한 locked여부 상태를 나타낸다.

synchronized method/block에 thread가 진입할 때, 공유 자원(객체)에 대한 lock을 취득했을 경우 진입이 가능하고 취득하지 못할 경우에는 대기 상태로 entry queue에서 머물게 된다.

진입한 thread가 synchronized method/block을 벗어날 때 lock을 반환하게 하게 되면 entry queue에서 대기하고 있던 스레드 중 하나가 다시 객체의 lock을 획득하고 진입하게 된다. 이 때 Context Switching이 발생하게 되고 발생하는 만큼 오버헤드를 유발하게 된다.

2) CAS Algorithm은 하드웨어적인 도움을 받는다.

아래의 코드는 getAndIncrement() method의 내부 구현 되어 있는 코드를 순차적으로 나열했다.

1번 부터 6번까지 주석으로 순차적으로 매기면서 설명을 달았는데 확인해보자.

public class AtomicInteger {
	
	// volatile keyword로 선언된 변수
	private volatile int value;
	
    // 1. getAndIncrement() method
	public final int getAndIncrement() {
        return U.getAndAddInt(this, VALUE, 1);
    }
    
    
    // 2. getAndAddInt() method
	@HotSpotIntrinsicCandidate
	public final int getAndAddInt(Object o, long offset, int delta) {
		int v;
		do {
			// getIntVolatile() method는 native code로, 실제 메모리!에서 v값을 가져온다.
			v = getIntVolatile(o, offset);
					
            // 여기서 o, offset은 실제 값의 메모리 위치를 찾기위한 매개변수이며, v, v + delta는 각각 현재 값과, 변경할 값을 나타낸다.
		} while (!weakCompareAndSetInt(o, offset, v, v + delta)); 
		return v;
	}
		
	//in Unsafe.java method
    // 3. weakCompareAndSetInt() method
	@HotSpotIntrinsicCandidate
	public final boolean weakCompareAndSetInt(Object o, long offset,
		int expected,
		int x) {
		return compareAndSetInt(o, offset, expected, x);
	}
	
	//in Unsafe.java native method
    // 4. 해당 native method 에서 매개변수인 expected값을 실제 메모리 값과 비교를 하게 된다. 이 때, 같을 경우 변경할 값인 x로 변경이 되고 true를 반환한다.
    // 다를 경우 false를 반환한다.
	@HotSpotIntrinsicCandidate
	public final native boolean compareAndSetInt(Object o, long offset,
      int expected,
    int x);
		
}

위의 코드와 주석을 통해서 내부 구현이 어떻게 되어있는지 확인해봤다.

volatile 변수를 통해서 값을 가져오고 native method 안에서도 한번 더 일치한지 검사한다고 해서 정말 원자성이 보장이 될까? CPU가 연산하는 중간에 다른 스레드로 Context Switching이 일어난다면?🤔

int v = 10;
v++;

이 코드는 CPU가 연산을 할 때 총 3번으로 나뉜다.

  1. 변수 v의 값(10)을 읽는다.(Read)
  2. 값에 1을 추가한다.(Modify)
  3. 변수 v에 추가한 값(11)을 저장한다.(Write)

CPU가 3개의 연산을 수행하는 중간에 다른 thread로 Context Switching이 일어나면서 다른 thread가 해당 자원에 접근했을 경우 원자성을 보장하지 못하는 원리인데

Read-Modify-Write 과정을 하나의 명령어로 처리하는 CPU의 도움을 받을 수 있다. 그렇기 때문에 결국 Thread-Safe하며, 성능적인 이점을 가져갈 수 있다.🧐

정리 & 마치면서

synchronized keyword vs CAS Algorithm이 성능 차이가 나는 이유를 총 두 가지로 정리했다.

1) synchronized method/block은 lock을 취득/해제하는 과정에서 Context Switching이 발생한다.
2) CAS Algorithm은 하드웨어적인 도움을 받는다.

다른 이유도 찾아보면서 발견했지만 위 두 가지가 성능 차이가 나는 요인 중에 핵심이라고 느꼈다. 🧐

CAS Algorithm을 공부하면서 계속 의문이었던 부분은 정말 원자성이 보장이 될까?였다. 이 부분에 대해서 궁금증을 해소하고자 계속 들여다 보던 중, CPU의 하드웨어적인 도움을 받는다는 사실을 얻게 되어서 나름 보람을 느꼈다😄

참조

Difference Between Atomic, Volatile, and Synchronized In java.

0개의 댓글