language/java

13. 쓰레드

wooweee 2023. 4. 7. 13:05
728x90

1. 프로세스와 쓰레드

  • 용어
    • 프로그램이 실행되면 프로세스가 된다.

    • program
      • pro:진행되는 , gram: 정보
      • 저장되는 파일 형태
      •  저장 공간만 존재하면 된다 - HDD, SDD
    • process
      • cess==go
      • 작업이 실행되는 것
      • 명령어가 필요한 것 -CPU
      • 프로그램을 더블 클릭 후 수행이 되는 상태로 변하면 프로세스가 된다.
        * 동영상같은 것은 데이터만 있기 때문에 프로그램이지만 프로세스가 아니다.

 

  • process - 쓰레드
    • 프로세스 : 작업에 필요한 데이터와 메모리 등의 자원 쓰레드로 구성되어 있는 것
    • 쓰레드: 프로세스의 자원을 이용해서 실제로 작업을 수행하는 것
      • 싱글쓰레드 : 자원 하나에 쓰레드 한 개
      • 멀티쓰레드 : 자원 하나에 쓰레드 여러 개 -> 자원을 공유하기 때문에 동기화 문제가 생긴다.

 

 

2. 멀티쓰레딩의 장단점

  • 장점
    1. CPU의 사용률을 향상시킨다.
    2. 자원을 보다 효율적으로 사용할 수 있다.
    3. 사용자에 대한 응답성이 향상된다. - 채팅하다가 파일 보내기
    4. 작업이 분리되어 코드가 간결해진다.

  • 단점
    자원을 공유하면서 작업을 하기 때문에 발생
    1. 동기화 문제 발생
    2. 교착상태 문제 발생

 

3. 쓰레드의 구현과 실행

  • 2가지 방법 존재
    1. Thread class를 상속받는 방법
    2. Runnable 인터페이스를 구현하는 방법
      • 인터페이스를 구현하는 방법이 재사용, oop, 다중상속이 가능해서 주로 사용 된다.

  • 메서드
    • Thread currentThread() : 현재 실행중인 쓰레드의 참조를 반환
    • getName() : 쓰레드의 이름을 반환
    • start() : 해당 메서드를 호출해야만 쓰레드가 실행
// Thread class를 상속
class MyThread extends Thread{
    public void run() {/* 작업 내용 */} // Thread class run() 오버라이딩
}

// Runnable interface 구현
class MyThread implements Runnable {
    public void run() {/* 작업 내용 */} // Runnable interface의 run() 구현
}

 

package practice;

public class Practice {
    public static void main(String[] args) {
        ThreadEx1 t1 = new ThreadEx1();

        Runnable r = new ThreadEx2(); // Runnable instance 생성
        Thread t2 = new Thread(r); // 실제 Thread에 매개변수로 넣어서 사용

        t1.start();
        t2.start();


    }

}

class ThreadEx1 extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 5; i++) {
            System.out.println(getName()); // 조상인 Thread의 getName()을 호출
        }
    }
}

class ThreadEx2 implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            System.out.println(Thread.currentThread().getName()); // 현재 실행중인 Thread를 반환
        }
    }
}

 

4. start() 와 run()

  • start() : 새로운 쓰레드가 작업을 실행하는데 필요한 새로운 호출스택을 생성
  • run() : start()가 깔아준 새로운 호출스택에서 run()이 실행된다
  • 실행 중인 사용자 쓰레드가 하나도 없을 때 프로그램이 종료된다. -> run() 작업이 다 종료되야 main()도 종료된다.

  • 참고 : main() 도 작업을 수행하는 쓰레드이다.
    • main 쓰레드를 사용자 쓰레드 혹은 non데몬 쓰레드라고 한다.
    • 다른 종류로 데몬 쓰레드가 존재

 

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

  • 예시
    • 싱글쓰레드 : 하나의 쓰레드로 2개의 작업을 수행하는 경우
    • 멀티쓰레드 : 2개 쓰레드로 2개의 작업을 수행하는 경우
  • 결과
    • 싱글 쓰레드가 더 빠르다.
    • 한가지 작업을 다 종료후 다음 작업을 수행한다.
    • 멀티쓰레드의 경우 2가지의 작업을 쓰레드가 각각 수행하지만 서로 병행해서 작업을 수행하기 때문에 context switching이 발생해서 시간의 delay가 생긴다.
  • 병행과 병렬
    1. 병행 : 반드시 다른 작업을 수행하는 것
    2. 병렬 : 같은 작업을 2개 이상 쓰레드로 수행하는 것

 

