public interface BoundedQueue {
void put(String data);
String take();
}
BoundedQueue: 버퍼 역할을 하는 큐의 인터페이스이다.
put(data) : 버퍼에 데이터를 보관한다. (생산자 스레드가 호출하고, 데이터를 생산한다.)
take(): 버퍼에 보관된 값을 가져간다. (소비자 스레드가 호출하고, 데이터를 소비한다.)
import java.util.ArrayDeque;
import java.util.Queue;
import static util.MyLogger.log;
public class BoundedQueueV implements BoundedQueue{
private final Queue<String> queue = new ArrayDeque<>();
private final int max;
public BoundedQueueV1(int max) {
this.max = max;
}
@Override
public synchronized void put(String data) {
if(queue.size() >= max) {
log("[put] 큐가 가득 참, 버림: " + data);
return;
}
queue.offer(data);
}
@Override
public synchronized String take() {
if (queue.isEmpty()) {
return null;
}
return queue.poll();
}
//확인용
@Override
public String toString() {
return queue.toString();
}
}
임계 영역
여기서 핵심 공유 자원은 바로 queue(ArrayDeque) 이다. 여러 스레드가 접근할 예정이므로 synchronized를 사용해서 한 번에 하나의 스레드만 put() 또는 take() 를 실행할 수 있도록 안전한 임계 영역을 만든다.
import java.util.ArrayList;
import java.util.List;
import static util.MyLogger.log;
import static util.ThreadUtils.sleep;
public class BoundedMain {
public static void main(String[] args) {
BoundedQueue queue = new BoundedQueueV1(2);
// 2. 생산자, 소비자 실행 순서 선택, 반드시 하나만 선택!
producerFirst(queue); // 생산자 먼저 실행
//consumerFirst(queue); // 소비자 먼저 실행
}
private static void producerFirst(BoundedQueue queue) {
log("== [생산자 먼저 실행] 시작, " + queue.getClass().getSimpleName() + " ==");
List<Thread> threads = new ArrayList<>();
startProducer(queue, threads);
printAllState(queue, threads);
startConsumer(queue, threads);
printAllState(queue, threads);
log("== [생산자 먼저 실행] 종료, " + queue.getClass().getSimpleName() + " ==");
}
private static void consumerFirst(BoundedQueue queue) {
log("== [소비자 먼저 실행] 시작, " + queue.getClass().getSimpleName() + " ==");
List<Thread> threads = new ArrayList<>();
startConsumer(queue, threads);
printAllState(queue, threads);
startProducer(queue, threads);
printAllState(queue, threads);
log("== [소비자 먼저 실행] 종료, " + queue.getClass().getSimpleName() + " ==");
}
private static void startProducer(BoundedQueue queue, List<Thread> threads)
{
System.out.println();
log("생산자 시작");
for (int i = 1; i <= 3; i++) {
Thread producer = new Thread(new ProducerTask(queue, "data" + i),"producer" + i);
threads.add(producer);
producer.start();
sleep(100); //순서대로 실행하기 위해서.
}
}
private static void startConsumer(BoundedQueue queue, List<Thread> threads) {
{
System.out.println();
log("소비자 시작");
for (int i = 1; i <= 3; i++) {
Thread consumer = new Thread(new ConsumerTask(queue), "consumer" + i);
threads.add(consumer);
consumer.start();
sleep(100);
}
}
}
private static void printAllState (BoundedQueue queue, List < Thread > threads){
System.out.println();
log("현재 상태 출력, 큐 데이터: " + queue);
for (Thread thread : threads) {
log(thread.getName() + ": " + thread.getState());
}
}
}
BoundedQueue 선택
BoundedQueue의 구현체를 선택해서 생성한다. 반드시 생산자, 소비자 실행 순서 선택에서 두 코드중에 하나만 선택해서 실행해야 한다. 생산자 먼저 실행을 선택하면 소비자 먼저 실행 부분은 주석으로 처리해야 한다
P: 생산자 , C: 소비자
14:14:37.124 [ main] == [생산자 먼저 실행] 시작, BoundedQueueV1 ==
14:14:37.128 [ main] 생산자 시작
14:14:37.139 [producer1] [생산 시도] data1 -> []
14:14:37.139 [producer1] [생산 완료] data1 -> [data1]
14:14:37.237 [producer2] [생산 시도] data2 -> [data1]
14:14:37.237 [producer2] [생산 완료] data2 -> [data1, data2]
14:14:37.344 [producer3] [생산 시도] data3 -> [data1, data2]
14:14:37.344 [producer3] [put] 큐가 가득 참, 버림: data3
14:14:37.344 [producer3] [생산 완료] data3 -> [data1, data2]
14:14:37.453 [ main] 현재 상태 출력, 큐 데이터: [data1, data2]
14:14:37.453 [ main] producer1: TERMINATED
14:14:37.453 [ main] producer2: TERMINATED
14:14:37.454 [ main] producer3: TERMINATED
14:14:37.454 [ main] 소비자 시작
14:14:37.455 [consumer1] [소비 시도] ? <- [data1, data2]
14:14:37.455 [consumer1] [소비 완료] data1 <- [data2]
14:14:37.564 [consumer2] [소비 시도] ? <- [data2]
14:14:37.564 [consumer2] [소비 완료] data2 <- []
14:14:37.671 [consumer3] [소비 시도] ? <- []
14:14:37.671 [consumer3] [소비 완료] null <- []
14:14:37.781 [ main] 현재 상태 출력, 큐 데이터: []
14:14:37.781 [ main] producer1: TERMINATED
14:14:37.782 [ main] producer2: TERMINATED
14:14:37.782 [ main] producer3: TERMINATED
14:14:37.782 [ main] consumer1: TERMINATED
14:14:37.783 [ main] consumer2: TERMINATED
14:14:37.783 [ main] consumer3: TERMINATED
14:14:37.785 [ main] == [생산자 먼저 실행] 종료, BoundedQueueV1 ==
데이터를 버리지 않는 대안
data3 을 버리지 않는 대안은, 큐에 빈 공간이 생길 때 까지 p3 스레드가 기다리는 것이다. 언젠가는 소비자 스레드가 실행되어서 큐의 데이터를 가져갈 것이고, 큐에 빈 공간이 생기게 된다. 이때 큐에 데이터를 보관하는 것이다.
큐에 데이터가 없다면 기다리자 소비자 입장에서 큐에 데이터가 없다면 기다리는 것도 대안이다.
큐에 데이터가 없는 상황은 앞서 큐의 데이터가 가득찬 상황과 비슷하다. 한정된 버퍼(Bounded buffer) 문제는 이렇듯 버퍼에 데이터가 가득 찬 상황에 데이터를 생산해서 추가할 때도 문제가 발생하고, 큐에 데이터가 없는데 데이터를 소비할 때도 문제가 발생한다.
14:29:08.050 [ main] == [소비자 먼저 실행] 시작, BoundedQueueV1 ==
14:29:08.051 [ main] 소비자 시작
14:29:08.054 [consumer1] [소비 시도] ? <- []
14:29:08.058 [consumer1] [소비 완료] null <- []
14:29:08.158 [consumer2] [소비 시도] ? <- []
14:29:08.158 [consumer2] [소비 완료] null <- []
14:29:08.267 [consumer3] [소비 시도] ? <- []
14:29:08.267 [consumer3] [소비 완료] null <- []
14:29:08.377 [ main] 현재 상태 출력, 큐 데이터: []
14:29:08.377 [ main] consumer1: TERMINATED
14:29:08.378 [ main] consumer2: TERMINATED
14:29:08.378 [ main] consumer3: TERMINATED
14:29:08.378 [ main] 생산자 시작
14:29:08.380 [producer1] [생산 시도] data1 -> []
14:29:08.380 [producer1] [생산 완료] data1 -> [data1]
14:29:08.485 [producer2] [생산 시도] data2 -> [data1]
14:29:08.485 [producer2] [생산 완료] data2 -> [data1, data2]
14:29:08.595 [producer3] [생산 시도] data3 -> [data1, data2]
14:29:08.595 [producer3] [put] 큐가 가득 참, 버림: data3
14:29:08.596 [producer3] [생산 완료] data3 -> [data1, data2]
14:29:08.702 [ main] 현재 상태 출력, 큐 데이터: [data1, data2]
14:29:08.702 [ main] consumer1: TERMINATED
14:29:08.702 [ main] consumer2: TERMINATED
14:29:08.703 [ main] consumer3: TERMINATED
14:29:08.703 [ main] producer1: TERMINATED
14:29:08.703 [ main] producer2: TERMINATED
14:29:08.703 [ main] producer3: TERMINATED
14:29:08.703 [ main] == [소비자 먼저 실행] 종료, BoundedQueueV1 ==
문제점
생산자 스레드 먼저 실행 의 경우 p3가 보관하는 data3는 버려지고 c3는 데이터를 받지 못함.
소비자 스레드 먼저 실행인경우 c1,c2,c3는 데이터를 받지 못한다. 그리고 p3의 data3은 버려지게 된다.
이것을 문제 해결하는 것이 중요하다.
@Override
public synchronized void put(String data) {
while (queue.size() == max) {
log("[put] 큐가 가득 참, 생산자 대기");
sleep(1000);
}
queue.offer(data);
}
@Override
public synchronized String take() {
while (queue.isEmpty()) {
log("[take] 큐에 데이터가 없음, 소비자 대기");
sleep(1000);
}
return queue.poll();
}
이렇게 while문으로 대기하는 것으로 해결해볼 수 있다.
put(data) - 데이터를 버리지 않는 대안: 큐가 가득 찾을 때, 큐에 빈 공간이 생길 때 까지, 생산자 스레드가 기다리면 된다. 언젠가는 소비자 스레드가 실행되어서 큐의 데이터를 가져갈 것이고, 그러면 큐에 데이터를 넣을 수 있는 공간이 생기게 된다.
take() - 큐에 데이터가 없다면 기다리자: 소비자 입장에서 큐에 데이터가 없다면 기다리는 것도 대안이다.
문제
생산자 먼저 실행의 경우 producer3 이 종료되지 않고 계속 수행되고, consumer1 consumer2 consumer3은 BLOCKED상태가 된다.
버퍼가 비었을 때 소비하거나, 버퍼가 가득 찾을 때 생산하는 문제를 해결하기 위해, 단순히 스레드가 잠깐 기다리면 될 것이라 생각했는데, 문제가 더 심각해졌다. 생각해보면 결국 임계 영역 안에서 락을 가지고 대기하는 것이 문제이다. 이 것은 마치 열쇠를 가진 사람이 안에서 문을 잠궈버린 것과 같다. 그래서 다른 스레드가 임계 영역안에 접근조차 할 수 없는 것이다
Object
Object.wait(): 현재 스레드가 가진 락을 반납하고 대기한다.
Object.notify(): 대기 중인 스레드 중 하나를 깨운다.
Object.notifyAll(): 대기 중인 모든 스레드를 깨운다.
@Override
public synchronized void put(String data) {
while (queue.size() == max) {
log("[put] 큐가 가득 참, 생산자 대기");
try {
wait(); // RUNNABLE -> WAITING, 락 반납
log("[put] 생산자 깨어남");
}
catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
queue.offer(data);
log("[put] 생산자 데이터 저장, notify() 호출");
notify(); // 대기 스레드, WAIT -> BLOCKED
//notifyAll(); // 모든 대기 스레드, WAIT -> BLOCKED
}
@Override
public synchronized String take() {
while (queue.isEmpty()) {
log("[take] 큐에 데이터가 없음, 소비자 대기");
try {
wait();
log("[take] 소비자 깨어남");
}
catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
String data = queue.poll();
log("[take] 소비자 데이터 획득, notify() 호출");
notify(); // 대기 스레드, WAIT -> BLOCKED
//notifyAll(); // 모든 대기 스레드, WAIT -> BLOCKED
return data;
}
Object.wait() 는 대기할 때 락을 반납하고 대기 한다. 그리고 대기 상태에 서 깨어나면, 다시 반복문에서 큐의 빈 공간을 체크한다.
wait() 를 호출해서 대기하는 경우 RUNNABLE -> WAITING 상태가 된다.
생산자가 데이터를 큐에 저장하고 나면 notify() 를 통해 저장된 데이터가 있다고 대기하는 스레드에 알려주어야 한다.
15:25:47.984 [ main] == [생산자 먼저 실행] 시작, BoundedQueueV3 ==
15:25:47.987 [ main] 생산자 시작
15:25:47.993 [producer1] [생산 시도] data1 -> []
15:25:47.993 [producer1] [put] 생산자 데이터 저장, notify() 호출
15:25:47.993 [producer1] [생산 완료] data1 -> [data1]
15:25:48.099 [producer2] [생산 시도] data2 -> [data1]
15:25:48.099 [producer2] [put] 생산자 데이터 저장, notify() 호출
15:25:48.099 [producer2] [생산 완료] data2 -> [data1, data2]
15:25:48.208 [producer3] [생산 시도] data3 -> [data1, data2]
15:25:48.208 [producer3] [put] 큐가 가득 참, 생산자 대기
15:25:48.319 [ main] 현재 상태 출력, 큐 데이터: [data1, data2]
15:25:48.319 [ main] producer1: TERMINATED
15:25:48.319 [ main] producer2: TERMINATED
15:25:48.320 [ main] producer3: WAITING
15:25:48.320 [ main] 소비자 시작
15:25:48.321 [consumer1] [소비 시도] ? <- [data1, data2]
15:25:48.321 [consumer1] [take] 소비자 데이터 획득, notify() 호출
15:25:48.321 [producer3] [put] 생산자 깨어남
15:25:48.321 [consumer1] [소비 완료] data1 <- [data2]
15:25:48.322 [producer3] [put] 생산자 데이터 저장, notify() 호출
15:25:48.322 [producer3] [생산 완료] data3 -> [data2, data3]
15:25:48.428 [consumer2] [소비 시도] ? <- [data2, data3]
15:25:48.428 [consumer2] [take] 소비자 데이터 획득, notify() 호출
15:25:48.428 [consumer2] [소비 완료] data2 <- [data3]
15:25:48.536 [consumer3] [소비 시도] ? <- [data3]
15:25:48.536 [consumer3] [take] 소비자 데이터 획득, notify() 호출
15:25:48.536 [consumer3] [소비 완료] data3 <- []
15:25:48.644 [ main] 현재 상태 출력, 큐 데이터: []
15:25:48.646 [ main] producer1: TERMINATED
15:25:48.647 [ main] producer2: TERMINATED
15:25:48.647 [ main] producer3: TERMINATED
15:25:48.647 [ main] consumer1: TERMINATED
15:25:48.647 [ main] consumer2: TERMINATED
15:25:48.648 [ main] consumer3: TERMINATED
15:25:48.648 [ main] == [생산자 먼저 실행] 종료, BoundedQueueV3 ==
임계영역 안이므로 P3가 깨어나더라도 Blocked 상태임. 만약 c1이 끝나면 그때서야 lock을 획득한다.
같은 종류의 스레드를 깨울 때 비효율이 발생한다. 생산자가 같은 생산자를 깨우거나, 소비자가 같은 소비자를 깨울 때 비효율이 발생 할 수 있다는 점이다. 생산자가 소비자를 깨우고, 반대로 소비자가 생산자를 깨운다면 이런 비효율은 발생하지 않는다
스레드 기아(thread starvation)
notify() 의 또 다른 문제점으로는 어떤 스레드가 깨어날 지 알 수 없기 때문에 발생할 수 있는 스레드 기아 문제가 있다.
notify() 로 다시 깨우는데 어떤 스레드를 깨울지 알 수 없다. 따라서 c1~c5 스레드가 반복해서 깨어날 수 있다. 이런 문제를 해결하는 방법 중에 notify() 대신에 notifyAll() 을 사용하는 방법이다.
notifyAll() 을 사용해서 스레드 기아 문제는 막을 수 있지만, 비효율을 막지는 못한다.
그렇다면....
생산자가 생산자를 깨우고, 소비자가 소비자를 깨우는 비효율 문제를 어떻게 해결할 수 있을까?
핵심은 생산자 스레드는 데이터를 생성하고, 대기중인 소비자 스레드에게 알려주어야 한다. 반대로 소비자 스레드는 데 이터를 소비하고, 대기중인 생산자 스레드에게 알려주면 된다. 결국 생산자 스레드가 대기하는 대기 집합과, 소비자 스레드가 대기하는 대기 집합을 둘로 나누면 된다. 그리고 생산자 스레드가 데이터를 생산하면 소비자 스레드가 대기하는 대기 집합에만 알려주고, 소비자 스레드가 데이터를 소비하면 생산자 스레드가 대기하는 대기 집합에만 열려주면 되는 것이다. 이렇게 생산자용, 소비자용 대기 집합을 서로 나누어 분리하면 비효율 문제를 깔끔하게 해결할 수 있다. Lock, ReentrantLock을 사용한다.
private final Lock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();
@Override
public void put(String data) {
lock.lock();
try {
while (queue.size() == max) {
log("[put] 큐가 가득 참, 생산자 대기");
try {
condition.await();
log("[put] 생산자 깨어남");
}
catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
queue.offer(data);
log("[put] 생산자 데이터 저장, signal() 호출");
condition.signal();
} finally {
lock.unlock();
}
}
@Override
public String take() {
lock.lock();
try {
while (queue.isEmpty()) {
log("[take] 큐에 데이터가 없음, 소비자 대기");
try {
condition.await();
log("[take] 소비자 깨어남");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
String data = queue.poll();
log("[take] 소비자 데이터 획득, signal() 호출");
condition.signal();
return data;
} finally {
lock.unlock();
}
}
Condition은 ReentrantLock 을 사용하는 스레드가 대기하는 스레드 대기 공간이다.
condition.await() : condition 에 현재 스레드를 대기( WAITING)상태로 보관한다.
이건 아직 하나인 경우다. 이걸 두개로 분리할 것이다.
private final Lock lock = new ReentrantLock();
private final Condition producerCond = lock.newCondition();
private final Condition consumerCond = lock.newCondition();
@Override
public void put(String data) {
lock.lock();
try {
while (queue.size() == max) {
log("[put] 큐가 가득 참, 생산자 대기");
try {
producerCond.await();
log("[put] 생산자 깨어남");
}
catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
queue.offer(data);
log("[put] 생산자 데이터 저장, signal() 호출");
consumerCond.signal();
} finally {
lock.unlock();
}
}
@Override
public String take() {
lock.lock();
try {
while (queue.isEmpty()) {
log("[take] 큐에 데이터가 없음, 소비자 대기");
try {
consumerCond.await();
log("[take] 소비자 깨어남");
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
String data = queue.poll();
log("[take] 소비자 데이터 획득, signal() 호출");
producerCond.signal();
return data;
} finally {
lock.unlock();
}
}
BlockingQueue 를 결국 쓰게 됨!
BoundedQueue 를 스레드 관점에서 보면 큐가 특정 조건이 만족될 때까지 스레드의 작업을 차단(blocking)한다.
데이터 추가 차단 : 큐가 가득 차면 데이터 추가 작업( put() )을 시도하는 스레드는 공간이 생길 때까지 차단된다.
데이터 획득 차단 : 큐가 비어 있으면 획득 작업( take() )을 시도하는 스레드는 큐에 데이터가 들어올 때까지 차단된다.
public class BoundedQueueV6_1 implements BoundedQueue {
private BlockingQueue<String> queue;
public BoundedQueueV6_1(int max) {
queue = new ArrayBlockingQueue<>(max);
}
public void put(String data) {
try {
queue.put(data);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
public String take() {
try {
return queue.take();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
17:31:12.207 [ main] == [생산자 먼저 실행] 시작, BoundedQueueV6_1 ==
17:31:12.212 [ main] 생산자 시작
17:31:12.227 [producer1] [생산 시도] data1 -> []
17:31:12.228 [producer1] [생산 완료] data1 -> [data1]
17:31:12.331 [producer2] [생산 시도] data2 -> [data1]
17:31:12.331 [producer2] [생산 완료] data2 -> [data1, data2]
17:31:12.442 [producer3] [생산 시도] data3 -> [data1, data2]
17:31:12.553 [ main] 현재 상태 출력, 큐 데이터: [data1, data2]
17:31:12.554 [ main] producer1: TERMINATED
17:31:12.554 [ main] producer2: TERMINATED
17:31:12.554 [ main] producer3: WAITING
17:31:12.555 [ main] 소비자 시작
17:31:12.559 [consumer1] [소비 시도] ? <- [data1, data2]
17:31:12.559 [producer3] [생산 완료] data3 -> [data2, data3]
17:31:12.560 [consumer1] [소비 완료] data1 <- [data2, data3]
17:31:12.662 [consumer2] [소비 시도] ? <- [data2, data3]
17:31:12.662 [consumer2] [소비 완료] data2 <- [data3]
17:31:12.771 [consumer3] [소비 시도] ? <- [data3]
17:31:12.771 [consumer3] [소비 완료] data3 <- []
17:31:12.880 [ main] 현재 상태 출력, 큐 데이터: []
17:31:12.881 [ main] producer1: TERMINATED
17:31:12.882 [ main] producer2: TERMINATED
17:31:12.882 [ main] producer3: TERMINATED
17:31:12.882 [ main] consumer1: TERMINATED
17:31:12.882 [ main] consumer2: TERMINATED
17:31:12.882 [ main] consumer3: TERMINATED
17:31:12.883 [ main] == [생산자 먼저 실행] 종료, BoundedQueueV6_1 ==
add(e) : 지정된 요소를 큐에 추가하며, 큐가 가득 차면 IllegalStateException 예외를 던진다.
remove() : 큐에서 요소를 제거하며 반환한다. 큐가 비어 있으면 NoSuchElementException 예외를 던진다.
element() : 큐의 머리 요소를 반환하지만, 요소를 큐에서 제거하지 않는다. 큐가 비어 있으면 NoSuchElementException 예외를 던진다.
offer(e) : 지정된 요소를 큐에 추가하려고 시도하며, 큐가 가득 차면 FALSE 반환
poll() : 큐에서 요소를 제거하고 반환한다. 큐가 비어 있으면 NULL반환
peek() : 큐의 머리 요소를 반환하지만, 요소를 큐에서 제거하지 않는다. 큐가 비어 있으면 NULL반환
put(e) : 지정된 요소를 큐에 추가할 때까지 대기한다. 큐가 가득 차면 공간이 생길 때까지 대기한다.
take() : 큐에서 요소를 제거하고 반환한다. 큐가 비어 있으면 요소가 준비될 때까지 대기한다.
offer(e, time, unit) : 지정된 요소를 큐에 추가하려고 시도하며, 지정된 시간 동안 큐가 비워지기를 기다리다가 시간이 초과되면 FALSE 반환
poll(time, unit) : 큐에서 요소를 제거하고 반환한다. 큐에 요소가 없다면 지정된 시간 동안 요소가 준비되기를 기다리다가 시간이 초과되면 NULL 반환
BlockingQueue - 즉시 반환
private BlockingQueue<String> queue;
public BoundedQueueV6_2(int max) {
queue = new ArrayBlockingQueue<>(max);
}
public void put(String data) {
boolean result = queue.offer(data);
log("저장 시도 결과 = " + result);
}
public String take() {
return queue.poll();
}
@Override
public String toString() {
return queue.toString();
}
offer(data)가 성공하면 TRUE 버퍼가 가득차면 FALSE
POLL() 버퍼에 데이터가 없으면 즉시 NULL 반환
private BlockingQueue<String> queue;
public BoundedQueueV6_3(int max) {
queue = new ArrayBlockingQueue<>(max);
}
public void put(String data) {
try {
// 대기 시간 설정 가능
boolean result = queue.offer(data, 1, TimeUnit.NANOSECONDS);
log("저장 시도 결과 = " + result);
}
catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
public String take() {
try {
// 대기 시간 설정 가능
return queue.poll(2, TimeUnit.SECONDS);
}
catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
BlockingQueue - 예외
private BlockingQueue<String> queue;
public BoundedQueueV6_4(int max) {
queue = new ArrayBlockingQueue<>(max);
}
public void put(String data) {
queue.add(data); // java.lang.IllegalStateException: Queue full
}
public String take() {
return queue.remove(); // java.util.NoSuchElementException
}
'운영체제' 카테고리의 다른 글
스레드 풀과 Executor 프레임워크 (1) | 2024.12.21 |
---|---|
원자적 연산 (2) | 2024.12.20 |
concurrent Lock (0) | 2024.12.16 |
Synchronized (0) | 2024.12.15 |
Volatile - 메모리가시성 (0) | 2024.12.15 |