본문 바로가기
TIL/JAVA

23.05.26

by J1-H00N 2023. 5. 26.

프로세스 vs 쓰레드

프로세스 - 운영체제로부터 작업을 할당받는 단위 (ex. 카카오톡, 크롬 창, injellij 같은 '실행 중인 프로그램')

쓰레드 - 프로세스가 할당받은 자원을 이용하는 실행의 단위 (프로세스에서 일하는 일꾼? 개념)

 

프로세스의 구조

  • 운영체제(OS)가 프로그램 실행을 위해 프로세스 안에 Code, Data, 메모리 영역(Stack, Heap)과 함께 할당
  • Code는 Java main 메서드와 같은 코드를 말한다
  • Data는 전역변수, 정적변수, 배열 등 초기화 된 데이터를 저장할 수 있는 저장공간
  • Memory(메모리 영역)
    • Stack : 지역변수와 매개변수의 리턴 값을 저장하는 공간
    • Heap : 프로그램이 동적으로 필요한 변수를 저장하는 공간 (ex. new ~~())

쓰레드의 구조

  • 쓰레드는 프로세스 안에서 활동하는 코드실행의 흐름이라고 생각
  • 프로세스가 프로그램에서 실행요청이 들어오면 쓰레드를 생성해 명령을 처리
  • 프로세스 안에는 여러 쓰레드들이 있고, 각자의 명령을 처리하기 위해 Stack을 할당받는다.
  • 또한 쓰레드들은 실행을 위해 프로세스 내 주소 공간, Heap을 공유받는다.

Java 쓰레드

  • 기본적인 구조는 위 쓰레드와 동일
  • JVM 프로세스 안에서 실행되는 쓰레드를 말하며, JVM에 의해 같이 실행된다.
  • 여기서 JVM은 자바 프로그램 실행환경을 만들어 주는 소프트웨어를 의미한다.

 

멀티 쓰레드 ↔ 싱글 쓰레드 (지금까지 해온 방식)

  • 싱글 쓰레드
    • 지금까진 메인 쓰레드가 main()메서드만 실행시키고 끝나면 JVM을 종료하는 방식이였기 때문에 싱글 쓰레드
    • main()의 쓰레드를 메인 쓰레드라고 한다.
  • 멀티 쓰레드
    • 메인 쓰레드 외에 다른 작업 쓰레드들을 만들어 병렬적으로 처리할 수 있다.
    • 장점
      • 작업을 여러개 동시에 처리하기 때문에 성능이 좋아진다.
      • 스택(각자의 처리공간)을 제외한 모든 영역에서 메모리를 공유하기 때문에 더 효율적으로 사용할 수 있다.
      • 응답 쓰레드와 작업 쓰레드를 분리하여 더 빠르게 응답할 수 있다. (비동기)
    • 단점
      • 프로세스의 자원을 공유하면서 작업을 처리하기 때문에 서로 자원을 사용하려고 하다가 충돌해 동기화 문제가 발생할 수 있다.
      • 둘 이상의 쓰레드가 서로의 자원을 요청하는 상태가 되었을 때 서로 요청만 하는 교착상태에 빠져 더 이상 진행이 안되는 상황이 발생할 수 있는데 이를 데드락이라고 부른다.

쓰레드를 구현하고 실행하는 방법

  1. Thread 클래스를 이용하는 방법
    • Thread 클래스를 상속받는 클래스 만들기
    • run 메서드를 오버라이딩해서 수행할 작업 작성
    • Main 쓰레드에서 그 클래스 변수를 만들어 실행
  2. Runnable 인터페이스 이용
    • Runnable 인터페이스를 구현하는 클래스 만들기
    • run 메서드를 오버라이딩해서 수행할 작업 작성
    • Main 쓰레드에서 Runnable 변수를 생성
    • 그 변수를 Thread 객체에 넣고 실행
  3. 람다식
    • 아래 코드를 통해 예시

↓Thread 예시

