Java:关于不可变和final的问题

3
我正在阅读《Effective Java》一书。在第三章“对于所有对象都通用的方法”中,作者Joshua Bloch提到了如何创建一个不可变类。
以下是创建不可变类的三个要点:
1. 不提供任何修改对象状态的方法 -- 这是可以的。 2. 确保该类不能被继承。-- 我们真的需要这样做吗? 3. 使所有字段都为final。-- 我们真的需要这样做吗?
例如,假设我有一个不可变类:
class A{
private int a;

public A(int a){
    this.a =a ;
}

public int getA(){
    return a;
}
}

如何才能让一个继承自A的类,打破A的不可变性?

质疑权威是好事,但我们在这里谈论的是Joshua Bloch。 - duffymo
3
"不可变类"是指其实例必须是不可变的,而不是可以转换为该类型的任何对象(例如从子类实例化的对象)。如果要创建一个具有新属性b并且可以进行设置的子类B,则在第二种情况下就足够了。这样,B的实例将是可变的。 - salman.mirghasemi
我推荐Brian Goetz的《Java并发实践》。阅读第3章(或第4章?)后,就会清楚为什么不可变、线程安全对象的字段应该是final的。预览见下面我的回答。 - Bruno Reis
5个回答

6

像这样:

public class B extends A {
    private int b;

    public B() {
        super(0);
    }

    @Override
    public int getA() {
        return b++;
    }
}

从技术上讲,您没有修改从A继承的字段,但在不可变对象中,同一getter的重复调用当然会产生相同的数字,而这里并非如此。

当然,如果您遵循规则#1,就不允许创建此覆盖。但是,您无法确定其他人是否会遵守该规则。如果您的某个方法接受A作为参数并在其上调用getA(),则其他人可能会创建类B并将其实例传递给您的方法; 然后,您的方法将在不知情的情况下修改对象。


但请注意,我已将“a”声明为私有的。B 将无法访问“a”。 - Vinoth Kumar C M
@cmv:捶着桌子 - 你完全正确。我已经修改了我的代码。 - Aasmund Eldhuset
@Aasmund 你的解决方案无法编译,因为你没有为 B 定义构造函数。相反,B 中正在调用 super() ,而由于 A 没有默认构造函数,因此无法编译。 - Dhruv Gairola

4
里氏替换原则说子类可以代替父类使用。从客户的角度看,子类是父类的一种。因此,如果你在子类中覆盖一个方法并使它可变,那么你就违反了与父类任何客户端的契约,他们期望它是不可变的。

1
我的问题是,如果父类仅包含私有属性,则子类无法更改其值,因为父类没有提供任何setter。 - Vinoth Kumar C M
你仍然可以使用序列化和反序列化来实现。而且你可以使用终极邪恶并使用反射。 - duffymo
嗯...序列化...我没想过。 - Vinoth Kumar C M
我们如何使用序列化和反序列化来实现呢?我尝试对一个对象进行序列化,然后在程序中更改它的状态,最后将其反序列化。我成功地得到了与序列化前相同的对象形式。你能否指出任何可以更好地解释你的评论的地方? - Vinoth Kumar C M

3
如果您将一个字段声明为final,不仅会产生编译时错误,试图修改该字段或使其未初始化。

在多线程代码中,如果使用数据竞争(也就是说,没有任何同步机制,例如将其存储在全局可用位置(如静态字段)中)与您的类A共享实例,则可能会发生一些线程看到getA()的值发生变化!

final字段可以保证(由JVM规范保证)在构造函数完成后,所有线程都可以看到其值,即使没有同步。

考虑这两个类:

final class A {
  private final int x;
  A(int x) { this.x = x; }
  public getX() { return x; }
}

final class B {
  private int x;
  B(int x) { this.x = x; }
  public getX() { return x; }
}

无论是A还是B都是不可变的,也就是说在初始化后你不能修改字段x的值(我们先不考虑反射)。唯一的区别是A中的字段x被标记为final。你很快就会意识到这个微小的差别所带来的巨大影响。

现在考虑以下代码:

class Main {
  static A a = null;
  static B b = null;
  public static void main(String[] args) {
    new Thread(new Runnable() { void run() { try {
      while (a == null) Thread.sleep(50);
      System.out.println(a.getX()); } catch (Throwable t) {}
    }}).start()
    new Thread(new Runnable() { void run() { try {
      while (b == null) Thread.sleep(50);
      System.out.println(b.getX()); } catch (Throwable t) {}
    }}).start()
    a = new A(1); b = new B(1);
  }
}

