주로 해결하려는 문제: 동시성 문제에서의 경쟁 상태
- 멀티 스레드 프로그래밍에서의 경쟁 상태를 해결하기 위해서 필요하다.
- 경쟁 상태란 여러 스레드가 순서에 영향을 받는 작업을 병렬적으로 할 때, 의도치 않은 결과가 나오는 것을 말한다.
- 비동기 프로그래밍에서 각 동작의 시작과 끝이 보장되지 않으면 실행/출력 결과가 일정하지 않을 수 있고 의도와 다른 결과물을 보여줄 수 있으며 심각한 버그까지 초래할 수 있다.
- ex)
object.method()
는 Thread1 완벽히 수행을 끝낸 이후에 Thread2 가 수행해야 한다. 행위에 대한Atomic
함이 보장되어야 한다.
- ex)
보통 자바 프로그래밍에서 멀티 스레드 프로그래밍 코드를 작성해 볼 일은 잘 없다고 생각하지만, 엔터프라이즈 서버 환경을 경험해볼 일은 매우 많다. 자바를 배우는 대부분은 스프링을 이용해 돈을 번다.
엔터프라이즈 서버 환경에서는 사용자 요청을 스레드 풀에 있는 다수의 스레드가 처리하는 경우가 많다. 서버는 다수의 스레드를 이용해 멀티 스레드를 기반으로 한 동작을 하기 때문에 대충 작성한 코드가 생각지도 못한 결과로 나타날 때가 있기 때문에 주의해야 한다.
문제가 되는 예시: 달걀을 좋아하는 가족
- 달걀을 잘 먹는 한 가족이 있다.
- 이 가족의 구성원은 엄마, 아빠, 아들이다.
- 이 가족은 달걀을 좋아한다.
- 그래서 주방 한가운데 달걀 바구니가 있다.
- 가족은 늘 달걀 바구니에서 달걀을 꺼내먹는다.
- 달걀 바구니에 달걀이 없으면 달걀을 1판씩 사오기로 약속했다.
- 아빠는 달걀 바구니가 빈 것을 보고 달걀을 사러 갔다.
- 아들은 아빠가 달걀을 사러 간 것을 모르고 또 달걀을 사러 갔다.
- 집에 오니 엄마가 산 달걀까지 있어 총 3판의 달걀이 쌓였다.
가족은 달걀 3판을 유통기한 내에 먹을 수 있을지 고민이다.
예시 문제 해결
- 달걀을 사러 가는 사람이 달걀 바구니를 같이 들고간다면?
- 다른 가족은 달걀을 사러간 동안 달걀 바구니에 접근할 수 없다.
- 달걀 바구니가 없는 것을 보고 누군가 달걀을 사러 갔겠거니 생각할 수 있다.
문제가 되는 예시: Counter 코드
int count = 0;
@Test
public void nonAtomicCountTest() throws InterruptedException {
Runnable runnable1 = () -> {
for (int i = 0; i < 100000; i++) {
count++;
}
};
Runnable runnable2 = () -> {
for (int i = 0; i < 100000; i++) {
count++;
}
};
Thread thread1 = new Thread(runnable1);
Thread thread2 = new Thread(runnable2);
thread1.start();
thread2.start();
Thread.sleep(1000);
System.out.println("count = " + count);
}
위의 코드를 실행하면 count
값으로 몇이 출력될까? 얼핏보면 당연히 200000
이 출력될 것 같다. 정답은 컴퓨터마다 다르겠지만, 20만보다 작은 랜덤한 값이 출력된다.
그 이유를 알아보려면 먼저, count++
의 동작부터 자세히 알아야 한다.
count++
는 겉보기에는 1개의 operation 처럼 보이지만, 사실은 3개의 operation 으로 이루어져있다. temp = count
, temp = temp + 1
, count = temp
와 같이 총 3가지의 연산으로 구성되어 있다.
다음으로는 스레드가 병렬처리를 하고 있다는 것을 인지해야 한다. 두 스레드는 temp = count
, temp = temp + 1
, count = temp
를 반복적으로 수행중이다.
count = 100000
일 때 thread1
과 thread2
의 동작을 가정해보자.
thread1
:temp1 = count
->temp1
:100000
,count
:100000
thread2
:temp2 = count
->temp2
:100000
,count
:100000
thread1
:temp1 = temp1 + 1
->temp1
:100001
,count
:100000
thread1
:count = temp1
->temp1
:100001
,count
:100001
thread1
:temp1 = count
->temp1
:100001
,count
:100001
thread1
:temp1 = temp1 + 1
->temp1
:100002
,count
:100001
thread1
:count = temp1
->temp1
:100001
,count
:100002
thread2
:temp2 = temp2 + 1
->temp2
:100001
,count
:100002
thread2
:count = temp2
->temp2
:100001
,count
:100001
(?)
thread1
과 thread2
의 순서가 보장되어있지 않다는 이유 하나만으로 이러한 일이 일어난다. thread1
이 이미 올려놓은 값에 thread2
가 이전 값을 할당해버린다.
위는 예제이며 정확한 JVM 엔진의 동작인지 검증된 바는 없다.
자바에서는 이러한 문제를 해결하기 위해서 많은 방법을 제공한다.
문제 해결 코드 예시들
문제 해결 코드 1: 메서드 코드 내에서 synchronized
블록문 사용
@Test
public void synchronizedBlockCountTest() throws InterruptedException {
Runnable runnable1 = () -> {
for (int i = 0; i < 100000; i++) {
synchronized (this) {
count = count + 1;
}
}
};
Runnable runnable2 = () -> {
for (int i = 0; i < 100000; i++) {
synchronized (this) {
count = count + 1;
}
}
};
Thread thread1 = new Thread(runnable1);
Thread thread2 = new Thread(runnable2);
thread1.start();
thread2.start();
Thread.sleep(1000);
System.out.println("count = " + count);
}
- 하나의 스레드에서
synchronized
블록 문에 접근할 때는 다른 스레드에서 해당 인스턴스에 동시에 접근할 수 없다.
문제 해결 코드 2: 메서드 시그니처에 synchronized
키워드 사용
@Test
public void synchronizedMethodCountTest() throws InterruptedException {
Runnable runnable1 = this::syncMethod;
Runnable runnable2 = this::syncMethod;
Thread thread1 = new Thread(runnable1);
Thread thread2 = new Thread(runnable2);
thread1.start();
thread2.start();
Thread.sleep(1000);
System.out.println("count = " + count);
}
public synchronized void syncMethod() {
for(int i=0; i<100000; i++) {
count++;
}
}
- 메서드에
synchronized
키워드를 붙여, 멀티 스레드가 동시에 해당 메서드를 수행할 수 없게 한다.
문제 해결 코드 3: 자바가 제공하는 Atomic...
클래스 사용
AtomicInteger atomicCount = new AtomicInteger();
@Test
public void atomicCountTest() throws InterruptedException {
Runnable runnable1 = () -> {
for (int i = 0; i < 100000; i++) {
atomicCount.incrementAndGet();
}
};
Runnable runnable2 = () -> {
for (int i = 0; i < 100000; i++) {
atomicCount.incrementAndGet();
}
};
Thread thread1 = new Thread(runnable1);
Thread thread2 = new Thread(runnable2);
thread1.start();
thread2.start();
Thread.sleep(1000);
System.out.println("atomicCount = " + atomicCount);
}
- 자바는 멀티 스레드 프로그래밍을 제공하는만큼 멀티 스레드에서 발생하는 문제를 해결하기 위한 다양한 클래스도 제공한다.
Atomic
이 붙은 클래스를 사용하면 동시성 문제에서 안전할 수 있다.
synchronized
블록 자세히 알아보기
synchronized
블록에 들어있는 코드는 한번에 하나의 스레드에서만 실행할 수 있다.- 한 번에 하나의 스레드만 실행할 수 있기에 경쟁 상태를 피하기 위해 사용될 수 있다.
원리 간단히 알아보기
- 이는
monitor
라는 것을 통해 구현된다. - 자바 오브젝트는
monitor
와 통신한다. - 스레드는
monitor
를 lock 하거나 unlock 할 수 있다. - 오직 하나의 스레드만
monitor
에 대해 락을 가질 수 있다.- 락을 가지려 하는 다른 스레드들은 락이 풀릴 때까지 블록된다.
synchronized
블록에서는 호출할 메서드가 있는 오브젝트를monitor
로 삼고 락을 가진다.
synchronized
블록의 사용법 4가지
- 인스턴스 메서드에서 사용하기
- 스태틱 메서드에서 사용하기
- 인스턴스 메서드 내부의 코드 블록에서 사용하기
- 스태틱 메서드 내부의 코드 블록에서 사용하기
synchronized
키워드가 걸린 인스턴스 메서드 블록
public class MyCounter {
private int count = 0;
public synchronized void add(int value){
this.count += value;
}
public synchronized void subtract(int value){
this.count -= value;
}
}
- 위와 같이 사용하면, 한 스레드 당 하나의 인스턴스에 대한 락을 갖는다.
add
와subtract
에 대한 락은 메서드라고 별도로 취급되는 것이 아니라, 인스턴스별로 하나의 락만 있다.- 즉, 스레드가 해당 인스턴스에 대한 락을 가지고 있을 때, 모든 메서드의 수행권한이 있는 것이다.
add
와subtract
에 대한 별도의 락이 존재하지 않는다.
스태틱 메서드에서의 synchronized
블록
public static MyStaticCounter{
private static int count = 0;
public static synchronized void add(int value){
count += value;
}
public static synchronized void subtract(int value){
count -= value;
}
}
- 한 번에 JVM 내에 존재하는 하나의 스레드만 메서드에 접근이 가능하다.
- 인스턴스 내부에 있던
synchronized
와 원리는 같다. 클래스를 monitor 로 사용하는데,static
키워드 덕에 JVM에 하나의 클래스만 존재하기 때문이다.
- 인스턴스 내부에 있던
인스턴스 메서드 내부에서의 synchronized
블록
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);
}
}
}
synchronized(this) { ... }
에서 쓰이는this
가monitor
오브젝트로 사용된다.- 그래서 위의
synchronized
메서드와synchronized
블록문은 절대 같은 타이밍에 동시에 실행될 수 없다. - 만일 두번째
synchronized
블록문에서(this)
가 아니라 다른 오브젝트였다면, 각각 동시에 실행될 수 있는 코드가 된다.
람다에서의 synchronized
블록
import java.util.function.Consumer;
public class SynchronizedExample {
public static void main(String[] args) {
Consumer<String> func = (String param) -> {
synchronized(SynchronizedExample.class) {
System.out.println(
Thread.currentThread().getName() +
" step 1: " + param);
try {
Thread.sleep( (long) (Math.random() * 1000));
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(
Thread.currentThread().getName() +
" step 2: " + param);
}
};
Thread thread1 = new Thread(() -> {
func.accept("Parameter");
}, "Thread 1");
Thread thread2 = new Thread(() -> {
func.accept("Parameter");
}, "Thread 2");
thread1.start();
thread2.start();
}
}
- 람다에서도 가능하다.
synchronized
블록문을 사용하며 주의할 점
monitor
객체가 될 대상에 String
Integer
등 불변 객체를 주지 말자.
JVM 내부에서 String
, Integer
등 특정 객체들에 대해 최적화를 해주기 때문에 이러한 Primitive Wrapper 객체들을 monitor
객체로 사용하는 경우엔 의도치 않은 결과를 초래할 수 있다.
this
나 new Object()
를 사용하는 것은 문제 없다.
읽기, 쓰기를 따로 구분해서 구현하고 싶다면
만일 한번에 2개의 스레드가 공유된 값을 읽기만 하고 업데이트 하지 않을 것이라면? 이는 thread-safe 하다고 볼 수 있다. 이 경우에는 synchronized
만으로 구현할 수 없는데 이 때는 ReadWriteLock 클래스를 이용하면 된다.
2개 이상의 스레드가 블록문에 들어오도록 구현하고 싶다면
N 개의 스레드가 synchronized
블록에 들어오게 하고 싶다면? Semaphore 를 이용해서 구현할 수 있다.
들어오는 스레드의 순서를 정하고 싶다면
synchronized
블록에 들어오는 스레드의 순서는 따로 정해져있지 않다. Fairness 를 구현해서 정해줄 수 있다.
하나의 스레드만 쓰기 역할을 하고 다른 스레드는 읽게 하고 싶다면?
volatile variable 을 잘 살펴보면 된다.
synchronized
블록의 오버헤드
synchronized
블록문에 입장하고 퇴장하는데 아주 약간의 오버헤드가 있는 편이다. 그러므로 꼭 필요한 부분만 synchronized
로 묶는 것이 중요하다.
synchronized
블록에 재입장하는 것도 허용된다.
public class MyClass {
List<String> elements = new ArrayList<String>();
public void count() {
if(elements.size() == 0) {
return 0;
}
synchronized(this) {
elements.remove();
return 1 + count();
}
}
}
위 코드처럼 하나의 스레드가 this
인스턴스를 monitor
오브젝트로 잡아놓고, count()
를 호출하여 다시 해당 인스턴스에 synchronized
블록에 재입장할 수 있다.
또 다른 오브젝트의 synchronized
블록에 입장하는 것도 가능한데, 잘못된 디자인을 할 경우 nested monitor lockout을 유발할 수 있기 때문에 주의해야 한다.
레퍼런스
'Java > 자바 잡지식' 카테고리의 다른 글
자바 EE 란? (0) | 2022.04.20 |
---|---|
클린 아키텍처 (by Robert C. Martin) 번역 (0) | 2022.04.14 |
Test Double 이란? (0) | 2022.04.13 |
Gradle 에서 Plugins 의 역할은 무엇일까? (0) | 2022.04.03 |
자바에서 주석 다는 방법: Javadoc 이란 무엇일까? (Feat. 위키피디아) (0) | 2022.03.30 |