더보기
public class TestThread extends Thread {
    @Override
    public void run() {
        // 실제 우리가 쓰레드에서 수행할 작업
        for (int i = 0; i < 100; i++) {
            System.out.print("*");
        }
    }
}
public class Main {
    public static void main(String[] args) {
        TestThread thread = new TestThread();
        thread.start();
    }
}

↓Runnable 예시

더보기
public class TestRunnable implements Runnable{
    @Override
    public void run() {
        // 쓰레드에서 수행할 작업 정의
        for (int i = 0; i < 100; i++) {
            System.out.print("$");
        }
    }
}
public class Main {
    public static void main(String[] args) {
		Runnable run = new TestRunnable();
		Thread thread = new Thread(run);

        thread.start();
    }
}

↓람다식 예시

더보기
package sparta_nbc.Syntax.thread;
        Runnable task = () -> {
            // TestThread 등에서 run()메서드에 적었던 부분을 여기다 적는다 라고만 알아두자
            int sum = 0;
            for (int i = 0; i < 50; i++) {
                sum += i;
                System.out.println(sum);
            }
            System.out.println(Thread.currentThread().getName() + " 최종 합 : " + sum);
        }; // 여기까지가 람다식

        Thread thread1 = new Thread(task); // Thread() 안에 Runnable 변수를 넣어주는 건 동일
        thread1.setName("thread1"); // 쓰레드 이름 정해주기
        Thread thread2 = new Thread(task);
        thread2.setName("thread2");

        thread1.start();
        thread2.start();
    }
}

 

↓싱글 쓰레드 예시

더보기
package sparta_nbc.Syntax.thread.single;

public class Main { // 엄밀히 말하면 Main 쓰레드 위에 thread1을 만들어 구동했으므로 멀티 쓰레드지만 예시를 위한거니 넘긴다
    public static void main(String[] args) {
        Runnable task = () -> {
            System.out.println("2번 => " + Thread.currentThread().getName());
            for (int i = 0; i < 100; i++) {
                System.out.print("$");
            }
        };

        System.out.println("1번 => " + Thread.currentThread().getName());
        Thread thread1 = new Thread(task);
        thread1.setName("thread1");

        thread1.start();
        // 1번 => main
        // 2번 => thread1
        // $$$.....
    }
}

↓멀티 쓰레드 예시

더보기
package sparta_nbc.Syntax.thread.multi;

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

        // 동시 진행이기에 걸리는 시간이나, 동작을 예측할 수 없다.

        // 1st
        Runnable task = () -> {
            for (int i = 0; i < 100; i++) {
                System.out.print("$");
            }
        };

        // 2nd
        Runnable task2 = () -> {
            for (int i = 0; i < 100; i++) {
                System.out.print("*");
            }
        };


        Thread thread1 = new Thread(task);
        thread1.setName("thread1");
        Thread thread2 = new Thread(task2);
        thread2.setName("thread2");

        thread1.start();
        thread2.start();
        // 동시 진행이기에 순서 보장 X
        // *********$$$$$$$$$$$$$$$$$********$$$$$$$...........
    }
}

 

데몬 쓰레드와 사용자 쓰레드

 

데몬 쓰레드 : 보이지 않는 곳(background)에서 실행되는 낮은 우선순위를 가진 쓰레드

낮은 우선순위 = 상대적으로 다른 쓰레드에 비해 리소스를 적게 할당 받는다. => 데몬 쓰레드의 작업이 끝나기 전에 나머지 쓰레드들의 작업이 끝나면 데몬 쓰레드의 작업도 멈춘다.

보조적인 역할을 하며 대표적으로 메모리 영역을 정리해주는 가비지 컬렉터(GC)가 있다.

데몬 쓰레드 설정 방법

Runnable demon = () -> {
    // ...
};

Thread thread = new Thread(demon);
thread.setDaemon(true); // true로 설정시 데몬스레드로 실행됨

 

사용자 쓰레드 : 보이는 곳(forweground)에서 실행되는 높은 우선순위를 가진 쓰레드

