一、概述
线程安全集合类可以分为三大类 :
① 遗留的安全集合如Hashtable,Vector。
② 使用Collections装饰的线程安全类如Collections.synchronizedList、Collections.synchronizedMap等。
③ JUC包下的线程安全集合如Blocking类、CopyOnWrite类、Concurrent类等。
①是直接在方法上加上了synchronized锁,②是方法内部加上了synchronized锁。这里主要介绍的是JUC包下的线程安全集合类
二、HashMap问题
jdk1.7之前HashMap采用数组+单向链表,jdk1.8增加了红黑树结构。
1. 静态常量
/**
* 默认初始大小,值为16,要求必须为2的幂
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
/**
* 最大容量,必须不大于2^30
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**
* 默认加载因子,值为0.75
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* hash冲突默认采用单链表存储,当单链表节点个数大于等于8时,会转化为红黑树存储
*/
static final int TREEIFY_THRESHOLD = 8;
/**
* hash冲突默认采用单链表存储,当单链表节点个数大于8时,会转化为红黑树存储。
* 当红黑树中节点少于6时,则转化为单链表存储
*/
static final int UNTREEIFY_THRESHOLD = 6;
/**
* hash冲突默认采用单链表存储,当单链表节点个数大于8时,会转化为红黑树存储。
* 但是有一个前提:要求数组长度大于64,否则不会进行转化
*/
static final int MIN_TREEIFY_CAPACITY = 64;
2. 构造方法
通过上面的静态常量可以知道,无参构造时初始大小为16,默认负载因子是0.75。
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity)//通过后面扩容的方法知道,该值就是初始创建数组时的长度
}
//返回initialCapacity最小二次幂。例如initialCapacity为7,该函数返回8.
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
问题:
① 为什么数组长度要是2的n次方?
应为HashMap要将数据放入底层数组中,使用的是hash &(length - 1)。这种位运算的效率高于普通的取余运算,但是这种位运算必须是length 必须是2的幂次。
并且如果length 不为2的幂次,那么length -1的最后一位一定为0,所以HashMap上的数组元素就分布不均匀,有些位置永远也利用不到。
3. put源码
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
static final int hash(Object key) {
int h;
// 将key的hash值的高16位和低16位异或,增加随机性
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 底层数组为懒加载,第一次操作map的时候才创建
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 计算key在数组中的索引位置
// 如果当前索引位置无元素,则创建Node对象,存入数组该位置。
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// 进到这里说明索引位置有其他元素
Node<K,V> e; K k;
// 判断hash和key是否相等,只要有一个不相同就进入下面的else判断
// 这里的key可以是引用地址相等,也可以是equals()方法相等
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果hash值或者key不同,且索引位置元素为红黑树结果,则插入到红黑树中
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 否则插入到链表中
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
// 加入到链表末尾,jdk1.8尾插法,jdk1.9头插法
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // 达到树化阀值则将该链表树化
treeifyBin(tab, hash);
break;
}
// 比较链表中每个node,看是否有key和hash相同的,有就break
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 通过上面的判断,如果e为空,那么说明链表中有node的hash和key和当前需要插入的相同
if (e != null) {
// 进行替换
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
4. 并发死链
并发死链在jdk1.7中存在,原因是hashmap多线程同时扩容并且底层链表采用头插法导致的。
① 现在假设hashmap底层数组下标i的位置存在一个链表:
table[i] -> a -> b -> c -> null
② 当数组内元素个数达到阈值时,两个线程都进入到扩容操作。
③ 扩容会遍历原数组table的每一个元素,thread1第一次循环:
e = a -> b -> c -> null
next = b -> c -> null
运行到这里thread1停下,thread2完成了扩容,扩容完后新数组链表为:
newtable[i] -> c -> b- > a-> null
thread1中的引用链会被改变为:
e = a -> null
next = b -> a -> null
④ thread1继续运行,在第一遍循环中会将e加入到新数组中,注意这里的新数组是局部变量和thread2中的newtable不是一个。
newtable[i] -> a-> null
⑤ 第二次循环
e = b -> a -> null
next = a -> null
然后将e加入新数组:
newtable[i] -> b -> a-> null
⑥ 第三次循环
e = a -> null
next = null
然后将e加入新数组:
newtable[i] -> a -> b -> a-> null
此时下一次循环中e为null,退出循环,但是死链已成。
5. 数据丢失
同死链一样,假如一个线程A正处于扩容循环:
e = a -> b -> c -> null
next = b -> c -> null
这是另一个线程B已经扩容好了,同时b应为扩容,求得的下标位置已经不在当前下标了,扩容后的新table为:
newtable[i] -> c -> a-> null
newtable[j] -> b -> null
此时线程A继续执行,线程A的新table为:
newtable[i] -> a-> null
然后进入下一次循环:
e = b -> null
next = null
此时next为null,这次循环完就结束了,此时线程A的新table为:
newtable[i] -> b -> a -> null
节点c就丢失了,数据丢失不仅JDK7,JDK8也会发生
三、ConcurrentHashMap
1. 重要属性和内部类
// 默认值时0
// 当初始化时,为-1
// 当扩容时,为-(1 + 线程数)
// 当初始化或扩容完成后,为下一次的扩容的阀值大小
private transient volatile int sizeCtl;
static class Node<K,V> implements Map.Entry<K,V> {}
// 底层数组
transient volatile Node<K,V>[] table;
// 扩容时新数组
private transient volatile Node<K,V>[] nextTable;
// 扩容时如果某个bin迁移完毕,用ForwardingNode作为旧table bin 的头结点
static final class ForwardingNode<K,V> extends Node<K,V> {}
// 用在compute以及computeIfAbsent时,用来占位,计算完成后替换为普通的Node
static final class ReservationNode<K,V> extends Node<K,V> {}
//作为treebin的头结点,存储root和first
static final class TreeBin<K,V> extends Node<K,V> {}
//作为treebin的结点,存储parent,left,right
static final class TreeNode<K,V> extends Node<K,V> {}
2. 构造方法
public ConcurrentHashMap(int initialCapacity,
float loadFactor, int concurrencyLevel) {
if (!(loadFactor > 0.0f) || initialCapacity < 0 || concurrencyLevel <= 0)
throw new IllegalArgumentException();
// 初始容量必须大于等于并发度
if (initialCapacity < concurrencyLevel)
initialCapacity = concurrencyLevel;
long size = (long)(1.0 + (long)initialCapacity / loadFactor);
// 转化为2的n次方
int cap = (size >= (long)MAXIMUM_CAPACITY) ?
MAXIMUM_CAPACITY : tableSizeFor((int)size);
// 初始化数组时使用 sizeCtl 作为数组初始化大小
this.sizeCtl = cap;
}
3. get方法
public V get(Object key) {
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
// 计算key的hashcode,spread()方法确保返回结果是正数
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// 查看头结点是否是要查找的key
if ((eh = e.hash) == h) {
if ((ek = e.key) == key || (ek != null && key.equals(ek)))
return e.val;
}
// 头结点的hashcode为负数表示该bin在扩容中或是treebin,此时用find方法来查找
else if (eh < 0)
return (p = e.find(h, key)) != null ? p.val : null;
// 正常遍历链表,用equals比较
while ((e = e.next) != null) {
if (e.hash == h &&
((ek = e.key) == key || (ek != null && key.equals(ek))))
return e.val;
}
}
return null;
}
4. put方法
/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
// key和value不允许有空值
if (key == null || value == null) throw new NullPointerException();
// 计算所存的key的hash
int hash = spread(key.hashCode());
// 链表长度
int binCount = 0;
for (Node<K,V>[] tab = table;;) {
// f是链表头结点,fh是链表头结点的hash,i是链表在table数组的位置
Node<K,V> f; int n, i, fh;
// 如果table未初始化就进行初始化
if (tab == null || (n = tab.length) == 0)
// 使用cas
tab = initTable();
// 所存的下标位置链表是否有头结点
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 empty bin
}
// 如果头结点的hash为-1,说明当前table正在扩容
else if ((fh = f.hash) == MOVED)
// 帮忙扩容
tab = helpTransfer(tab, f);
else {
V oldVal = null;
// 锁住当前链表
synchronized (f) {
if (tabAt(tab, i) == f) {
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
// 找到相同的key
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
// 更新
if (!onlyIfAbsent)
e.val = value;
break;
}
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) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
// 如果链表长度 >= 树化阈值,则将链表转为红黑树
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
5. 特点
内部使用cas优化,即保证线程并发的安全性,又保证吞吐量。
遍历时的弱一致性,即当利用迭代器遍历时,如果容器发生修改,迭代器仍然可以继续遍历,但是内容是旧的。普通的hashmap则直接抛出异常
四、LinkedBlockingQueue
1. 主要源码
public class LinkedBlockingQueue<E> extends AbstractQueue<E>
implements BlockingQueue<E>, java.io.Serializable {
static class Node<E> {
E item;
/**
* 有三种情况
* - 真正的后继节点
* - 自己,发生在出队时
* - null, 表示没有后继节点
*/
Node<E> next;
Node(E x) { item = x; }
}
private final int capacity;
private final AtomicInteger count = new AtomicInteger();
transient Node<E> head;
private transient Node<E> last;
// 用于take阻塞
private final ReentrantLock takeLock = new ReentrantLock();
private final Condition notEmpty = takeLock.newCondition();
// 用于put阻塞
private final ReentrantLock putLock = new ReentrantLock();
private final Condition notFull = putLock.newCondition();
}
2. 基本流程
① 初始化链表last = head = new Node<E>(null);Dummy节点为头结点用来占位
② 当一个节点入队时,last = last.next = node;
③ 出队
// 将头结点赋值给h
Node<E> h = head;
// 将头结点下一个节点赋值给first
Node<E> first = h.next;
// 让头结点自己指向自己
h.next = h; // help GC
// 将下一个节点赋为头结点
head = first;
// 取出头结点中的值
E x = first.item;
// 将头结点的值设置为null
first.item = null;
// 返回头结点的值
// 其实就是取出头结点下一个节点的值,并将头结点下一个节点变成头结点
return x;
3. 加锁分析
用了两把锁
- 如果用一把锁,同一时刻最多只允许有一个线程(生产者或消费者二选一)执行
- 如果用两把锁,同一时刻,可以允许两个线程(一个生产者与一个消费者)同时执行
当节点总数大于2时,putLock保证last节点的线程安全,takelock保证的是head节点的线程安全,两把锁保证入队和出队没有竞争。
当节点总数等于2时,也没有竞争。
当节点总数等于1时,这时候入队出队有竞争,会产生阻塞。
public void put(E e) throws InterruptedException {
if (e == null) throw new NullPointerException();
int c = -1;
Node<E> node = new Node<E>(e);
final ReentrantLock putLock = this.putLock;
// 计数
final AtomicInteger count = this.count;
putLock.lockInterruptibly();
try {
// 如果队列中元素满了,则进入队列中等待
while (count.get() == capacity) {
notFull.await();
}
// 有空位,则入队加一
enqueue(node);
c = count.getAndIncrement();
// 如果还有空位,则唤醒一个其他等待的生产者
if (c + 1 < capacity)
notFull.signal();
} finally {
putLock.unlock();
}
// 队列中有一个元素,就叫醒take线程
if (c == 0)
signalNotEmpty();
}
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();
c = count.getAndDecrement();
// 唤醒其他消费者
if (c > 1)
notEmpty.signal();
} finally {
takeLock.unlock();
}
// 当容量不满时,则唤醒put线程
if (c == capacity)
signalNotFull();
return x;
}
4. 对比ArrayBlockingQueue
① Linked支持有界,Array强制有界
② Linked实现是链表,Array实现是数组
③ Linked是懒惰的,而Array需要提前初始化Node数组
④ Linked每次入队会生成新Node,而Array的Node是提前创建好的
⑤ Linked两把锁,Array一把锁
五、CopyOnWriteArrayList
CopyOnWriteArraySet是它的马甲,CopyOnWriteArraySet类中的方法功能都是调用CopyOnWriteArrayList来实现的。
底层采用写入时拷贝的思想,增删改操作会将底层数组拷贝一份,更改操作在新数组上执行。不影响其他线程的并发读。读写分离。
传统的ArrayList存在的问题:
① 多线程同时add会导致有的下标并未赋值的问题。
② ArrayList在遍历时数据发生改变就会抛出异常。
③ 如果一个线程读一个线程写,读到的数据可能有问题。例如读最后一个数据,结果最后一个数据正好被另一个线程给删了。就会发生数组下标越界的问题。
CopyOnWriteArrayList解决了ArrayList的并发问题。适合读多写少。