목표
자바의 멀티쓰레드 프로그래밍에 대해 학습하세요.
학습할 것 (필수)
- Thread 클래스와 Runnable 인터페이스
- 쓰레드의 상태
- 쓰레드의 우선순위
- Main 쓰레드
- 동기화
- 데드락
1. Thread 클래스와 Runnable 인터페이스
프로세스와 스레드
Process
- 실행 중인 프로그램, OS로부터 메모리를 할당 받음
- 프로세스 간에는 각 프로세스의 데이터 접근이 불가
Thread
- 실제 프로그램이 수행되는 작업의 최소 단위, 하나의 프로세스는 하나 이상의 Thread를 가지게 됨
- 스레드들은 동시에 실행 가능
- 프로세스 안에 있으므로, 프로세스의 데이터를 모두 접근 가능
스레드 장점
- CPU 활용도를 높이고,
- 성능 개선 가능
- 응답성 향상
- 자원 공유 효율 (IPC를 안 써도 됨)
스레드 단점
- 하나의 스레드 문제가, 프로세스 전반에 영향을 미침
- 여러 스레드 생성 시 성능 저하 가능
Thread을 구현하는 방법에는 2가지가 있다.
1. Thread클래스를 상속받는 방법
-> 자식 클래스는 run 메소드를 재정의 해야 한다.
2. Runnable인터페이스를 구현하는 방법
-> 해당 클래스는 run() 메소드를 구현해야 한다.
어느 방법을 선택해도 별 차이는 없지만 Thread클래스를 상속받으면 다른 클래스를 상속받을 수 없기 때문에, Runnable인터페이스를 구현하는 방법이 일반적입니다.
Runnable인터페이스를 구현하는 방법은 재사용성(resuability)이 높고 코드의 일관성(consistency)을 유지할 수 있기 때문에보다 객체지향적인 방법이라 할 수 있습니다.
class ThreadEx extends Thread {
public void run() {
}
}
class ThreadEx implements Runnable {
public void run() {
}
}
Runnable인터페이스는 오직 run()만 정의되어 있는 함수형 인터페이스이다.
람다를 이용해서도 표현 가능합니다.
Thread thread = new Thread(() -> System.out.println("world : " + Thread.currentThread().getName()));
thread.start();
System.out.println("hello : " + Thread.currentThread().getName());
스레드 예제)
package choi.hyang.study.chapter10;
public class ThreadEx {
public static void main(String[] args) {
ThreadExtended thread1 = new ThreadExtended();
Thread thread2 = new Thread(new ThreadImple());
thread1.run();
thread2.run();
}
}
class ThreadExtended extends Thread {
@Override
public void run() {
for (int i=0; i<10; i++) {
System.out.println(getName());
}
}
}
class ThreadImple implements Runnable {
@Override
public void run() {
for (int i=0; i<10; i++) {
System.out.println(Thread.currentThread().getName());
}
}
}
실행결과
- static Thread currentThread() : 현재 실행 중인 스레드의 참조를 반환한다.
- String getName() : 쓰레드의 이름을 반환한다.
위의 예제에서도 알 수 있듯이 쓰레드의 이름을 지정하지 않으면 "Thread-번호"의 형식으로 이름이 정해진다.
Thread에 무슨 메소드가 있을까
start()와 run()
main메소드에서 run()을 호출하는 것은 생성된 쓰레드를 실행시키는 것이 아니라 단순히 클래스에 선언된 메서드를 호출하는 것일 뿐입니다.
반면 start()는 새로운 쓰레드가 작업을 실행하는데 필요한 호출스택(call stack)을 생성한 다음에 run()을 호출해서, 생성된 호출 스택에 run()이 첫 번째로 올라가게 합니다.
run()은 싱글 쓰레드만 실행되며 멀티 스레드를 사용하는 경우에는 run()이 아닌 start를 사용해야 한다.
sleep()
static void sleep(long millis)
static void sleep(long millis, int nanos)
현재 실행중인 쓰레드를 잠시 일시 정시 킨다.
다른 쓰레드가 처리할 수 있도록 기회를 주지만 그렇다고 락을 놔주진 않는다
public class ThreadEx {
public static void main(String[] args) {
ThreadExtended thread1 = new ThreadExtended();
Thread thread2 = new Thread(new ThreadImple());
thread1.run();
thread2.run();
}
}
class ThreadExtended extends Thread {
@Override
public void run() {
System.out.println("시작1");
for (int i=0; i<10; i++) {
System.out.println(getName());
}
}
}
class ThreadImple implements Runnable {
@Override
public void run() {
System.out.println("시작2");
for (int i=0; i<10; i++) {
try {
Thread.sleep(1000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName());
}
}
}
interrupt()
void interrupt()
sleep()이나 join()에 의해 일시 정지 상태인 쓰레드를 깨워서 실행 대기상태로 만든다.
쓰레드를 깨워서 interruptedExeption을 발생시킨다. 그 에러가 발생했을 때 할 일은 코딩하기 나름이다. 종료시킬 수도 있고 계속하던 일 할 수도 있고
join()
void join()
void join(long millis)
void join(long millis, int nanos)
다른 쓰레드가 끝날 때까지 기다린다.
다른 thread의 결과를 보고 진행해야 하는 일이 있는 경우 join() 메소드를 활용한다.
join메소드를 호출한 thread가 non-runnable 상태가 된다.
yield()
static void yield()
실행 중에 자신에게 주어진 실행시간을 다른 쓰레드에게 양보하고 자신은 실행 대기상태가 된다.
callable과 Future
callable
- Runnable은 리턴타입이 void
- Callable은 Runnable과 같지만 차이가 있다면 리턴할 수 있다는 것
Future
- 비동기적인 작업의 현재 상태를 조회하거나 결과를 가져올 수 있다.
- 작업 상태 확인하기 isDone() : 완료했으면 true 아니면 false를 리턴한다.
- 결과를 가져오기 get() : 블록킹 콜이다.
- 작업 취소하기 cancel() : 취소했으면 true 못했으면 false를 리턴한다.
package choi.hyang.java8to11.chapter16;
import java.util.concurrent.*;
public class App {
public static void main(String[] args) throws ExecutionException, InterruptedException {
ExecutorService executorService = Executors.newSingleThreadExecutor();
Callable<String> hello = () -> {
Thread.sleep(2000L);
return "Hello";
};
Future<String> HelloFuture = executorService.submit(hello);
// 취소시킴
// HelloFuture.cancel(true);
System.out.println(HelloFuture.isDone()); // 상태 출력
System.out.println("Started!!");
HelloFuture.get(); // 블로킹 콜 - 결과 값을 가져올떄까지 기다린다.
System.out.println(HelloFuture.isDone());
System.out.println("End!!");
executorService.shutdown(); // 지금처리하던 작업까지 마치고 show down
}
}
2. 쓰레드의 상태
- 쓰레드를 생성하고 바로 start()를 호출하면 바로 실행되는 것이 아니라 실행 대기열에 저장되어 자신의 차례가 될 때까지 기다려야 한다. 실행 대기열(Runnable, 그림은 큐로 그리진 않았다..)은 큐(queue)와 같은 구조로 먼저 실행대기열에 들어온 쓰레드가 먼저 실행된다.
- 실행 대기 상태에 있다가 자신의 차례가 되면 실행상태가 된다.
- 주어진 실행시간이 다되거나 yield()를 만나면 다시 실행 대기상태가 되고 다음 차례의 쓰레드가 실행상태가 된다.
- 실행 중에는 sleep(), wait(), join()에 의해 일시 정지가 될 수 있다.
- 일시 정지시간이 다되거나 notify(), interrupt() 등이 호출되면 다시 실행 대기열에 저장되어 자신의 차례를 기다린다.
- 실행을 모두 마치거나 stop()이 호출되면 쓰레드는 소멸된다.
3. 쓰레드의 우선순위
2개 이상의 쓰레드에서 각 쓰레드에대해 우선순위를 부여하여 우선순위가 높은 쓰레드가 먼저 실행되도록 하는것을 말합니다.
작업의 중요도에 따라 우선순위를 다르게 지정하여 특정 쓰레드가 더 많은 작업을 갖도록 할 수 있습니다.
범위는 1~10이며 숫자가 높을수록 우선순위가 높다.
void setPriority(int newPriority) : 쓰레드의 우선순위를 지정한 값으로 변경
int getPriority() : 쓰레드의 우선순위를 반환한다.
public static final int MAX_PRIORITY = 10 // 최대우선순위
public static final int MIN_PRIORITY = 1 // 최소우선순위
public static final int NORM_PRIORITY = 5 // 보통우선순위
package choi.hyang.study.chapter10;
public class ThreadPriority {
public static void main(String[] args) {
ThreadPrior1 threadPrior1 = new ThreadPrior1();
ThreadPrior2 threadPrior2 = new ThreadPrior2();
threadPrior2.setPriority(7);
System.out.println("쓰레드1 우선순위는 " + threadPrior1.getPriority());
System.out.println("쓰레드2 우선순위는 " + threadPrior2.getPriority());
threadPrior1.start();
threadPrior2.start();
}
}
class ThreadPrior1 extends Thread {
@Override
public void run() {
for(int i=0; i < 200; i++) {
System.out.print("--");
}
}
}
class ThreadPrior2 extends Thread {
@Override
public void run() {
for(int i=0; i < 200; i++) {
System.out.print("||");
}
}
}
실행결과
쓰레드1 우선순위는 5
쓰레드2 우선순위는 7
------------------------------------------------------------------------------------------------
------------------------------------------------------||||||||||||||||--------------------------
------------------------------------------------------------------------------------------------
------------------||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
------------------------------------------------------------||||||||||||||||||||||||||||||||||||
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||--------------
------------------------------------||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
||||||||||||||||||||||||||||||||
우선순위가 높음에도 스레드 1이 먼저 실행되고 1이 먼저 종료되었다
쓰레드에 높은 우선순위를 부여한다고 무조건 먼저 실행된다고 생각하면 안된다. 그저 우선순위를 주면 더 많은 실행시간과 실행기회를 갖게 될 것이라고 예상만 할 수 있는 것이다.
그리고 어떤 core의 CPU냐 어떠한 OS이냐에 따라서 스케줄링 방식이 또 다르므로 다른 결과가 나올 수 있다.
4. Main 쓰레드
main메서드의 작업을 수행하는 것도 쓰레드이며, 이를 main쓰레드라고 한다.
지금까지 우리도 모르는 사이에 이미 쓰레드를 사용하고 있었다. 프로그램이 실행되기 위해서는 작업을 수행하는 일꾼이 최소한 하나는 필요하므로, 기본적으로 하나의 쓰레드(일꾼)을 생성하고 그 쓰레드가 main메서드를 호출해서 작업이 수행되도록 하는 것이다.
지금까지는 main메서드가 수행을 마치면 프로그램이 종료되었으나 혹여 main메서드가 수행을 마쳤더라도 다른 쓰레드가 아직 작업을 마치지 않은 상태라면 프로그램이 종료되지 않는다.
데몬 쓰레드(Demon Thread)
- 일반 쓰레드의 작업을 보조하는 역할을 수행하는 쓰레드를 말한다.
- 일반 쓰레드가 모두 종료되면 데몬 쓰레드는 강제로 자동종료되는데, 그 이유는 데몬 쓰레드는 일반 쓰레드의 보조역할을 수행하기 때문이다.
boolean isDaemon() : 쓰레드가 데몬 쓰레드인지 확인한다.(데몬 쓰레드이면 true)
void setDaemon(boolean on) : 쓰레드를 데몬 쓰레드로 or 사용자 쓰레드로 변경한다. (매개변수 on의 값을 true로 지정하면 데몬 쓰레드가 된다.)
5. 동기화
동기화
동기화란 한 쓰레드가 진행 중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는 것을 말합니다.
임계 영역에 여러 thread가 접근하는 경우 한 thread가 수행하는 동안 공유 자원을 lock 해서 다른 thread 접근을 막는다. (lock 획득한 스레드만 실행된다.)
동기화를 잘못 구현하면 deadlock에 빠질 수 있음
임계영역(critical section)
두 개 이상의 thread가 동시에 접근하게 되는 리소스(데이터)를 말한다.
synchronized를 이용한 동기화
1. 메서드 전체를 임계 영역으로 지정
public synchronized void run() {
}
2. 특정한 영역을 임계 영역으로 지정
synchronized (객체 참조변수) {
}
첫 번째 방법은 메서드 앞에 synchronized를 붙이는 것인데, synchronized를 붙이면 메서드 전체가 임계 영역으로 설정된다.
쓰레드는 synchronized메서드가 호출된 시점부터 해당 메서드가 포함된 객체의 Lock을 얻어 작업을 수행하다가 메서드가 종료되면 lock을 반환한다.
두 방법 모두 lock의 획득과 반납이 모두 자동적으로 이루어지므로 우리가 해야 할 일은 그저 임계 영역만 설정해주는 것 뿐이다.
동기화 예제 - money와 balance
package choi.hyang.study.chapter10;
public class SynEx {
public static void main(String[] args) {
Runnable r = new RunnableEx();
new Thread(r).start();
new Thread(r).start();
}
}
class Account {
private int balance =1000; // private으로 해야 동기화가 의미가 있다.
public int getBalance() {
return balance;
}
public synchronized void withdraw(int money) {
if(balance >= money) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
balance -= money;
}
}
}
class RunnableEx implements Runnable {
Account acc = new Account();
@Override
public void run() {
while(acc.getBalance() > 0) {
int money = (int)(Math.random() *3 +1) * 100;
acc.withdraw(money);
System.out.println("balance :" + acc.getBalance());
}
}
}
6. 데드락(Dead Lock, 교착상태)
데드락이란??
두개 이상의 thread가 하나의 리소스를 사용할때 발생할 수 있다.
아래의 상황이 있다고 가정하자
- 프로세스 1이 리소스 A를 얻고 리소스B를 기다리는 상황
- 프로세스 2가 리소스B를 얻고 리소스A를 기다리는 상황
원하는 리소스가 상대방에게 할당되어 있으므로 프로세스 1,2는 무한정 대기상태에 빠지게 되는데 이러한 상황을 DeadLock상태라고 한다.
데드락 발생조건
1) 상호 배제 (Mutual exclusion)
프로세스들이 필요로 하는 자원에 대해 배타적인 통제권을 요구한다.
2) 점유 대기 (Hold and wait)
프로세스가 할당된 자원을 가진 상태에서 다른 자원을 기다린다.
3) 비선점 (No preemption)
프로세스가 어떤 자원의 사용을 끝낼 때까지 그 자원을 뺏을 수 없다.
4) 순환 대기 (Circular wait)
각 프로세스는 순환적으로 다음 프로세스가 요구하는 자원을 가지고 있다.
교착상태 예방(deadlock prevention)
4가지 조건 중 하나를 제거하는 방법
교착상태 회피(deadlock avoidance)
교착상태 조건 1, 2, 3은 놔두고, 4번만 제거
기아상태(starvation)
특정 프로세스의 우선순위가 낮아서 원하는 자원을 계속 할당받지 못하는 상태
기아상태 해결 방안
- 프로세스 우선순위를 수시로 변경해서, 각 프로세스가 높은 우선순위를 가질 기회 주기
- 오래 기다린 프로세스의 우선순위를 높여주기
- 우선순위가 아닌, 요청 순서대로 처리하는 FIFO 기반 요청 큐 사용
wait()와 notify(), notifyAll()
synchronized로 동기화를 해서 공유 데이터를 보호하는 것까지는 좋은데, 특정 쓰레드가 객체의 락을 가진 상태로 오랜 시간을 보내지 않도록 하는 것도 중요하다.
다른 쓰레드들이 어떤 객체의 락을 기다리느라 다른 작업들도 원할히 진행되지 않을 수 있기 때문이다.
wait() (=락을 반납) : 리소스가 더 이상 유효하지 않은 경우 리소스가 사용 가능할 때까지 thread를 non-runnable 상태로 전환한다.
wait() 상태가 된 thread은 notify()가 호출될 때까지 기다린다.
notify() (=락을 다시얻음) : wait()하고 있는 thread 중 한 thread를 runnable 한 상태로 꺠운다.
wait()에 의해서 lock을 반납했다가, 다시 lock을 얻어서 임계영역에 들어오는 것을 재진입(reentrance)라고 한다.
notifyAll() : wait()하고 있는 모든 thread가 runnable 한 상태가 되도록 한다. notify()보다 notifyAll()을 사용하기를 권장한다.
특정 thread가 통지를 받도록 제어할 수 없으므로 모두 깨운 후 scheduler에 cpu를 점유하는 것이 좀 더 공평하다
volatile 키워드
멀티코어 프로세서에는 코어마다 별도의 캐시를 가지고 있다. 코어에는 메모리에서 읽어온 값을 캐시에 저장하고 캐시에서 값을 읽어서 작업한다. 다시 같은 값을 읽어올 때는 먼저 캐시에 있는지 확인하고 없을 때만 메모리에서 읽어온다.
그러다 보니 도중에 메모리에 저장된 변수의 값이 변경되었는데도 캐시에 저장된 값이 갱신되지 않아서 메모리에 저장된 값이 다른 경우가 발생한다.
이러한 경우 변수 앞에 volatile을 붙이면, 코어가 변수의 값을 읽어올 때 캐시가 아닌 메모리에서 읽어오기 때문에 캐시와 메모리 간의 불일치를 해결할 수 있다.
변수에 volatile을 붙이는 대신에 synchronized블럭을 사용해도 같은 효과를 얻을 수 있다. 쓰레드가 synchronized블럭으로 들어갈 때와 나올 때, 캐시와 메모리 간의 동기화가 이루어지기 때문에 값의 불일치가 해소되기 때문이다.
(상수에는 volatile을 붙일 수 없다. 즉 final과 volatile을 같이 붙일 수 없다.)
Reference
자바의정석
패캠 컴퓨터공학(운영체제)
'Java' 카테고리의 다른 글
11주차 과제: Enum (0) | 2021.02.13 |
---|---|
스터디할래 10주차 과제: 멀티쓰레드 프로그래밍 (feeback, 피드백) (0) | 2021.02.12 |
8주자 과제: 인터페이스(피드백) (0) | 2021.01.28 |
9주차 과제: 예외 처리(피드백) (0) | 2021.01.17 |
스터디 할래 9주차 과제: 예외 처리 (0) | 2021.01.16 |