技术开发 频道

实现一个不受约束的不变性模型

  【IT168 技术文章】编写线程安全的 Java 程序通常是一项很艰难的任务,简化这项任务的一种非常好的方法是依赖不变对象。支持对象不变性的主要机制是 final 字段。从 Java 5 开始,作为对 Java 内存模型(Java Memory Model,JMM)彻底修订的一部分,final 字段的语义得到加强。例如,和直观的感觉相反,将一个字段声明为 private,且永远不在构造函数外改变其值对于确保线程安全的访问来说是不 够的。为避免对它的同步访问,必需将一个字段声明为 final。但在一些情况下,JMM 所用的不变性模型局限性太强。本文介绍一种替代方法,将计算过的值缓存到标准字段中,而不会损害类的不变性。

  JMM 的不变性模型

  在 JMM 下,必须在构造函数中将 final 字段设置为其最终值。执行完构造函数(且在构造过程中不提供对对象本身的转义引用),JMM 会确保所有线程即使不同步访问 final 字段,也会读取相同的字段值。(这一点很重要,以 String 类为例,它阻止了可能出现的安全漏洞,即在不同线程中的可见性问题,导致 final 字段可能会改变值。)

  而且,如果 final 字段是个引用,以该引用为根的整个对象图(由其他线程所见的)的状态将会被读取为至少和当前 final 字段本身一样的状态,而不管图中对象的字段是否被声明为 final。如果图的状态在包含 final 根引用的对象构造完成后还没有修改,则所有的线程只要从该字段开始遍历,它们在任何地方读取到的就会都是一样的图。(相反,如果通过标准引用也能达到图的某一部分,那就无从保证从外界遍历该图的线程会读取到什么了。)

  当需要一个限制较少的不变性模型时

  在某些情况中,由 JMM 隐式运用的不变性模型过于局限。以此为例,假设您有一个针对欧几里德平面几何的不变的 Point 类。x 和 y 是表示笛卡尔坐标的 final 字段,getRho() 和 getPhi() 方法获取极坐标。由 getRho() 和 getPhi() 返回的值是恒定的,因为它们在功能上只依赖于不变的 x 和 y。

  可以用三种方法实现 getRho() 和 getPhi():

  随需重算:一般的方法是在每次调用时重新计算值。这种方法会保持线程安全,但如果频繁请求或者计算本身代价昂贵,则这种方法也许会损失性能。

  预先初始化:另一种方法是在构造过程中预先计算值,并把它们存储到 rho 和 phi 这两个 final 字段中。然后,其方法仅返回字段中的值,和标准 getter 方法的处理方式一样。这种方法也保持线程安全,但如果永远不需要该值,则预先计算也会是没用的或代价昂贵的。请注意,有必要 将 rho 和 phi 声明为 final (或 volatile),从而避免将 getRho() 和 getPhi() 声明为 synchronized。

  随需初始化:如果性能至关重要,且要避免与随需重算及预先初始化相关联的问题,另一种策略是:把计算延迟到第一次需要该值的时候,并把这些值缓存到 rho 和 phi 字段中,以便在接下来的请求中重用这些值。但字段不能是 final,因为您是在构造函数外设置它们的。而接下来,似乎 getRho() 和 getPhi() 需要被声明为 synchronized (或如 volatile 的字段),从而保证线程安全访问。但请以观察 rho 为例,尽管从 JMM 的角度看它是可变的,但它在逻辑上只被编写了一次:其值,一经计算和设置即不再改变。似乎预先初始化的预先计算部分推迟到了需要 rho 中的值的时候,即,最终用其定义值将其初始化的时候。

  强调这点很重要,即由 getRho() 和 getPhi() 返回的值在功能上只依赖于 x 和 y 这两个 final 字段。即,一定能从 x 和 y 中的不变值中将它们计算出来,且不依赖于其他任何状态。

  在随需初始化设置中,rho 和 phi 都是我称为单写 字段的例子。这样的字段有一个值,该值要么是该字段类型的初始默认值(null、0、false),要么是第一次写入即不再更改的值。(随着下面探讨的进一步深入,这个值可以写入两次,但通常是相同的值。)

  您可以有效地实现随需初始化策略,在该策略中,在构造函数外将单写字段设置为其最终值。它需要一些仔细的编程,有时也需要一些额外对象,但它明确避免任何其他形式的同步。只要一个不变类暴露一些在功能上只依赖于其(不变)状态的值,这些值就能被延迟计算、缓存在标准字段中,及通过非同步方法访问。总的方针是,只要预先初始化可行,随需初始化也就可行。

  警告:然而缺少同步会导致多个严重的安全问题。运行这项技术要求缓存字段在逻辑上是单写的,并且 如果该字段在物理上写入了不止一次,没人能看出这中间的区别。

  作为一道开胃菜,请看 Point 的一种可能的实现,如清单 1 所示。getPhi() 的实现是类似的(此处未显示)。

  清单 1. 单写字段的随需初始化

