体系化深入学习并发编程(八)并发容器

早期的并发容器

Vector和Hashtable

Vector和Hashtable都是JDK1.0就有了的并发容器,其作用和ArrayList和HashMap的用法相差无几。
不过前二者是线程安全的,而后两者是线程不安全的。

不过这两个线程安全的容器,现在也被后来者取代了。
这是由于,这两个容器为了保证并发安全,在可能出现并发冲突的方法上,直接采用synchronized关键字来保障线程安全。
比如在Vector中:

public synchronized boolean add(E e)
public synchronized E remove(int index)
public synchronized boolean containsAll
...

Hashtable中:

public synchronized boolean contains(Object value)
public synchronized V get(Object key)
public synchronized V put(K key, V value)
...

这就导致了一个问题:性能低
在锁的文章中关于synchronized的使用,我们知道:
如果一个线程进入调用了这个同步方法,那么它就会拿到这个实例对象的monitor锁,就意味着其他线程不能对这个对象进行同步操作。
而大多数方法又是同步方法,导致大多数线程在调用这些方法时,只能干瞪眼等待monitor锁的释放。

Collections类中的同步方法

我们知道,HashMap和ArrayList这些集合并不是线程安全的,所以Collections类提供了一些方法,可以使得把这些集合包装成一个线程安全的集合。
比如这些方法

public static <T> List<T> synchronizedList(List<T> list)
public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m)
...

将一个集合对象作为参数传入,返回一个包装好了的线程安全的集合对象。

比如synchronizedList方法,使用三元表达式,判断是否是支持随机访问的List,返回一个新的对象

public static <T> List<T> synchronizedList(List<T> list) {
	return (list instanceof RandomAccess ?
		new SynchronizedRandomAccessList<>(list) :
		new SynchronizedList<>(list));
}

跳转到SynchronizedList类中的方法

public int hashCode() {
    synchronized (mutex) {return list.hashCode();}
}
public E get(int index) {
    synchronized (mutex) {return list.get(index);}
}
public E set(int index, E element) {
    synchronized (mutex) {return list.set(index, element);}
}
public void add(int index, E element) {
    synchronized (mutex) {list.add(index, element);}
}
public E remove(int index) {
    synchronized (mutex) {return list.remove(index);}
}

可以看到,这个类Vector的不同是,使用的是同步代码块的方式,锁的是一个mutex对象。

final Object mutex;     // Object on which to synchronize

相较于Vector,并没有多少性能上的提升。

更高效的并发容器

ConcurrentHashMap

在之前关于HashMap的笔记中,对1.7和1.8的HashMap进行了深入比较。
简单回顾一下:1.7及以前HashMap并发扩容会导致循环链表的死锁,造成OOM;1.7使用的是拉链法,而1.8添加了红黑树的数据结构。

现在再来比较下1.7的ConcurrentHashMap和1.8的差异

1.7的ConcurrentHashMap内部保证线程安全采用的是分段锁(Segment)的概念
每个segment的内部就有一个类似HashMap的结构,而每一个Segment独立上锁,相互之间互不影响,比如线程1操作Segment1时,线程2可以去操作Segement2。
ConcurrentHashMap默认有16个Segments,默认值可以在初始化时进行修改,但是生成之后,不能再扩容。
1.7ConcurrentHashMap
1.8的ConcurrentHashMap和1.7版本大相径庭,它抛弃了1.7的版本进行了重构,代码量从1000多行增加到了6000多行,且底层结构也不再采用多段锁,而是和1.8的HashMap相差无二。
1.8ConcurrentHashMap
现在来看看1.8中ConCurrentHashMap的put和get方法。
put方法

//实际调用的是putVal方法
public V put(K key, V value) {
	return putVal(key, value, false);
}

