Java 파트12-1. 멀티 스레드

1. 시작하기 전에


프로세스

운영체제에서 실행 중인 하나의 애플리케이션을 프로세스라고 한다. 사용자가 애플리케이션을 실행하면 운영체제로부터 실행에 필요한 메모리를 할당받아 애플리케이션의 코드를 실행하는데 이것이 프로세스 이다. 하나의 애플리케이션이 멀티 프로세스를 만들기도 한다. 예를 들어, 메모장을 두 개 키면 2개의 메모장 프로세스가 생성된 것이다.

스레드

프로세스 내부에서 코드의 실행 흐름 을 스레드라고 한다.


2. 스레드


운영체제는 두 가지 이상의 작업을 동시에 처리하는 멀티 태스킹을 할 수 있도록 CPU 및 메모리 자원을 프로세스마다 적절히 할당해주고, 병렬로 실행시킨다. 멀티 태스킹이 멀티 프로세스를 뜻하는 것은 아니다. 예들 들면, 메신저는 채팅 기능을 제공하면서 동시에 파일 전송 기능을 수행하기 때문에 하나의 프로세스지만 멀티 태스킹을 한다.
스레드는 한 가지 작업을 실행하기 위해 순차적으로 실행할 코드를 실처럼 이어놓았다고 해서 유래된 이름이다. 하나의 스레드는 하나의 코드 실행 흐름이기 때문에 한 프로세스 내에 스레드가 2개라면 2개의 코드 실행 흐름이 생긴다는 뜻이다.

  • 멀티 프로세스 : 운영체제에서 할당받은 자신의 메모리를 가지고 실행 -> 각 프로세스는 독립적
  • 멀티 스레드 : 하나의 프로세스 내부에 생성 -> 하나의 스레드 예외시 다른 프로세스에 영향


3. 메인 스레드


자바의 모든 애플리케이션은 메인 스레드가 main() 메소드를 실행하면서 시작한다.

싱글 스레드 애플리 케이션

순차적으로 진행되어 마지막 코드를 실행하거나 return 문을 만나면 실행이 종료된다. 이런 순차적 코드 실행 흐름이 스레드이다.

멀티 스레드 애플리케이션

메인 스레드는 필요에 따라 작업 스레드들을 만들어서 병렬로 코드를 실행할 수 있다. 즉, 멀티 스레드를 만들어서 멀티 태스킹을 수행하는 것이다. 예를 들면, 메인 스레드가 진행되면서 메인 스레드에서 분기처럼 나와 작업 스레드1, 작업 스레드2를 생성하는 것이다. 이런 멀티 스레드의 경우 메인 스레드가 종료되어도 작업 스레드가 실행 중이라면 프로세스는 종료되지 않는다.


4. 작업 스레드 생성과 실행


멀티 스레드로 실행하는 애플리케이션을 개발하려면 먼저 몇 개의 작업을 병렬로 실행할지 결정하고 각 작업별로 스레드를 생성해야 한다. 자바 애플리케이션은 반드시 메인 스레드가 존재하기 때문에 메인 작업 이외에 추가적인 병렬 작업의 수만큼 스레드를 생성하면 된다. 자바에서는 작업 스레드도 객체로 생성되기 때문에 클래스가 필요하다. java.lang.Thread 클래스를 직접 객체화해서 생성해도 되지만, Thread 클래스를 상속해서 하위 클래스를 만들어 생성할 수도 있다.

Thread 클래스로부터 직접 샐성

Thread thread = new Thread(Runnable target);

java.lang.Thread 클래스로부터 작업 스레드 객체를 직접 생성하려면 Runnable을 매개값으로 같은 생성자를 호출해야 한다. Runnable은 작업 스레드가 실행할 수 있는 코드를 가지고 있는 객체라고 해서 붙여진 이름이다. Runnable은 인터페이스 타입이기 때문에 구현 객체를 만들어 대입해야 하고, run() 메소드 하나가 정의되어 있다. Runnable은 작업 내용을 가지고 있는 객체이지 실제 스레드가 아니다. 구현 객체를 생성한 후 매개값으로 Thread 생성자를 호출해야 비로소 작업 스레드가 생성된다. 이제 만들어진 스레드는 start메소드를 호출하면 실행이 된다.

// 구현 클래스 
public class Task implements Runnable{

    @Override // 재정의
    public void run() {
        // 스레드가 실행할 코드
    }
}

// 작업 스레드 생성
Runnable task = new Task();
Thread thread = new Thread(task);

// 코드를 절약하기 위해 익명 객체를 사용하는데 이 방법이 더 자주 쓰인다.
// 구현 클래스를 없이 바로 익명 객체를 사용
Thread thread = new Thread(new Runnable() {
        @Override
        public void run() {
            // 스레드 실행 코드
        }
    });