1 @Immutable
2 public class Point {
3     private final double x;
4     private final double y;
5     
6     private double rho;
7     private double phi;
8     
9     public Point(double x, double y) {
10         this.x = x;
11         this.y = y;
12     }
13     
14     public double getRho() {
15         double r = rho;
16         if (r == 0) {
17             r = Math.hypot(x, y); // never returns -0.0
18             if (r != 0)
19                 rho = r;
20         }
21         return r;
22     }
23
24     ...
25
26 }
27

  更完整的例子

  现在,这里有一个略微复杂点的例子:有理数(分数)。本部分介绍 Rational 类,并用此类来说明一些实现自由同步访问缓存字段的实用技术。我提出几种方法,从最自然的实现到更加复杂的实现。您将看到我用于实现 getRho() 的技术以多种形式重现。

  自然实现

  清单 2 显示了一个直截了当的 Rational 实现。它定义了两个字段:n 和 d 分别代表分子和分母。

  清单 2. Rational 类的自然实现

1 import java.math.BigInteger;
2
3 @Immutable
4 public class Rational {
5     private final BigInteger n;
6     private final BigInteger d;
7     
8     private static boolean isZero(BigInteger val) {
9         return val.signum() == 0;
10     }
11     
12     public Rational(BigInteger n, BigInteger d) {
13         if (isZero(d))
14             throw new IllegalArgumentException("denominator is 0");
15         this.n = n;
16         this.d = d;
17     }
18     
19     public boolean equals(Object o) { ... }
20     
21     public int hashCode() { ... }
22     
23     public Rational multiply(Rational val) { ... }
24
25     // other operations
26     ...
27
28 }
29

  在清单 2 中,字段被声明为 final,从而使 Rational 成为不变类,也因而线程安全。

  出现的第一个问题是如何实现一个适当的 hashCode() 方法。请记住 hashCode() 总的契约:在其他属性中,最重要的是只要一个对象和另一个对象 equals(),这两个对象也必须具有相同的 hashCode()。您想让 -4/6 和 6/-9 equals(),也需要让 -4/6 的 hashCode() 等于 6/-9 的。例如,也可以总是轻松地返回 42,但这会是一个差的 hashCode() 实现。好的实现必须返回对象可能不同的不等值,因而需要在某种程度上依赖于对象的状态。

  代价昂贵的实现

  深入阅读前,请试着实现一个基于 Rational 类的 n 和 d 字段值的 hashCode()。不把 n 和 d 简化为一种规范且惟一的形式,就没有什么好方法来正确完成它。Rational 最自然正规的表示是让 n 和 d 互为质数(gcd(n, d) = 1)且 d > 0。这些可以在构造过程中强制执行,如清单 3 所示:

  清单 3. 代价昂贵的 Rational 实现

