본문 바로가기

Runnable을 만드는 세 가지 방법과 람다 캡처

같은 작업을 정적 중첩 클래스, 익명 클래스, 람다로 만들어 비교하고, 람다가 지역 변수를 캡처하는 규칙과 작업 객체 하나를 여러 스레드가 함께 쓸 때 생기는 문제를 배웁니다.

앞 글에서는 스레드가 할 일을 Runnable로 따로 떼어 Thread 생성자에 넘겼습니다. Runnable에는 추상 메서드가 run() 하나뿐이어서 함수형 인터페이스(functional interface)에 해당합니다. 그래서 이름 있는 클래스로도, 익명 클래스로도, 람다식으로도 만들 수 있습니다. 세 문법 자체는 11장의 정적 중첩 클래스와 익명 클래스에서 이미 보았습니다.

스레드에 넘길 작업을 만들 때는 문법의 길이보다 중요한 질문이 있습니다. 그 작업 객체가 어떤 값을 품고 있는지, 그리고 그 값과 객체를 다른 스레드와 함께 쓰게 되는지입니다. 람다는 짧은 대신 바깥의 무엇을 붙잡았는지가 잘 드러나지 않습니다. 이 글에서는 같은 작업을 세 문법으로 만들어 비교한 뒤, 람다가 지역 변수를 캡처하는 규칙과 작업 객체 하나를 여러 스레드가 공유할 때 생기는 문제를 차례로 살펴봅니다.

세 가지 문법으로 같은 작업 만들기

아래 프로그램은 '현재 스레드 이름=값'을 한 줄 출력하는 작업을 세 가지 문법으로 만들고, 각각 다른 스레드에서 실행합니다. NamedTask는 Runnable을 구현한 정적 중첩 클래스로, 출력할 값을 생성자로 받아 필드에 담아 둡니다. 두 번째 작업은 Thread 생성자의 인수 자리에서 바로 만든 익명 클래스이고, 세 번째는 람다식입니다. 세 작업 모두 같은 print 메서드를 호출하므로 출력 형식이 같습니다.

app/RunnableStyleCatalog.java
public final class RunnableStyleCatalog {
    private static final class NamedTask implements Runnable {
        private final String value;

        NamedTask(String value) {
            this.value = value;
        }

        public void run() {
            print(value);
        }
    }

    private static void print(String value) {
        System.out.println(Thread.currentThread().getName() + "=" + value);
    }

    public static void main(String[] args) throws InterruptedException {
        Thread named = new Thread(new NamedTask("class"), "style-1");
        Thread anonymous =
                new Thread(
                        new Runnable() {
                            public void run() {
                                print("anonymous");
                            }
                        },
                        "style-2");
        Thread lambda = new Thread(() -> print("lambda"), "style-3");
        Thread[] all = {named, anonymous, lambda};
        for (Thread worker : all) {
            worker.start();
        }
        for (Thread worker : all) {
            worker.join();
        }
    }
}

한 번 실행한 결과는 다음과 같았습니다. 세 스레드를 style-1, style-2, style-3 순서로 시작했으니 출력도 그 순서일 것 같지만, 실제 줄 순서를 확인해 보세요.

style-1=class
style-3=lambda
style-2=anonymous

각 줄에서 스레드 이름과 값의 짝은 언제나 같습니다. style-1 스레드에는 NamedTask("class")를, style-3 스레드에는 print("lambda")를 호출하는 람다를 만들 때 넘겼기 때문입니다. 반면 줄의 순서는 실행할 때마다 달라질 수 있습니다. 세 스레드를 거의 동시에 시작하면 어느 스레드가 먼저 CPU를 받아 println까지 가는지는 운영체제의 스케줄러가 정하기 때문입니다. 실제로 다른 실행에서는 style-3=lambda가 맨 앞에 나오기도 했습니다. 순서와 상관없이 세 줄이 모두 출력되는 것은 세 스레드가 데몬이 아닌 사용자 스레드여서, JVM이 세 스레드가 끝날 때까지 종료하지 않기 때문입니다. main의 두 번째 반복문은 main 자신이 세 스레드가 끝난 뒤에 끝나도록 기다릴 뿐입니다.

세 Runnable 표현은 같은 출력 형식을 만든다

RunnableStyleCatalog의 NamedTask는 value 필드를 쓰고 익명 클래스와 람다는 각각 짧은 print 본문을 전달합니다. 세 이름과 값의 짝은 고정이며 출력 순서는 보장하지 않습니다.

RUNNABLE · 세 표현

세 Runnable 표현은 같은 출력 형식을 만든다

클래스·익명 클래스·람다는 같은 이름=값 형식으로 출력합니다. 표현을 바꿔도 공유 상태 규칙은 같습니다.

