관리 메뉴

제뉴어리의 모든것

[Section1][Java] 심화 - 스레드 본문

코드스테이츠/정리 블로깅

[Section1][Java] 심화 - 스레드

제뉴어리맨 2022. 7. 20. 01:44

스레드란

스레드란 프로세스에서 작업을 처리하는 주체이다.

프로그램이 돌아가고 있다면 이것은 쓰레드가 프로세스가 할당받은 메모리를 가지고 작업을 하고 있는것이다.

쉽게 표현하자면, 프로세스는 공장이라는 공간이고 그 공장이 돌아가려면 그 안에 실제 일하는 사람이 최소 1명은 있어야 할것이다. 이때 일꾼이 스레드인것이다.

 

+ 프로세스란?

실행중인 프로그램 (앱)을 말한다.

프로세스의 구성은

스레드, 컴퓨터 자원(예를들면 메모리), 데이터 이다.

 

+ 싱글스레드란?

한개의 스레드가 작동하는것

 

+ 멀티스레드란?

다수의 스레드가 작동하는것

 

+ 프로그램 실행시 내부 진행상황

사용자가 앱을 실행시키면 앱은 프로세스가 되고 os는 그 프로세스에게 메모리를 할당하여 준다.

그리고 쓰레드가 할당받은 메모리를 가지고 프로그램의 코드를 진행시켜 작업을 수행한다

 

메인 스레드

main 메소드를 실행하며 프로세스에 기본적으로 최소 한개는 존재하는 스레드이다.

나머지 다른 스레드들은 메인스레드에서 스레드를 생성하여 실행시킨다.

 

싱글 스레드

스레드가 하나만 작동하는 환경을 말한다.

싱글 스레드 환경은 순간에 하나의 작업만을 처리 할 수 있다.

EX : 채팅중에 파일전송 불가. 파일 전송할때는 채팅불가 (채팅글을 쓸수도 읽을수도 없는것이다)

 

멀티 스레드

다수의 스레드가 작동하는 환경을 말한다.

동시에 여러 작업을 병렬처리가 가능하다.

동시에 여러작업을 처리 할 수 있다고 무조건적으로 좋은것도 아니며, 스레드를 무작정 만드는것도 불가능하다.

왜냐하면 만들 수 있는 스레드의 갯수가 정해지진 않았으나, 스레드가 하나 생길때마다 호출스택이란 공간을 필요로 한다.

쓰레드가 코드를 읽으며 메소드를 호출할때마다 호출스택이란 공간에 해당 메소드를 적재한다.

그렇게 호출되는 메소드마다 호출 스택에 적재되어(메소드 처리를 하고 다시 돌아갈 주소값, 지역변수 등의 데이터가 적재) 작업이 처리하게 되는데, 이 호출스택 공간이란것도 결국 프로세스가 os로부터 할당받은 메모리에서 만들어지기 때문에 무조건 많이 만들 수는 없다.

 

+ 멀티 태스킹

동시에 여러 작업(프로세스)이 가능한 것을 말한다.

그리고 그 하나하나의 프로세스가 또 멀티 쓰레드가 가능한것이다.

동시에 여러 쓰레드로 프로그램의 메소드를 호출하여 사용 가능한 것이다.

 

멀티 스레드의 진실

위에서 계속 멀티 스레드는 동시에 여러 스레드로 작업이 가능하다고 했다.

그러나 사실은 그렇게 보이게끔 하는것 뿐이다.

스레드가 10개 생성되어 있다고 무조건 동시에 10개의 작업이 가능한 것이 아니다.

실제로 그 순간에 처리 가능한 스레드의 갯수는 cpu 코어의 갯수와 동일하다.

즉, 듀얼 코어라면 실제로 한 순간에 처리가 가능한 스레드는 2개이고,

싱글 코어라면 1개인것이다.

그런데 왜 자꾸 다수의 쓰레드가 동시에 작업이 가능하다고 했을까?

그것은 한 스레드의 처리를 완전히 다 끝내고 다른 스레드를 처리하는게 아니라

한 스레드의 처리를 일부분만 하고 바로 다른 스레드의 처리를 또 일부분하고 이런식으로 짧게짧게 서로 번갈아가면서 처리하기 때문에 동시에 처리가 되는것처럼 보이는것이다.

 

멀티 스레딩의 장점

  • CPU의 사용을 향상시킨다.
  • 자원을 효율적으로 사용 가능
  • 사용자에 대한 응답성이 향상된다. (채팅을 하면서도 상대로부터 전송 받은 파일을 바로 받을  수 있다)
  • 작업이 분리되어 코드가 간결화됨

 

