JavaJava多线程专题

[Java多线程编程之十二] 线程安全类的设计与使用

2020-08-24  本文已影响0人  小胡_鸭

  在并发编程中,经常需要编写线程安全的类,设计线程安全类,优先考虑使用JUC包中封装的各种线程安全类。在不能满足需求自行封装的情况下,按三步走:分析共享变量、分析不变性条件、设计并发管理策略。需要对线程安全类进行扩展时,优先级为:修改源码 > 组合 > 继承。

一、设计线程安全类

在设计线程安全类的过程中,需要包含以下三个基本要素:

  • 找出构成对象状态的所有变量。
  • 找出约束状态变量的不变性条件。
  • 建立对象状态的并发管理策略。

1、收集同步需求

准则:不会改变的域应该声明为final,有利于简化对象状态的分析。

不可变条件:判断状态是有效还是无效。

  比如商品的价格必须正的,计数器的次数必须是正的,但是保存商品价格、计数器次数的变量类型如double、int的取值范围却可以是负数的,这就要求在封装了这些状态的线程安全类里去控制保持不可变条件。

后验条件:判断状态迁移是否有效。

  比如账户余额是100,转入了50块钱,同时有因为购买交易转出了30块钱,最终账户余额肯定是120,如果多笔交易并发,没有控制好后验条件,最终账户余额就是错误的。

2、依赖状态的操作

先验条件:执行操作时判断状态是否满足条件。

  有些操作需要先判断当前状态是否满足,否则必须阻塞或无法执行,比如账户余额100,要购买一个200块钱的商品,肯定要先检查账户余额是否足够,否则应该报错,如果没控制好先验条件,用少于商品价格的金额买到了商品,就会引发账务问题。

3、状态的所有权

  所有权和封装性总是相互关联的:对象封装它拥有的状态,反之也成立,即对它封装的状态拥有所有权。所有权意味着控制权,如果程序发布了某个可变对象的引用,那么就不再拥有独占的控制权,最多是“共享所有权”。

// 独占所有权
public class ExclusiveOwnership {
    public String[] status = {"start", "running", "stop", "terminal"};

    public ExclusiveOwnership() {}

    public String[] getStatus() {
        String[] ret = new String[status.length];
        for (int i = 0; i < status.length; i++)
            ret[i] = status[i];
        return ret;
    }
}

// 共享所有权
public class SharedOwnership {
    public String[] status = {"start", "running", "stop", "terminal"};

    public SharedOwnership() {}

    public String[] getStatus() {
        return status;
    }
}

  为了协调封装的多个状态的一致性,状态变量的所有者通常需要采用加锁协议来维持变量状态的完整性。

public class SafePoint {
    private int x, y;

    private SafePoint(int[] a) {
        this(a[0], a[1]);
    }

    // 拷贝构造函数
    // 如果实现为this(p.x, p.y)会产生竞态条件
    // 因为该实现中会前后分别两次去读取x、y,有可能读取到x之后坐标被改变了
    // 第二次读取到的y跟第一次不匹配
    public SafePoint(SafePoint p) {
        this(p.get());
    }

