并发编程学习笔记之组合对象(三)

换了个markdown的编辑器,感觉挺方便的,但是手机端的格式显示不正确,如果读者是手机端用户,点击右上角作者主页查看,就可以了

前文回顾

通过博主之前发布的两篇博客从零开始学多线程之线程安全(一)从零开始学多线程之共享对象(二)讲解的知识点,我们现在已经可以构建线程安全的类了,本篇将为您介绍构建类的模式,这些模式让类更容易成为线程安全的,并且不会让程序意外破坏这些类的线程安全性.

本篇博客将要讲解的知识点

  1. 构建线程安全类要关注那些因素.
  2. 使用实例限制+锁的模式,使非线程安全的对象,可以被并发的访问。
  3. 扩展一个线程安全的类的四种方式

构建线程安全的类

我们已经知道多线程操纵的类必须是线程安全的,否则会引发种种问题,那么如何设计线程安全的类呢?我们可以从以下三个方面考虑:

  1. 确定对象状态是由哪些变量构成的;
  2. 确定限制状态变量的不变约束;
  3. 制定一个管理并发访问对象状态的策略

当我们想要创建一个线程安全的类的时候,首先要关注的就是这个类的成员变量是否会被发布,如果被发布,那么就要根据对象的可变性(可变对象、不可变对象、高效不可变对象)去决定如何发布这个对象(如果不明白安全发布的概念,请移驾从零开始学多线程之共享对象(二))

然后再看状态是否依靠外部的引用实例化:如果一个对象的域引用了其他对象,那么它的状态也同时包含了被引用对象的域.

public class Domain {
  private Object obj;

  public Domain(Object obj) {
      this.obj = obj;
  }
}

这时候就要保证传入的obj对象的线程安全性.否则obj对象在外部被改变,除修改线程以外的线程,不一定能感知到对象已经被改变,就会出现过期数据的问题.

我们应该尽量使用final修饰的域,这样可以简化我们对对象的可能状态进行分析(起码保证只能指向一块内存地址空间).

然后我们再看类的状态变量是否涉及不变约束,并要保护类的不变约束

public class Minitor {
    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;
    }
}

我们通过封装使状态value没有被发布出去,这样就杜绝了客户端代码将状态置于非法的状况,保护了不变约束if(value == Long.MAX_VALUE).

维护类的线程安全性意味着要确保在并发访问的情况下,保护它的不变约束;这需要对其状态进行判断.

increment()方法,是让value++进行一次自增操作,如果value的当前值是17,那么下一个合法值是18,如果下一状态源于当前状态,那么操作必须是原子操作.

这里涉及到线程安全的可见性与原子性问题,如果您对此有疑问请移驾从零开始学多线程之线程安全(一)

实例限制

一个非线程安全的对象,通过实例限制+锁,可以让我们安全的访问它.

实例限制:把非线程安全的对象包装到自定义的对象中,通过自定义的对象去访问非线程安全的对象.

public class ProxySet {
    private Set<String> set = new HashSet<>();

    public synchronized void add(String value){
        set.add(value);
    }

    public synchronized  boolean contains(String value){
        return set.contains(value);
    }
}

HashSet是非线程安全的,我们把它包装进自定义的ProxySet类,只能通过ProxySet加锁的方法操作集合,这样HashSet又是线程安全的了.

如果我们把访问修饰符改为public的,那么这个集合还是线程安全的吗?

public Set<String> set = new HashSet<>();

这时候其它线程就可以获取到这个set集合调用add(),那么Proxyset的锁就无法起到作用了.所以他又是非线程安全的了.所以我们一定不能让实例限制的对象逸出.

将数据封装在对象内部,把对数据的访问限制在对象的方法上,更易确保线程在访问数据时总能获得正确的锁

实例限制使用的是监视器模式,监视器模式的对象封装了所有的可变状态,并由自己的内部锁保护.(完成多线程的博客后,博主就会更新关于设计模式的博客).

扩展一个线程安全的类

我们使用Java类库提供的方法可以解决我们的大部分问题,但是有时候我们也需要扩展java提供的类没有的方法.