멀티 스레딩의 단점

  • 교착상태 발생 가능성 생김
  • 공유 데이터의 동기화 처리가 필요함
  • 모든 작업의 총 처리시간은 실제 순차적인 싱글스레드에 비해 더 걸릴 수도 있다
    왜냐하면 멀티 스레딩은 하나의 스레드가 하나의 작업을 조금 처리하고 다른 스레드에게 작업을 처리할 수 있는 차례를 부여 받을때마다 스레드간의 작업 전환 (context switching)이 이루어져야 하기 때문이다.
    쉽게 생각하면, 작업 할 수 있는 컴퓨터는 하나이고 다수의 사람이 있다.
    그런데 그 다수의 사람은 각각 작업 해야하는 업무가 다 다르다.
    그러면 어떤 사람이 본인의 작업을 일부분만 하고 다른 사람에게 자리를 넘겨주기 위해 자리에서 일어나고,
    컴퓨터 자리를 넘겨 받을 사람은 그 자리로 가서 자리에 앉아 자신이 사용하는 프로그램을 켜서 작업을 해야한다.
    이러한 상황이 컴퓨터 사용 권한을 받을때마다 반복되는것이다.
    멀티 스레드 또한 이러한 상황과 유사하다.

 

스레드의 생성과 실행

스레드 생성에는 두가지 방법이 있다.

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

그러나 실행 방법은 같다. 결국 Thread 클래스 안에 존재하는 start() 메소드를 실행하는 것이다.

 

  • Runnable 인터페이스 구현 방법
public class ThreadConstructPractice {
    public static void main(String[] args) {

        //Runnable 인터페이스를 사용한 Thread 생성과 사용
        ThreadClass1 threadClass1 = new ThreadClass1();
        Thread thread = new Thread(threadClass1);
        thread.start();


        for (int i = 0; i < 50; i++) {
            System.out.print("M");
        }

        System.out.println();
    }
}

class ThreadClass1 implements Runnable {
    @Override
    public void run() {
        for(int i = 0; i < 50; i++)
        {
            System.out.print("T");
        }
        
          System.out.println("현재 작업중인 쓰레드 이름 : " + Thread.currentThread().getName());
    }
}

 

  • Thread 상속 구현 방법
public class ThreadConstructPractice2 {
    public static void main(String[] args) {

        ThreadClass2 threadClass2 = new ThreadClass2();
        threadClass2.start();

        for(int i = 0; i < 50; i++)
        {
            System.out.print("M");
        }
    }
}

class ThreadClass2 extends Thread { 

    @Override
    public void run() {
        for(int i = 0; i < 50; i++)
        {
            System.out.print("T");
        }
        
        System.out.println("현재 작업중인 쓰레드 이름 : " + getName());
    }
}

 

위에 코드를 보면 
run() 메소드 body 최하단에 현재 작업중인 스레드의 이름을 출력하기 위한 코드가 있다.

그러나 Runnable 인터페이스를 구현한 클래스는 Thread 클래스를 상속받지 않았으므로,
Thread 클래스의 멤버 메소드인 getName() 직접 호출하지 못하고 정적메소드를 호출하고 있다.

 

위에 코드를 해석 하자면
어떤 방법을 하든지 결국 run() 메소드의 내용을 정의해주고, start() 메소드를 호출하면 새로운 쓰레드가 생성되고 그 쓰레드는 새로 생성된 호출스택(call stack)을 할당 받아 사용하게 된다. 그리고 이 호출스택에 우리가 정의한 run() 메소드가 가장 먼저 적재되는것이다.

 

 

 

스레드 실행 - start()

사실 스레드의 start() 메소드를 호출한다고 스레드가 바로 시작 되는것은 아니다.

start() 메소드의 역할은 앞에서 말했다시피

1. 새로운 스레드를 생성한다

2. 새로운 호출스택 영역을 만들어 스레드에게 할당한다 (호출스택은 그냥 스레드가 실행할 메소드들이 적재되어 메모리를 사용하는 공간이라 생각하자)

3. 호출 스택 영역에 스레드의 run() 메소드를 최상위에 적재한다 (비어있는 호출 스택이다)

4. 새로 생성된 스레드는 "실행대기" 상태가 된다 (os에게 작업처리 권한을 받으면 언제든 스레드가 작업 할 수 있는 상태)

이렇게 실행대기 상태가 된 스레드가 바로 실행상태가 되는것은 아니다.

