UDN-企业互联网技术人气社区

板块导航

浏览  : 499
回复  : 3

[讨论交流] Java开发教程重点(四)对象的不变性和安全的发布对象

[复制链接]
王许柔的头像 楼主
发表于 2016-8-2 10:26:49 | 显示全部楼层 |阅读模式
本帖最后由 王许柔 于 2016-8-3 10:48 编辑

  一、不变性

  满足同步需求的另一种方法是使用不可变对象(Immutable Object)。到目前为止,我们介绍了许多与原子性和可见性相关的问题,例如得到失效数据,丢失更新操作或光查到某个对象处于不一致的状态等等,都与多线程视图同时访问同一个可变的状态相关。如果对象的状态不会改变,那么这些问题与复杂性也就自然消失了。

  如果某个对象在被创建后其状态就不能被修改,那么这个对象就被成为不可变对象。线程安全型是不可变对象的固有属性之一,他们的不变性条件是由构造函数创建的,只要他们的状态不改变,那么这些不变性条件就能得以维持。

  不可变对象很简单。他们只有一种状态,并且该状态由构造函数来控制。在程序设计中一个最困难的地方就是判断复杂对象的可能状态。然而,判断不可变对象的状态却很简单。

  虽然在 Java 规范和 Java 内存模型中都没有给出不可变性的正式定义,但不可变性并不等于将对象中所有的域都声明为 final 类型,即使对象中所有的域都是 final 类型的,这个对象也仍然是可变的,因为在 final 类型的域中可以保存对可变对象的引用。

  当满足以下条件时,对象才是不可变的:

  对象创建完之后其状态就不能修改

  对象的所有与都是 final 类型

  对象时正确创建的(创建期间没有 this 的逸出)

  我们来分析下面这个类。
  1. @Immutable
  2. public final class ThreeStooges {
  3.     private final Set<String> stooges = new HashSet<String>();

  4.     public ThreeStooges() {
  5.         stooges.add("Moe");
  6.         stooges.add("Larry");
  7.         stooges.add("Curly");
  8.     }

  9.     public boolean isStooge(String name) {
  10.         return stooges.contains(name);
  11.     }
  12. }
