Java EnumSet 을 파헤차다가 다시 마주치게 된 synchronized 키워드
운영체제 수업시간에도 잠깐 등장했었는데, 이렇게 다시 만나게 된 김에 제대로 공부해보려고 한다.
참고) 이 글의 내용은 자바의 synchronized 공식 문서에 대한 번역에 가까워질 것 같다.
문제 상황
프로세스의 실행 흐름 중 하나인 스레드는 주로 필드와 필드가 참조하고 있는 객체 참조 (object reference) 에 대한 접근 권한을 공유하며 서로 상호작용한다.
이와 같은 상호작용 방법은 매우 효율적이지만, 2가지 에러를 발생시킬 가능성이 있다.
1. Thread Interference (스레드 간섭)
2. Memory Consistency Errors (메모리 일관성 문제)
그리고 Synchronization(동기화)를 통해 이 문제를 회피할 수 있다.
참고) 동기화는 Thread Contention (스레드 경합) 을 일으킬 수 있다.
운영체제에서 나오는 race condition 과 비슷하게, thread contention 은 2개 이상의 스레드가 같은 자원에 동시에 접근하려고 할 때 발생한다.
Thread Contention 이 발생하면 Java Runtime 이 일부 스레드를 천천히 실행하거나, 아예 실행을 중지시키기도 한다.
(starvation 과 livelock 도 thread contention의 한 형태이다. 이 개념들에 대해서는 다음 글에서 정리한다.)
스레드 간섭과 메모리 일관성 문제를 해결하는데 있어 synchronization 은 매우 효과적이다.
먼저 synchronization을 정리하기에 앞서, synchronization을 통해 해결하려는 문제 상황을 자세히 정리해보자.
1. Thread Interference
class Counter {
private int c = 0;
public void increment() {
c++;
}
public void decrement() {
c--;
}
public int value() {
return c;
}
}
위와 같은 간단한 카운터 클래스를 생각해보자.
이 클래스는 내부에 갖고 있는 카운트 변수 c 의 값을 매번 1씩 증가시키거나, 감소시키거나, 현재 값을 조회하는 메서드를 갖고 있다.
하지만 이 클래스의 객체에 대한 참조를 여러 스레드에서 갖고 있는 경우, 서로가 간섭하여 의도와 다르게 동작할 수 있다.
간섭은 같은 데이터에 대한 서로 다른 두 개의 동작이 서로 다른 스레드에서 실행될 때 발생한다.
이때 '동작'은 여러 단계의 행동 나열이 될 수 있는데, 특히 이 일련의 행동이 서로 중첩될 때 문제가 발생한다.
근데 위에서 보는 Counter 클래스를 보면 변수도 1개고, 메서드의 동작도 1줄의 코드가 전부이다.
그런데 어떻게 하나의 동작이 여러 단계의 행동 단계로 나뉠 수 있는 걸까?
바로 코드를 실제로 실행하는 JVM이 한 줄의 코드를 처리할 때 여러 동작을 수행할 수도 있기 때문에 그렇다.
하지만 꼭 JVM 수준에서 뜯어보지 않아도 c++; 라는 구문은 다음과 같이 3가지 단계로 구성되는 것을 알 수 있다.
1. 현재 c 값을 읽어온다.
2. 해당 값을 1 증가시킨다.
3. c 에 증가시킨 값을 저장한다.
그리고 c--; 도 비슷하게 단계를 나눌 수 있다.
이제 이 상황에서 A 스레드는 increment(), B 스레드는 decrement() 를 동시에 호출했다고 해보자.
c 의 현재 값은 0이라고 할 때 다음과 같이 3가지 단계가 수행된다.
1. A 스레드에서 c 값을 읽어온다. ( c = 0, v = 0 )
2. B 스레드에서 c 값을 읽어온다. ( c = 0, v = 0 )
3. A 스레드에서 c 값을 증가시킨다. ( c = 0, v = 1 )
4. B 스레드에서 c 값을 감소시킨다. ( c = 0, v = -1)
5. A 스레드에서 증가시킨 값을 저장한다. ( c = 1 )
6. B 스레드에서 감소시킨 값을 저장한다. ( c = -1 )
따라서 실제로 저장된 값은 c = -1 이다.
하지만 논리적인 흐름으로 생각했을 때, A는 증가시키고 B는 감소시켰다면 여전히 c = 0 이어야 한다.
즉, A 스레드가 수행한 동작이 B에 덮어씌워지며 사라진 셈이다.
상황에 따라서는 거꾸로 B 가 수행한 동작이 덮어씌워지며 사라질 수도, 아니면 아무런 문제가 안 생길 수도 있다.
지금은 매우 단순한 예제이지만, 프로그램이 복잡해지면 문제가 생길지 안생길지 예측하는 것은 더욱 어려워지므로 버그를 찾기도 고치기도 힘들어진다.
2. Memory Consistency Errors
이 에러는 서로 다른 스레드가 같은 값이어야 하는 데이터를 서로 다른 값으로 보고 있을 때 발생한다.
메모리 일관성 에러는 매우 복잡하고, 튜토리얼에서 다룰 내용도 아니지만, 우리는 모든 걸 이해할 필요는 없다.
중요한 것은 어떻게 메모리 일관성 문제를 회피하는지 그 전략을 이해하는 것이다.
memory consistency error 를 회피하는 제일 중요한 핵심은 happens-before 관계를 이해하는 것이다.
happens-before 관계는 어떤 statement에 의한 메모리 쓰기 작업들을 다른 statement도 인지하도록 보장하는 관계를 말한다.
int counter = 0;
예를 들어, counter 변수가 0으로 초기화 되어 있는 상황을 보자.
이 상황에서 다음의 두 코드가 A, B 스레드에서 실행되는 상황을 생각해보자.
먼저 A 스레드는 다음 코드를 실행한다.
counter++;
그리고 매우 짧은 시간 뒤에 B 스레드가 다음 코드를 실행한다.
System.out.println(counter);
만약 이 두 코드가 하나의 스레드에서 순차적으로 실행되었다면, counter 값은 1이 출력되는 것이 보장된다.
하지만, 두 코드가 서로 다른 스레드에서 실행되고 있기 때문에, counter 값이 0 으로 출력될 가능성이 남아있다.
스레드 B는 스레드 A가 실행한 counter++; 라는 구문이 보이지 않기 때문이다.
따라서 프로그래머가 두 구문 사이에 명시적으로 happens-before 관계를 만들어주어야 문제를 예방할 수 있다.
그리고 synchronization 은 happens-before 관계를 만들어내는 방법 중 하나이다.
(이외에도 다양한 방법이 있는데, 이와 관련해서는 공식문서를 직접 읽어보자)
1번과 2번 문제 상황을 요약하면, 1번은 두 스레드가 모두 똑같이 '0' 이라는 초기 메모리 값을 올바르게 읽었으나, 하나는 1을 증가시키려고 하고, 하나는 1을 감소시키려고 하다보니 결과적으로 먼저 수행된 쓰기 동작이 사라지는 문제를 말한다.
즉, 내가 하려는 행동이 다른 스레드의 행동에 간섭을 받은 것이다.
2번 문제는 내가 행동하는 과정에서 읽은 메모리 값이 과연 믿을 수 있는 (일관성있는) 메모리 값인지 알 수 없다는 문제이다.
(1번에서는 둘 다 0이라는 값을 동시에 읽고나서 각자 작업을 수행했으므로 메모리 값 자체는 일관성있게 읽었다.)
1번은 쓰는 행동에 대한 문제, 2번은 읽는 행동에 대한 문제라고도 볼 수 있겠다.
Synchronization
이 문제를 해결하는 방법 중 하나로 synchronization이 존재한다.
그리고 JAVA는 synchronization 과 관련하여 2가지 문법을 사용할 수 있다.
1. synchronized methods
2. synchronized statements
하나씩 자세하게 살펴보자.
1. synchronized methods
이전의 카운터 예시에서 각각의 메서드를 호출했을 때 문제가 발생했던 이유는 c++; 와 c--; 를 실행하는 세부적인 단계가 뒤섞이는 것이엇다.
이 문제를 해결하기 위해, 각각의 메서드의 동작은 모두 하나의 스레드에서 실행되도록 강제할 수 있다.
하나의 메서드가 호출되고, 그 메서드의 모든 코드가 실행될 때까지는 다른 메서드의 실행이 사이에 낄 수 없는 것이다.
public class SynchronizedCounter {
private int c = 0;
public synchronized void increment() {
c++;
}
public synchronized void decrement() {
c--;
}
public synchronized int value() {
return c;
}
}
이를 위해서는 접근제한자와 return type 사이에 synchronized 키워드를 넣어주면 된다.
그리고 이렇게 개선한 클래스의 카운터 객체를 사용하면 다음의 2가지 효과를 얻을 수 있다.
1. 같은 객체에서 호출된 두 synchronized 메서드는 서로 간섭할 수 없다. 만약 서로 다른 스레드에서 같은 객체의 synchronized 메서드를 호출한다면 (같은 메서드를 호출했든, 다른 메서드를 호출했든) 하나의 메서드의 실행이 종료될 때까지 다른 메서드는 실행되지 않는다.
2. 하나의 메서드 호출이 끝나면, 그 다음에 연속적으로 실행되는 메서드에 대해 자연스럽게 happens-before 관계가 성립한다.
따라서 이 객체의 상태 변화가 모든 스레드에게 보이게 된다.
* 참고) synchronized 키워드는 생성자에 사용할 수 없다. (문법 오류) 당연한 것이, 생성자 함수에 의해 객체가 만들어지는 동안엔 생성자를 호출한 스레드만 객체에 접근할 수 있어야 하기 때문이다.
* 주의) 여러 스레드에서 공유될 객체를 생성할 때, 해당 객체에 대한 참조가 성급하게 노출되지 않도록 주의해야 한다.
instances.add(this);
어떤 클래스의 모든 객체를 관리하는 instances 라는 리스트에 대해, 위와 같은 코드를 호출한다고 해보자.
이때 객체가 다 만들어지기도 전에 다른 스레드가 instances 리스트를 읽을 수 있으므로, 객체가 다 만들어지고 리스트에 담긴 다음에 읽을 수 있도록 해야한다.
synchronized method 는 스레드 간섭과 메모리 일관성 오류 문제를 해결하는 간단한 방법이다.
만약 어떤 객체를 여러 스레드에서 볼 수 있다면, 해당 객체의 모든 필드에 대한 읽고 쓰는 모든 과정은 synchronized method 에 의해 일어나야 한다.
(final 키워드로 정의된 필드는 예외이다. 객체가 생성된 이후에 수정되지 않기 때문에 non-synchronized method 가 읽어도 상관없다.)
참고) synchronized method 전략은 매우 간단하고 효율적이지만 liveness 와 관련된 문제를 일으킬 수 있다.
이와 관련해서는 밑에서 가볍게 언급한다.
2. synchronized statements
synchronization을 설정하는 두 번째 방법은 synchronized statements 문법을 활용하는 것이다.
synchronization은 intrinsic lock 또는 monitor 라고 부르는 내부 엔티티를 기반으로 만들어진다.
(주로 JAVA API 명세에서는 간단하게 monitor 라고 부른다.)
이 엔티티는 객체의 상태에 대한 exclusive access를 보장하고, happens-before 관계를 형성하여 synchronization 을 수행한다.
자바에서 모든 객체는 자신에 대한 intrinsic lock을 갖고 있다.
어떤 객체의 필드에 대해 상호배제적이고, 일관성을 지킬 수 있는 접근을 원하는 스레드는 사전에 intrinsic lock을 획득해야만 하며, 원하는 동작을 수행한 이후에는 intrinsic lock을 돌려주어야 한다.
그리고 lock과 다른 스레드의 접근에 대한 부분은 JVM이 관리한다.
어떤 스레드가 intrinsic lock 을 획득해서 돌려주기 전까지 그 스레드가 intrinsic lock 을 소유하고 있다(own)고 말한다.
그리고 어떤 스레드가 intrinsic lock 을 소유하고 있는 동안에는 다른 스레드가 그 intrinsic lock 을 소유할 수 없으므로, lock을 획득하려는 동작은 중지된다. (block)
그리고 intrinsic lock 을 돌려주면, 이 행동은 그 직후에 이 intrinsic lock 을 획득 하는 행동과 happens-before 관계가 성립한다.
위에서 말한 synchronized method 방식에서, synchronized method를 호출하려는 스레드는 자동으로 해당 메서드를 가진 객체에 대한 intrinsic lock 을 획득하고, 정상적이든 에러가 발생했든 메서드를 return 할 때 다시 lock 을 돌려준다.
만약 static method 에 synchronized 키워드를 붙인 경우, static method 는 클래스에 관련되어 있으므로 Class object 에 대한 intrinsic lock 을 획득하여 동작을 수행한다. (이 lock 은 클래스의 instance 객체에 대한 lock 과는 다른 lock 이다.)
(그렇다면 클래스에 대한 lock 을 획득한 스레드가 존재하는 상태에서 instance 에 대한 lock 을 다른 스레드가 획득할 수 있을까?
-> 내 예상으론 획득 가능하다. 만약 instance 에 대한 lock 을 획득한 상태에서 static 변수에 접근하려고 하면 다시 class object 에 대한 instrinsic lock 을 획득해야 하므로 2개의 lock 이 존재하는 것은 문제되지 않을 것 같다.)
한번 코드로 확인해보자.
public class Main {
public static void main(String[] args) {
System.out.println("Hello World!");
TestClass t = new TestClass();
Thread t1 = new Thread(() -> {
try {
t.instanceIncrement();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
t1.start();
Thread t2 = new Thread(() -> {
int c = t.instanceGetCount();
System.out.println(c);
});
t2.start();
System.out.println("Hello World!2");
}
}
class TestClass {
int instanceCount = 0;
public void instanceIncrement() throws InterruptedException {
System.out.println("increment instance");
Thread.sleep(2000);
instanceCount++;
}
public int instanceGetCount() {
System.out.println("read instance count");
return instanceCount;
}
}
먼저 인스턴스에 대한 intrinsic lock 을 테스트 해보자.
이렇게 synchronized 키워드 없이 호출했을 때, 첫 번째 스레드가 먼저 increment 를 호출해도 지연이 발생하면 두 번째 스레드에서 읽은 값은 첫 번째 스레드가 증가시키기 전의 값인 0을 읽을 수 있다.
public class Main {
public static void main(String[] args) {
System.out.println("Hello World!");
TestClass t = new TestClass();
Thread t1 = new Thread(() -> {
try {
t.instanceIncrement();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
t1.start();
Thread t2 = new Thread(() -> {
int c = t.instanceGetCount();
System.out.println(c);
});
t2.start();
System.out.println("Hello World!2");
}
}
class TestClass {
int instanceCount = 0;
public synchronized void instanceIncrement() throws InterruptedException {
System.out.println("increment instance");
Thread.sleep(2000);
instanceCount++;
}
public synchronized int instanceGetCount() {
System.out.println("read instance count");
return instanceCount;
}
}
하지만 이렇게 synchronized 키워드를 추가하면 첫번째 스레드에서 먼저 intrinsic lock 을 획득했으므로 두번째 스레드에서 접근할 수 없어 강제로 대기하게 된다.
따라서 이렇게 정상적으로 1이 출력되는 것을 알 수 있다.
(1이 출력되기까지 2초의 딜레이가 존재한다.)
이번엔 synchronized 를 걸은 static 메서드를 이용해서 섞어 호출해보자.
public class Main {
public static void main(String[] args) {
System.out.println("Hello World!");
TestClass t = new TestClass();
Thread t1 = new Thread(() -> {
try {
TestClass.staticIncrement();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
t1.start();
Thread t2 = new Thread(() -> {
int c = t.instanceGetStaticCount();
System.out.println(c);
});
t2.start();
System.out.println("Hello World!2");
}
}
class TestClass {
static int staticCount = 0;
static synchronized void staticIncrement() throws InterruptedException {
System.out.println("increment static");
Thread.sleep(2000);
staticCount++;
}
public synchronized int instanceGetStaticCount() {
System.out.println("read static count");
return staticCount;
}
}
이렇게 static 변수의 값을 static 메서드에서 증가시킨 뒤, instance 메서드에서 읽어오도록 했다.
두 메서드에는 모두 synchronized 키워드를 걸었다.
실행 결과, 전과 마찬가지로 0이 발생하면서 synchronized 되지 않았고, 0도 바로 출력되었다.
(프로그램은 2초뒤 종료된다.)
결론적으로 내 예상과 다르게 객체에서 static 필드에 접근할 때는 별도의 lock 획득이 필요하지 않았다.
즉, class 단위의 intrinsic lock 과 객체 단위의 intrinsic lock 은 완전히 독립적임을 알 수 있다.
따라서 static 변수에 static method 와 instance method 로 접근해서 데이터를 다룰 때는 주의해야 한다.
다시 본론으로 돌아와서 synchronized statement 에 대해 정리해보자.
synchronized method 는 메서드 단위로 통으로 intrinsic lock 을 적용했다면 synchronized statement 는 메서드 내에서 일부 코드 영역에 대해 임계 구역을 만드는데 사용된다.
public void addName(String name) {
synchronized(this) {
lastName = name;
nameCount++;
}
nameList.add(name);
}
이런 식으로 synchronized( object ) { } 라고 하면, 해당 object 에 대한 lock을 획득했을 때에만 내부의 코드블럭을 실행할 수 있다.
이때의 object 는 꼭 자기 자신이 아닌 다른 객체가 될 수도 있다는 점이 synchronized method 와의 차이점이다.
(즉, 다른 객체의 intrinsic lock 을 기반으로 코드를 실행할 수 있다)
위 예시에서는 this 를 통해 자기 자신을 넘겼으므로, 이 메서드가 호출된 그 객체에 대한 intrinsic lock 을 획득해야만 코드 블럭 내부가 실행된다.
(참고로, 이 때 synchronized statement 내에서 다른 오브젝트의 synchronized method 를 호출하는 것은 피해야 한다. liveness 에 문제가 생길 수 있기 때문이다. 데드락이라든가.. 이 내용은 이 글에서 다루지 않는다.)
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++;
}
}
}
또한 synchronized statement 는 위와 같이 lock 을 분리해서 적용하고 싶을 때도 유용하다.
위 예시에서는 c1, c2 라는 서로 독립적인 카운트 값을 lock1, lock2 라는 독립적인 lock 을 사용하여 관리하고 있다.
따라서 서로 다른 스레드에서 inc1 과 inc1 을 동시에 호출하는 것은 불가능하지만, inc1 과 inc2 를 동시에 호출하는 것은 가능하다.
만약 synchronized method 를 사용했다면, inc1() 호출이 inc2() 호출을 방해하는 상황이 발생한다.
하지만 이렇게 락을 구분해서 사용하는 것은 기존의 스레드 간섭 문제가 발생할 여지를 다시 남겨두므로, c1, c2 가 서로 영향을 주지 않고 완전히 독립되어있음을 확신할 수 있을 때만 사용해야 한다.
'Tool & Language > Java' 카테고리의 다른 글
[Java] EnumSet 파헤치기 (feat. Thread-Safe) (0) | 2025.04.05 |
---|