본문 바로가기
개념

[JAVA의 정석] Chapter13

by cook_code 2024. 9. 8.
반응형

교재 목차

Chapter13. 쓰레드 (Thread)

Chapter13. 쓰레드 (Thread)


 

1. 프로세스와 쓰레드 

1-1. 개념

프로세스란 간단히 말해서 실행 중인 프로그램이다.

프로그램을 실행하면 os로부터 실행에 필요한 자원(메모리)를 할당받아 프로세스가 된다.

1-2. 멀티태스킹과 멀티쓰레딩

공통점

아주 짧은 시간 간격을 두고 여러 개의 프로세스를 번갈아 실행함으로써 동시에 여러 개의 프로세스가 실행되는 것처럼 보이게 함

차이점

멀티태스킹 : 동시에 여러 개의 프로그램을 실행, os가 알아서 처리

멀티스레드: 하나의 프로그램을 여러 개의 기능으로 나누어 이를 동시에 실행, 개발자가 프로그램 설계 시 직접 구현

멀티쓰레딩의 장점

  • CPU의 사용률을 향상시킨다.
  • 자원을 보다 효율적으로 사용할 수 있다.
  • 사용자에 대한 응답성이 향상된다.
  • 작업이 분리되어 코드가 간결해진다.

 

2. 쓰레드의 구현과 실행 

2-1. 쓰레드의 구현 방법

⑴ Thread 클래스를 상속받기

public class MyThread extends Thread{

    @Override
    public void run() {
        super.run();
    }
}

public class MyThread1 extends Thread {
        String str;
        public MyThread1(String str){
            this.str = str;
        }

        public void run(){
            for(int i = 0; i < 10; i ++){
                System.out.print(str);
                try {
                    Thread.sleep((int)(Math.random() * 1000));
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            } 
        } 
    }

 public class ThreadExam1 {
        public static void main(String[] args) {
            // MyThread인스턴스를 2개 만듭니다. 
            MyThread1 t1 = new MyThread1("*");
            MyThread1 t2 = new MyThread1("-");

            t1.start();
            t2.start();
            System.out.print("!!!!!");  
        }   
    }

 

⑵ Runnable 클래스를 상속받기

public class MyThread2 implements Runnable {

    @Override
    public void run() {
        
    }
}

public class MyThread2 implements Runnable {
        String str;
        public MyThread2(String str){
            this.str = str;
        }

        public void run(){
            for(int i = 0; i < 10; i ++){
                System.out.print(str);
                try {
                    Thread.sleep((int)(Math.random() * 1000));
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            } 
        } 
    }

 public class ThreadExam2 {  
        public static void main(String[] args) {
            MyThread2 r1 = new MyThread2("*");
            MyThread2 r2 = new MyThread2("-");

            Thread t1 = new Thread(r1);
            Thread t2 = new Thread(r2);

            t1.start();
            t2.start();
            System.out.print("!!!!!");  
        }   
    }

 

Runnable 로 구현하면 Thread 클래스의 static 메서드인 currentThread()를 호출하여 쓰레드에 대한 참조를 얻어와야만 호출이 가능하다. 이외에 실행 결과는 Thread 클래스를 구현한 것과 동일하다.

2-2. 쓰레드의 실행 - start()

start()를 호출해야만 쓰레드가 실행된다.

하나의 쓰레드에 대해 start()는 한 번만 호출될 수 있다.

한 번 실행이 종료된 쓰레드는 다시 실행할 수 없다.

2-3. start()와 run()

start() - 생성된 쓰레드를 실행

 

run() - 생성된 쓰레드를 실행하는 것이 아니라 단순히 클래스에 선언된 메서드를 호출하는 것

 

3. 싱글쓰레드와 멀티쓰레드 

3-1. 싱글쓰레드와 멀티쓰레드의 차이

쓰레드 개념