thread.start(); // 작업 스레드 실행

전체적 과정을 정리하자면, 메인 스레드가 실행 -> 스레드 객체 생성과 start메소드 호출 -> 작업 스레드 실행, 메인스레드도 계속 진행 -» 2가지 분기로 나뉘어서 계속 진행

실습

0.5초 주기로 비프음을 발생시키면서 동시에 “띵”을 출력하는 작업을 만들어보자.

import java.awt.*;

public class BeepExam {
    public static void main(String[] args) {
        // 작업 스레드로 브프음 주기
        Thread thread = new Thread(new Runnable() { // 작업스레드 익명 객체로 생성
            @Override
            public void run() {
                Toolkit toolkit = Toolkit.getDefaultToolkit(); // 툴킷 객체에 비프음 발생이 있다.
                for (int i =0;i<5;i++){
                    toolkit.beep();
                    try {
                        toolkit.beep();
                        Thread.sleep(500); // 0.5초 해당 스레드 0.5초 중지
                    } catch (Exception e){

                    }

                }
            }
        });
        thread.start(); // 작업 스레드 실행

        for(int i=0;i<5;i++){
            System.out.println("띵");
            try{Thread.sleep(500);} catch (Exception e){}// 해당 스레드 0.5초 중지
        }

    }
}


Thread 하위 클래스로부터 생성

작업 스레드가 실행할 작업을 Runnable로 만들지 않고, Thread의 하위 클래스로 작업 스레드를 정의하면서 작업 내용을 포함시킬 수도 있다.

// 하위 클래스 
package test;

public class WorkerThread extends Thread{
    @Override
    public void run(){

    }
}


// 메인
public class BeepExam {
    public static void main(String[] args) {

        // Thread 상속받은 하위 클래스로 객체 만들어서 대입
        Thread thread = new WorkerThread();

        // Thread를 상속한 익명객체로 하위 클래스 바로 만들기
        Thread thread1 = new Thread(){
            @Override
            public void run(){

            }
        };
    }
}


스레드의 이름

스레드는 자신의 이름을 가지고 있는데 딱히 큰 역할을 하는 것은 아니고 디버깅할 때 어떤 스레드가 어떤 작업을 하느지 조사할 목적으로 가끔 사용한다.
메인 스레드는 main이라는 이름을 갖고, 직접 생선한 스레드는 자동적으로 Thread-n이라는 이름을 갖는다. n는 스레드 번호를 말하는데 다른 이름을 설정하고 싶다면 Thread 클래스의 setName()메소드로 변경하면 된다. 스레드 이름을 알고 싶다면 getName()을 사용한다.

thread.setName("스레드이름"); // 스레드 이름 설정
thread.getName(); // 스레드 이름 반환

게터와 세터는 클래스의 인스턴스 메소드이므로 객체 참조가 필요하다. 그런데 만약 객체의 참조를 갖고 있지 않을 경우 Thread 클래스의 정적 메소드인 currentThread()를 이용해서 현재 스레드의 객체를 얻을 수 있다.

Thread thread = Thread.currentThread();

// 예시
// Thread 상속받은 하위 클래스 
package test;

public class ThreadA extends Thread{
    @Override
    public void run(){
        // 스레드 클래스 안에서는 그냥 getName만 해도 스레드 이름 반환 
        System.out.println(getName());
    }

}

// 메인
package test;

public class ThreadNameExam {
    public static void main(String[] args) {
        Thread mainThread = Thread.currentThread(); // 이 코드를 실행하는 스레드 객체 얻기
        System.out.println("시작 스레드 이름 -> "+ mainThread.getName()); // 스레드 객체의 이름 얻기

        ThreadA threada = new ThreadA();
        System.out.println("작업 스레드 이름 ->" + threada.getName()); // 스레드 객체의 이름 얻기
        threada.setName("hello"); // 객체 이름 수정
        threada.start(); // 작업 스레드 실행

        System.out.println("바뀐 작업 스레드 이름 ->" + threada.getName()); // 스레드 객체의 이름 얻기
    }
}


5. 동기화 메소드


싱글 스레드에서는 1개의 스레드만 객체를 사용하지만, 멀티 스레드 프로그램의 경우 스레드들이 객체를 공유해서 작업하는 경우가 있다. 이때 서로 객체를 사용하기 때문에 수정이 이루어지면서 원하는 결과가 나오지 않을 수 있다. 코드로 한 번 확인해보자.

// 메인 스레드
package sec01.exam07;

