从Java构造函数传递'this'的危险究竟是什么?

时间:2014-08-13 08:19:42

标签: java

因此,似乎从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,但没有详细说明。

可能会出现哪些其他问题,您能详细说明上述问题吗?

1 个答案:

答案 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

如果你真的需要在构造函数中调用自己的方法,那么要么只使用staticfinal方法,因为这些方法不能被无辜的派生类覆盖。

并发

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 =这里是龙。