anonymous inner class는 final 변수에만 접근해야 하는 이유는?

2014-05-29 13:52

오늘 자바 8에 추가된 람다와 관련한 내용을 읽다가 다음과 같이 내용이 있어 궁금증이 생겼다.

람다 표현식에서 변수를 변경하는 작업은 스레드에 안전하지 않다. - 가장 빨리 만나는 자바8 28페이지...

람다 표현식을 이전 버전의 anonymous inner class와 같은 용도로 판단했을 때 기존의 anonymous inner class에서도 final 변수에만 접근할 수 있었다.

지금까지 anonymous inner class에서 final 변수로 정의하는 이유가 현재 method의 Context가 anonymous inner class 인스턴스까지 확대되기 때문에 anonymous inner class 내에서 값을 변경할 경우 그에 따른 side effect가 생길 가능성이 많아 final로 정의하는 것으로 생각했다.

그런데 위 내용은 스레드에 안전하지 않기 때문에 람다 표현식에서 변수 값을 변경하는 것을 막는다고 이야기하고 있다. 왜 스레드에 안전하지 않은 것일까?

http://blog.naver.com/PostView.nhn?blogId=javaking75&logNo=140178355095 위 글에서는 "final로 선언하는 경우 JVM constant pool에 변수가 관리되기 때문에 method를 종료하더라도 final로 선언한 변수를 anonymous inner class에서 접근할 수 있기 때문이다."라고 설명하고 있다.

http://tech-read.com/2008/06/19/why-inner-class-can-access-only-final-variable/ 위 글에서는 anonymous inner class의 인스턴스가 만들어지는 시점에 메서드의 값이 인스턴스 필드에 복사되는 구조이다. 따라서 한 곳에서 값을 변경하는 경우 서로 다른 값이 되기 때문에 문제가 발생할 가능성이 많기 때문이라고 이야기하고 있다.

두 가지고 모두 맞는 이야기인가? 위 두가지 이외에 람다 표현식의 스레드에 안정하지 않기 때문이라는 것은 무슨 이야기일까? 다른 부분은 이해하겠는데 스레드에 안전하지 않은 이유는 이해가 잘 되지 않는다.

0개의 의견 from FB

11개의 의견 from SLiPP

2014-05-30 01:09

Thread safe 랑 final은 관계가 있는거지만 다르게 봐야 하는게 아닌가?

굳이 lambda로 변수를 쓸 때 final 지정을 하지 않은 변수 더라도 final효과처럼 사용 한다면 (읽기만 한다 던지...) 사용 가능 하니

Final 과 꼭 lambda를 연관 하지 말고 thread safe하게 프로그래밍 하기 위해 final(또는 final처럼)을 해야 한다는 의미가 아닐까? 생각 하는데...

2014-05-30 09:49

저도 잘은 모르겠지만, 그냥 몇글자 적어볼께요. 일단 변수의 생명 주기랑, 값이 아닌 레퍼런스에 의한 부수효과는 무시하고, 쓰레드 관점에서만 볼때에, 간단히 생각하면, 서블릿에서 인스턴스 변수를 사용하는 것은 쓰레드에 안전할까요? 안전하지 않을까요? 저는 같은 맥락인거 같은데 ^^;; 아 그러고 보니 "안정"이라고 되어있네요. 저건 다른 의미인가.. ^^;;

2014-05-30 09:58

@강우 오타였습니다. 안전이 맞습니다.

서블릿의 경우 single 인스턴스이기 때문에 인스턴스 변수를 사용하는 경우 쓰레드에 안전하지 않은데요. anonymous class나 람다의 경우에는 매 쓰레드마다 생성되는 구조이기 때문에 인스턴스 변수로 사용해도 쓰레드 안전하다고 생각되거든요.

