자바 Synchronization

Jeongmin Yeo (Ethan)·2021년 1월 9일
3

Java

목록 보기
3/4
post-thumbnail

자바 Synchronization에 대해 알아보고 정리합니다.

학습할 내용은 다음과 같습니다.

  • What is Synchronization?
  • Synchronized Methods
  • Intrinsic Locks and Synchronization
  • What does variable "synchronization with main memory" mean?
  • Liveness
  • Atomic Variables in Java

References


1. What is Synchronization?

자바에서 Synchronization은 공유된 리소스에 접근하는 멀티 스레드를 제어하기 위한 기능입니다.

스레드는 Object나 field를 참조하는 엑세스를 공유해서 통신합니다. 이러한 통신 방법은 효율적이나 thread interference 나 memory consistency errors를 일으킬 수 있습니다.

이러한 문제를 해결하기 위해 자바에서 synchronization은 오로지 하나의 스레드만 공유 리소스에 접근하도록 해서 해결합니다.

Thread Interference

다음 클래스 Counter가 있다고 생각해봅시다.

class Counter {
    private int c = 0;

    public void increment() {
        c++;
    }

    public void decrement() {
        c--;
    }

    public int value() {
        return c;
    }
}

이 클래스는 자신의 현재 값을 조회하는 메소드, 1을 더하는 메소드, 1을 빼는 메소드가 있습니다. 이 카운터가 멀티 스레드 환경에서 동작한다면

thread interference로 인해 예상과 다르게 동작할 수 있습니다.

thread interference는 서로 다른 스레드에서 동일한 데이터에 대해 작동할 때 발생합니다.

다음 예시를 통해 보겠습니다.

두 개의 스레드 Thread A, Thread B가 있고 Thread A가 increment()을 수행하고 Thread B가 decrement()를 수행합니다. 그리고 초기 Counter 인스턴스의 c의 초기값은 0입니다.

  1. Thread A: Retrieve c.
  2. Thread B: Retrieve c.
  3. Thread A: Increment retrieved value; result is 1.
  4. Thread B: Decrement retrieved value; result is -1.
  5. Thread A: Store result in c; c is now 1.
  6. Thread B: Store result in c; c is now -1.

Thread A의 결과값은 Thread B에 의해서 Overwrite되는 문제가 발생합니다. 이걸 thread interference라고 합니다.

Memory Consistency Errors

Memory consistency errors는 서로 다른 스레드가 동일한 데이터에 대해 일관성이 없는 경우입니다.

memory consistency errors을 피하기 위해서는 happens-before relationship을 알아야 합니다.

일단 예시를 먼저 보겠습니다.

int counter = 0;

// Thread A Operation 
counter++;

// Thread B Operation
System.out.println(counter);

이 counter field가 서로 다른 스레드 Thread A, Thread B가 공유하고 있고 Thread A는 이 값을 증가시킵니다. 그 후 바로 Thread B는 값을 조회합니다.

이 경우 Thread B는 1이 아닌 0이 출력됩니다. 왜냐하면 Thread A가 counter를 바꾼 행동이 Thread B가 업데이트하지 않았기 떄문입니다.

이 문제를 해결하기 위해서 Synchronization 중 하나인 happens-before relationship를 만드는 것입니다.

간단하게 자바에서 Thread.start()와 Thread.join()을 통해서 만들 수 있습니다.

happens-before relationship

Happens-before relationship은 하나의 스레드에서 작업한 행동이 다른 스레드에게 반영되는 것을 말합니다.

어떻게 이걸 가능하게 하냐면 Happens-before은 Program 실행에 대한 순서를 재정의합니다. Thread B가 Thread A의 결과를 보기 위해서는 Thread B는 Thread A 이후에 실행되야 합니다. 만약 이런 관계가 없다면 JIT Compiler는 알아서 최적화해서 실행할 것입니다.


2. Synchronized Methods

자바에서 synchronization을 하기 위해서는 synchronized methods와 synchronized statements 방법이 있습니다.

여기서는 synchronized methods에 대해서 살펴보겠습니다.

synchronized methods를 사용하기 위해서는 메소드 이름 앞에 synchronized 키워드를 붙이면 됩니다.

public class SynchronizedCounter {
    private int c = 0;

    public synchronized void increment() {
        c++;
    }

    public synchronized void decrement() {
        c--;
    }

    public synchronized int value() {
        return c;
    }
}

이런 SynchronizedCounter는 이전 Counter와는 좀 다른 기능을 가집니다.

첫번째로 서로 다른 스레드에서 같은 오브젝트에 대해서 synchronized methods를 호출 할 수 없습니다. 한 스레드가 synchronized methods를 호출하고 있다면 다른 스레드가 synchronized methods를 호출할려고 할 때 block 당합니다.

두번째로 한 스레드에서 오브젝트에 대한 synchronized methods가 종료되면 그 후 자동적으로 happens-before relationship 관계가 성립되서 다른 스레드에서 synchronized method가 호출됩니다. 이로 인해 synchronization이 보장됩니다.

참고로 생성자는 synchronized method로 만들 수 없고 static method는 synchronized method가 될 수 있습니다.

