이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

원문 URL : http://tutorials.jenkov.com/java-concurrency/slipped-conditions.html



슬립 상태란?


슬립 상태란 한 쓰레드가 특정 상태를 확인하고 이 상태에 대해 어떤 동작을 수행하는 사이에 다른 쓰레드가 상태를 변경하여 처음 쓰레드가 잘못된 동작을 수행하게 되는 현상을 의미한다. 


예제를 보자.


public class Lock {

    private boolean isLocked = true;

    public void lock(){
      synchronized(this){
        while(isLocked){
          try{
            this.wait();
          } catch(InterruptedException e){
            //do nothing, keep waiting
          }
        }
      }

      synchronized(this){
        isLocked = true;
      }
    }

    public synchronized void unlock(){
      isLocked = false;
      this.notify();
    }

}


lock() 메소드가 동기화를 수행하는 방식을 보자. 첫 synchronized 블록은 isLocked 가 false 일 때까지 기다린다. 두 번째 블록은 isLocked 를 true 로 세팅하여 Lock 인스턴스를 다른 쓰레드로부터 잠근다(락을 건다).


isLocked 가 false 이고 두 쓰레드가 동시에 lock() 메소드를 호출하는 경우를 생각해보자. 첫 번째 쓰레드는 첫 synchronized 블록에 진입하자마자 isLocked 가 false 인 것을 확인한다. 그리고 두 번째 쓰레드도 똑같이 synchronized 블록에 진입하고 isLocked 가 false 임을 확인한다. 두 쓰레드 모두 두 번째 synchronized 블록으로 진입하여 isLocked 를 true 로 세팅하고 자신의 동작을 


이 상황은 슬립 상태의 예시이다. 두 쓰레드는 상태를 확인하고, synchronized 블록을 벗어나고, 상태를 변경한다(isLocked=true). 이런 동작은 처음 synchronized 로 진입한 쓰레드가 isLocked 에 true 를 세팅하기 전에 다른 쓰레드들이 상태를 확인하는 것을 허용하게 된다. 다시 말해서, '상태' 는 쓰레드의 연이은 확인-변경 동작 사이에 다른 쓰레드들에게로 '탈선'된다.


슬립 상태를 피하기 위해서는 상태의 확인-변경 동작이 원자적으로 이루어져야 한다. 한 쓰레드가 상태 확인-변경 동작을 수행하는 동안 다른 쓰레드들이 상태를 확인할 수 없게끔 하여야 한다는 말이다.


위 예제에 대한 해결책은 간단하다. isLocked=true; 를 처음 synchronized 블록 안, while 루프 바로 다음으로 옮긴다.


public class Lock {

    private boolean isLocked = true;

    public void lock(){
      synchronized(this){
        while(isLocked){
          try{
            this.wait();
          } catch(InterruptedException e){
            //do nothing, keep waiting
          }
        }
        isLocked = true;
      }
    }

    public synchronized void unlock(){
      isLocked = false;
      this.notify();
    }

}


이제 isLocked 상태의 확인-변경 동작은 한 synchronized 블록 안에서 원자적으로 수행된다.



더 현실적인 예제


당신은 Lock 클래스를 위와 같이 구현하지 않을 수 있고, 때문에 슬립 상태는 상당히 이론적인 문제일 뿐이라고 이야기할 수 있다. 첫 번째 예제는 슬립 상태의 개념을 보다 잘 전달하기 위해 다소 단순하게 작성되었다.


더 현실적인 예제는 기아상태와 공정성에서 다룬, 공정한 락 구현 중에 등장한다. 슬립 상태는 중첩 모니터 락아웃에서의 잘못된 구현을 제거하려 할 때 발생하기 쉽다. 아래는 중첩 모니터 락아웃의 예제이다.


// 중첩 모니터 락아웃을 야기하는 공정한 락 구현 public class FairLock { private boolean isLocked = false; private Thread lockingThread = null; private List<QueueObject> waitingThreads = new ArrayList<QueueObject>(); public void lock() throws InterruptedException{ QueueObject queueObject = new QueueObject(); synchronized(this){ waitingThreads.add(queueObject); while(isLocked || waitingThreads.get(0) != queueObject){ synchronized(queueObject){ try{ queueObject.wait(); }catch(InterruptedException e){ waitingThreads.remove(queueObject); throw e; } } } waitingThreads.remove(queueObject); isLocked = true; lockingThread = Thread.currentThread(); } } public synchronized void unlock(){ if(this.lockingThread != Thread.currentThread()){ throw new IllegalMonitorStateException( "Calling thread has not locked this lock"); } isLocked = false; lockingThread = null; if(waitingThreads.size() > 0){ QueueObject queueObject = waitingThread.get(0); synchronized(queueObject){ queueObject.notify(); } } } }

public class QueueObject {}


synchronized(this) 블록 안에서 synchronized(queueObject) 와 queueObject.wait() 이 중첩되어 있는 형태를 보자. 여기서 중첩 모니터 락아웃 문제가 발생한다. 이 문제를 피하기 위해서 synchronized(queueObject) 블록은 synchronized(this) 블록 밖으로 옮겨져야 한다. 


// 슬립 상태를 야기하는 락 구현 public class FairLock { private boolean isLocked = false; private Thread lockingThread = null; private List<QueueObject> waitingThreads = new ArrayList<QueueObject>(); public void lock() throws InterruptedException{ QueueObject queueObject = new QueueObject(); synchronized(this){ waitingThreads.add(queueObject); } boolean mustWait = true; while(mustWait){ synchronized(this){ mustWait = isLocked } synchronized(queueObject){ if(mustWait){ try{ queueObject.wait(); }catch(InterruptedException e){ waitingThreads.remove(queueObject); throw e; } } } } synchronized(this){ waitingThreads.remove(queueObject); isLocked = true; lockingThread = Thread.currentThread(); } } }


lock() 메소드만 수정되었기 때문에 여기서는 unlock() 메소드는 등장하지 않는다.


이 lock() 메소드에는 세 번의 synchronized 블록이 등장한다. 


두 번째 synchronized(this) 블록은 mustWait=isLocked 을 세팅하여 락 상태를 확인한다.


synchronized(queueObject) 블록은 쓰레드가 대기 상태(wait() 호출)로 갈 것인지 아닌지를 체크한다. 다른 쓰레드가 이미 락을 건 상태가 아니라면 이 synchronized(queueObject) 블록을 바로 빠져나간다.


while(mustWait) 으로 인해 세 번째 synchronized(this) 블록은 mustWait 이 false 일 때만 실행된다. 이 블록에서는 isLocked 을 다시 true 로 세팅하고 그외 작업을 수행한다. 그리고 lock() 메소드 수행은 완료된다.


이제 이 FairLock 인스턴스에 락이 걸리지 않은 상황에서 두 쓰레드가 동시에 lock() 메소드를 호출했을 때 어떤 상황이 발생할지 생각해보자. 쓰레드1 은 isLocked 상태를 확인한다. 값은 락이 걸리지 않은 상태이므로 기본값인 false 이다. 다음으로 쓰레드2 도 같은 동작을 수행한다. 그리고 두 쓰레드 모두 대기 상태로 가지 않고 isLocked 를 true 로 세팅한다. 슬립 상태의 전형적인 예이다.



슬립 상태 제거


위 예제에서 슬립 상태를 제거하기 위해서는 마지막 synchronized(this) 블록의 내용이 처음 synchronize(this) 블록 안으로 옮겨져야 한다. 그리고 코드 이동을 위해 자연스럽게 다른 코드들도 조금씩 변경된다.


// 중첩 모니터 락아웃이 없지만 // 신호 실종 문제를 가진 공정한 락 구현 public class FairLock { private boolean isLocked = false; private Thread lockingThread = null; private List<QueueObject> waitingThreads = new ArrayList<QueueObject>(); public void lock() throws InterruptedException{ QueueObject queueObject = new QueueObject(); synchronized(this){ waitingThreads.add(queueObject); } boolean mustWait = true; while(mustWait){ synchronized(this){ mustWait = isLocked || waitingThreads.get(0) != queueObject; if(!mustWait){ waitingThreads.remove(queueObject); isLocked = true; lockingThread = Thread.currentThread(); return; } } synchronized(queueObject){ if(mustWait){ try{ queueObject.wait(); }catch(InterruptedException e){ waitingThreads.remove(queueObject); throw e; } } } } } }


지역변수 mustWait 에 대한 확인-변경 동작은 이제 한 synchronized 블록 안에서 이루어진다. 또한 만일 지역변수 mustWait 를 synchronized(this) 블록 외부에서 검사하더라도 while(mustWait) 안에서 mustWait 은 절대로 synchronized(this) 밖에서 변경되지 않는다. mustWait 을 false 로 판단하는 쓰레드는 내부 조건(isLocked) 를 원자적으로 설정하여, 상태를 확인하는 다른 쓰레드들이 이를 true 로 판단하도록 한다.


synchronized(this) 블록 안의 return 문은 꼭 필요한 것은 아니다. 이것은 작은 최적화의 일환이다. mustWait 이 false 인, 대기 상태로 들어가지 않을 쓰레드가 synchronized(queueObject) 블록으로 진입하여 if(mustWait) 을 실행할 필요는 없다.


관찰력 있는 독자는 위의 공정한 락 구현은 여전히 신호 실종 문제를 가지고 있음을 알 수 있을 것이다. FairLock 인스턴스가 잠겨 있을 때 lock() 메소드가 호출된 상황을 생각해보자. while(mustWait) 안의 synchronized(this) 블록에서 mustWait 은 true 이다. lock() 을 호출하는 쓰레드는 . 그리고 처음 락을 건 쓰레드가 unlock() 을 호출한다. unlock() 메소드 안에서는 queueObject.notify() 가 호출되는데, 대기하는 쓰레드는 아직 queueObject.wait() 을 호출하지 않았다. queueObject.nofity() 는 무효하게 된다. 신호가 실종된 것이다. lock() 메소드를 호출한 쓰레드는 queueObject.wait() 를 호출하여 블록 상태가 된다. unlock() 이 호출되어야 이 블록 상태를 벗어날 수 있지만, unlock() 은 절대 호출되지 않는다.


이 신호 실종 문제는 기아상태와 공정성에서 QueueObject 클래스가 두 메소드와 함께 세마포어로 수정된 이유이다. (doWait(), doNotify()) 이 메소드들은 QueueObject 내부에서 신호를 저장하고, 신호에 반응한다. 이 방법으로 doNotify() 메소드 호출이 doWait() 메소드 호출보다 먼저 발생하는 상황에서도 신호는 실종되지 않는다.













이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

원문 URL : http://tutorials.jenkov.com/java-concurrency/nested-monitor-lockout.html



중첩 모니터 락아웃의 발생


중첩 모니터 락아웃은 데드락과 유사하다. 이 현상은 다음과 같이 발생한다.


쓰레드1 은 A 의 락을 획득한다.

쓰레드1 은 B 의 락을 획득한다.

쓰레드1 은 B.wait() 을 호출하여 B 의 락을 해제한다. (A 의 락은 해제하지 않는다.)


쓰레드2 쓰레드1 에게 신호를 보내기 위해 A 와 B 의 락 모두 필요하다. (같은 순서로)

쓰레드2 은 A 의 락을 획득하려 시도하지만 획득할 수 없다. 이 락은 쓰레드1 이 소유하고 있다.

쓰레드2 은 쓰레드1 이 A 의 락을 해제하기를 기대리며 무한정 블록 상태에 빠진다.


쓰레드1 은 쓰레드2 의 신호를 기다리며 무한정 블록(대기) 상태로 남는다.


이렇게 쓰레드1 은 A 의 락을 절대 해제하지 않으며, 이로 인해 쓰레드2 는 쓰레드1 에게 신호를 보낼 수 없게 된다.


둘은 서로를 기다리며 영원히 대기한다.


이것은 상당히 이론적인 상황으로 들린다. 하지만 신중하게 구현하지 않은 아래의 Lock 클래스를 보자.


// 중첩 모니터 락아웃을 야기하는 락 구현 public class Lock{ protected MonitorObject monitorObject = new MonitorObject(); protected boolean isLocked = false; public void lock() throws InterruptedException{ synchronized(this){ while(isLocked){ synchronized(this.monitorObject){ this.monitorObject.wait(); } } isLocked = true; } } public void unlock(){ synchronized(this){ this.isLocked = false; synchronized(this.monitorObject){ this.monitorObject.notify(); } } } }


lock() 메소드가 어떻게 'this' 와 멤버변수 monitorObject 의 락을 획득하는지 보자. isLocked 이 false 일 때는 아무런 문제가 없다. 쓰레드는 monitorObject.wait() 을 호출하지 않는다. 하지만 isLocked 이 true 이면 lock() 을 호출하는 쓰레드는 monitorObejct.wait() 을 호출하여 대기 상태에 빠진다.


문제는, monitorObject.wait() 은 오직 monitorObject 의 락만을 해제한다는 것이다. 'this' 의 락은 여전히 보유 상태다.


처음 lock() 메소드를 호출한 쓰레드는 unlock() 메소드를 호출하여 자신이 건 락을 해제하려 한다. 이 쓰레드는 unlock() 메소드의 synchronized(this) 으로의 진입을 시도하며 블록 상태에 빠진다. 이 블록은 lock() 메소드에서 대기중인 쓰레드가 lock() 메소드 안의 synchronized(this) 를 빠져나갈 때까지 해제되지 않는다. 하지만 lock() 에서 대기중인 쓰레드는 monitorObject.notify() 가 호출될 때가지, 그리고 isLock() 이 false 가 될 때까지 synchronized(this) 를 떠나지 않는다. 


간단히 말해서, lock() 에서 대기중인 쓰레드는 lock() 을 빠져나가기 위해 unlock() 메소드가 호출되어야 하지만, unlock() 을 호출해줄 다른 쓰레드들은 lock() 에서 대기중인 쓰레드로 인해 블록 상태가 되어 unlock() 을 실행할 수 없게 된다.


