文章目录
容器类关系图
HashMap实现分析
HashMap实际上是一个“链表散列”的数据结构,即数组和链表的结合体。数组的特点是:存储区间连续,占用内存严重,寻址容易,插入删除困难;而链表的特点是:存储区间离散,占用内存比较宽松,寻址困难,插入删除容易;HashMap综合应用了这两种数据结构,实现了寻址容易,插入删除也比较容易的特点。
JDK1.8之前的并发问题
1.使用HashMap进行put操作时,会调用如下的方法:
void addEntry(int hash, K key, int bucketIndex){
//将bucketIndex下标对应的值保存到“e”中
Entry<K,V> e = table[bucketIndex];
//采用“头插法”,将新元素插入table[bucketIndex]上,e作为新元素的下一个节点
table[bucketIndex] = new Entry<K,V>(hash,key,value,e);
//当HashMap中的数组中的实际元素数量大于等于“阈值”时,需要对HashMap进行扩容
if(size++ >= threshold)
resize(2 * table.length);
}
上面的这个方法没有经过任何同步以及加锁的操作,是线程不安全的。当A线程和B线程同时对同一个数组下标位置调用addEntry方法时,两个线程会同时得到同一个头节点,然后A线程写入新的头节点后,B线程也写入一个新的头节点,那么B线程的写入操作就会覆盖A线程的写入操作,从而导致数据丢失。
2.在删除键值对时,会调用以下代码:
final Entry<K,V> removeEntryForKey(Object key){
//获取哈希值,当key为null时,哈希值为0;
//当key不为null时,调用hash()计算对应的哈希值
int hash = (key == null) ? 0 : hash(key.hashCode());
int i = indexFor(hash, table.length);
Entry<K,V> prev = table[i];
Entry<K,V> e = prev;
//删除HashMap中键为key的元素
//也可以理解为删除单向链表中的元素
while(e != null){
Entry<K,V> next = e.next;
Object k;
if(e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))){
modCount++;
size--;
if(prev == e)
table[i] = next;
else
//e为当前节点
//将e的前驱节点的后继节点设置为e的后继节点
prev.next = next;
e.recordRemoval(this);
return e;
}
prev = e;
e = next;
}
}
上面这个方法也没有经过任何同步以及加锁的操作,也是线程不安全的。如果有多个线程同时操作同一个数组的同一个下标位置时,这些线程也都会取得现在状态下该位置存储的头节点,然后各自去进行计算操作,之后再把结果写到该数组位置上。但是在写回的时候,有可能其他线程已经将该位置上的元素修改过了,再进行写操作时,就会覆盖掉其他线程的操作。
3.当我们需要给HashMap扩容时,会调用以下代码:
void resize(int newCapacity){
Entry[] oldTable = table;
int oldCapacity = oldTable.length;
//如果容量已达最大值,则不能扩容
if(oldCapacity == MAXIMUM_CAPACITY){
threshold = Integer.MAX_VALUE;
return;
}
//新建一个HashMap,将原来的HashMap中的元素全部添加到新的HashMap中
//再将新的HashMap赋值给旧的HashMap,就实现了扩容的目的
Entry[] newTable = new Entry[newCapacity];
transfer(newTable);
table = newTable;
threshold = (int)(newCapacity * loadFactor);
}
这个操作会在HashMap内部产生一个新的数组,然后对原数组的所有键值对进行重新计算位置并写入新的数组中,再让table指向新的数组。当有多个线程同时需要进行扩容操作调用resize方法时,每个线程会各自生成新的数组并重新哈希后赋值给该map内部的数组table,最后的结果是只有一个线程生成的新数组会被赋值给table变量,其他线程的新数组均会丢失。并且当某些线程已经完成数赋值而其他线程刚开始的时候,就会使用已经被赋值过的table作为原始数组,这样也会产生问题
JDK1.8并发问题
HashMap中迭代器源码:
abstract class HashIterator{
Node<K,V> next;
Node<K,V> current;
int exceptedModCount;
int index;
HashIterator(){
exceptedModCount = modCount;
Node<K,V>[] t = table;
current = next = null;
index = 0;
if(t != null && size > 0){
do{}while(index < t.length && (next = t[index++]) == null);
}
}
public final boolean hasNext(){
return next != null;
}
final Node<K,V> nextNode(){
Node<K,V>[] t;
Node<K,V> e= next;
if(modCount != exceptedModeCount)
throw new ConcurrentModificationException();
if(e == null)
throw new NoSuchElementException();
if((next = (current = e).next) == null && (t = table) != null){
do{}while(index < t.length && (next = t[index++]) == null);
}
return e;
}
}
注意:
modCount是HashMap中的成员变量,可以将它理解为HashMap的版本号,来记录HashMap是否被操作过;
在调用put(), remove(), clear(), ensureCapacity()这些会修改数据结构的方法中,都会使modCount++;
在获取迭代器的时候会把modCount赋值给exceptedModCount,此时二者相等;
在迭代元素的过程中如果HashMap调用自身的方法使集合发生变化,那么就会修改modCount的值,此时modCount与exceptedModCount的值不相等;
在迭代的过程中,如果发现modCount与exceptedModeCount不相等,则说明HashMap结构发生了变化,所以也就没有必要继续迭代了,此时会抛出ConcurrentModificationException,终止迭代操作;
HashMap中并发问题的解决
我们可以通过Synchronized关键字、Lock锁、同步类容器、并发类容器来解决并发问题
同步容器介绍
在Java中,同步容器主要分为两类:
①:Vector、Stack、HashTable等(可以独立创建)
②:Collections类中提供的静态工厂方法创建的类(借助工具类创建)
**Vector:**实现了List接口,Vector的底层实际上就是一个数组,和ArrayList类似,但不同的是,Vector中的方法都是加了synchronized关键字的,也就是进行了同步措施(目前很少使用Vector)
**Stack:**也是一个同步容器,继承于Vector类
**HashTable:**实现了Map接口,它与HashMap类似,但是HashTable进行了同步处理
**Collections:**是一个工具提供类,需要注意的是,它与Collection不同,Collection是一个顶层的接口。在Collections类中提供了大量的方法,比如对集合或者容器进行排序、查找等操作。另外,在Collections类中提供了几个静态工厂方法来创建同步容器类,如下图:
下面以HashTable为例分析同步容器的实现原理和特点:
HashTable
HashTable在jdk1.1的时候就有了,它是如何实现线程安全的?它的put、remove、get方法是进行了同步控制的
public synchronized V get(Object key) {
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length; //获取数组待插入的下标
for (Entry<?,?> e = tab[index] ; e != null ; e = e.next) {
if ((e.hash == hash) && e.key.equals(key)) {
return (V)e.value;
}
}
return null;
}
public synchronized V remove(Object key) {
Entry<?,?> tab[] = table;
int hash = key.hashCode();
int index = (hash & 0x7FFFFFFF) % tab.length;
@SuppressWarnings("unchecked")
Entry<K,V> e = (Entry<K,V>)tab[index];
for(Entry<K,V> prev = null ; e != null ; prev = e, e = e.next) {
if ((e.hash == hash) && e.key.equals(key)) {
modCount++;
if (prev != null) {
prev.next = e.next;
} else {
tab[index] = e.next;
}
count--;
V oldValue = e.value;
e.value = null;
return oldValue;
}
}
return null;
}
由于HashTable的remove、get等方法都是用了synchronized关键字修饰的,所以HashTable是线程安全的,但是synchronized又是一个重量级锁,加上之后代码的效率就会下降,所以HashTable的效率较低
并发容器介绍
同步容器的基本原理就是在每个方法上加上synchronized关键字,这样虽然可以保证我们的方法是线程安全的,但是这严重地降低了代码执行的效率。为了让我们的容器既实现线程安全,又不降低代码的执行效率,Java在jdk1.5中引入了并发性能较好的并发容器,引入了java.util.concurrent包。
ConcurrentHashMap:
对应的非并发容器:HashMap
目标:代替HashMap、synchronizedMap,支持复合操作
原理:JDK1.6中采用了一种更加细粒度的加锁机制Segment“分段锁”,JDK1.8中采用了CAS无锁算法
CopyOnWriteArrayList:
对应的非并发容器:ArrayList
目标:代替Vector、synchronizedList
原理:利用高并发往往是读多写少的特性,对读操作不加锁而写操作才加锁;对于写操作,先复制一份新的集合,然后再新的集合上进行修改,再将新的集合赋值给旧的引用,并且利用volatile关键字来保证其可见性。
CopyOnWriteArraySet:
对应的非并发容器:HashSet
目标:代替synchronizedSet
原理:基于CopyOnWriteArrayList实现,其不同之处在于它的add方法调用的是CopyOnWriteArrayList中的addIfAbsent方法,也就是遍历当前Object数组,如果Object数组中以及存在了当前元素,那么就直接返回,如果没有则直接放入Object数组的尾部,并返回
ConcurrentSkipListMap:
对应的非并发容器:TreeMap
目标:代替synchronizedSortedMap(TreemMap)
原理:SkipList是一种可以媲美平衡树的数据结构,默认是按照key值升序的。SkipList将已排序的数据分布在多层链表中,以0-1随机数来决定一个数据向上攀升与否,这是一种利用空间来换时间的算法。ConcurrentSkipListMap提供了一种线程安全的并发访问的排序映射表,其内部是通过SkipList结构实现的,在理论上来说,其查找、插入、删除操作的时间复杂度为O(log n)
ConcurrentSkipListSet:
对应的非并发容器:TreeSet
目标:代替synchronizedSortedSet
原理:内部基于ConcurrentSkipListMap实现
下面以ConcurrentHashMap为例,深入分析其数据结构在jdk1.7之前和jdk1.8中的区别:
ConcurrentHashMap数据结构
jdk1.7中基于分段的数据结构:
put方法:
public V put(K key, V value){
Segment<K,V> s;
if(value == null){
throw new NullPointerException();
}
//计算key的hash值
int hash = hash(key);
//根据hash值找到Segment数组中的位置
int j = (hash >>> segmentShift) & segmentMask;
if((s = (Segment<K,V>)UNSAFE.getObject
(segments,(j << SSHIFT) + SBASE)) == null)
s = ensureSegment(j);
//插入新值到槽s中
return s.put(key, hash, value, false);
}
Segment继承了ReentrantLock,其内部的put方法(上述代码里的s.put(key,hash,value,false))如下:
final V put(K key, int hash, V value, boolean onlyIfAbsent){
//在向该segment段中写入数据前,需要获取到该segment的独占锁
HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);
V oldValue;
try{
//这是segment内部的数组
HashEntry<K,V>[] tab = table;
//再利用hash值,求应该放置的数组下标
int index = (tab.length - 1) & hash;
//first是数组该位置处的链表的表头
HashEntry<K,V> first = entryAt(tab, index);
for(HashEntry<K,V> e = first ;;){
if(e != null){
K k;
if((k = e.key) == key || (e.hash == hash && key.equals(k))){
oldValue = e.value;
if(!onlyIfAbsent){
//覆盖旧值
oldValue = e.value;
++modCount;
}
break;
}
//继续遍历链表
e = e.next;
}else{
// node 是否为null,这个得看获取锁的过程
//如果node不为null,那就直接将他设为链表的表头,如果为null,初始化并设置为链表表头
if(node != null)
node.setNext(first);
else
node = new HashEntry<K,V>(hash, key, value, first);
int c = count + 1;
//当超过segment的阈值的时候,我们需要为segment进行扩容
if(c > threshold && tab.length < MAXIMUM_CAPACITY){
rehash(node);
}else
//没有达到阈值,则将node放到数组tab的index位置
//也就是将新节点设置为该index位置的链表的表头
setEntryAt(tab, index, node);
++modCount;
count = c;
oldValue = null;
break;
}
}
}finally{
//解锁
unlock();
}
return oldValue;
}
scanAndLockForPut()方法获取锁,对应于上边的scanAndLockForPut(key, hash, value)
如果tryLock成功了,循环终止;
当重试的次数超过了MAX_SCAN_RETRIES,则进入到lock方法,lock方法会阻塞等待,直到成功拿到独占锁
以下为代码演示:
private HashEntry<K,V> scanAndLockForPut(K key, int hash, V value){
//根据hash值找到segment中的HashEntry节点
HashEntry<K,V> first = entryForHash(this, hash); //首先获取头节点
HashEntry<K,V> e = first;
HashEntry<K,V> node = null;
int retries = -1;
//循环获取锁
while(!tryLock){ //持续遍历该哈希链
HashEntry<K,V> f;//首先再检查下面
if(retries < 0){
if(e == null){
if(node == null)
//代码走到这一步说明数组该位置的链表为空,没有任何元素
//还有一个原因是,tryLock失败,所以该位置存在并发问题,所以不一定是该位置
node = new HashEntry<K,V>(hash, key, value, null);
retries = 0;
}else if(key.equals(e.key))
retries = 0;
}else
//顺着链表继续走
e = e.next;
}else if(++retries > MAX_SCAN_RETRIES){
//重试的次数如果超过了MAX_SCAN_CAPACITY,那么就不再尝试获取锁了,而是直接进入阻塞队列等待锁‘
//lock是阻塞方法,直到获取锁后返回
lock();
break;
}else if((retries & 1) == 0
(f = entryForHash(this, hash)) != first){
e = first = f;
retries = -1;
}
}
return node;
jdk1.8中基于CAS的数据结构:
put方法:
public V put(K key, V value) {
return putVal(key, value, false);
}
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
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) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
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;
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;
}