1 @Immutable
2 public class Rational {
3     private final BigInteger n;
4     private final BigInteger d;
5     
6     private static boolean isZero(BigInteger val) { ... }
7     
8     private static boolean isOne(BigInteger val) {
9         return val.signum() > 0 && val.bitLength() == 1;
10     }
11     
12     public Rational(BigInteger n, BigInteger d) {
13         if (isZero(d))
14             throw new IllegalArgumentException("denominator is 0");
15         BigInteger gcd = n.gcd(d);
16         if (!isOne(gcd)) {
17             n = n.divide(gcd);
18             d = d.divide(gcd);
19         }
20         if (d.signum() < 0) {
21             n = n.negate();
22             d = d.negate();
23         }
24         this.n = n;
25         this.d = d;
26     }
27     
28     public boolean equals(Object o) {
29         if (this == o) return true;
30         if (!(o instanceof Rational))
31             return false;
32         Rational other = (Rational) o;
33         return n.equals(other.n) && d.equals(other.d);
34     }
35     
36     public int hashCode() {
37         return n.hashCode() ^ d.hashCode();
38     }
39     
40     public Rational multiply(Rational val) {
41         return new Rational(n.multiply(val.n), d.multiply(val.d));
42     }
43
44     // other operations
45     ...
46     
47 }
48

  该构造函数保证 Rational 的实例总是以其简化形式出现,n 和 d 互为质数,且 d > 0。这允许 equals() 和 hashCode() 的直接实现。这是惟一的构造函数,Rational 是不变的,因为所有的方法只在 final 字段上操作。另外,hashCode() 运行良好:它履行契约并生成适度合理分布的值。但这个 Rational 决不是有效的。

  它需要每个新构造的 Rational 以其简化的形式出现。这花费很大:它必需要计算最大公约数,这需要许多花费巨大的除法或位转换。另外,如果最大公约数不是 1,还需要执行两次整除。构造函数的所有调用都要计算最大公约数,大约 2/5 的调用需要进行化简除法。(一条数论的定理称两个整数互为质数的可能性为 61%,精确的说有 6/pi2 )

  更好的策略

  更好的策略是明确地允许非规范表示,并展示一个 getReduced() 方法,把该方法留给 Rational 的客户程序,让它来决定何时进行简化运算是合适的。配备了 getReduced() 方法,Rational 能如清单 4 那样被重新实现:

  清单 4. 含 getReduced() 但不含缓存的 Rational

1 @Immutable
2 public class Rational {
3     private final BigInteger n;
4     private final BigInteger d;
5     
6     private static boolean isZero(BigInteger val) { ... }
7     
8     private static boolean isOne(BigInteger val) { ... }
9     
10     public Rational(BigInteger n, BigInteger d) {
11         if (isZero(d))
12             throw new IllegalArgumentException("denominator is 0");
13         this.n = n;
14         this.d = d;
15     }
16     
17     private Rational newReduced(BigInteger gcd) {
18         BigInteger nRed = isOne(gcd) ? n : n.divide(gcd);
19         BigInteger dRed = isOne(gcd) ? d : d.divide(gcd);
20         return dRed.signum() > 0 ?
21             new Rational(nRed, dRed) :
22             new Rational(nRed.negate(), dRed.negate());
23     }
24     
25     public Rational getReduced() {
26         BigInteger gcd = n.gcd(d);
27         return isOne(gcd) && d.signum() > 0 ?
28             this :
29             newReduced(gcd);
30     }
31     
32     public boolean equals(Object o) { ... }
33     
34     public int hashCode() {
35         Rational thiz = getReduced();
36         return thiz.n.hashCode() ^ thiz.d.hashCode();
37     }
38     
39     public Rational multiply(Rational val) { ... }
40
41     // other operations
42     ...
43     
44 }
45

  尽管不采用 getReduced() 也能实现 equals(),但 hashCode() 需要该对象的简化表示来满足 hashCode() 的契约。但一旦计算了一个简化值(要么是因为客户程序提出请求,要么是因为 hashCode() 需要),将这个值缓存起来就对将来很有用:如果使用简化值,则数学运算会计算得更快。但如果不在每个方法间强制同步,则不能修改 final 字段或把它们变成标准字段。

  您愿意实现一个这样的 Rational 类,该类:

  是不变的且线程安全。

  并不要求只实例化简化表示。

  一旦由于外部或内部原因需要该类,该类就将其简化值缓存起来。

  一经计算,只要可能就使用该缓存过的简化值。

  清单 5 是一次尝试:

  清单 5. 用单写字段的实现