    public SafePoint(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public synchronized int[] get() {
        return new int[] { x, y };
    }

    public synchronized void set(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

  对于构造函数或者方法通过参数传递进来的对象,除非这些方法是被专门设计为转移传递进来的对象的所有权的(例如同步容器封装器的工厂方法),否则类通常并不拥有这些对象。

public class SharedOwnership {
    public String[] status;
    public SharedOwnership(String[] status) {
        this.status = status;
    }
}


二、实现线程安全类的方式

1、实例封闭

  实例封闭就是将数据(通常是非线程安全类的对象)封装在对象内部,这样可以将数据的访问限制在对象的方法上,从而更容易确保线程在访问数据时总能持有正确的锁

  这是最常见的、最快速的线程安全类的实现方式,比如Java集合中的工具类CollectionsSychronized开头的方法,都是使用了实例封闭的方式来快速生成一个线程安全的类,比如SychronizedMap(),使用了内部静态类SynchronizedMap,该类对传入的Map对象进行封装,并使用类的mutex成员作为锁,对每个方法都进行了加锁同步,代码如下所示:


  封闭机制更易于构造线程安全的类,因此当封闭类的状态时,在分析类的线程安全性时就无需检查整个程序,上面的代码的这种设计类似典型的Java监视器模式,遵循Java监视器模式的对象会把对象的所有可变状态都封装起来,并由对象自己的内置锁来保护,当然上面的代码用的不是SynchronizedMap类的内置锁,而是定义了另一个成员mutex来当锁,但是策略是类似的。

2、线程安全委托

  线程安全委托,就是将线程安全性保障委托给线程安全类实现。比如现在要实现一个线程安全的缓存类Cache,根据key来获取value,这种一对一映射关系适合用Map来实现,可以用HashMap + sychronized简单实现数据缓存和线程安全,即上面用的封闭实例的监视器模式,但是性能上存在瓶颈,如果将其委托给ConcurrentHashMap实现,就能在实现数据缓存和线程安全的同时,还能有性能上的提升,因为ConcurrentHashMap使用细粒度的分段锁来支持多线程并发操作。

  代码如下所示:

public class SafeCache {
    private final ConcurrentHashMap<String, Object> cacheMap = new ConcurrentHashMap<String, Object>();

    public SafeCache() {}

    public Object get(String key) {
        return cacheMap.get(key);
    }

    public void set(String key, Object value) {
        cacheMap.put(key, value);
    }
}

  当然,有的时候线程安全类未必能满足全部需求,比如要保护同步的状态有多个,且多个状态间存在关联关系,那么仅依靠委托并不足实现线程安全性,这时设计类的时候就要提供必须的加锁机制以保证这些复合关联操作都是原子操作。

  如果一个类是由多个独立且线程安全的状态变量组成,并且在所有的操作中都不包含无效状态转换,那么可以将线程安全性委托给底层的状态变量。

3、扩展现有线程安全类功能

  设计线程安全类,优先选择重用现有的线程安全类而不是创建新的类:重用能降低开发工作量、开发风险(因为现有的类都已经通过测试)以及维护成本。但是很多时候,线程安全类不能完全满足我们的要求,比如要做一些功能的扩展,这时就有几种策略:

3.1 继承

  为Vector扩展一个若没有则添加的功能,代码如下:

/**
 * 同步策略被分布到多个独立维护的源码中
 * 底层类的同步策略会影响封装类同步策略的正确性
 *
 */
public class BetterVector<E> extends Vector<E> {
    public synchronized boolean putifAbsent(E x) {
        boolean absent = !contains(x);
        if (absent)
            add(x);
        return absent;
    }

    // other code
}

  在很多类库中,都明确声明了类不可继承,这是一种防止类库使用人员误用写出脆弱代码的安全策略。

3.2 客户端加锁
public class BetterVector2<E> {
    private Vector<E> vector;

    public BetterVector2(Vector<E> vector) {
        this.vector = vector;
    }

    public synchronized boolean putifAbsent(E x) {
        boolean absent = !vector.contains(x);
        if (absent)
            add(x);
        return absent;
    }
    
    // other code
}

  客户端加锁存在的问题和继承类似,而且有些线程安全类是无法通过监视器锁去扩展功能的,比如ConcurrentHashMap并没有使用Java监视器锁,而是内置了分段锁,这种情况下,使用sychronized(mapObj)根本无法保证线程安全。

3.3 组合
/**
 * 通过组合的方式,统一同步策略的使用
 */
public class ImprovedList<T> implements List<T> {
    private final List<T> list;

    public ImprovedList(List<T> list) {
        this.list = list;
    }

    public synchronized boolean putIfAbsent(T x) {
        boolean contain = list.contains(x);
        if (!contain)
            list.add(x);
        return !contain;
    }

    // other code
}

使用客户端加锁和继承的缺陷:

  • 加锁机制分散到多个类文件中
  • 破坏同步策略的封装性
  • 依赖于基类的同步策略,一旦策略有变,比如从用监视器锁改成内置锁,扩展类的同步策略会失效
上一篇 下一篇

猜你喜欢

热点阅读