6. 쓰레드의 I/O blocking

  • 5.의 경우 싱글쓰레드가 더 빠르다고 했지만 I/O 같은 경우는 외부로부터 들어와야하는 값으로 인해서 생기는 blocking 시간이 존재하게 된다.
  • 위와 같은 경우에는 멀티쓰레드를 사용하면blocking 되는 시간에 다른 작업을 수행하면 되므로 멀티쓰레드의 작업 처리 속도가 훨씬 빠르다.

 

  • 면접 단골 질문
    1. blocking
      • 사용자로부터 입력을 기다리는 구간, 아무 일도 하지 않는다. - I/O blocking
      • 싱글 쓰레드에서 2가지 일을 하는 경우 발생
    2. non blocking
      • 멀티 쓰레드에서 2가지 일을 하는 경우 한 쓰레드가 I/O를 기다리는 동안 다른 쓰레드가 자신의 일을 수행한다.
      • 작업 처리 속도가 빠르다
    3. 동기화
      • 값 넣는 순서대로 수행
    4. 비동기화
      • 값을 넣었다고 먼저 답을 주지 않는 것. ex) cgv - 표 끊기 표 100장 끊는 사람것은 마지막으로 미룬다.
package practice;

import javax.swing.*;

public class Practice {
    public static void main(String[] args) {
        Runnable r = new ThreadEx();
        Thread th = new Thread(r);
        th.start();

        String input = JOptionPane.showInputDialog("any value");
        System.out.println("input = " + input);
        
    }

}

class ThreadEx implements Runnable {
    @Override
    public void run() {
        for (int i = 0; i < 10; i++) {
            System.out.println(i);
            try {
                Thread.sleep(1000);
            } catch (Exception e) {}
        }
    }
}

 

7. 쓰레드 우선 순위

  • java app보다 OS가 상위에 있기 때문에 희망사항일 뿐 100% 그대로 수행되지 않는다.
  • java에서는 10 ~ 1 까지의 우선 순위가 있고 숫자가 클수록 우선 순위가 높다.

  • 메서드
void setPriority(int newPriority) // 쓰레드의 우선순위를 지정한 값으로 변경   default = 5
int getPriority() // 쓰레드의 우선순위를 반환

 

 

8. 쓰레드 그룹

  • 서로 관련된 쓰레드를 그룹으로 다루기 위한 것. class가 존재
  • 보안상의 이유로 도입 : 자신이 속한 쓰레드 그룹이나 하위 쓰레드 그룹은 변경할 수 있지만 다른 쓰레드 그룹의 쓰레드를 변경할 수는 없다.
  • 쓰레드 그룹에 넣는 방법 - 쓰레드 생성자를 이용해서 넣는다.
  • 기본 생성자를 통해 생성한 쓰레드는 자신을 호출한 쓰레드의 그룹에 들어가게 된다.
    main에서 쓰레드를 생성하면 main 쓰레드 그룹에 속한다.

 

// 쓰레드 생성자
Thread(ThreadGroup group, String name)
Thread(ThreadGroup group, Runnable target)
Thread(ThreadGroup group, Runnable target, String name)
Thread(ThreadGroup group, Runnable target, String name, long stackSize)

 

