OverView

이번시간에는 java에서 synchronized 키워드에 대해서 알아보도록 하겠다.

multi-thread와 race-condition

multi-thread 환경에서 공유되는 자원을 서로 다른 thread가 동시에 접근해서 변경하려고하면 race-condition이 발생한다. 이런 race-condition은 데이터 정합성과 크게 연관되어 있기 때문에 multi-thread환경에서는 반드시 주의해야 한다.

간단한 예로 Thread A, Thread B가 있다고 가정했을때 하나의 공유되는 인스턴스의 class 변수인 count를 증가시키는 상황을 살펴보자.

Screenshot-2020-03-27-at-06 53 27-1024x235

출처 : https://www.baeldung.com/java-testing-multithreaded

위와 같은 상황은 Thread A, Thread B가 격리된 상태로 동작하기 때문에 count 값이 2번 증가된 값으로 출력된다. 하지만 만약 동시에 같은 자원에 접근한다면 어떻게 될까?

Screenshot-2020-03-27-at-06 54 15-1024x243

출처 : https://www.baeldung.com/java-testing-multithreaded

위와 같은 상황은 Thread A, Thread B가 동시에 같은 값을 읽고 같은 값으로 증가시키기 때문에 count 값은 2가아닌 1로 출력이된다. 이는 데이터 정합성에 있어서 매우 주의해야하는 상황이다.

아래는 실제 MyObject.java 를 생성하고 테스트한 결과값이다.

MyObject.java

package me.sup2is;

public class MyObject {
    int count = 0;

    public void increaseCnt() {
        this.count ++;
    }

    public int getCount() {
        return count;
    }

    @Override
    public String toString() {
        return "MyObject{" +
            "count=" + count +
            '}';
    }
}


MyObjectTest.java

package me.sup2is;

import org.junit.jupiter.api.RepeatedTest;
import org.junit.jupiter.api.Test;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import static org.junit.jupiter.api.Assertions.*;

class MyObjectTest {

    @RepeatedTest(5)
    public void test_concurrency_not_safe() throws Exception{
        //given
        int numberOfThreads = 1000;
        ExecutorService service = Executors.newFixedThreadPool(numberOfThreads);
        CountDownLatch latch = new CountDownLatch(numberOfThreads);
        MyObject counter = new MyObject();

        //when
        for (int i = 0; i < numberOfThreads; i++) {
            service.execute(() -> {
                counter.increaseCnt();
                latch.countDown();
            });
        }

        //then
        latch.await();
        assertEquals(numberOfThreads, counter.getCount());
    }
}
  • Executors.newFixedThreadPool(numberOfThreads): numberOfThreads만큼의 스레드 풀을 생성
  • new CountDownLatch(numberOfThreads): CountDownLatch는 thread 관련 테스트할때 유용한데 간단하게 설명하면 인스턴스 생성시점에 int형 인자를 주었을때 latch.countDown();을 주어진 int형 인자만큼 실행시켜야 latch.await(); 이후 코드가 실행됨.


위 코드를 테스트해보면 다음과 같은 결과가 나온다.

20201102_233733

운이 좋을 경우 1~2개는 성공할 수있지만 대부분의 경우는 실패할 것이다.


java에서는 이런 multi-thread 환경에서 동시성을 제어하는 키워드인 synchronized 를 제공한다.

synchronized

java의 synchronized는 총 세가지로 분류할 수 있다.

  • Instance methods
  • Static methods
  • Code blocks

Instance Method 내부 synchronized

가장 간단한 방법은 값을 증가하는 메서드에 synchronized 키워드를 붙이는 방법이다.

MyObject.java

    public synchronized void increaseCntWithSynchronized() {
        this.count ++;
    }

MyObjectTest.java

    @RepeatedTest(5)
    public void test_concurrency_sync_method() throws Exception{
        //given
        int numberOfThreads = 1000;
        ExecutorService service = Executors.newFixedThreadPool(numberOfThreads);
        CountDownLatch latch = new CountDownLatch(numberOfThreads);
        MyObject counter = new MyObject();

        //when
        for (int i = 0; i < numberOfThreads; i++) {
            service.execute(() -> {
                counter.increaseCntWithSynchronized();
                latch.countDown();
            });
        }

        //then
        latch.await();
        assertEquals(numberOfThreads, counter.getCount());
    }

20201102_233747

인스턴스 메서드에 synchronized 를 사용하는 방법은 메서드를 소유한 클래스의 인스턴스를 통해 동기화하기 때문에 클래스 인스턴스 당 하나의 thread 만이 메서드를 실행할 수 있다.

Static Method 내부 synchronized

두번째 방법은 static method에 synchronized를 사용하는 방법이다. 이 방법은 위에서 설명한 방법과 유사해보이지만 약간 다르다.

MyObject.java

    static int staticCnt = 0;


...


	//static method에 synchronized 추가
    public static synchronized void increaseCntWithStaticSynchronized() {
        staticCnt ++;
    }