复制代码

  在不可变对象的内部仍可以使用可变对象来管理它们的状态,如 ThreeStooges 所示。尽管保存姓名的Set对象是可变的,但从ThreeStooges的设计中可以看到,在Set对象构造完成后无法对其进行修改。stooges是一个final类型的引用变量,因此所有的对象状态都通过一个final域来访问。最后一个要求是“正确地构造对象”,这个要求很容易满足,因为构造函数能使该引用由除了构造函数及其调用者之外的代码来访问。

  由于程序的状态总在不断地变化,你可能会认为需要使用不可变对象的地方不多,但实际情况并非如此。在“不可变的对象”与“不可变的对象引用”之间存在着差异。保存在不可变对象中的程序状态仍然可以更新,即通过将一个保存新状态的实例来“替换”原有的不可变对象。

  Final 域

  关键字 final 可以视为 C++ 中 const 机制的一种受限版本,用于构造不可变对象。final 类型的域是不能修改的(但如果 final 域所引用的对象时可变的,那么这些被引用的对象是可以修改的)。然而,在 Java 内存模型中,final 域还有着特殊的语义。final 域能确保初始化过程的安全性,从而可以不受限制的访问不可变对象,并在共享这些对象时无需同步。

  注:个人理解为,final 字段一旦被初始化完成,并且构造器没有把 this 引用传递出去,那么在其他线程中就能看到 final 字段的值(域内变量可见性,和 volatile 类似),而且其外部可见状态永远也不会改变。它所带来的安全性是最简单最纯粹的。

  注:即使对象是可变的,通过将对象的某些域声明为final类型,仍然可以简化对状态的判断,因此限制对象的可变性也就相当于限制了该对象可能的状态集合。仅包含一个或两个可变状态的“基本不可变”对象仍然比包含多个可变状态的对象简单。通过将域声明为final类型,也相当于告诉维护人员这些域是不会变化的。

  正如“除非需要更高的可见性,否则应将所有的饿域都声明为私有域”[EJ Item 12]是一个良好的变成习惯,“除非需要某个域是可变的,否则应将其声明为final域”也是一个良好的变成习惯。

  示例:使用 Volatile 类型来发布不可变对象

  之前我们讲过, volatile 可以用来保证域的可见性而不能保证变量操作的原子性,更为准确的讲,只能保证读写操作具有原子性,而不能保证自增 i++ 等运算操作的原子性。

  在前面的UnsafeCachingFactorizer类中,我们尝试用两个AtomicReferences变量来保存最新的数值及其因数分解结果,但这种方式并非是线程安全的,因为我们无法以原子方式来同时读取或更新这两个相关的值。同样,用volatile类型的变量来保存这些值也不是线程安全的。然而,在某些情况下,不可变对象能提供一种弱形式的原子性。

  因式分解Servlet将执行两个原子操作:更新缓存的结果,以及通过判断缓存中的数值是否等于请求的数值来决定是否直接读取缓存中的因数分解结果。每当需要对一组相关数据以原子方式执行某个操作时,就可以考虑创建一个不可变的类来包含这些数据,例如 OneValueCache。
  1. @Immutable
  2. class OneValueCache {
  3.     private final BigInteger lastNumber;
  4.     private final BigInteger[] lastFactors;

  5.     /**
  6.      * 如果在构造函数中没有使用 Arrays.copyOf()方法,那么域内不可变对象 lastFactors却能被域外代码改变
  7.      * 那么 OneValueCache 就不是不可变的。
  8.      */
  9.     public OneValueCache(BigInteger i,
  10.                          BigInteger[] factors) {
  11.         lastNumber  = i;
  12.         lastFactors = Arrays.copyOf(factors, factors.length);
  13.     }

  14.     public BigInteger[] getFactors(BigInteger i) {
  15.         if (lastNumber == null || !lastNumber.equals(i))
  16.             return null;
  17.         else
  18.             return Arrays.copyOf(lastFactors, lastFactors.length);
  19.     }
  20. }
复制代码

  对于在访问和更新多个相关变量时出现的竞争条件问题,可以通过将这些变量全部保存在一个不可变对象中来消除。如果是一个可变的对象,那么就必须使用锁来确保原子性。如果是一个不可变对象,那么当线程获得了对该对象的引用后,就不必担心另一个线程会修改对象的状态。如果要更新这些变量,那么可以创建一个新的容器对象,但其他使用原有对象的线程仍然会看到对象处于一致的状态。

        在 VolatileCachedFactorizer使用了OneValueCache来保存缓存的数值及其因数。我们将 OneValueCache 声明为 volatile,这样当一个线程将cache设置为引用一个新的OneValueCache时,其他线程就会立即看到新缓存的数据。
  1. @ThreadSafe
  2. public class VolatileCachedFactorizer implements Servlet {
  3.     private volatile OneValueCache cache =
  4.         new OneValueCache(null, null);

  5.     public void service(ServletRequest req, ServletResponse resp) {
  6.         BigInteger i = extractFromRequest(req);
  7.         BigInteger[] factors = cache.getFactors(i);
  8.         if (factors == null) {
  9.             factors = factor(i);
  10.             cache = new OneValueCache(i, factors);//声明为 volatile ,防止指令重排序,保证可见性
  11.         }
  12.         encodeIntoResponse(resp, factors);
  13.     }
  14. }
复制代码

  与cache相关的操作不会相互干扰,因为OneValueCache是不可变的,并且在每条相应的代码路径中只会访问它一次。通过使用包含多个状态变量的容器对象来维持不变性条件,并使用一个volatile类型的引用来确保可见性,使得Volatile Cached Factorizer在没有显式地使用锁的情况下仍然是线程安全的。

  二、安全发布

  到目前为止,我们重点讨论的是如何确保对象不被发布,例如让对象封闭在线程或另一个对象的内部。当然,在某些情况下我们希望在多个线程间共享对象,此时必须确保安全地进行共享。然而,如果只是像下面程序那样将对象引用保存到公有域中,那么还不足以安全地发布这个对象。
  1. //不安全的发布
  2. public Holder holder;

  3. public void initialize() {
  4.     holder = new Holder(42);
  5. }