synchronized method는 thread interference나 memory consistency errors를 막을 수 있지만 liveness 문제를 발생시킬 수 있습니다. 이건 이후에 소개하겠습니다.

Synchronized Statements

synchronized methods를 쓰지않고 synchronized statements를 통해서도 synchronization이 가능합니다.

예시는 다음과 같습니다.

public void addName(String name) {
    synchronized(this) {
        lastName = name;
        nameCount++;
    }
    nameList.add(name);
}

synchronized statements는 객체 안에서 intrinsic lock을 얻을 부분을 정의합니다. 이 예제는 this를 통해 lock을 얻습니다.

Synchronized Statements는 concurrency 상황에서 유용하게 사용할 수 있습니다.

다음 예시를 보겠습니다.

public class MsLunch {
    private long c1 = 0;
    private long c2 = 0;
    private Object lock1 = new Object();
    private Object lock2 = new Object();

    public void inc1() {
        synchronized(lock1) {
            c1++;
        }
    }

    public void inc2() {
        synchronized(lock2) {
            c2++;
        }
    }
}

MsLunch 클래스에 두 개의 인스턴스 필드(c1 및 c2)가 있습니다. 여기 필드의 모든 업데이트는 synchronization 되어야 합니다. 하지만 서로 연관이 없는 c1의 업데이트가 c2의 업데이트를 막을 필요는 없습니다. 즉 synchronized method를 쓰면 이 객체와 관련된 intrinsic lock을 얻어서 다른 스레드의 접근을 차단하지만 여기서는 lock으로 사용하는 부분이 서로 다른 오브젝트를 사용하므로 불필요한 차단을 만들어 동시성을 줄일 수 있습니다.


3. Intrinsic Locks and Synchronization

intrinsic lock (a.k.a monitor lock)은 object instance 내부에 있는 entity 입니다. 이 intrinsic lock은 object's state에 대한 접근을 가능하게 해줍니다.

synchronized method를 호출하기 위해서는 각 객체에 있는 intrinsic lock을 얻어야 호출할 수 있습니다.

만약 한 스레드가 intrinsic lock을 얻었다면 다른 스레드에서 같은 lock을 얻을 수 없습니다. 즉 lock을 반환하기 전까지 기다려야 합니다.

그리고 Intrinsic lock은 reentrant 특징이 있습니다. 즉 한 객체 인스턴스에 대한 intrinsic lock을 얻었다면 그 객체에 있는 다른 synchronized method를 호출 할 수 있습니다. 한 메소드에만 국한된 게 아닙니다.

실졔 예를 보겠습니다.

public class ReentrantDemo {
    public static void main (String[] args) throws InterruptedException {
        ReentrantDemo demo = new ReentrantDemo();
        Thread thread1 = new Thread(() -> {
            System.out.println("thread1 before call "+ LocalDateTime.now());
            demo.syncMethod1("from thread1");
            System.out.println("thread1 after call "+LocalDateTime.now());
        });
        Thread thread2 = new Thread(() -> {
            System.out.println("thread2 before call "+LocalDateTime.now());
            demo.syncMethod2("from thread2");
            System.out.println("thread2 after call "+LocalDateTime.now());
        });

        thread1.start();
        thread2.start();
    }

    private synchronized void syncMethod1 (String msg) {
        System.out.println("in the syncMethod1 "+msg+" "+LocalDateTime.now());
        syncMethod2("from method syncMethod1, reentered call");
    }