이렇게 lock(), unlock() 을 호출하는 쓰레드 모두 무한정 블록 상태에 되고, 이런 현상을 중첩 모니터 락아웃이라 한다.



보다 현실적인 예제


당신은 절대로 위와 같은 락을 구현하지 않는다고 주장할지 모른다. 내부 모니터 객체에 대한 wait() 과 notify() 를 호출하지 않는다는 것이다. 그 말은 아마 사실일 것이다. 하지만 실제로 위와 같은 구현이 발생할 수 있는 상황이 있다. 예를 들어, 락에 공정성을 구현할 때다. 한 번에 한 쓰레드를 깨우기 위해 각 쓰레드가 자신의 큐 객체의 wait() 을 호출하게 하는 것이다.


아래는 신중하지 못한 공정한 락 구현이다.


// 중첩 모니터 락아웃을 야기하는 공정한 락 public class FairLock { private boolean isLocked = false; private Thread lockingThread = null; private List<QueueObject> waitingThreads = new ArrayList<QueueObject>(); public void lock() throws InterruptedException{ QueueObject queueObject = new QueueObject(); synchronized(this){ waitingThreads.add(queueObject); while(isLocked || waitingThreads.get(0) != queueObject){ synchronized(queueObject){ try{ queueObject.wait(); }catch(InterruptedException e){ waitingThreads.remove(queueObject); throw e; } } } waitingThreads.remove(queueObject); isLocked = true; lockingThread = Thread.currentThread(); } } public synchronized void unlock(){ if(this.lockingThread != Thread.currentThread()){ throw new IllegalMonitorStateException( "Calling thread has not locked this lock"); } isLocked = false; lockingThread = null; if(waitingThreads.size() > 0){ QueueObject queueObject = waitingThread.get(0); synchronized(queueObject){ queueObject.notify(); } } } }

public class QueueObject {}


이 구현은 언뜻 보기에는 괜찮아 보인다. 그러나 lock() 메소드가 queueObject.wait() 을 호출하는 방식을 보자. 이 호출은 두 synchronized 블럭 안에서 이루어진다. 하나는 'this' 이다. 다른 하나는 'this' 안의 queueObject 이다. 한 쓰레드가 queueObject.wait() 을 호출하면 QueueObject 인스턴스에 대한 락을 해제한다. 하지만 역시 'this' 의 락을 해제하지는 않는다.


unlock() 메소드는 synchronized 로 선언되어 있다. 이 선언은 synchronized(this) 와 같다. 이는 lock() 메소드를 호출하여 대기 상태에 놓인 쓰레드가 있다면 'this' 의 락을 획득하려는 쓰레드들은 블록 상태가 된다는 것을 의미한다. unlock() 을 호출하는 모든 쓰레드들은 무한정 블록된다. 'this' 의 락을 소유한 쓰레드가 락을 해제해주기를 기다리지만, 이 락을 절대 해제되지 않는다. 왜냐하면 이 해제는 오직 lock() 에서 대기중인 쓰레드를 깨워야만 가능하고, 이 쓰레드를 깨우는 일은 unlock() 메소드를 호출하는 방법 외에는 없기 때문이다.


결국 위의 공정한 락 구현은 중첩 모니터 락아웃을 일으킨다. 더 나은 공정한 락 구현은 여기에 있다.



중첩 모니터 락아웃 vs 데드락


중첩 모니터 락아웃과 데드락의 결과는 같다. 관련된 쓰레드들은 결국 영원히 블록되어 서로를 기다리는 것이다.


하지만 이 둘이 완전히 같은 것은 아니자. 데드락에서 설명했듯, 데드락은 두 쓰레드가 서로 다른 순서로 락을 획득할 때 발생한다. 쓰레드1 이 A 의 락을 획득한 상태로 B 를 기다리고, 쓰레드2 는 B 의 락을 획득한 상태로 A 를 기다린다. 그리고 데드락 방지에서 설명했듯, 데드락은 언제나 락을 같은 순서로 거는 방법으로 회피할 수 있다. (Lock Ordering) 그러나 중첩 모니터 락아웃은 두 쓰레드가 완벽히 같은 순서로 락을 획득하는 상황에서 발생한다. 쓰레드1 은 A 와 B 의 락을 획득한 후 B 의 락을 해제하고 쓰레드2 의 신호를 기다린다. 쓰레드2 는 쓰레드1 에게 신호를 보내기 위해 A 와 B 의 락이 필요하다. 그리하여 한 쓰레드는 신호를 기다리고, 다른 쓰레드는 락이 해제되기를 기다린다.


아래는 요약.


데드락:

두 쓰레드가 서로의 락이 해제되기를 기다린다.


중첩 모니터 락아웃:

쓰레드1 은 A 의 락을 가지고 쓰레드2 로부터의 신호를 기다리며 대기한다.

쓰레드2 는 쓰레드1 에게 신호를 보내기 위해 쓰레드1 이 가진 A 의 락을 얻으려 대기한다.









'Java > Concurrency' 카테고리의 다른 글

자바의 락(Locks in Java)  (0) 2017.05.09
슬립 상태(Slipped Conditions)  (0) 2017.04.23
기아상태와 공정성(Starvation and Fairness)  (0) 2017.04.09
데드락 방지(Deadlock Prevention)  (0) 2017.04.09
데드락(Deadlock)  (0) 2017.04.09

이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

원문 URL : http://tutorials.jenkov.com/java-concurrency/starvation-and-fairness.html



어떤 쓰레드가 다른 쓰레드들이 CPU시간을 모두 잡고 있어 CPU시간을 사용할 수 없게 되는 현상을 "기아상태(starvation)" 라 한다. 이 기아 쓰레드는 글자 그대로 "굶어 죽게" 된다. 다른 쓰레드들이 CPU시간을 사용하도록 되어 있기 때문이다. 기아상태의 해결책은 "공정성(fairness)" 이다. 공정성이란, 모든 쓰레드들이 자신의 작업을 수행할 기회를 공정하게 갖는 것을 의미한다.



자바에서의 기아상태의 원인


다음 세 가지는 자바에서 쓰레드의 기아상태를 일으키는 일반적인 원인들이다.


  1. 높은 우선순위를 가진 쓰레드들의 CPU시간 독점
  2. 동기화 영역 진입을 위한 무한한 대기 중 블록 상태
  3. 객체에 대한 무한한 대기 상태(wait() 호출로 인한)

높은 우선순위를 가진 쓰레드들의 CPU시간 독점

자바에서는 쓰레드들 각각에 대한 우선순위를 설정할 수 있다. 이 우선순위가 높은 쓰레드는 더 많은 CPU시간을 획득한다. 우선순위는 1 에서 10 까지 지정할 수 있으며, 지정된 우선순위를 어떻게 해석하고 작업을 수행하게 할 것인지는 어플리케이션이 작동하는 OS에 달려있다. 대부분의 어플리케이션에 있어서 이 우선순위는 지정하지 않고 그냥 두는 편이 낫다.


동기화 영역 진입을 위한 무한한 대기 중 블록 상태

자바의 동기화는 기아상태의 또다른 원인이다. 자바의 동기화는 대기 중인 쓰레드의 순서를 전혀 보장하지 않는다. 이것은 이론적으로 쓰레드가 동기화 영역으로의 진입을 계속 시도하기만 하며 영원히 블록된 상태로 놓일 수 있음을 의미한다. 왜냐하면 이 동기화 영역으로의 진입권을 다른 쓰레드들이 계속, 거듭 획득하기 때문이다. 이 문제를 "기아상태" 라 부르고, 이 현상의 피해자 쓰레드는 "굶어 죽게" 된다. 구체적인 이유는 동기화 영역으로의 진입권을 계속 획득하고 있는 다른 쓰레드들이 CPU시간을 계속 할당받기 때문이다.


객체에 대한 무한한 대기 상태(wait() 호출로 인한)

둘 이상의 쓰레드가 동일한 객체의 wait() 메서드 호출로 대기 상태에 놓였을 때, nofity() 메소드는 이들 중 어떤 쓰레드를 깨울 것인지 전혀 알 수 없다. 즉 어떤 쓰레드 깨어날지 아무도 모른다. 때문에 여기에는 깨어나지 못하고 계속 대기중인 쓰레드가 존재하게 될 위험성이 존재한다. nofity() 메소드가 계속 다른 쓰레드만 깨울 수 있기 때문이다.


자바에서의 공정성 구현

자바에서 100%의 공정성을 구현하기란 불가능하지만 우리는 쓰레드들 사이의 공정성을 높이기 위한 동기화 구성을 구현할 수 있다.

먼저 아래 간단한 코드를 보자.

public class Synchronizer{ public synchronized void doSynchronized(){ // 오랜 시간이 소요되는 작업을 수행한다. } }


다수의 쓰레드가 위의 doSynchronized() 메소드를 호출하게 되면 처음 메소드 안으로 진입한-코드를 수행하는-쓰레드를 제외한 나머지 쓰레드들은 모두 블록된다. 메소드 안으로 진입한 쓰레드가 메소드 수행을 마치고 메소드 밖으로 빠져나가게 되면 대기 중이던 쓰레드들 중 하나가 메소드로의 진입권을 획득하게 되는데, 이 쓰레드가 어떤 쓰레드일지에 대한 보장은 전혀 없다.


Synchronized 대신 Lock 사용하기

공정성을 높이기 위한 다른 방법으로 synchronized 를 버리고 Lock 객체를 사용해보자.

public class Synchronizer{ Lock lock = new Lock(); public void doSynchronized() throws InterruptedException{ this.lock.lock(); // 크리티컬 섹션. 오랜 시간이 소요되는 작업을 수행한다. this.lock.unlock(); } }


이제 synchronized 선언은 없다. 크리티컬 섹션은 Lock.lock(), Lock.unlock() 이 보호한다.

간단한 Lock 클래스 구현은 다음과 같이 이루어질 수 있다.

public class Lock{ private boolean isLocked = false; private Thread lockingThread = null; public synchronized void lock() throws InterruptedException{ while(isLocked){ wait(); } isLocked = true; lockingThread = Thread.currentThread(); } public synchronized void unlock(){ if(this.lockingThread != Thread.currentThread()){ throw new IllegalMonitorStateException( "호출 쓰레드가 이 락을 소유하지 않음."); } isLocked = false; lockingThread = null; notify(); } }


Synchronizer 클래스와 Lock 구현 클래스를 보자. 둘 이상의 쓰레드가 lock() 을 동시에 호출하게 되면 lock() 메소드로의 진입에서 블록된다는 것을 알 수 있다. 그리고 lock이 잠기면(isLocked=true) 쓰레드들은 while 안에서 wait() 을 호출하여 다시 블록 상태에 놓인다(여기서 wait() 메소드 호출은 synchronized 메소드로 진입하며 획득한 락을 해제한다는 사실을 기억하자). 결국 처음 lock() 을 수행한 쓰레드를 제외한 나머지 쓰레드들은 lock() 안에서의 wait() 호출로 모두 블록 상태에 놓이게 된다.

doSynchronized() 메소드의 lock() 과 unlock() 사이의 주석은 lock() 과 unlock() 사이의 코드가 수행에 오랜 시간이 소요됨을 명시하고 있다. 이 코드의 수행이 lock() 메소드로의 진입과 wait() 메소드 호출에 비해 오랜 시간이 소요된다고 가정해보자. 이것은 크리티컬 섹션으로의 진입을 위해 대기한 시간의 대부분이 lock() 메소드로의 진입을 위해서가 아닌, lock() 메소드 안의 wait() 메소드 호출로 인해 블록 상태에 되어 소요됨을 의미한다.

위에서 언급되었듯, synchronized 는 다수의 쓰레드가 경합 중에 있을 때 어떤 쓰레드가 진입권을 획득할지에 대해 보장하지 않는다. wait() 메소드도 마찬가지로 notify() 가 호출될 때 어떤 쓰레드가 깨어날지 보장하지 않는다. 고로, 이 Lock 클래스는 synchronized 로 동기화된 처음 버전의 doSynchronized() 메소드와 그 공정성에 있어 아무런 차이가 없다.

현재의 Lock 클래스는 자신의(this) wait() 메소드를 호출한다. 만약 쓰레드들이 각각 다른 객체의 wait() 을 호출하게끔 하여 한 객체에 대해 wait() 을 호출한 쓰레드를 하나로 만들어 쓰레드와 wait() 객체를 1:1 관계로 구현할 수 있다면, Lock 클래스는 어떤 객체에 대해 notify() 를 호출할지 선택할 수 있고, 즉 정확히 어떤 쓰레드를 깨울지 효과적으로 선택할 수 있을 것이다.


공정한 락

다음은 Lock 클래스를 공정한 락이 구현된, FairLock 으로 바꾼 것이다. 동기화와 wait() / notify() 메소드 활용에 있어 아까의 Lock 클래스와 비교해보면 구현이 다소 바뀐 것을 알 수 있을 것이다.

코드의 디자인이 이전의 Lock 클래스에서 여기에 도달하기까지에는 늘어나는 디자인 과정과 관련된 더 긴 이야기가 있는데, 이전 과정의 문제점을 수정했다: 중첩 모니터 락아웃, 이탈 상태, 신호 소실. 이 논의를 여기에 가져오기에는 이 글이 너무 길어지므로 생략하기로 한다. 각 과정에 대한 내용은 각각에 알맞는 별도의 글에 포함되어 있다(링크를 따라가면 됨).

중요한 것은 이제 lock() 을 호출하는 쓰레드는 큐에 담기게 되고 오직 이 큐에서 처음에 위치한 쓰레드만이 FairLock 인스턴스에 락을 걸 수 있게 된다(물론 아직 락이 걸리지 않을 상태일 경우). 다른 모든 쓰레드들은 큐의 최상위 쓰레드가 될 때 까지 대기 상태로 놓인다.

public class FairLock {
    private boolean           isLocked       = false;
    private Thread            lockingThread  = null;
    private List<QueueObject> waitingThreads =
            new ArrayList<QueueObject>();

