Java - Synchronized block(동기화 블록)

JS · 13 Sep 2020

synchronized 키워드는 멀티 쓰레드 환경에서 두개 이상의 쓰레드가 하나의 변수에 동시에 접근을 할 때 Race condition(경쟁상태)이 발생하지 않도록 합니다.

만약 Race condition이 발생할 수 있는 code block을 synchronized 키워드로 감싸면, 하나의 쓰레드만 이 code block에 진입할 수 있습니다. 그 외에 다른 쓰레드는 먼저 진입한 쓰레드가 이 code block을 나갈 때 까지 기다리도록 하여 Race condition이 발생하지 않도록 합니다.

이 글은 synchronized를 사용하는 방법과 효율적으로 사용하는 방법에 대해서 알아봅니다.

synchronized 키워드를 사용하는 이유

다음 코드를 보시면, Thread pool을 만들어 멀티 쓰레드에서 number 변수의 숫자를 증가시키고 있습니다.

public class SynchronizedKeywordExample {
    static long number = 0;

    public static void main(String args[]) {
        ExecutorService service = Executors.newCachedThreadPool();

        for (int i = 0; i < 1000; i++) {
            service.submit(() -> {
                number++;
                System.out.println(number);
            });
        }
    }
}

위 코드를 실행해보면 다음과 같이 출력이 됩니다. 처음에는 순차적으로 숫자가 증가하지만, 나중에는 순차적으로 증가하고 있지 않습니다. 사실 Java 코드 한 줄은 여러 줄의 기계어로 변환되기 때문에, 멀티 쓰레드 환경에서 순차적으로 코드가 실행된다는 것을 보장할 수 없습니다.

1
2
3
4
....
15
16
17
19
20
18
21
....

위의 코드에서 Race condition이 발생할 수 있는 부분을 synchronized 키워드로 감싸보겠습니다. 멀티쓰레드에서 동시에 접근이 가능한 변수는 number입니다. 이것을 synchronized block으로 감싸면 됩니다.

public class SynchronizedKeywordExample {
    static long number = 0;

    public static void main(String args[]) {
        ExecutorService service = Executors.newCachedThreadPool();

        for (int i = 0; i < 1000; i++) {
            service.submit(() -> {
                synchronized (SynchronizedKeywordExample.class) {
                    number++;
                    System.out.println(number);
                }
            });
        }
    }
}

이제 위의 코드를 실행해보면, 다음과 같이 순차적으로 숫자가 증가하는 것을 볼 수 있습니다. synchronized block에는 하나의 쓰레드만 진입할 수 있기 때문에, 하나의 쓰레드가 값을 변경한 이후에 다음 쓰레드가 값을 변경할 수 있습니다. 이렇게 Race condition이 발생하지 않도록 하여 의도한 대로 동작하게 만들 수 있었습니다.

1
2
3
4
.....
14
15
16
17
18
19
20
.....

일반적으로 synchronized 키워드를 사용하여, 다음과 같은 패턴으로 동기화할 수 있습니다.

  • 인스턴스 메소드 동기화
  • 스태틱 메소드 동기화
  • 메소드 안에서 동기화

인스턴스 메소드 동기화

synchronized 키워드를 사용하여 인스턴스 단위로 동기화를 할 수 있습니다.

다음과 같이 Counter 클래스를 만들었고, 멀티 쓰레드에서 increase()를 호출할 것입니다.

public class Counter {
    private long number = 0;

    public void increase() {
        number++;
        System.out.println(number);
    }
}

만약 아래와 같이 멀티쓰레드에서 increase()를 호출하도록 만들면 Race condition이 발생하여 문제가 될 수 있습니다.

ExecutorService service = Executors.newCachedThreadPool();
Counter counter = new Counter();

for (int i = 0; i < 1000; i++) {
    service.submit(() -> {
        counter.increase();
    });
}

다음과 같이 메소드에 synchronized 키워드를 추가하면 메소드 단위로 동기화를 적용할 수 있습니다. 즉, 1개의 쓰레드만 이 메소드에 진입할 수 있습니다. 다른 쓰레드들은 먼저 진입한 쓰레드에서 메소드 호출이 종료될 때까지 대기하게 됩니다.

public class Counter {
    private long number = 0;