  • 프로세스가 할당받은 자원을 이용하는 실행의 단위
  • 한 프로세스 내에서 동작되는 여러 실행 흐름으로 프로세스 내의 Heap, Data, Code 영역을 공유 ( 멀티 스레드의 경우, 이 프로세스의 영역들을 공유한다 )
  • 하나의 프로세스를 안에서 다양한 작업을 담당하는 최소 실행 단위를 스레드라고 한다.
  • ex) 크롬 브라우저(=프로세스)에서 벨로그 작성하기(=스레드1) & 유튜브로 음악 듣기(=스레드2)
  • 멀티 스레딩의 경우, 스레드 간의 자원을 공유하고 자원의 생성과 관리의 중복성을 최소화하여 수행 능력을 향상시킨다.
  • 각각의 스레드는 독립적인 작업을 수행해야 하기 때문에 고유한 스레드 ID, 프로그램 카운터, 레지스터 집합, 스택을 가지고 있다.

싱글쓰레드의 개념

하나의 프로세스에서 오직 하나의 스레드로만 실행한다. 그렇기 때문에 하나의 레지스터와 스택으로 표현이 가능하다.

싱글쓰레드의 장점

  • 문맥 교환 작업을 요구하지 않는다.
  • 자원 접근에 대한 동기화를 신경쓰지 않아도 된다.
  • 단순히 CPU만을 사용하는 계산작업이라면, 오히려 멀티 쓰레드보다 싱글 쓰레드로 프로그래밍 하는 것이 더 효율적이다.
  • 프로그래밍 난이도가 쉽고, CPU와 메모리를 적게 사용한다. (COST가 적게 든다.)

싱글쓰레드의 단점

  • 여러 개의 CPU를 활용하지 못한다.
  • 연산량이 많은 작업을 하는 경우, 그 작업이 완료되어야 다른 작업을 수행할 수 있다.
  • 싱글쓰레드모델은 에러 처리를 못하는 경우, 멈춘다.

멀티쓰레드의 개념

context switching이 엄청 빠르게 일어나면서, 유저(=User)의 시선에서는 프로그램들이 동시에 수행되는 것처럼 보인다.

 

멀티쓰레드의 장점

  • 응답성 : 프로그램의 일부분이 중간되거나 긴 작업을 하더라도 다른 작업은 수행이 계속됨
  • 경제성 : 프로세스 내 자원들과 메모리를 공유하기 때문에 메모리 공간과 시스템 자원 소모가 줄어든다. 스레드 간의 context switching은 캐시 메모리를 비울 필요가 없어 처리 속도가 빠르다.

멀티쓰레드의 단점

  • context switching, 동기화 등의 이유 때문에 싱글 코어 멀티 스레딩은 생성 시간이 오히려 오버헤드로 작용해 단일 스레드보다 느리다.
  • 공유하는 자원에 동시에 접근하는 경우, 엉뚱한 값을 읽어오거나 수정할 가능성이 있다. 따라서 동기화 필수!
  • 멀티 스레딩을 위해서는 운영체제의 지원이 필요하다.
  • 멀티 스레드 모델은 프로그래밍 난이도가 높다. 또한 스레드 수만큼 자원을 많이 사용한다.

3-2. 쓰레드의 우선순위

쓰레드는 우선순위(priority)라는 속성(멤버변수)을 가지고 있는데, 이 우선순위의 값에 따라 쓰레드가 얻는 실행시간이 달라진다. 쓰레드가 수행하는 작업의 중요도에 따라 쓰레드의 우선순위를 서로 다르게 지정하여 특정 쓰레드가 더 많은 작업시간을 갖도록 할 수 있다.

시각적인 부분이나 사용자에게 빠르게 반응해야 하는 작업을 하는 쓰레드의 우선순위는 다른 작업을 수행하는 쓰레드에 비해 높아야 한다.

public final int getPriority() : 주어진 스레드의 우선 순위를 반환합니다.
public final void setPriority(int newPriority) : 스레드의 우선 순위를 newPriority 값으로 변경합니다. 이 메소드는 newPriority 매개변수의 값이 minimum(1) 및 maximum(10) 제한을 초과하는 경우 IllegalArgumentException을 발생시킵니다.

 

class PriorityThreadMain extends Thread {