세 정의 방식과 각 작업의 출력 이름클래스·익명클래스·람다 각각을해당출력style-1=class/style-2=anonymous/style-3=lambda로 연결합니다. 세작업을 고정출력순서나 공유안전의 보장으로 해석하지 않습니다. 이름 있는 클래스 NamedTask.value run에서 print(value) style-1의 출력 style-1=class 익명 클래스 run 본문에 값 print(anonymous) style-2의 출력 style-2=anonymous 람다 짧은 실행 본문 print(lambda) style-3의 출력 style-3=lambda 실선: 각 정의와 출력의 관계 세 작업자의 출력 순서는 가변 문법만으로 공유 안전 보장 안 함
세 정의 방식과 각 작업의 출력 이름클래스·익명클래스·람다 각각을해당출력style-1=class/style-2=anonymous/style-3=lambda로 연결합니다. 세작업을 고정출력순서나 공유안전의 보장으로 해석하지 않습니다. 이름 있는 클래스 NamedTask.value run에서 print(value) style-1의 출력 style-1=class 익명 클래스 run 본문에 값 print(anonymous) style-2의 출력 style-2=anonymous 람다 짧은 실행 본문 print(lambda) style-3의 출력 style-3=lambda 실선: 각 정의와 출력의 관계 세 작업자의 출력 순서는 가변 문법만으로 공유 안전 보장 안 함

이름 있는 클래스

실제 작업 본문의 구성
NamedTask의 value 필드
run()이 print(value) 호출
이름과 값의 출력
style-1=class

익명 클래스

실제 작업 본문의 구성
run()에서 print("anonymous") 호출
이름과 값의 출력
style-2=anonymous

람다

실제 작업 본문의 구성
() -> print("lambda")
이름과 값의 출력
style-3=lambda

세 줄의 순서는 고정하지 않습니다. 차이는 작업을 정의하는 위치와 상태 표현이며, 어느 문법도 가변 객체의 안전한 공유를 자동으로 보장하지 않습니다.

그림에서 보듯 세 문법의 차이는 작업을 어디에 정의하고 값을 어떻게 담느냐에 있습니다. NamedTask는 값을 필드에 담았고, 익명 클래스와 람다는 실행할 문장 안에 값을 직접 적었습니다. 그래도 세 경우 모두 결국 Runnable 객체 하나가 만들어져 Thread 생성자에 전달된다는 점은 같습니다. 스레드 사이에서 객체를 함께 쓸 때 적용되는 규칙도 문법과 상관없이 같으므로, 문법을 바꾼다고 공유 문제가 생기거나 사라지지는 않습니다.

더 깊이: 스택 트레이스에 남는 이름

세 작업이 실행될 때 호출 스택에는 어떤 이름이 남을까요? print를 잠시 다음처럼 고쳐 자신을 호출한 메서드를 함께 출력하게 하면 확인할 수 있습니다. new Throwable().getStackTrace()는 지금의 호출 스택을 배열로 돌려주고, 0번이 print 자신, 1번이 print를 호출한 메서드입니다.

private static void print(String value) {
    StackTraceElement caller = new Throwable().getStackTrace()[1];
    System.out.println(Thread.currentThread().getName() + "=" + value
            + " <- " + caller.getClassName() + "." + caller.getMethodName());
}

JDK 21의 javac로 컴파일해 실행하면 다음과 같이 나옵니다. 줄의 순서는 역시 실행마다 다릅니다.

style-3=lambda <- RunnableStyleCatalog.lambda$main$0
style-2=anonymous <- RunnableStyleCatalog$1.run
style-1=class <- RunnableStyleCatalog$NamedTask.run

이름 있는 클래스는 소스에 쓴 이름 NamedTask가 그대로 남으므로, 장애 로그에서 이 이름으로 코드를 바로 찾을 수 있습니다. 익명 클래스는 익명 클래스로 동작 전달하기에서 본 것처럼 javac가 소스에 나온 순서대로 붙인 $1로 나타납니다. 람다 본문은 바깥 클래스 안의 lambda$main$0이라는 메서드로 컴파일됩니다. 익명 클래스 뒤에 붙는 숫자와 람다 메서드의 이름은 언어 명세가 아니라 컴파일러가 정하므로, 컴파일러나 코드 위치가 바뀌면 달라질 수 있습니다. 람다나 익명 클래스가 많아지면 로그만 보고 어느 작업인지 알아보기 어려워지는 이유가 여기에 있습니다.

이름 있는 클래스: 값을 생성자로 받는 작업

여러 곳에서 다시 쓰고, 생성자로 받는 값이 있고, 따로 테스트하고 싶은 작업이라면 이름 있는 클래스가 읽기 쉽습니다. 다음 RepeatMessage는 메시지와 반복 횟수를 생성자로 받습니다. 횟수가 1보다 작으면 객체를 만드는 시점에 바로 거부합니다.

src/NestedClassRunnable.java
public final class NestedClassRunnable {
    private static final class RepeatMessage implements Runnable {
        private final String message;
        private final int count;

        RepeatMessage(String message, int count) {
            if (count < 1) throw new IllegalArgumentException("count");
            this.message = message;
            this.count = count;
        }

