본문 바로가기
Programming/JAVA

[JAVA] 스레드

 

프로세스 

컴퓨터에서 설치하여 사용하는 이클립스나 워드 프로세서, 브라우저와 같은 코드 덩어리를 애플리케이션이라고 한다. 

설치된 애플리케이션을 실행하게 되면 운영체제(OS)로 부터 메모리의 일정 영역을 할당받고(운영체제도 메모리에 올라간다.) CPU와 HDD를 이용해 동작하는데, 이것을 프로세스라고 부른다. 

일반적으로 운영체제들은 여러 프로세스를 실행할 수 있는데, 이것을 멀티 프로세스라고 부른다. 예컨대 음악 플레이어와 인텔리제이를 동시에 실행하며 채팅을 할 수 있는 것처럼. 

이 개별 프로세스들은 동작을 위해 자신만의 데이터, 메모리 등의 자원과 여러 스레드라는 것으로 구성된다. 그래서 인텔리제이를 여러개 실행시켜도 일반적으로 프로세스 간의 공유는 발생하지 않는다. 

 

스레드

스레드는 프로세스 동작의 최소 단위다. 

모든 프로세스는 하나 이상의 스레드로 구성된다. 둘 이상의 스레드로 구성된 프로세스를 멀티 스레드라고 부른다. 예를 들어 카카오톡이 있다. 사진을 공유하거나 파일을 업로드 할 때도 채팅을 함께 진행할 수 있다. 또한, word 작업 시 자동 저장을 경험했을 것이다. 그것 또한 멀티 스레드 프로세스다. 

엄밀히 따지자면 CPU는 여러 작업을 동시에 수행할 수 없다. 여러 작업을 동시에 진행하는 '것처럼' 보이게 동작하는 것인데, 운영체제에 대해서 알아보면 여러가지 기법이 있음을 알 수 있다. 우선은 멀티 프로세스는 동시에 진행한다고 표현할 것이다.

 

 

스레드 생성

1. Runnable 인터페이스의 구현 

Runnable 인터페이스는 전형적인 함수형 인터페이스로 람다식을 이용해서 쉽게 작성할 수 있다. Runnable에는 run() 메서드가 하나 존재하고 이 메서드를 오버라이딩해서 필요한 내용을 작성한다. 우리가 익히 아는 일반적인 자바의 출발이 main()이라면, 스레드의 출발은 run() 메서드다. 

Runnable.java이다. 잘 보면, 인터페이스 Runnable에 추상 클래스 run()이 존재한다. 

💡 인터페이스와 추상 클래스의 차이?

결론부터 말하자면, 인터페이스는 여러 객체에 같은 동작을 하는 것을 보장하는 것, 추상 클래스는 해당 기능을 확장하는 것이다. 상속에 대해서 포스팅했을 때 자바는 다중 상속을 지원하지 않는다고 했다. 그렇다고 해서 같은 운전 기능이 있는 경차와 대형차를 drive() 메소드만 각각 구현하면 비효율적이다. 
drive라는 인터페이스를 클래스 kia와 sportscar에서 implements(구현)했다. 이로써 서로다른 객체가 drive()를 구현할 수 있다. 그래서 인터페이스에서 메서드를 구현하려고 하면,
와 같은 예외가 발생한다. 

그러나 추상클래스는 다르다. '기능 확장'에 대해 초점을 맞추면 이해하기 쉽다. 인터페이스와 달리 구현된 메서드와 그렇지 않은 메서드를 동시에 정의할 수 있다. 

 

다시 돌아와서, Runnable.java의 Runnable은 인터페이스고, 그 안의 run()은 추상 메서드다. 그래서 우리가 아래와 같이 스레드를 생성할 때 새로운 Runnable 객체를 만들고, run() 메서드를 구현하는 것이다.

public class ThreadPrac {
    Thread t1 = new Thread(new Runnable() {
        @Override
        public void run() {
            System.out.println("t1 Thread");
        }
    });

    Thread t2 = new Thread(new Runnable() {
        @Override
        public void run() {
            System.out.println("t2 Thread");
        }
    });
}

 

 

2. Thread 클래스 상속

Thread 클래스는 Runnable을 구현하고 있다.

Thread.java

따라서 별도로 Runnable 객체를 파라미터로 넣을 필요 없이 Thread 클래스만 상속 받아도 스레드를 만들 수 있다. '

public class ThreadPrac extends Thread{
    public void run(){
        System.out.println("Hello");
    }
}

//main.java
Thread t3 = new ThreadPrac();

 

 

스레드의 실행

아래 코드를 실행해보자. 참고로 Thread는 run()이 아닌 start()를 사용해 시작한다.

public class ThreadPrac extends Thread{
    public static void main(String[] args) {
        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                for(int i =0;i<30;i++){
                    System.out.print("-");
                }
            }
        });
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                for(int i = 0;i<30;i++){
                    System.out.print("@");
                }
            }
        });
        t1.start();
        t2.start();
        System.out.println("Main is over!");
    }

}

 

아마 결과가 실행시마다 달라질 것이다. run()이 호출되지 않았지만 run메서드가 실행됐다. 

