浅析几种线程安全模型

来源:saymagic
blog.saymagic.cn/2016/08/30/java-thread-safe-model-analyze.html#post__title

多线程编程一直是老生常谈的问题,在Java中,随着JDK的逐渐发展,JDK提供给我们的并发模型也越来越多,本文摘取三例使用不同原理的模型,分析其大致原理。


COW之CopyOnWriteArrayList

cow是copy-on-write的简写,这种模型来自于Linux系统的fork命令。Java中利用cow模型来实现的并发类是CopyOnWriteArrayList。相对于Vector,它的读操作是无需加锁的:

public E get(int index){
	return (E) elements[index];
}

之所以有如此神奇的功效,其采用的是用空间换时间的方法,查看其add方法

public  boolean add(E e){
	final ReentrantLock lock = this.lock;
        lock.lock();
        try {
            Object[] elements = getArray();
            int len = elements.length;
            Object[] newElements = Arrays.copyOf(elements, len + 1);
            newElements[len] = e;
            setArray(newElements);
            return true;
        } finally {
            lock.unlock();
        }

需注意CopyOnWriteArrayList的add方法是需要加锁的,但其内部并没有对elements数组直接操作,而是先copy一份当前的数据到一个新的数组中,然后对新的数组进行赋值操作。这样做的就能让get操作从同步中解脱出来。因为更改的数据并没有发生在get所操作的数据对象引用中,而是放在新生成的副本中,这就带来了脏读的问题。
缺点:

  1. 占用内存,每次执行写操作都要将原容器copy一份,数据量大时,对内存压力较大,可能频繁GC
  2. 无法数据保证一致性,Vector对于读写操作都做了同步,保障了读和写的一致性,但是CopyOnWriteArrayList,读和写分别作用在新老不同容器上,在读的过程中不会阻塞,但是读到的可能是老容器的数据

CopyOnWriteArayList的另一个特点是允许多线程遍历,且其他线程更改数据不会导致遍历线程抛出CurrentModificationException异常,来看下iterator(),

public Iterator<E> iterator(){
	return new COWIterator(getArray, 0);
}

/**
 *获得当前类的元素存储数组
 **/
final Object[] getArray() {
        return array;
}

我们发现CopyOnWriteArayList在得到构造器时,返回了一个Itrerator的子类COWIterator,来看一下这个类的构造器

static final class COWIterator<E> implements ListIterator<E> {
        /** 储存数组快照 */
        private final Object[] snapshot;
        /** 调用next方法时,返回对象的数组下标  */
        private int cursor;
 
        private COWIterator(Object[] elements, int initialCursor) {
            cursor = initialCursor;
            snapshot = elements;
        }

这个snapshot是保证多线程遍历时,其他线程更改数据不会导致遍历线程抛出CurrentModificationException异常的关键。
为什么这么说?先来看一些CopyOnWriteArayList中的COWIterator的next()方法:

@SuppressWarnings("unchecked")
 public E next() {
     if (! hasNext())
         throw new NoSuchElementException();
     return (E) snapshot[cursor++];
 }
 
 public boolean hasNext() {
     return cursor < snapshot.length;
 }

再来看ArrayList中的next()方法:

public E next() {
    checkForComodification();
    int i = cursor;
    if (i >= size)
        throw new NoSuchElementException();
    Object[] elementData = ArrayList.this.elementData;
    if (i >= elementData.length)
        throw new ConcurrentModificationException();
    cursor = i + 1;
    return (E) elementData[lastRet = i];
}

发现它和ArrayList中Itr的next()方法的区别了吗?COWIterator的next()不做checkForComodification()

为什么不做检查?
CopyOnWriteArrayList遍历的时候,使用的对象是储存数组快照,而不是储存数组,当储存数组发生数据增删改,都会生成新数组,而不会改变snapshot,那就没必要检查并发更新。
正是由于这个设计,COWIterator并不支持对数据的迭代更改操作:

static final class COWIterator<E> implements ListIterator<E> {
public void add(E object) {
     throw new UnsupportedOperationException();
}

public void remove() {
    throw new UnsupportedOperationException();
}

public void set(E object) {
    throw new UnsupportedOperationException();
}
...

list中有数据更新都会生成新数组,而不会改变snapshot,此时Iterator没办法再将更改的数据写回list了,故这几个操作都是不支持的。
同理,list数据有更新也不会反映在CowIterator中,CowIterator只是保证其迭代过程不会发生异常。


CAS之ConcurrentHashMap

CAS即Compare And Swap,意思是“比较与替换”,CAS操作将一组比较和替换操作封装成原子操作,不会被外部打断。这种原子操作往往由处理器层面提供。

在Java中有一个非常神奇的Unsafe类来对CAS提供语言层面的接口。但类如其名,此等神器如果使用不当,会造成武功尽失的,所以Unsafe不对外开放,想使用的话需要通过反射等技巧。这里不对其做展开。介绍它的原因是因为它是JDK1.8中ConcurrentHashMap的实现基础。

ConcurrentHashMap与HashMap对数据的存储有着相似的地方,都采用数组+链表+红黑树的方式。基本逻辑是内部使用Node来保存map中的一项 key,value 结构,对于hash不冲突的key,使用数组来保存Node数据,而每一项Node都是一个链表,用来保存hash冲突的Node,当链表的大小达到一定程度会转为红黑树,这样会使在冲突数据较多时也会有比较好的查询效率。

了解了ConcurrentHashMap的存储结构后,我们来看下在这种结构下,ConcurrentHashMap是如何实现高效的并发操作,这得益于ConcurrentHashMap中的如下三个函数。

/***
 **返回tab数组第i项
 **/
static final <K,V> Node<K,V> tabAt(Node<K,V>[] tab, int i) {
    return (Node<K,V>)U.getObjectVolatile(tab, ((long)i << ASHIFT) + ABASE);
}

/***
 **对比tab第i项是否与c相等,相等的话将其设置为v。
 **/
static final <K,V> boolean casTabAt(Node<K,V>[] tab, int i,
                                    Node<K,V> c, Node<K,V> v) {
    return U.compareAndSwapObject(tab, ((long)i << ASHIFT) + ABASE, c, v);
}

/***
 **将tab的第i项设置为v
 **/
static final <K,V> void setTabAt(Node<K,V>[] tab, int i, Node<K,V> v) {
    U.putOrderedObject(tab, ((long)i << ASHIFT) + ABASE, v);

}

其中的U就是我们前文提到的Unsafe的一个实例,这三个函数都通过Unsafe的几个方法保证了是原子性。

有了这三个函数就可以保证ConcurrentHashMap的线程安全吗?并不是的,ConcurrentHashMap内部也使用比较多的synchronized,不过与HashTable这种对所有操作都使用synchronized不同,ConcurrentHashMap只在特定的情况下使用synchronized,来较少锁的定的区域。来看下putVal方法(精简版):

final V putVal(K key, V value, boolean onlyIfAbsent) {
	//判断key与value是否为空,为空抛异常
    if (key == null || value == null) throw new NullPointerException();
    
    //计算kek的hash值,然后进入死循环,一般来讲,caw算法与死循环是搭档。
    int hash = spread(key.hashCode());
    
    int binCount = 0;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh;
        //判断table是否初始化,未初始化进行初始化操作
        if (tab == null || (n = tab.length) == 0)
            tab = initTable();
            
        //Node在table中的目标位置是否为空,为空的话使用caw操作进行赋值,当然,这种赋值是有可能失败的,所以前面的死循环发挥了重试的作用   
        else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
            if (casTabAt(tab, i, null,
                         new Node<K,V>(hash, key, value, null)))
                    break;                   // no lock when adding to embin
        }
		//如果当前正在扩容,则尝试协助其扩容,死循环再次发挥了重试的作用,有趣的是ConcurrentHashMap是可以多线程同时扩容的。
		//这里说协助的原因在于,对于数组扩容,一般分为两步:1.新建一个更大的数组;2.将原数组数据copy到新数组中。
		//对于第一步,ConcurrentHashMap通过CAW来控制一个int变量保证新建数组这一步只会执行一次。
		//对于第二步,ConcurrentHashMap采用CAW + synchronized + 移动后标记 的方式来达到多线程扩容的目的。感兴趣可以查看transfer函数
        else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);

		//最后的一个else分支,黑科技的流程已尝试无效,目标Node已经存在值,只能锁住当前Node来进行put操作,当然,这里省略了很多代码,包括链表转红黑树的操作等等
        else {
            V oldVal = null;
            synchronized (f) {
                    ....
            }
        }
    }
    addCount(1L, binCount);
    return null;

}