    public void run() {
        System.out.println("Inside run method");
    }

    public static void main(String[] args) {
        PriorityThreadMain t1 = new PriorityThreadMain();
        PriorityThreadMain t2 = new PriorityThreadMain();
        PriorityThreadMain t3 = new PriorityThreadMain();

        // Thread 1 우선순위 5
        System.out.println("t1 thread priority : " + t1.getPriority());

        // Thread 2 우선순위 5
        System.out.println("t2 thread priority : " + t2.getPriority());

        // Thread 3 우선순위 5
        System.out.println("t3 thread priority : " + t3.getPriority());

        // Setting priorities of above threads by
        // passing integer arguments
        t1.setPriority(2);
        t2.setPriority(5);
        t3.setPriority(8);
        // t3.setPriority(21); 같이 1~10 사이로 지정하지 않게되면 throw IllegalArgumentException 발생된다

        // 2
        System.out.println("t1 thread priority : "+ t1.getPriority());

        // 5
        System.out.println("t2 thread priority : "+ t2.getPriority());

        // 8
        System.out.println("t3 thread priority : "+ t3.getPriority());

        // Main thread
        // 현재 실행중인 스레드 이름 반환
        System.out.println("Currently Executing Thread : "+ Thread.currentThread().getName());

        System.out.println("Main thread priority : "+ Thread.currentThread().getPriority());

        // 메인 스레드의 우선 순위를 10으로 세팅
        Thread.currentThread().setPriority(10);

        System.out.println("Main thread priority : "+ Thread.currentThread().getPriority());
    }
}

t1 thread priority : 5
t2 thread priority : 5
t3 thread priority : 5
t1 thread priority : 2
t2 thread priority : 5
t3 thread priority : 8
Currently Executing Thread : main
Main thread priority : 5
Main thread priority : 10

 

3-3. 쓰레드 그룹

쓰레드 그룹은 서로 관련된 쓰레드를 그룹으로 다루기 위한 것으로, 폴더를 생성해서 관련된 파일들을 함께 넣어서 관리하는 것처럼 쓰레드 그룹을 생성해서 쓰레드를 그룹으로 묶어서 관리할 수 있다.

쓰레드 그룹 메서드

생성자 / 메서드
설명
ThreadGroup(String name)
지정된 이름의 새로운 쓰레드 그룹을 생성
ThreadGroup(ThreadGroup parent, String name)
지정된 쓰레드 그룹에 포함되는 새로운 쓰레드 그룹을 생성
int activeCount()
쓰레드 그룹에 포함된 활성상태에 있는 쓰레드의 수를 반환
int activeGroupCount()
쓰레드 그룹에 포함된 활성항태에 있는 쓰레드 그룹의 수를 반환
void checkAccess()
현재 실행중인 쓰레드가 쓰레드 그룹을 변경할 권한이 있는지 체크합니다. 만일 권한이 없다면 SecurityException이 발생시킨다.
void destory()
쓰레드 그룹과 하위 쓰레드 그룹까지 모두 삭제합니다. 단, 쓰레드 그룹이나 하위 쓰레드 그룹이 비어있어야 합니다.
int enumerate(Thread[] list)
int enumerate(Thread[] list, boolean recurse)
int enumerate(ThreadGroup[] list)
int enumerate(ThreadGroup[] list, boolean recurse)
쓰레드 그룹에 속한 쓰레드 또는 하위 쓰레드 그룹의 목록을 지정된 배열에 담고 그 개수를 반환.
두번째 매개변수인 recurse의 값을 true로 하면 쓰레드 그룹에 속한 하위 쓰레드 그룹에 쓰레드 또는 쓰레드 그룹까지 배열에 담습니다.
int getMaxPriority()
쓰레드 그룹의 최대 우선순위를 반환
String getName()
쓰레드 그룹의 이름을 반환
ThreadGroup getParent()
쓰레드 그룹의 상위 쓰레드 그룹을 반환
void interrupt()
쓰레드 그룹에 속한 모든 쓰레드를 interrupt
boolean isDaemon()
쓰레드 그룹이 데몬 쓰레드그룹인지 확인
boolean isDestroyed()
쓰레드 그룹이 삭제되었는지 확인
void list()
쓰레드 그룹에 속한 쓰레드와 하위 쓰레드 그룹에 대한 정보를 출력
boolean parentOf(ThreadGroup g)
지정된 쓰레드 그룹의 상위 쓰레드 그룹인지 확인
void setDaemon(boolean daemon)
쓰레드 그룹을 데몬 쓰레드 그룹으로 설정/해제
void setMaxPriority(int pri)
쓰레드 그룹의 최대우선순위 설정

쓰레드 그룹 관련 메서드

ThreadGroup getThreadGroup() : 쓰레드 자신이 속한 쓰레드 그룹을 반환
void uncaughtException(Thread t, Throwable e) : 쓰레드 그룹의 쓰레드가 처리되지 않은
예외에 의해 실행이 종료되었을 때, JVM에 의해 이 메서드가 자동적으로 호출됨

 

4. 데몬 쓰레드 

4-1. 데몬 쓰레드란?

데몬 쓰레드는 다른 일반 쓰레드의 작업을 돕는 보조적인 역할을 수행하는 쓰레드이다.

일반 쓰레드가 모두 종료되면 데몬 쓰레드는 강제적으로 자동 종료되는데, 그 이유는 데몬 쓰레드는 일반 쓰레드의 보조 역할을 수행하므로 일반 쓰레드가 모두 종료되고 나면 데몬 쓰레드의 존재의 의미가 없기 때문이다.

4-2. 데몬 쓰레드의 특징