@jhindhal.jhang final을 쓰는 것이 thread safe하게 프로그래밍하는데 좋은 습관이라고 하더라도 지금처럼 강제할 필요는 없잖아? 강제하는 이유가 있을 듯 한데.. final을 반드시 쓰도록 하는 이유가 thread safe한 것과 어떻게 연결되는지 잘 이해가 되지 않되네. 아님 책의 내용이 잘못된 것인가? 변수 값의 변경으로 인한 side effect가 더 큰 것인가?

2014-05-30 11:26

"람다 표현식에서 변수를 변경하는 작업은 스레드에 안전하지 않다."

람다식에서 사용되는 변수라면 람다식 내부에서 정의된 로컬 변수이거나 람다식이 선언된 외부의 변수를 참조하는 것일텐데, 전자라면 아무리 변경해도 문제될 이유가 없고, 후자는 변경 자체가 허용이 안될텐데. 이 설명이 무슨 뜻인지 이해가 안 됨.

2014-05-30 11:48

두 가지고 모두 맞는 이야기인가? 위 두가지 이외에 람다 표현식의 스레드에 안정하지 않기 때문이라는 것은 무슨 이야기일까? 다른 부분은 이해하겠는데 스레드에 안전하지 않은 이유는 이해가 잘 되지 않는다.

anonymous class나 람다의 경우에는 매 쓰레드마다 생성되는 구조이기 때문에 인스턴스 변수로 사용해도 쓰레드 안전하다고 생각되거든요.

아래 테스트는 람다표현식으로 생성된 객체가 스레드에 대해 안전한지를 간략히 테스트 해 본 것입니다. 람다표현식으로 생성된 NumberGenerator 객체의 generate함수가 스레드에 대해 안전하다라고 말할 수 있으려면, generate함수가 다중 스레드 조건에서도 매번 유니크한 숫자를 반환해야 할 것입니다. 그러나 아래 테스트는 그렇지 않다라는 것을 보여 줍니다. 테스트 실패를 보기 위해서 여러번 실행해 보시길 바랍니다.(제 컴퓨터에서는 한번에 잘 되질 않네요.)

저는 c#을 하는 사람이라 자바를 잘 모릅니다. 아래 코드 중 혹, 잘못된 부분이나, 의문사항이 있으시면 말씀 주시기 바랍니다.

public interface NumberGenerator {
    int generate();
}


public class LocalVariableGeneratorFactory {


    public NumberGenerator create() {
        final Holder<Integer> holder = new Holder<>();
        holder.setValue(0);
        return () -> {
            holder.setValue(holder.getValue() + 1);
            return holder.getValue();
        };
    }


    private static class Holder<T>{
        private T value;


        public T getValue(){
            return value;
        }


        public void setValue(T value){


            this.value = value;
        }
    }
}



    @Test
    public void numberGeneratorConstructedByFinalLocalVariableIsThreadSafe() throws InterruptedException {
        // Fixture setup
        NumberGenerator sut = new LocalVariableGeneratorFactory().create();


        int count = 20;
        Thread[] threads = new Thread[count];
        int[] actual = new int[count];
        int[] expected = new int[count];


        for (int i = 0; i < count; i++) {
            expected[i] = i + 1;
            final int index = i;
            threads[i] = new Thread(() -> actual[index] = sut.generate());
        }


        // Exercise system;
        for (Thread thread : threads) thread.start();
        for (Thread thread : threads) thread.join();


        // Verify outcome
        Arrays.sort(actual);
        Assert.assertArrayEquals(expected, actual); // failed!!!
    }

위 코드에서, Holder대신 @Youngjae Kim, @박성철께서 말씀하신 배열을 사용해도 "스레드에 안전하지않다"라는 결과가 나오겠네요.

public class ArrayLocalVariableGeneratorFactory {
    public NumberGenerator create() {
        final int[] holder = new int[]{0};
        return () -> ++holder[0];
    }
}