1 @Immutable
2 public class Rational {
3     private final BigInteger n;
4     private final BigInteger d;
5     
6     private Rational red; // the cached reduced representation of this
7     private int hash; // the cached hashCode()
8
9     
10     private static boolean isZero(BigInteger val) { ... }
11     
12     private static boolean isOne(BigInteger val) { ... }
13     
14     public Rational(BigInteger n, BigInteger d) {
15         if (isZero(d))
16             throw new IllegalArgumentException("denominator is 0");
17         this.n = n;
18         this.d = d;
19     }
20     
21     private Rational newReduced(BigInteger gcd) { ... }
22     
23     public Rational getReduced() {
24         Rational r = red;
25         if (r == null) {
26             BigInteger gcd = n.gcd(d);
27             if (isOne(gcd) && d.signum() > 0)
28                 r = this;
29             else {
30                 r = newReduced(gcd);
31                 r.red = r; // red of an already reduced r is itself
32             }
33             red = r;
34         }
35         return r;
36     }
37     
38     public boolean equals(Object o) { ... }
39     
40     public int hashCode() {
41         Rational thiz = getReduced();
42         int h = thiz.hash;
43         if (h == 0) {
44             h = thiz.n.hashCode() ^ thiz.d.hashCode();
45             if (h != 0)
46                 thiz.hash = h;
47         }
48         return h;
49     }
50
51     public Rational multiply(Rational val) {
52         Rational thiz = red;
53         if (thiz == null)
54             thiz = this;
55         Rational other = val.red;
56         if (other == null)
57             other = val;
58         return new Rational(thiz.n.multiply(other.n), thiz.d.multiply(other.d));
59     }
60     
61     // other operations
62     ...
63
64 }
65

  很明显,red 不能成为 final,因为它是延迟计算的。所以 Rational 有可能不是不变的,因为它声明非 final 字段。尽管如此,如 清单 1 的 Point 类中的 rho,red 并不真是对象状态的一部分:它在这里只为改善其性能。这是一个总的原则:只要一个值在功能上依赖于一个对象的不变状态,该值就是多余的,且在逻辑上并不是该对象状态的一部分。从逻辑的角度看,Rational 的状态只由 n 和 d 定义。

  这项依赖于 JMM 的实现保证了非凭空产生的安全:当获取一个字段值时,线程会读取默认值或由某个线程设置的值,而决不会是一个随机值。

  考虑 hashCode(),这是 getReduced() 的一个客户程序。可以假设计算 BigInteger.hashCode() 花费很大,特别是计算大整数。所以一旦计算了 Rational.hashCode(),就可以将其缓存。保存它正确的位置是在缓存简化实例。这个 hashCode() 实现是线程安全的吗?两个不同的线程可以在相同的 hash 字段上操作,一个进行读取,另一个向其写入。因为对该字段的访问并不是同步的,所以这个对 hash 的访问是一个数据竞赛,但却是一个最终无害的竞赛(回答我刚才的问题)。

  良性数据竞赛

  事实上,hash 的值完全是由简化值决定的。反过来,简化值只依赖于由 this 引用的对象的不变状态。线程只会读取默认的 0 值或是由某个线程写入的值。由任何线程式写入的值总是相同的,不论线程间如何相互计算,如何设置值。一旦线程从 hash 中读取了一个非零值,接下来它总会看到相同的值,即使该线程明显同时被另一个线程写入。这叫做良性 数据竞赛。像 hash 这样的单写字段起作用是因为它们正在经历的数据竞赛是良性的。(String.hashCode() 使用了类似的实现。)

  到目前为止,像 hash 这样的原语一切都好。(对于浮点,务必注意,-0.0 和默认初始值 +0.0 是不同的,但 == 认为它们相等。)对于引用,该问题显得更复杂一些了。

  为找出原因,我将在 Rational 中的一个特别的实例(rat)上分析 red 发生了什么。惟一向 red 写入的方法是 getReduced()。由于多线程无需同步访问字段就能更新相同的字段(getReduced() 不是 synchronized 的),因而您面对着一场数据竞赛。但由 red 引用的值完全由 rat 的不变状态所决定。不管向 red 写入了多少次,一个线程一旦读取到非空,接下来它将总看到相同的简化值,尽管不必要是同一个对象。再说一次,该数据竞赛是良性的。

  并不保证线程会继续在 red 中看到相同的 Rational 实例。相反,能够保证的是该值是不可区分的,即使引用本身发生改变。事实上,假设线程向 red 写入并不对第二个线程可见。由于该写入并不同步,所以这是可以发生的。第二个线程在相同的 rat 上第一次执行 getReduced(),随后会将 red 设置到同一个简化值的新实例。稍后,这一写入也许会被第一个线程看到。从第一个线程的角度看,red 中的引用 会被改变,尽管值 还会是相同的。

  同样,对于一个特别的 rat 来说,线程至多会计算一次其简化值,如果由其他线程对 red 的写入立刻可见,也许一次也不需要计算。这为同一个 rat 重算多少次简化值定义了上限。计算的次数至多和在 rat 上操作的线程数一样多。

  现在,考虑 multiply() 发生了什么。再说一次,它是线程安全的。请观察,该 multiply() 首先仔细地快照了本地变量 thiz 中的 this 或其简化值。这些步骤再一次采用良性数据竞赛,thiz 总引用一个正确的数值:要么是 this 的原始值,要么是其简化值。同样的情况发生在 val 参数和其对应的局部变量 other 身上。因此,通过 thiz 和 other,multiply() 总见到正确的数值,对于正确计算产品来说这足够了。对于所有其他操作,您也能编写相似的代码。

  不变包装对象

  但 hashCode() 中也有一个小的令人烦恼的地方。当此方法碰巧返回 0 时,所有线程将一遍遍对它进行重算。在实现中,hashCode() 没办法区分出用其默认的 0 值初始化的哈希和真正计算过的 0 哈希。您可以引入一个布尔型 isComputed 字段来指示 0 哈希值是计算的结果还是初始默认值。但如果不通过某种形式的同步来协调好对 isComputed 和 hash 的访问,这将无从做起;将这个作为一项说服性练习试试吧。

  由于性能原因,几乎不需要这样做,但通过依赖内部利用了 final 字段的不变包装对象,您可以不必同步而解决此问题,如清单 6 所示:

  清单 6. hashCode() 的另一种形式