        @Override
        public void run() {
            for (int i = 1; i <= count; i++)
                System.out.println(Thread.currentThread().getName() + " " + i + ":" + message);
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread worker = new Thread(new RepeatMessage("review", 3), "nested-task");
        worker.start();
        worker.join();
    }
}
nested-task 1:review
nested-task 2:review
nested-task 3:review

작업자 스레드 하나가 반복문을 차례로 실행하므로 이 세 줄은 실행할 때마다 같은 순서로 나옵니다. 스레드 이름 nested-task는 Thread 생성자의 두 번째 인수로 준 것이고, review와 반복 횟수 3은 RepeatMessage 객체의 필드에서 왔습니다. 두 필드는 모두 final이고 생성자에서만 값을 정하므로, 이 객체를 어느 스레드가 언제 실행하든 바뀌지 않습니다. 작업이 어떤 값을 가지고 실행되는지가 필드 선언에 그대로 드러나는 셈입니다.

RepeatMessage에 static을 붙인 데도 이유가 있습니다. static이 없는 내부 클래스의 객체는 내부 클래스와 바깥 인스턴스에서 본 것처럼 자신을 만든 바깥 객체와 연결되고, 바깥 객체의 필드나 메서드를 쓰면 그 객체를 가리키는 참조를 안에 품습니다. 그런 작업을 스레드에 넘기면 작업이 끝날 때까지 바깥 객체도 메모리에서 사라지지 않습니다. 게다가 작업 안에서 바깥 객체의 필드를 건드리면, 그 필드를 다른 스레드와 함께 쓰게 됩니다. 정적 중첩 클래스는 바깥 객체와 연결되지 않으므로, 작업이 쓰는 값은 생성자로 받은 것뿐이라는 사실이 코드에 분명히 남습니다.

익명 클래스: 필드와 도우미 메서드가 필요할 때

익명 클래스는 람다와 달리 필드와 메서드를 여러 개 가질 수 있습니다. 한 곳에서만 쓰는 작업이지만 진행 상황을 세는 필드나 반복되는 출력을 맡을 도우미 메서드가 필요하다면 익명 클래스가 어울립니다. 다음 작업은 completed 필드로 처리한 단계 수를 세고, 출력은 record 메서드에 맡깁니다. 같은 작업 객체 task를 두 스레드에 차례로 넘겨 실행해 보겠습니다.

src/AnonymousRunnableState.java
public final class AnonymousRunnableState {
    public static void main(String[] args) throws InterruptedException {
        Runnable task =
                new Runnable() {
                    private int completed;

                    @Override
                    public void run() {
                        for (String stage : new String[] {"parse", "verify", "store"})
                            record(stage);
                    }

                    private void record(String stage) {
                        completed++;
                        System.out.println(
                                Thread.currentThread().getName() + " " + stage + " #" + completed);
                    }
                };
        Thread first = new Thread(task, "pipeline-1");
        first.start();
        first.join();
        Thread second = new Thread(task, "pipeline-2");
        second.start();
        second.join();
    }
}
pipeline-1 parse #1
pipeline-1 verify #2
pipeline-1 store #3
pipeline-2 parse #4
pipeline-2 verify #5
pipeline-2 store #6

두 번째 스레드의 번호가 1이 아니라 4부터 시작합니다. 두 스레드가 실행한 것이 같은 익명 객체이고, completed는 그 객체의 필드이기 때문입니다. 스레드는 새로 만들었지만 작업 객체는 새로 만들지 않았으므로 상태가 그대로 이어집니다. 이 예제에서는 main이 first.join()으로 첫 스레드가 끝나기를 기다린 뒤에 두 번째 스레드를 시작했습니다. 그래서 두 스레드가 completed를 동시에 바꾸는 일이 없고, 출력도 늘 같습니다. join()을 빼고 두 스레드를 함께 실행하면 같은 필드를 동시에 바꾸게 되는데, 그때 무슨 일이 생기는지는 뒤에서 따로 살펴봅니다.

실행할 때마다 독립된 상태가 필요하다면 스레드마다 작업 객체를 새로 만들면 됩니다. 다만 작업 객체가 다른 객체를 참조하고 있다면, 그 객체까지 공유되는지도 따로 확인해야 합니다. 작업 객체를 두 개 만들었더라도 둘이 같은 목록을 가리킨다면 그 목록은 여전히 공유되기 때문입니다.

익명 클래스와 람다는 this가 가리키는 대상도 다릅니다. 인스턴스 메서드 안에서 만든 익명 Runnable의 this는 그 익명 객체 자신입니다. 같은 자리에서 만든 람다의 this는 람다를 감싼 바깥 객체인데, 람다는 새로운 this를 만들지 않기 때문입니다(JLS 15.27.2). 그래서 익명 클래스 안에서 자기 필드를 this로 쓰던 코드를 람다로 옮기면, 같은 글자가 바깥 객체의 필드를 가리키게 됩니다. 정적 메서드 안에는 바깥 this가 없으므로 람다 본문에서 this를 쓸 수도 없습니다. 자세한 비교는 익명 클래스로 동작 전달하기에 있습니다.

익명 객체를 변수에 담지 않고 new Thread(new Runnable() { ... }, "name").start()처럼 한 식으로 만들어 바로 시작할 수도 있습니다. 하지만 이렇게 쓰면 작업 정의, 스레드 생성, 시작이 한 식에 몰려 읽기 어렵습니다. 무엇보다 시작한 스레드의 참조가 남지 않아 나중에 join()으로 끝나기를 기다릴 방법이 없습니다. 스레드가 끝나기를 기다리거나 상태를 확인해야 한다면 Thread 객체를 지역 변수나 배열에 담아 두세요.

람다는 바뀌는 지역 변수를 캡처할 수 없다

람다는 한두 문장짜리 작업을 그 자리에서 만들 때 가장 간결합니다. 대신 람다 본문에서 바깥 메서드의 지역 변수를 쓰려면 규칙 하나를 지켜야 합니다. 그 변수가 final이거나 사실상 final(effectively final), 즉 초기화한 뒤 다시 대입하지 않는 변수여야 합니다. 다음 코드는 람다가 쓰는 index를 뒤에서 index++로 바꾸므로 컴파일되지 않습니다.

lab/MutableLocalCaptureFailure.java
public final class MutableLocalCaptureFailure {
    public static void main(String[] args) {
        int index = 0;
        Runnable task = () -> System.out.println(index);
        index++;
        task.run();
    }
}
lab/MutableLocalCaptureFailure.java:4: error: local variables referenced from a lambda expression must be final or effectively final
        Runnable task = () -> System.out.println(index);
                                                 ^
1 error

이 오류는 컴파일할 때 나므로 프로그램은 실행되지도 않습니다. index++를 람다보다 앞줄로 옮겨도 같은 오류가 납니다. 컴파일러는 대입이 람다보다 먼저인지 나중인지 따지지 않고, 그 변수가 한 번이라도 다시 대입되는지만 보기 때문입니다.

왜 이런 규칙이 있을까요? 람다는 지역 변수 자체가 아니라 그 값을 복사해서 가지고 다닙니다. 지역 변수는 메서드가 끝나면 사라지지만, 람다 객체는 다른 스레드에 넘겨져 메서드가 끝난 뒤에 실행될 수도 있기 때문입니다. 원래 변수가 계속 바뀔 수 있다면 복사해 둔 값과 원래 변수가 서로 달라지고, 람다가 어느 쪽 값을 보는지 헷갈리게 됩니다. 자바는 다시 대입되지 않는 변수만 캡처하게 해서 이런 혼란을 처음부터 막습니다. 자세한 배경은 지역 클래스와 변수 캡처에서 다뤘습니다.

반복문에서 회차마다 값을 고정하기

이 규칙은 반복문에서 스레드를 여러 개 만들 때 자주 부딪힙니다. for (int index = 0; ...; index++)의 index는 매 회차 index++로 다시 대입되므로 람다에서 바로 쓸 수 없습니다. 해결책은 반복문 본문 안에 새 지역 변수를 선언하고 그 회차의 값을 담는 것입니다. 본문 안에서 선언한 변수는 회차마다 새로 만들어지는 별개의 변수이고, 어느 회차에서도 다시 대입되지 않으므로 사실상 final입니다.

src/LambdaTaskBatch.java
import java.util.ArrayList;
import java.util.List;

public final class LambdaTaskBatch {
    public static void main(String[] args) throws InterruptedException {
        List<String> actions = List.of("publish", "index", "notify");
        List<Thread> workers = new ArrayList<>();
        for (int index = 0; index < actions.size(); index++) {
            String action = actions.get(index);
            int number = index + 1;
            Runnable task =
                    () ->
                            System.out.println(
                                    Thread.currentThread().getName()
                                            + " action="
                                            + action
                                            + ", number="
                                            + number);
            workers.add(new Thread(task, "lambda-" + number));
        }
        for (Thread worker : workers) {
            worker.start();
        }
        for (Thread worker : workers) {
            worker.join();
        }
    }
}

action과 number가 바로 그런 변수입니다. 첫 회차의 람다는 문자열 "publish"와 1을, 두 번째 회차의 람다는 문자열 "index"와 2를 캡처합니다. 반복 변수 index 자체는 어느 람다도 캡처하지 않습니다. 스레드는 세 개를 모두 만든 뒤에 한꺼번에 시작했습니다. 한 번 실행한 결과는 다음과 같습니다.

lambda-3 action=notify, number=3
lambda-1 action=publish, number=1
lambda-2 action=index, number=2

줄의 순서는 앞의 예제처럼 실행마다 달라집니다. 이 실행에서는 lambda-3이 먼저 나왔지만, 다른 실행에서는 lambda-2가 먼저 나왔습니다. 그래도 lambda-1에는 늘 publish와 1이, lambda-3에는 늘 notify와 3이 짝지어 나옵니다. 각 람다가 자기 회차의 값을 복사해 가지고 있기 때문입니다.

변경한 지역 변수는 거부되고 각 회차의 고정 바인딩은 유지된다

실패 예제의 index는 뒤의 증가 연산 때문에 캡처할 수 없습니다. LambdaTaskBatch는 각 회차의 action과 number를 고정해 publish 1, index 2, notify 3의 짝을 출력합니다.

람다 캡처 · 고정된 바인딩

변경한 지역 변수는 거부되고 각 회차의 고정 바인딩은 유지된다

실패 예제의 index++는 컴파일 전에 거부됩니다. 성공한 반복은 action·number의 짝을 회차별로 고정합니다.

고정된 바인딩이 작업의 짝을 보존 변경되는index캡처는 컴파일실패에서 끝나고 세회차action·number는 publish1/index2/notify3의 짝을 고정합니다. 출력순서는 가변이며 객체를 깊게복사하지 않습니다. 변경되는 지역 변수 index=0 뒤 index++ 사실상 final 아님 캡처는 컴파일 거부 스레드 출력 없음 회차별 고정된 바인딩 지역 action·number publish1 / index2 notify3 각 람다에 해당 값 전달 1→publish / 2→index 3→notify 세 작업자의 출력 짝은 고정 / 순서는 가변 실선: 바인딩과 컴파일·실행 관계 참조 대상 객체를 복제하지 않음
고정된 바인딩이 작업의 짝을 보존 변경되는index캡처는 컴파일실패에서 끝나고 세회차action·number는 publish1/index2/notify3의 짝을 고정합니다. 출력순서는 가변이며 객체를 깊게복사하지 않습니다. 변경되는 지역 변수 index=0 뒤 index++ 사실상 final 아님 캡처는 컴파일 거부 스레드 출력 없음 회차별 고정된 바인딩 지역 action·number publish1 / index2 notify3 각 람다에 해당 값 전달 1→publish / 2→index 3→notify 세 작업자의 출력 짝은 고정 / 순서는 가변 실선: 바인딩과 컴파일·실행 관계 참조 대상 객체를 복제하지 않음

실패 예제의 index

지역 변수의 값·변경
index=0
뒤에서 index++
컴파일 또는 출력 결과
사실상 final이 아니어서 컴파일 오류

작업 목록 · 1회차

지역 변수의 값·변경
action=publish
number=1
컴파일 또는 출력 결과
lambda-1 action=publish, number=1

작업 목록 · 2회차

지역 변수의 값·변경
action=index
number=2
컴파일 또는 출력 결과
lambda-2 action=index, number=2

작업 목록 · 3회차

지역 변수의 값·변경
action=notify
number=3
컴파일 또는 출력 결과
lambda-3 action=notify, number=3

실패 예제에는 반복문이 없습니다. 성공한 세 회차는 LambdaTaskBatch의 서로 다른 지역 바인딩이며 출력 순서가 아닙니다. 캡처한 참조의 대상 객체가 복제되는 것도 아닙니다.

그림은 앞의 실패 예제와 이 예제의 세 회차를 함께 정리한 것입니다. 실패 예제의 index는 컴파일 단계에서 거부되어 아무것도 출력하지 못하고, 성공한 예제는 회차마다 고정된 action과 number를 각 스레드의 출력으로 이어 줍니다. 그림에도 적혀 있듯 출력 순서는 정해지지 않으며, 캡처한 참조가 가리키는 객체가 복제되지도 않습니다. 이 마지막 점은 다음 절에서 자세히 봅니다.

향상된 for문을 쓰면 이 일이 더 간단해집니다. for (String action : actions)의 action은 회차마다 새로 만들어지고, 본문에서 다시 대입하지 않는 한 사실상 final입니다. 그래서 따로 복사하지 않고 바로 람다에서 쓸 수 있습니다. 이 예제가 인덱스를 쓰는 일반 for문을 고른 것은 number처럼 순번도 함께 필요했기 때문입니다.

final 참조를 캡처해도 객체는 공유된다

캡처 규칙을 피하려고 값을 배열에 담는 경우가 있습니다. 배열 변수 자체는 다시 대입하지 않으니 컴파일은 통과합니다. 하지만 이것은 규칙을 비켜 갔을 뿐이므로, 무엇이 공유되는지 정확히 알고 써야 합니다. 다음 예제는 배열의 0번 칸을 출력하는 람다를 만든 뒤, 스레드를 시작하기 전에 그 칸을 바꿉니다.

src/CaptureReferenceBoundary.java
public final class CaptureReferenceBoundary {
    public static void main(String[] args) throws InterruptedException {
        final int[] box = {7};
        Thread worker = new Thread(() -> System.out.println("captured=" + box[0]));
        box[0] = 9;
        worker.start();
        worker.join();
    }
}
captured=9

람다를 만들 때 box[0]은 7이었는데 출력은 9입니다. 람다가 복사해 둔 것은 box 변수에 들어 있던 참조, 즉 배열 객체가 어디 있는지에 대한 정보이지 그 순간의 배열 내용이 아니기 때문입니다. 람다 본문의 box[0]은 람다가 실행될 때 배열을 찾아가 읽으므로, 그사이 main이 바꾼 9를 읽습니다. final은 box에 다른 배열을 대입하지 못하게 할 뿐 배열 안의 칸이 바뀌는 것은 막지 않습니다. final 참조와 객체 변경에서 본 것과 같은 이야기입니다.

이 예제의 출력이 언제나 9인 것은 main이 box[0] = 9를 start() 전에 끝냈기 때문입니다. Java 언어 명세는 어떤 스레드가 start()를 호출하기 전에 한 일을 새로 시작된 스레드가 볼 수 있다고 정해 두었습니다(JLS 17.4.5). 반대로 스레드를 시작한 뒤에 main이 배열을 바꾸거나, 여러 스레드가 같은 칸을 동시에 바꾼다면 이런 약속이 없습니다. 그런 경우에 필요한 동기화는 19장에서 다룹니다.

목록도 마찬가지입니다. 람다가 목록을 캡처해 두었다가 실행할 때 원소를 꺼낸다면, 그사이 다른 코드가 목록을 바꿀 수 있습니다. 이럴 때는 List.copyOf로 수정할 수 없는 복사본을 만들어 캡처합니다. 그러면 원래 목록에 원소를 넣거나 빼도 복사본의 구성은 바뀌지 않습니다. 다만 List.copyOf가 복사하는 것은 원소를 가리키는 참조들이지 원소 객체 자체가 아닙니다. String처럼 불변인 원소라면 이것으로 충분하지만, 내용이 바뀌는 게시글 객체를 담은 목록이라면 원소 객체를 누가 언제 바꿀 수 있는지를 따로 정해야 합니다. 앞의 LambdaTaskBatch는 처음부터 수정할 수 없는 List.of를 썼고, 람다가 목록이 아니라 회차마다 꺼낸 String을 캡처했으므로 이런 걱정이 없습니다.

작업 객체를 여러 스레드가 함께 쓰면

어떤 문법으로 만들든 Thread 생성자에 들어가는 것은 Runnable 객체 하나입니다. 그래서 스레드마다 새 작업 객체를 주는지, 하나를 여럿이 함께 쓰는지가 문법보다 훨씬 중요합니다. 다음 예제는 필드 count를 10만 번 늘리는 작업 객체 하나를 두 스레드에 함께 넘깁니다. 두 스레드가 10만 번씩 늘리니 결과는 200000이어야 할 것 같습니다.

lab/SharedCounterRace.java
public final class SharedCounterRace {
    private static final class CountTask implements Runnable {
        private int count;