  public void lock() throws InterruptedException{
    QueueObject queueObject           = new QueueObject();
    boolean     isLockedForThisThread = true;
    synchronized(this){
        waitingThreads.add(queueObject);
    }

    while(isLockedForThisThread){
      synchronized(this){
        isLockedForThisThread =
            isLocked || waitingThreads.get(0) != queueObject;
        if(!isLockedForThisThread){
          isLocked = true;
           waitingThreads.remove(queueObject);
           lockingThread = Thread.currentThread();
           return;
         }
      }
      try{
        queueObject.doWait();
      }catch(InterruptedException e){
        synchronized(this) { waitingThreads.remove(queueObject); }
        throw e;
      }
    }
  }

  public synchronized void unlock(){
    if(this.lockingThread != Thread.currentThread()){
      throw new IllegalMonitorStateException(
        "호출 쓰레드가 이 락을 소유하지 않음.");
    }
    isLocked      = false;
    lockingThread = null;
    if(waitingThreads.size() > 0){
      waitingThreads.get(0).doNotify();
    }
  }
}
public class QueueObject {

  private boolean isNotified = false;

  public synchronized void doWait() throws InterruptedException {
    while(!isNotified){
        this.wait();
    }
    this.isNotified = false;
  }

  public synchronized void doNotify() {
    this.isNotified = true;
    this.notify();
  }

  public boolean equals(Object o) {
    return this == o;
  }
}

먼저 lock() 메소드 선언에는 더이상 synchronized를 사용하지 않는다. synchronized는 메소드 실행 코드에서 필요한 곳에 한하여 사용된다.

FairLock 은 lock() 메소드가 호출되면 새로운 QueueObject 인스턴스를 생성하여 이것을 대기 목록을 의미하는 waitingThreads에 넣는다. unlock() 을 호출하는 쓰레드는 대기 목록에서 가장 상위에 위치한 QueueObject 를 가져와 doNotify() 를 호출하여 대기 중인 쓰레드를 깨운다. 이런 방법으로 쓰레드는 한 번에 오직 하나의 쓰레드를 깨우게 된다. 이것이 FairLock 에서 공정성을 관리하는 부분이다.

여기서 이탈 상태를 피하기 위해 같은 synchronized 블록 안의 상태를 어떻게 체크하고 세팅하는지를 알아야 한다.

또한 QueueObject 는 하나의 세마포어이다. doWait() 과 doNotify() 메소드는 QueueObject 내부에 신호를 저장한다. unlock() 을 호출하는 다른 쓰레드에 의해 수행되는 이 작업은 queueObject.doWait() 호출 직전에 선점되는 쓰레드로 인해 발생하는 신호 소실 문제를 피하기 위함이다. 중첩 모니터 락아웃을 방지하기 위해 queueObject.doWait() 호출은 synchronized(this) 밖에서 이루어진다. 이렇게 함으로써 lock() 메소드 안의 synchronized(this)에 진입한 쓰레드가 하나도 없을 때 다른 쓰레드가 unlock() 을 호출하는 일이 가능해진다.

마지막으로 queueObject.doWait() 이 try-catch 블록 안에서 호출된다는 점을 보자. 이는 InterruptedException 이 발생하면 쓰레드는 lock() 메소드를 벗어나게 되는데, 이 때 queueObject 를 대기 목록(waitingThreads)에서 제거해야 하기 때문이다.


성능

Lock 과 FairLock 클래스를 비교해보면 FairLock 클래스 쪽이 lock() 과 unlock() 메소드에서 뭔가 더 많은 것을 한다는 것을 알 수 있다. 이런 코드들은 FairLock 의 동기화 성능을 Lock 보다 조금 떨어지게 만드는 원인이 된다. 이것이 어플리케이션에 얼마나 영향을 미치느냐는 FairLock 으로 보호되는 크리티컬 섹션 코드의 수행 시간에 달려 있다. 크리티컬 섹션 안의 코드 수행 시간이 길 수록 동기화의 오버헤드는 줄어든다. 물론 이것은 이 코드의 호출 빈도와도 관련이 있다.











'Java > Concurrency' 카테고리의 다른 글

슬립 상태(Slipped Conditions)  (0) 2017.04.23
중첩 모니터 락아웃(Nested Monitor Lockout)  (0) 2017.04.16
데드락 방지(Deadlock Prevention)  (0) 2017.04.09
데드락(Deadlock)  (0) 2017.04.09
쓰레드 시그널링  (0) 2017.04.09


이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

원문 URL : http://tutorials.jenkov.com/java-concurrency/deadlock-prevention.html



상황에 따라 데드락을 방지하는 것이 가능하다. 여기서는 세 가지 기술을 소개한다.


  1. 락 정렬
  2. 락 타임아웃
  3. 데드락 감지



락 정렬


데드락은 다수의 쓰레드들이 같은 락들을 필요로 하면서 서로 다른 순서로 서로의 락을 획득하려 할 때 발생한다.

만약 모든 락이 항상 같은 순서로 획득된다는 보장이 있다면, 데드락은 발생할 수 없다. 다음 예제를 보자:


Thread 1:

  lock A 
  lock B


Thread 2:

   wait for A
   lock C (when A locked)


Thread 3:

   wait for A
   wait for B
   wait for C


한 쓰레드가 여기 Thread 3 처럼 다수의 락을 필요로 한다면, 이 쓰레드는 반드시 정해진 순서로 락을 획득하여야 한다. 이 쓰레드의 락 획득은 정해진 순서 안에서 이전 락을 획득할 때까지 다음 락을 획득할 수는 없다.


예를 들어서, Thread 2 나 Thread 3 은 먼저 A 의 락을 획득해야 C 의 락을 획득할 수 있다. Thread 1 이 A 의 락을 잡고 있기 때문에, Thread 2 와 Thread 3 은 일단 A 의 락이 풀릴 때까지 기다려야 하고, B 나 C 의 락을 시도하기 전에 반드시 A 의 락을 계속 시도하게 된다.


락 정렬은 아직까지 효과적인 데드락 방지 기술이다. 하지만, 이 방법은 획득하려는 락의 순서를 알고 있는 상태에서만 사용될 수 있다. 언제나 사용할 수 있는 방법은 아니다.



락 타임아웃


다른 데드락 방지 기술로는 락 시도에 타임아웃을 거는 방법이 있다. 이 타임아웃이란 쓰레드가 락을 획득하기 위해 기다리는 시간을 정해놓는다는 것을 의미한다. 락 타임아웃을 건 쓰레드가 주어진 시간을 소진하기까지 락을 획득하지 못하면 쓰레드는 이 락을 포기한다. 만약 쓰레드가 반드시 획득해야 하는 락에서 타임아웃이 걸리면, 이 쓰레드가 잡고 있는 모든 락을 해제하고 '얼마간의' 시간을 기다린 뒤 다시 락을 시도한다. 이 얼마간의 시간이란, 같은 락(타임아웃이 걸린)을 획득하려는 다른 쓰레드에게 모든 락을 획득할 기회를 주는 것이다. 때문에 이런 경우에는 어플리케이션이 어디에도 락을 걸지 못하고 계속 돌아가는 경우가 생길 수도 있다.


다음 예제는 두 쓰레드가 다른 순서로 같은 두 락을 획득하려 하다가 재시도를 하게 되는 경우이다:


Thread 1 locks A
Thread 2 locks B

Thread 1 attempts to lock B but is blocked
Thread 2 attempts to lock A but is blocked

Thread 1's lock attempt on B times out
Thread 1 backs up and releases A as well
Thread 1 waits randomly (e.g. 257 millis) before retrying.

Thread 2's lock attempt on A times out
Thread 2 backs up and releases B as well
Thread 2 waits randomly (e.g. 43 millis) before retrying.

위 예제에서 Thread 2 는 Thread 1 보다 약 200 밀리초 먼저 락 획득을 시도하게 될 것이다. 그리고 Thread 1 는  락 A 획득을 위해 기다리다가 Thread 2 의 작업이 끝나면 락을 획득할 수 있게 된다(그 사이 Thread 2 나 다른 쓰레드가 끼어들어 락을 가져가지 않는다면).


여기서 기억할만한 점은, 락 타임아웃은 꼭 데드락에 대해서만 발생하지 않는다는 것이다. 락 타임아웃은 데드락 뿐만 아니라, 단순히 작업을 처리하기 위해 락을 오래 잡고 있게 되면 얼마든지 발생할 수 있다.


이에 더하여, 만약 충분한 수의 쓰레드들이 공유 자원에 대한 작업을 완료한다 해도 이 쓰레드들은 여전히 동시에 자원에 접근한다는 위험부담을 감수해야 하고, 이 사실은 타임아웃과 백업을 적용하더라도 마찬가지다. 타임아웃은 두 쓰레드간의 대기시간이 0 에서 500 밀리초 사이인 경우는 발생하지 않을 수 있지만, 쓰레드의 수가 10이나 20 정도로 많아지면 상황은 달라진다. 두 쓰레드간의 동기 대기에 있어서 발생할 공산이 훨씬 높다.


락 타임아웃 메카니즘의 문제점은 동기화블록에의 진입에 대해서는 타임아웃을 설정할 수 없다는 것이다. 이를 위해서는 커스턴 락 클래스를 만들거나 java.util.concurrency 패키지의 컨커런시 구조를 사용해야 한다. 커스텀 락 클래스 작성은 그리 어렵지는 않지만 이 글의 범위를 벗어나는 일이다. 자바 컨커런시의 다음 글에서 이를 다뤄보기로 한다.



데드락 감지


데드락 감지는 더 무거운 데드락 방지 메카니즘이다. 이것은 락 정렬이나 락 타임아웃으로 커버할 수 없는 상황에 적용될 수 있다.


한 쓰레드가 락을 획들하면 이는 쓰레드와 락의 데이터 스트럭처(맵, 그래프 기타등등) 에 알려진다. 쓰레드가 락을 요청할 때는 언제나 이 데이터 스트럭처에 알려진다.


한 쓰레드가 락을 요청했는데 이 요청이 거부된다면, 이 쓰레드는 데드락을 체크하기 위해 락 그래프를 가로지르며 횡단한다. 예를 들어, Thread A 가 7번 락을 요청했는데 7번 락이 Thread B 에 잡혀 있다면, Thread A 는 Thread B 가 자신(Thread A)이 가진 락들 요청하지 않았는지 체크한다. 만약 thread B 가 욫어을 했엇다면, 데드락이 발생한다.


물론 실제 데드락 시나리오는 두 쓰레드간에 발생하는 방황보단 훨씬 복잡할 수 있다. Thread A 는 Thread B 를 기다리고, Thread B 는 Thread A 를 기다릴 수 있다. Thread A 가 데드락을 감지하기 위해서는 Thread B 에 의해 요청된 락을 수동적으로 확인해야 한다. Thread B 의 락 요청에서부터, Thread A 는 Thread C 로, Thread C 는 Thread D 로, Thread A 가 가진 락을 찾아나가게 된다. 그리고 락을 찾으면 데드락이 발생한다.


아래는 네 개의 쓰레드로부터 요청된 락들의 그래프이다. 이건 데이터 구조가 데드락 감지를 위해 쓰인다.



그래서, 데드락이 감지되면 쓰레드는 무엇을 하는걸까?


한가지 가능한 일은 모든 락을 해제하고, 백업하고, 무작위의 시간을 대기한 뒤 재시도하는 것이다. 이것은 락 타임아웃 메카니즘과 비슷한데, 락 타임아웃에서의 쓰레드는 백업까지만 수행한다. 하지만 백업-대기 를 수행하더라도 만약 많은 수의 쓰레드가 같은 락을 요청한다면 쓰레드들은 결국 반복적으로 데드락에 빠질 수 있다.


여기서 더 나은 선택은, 쓰레드의 우선순위를 정하여 한 번에 한 쓰레드(혹은 적은 수, 몇 개의 쓰레드)만 백업을 하도록 하는 것이다. 남은 쓰레드들은 마치 데드락이 발생하지 않은 것처럼, 필요한 락 획득을 계속해서 시도할 것이다. 만약 쓰레드의 우선순위가 고정적이라면, 동일한 쓰레드들은 언제나 높은 우선순위를 가지게 될 것이다. 이를 방지하기 위해 데드락이 감지될 때마다 우선순위 할당을 랜덤하게 하는 방법이 있다.



'Java > Concurrency' 카테고리의 다른 글

중첩 모니터 락아웃(Nested Monitor Lockout)  (0) 2017.04.16
기아상태와 공정성(Starvation and Fairness)  (0) 2017.04.09
데드락(Deadlock)  (0) 2017.04.09
쓰레드 시그널링  (0) 2017.04.09
자바 쓰레드로컬(ThreadLocal)  (1) 2017.04.09


이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

원문 URL : http://tutorials.jenkov.com/java-concurrency/deadlock.html



쓰레드 데드락


데드락이란, 둘 이상의 쓰레드가 lock 을 획득하기 위해 기다리는데, 이 lock 을 잡고 있는 쓰레드도 똑같이 다른 lock 을 기다리며 서로 블록 상태에 놓이는 것을 말한다. 데드락은 다수의 쓰레드가 같은 lock 을, 동시에, 다른 명령에 의해, 획득하려 할 때 발생할 수 있다.


예를 들자면, thread1 이 A 의 lock 을 가지고 있는 상태에서 B 의 lock 을 획득하려 한다. 그리고 thread2 는 B 의 lock 을 가진 상태에서 A 의 lock 을 획득하려 한다. 데드락이 생긴다. thread1 은 절대 B 의 lock 을 얻을 수 없고, 마찬가지로 thread2 는 절대 A 의 lock 을 얻을 수 없다. 두 쓰레드 중 어느 쪽도 이 사실을 모를 것이며, 쓰레드들은 각자의 lock - A 와 B 의 - 을 가진 채로 영원히 블록 상태에 빠진다. 이러한 상황이 데드락이다.


아래와 같이 표현된다:


Thread 1  locks A, waits for B
Thread 2  locks B, waits for A


그리고 다음은 서로 다른 인스턴스가 동기화된 메소드를 호출하는, TreeNode 클래스의 예제이다.


public class TreeNode {
 
