Java并发编程 设计线程安全的类

在线程安全的程序中,虽然可以将程序的所有状态都保存在公有的静态域中,但与那些将状态封装起来的程序相比,这些程序的线程安全性更难以得到验证,并且在修改时也更难以始终确保其线程安全性。通过使用封装技术,可以使得在不对整个程序进行分析的情况下就可以判断一个类是否是线程安全的。

在设计线程安全类的过程中,需要包含以下三个基本要素:
1. 找出构成对象状态的所有变量;
2. 找出约束状态变量的不变性条件;
3. 建立对象状态的并发访问管理策略。

要分析对象的状态,首先从对象的域开始。如果对象中所有的域都是基本类型的变量,那么这些域将构成对象的全部状态。下面代码的 Counter 只有一个域 value,因此这个域就是Counter 的全部状态。对于含有 n 个基本类型域的对象,其状态就是这些域构成的 n 元组。例如,二维点的状态就是它的坐标值(x,y)。如果在对象的域中引用了其他对象,那么该对象的状态将包含被引用对象的域。例如,LinkedList 的状态就包括该链表中所有节点对象的状态。

// 使用 Java 监视器模式的线程安全计数器
@ThreadSafe
public final class Counter {
    @GuardedBy("this")
    private long value = 0;
    
    public synchronized long getValue() {
        return value;
    }
    
    public synchronized long increment() {
        if (value == Long.MAX_VALue) {
            throw new IllegalStateException("counter overflow");
        }
        return ++value;
    }
}

同步策略定义了如何在不违背对象不变条件的情况下对其状态的访向操作进行协同。同步策略规定了如何将不可变性、线程封闭与加锁机制等结合起来以维护线程的安全性,并且还规定了哪些变量由哪些锁来保护。

1. 收集同步需求

要确保类的线程安全性,就需要确保它的不变性条件不会在并发访问的情况下被破坏,这就需要对其状态进行推断。对象与变量都有一个状态空间,即所有可能的取值。状态空间越小,就越容易判断线程的状态。final 类型的域使用得越多,就越能简化对象可能状态的分析过程。

在许多类中都定义了一些不可变条件,用于判断状态是有效的还是无效的。Counter 中的 value 域是 long 类型的变量,其状态空间为从 Long.MIN_VALUE 到 Long.MAX_VALUE,但Counter 中 value 在取值范围上存在着一个限制,即不能是负值。

同样,在操作中还会包含一些后验条件来判断状态迁移是否是有效的。如果 Counter 的当前状态为 17,那么下一个有效状态只能是 18。当下一个状态需要依赖当前状态时,这个操作就必须是一个复合操作。并非所有的操作都会在状态转换上施加限制,例如,当更新一个保存当前温度的变量时,该变量之前的状态并不会影响计算结果。

由于不变性条件以及后验条件在状态及状态转换上施加了各种约束,因此就需要额外的同步与封装。如果某些状态是无效的,那么必须对底层的状态变量进行封装,否则客户代码可能会使对象处于无效状态。如果在某个操作中存在无效的状态转换,那么该操作必须是原子的。另外,如果在类中没有施加这种约束,那么就可以放宽封装性或序列化等需求,以便获得更高的灵活性或性能。

如果不了解对象的不变性条件与后验条件,那么就不能确保线程安全性。
要满足在状态变量的有效值或状态转换上的各种约束条件,就需要借助于原子性与封装性。

2. 依赖状态的操作

类的不变性条件与后验条件约束了在对象上有哪些状态和状态转换是有效的。在某些对象的方法中还包含一些基于状态的先验条件(Precondition)。例如,不能从空队列中移除一个元素,在删除元素前,队列必须处于“非空的”状态。如果在某个操作中包含有基于状态的先验条件,那么这个操作就称为依赖状态的操作。

在单线程程序中,如果某个操作无法满足先验条件,那么就只能失败。但在并发程序中,先验条件可能会由于其他线程执行的操作而变成真。在并发程序中要一直等到先验条件为真,然后再执行该操作。

在 Java 中,等待某个条件为真的各种内置机制(包括等待和通知等机制)都与内置加锁机制紧密关联,要想正确地使用它们并不容易。要想实现某个等待先验条件为真时才执行的操作,一种更简单的方法是通过现有库中的类(例如阻塞队列 [Blocking Queue] 或信号量 [Semaphore] )来实现依赖状态的行为。(后续将会进行分析)

3. 状态的所有权

在定义哪些变量将构成对象的状态时,只考虑对象拥有的数据。所有权(Ownership)在 Java 中并没有得到充分的体现,而是属于类设计中的一个要素。如果分配并填充了一个 HashMap 对象,那么就相当于创建了多个对象:HashMap 对象,在 HashMap 对象中包含的多个对象,以及在 Map.Entry 中可能包含的内部对象。HashMap 对象的逻辑状态包括所有的 Map.Entry 对象以及内部对象,即使这些对象都是一些独立的对象。

无论如何,垃圾回收机制使我们避免了如何处理所有权的问题。在 C++ 中,当把一个对象传递给某个方法时,必须认真考虑这种操作是否传递对象的所有权,是短期的所有权还是长期的所有权。在 Java 中同样存在这些所有权模型,只不过垃圾回收器为我们减少了许多在引用共享方面常见的错误,因此降低了在所有权处理上的开销。

许多情况下,所有权与封装性总是相互关联的:对象封装它拥有的状态,反之也成立,即对它封装的状态拥有所有权。状态变量的所有者将决定采用何种加锁协议来维持变量状态的完整性。所有权意味着控制权。然而,如果发布了某个可变对象的引用,那么就不再拥有独占的控制权,最多是“共享控制权”。对于从构造函数或者从方法中传递进来的对象,类通常并不拥有这些对象,除非这些方法是被专门设计为转移传递进来的对象的所有权(例如,同步容器封装器的工厂方法)。

容器类通常表现出一种“所有权分离”的形式,其中容器类拥有其自身的状态,而客户代码则拥有容器中各个对象的状态。Servlet 框架中的 ServletContext 就是其中一个示例。 ServletContext 为 Servlet 提供了类似于 Map 形式的对象容器服务,在 ServletContext 中可以通过名称来注册( setAttribute)或获取 (getAttribute)应用程序对象。由 Servlet 容器实现的 ServletContext 对象必须是线程安全的,因为它肯定会被多个线程同时访问。当调用 setAttribute 和 getAttribute 时,Servlet 不需要使用同步,但当使用保存在 ServletContext 中的对象时,则可能需要使用同步。这些对象由应用程序拥有,Servlet 容器只是替应用程序保管它们。与所有共享对象一样,它们必须安全地被共享。为了防止多个线程在并发访问同一个对象时产生的相互干扰,这些对象应该要么是线程安全的对象,要么是事实不可变的对象,或者由锁来保护的对象。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值