9. 데몬 쓰레드

  • 중요
  • 보조역할을 하는 쓰레드
  • 일반 쓰레드가 모두 종료시 데몬 쓰레드는 강제적으로 자동 종료
  • 예 : 가비지 컬렉터, 자동저장, 화면자동갱신

 

  • 생성 조건
    1. while(true) // 항상 무한루프로 돌리기. 결국 일반쓰레드가 종료되면 자동 종료가 된다.
    2. 특정조건을 만족할 때 작업을 수행하는 조건문 작성
    3. 일반 쓰레드 수행 전 setDaemon(boolean on)을 통해서 쓰레드를 데몬 쓰레드로 변경
      on이 true일 때 데몬 쓰레드로 설정 된다.
  • 예제를 보면 이해가 쉽다.
package practice;

import javax.swing.*;

public class Practice implements Runnable {
    static boolean autoSave = false;

    public static void main(String[] args) {
        Runnable r = new Practice();
        Thread th = new Thread(r);
        th.setDaemon(true); // start 전에 daemon 설정
        th.start();

        for (int i = 0; i <= 10; i++) {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {}
            System.out.println(i);

            if (i == 5) autoSave = true; // 조건문 작성

        }

        System.out.println("프로그램 종료");
    }

    @Override
    public void run() {
        while (true) { // 1. 무한 루프 -> 조건은 psvm에 작성 -> main thread 종료시 자동 종료
            try {
                Thread.sleep(3 * 1000);
            }catch (Exception e){}

            if (autoSave) autoSave(); // 2. auto save가 true일 때 3초마다 수행
        }
    }

    // 수행할 method
    public void autoSave() {
        System.out.println("작업파일이 자동 저장되었습니다.");
    }
}

 

10. 쓰레드 상태

자바의 정석 기초편

 

11. 쓰레드 실행 제어

  • stop(), suspend(), resume() -> 교착상태의 문제로 인해서 deprecated 되었다.
  • 사용하고 싶으면 직접 구현해서 사용하기

 

  • 실행 제어 메서드
    • static
      1. static void sleep(long millis)
        • 자기 자신을 재우는 것
        • 일시 정지상태로 이동
      2. static void yield()
        • 자기 자신이 양보를 하는 것. 자신에게 주어진 실행시간에 한해서 양보를 해준다.
        • 실행 대기상태로 이동
    • instance
      1. void interrupt()
        • sleep() 이나 join()에 의해 일시정지상태인 쓰레드를 깨워서 실행대기상태로 만다.
          • sleep() 중 interrupt()로 깨우면 예외가 터지면서 boolean이 false로 초기화된다. 그래서 catch문 안에 interrupt()를 작성해 예외 처리를 하면 정상 작동하게 된다.
        • 사용법
          1. Thread class 내부 boolean isInterrupted()의 false 값을 void interrupt()는 true로 변경한다.
          2. true로 변경 해서 쓰레드를 깨운 후
          3. boolean interrupted()를 이용해서 다시 현재 상태의 boolean을 반환 후 boolean isInterrupted()를 false로 만들어 줘야한다.
        • 정리
          • interrupted 자체가 의미가 있는 것이아니라 이 메서드를 통해서 내부 isInterrupted boolean 값을 true로 변경 후 true 조건식을 만들어서 뭔가를 작동시킨다.
      2. void join()
        • 지정된 시간동안 쓰레드가 실행되도록한다.
        • 지정 시간이 지나거나 작업이 종료시 join()을 호출한 쓰레드로 다시 돌아와 작업을 수행

 

  • sleep 예제
package practice;


public class Practice{
    public static void main(String[] args) {

        Thread1 t1 = new Thread1();
        Thread2 t2 = new Thread2();
        t1.start(); 
        t2.start();

        // 해당 try-catch문을 주석 처리하면 main, t1, t2 상관없이 다 랜덤으로 실행
        try{
            Thread.sleep(2000);
        }catch (InterruptedException e){
        }

        System.out.print("main finish");
    }