  • 백그라운드 실행 - 주요동작에 영향을 미치지 않고 보조 작업 처리
  • 자동 종료 - 메인(일반) 스레드 종료시 자동 종료
  • setDaemon() 메서드 - 데몬스레드가 생성한 쓰레드는 자동으로 데몬 쓰레드가 됨

5. 쓰레드 제어 

5-1. 쓰레드의 실행제어

쓰레드 프로그래밍이 어려운 이유는 동기화와 스케쥴링 떄문이다.

쓰레드 클래스

쓰레드 클래스 스케쥴링 관련 메서드

메서드
설명
static void sleep(long millis)
지정된 시간(천분의 일초 단위)동안 쓰레드를 일시정지시킨다.
지정한 시간이 지나고 나면, 자동적으로 다시 실행대기상태가 된다.
void join()
지정된 시간동안 쓰레드가 실행되도록 한다.
지정된 시간이 지나거나 작업이 종료된면 join()을 호출한 쓰레드로 다시 돌아와 실행을 계속한다.
void interrupt()
sleep()이나 join()에 의해 일시정지상태인 쓰레드를 깨워서 실행대기상태로 만든다. 해당 쓰레드에서는 InterruptedException이 발생함으로써 일시정지상태를 벗어나게 된다.
void stop()
쓰레드를 즉시 종료시킨다.
void suspend()
쓰레드를 일시정지시킨다.
resume()을 호출하면 다시 실행대기상태가 된다.
void resume()
suspend()에 의해 일시정지상태에 있는 쓰레드를 실행대기상태로 만든다.
static void yield()
실행 중에 자신에게 주어진 실행시간을 다릉 쓰레드에게 양보하고 자신은 실행대기 상태가 된다.

쓰레드 상태

상태
설명
NEW
쓰레드가 생성되고 아직 start()가 호출되지 않은 상태
RUNNABLE
실행 중 또는 실행 가능한 상태
BLOCKED
동기화 블럭에 의해서 일시정지된 상태(lock이 풀릴 때까지 기다리는 상태)
WAITING, TIMED_WAITING
쓰레드의 작업이 종료되지 않았지만 실행가능하지 않은 일시정지상태. TIMED_WAITING은 일시정지시간이 지정된 경우를 의미한다.
TERMINATED
쓰레드의 작업이 종료된 상태

쓰레드 상태 변화과정

5-2. 쓰레드의 동기화

한 쓰레드가 진행 중인 작업을 다른 쓰레드가 간섭하지 못하도록 막는 것.

공유 데이터를 사용하는 코드 영역을 임계 영역으로 지정해놓고, 공유 데이터가 가지고 있는 lock을 획득한 단 하나의 쓰레드만 이 영역 내의 코드를 수행할 수 있게 한다. 그리고 해당 쓰레드가 임계 영역 내의 모든 코드를 수행하고 벗어나서 lock을 반납해야만 다른 쓰레드가 반납된 lock을 획득하여 임계 영역의 코드를 수행할 수 있게 된다.

⑴ synchronized 를 이용한 동기화

class Money {
    private int myMoney = 10000;