1 @Immutable
2 public class Rational {
3     private final BigInteger n;
4     private final BigInteger d;
5     
6     private Rational red; // the cached reduced representation of this
7     private Integer hash; // the cached hashCode()
8
9     // autoboxing and unboxing as in Java 5
10     public int hashCode() {
11         Rational thiz = getReduced();
12         Integer h = thiz.hash;
13         if (h == null) {
14             h = thiz.n.hashCode() ^ thiz.d.hashCode();
15             thiz.hash = h;
16         }
17         return h;
18     }
19
20     ...
21 }
22       

  现在 hashCode() 可以可靠地区分出 hash 是否被计算过。额外的 null 值在这里是有帮助的,但它多花费了一个对象。提供的 BigInteger.hashCode() 在 232 (大约四十亿)个不同的 int 值间将其结果分配得相当好,计算过的 hashCode() 为 0 的概率相对地小。原始 hash 字段的解决方案因而更加有效也更节省空间。令 hashCode() = 0 的最明显的 Rational 是 1/1,所以如果反复重算这个哈希,在这里不会出现性能瓶颈。所以,只有在计算代价太大和 返回默认值的概率很大的情况下采纳包装对象才有意义。

  结束语

  正如您所见,Point 类的单写字段 rho 和 phi,Rational 类的 red 和 hash 字段的计算过的值在功能上只依赖于其实例的不变状态。所以它们反过来在逻辑上是不变的。但不需要将它们存储在 final 字段中,且能够延迟计算。

  它们说明了一条总的原则,该原则对于在功能上只依赖于不变状态的值有效。通过小心编程,可以将这个计算过的值缓存到标准字段中,而不会损害类的不变性。如果这样的一个值很少或从不获取其类型的默认值(null、0、false),相同类型的标准字段就能直接将其缓存。否则,引用不变包装对象(Integer、Double、Rational 本身,等等)的标准字段将完成这一任务,因为您总能测试计算过的值是否被赋值到字段。

0
相关文章