final을 반드시 쓰도록 하는 이유가 thread safe한 것과 어떻게 연결되는지 잘 이해가 되지 않되네. 아님 책의 내용이 잘못된 것인가? 변수 값의 변경으로 인한 side effect가 더 큰 것인가?

람다표현식이 thread safe하려면? 조건1. capture하고 있는 로컬변수가 final이어야하고, 조건2. 그 로컬변수 자체가 다중 스레드에 안전해야 한다.

이렇게 이해하면 되지 않을까요? 제가 이해하기에는 로컬변수에 대해 final을 쓰도록 하는 이유는 변수 값의 변경으로 인한 side effect 밖에 없을 것 같습니다. 왜냐하면 아래와 같이 enclosing 타입의 final 이아닌 맴버필드를 로컬클래스 혹은 람다에서 사용할 수 있으니깐요. 이 말은 참조 자체의 변경을 막기 위함으로 보아야할 것 같습니다.

public class FieldMemberGeneratorFactory { int num = 0; public NumberGenerator create() { return () -> ++num; } }

2014-05-30 12:37

@jwchung 답변 감사드립니다. 많은 공부가 되었어요. 자바에도 람다 추가되니 재미있네요.

공유해준 코드 테스트해보니 fail이 발생하네요. 그 이야기는 람다식이 Holder 클래스에 대한 reference를 공유하기 때문에 멀티 쓰레드 상황에서 쓰레드 안전하지 않은 상황이 발생할 수 있다는 것으로 이해하면 되겠네요.

이 책의 예에서는 다음과 같은 예를 들면서 이야기하고 있는데 이 또한 같은 맥락으로 볼 수 있겠네요.

int matches = 0;
for (Path p : files) 
    new Thread(() -> {if (p가 어떤 프로퍼티를 포함하면) matches++; }).start();

위 코드와 같은 상황일 발생할 경우 matches가 thread safe하지 않은 상황이 발생할 수 있기 때문에 final을 사용해 변경하지 못하도록 하는 것으로 이해할 수 있겠네요.

위 코드의 경우에는 reference 복사가 아니라 matches의 값이 복사되니까 요구사항을 만족할 수 없는 예제를 위한 예제 코드일 수 있겠네요. 이제 시작 단계라 좀 더 공부해야겠음다.

2014-05-30 13:12

@자바지기

(자바지기 님이 답변 하시는 중에 제 의견을 조금 추가하였습니다.) 이렇게 생각하면 되지 않을까요?

  • final은 스레드 안전성을 보장하기 위한 하나의 조건이지, final조건 자체만으로 스레드 안전성을 논할 수 없다.
  • final조건으로 말할 수 있는 것은 참조 자체의 값의 싱크를 보장하기 위함이다.(변수 값의 변경으로 인한 side effect 관련 - synch)

제 생각에 가장 빨리 자바의 람다를 이해하기 위해서는 자바에서 람다를 어떤식으로 클래스를 만드는지(디컴파일), 필드의 경우와 로컬변수의 경우로 나누어 살펴보는 것이 좋을 것 같습니다.

new LocalVariableGeneratorFactory().create().getClass();
new FieldMemberGeneratorFactory ().create().getClass();

이 두 타입을 디컴파일해서 비교해 보시면 좋을 것 같습니다. 디컴파일은 잘 몰라서 리플렉션으로 일단 살펴 보았는데요.

    public void reflectionReturnsCorrectNames() throws NoSuchMethodException {
        Class aClass = new LocalVariableGeneratorFactory().create().getClass();
        System.out.println(aClass);
        System.out.println("-----------------------------");
        Field[] fields = aClass.getDeclaredFields();
        for (Field field : fields) {
            System.out.println(field);
        }
        System.out.println("-----------------------------");
        Method[] methods = aClass.getDeclaredMethods();
        for (Method method : methods) {
            System.out.println(method);
        }
    }


결과
1. LocalVariableGeneratorFactory의 경우