MyObjectTest.java

    @RepeatedTest(5)
    public void test_concurrency_static_sync_method() throws Exception{
        //given
        int numberOfThreads = 1000;
        ExecutorService service = Executors.newFixedThreadPool(numberOfThreads);
        CountDownLatch latch = new CountDownLatch(numberOfThreads);

        //when
        for (int i = 0; i < numberOfThreads; i++) {
            service.execute(() -> {
                MyObject.increaseCntWithStaticSynchronized();
                latch.countDown();
            });
        }

        //then
        latch.await();
        assertEquals(numberOfThreads, MyObject.getStaticCnt());

        MyObject.staticCnt = 0;
    }

20201102_233759

java의 static 키워드의 특징 상 JVM 힙의 Metaspace에 저장된다. 이 static method 들은 선언된 클래스와 연관이 있기 때문에 클래스의 선언 없이 변수에 접근하거나 메서드를 호출할 수 있다. 따라서 위에서 언급한 방법과는 다르게 선언된 클래스 당 하나의 thread 만이 메서드를 실행할 수 있다.

Code Blocks synchronized

synchronized 블록은 메서드 전체에 synchronized를 적용하지 않고 싶을때 사용할 수 있다.

MyObject.java

    public synchronized void increaseCntWithStaticBlock() {
        synchronized (this) {
            this.count ++;
        }
    }

MyObjectTest.java

    @RepeatedTest(5)
    public void test_concurrency_sync_block() throws Exception{
        //given
        int numberOfThreads = 1000;
        ExecutorService service = Executors.newFixedThreadPool(numberOfThreads);
        CountDownLatch latch = new CountDownLatch(numberOfThreads);
        MyObject counter = new MyObject();

        long start = System.currentTimeMillis();
        //when
        for (int i = 0; i < numberOfThreads; i++) {
            service.execute(() -> {
                counter.increaseCntWithStaticBlock();
                latch.countDown();
            });
        }
        long end = System.currentTimeMillis();
        System.out.println((end - start));

        //then
        latch.await();
        assertEquals(numberOfThreads, counter.getCount());
    }

20201102_233814

이 방법을 사용한다면 중첩해서 락을 획득할 수 있다.

    public synchronized void increaseCntWithStaticBlock() {
        synchronized (this) {
            this.count ++;
            synchronized (this) {
                System.out.println("연속적으로 lock 획득 가능" + count);
            }
        }
    }

20210428 추가내용

synchronized 블록을 사용함으로써 클라이언트 레벨로 명시적인 모니터 락을 얻어낼 수 있다.

public class PrivateLock {
    private final Object myLock = new Object();
    @GuardedBy("myLock") Widget widget;

    void someMethod() {
        synchronized (myLock) {
            // Access or modify the state of widget
        }
    }
}

위 코드는 자바병렬프로그래밍 책에서 클라이언트 락을 사용하여 자바 모니터 패턴을 구현한 예제이다. 위와 같은 형태로 사용하면 myLock이라는 객체에 대한 락을 얻어낼 수 있다.

비슷한 예제로

public class UnsafeVectorHelpers {
    public static Object getLast(Vector list) {
        int lastIndex = list.size() - 1;
        return list.get(lastIndex);
    }

    public static void deleteLast(Vector list) {
        int lastIndex = list.size() - 1;
        list.remove(lastIndex);
    }
}

위와 같은 코드는 list.size()를 얻어내는 시점이 다르기때문에 Vector가 Thread-Safe하더라도 멀티스레드 환경에서는 문제가 생길 수 있는 코드이다.

이런 경우에 명시적인 클라이언트 락을 얻어내면 넘어온 인스턴스 자체의 lock을 걸기때문에 두 개 이상의 스레드가 저 synchronized 블록을 실행할 수 없게되어 문제가 생기지 않는다.

public class SafeVectorHelpers {
    public static Object getLast(Vector list) {
        synchronized (list) {
            int lastIndex = list.size() - 1;
            return list.get(lastIndex);
        }
    }

    public static void deleteLast(Vector list) {
        synchronized (list) {
            int lastIndex = list.size() - 1;
            list.remove(lastIndex);
        }
    }
}

synchronized와 Moniter

java의 synchronized는 Monitor를 이용해 Thread의 동기화를 보장한다. 모든 객체는 하나의 Monitor를 가지고 있고 Monitor는 하나의 Thread만을 소유할 수 있다. Monitor를 소유하고 있는 Thread가 Monitor를 해제할 때까지 Wait Queue에서 대기해야 한다. 관련해서 https://www.kdata.or.kr/info/info_04_view.html?field=&keyword=&type=techreport&page=18&dbnum=183741&mode=detail&type=techreport 이 글을 읽어보면 도움이 될 수 있다.

마무리

실제로 실무에서 synchronized를 사용하는 경우는 많지 않은데 이 방법은 성능에 치명적일 수 있기 때문이다. 따라서 synchronized의 사용은 지양해야한다. 동시성을 보장해야 하는 경우에는 자바가 제공하는 thread-safe한 자료구조 (ConcurrentHashMap, BlockingQueue 등) 를 사용해야 한다.



포스팅은 여기까지 하겠습니다. 퍼가실때는 출처를 반드시 남겨주세요!

예제: https://github.com/sup2is/study/tree/master/java/java-synchronized-test


References