스레드가 여러개일 경우 각각의 호출스택에서 최상위에 존재하는 작업(메소드)들은 os 스케줄러에 의해 자신의 순서가 되면 정해진 그제서야 "실행상태" 가 되고 정해진 시간동안 작업을 수행한다,

 

 

 

그리고 그 작업 처리 권한의 순서는 os의 스케줄러에 의해 결정된다.

 

+ 주의사항

한번 종료된 스레드는 다시 실행시킬 수 없다. start()가 한번만 호출 가능하므로 스레드를 다시 만들어야 한다.

 

스레드의 우선순위

스레드는 우선순위라는 속성(멤버 변수)를 가지고 잇다.

이 우선순위 값에 따라 부여받는 작업 처리 시간이 다르다.

그리고 각 스레드마다 이 우선순위 값을 다르게 하여 처리시간을 조절할 수도 있다.

우선순위 값의 범위는 1 ~ 10이며, 숫자가 높을수록 우선순위가 높은것이다.

 

- 우선순위 주의사항

1. 우선순위는 상속이된다.

이말이 무슨말이냐면, 만약 main 스레드 (main 스레드는 기본값이 5이다)에서 어떤 스레드를 생성하였다.

그렇다면 그 만들어진 스레드는 main 스레드의 우선순위를 그대로 상속받아 우선순위가 5가 된다.

2. 우선순위 설정은 스레드 실행 전에만 설정이 가능하다.

 

그러나 이 우선순위는 os마다의 스케줄링 방식이 다르고, 멀티코어 상황에 따라 또 다르다.

그러므로 우선순위를 높게 한다고 해서 꼭 무조건 많은 시간과 더 많은 작업처리권한을 얻을거란 보장은 없다.

 

스레드 그룹

스레드를 그룹에 속하게 하여 관리 할 수도 있다.

그룹이 생겨난 이유는 보안상의 이유로 다른그룹에 속한 스레드의 내용을 변경할 수 없게 하기 위해 사용한다.

 

 

데몬 스레드

데몬 스레드는 일반 스레드를 돕는 보조적인 역할을 하는 스레드이다.

 

 

스레드 동기화

여러 쓰레드가 존재할때, 여러 쓰레드가 다같이 공유하는 공통자원을 동시에 접근하여 데이터를 추가, 삭제, 수정 할때 발생되는 데이터의 불일치를 막고 각 쓰레드가 공통된 자원(공유데이터)을 일치된 상태에서 사용하는것.
즉, 우리가 핸드폰을 컴퓨터와 동기화 시킨다는것은 무엇인가, 핸드폰에 있는 데이터를 컴퓨터로 옮겨서 핸드폰에 있는 데이터를 컴퓨터에도 동일(일치)하게 가지고 있는것이다.
이것도 마찬가지다. 쓰레드들이 어떤 데이터(공유데이터)를 쓸때 똑같은 값을 받아 사용하겠다는것이다.
그러나 쓰레드 동기화가 제대로 이루어지지 않은 멀티쓰레딩 처리는 특정 쓰레드가 공유하는 List의 데이터를 코드상에서 삭제하고 있는 찰나에 다른 쓰레드가 끼어들어 삭제가 내부적으로 완벽하게 처리되지 않은 데이터를 가지고가서 자기가 먼저 삭제해버린다면, 원래 삭제 처리를 하던 쓰레드는 내부적인 에러가 발생할것이다.

스레드 동기화는 대표적으로 synchronized 키워드를 사용하여 적용할 수 있다

 

임계영역(Critical section)과 락(Lock)

  • 임계영역
    오직 한 스레드만 접근하여 작업을 할수 있는 영역을 말한다.
    임계영역 설정으로 인해 스레드 동기화가 가능한것이다.
    오직 한 스레드만 접근하도록 했으므로 스레드들이 서로 난입하여 처리중에 처리를 하여 값의 불일치가 일어날 경우를 방지하기 때문이다.

  • 객체의 임계영역에 대한 접근 권한을 의미한다.
    객체당 락은 1개이다.
    만약 어떤 클래스에 동기화 메소드가 두개일 경우.
    두개의 스레드가 각각 다른 동기화 메소드에 접근 하더라도 이미 한 스레드가 락을 받아 임계영역에 들어가있다면
    다른 스레드는 다른 임계영역이여도 락을 지닌 스레드가 락을 반납해야 임계영역에 진입할 수 있다.

대표적 동기화 방법

대표적으로 synchronized 키워드를 쓰는 방법이 있다.

물론 더 많은 방법이 존재하지만 대표적인 synchronized만 알아보겠다.

 

  •  메소드 전체를 임계영역으로 지정
