프로그래밍 언어/[ Java ]

[ Java ] 25. lambda와 effectively final

kim.svadoz 2021. 11. 23. 20:56
반응형

lambda 와 effectively final

JDK 1.8에서 추가된 람다식에는 규칙이 있다.

  1. 람다식은 외부 block에 있는 변수에 접근할 수 있다.
  2. 외부에 있는 변수가 지역 변수 일 경우 final 혹은 effectively final인 경우에만 접근할 수 있다.

effectively final이란?

A non-final local variable or method parameter whose value is never changed after initialization is known as effectively fianl.

Java8에 추가된 syntatic sugar의 일종으로, 초기화 된 이후 값이 한번도 변경되지 않았다면 effectively final이라고 할 수 있다.

 

effectively final 변수는 final 키워드가 붙지 않았지만 final 키워드를 붙힌것과 동일하게 컴파일러에서 처리하므로 '의미상 final'하다고 이해해도 좋다.

 

effectively final은 anonymous class나 람다식에서 코드를 더 간결하게 해준다.

 

java 7에서는 anonymous class가 외부지역변수 가 final인 경우에만 접근이 가능했기 때문에 항상 final 키워드를 추가해줘야 했다.

java 8에서는 effectively final인 경우에도 접근이 가능하도록 바뀌어서 조건을 만족한다면 final 키워드를 생략할 수 있다.

// Java 7
public void add() {
    final int number = 1;

    Addable addableImple = new Addable() {
        @Override
        public int addOne() {
            return number + 1;
        }
    };
}

// Java 8
public void add() {
    int number = 1; // Effectively final하다.

    Addable addableImple = new Addable() {
        @Override
        public int addOne() {
            return number + 1;
        }
    }
}

이는 lambda에서도 동일하다.

// Java 8
public void add() {
    int number = 1; // Effectively final하다.

    Addable addableImple = () -> number + 1;
}

람다가 사용하는 지역변수는 왜 final(effectively final) 이어야 할까?

람다식에서 참조하는 외부 지역 변수는 final 혹은 effectively fianl이어야 한다.

외부 변수라는 단어에는 지역변수, 인스턴스 변수, 클래스 변수가 모두 포함될 수 있는데, 인스턴스 변수나 클래스 변수는 final 혹은 effectively final하지 않아도 람다식에서 사용할 수 있다.

private int instanceNumber = 1;
private static int staticNumber = 1;

// Error : 외부 지역변수는 final 혹은 effectively final 이어야 람다에서 사용가능하다.
public void addByLocalVariable() {
    int localNumber = 1;

    localNumber = 2;
    Addable addalbeImple = () -> localNumber + 1;
}

// OK : 클래스 변수(static)는 값을 변경하더라도 문제없다.
public void addByInstanceVariable() {
    instanceNumber = 2;
    Addable addableImple = () -> instanceNumber + 1;
}

// OK : 인스턴스 변수(non-static)는 값을 변경하더라도 문제 없다.
public void addByStaticVariable() {
    staticNumber = 2;
    Addable addableImple = () -> staticNumber + 1;
}

람다식에서 사용되는 지역변수가 final 혹은 effectively final 이어야 하는 이유를 알기 위해서는 Capturing lambda라는 키워드를 알아야 한다.

 

Capturing lambda vs Non-Capturing lambda

람다에는 2가지 타입이 존재한다.

  • Capturing lambda
    • 외부 변수를 이용하는 람다식이다.
    • 외부 변수는 지역변수, 인스턴스 변수, 클래스 변수를 모두 포함한다.
    • String msg = "CapturingLambda"; Runnable runnable = () -> System.out.println(msg);
  • Non-Captuing lambda
    • 외부 변수를 사용하지 않는 람다식이다.
    • Runnable runnable = () -> System.out.println("NonCapturingLambda"); Runnable runnable = () -> { String msg = "NonCapturingLambda"; System.out.println(msg); }

 

Capturing lambda는 다시 local Capturing lambda와 non-local Capturing lambda로 구분된다.

local <-> non-local 로 다시 구분하는 이유는 지역 변수가 가지는 특징으로 내부 동작 방식이 다르기 대문이다.

 

- Local Capturing Lambda

public void addByLocalVariable() {
    int localNumber = 1;
    Addable addableImple = () -> localNumber + 1;
}

외부변수로 지역변수를 이용하는 람다식을 의미하며 다음과 같은 특징을 갖는다.

  • 람다식에서 사용되는 외부 지역 변수는 복사본이다.
  • fianl 혹은 effectively fianl인 지역 변수만 람다식에서 사용할 수 있다.
  • 복사된 지역 변수 값은 람다식 내부에서도 변경할 수 없다. 즉, final 변수로 다뤄야 한다.

각 특징에 대해 조금 더 자세히 알아보자.

 

1. 람다식에서 사용되는 외부 지역변수는 복사본이다.

람다식에서 외부 지역변수를 그대로 사용하지 못하고 복사본을 사용하는 이유는 다음과 같다.

  • 지역 변수는 스택영역에 생성된다. 따라서 지역 변수가 선언된 block이 끝나면 스택에서 제거된다.
    • 메소드 내 지역변수를 참조하는 람다식을 리턴하는 메소드가 있을 경우, 메소드 block이 끝나면 지역 변수가 스택에서 제거되므로 추후에 람다식이 수행될 때 참조할 수 없다.
  • 지역 변수를 관리하는 쓰레드와 람다식이 실행되는 쓰레드가 다를 수 있다.
    • 스택은 각 쓰레드의 고유 공간이고, 쓰레드끼리 공유되지 않기 때문에 마찬가지로 람다식이 수행될 때 값을 참조할 수 없다.