        @Override
        public void run() {
            for (int i = 0; i < 100_000; i++) {
                count++;
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        CountTask task = new CountTask();
        Thread a = new Thread(task, "counter-a");
        Thread b = new Thread(task, "counter-b");
        a.start();
        b.start();
        a.join();
        b.join();
        System.out.println("count=" + task.count);
    }
}

이 프로그램은 실행할 때마다 한 줄을 출력합니다. 다섯 번 실행해 각 실행의 출력을 모으면 다음과 같았습니다.

count=159936
count=200000
count=173318
count=177278
count=160065

실행할 때마다 값이 다르고, 200000보다 작은 값이 자주 나옵니다. 가끔 200000이 나오기도 하지만 그렇다고 코드가 맞는 것은 아닙니다. 그 실행에서는 두 스레드가 실행된 시간이 마침 거의 겹치지 않았을 수 있습니다. 여러분이 실행하면 또 다른 값이 나올 수 있고, 환경에 따라서는 200000이 여러 번 연달아 나오기도 합니다. 그럴 때는 반복 횟수를 100만 번으로 늘려 보세요. 두 스레드가 겹쳐 실행될 기회가 많아져 차이가 드러나기 쉽습니다.

값이 사라지는 이유는 count++가 한 번에 끝나는 동작이 아니기 때문입니다. 이 한 줄은 필드 값을 읽고, 1을 더하고, 그 결과를 필드에 쓰는 세 단계로 실행되며, 두 스레드의 단계가 사이사이 끼어들 수 있습니다. count가 41일 때 두 스레드가 거의 동시에 증가를 시작하면 다음과 같은 일이 생길 수 있습니다.

순서counter-acounter-bcount
141을 읽음41
241을 읽음41
342를 씀42
442를 씀42

두 스레드가 한 번씩 늘렸는데 count는 1만 늘었습니다. counter-b가 counter-a의 결과를 보기 전에 옛 값 41을 읽어 버렸기 때문입니다. 10만 번의 반복 가운데 이런 겹침이 몇 번 일어나느냐에 따라 결과가 달라집니다. 이처럼 여러 스레드가 같은 값을 동시에 바꿔서 결과가 실행 순서에 따라 달라지는 상황을 경쟁 상태(race condition)라고 합니다.

문법을 바꿔도 결과는 같습니다. 같은 일을 람다로 만들어 캡처한 int[] 배열의 칸을 늘리게 하든, 익명 클래스의 필드를 늘리게 하든, 두 스레드가 같은 칸이나 같은 필드를 바꾸는 한 똑같이 값이 사라질 수 있습니다. 가장 간단한 해결책은 공유를 없애는 것입니다. CountTask는 그대로 두고 main만 다음처럼 바꾸면, 두 스레드가 각자의 객체만 바꾸고 main은 두 스레드가 끝난 뒤에 결과를 합칩니다.

public static void main(String[] args) throws InterruptedException {
    CountTask taskA = new CountTask();
    CountTask taskB = new CountTask();
    Thread a = new Thread(taskA, "counter-a");
    Thread b = new Thread(taskB, "counter-b");
    a.start();
    b.start();
    a.join();
    b.join();
    System.out.println("count=" + (taskA.count + taskB.count));
}

이렇게 바꾸면 몇 번을 실행해도 count=200000이 나옵니다. 각 count를 바꾸는 스레드가 하나뿐이고, main은 join()으로 두 스레드가 끝나기를 기다린 뒤에 값을 읽기 때문입니다. join()이 끝난 뒤에는 그 스레드가 쓴 값을 볼 수 있다는 점은 join으로 스레드가 끝날 때까지 기다리기에서 자세히 다룹니다. 하나의 값을 꼭 함께 바꿔야 한다면 한 번에 한 스레드만 바꾸도록 락을 쓰거나 AtomicInteger 같은 원자적 타입을 써야 하는데, 이것은 19장과 21장에서 배웁니다.

정리하면 여러 스레드가 함께 써도 되는 작업 객체는 두 종류입니다. 하나는 필드가 없는 작업이고, 다른 하나는 앞의 RepeatMessage처럼 생성할 때 정한 불변 값만 가진 작업입니다. 카운터, 버퍼, 반복자처럼 실행하면서 바뀌는 상태를 가진 작업이라면 스레드마다 따로 만들거나, 락이나 원자적 타입으로 보호하거나, 20장에서 배울 큐처럼 값을 메시지로 주고받는 방식 가운데 하나를 골라야 합니다.

어떤 문법을 고를까

지금까지 본 내용을 바탕으로 상황별 선택을 정리하면 다음과 같습니다.

상황알맞은 문법이유
여러 곳에서 다시 쓰고, 생성자로 값을 받고, 따로 테스트한다이름 있는 클래스(정적 중첩 클래스나 최상위 클래스)필드가 작업이 가진 값을 보여 주고, 스택 트레이스에 이름이 남는다
한 곳에서만 쓰지만 필드나 도우미 메서드가 필요하다익명 클래스객체 구조를 쓰는 자리에서 바로 정의할 수 있다
한두 문장짜리 짧은 작업이고 바뀌는 상태가 없다람다가장 짧고, 캡처한 지역 변수가 바뀌지 않는다

어느 문법을 고르든 확인할 것은 같습니다. 작업이 어떤 값을 캡처하거나 필드로 가지고 있는지, 그리고 그 작업 객체와 그것이 가리키는 객체를 여러 스레드가 함께 쓰는지입니다. 같은 작업을 여러 번 실행하고, 예외와 결과를 모으고, 동시에 실행되는 스레드 수를 제한해야 하는 상황이 오면 작업을 만드는 문법을 더 다듬기보다 실행기(executor)에 실행을 맡기는 편이 낫습니다. 실행기는 22장에서 배웁니다.

정리

