[Java] 왜 람다가 사용하는 지역변수는 Effectively Final 이어야 하는가?

charco·2021년 8월 3일
0

나도TIL

목록 보기
16/55

Baeldung 을 번역한 글입니다.

아주아주 좋은 글이 있길래 번역을 하기로 했다.

1. 소개

자바 8 은 람다와 Effectively Final 이라는 개념을 소개했다.
람다에서 쓰이는 지역 변수가 왜 effectively final 이나 final 이어야 하는지 궁금하지 않나?

오라클 공식 문서에 약간의 힌트가 있다.
"effectively final 제한은 동시성 문제를 일으킬 수 있는 다이내믹하게 바뀌는 지역 변수로의 접근을 막아준다." 무슨 말일까?

다음 단계부터 이것이 싱글스레스, 동시성 애플리케이션에 어떤 영향을 주는지 알아보고 이 제한에 대한 흔히 쓰이는 안티 패턴에 대해 알아볼 것이다.

2. Capturing Lambda

람다 표현식은 바깥 범위에 있는 변수들을 사용할 수 있다.
이 람다들을 capturing lambda 라고 부른다.
capturing lambda 들은 스태틱, 인스턴스, 지역 변수를 사용할 수 있다.
하지만 지역 변수는 꼭 effectively final 이나 final 이어야 한다.

람다가 사용하는 지역 변수에 final 이 없을때는 컴파일러가 알아챈다.
컴파일러가 final 을 붙혀주기 때문에 해당 변수를 조작하려는 시도가 있으면
컴파일 에러를 띄운다. 이것을 effectively final 이라고 한다.

3. Capturing Lambda 안의 지역 변수

다음의 코드는 컴파일되지 않을 것이다.

Supplier<Integer> incrementer(int start) {
  return () -> start++;
}

start 는 지역 변수고 이 코드는 람다 표현식 안에서 이 변수를 조작하려고 한다.
이 코드가 컴파일되지 않는 이유는 람다가 start 값의 복사본을 만들려고 하기 때문이다.

이 변수를 final 로 강제하는 것은 start 를 람다 안에서 증가시키는 것이
실제로 start 메서드 파라미터를 조작할 수 있다는 인상을 막아준다(?)
(Forcing the variable to be final avoids giving the impression that incrementing start inside the lambda could actually modify the start method parameter.)

왜 복사본을 만들려는 것일까?
위의 메서드는 람다를 반환하려고 한다.
그래서 람다는 start 메서드 파라미터가 가비지 컬렉터에게 수집된 이후까지는 동작하지 않을 것이다.
자바는 이 람다가 메서드 밖에서도 살아남을 수 있도록 start의 복사본을 만들어줘야 한다.

3. 동시성 문제

만약 final 제약이 없어졌다고 생각해보자.

public void localVariableMultithreading() {
    boolean run = true;
    executor.execute(() -> {
        while (run) {
            // do operation
        }
    });
    
    run = false;
}

아무 문제 없어보이지만, "투명성"에 대한 잠재적인 문제가 있다.
각각의 스택을 갖고있는 여러 스레드에서 실행한다면
이 while 문이 다른 스택의 run 변수의 변화를 감지할 수 있다는 것을
어떻게 보장할 수 있을까? 답은 synchronized 블럭이나
volatile 키워드를 사용하면 된다는 것이다.

하지만 자바가 effectively final 을 강제하기 때문에 이러한 복잡한 문제에 대해 신경쓰지 않아도 되는 것이다.

Capturing Lambda 안의 static, instance 변수

start 변수를 instance 변수로 바꿔보자.

private int start = 0;

Supplier<Integer> incrementer() {
    return () -> start++;
} //컴파일됨

어떻게 start 를 조작하는게 가능한 것인가?

간단히 멤버 변수들이 어디에 저장되는가에 답이 있다.
로컬 변수들은 스택, 멤버 변수들은 힙에 저장된다.
힙 메모리를 사용하기 때문에 (Because we're dealing with heap memory)
컴파일러는 람다가 start의 가장 최근의 값에 대해 접근할 수 있다는 것을 보장한다.

위의 예제를 바꿔보자.

private volatile boolean run = true;

public void instanceVariableMultithreading() {
    executor.execute(() -> {
        while (run) {
            // do operation
        }
    });

    run = false;
}

람다가 다른 스레드에서 실행되도, volatile 키워드를 붙혔기 때문에
run 변수에 접근할 수 있게 됐다.

일반적으로 말하면, 인스턴스 변수를 캡쳐링할때 final 변수인 this 를 캡쳐링하는 것이라고 생각할 수 있다.
아무튼, 컴파일러가 final 을 자동으로 붙혀준다고 해도 우리는 지침들을 따라야한다, 특히 멀티스레드 환경에서.

피해야 할 해결법

제약을 피하기 위해 누군가는 로컬 변수를 조작하기 위해 변수 저장소를 사용하는 방법을 생각할 수 도 있다.

변수를 배열에 담는 싱글스레드 애플리케이션 예제다.

public int workaroundSingleThread() {
    int[] holder = new int[] { 2 };
    IntStream sums = IntStream
      .of(1, 2, 3)
      .map(val -> val + holder[0]);

    holder[0] = 0;

    return sums.sum();
}

스트림이 1, 2 ,3 에 holder[0] 을 각각 더하는 것을 볼 수있다.
어떤 값이 더해질까?
사실 0 이 더해진다. 왜냐하면 람다가 실행될때 가장 최근의 값을 사용하기 때문이다.

한 단계 더 가서 sum 메서드를 다른 스레드에서 실행하는 예제다.

public void workaroundMultithreading() {
    int[] holder = new int[] { 2 };
    Runnable runnable = () -> System.out.println(IntStream
      .of(1, 2, 3)
      .map(val -> val + holder[0])
      .sum());

    new Thread(runnable).start();

    // simulating some processing
    try {
        Thread.sleep(new Random().nextInt(3) * 1000L);
    } catch (InterruptedException e) {
        throw new RuntimeException(e);
    }

    holder[0] = 0;
}

어떤 값들을 더하고 있을까?
프로세스가 얼마나 걸릴지에 달려있다.
만약 다른 스레드가 실행되기 전에 메서드의 실행이 끝날만큼 빠르다면 6을, 아니면 12 를 출력할 것이다.

일반적으로 이런 종류의 해결법들은 에러가 발생하기 쉽고 예상치 못한 결과가 나올 수 도 있다.
그래서 항상 피해야한다.

결론

이 글에서 우리는 왜 람다 표현식이 final 이나 effectively final 지역 변수만 사용할 수 있는지 알아봤다. 위에서 봤듯이 이 제약은 변수들의 different nature 와 자바가 변수들을 메모리에 어떻게 저장하는지에서 왔다. 그리고 특정 해결법에 대한 위험성도 살펴봤다.

항상 그렇듯이 모든 소스코드는 깃헙 에서 확인 가능하다

profile
아직 배우는 중입니다

0개의 댓글