class LocalVariableGeneratorFactory$$Lambda$1/1766822961
-----------------------------
private final LocalVariableGeneratorFactory$Holder LocalVariableGeneratorFactory$$Lambda$1/1766822961.arg$1
-----------------------------
public int LocalVariableGeneratorFactory$$Lambda$1/1766822961.generate()
private static NumberGenerator LocalVariableGeneratorFactory$$Lambda$1/1766822961.get$Lambda(LocalVariableGeneratorFactory$Holder)



2. FieldMemberGeneratorFactory의 경우


class FieldMemberGeneratorFactory$$Lambda$1/683287027
-----------------------------
private final FieldMemberGeneratorFactory FieldMemberGeneratorFactory$$Lambda$1/683287027.arg$1
-----------------------------
public int FieldMemberGeneratorFactory$$Lambda$1/683287027.generate()
private static NumberGenerator FieldMemberGeneratorFactory$$Lambda$1/683287027.get$Lambda(FieldMemberGeneratorFactory)


결과적으로 람다를 통해 생성되는 타입을 비교해 보면, 1,2 번에서 모두에서 enclosing type, method에서 받은 참조를 맴버필드로 선언하고 있습니다. 이 맴버필드 타입의 보면(LocalVariableGeneratorFactory$Holder, FieldMemberGeneratorFactory) 왜 final이 필요한지를 이해할 수 있을 것 같습니다.

즉 필드의 경우는 감싸고 있는 객체를 넘겨 받기 때문에 final을 요구하지 않지만 -(captured될 수 있음)-, 로컬변수의 경우는 그 자체 참조를 넘겨 받기 때문에 -captured될 수 없으므로- synch를 유지하기 위해 final을 요구한다.

2014-05-30 14:29

@jwchung 작은 호기심에 시작한 질문인데 이렇게까지 구체적인 답변 감사합니다. 저도 머릿 속에 좀 더 체계적으로 정리한 후에 문서로 만들어 볼께요.

특히 field 접근은 흥미롭네요. 마지막으로 한 가지만 더 질문할께요. 위 답변 마지막 문장에 capture라는 용어를 사용했는데요. 제가 capture라는 용어를 처음 들어 보네요.

capture라는 것이 외부 변수를 람다 내부에서 사용할 수 있도록 복사하는 개념이라고 생각하면 될까요?

2014-05-30 15:05

저도 capture라는 용어를 어제 아래주소에서 보았습니다만, 제가 위에서는 capture라는 용어를 조금 잘못 사용한 것 같아서 해당부분을 삭제 했습니다. 혼란을 드렸네요... 착오 없으시길...

capture라는 것이 외부 변수를 람다 내부에서 사용할 수 있도록 복사하는 개념이라고 생각하면 될까요? 말씀하신 내용 그대로인 것 같습니다.

http://docs.oracle.com/javase/tutorial/java/javaOO/localclasses.html#accessing-members-of-an-enclosing-class

When a local class accesses a local variable or parameter of the enclosing block, it captures that variable or parameter. For example, the PhoneNumber constructor can access the local variable numberLength because it is declared final; numberLength is a captured variable.

2014-05-30 16:52

@jwchung 저도 간략하게 찾아보다가 더 명확하게 설명해 주실 듯 해서 질문을 남겼는데 이렇게 관련 내용까지 공유해 주셔서 감사해요. 덕분에 많은 도움 되었습니다.

2014-06-17 22:41

Java 8 표준문서 15.27.2 Lambda Body에서 다음 문장을 발취했습니다.

Similar rules on variable use apply in the body of an inner class (§8.1.3). The restriction to effectively final variables prohibits access to dynamically-changing local variables, whose capture would likely introduce concurrency problems. Compared to the final restriction, it reduces the clerical burden on programmers.

컴파일러에게나 프로그래머에게나 윈윈 전략인 것 같습니다.

의견 추가하기