Backend/Java

Java - Multi Thread (1)

elevne 2023. 5. 7. 02:10

운영체제에서 실행 중인 하나의 애플리케이션을 Process 라고 부른다. 사용자가 애플리케이션을 실행하면 운영체제로부터 실행에 필요한 메모리를 할당받아 애플리케이션의 코드를 실행하는 것이 프로세스이다. 멀티 태스킹은 두 가지 이상의 작업을 동시에 처리하는 것을 말한다. 운영체제는 멀티 태스킹을 할 수 있도록 CPU 및 메모리 자원을 프로세스마다 적절히 할당해주고, 병렬로 실행시킨다. 하지만 멀티 태스킹이 꼭 멀티 프로세스를 뜻하는 것은 아니다. 멀티 쓰레드를 활용하여 애플리케이션 내부에서 멀티 태스킹을 수행할 수 있는 것이다. 

 

 

멀티 프로세스들은 운영체제에서 할당받은 자신의 메모리를 가지고 실행하기 때문에 서로 독립적이다. 하나의 프로세스에서 오류가 발생해도 다른 프로세스에 영향을 주지 않지만, 멀티 쓰레드는 하나의 프로세스 내부에 생성되기 때문에 하나의 스레드가 예외를 발생시키면 프로세스 자체가 종료될 수 있어 다른 스레드에 영향을 미치게 된다.

 

 

 

모든 자바 애플리케이션은 메인 스레드가 main() 메서드를 실행하면서 시작된다. 싱글 스레드 애플리케이션에서는 메인 스레드가 종료되면 프로세스도 종료되지만, 멀티 스레드 애플리케이션에서는 실행 중인 스레드가 하나라도 있으면 프로세스는 종료되지 않는다. 이러한 멀티 스레드 애플리케이션을 개발하기 위해 먼저 몇 개의 작업을 병렬로 실행할지 결정하고 각 작업별로 스레드를 생성해야 한다. 어떤 자바 애플리케이션이건 메인 스레드는 반드시 존재한다. 메인 작업 외의 추가적인 병렬 작업의 수만큼 스레드를 생성해주면 된다. 이 때 java.lang.Thread 클래스 혹은 이를 상속한 하위 클래스가 사용된다. Thread 클래스로부터 작업 스레드 객체를 직접 생성할 때는 아래와 같이 Runnable 을 매개로 받는 생성자를 호출해야 한다.

 

 

 

Thread thread = new Thread(Runnable target)

 

 

 

위에서 사용된 Runnable 은 작업 스레드가 실행할 수 있는 코드를 가지고 있는 객체이다. Runnable 은 인터페이스 타입으로, 구현 객체를 만들어 대입해야 한다. Runnable 내에는 run() 메서드 하나가 정의되어 있으며, 구현 클래스는 이를 재정의해서 작업 스레드가 실행할 코드를 작성한다. 코드를 절약하기 위해 보통 Runnable 익명 객체를 매개값으로 사용한다. 또, Runnable 인터페이스는 run() 메서드 하나만 정의되어 있기 때문에 함수적 인터페이스이다. 따라서 아래와 같이 람다식을 매개로 사용해볼 수도 있다.

 

 

 

Thread thread = new Thread(() -> {
	...
});

 

 

 

작업 스레드는 생성되고 즉시 실행되는 것은 아니고, start() 메서드를 호출하면 실행된다. 아래와 같이 작업 스레드와 메인 스레드를 동시에 실행해볼 수 있다.

 

 

 

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

        Thread thread = new Thread(() -> {
            Toolkit toolkit = Toolkit.getDefaultToolkit();
            for (int i=0; i<5; i++) {
                toolkit.beep();
                try {Thread.sleep(1000);} catch (Exception e) {e.printStackTrace();}
            }
        });
        thread.start();

        for (int i=0; i<5; i++){
            System.out.println("TEST");
            try { Thread.sleep(1000); } catch (Exception e) {e.printStackTrace();}
        }

    }
}

 

 

 

