
스레드를 직접 만들어 보기 전에, 자바 메모리 구조에 대해 복습하고 진행하도록 하자.

메서드 영역: 클래스의 실행 코드(바이트 코드)나 필드, 메서드와 생성자 코드 등 모든 실행 코드가 존재하는 클래스 정보, static 변수들을 보관하는 static 영역, 공통 리터럴 상수를 보관하는 런타임 상수 풀과 같이 프로그램을 실행하는데 필요한 공통 데이터를 관리하는 영역이다. 메서드 영역은 프로그램의 모든 영역에서 공유한다.
스택 영역: 자바가 실행될 때 생성되는 것으로, 각 스레드 수 만큼의 실행 스택이 생성된다. 각 스택 프레임은 지역 변수나 중간 연산 결과, 메서드 호출 정보를 포함한다. 스택 프레임은 메서드를 호출할 때마다 쌓이고, 메서드가 종료되면 제거된다.
힙 영역: 객체의 인스턴스나 배열이 생성되는 영역이다. GC가 이루어지는 주요 공간이다.
스레드를 만드는 방법에는 크게 2가지가 있다. 그냥 Thread 클래스를 상속 받는 방법과 Runnable 인터페이스를 구현하는 방법이다.
자바에서는 스레드도 객체로 다룰 수 있다. 그래서 그냥 스레드 객체를 생성해서 사용하면 된다.
package thread.start;
public class HelloThread extends Thread {
@Override
public void run() { // 스레드가 실행할 코드
System.out.println(Thread.currentThread().getName() + ": run()");
}
}
현재 Thread 클래스를 상속 받는 HelloThread 클래스를 생성하고, 스레드가 실행할 코드를 run() 메서드에 오버라이딩 했다. Thread.currentThread()를 호출하면 해당 코드를 실행하는 스레드 객체를 조회할 수 있다. 스레드의 이름으로 실행 결과를 확인해보자.
package thread.start;
public class HelloThreadMain {
public static void main(String[] args) {
System.out.println(Thread.currentThread().getName() + ": main() start...");
HelloThread helloThread = new HelloThread();
System.out.println(Thread.currentThread().getName() + ": start() 호출 전");
helloThread.start(); // main 스레드가 Thread-0 스레드에게 알아서 run()을 실행하라고 지시
System.out.println(Thread.currentThread().getName() + ": start() 호출 후");
System.out.println(Thread.currentThread().getName() + ": main() end...");
}
}
/*
<1번 실행했을 때는 이랬는데>
main: main() start...
main: start() 호출 전
main: start() 호출 후
Thread-0: run()
main: main() end...
<2번 실행했을 뿐인데 호출 순서가 달라졌다.>
main: main() start...
main: start() 호출 전
main: start() 호출 후
main: main() end...
Thread-0: run()
*/
스레드 객체를 생성하고 실행해줬다. "매우 매우 주의해야 하는 점은, run() 메서드를 실행하는게 아니라 start() 메서드를 실행해야 한다는 것이다." 일단 위의 실행 결과의 흐름을 살펴보자.

일단 main이라는 이름을 가진 스레드가 main() 메서드를 호출한다. 자바가 실행 시점에 main 스레드를 만들고, 프로그램의 시작점인 main() 메서드를 실행한다는 말이다.

그리고 나서 HelloThread 인스턴스를 생성하고 start() 메서드를 호출하게 되면, 자바는 스레드를 위한 별도의 스택 공간을 할당해준다. 지금은 스레드의 이름을 명시해주지 않았기 때문에 Thread-0이라는 임의의 이름으로 되어 있다. 이제 Thread-0 스레드의 스택에 run() 메서드 스택 프레임이 살포시 얹어진 것을 볼 수 있다.
근데 출력 결과를 보면… Thread-0: run()는 왜 제멋대로 출력되는거지? 시간의 흐름으로 분석해보도록 하자.