/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
	//不允许key和value为空
    if (key == null || value == null) throw new NullPointerException();
    //计算hash值
	int hash = spread(key.hashCode());
	int binCount = 0;
	for (Node<K,V>[] tab = table;;) {
		Node<K,V> f; int n, i, fh;
		//如果还没初始化就进行初始化
		if (tab == null || (n = tab.length) == 0)
			tab = initTable();
		//否则如果当前存入位置是空的
		else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
			//通过cas操作尝试存入数据
			if (casTabAt(tab, i, null, new Node<K,V>(hash, key, value, null)))
				//如果成功就跳出循环
				break;                   // no lock when adding to empty bin
        }
        //当前位置有值了
        //判断节点是否属于MOVED(扩容阶段),是就帮助扩容
		else if ((fh = f.hash) == MOVED)
            tab = helpTransfer(tab, f);
        else {
        	//如果当前位置有值,且不是扩容,就进行拉链法插入
            V oldVal = null;
            //使用synchronized保证并发安全
            synchronized (f) {
                if (tabAt(tab, i) == f) {
                	//如果是链表,就进行链表操作
                    if (fh >= 0) {
                        binCount = 1;
                        for (Node<K,V> e = f;; ++binCount) {
                            K ek;
                            //通过hash计算位置,如果已经存在key,就更新并得到oldValue
                            if (e.hash == hash &&
                                ((ek = e.key) == key ||
                                 (ek != null && key.equals(ek)))) {
                                oldVal = e.val;
                                if (!onlyIfAbsent)
                                    e.val = value;
                                break;
                            }
                            //没有存在新的key,就创建新的节点
                            Node<K,V> pred = e;
                            if ((e = e.next) == null) {
                            	//将新的节点插在后面
                                pred.next = new Node<K,V>(hash, key, value, null);
                                break;
                            }
                        }
                    }
                    //如果是红黑树,就进行红黑树的操作
                    else if (f instanceof TreeBin) {
                        Node<K,V> p;
                        binCount = 2;
                        //调用红黑树的存入方法
                        if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key, value)) != null) {
                        	//得到oldValue
                            oldVal = p.val;
                            if (!onlyIfAbsent)
                                p.val = value;
                        }
                    }
                }
            }
            //添加完成后
            if (binCount != 0) {
            	//判断是否需要树化
                if (binCount >= TREEIFY_THRESHOLD)
                    treeifyBin(tab, i);
                //返回oldValue
                if (oldVal != null)
                    return oldVal;
                break;
            }
        }
    }
    addCount(1L, binCount);
    return null;
}

putVal的流程大致如下:

  1. 判断key value是否为空
  2. 计算hashcode
  3. 判断是CAS插入槽还是帮忙扩容转移,或者是同步进行链表插入或者红黑树插入
  4. 判断是否满足要求进行树化
  5. 返回oldValue

可以看到ConcurrentHashMap是不允许key-value为null的,而HashMap是可以的
下面在看看get方法:

public V get(Object key) {
    Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
    //获取hashcode
    int h = spread(key.hashCode());
    //如果没有初始化或者内部没有数据,就返回null
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (e = tabAt(tab, (n - 1) & h)) != null) {
        //如果在槽点中hashcode符合
        if ((eh = e.hash) == h) {
        	//如果key也符合,就返回value
            if ((ek = e.key) == key || (ek != null && key.equals(ek)))
                return e.val;
        }
        //如果hash值为负数,判断为红黑树
        else if (eh < 0)
        	//三元表达式,通过find去查找数据
            return (p = e.find(h, key)) != null ? p.val : null;
        //既没在槽点(数组)中,也不是红黑树,就遍历链表
        while ((e = e.next) != null) {
        	//如果在链表中找到,就返回value
            if (e.hash == h &&
                ((ek = e.key) == key || (ek != null && key.equals(ek))))
                return e.val;
        }
    }
    return null;
}

getl的流程大致如下:

  1. 计算hashcode
  2. 判断table是否初始化或是否为空
  3. 判断数据是否在槽中,在槽中就直接返回
  4. 否则判断数据是否在红黑树中,通过红黑树查找操作获取
  5. 否则遍历链表获取数据

那么1.7升级到1.8在哪些方面进行了优化呢?

