[Java] Thread란? - (1)

jinni·2022년 12월 8일
0

Java

목록 보기
1/5

해당 내용은 자바의 정석을 공부하면서 정리한 내용 입니다.

음.. Thread 분량이 많기 때문에 파트를 좀 나눠서 포스팅 할 예정이다.

Thread

프로세스와 쓰레드

프로세스(process)란, 간단히 말해서 실행 중인 프로그램이다. 프로그램을 실행하면, OS로부터 실행에 필요한 자원(메모리)을 할당받아 프로세스가 된다.

프로세스는 프로그램을 수행하는 데 필요한 데이터와 메모리 등의 자원 그리고 쓰레드로 구성되어 있다. 프로세스의 자원을 이용해서 실제로 작업 수행하는 것이 바로 쓰레드다. 그래서 모든 프로세스에는 최소한 하나 이상의 쓰레드가 존재하며, 둘 이상의 쓰레드를 가진 프로세스를 멀티쓰레드 프로세스라고 한다.

💡 싱글 쓰레드: 자원 + 쓰레드 멀티 쓰레드; 자원 + 쓰레드 + 쓰레드 + 쓰레드 + …

멀티 태스킹과 멀티 쓰레딩

현재 우리가 사용하는 대부분의 OS는 멀티태스킹을 지원하기 때문에 여러 개의 프로세스가 동시에 실행될 수 있다. 이와 마찬가지로 멀티쓰레딩 은 하나의 프로세스 내에서 여러 쓰레드가 동시에 작업을 수행하는 것이다. CPU의 코어가 한 번에 단 하나의 작업만 수행할 수 있으므로 실제로 동시에 처리되는 작업의 개수는 코어의 개수와 일치한다.

쓰레드의 수는 언제나 코어의 개수보다 많기 때문에 각 코어가 아주 짧은 시간 동안 여러 작업을 번갈아 가며 수행함으로써 여러 작업들이 모두 동시에 수행되는 것처럼 보인다.

멀티 쓰레딩의 장단점

장점

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

예를 들어, 메신저로 채팅하면서 파일 다운로드 받거나 음성대화를 나눌 수 있는 것이 가능한 이유는 멀티쓰레드로 작성되어 있기 때문이다. 만약, 싱글쓰레드로 작성되어 있다면, 하나의 쓰레드가 완료될 때까지 다른 쓰레드를 작동할 수 없다. (다운로드 받는다면, 다운로드 완료 시까지 음성대화를 나눌 수 없다.)

또한, 싱글 쓰레드로 서버 프로그램을 작성한다면, 사용자의 요청마다 새로운 프로세스를 생성해야 하는데, 이는 쓰레드 생성하는 것에 비해 더 많은 시간과 메모리 공간이 필요하다.

단점

  • 여러 쓰레드가 같은 프로세스 내에서 자원을 공유하면서 작업하기 때문에 발생할 수 있는 동기화(Syschronization), 교착상태(deadlock)와 같은 무제를 고려해야 한다.

교착상태란? 두 쓰레드가 자원을 점유한 상태에서 서로 상대편이 점유한 자원을 사용하려고 기다리느라 진행히 멈춰 있는 상태를 말한다.

쓰레드의 구현과 실행

  1. Thread 클래스를 상속받는 방법
  2. Runnable 인터페이스를 구현하는 방법

위에 두 가지 방식을 선택해도 별 차이는 없지만, Thread 클래스를 상속 받으면, 다른 클래스를 상속 받을 수 없기 때문에 Runnable 인터페이스를 구현하는 것이 일반적이다.

Runnable → 재사용성 높고, 코드의 일관성 유지 ⇒ 객체지향적

1. Thread 클래스 상속
class MyThread extends Thread {
	public void run() {
		// Thread 클래스의 run 메서드 오버라이딩.		
	}
}

2. Runnable 인터페이스 구현
class MyThread implements Runnable {
	public void run() {
		// 해당 인터페이스의 run 메서드 구현
	}
}