相比于put,get的代码更好理解一下:

public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
	//获取key的hash h
    int h = spread(key.hashCode());
    
	//检查表是否为空
    if ((tab = table) != null && (n = tab.length) > 0 &&
		//获取key在table中对应的Node e
        (e = tabAt(tab, (n - 1) & h)) != null) {
        
		//判断Node e的第一项是否与预期的Node相等,相等话, 则返回e.val
        if ((eh = e.hash) == h) {
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }

		//如果e.hash < 0, 说明e为红黑树,调用e的find接口来进行查找
        else if (eh < 0)
            return (p = e.find(h, key)) != null ? p.val : null;

		//走到这一步,e为链表无疑,且第一项不是需要查询的数据,一直调用next来进行查找即可
        while ((e = e.next) != null) {
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}

读写分离之LinkedBlockingQueue

还有一种实现线程安全的方式是通过将读写进行分离,这种方式的一种实现是LinkedBlockingQueue。LinkedBlockingQueue整体设计的也十分精巧,它的全局变量分为三类

  • final 型
  • Atomic 型
  • 普通变量

final型变量由于声明后就不会被修改,所以自然线程安全,Atomic型内部采用了cas模型来保证线程安全。对于普通型变量,LinkedBlockingQueue中只包含head与last两个表示队列的头与尾。并且私有,外部无法更改,所以,LinkedBlockingQueue只需要保证head与last的安全即可保证正个队列的线程安全。
并且LinkedBlockingQueue属于FIFO型队列,一般情况下,读写会在不同元素上工作,所以, LinkedBlockingQueue定义了两个可重入锁,巧妙的通过对head与last分别加锁,实现读写分离,来实现良好的安全并发特性。

/** Lock held by take, poll, etc */
private final ReentrantLock takeLock = new ReentrantLock();

/** Wait queue for waiting takes */
private final Condition notEmpty = takeLock.newCondition();

/** Lock held by put, offer, etc */
private final ReentrantLock putLock = new ReentrantLock();

/** Wait queue for waiting puts */
private final Condition notFull = putLock.newCondition();

首先看下它的offer 方法:

public boolean offer(E e) {
    if (e == null) throw new NullPointerException();
    final AtomicInteger count = this.count;
    if (count.get() == capacity)
        return false;
    int c = -1;
    Node<E> node = new Node<E>(e);
    final ReentrantLock putLock = this.putLock;
    putLock.lock();
    try {
        if (count.get() < capacity) {
            enqueue(node);
            c = count.getAndIncrement();
            if (c + 1 < capacity)
                notFull.signal();
        }
    } finally {
        putLock.unlock();
    }
    if (c == 0)
        signalNotEmpty();
    return c >= 0;
}

可见,在对队列进行添加元素时,只需要对putLock进行加锁即可,保证同一时刻只有一个线程可以对last进行插入。同样的,在从队列进行提取元素时,也只需要获取takeLock锁来对head操作即可:

public E poll() {
    final AtomicInteger count = this.count;
    if (count.get() == 0)
        return null;
    E x = null;
    int c = -1;
    final ReentrantLock takeLock = this.takeLock;
    takeLock.lock();
    try {
        if (count.get() > 0) {
            x = dequeue();
            c = count.getAndDecrement();
            if (c > 1)
                notEmpty.signal();
        }
    } finally {
        takeLock.unlock();
    }

    if (c == capacity)
        signalNotFull();
    return x;
}

LinkedBlockingQueue整体还是比较好理解的,但有几个点需要特殊注意:

  • LinkedBlockingQueue是一个阻塞队列,当队列无元素为空时,所有取元素的线程会通过notEmpty 的await()方法进行等待,直到再次有数据enqueue时,notEmpty发出signal信号。对于队列达到上限时也是同理。

  • 对于remove,contains,toArray, toString, clear之类方法,会调用fullyLock方法,来同时获取读写锁。但对于size方法,由于队列内部维护了AtomicInteger类型的count变量,是不需要加锁进行获取的。

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

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值