并发容器
早期的并发容器
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.8的ConcurrentHashMap和1.7版本大相径庭,它抛弃了1.7的版本进行了重构,代码量从1000多行增加到了6000多行,且底层结构也不再采用多段锁,而是和1.8的HashMap相差无二。
现在来看看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的流程大致如下:
- 判断key value是否为空
- 计算hashcode
- 判断是CAS插入槽还是帮忙扩容转移,或者是同步进行链表插入或者红黑树插入
- 判断是否满足要求进行树化
- 返回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的流程大致如下:
- 计算hashcode
- 判断table是否初始化或是否为空
- 判断数据是否在槽中,在槽中就直接返回
- 否则判断数据是否在红黑树中,通过红黑树查找操作获取
- 否则遍历链表获取数据
那么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;
}