예시 코드를 보면 알겠지만, 두 가지 방법 중 무엇을 선택하든 간에, run 메서드를 구현해야 한다.

두 가지 방식의 차이점을 알아보자!

public classThreadEx1 {
public static voidmain(String[] args) {
//쓰레드의 자손 클래스의 인스턴스를 생성
ThreadEx1_1 t1 =newThreadEx1_1();

// Runnable을 구현한 클래스의 인스턴스를 생성
//        Runnable r = new ThreadEx1_2();
        //생성자 Thread(Runnable target)
//        Thread t2 = new Thread(r);
        //위 두줄 간략하게 표현
// Runnable인터페이스를 구현한 클래스의 인스턴스 생성 ->해당 인스턴스를 Thread클래스의 생성자의 매개변수로 제공
Thread t2 =newThread(newThreadEx1_2());

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

static classThreadEx1_1extendsThread {
        @Override
public voidrun() {
for(inti = 0; i < 5; i++) {
//조상인 Thread의 getName호출
//상속 받았기 때문에 그냥 getName
System.out.println(getName());
            }
        }
    }

static classThreadEx1_2implementsRunnable{
        @Override
public voidrun() {
for(inti = 0; i < 5; i++) {
// Thread.currentThread()현재 실행 중인 쓰레드 반환
//멤버라고는 run밖에 없기 때문에 Thread클래스의 getName호출하려면 static메서드로 호출해야 한다.
System.out.println(Thread.currentThread().getName());
            }
        }
    }
}

쓰레드의 실행 - start()

쓰레드를 생성하면, start 메서드를 통해 실행시켜줘야 한다.

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

사실, 해당 메서드로 실행시켰다고 바로 실행되는 것은 아니다. 일단, 실행 대기 상태로 존재하다 자신의 차례가 되어야 실행된다.

쓰레드의 실행순서 → OS 스케쥴러를 통해 결정

한 번 종료된 쓰레드는 다시 실행할 수 없다. (쓰레드 당 start 메서드가 한 번 호출할 수 있다.)

두 번 호출할 시, IllegalThreadStateException 을 뱉는다. 그렇기 때문에 한 번 실행 후 인스턴스를 다시 생성해서 실행시켜 주어야 한다.

start()와 run()

main 메서드에서 run() 을 호출하는 것은 생성된 쓰레드를 실행시키는 것이 아니라, 단순히 클래스에 선언된 메서드를 호출시키는 것일 뿐이다.

반면, start() 는 새로운 쓰레드가 작업을 실행하는데 필요한 호출 스택을 생성한 다음에 run() 을 호출해 생성된 스택에 run() 이 첫 번째로 올라가게 한다. (모든 쓰레드는 독립적인 작업을 수행 → 자신만의 스택 존재)

프로그램의 종료는 실행 중인 사용자 쓰레드가 없을 때 종료된다.

packagechap13;

//한 쓰레드가 예외로 종료하게 되어도,다른 쓰레드의 실행에는 영향을 미치지 않는다.
public class ThreadEx2 {
public static voidmain(String[] args) {
    ThreadEx2_1 t1 =newThreadEx2_1();
		// start -> 쓰레드 실행
		// main은 에러 터짐.
		t1.start();
}

static class ThreadEx2_1 extends Thread {
    @Override
		public void run() {
				throwException();
    }
}

public static void throwException() {
		//얘는 run메서드와 함께 다른 호출 스택에 쌓여있다.
		try {
				throw newException();
    } catch(Exception e) {
        e.printStackTrace();
		    }
    }
}

packagechap13;

public class ThreadEx3 {
public static voidmain(String[] args) {
    ThreadEx3_1 t1 =newThreadEx3_1();
		// 얘는 그냥 run 메서드를 실행 시키는 것.
    t1.run();
}

static class ThreadEx3_1 extends Thread {
		public void run() {
				throwException();
    }
}

public static void throw Exception() {
		try{
				throw newException();
    }catch(Exception e) {
        e.printStackTrace();
        }
    }
}

run() 은 해당 메서드를 실행시키는 것 뿐이고, start() 는 쓰레드를 실행시키는 것이다. 그래서 첫 번째 예시는 main 과 run() 이 호출 스택을 각각 부여 받는다. (main도 쓰레드기 때문)

하지만, 두 번째 예시는 run() 을 실행했기 때문에 하나의 스택에서 메서드를 관리한다.

싱글쓰레드와 멀티쓰레드

싱글코어에서 두 개의 작업을 하나의 쓰레드로 처리하는 경우와 두 개의 쓰레드로 처리하는 경우를 가정해보자.

하나의 쓰레드로 두 작업을 처리하는 경우는 한 작업을 마친 후 다른 작업을 시작.

두 개의 쓰레드로 작업하는 경우는 짧은 시간동안 2개의 쓰레드가 번갈아가면서 작업을 동시에 수행한다.

이 둘은 작업을 수행하는 시간은 같지만, 오히려 두 개의 쓰레드로 작업한 시간이 싱글쓰레드로 작업한 시간보다 더 길리게 된다. 그 이유는 쓰레드 간의 작업 전환(context switching)이 걸리기 때문이다.

packagechap13;

public class ThreadEx4 {
public static void main(String[] args) {
		longstartTime = System.currentTimeMillis();

		for(inti = 0; i < 300; i++) {
        System.out.printf("%s",newString("-"));
    }
    System.out.println("소요시간1: " + (System.currentTimeMillis() - startTime));

		for(inti = 0; i < 300; i++) {
        System.out.printf("%s",newString("|"));
    }
        System.out.println("소요시간2: " + (System.currentTimeMillis() - startTime));
    }
}

packagechap13;

public classThreadEx5 {
static longstartTime= 0;

public static void main(String[] args) {
    ThreadEx5_1 th1 =newThreadEx5_1();
    th1.start();
		startTime= System.currentTimeMillis();

		for(inti = 0; i < 300; i++) {
        System.out.printf("%s",newString("-"));
    }
    System.out.println("소요시간1: " + (System.currentTimeMillis() - ThreadEx5.startTime));
    }

static class ThreadEx5_1 extends Thread {
    @Override
		public voidrun() {
		for(inti = 0; i < 300; i++) {
        System.out.printf("%s",newString("|"));
    }
    System.out.println("소요시간2: " + (System.currentTimeMillis() - ThreadEx5.startTime));
        }
    }
}

테스트 할 때, 매번 시간이 달라지는 것을 알 수 있다.

왜?

OS의 프로세스 스케쥴러의 영향을 받기 때문이다. 그래서 JVM의 쓰레드 스케쥴러에 의해 어떤 스레드가 얼마동안 실행될 것인지 결정된다.

프로세스도 프로세스 스케쥴러의 의해서 실행순서와 실행시간이 결정되기 때문에 매 순간 상황에 따라 프로세스에게 할당되는 실행시간이 일정하지 않고 쓰레드에게 할당되는 시간 역시 일정하지 않게 된다.

따라서, 쓰레드는 이러한 불확실성을 가지고 있다는 것을 염두 !!

쓰레드의 우선 순위

쓰레드는 우선 순위(priority)라는 속성을 가지고 있다. 이 우선 순위의 값에 따라 쓰레드가 얻는 실행시간이 달라진다. 또한, 쓰레드가 수행하는 작업의 중요도에 따라 쓰레드의 우선 순위를 서로 다르게 지정할 수 있다.

우선 순위가 높을 수록 더 많은 작업 시간을 부여 받는다.

쓰레드 우선 순위 지정하기

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   // 보통 우선 순위

위와 같이 쓰레드는 1 ~ 10 까지의 우선 순위를 정할 수 있다.

높으면 높을 수록 우선 순위가 높다.

그러나, 굳이 굳이 우선 순위에 차등을 두어 쓰레드를 실행하기 위해서는, 특정 OS의 스케쥴링 정책과 JVM의 구현을 직접 확인해봐야 한다. Java는 쓰레드 우선 순위를 강제하지 않아, 순위와 관련된 구현이 JVM 마다 다를 수 있다. 만일 한다 하더라도, OS의 스케쥴러에 종속적이라서 어느 정도 예측만 가능.

차라리 우선 순위 부여 대신 작업에 우선 순위를 두는 것이 좋다. (PriorityQueue 우선 순위 큐)

우선 순위 설정은 쓰레드 실행 전에만 설정할 수 있다!

쓰레드 그룹 (Thread Group)

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

또한, 쓰레드 그룹에 다른 쓰레드 그룹을 포함 시킬 수 있다. 이는 자신이 속한 쓰레드 그룹이나 하위 쓰레드 그룹은 변경할 수 있지만, 다른 쓰레드 그룹의 쓰레드를 변경할 수는 없다.

쓰레드를 그룹에 포함시키려면 아래와 같이 Thread 생성자 사용

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)