public class MainThreadExample {
	public static void main(String[] args) {
		Calculator calculator = new Calculator(); // 객체 생성
		
        // 여기서부터 코드 시작전에 혼동하지 말아야 할 것이, 자바는 객체지향이다.
        // -> c의 함수인자처럼 복사되는게 아니라 다 이어져있다.

		User1 user1 = new User1(); // Thread 상속 하위 클래스 객체 생성
		user1.setCalculator(calculator); // 작업 스레드에 객체 저장
		user1.start();

		User2 user2 = new User2();
		user2.setCalculator(calculator);
		user2.start();
	}
}

// 공유 객체
package sec01.exam07;

public class Calculator {
	private int memory;

	public int getMemory() {
		return memory;
	}

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

// 작업 스레드 1
package sec01.exam07;

public class User1 extends Thread {	
	private Calculator calculator;
	
	public void setCalculator(Calculator calculator) {
		this.setName("User1"); // 작업 스레드 이름 변경
		this.calculator = calculator; // 받아온 객체를 필드에 저장
	}
	
	public void run() {
		calculator.setMemory(100); // 실행되면 객체에 값이 100으로 들어간다.
	}
}

// 작업 스레드 2
package sec01.exam07;

public class User2 extends Thread {	
	private Calculator calculator;
	
	public void setCalculator(Calculator calculator) {
		this.setName("User2");
		this.calculator = calculator;
	}
	
	public void run() {
		calculator.setMemory(50);
	}
}

코드의 의도는 user1 스레드가 작동하면 100이 들어가서 100이 출력되고 user2가 작동되면 50이 들어가서 50이 출력되는 것이었다. 하지만 결과는 50으로 2번 출력된다. 이유는 객체가 공유되고 있고 중간에 2초간 쉬기 때문이다. 즉, user1 작업 스레드에 의해 100의 값이 들어가서 2초동안 중지하고 있는 동안 user2 작업 스레드가 실행되어 user1에 의한 출력 전에 객체 값이 50으로 변한 것이다.
이런 문제를 해결하기 위해서는 스레드가 사용 중인 객체는 현재 스레드가 끝나기 전까지는 다른 스레드가 사용할 수 없도록 해야한다. 멀티 스레드 프로그램에서 단 하나의 스레드만 실행할 수 있는 코드 영역을 임계영역 이라고 하는데, 자바는 임계 영역을 지정하기 위해 동기화 메소드 를 제공한다. 즉, 동기화 메소드가 실행하면 객체에 잠금을 걸어 다른 스레드가 동기화 메소드를 실행하지 못하게 하는 것이다.

public synchronized void method(){
    // 임계 영역 , 하나의 스레드만 실행
}

스레드가 동기화 메소드를 실행하는 즉시 객체에는 잠금이 일어나고 메소드가 종료되면 잠금이 풀린다. 동기화 메소드가 여러 개 있을 경우도 마찬가지로 스레드에서 실행중이면 다른 스레드는 사용할 수 없다. 하지만, 다른 스레드에서 일반 메소드는 실행이 가능하다.
그럼 위의 예제에서 동기화 메소드는 어디에 적용하면 될까? 출력하는 동안 calculator 객체의 값이 수정되었으므로 출력하는 메소드를 동기화 메소드로 만들어서 출력되는 동안에는 calculator 객체가 수정되지 않도록 막으면 된다.

package sec01.exam08;

public class Calculator {
	private int memory;

	public int getMemory() {
		return memory;
	}

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

setMemory가 동기화 메소드로 지정되어 있으므로 setMemory가 실행되는 순간 클래스 Calculator의 객체는 잠금되는 것이다. 따라서 user1이 해당 메소드를 끝낸 후 user2가 사용할 수 있게 된다.


6. 정리하기


  • 프로세스 : 애플리케이션을 실행하면 운영체제로부터 실행에 필요한 메모리를 할당받아 애플리케이션이 실행되는 것
  • 멀티 스레드 : 하나의 프로세스 내에 동시 실행을 하는 스레드들이 2개 이상인 경우
  • 메인 스레드 : 자바의 모든 애플리케이션은 메인 스레드가 main() 메소드를 실행하면서 시작
  • 작업 스레드 : 메인 작업 이외에 병렬 작업의 수만큼 생성되는 스레드
    • 작업 스레드는 객체로 생성되기에 클래스가 필요 -> Thread 클래스를 직접 객체화(Runnable 인터페이스 사용), Thread 클래스를 상속해서 하위 클래스 생성
  • 동기화 메소드 : 멀티 스레드 프로그램에서 단 하느이 스레드만 실행할 수 있는 코드 영역을 임계영역이라 하고, 임계영역 지정을 위해 자바는 동기화 메소드를 제공한다. 동기화 메소드 사용시 동기화 메소드가 실행되는 동안 해당 클래스 객체는 잠금된다.



본 포스팅은 ‘혼자 공부하는 자바’를 읽고 공부한 내용을 바탕으로 작성하였습니다.


© 2021. By Backtony