인프런 얄코의 제대로 파는 자바 강의를 듣고 정리한 내용이다.
중요하거나 실무를 하면서 놓치고 있었던 부분들 위주로만 요약 정리한다.
자세한 내용은 강의를 직접 수강하는 것이 좋다.
1. 동기화
Synchronized
공유자원의 동시 접근을 막기 위해서 동기화를 한다. 강의에서는 ATM에서 여러 사람(쓰레드)가 인출을 동시에 하는 상황을 가정한다.
ATM 클래스의 인스턴스를 만들고, addMoney 메서드로 amount 만큼 넣은 후, 여러 쓰레드가 withdraw 메서드를 반복적으로 호출하게 한다. 그럼 withdraw 메서드의 상단 잔액을 체크하는 부분에서 남은 금액인 balance보다 요청 금액 amount가 큰 경우 return;을 해서 메서드를 끝내버려야하는데, 동시에 호출하면 요청이 끝나지 않고 balance가 음수가 되는 상황이 발생한다.
이는 하나의 쓰레드가 withdraw() 메서드에 접근해서 Thread.sleep()이 실행되는 동안 다른 쓰레드가 또 withdraw() 메서드에 접근해서 아직 업데이트 되지 않은 balance 값을 바탕으로 판단하여 중복처리하기 때문이다.
public class ATM {
private int balance = 0;
public void addMoney(int amount) {
balance += amount;
}
public int getBalance() {
return balance;
}
public void withdraw(String name, int amount) {
if (balance < amount) return;
System.out.printf("%s 인출 요청 (현 잔액 %d)%n", name, balance);
try {
Thread.sleep(new Random().nextInt(700, 1000));
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
balance -= amount;
System.out.printf("%s 인출 완료 (현 잔액 %d)%n", name, balance);
}
}
이를 방지하기 위해서는 synchronized를 메서드 앞에 붙여주면 된다.
synchronized public void withdraw(String name, int amount) {
Volatile
volatile은 특정 값을 캐시에 저장하지 말고 값이 변할 때마다 계속 업데이트하라는 의미가 된다. 아래 코드에서 while 문이 도는 동안 계속 i값을 업데이트 및 출력 하다가, 1초가 지난 뒤 stop = true 로 변경되면서 쓰레드가 종료된다.
그런데 i 값을 출력하는 부분을 주석처리하면, stop 값이 업데이트되지 않아서 프로그램이 중단되지 않는 문제가 발생한다. 이는 JVM이 해당 값이 꼭 필요하지 않은 경우 캐시처리하여 불필요한 메모리 공간 사용을 막기 때문이다(컴퓨터마다 다를 수 있다고 한다).
public class Cache {
static boolean stop = false;
public static void main(String[] args) {
new Thread(() -> {
int i = 0;
while (!stop) {
i++;
System.out.println(i); //이 부분 주석
}
System.out.println("Thread Stop");
}).start();
try {
Thread.sleep(1000);
} catch (InterruptedException e) {}
stop = true;
}
}
따라서 이럴 경우 2가지 방법으로 처리한다.
stop 변수 앞에 volatile로 선언하면 캐싱처리 없이 반드시 변수값을 확인한다.
volatile static boolean stop = false;
또는 stop 변수에 접근하는 getter와 setter 메서드를 만들고 이들을 synchronized 처리한다.
public static void setStop(boolean stop) {
Cache.stop = stop;
}
public static boolean isStop() {
return Cache.stop;
}
2. Notify, Wait
Object 클래스에서 제공하는 쓰레드 관련 메서드들이다.
- wait: synchronized 처리되어있는 동기화 메소드의 사용 중 자신의 일을 멈춘다
- notify: 일을 멈춘 상태의 쓰레드에게 자리가 비었음을 통보한다. 대기열의 쓰레드 중 하나에만 통보한다
- notifyAll: 대기 중인 모든 쓰레드에 통보한다
강의에서 나온 생산자 - 소비자 모델을 통해서 notify와 wait을 이해해본다.
생산자인 Manager와 소비자인 Customer를 Runnable을 구현하도록 만든다. Manager는 fill 메서드를, Customer는 takeout 메서드를 무한정 실행한다.
public class ManagerRun implements Runnable {
CoffeeMachine coffeeMachine;
public ManagerRun(CoffeeMachine coffeeMachine) {
this.coffeeMachine = coffeeMachine;
}
@Override
public void run() {
while (true) {
coffeeMachine.fill(this);
}
}
}
public class CustomerRun implements Runnable {
String name;
CoffeeMachine coffeeMachine;
public CustomerRun(String name, CoffeeMachine coffeeMachine) {
this.name = name;
this.coffeeMachine = coffeeMachine;
}
@Override
public void run() {
while (true) {
coffeeMachine.takeout(this);
}
}
}
그리고 Customer와 Manager에 공유될 CoffeeMachine 클래스를 다음과 같이 만든다.
- takeout, fill 두 메서드가 모두 synchronized가 걸려있어서 두 가지 행동이 동시에 일어날 수 없도록 되어있다.
- 각 메서드 처리 후 notifyAll()을 통해 대기 중인 쓰레드 전체에게 자원 사용이 끝났음을 알린다.
- 이후 wait() 메서드를 통해 다른 쓰레드에게 양보할 때까지 기다린다. wait 메서드는 try-catch로 감싸서 예외처리를 해줘야한다.
public class CoffeeMachine {
final int COFFEE_CNT_MAX = 10;
int coffeeCnt = COFFEE_CNT_MAX;
synchronized public void takeout(CustomerRun customer) {
if (coffeeCnt < 1) {
System.out.printf("[%d] 😅 커피 없음 %n", coffeeCnt, customer.name);
} else {
try {Thread.sleep(1000);} catch (InterruptedException e) {}
System.out.printf("[%d] ☕️ %s 테이크 아웃 %n", coffeeCnt, customer.name);
coffeeCnt--;
}
notifyAll();
try {wait();} catch (InterruptedException e) {}
}
synchronized public void fill(ManagerRun manager) {
if (coffeeCnt > 3) {
System.out.printf("[%d] 👌 여유 있음... %n", coffeeCnt);
} else {
try {Thread.sleep(1000);} catch (InterruptedException e) {}
System.out.printf("[%d] ✅ 커피 채워넣음%n", coffeeCnt);
coffeeCnt = COFFEE_CNT_MAX;
}
notifyAll();
try { wait(); } catch (InterruptedException e) {}
}
}
그리고 Main 메서드를 아래처럼 작성 후 실행하면 아래 사진처럼 각 쓰레드가 번갈아가면서 하나씩만 실행되고, takeout과 fill도 synchronized 되어 동시에 실행은 안되는 것을 확인할 수 있다.
public class Main {
public static void main(String[] args) {
CoffeeMachine coffeeMachine = new CoffeeMachine();
Arrays.stream("피카츄, 라이츄, 파이리, 꼬부기, 버터풀, 야도란".split(","))
.forEach(s -> new Thread(
new CustomerRun(s, coffeeMachine)
).start());
new Thread(new ManagerRun(coffeeMachine)).start();
}
}
3. 쓰레드풀, Future
Executors, ExecutorService
쓰레드가 너무 많이 만들어지거나, 요청이 들어올 때마다 생성 시 자원이 많이 들기 때문에 쓰레드풀을 만들어두고 미리 쓰레드들을 생성해둔다. 이러면 개발자가 직접 쓰레드를 생성하고 조작할 필요가 없게 되며 자원을 효율적으로 사용할 수 있게 된다.
대략 다음과 같은 방식으로 사용하며 각 메서드들을 눈에 익혀두면 좋을 것 같다.
public static void test() {
// 쓰레드풀 실행
ExecutorService es = Executors.newFixedThreadPool(5);
// 쓰레드 1개가 Runnable을 수행하도록 함
es.execute(new SomethingRunnable);
// 실행 중인 쓰레드는 제외하고 풀을 닫음. 이게 실행되어야 프로그램이 끝남
es.shutdown();
// 풀을 닫고 진행 중인 업무 강제 종료
// 각 Runnable을 구현한 쓰레드 메서드에서 InterruptedException을 처리하고 거기서 return을 해줘야 종료됨
// 대기 중인 쓰레드의 리스트를 반환한다.
List<Runnable> waitings = es.shutdownNow();
}
Callable, Future
Callable
Runnable처럼 @FunctionalInterface이다. 쓰레드의 인자로 들어가서 실행이 가능하며 메서드의 이름은 call()이다. Supplier처럼 call 메서드 이후 값을 반환한다.
Future
비동기적 연산의 결과이다. main 쓰레드 외부에서 작업된 결과를 반환하며, ExecutorService 인스턴스의 submit 메서드가 Future를 반환한다. 그리고 Future는 Callable을 인자로 받는다. get 메서드로 최종 값을 받아온다.
Callable은 main 쓰레드의 흐름을 방해하지 않다가, 결과인 Future<> 타입의 .get()으로 호출하는 시점이 되면 main 쓰레드의 작업에 관여한다.
public class FutureCallable {
public static void main(String[] args) {
ExecutorService es = Executors.newSingleThreadExecutor();
// submit으로 실행된 callable은 아래 메인 쓰레드의 흐름을 방해하지 않는다.
Future<String> answer = es.submit(() -> {
Thread.sleep(5000);
return "여보세요";
});
while (!answer.isDone()) {
System.out.println("☎️ 따르릉...");
try{ Thread.sleep(400);} catch (InterruptedException e) {}
}
// Future<>.get()이 호출되면 Callable 실행 결과를 갖고 main 쓰레드에 관여하게 된다.
String result = null;
try { result = answer.get(); } catch (InterruptedException | ExecutionException e) {}
System.out.println("통화 시작 - " + result);
System.out.println("종료");
es.shutdown();
}
}