위 코드를 실행시키면 컴퓨터 효과음이 남과 동시에 콘솔에 TEST 가 순차적으로 찍히는 것을 확인할 수 있다.

 

 

 

위 방법 외에도 Thread 클래스를 상속 받는 하위 클래스를 정의하며 그 안에 작업 내용을 포함시킬 수도 있다. Thread 클래스의 run 메서드를 Override 하여 스레드가 실행할 코드를 작성한다. 아래는 Thread 익명 객체를 사용한 예시이다.

 

 

 

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

        Thread thread = new Thread() {
            @Override
            public void run() {
                Toolkit toolkit = Toolkit.getDefaultToolkit();
                for (int i=0; i<5; i++) {
                    toolkit.beep();
                    try {Thread.sleep(1000);} catch (Exception e) {e.printStackTrace();}
                }
            }
        };
        thread.start();

        for (int i=0; i<5; i++){
            System.out.println("TEST");
            try { Thread.sleep(1000); } catch (Exception e) {e.printStackTrace();}
        }

    }
}

 

 

 

스레드는 자신의 이름을 가지고 있다. 메인 스레드는 "main" 이라는 이름을 가지고 있으며, 직접 생성하는 스레드는 자동으로 "Thread-n" 이라는 이름을 설정된다. 이름을 직접 지정하고 싶다면 Thread 클래스의 setName() 메서드로 변경할 수 있다. (스레드의 이름은 디버깅 시 사용됨) 만약 스레드 객체의 참조가 필요하다면 Thread 의 정적 메서드인 currentThread() 로 코드를 실행하는 현재 스레드의 참조를 얻을 수 있다.

 

 

멀티 스레드는 동시성 (Concurrency) 또는 병렬성 (Parallelism) 으로 실행된다. 동시성은 멀티 작업을 위해 하나의 코어에서 멀티 스레드가 번갈아가며 실행되는 성질을 말하며, 병렬성은 멀티 작업을 위해 멀티 코어에서 개별 스레드를 동시에 실행하는 성질을 말한다. (싱글코어 CPU 를 이용한 멀티 스레드 작업을 병렬적으로 실행되는 것처럼 보이지만 사실은 번갈아가며 실행하는 동시성 작업인 것) 이 때, 만약 스레드의 개수가 코어의 수보다 많을 경우에, 스레드를 어떤 순서에 의해 동시성으로 실행할 것인가를 결정해야 하는데, 이를 스레드 스케줄링이라고 한다. 스레드 스케줄링에 의해 스레드들은 아주 짧은 시간에 번갈아가면서 run() 메서드를 조금씩 실행한다. 

 

 

자바의 스레드 스케줄링은 우선순위 (Priority) 방식과 순환할당 (Round-Robin) 방식을 사용한다. 우선순위 방식은 우선순위가 높은 스레드가 실행 상태를 더 많이 가지도록 스케줄링하는 것을 말하고, 순환할당 방식은 시간 할당량을 정해서 하나의 스레드를 정해진 시간만큼 실행하고 다시 다른 스레드를 실행하는 방식을 말한다. (우선순위 방식은 스레드 객체에 우선순위 번호를 부여할 수 있기에 개발자가 코드로 제어 가능 / 순환할당 방식은 JVM 에 의해서 정해져 코드로 제어 불가) 우선순위 방식에서 스레드의 순위를 변경하고자 한다면 Thread 클래스의 setPriority() 메서드를 사용하면 된다.

 

 

 

멀티 스레드 프로그램에서는 스레드들이 객체를 공유하여 작업해야 하는 경우가 있다. 이 때는 스레드가 사용 중인 객체를 다른 스레드가 변경할 수 없도록, 스레드 작업이 끝날 때까지 객체에 잠금을 걸어서 다른 스레드가 사용할 수 없게끔 할 수 있다. 이러한 처리를 해주지 않은 경우의 예시이다.

 

 

 

package thisisjava;

