Runnable 표현과 람다 캡처
정적 중첩 클래스, 익명 클래스, 람다를 동일 작업에 적용하고 상태·재사용·캡처 기준으로 구분합니다.
Runnable은 추상 메서드가 하나인 functional 인터페이스라 여러 문법으로 만들 수 있습니다. 표현이 짧다는 이유만으로 람다를 고르면 캡처된 상태와 재사용 범위가 감춰질 수 있습니다. 작업의 이름, 상태, 생성 횟수, 테스트 구분을 보고 적절한 표현을 선택합니다.
lambda가 변경되는 지역 변수를 캡처
람다가 바깥 지역 변수를 캡처하려면 그 변수는 final이거나 사실상 final이어야 합니다.
아래 코드는 반복문 변수 index 자체가 반복마다 변경되므로 실제 컴파일 오류가 발생합니다.
나중에 어떤 값이 될지 모호한 변수를 클로저가 캡처하지 못하도록 Java가 거부하는 사례입니다.
public final class MutableLocalCaptureFailure {
public static void main(String[] args) {
int index = 0;
Runnable task = () -> System.out.println(index);
index++;
task.run();
}
}오류를 가변 배열로 우회하면 컴파일은 되지만 모든 작업이 같은 box를 공유해 의도하지 않은 최종값을 볼 수 있습니다.
반복 안에서 int taskIndex=index;처럼 각 반복의 값을 고정하거나 필요한 값을 생성자 필드로 명시합니다.
캡처 규칙은 스레드 safety를 보장하지 않고 지역 변수 재할당만 제한합니다.
정적 중첩 클래스의 상태 규칙
반복 사용되고 생성자 매개변수가 있으며 독립 테스트가 필요한 작업은 이름 있는 class가 읽기 쉽습니다.
static 중첩 클래스로 만들면 바깥 인스턴스를 암묵적으로 캡처하지 않습니다.
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();
}
}클래스 이름이 스택 추적과 검색 결과에 남고 필드가 공유 범위를 보여 줍니다.
비정적 inner class였다면 외부 객체 참조까지 가지므로 수명이 늘고 스레드 사이에 예상치 못한 상태가 공유될 수 있습니다.
익명 클래스의 다중 메서드
Runnable처럼 메서드 하나인 경우에도 익명 class는 필드와 도우미 메서드를 둘 수 있습니다.
생성 위치에서만 쓰이고 로직이 짧지만 람다보다 객체 구조를 명시하고 싶을 때 적절합니다.
이름 없는 타입이라 다른 위치에서 직접 재사용하기 어렵습니다.
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 worker = new Thread(task, "anonymous-pipeline");
worker.start();
worker.join();
}
}같은 작업 인스턴스를 두 스레드에 동시에 전달하면 completed를 함께 바꾸므로 경합이 됩니다.
상태가 필요 없으면 불변 필드만 캡처하고, 실행마다 독립 상태가 필요하면 Runnable도 매번 새로 만듭니다.
익명 객체 변수를 따로 두지 않고 new Thread(new Runnable(){...}, name).start()로 즉시 전달할 수도 있습니다.
다만 생성, 처리기 설정, start가 한 표현에 몰리면 읽기 어렵고 스레드 참조를 잃어 join할 수 있습니다.
생명 주기 제어가 필요하면 지역 변수에 보관합니다.
람다와 작은 무상태 동작
람다는 매개변수가 없는 run() 본문만 표현합니다.
한두 단계의 작업, 불변 캡처, 주변 문맥만으로 목적이 분명한 경우에 가장 간결합니다.
긴 분기와 여러 자원 정리까지 람다에 넣으면 이름과 책임이 사라집니다.
import java.util.ArrayList;
import java.util.List;
public final class LambdaTaskBatch {
public static void main(String[] args) throws InterruptedException {
List<String> topics = List.of("thread", "visibility", "lock");
List<Thread> workers = new ArrayList<>();
for (int index = 0; index < topics.size(); index++) {
String topic = topics.get(index);
int number = index + 1;
Runnable task =
() ->
System.out.println(
Thread.currentThread().getName()
+ " topic="
+ topic
+ ", number="
+ number);
workers.add(new Thread(task, "lambda-" + number));
}
for (Thread worker : workers) {
worker.start();
}
for (Thread worker : workers) {
worker.join();
}
}
}topic과 number는 반복마다 새 effectively-final 바인딩입니다.
목록 자체가 이후 변경될 가능성이 있다면 List.copyOf로 스냅샷을 만들고 그 스냅샷에서 값을 꺼냅니다.
람다 인스턴스가 자동으로 스레드마다 복제된다고 가정하지 않습니다.
표현 방식보다 작업 객체의 공유 여부가 더 중요성
정적 중첩 class, 익명 class, 람다는 모두 결국 Runnable 인스턴스를 Thread 생성자에 전달합니다.
각 Thread에 새 인스턴스를 주는지, 하나를 공유하는지에 따라 필드 경쟁이 결정됩니다.
문법이 다르다고 Java Memory Model이 달라지지 않습니다.
상태 없는 작업은 하나를 공유해도 괜찮습니다. 읽기 전용 불변 구성도 안전하게 공유할 수 있습니다. 가변 카운터, 버퍼, 반복자가 있다면 스레드 confinement, 락, 원자 타입, 메시지 passing 가운데 규칙에 맞는 방법을 택합니다.
네 문제를 같은 결과 형식으로 풀어 보기
Thread 상속, Runnable class, 익명 class, 람다 중 무엇을 쓰더라도 작업자 이름과 입력값을 출력한다는 외부 결과는 같게 만들 수 있습니다.
차이는 코드 위치와 상태 표현입니다.
학습할 때는 네 형태를 모두 작성하되 제품 코드에서는 팀이 추적하기 쉬운 최소 표현을 고릅니다.
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();
}
}
}세 출력의 순서는 변하지만 이름과 값의 pair는 고정입니다. 반복 실행, 예외 수집, 최대 동시성 제어가 필요해지면 표현을 더 늘리기보다 실행기로 실행 정책을 올립니다.
연습 문제
문자열 alpha, beta, gamma, delta를 각각 별도 스레드에서 출력하세요.
index와 값이 맞아야 하며 main은 모든 스레드를 join해야 합니다.
정답과 캡처 해설
각 반복에서 slot과 value를 새 지역 변수에 복사합니다.
배열에는 작업자 참조를 저장해 종료를 회수합니다.
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);
}
}앞의 네 줄은 순서가 달라도 각 index-값 조합이 한 번씩 나타납니다.
마지막 줄은 join 뒤라 항상 나중입니다.
선택 기준을 요약하면 반복 사용과 명시적 상태는 이름 있는 class, 한 지점의 객체 구조는 익명 class, 짧고 무상태한 동작은 람다입니다.
어느 문법이든 캡처와 Runnable 인스턴스 공유 범위를 먼저 검토해야 합니다.