@Async
- Spring에서 비동기 처리를 위한 어노테이션
- Public Method에만 이용이 가능하며 해당 메서드는 반드시
비동기 실행이 되도록 보장
한다. - @Async 동작을 별도로 설정하지 않으면 Proxy 모드로 동작하게 된다.
- Proxy 모드로 동작하게 되면
동일한 클래스 내의 메서드 호출
은동기적으로 동작
하게 된다.- Proxy 모드 :
- 프록시 객체를 생성하여 실제 객체를 대신 호출한다.
- AOP Proxy을 통해 비동기 실행을 구현한다.
@Configuration
@EnableAsync
class AsyncConfig {
@Bean(name = "threadPoolTaskExecutor")
public ThreadPoolTaskExecutor threadPoolTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(50); //
return executor;
}
}
class AsyncTmp {
@Async
public void asyncMethod() {
System.out.println("비동기 메서드 시작");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("비동기 메서드 종료");
}
public void main() {
System.out.println("메인 메서드 시작");
asyncMethod();
System.out.println("메인 메서드 종료");
}
}
Synchronized
synchronized
키워드를 메서드에 쓰면 해당 메서드는 임계영역이 되는거다.
Synchronized의 문제점
blocking을 사용하여 공유 객체를 동기화하기 때문에 특정 스레드에 락을 걸었을
경우 이 락으로 인해서 무한히 대기하는 스레드가 생길 수 있다. 이러한 문제로
인해서 성능저하가 일어날 수 있다.
class SharedObject {
private int count = 0;
public void plus() {
synchronized (this) {
count++;
}
}
public void minus() {
synchronized (this) {
count--;
}
}
public int getCount() {
return count;
}
}
synchronized 블럭과 synchronized 키워드의 차이점은 synchronized 블럭
은 해당 블럭에 대해서만 임계영역을 설정
할 수 있지만 synchronized 키워드
는 메서드 전체에 대해서 임계영역을 설정
한다.
예를 들어서 아래 코드는 race condition이 발생할 수 있다.
class SharedObject {
private int count = 0;
public void plus() {
synchronized (this) {
count++;
}
count++;
}
public void minus() {
synchronized (this) {
count--;
}
count--;
}
public int getCount() {
return count;
}
}
- static synchronized 블럭
static method 안에 synchronized block을 지정하면 클래스 단위로 Lock이 발생한다. block의 인자로 정적 인스턴스나 클래스를 사용한다.
public class MyClass {
private static int count = 0;
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
synchronized (MyClass.class) {
count++;
}
}
});
Thread thread2 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
synchronized (MyClass.class) {
count--;
}
}
});
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(count);
}
}
Synchronized와 monitor
monitor란 뮤텍스나 세마포어보다 더 고수준의 동기화 기법이다.
공유 자원을 사용할 때 모든 프로세스가 세마포어 알고리즘을 따른다면 굳이 P()와 V()를 사용할 필요 없이 자동으로 처리하면 된다.
또한 P() 연산과 V() 연산을 프로세스가 각각 수행할 경우 V() 연산을 수행하지 않는 다면 다른 프로세스가 P() 연산을 수행할 수 없다.
이러한 문제를 방지하기 위해서 중앙에서 P() 연산과 V() 연산을 수행하는 것을 monitor라고 한다.
모든 자바 객체는 모니터를 가진다.
여러 스레드가 객체의 임계 영역에 진입하려고 할 때 Monitor는 단 1개의 스레드만이 임계 영역에 진입할 수 있도록 보장한다.
JVM은 synchronized 키워드를 사용해서 monitor를 사용할 수 있게 해준다.
synchronized 블록은 해당 객체의 모니터를 획득할 수 있으며 모니터를 획득한 스레드만이 임계영역에 접근 가능하고 그 외 다른 스레드들은 차단되어 대기 상태가 된다.
synchronized block이 class 내에 여러 개 선언되어 있다고 하더라도 monitor는 객체당 1개가 존재하기 때문에 A syncrhonized block을 1번 스레드에서 실행하는 동안 B synchronized block을 2번 스레드가 사용하는 방식으로는 동작할 수 없다.
Monitor의 세부구현
Monitor는 객체의 Header 영역에 포함되어 있으며, Java 객체가 heap 영역에 생성될 때 객체의 Header에 monitor 정보가 함께 생성됩니다.
Object Header (Mark Word + Class Metadata Address)
├── Mark Word (32/64 bits)
│ ├── Monitor Lock 정보
│ ├── HashCode
│ ├── Age (GC 관련)
│ └── Thread ID
└── Class Metadata Address
Monitor는 각 생성된 Instance 마다 1개씩 생성이 되며 같은 class 더라도 다른 Instance면 다른 Monitor를 가지게 된다.
Monitor lock을 이미 다른 thread가 보유하고 있다면, 현재 thread는 entry set(waiting queue)에서 대기합니다. Monitor lock을 획득한 thread가 lock을 해제하면, entry set에서 대기중인 thread 중 하나가 선택되어 실행됩니다.
세부 구현 의사코드
- Lock Tracking
class ObjectMonitor {
private Thread owner = null; // 현재 lock을 소유한 thread
private int recursions = 0; // 재진입 횟수
private WaitSet waitSet; // wait() 호출로 대기중인 threads
private EntryList entryList; // lock 획득을 기다리는 threads
public void enter(Thread thread) {
if (owner == null) {
// lock이 해제된 상태
owner = thread;
recursions = 1;
} else if (owner == thread) {
// 재진입
recursions++;
} else {
// lock 충돌 - thread를 entry list에 추가
entryList.add(thread);
park(thread); // thread 대기
}
}
public void exit() {
recursions--;
if (recursions == 0) {
// 모든 재진입이 해제됨
owner = null;
// 대기중인 thread가 있다면 깨움
if (!entryList.isEmpty()) {
Thread next = entryList.removeFirst();
unpark(next);
}
}
}
}
- Exception 처리:
// JVM의 synchronized 블록 실행 관리 (의사 코드)
public void executeSynchronizedBlock(Object obj, Runnable block) {
ObjectMonitor monitor = obj.getObjectMonitor();
boolean acquired = false;
try {
monitor.enter(Thread.currentThread());
acquired = true;
block.run();
} finally {
if (acquired) {
monitor.exit();
}
}
}
- wait / notify 구현:
// JVM의 wait/notify 관리 (의사 코드)
class ObjectMonitor {
public void wait() {
// 현재 thread를 wait set으로 이동
recursions = 0; // lock count 초기화
waitSet.add(Thread.currentThread());
owner = null; // lock 해제
// entry list의 다른 thread를 깨움
if (!entryList.isEmpty()) {
Thread next = entryList.removeFirst();
unpark(next);
}
park(Thread.currentThread()); // 현재 thread 대기
}
public void notify() {
if (!waitSet.isEmpty()) {
Thread thread = waitSet.removeFirst();
entryList.add(thread); // lock 재획득을 위해 entry list로 이동
unpark(thread);
}
}
}
이러한 메커니즘을 통해 JVM은
- Lock의 획득과 해제를 자동으로 관리
- 예외 발생 시에도 안전하게 lock 해제
- Thread 간의 상태 전이를 추적
- Deadlock 방지를 위한 기본적인 메커니즘 제공
을 수행합니다. 이 모든 과정이 JVM 레벨에서 자동으로 이루어지기 때문에, 개발자는 synchronized 키워드만으로도 안전한 동기화를 구현할 수 있습니다.
Volatile
가시성 문제를 해결하기 위한 키워드이다.
가시성 문제란 멀티 스레드 환경에서 각 CPU는 메인 메모리 변수 값을 참조하지 않고, 각 CPU의 캐시 영역에서 메모리를 참고한다.
멀티 스레드 환경에서 메인 메모리와 CPU의 캐시값이 다른 경우 발생
할 수 있는 문제를 가시성 문제
라고 한다.
non-volatile 변수
는 메인 메모리로부터 CPU 캐시에 값을 복사하여 작업을 수행
한다.
여러 스레드가 각각 non-volatile 값을 읽을 때 CPU 캐시에 저장된 값이 다를 경우, 값의 불일치 발생(가시성 문제)한다.
volatile 키워드가 붙은 자원
은 read, write 작업이 CPU Cache Memory가 아닌 Main Memory에서 이루어진다.
메인 메모리에 저장하고 읽어오기 때문에 값의 불일치(가시성 문제)를 해결할 수 있다.
아래의 코드를 간단하게 살펴보면 다음 코드는 stopRequested
변수가 1초 뒤에 true
로 변경되고 금방 종료될 것처럼 보이지만 실제로는 종료되지 않거나 아주 오래 걸린다.
이유를 간단하게 설명하자면 main Thread는 1번 CPU에서 실행되고 backgroundThread는 2번 CPU에서 실행되는데 1번 CPU의 캐시 공간에는 stopRequested 변수가 1초뒤 true로 변경되지만 2번 CPU의 캐시 공간에는 stopRequested 변수가 변경되지 않은 상태로 남아있기 때문에 backgroundThread는 계속해서 while문을 돌게된다.
public class Main {
private static boolean stopRequested = false;
public static void main(String[] args) throws InterruptedException {
Thread backgroundThread = new Thread(() -> {
int i = 0;
while (!stopRequested) {
i++;
}
System.out.println("i: " + i);
});
backgroundThread.start();
Thread.sleep(1000);
stopRequested = true;
}
}
이러한 문제를 해결하기 위해서 stopRequested
변수에 volatile
키워드를 붙여주면 된다.
public class Main {
private static volatile boolean stopRequested = false;
public static void main(String[] args) throws InterruptedException {
Thread backgroundThread = new Thread(() -> {
int i = 0;
while (!stopRequested) {
i++;
}
System.out.println("i: " + i);
});
backgroundThread.start();
Thread.sleep(1000);
stopRequested = true;
}
}
Atomic type
자바의 concurrency API에서 제공되는 타입이다.
해당 타입은 blocking
방식이 아닌 non-blocking
방식으로 원자성을 보장하며 사용시 내부적으로 CAS(Compare-And-Swap) 알고리즘을 사용
해서 Lock없이 동기화로 처리할 수 있다.
원자성
어떠한 작업이 실행될 때, 언제나 완전하게 진행되어 종료되거나 그럴 수 없는 경우
실행을 하지 않는 경우를 말한다.
public class AtomicBooleanExample {
private static AtomicBoolean flag = new AtomicBoolean(false);
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
flag.set(true);
});
Thread thread2 = new Thread(() -> {
while (!flag.get()) {
// do something
}
System.out.println("flag is set to true");
});
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
public class AtomicReferenceExample {
private static AtomicReference<String> name = new AtomicReference<>("John");
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
name.set("Alice");
});
Thread thread2 = new Thread(() -> {
String currentName = name.get();
System.out.println("current name: " + currentName);
});
thread1.start();
thread2.start();
try {
thread1.join();
thread2.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
CAS Algorithm
- 인자로 기존값(Compared Value)과 변경할 값(Exchnaged Value)을 전달한다.
- 기존값이 현재 메모리가 가지고 있는 값과 같다면 변경할 값을 반영하여 true를 반환한다.
- 반대로 기존값이 현재 메모리가 가지고 있는 값과 다르다면 값을 반영하지 않고 false를 반환한다.
public class AtomicInteger extends Number implements java.io.Serializable {
private volatile int value;
public final int incrementAndGet() {
int current;
int next;
do {
current = get(); next = current + 1;
}
while (!compareAndSet(current, next));
return next;
}
public final boolean compareAndSet(int expect, int update) {
return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
}