run() 메서드는 스레드에서 수행할 작업을 정의하는 메서드고, start()는 스레드의 run() 메서드가 호출될 수 있도록 준비하는 과정이다. 실제 run() 메서드를 실행하는 건 JVM이다. start()가 호출되면 JVM이 운영체제의 스레드 스케줄러에 의해 가능할 때 스레드의 run() 메서드를 호출한다. 마치 우리가 main()을 직접 호출하지 않은 것처럼. 

 

 

 

 

초기의 메모리 구조는 이 그림과 같을 것이다. JVM의 메모리 구조는 스택과 힙으로 설명할 수 있다. 스택 공간은 스레드별로 생성되며, 최초 애플리케이션이 구동되면 동작하는 main 스레드도 하나의 스택을 차지한다. 

그래서 처음 실행할 때 main 스택이 생성됐을 것이다. 각 스택은 자원 공유가 불가하나 공유 자원에는 접근 가능하다. 

 

 

 

 

 

 

main에서 t1의 start()가 호출되며 새로운 t1 스레드 스택이 별도로 생성됐다. 이 공간은 메인 스레드와는 전혀 무관한 t1 스레드만의 공간이다. 마찬가지로 어떤 스레드 스택도 t1에 접근할 수 없고, 공유 자원은 접근이 가능하다. 

 

 

 

 

 

 

 

 

 

이제 t1 스레드에서 run()이 실행될 것이다. 기존의 메인 스레드와는 전혀 별도로 존재하는 흐름이 생겨나게 됐다. 이후 두 스레드가 병렬(실제 병렬은 아니다.) 작업을 하여 예제에서 "@"와 "-"가 뒤섞여 나오는 것이다. 하나의 스레드가 종료하는 건 다른 스레드와 무관하며, mian()이 메서드가 '"main is over"를 출력했음에도 불구하고 상관없이 다른 스레드들은 마저 작업을 진행하고 끝낼 것이다. 모든스레드가 종료됐을 때 애플리케이션도 종료된다.

 

 

 


 

run()을 쓰면? 

만약 start를 쓰지 않고 run을 쓴다면 단순하게 main 스택에 run()이 올라갈 것이다. t1, t2, main의 문자 출력까지 모두 같은 순서를 지켜 실행됐을 것이다. 

Thread.java

start()는 synchronized, 즉 동기적으로 선언되었다. 이것은 해당 메서드가 한번에 한 스레드만 접근할 수 있음을 나타낸다. 

 

 

스레드의 상태 변화

출처:&nbsp;https://yanghs6.github.io/posts/1004_process_thread/

 

1. create, 생성: 최초 스레드 객체를 생성하면 NEW 상태가 된다. 이 상태의 객체는 타입만 Thread일 뿐, 스레드로서 동작하지는 않는다. 이 때 start()를 호출하면 스레드의 상태가 RUNNABLE로 변경된다. 

 

2. ready, 준비: Thread 객체에서 start()를 호출한 상태. 이 상태에서 스레드 스케줄러에 의해 선택되면 동작할 수 있다. 만약 여러 개의 스레드가 RUNNABLE 상태라면 선택되기 위한 스레드 간 경합이 일어나 정확한 동작 시점은 알 수 없다. 

 

3. running, 실행: JVM이 스레드의 run() 메서드를 호출하면 스레드가 동작한다. 

 

4. waiting, 대기: 동작 중인 스레드는 스레드의 sleep(), wait(), join() 메서드가 호출되거나 I/O에 의한 블로킹이 발생하는 경우 대기 풀(waiting pool)로 이동해 대기 상태로 변경된다. 이 때, 스레드의 동작은 일시 정지된다. 

 

5. terminated, 종료: run() 메서드가 종료되면 스레드는 소멸된다. 이때 스레드 스택 자체가 없어지는 것이므로 한번 소멸한 스레드는 더이상 동작하지 않는다. 만약 이 스레드 동작이 필요하다면 다시 생성하는 수밖에 없다. 

 

 

Thread의 주요 메서드

start() 새로운 스레드를 생성하고 실행
run() 스레드의 작업을 정의하는 메서드
sleep(long millis) 현재 실행 중인 스레드를 지정된 시간(밀리초) 동안 일시 중지
yield() 현재 실행 중인 스레드가 다른 스레드에게 실행을 양보(대기가 아닌 RUNNABLE 상태가 됨)
join() 다른 스레드가 종료될 때까지 현재 스레드를 일시 중지
interrupt() 스레드를 인터럽트하여 예외를 발생시키거나 인터럽트 상태를 설정
isAlive() 스레드가 실행 중인지 여부를 반환
getName() / setName() 스레드의 이름을 가져오거나 설정
getId() 스레드의 고유 식별자(ID)를 반환
isDaemon() / setDaemon() 스레드를 데몬 스레드로 설정하거나 확인
다른 일반 스레드를 도움주는 동작, 예를 들어 word의 자동저장 기능.
interrupted() / isInterrupted() 스레드가 인터럽트되었는지 여부를 확인