  • Runnable은 추상 메서드가 run() 하나인 함수형 인터페이스여서 이름 있는 클래스, 익명 클래스, 람다로 모두 만들 수 있습니다. 어느 문법이든 결과는 Thread에 넘길 Runnable 객체 하나입니다.
  • 정적 중첩 클래스는 바깥 객체와 연결되지 않고 생성자로 받은 값만 가지므로, 작업이 무엇을 가지고 실행되는지가 분명합니다. 익명 클래스는 필드와 도우미 메서드를 가질 수 있고, 그 this는 익명 객체 자신입니다.
  • 람다가 캡처하는 지역 변수는 final이거나 사실상 final이어야 합니다. 반복문에서는 본문 안에 새 지역 변수를 선언해 회차마다 값을 고정합니다.
  • final 배열이나 목록을 캡처하면 참조만 복사되고 객체는 공유됩니다. start() 전에 끝낸 변경은 새 스레드가 볼 수 있지만, 시작한 뒤의 동시 변경에는 동기화가 필요합니다.
  • 바뀌는 필드를 가진 작업 객체 하나를 여러 스레드에 넘기면 경쟁 상태가 생겨 결과가 실행마다 달라집니다. 스레드마다 작업 객체를 따로 만들거나 동기화로 보호합니다.

연습 문제

