Java: 引用逃逸

39

阅读到以下代码是“不安全的构造”的示例,因为它允许此引用逃逸。我无法理解'this'如何逃逸。我对Java世界非常陌生。有人可以帮助我理解吗。

public class ThisEscape {
    public ThisEscape(EventSource source) {
        source.registerListener(
            new EventListener() {
                public void onEvent(Event e) {
                    doSomething(e);
                }
            });
    }
}
5个回答

36

你在问题中发布的示例来自Brian Goetz等人的"Java Concurrency In Practice"。它在3.2节“发布和逸出”中。我不会在这里尝试重现该部分的详细信息。(去买一本放在书架上,或者从同事那里借一本!)

示例代码所说明的问题是,在构造函数完成创建对象之前,允许引用正在构建的对象“逸出”。这有两个问题:

  1. 如果引用逸出,则在其构造函数完成初始化之前,某些内容可以使用该对象,并以不一致(部分初始化)的状态查看它。即使对象在初始化完成后逸出,声明子类也可能导致违反此规则。

  2. 根据JLS 17.5,对象的最终属性可以在没有同步的情况下安全使用。但是,只有在其构造函数完成之前未发布(未逸出)对象引用时,才成立这一点。如果您违反此规则,则结果是一个难以察觉的并发错误,当代码在多核/多处理器机器上执行时,可能会咬你。

< p > ThisEscape示例很棘手,因为引用通过隐式传递给匿名EventListener类构造函数的this引用而逃逸。但是,如果引用过早地显式发布,同样的问题也会出现。

以下是一个示例,用于说明初始化不完整对象的问题:

public class Thing {
    public Thing (Leaker leaker) {
        leaker.leak(this);
    }
}

public class NamedThing  extends Thing {
    private String name;

    public NamedThing (Leaker leaker, String name) {
        super(leaker);

    }

    public String getName() {
        return name; 
    }
}

如果Leaker.leak(...)方法在泄漏的对象上调用getName(),它将得到null ...因为此时对象的构造函数链尚未完成。以下是一个示例,说明了final属性的不安全发布问题。
public class Unsafe {
    public final int foo = 42;
    public Unsafe(Unsafe[] leak) {
        leak[0] = this;   // Unsafe publication
        // Make the "window of vulnerability" large
        for (long l = 0; l < /* very large */ ; l++) {
            ...
        }
    }
}

public class Main {
    public static void main(String[] args) {
        final Unsafe[] leak = new Unsafe[1];
        new Thread(new Runnable() {
            public void run() {
                Thread.yield();   // (or sleep for a bit)
                new Unsafe(leak);
            }
        }).start();

        while (true) {
            if (leak[0] != null) {
                if (leak[0].foo == 42) {
                    System.err.println("OK");
                } else {
                    System.err.println("OUCH!");
                }
                System.exit(0);
            }
        }
    }
}

这个应用程序的一些运行可能会打印“OUCH!”而不是“OK”,表明主线程观察到由于通过“leak”数组进行不安全发布,导致“不可能”的状态的Unsafe对象。是否发生取决于您的JVM和硬件平台。现在,这个例子显然是人为的,但很容易想象这种情况如何在真正的多线程应用程序中发生。

当前Java内存模型是在Java 5中指定的(JLS的第3版),这是JSR 133的结果。在那之前,Java的与内存相关的方面没有明确定义。引用早期版本/版本的来源已经过时,但Goetz第1版中有关内存模型的信息是最新的。

内存模型的某些技术方面显然需要修订;请参见https://openjdk.java.net/jeps/188https://www.infoq.com/articles/The-OpenJDK9-Revised-Java-Memory-Model/。然而,这项工作尚未出现在JLS修订版中。


那么这本质上是JVM内存模型中的“bug”吗? - Gabe
那么,http://www.ibm.com/developerworks/java/library/j-jtp0618.html 中有多少内容已经过时了? - Gabe
1
Stephen: Goetz说:“JMM正在Java社区进程JSR 133下进行修订,这将(除其他事项外)改变volatile和final的语义,使它们更符合一般直觉。”那是在2002年写的。在过去的8年中有多少变化? - Gabe
1
@Gabe - 由于JSR 133的结果,内存模型从JLS 2更改为JLS 3。要查看差异,请比较JLS第2版和第3版的第17章。据我所知,这些更改中没有使Goetz在2002年发表的文章无效。他的书于2006年修订,并描述了JLS第3版内存模型。自JLS第3版/Java 5.0以来,内存模型没有更改。 - Stephen C
"自Java语言规范第三版/ Java 5.0以来,内存模型并未改变。" - 虽然已对内存模型的规范进行了更正以解决专家指出的各种问题...但目的并未改变。 - Stephen C
@Gabe - "那么这本质上是JVM内存模型中的一个“bug”吗?" - 不是。这是一种情况,如果你编写的程序不遵循JMM中规定的规则,就会发生糟糕的事情。 - Stephen C