  TreeNode parent   = null;  
  List     children = new ArrayList();

  public synchronized void addChild(TreeNode child){
    if(!this.children.contains(child)) {
      this.children.add(child);
      child.setParentOnly(this);
    }
  }
  
  public synchronized void addChildOnly(TreeNode child){
    if(!this.children.contains(child){
      this.children.add(child);
    }
  }
  
  public synchronized void setParent(TreeNode parent){
    this.parent = parent;
    parent.addChildOnly(this);
  }

  public synchronized void setParentOnly(TreeNode parent){
    this.parent = parent;
  }
}


Thread1 이 parent.addChild(child) 메소드를, Thread 2 는 child.setParent(parent) 메소드를 각각 동시에, 같은 parent 와 child 인스턴스에 호출한다면,데드락이 발생할 수 있다.


이에 대한 유사코드가 있다:


Thread 1: parent.addChild(child); //locks parent
          --> child.setParentOnly(parent);

Thread 2: child.setParent(parent); //locks child
          --> parent.addChildOnly()


먼저, Thread 1 이 parent.addChild(child) 를 호출한다. addChild() 는 동기화된 메소드이기 때문에 Thread 1 은 parent 객체를 다른 쓰레드로부터의 접근을 막도록 lock 을 건다.


다음으로 Thread 2 은 child.setParent(parent) 를 호출한다. setParent() 역시 동기화된 메소드이고, Thread 2 는 child 객체를 다른 쓰레드로부터의 접근을 막도록 lock 을 건다.


이렇게 child 와 parent 객체들은 두 개의 다른 쓰레드에 의해 lock 이 걸린다. 그리고 Thread 1 은 child.setParentOnly() 메소드 호출을 시도하는데, 이 child 객체는 Thread 2 에 의해 lock 이 걸린 상태이고,Thread 1 은 블록 상태가 된다. Thread 2 는 또한 parent.addChildOnly() 호출을 시도하지만, parent 객체는 Thread 1 에 의해 lock 이 걸린 상태이고 Thread 2 역시 블록 상태가 된다. 이제 두 쓰레드 모두 서로가 잡고 있는 lock 을 기다리며 블록 상태가 된다.


주목할 점: 위 설명에 나와있듯, 이 두 쓰레드의 parent.addChild(child) 와 child.setParent(parent) 호출은 반드시 동시에, 서로의 parent, child 인스턴스에 대해 호출하여야만 데드락이 발생할 수 있다. 위 코드는 갑자기 데드락이 발생하기 전 까지는 정상적으로 작동할 수 있다.


쓰레드들은 정말로 lock 을 '동시에' 획득해야만 한다. 예를 들어, Thread 1 이 Thread 2 보다 약간 앞선다면, 그래서 child 와 parent 양쪽에 모두 lock 을 걸어버린다면, Thread 2 는 child 의 lock 을 획득하려 할 때 이미 블록 상태에 빠질 것이다. 이렇게 되면 데드락은 없다. 쓰레드의 스케쥴링은 예측할 수 없는 경우가 많기 때문에, 데드락이 '언제' 발생할지는 알 수 없다. 그저 '발생할 수 있을' 뿐이다.



더 복잡한 데드락


데드락은 셋 이상의 쓰레드에서도 발생할 수 있다. 이런 현상은 감지되기 더 어렵다.


다음은 네 쓰레드가 데드락에 빠진 예제이다.


Thread 1  locks A, waits for B
Thread 2  locks B, waits for C
Thread 3  locks C, waits for D
Thread 4  locks D, waits for A


Thread 1 은 Thread 2 를 기다리고, Thread 2 는 Thread 3 을, Thread 3 은 Thread 4 를 기다린다. 그리고 Thread 4 는 Thread 1 을 기다린다.



데이터베이스 데드락


데드락이 발생할 수 있는 더 복잡한 상황은, 데이터베이스 트랜젝션이다. 데이터베이스 트랜젝션은 많은 SQL 업데이트 요청으로 구성되곤 한다. 한 트랜잭션에서 어떤 레코드에 대해 업데이트가 수행될 때, 이 레코드는 업데이트 수행을 위해 다른 트랜잭션의 접근을 막도록 lock 이 걸린다. 이 lock 은 업데이트를 수행하는 트랜젝션이 끝날 때까지 지속된다. 같은 트랜잭션 안에서의 각 업데이트는 데이터베이스의 레코드들에 lock 을 걸 수 있다.


다수의 트랜젝션이 동시에 같은 레코드들을 업데이트한다면, 이는 데드락에 빠질 위험성이 있다.


예제:


Transaction 1, request 1, locks record 1 for update
Transaction 2, request 1, locks record 2 for update
Transaction 1, request 2, tries to lock record 2 for update.
Transaction 2, request 2, tries to lock record 1 for update.


여기서 lock 은 서로 다른 요청에 의해 잡혀있고, 어느 쪽도 먼저 알려져 있지 않기 때문에, 데이터베이스 트랜젝션에서의 데드락은 감지하거나 방지하기가 어렵다.



'Java > Concurrency' 카테고리의 다른 글

기아상태와 공정성(Starvation and Fairness)  (0) 2017.04.09
데드락 방지(Deadlock Prevention)  (0) 2017.04.09
쓰레드 시그널링  (0) 2017.04.09
자바 쓰레드로컬(ThreadLocal)  (1) 2017.04.09
자바 volatile 키워드  (2) 2017.04.09

이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

원문 URL : http://tutorials.jenkov.com/java-concurrency/thread-signaling.html



쓰레드 시그널링의 목적은 쓰레드들이 서로 신호를 교환하도록 하기 위함이다. 쓰레드 시그널링은 쓰레드에게 다른 쓰레드로부터의 신호를 기다리도록 할 수 있다. 예를 들어, Thread B 는 Thread A 의 신호 -처리될 데이터가 준비되었다는- 가 올 때 까지 대기할 수 있다.



공유 객체를 이용한 시그널링


쓰레드들이 서로 신호를 보내도록 하는 간단한 방법은 공유 객체 변수에 신호값을 세팅하는 것이다. Thread A 는 동기화블록 안에서 boolean 타입 멤버변수인 hasDataToProcess 에 true 를 세팅하고, Thread B 는 동기화블록 안에서 이 변수의 값을 읽는다. 여기 시그널 변수를 가진 한 객체가 이 변수에 값을 세팅하고 확인하는 메소드를 제공하는 예제가 있다:


public class MySignal{

  protected boolean hasDataToProcess = false;

  public synchronized boolean hasDataToProcess(){
    return this.hasDataToProcess;
  }

  public synchronized void setHasDataToProcess(boolean hasData){
    this.hasDataToProcess = hasData;  
  }

}


Thread A 와 B 는 시그널링을 위해 MySignal 의 인스턴스를 공유해야 한다. 만일 이 두 쓰레드가 서로 다른 MySignal 인스턴스를 참조한다면, 이들은 서로의 신호를 감지할 수 없을 것이다. 시그널링을 위한 데이터는 MySignal 인스턴스로부터 분리된 공유 버퍼에 위치할 수 있다.



Busy Wait


데이터를 처리하는 쓰레드인 Thread B 는 데이터가 접근 가능해질 때까지 기다린다. 다시 말해서, 이 쓰레드는 Thread A 가 hasDataToProcess() 메소드에서 true 를 반환할 때까지 기다린다. Thread B 돌고 있는 루프는 다음과 같이 신호를 기다린다:


protected MySignal sharedSignal = ...

...

while(!sharedSignal.hasDataToProcess()){
  //do nothing... busy waiting
}


루프는 hasDatToProcess() 메소드가 true 를 호출할 때까지 그저 기다린다. 이를 busy waiting 이라 부른다. 이 쓰레드는 기다리기 바쁘다(The thread id busy while waiting).



wait(), notify() and notifyAll()


Busy waiting 은 대기(waiting)하는 쓰레드를 돌리기 위한 CPU의 활용으로써 그다지 효율적인 활용은 아니다. 평균 대기 시간이 아주 짧을 경우에는 괜찮겠지만, 그렇지 않다면 차라리 대기중인 쓰레드로 하여금 - 기다리는 신호가 도착할 때까지 - sleep 상태나 inactive 하도록 만드는 편이 현명하다.


자바에는 신호를 기다리는 동안 쓰레드의 활동을 중단하도록 하는 대기 메카니즘이 내장되어 있다. java.lang.Object 클래스에는 이를 위한 세 가지 메소드, wait(), notify() 그리고 notifyAll() 이 정의되어 있다.


wait() 메소드는 java.lang.Object 클래스에 정의되어 있기 때문에 어느 객체에든 이 메소드를 호출할 수 있는데, 어느 객체의 wait() 메소드를 호출하든, 이를 호출하는 쓰레드는 다른 쓰레드가 notify() 를 호출할 때까지 활동을 중단한다. wait() 이나 waiting 을 깨우는 메소드를 호출하기 위해서는 쓰레드는 먼저 반드시 해당 객체의 lock 을 획득하고 있어야 한다. 다시말해, wait() 이나 notify() 메소드 호출은 반드시 synchronized 블록 안에서 이루어져야 한다는 것이다. 


다음은 MySignal 클래스가 이를 활용하도록 수정한 버전이다:


public class MonitorObject{
}

public class MyWaitNotify{

  MonitorObject myMonitorObject = new MonitorObject();

  public void doWait(){
    synchronized(myMonitorObject){
      try{
        myMonitorObject.wait();
      } catch(InterruptedException e){...}
    }
  }

  public void doNotify(){
    synchronized(myMonitorObject){
      myMonitorObject.notify();
    }
  }
}


waiting 쓰레드는 doWait() 을 호출하고, notifying 쓰레드는 doNotify() 를 호출할 것이다. 한 쓰레드가 한 객체의 notify() 를 호출하면, 이 객체의 wait() 메소드 호출을 통해 대기중인 쓰레드들 중 한 쓰레드가 대기 상태에서 깨어나고 실행 가능한 상태가 된다. notify() 대신 notifyAll() 메소드를 호출하면 이 객체를 두고 대기중인 모든 쓰레드가 깨어난다.


보다시피 wait() 와 notify() 메소드 호출은 synchronized 블록 안에서 호출된다. 이것은 필수이다. 쓰레드는 wait(), notify() notifyAll() 메소드들이 호출되는 객체에 대한 lock 을 가지고 있지 않은 상태에서 이 메소드들을 호출할 수 없다. lock 을 가지지 않고 이 메소드들을 호출하면 IllegalMonitorStateexception 이 던져진다.


그런데, 어떻게 이게 가능할까? waiting 쓰레드는 synchronized 블록 안의 코드를 실행하는 한, 모니터 객체(myMonitorObject)에 대한 lock 을 유지하고 있지 않은가? waiting 쓰레드는 notifying 쓰레드가 doNotify() 를 호출하며 synchronized 블록 안으로 진입하는 것을 막지 않는 것인가? 대답은 no 다. 쓰레드는 wait() 을 호출하면 자신이 가지고 있던 lock 을 풀어두게 된다. 그리고 이로 인해 다른 쓰레드들이 또 lock 을 획득하고, wait() 이나 notify() 메소드를 호출할 수 있게 되는 것이다. 


한 번 wait() 메소드 호출로 잠들었다가 notify() 호출에 의해 깨어난 쓰레드는 notify() 메소드를 호출한 쓰레드가 풀어놓은 lock 을 다시 획득해야만 wait() 호출을 벗어날 수 있다. 다시말해: wait() 호출은 synchronized 블록 안에서 이루어지기 때문에, notify() 에 의해 깨어난 쓰레드는 wait() 호출을 벗어나기 위해 lock을 재획득해야만 한다. notifyAll() 호출이 잠들어있던 다수의 쓰레드를 깨우면, 이 잠들어있던 쓰레드들은 차례대로 lock 을 재획득하기 때문에, 오직 한 시점에 한 쓰레드만이 wait() 메소드를 벗어날 수 있는 것이다.



Missed Signals


wait() 를 호출한 쓰레드가 없는 상태에서 notify(), notifyAll() 메소드를 호출하면, notify(), notifyAll() 의 신호는 그대로 분실된다. 이는 문제가 될 수도, 안 될 수도 있는데, 때때로 이런 상황은 영영 깨어나지 못하고 계속 잠들어 있는 쓰레드를 만들어 내기도 한다. 이 쓰레드를 꺠우기 위한 신호가 분실됐기 때문이다.


이렇게 신호를 분실하는 상황을 피하기 위해, 신호를 내부에 저장하는 클래스를 만들어야 한다. 위 예제의 MyWaitNotify 는 notify 신호를 MyWaitNotify 인스턴스의 멤버변수로 저장할 필요가 있다. 아래 예제는 그것을 적용한 버전이다:


public class MyWaitNotify2{

  MonitorObject myMonitorObject = new MonitorObject();
  boolean wasSignalled = false;

  public void doWait(){
    synchronized(myMonitorObject){
      if(!wasSignalled){
        try{
          myMonitorObject.wait();
         } catch(InterruptedException e){...}
      }
      //clear signal and continue running.
      wasSignalled = false;
    }
  }

