자바의 멀티쓰레드 프로그래밍에 대해 학습하세요.
Thread에 대해 알기 위해서는 우선 Process부터 짚고 넘어가야 한다. OS상에서 동작중인 하나의 어플리케이션, 즉 프로그램을 프로세스라고 한다. 우리가 작업 관리자를 들어가서 프로세스 탭에 들어가면 나와있는 수많은 프로그램들이 다 하나하나의 프로세스인 것이다.일반적으로 하나의 프로세스는 한 가지 일을 하는데, 소프트웨어가 발달하면서 점점 하나의 프로그램에 복잡한 동시 작업이 요구되었고, 여러개의 프로세스를 만드는 것으로는 이런 동시 작업 문제를 해결하기 어려웠기 때문에(각각의 프로세스는 메모리를 서로 별도로 관리하므로 프로세스 간에 데이터를 공유하려면 일일이 복사해줘야 해서 느리기도 하고 메모리도 많이 소모한다.) 쓰레드라는 더 작은 개념이 만들어지게 되었다.
쓰레드는 프로세스 내에서 독립적으로 실행되는 하나의 작업 단위다. 프로세스가 서로간에 완벽히 독립된 작업 공간을 가지는 반면에, 쓰레드끼리는 코드 영역과 데이터 영역을 공유한다. (스택은 공유하지 않는다.) 따라서 한 프로그램이 여러 개의 프로세스를 가지는 것에 비해 여러 개의 쓰레드를 가지면 더 빠른 작업이 가능하다. 프로세스안에는 최소한 1개의 쓰레드는 존재하며 하나의 프로세스 내에는 여러개의 쓰레드가 존재할 수 있는데, 2개 이상의 쓰레드가 존재하는 프로세스를 멀티 쓰레드 프로세스라고 지칭한다.
자바에서 쓰레드를 생성하는 방법은 두 가지가 있다. 첫번째는 java.lang 패키지의 Thread 클래스를 사용하는 것이고, 두번째는 같은 패키지의 Runnable 인터페이스를 사용하는 것이다. 참고로 Thread 클래스 또한 Runnable 인터페이스의 구현체다.
public class Test extends Thread {
@Override
public void run() {
System.out.println("Thread run");
}
public static void main(String[] args) {
Test test = new Test();
test.start();
}
}
첫번째 방법으로는 위 예제 코드처럼 Thread 클래스를 상속받은 클래스를 작성하는 방법이 있다. Thread 클래스의 run() 메소드는 새 쓰레드를 만들며, Thread를 상속받은 클래스에서는 이 run() 메소드를 오버라이딩해서 사용해야 한다. run메소드는 단순 return하도록 작성되어 있기 때문에, 오버라이딩 하지 않으면 쓰레드가 바로 종료된다.
그런데, 예제 코드에서 보면 Test 객체 test를 만들고는 test의 run이 아니라 start 메소드를 호출한 것을 볼 수 있다. 만약 run 메소드를 호출하게 되면 쓰레드가 생성만 되고 후술할 RUNNABLE한 상태가 되지 않기 때문에, 정상적으로 실행되지 않는다. 때문에 start 메소드를 통해 실행시켜줘야 한다. start 메소드는 Thread 클래스에 구현된 메소드이며, 오버라이딩 해서는 안된다. start 메소드는 생성된 쓰레드 객체를 RUNNABLE하게 전환시킨다. 이후 JVM에 의해 이 쓰레드가 선택되면 run 메소드가 호출되고 실행된다.
이렇게 Thread 클래스를 상속받아서 작성하는 방법도 있지만, Runnable 인터페이스를 구현해서 쓰레드를 만드는 방법도 있다.
public class Test {
public static void main(String[] args) {
Thread thread = new Thread(new Foo());
thread.start();
}
}
class Foo implements Runnable {
@Override
public void run() {
System.out.println("Thread run");
}
}
Runnable 인터페이스를 구현해서 만드는 구현체에는 start() 메소드가 존재하지 않는다. 때문에 별도의 Thread 객체를 생성하고, 이 때 Runnable 인터페이스의 구현체를 인자로 넘겨주어야 한다.
Runnable 인터페이스의 구현체로 만드는 쓰레드도 Thread 클래스를 상속해서 만드는 쓰레드와 마찬가지로 start() 메소드를 통해 실행시킨다.
Runnable 인터페이스의 구현체를 이용해 쓰레드를 만들면
쓰레드는 총 6가지의 상태를 가지며 이 상태들은 JVM에 의해 관리된다.
new Thread()를 통해 쓰레드를 생성하면 쓰레드는 NEW 상태가 된다. 이후 start() 메소드가 호출되면 쓰레드는 실행될 수 있는 RUNNABLE 상태가 된다. JVM은 RUNNABLE 상태의 쓰레드들 중 하나를 선택하여 run() 메소드를 호출시켜 실행시킨다. 이 때 여러 개의 쓰레드가 RUNNABLE이면 우선순위가 높은 쓰레드를 먼저 선택한다.
RUNNABLE 상태는 준비 상태와 실행 상태를 모두 포함한다. start() 메소드를 통해 RUNNABLE 상태가 되어도 run() 메소드가 호출되기 전 까지는 실행되지 않는다. run() 메소드가 호출되면 실행되는데, 이 때 실행중인 쓰레드에서 yield()를 호출하면 현재 실행중인 쓰레드가 다시 준비 상태로 돌아가고, 다른 쓰레드가 스케줄링된다.
실행중인 쓰레드가 일시정지되는 경우는 쓰레드 내에서 객체의 wait() 호출, I/O 작업 요청, sleep() 메소드 호출의 경우가 있다. 이 때 I/O 작업 요청으로 인해 쓰레드가 일시정지 될 경우, I/O 작업이 완료되면 다시 RUNNABLE 상태로 돌아가게 된다. sleep 메소드를 통해 일시정지 될 경우 TIMED_WAITING 상태가 되며, 지정된 시간이 지난 후에 다시 RUNNABLE 상태가 된다. WAITING 상태는 조금 복잡한데, 다른 쓰레드의 객체에서 notify() 또는 notifyAll() 메소드를 호출해줄 때까지 깨어나지 않고 대기한다.
쓰레드의 현재 상태가 어떤지 확인하고 싶다면, 쓰레드 객체의 getState() 메소드를 호출하면 쓰레드의 상태에 따라서 열거 상수를 반환받을 수 있다.
쓰레드의 스케줄링은 우선순위에 따라 이루어진다. 쓰레드가 수행하는 작업의 중요도에 따라 쓰레드에 우선순위를 부여하여 쓰레드 별로 작업시간을 다르게 부여할 수 있다. 우선순위가 높은 쓰레드 일 수록 더 많은 작업시간을 할당받는다. 쓰레드의 우선순위는 절대적인 값이 아니라 상대적인 것이며, 쓰레드 클래스 내부에 멤버 변수로 존재한다.
자바 쓰레드의 우선순위 체계는 최댓값(MAX_PRIORITY) 10부터 최솟값(MIN_PRIORITY) 1까지 이며, 자식 쓰레드는 생성될 때 부모 쓰레드의 우선순위를 물려받는다. 기본적으로 main 쓰레드가 우선순위로 보통 값(NORMAL_PRIORITY) 5를 가지고 있기 때문에 main 쓰레드의 모든 자식 쓰레드들은 5의 우선순위를 가지고 생성된다. 쓰레드의 setPriority(int newPriority) 메소드를 사용하면 우선순위를 newPriority 값으로 바꿀 수 있다.
JVM은 자바 어플리케이션을 실행하기 직전 하나의 쓰레드를 만드는데, 이 쓰레드가 메인 쓰레드가 된다. 메인 쓰레드는 프로그램의 main() 메소드를 실행시킨다. 메인 쓰레드에서 다른 쓰레드를 분기하지 않으면 싱글 쓰레드 어플리케이션이 되고, 메인 쓰레드가 다른 작업 쓰레드를 생성하여 프로그램 내에 3개 이상의 쓰레드(기본적으로 메인 쓰레드와 가비지 컬렉션 쓰레드는 자동으로 생성된다.)가 존재하게 되면 멀티 쓰레드 어플리케이션이 된다.
가비지 컬렉션 쓰레드는 메인 쓰레드의 데몬 쓰레드(Daemon Thread)인데, 데몬 쓰레드란 응용프로그램의 관리를 위해 존재하는 쓰레드다. 데몬 쓰레드가 아닌 쓰레드들은 사용자 쓰레드라고 부르며, 데몬 쓰레드가 살아 있더라도 프로그램 내의 모든 사용자 쓰레드가 종료되면 어플리케이션도 종료된다. 사용자가 임의로 만든 쓰레드의 경우에도 setDaemon(true) 메소드를 호출해주면 데몬 쓰레드가 된다.
싱글 쓰레드 프로세스의 경우 한 프로세스에서 하나의 쓰레드로만 작업을 하기 때문에 프로세스의 자원을 활용하는데 문제가 없지만, 멀티 쓰레드 프로세스의 경우 프로세스 내의 자원이 여러 쓰레드에서 공유될 수 있다. 이렇게 될 경우, 쓰레드 A에서 사용하던 데이터를 쓰레드 B에서 임의로 변경했을 때, 쓰레드 A의 작업의 결과가 의도치 않게 달라질 수 있다.
따라서 쓰레드 A에서 하고 있는 작업에 쓰레드 B가 간섭하지 못하도록 막아줄 필요가 있는데, 이 작업을 동기화(Synchronization)라고 한다. 자바의 동기화는 synchronized를 이용한다.
synchronized는 특정 객체나 메소드에 lock을 걸 수 있다. synchronized가 붙으면, 해당 블록이 임계 영역으로 지정되어 해당 블록 안에서는 다른 쓰레드가 간섭할 수 없다.
// 메소드에 lock을 거는 방법
public synchronized void test () {
...
// 이 메소드가 실행되는 동안 메소드가 포함된 this 객체에 lock이 걸린다.
}
``'
메소드의 선언부에 synchronized를 붙여주면 해당 메소드를 가지고 있는 객체에 lock이 걸린다. 이때 객체에 lock이 걸리는 스코프는 메소드 블록이다. 즉, synchronized가 붙어있는 메소드가 실행되는 동안은 다른 쓰레드가 메소드의 this 객체에 영향을 미칠 수 없다.
```java
// 특정 객체에 lock을 거는 방법
synchronized(객체의 참조변수) {
...
// 이 안에서는 참조한 객체에 lock이 걸린다.
}
이렇게 synchronized(객체의 참조변수) {} 를 사용해주면, 중괄호 블록 안에서는 참조한 객체에 lock이 걸리게 된다. 예를들어 synchronized(this)를 이용하면 메소드에 synchronized를 붙이는 것처럼 해당 객체에 lock이 걸린다.
synchronized(참조변수)는 블록 안에만 lock이 걸린다는 점을 유의해야 한다. 예를 들어
public void test () {
/* lock이 걸리지 않는 부분 */
synchronized(this) {
// 이 블록에서만 lock이 걸린다.
}
/* lock이 걸리지 않는 부분 */
}
같은 코드가 있을 경우, lock이 걸리지 않는 부분이라고 주석처리한 부분의 코드에서는 객체에 lock이 걸리지 않는다. 만약 해당 부분에 아무런 코드가 없을 경우, 메소드에 synchronized를 걸어주는 것과 동일하다.
wait()과 notify() 메소드는 동기화의 효율을 높이기 위해 사용한다. 다음과 같은 코드를 보자.
public class Test {
public static void main(String[] args) {
Product product = new Product();
Producer producer = new Producer(product);
Customer customer = new Customer(product);
producer.start();
customer.start();
}
}
}
class Product {
private int amount = 0;
public synchronized void buyProduct() {
// 편의를 위해 물건을 1개씩만 구매한다고 가정한다.
if (amount < 1) {
try {
wait();
} catch (Exception e) {
}
}
amount--;
System.out.println("상품 구매 // 잔고: " + amount + "개");
notify();
}
public synchronized void bringProduct(int count) {
if (amount >= 10) {
try {
wait();
} catch (Exception e) {
}
}
amount += count;
System.out.println("상품 10개 입고 // 잔고: " + amount + "개");
notify();
}
}
class Customer extends Thread{
private Product p;
public Customer(Product p) {
this.p = p;
}
@Override
public void run() {
while(true) {
p.buyProduct();
}
}
}
class Producer extends Thread {
private Product p;
public Producer(Product p) {
this.p = p;
}
@Override
public void run() {
while(true) {
p.bringProduct(10);
}
}
}
결과
상품 10개 입고 // 잔고: 10개
상품 구매 // 잔고: 9개
상품 구매 // 잔고: 8개
상품 구매 // 잔고: 7개
상품 구매 // 잔고: 6개
상품 구매 // 잔고: 5개
상품 구매 // 잔고: 4개
상품 구매 // 잔고: 3개
상품 구매 // 잔고: 2개
상품 구매 // 잔고: 1개
상품 구매 // 잔고: 0개
상품 10개 입고 // 잔고: 10개
상품 구매 // 잔고: 9개
상품 구매 // 잔고: 8개
...
Customer 클래스와 Producer 클래스는 Product를 공유한다. 편의상 Customer는 1개씩만 물건을 구입한다고 한다. 만약 Product 클래스의 amount가 0이 되면 물건을 구매할 수 없다. 때문에 buyProduct 메소드에서 amount가 0 이면 wait()을 호출해서 쓰레드를 대기 상태로 만들어야 한다.
대기 상태가 되면 Producer 쓰레드가 동작한다. Producer 쓰레드는 상품을 공급해서 Product 클래스의 amount를 증가시킨다. 이때 bringProduct메소드 안에 notify() 메소드를 호출하기 때문에, 대기 상태이던 Customer 쓰레드가 다시 RUNNABLE이 되고, JVM에 의해 다시 스케줄링 된다.
만약 wait()과 notify()를 사용하지 않았다면, Product 클래스의 buyProduct와 bringProduct가 synchronized가 되어있기 때문에 Producer 쓰레드가 다 끝나고 나서 Customer 쓰레드가 실행된다. 무한루프를 걸어뒀기 때문에 계속 상품의 공급만 지속될 것이다. wait()과 notify()를 사용해서 이런 쓰레드의 동기화를 유동적으로 조정할 수 있다.
데드락(Deadlock)이란 어떤 한 객체를 공유하는 여러 쓰레드들이 서로 다른 쓰레드를 기다리느라 실행되지 못하고 교착 상태에 빠져있는 상황을 의미한다.
public class DeadlockExample {
private final Object A = new Object();
private final Object B = new Object();
public void lockAB() {
synchronized(A) {
synchronized(B) {
System.out.println("AB");
}
}
}
public void lockBA() {
synchronized(B) {
synchronized(A) {
System.out.println("BA");
}
}
}
}
위 코드처럼 코드가 짜일 경우, lockAB를 호출하는 스레드 1은 A 객체에 lock을 걸고, B 객체의 락을 확보하기 위해 대기한다. lockBA를 호출하는 스레드 2는 B 객체에 lock을 걸고, A 객체의 락을 확보하기 위해 대기한다. 한 스레드에서 객체에 lock이 걸리면 다른 스레드가 해당 객체를 참조할 수 없기 때문에, 두 스레드는 객체 A, B의 lock이 풀리기를 기다리며 영원히 대기하게 된다. 이 상황이 바로 데드락이다.
이렇게 데드락 상황이 발생하게 되면, 어플리케이션을 종료하는 것 외에는 해결할 방법이 없다. 때문에 synchronized 대신 Lock 클래스의 tryLock 메소드를 사용해서 lock의 시간을 제한하거나, 모든 스레드에 필요한 lock의 순서를 통일해서 데드락이 걸리지 않도록 코드를 짜야 한다.
참고자료
명품 JAVA Programming (황기태, 김효수 지음)
JAVA의 정석 (남궁성 지음)