  1. 문자열 alpha, beta, gamma, delta를 각각 별도의 스레드에서 순번:값 형식으로 출력하는 프로그램을 작성하세요. 순번과 값의 짝이 맞아야 하고, main은 모든 스레드가 끝나기를 기다린 뒤 마지막 줄에 스레드 개수를 출력해야 합니다.
해설 보기

반복문의 i는 매 회차 다시 대입되므로 람다에서 바로 쓸 수 없습니다. 그래서 회차마다 slot과 value라는 새 지역 변수에 그 회차의 순번과 값을 복사해 캡처합니다. 시작한 스레드는 배열에 담아 두었다가 모두 join()합니다.

exercise/SafeLoopCaptureSolution.java
public final class SafeLoopCaptureSolution {
    public static void main(String[] args) throws InterruptedException {
        String[] values = {"alpha", "beta", "gamma", "delta"};
        Thread[] workers = new Thread[values.length];
        for (int i = 0; i < values.length; i++) {
            int slot = i;
            String value = values[i];
            workers[i] =
                    new Thread(() -> System.out.println(slot + ":" + value), "capture-" + slot);
            workers[i].start();
        }
        for (Thread worker : workers) {
            worker.join();
        }
        System.out.println("captured=" + workers.length);
    }
}

한 번 실행한 결과는 다음과 같았습니다.

3:delta
0:alpha
1:beta
2:gamma
captured=4

앞의 네 줄은 실행마다 순서가 바뀝니다. 다른 실행에서는 2:gamma가 맨 앞에 나오기도 했고, 코드 순서대로 나온 실행도 있었습니다. 하지만 0:alpha, 1:beta처럼 순번과 값의 짝은 언제나 맞고, 네 짝이 한 번씩 나타납니다. 마지막 줄 captured=4는 main이 네 스레드를 모두 join()한 뒤에 출력하므로 항상 맨 끝에 나옵니다.