  public void doNotify(){
    synchronized(myMonitorObject){
      wasSignalled = true;
      myMonitorObject.notify();
    }
  }
}


doNotify() 메소드가 notify() 를 호출하기 전에 wasSignalled 를 true 로 세팅하는 부분과, doWait() 메소드가 wait() 를 호출하기 전에 wasSignalled 를 체크하는 부분을 잘 봐야한다. wait() 메소드는 doNotify() 가 호출되지 않은 상태에서만 호출된다.



Spurious wakeups


알 수 없는 이유로, notify() 나 notifyAll() 이 호출되지 않았음에도 불구하고 쓰레드가 깨어나는 현상이 있다. 이는 spurious wakeups 라 불리는데, 어떤 분명한 이유없이 쓰레드가 깨어난다.


만일 MyWaitNotify2 클래스의 doWait() 메소드에 spurious wakeup 이 발생한다면, 필요할 때 doNotify() 메소드 호출을 통해 깨어났어야 할 waiting 쓰레드가 제멋대로 깨어나 동작을 재개하는 현상이 발생할 수 있고, 이는 어플리케이션에 심각한 문제가 될 수 있다.


spurious wakeups 현상을 막기 위해서 신호를 저장하는 멤버변수 체크를 if 대신 while 루프에서 수행하는 방법이 있다. 이 역할을 하는 루프를 spin lock 이라 부른다. spin lock 안의 조건이 false 가 될 때가지 쓰레드는 루프를 계속 돈다.


MyWaitNotify2 에 spin lock 을 적용한 예제는 다음과 같다:


public class MyWaitNotify3{

  MonitorObject myMonitorObject = new MonitorObject();
  boolean wasSignalled = false;

  public void doWait(){
    synchronized(myMonitorObject){
      while(!wasSignalled){
        try{
          myMonitorObject.wait();
         } catch(InterruptedException e){...}
      }
      //clear signal and continue running.
      wasSignalled = false;
    }
  }

  public void doNotify(){
    synchronized(myMonitorObject){
      wasSignalled = true;
      myMonitorObject.notify();
    }
  }
}


wait() 호출은 if 가 아닌 while 루프 안에 있다. 이제 doNotify() 메소드가 호출되지 않는 한 wasSignalled 변수는 false 를 유지하게 되기 때문에, waiting 쓰레드가 깨어나도 wasSignalled 를 반복 체크하여 다시 wai() 메소드를 호출하고 대기 상태로 돌아갈 것이다.



같은 신호를 기다리는 멀티플 쓰레드


만약 다수의 waiting 쓰레드들이 있고, 이 쓰레드들이 모두 notifyAll() 호출을 통해 깨워지지만, 이들 중 한 쓰레드만 작업이 허용되는 상황에서라면, while 루프는 좋은 해결책이 된다. 


한 시점에 오직 한 쓰레드만 lock 을 획득할 것이고, 이는 이 한 쓰레드만 wait() 호출을 벗어나 wasSignalled 플레그를 초기화 할 수 있다는 것을 의미한다. 이 쓰레드가 wait() 호출을 벗어나고 synchronized 블록을 벗어나면, 대기중이던 다른 쓰레드들도 lock 을 획득하여 wait() 호출을 벗어나고 while 루프의 wasSignalled 변수를 체크한다. 그런데 여기서 이 wasSignalled 플레그 변수는 첫 번째로 루프를 빠져나간 쓰레드에 의해 false 로 초기화되었기 때문에, 나머지 다른 쓰레드들은 다시 루프를 반복하여 대기상태로 놓이게 된다.



글로벌 객체나 문자열 상수에 wait() 을 호출하지 말 것


이 글의 예전 버전에서는 MyWaitNotify 예제 클래스의 수정판이 있었는데, 이 클래스는 상수 문자열 ("") 을 모니터 객체로 사용했었다:


public class MyWaitNotify{

  String myMonitorObject = "";
  boolean wasSignalled = false;

  public void doWait(){
    synchronized(myMonitorObject){
      while(!wasSignalled){
        try{
          myMonitorObject.wait();
         } catch(InterruptedException e){...}
      }
      //clear signal and continue running.
      wasSignalled = false;
    }
  }

  public void doNotify(){
    synchronized(myMonitorObject){
      wasSignalled = true;
      myMonitorObject.notify();
    }
  }
}


빈 문자열이나 다른 문자열 상수에 대해 wait() 과 notify() 를 사용할 때의 문제점은, JVM/컴파일러는 내부적으로 상수 문자열을 같은 객체로 변환한다는 것이다. 같은 문자로 구성된 문자열 상수는 하나의 인스턴스가 되어 참조되는데, 때문에 빈 문자열을 모니터 객체로 사용하게 되면, MyWaitNotify 의 인스턴스가 여러개일 때, 모두 같은 하나의 인스턴스 - 문자열 인스턴스, ("") - 를 모니터 객체로 공유하게 되는 것이다.


이 상황을 다이어그램으로 나타내자면 다음과 같다:



MyWaitNotify1 인스턴스과 MyWaitNotify2 인스턴스는 같은 문자열 인스턴스를 모니터 객체로 공유한다. doWait(), doNotify() 의 신호 변수는 각각의 인스턴스에 개별적으로 저장되어 있는데, 같은 모니터 객체 하나("")를 공유해서 사용하기 때문에 이럴 경우 MyWaitNotify1 의 doNotify() 호출이 MyWaitNotify2 를 깨우고, 그 신호는 MyWaitNotify1 에 저장하는 문제가 발생할 수 있다.


이 현상은 큰 문제가 될 수 있는데, 일단 MyWaitNotify2 인스턴스에 대한 doNotify() 호출이 Thread A 와 Thread B 를 의도치 않게 깨울 수 있다. 이렇게 깨어난 쓰레드들은 while 루프에서 자신의 신호(signal) 변수를 체크하고 다시 대기상태로 돌아갈 것이다. 왜냐하면 doNotify() 는 Thread A/B 가 대기중인 MyWaitNotify 인스턴스에 대해 호출된 것이 아니기 때문이다. 이 상황은 spurious wakeup 이 발생한 것과 같은 상황이다. Thread A 나 B 는 의도치 않게 깨워졌다. 하지만 신호 변수가 바뀌지 않았기에 이 쓰레드들은 다시 대기상태로 돌아가고, 아직까진 괜찮은 상황이다.


문제는 doNotify() 메소드는 notifyAll() 이 아닌, notify() 를 호출한다는 것인데, 같은 모니터 객체("") 를 두고 4개의 쓰레드가 대기중에 있지만 오직 한 쓰레드만 깨어난다. 때문에, 만약 원래 깨우려고 했던 쓰레드가 C 나 D 였지만 Thread A 나 B 중 하나가 깨어난 상황이 발생하면, 이 깨어난 쓰레드는 자신의 신호 변수를 체크하고는 자신에게 주어진 신호가 없음을 알게된다. 그리고 다시 대기상태로 돌아간다. Thread C 나 D 중 어느쪽도 - 깨워지지 못했기 때문에 - 자신에게 주어진 신호를 체크하지 않는다. 신호가 분실된 것이다. 이 상황은 위에서 설명된, 신호 분실(missed signals) 문제와 같다. Thread c 와 D 는 신호를 받았지만, 응답에는 실패했다.


doNotify() 메소드가 notify() 가 아닌 notifyAll() 을 호출한다면, 대기중이던 모든 쓰레드들이 깨어나서 차례대로 자신들의 신호 변수를 체크할 것이다. 그리고 Thread A 와 B 는 자신들에게 온 신호가 없기에 다시 대기상태로 돌아가고, C 와 D 중 하나는 신호를 체크하고 doWait() 메소드 호출을 벗어나게 된다.

이 설명을 읽은 당신은 어쩌면 언제나 notify() 대신 notifyAll() 을 호출하려 할 수도 있다. 하지만 이는 성능을 고려할 때 바람직하지 못하다. 신호를 받고 응답하는 쓰레드가 단 하나뿐인데 모든 쓰레드를 깨울 이유는 없다.


결론: wait()/notify() 를 글로벌한 객체나, 문자열 상수같은 곳에 사용하지 말아야 한다. 유니크하게 사용될 수 있는 객체를 모니터로 사용하자. 예를 들자면 위 예제들 중 MyWaitNotify3 인스턴스가 자신의 MonitorObject 인스턴스를 가지고 있는 것처럼.



'Java > Concurrency' 카테고리의 다른 글

데드락 방지(Deadlock Prevention)  (0) 2017.04.09
데드락(Deadlock)  (0) 2017.04.09
자바 쓰레드로컬(ThreadLocal)  (1) 2017.04.09
자바 volatile 키워드  (2) 2017.04.09
자바 동기화 블록(Java Synchronized Blocks)  (0) 2017.04.09

이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

원문 URL : http://tutorials.jenkov.com/java-concurrency/threadlocal.html



자바 ThreadLocal 클래스는 오직 한 쓰레드에 의해 읽고 쓰여질 수 있는 변수를 생성할 수 있도록 한다. 때문에 만일 두 쓰레드가 같은 코드를 실행하고 이 코드가 하나의 ThreadLocal 변수를 참조하더라도, 이 두 쓰레드는 서로의 ThreadLocal 변수를 볼 수 없다. 글자 그대로 쓰레드의 지역변수이다.



쓰레드로컬 생성


다음은 ThreadLocal 을 생성하는 간단한 예제이다:


private ThreadLocal myThreadLocal = new ThreadLocal();


보다시피, 새로운 ThreadLocal 객체를 생성했다. 이 코드는 오직 한 쓰레드 당 한 번의 실행을 허용하여, 다수의 쓰레드에 의한 실행에도 각 쓰레드는 자신들 각자의 ThreadLocal 인스턴스를 가지게 된다. 두 쓰레드가 각자의 변수를 ThreadLocal 객체에 세팅했다면, 쓰레드들은 서로 다른 ThreadLocal 변수값을 가지게 되며, 오직 자신의 변수만 볼 수 있게 된다.



쓰레드로컬 다루기


쓰레드로컬을 생성하면 다음과 같은 방법으로 쓰레드로컬에 값을 저장할 수 있다:


myThreadLocal.set("A thread local value");


다음으로 쓰레드로컬의 값을 읽는 방법은 다음과 같다:


String threadLocalValue = (String) myThreadLocal.get();


get() 메소드는 Object 타입 객체를 반환하고, set() 메소드 역시 Object 타입을 파라미터로 받는다.



쓰레드로컬 제너릭


쓰레드로컬은 변수 타입을 다루기 쉽도록, 제너릭으로 생성 가능하다:


private ThreadLocal myThreadLocal = new ThreadLocal<String>();
myThreadLocal.set("Hello ThreadLocal");

String threadLocalValue = myThreadLocal.get();



쓰레드로컬 변수 초기화


쓰레드로컬 객체에 세팅한 값은 오직 값을 세팅한 쓰레드만 접근할 수 있기 때문에, 모든 쓰레드가 사용할 수 있는 쓰레드로컬 초기값(디폴트)은 없다. 대신, 쓰레드로컬을 서브클래싱하고 initialValue() 메소드를 오버라이딩하는 방법으로 쓰레드로컬의 초기값을 설정할 수 있다:


private ThreadLocal myThreadLocal = new ThreadLocal<String>() {
    @Override protected String initialValue() {
        return "This is the initial value";
    }
};  


이제 이 코드를 이용하는 모든 쓰레드들은 set() 메소드로 값을 세팅하지 않아도 get() 메소드를 호출하면 이 초기값을 볼 수 있을 것이다.



쓰레드로컬 예제


다음은 쓰레드로컬 예제이다:


public class ThreadLocalExample {


    public static class MyRunnable implements Runnable {

        private ThreadLocal<Integer> threadLocal =
               new ThreadLocal<Integer>();

        @Override
        public void run() {
            threadLocal.set( (int) (Math.random() * 100D) );
    
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
            }
    
            System.out.println(threadLocal.get());
        }
    }


    public static void main(String[] args) {
        MyRunnable sharedRunnableInstance = new MyRunnable();

        Thread thread1 = new Thread(sharedRunnableInstance);
        Thread thread2 = new Thread(sharedRunnableInstance);

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

        thread1.join(); //wait for thread 1 to terminate
        thread2.join(); //wait for thread 2 to terminate
    }

}


이 예제는 Runnable 인스턴스인 MyRunnable 클래스의 인스턴스를 1개 생성하여 두 개의 다른 쓰레드에게 전달한다. 각 쓰레드는 run() 메소드를 호출하고, ThreadLocal 인스턴스에 각자 다른 값을 세팅한다. 만약 여기서 ThreadLocal 객체가 없고 set() 메소드 호출부가 동기화(synchronized)되어 있다면, 두 번째 쓰레드의 값이 첫 번째 쓰레드가 세팅한 값을 덮어쓸 것이다.


하지만 여기선 ThreadLocal 객체가 있고, 두 쓰레드는 서로의 값에 접근하지 않은 채로 각자의 값을 가진다.



InheritableThreadLocal


InheritableThreadLocal 클래스는 ThreadLocal 클래스의 서브클래스이다. ThreadLocal 은 모든 쓰레드가 각자의 값을 가지게 하지만, InheritableThreadLocal 은 자신에게 세팅된 값을, 값을 세팅한 쓰레드는 물론이고 이 쓰레드에 의해 생성된 자식 쓰레드들도 볼 수 있도록 한다.



'Java > Concurrency' 카테고리의 다른 글

데드락(Deadlock)  (0) 2017.04.09
쓰레드 시그널링  (0) 2017.04.09
자바 volatile 키워드  (2) 2017.04.09
자바 동기화 블록(Java Synchronized Blocks)  (0) 2017.04.09
자바 메모리 모델  (2) 2017.04.09

이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

원문 URL : http://tutorials.jenkov.com/java-concurrency/volatile.html