数据结构:Segment—>Node
将粒度降低了,1.7是分段锁,每个segment就是锁,默认支持16个线程同时进行并发操作,而如果通过修改默认来提高并发效率,会占用更多的空间。
而在1.8中,锁的粒度细化到了节点,每个Node是线程安全的,并发性增强了

hash碰撞:1.8中添加了红黑树,避免了拉链过长而导致性能的下降。

CopyOnWriteArrayList

CopyOnWriteArrayList是从JDK1.5之后引进的,也是为了增加同步的效率,用来取代Vector和SynchronizedList。和这个方法类似的还有一个CopyOnWriteArraySet

下面就用CopyOnWriteArrayList来举例

它的适用场景:重读轻写
尽力满足的性能,读操作要尽可能的快。
操作即使慢一点也无关紧要

为了达到这些效果,它的读写规则是:
读取操作完全不加锁,只用写写操作会互斥(读写不互斥)

演示一下ArrayList和CopyOnWriteArrayList的不同:

ArrayList

public static void main(String[] args) {
    ArrayList<Integer> list = new ArrayList<>();
    for (int i = 0; i <5; i++) {
        list.add(i);
    }
    Iterator<Integer> iterator = list.iterator();

    while (iterator.hasNext()){
        System.out.println(list);
        Integer next = iterator.next();
        System.out.println(next);

        if (next==1){
            list.remove(4);
        }
        if (next==2){
            list.add(5);
        }
    }
}

尝试在迭代器(读操作)中,对list进行修改(写操作),会出现并发修改异常。

[0, 1, 2, 3, 4]
0
[0, 1, 2, 3, 4]
1
[0, 1, 2, 3]
Exception in thread "main" java.util.ConcurrentModificationException

CopyOnWriteArrayList

public static void main(String[] args) {
	//换成CopyOnWriteArrayList
    CopyOnWriteArrayList<Integer> list = new CopyOnWriteArrayList<>();
    for (int i = 0; i <5; i++) {
        list.add(i);
    }
    Iterator<Integer> iterator = list.iterator();

    while (iterator.hasNext()){
        System.out.println(list);
        Integer next = iterator.next();
        System.out.println(next);

        if (next==1){
            list.remove(4);
        }
        if (next==2){
            list.add(5);
        }
    }
}

而将ArrayList换成CopyOnWriteArrayList,其他代码不改动,则没有发生并发修改异常。

[0, 1, 2, 3, 4]
0
[0, 1, 2, 3, 4]
1
[0, 1, 2, 3]
2
[0, 1, 2, 3, 5]
3
[0, 1, 2, 3, 5]
4

不过可以看到得是:最后打印的是“4”,而不是我们修改后的“5”

这就是CopyOnWrite的设计思想:
进行写操作时,将拷贝一份原来的容器,我在新的容器里进行写操作,而读操作是读得原来的容器,当我们写完后,将指向原容器的引用改为指向新容器。
这也是一种读写分离的思想。
同样,原容器具有“不可变性”,原容器是不能被修改的,也没有对象逸出,我们只能对创建的容器副本进行操作。
可以看到,我们的迭代器就是用的原容器,新的容器的写操作并不会对迭代器的读操作造成影响。
不过迭代器读取到的数据可能是过期数据。

现在来看看CopyOnWriteArrayList的源码:
用可重入锁保证了并发安全
底层数据结构是一个对象数组
getArray()获取当前数组
setArray(Object[] a)方法改变引用为新的数组

/** The lock protecting all mutators */
final transient ReentrantLock lock = new ReentrantLock();

/** The array, accessed only via getArray/setArray. */
private transient volatile Object[] array;

/**
 * Gets the array.  Non-private so as to also be accessible
 * from CopyOnWriteArraySet class.
 */
final Object[] getArray() {
    return array;
}

/**
 * Sets the array.
 */
final void setArray(Object[] a) {
    array = a;
}

add():

