因此,似乎从Java中的构造函数传递this
是个坏主意。
class Foo {
Foo() {
Never.Do(this);
}
}
我的简单问题是:为什么?
Stackoverflow上有一些相关的问题,但它们都没有提供可能出现的问题的完整列表。
例如,in this question要求解决此问题,one of the answers说明:
例如,如果您的类具有最终(非静态)字段,那么您通常可以依赖它设置为值并且永远不会更改。
当您查看的对象当前正在执行其构造函数时,该保证不再成立。
怎么样?
另外,我理解子类化是一个大问题,因为超类构造函数总是在子类构造函数之前被调用,这可能会导致问题。
此外,我读到可能会出现Java Memory Model (JMM) issues such as differences in visibility across threads and memory access reordering,但没有详细说明。
可能会出现哪些其他问题,您能详细说明上述问题吗?
答案 0 :(得分:18)
基本上,你已经列举了可能发生的坏事,所以你已经部分回答了自己的问题。我将提供您提到的事项的详细信息:
this
例如,如果您的班级有最终(非静态)字段,那么您可以 通常取决于它被设置为一个值而永远不会改变。
当您查看的对象当前正在执行其构造函数时, 那保证不再适用。
怎么样?
非常简单:如果您在设置this
字段之前分发final
,那么它将无法设置:
class X{
final int i;
X(){
new Y(this); // ouch, don't do this!
i = 5;
}
}
class Y{
Y(X x){
assert(x.i == 5);//This assert should be true, since i is final field, but it fails here
}
}
很简单,对吧?类Y
会看到X
,其中包含未初始化的final
字段。这是一个很大的禁忌!
Java通常确保final
字段只初始化一次,并且在初始化之前不会被读取。一旦泄漏this
,此保证就会消失。
请注意,非final
字段也会出现同样的问题。但是,如果发现final
字段未初始化,则会更加惊讶。
子类化的问题与上面的问题非常类似:基类在派生类之前被初始化,所以如果在基类构造函数中泄漏this
引用,则泄漏尚未初始化它的对象派生字段。这个可以
在多态方法的情况下变得非常讨厌,如这个例子所示:
class A{
static void doFoo(X x){
x.foo();
}
}
class X{
X(){
A.doFoo(this); // ouch, don't do this!
}
void foo(){
System.out.println("Leaking this seems to work!");
}
}
class Y extends X {
PrintStream stream;
Y(){
this.stream = System.out;
}
@Overload // Polymorphism ruins everything!
void foo(){
// NullPointerException; stream not yet initialized
stream.println("Leaking + Polymorphism == NPE");
}
}
如您所见,有一个X
类,foo
方法。 X
在其构造函数和A
调用A
中泄露给foo
。对于X
类,这很好用。但是对于Y
类,会抛出NullPointerException
。原因是Y
会覆盖foo
并在其中使用其中一个字段(stream
)。由于在stream
调用A
时尚未初始化foo
,因此您会收到异常。
此示例显示了泄漏此问题的下一个问题:即使您的基类在泄漏this
时可能正常工作,也是从您的基类继承的类(可能不是您自己编写的类)否则谁不知道泄露this
)可能会搞砸一切。
this
本节并未完全讨论自己的问题,但需要记住的一点是:即使调用自己的一种方法也可以被视为泄露this
,因为它会带来类似的问题。参考泄露给另一个班级。例如,考虑前面的示例使用不同的X
构造函数:
X(){
// A.doFoo();
foo(); // ouch, don't do this!
}
现在,我们不会将this
泄露给A
,但我们会通过调用foo
将其泄露给我们自己。同样,同样的坏事发生了:一个覆盖Y
并使用其自己的字段之一的foo()
类将会肆虐。
现在考虑使用final
字段的第一个示例。再次,通过方法泄露给自己可能允许找到未初始化的final
字段:
class X{
final int i;
X(){
foo();
i = 5;
}
void foo(){
assert(i == 5); // Fails, of course
}
}
当然,这个例子是相当构造的。每个程序员都会注意到,先调用foo
然后设置i
是错误的。但现在再次考虑继承:您的X.foo()
方法可能甚至不使用i
,因此在初始化i
之前调用它是好的。但是,子类可能会覆盖foo()
并在其中使用i
,再次破坏所有内容。
另请注意,重写foo()
方法可能会将this
传递给其他类,从而进一步泄漏this
。因此,虽然我们只打算通过调用foo()
将foo()
泄露给自己,但子类可能会覆盖this
并将this
发布到整个世界。
调用自己的方法是否被视为泄露this
可能存在争议。但是,如你所见,它带来了类似的问题,所以我想在这里讨论它,即使很多人可能不同意调用自己的方法被认为是泄漏的final
。
如果你真的需要在构造函数中调用自己的方法,那么要么只使用static
或final
方法,因为这些方法不能被无辜的派生类覆盖。
Java内存模型中的最后一个字段具有一个很好的属性:它们可以在不锁定的情况下同时读取。 JVM必须确保即使是并发的未锁定访问也始终会看到完全初始化的this
字段。例如,这可以通过向分配最终字段的构造函数的末尾添加内存屏障来完成。 然而,一旦你过早发布class X{
final int i;
X(Y y){
i = 5;
y.x = this; // ouch, don't do this!
}
}
class Y{
public static Y y;
public X x;
Y(){
new X(this);
}
}
//... Some code in one thread
{
Y.y = new Y();
}
//... Some code in another thread
{
assert(Y.y.x.i == 5); // May fail!
}
,这种保证就会消失。再次举个例子:
this
如您所见,我们过早地发布i
,但初始化Y
后仅。所以在单线程环境中,一切都很好。但现在进入并发:我们在一个线程中创建一个静态X
(它接收受污染的i = 5
实例)并在第二个线程中访问它。现在,断言可能会再次失败,因为编译器或CPU乱序执行现在允许重新排序 Y.y = new Y()
的分配和{
Y.y = new Y();
}
的分配。
为了使事情更清楚,假设JVM将内联所有调用,因此代码
rX
将首先内联到({
r1 = 'allocate memory for Y' // Constructor of Y
r1.x = new X(r1); // Constructor of Y
Y.y = r1;
}
是本地寄存器):
new X()
现在我们还会调用{
r1 = 'allocate memory for Y' // constructor of Y
r2 = 'allocate memory for X' // constructor of X
r2.i = 5; // constructor of X
r1.x = r2; // constructor of X
Y.y = r1;
}
:
r2.i = 5
到现在为止,一切都很好。但现在,允许重新排序。我们(即JVM或CPU)将{
r1 = 'allocate memory for Y' // 1.
r2 = 'allocate memory for X' // 2.
r1.x = r2; // 3.
Y.y = r1; // 4.
r2.i = 5; // 5.
}
重新排序到最后:
4.
现在,我们可以观察到错误的行为:考虑线程1执行最多final
的所有步骤然后被中断(在assert(Y.y.x == 5);
字段设置之前!)。现在,线程2执行所有代码,因此其this
失败。
基本上,你提到的三个问题和我上面解释的是最糟糕的问题。当然,有许多不同的方面可以解决这些问题,因此可以构建数千个例子。只要你的程序是单线程的,早期发送可能就可以了(但不管怎么做!)。一旦并发发挥作用,永远不会这样做,你会得到奇怪的行为,因为在这种情况下,JVM基本上可以根据需要重新排序。而不是记住可能发生的各种具体问题的血腥细节,只需记住可能发生的两个概念性事件:
this
表示部分构造的对象,通常你永远不会想要部分构造的对象,因为它们很难推理(参见我的第一个例子)。特别是当继承发挥作用时,事情变得更加复杂。简单地说:泄漏this
+继承=这里是龙。 this
,内存模型就会放弃大部分保证,因此疯狂的重新排序可能会产生几乎无法调试的非常奇怪的执行。简单地说:泄漏{{1}} + concurreny =这里是龙。