    // 수행할 method
    public void autoSave() {
        System.out.print("작업파일이 자동 저장되었습니다.");
    }
}

class Thread1 extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 300; i++) {
            System.out.print("-");
        }
        System.out.println("t1 finish");
    }
}

class Thread2 extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 300; i++) {
            System.out.print("|");
        }
        System.out.print("t2 finish");
    }
}

 

 

  • interrupt() 예제
package practice;


import javax.swing.*;

public class Practice {
    public static void main(String[] args) {

        Thread1 t1 = new Thread1();
        t1.start();


        String input = JOptionPane.showInputDialog("put string");
        System.out.println("input = " + input);
        t1.interrupt(); // false -> true
        System.out.println("t1.isInterrupted() = " + t1.isInterrupted()); // expect : true

    }


    // 수행할 method
    public void autoSave() {
        System.out.print("작업파일이 자동 저장되었습니다.");
    }
}

class Thread1 extends Thread {
    @Override
    public void run() {
        int i = 10;
        while (i != 0 && !isInterrupted()) {
            System.out.println(i--);
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                interrupt(); // catch문으로 잡아야한다.
            }
        }
        System.out.println("count finish");
    }
}

class Thread2 extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 300; i++) {
            System.out.print("|");
        }
        System.out.print("t2 finish");
    }
}

 

  • join() yield()
package practice;

public class Practice {
    static long startTime = 0;
    public static void main(String[] args) {

        Thread1 t1 = new Thread1();
        Thread2 t2 = new Thread2();
        t1.start();
        t2.start();

        startTime = System.currentTimeMillis();

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {

        }

        System.out.println("time : " + (System.currentTimeMillis()-Practice.startTime));

    }


    // 수행할 method
    public void autoSave() {
        System.out.print("작업파일이 자동 저장되었습니다.");
    }
}

class Thread1 extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 300; i++) {
            System.out.print(new String("-"));
        }
    }
}

class Thread2 extends Thread {
    @Override
    public void run() {
        for (int i = 0; i < 300; i++) {
            System.out.print("|");
        }
    }
}

 

 

12. 쓰레드의 동기화

  • 멀티 쓰레드 프로세스의 경우 문제점
    • 서로의 작업에 영향을 준다.
  • 한 쓰레드가 특정 작업을 끝마치기 전까지 다른 쓰레드에 의해 방해받지 않도록 하는 것이 필요
    • 쓰레드의 동기화
      • 한 쓰레드가 진행중인 작업을 다른 쓰레드가 간섭하지 못하게 막는 것
    • 임계 영역(critical section)과 잠금(lock) 개념 도입
      1. 임계 영역
        • 간섭받으면 안되는 code 영역
        • 공유 데이터(==객체)를 사용하는 코드 영역을 임계 영역으로 지정
        • 임계 영역 지정시, 내부의 객체에 lock이 생성된다.
      2. lock
        • 공유데이터(객체)가 가지고 있는 lock을 획득한 쓰레드만 이 영역 내의 코드를 수행
        • 객체 1개마다 lock 1개를 가진다. 
      3. 참고 - private
        • 동기화를 하고자 하는 부분에는 분명이 공유되는 객체 혹은 기본형 값들이 존재할 것이다.
        • 따라서 해당 변수들을 private으로 지정을 해주어서 다른 쓰레드에서 접근조차 못하도록 막아야 동기화 자체에 의미를 가지게 된다.

 

  • 임계영역 
    임계 영역은 좁을수록 성능면에서 좋다.
    1. 메서드 전체를 임계 영역으로 지정 - 내부의 객체를 공유 못하는 거지만 메서드 자체를 lock을 걸었다고 생각해도 무방
    2. 특정한 영역을 임계 영역으로 지정

 

// 1. 메서드 전체를 임계 영역으로 지정
public synchronized void calcSum(){}

// 2. 특정한 영역을 임계 영역으로 지정
synchronized(객체의 참조변수){}

 

예제

 