public boolean add(E e) {
    final ReentrantLock lock = this.lock;
    //加锁保证并发安全
    lock.lock();
    try {
    	//获取到当前数组
        Object[] elements = getArray();
        //获取当前数组长度
        int len = elements.length;
        //copy一个长度加1的新数组
        Object[] newElements = Arrays.copyOf(elements, len + 1);
        //新数组末尾插入新数据
        newElements[len] = e;
        //将引用改为新数组
        setArray(newElements);
        return true;
    } finally {
    	//解锁
        lock.unlock();
    }
}

而get()方法很简单,并没有加锁,直接读取。

private E get(Object[] a, int index) {
    return (E) a[index];
}

虽然CopyOnWriteArrayList保证了并发安全,并且性能也比vector和synchronizedList好(读多写少的情况下),但是它的缺点也很明显:

  • 数据一致性:读取操作有时不能立刻读取到最新的值。
  • 内存消耗:每次操作都需要复制一个新的数组,内存占用率过高。

BlockingQueue

阻塞队列,顾名思义:一个会被阻塞的队列
当队列满了时,添加操作会被阻塞
当队列为空时,取出操作会被阻塞
因为阻塞队列自身就是线程安全的,所以我们使用它时不需要再考虑额外的线程安全问题。
阻塞队列BlockingQueue我们之前就用到过,比如实现生产者消费者模型时。
同时,它也是线程池的重要组成部分,用于存储Task。

阻塞队列的一些重要的方法:

  • put(),take(),如果满了或为空,存放和取出会被阻塞
  • add(),remove()如果满了或为空,添加、移除的操作会抛出异常。
  • offer(),poll(),peek(),如果满了或为空,添加、弹出(取出并删除)、取出头结点的操作会返回boolean类型。

ArrayBlockingQueue

ArrayBlockingQueue是有界的,生成时必须手动添加容量大小。
此外,它可以指定是否公平:是否让等待时间最久的线程优先处理。

演示一个ATM取钱的场景:
自助取钱房间最多只能有有三个人,其中只能有一个人操作ATM,另外两个人在等待区站着。其他想取钱的人只能在房间外等候。

public class ATMServices {

    private static void handleBusiness(BlockingQueue<String> queue) throws InterruptedException {
        String take;
        while (!(take = queue.take()).equals("end")){
            TimeUnit.SECONDS.sleep(1);
            System.out.println(take+"办理完业务");
        }
        System.out.println("所有业务处理完成");
    }