public class Calculator {
    private int memory;
    public int getMemory() {
        return memory;
    }

    public void setMemory(int memory) {
        this.memory = memory;
        try {
            System.out.println("START: " + memory);
            Thread.sleep(2000);
        } catch (InterruptedException e) {}
        System.out.println(Thread.currentThread().getName() + ": " + this.memory);
    }
}

 

 

package thisisjava;

public class User1 extends Thread {

    private Calculator calculator;

    public void setCalculator(Calculator calculator) {
        this.setName("USER1");
        this.calculator = calculator;
    }

    @Override
    public void run() {
        calculator.setMemory(100);
    }

}



package thisisjava;

public class User2 extends Thread {

    private Calculator calculator;
    public void setCalculator(Calculator calculator) {
        this.setName("USER2");
        this.calculator = calculator;
    }
    @Override
    public void run() {
        calculator.setMemory(50);
    }

}

 

 

public class Main {

    public static void main(String[] args) {

        Calculator calculator = new Calculator();

        User1 user1 = new User1();
        User2 user2 = new User2();
        user1.setCalculator(calculator);
        user2.setCalculator(calculator);
        user1.start();
        user2.start();

    }
}

 

 

result

 

 

 

멀티 스레드 프로그램에서 하나의 스레드만 실행할 수 있는 코드 영역을 임계 영역이라고 한다. 자바는 임계 영역을 지정하기 위해 동기화 (synchronized) 메서드와 동기화 블록을 제공한다. synchronized 키워드가 사용된다. synchronized 키워드는 인스턴스와 정적 메서드 어디에든 붙일 수 있다. 

 

 

 

package thisisjava;

public class Calculator {
    private int memory;
    public int getMemory() {
        return memory;
    }

    public synchronized void setMemory(int memory) {
        this.memory = memory;
        try {
            System.out.println("START: " + memory);
            Thread.sleep(2000);
        } catch (InterruptedException e) {}
        System.out.println(Thread.currentThread().getName() + ": " + this.memory);
    }
}

 

 

result

 

 

 

동기화 메서드는 메서드 전체 내용이 임계 영역이므로 스레드가 동기화 메서드를 실행하는 즉시 객체에는 잠금이 일어나고, 스레드가 동기화 메서드를 실행 종료하면 잠금이 풀린다. 메서드 전체 내용이 아니라, 동기화 블록을 만들어서 일부 내용만 임계 영역으로 만들어서 사용할 수도 있다. 동기화 블록의 외부 코드들은 여러 스레드가 동시에 실행할 수 있지만 동기화 블록의 내부 코드는 임계 영역이므로 한 번에 한 스레드만 실행할 수 있고 다른 스레드는 실행할 수 없다. 만약 동기화 메서드와 동기화 블록이 여러 개 있을 경우 스레드가 이들 중 하나를 실행할 때 다른 스레드는 해당 메서드는 물론이고 다른 동기화 메서드 및 블록도 실행할 수 없다. (일반 메서드는 실행 가능)

 

 

위 코드에서 User1 스레드는 Calculator 객체의 동기화 메서드인 setMemory() 를 실행하는 순간 Calculator 객체를 잠근다. 메인 스레드가 User2 스레드를 실행시키지만, 동기화 메서드인 setMemory() 를 실행시키지는 못하고 User1 이 setMemory() 를 모두 실행할 동안 대기해야 한다. User1 스레드가 setMemory() 메서드를 모두 실행하고 나면 User2 스레드가 setMemory() 메서드를 실행한다. 동기화 블록으로 위 코드를 작성해보자면 아래와 같다.

 

 

 

public void setMemory(int memory) {
    synchronized (this) {
        this.memory = memory;
        try {
            System.out.println("START: " + memory);
            Thread.sleep(2000);
        } catch (InterruptedException e) {}
        System.out.println(Thread.currentThread().getName() + ": " + this.memory);
    }
}

 

 

 

 

 

 

Reference:

이것이 자바다