main 스레드가 “start() 호출 전”을 호출한다. 이 부분은 아주 자연스럽다. start()를 호출하는 시점이 문제인데… 이때 main 스레드가 Thread-0 스레드에게 “이제 니가 알아서 해” 라고 말해준다. 그리고 main 스레드는 “start() 호출 후”를 호출하는 걸 보면 알 수 있듯이 본인 갈 길 간다. 이제 main 스레드와 Thread-0 스레드는 동시에 실행되고 있는 것이다. 그렇기 때문에 스레드 간 실행 순서는 언제든지 달라질 수 있는 것이다. 그리고 한 스레드가 얼마나 오랜 기간 실행 되는지도 보장할 수 없다. "이것이 바로 멀티스레드다."
run() 메서드를 실행한다면?package thread.start;
public class BadThreadMain {
public static void main(String[] args) {
System.out.println(Thread.currentThread().getName() + ": main() start...");
HelloThread helloThread = new HelloThread();
System.out.println(Thread.currentThread().getName() + ": start() 호출 전");
helloThread.run(); // 만약 이렇게 직접 run()을 실행하면...?
System.out.println(Thread.currentThread().getName() + ": start() 호출 후");
System.out.println(Thread.currentThread().getName() + ": main() end...");
}
}
/*
main: main() start...
main: start() 호출 전
main: run()
main: start() 호출 후
main: main() end...
*/
실행 결과를 보면 알 수 있듯이, 스레드가 생성되지 않는다. 그냥 main 스레드가 HelloThread 인스턴스에 있는 run() 메서드를 호출하고 있다.
스레드는 크게 2가지로 구분할 수 있다. 하나는 사용자 스레드, 다른 하나는 데몬 스레드다.
사용자 스레드: 프로그램의 주요 작업을 수행하고, 작업이 완료될 때까지 실행된다. 모든 사용자 스레드가 종료되는 그 시점이 자바가 종료되는 시점이다.
데몬 스레드: 백그라운드에서 보조적인 작업을 수행하는 스레드로, 모든 사용자 스레드가 종료되면 데몬 스레드는 자동으로 종료된다.
데몬 스레드를 생성하는 코드를 살펴보자.
package thread.start;
public class DaemonThreadMain {
public static void main(String[] args) {
System.out.println(Thread.currentThread().getName() + ": main() start");
DaemonThread dt = new DaemonThread();
dt.setDaemon(true); // 데몬 스레드로 설정 (default: false -> 사용자 스레드)
dt.start();
System.out.println(Thread.currentThread().getName() + ": main() end");
}
static class DaemonThread extends Thread {
@Override
public void run(){
System.out.println(Thread.currentThread().getName() + ": run()");
try {
Thread.sleep(10000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
System.out.println(Thread.currentThread().getName() + ": run() end");
}
}
}
/*
<데몬 스레드 선택 시>
main: main() start
main: main() end
Thread-0: run()
<사용자 스레드 선택 시>
main: main() start
main: main() end
Thread-0: run()
Thread-0: run() end
*/
setDaemon(true)로 설정하면, 데몬 스레드로 설정된다. 그리고 이 설정은 start() 메서드가 실행되기 전에 결정돼야 한다. 그리고 run() 메서드 안에서 Thread.sleep()를 호출할 때, 체크 예외인 InterruptedException은 밖으로 던질 수 없고 무조건 잡아야 한다. 생각없이 던져버리면 아래와 같이 컴파일 오류가 터진다.

그리고 데몬 스레드는 아까 사용자 스레드가 종료되면 자동으로 종료된다고 했다. 데몬 스레드의 실행 결과를 살펴보면 그 점을 확인할 수 있다. 이제 스레드를 생성하는 다른 방법에 대해 알아보자.
실무에서는 그냥 이 방법을 사용한다고 생각하면 된다.
package java.lang;
public interface Runnable {
void run();
}
Runnable 인터페이스 내부는 진짜 이게 전부다. 그냥 깔끔하게 개발자가 원하는 작업만 그대로 적으면 끝이다.
package thread.start;
public class HelloRunnable implements Runnable {
@Override
public void run() {
System.out.println(Thread.currentThread().getName() + ": run()");
}
}
package thread.start;
public class HelloRunnableMain {
public static void main(String[] args) {
System.out.println(Thread.currentThread().getName() + ": main() start");
HelloRunnable helloRunnable = new HelloRunnable();
Thread thread = new Thread(helloRunnable);
thread.start();
System.out.println(Thread.currentThread().getName() + ": main() end");
}
}
/*
main: main() start
main: main() end
Thread-0: run()
*/
실행 결과는 당연하게도 Thread 클래스를 상속 받는 방법과 같다. 차이가 있다면 스레드와 해당 스레드가 실행할 작업이 서로 분리되어 있다는 점이다. 이 이유만으로 Runnable 인터페이스를 구현하는 방법을 채택하는 것일까? Thread 클래스를 상속 받는 방법과 Runnable 인터페이스를 구현하는 방법의 장단점을 따져보자.
Thread 상속
장점: 상속만 받아서 run() 메서드를 오버라이딩 해주기만 하면 된다.
단점: 알다시피 자바는 단일 상속만을 허용하고 있기 때문에 이미 다른 클래스를 상속 받고 있을 경우, Thread 클래스를 상속 받을 수 없고, 인터페이스를 사용하는 방법에 비해 유연성이 떨어진다.
Runnable 구현장점: 일단 다른 클래스를 상속 받아도 문제 없이 구현이 가능하다. 그리고 앞서 말했듯이 Thread 클래스의 다른 수 많은 기능들을 끌고 올 필요 없이 스레드와 실행할 작업을 분리해서 코드의 가독성을 높일 수 있고, 여러 스레드가 동일한 Runnable 객체를 공유할 수 있어서 자원 관리를 효율적으로 할 수 있다.
단점: Runnable 객체를 생성하고, 실행할 작업을 생성자로 넘겨줘야 한다는 점이 단점이라면 그나마 단점이다.
Thread 클래스는 "스레드 자체", 즉 작업을 실행할 수 있는 객체를 의미하고, Runnable은 "실행할 작업의 내용" 을 의미한다. 따라서 Thread 상속 방식의 코드를 보면, 스레드 객체와 작업 내용을 하나의 클래스에 다 때려 넣는다. 하지만, Runnable 인터페이스를 구현하는 방식은, 인터페이스를 구현하면서 작업 내용만 정의하고, 스레드는 별도로 생성한다.
더 알아볼 필요도 없다, 그냥 Runnable 인터페이스 구현하자…
위의 스레드 객체를 조회하는 코드, System.out.println(Thread.currentThread().getName())를 보기만 해도 답답하다. 무슨 스레드가 언제 실행됐고, 뭘 작업 했는지 출력 내용으로 보기 좋게 알 수 있는 방법이 없을까? 아래 코드를 보자.
package util;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
// 추상 클래스로 만들어서 직접 생성할 수 없도록 함.
public abstract class MyLogger {
private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("HH:mm:ss.SSS");
public static void log(Object object) {
String time = LocalTime.now().format(formatter);
System.out.printf("%s [%9s] %s\n", time, Thread.currentThread().getName(), object);
}
}
현재 시간을 원하는 형식으로 출력하기 위해 DateTimeFormatter를 사용하고, 출력 시 깔끔하게 정렬 시키기 위해 %9s로 설정했다.
package util;
import static util.MyLogger.*;
public class MyLoggerMain {
public static void main(String[] args) {
log("hello thread");
log(123);
}
}
/*
11:19:49.722 [ main] main() hello thread
11:19:49.723 [ main] main() 123
*/
스레드의 이름과 언제 실행되었는지 확인하는 것은 아주 중요하다. 따라서 스레드 관련 작업을 할 때는 이처럼 로거 클래스를 생성해두고 사용하는 것이 바람직하다.
이번엔 스레드를 많이 만들어보자.
package thread.start;
import static util.MyLogger.log;
public class ManyThreadMainV1 {
public static void main(String[] args) {
log("main() start");
HelloThread runnable = new HelloThread();
Thread thread1 = new Thread(runnable);
thread1.start();
Thread thread2 = new Thread(runnable);
thread2.start();
Thread thread3 = new Thread(runnable);
thread3.start();
log("main() end");
}
}
/*
11:23:23.614 [ main] main() start
11:23:23.615 [ main] main() end
Thread-3: run()
Thread-2: run()
Thread-1: run()
*/
위를 보면 생성한 3개의 스레드 모두 같은 HelloThread 인스턴스를 스레드의 실행 작업으로 전달했다. x001에 있는 HelloRunnable 인스턴스의 run() 메서드를 Thread-0, Thread-1, Thread-2 스레드 스택에 스택 프레임으로 올린다.

반복문을 사용하면 스레드의 숫자를 유동적으로 변경하면서 실행 가능하다. 예를 들어 스레드 100개를 실행해보자.
package thread.start;
import static util.MyLogger.log;
public class ManyThreadMainV2 {
public static void main(String[] args) {
log("main() start");
HelloThread runnable = new HelloThread();
for (int i = 0; i < 100; i++) {
Thread thread = new Thread(runnable);
thread.start();
}
log("main() end");
}
}
/*
11:30:16.352 [ main] main() start
Thread-6: run()
Thread-8: run()
Thread-7: run()
Thread-9: run()
Thread-5: run()
Thread-2: run()
Thread-11: run()
Thread-1: run()
Thread-4: run()
Thread-13: run()
Thread-14: run()
...
Thread-88: run()
Thread-89: run()
Thread-90: run()
Thread-91: run()
Thread-93: run()
Thread-92: run()
Thread-94: run()
Thread-95: run()
Thread-96: run()
Thread-97: run()
Thread-98: run()
Thread-100: run()
11:30:16.360 [ main] main() end
Thread-99: run()
*/
아까 Runnable 인터페이스를 구현하는 방법에서 더 편리하게 만들 수 있는 방법들이 존재한다. 가볍게 살펴보도록 하자.
package thread.start;
import static util.MyLogger.log;
public class InnerRunnableMainV1 {
public static void main(String[] args) {
log("main() start");
MyRunnable myRunnable = new MyRunnable();
Thread thread = new Thread(myRunnable);
thread.start();
log("main() end");
}
// 특정 클래스 내부에서만 사용할 것 같다면 정적 중첩 클래스로 처리해도 무방
static class MyRunnable implements Runnable {
@Override
public void run() {
log("run()");
}
}
}
/*
11:36:59.850 [ main] main() start
11:36:59.851 [ main] main() end
11:36:59.851 [ Thread-0] run()
*/
package thread.start;
import static util.MyLogger.log;
public class InnerRunnableMainV2 {
public static void main(String[] args) {
log("main() start");
// 익명 클래스: Runnable 인터페이스를 바로 구현해버려도 된다.
// (해당 기능을 특정 메서드 안에서만 사용할 경우)
Runnable runnable = new Runnable() {
@Override
public void run() {
log("run()");
}
};
Thread thread = new Thread(runnable);
thread.start();
log("main() end");
}
}
/*
11:39:01.162 [ main] main() start
11:39:01.163 [ main] main() end
11:39:01.163 [ Thread-0] run()
*/
package thread.start;
import static util.MyLogger.log;
public class InnerRunnableMainV3 {
public static void main(String[] args) {
log("main() start");
// 익명 클래스: 변수 없이 직접 전달하는 것도 가능
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
log("run()");
}
});
thread.start();
log("main() end");
}
}
/*
11:41:09.352 [ main] main() start
11:41:09.354 [ main] main() end
11:41:09.354 [ Thread-0] run()
*/
package thread.start;
import static util.MyLogger.log;
public class InnerRunnableMainV4 {
public static void main(String[] args) {
log("main() start");
// 익명 클래스: 변수 없이 직접 전달하는 것도 가능
Thread thread = new Thread(() -> log("run()"));
thread.start();
log("main() end");
}
}
/*
11:42:44.636 [ main] main() start
11:42:44.638 [ main] main() end
11:42:44.638 [ Thread-0] run()
*/