- 스레드
운영체제는 두가지 이상의 작업을 동시에 처리하는 멀티 태스킹(multi tasking)을 할 수 있도록 CPU 및 메모리 자원을 프로세스마다 적절히 할당해주고, 병렬로 실행시킵니다. 예를 들어, 워드로 문서 작업을 하면서 동시에 윈도우 미디어 플레이어로 음악을 들을 수 있습니다.
멀티 태스킹은 꼭 멀티 프로세스(프로세스는 프로그램을 수행하는데 필요한 데이터와 메모리 등의 자원과 쓰레드로 구성되어 있으며 프로세스의 자원을 이용해서 실제로 작업을 수행하는것이 바로 쓰레드입니다.)를 뜻하는 것은 아닙니다. 한 프로세스 내에서 멀티 태스킹을 할 수 있도록 만들어진 애플리케이션도 있습니다. 대표적인 것이 미디어 플레이어와 메신저입니다. 미디어 플레이어는 동영상과 재생과 음악 재생이라는 두 가지 작업을 동시에 처리하고, 메신저는 채팅 기능을 제공하면서 동시에 파일 전송 기능을 수행하기도 합니다. 어떻게 하나의 프로세스가 두 가지 이상의 작업을 처리할 수 있을까요? 그 비밀은 멀티 스레드(multi thread)에 있습니다.
스레드(thread)는 사전적 의미로 한 가닥의 실이라는 뜻인데, 한 가지 작업을 실행하기 위해 순차적으로 실행할 코드를 실처럼 이어놓았다고 해서 유래된 이름입니다. 하나의 스레드는 하나의 코드 실행 흐름이기 때문에 한 프로세스 내에 스레드가 2개라면 2개의 코드 실행 흐름이 생긴다는 의미입니다.
멀티 프로세스는 운영체제에서 할당받은 자신의 메모리를 가지고 실행하기 때문에 각 프로세스는 서로 독립적입니다.
따라서 하나의 프로세스에서 오류가 발생해도 다른 프로세스에 영향을 미치지 않습니다. 하지만 멀티 스레드는 하나의 프로세스 내부에 생성되기 때문에 하나의 스레드가 예외를 발생시키면 프로세스 자체가 종료될 수 있어 다른 스레드에 영향을 미치게 됩니다.
예를 들어 멀티 프로세스인 원드와 엑셀을 동시에 사용하던 도중, 워드에 오류가 생겨 먹통이 되더라도 엑셀은 여전히 사용 가능합니다. 그러나 멀티 스레드로 동작하는 메신저의 경우 파일을 전송하는 스레드에서 예외가 발생하면 메신저 프로세스 자체가 종료 되므로 ㅊ채팅 스레드도 같이 종료됩니다. 그렇기 떄문에 멀티 스레드에서는 예외 처리에 만전을 기해야 합니다.
멀티 스레드는 다양한 곳에서 사용됩니다. 대용량 데이터의 처리 시간을 줄이기 위해 데이터를 분할해서 병렬로 처리하기도 하고, UI를 가지고 있는 애플리케이션에서 네트워크 통신을 하기 위해서 사용되기도 합니다. 또한 다수 클라이언트의 요청을 처리하는 서버를 개발할 때에도 사용합니다.
멀티 스레드는 애플리케이션을 개발하는 데 꼭 필요한 기능이기 때문에 반드시 이해하고 활용할 수 있도록 해야합니다.
- Thread 클래스와 Runnalbe 인터페이스
쓰레드를 구현하는 방법은 Thread 클래스를 상속받는 방법과 Runnable 인터페이스를 구현하는 방법 2가지가 있습니다.
둘 중 어느 쪽을 사용해도 별 차이는 없지만 Java에서는 다중 상속을 허용하지 않기 때문에, Thread 클래스를 상속받으면 다른 클래스를 상속받을 수 없어 Runnable 인터페이스를 구현하는 방법이 일반적입니다.
Runnable 인터페이스를 구현하는 방법은 재사용성이 높고 코드의 일관성을 유지할 수 있다는 장점이 있기 때문에 보다 객체 지향적인 방법이라 할 수 있습니다.
Runnable인터페이스는 run()메소드만 정의되어 있는 간단한 인터페이스입니다.
1. Thread클래스를 상속
public class MyThread extends Thread {
@Override
public void run() {
/* 작업내용 */ // Thread 클래스의 run()을 오버라이딩
}
}
2. Runnable 인터페이스를 구현
public class MyThread implements Runnable {
@Override
public void run() {
/* 작업내용 */ // Runnable 인터페이스의 추상메소드 run()을 구현
}
}
Thread 클래스와 Runnable 인터페이스 구현
- Thread 클래스 확장하기
첫 번째 방법으로 java.lang.Thread 클래스를 확장할 수 있습니다. Thread 클래스에는 상당히 많은 메소드가 있는데, 그 중에 run() 이라는 메소드만 오버라이딩 해주면 됩니다.
import java.util.Random;
public class MyThread extends Thread {
private static final Random random = new Random();
@Override
public void run() {
String threadName = Thread.currentThread().getName();
System.out.println("- " + threadName + " has been started");
int delay = 1000 + random.nextInt(4000);
try {
Thread.sleep(delay);
} catch(InterruptedException e) {
e.printStackTrace();
}
System.out.println("- " + threadName + " has been ended (" + delay + "ms)");
}
}
쓰레드마다 수행 시간을 다르게 해보고 싶어서 Thread.sleep() 메소드를 이용하여 1초 이상 6초 미만의 랜덤 딜레이를 주었습니다. 그리고 각 쓰레드의 사작과 종료 시점에 Thread.currentThread().getName() 메소드를 통해 쓰레드 이름이 출력되도록 하였습니다.
이 쓰레드 이름은 String을 인자로 받는 생성자를 통해 객체 생성 시점에 셋팅될 것입니다.
- Runnable 인터페이스 구현하기
Thread 확장 예제와 동일한 기능을 Runnable 인터페이스를 구현하여 작성합니다.클래스 뒤에 extends Thread에서 implements Runnable로 바뀐 것 빼고는 동일한 코드입니다.
import java.util.Random;
public class MyRunnable implements Runnable {
private static final Random random = new Random();
@Override
public void run() {
String threadName = Thread.currentThread().getName();
System.out.println("- " + threadName + " has been started");
int delay = 1000 + random.nextInt(4000);
try {
Thread.sleep(delay);
} catch(InterruptedException e) {
e.printStackTrace();
}
System.out.println("- " + threadName + " has been ended (" + delay + "ms)");
}
}
- 실행하기
위의 작성한 클래스의 쓰레드 실행 방법은 약간 다릅니다.
두 가지 클래스 모두 Thread 클래스의 start() 메소드를 통해 실행 시킬 수 있는데, Thread를 확장한 Mythread 클래스의 경우, 해당 객체에 start() 메소드를 직접 호출할 수 있습니다. 반면에 Runnable을 구현한 MyRunnable 클래스의 경우, Runnable 형 인자를 받는 생성자를 통해 Thread 객체를 생성 후 start() 메소드를 호출해야 합니다.
public class ThreadRunner {
public static void main(String[] args) {
// create thread objects
Thread thread1 = new MyThread();
thread1.setName("Thread #1");
Thread thread2 = new MyThread();
thread2.setName("Thread #2");
// create runnable objects
Runnable runnable1 = new MyRunnable();
Runnable runnable2 = new MyRunnable();
Thread thread3 = new MyThread();
thread3.setName("Thread #3");
Thread thread4 = new MyThread();
thread4.setName("Thread #4");
// start all threads
thread1.start();
thread2.start();
thread3.start();
thread4.start();
}
}
아래의 실행 결과를 보면 4개의 쓰레드가 순차적으로 실행되지 않고, 랜덤 딜레이 때문에 끝나는 시간도 재 각각인것을 알 수 있습니다.
그리고 매번 실행할 때 마다 딜레이가 달라지기 때문에, 실행 결과가 항상 동일하지 않습니다.
- Thread #4 has been started
- Thread #3 has been started
- Thread #1 has been started
- Thread #2 has been started
- Thread #1 has been ended (1027ms)
- Thread #4 has been ended (1027ms)
- Thread #3 has been ended (1027ms)
- Thread #2 has been ended (1027ms)
Process finished with exit code 0
- 쓰레드의 상태
쓰레드 객체를 생성하고 start() 메소드를 호출하면 곧바로 쓰레드가 실행되는 것처럼 보이지만 사실은 실행 대기 상태가 됩니다. 실행 대기 상태란 실행을 기다리고 있는 상태를 말합니다. 실행 대기 상태에 있는 쓰레드 중에서 운영체제는 하나의 쓰레드를 선택하고 CPU(코어)가 run() 메소드를 실행하도록 합니다. 이때를 실행(running)상태라고 합니다.
실행 상태의 쓰레드는 run() 메소드를 모두 실행하기 전에 다시 실행 대기 상태로 돌아갈 수 있습니다. 그리고 실행 대기 상태에 있는 다른 쓰레드가 선택되어 실행 상태가 됩니다.
이렇게 쓰레드는 실행 대기 상태와 실행 상태를 번갈아가면서 자신의 run() 메소드를 조금씩 실행합니다. 실행 상태에서 run() 메소드가 종료되면, 더 이상 실행할 코드가 없기 때문에 쓰레드의 실행은 멈추게 됩니다. 이 상태를 종료(terminated)상태라고 합니다.이 처럼 쓰레드는 실행 대기 상태와 실행 상태로 번갈아 변하면서, 경우에 따라서 실행 상태에서 일시정지 상태로 가기도 합니다. 일시 정지 상태는 쓰레드가 실행할수 없는 상태입니다. 일시 정지 상태에서는 바로 실행 상태로 돌아갈 수 없고, 일시 정지 상태에서 빠져나와 실행 대기 상태로 가야 합니다.
이 처럼 쓰레드도 상태를 제어해야 할 필요성이 있는데, 제어를 하기 전에 해당 쓰레드의 상태를 알아야 합니다.
그러기 위해서는 getState() 메소드를 사용하여 상태를 확인해야 합니다.
- 쓰레드의 상태는 크게 5가지가 있습니다
- NEW - 쓰레드가 생성된 상태, 아직 start() 되지 않았습니다.
- RUNNABLE - start()가 호출되어 실행대기, run()하면 RUNNING(CPU 점유)이 된다. Runnable Pool에 모여있다.
- WATING / TIMED_WAITING - 일시정지, 주어진 시간동안 기다림
- BLOCK - 일시정지, 사용하려는 객체의 Lock이 풀릴 때까지 대기
- TERMINATED - 실행마치고 종료, run()이 끝나면 TERMINATED 되면서 소멸
아래 그림은 쓰레드의 생성부터 소멸까지의 모든 과정을 그린 것입니다.
- 쓰레드를 생성하고 start()를 호출하면 바로 실행되는 것이 아니라 실행 대기열에 저장되어 자신의 차례가 될 때까지 기다려야 합니다.(실행 대기열은 큐(queue)와 같은 구조로 먼저 실행대기열에 들어온 쓰레드가 먼저 실행됩니다.)
- 자신의 차례가 되면 실행상태가 됩니다.
- 할당된 실행시간이 다되거나 yield() 메소드를 만나면 실행 대기상태가 되고 다음 쓰레드가 실행상태가 됩니다.
- 실행 중에 suspend(), sleep(), wait(), join(), I/O block에 위해 일시정지상태가 될 수 있습니다. (I/O block은 입출력 작업에서 발생하는 지연 상태를 말합니다. 사용자의 입력을 받는 경우를 예로 들 수 있습니다.)
- 지정된 일시정지시간이 다 되거나, notify(), resume(), interrupt()가 호출되면 일시정지상태를 벗어나 다시 실행 대기열에 저장되어 자신의 차례를 기다리게 됩니다.
- 실행을 모두 마치거나 stop()이 호출되면 쓰레드는 소멸됩니다.
- 쓰레드 상태 제어 메소드
- sleep(long miles) - 주어진 시간 동안 쓰레드를 일시 정지 상태로 만듭니다.(interrrupt()가 호출되면 실행 대기 상태로 만듭니다.) 주어진 시간이 지나면 자동적으로 실행 대기 상태가 됩니다.
- interrupt() - 일시 정지 상태의 쓰레드에서 InterruptedException을 발생시켜, 예외 처리 코드(catch)에서 실행 대기 상태로 가거나 종료 상태로 갈 수 있도록 합니다.
- yield() - 쓰레드 자신에게 주어진 시간을 다음 쓰레드에게 양보하는 메소드
- join(long miles)/(int nanos) - 동작중인 작업을 멈추고 다른 쓰레드가 지정 시간동안 작업 수행하도록 넘겨주는 메소드로 시간을 지정하지 않으면 다른 쓰레드가 작업을 마칠 때까지 기다립니다.
- stop() - 쓰레드를 즉시 종료합니다. 불안전한 종료를 유발하므로 사용하지 않는 것이 좋습니다.
아래의 쓰레드는 상태를 출력하는 StatePrintThread 클래스입니다.
public class SatePrintThread Extends Thread {
private Thread targetThread;
public StatePrintThread(Thread targetThread) {
this.targetThread = targetThread;
}
@Override
public void run() {
while(true) {
// Thread State get
Thread.State state = targetThread.getState();
System.out.println("Thread State : " + state);
// New State? => RUNNABLE
if (state == State.NEW) {
targetThread.start();
}
// TERMINATED? => end while
if (state == State.TERMINATED) break;
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
다음은 TargetThread 클래스입니다.
10억번 for문을 돌려 RUNNABLE 상태를 유지하고, Thread.sleep() 메소드를 호출해서 1.5초간 TIMED_WATING 상태를 유지하고, 다시 10억 번 for문을 돌려 RUNNABLE 상태를 유지합니다.
public class TargetThread extends Thread {
@Override
public void run() {
for(long i = 0; i < 1000000000; i++) {}
try {
Thread.sleep(1500);
} catch(InterruptedException e) {
e.printStackTrace();
}
}
}
TargetThread가 객체로 생성되면 NEW 상태를 가지고, run() 메소드가 종료되면 TERMINATED 상태가 되므로 다음과 같은 순서로 변합니다.
NEW -> RUNNABLE -> TIMED_WAITING -> RUNNABLE -> TERMINATED
public class ThreadStateExample {
public static void main(String[] args) {
StatePrintThread statePrintThread = new StatePrintThread(new TargetThread());
statePrintThread.start();
}
}
Thread State : NEW
Thread State : RUNNABLE
Thread State : TIMED_WAITING
Thread State : TIMED_WATING
Thread State : TIMED_WATING
Thread State : RUNNABLE
Thread State : TERMINATED
Process finished with exit code 0
- 쓰레드 우선순위
싱글 쓰레드 프로세스의 경우, 단 하나만 작업합니다.
멀티 쓰레드 프로세스의 경우, 같은 프로세스 내의 자원을 공유하면서 작업을 하므로 서로의 작업에 영향을 줍니다.
멀티쓰레드 프로세스는 동시성 또는 병렬성으로 실행됩니다.
- 동시성 : 하나의 코어에서 여러개의 프로세스가 번갈아 가면서 실행됩니다.
- 병렬성 : 멀티 코어에서 개별 쓰레드를 동시에 실행합니다.
싱글코어 CPU는 동시성으로 멀티 쓰레드를 진행합니다. 병렬적으로 보이는 이유는 속도가 빠르기 때문입니다.
쓰레드는 우선순위(priority)라는 속성(멤버변수)을 가지고 있는데, 이 우선순위의 값에 따라 쓰레드가 얻는 실행시간이 달라집니다.
- 쓰레드가 수행하는 작업의 중요도에 따라 쓰레드의 우선순위를 다르게 지정하여 특정 쓰레드가 더 많은 작업시간을 갖도록 할 수 있습니다.(우선 순위가 10인 쓰레드가 우선순위가 1인 쓰레드보다 10배 더 빨리 수행되는 것이 아니라 단지, 우선순위가 10인 쓰레드는 우선순위가 1인 쓰레드 보다 좀 더 많이 실행 큐에 포함되어, 좀 더 많은 작업 시간을 할당 받을 뿐입니다.)
예를 들어 파일전송기능이 있는 메신저의 경우, 파일 다운로드를 처리하는 쓰레드 보다 채팅내용을 전송하는 쓰레드의 우선순위가 더 높아야 사용자가 채팅을 하는데 불편함이 없을 것입니다.(대신 파일 전송에 걸리는 시간은 더 길어집니다.)
쓰레드의 우선순위를 지정하는 필드와 메소드
- public final static int MIN_PRIORITY = 1 (쓰레드가 가질 수 있는 우선 순위의 최소값입니다.)
- public final static int NORM_PRIORITY = 5 (쓰레드가 가지는 기본 우선 순위 값입니다.)
- public final static int MAX_PRIORITY = 10 (쓰레드가 가질 수 있는 우선 순위의 최대 값입니다.)
- setPriority(int newPriority) : 쓰레드의 우선순위를 지정하는 값으로 변경합니다.
- getPriority() : 쓰레드의 우선순위를 반환합니다.
쓰레드가 가질 수 있는 우선순위의 범위는 1 ~ 10이며 숫자가 높을수록 우선순위가 높습니다. 생성한 쓰레드의 우선순위는 쓰레드를 생성한 쓰레드로부터 상속받습니다.
main 메소드를 수행하는 쓰레드(Main 쓰레드)는 우선순위가 5이므로 main메소드 내에서 생성하는 쓰레드의 우선순위는 기본적으로 5가 됩니다.
아래 코드는 2개의 우선순위를 다르게 설정하는 예제입니다. setPriority() 메소드는 쓰레드를 시작하기 전에만 우선순위를 변경할 수 있습니다.
public class ThreadExample {
public static void main(String[] args) {
MyThread_1 th1 = new MyThread_1();
MyThread_1 th2 = new MyThread_1();
th1.setPriority(1);
th2.setPriority(1);
System.out.println("Priority of th(-) " : + th1.getPriority());
System.out.println("Priority of th(|) " : + th2.getPriority());
th1.start();
th2.start();
}
}
class MyThread_1 extends Thread {
@Override
public void run() {
for (int i = 0; i < 300; i++) {
System.out.print("-");
for (int i = 0; x < 2100000000; x++);
}
}
}
class MyThread_2 extends Thread {
@Override
public void run() {
for (int i = 0; i < 300; i++) {
System.out.print("|");
for (int i = 0; x < 2100000000; x++);
}
}
}
쓰레드의 수가 코어의 수보다 많을 경우, 쓰레드를 어떤 순서에 의해 동시성으로 실행할 것인가를 결정해야 하는데, 이것을 쓰레드 스케줄링이라고 합니다.
자바의 쓰레드 스케줄링은 우선순위 방식과 순환 할당 방식을 사용합니다.
- 우선순위 방식
우선순위가 높은 쓰레드가 실행 상태를 더 많이 가지도록 스케줄링하는 것을 말합니다.
- 순환 할당 방식
순환 할당 방식은 시간 할당량(Time Slice)을 정해서 하나의 쓰레드를 정해진 시간만큼 실행하고 다시 다른 쓰레드를 실행하는 방식을 말합니다.
쓰레드 우선순위 방식은 쓰레드 객체에 우선 순위 부여를 부여할 수 있기 때문에 개발자가 코드로 제어할 수 있습니다. 하지만 순환 할당방식은 JVM에 의해서 정해지기 때문에 코드로 제어할 수 없습니다.
우선 순위 방식에서 우선순위는 1에서 10까지 부여되는데, 1이 가장 우선순위가 낮고, 10이 가장 높습니다.
우선 순위를 부여하지 않으면 모든 쓰레드들은 기본적으로 5의 우선순위를 할당 받습니다.
만약 우선 순위를 변경하고 싶다면 Thread 클래스가 제공하는 setPriority() 메소드를 이용하면 됩니다.
thread.setPriority(우선순위)
순위가 높은 쓰레드가 실행 기회를 더 많이 가지기 때문에 우선순위가 낮은 쓰레드보다 계산 작업을 빨리 끝냅니다. 쿼드 코어일 경우에는 4개의 쓰렐닥 병렬성으로 실행될 수 있기 때문에 4개 이하의 쓰레드를 실행할 경우에는 우선순위 방식이 크게 영향을 미치지 못합니다. (최소 5개 이상의 쓰레드가 실행되어야 우선순위의 영향을 받습니다.)
- Main 쓰레드
자바 애플리케이션은 기본적으로 하나의 메인쓰레드를 가집니다. Java 프로그램을 실행하기 위해 Main Thread는 main() 메소드를 실행합니다. main() 메소드는 메인 쓰레드의 시작점을 선언하는 것입니다.
메인 쓰레드는 자바에서 처음으로 실행되는 쓰레드이자 모든 쓰레드는 메인 쓰레드로부터 생성됩니다.
메인 쓰레드가 종료되더라도 생성된 쓰레드가 실행 중이라면 모든 쓰레드가 작업을 완료하고 종료될때 까지 프로그램은 종료되지 않습니다. 즉, 실행 중인 사용자가 하나도 없다면 프로그램은 종료됩니다.
public cclass ThreadExample {
public static void main(String[] args) {
Thread main = Thread.currentThread();
System.out.println(main);
System.out.println(main.getName);
}
}
[===== main 메소드를 실행하고 현재의 쓰레드를 출력하는 코드 ====]
메인 쓰레드는 필요에 따라 작업 쓰레드들을 만들어서 병렬로 코드를 실행할 수 있습니다. 즉, 멀티 쓰레드를 생생해서 멀티 태스킹을 수행합니다.
다음 그림에서 오른쪽 멀티 쓰레드 애플리케이션을 보면 메인 쓰레드가 작업 쓰레드1을 생성하고 실행한 다음, 곧이어 작업 쓰레드2를 생성하고 실행합니다.
싱글 쓰레드 애플리케이션에서는 메인 쓰레드가 종료하면 프로세스도 종료됩니다. 하지만 멀티 쓰레드 애플리케이션에서는 실행 중인 쓰레드가 하나라도 있다면, 프로세스는 종료되지 않습니다. 멩니 쓰레드가 작업 쓰레드보다 먼저 종료되더라도 작업 쓰레드가 계속 실행 중이라면 프로세스는 종료되지 않습니다.
class App {
public static void main(String[] args) {
Thread th1 = new ThreadOne();
th1.start();
}
class ThreadOne extends Thread {
@Override
public void run() {
throwException();
}
public void throwExcception() {
try {
throw new Exception();
} catch(Exception e) {
e.printStackTrace();
}
}
}
}
결과를 보면 알 수 있듯이 호출 스택의 첫 번째 메소드가 main 메서드가 아니라 run 메소드입니다.
한 쓸드가 예외를 발생해서 종료되어도 다른 쓰레드 실행에는 영향을 미치지 않습니다.
class App {
public static void main(String[] args) {
Thread th1 = new ThreadOne();
th1.run();
}
class ThreadOne extends Thread {
@Override
public void run() {
throwException();
}
public void throwExcception() {
try {
throw new Exception();
} catch(Exception e) {
e.printStackTrace();
}
}
}
}
이전 예와 달리 run()을 사용하여 새로운 쓰레드가 생성되지 않았습니다.
- 동기화
멀티쓰레드로 동작하는 프로세스의 경우 여러 쓰레드가 같은 프로세스 내의 자원을 공유해서 작업하기 때문에 서로의 작업에 영향을 주게 됩니다.
만일 쓰레드 A가 작업하던 도중 다른 쓰레드 B에게 제어권이 넘어갔을 때, 쓰레드 A가 작업하던 공유 데이터를 쓰레드B가 임의로 변경하였다면, 다시 쓰레드A가 제어권을 받아서 나머지 작업을 마쳤을 때 의도하지 않았던 결과를 얻을 수 있습니다.
이러한 일을 방지하기 위해 한 쓰레드가 특정 작업을 끝마치기 전에 다른 쓰레드에 의해 방해받지 않도록 하는 것이 필요합니다. 그래서 도입된 개념이 바로 '임계 영역(Critical Section)' 과 '잠금(락, lock)' 입니다.
공유 데이터를 사용하는 코드 영역을 임계 영역으로 지정해두고, 공유 데이터가 가지고 있는 lock을 획득한 단 하나의 쓰레드만 이 영역 내의 코드를 수행할 수 있게 합니다.쓰레드가 임계영역 내의 모든 코드를 수행하고 벗어나서 lock을 반납해야만, 다른 쓰레드가 반납된 lock을 획득하여 임계영역의 코드를 수행할 수 있게 됩니다.
한 쓰레드가 진행 중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는 것을 "쓰레드 동기화(synchronization)"이라 합니다.
Java에서는 synchronized 블럭을 이용해 쓰레드 동기화를 지원했지만, JDK 1.5부터는 "java.util.concurrent.locks"와 "java.util.concurrent.atomic" 패키지를통해 다양한 방식으로 동기화를 구현할 수 있도록 지원합니다.
synchronized를 이용한 동기화
- 메서드 전체를 임계영역으로 지정
- 메소드 전체가 임계영역으로 설정됩니다.
- 쓰레드는 synchronized 메소드가 호출된 시점부터 lock을 얻어 수행하다가 종료되면 lock을 반환합니다.
- 특정한 영역을 임계영역으로 지정
- 메소드 내의 일부를 블럭으로 감싸고 synchronized(참조변수)를 붙이는 방식입니다.
- 참조변수는 lock을 걸고자하는 객체를 참조하는 것이여야 합니다.
- 이또한, 블럭을 진입하면서 lock을 얻게되고 벗어나면 lock를 반환합니다.
메소드를 synchronized로 임계영역을 잡아 테스트 해보자
두 방법 모두 lock의 획득과 반납이 모두 자동적으로 이루어지므로 우리가 해야할 일은 임계영역만 설정해주면 됩니다.
class App {
public static void main(String[] args) {
Runnable r = new RunnableEx();
new Thread(r).start();
new Thread(r).start();
new Thread(r).start();
}
}
class Acccount {
private int balance = 1000;
public int getBalance() {
return balance;
}
public void withdraw(int money) {
if (money <= balance) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
balance -= money;
}
}
}
class Runnable Ex implements Runnable {
Account acc = new Account();
public void run() {
while (acc.getBalance() > 0) {
// 100, 200, 300 중 임의의 한 값으로 출금(withdraw)
int money = (int)(Math.random() * 3 + 1) * 100;
acc.withdraw(money);
System.out.println("balance : " + acc.getBalance());
System.out.println("출금되었습니다.")
}
}
}
은행계좌에서 잔고를 확인하고 임의의 금액을 출금하는 예제입니다. 코드 중 withdraw부분을 살펴보면 잔고가 출금하려는 금액보다 큰 경우에만 출금하도록 되어 있는 것을 확인할 수 있습니다.
public void withdraw(int money) {
if (balance >= money) {
try {
Thread.sleep(1000);
} catch (InterruptedExcception e) {
balance -= money;
}
}
}
그러나 실행결과를 보면 잔고가 음수 값으로 되어있는데, 그 이유는 한 쓰레드가 if문의 조건식을 통과하고 출금하기 바로 직전에 다른 쓰레드가 끼어들어서 출금을 먼저 했기 때문이니다.
이러한 상황을 막기 위해서 synchronized를 사용하는 것입니다.
아래의 코드는 synchronized를 사용하여 수정한 코드입니다.
class App {
public static void main(String[] args) {
Runnable r = new RunnableEx();
new Thread(r).start();
new Thread(r).start();
new Thread(r).start();
}
}
class Acccount {
private int balance = 1000;
public int getBalance() {
return balance;
}
public synchronized void withdraw(int money) {
if (money <= balance) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
}
balance -= money;
}
}
}
class Runnable Ex implements Runnable {
Account acc = new Account();
public void run() {
while (acc.getBalance() > 0) {
// 100, 200, 300 중 임의의 한 값으로 출금(withdraw)
int money = (int)(Math.random() * 3 + 1) * 100;
acc.withdraw(money);
System.out.println("balance : " + acc.getBalance());
System.out.println("출금되었습니다.")
}
}
}
결과 값에 음수가 사라진 것을 확인할 수 있습니다. 여기서 한 가지 주의할 점은 Account클래스의 인스턴스 변수인 balance의 접근 제어자가 private이라는 것입니다. 만일 private이 아니면, 외부에서 직접 접근할 수 있기 때문에 동기화가 무의미해집니다. synchronized를 이용한 동기화는 지정된 영역의 코드를 한번에 하나의 쓰레드가 수행하는 것을 보장하는 것일 뿐이기 때문입니다.
wait()와 notify()
동기화를 이용해서 공유 데이터를 보호하는 것은 좋은데, 특정 쓰레드가 락을 보유한 채로 상황이 해결될 때까지 오랜 시간을 보내게 된다면, 다른 작업들이 원활히 진행되지 않을 것입니다.
이러한 상황을 개선하기 위해 고안된 것이 바로 wait()와 notify() 이다. 동기화된 임계 영역의 코드를 수행하다가 작업을 더 이상 진행할 상황이 아니면, 일단 wait()를 호출하여 쓰레드가 락을 반납하고 기다리게 합니다. 그러면 다른 쓰레드가 락을 얻어 해당 객체에 대한 작업을 수행할 수 있게 됩니다. 나중에 작업을 진행할 수 있는 상황이 되면 notify()를 호출해서, 작업을 중단했떤 쓰레드가 다시 락을 얻어 작ㅇ버을 진행할 수 있게 합니다.
lock니 넘어간다고 해서 무조건 오래 기다리던 쓰레드가 받게 된다는 보장은 없습니다. wait을 호출하면 작업을 하던 쓰레드는 해당 객체의 대기실(waiting pool)로 이동하여 연락을 기다립니다. notify()가 호출되면, 해당 객체의 대기실에 있던 모든 쓰레드 중 임의의 쓰레드만 연락의 받게되고, notifyAll()이 호출되면 연락은 모든 쓰레드가 받지만 래던하게 선택된 하나의 쓰레드가 lock을 받게 됩니다.
waith()와 notify()는 특정 객체에 대한 것이므로 Object클래스에 정의되어 있습니다.
void wait()
void wait(long timeout)
void wait(long timeout, int nanos)
void notify()
void notifyAll()
waith()는 notify() 또는 notifyAll()이 호출될 때까지 기다리지만, 매개변수가 있는 wait()는 지정된 시간동안만 기다립니다.
waiting pool은 객체마다 존재하는 것이므로 notifyAll()이 호출된다고 해서 모든 객체의 wait pool에 있는 쓰레드가 깨워지는 것은 아닙니다.
notifyAll()이 호출된 객체의 waiting pool이 대기 중인 쓰레드만 해당됩니다.
wait(), notify(), notifyAll()
- Object에 정의되어 있습니다.
- 동기화 블럭(synchronized 블록) 내에서만 사용할 수 있습니다.
- 보다 효율적인 동기화를 가능하게 합니다.
기아 현상과 경쟁 상태
정말 지동하게 운이 나빠서 쓰레드가 연락을 받지 못하고 오랫동안 기다리게 되는데, 이것은 기아(starvation) 현상이라고 합니다. 이 현상을 막으려면, notify() 대신 notifyAll()을 사용해야 합니다.
notifyAll()을 원하는 쓰레드의 기아현상은 막았지만, 다른 쓰레드까지 연락을 받아서 불피요하게 lock을 얻기 위해 경쟁하게 됩니다. 이처럼 여러 쓰레드가 lock을 얻기 위해 서로 경쟁하는 것을 경쟁 상태(race condition)라고 하는데, 이것을 개선하기 위해서는 구별해서 연락하는 것이 필요합니다.
- 데드락
교착상태(데드락, deadlock)은 두 개 이상의 작업이 서로 상대방의 작업이 끝나기를 기다리고 있어서 아무것도 완료되지 못하는 상태를 말합니다.
교착생태의 조건
- 상호배제(Mutual exclusion) : 프로세스들이 필요로 하는 자원에 대해 배타적인 통제권을 요구합니다.
- 점유대기(Hold and wait) : 프로세스가 할당된 자원을 가진 상태에서 다른 자원을 기다립니다.
- 비선점(No preemption) : 프로세스가 어떤 자원의 사용을 끝날 때까지 그 자원을 뺏을 수 없습니다.
- 순환대기(Circular wait) : 각 프로세스는 순환적응로 다음 프로세스가 요구하는 자원을 가지고 있습니다.
위 조건 중에서 한 가지만이라도 만족하지 않으면 교착생태는 발생하지 않습니다. 이 중 순환대기 조건은 점유대기 조건과 비선점 조건을 만족해야 성립하는 조건이므로, 위 4가지 조건은 서로 완전히 독립적인 것은 아닙니다.
교착상태는 예방, 회피, 무시 세 가지 방법으로 관리할 수 있습니다.
예방
- 상호배제의 조건의 제거
- 교착 상태는 두 개이상의 프로세스가 공유가능한 자원을 사용할 때 발생하는 것이므로 공유 불가능한, 즉 상호 배제 조건을 제거하면 교착 상태를 해결할 수 있습니다.
- 점유와 대기 조건의 제거
- 한 프로세스에 수행되기 전에 모든 자원을 할당시키고 나서 점유하지 않을 때에는 다른 프로세스가 자원을 요구하도록 하는 방법입니다. 자원 과다 사용으로 인한 효율성, 프로세스가 요구하는 자원을 파악하는 데에 대한 비용, 자원에 대한 내용을 저장 및 복원하기 위한 비용, 기아 상태, 무한대기 등의 문제점이 있습니다.
- 비선점 조건의 제거
- 비선점 프로세스에 대한 선점 가능한 프로토콜을 만들어 줍니다.
- 환형 대기 조건의 제거
- 자원 유형에 따라 순서를 매깁니다.
이 해결 방법들은 자원 사용의 효율성이 떨어지고 비용이 많이 드는 문제점이 있습니다.
회피
자원이 어떻게 요청될지에 대한 추가정보를 제공하도록 요구한느 것으로 시스템에 circular wait가 발생하지 않도록 자원 할당 상태를 검사합니다.
교착 상태의 회피 알고리즘은 크게 두가지가 있습니다.
- 자원 할당 그래프 알고리즘(Resource Alloccation Graph Algorithm)
- 은행원 알고리즘(Banker's algorithm)
무시
예방과 회피방법을 활용하면 성능 상 이슈가 발생하는데, 데드락 발생에 대한 상황을 고려한느 것에 대한 비용이 낮다면 별다른 조치를 하지 않을 수 있다고 합니다.
다음 코드는 오라클에서 제공하는 데드락의 예제입니다.
public class Deadlock {
static class Friend {
private final String name;
public Friend(String name) {
this.name = name;
}
public synchronized void bow(Friend bower) {
System.out.format("%s: %s" + "has bowed to me!\n", this.name, bower.getName());
bower.bowBack(this);
}
public synchronized void bowBack(Friend bower) {
System.out.println("%s: %s" + " has bowed back to me!\n", this.name,, bower.getName());
}
}
pubic static void main(String[] args) {
final Friend alphonse = new Friend("Alphonse");
final Friend alphonse = new Friend("Gaston");
new Thread(new Runnable() {
public void run() {
alphonse.bow(gaston);
}
}).start();
new Thread(new Runnable() {
public void run() {
public void run() {
gston.bow(alphonse);
}
}
}).start();
}
}
[참조]
혼자 공부하는 자바
github.com/sungpillhong/whiteshipstudy/blob/master/10%EC%A3%BC%EC%B0%A8.md
github.com/sungpillhong/whiteshipstudy/blob/master/10%EC%A3%BC%EC%B0%A8.md
'JAVA' 카테고리의 다른 글
어노테이션 (0) | 2021.02.03 |
---|---|
Enum (0) | 2021.01.25 |
자바의 예외처리 (0) | 2021.01.16 |
StringBuffer, StringBuilder 가 String 보다 성능이 좋은 이유와 원리 (0) | 2021.01.03 |
어노테이션(Annotation) (0) | 2020.04.05 |