假设在主线程设置完字段后,两个线程都看到它们正在观察的字段不为空(请注意,尽管这个假设可能看起来微不足道,但JVM不能保证这一点!)。
在这种情况下,我们可以确定观察 a 的线程将打印值为 1,因为它的 x 字段是 final 的——因此,在构造函数完成后,保证所有看到该对象的线程都将看到 x 的正确值。
然而,我们不能确定另一个线程会做什么。规范只能保证它将打印 01。由于该字段不是 final,并且我们没有使用任何类型的同步(synchronizedvolatile),该线程可能会看到未初始化的字段并打印 0!另一种可能性是它实际上看到了初始化的字段,并打印 1。它无法打印任何其他值。
此外,如果您继续读取和打印 bgetX() 值,它可能会在打印了一段时间的 0 后开始打印 1!在这种情况下,很明显为什么不可变对象必须具有其字段 final:从第二个线程的角度来看,即使不提供设置器,b 已经改变了!
如果您想保证第二个线程在不将字段设为 final 的情况下看到正确的 x 值,则可以将保存 B 实例的字段声明为 volatile。
class Main {
  // ...
  volatile static B b;
  // ...
}

另一种可能性是在设置和读取字段时同步,可以通过修改类B来实现:
final class B {
  private int x;
  private synchronized setX(int x) { this.x = x; }
  public synchronized getX() { return x; }
  B(int x) { setX(x); }
}

或者通过修改Main代码,在读取和写入b字段时添加同步 -- 注意,这两个操作必须在相同的对象上同步!

正如您所看到的,最优雅、可靠和高效的解决方案是将字段x设置为final。


最后需要注意的是,不是绝对必要的,线程安全的不可变类必须将所有字段都设置为final。然而,这些类(线程安全、不可变、包含非final字段)必须经过精心设计,并应该留给专家处理。

java.lang.String类就是一个例子。它有一个私有的int类型的hash字段,该字段不是final,并用作hashCode()的缓存:

private int hash;
public int hashCode() {
  int h = hash;
  int len = count;
  if (h == 0 && len > 0) {
    int off = offset;
    char val[] = value;
    for (int i = 0; i < len; i++)
      h = 31*h + val[off++];
    hash = h;
  }
  return h;
}

如您所见,hashCode()方法首先读取(非final)字段hash。如果它未初始化(即为0),则会重新计算其值并设置它。对于计算哈希码并写入该字段的线程,它将永久保留该值。
然而,其他线程可能仍然看到该字段的值为0,即使某个线程已将其设置为其他值。在这种情况下,这些其他线程将重新计算哈希值,并获得完全相同的值,然后设置它。
这里,证明了类的不可变性和线程安全性,因为每个线程将获得完全相同的hashCode()值,即使它被缓存在非final字段中,因为它将被重新计算,并获得完全相同的值。
所有这些推理都非常微妙,这就是为什么建议在不可变的、线程安全的类上标记所有字段为final的原因。

0

在这里添加答案,指向JVM规范中确切部分,该部分提到为什么成员变量需要是final才能在不可变类中实现线程安全。以下是规范中使用的示例,我认为非常清楚:

class FinalFieldExample { 
    final int x;
    int y; 
    static FinalFieldExample f;

    public FinalFieldExample() {
        x = 3; 
        y = 4; 
    } 

    static void writer() {
        f = new FinalFieldExample();
    } 

    static void reader() {
        if (f != null) {
            int i = f.x;  // guaranteed to see 3  
            int j = f.y;  // could see 0
        } 
    } 
}

再次引用规范:

类FinalFieldExample有一个final int字段x和一个非final int字段y。一个线程可能执行writer方法,另一个线程可能执行reader方法。

因为writer方法在对象的构造函数完成后写入f,所以reader方法将保证看到f.x的正确初始化值:它将读取值3。然而,f.y不是final的;因此,reader方法不能保证看到它的值4。


0

如果类被扩展,则派生类可能不是不可变的。

如果您的类是不可变的,则所有字段在创建后都不会被修改。final关键字将强制执行此操作,并使其对未来的维护者明显。


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