    public synchronized void increase() {
        number++;
        System.out.println(number);
    }
}

주의할 점은 synchronized는 특정 메소드 단위로 동기화되는 것이 아니라, 그 객체 단위로 동기화가 되는 것입니다. 예를 들어, 아래 코드처럼 AAA라는 클래스의 increase()decrease()에 synchronized를 추가하였을 때 이 두개의 메소드는 동시에 수행될 수 없습니다. 메소드에 설정되는 synchronized는 인스턴스 단위로 동기화가 되기 때문에 이 객체 내에서 synchronized block 안으로 들어갈 수 있는 것은 1개의 쓰레드만 허용되기 때문입니다.

class AAA {
  public synchronized void increase() {
  }
  public synchronized void decrease() {
  }
}

즉, 메소드에 synchronized를 적용하는 것은 매우 비효율적일 수 있습니다.

스태틱 메소드 동기화

synchronized로 스태틱 메소드 단위로 동기화를 할 수 있습니다.

다음 코드는 static 메소드인 increase()에 synchronized를 추가하였습니다.

public class Counter {
    private static long number = 0;

    public static synchronized void increase() {
        number++;
        System.out.println(number);
    }
}

클래스 내의 static 메소드는 오직 하나만 존재합니다. 여러 객체가 생성되어도 하나의 static 메소드를 공유합니다. 따라서, 2개 이상의 Counter 객체가 생성되었고, 이 객체들이 동시에 increase()를 호출하였을 때 1개의 쓰레드만 이 메소드에 진입할 수 있습니다. 따라서, 동일한 클래스로 만들어진 여러 객체들을 동기화할 수 있습니다.

메소드 안에서 동기화

메소드 단위로 synchronized를 적용하는 것이 비효율적일 수 있습니다. 아래 예제에서, Race condition이 발생하는 것은 number 변수 하나인데, System.out.prinln() 코드도 함께 동기화되기 때문입니다. 따라서 다음과 같이 synchronized (lock) block으로 number에 접근하는 코드만 동기화할 수 있습니다. 여기서 lock은 동기화되는 단위라고 생각할 수 있습니다. this는 인스턴스를 의미하기 때문에 객체 단위로 동기화가 됩니다.

public class Counter {
    private long number = 0;

    public void increase() {
        long temp = 0;

        synchronized (this) {
            number++;
            temp = number;
        }

        System.out.println(temp);
    }
}

만약 다음과 같이 decrease()에 synchronized가 적용되었다면, decrease()가 수행되는 동안 increase() 내부의 synchronized block은 수행될 수 없습니다. 모두 인스턴스 객체 단위로 동기화를 하였기 때문입니다.

public class Counter {
    private long number = 0;

    public void increase() {
        long temp = 0;

        synchronized (this) {
            number++;
            temp = number;
        }

        System.out.println(temp);
    }

    public synchronized void decrease() {
        number--;
    }
}

만약 다음과 같이 lock이라는 객체를 만들고, increase 메소드의 내부 코드를 synchronized (lock)으로 변경한다면 decrease()와 함께 수행될 수 있습니다. decrease()는 인스턴스를 단위로 동기화하였고, increase()는 lock이라는 객체를 단위로 동기화하였기 때문입니다.

public class Counter {
    private long number = 0;
    private Object lock = new Object();

    public void increase() {
        long temp = 0;

        synchronized (lock) {
            number++;
            temp = number;
        }

        System.out.println(temp);
    }

    public synchronized void decrease() {
        number--;
    }
}

위의 예제에서 본 것처럼, Race condition이 발생할 수 있는 객체들을 동기화할 때 동기화되는 단위를 다르게 설정하면 효율적으로 동작할 수 있습니다.

정리

synchronized 키워드는 Race condition이 발생할 수 있는 코드를 동기화하여 1개의 쓰레드만 코드를 수행할 수 있도록 보장하는 것입니다. synchronized를 적용할 때, 동기화되는 단위를 어떤 것으로 설정하냐에 따라서 프로그램이 효율적으로 동작할 수도 있고 비효율적으로 동작할 수도 있습니다.

댓글을 보거나 쓰려면 이 버튼을 눌러주세요.
codechachaCopyright ©2019 codechacha