  1. 다음 프로그램은 배열 하나를 세 람다가 함께 캡처합니다. 람다를 만드는 동안 box[0]을 1, 2, 3으로 차례로 바꾸고, 모든 변경이 끝난 뒤에 세 스레드를 시작합니다. 출력을 예상해 보세요. 그리고 각 람다가 자신을 만들 때의 값 1, 2, 3을 출력하게 하려면 어떻게 고쳐야 할까요?
exercise/SharedBoxQuestion.java
public final class SharedBoxQuestion {
    public static void main(String[] args) throws InterruptedException {
        final int[] box = {0};
        Thread[] workers = new Thread[3];
        for (int i = 0; i < workers.length; i++) {
            box[0] = i + 1;
            workers[i] = new Thread(() -> System.out.println("box=" + box[0]));
        }
        for (Thread worker : workers) worker.start();
        for (Thread worker : workers) worker.join();
    }
}
해설 보기

세 줄 모두 box=3입니다. 세 람다가 캡처한 것은 같은 배열을 가리키는 참조 하나이고, box[0]은 람다가 실행될 때 읽습니다. 스레드가 실행될 때는 이미 마지막 대입 box[0] = 3이 끝난 뒤입니다. 모든 대입을 start() 전에 마쳤으므로 세 스레드 모두 3을 보게 되며, 실행 결과도 그랬습니다.

만들 때의 값을 남기려면 회차마다 그 값을 새 지역 변수에 복사하고, 배열 대신 그 변수를 캡처합니다.

box[0] = i + 1;
int captured = box[0];
workers[i] = new Thread(() -> System.out.println("captured=" + captured));

이렇게 고치면 captured=1, captured=2, captured=3이 한 번씩 나옵니다. 세 줄의 순서는 실행마다 달라질 수 있습니다.

