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

use*_*621 7 java

因此,似乎this从Java中的构造函数传递是一个坏主意.

class Foo {
    Foo() {
        Never.Do(this);
    }
}
Run Code Online (Sandbox Code Playgroud)

我的简单问题是:为什么?

Stackoverflow上有一些相关的问题,但它们都没有提供可能出现的问题的综合列表.

例如,在这个要求解决此问题的问题中,其中一个答案指出:

例如,如果您的类具有最终(非静态)字段,那么您通常可以依赖它将其设置为值并且永远不会更改.

当您查看的对象当前正在执行其构造函数时,该保证不再成立.

那个怎么样?

另外,我理解子类化是一个大问题,因为超类构造函数总是在子类构造函数之前调用,这可能会导致问题.

此外,我读到Java内存模型(JMM)问题,例如线程间的可见性差异和内存访问重新排序可能会出现,但没有详细说明.

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

gex*_*ide 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
    }
}
Run Code Online (Sandbox Code Playgroud)

很简单吧?类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");      
     }

}
Run Code Online (Sandbox Code Playgroud)

如你所见,有一个类X有一个foo方法.在它的构造函数中X被泄露AA调用foo.对于X课程,这很好用.但对于Y课程,a会NullPointerException被抛出.原因是Y覆盖foo并使用其中的一个字段(stream).由于streamA调用时尚未初始化foo,因此您会收到异常.

这个例子显示了泄漏这个问题的下一个问题:即使你的基类在泄漏时可能正常工作this,从你的基类继承的类(可能不是你写的,但是其他不知道泄露的人this)可能会破坏一切起来.

泄露this给自己

本节并没有完全讨论自己的问题,但需要记住的是:即使调用自己的方法也可以被视为泄露this,因为它会将类似的问题作为泄露给另一个类的引用.例如,考虑前面的示例使用不同的X构造函数:

    X(){
        // A.doFoo();
        foo(); // ouch, don't do this!
    }
Run Code Online (Sandbox Code Playgroud)

现在,我们不会泄漏this,A但是我们通过打电话将它泄漏给我们自己foo.同样,同样的坏事发生了:一个Y覆盖foo()并使用其自己的字段之一的类将会肆虐.

现在考虑我们在该final领域的第一个例子.同样,通过方法泄漏给自己可能允许找到final未初始化的字段:

class X{
   final int i;
   X(){ 
      foo();
      i = 5;
   }

   void foo(){
      assert(i == 5); // Fails, of course
   }
}
Run Code Online (Sandbox Code Playgroud)

当然,这个例子是相当构造的.每个程序员都会注意到第一次调用foo然后设置i是错误的.但现在再次考虑继承:你的X.foo()方法可能甚至不使用i,所以在初始化之前调用它是好的i.但是,子类可能会覆盖foo()i在其中使用,再次破坏所有内容.

另请注意,重写的foo()方法可能会this通过将其传递给其他类而进一步泄漏.因此,虽然我们只想this通过调用foo()子类来泄露自己,但可能会覆盖foo()并发布this到整个世界.

是否将自己的方法称为泄露this可能是有争议的.然而,如你所见,它带来了类似的问题,所以我想在这里讨论它,即使许多人可能不同意调用自己的方法被认为是泄露的this.

如果你真的需要打电话给你自己的方法在构造函数中,然后要么只使用finalstatic方法,因为这些不能由无辜的派生类中重写.

并发

Java内存模型中的最后一个字段具有一个很好的属性:它们可以在不锁定的情况下同时读取.JVM必须确保即使是并发的未锁定访问也始终会看到完全初始化的final字段.例如,这可以通过在分配最终字段的构造函数的末尾添加内存屏障来完成.但是,一旦你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!
}
Run Code Online (Sandbox Code Playgroud)

如你所见,我们再次分发this,但只是初始化之后i.所以在单线程环境中,一切都很好.但现在进入并发:我们在一个线程中创建一个静态Y(接收受污染的X实例)并在第二个线程中访问它.现在,断言可能再次失败,因为现在允许编译器或CPU乱序执行重新排序赋值i = 5和赋值Y.y = new Y().

为了使事情更清楚,假设JVM将内联所有调用,因此代码

{
        Y.y = new Y();
}
Run Code Online (Sandbox Code Playgroud)

将首先内联(rX是本地寄存器):

{
        r1 = 'allocate memory for Y' // Constructor of Y
        r1.x = new X(r1);            // Constructor of Y
        Y.y = r1;
}
Run Code Online (Sandbox Code Playgroud)

现在我们还要内联调用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;
}
Run Code Online (Sandbox Code Playgroud)

到现在为止,一切都很好.但现在,允许重新排序.我们(即JVM或CPU)重新排序r2.i = 5到最后:

{
        r1 = 'allocate memory for Y' // 1.
        r2 = 'allocate memory for X' // 2.
        r1.x = r2;                   // 3.
        Y.y = r1;                    // 4.
        r2.i = 5;                    // 5.
}
Run Code Online (Sandbox Code Playgroud)

现在,我们可以观察到错误的行为:考虑线程1执行所有步骤4.然后被中断(在设置final字段之前!).现在,线程2执行所有代码,因此它assert(Y.y.x == 5);失败了.

可能会出现什么其他问题

基本上,你提到的三个问题和我上面解释的是最糟糕的问题.当然,有许多不同的方面可以解决这些问题,因此可以构建数千个例子.只要你的程序是单线程的,早期发布可能没问题(但不管怎么说都不要这样做!).一旦并发发挥作用,永远不会这样做,你会得到奇怪的行为,因为在这种情况下,JVM基本上可以根据需要重新排序.不要记住可能发生的各种具体问题的血腥细节,只需记住可能发生的两个概念性事物:

  • 构造函数通常首先彻底构造一个对象,然后将它交给调用者.一个this逃逸构造表示部分构造的对象,通常你从来没有想要有部分构造的对象,因为他们很难推理(见我的第一个例子).特别是当继承发挥作用时,事情变得更加复杂.简单地说:泄漏this+继承=这里是龙.
  • 一旦你this在构造函数中分发,内存模型就会放弃大部分保证,因此疯狂的重新排序可能会产生几乎无法调试的非常奇怪的执行.简单地说:泄漏this+ concurreny =这里是龙.