이러한 이유로, 람다식에서는 외부 지역 변수를 직접 참조하지 않고 복사본을 전달받아서 사용하게 된다.

 

2. final 혹은 effectively final인 지역 변수만 람다식에서 사용할 수 있다.

만약 참조하고자 하는 지역변수가 final이나 effectively final이 아닐 경우 즉, 변경이 가능할 경우 어떤 문제가 일어날까?

public void executelocalVariableInMultiThread() {
    boolean flag = true;
    executor.execute(() -> {
        while(flag) {
            // do
        }
    });       
    flag = false;
}

람다식이 정확히 어떤 쓰레드에서 수행되는지 미리 알 수 없다. 즉, 외부 지역변수를 다루는 쓰레드와 람다식이 수행되는 쓰레드가 다를 수 있다.

지역 변수 값(flag) 을 제어하는 쓰레드를 A, 람다식이 수행되는 쓰레드를 B라고 가정하면 문제는 다음과 같다.

 

쓰레드 B의 flag 값이 가장 최신 값으로 복사되어 전달됐는지 확신할 수 없다.

왜냐하면 flag는 변경 가능한 지역 변수이고, 지역변수를 쓰레드 간에 동기화해주는 것은 불가능하기 때문이다.

지역 변수는 쓰레드 A의 스택영역에 존재하기 때문에 다른 쓰레드에서 접근이 불가능하다. volatile과 같은 키워드가 로컬 변수에서 사용될 수 없는 이유도 이와 같다.

 

값이 보장되지 않는 다면 매번 다른 결과가 도출될 수 있고 예측할 수 없는 코드는 사용할 수 없다.

이러한 이유로 외부 지역 변수는 전달되는 복사본이 변경되지 않은 최신 값임을 보장하기 위해 final 혹은 effectively final이어야 한다.

 

3. 복사된 지역 변수 값은 람다식 내부에서 변경할 수 없다. 즉, final처럼 다뤄야 한다.

이미 복사가 된 값이므로 변경해도 문제가 없는게 아닌가 생각할 수 있지만 아니다.

복사될 값의 변조를 막아 항상 최신의 값임을 보장하기 위해서 fianl 제약을 걸었는데, 내부에서 변경가능하다면 말짱 도루묵이 된다.

 

또한, 컴파일 된 람다식은 static 메소드 형태로 변경되는데, 이 때 복사된 값이 파라미터로 전달되므로 마찬가지로 스택영역에 존재하기 때문에 동기화 해주는 게 불가능하다.

따라서 람다식 내부에서도 값이 변경 되어서는 안되며 컴파일러 레벨에서 앞, 뒤로 final 제약을 걸어줌으로써 멀티 쓰레드 환경에서 대응하기 어려운 이슈를 미연에 방지하는 것이다.

 

- Non-Local Capturing Lambda

private int instanceNumber = 1;
private static int staticNumber = 1;

public void addByInstanceVariable() {
    instanceNumber = 2;
    Addable addableImple = () -> instanceNumber + 1;
}
public void addByStaticVariable() {
    staticNumber = 2;
    Addable addableImple = () -> staticNumber + 1;
}

외부 변수로 지역 변수가 아닌, 인스턴스 변수나 클래스 변수를 이용하는 람다식이다.

final 제약 조건이 없고, 외부 변수 값도 복사해서 사용하지 않는다.

인스턴스 변수(non-static) : 인스턴스가 생성될 때마다 heap 영역에 매번 새로 생성되고 GC에 의해 소멸

클래스 변수(static) : 클래스가 메모리에 올라갈 때 method 영역에 한 개만 생성되고 프로그램 종료 시 소멸

지역 변수 : 메서드 수행시 stack 영역에 생성 후 메소드 종료 시 소멸

하나의 쓰레드는 다른 쓰레드로 접근할 수 없지만, static(method) 영역과 heap 영역은 공유해서 사용할 수 있다.

그 이유는 인스턴스 변수나 클래스 변수가 저장하고 있는 메모리 영역은 공통 영역이고 값이 메모리에서 바로 회수되지 않기 때문에 여러 스레드나 람다식에서 바로 참조가 가능한 것이다.

 

따라서, 복사 과정이 불필요하고 참조 시 최신 값 임을 보장할 수 있다. 다만 멀티 쓰레드 환경일 경우에는 volatile이나 synchronized 등을 이용해서 동기화를 맞춰주는 작업을 해야한다.

결론

람다식에서 외부 지역 변수를 사용하는 경우 final 혹은 effectively fianl이어야 하는 이유는 지역변수가 스택에 저장되기 때문에 람다식에서 값을 바로 참조하는 것에 제약이 있어 복사된 값을 사용하는데, 이 때 멀티 쓰레드 환경에서 복사된(될) 값이 변경 가능할 경우 동시성 이슈를 대응할 수 없기 때문이다.

참조

https://vagabond95.me/posts/lambda-with-final/

반응형