14

我有完全相同的疑问。

问题在于,每个在其他类中实例化的类都在变量$this中引用封闭类。

这就是Java所谓的合成,它不是你定义的内容,而是Java自动为你完成的内容。

如果您想亲自查看,请在doSomething(e)行中设置断点并检查EventListener有哪些属性。


1
查看我的答案以获取真正的解释。 - Stephen C
4
我认为这是问题的一个重要部分;它精确地解释了this是如何逃逸的,这就是问题所问的(为什么逃逸是不好的并不是问题的明确部分)。 - erickson

8
我的猜测是doSomething方法在ThisEscape类中声明,这种情况下引用肯定可以“逃逸”。也就是说,在ThisEscape构造函数完成之前,某个事件可能会触发此EventListener。而监听器将调用ThisEscape的实例方法。
我会稍微修改您的示例。现在,在构造函数中分配之前,变量var可以在doSomething方法中访问。
public class ThisEscape {
    private final int var;

    public ThisEscape(EventSource source) {
        source.registerListener(
            new EventListener() {
                public void onEvent(Event e) {
                    doSomething(e);
                }
            }
        );

        // more initialization
        // ...

        var = 10;
    }

    // result can be 0 or 10
    int doSomething(Event e) {
        return var;
    }
}

1
那不是最幸福的例子,因为这个问题来自的书(《Java并发编程实战》)说即使registerListener是构造函数中的最后一行,仍然可以逃脱出糟糕构造的对象。 - Pablo Fernandez
@Pablo 怎么做?(我不是在质疑这本书,只是对“失败”的例子感到好奇) - Nikita Rybak
@Pablo 好的,如果你在书中找到解释,请务必在这里发布 :) 毕竟它不是圣经,我们并不期望只是相信书中的每个陈述。 - Nikita Rybak
@Nikita - 请看我的答案,了解真正的解释。 - Stephen C
1
@StephenC,您的解释涉及显式this引用,其中很明显this逃逸了。然而,在书中的例子中,并没有明确提到“this”。我认为Nikita指出了问题:this引用被“隐蔽地”逃逸了,在不幸的时机下,外部对象能够在ThisEscape构造函数完成之前(间接地)调用doSomething()。这就是为什么该书使用EventSource和监听器的示例的原因。 - Devabc
显示剩余2条评论

4
我在阅读Brian Goetz的《Java并发实践》时,遇到了与此处相同的问题。
Stephen C的答案(被接受的答案)非常好!我只想再添加一个我发现的资源。这个资源来自JavaSpecialists,Heinz M. Kabutz博士分析了devnull发布的代码示例。他解释了编译后生成的类(外部、内部)以及this如何逃逸。我觉得这个解释很有用,所以我想分享一下 :) 这里是扩展示例并提供竞态条件的地方。 这里是解释编译后生成的类的类型以及this如何逃逸的地方。

0

这也让我感到困惑了很久。查看完整的代码示例并反复阅读它数百次最终帮助我理解了它。虽然要赞一下Stephen C,他的答案非常详尽,并提供了一个简化的示例。

问题出在source.registerListener()上,它作为ThisEscape的成员提供。谁知道这个方法是做什么的?我们不知道。因为它是在ThisEscape中声明的接口中声明的。

public class ThisEscape {
    // ...
    
    interface EventSource {
        void registerListener(EventListener e);
    }

    interface EventListener {
        void onEvent(Event e);
    }

    // ...
}

无论实现了何种类的 EventSource,都会为 registerListener() 提供实现,但我们不知道它对提供的 EventListener 做了什么。因为 EventListenerThisEscape 的一部分,所以它也包含一个对它的引用。这就是为什么这个例子如此棘手的原因。基本上,在构造 ThisEscape 时,通过 source.registerListener(<reference>) 发布对其的引用,而谁知道那个 EventSource 会对它做些什么。

这是使用 private 构造函数和静态工厂方法的众多优秀案例之一。您可以将构造限制为静态工厂方法中的一行,确保在将其传递给 source 之前完成。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接