 자바 volatile 키워드는 자바 코드의 변수를 '메인 메모리에 저장' 할 것을 명시하기 위해 쓰인다. 정확히 말해서, 모든 volatile 변수는 컴퓨터의 메인 메모리로부터 읽히고, volatile 변수에 대한 쓰기 작업은 메인 메모리로 직접 이루어진다. - CPU 캐시가 쓰이지 않는다.


실제로는 자바 5 부터 volatile 키워드는 메인 메모리로부터 읽고 쓰는 작업 이상의 것을 보장한다. 다음 섹션에서 이에 대해 설명한다.



volatile 의 가시성 보장


volatile 키워드는 쓰레드들에 대한 변수의 변경의 가시성을 보장한다. 이는 다소 추상적으로 들릴 수 있는데, 상세히 들어가보자.


멀티쓰레드 어플리케이션에서의 non-volatile 변수에 대한 작업은 성능상의 이유로 CPU 캐시를 이용한다. 둘 이상의 CPU가 탑제된 컴퓨터에서 어플리케이션을 실행한다면, 각 쓰레드는 변수를 각 CPU의 캐시로 복사하여 읽어들인다. 



non-volatile 변수에 대한 작업은 JVM 이 메인 메모리로부터 CPU 캐시로 변수를 읽어들이거나, CPU 캐시로부터 메인 메모리에 데이터를 쓰거나 할 때에 대한 어떠한 보장도 하지 않는다. 이는 몇가지 문제를 야기할 수 있는데, 다음 섹션을 보자.


둘 이상의 쓰레드가 다음과 같은 공유 객체로 접근하는 경우를 생각해보자.


public class SharedObject {

    public int counter = 0;

}


Thread1 은 counter 변수를 증가시키고, Thread1 과 Thread2 가 때에 따라서 counter 변수를 읽는다.


만일 counter 변수에 volatile 키워드가 없다면, counter 변수가 언제 CPU 캐시에서 메인 메모리로 쓰일지(written) 보장할 수 없다. CPU 캐시의 counter 변수와 메인 메모리의 counter 변수가 다른 값을 가질 수 있다는 것이다.



쓰레드가 변경한 값이 메인 메모리에 저장되지 않아서 다른 쓰레드가 이 값을 볼 수 없는 상황을 '가시성' 문제라 한다. 한 쓰레드의 변경(update)이 다른 쓰레드에게 보이지 않는다.


counter 변수에 volatile 키워드를 선언한다면 이 변수에 대한 쓰기 작업은 즉각 메인 메모리로 이루어질 것이고, 읽기 작업 또한 메인 메모리로부터 다이렉트로 이루어질 것이다.


public class SharedObject {

    public volatile int counter = 0;

}


volatile 선언은 다른 쓰레드의 쓰기 작업에 대한 가시성을 보장한다.



volatile 키워드의 Happens-Before 보장


자바 5 에서부터 volatile 키워드는 변수에 대한 읽기/쓰기 작업의 메인 메모리 사용을 보장하는 것 이상의 것을 보장한다고 했는데, 이는 다음과 같다.


  • It Thread A writes to a volatile variable and Thread B subsequently reads the same volatile variable, then all variables visible to Thread A before writing the volatile variable, will also be visible to Thread B after it has read the volatile variable.

  • The reading and writing instructions of volatile variables cannot be reordered by the JVM (the JVM may reorder instructions for performance reasons as long as the JVM detects no change in program behaviour from the reordering). Instructions before and after can be reordered, but the volatile read or write cannot be mixed with these instructions. Whatever instructions follow a read or write of a volatile variable are guaranteed to happen after the read or write.


이 문장에 대해서는 더 깊은 설명이 필요하다.


한 쓰레드가 volatile 변수를 수정할 때, 단지 이 volatile 변수만이 메인 메모리로 저장되는 것이 아니라, 이 쓰레드가 volatile 변수를 수정하기 전에 수정한 모든 변수들이 함께 메인 메모리에 저장(flushed)된다. 그리고 쓰레드가 volatile 변수를 메인 메모리에서 읽어들일 때, volatile 변수를 수정하면서 메인 메모리로 함께 저장된 다른 모든 변수들도 메인 메모리로부터 함께 읽어들여진다.


예제를 보자:


Thread A:
    sharedObject.nonVolatile = 123;
    sharedObject.counter     = sharedObject.counter + 1;

Thread B:
    int counter     = sharedObject.counter;
    int nonVolatile = sharedObject.nonVolatile;


Thread A 가 non-volatile 변수인 sharedObject.nonVolatile 을, volatile 변수인 sharedObject.counter 를 읽기 전에 수정하기 때문에, 두 변수 sharedObject.nonVolatile 과 sharedObject.counter 는 Thread A 가 sharedObject.counter(volatile 변수) 를 수정할 때 메인 메모리로 함께 저장된다.


Thread B 는 sharedObject.counter 를 읽으면서 시작하는데, 이 때 sharedObject.nonVolatile 도 메인 메모리에서 CPU 캐시로 읽어온다. Thread B 가 sharedObject.nonVolatile 변수를 읽었을 때 이 변수의 값은 Thread A 에 의해 수정된 값이 읽혀진다.


개발자들은 쓰레드간의 변수의 가시성의 최적화를 보장하기 위해 이 확장된 가시성을 활용할 수 있다. 모든 변수에 volatile 을 선언하는 대신, 단 하나, 혹은 몇몇의 변수에만 volatile 을 선언하면 된다. 여기 Exchanger 클래스를 보자:


public class Exchanger {

    private Object   object       = null;
    private volatile boolean hasNewObject = false;

    public void put(Object newObject) {
        while(hasNewObject) {
            //wait - do not overwrite existing new object
        }
        object = newObject;
        hasNewObject = true; //volatile write
    }

    public Object take(){
        while(!hasNewObject){ //volatile read
            //wait - don't take old object (or null)
        }
        Object obj = object;
        hasNewObject = false; //volatile write
        return obj;
    }
}


Thread A 는 시간에 따라 put() 을 호출하여 객체를 대입하고, Thread B 는 take() 를 호출하여 객체를 읽어 반환한다. 두 쓰레드가 맡은대로 Thread A 는 put() 만을, Thread B 는 take() 만을 호출하는 한, 이 Exchanger 클래스는 synchronized 블록 없이도 volatile 변수를 이용해 문제 없이 작동할 수 있다.


하지만 이 코드에서 만일 JVM 이 코드 실행 동작을 바꾸지 않으면서 코드를 재정리할 수 있다면, JVM 은 성능 향상을 위해 코드를 재정리하려 할 것이다. JVM 이 put() 과 take() 의 읽기와 쓰기 작업의 순서를 바꾼다면 어떻게 될까? 


put() 의 실행 코드가 다음과 같이 바뀔 수(재정리) 있다고 생각해보자.


while(hasNewObject) {
    //wait - do not overwrite existing new object
}
hasNewObject = true; //volatile write
object = newObject;


volatile 변수 hasNewObject 로의 쓰기 작업이 object 가 세팅되기 전으로 바뀌었다. JVM 의 시각으로 이것은 완전히 유효한 코드이다. 두 쓰기 작업은 서로에게 의존하지 않는다.


그러나 이 재정리는 object 변수의 가시성에 손상을 줄 수 있다. 먼저, Thread B 는 Thread A 가 실제로 object 에 newObject 를 세팅하기도 전에 hasNewObject 값을 true 로 읽을 수가 있다. 둘째로, 새 객체가 세팅된 object 변수가 어느 시점에 메인 메모리로 저장될지에 대한 보장이 없다.


이러한 상황을 피하기 위해, volatile 키워드는 "happends before guarantee" 성질을 갖는데, 이것은 volatile 변수에 대한 읽기/쓰기 명령은 JVM 에 의해 재정리되지 않음을 보장한다는 의미이다. volatile 변수에 대한 읽기/쓰기 명령을 기준으로, 이 변수 전에 존재하는 다른 명령들은 자기들끼리 얼마든지 재정리 될 수 있다. 그리고 이 변수 뒤에 존재하는 다른 명령들 또한 자기들끼리 재정리 될 수 있다. 다만, volatile 변수에 대한 명령 이전/이후에 존재한다는 그 전제는 반드시 지켜진다.


혼동될 수 있으니 다음 예제와 설명을 자세히 보자:


sharedObject.nonVolatile1 = 123;
sharedObject.nonVolatile2 = 456;
sharedObject.nonVolatile3 = 789;

sharedObject.volatile     = true; //a volatile variable

int someValue1 = sharedObject.nonVolatile4;
int someValue2 = sharedObject.nonVolatile5;
int someValue3 = sharedObject.nonVolatile6;


JVM 은 -volatile 변수에 쓰기 명령이 실행되기 전에 실행되는- 처음 3개의 명령을 재정리할 수 있다. 다만 이 명령들은 반드시 volatile 변수에 쓰기 명령이 실행되기 전에 실행되어야 한다.


같은 맥락으로, JVM 은 volatile 변수에 쓰기 명령이 실행 먼저 실행되기만 한다면, 마지막 3개의 명령을 재정리할 수 있다. 다만 이 명령들은 반드시 volatile 변수에 쓰기 명령이 실행된 후에 실행되어야 한다.


이 성질을 Java volatile happends before guarantee 라 한다.



volatile 은 만병통치약이 아니다


volatile 선언이 변수의 읽기/쓰기 명령을 메인 메모리로부터 수행한다는 것을 보장한다고 할지라도, volatile 선언으로 해결할 수 없는 상황들은 여전히 남아있다.


위의 예제들 중, 공유 변수인 counter 가 있고 Thread1 만이 이 변수를 수정하고, Thread2 만이 이 변수를 읽는 이런 상황에서라면 volatile 선언이 변수의 가시성을 보장해준다.


멀티쓰레드 환경에서, volatile 공유 변수에 세팅된 새로운 값이 이 변수가 가지고 있던 이전의 값에 의존적이지 않는다면, 다수의 쓰레드들이 volatile 공유 변수를 수정하면서도 메인 메모리에 존재하는 정확한 값을 읽을 수 있다. 달리 말하자면, 만일 volatile 공유 변수를 수정하는 한 쓰레드가 이 변수의 다음 값을 알아내기 위해 이전의 값을 필요로 하지 않는다면 말이다.


쓰레드가 volatile 변수의 초기값을 필요로 할 때, 그리고 volatile 변수의 새 값이 이 초기값을 근거로 할 때, volatile 선언은 더이상 정확한 가시성을 보장하기 못한다. volatile 변수를 읽고, 새 값을 쓰는 사이의 짧은 갭은 경합 조건을 일으킨다 - 다수의 쓰레드가 volatile 변수의 값을 똑같이 읽고, 새 값을 생성하여 이를 메인 메모리로 저장하는 동안, 서로의 값을 덮어쓸 수 있다.


다수의 쓰레드가 같은 counter 값을 증가시키는 상황이 바로 volatile 변수가 불완전해지는 상황이다. 다음 섹션에서 이에 대해 자세히 설명한다.


상황을 가정해보자. Thread1 이 공유 변수인 counter 의 값 0 을 자신의 CPU 캐시에 읽어들이고, 이 값을 1 증가시키고, 아직 메인 메모리로 저장하지 않았다. 그리고 Thread2 는 같은 counter 변수를 메인 메모리에서 자신의 CPU 캐시로 읽어들였고, 이 때의 값은 여전히 0 이다. 그리고 Thread2 도 이 값을 1 증가시키고 메인 메모리로 저장하지는 않았다. 다음은 이 상황을 묘사한 다이어그램이다.



Thread1 과 Thread2 는 사실상 동기화에서 완전히 멀어진 상태이다. counter 변수의 실제 값은 2 가 되어야 하지만, 두 쓰레드는 각자의 값, 1 을 자신들의 캐시에 가지고 있다. 그리고 메인 메모리의 값은 아직 0 이다. 이 상황에서 쓰레드들이 캐시에 가진 변수의 값은 메인 메모리에 저장한다고 해도, counter 의 값은 1 이 된다. 잘못된 상황이다.



volatile 을 사용하기 적절한 때는?


앞서 설명했듯, 두 쓰레드가 공유 변수에 읽기/쓰기 를 실행할 때, volatile 선언은 충분치 않다. 이런 상황에서는 변수값의 읽기/쓰기 명령의 원자성을 보장하기 위해 synchronized 를 써야한다. 변수를 읽고 쓸 때 volatile 선언은 변수에 접근하는 쓰레드들을 블록시키지 않는다. 이런 임계 영역에는 synchronized 키워드가 필요하다.


synchronized 블록을 대체하는 다른 것을 찾는다면, java.util.concurrent 패키지의 많은 원자성 데이터 타입들을 사용할 수도 있다. 에를 들자면 AtomicLong 이나 AtomicReference 와 같은 것들이다.


한 변수를 두고 오직 한 쓰레드만 이 변수에 읽기/쓰기 작업을 하고, 다른 쓰레드들은 읽기 작업만 하는 상황에서라면 이 때는 volatile 선언이 유효하다. 읽기 작업을 수행하는 쓰레드들은 언제나 이 변수의 가장 최근 수정된 값을 봐야하고, volatile 은 이를 보장해준다.


그리고 volatile 은 32비트와 64비트 변수에서 효과를 볼 수 있다.



volatile 의 성능에 대한 고찰


volatile 변수의 읽기/쓰기는 메인 메모리를 이용한다. 메인 메모리로부터 데이터를 읽고 쓰는 작업은 CPU 캐시를 이용하는 것 보다 많은 비용이 요구된다. 또한 volatile 선언은 JVM 의 성능 향상을 위한 기술인, 코드 재정리를 막기도 한다. 그러므로 volatile 키워드는 변수의 가시성 보장이 반드시 필요한 경우에만 사용되어야 한다.


이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

원문 URL : http://tutorials.jenkov.com/java-concurrency/synchronized.html



자바 동기화 블록은 메소드나 블록 코드에 동기화 영역을 표시하며 자바에서 경합 조건을 피하기 위한 방법으로 쓰인다.



자바 synchronized 키워드