public class Practice {
    public static void main(String[] args) {

        Runnable r = new Thread1();
        new Thread(r).start();
        new Thread(r).start();
    }
}

class Account {
    private int balance = 1000; 
    // balance라는 것을 동시성 문제로 부터 해결하기 위해서 synchronized 했는데 private이 아니면 외부에서 임의 접근히 가능해져서 의미가 없어진다

    public int getBalance(){
        return balance;
    }

    public synchronized void withdraw(int money) { // 동기화
        if (balance >= money) {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {}
            balance -= money;
        }
    }

}

class Thread1 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());
        }
    }
}

 

 

 

13. wait() & notify(), notifyAll()

  • 동시성 문제 해결한 동기화의 문제점
    • 특정 쓰레드가 객체의 락을 가진 상태로 오랜 시간을 보내게 되는 경우 다른 쓰레드들이 모두 해당 객체의 락을 기다린다고 다른 작업이 원활하게 이루어지지 않게 된다.
    • 예시 : 만약 위의 예제의 sleep이 1일로 되어있고 내부에 돈을 넣는 과정 또한 동일 synchronize에 포함이 된다고 하면 돈이 없는 것을 확인하고 돈을 통장에 넣으려고 해도 1일 뒤에 할 수 있게 된다.
  • wait(), notify(), notifyAll()을 통해서 해결
    1. 공통 특징
      • Object에 정의
      • 동기화 블록 내에서만 사용
      • 효율적인 동기화를 가능하게 한다.
    2.  wait()
      • 해당 쓰레드가 lock을 반납하게 하고 일시정지상태로 들어가도록 한다.
      • notify(), notifyAll() 을 통해서 깨워야지 실행대기 상태로 들어가게 된다.
      • 아니면 wait()에 매개변수를 넣어서 일정시간동안만 일시정지상태를 유지한다.
    3. notify()
      • 작업이 종료된 후 다시 일시정지된 쓰레드들 중 임의의 한 쓰레드에게 lock을 주어서 실행되게 한다.
    4.  notifyAll()
      • 작업이 종료된 후 다시 일시정지된 쓰레드들 모두를 실행대기상태로 들어가게 한 후  한 쓰레드에게 lock을 주어서 실행되게 한다.
      • 비효율적으로 굳이 다 실행대기상태로 넣는 이유
        • starvation(기아 현상) 방지 목적
        • 임의의 한 쓰레드만 깨울 시, 운이 나쁘면 어떤 한 쓰레드는 일시정지상태에서 일어나지 않게 된다. (운없이 호출이 안됬기 때문) 

 

 

14. Lock과 condition

 

  • wait(), notify()의 비효율성
    • 입금을 하기 위한 쓰레드나 출금을 하기 위한 쓰레드가 동일한 공간(waiting pool)에서 일시정지상태를 유지하게 된다.
    • 그래서 입금을 위해 wait()을 출금을 일시정지시키고 입금후 다시 출금을 하는 과정이 반복이 될 때 나중에는 어떤 쓰레드가 호출 될지 모르게 된다.
      - 물론 입금을 하는 쓰레드가 와야하는데 출금 쓰레드가 오게 되면 다시 입금 쓰레드로 변경이 되도록 설계가 되어있지만 문제라기 보단 비효율성이 발생한다.
    • 이 외에도 비효율성이 존재한다.

  • 해결책
    • lock() 과 condition()을 이용한 동기화 수행
    • 실질적으로는 condition을 통해서 wait(), notify()를 해결하는데 lock은 왜 있냐? 
      -> locks 패키지에 condition, lock 의 인터페이스가 존재하고 lock의 method에 condition 생성하는 method가 존재한다.
    • 따라서 lock을 구현해야지 condition을 사용해서 비효율성을 해결할 수 있다.
      * 참고 lock으로만 waitiong pool 분리가 가능하다. 하지만 구현하는 것 자체가 비효율적이다.

  • Lock
    1. ReentrantLock - 구현체
      • 재진입이 가능한 lock
      • 원래 사용하던 lock이랑 동일
      • 하지만 synchronize 내부의 lock의 자동 기능이 없기에 method를 통해서 lock 기능 수행 필요
    2. ReentrantReadWriteLock - 구현체
      • 읽기에는 공유적이고, 쓰기에는 배타적인 lock
      • read와 write를 분리 read일 때는 read를 하는 모든 쓰레드는 다 접근 가능하고 write는 접근이 불가, write는 아무도 접근 못함
      • 원리
        • 원래 일반적인 동기화의 lock은 true/false로 해결 - Mutex
        • 해당 lock은 0, -1, 양수로 진행
            사용전 상태를 0
            읽기 쓰레드가 작동시 +1씩 증가 & 종료시 -1 씩 감소 -> read만 접근 가능
            write일 때는 무조건 -1로 고정 -> read나 write 접근 불가

    3. StampedLock - 구현 안되어있음
      • 낙관적인 lock
      • 원래는 read인지 write인지 보고 접근해야하지만 일단 읽고(아무도 write 안하고 있겠지 마인드) 나중에 write인지 확인하는 것 - 역순으로 진행
      • 하지만 빨랐죠?

 