复制代码

  你可能会奇怪,这个看似没有问题的示例何以会运行失败。由于存在可见性问题,其他线程看到的Holder对象将处于不一致的状态,即便在该对象的构造函数中已经正确地构建了不变性条件。这种不正确的发布导致其他线程看到尚未创建完成的对象。

  不正确的发布:正确的对象被破坏

  你不能指望一个尚未被完全创建的对象拥有完整性。某个观察该对象的线程将看到对象处于不一致的状态,然后看到对象的状态突然发生变化,即使线程在对象发布后还没有修改过它。事实上,如果下面程序中的Holder使用前面程序中的不安全发布方式,那么另一个线程在调用assertSanity时将抛出AssertionError。
  1. public class Holder {
  2.     private int n;

  3.     public Holder(int n) { this.n = n; }

  4.     public void assertSanity() {
  5.         if (n != n)
  6.             throw new AssertionError("This statement is false.");
  7.     }
  8. }
复制代码

  由于没有使用同步来确保Holder对象对其他线程可见,因此将Holder称为“未被正确发布”。在未被正确发布的对象中存在两个问题。

  首先,除了发布对象的线程外,其他线程可以看到的Holder域是一个失效值,因此将看到一个空引用或者之前的旧值。

  然而,更糟糕的情况是,线程看到Holder引用的值是最新的,但Holder状态的值却是失效的。情况变得更加不可预测的是,某个线程在第一次读取域时得到失效值,而再次读取这个域时会得到一个更新值,这也是assertSainty抛出AssertionError的原因。

  如果没有足够的同步,那么当在多个线程间共享数据时将发生一些非常奇怪的事情。

  不可变对象与初始化安全性

  由于不可变对象是一种非常重要的对象,因此Java内存模型为不可变对象的共享提供了一种特殊的初始化安全性保证。我们已经知道,即使某个对象的引用对其他线程是可见的,也并不意味着对象状态对于使用该对象的线程来说一定是可见的。为了确保对象状态能呈现出一致的视图,就必须使用同步。

  另一方面,即使在发布不可变对象的引用时没有使用同步,也仍然可以安全地访问该对象。为了维持这种初始化安全性的保证,必须满足不可变性的所有需求:状态不可修改,所有域都是final类型,以及正确的构造过程。(如果Holder对象是不可变的,那么即使Holder没有被正确地发布,在assertSanity中也不会抛出AssertionError。)

  任何线程都可以在不需要额外同步的情况下安全地访问不可改变对象,即使在发布这些对象时没有使用同步。

  这种保证还将延伸到被正确创建对象中所有final类型的域。在没有额外同步的情况下,也可以安全地访问final类型的域。然而,如果final类型的域所指向的是可变对象,那么在访问这些域所指向的对象的状态时仍然需要同步。

  安全发布的常用模式

  可变对象必须通过安全的方式来发布,这通常意味着在发布和使用该对象的线程时都必须使用同步。现在,我们将重点介绍如何确保使用对象的线程能够看到该对象处于已发布的状态,并稍后介绍如何在对象发布后对其可见性进行修改。

  安全地发布一个对象,对象的应用以及对象的状态必须同时对其他线程可见。一个正确构造的对象可以通过以下方式来安全地发布:

  在静态初始化函数中初始化一个对象引用

  • 将对象的应用保存到volatile类型的域或者AtomicReferance对象中
  • 将对象的引用保存到某个正确构造对象的final类型域中
  • 将对象的引用保存到一个由锁保护的域中。

        在线程安全容器内部的同步意味着,在将对象放入到某个容器,例如Vector或synchronizedList时,将满足上述最后一条需求。如果线程A将对象X放入一个线程安全的容器,随后线程B读取这个对象,那么可以确保B看到A设置的X状态,即便在这段读/写X的应用程序代码中没有包含显式的同步。尽管Javadoc在这个主题上没有给出很清晰的说明,但线程安全库中的容器类提供了以下的安全发布保证:

  • 通过将一个键或者值放入Hashtable、synchronizedMap或者ConcurrentMap中,可以安全地将它发布给任何从这些容器中访问它的线程(无论是直接访问还是通过迭代器访问)
  • 通过将某个元素放入Vector、CopyOnWriteArrayList、CopyOnWriteArraySet、synchronizedList或synchronizedSet中,可以将该元素安全地发布到任何从这些容器中访问该元素的线程
  • 通过将某个元素放入BlockingQueue或者ConcurrentLinkedQueue中,可以将该元素安全地发布到任何从这些队列中访问该元素的线程。

  类库中的其他数据传递机制(例如Future和Exchanger)同样能实现安全发布,在介绍这些机制时将讨论它们的安全发布功能。

  通常,要发布一个静态构造的对象,最简单和最安全的方式是使用静态的初始化器:

  public static Holder holder = new Holder(42);

  静态初始化器由JVM在类的初始化阶段执行。由于在JVM内部存在着同步机制,因此通过这种方式初始化的任何对象都可以被安全地发布[JLS 12.4.2]。

  事实不可变对象

  如果对象在发布后不会被修改,那么对于其他在没有额外同步的情况下安全地访问这些对象的线程来说,安全发布是足够的。所有的安全发布机制都能确保,当对象的引用对所有访问该对象的线程可见时,对象发布时的状态对于所有线程也将是可见的,并且如果对象状态不会再改变,那么就足以确保任何访问都是安全的。

  如果对象从技术上来看是可变的,但其状态在发布后不会再改变,那么把这种对象称为“事实不可变对象(Effectively Immutable Object)”。这些对象不需要满足之前提出的不可变性的严格定义。在这些对象发布后,程序只需将它们视为不可变对象即可。通过使用事实不可变对象,不仅可以简化开发过程,而且还能由于减少了同步而提高性能。

  在没有额外的同步的情况下,任何线程都可以安全地使用被安全发布的事实不可变对象。

  例如,Date本身是可变的,但如果将它作为不可变对象来使用,那么在多个线程之间共享Date对象时,就可以省去对锁的使用。假设需要维护一个Map对象,其中保存了每位用户的最近登录时间:

  public Map lastLogin =Collections.synchronizedMap(new HashMap());

  如果Date对象的值在被放入Map后就不会改变,那么synchronizedMap中的同步机制就足以使Date值被安全地发布,并且在访问这些Date值时不需要额外的同步。

  可变对象

  如果对象在构造后可以修改,那么安全发布只能确保“发布当时”状态的可见性。对于可变对象,不仅在发布对象时需要使用同步,而且在每次对象访问时同样需要使用同步来确保后续修改操作的可见性。要安全地共享可变对象,这些对象就必须被安全地发布,并且必须是线程安全的或者由某个锁保护起来。

  对象的发布需求取决于它的可变性:
  • 不可变对象可以通过任意机制来发布
  • 事实不可改变必须通过安全方式发布
  • 可变对象必须通过安全方式发布,并且必须是线程安全的或者由某个锁保护起来

  安全的共享对象

        当获得对象的一个引用时,你需要知道在这个引用上可以执行哪些操作。在使用它之前是否需要获得一个锁?是否可以修改它的状态,或者只能读取它?许多并发错误都是由于没有理解共享对象的这些“既定规则”而导致的。当发布一个对象时,必须明确地说明对象的访问方式。

  扩展阅读:

  Java开发教程重点(汇总)

相关帖子

发表于 2016-8-2 10:56:45 | 显示全部楼层
阿里资深技术总监:基于大数据的全球电商系统性能优化
使用道具 举报

回复

发表于 2016-8-2 10:57:10 | 显示全部楼层
赞,帖子很棒!
使用道具 举报

回复

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则

关于我们
联系我们
  • 电话:010-86393388
  • 邮件:udn@yonyou.com
  • 地址:北京市海淀区北清路68号
移动客户端下载
关注我们
  • 微信公众号:yonyouudn
  • 扫描右侧二维码关注我们
  • 专注企业互联网的技术社区
版权所有:用友网络科技股份有限公司82041 京ICP备05007539号-11 京公网网备安1101080209224 Powered by Discuz!
快速回复 返回列表 返回顶部