 자바 코드에서 동기화 영역은 synchronizred 키워드로 표시된다. 동기화는 객체에 대한 동기화로 이루어지는데(synchronized on some object), 같은 객체에 대한 모든 동기화 블록은 한 시점에 오직 한 쓰레드만이 블록 안으로 접근하도록 - 실행하도록 - 한다. 블록에 접근을 시도하는 다른 쓰레드들은 블록 안의 쓰레드가 실행을 마치고 블록을 벗어날 때까지 블록(blocked) 상태가 된다.


synchronized 키워드는 다음 네 가지 유형의 블록에 쓰인다.


  1. 인스턴스 메소드
  2. 스태틱 메소드
  3. 인스턴스 메소드 코드블록
  4. 스태틱 메소드 코드블록


어떤 동기화 블록이 필요한지는 구체적인 상황에 따라 달라진다.



인스턴스 메소드 동기화


다음은 동기화 처리된 인스턴스 메소드이다.


public synchronized void add(int value){
      this.count += value;
  }


메소드 선언문의 synchronized 키워드를 보자. 이 키워드의 존재가 이 메소드의 동기화를 의미한다.


인스턴스 메소드의 동기화는 이 메소드를 가진 인스턴스(객체)를 기준으로 이루어진다. 그러므로, 한 클래스가 동기화된 인스턴스 메소드를 가진다면, 여기서 동기화는 이 클래스의 한 인스턴스를 기준으로 이루어진다. 그리고 한 시점에 오직 하나의 쓰레드만이 동기화된 인스턴스 메소드를 실행할 수 있다. 결국, 만일 둘 이상의 인스턴스가 있다면, 한 시점에, 한 인스턴스에, 한 쓰레드만 이 메소드를 실행할 수 있다. 


인스턴스 당 한 쓰레드이다. 



스태틱 메소드 동기화


스태틱 메소드의 동기화는 인스턴스 메소드와 같은 방식으로 이루어진다.


  public static synchronized void add(int value){
      count += value;
  }

역시 선언문의 synchronized 키워드가 이 메소드의 동기화를 의미한다.


스태틱 메소드 동기화는 이 메소드를 가진 클래스의 클래스 객체를 기준으로 이루어진다. JVM 안에 클래스 객체는 클래스 당 하나만 존재할 수 있으므로, 같은 클래스에 대해서는 오직 한 쓰레드만 동기화된 스태틱 메소드를 실행할 수 있다.


만일 동기화된 스태틱 메소드가 다른 클래스에 각각 존재한다면, 쓰레드는 각 클래스의 메소드를 실행할 수 있다.


클래스 -쓰레드가 어떤 스태틱 메소드를 실행했든 상관없이- 당 한 쓰레드이다.



인스턴스 메소드 안의 동기화 블록


동기화가 반드시 메소드 전체에 대해 이루어져야 하는 것은 아니다. 종종 메소드의 특정 부분에 대해서만 동기화하는 편이 효율적인 경우가 있다. 이럴 때는 메소드 안에 동기화 블록을 만들 수 있다.


  public void add(int value){

    synchronized(this){
       this.count += value;   
    }
  }


이렇게 메소드 안에 동기화 블록을 따로 작성할 수 있다. 메소드 안에서도 이 블록 안의 코드만 동기화하지만, 이 예제에서는 메소드 안의 동기화 블록 밖에 어떤 다른 코드가 존재하지 않으므로, 동기화 블록은 메소드 선언부에 synchronized 를 사용한 것과 같은 기능을 한다.


동기화 블록이 괄호 안에 한 객체를 전달받고 있음에 주목하자. 예제에서는 'this' 가 사용되었다. 이는 이 add() 메소드가 호출된 객체를 의미한다. 이 동기화 블록 안에 전달된 객체를 모니터 객체(a monitor object) 라 한다. 이 코드는 이 모니터 객체를 기준으로 동기화가 이루어짐을 나타내고 있다. 동기화된 인스턴스 메소드는 자신(메소드)을 내부에 가지고 있는 객체를 모니터 객체로 사용한다.


같은 모니터 객체를 기준으로 동기화된 블록 안의 코드를 오직 한 쓰레드만이 실행할 수 있다.


다음 예제의 동기화는 동일한 기능을 수행한다.


  public class MyClass {
  
    public synchronized void log1(String msg1, String msg2){
       log.writeln(msg1);
       log.writeln(msg2);
    }

  
    public void log2(String msg1, String msg2){
       synchronized(this){
          log.writeln(msg1);
          log.writeln(msg2);
       }
    }
  }


한 쓰레드는 한 시점에 두 동기화된 코드 중 하나만을 실행할 수 있다. 여기서 두 번째 동기화 블록의 괄호에 this 대신 다른 객체를 전달한다면, 쓰레드는 한 시점에 각 메소드를 실행할 수 있다. -동기화 기준이 달라지므로.



스태틱 메소드 안의 동기화 블록


다음 예제는 스태틱 메소드에 대한 것이다. 두 메소드는 각 메소드를 가지고 있는 클래스 객체를 동기화 기준으로 잡는다.


  public class MyClass {

    public static synchronized void log1(String msg1, String msg2){
       log.writeln(msg1);
       log.writeln(msg2);
    }

  
    public static void log2(String msg1, String msg2){
       synchronized(MyClass.class){
          log.writeln(msg1);
          log.writeln(msg2);  
       }
    }
  }


같은 시점에 오직 한 쓰레드만 이 두 메소드 중 어느 쪽이든 실행 가능하다. 두 번째 동기화 블록의 괄호에 MyClass.class 가 아닌 다른 객체를 전달한다면, 쓰레드는 동시에 각 메소드를 실행할 수 있다.



자바 동기화 예제


두 쓰레드가 실행되고 이 쓰레드들은 같은 Counter 인스턴스의 add() 메소드를 호출한다. 한 시점에 오직 한 쓰레드만 add() 메소드를 호출할 수 있을 것이다 - 같은 인스턴스를 동기화 기준으로 잡았기 때문에. 왜냐하면 add() 메소드는 자신을 가진 클래스의 인스턴스를 기준으로 동기화되고 있다.


  public class Counter{
     
     long count = 0;
    
     public synchronized void add(long value){
       this.count += value;
     }
  }
  public class CounterThread extends Thread{

     protected Counter counter = null;

     public CounterThread(Counter counter){
        this.counter = counter;
     }

     public void run() {
	for(int i=0; i<10; i++){
           counter.add(i);
        }
     }
  }
  public class Example {

    public static void main(String[] args){
      Counter counter = new Counter();
      Thread  threadA = new CounterThread(counter);
      Thread  threadB = new CounterThread(counter);

      threadA.start();
      threadB.start(); 
    }
  }


두 쓰레드가 생성되었고, 같은 Counter 인스턴스가 각 쓰레드의 생성자로 전달되었다. Counter.add() 메소드는 인스턴스 메소드이기 때문에, Counter.add() 메소드는 생성자로 전달된 Counter 인스턴스를 기준으로 동기화된다. 이로써 한 시점에 두 쓰레드 중 한 쓰레드만이 add() 메소드를 호출할 수 있게 되었다. 한 쓰레드가 add() 메소드를 실행하는 동안 다른 쓰레드는 이 실행이 끝나고 실행 쓰레드가 동기화 블록을 빠져나갈 때까지 기다리게 된다.


여기서 만일 두 쓰레드가 서로 다른 Counter 인스턴스를 전달받았다면, add() 메소드는 동시에 호출될 수 있을 것이다. add() 메소드의 호출은 서로 다른 객체에 의해 이루어지고, 당연히 동기화의 기준이 달라진다. 여기에 쓰레드가 블록 상태에 놓이는 일은 없다.


  public class Example {

    public static void main(String[] args){
      Counter counterA = new Counter();
      Counter counterB = new Counter();
      Thread  threadA = new CounterThread(counterA);
      Thread  threadB = new CounterThread(counterB);

      threadA.start();
      threadB.start(); 
    }
  }


threadA, threadB 는 더이상 같은 인스턴스를 참조하지 않는다. add() 메소드는 각자의 인스턴스를 기준으로 동기화된다. counterA 에 대한 add() 메소드 호출은 counterB 의 add() 를 블록시키지 않는다.



자바 컨커런시 유틸리티


synchronized 매카니즘은 다수의 쓰레드에게 공유되는 객체로의 접근에 대한 자바의 첫 번째 동기화 매카니즘이었다. 이 매카니즘이 아주 훌륭하지는 못했기 때문에, 이보다 한층 나은 동시성 컨트롤을 위해 자바 5 에서는 컨커런시 유틸리티 클래스들이 출현하게 된다.



이 글은 원 저자 Jakob Jenkov의 허가로 포스팅된 번역물이다.

원문 URL : http://tutorials.jenkov.com/java-concurrency/java-memory-model.html


 자바 메모리 모델은 컴퓨터의 메모리를(RAM)을 통해 JVM이 어떻게 작동되는지 보여준다. JVM은 그 자체로 하나의 컴퓨터 모델이고, 때문에 이 모델은 내부적으로 메모리 모델을 포함한다 - AKA the Java memory model.


제대로 작동하는 컨커런트 프로그램을 구상할 때 자바 메모리 모델을 이해하는 일은 매우 중요하다. 자바 메모리 모델은 어떻게, 그리고 언제 쓰레드들이 공유 변수의 값 - 다른 쓰레드들에 의해 쓰여진 - 을 볼 수 있는지 명시한다. 그리고, 공유 변수로의 접근을 어떻게 동기화하는지 알려준다. (The Java memory model specifies how and when different threads can see values written to shared variables by other threads, and how to synchronize access to shared variables when necessary.)


오리지널 자바 메모리 모델은 완전하지 못했기 때문에 자바 메모리 모델은 자바 1.5 에서 수정되었고, 이 버전의 자바 메모리 모델은 자바 8 에서도 여전히 사용된다.



자바 메모리 모델의 내부구조


 JVM에서 내부적으로 사용되는 자바 메모리 모델은 메모리를 쓰레드 스택(들)과 힙(heap)으로 나눈다. 아래의 다이어그램은 자바 메모리 모델을 논리적인 관점에서 보여준다.



JVM에서 돌아가는 각 쓰레드들은 자신들만의 쓰레드 스택을 가진다. 이 쓰레드 스택에는 쓰레드가 호출한 메소드들의 현재 실행되는 지점(코드)을 보여주기 위한 정보가 들어있다. 이것을 '콜 스택(call stack)' 이라 부를 것이다. 이 콜 스택은 쓰레드가 코드를 실행함에 따라 변화한다.


쓰레드 스택은 쓰레드에 의해 실행되는 메소드(콜 스택에 존재하는 모든 메소드)가 가진 지역변수 또한 가지고 있다. 하나의 쓰레드는 오직 자신의 쓰레드 스택에만 접근할 수 있다. 쓰레드에 의해 생성된 지역변수는 자신을 생성한 쓰레드 외에 다른 쓰레드들에게는 보이지 않는다. 만일 두 쓰레드가 완벽하게 같은 코드를 실행한다 하더라도, 이 두 쓰레드는 각자의 쓰레드 스택에 코드의 지역변수를 저장한다. 이로 인해 각 쓰레드는 자신이 실행하는 코드의 지역변수에 대한 자기만의 버전을 갖는다.


모든 기본형(레퍼(wrapper) 타입:boolean, byte, short, char, int ,long, float, double) 지역변수는 쓰레드 스택에 저장되며, 변수를 저장한 쓰레드가 아닌 다른 쓰레드는 여기에 접근할 수 없다. 쓰레드는 기본형 변수의 복사본을 다른 쓰레드에게 전달할 수 있지만, 기본형 지역변수의 원본을 공유하는 일은 불가능하다.


힙에는 자바 어플리케이션이 생성한 모든 객체들이 들어있다. 여기서 어떤 객체가 어떤 쓰레드에 의해 생성되었는가는 아무런 상관이 없다. 여기에는 기본형 타입의 객체(Byte, Integer, Long 기타 등등)도 포함되며, 객체가 지역변수로 할당되었든, 아니면 다른 객체의 멤버 변수로 할당되었든, 객체는 여전히 힙에 저장된다.


다음 다이어그램은 쓰레드 스택에 저장된 지역변수들과 콜 스택, 그리고 힙에 저장된 객체들을 보여준다.


기본형 지역변수는 완전하게 쓰레드 스택에 저장되고, 객체를 참조하는 지역변수의 경우는 변수는 쓰레드 스택에 오지만 객체는 힙 영역에 저장된다.

객체는 메소드를 가질 수 있고 이 메소드는 지역변수를 가질 수 있는데, 이런 지역변수도 -자신이 소속된 메소드를 가진 객체가 힙 영역에 있다 하더라도- 역시 다른 지역변수와 마찬가지로 쓰레드 스택에 저장된다.


객체의 멤버변수는 그 타입이 기본형이든 참조형이든 상관없이 객체와 함께 힙에 저장된다.


스태틱 클래스 변수 또한 클래스 정의와 함께 힙에 저장된다.


힙에 위치한 객체는 이 객체로의 참조를 지닌 모든 쓰레드들이 접근 가능하다. 한 쓰레드가 어떤 객체로 접근할 때, 이 쓰레드는 이 객체와 함께 이 객체가 내부에 지닌 멤버변수로의 접근 또한 가능해진다. 만일 두 쓰레드가 동시에 같은 객체로 접근한다면 이 쓰레드들은 이 한 객체의 멤버변수로 접근하게 되지만, 각 쓰레드는 자신의 쓰레드 스택에 지역변수로 이 객체의 본사본을 가지게 된다.


다음 다이어그램을 보자.



두 쓰레드는 지역변수들을 가지고 있고, 여기서 한 지역변수(Local variable 2)는 힙에 위치한 한 객체를 가리킨다(Object 3). 두 쓰레드는 같은 하나의 객체를 가리키는 서로 다른 지역변수를 가지게 된다.


그런데 여기서 공유된 객체(Object 3)는 다른 객체(Object 2, Object 4)로의 참조를 가지고 있다(객체가 힙 영역에서 다른 객체를 참조하고 있으니 이 참조를 지닌 변수는 당연히 멤버변수이다).


다이어그램에는 각각 다른 객체를 참조하고 있는(Local variable 1 -> Object 1/5) 또다른 변수(Local variable 1 in methodTwo())들도 존재한다. 이들 변수가 참조하고 있는 객체는 서로 다른 객체이다. 이론적으로는 이 두 쓰레드가 Object 1/5 에 다른 참조만 가지고 있다면 양쪽 모두 접근 가능하지만, 다이어그램상으로 두 쓰레드는 각각 한 객체의 참조만을 가지고 있다.


위의 다이어그램을 재현할 수 있는 자바 코드는 어떤 것일까?


public class MyRunnable implements Runnable() {