    public int getMyMoney() {
        return myMoney;
    }

    // 메서드 전체를 임계영역으로 설정
    public synchronized boolean withdraw(int money) {
        if (myMoney >= money) {
            try {
                Thread.sleep(1000);
            } catch (Exception e) {
                System.out.println(e);
            }
            myMoney -= money;
            return true;
        }
        return false;
    }
}
출처: https://ittrue.tistory.com/173 [IT is True:티스토리]

 

⑵ 메서드 내의 코드 일부를 블럭{}으로 감싸고 블럭 앞에 synchronized를 붙이기

synchronized (객체의 참조) {
    ...
}

 

5-3. wait()과 notify()

synchronized를 사용해 공유 데이터를 보호하는 것도 중요하지만 특정 쓰레드가 객체의 락을 가진 상태로 오랜 시간을 보내지 않도록 하는 것도 중요하다.

동기화된 임계영역의 코드를 수행하다가 작업을 더 이상 진행할 상황이 아니면, 일단 wait() 을 호출하여 쓰레드가 락을 반납하고 기다리게 한다. 그러면 다른 쓰레드가 락을 얻어 해당 객체에 대한 작업을 수행할 수 있게 된다. 나중에 작업을 진행할 수 있는 상황이 되면 notify() 를 호출해서, 작업을 중단했던 쓰레드가 다시 락을 얻어 작업을 진행할 수 있게 한다.

wait()과 notify()의 공통점

  • Object에 정의되어 있다.
  • 동기화 블록 내에서만 사용할 수 있다.
  • 보다 효율적인 동기화를 가능하게 한다.

5-4. Lock과 Condition 을 이용한 동기화

synchronized 블럭 이외에도 lock 클래스들을 사용해 동기화가 가능하다.

  • ReentrantLock : 재진입이 가능한 lock, 가장 일반적인 배타 lock
  • ReentrantReadWriteLock : 읽기에는 공유적이고, 쓰기에는 배타적인 lock
  • StampedLock : ReentrantReadWriteLock에 낙관적인 lock의 기능들을 추가
    • StampedLock 클래스는 JDK 1.8부터 추가됨
    • Lock 인터페이스를 구현하지 않음

Lock과 Condition 을 이용한 동기화의 특징