프로그램 기능을 하며 대표적으로 메인 쓰레드가 있다.

기존 쓰레드들이 사용자 쓰레드이다.

 

멀티 쓰레드를 사용하며 각 쓰레드 작업의 중요도에 따라 우선순위를 부여할 수 있다.

중요한 쓰레드의 우선순위를 높게 하면 더 많은 작업시간을 부여받아 더 빠르게 처리 될 수 있다.

쓰레드의 우선순위는 생성될 때 정해진다.

우선순위는 최대(MAX_PRIORITY = 10), 최소(MIN_PRIORITY = 1), 보통(NROM_PRIORITY = 5)로 나뉘고 더 자세하게 1~10으로 나눌 수도 있다. 이 우선순위의 범위는 운영체제가 아니라 JVM에서 정한 우선순위이다.

setPriority()로 설정할 수 있고 getPriority()로 확인 가능

*우선순위가 높으면 먼저 종료될 확률이 높을 뿐 무조건은 아님

 

쓰레드를 하나하나 관리할 수 없기 때문에, 관련이 있는 쓰레드끼리 묶어서 관리할 수 있다. 이를 쓰레드 그룹이라고 한다.

쓰레드들은 기본적으로 JVM이 시작될 때 생기는 system 그룹에 포함된다.

메인 쓰레드도 시스템 쓰레드 그룹에 포함되어 있다

모든 쓰레드들은 반드시 하나의 그룹에 포함되어 있어야 한다

그룹을 지정받지 못한 쓰레드들은 자신을 생성한 그룹과 우선순위를 상속받는데 우리가 생성하는 쓰레드는 모두 메인 쓰레드 하위에 포함되므로 따로 지정하지 않으면 자동으로 메인 쓰레드에 포함된다.

↓쓰레드 그룹 코드 예시

더보기
package sparta_nbc.Syntax.thread.group;

public class Main {
    public static void main(String[] args) {
        Runnable task = () -> {
            while (!Thread.currentThread().isInterrupted()) { // 지정된 시간이 지날 때까지 아래 무한 반복
                try {
                    Thread.sleep(1000); // 매초 아래 반복
                    System.out.println(Thread.currentThread().getName());
                } catch (InterruptedException e) {
                    break;
                }
            }
            System.out.println(Thread.currentThread().getName() + " Interrupted");
        };

        // ThreadGroup 클래스로 객체를 만듭니다.
        ThreadGroup group1 = new ThreadGroup("Group1");

        // Thread 객체 생성시 첫번째 매개변수로 넣어줍니다.
        // Thread(ThreadGroup group, Runnable target, String name)
        Thread thread1 = new Thread(group1, task, "Thread 1");
        Thread thread2 = new Thread(group1, task, "Thread 2");

        // Thread에 ThreadGroup 이 할당된것을 확인할 수 있습니다.
        System.out.println("Group of thread1 : " + thread1.getThreadGroup().getName());
        System.out.println("Group of thread2 : " + thread2.getThreadGroup().getName());

        thread1.start();
        thread2.start();

        try {
            // 현재 쓰레드를 지정된 시간동안 멈추게 합니다.
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // interrupt()는 일시정지 상태인 쓰레드를 실행대기 상태로 만듭니다.
        group1.interrupt();
        // Group of thread1 : Group1
        // Group of thread2 : Group1
        // Thread 2  // 1초 경과
        // Thread 1
        // Thread 1  // 2초 경과
        // Thread 2
        // Thread 2  // 3초 경과
        // Thread 1
        // Thread 1  // 4초 경과
        // Thread 2
        // Thread 2 Interrupted  // 5초가 지나서 while문 탈출
        // Thread 1 Interrupted
    }
}

 

쓰레드 상태와 제어

위 쓰레드 그룹 코드에서 나왔던 sleep, interrupt 등이 그 예시다.

위 그림처럼 쓰레드는 실행과 대기를 반복하며 run()을 수행한다.

쓰레드는 일시정지 시킬 수 있는데, 이때는 실행할 수 없는 상태가 된다.

다시 실행하기 위해서는 실행 대기 상태가 되야 한다.

아래는 쓰레드 상태 정리표다.

상태 Enum(= 상수) 설명
객체생성 NEW 쓰레드 객체 생성, 아직 start() 메서드 호출 전의 상태
실행대기 RUNNABLE 실행 상태로 언제든지 갈 수 있는 상태
일시정지 WAITING 다른 쓰레드가 통지(notify) 할 때까지 기다리는 상태
일시정지 TIMED_WAITING 주어진 시간 동안 기다리는 상태
일시정지 BLOCKED 사용하고자 하는 객체의 Lock이 풀릴 때까지 기다리는 상태
종료 TERMINATED 쓰레드의 작업이 종료된 상태

쓰레드 제어

이제 쓰레드의 상태를 설정하는 장치들을 알아보자

