我正在读“Effective Java”一书。
在最小化可变性项目中,Joshua Bloch讨论了使一个类不可变的问题。
不要提供任何修改对象状态的方法 - 这很好。
确保无法扩展该类。 - 我们真的需要这样做吗?
让所有字段最后 - 我们真的需要这样做吗?
例如,假设我有一个不可变的类,
class A{
private int a;
public A(int a){
this.a =a ;
}
public int getA(){
return a;
}
}
从A延伸的类如何影响A的不变性?
答案 0 :(得分:6)
像这样:
public class B extends A {
private int b;
public B() {
super(0);
}
@Override
public int getA() {
return b++;
}
}
从技术上讲,你不是要修改从A
继承的字段,但是在一个不可变对象中,同一个getter的重复调用当然应该产生相同的数字,这不是这里的情况。 / p>
当然,如果您坚持使用规则#1,则不允许您创建此覆盖。但是,您无法确定其他人是否会遵守该规则。如果您的某个方法将A
作为参数并在其上调用getA()
,则其他人可以创建上述类B
并将其实例传递给您的方法;然后,您的方法将在不知情的情况下修改对象。
答案 1 :(得分:4)
Liskov替换原则说子类可以在超类的任何地方使用。从客户的角度来看,孩子是IS-A的父母。
因此,如果您覆盖子进程中的方法并使其变为可变,那么您违反了与父进程中任何期望它是不可变的客户端的契约。
答案 2 :(得分:3)
如果声明一个字段final
,那么除了尝试修改字段或使其保持未初始化之外,还有一个编译时错误。
在多线程代码中,如果您使用数据竞争共享类A
的实例(即,没有任何类型的同步,即将其存储在全局可用的位置,例如静态字段),有些线程可能会看到getA()
更改的值!
Final
字段(由JVM specs保证)在构造函数完成后对所有线程都可见,即使没有同步也是如此。
考虑这两个类:
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
的值(让我们忘记反射)。唯一的区别是,字段x
在A中标记为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
字段是最终的 - 因此,在构造函数完成后,保证看到该对象的所有线程都会看到x
的正确值。
但是,我们无法确定其他线程会做什么。规格只能保证打印0
或1
。由于该字段不是final
,并且我们没有使用任何类型的同步(synchronized
或volatile
),该线程可能会看到该字段未初始化并打印0!另一种可能性是它实际上看到字段已初始化,并打印1.它无法打印任何其他值。
此外,如果您继续阅读并打印getX()
b
的值,可能会发生一段时间后打印1
0
}!在这种情况下,很清楚为什么不可变对象必须具有其字段final
:从第二个线程的角度来看,b
已经改变,即使它因为不提供setter而应该是不可变的!
如果你想保证第二个线程在没有创建字段x
的情况下看到final
的正确值,你可以声明保存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
成为最终字段。
作为最后一点,对于不可变的,线程安全的类来说,并非绝对必须将所有字段设为最终。但是,这些类(线程安全,不可变,包含非最终字段)必须非常谨慎地设计,并且应留给专家。
这方面的一个例子是类java.lang.String。它有一个private 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()方法首先读取(非最终)字段hash
。如果它未初始化(即,如果它是0),它将重新计算其值,并进行设置。对于已计算哈希码并写入字段的线程,它将永远保留该值。
但是,即使在线程将其设置为其他线程之后,其他线程仍可能看到该字段为0。在这种情况下,这些其他线程将重新计算散列,并获得完全相同的值,然后设置它。
在这里,证明类的不变性和线程安全性的理由是每个线程都将获得hashCode()的完全相同的值,即使它被缓存在非final字段中,因为它将被重新计算并且将获得完全相同的值。
所有这些推理都非常微妙,这就是建议所有字段在不可变的,线程安全的类上标记为final的原因。
答案 3 :(得分:0)
如果扩展了类,那么派生类可能不是不可变的。
如果您的类是不可变的,那么创建后不会修改所有字段。 final关键字将强制执行此操作,并使其对未来的维护者显而易见。
答案 4 :(得分:0)
添加此答案以指向the exact section of the JVM spec,其中提到为什么成员变量必须是最终的才能在不可变类中保持线程安全。这是规范中使用的示例,我认为非常清楚:
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有一个最终的int字段x和一个非final的int字段y。一个线程可能执行方法编写器,另一个线程可能执行方法读取器。
因为writer方法在对象的构造函数完成后写入f,所以读者方法将保证看到f.x的正确初始化值:它将读取值3.但是,f.y不是final;因此,读者方法不能保证看到它的值。