// ReentrantLock의 생성자
ReentrantLock()

// lock method
void lock()         // lock 잠금
void unlock()       // lock 해지
boolean isLocked()  // lock 잠겼는지 확인


// lock 사용방법

ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
    // 임계 영역 작성
} catch(Exception e){
} finally{
    lock.unlock();
}

 

 

  • condition : wait(), notify()문제 해결
    • waitingPool 분리시켜준다.
    • wait()과 notify()에 관련된 메서드 존재
      waitingPool.method() 형식으로 작성  -  이전에는 wait()이렇게 썻었다.
// condition method
// wait()
void await()
void awaitUninterruptibly()

// wait(long timeout)
boolean await(long time, TimeUnit unit)
long awaitUntil(Date deadline)

// notify()
void signal()
// notifyAll()
void signalAll()

// 예제
private ReenTrantLock lock = new ReentrantLock(); // lock 생성

// lock으로 condition 생성
// newCondition()이 lock interface method 중 condition 생성하는 method
private Condition forCook = lock.newCondition();
private Condition forCust = lock.newCondition();

... 생략
forCook.await();

... 생략
forCust.signal();

 

14. 동기화 흐름

  1. 멀티 쓰레드 사용
    -> 데이터 공유 문제 발생

  2. 동기화 : lock을 걸어버림 - 아무도 못들어오도록 함 (문제 해결)
    -> 동기화의 문제 발생 : 아무도 못들어오니깐 정말 들어와야하는 경우 lock으로 인해 진행이 안됨

  3. wait(), notify()  - 특정 경우 wait()으로 해당 객체의 lock을 풀고 notify()로 다시 깨워서 작동 (문제 해결)
    -> notify(), wait()의 비효율성 발생 :  waiting pool이 하나여서 아무거나 막 들어옴

  4. lock & condition : 그룹별로라도 waiting pool을 분리해서 아무거나 다음 턴에 못들어오도록 비효율성 해결 (문제 해결)

 

15.volatile

  • 멀티 코어의 문제점 해결
  • 코어는 처음에만 memory로부터 data를 읽어온 후 이후 core 내부의 cache에서부터 data를 읽는데 이때부터 다른 corerk write를 한 것이 동기화가 되지 않는다.
  • 따라서 자주 write되는 code에는 volatile을 붙여주면 cpu가 해당 정보를 읽을 때마다 cache를 사용하지 않고 memory로부터 data를 읽게되어서 동기화가 이루어지게 된다.

 

 

 

 

 

이전 발행글 : 2023.03.23 - [java/java 기본] - 12. 지네릭스, 열거형, 애너테이션

 

다음 발행글 : 2023.03.24 - [java/java 기본] - 14. 람다와 스트림