이 글은 원 저자 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