class Account {
	...
	public synchronized boolean withdraw(int money) { //public 접근제어자 뒤에 synchronized
	    if (balance >= money) {
	        try { Thread.sleep(1000); } catch (Exception error) {}
	        balance -= money;
	        return true;
	    }
	    return false;
	}
}

메소드의 접근제어자 뒤에 synchronized 만 넣어주면 된다.

그럼 메소드 진입자체를 락을 받아야만 가능하다.

  • 특정 영역을 임계영역으로 지정
class Account {
	...
	public boolean withdraw(int money) {
			synchronized (this) {
			    if (balance >= money) {
			        try { Thread.sleep(1000); } catch (Exception error) {}
			        balance -= money;
			        return true;
			    }
			    return false;
			}
	}
}

withdraw 메소드 바로 아래에 synchronized (this) { .... } 로 임계영역을 묶어주었다. 

 

스레드 실행 제어 메서드

 

  • sleep(long milliSecond) : milliSecond 동안 스레드를 잠시 멈춤. sleep은 정적메소드(클래스 메소드)로써
    즉, 해당 메소드를 호출한 쓰레드가! 일시정지가 되는것이다.

    +  sleep으로 인해 일시정지 된 쓰레드를 다시 실행대기 상태로 바꾸는 방법
    1. 인자로 전달한 시간만큼이 지난 경우
    2. interrupt()를 호출한 경우 (try, catch 필수, interrupt가 발생하면 일단 예외가 발생된다.)

  • interrupt() : 일시 중지 상태인 스레드를 실행 대기 상태로 복귀시킨다
    interrupt()는 sleep(), wait(), join()에 의해 일시 정지 상태에 있는 스레드들을 실행 대기 상태로 복귀시킨다.
    멈춰있는 쓰레드가 아닌 다른 쓰레드에서 "멈춰있는쓰레드.interrupt()"를 호출하면 기존에 호출되어 스레드를 멈추게 했던 sleep(), wait(), join() 메서드에서 예외가 발생되며, 그에 따라 일시 정지가 풀리게 됩니다. (즉, 멈추게 했던 코드 구문에서 예외발생!)

    +  sleep(), wait(), join()에 의해 일시 정지된 스레드들의 코드 흐름은 각각 sleep(), wait(), join()에 멈춰있다.
    +WAITING, TIMED_WAITING: 스레드의 작업이 종료되지는 않았지만 실행가능하지 않은 (UNRUNNABLE) 일시정지 상태
    TIMED_WAITING은 일시정지 시간이 지정된 경우임.

    + TERMINATED: 스레드의 작업이 종료된 상태

  • - yield() : 다른 스레드에게 실행을 양보합니다.
    해당 메소드를 호출한 쓰레드는 자신에게 남은 실행 시간을 실행 대기열 상 우선순위가 높은 다른 스레드에게 양보합니다.

  • join() : 다른 스레드의 작업이 끝날 때까지 기다립니다.
    join()은 특정 스레드가 작업하는 동안에 자신을 일시 중지 상태로 만드는 상태 제어 메서드입니다.
    인자로 시간을 밀리초 단위로 전달할 수 있으며, 전달한 인자만큼의 시간이 경과하거나, interrupt()가 호출되거나, join() 호출 시 지정했던 다른 스레드가 모든 작업을 마치면 다시 실행 대기 상태로 복귀합니다.

    main 쓰레드에서 특정 쓰레드 thread1의 join() 메소드를 호출했다면,
    thread1이란 쓰레드놈이 참가(join)한것이므로 
    thread1.join() 을 호출한 메인 쓰레드가 일시정지 되어
    thread1의 작업이 다 끝난 후 다시 본인의 작업을 수행한다

    + join()과 sleep()의 유사성
    1. join()을 호출한 스레드는 일시 중지 상태가 됩니다.
    2. try … catch문으로 감싸서 사용해야 합니다.
    3. interrupt()에 의해 실행 대기 상태로 복귀할 수 있습니다.

    + join()과 sleep()의 차이점
    sleep()은 Thread 클래스의 static 메서드입니다. 반면, join()은 특정 스레드에 대해 동작하는 인스턴스 메서드입니다.


  • wait(), notify() : 스레드 간 협업에 사용됩니다.
    스레드를 활용하다보면, 두 스레드가 교대로 작업을 처리해야할 때가 있습니다. 이 때 사용할 수 있는 상태 제어 메서드가 바로 wait()과 notify()입니다.



 

쓰레드의 상태 참조 사이트
https://velog.io/@godkimchichi/Java-6-Thread-State