Effectively final 이란?
A non-final local variable or method parameter whose value is never changed
after initialization is known as effectively final.
Java 8 에 추가된 syntactic sugar 일종으로, 초기화 된 이후 값이 한번도 변경되지 않았다면 effectively final 이라고 할 수 있다.
effectively final 변수는 final 키워드가 붙어있지 않았지만 final 키워드를 붙힌 것과 동일하게 컴파일러에서 처리한다.
의미상 final 하다
라고 생각하는 사람들이 많다.
java 7 에서는 anonymous class 가 외부지역변수 가 final 인 경우에만 접근이 가능했기에 항상 final 키워드를 추가해줘야 했다.
java 8 에서는 effectively final 인 경우에도 접근이 가능하게 바뀌어 조건을 만족한다면 final 키워드를 생략할 수 있다.
// Java 7
public void testPlus() {
final int number = 1;
Addable addableImple = new Addable() {
@Override
public int plusOne() {
return number + 1;
}
};
}
// Java 8
public void testPlus() {
int number = 1; // Effectively final
Addable addableImple = new Addable() {
@Override
public int plusOne() {
return number + 1;
}
};
}
// Java 8
public void testPlus() {
int number = 1; // Effectively final
Addable addableImple = () -> number + 1;
}
람다에서 사용되는 지역 변수가 final 혹은 effectively final 이어야 하는 이유
람다식에서 참조하는 외부 변수는 final 혹은 effectively final이어야 한다.
가 100% 맞는 말은 아니다.
외부 변수는 지역변수, 인스턴스 변수, 클래스 변수가 모두 포함될 수 있는데
인스턴스 변수나 클래스 변수는 final 혹은 effective final 하지 않아도 람다식에서 사용할 수 있기 때문이다.
private int instanceNumber = 1;
private static int staticNumber = 1;
// Error, 외부 지역변수는 final 혹은 effectively final 이어야 람다식에서 사용할 수 있다.
public void testPlusByLocalVariable() {
int localNumber = 1;
localNumber = 2;
Addable addableImple = () -> localNumber + 1;
}
// OK, 값을 변경하더라도 문제 없다.
public void testPlusByInstanceVariable() {
instanceNumber = 2;
Addable addableImple = () -> instanceNumber + 1;
}
// OK, 값을 변경하더라도 문제 없다.
public void testPlusByStaticVariable() {
staticNumber = 2;
Addable addableImple = () -> staticNumber + 1;
}
람다식에서 사용되는 지역 변수가 final 혹은 effective final이어야 하는 이유를 알아보자.
Capturing lambda & Non-Capturing lambda
람다식에는 2가지 타입이 존재한다.
- Capturing lambda
- Local Capturing lambda
- Non-Local Capturing lambda
- Non-Capturing lambda
Capturing lambda
외부 변수를 이용하는 람다식을 의미한다.
외부 변수는 지역변수, 인스턴스 변수, 클래스 변수를 모두 포함한다.
String message = "CapturingLambda";
Runnable runnable = () -> System.out.println(message);
Non-Capturing lambda
외부 변수를 이용하지 않는 람다식을 의미한다.
Runnable runnable = () -> System.out.println("NonCapturingLambda");
Runnable runnable = () -> {
String message = "NonCapturingLambda";
System.out.println(message);
}
Capturing lambda는 다시 local capturing lambda와 non-local capturing lambda로 구분할 수 있다.
local과 non-local을 구분하는 이유는 지역 변수가 가지는 특징으로 인해 내부 동작 방식이 다르기 때문이다.
Local Capturing lambda
public void testPlusByLocalVariable() {
int localNumber = 1;
Addable addableImple = () -> localNumber + 1;
}
외부 변수로 지역 변수를 이용하는 람다식을 의미한다.
다음과 같은 특징이 있다.
(1) 람다식에서 사용되는 일부 지역 변수는 복사본이다.
(2) final 혹은 effectively final인 지역 변수만 람다식에서 사용할 수 있다.
(3) 복사된 지역 변수 값은 람다식 내부에서도 변경할 수 없다. 즉 final 변수로 다뤄야 한다.
위와 같은 특징이 생긴 이유를 알아보자.
1. 람다식에서 사용되는 외부 지역변수는 복사본이다.
람다식에서는 외부 지역변수를 그대로 사용하지 못하고 복사본을 사용하는 이유는 다음과 같다.
-
지역 변수는 스택 영역에 생성된다. 따라서 지역 변수가 선언된 block이 끝나면 스택에서 제거된다.
→ 메소드 내 지역 변수를 참조하는 람다식을 리턴하는 메소드가 있을 경우, 메소드 block이 끝나면
지역 변수가 스택에서 제거되므로 추후에 람다식이 수행될 때 참조할 수 없다. -
지역 변수를 관리하는 스레드와 람다식이 실행되는 스레드가 다를 수 있다.
→ 스택은 각 스레드의 고유의 공간이고, 스레드끼리 공유되지 않기 때문에
마찬가지로 람다식이 수해오딜 때 값을 참조할 수 없다.
위와 같은 이유로 인해 람다식에서는 외부 지역 변수를 직접 참조하지 않고 복사본을 전달받아 사용하게 된다.
2. final 혹은 effectively final인 지역 변수만 람다식에서 사용할 수 있다.
만약 참조하고자 하는 지역 변수가 final 혹은 effectively final이 아닐 경우
즉 변경이 가능할 경우 어떤 문제가 발생할까?
public void executelocalVariableInMultiThread() {
boolean shouldRun = true;
executor.execute(() -> {
while (shouldRun) {
// do operation
}
});
shouldRun = false;
}
람다식이 어떤 스레드에서 수행될지는 미리 알 수 없다.
이 얘기는 곧 외부 지역 변수를 다루는 스레드와 람다식이 수행되는 스레드가 다를 수 있다는 의미이다.
지역 변수 값(shouldRun
)을 제어하는 스레드 A, 람다식이 수행되는 스레드 B가 있다고 가정해보자.
스레드 B의 shouldRun 값이 가장 최신 값으로 복사되어 전달 됐는지 확신할 수 없다.
shouldRun은 변경이 가능한 지역 변수이고, 지역 변수를 스레드 간에 sync 해주는 것이 불가능하기 때문이다.
지역 변수는 스레드 A의 스택 영역에 존재하기 때문에 다른 스레드에서 접근이 불가능하다.
volatile과 같은 키워드가 로컬 변수에서 사용될 수 없는 이유도 이와 같다.
값이 보장되지 않는다면 매번 다른 결과가 도출될 수 있다.
이러한 이유로 인해 외부 지역 변수는 전달되는 복사본이 변경되지 않은
최신 값이라는 것을 보장하기 위해 final 혹은 effectively final이어야 한다.
3. 복사된 지역 변수 값은 람다식 내부에서도 변경할 수 없다. 즉 final 변수로 다뤄야 한다.
이미 복사된 값이므로 변경해도 문제가 없는거 아닐까? 아니다.
복사될 값의 변조를 막아 최신 값임을 보장하기 위해 final 제약을 걸었는데 람다식 내부에서 변경이 가능할 경우 다시 제자리로 돌아온다.
또한 컴파일 된 람다식은 static 메소드 형태로 변경이 되는데 이때 복사된 값이 파라미터로 전달되므로
마찬가지로 스택 영역에 존재하기 때문에 sync를 해주는 것도 불가능하다.
따라서 람다식 내부에서도 값이 변경 되어서는 안된다.
컴파일러 레벨에서 앞, 뒤로 final 제약을 걸어줌으로써 멀티 스레드 환경에서 대응하기 어려운 이슈를 미연에 방지한 것이다.
Non - local capturing lambda
private int instanceNumber = 1;
private static int staticNumber = 1;
public void testPlusByInstanceVariable() {
instanceNumber = 2;
Addable addableImple = () -> instanceNumber + 1;
}
public void testPlusByStaticVariable() {
staticNumber = 2;
Addable addableImple = () -> staticNumber + 1;
}
외부 변수로 인스턴스 변수 혹은 클래스 변수를 이용하는 람다식을 의미한다.
local capturing lambda와 다르게 final 제약 조건이 없고 외부 변수 값도 복사하지 않는다.
이유는 인스턴스 변수나 클래스 변수를 저장하고 있는 메모리 영역은 공통 영역이고
값이 메모리에서 바로 회수되지 않기 때문에 람다식에서 바로 참조가 가능하다.
따라서 복사 과정이 불필요하고 참조 시 최신 값 임을 보장할 수 있다.
단, 멀티 스레드 환경일 경우 volatile, synchronized 등을 이용하여 sync를 맞춰주는 작업을 잊어서는 안된다.
정리
람다식에서 외부 지역 변수를 이용할 경우 final 혹은 effectively final 이어야 하는 이유는 지역 변수가 스택에 저장되기 때문에 람다식에서 값을 바로 참조하는 것에 제약이 있어 복사된 값을 이용하게 되는데, 이때 멀티 쓰레드 환경에서 복사 될/복사된 값이 변경 가능 할 경우 이로 인한 동시성 이슈를 대응할 수 없기 때문이다.
참고
- https://www.baeldung.com/java-effectively-final
- https://www.baeldung.com/java-lambda-effectively-final-local-variables
- https://dzone.com/articles/how-lambdas-and-anonymous-inner-classesaic-work