모든 쓰레드는 반드시 그룹에 속해야 하기 때문에 따로 그룹을 지정해주지 않으면 자신을 생성한 쓰레드의 그룹으에 속한다.

자바 어플리케이션이 실행되면 JVM은 main과 system이라는 쓰레드 그룹을 생성해 JVM 운영에 필요한 쓰레드들을 생성해서 이 쓰레드 그룹에 포함시킨다.

예를 들어, main 메서드를 수행하는 main 이라는 메서드는 main 쓰레드 그룹에 속하고, CG 를 수행하는 Finalizer 쓰레드는 system 그룹에 속한다.

package chap13;

public class ThreadEx6 {
    public static void main(String[] args) {
        ThreadGroup main = Thread.currentThread().getThreadGroup();
        ThreadGroup grp1 = new ThreadGroup("Group1");
        ThreadGroup grp2 = new ThreadGroup("Group2");

        // ThreadGroup(ThreadGroup parent, String name)
        ThreadGroup subGrp1 = new ThreadGroup(grp1, "SubGroup1");

        // 쓰레드 그룹 grp1의 최대 우선 순위를 3 변경
        grp1.setMaxPriority(3);

        Runnable r = new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(1000); // 1초 멈춤
                } catch (InterruptedException e) {}
            }
        };
        // Thread(ThreadGroup target, Runnable r, String name)
        new Thread(grp1, r, "th1").start();
        new Thread(subGrp1, r, "th2").start();
        new Thread(grp2, r, "th3").start();

        // activeCount -> 쓰레드 그룹에 속해 있는 활성화 쓰레드 수 반환
        // activeGroupCount -> 쓰레드 그룹에 속해 있는 활성화 쓰레드 그룹의 수 반환
        System.out.println("List of ThreadGroup: " + main.getName()
        + ", Active ThreadGroup: " + main.activeGroupCount()
        + ", Active Thread: " + main.activeCount());

        // list() -> 쓰레드 그룹에 속한 쓰레드와 하위 쓰레드 그룹에 대한 정보 출력
        main.list();
    }
}

데몬 쓰레드(daemon thread)

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

따라서, 일반 쓰레드가 모두 종료되면, 데몬 쓰레드는 강제적으로 자동 종료된다.

그 이유는 보조 역할이기 때문에 일반 쓰레드가 종료된다면, 존재 이유가 사라지기 때문이다.

하나의 특징으로는 무한루프조건문을 이용해서 실행 후 대기하고 있다가 특정 조건을 만족하면, 작업을 수행하고 다시 대기하도록 작성한다.

데몬 쓰레드의 예 - GC, 워드프로세서의 자동 저장, 화면 자동 갱신

사용 방법

일반 쓰레드의 작성 방법과 실행 방법이 같다. 다만, 쓰레드를 생성한 다음 실행하기 전에,

setDaemon(true)

호출해주면 된다.

그리고 데몬 쓰레드가 생성한 쓰레드는 자동적으로 데몬 쓰레드가 된다는 점이 있다.

profile
조금씩 천천히 꾸준하게

0개의 댓글