  • 특정 조건에서 lock을 풀고 나중에 다시 lock을 얻고 임계영역으로 들어와서 이후의 작업을 수행할 수 있습니다.
  • 임계 영역의 공유자원을 읽거나 또는 쓰기 위해서는 무조건 lock을 가지고 있어야 합니다.
  • 무조건 읽기 lock을 걸지 않고, 쓰기와 읽기가 충돌할 때만 쓰기가 끝난 후에 읽기 lock을 거는 형태

5-5. volatile - 자바 변수를 메인 메모리에 저장할 것을 명시하기 위해 쓰임

요약하자면 각 스레드는 메인 메모리 로부터 값을 복사해 CPU 캐시 에 저장하여 작업한다.

CPU 가 2개 이상이라면 멀티 스레드 환경에서 각 스레드는 서로 다른 CPU 에서 동작하고 있으며

이는 각 스레드가 같은 변수에 대해 읽기, 쓰기 동작을 수행할 시

각자의 CPU 캐시 에 메인 메모리 의 값과 다른 값을 갖고 있을 수 있게 된다.

하지만 자바에서 어떠한 변수에 volatile 키워드를 붙이면 해당 변수는 모든 읽기와 쓰기 작업이 CPU 캐시가 아닌 메인 메모리 에서 이루어지게 되고 이로써 해당 변수 값에 대해 가시성 을 보장할 수 있다.

사실 이 가시성 이란 용어는 읽기, 쓰기 작업에 대해 '동시성 문제 를 해결해준다' 는 의미가 아니다.

메인 메모리에서 작업이 이루어진다는 이유로 해당 키워드만으로 동기화 처리가 이루어진다고

잘못 받아들이면 다음과 같이

private static Singleton singleton;
private volatile Singleton singleton;

단순히 static -> volatile 키워드만 변경하고 synchronized 키워드로 동기화 처리는 하지 않아도 되는 것으로 오해할 수 있다.

하지만, 반드시 synchronized 키워드로 공유 객체에 대한 동기화 처리 까지 해주어야 싱글톤을 보장 받을 수 있다.

volatile 키워드를 사용하여 모든 스레드가 항상 같은 공유 변수의 값을 읽어올 수 있도록 보장한 뒤

어느 한 스레드가 synchronized 키워드를 사용한 getInstance 메서드 (하나의 스레드만이 접근할 수 있는 메서드) 로 공유 변수에 인스턴스를 할당하면 volatile 키워드에 의해 메인 메모리 에 해당 인스턴스의 값이 갱신되고 이로써 다른 모든 스레드들은 null 이 아닌 공유 변수에 할당된 인스턴스를 메인 메모리로부터 바로 읽어 올 수 있게 된다.

공유 변수 값의 불일치가 일어나지 않기 때문에 다른 스레드가 또 다시 getInstance 의 if 문 블록에 진입하는 경우는 발생하지 않게 된다.

5-6. fork & join 프레임웍

하나의 작업을 작은 단위로 나눠서 여러 쓰레드가 동시에 처리하는 것을 쉽게 만드는 것.

수행할 작업에 따라 아래 두 클래스 중 하나를 상속받아 구현한다.

RecursiveAction : 반환값이 없는 작업을 구현할 때 사용
RecursiveTask : 반환값이 있는 작업을 구현할 때 사용

 

두 클래스 모두 compute()라는 추상 메서드를 가지고 있고, 이 추상 메서드를 구현하면 된다.

 

1) compute()에 작업을 수행하기 위한 코드 넣기

2) 쓰레드풀과 수행할 작업 생성

3) invoke()로 작업 시작.

fork()

해당 작업을 쓰레드 풀의 작업 큐에 넣는다. 비동기 메서드

작업 큐에 들어간 작업은 더 이상 나눌 수 없을 때까지 나뉜다.

즉 compute()로 나누고, fork()로 작업 큐에 넣는 작업이 계속 반복된다.

나눠진 작업은 각 쓰레드가 골고루 나눠서 처리하고, 작업의 결과는 join()을 호출해서 얻을 수 있다.

join()

해당 작업의 수행이 끝날 때까지 기다렸다가, 수행이 끝나면 그 결과를 반환한다. 동기 메서드

반응형

'개념' 카테고리의 다른 글

[JAVA의 정석] Chapter15  (0) 2024.09.08
[JAVA의 정석] Chapter14  (2) 2024.09.08
[JAVA의 정석] Chapter12  (1) 2024.09.08
[JAVA의 정석] Chapter11  (1) 2024.09.08
[JAVA의 정석] Chapter10  (0) 2024.09.08