  1. 2번 문제를 바꿔서, 세 스레드를 시작한 뒤에 각 스레드가 box[0]++를 한 번씩 실행한다고 해 봅시다. box가 final이니 세 스레드가 끝난 뒤 box[0]이 정확히 3만큼 늘어난다고 말할 수 있을까요?
해설 보기

말할 수 없습니다. final은 box에 다른 배열을 대입하지 못하게 할 뿐, 배열 칸을 바꾸는 연산을 안전하게 만들어 주지 않습니다. box[0]++도 count++처럼 읽기, 더하기, 쓰기의 세 단계이므로, 두 스레드가 같은 옛 값을 읽으면 증가 하나가 사라질 수 있습니다. 세 번뿐이라 실제로 겹칠 확률은 낮지만, 낮다는 것과 일어나지 않는다는 것은 다릅니다. 정확히 3만큼 늘리려면 AtomicInteger나 락 같은 동기화를 쓰거나, 스레드마다 따로 세게 한 뒤 join() 다음에 합쳐야 합니다.

  1. 어떤 객체의 인스턴스 메서드 안에서 익명 Runnable과 람다를 하나씩 만들고, 둘 다 본문에서 this를 사용합니다. 두 this는 같은 객체인가요? 또 익명 클래스의 카운터 필드 때문에 생긴 공유 문제를 "람다로 바꾸면 해결된다"고 말할 수 있을까요?
해설 보기

다른 객체입니다. 익명 Runnable의 this는 익명 객체 자신이고, 람다의 this는 람다를 감싼 바깥 객체입니다. 문법을 람다로 바꾼다고 공유 문제가 해결되지는 않습니다. 카운터를 람다로 옮기려면 배열이나 바깥 객체의 필드처럼 어딘가에 바뀌는 상태를 두어야 하는데, 그 상태를 여러 스레드가 함께 쓰면 문제는 그대로입니다. 실행마다 독립된 카운터가 필요하다면 작업 객체와 그 작업이 가리키는 바뀌는 객체를 스레드마다 따로 만들어야 합니다.

다음 글

스레드 정보와 생명 주기에서는 스레드의 ID, 이름, 우선순위 같은 정보를 조회하고, 스레드가 NEW에서 TERMINATED까지 어떤 상태를 거치는지 직접 만들어 확인합니다.