    private static void waitInLine(BlockingQueue<String> queue) throws InterruptedException {
        System.out.println("现在有7个人在等待操作");
        for (int i = 0; i <7; i++) {
            queue.put("排队者"+i);
            System.out.println("排队者"+i+"进入房间等待");
        }
        System.out.println("没人排队了");
        queue.put("end");
    }
    public static void main(String[] args) {
        BlockingQueue<String> queue = new ArrayBlockingQueue<>(2);
        new Thread(()-> {
            try {
                waitInLine(queue);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
        new Thread(()-> {
            try {
                handleBusiness(queue);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();

    }
}
现在有7个人在等待操作
排队者0进入房间等待
排队者1进入房间等待
排队者2进入房间等待
排队者0办理完业务
排队者3进入房间等待
排队者1办理完业务
排队者4进入房间等待
排队者2办理完业务
排队者5进入房间等待
排队者3办理完业务
排队者6进入房间等待
没人排队了
排队者4办理完业务
排队者5办理完业务
排队者6办理完业务
所有业务处理完成

可以看到,当队列满了时,put会被阻塞,等待队首出队后才能继续添加。
它的put方法:

public void put(E e) throws InterruptedException {
	//判断是否为空
    checkNotNull(e);
    final ReentrantLock lock = this.lock;
    //可中断
    lock.lockInterruptibly();
    try {
    	//如果满了就等待
        while (count == items.length)
            notFull.await();
        //否则入队
        enqueue(e);
    } finally {
    	//解锁
        lock.unlock();
    }
}

take方法也是差不多,当队列为空时,就阻塞等待。

public E take() throws InterruptedException {
    final ReentrantLock lock = this.lock;
    lock.lockInterruptibly();
    try {
        while (count == 0)
            notEmpty.await();
        return dequeue();
    } finally {
        lock.unlock();
    }
}

LinkedBlockingQueue

LinkedBlockingQueue内部是采用链表形式的阻塞队列

static class Node<E> {
    E item;

    /**
     * One of:
     * - the real successor Node
     * - this Node, meaning the successor is head.next
     * - null, meaning there is no successor (this is the last node)
     */
    Node<E> next;

    Node(E x) { item = x; }
}

如果没有设置边界值,那么它的边界值就是整型的最大数(231-1),可以视作无界了

/** The capacity bound, or Integer.MAX_VALUE if none */
private final int capacity;

它的内部采用了两把锁:takeLock和putLock

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

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

put方法:
使用的是put锁
如果不传入capacity,就几乎不会调用await()方法,所以每次都会调用signal()方法

public void put(E e) throws InterruptedException {
    if (e == null) throw new NullPointerException();
    // Note: convention in all put/take/etc is to preset local var
    // holding count negative to indicate failure unless set.
    int c = -1;
    Node<E> node = new Node<E>(e);
    final ReentrantLock putLock = this.putLock;
    final AtomicInteger count = this.count;
    putLock.lockInterruptibly();
    try {
        /*
         * Note that count is used in wait guard even though it is
         * not protected by lock. This works because count can
         * only decrease at this point (all other puts are shut
         * out by lock), and we (or some other waiting put) are
         * signalled if it ever changes from capacity. Similarly
         * for all other uses of count in other wait guards.
         */
        while (count.get() == capacity) {
            notFull.await();
        }
        enqueue(node);
        c = count.getAndIncrement();
        if (c + 1 < capacity)
            notFull.signal();
    } finally {
        putLock.unlock();
    }
    if (c == 0)
        signalNotEmpty();
}

take方法:
使用的是take锁

public E take() throws InterruptedException {
    E x;
    int c = -1;
    final AtomicInteger count = this.count;
    final ReentrantLock takeLock = this.takeLock;
    takeLock.lockInterruptibly();
    try {
    	//队列为空就等待
        while (count.get() == 0) {
            notEmpty.await();
        }
        //出队一个
        x = dequeue();
        //原子性自减1
        c = count.getAndDecrement();
        //如果还有就唤醒一个
        if (c > 1)
            notEmpty.signal();
    } finally {
        takeLock.unlock();
    }
    if (c == capacity)
        signalNotFull();
    return x;
}

PriorityBlockingQueue

优先级队列PriorityBlockingQueue,并不是按照先进先出的原则,而是按照优先级顺序出队。
其底层数据结构为对象数组,默认初始容量为11,最大容量为整型最大值-8,因为在对象头中需要保存数组的容量大小,如果过大会导致OOM。
由于优先级队列会进行扩容,所以它也是一个无界队列。
通过比较来确认队列中的优先级顺序,如果不传入comparator,就按照自然顺序。
也是只用了一把锁。

private static final int DEFAULT_INITIAL_CAPACITY = 11;

/**
 * The maximum size of array to allocate.
 * Some VMs reserve some header words in an array.
 * Attempts to allocate larger arrays may result in
 * OutOfMemoryError: Requested array size exceeds VM limit
 */
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;

/**
 * Priority queue represented as a balanced binary heap: the two
 * children of queue[n] are queue[2*n+1] and queue[2*(n+1)].  The
 * priority queue is ordered by comparator, or by the elements'
 * natural ordering, if comparator is null: For each node n in the
 * heap and each descendant d of n, n <= d.  The element with the
 * lowest value is in queue[0], assuming the queue is nonempty.
 */
private transient Object[] queue;
/**
* The comparator, or null if priority queue uses elements'
* natural ordering.
*/
private transient Comparator<? super E> comparator;
/**
* Lock used for all public operations
*/
private final ReentrantLock lock;

SynchronousQueue

这个我们在线程池时就发现了,它是不存储数据的。
它的容量为0,只起到传递作用,所以效率很高。
所以peek方法也是返回null

public E peek() {
    return null;
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值