以下是创建不可变类的三个要点:
1. 不提供任何修改对象状态的方法 -- 这是可以的。 2. 确保该类不能被继承。-- 我们真的需要这样做吗? 3. 使所有字段都为final。-- 我们真的需要这样做吗?
例如,假设我有一个不可变类:
class A{
private int a;
public A(int a){
this.a =a ;
}
public int getA(){
return a;
}
}
如何才能让一个继承自A的类,打破A的不可变性?
class A{
private int a;
public A(int a){
this.a =a ;
}
public int getA(){
return a;
}
}
像这样:
public class B extends A {
private int b;
public B() {
super(0);
}
@Override
public int getA() {
return b++;
}
}
从技术上讲,您没有修改从A
继承的字段,但在不可变对象中,同一getter的重复调用当然会产生相同的数字,而这里并非如此。
当然,如果您遵循规则#1,就不允许创建此覆盖。但是,您无法确定其他人是否会遵守该规则。如果您的某个方法接受A
作为参数并在其上调用getA()
,则其他人可能会创建类B
并将其实例传递给您的方法; 然后,您的方法将在不知情的情况下修改对象。
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);
}
}
a
的线程将打印值为 1
,因为它的 x
字段是 final 的——因此,在构造函数完成后,保证所有看到该对象的线程都将看到 x
的正确值。0
或 1
。由于该字段不是 final
,并且我们没有使用任何类型的同步(synchronized
或 volatile
),该线程可能会看到未初始化的字段并打印 0!另一种可能性是它实际上看到了初始化的字段,并打印 1。它无法打印任何其他值。b
的 getX()
值,它可能会在打印了一段时间的 0 后开始打印 1!在这种情况下,很明显为什么不可变对象必须具有其字段 final
:从第二个线程的角度来看,即使不提供设置器,b
已经改变了!final
的情况下看到正确的 x
值,则可以将保存 B
实例的字段声明为 volatile。class Main {
// ...
volatile static 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;
}
在这里添加答案,指向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。
如果类被扩展,则派生类可能不是不可变的。
如果您的类是不可变的,则所有字段在创建后都不会被修改。final关键字将强制执行此操作,并使其对未来的维护者明显。