  • sleep()
    • 현재 쓰레드를 지정된 시간동안 멈추게 할 수 있다.
    • 특정 쓰레드를 지칭해서 멈추게 하는 메서드가 아니라서 static 메서드이고, 따라서 객체를 지정할 필요도 없다.
    • 위 특성 때문에 다른 쓰레드에서는 객체를 사용하더라도 sleep으로 멈추게 할 수 없다.
    • sleep메서드는 예외(InterruptedException)를 throws하는 메서드이기 때문에 클래스에서 throw를 해서 회피하거나 try - catch문을 사용해야한다.
    • InterruptedException은 아래에 있는 실행 대기 상태로 만드는 interrupted() 메서드가 호출되면 sleep을 무시하고 실행대기 상태로 만들 수 있기 때문에 예외 처리
  • interrupt()
    • 일시정지 상태인 쓰레드를 실행대기 상태로 만든다.
    • sleep상태에서 이 메서드를 만나면 InterruptedException가 발생하고, catch문을 반환한다.
    • 또한 쓰레드에서 isInterrupted()를 통해 interrupt된 상태인지 확인 가능
  • join()
    • 정해진 시간동안 지정한 쓰레드가 작업하는 것을 기다린다.
    • 시간을 정하지 않으면 지정한 쓰레드가 작업을 끝낼 때까지 기다린다.
    • sleep()과 마찬가지로 기다리는 와중 interrupt를 만나면 기다리는걸 무시하기 때문에 예외처리를 해야한다.
  • yield()
    • 남은 시간을 다음 쓰레드에게 양보하고 쓰레드 자신은 실행 대기상태가 된다. 아래는 예시
더보기
package sparta_nbc.Syntax.thread.yield;

public class Main {
    public static void main(String[] args) {
        Runnable task = () -> {
            try {
                for (int i = 0; i < 10; i++) { // 1초마다 현재 쓰레드 이름 출력 10번 반복
                    Thread.sleep(1000);
                    System.out.println(Thread.currentThread().getName());
                }
            } catch (InterruptedException e) { // sleep 상태에서 interrupt 만나면 yield 실행
                Thread.yield();
            }
        };

        Thread thread1 = new Thread(task, "thread1");
        Thread thread2 = new Thread(task, "thread2");

        thread1.start(); // thread1 1초마다 출력 10번 반복
        thread2.start(); // thread2 1초마다 출력 10번 반복

        // 메인 쓰레드 5초 정지
        try {
            Thread.sleep(5000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        thread1.interrupt(); // 위에서 5초 지난 뒤 thread1에서 InterruptedException 유도

        // thread1 // 1초 경과
        // thread2
        // thread1 // 2초 경과
        // thread2
        // thread1 // 3초 경과
        // thread2
        // thread2 // 4초 경과
        // thread1
        // thread2 // 5초에 thread1은 InterruptedException 발생, yield()로 시간 양보, 대기 상태로 전환 
        // thread2 // 6초 경과
        // thread2 // 7초 경과
        // thread2 // 8초 경과
        // thread2 // 9초 경과
        // thread2 // 10초 경과, 반복문 정지
    }
}
  • synchronized
    • 멀티 쓰레드의 경우 여러 쓰레드가 한 프로세스에서 자원을 공유하기 때문에 작업 중 충돌이 생길 수 있다.
    • 이런 일을 방지하고자 한 쓰레드가 작업 중인 공간을 다른 쓰레드가 침범하지 못하도록 막는 것을 쓰레드 동기화(synchronized)라고 한다.
    • 다른 쓰레드가 작업 중인 공간에 들어오지 못하게 막는 코드들을 '임계 영역'으로 설정해야 한다.
    • 임계 영역에는 Lock을 가진 단 하나의 쓰레드만 출입이 가능하다.
    • 즉, 임계 영역은 한번에 한 쓰레드만 사용 가능
    • 임계 영역 지정 방법
      1. 메서드 전체를 임계 영역으로 지정한다 => 반환 타입 앞에 synchronized 붙이기
      2. 특정 영역(ex.코드 묶음 앞에)을 임계 영역으로 지정한다 => 해당 객체 앞에 synchronized 붙이고 {}로 감싸기
더보기

synchronized 없을 때

public class Main {
    public static void main(String[] args) {
        AppleStore appleStore = new AppleStore();

        Runnable task = () -> {
            while (appleStore.getStoredApple() > 0) {
                appleStore.eatApple();
                System.out.println("남은 사과의 수 = " + appleStore.getStoredApple());
            }

        };

        for (int i = 0; i < 3; i++) {
            new Thread(task).start();
        }
    }
}

class AppleStore {
    private int storedApple = 10;

    public int getStoredApple() {
        return storedApple;
    }

    public void eatApple() {
        if (storedApple > 0) {
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            storedApple -= 1;
        }
    }
}

남은 사과의 수가 뒤죽박죽 섞일 뿐만 아니라 없는 사과를 먹는 경우도 발생

 

synchronized 추가

public class Main {
    public static void main(String[] args) {
        AppleStore appleStore = new AppleStore();

        Runnable task = () -> {
            while (appleStore.getStoredApple() > 0) {
                appleStore.eatApple();
                System.out.println("남은 사과의 수 = " + appleStore.getStoredApple());
            }

        };

        for (int i = 0; i < 3; i++) {
            new Thread(task).start();
        }
    }
}

class AppleStore {
    private int storedApple = 10;

    public int getStoredApple() {
        return storedApple;
    }

    public void eatApple() {
        synchronized (this) { // synchronized로 코드 감싸기
            if(storedApple > 0) {
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                storedApple -= 1;
            }
        }
    }
}
  • wait(), notify()
    • wait과 notify는 같이 쓰는 한 쌍의 메서드다. 위에서 임계 영역을 통해 막아놓은 코드에서 작업을 수행하고 더 이상 작업할 게 없으면 다른 쓰레드가 들어올 수 있게 Lock을 반납해야 하는데, 이를 맡는 메서드가 wait()이다.
    • wait()을 통해 작업을 끝낸 쓰레드는 Lock을 반납하고 기다리게 한다.
    • 여기서 쓰레드가 기다리는 곳은 해당 객체의 대기실(waiting pool)이다.
    • 다른 쓰레드가 Lock을 얻어 해당 코드를 수행하고
    • Lock을 반납하고 기다리던 쓰레드는 진행할 수 있는 상황이 되면 notify()로 호출되어 작업을 이어 진행한다.
    • 이때 당연히 하나의 쓰레드만 호출될 수 있고, 해당 객체에서 작업중인 쓰레드가 있다면 호출될 수 없다.

wait, notify 예시코드와 Lock, Condition은 내일 이어서!

연휴라고 풀어지지 말고 개인과제를 위해 더 빡세게!

내일 저녁 전까진 5주차 풀강하기!!

'TIL > JAVA' 카테고리의 다른 글

23.06.06  (0) 2023.06.06
23.05.27  (0) 2023.05.27
23.05.25  (0) 2023.05.25
23.05.24  (0) 2023.05.24
23.05.23  (0) 2023.05.23