    private synchronized void syncMethod2 (String msg) {
        System.out.println("in the syncMethod2 "+msg+" "+LocalDateTime.now());
        try {
            TimeUnit.SECONDS.sleep(3);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}


// expected output 
thread1 before call 2016-03-27T20:53:21.373
thread2 before call 2016-03-27T20:53:21.373
in the syncMethod1 from thread1 2016-03-27T20:53:21.384
in the syncMethod2 from method syncMethod1, reentered call 2016-03-27T20:53:21.384
thread1 after call 2016-03-27T20:53:24.385
in the syncMethod2 from thread2 2016-03-27T20:53:24.385
thread2 after call 2016-03-27T20:53:27.386

4. What does variable "synchronization with main memory" mean?

여기서 말하는 main memory는 physical memory를 말하는게 아니라 JVM에 있는 Heap을 말합니다.

JVM에서 synchronized blocks과 methods들은 안전하게 실행할려고 합니다. 한 스레드에서 synchronized block에 있는 변수에 값을 읽고 쓰는걸 다른 스레드에서 동기화를 하기 위해서는 일을 처리한 후 각 스레드에 있는 스택안에 변수를 공유해야하는데 이건 불가능하고 JIT Compiler에 의해 생성된 registers 값을 통해서 동기화 하는건 적합하지 않습니다.

즉 main memory를 통해서 synchronization을 해야합니다. 이걸 위해서 JVM에서 synchronized block을 만나면 즉시 main memory에 접근해서 해당 값을 읽어오고 synchronized block을 종료할 때는 main memory에 값을 저장하는 방식을 사용합니다.

레지스터간에 읽고 쓰기 작업이 메모리보다 훨씬 빠르니까 이런 방법은 성능상으로 느릴 수 있습니다. 이게 이제 ArrayList에는 없고 Vector에 있는Synchronization의 overhead입니다.


5. Liveness

간단하게 concurrent application에서 발생할 수 있는 liveness problem인 deadlock과 starvation, livelock을 살펴보겠습니다.

deadlock

deadlock은 둘 이상의 스레드가 영원히 block되어 서로를 기다리는 상황을 나타냅니다.

다음 예를 보겠습니다.

public class Deadlock {
    static class Friend {
        private final String name;
        public Friend(String name) {
            this.name = name;
        }
        public String getName() {
            return this.name;
        }
        public synchronized void bow(Friend bower) {
            System.out.format("%s: %s"
                + "  has bowed to me!%n", 
                this.name, bower.getName());
            bower.bowBack(this);
        }
        public synchronized void bowBack(Friend bower) {
            System.out.format("%s: %s"
                + " has bowed back to me!%n",
                this.name, bower.getName());
        }
    }

    public static void main(String[] args) {
        final Friend alphonse =
            new Friend("Alphonse");
        final Friend gaston =
            new Friend("Gaston");
        new Thread(new Runnable() {
            public void run() { alphonse.bow(gaston); }
        }).start();
        new Thread(new Runnable() {
            public void run() { gaston.bow(alphonse); }
        }).start();
    }
}

deadlock이 실행될 때 두 스레드가 bowBack을 호출하려고 할 때 차단될 가능성이 매우 높습니다. 각 스레드가 다른 스레드가 bow에서 나가기를 기다리고 있기 때문에 두 블록 모두 끝나지 않습니다.

Starvation

Starvation은 한 스레드가 공유 리소스에 정기적으로 액세스할 수 없고 진전을 이룰 수 없는 상황을 나타냅니다. 이는 "Greedy" 스레드 때문에 공유 리소스를 장기간 사용할 수 없게 된 경우에 발생합니다.

예를 들어, 객체가 반환하는 데 종종 시간이 오래 걸리는 synchronized method를 제공한다고 가정합니다. 한 스레드가 이 메서드를 자주 호출하는 경우 동일한 개체에 대한 자주 동기화된 액세스가 필요한 다른 스레드가 차단되는 경우가 많습니다.

Livelock

스레드는 종종 다른 스레드의 작업에 응답하여 작동합니다. 한 스레드의 수행이 다른 스레드의 수행에 대한 응답일 경우, livelock이 발생할 수 있습니다. deadlock과 마찬가지로 livelock이 걸린 스레드는 더 이상 진행할 수 없습니다.


6. Atomic Variables in Java

보시다시피 lock을 이용하는 동시 엑세스에는 성능상에 단점이 있습니다. 여러 스레드가 lock을 획득하려고 하면 스레드 중 하나가 승리하고 나머지 스레드는 차단되거나 일시 중단됩니다.

스레드를 일시 중단했다가 다시 시작하는 프로세스는 매우 비용이 많이 들고 시스템의 전반적인 효율성에 영향을 미칩니다.

자바에서는 이를 해결하기 위해서 atomic variables을 대안으로 사용합니다.

Atomic Operations

Atomic Operations은 concurrent environment에 대한 non-blocking 알고리즘입니다. 이 알고리즘이 바로 CAS(Compare and Swap Algorithm) 입니다.

이 알고리즘은 메모리 위치의 내용을 지정된 값과 비교하고, 동일한 경우에만 해당 메모리 위치의 내용을 지정된 새 값으로 수정합니다.

새 값이 최신 정보를 기반으로 계산됨을 보장하며, 그 동안 다른 스레드에 의해 값이 업데이트되면 쓰기가 실패합니다.

CAS 알고리즘은 다음 세 가지 피라미터가 필요합니다.

  1. 변수 값 교체를 위한 memory location (V)

  2. 이전 스레드에서 읽은 예상되는 변수의 값 (A)

  3. V에 새로 업데이트 할 변수 값 (B)

CAS는 간단하게 말하면 메모리에 A가 있다면 거기에 B를 업데이트 할게 만약 실패하면 다음 작업을 수행할게 입니다.

Atomic Variables in Java

자바에서 가장 일반적으로 사용하는 Atomic Variables은 Atomic Integer, Atomic Long, Atomic Boolean, Atomic Reference입니다.

이러한 클래스는 각각 int, long, boolean 및 객체 참조를 나타냅니다.

이 클래스들의 main methods는 다음과 같습니다.

  • get()

    • 메모리에서 값을 가져와 다른 스레드에서 변경한 내용을 볼 수 있도록 합니다.
  • set()

    • 값을 메모리에 기록하여 변경 내용이 다른 스레드에 표시되도록 합니다.
  • lazySet()

    • 이전 값을 업데이트하고 매개 변수에 전달된 새 값으로 설정하는 메소드 입니다.
  • compareAndSet()

    • 현재 값이 매개 변수에서도 전달되는 예상 값과 동일한 경우 매개 변수의 전달된 값으로 값을 설정하는 메소드입니다.
profile
좋은 습관을 가지고 싶은 평범한 개발자입니다.

0개의 댓글