现在假设我们要给同步的list集合,扩展一个缺少即加入的方法(必须保证这个方法是线程安全的,否则可能某一时刻会出现加入两个一样的值).

我们有四种方法可以实现这个功能:

  1. 修改原始的类
  2. 扩展这个类(继承)
  3. 扩展功能而,不是扩展类本身(客户端加锁,在调用这个对象的地方,使用对象的锁确保线程安全)
  4. 组合

我们一个一个来分析以上方法的利弊.

1.修改原始的类:

优点: 最安全的方法,所有实现类同步策略的代码仍然包含在要给源代码文件中,因此便于理解与维护.

缺点:可能无法访问源代码或没有修改的自由.

2.扩展这个类:

优点:方法相当简单直观.

缺点:并非所有类都给子类暴露了足够多的状态,以支持这种方案,还有就是同步策略的
实现会被分布到多个独立维护的源代码文件中,所以扩展一个类比直接在类中加入代码更脆弱.如果底层的类选择了
不同的锁保护它的状态变量,从而会改变它的同步策略,子类就在不知不觉中被破坏,
因为他不能再用正确的锁控制对基类状态的并发访问.

3.扩展功能而,不是扩展类本身:

public class Lock {
    public List<String> list = Collections.synchronizedList(new ArrayList<String>());

    public  synchronized boolean putIfAbsent(String value){
        boolean absent = !list.contains(value);
        if(!absent){
            list.add(value);
        }
        return absent;
    }
}

这个方法是错的.使用synchronized关键字虽然同步了缺少即加入方法, 而且使用list也是线程安全的,但是他们用的不是同一个锁,list由于pulic修饰符,任意的线程都可以调用它.那么在某一时刻,满足if(!absent)不变约束的同时准备add()这个对象的时候,已经有另一个线程通过lock.list.add()过这个对象了,所以还是会出现add()两个相同对象的情况.

正确的代码,要确保他们使用的是同一个锁:

public class Lock {
    public List<String> list = Collections.synchronizedList(new ArrayList<String>());

    public   boolean putIfAbsent(String value){
        synchronized(list){
        boolean absent = !list.contains(value);
        if(!absent){
            list.add(value);
        }
            return absent;
        }
    }
}

现在都使用的是list对象的锁,所以也就不会出现之前的情况了.

这种方式叫客户端加锁.

优点: 比较简单.

缺点:
如果说为了添加另一个原子操作而去扩展一个类容易出问题,是因为它将加锁的代码分布到对象继承体系中的多个类中.然而客户端加锁其实是更加脆弱的,因为他必须将类C中的加锁代码(locking code)置入与C完全无关的类中.在那些不关注锁策略的类中使用客户端加锁时,一定要小心

客户端加锁与扩展类有很多共同之处--所得类的行为与基类的实现之间都存在耦合.正如扩展会破坏封装性一样,客户端加锁会破坏同步策略的封装性.

  1. 组合对象:
public class ImprovedList<T> implements List<T> {
    private final List<T> list;

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

    public synchronized boolean putIfAbsent(Object obj){
        boolean absent = list.contains(obj);
        if(absent){
            list.add((T) obj);
        }
        return absent;
    }
}

通过ImprovedList对象来操作传进来的list对象,用的都是Improved的锁.即使传进来的list不是线程安全的,ImprovedList也能保证线程安全.

优点:相比之前的方法,这种方式提供了更健壮的代码.

缺点:额外的同步带来一些微弱的性能损失.

总结

本篇博客我们讲解了,要设计线程安全的类要从三个方面考虑:

  1. 确定对象状态是由哪些变量构成的;
  2. 确定限制状态变量的不变约束;
  3. 制定一个管理并发访问对象状态的策略

对于非线程安全的对象,我们可以考虑使用锁+实例限制(Java监视器模式)的方式,安全的访问它们.

我们还学会了如何扩展一个线程安全的的类:扩展有四法,组合是最佳.

下一篇博客,我会为介绍几种常用的线程安全容器同步工具.来构建线程安全的类.

好了本篇博客就分享到这里,我们下篇再见.

posted @ 2018-10-12 09:35  lbr617  阅读(590)  评论(0编辑  收藏  举报