    public void run() {
        methodOne();
    }

    public void methodOne() {
        int localVariable1 = 45;

        MySharedObject localVariable2 =
            MySharedObject.sharedInstance;

        //... do more with local variables.

        methodTwo();
    }

    public void methodTwo() {
        Integer localVariable1 = new Integer(99);

        //... do more with local variable.
    }
}
public class MySharedObject {

    //static variable pointing to instance of MySharedObject

    public static final MySharedObject sharedInstance =
        new MySharedObject();


    //member variables pointing to two objects on the heap

    public Integer object2 = new Integer(22);
    public Integer object4 = new Integer(44);

    public long member1 = 12345;
    public long member1 = 67890;
}

두 개의 쓰레드가 run() 메소드를 실행한다면, 위의 다이어그램이 재현될 것이다. run() 메소드는 methodOne() 메소드를 호출하고, methodOne() 은 methodTwo() 메소드를 호출한다.


methodOne() 은 기본형 지역변수(int localVariable1)와 참조형 지역변수(MySharedObject localVariable2)를 함께 선언했다.

 

methodOne() 을 호출하는 각 쓰레드는 localVariable1, localVariable2의 본사본을 각각 자신의 쓰레드 스택에 생성한다. 기본형 변수인 localVariable1은 각 쓰레드 스택에 완전히 개별적으로 위치하게 되고, 이 때 한 쓰레드에서 이 변수의 값을 변경하여도 이 변경은 이 쓰레드 자신만이 볼 수 있으며, 다른 쓰레드는 이를 볼 수 없게 된다 - 다른 쓰레드 스택에서 발생한 일이기에.


그런데 localVariable2의 경우는 다르다. 이 변수는 각각 다른 쓰레드 스택에 존재하지만, 힙에 위치한 한 같은 객체를 참조한다. MySharedObject 인스턴스는 스태틱 변수에 의해 참조되고, localVariable2 를 이 스태틱 변수를 통해 이 인스턴스를 참조한다. 스태틱 변수는 JVM의 힙 영역에 오직 하나만 존재할 수 있고, 결국 두 쓰레드가 자신들의 스택에 각각 가진 localVariable2는 같은 인스턴스를 참조하게 되는 것이다. 이 MySharedObject가 다이어그램의 Object 3 에 해당된다.


MySharedObject 클래스가 두 멤버변수를 어떤식으로 포함하고 있는지도 보자. 두 멤버변수는 MySharedObject 를 따라서 힙 영역에 저장되어 있다. 이 변수들이 참조하는 Integer 타입 객체들은 각각 다이어그램의 Object 2, Object 4 에 해당된다.


methodTwo() 메소드가 지역변수 localVariable1을 생성하는 부분도 지켜볼만한 부분이다. 이 지역변수는 Integer 타입 참조변수이고, methodTwo() 는 이 변수에 Integer 인스턴스를 세팅한다. localVariable1 의 참조는 쓰레드가 methodTwo() 를 호출할 때마다 새로 생성될 것이다. 앞의 Object 2, Object 4는 각각 힙에 저장되지만, methodTwo() 의 localVariable1 은 메소드가 호출될 때마다 새로 생성되기 때문에 두 쓰레드가 메소드를 실행할 때마다 서로 개별적인 Integer 인스턴스가 생성될 것이다. methodTwo() 의 localVariable1 은 다이어그램의 Object 1, Object 5 에 해당된다.


MySharedObject 의 나머지 변수인 long member1 을 보자. 이 변수는 멤버변수이기 때문에 MySharedObject 객체를 따라서 힙에 저장된다. 결국 쓰레드 스택에 저장되는 변수는 오직 지역변수 뿐이다.



하드웨어 메모리 아키텍처


요즘의 하드웨어 메모리 아키텍처는 자바 메모리 모델의 내부구조와 다소 차이가 있다. 자바 메모리 모델이 하드웨어와 어떻게 작동하는지 이해하기 위해, 하드웨어 메모리 아키텍처를 이해하는 일 또한 중요하다. 이 섹션에서는 일반적인 하드웨어 메모리 아키텍처에 대해 기술하고, 다음 섹션에서는 자바 메모리 모델이 하드웨어 메모리 아키텍처와 어떻게 작동하는지에 대해 다룰 것이다.


근래의 하드웨어 메모리 아키텍처를 단순한 다이어그램으로 표현해보면 다음과 같다.


근래의 컴퓨터에는 흔히 2개 이상의 CPU 가 탑제되어 있다. 그리고 이런 CPU는 멀티코어로 작동하기도 한다. 여기서 중요한 점은, 오늘날의 컴퓨터는 2개 이상의 쓰레드를 동시에 돌리는 일이 가능하다는 점이다. 각 CPU는 언제든 주어진 시간에 한 쓰레드를 돌릴 수 있다. 이는 당신의 자바 어플리케이션이 멀티쓰레드를 지원한다면, 어플리케이션 안에서 CPU당 하나의 쓰레드가 동시에 작동될 수 있다는 것을 의미한다.


CPU는 CPU 메모리에 기본적으로 존재하는 레지스터들을 포함하고 있다. CPU는 이 레지스터들을 통해 메인 메모리 안의 데이터를 작업할 때보다 훨씬 빠른 속도로 명령을 수행할 수 있다. 이것이 가능한 이유는 CPU가 레지스터에 접근하는 속도가 메인 메모리에 접근하는 속도보다 훨씬 빠른 데에 있다.


CPU는 또한 케시 메모리 영역을 가지고 있는데, 요즘 대부분의 CPU는 어느 정도 크기의 케시 메모리 영역을 가지고 있다. CPU는 케시 메모리 영역을 통해 명령을 수행하는 일이 가능한데, 이 속도 또한 메인 메모리에 접근하는 속도보다 훨씬 빠르다. 하지만 이 속도는 일반적으로 앞서 언급된, CPU가 내부에 가진 레지스터만큼 빠르지는 않다. 즉, CPU 캐시 메모리는 내부 레지스터와 메인 메모리의 중간쯤에 위치해 있다. 어떤 CPU는 이 캐시 메모리 영역을 Level1, Level2 방식으로 멀티플하게 가지기도 하는데, 이는 자바 메모리 모델과의 상호작용을 이해하는 데에 그다지 중요하지 않다. 여기서 기억해야 할 중요한 사실은 CPU는 어느 정도의 캐시 메모리 영역을 가진다는 점이다.


컴퓨터는 메인 메모리 영역(RAM)을 가지고 있다. 모든 CPU는 이 메인 메모리에 접근 가능하며, 일반적으로 이 메모리 영역은 CPU의 캐시 메모리보다 훨씬 크다.


보통, CPU가 메인 메모리로의 접근을 필요로 할 때, CPU는 메인 메모리의 일부분을 CPU 캐시로 읽어들일다(RAM -> Cache). 그리고 이 캐시의 일부분을 자신의 내부 레지스터로 다시 읽어들이고(Cache -> CPU Registers), 이 읽어들인 데이터로 명령을 수행한다. 후에 이 데이터를 다시 메인 메모리에 저장(writing)하기 위해서는 데이터를 읽어들일 때의 과정을 역순으로 밟는다. 작업 결과를 레지스터에서 캐시로 보내고, 적절한 시점에 캐시에서 메인 메모리로 보낸다.


캐시가 데이터를 메인 메모리로 보내는 적절한 시점이란, CPU가 캐시 메모리에 다른 데이터를 저장해야 할 때이다. CPU 캐시는 자신의 메모리에 데이터를 한 번 저장할 때(at a time), 메모리의 일부에 데이터를 저장해둘 수 있고, 또 일부분만을 보내는(flush) 일도 가능하다. 즉 캐시가 데이터를 읽거나 쓸 때, 반드시 한번에 캐시 메모리의 모든 데이터를 처리하지 않아도 된다는 말이다. 보통 캐시는 '캐시 라인(cache lines)' 이라고 불리는 작은 메모리 블록에 데이터를 갱신한다. 캐시 메모리에는 한 줄 이상의 캐시 라인을 읽어들일 수 있고, 반대로 한 줄 이상의 캐시 라인을 메인 메모리로 보낼(저장할) 수도 있다.



자바 메모리 모델과 하드웨어 메모리 아키텍처의 연결


 이미 언급된 바와 같이, 자바 메모리 모델과 하드웨어 메모리 아키텍처에는 차이가 있다. 하드웨어 메모리 아키텍처는 쓰레드 스택과 힙을 구분하지 않는다. 하드웨어에서 쓰레드 스택과 힙은 모두 메인 메모리에 위치한다. 그리고 쓰레드 스택과 힙의 일부분은 종종 CPU 캐시와 CPU 레지스터에도 나타날 수 있다. 다음 다이어그램이 이런 현상을 보여준다.



객체와 변수들은 컴퓨터의 다양한 메모리 영역에 존재할 수 있으며, 여기서 어떤 문제점들이 발생할 수 있다. 두가지 주요한 문제점은 다음과 같다.


  • 공유 변수에 대한 쓰레드 업데이트(쓰기 작업)의 가시성(visibility)
  • 공유 변수에의 읽기, 확인(checking), 쓰기(writing) 작업의 경합 조건


다음 섹션에서 이 두 문제점에 대해 설명한다.



공유 객체의 가시성(visibility)


 둘 이상의 쓰레드가 volatile 선언이나 synchronization을 사용하지 않은 상태로 한 객체를 공유한다면, 이 공유 객체로의 업데이트(쓰기) 작업은 작업을 실행하는 쓰레드 외에 다른 쓰레드에게는 이 객체의 변경이 보이지 않는 상황이 발생할 수 있다.


한 상황을 상상해보자. 이 공유 객체가 메인 메모리에 생성되었다. 그리고 한 CPU에서 실행되는 한 쓰레드가 이 객체를 CPU 캐시로 읽어들이고 객체의 어떤 값을 변경하였다. 이 CPU 캐시가 변경된 객체 정보를 메인 메모리로 보내지 않는 한, 공유 객체의 변경된 정보는 다른 CPU에서 실행되는 쓰레드는 볼 수 없다. 각 CPU는 자신들의 캐시에 한 공유 객체의 다른 버전 - 한쪽은 데이터가 변경된, 다른 한쪽은 변경되기 이전의 - 의 복사본을 가지게 된다.


다음 다이어그램은 이 상황을 그림으로 나타낸 것이다. 왼쪽의 CPU는 객체를 자신의 캐시에 읽어들인 후 객체의 변수값을 변경하였고, 아직 이 변경된 정보를 메인 메모리에 보내지 않은 상태이기 때문에, 오른쪽의 CPU에서는 이 변경 정보를 볼 수 없다.


이 문제에 대한 해결책으로 자바의 volatile 키워드를 사용할 수 있다. volatile 키워드는 특정 변수에 대한 읽기 작업은 반드시 메인 메모리로부터 수행하고, 쓰기 작업은 항상 메인 메모리에 즉각 반영하도록 강제할 수 있다.



경합 조건


 둘 이상의 쓰레드가 한 객체를 공유하는 상황에서 한 쓰레드가 이 객체의 변수값을 변경했을 때 경합 조건이 발생할 수 있다.


여기서도 어떤 상황을 상상해보자. 쓰레드 A 가 한 공유 객체의 변수, count 를 자신의 캐시에 읽어들였다. 그리고 다른 CPU에서 실행되는 쓰레드 B 역시 이 변수를 자신의 캐시에 읽어들였다. 그리고 쓰레드 A 는 이 변수값에 1을 더하였고, 쓰레드 B 도 같은 작업을 수행했다. 두 CPU 에서 각각 공유된 한 객체의 변수 count 에 두 번의 더하기 작업이 수행되어, 결과적으로 변수의 값은 +2 가 되었다.


이 작업이 순차적 - 동시적이 아닌 - 으로 수행되었다면 변수 count 의 값은 기존 값에서 +2 가 되어 메인 메모리에 저장될 것이다.


그러나 이 두 번의 작업은 동시에 수행되었다. 쓰레드 A 나 쓰레드 B 중 어느 쪽의 객체가 메인 메모리로 저장될지와는 관계없이, 메인 메모리에 저장될 값은 초기 값에서 1 만을 더한 값이 될 것이다. 다음 다이어그램은 이 경합 조건을 보여준다.



이 상황에 대한 해결책으로는 synchronized 블록을 사용할 수 있다. 이 블록은 한 시점에 오직 하나의 쓰레드만이 특정 코드 영역에 접근할 수 있도록 보장해준다. 또한 volatile 키워드처럼, 이 블록 안에서 접근되는 모든 변수들은 메인 메모리로부터 읽어들여지고, 쓰레드의 실행이 이 블록을 벗어나면 블록 안에서 수정된 모든 변수들이 즉각 메인 메모리로 반영될 수 있도록 해준다 - volatile 키워드가 선언되어 있든 없든.



+ Recent posts