并发编程与高并发解决方案:HashMap与ConcurrentHashMap

转自:慕课网实战·高并发探索(十):HashMap与ConcurrentHashMap

HashMap最多只允许一条记录的键为null,允许多条记录的值为null。HashMap非线程安全。如果需要满足线程安全,可以用 Collections的synchronizedMap方法使HashMap具有线程安全的能力,或者使用ConcurrentHashMap。

HashMap

(1)初始化方法

HashMap的实现方式是:
JDK1.7 数组+链表
JDK1.8:数组+链表+红黑树

初始容量:Hash表中桶的数量
加载因子:是Hash表在自动增加之前可以达到多满的一个尺度。

HashMap在类中定义了这两个参数:

//初始容量,默认16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 
//加载因子,默认0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;

当Hash表中的条目数量超过了加载因子与当前容量的乘积,将会调用resize()进行扩容,将容量翻倍。

这两个参数在初始化HashMap的时候可以进行设置:可以单独指定初始容量,也可以同时设置 初始容量、加载因子

(2)寻址方式

对于一个新插入的数据或者要读取的数据,HashMap将key按一定规则计算出hash值,并对数组长度进行取模结果作为在数组中查找的index。由于 在计算机中取模的代价远远高于位操作的代价,因此HashMap要求数组的长度为2的N次方。此时它将key的hash值对2的n-1次方进行与运算,等同于取模运算。HashMap并不要求用户一定要设置一个2的N次方的初始化大小,它本身内部会通过运算(tableSizeFor方法)确定一个合理的符合2的N次方的大小去设置。

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;
}

Hash算法本质上就是三步:取key的hashCode值、高位运算、取模运算。
通过h & (table.length -1)来得到该对象的保存位,而HashMap底层数组的长度总是2的n次方,这是HashMap在速度上的优化。当length总是2的n次方时,h& (length-1)运算等价于对length取模,也就是h%length,但是&比%具有更高的效率。

在JDK1.8的实现中,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销。
位操作

(3)HashMap的线程不安全原因一:死循环

原因在于HashMap在多线程情况下,执行resize()进行扩容时容易造成死循环。
扩容思路为它要创建一个大小为原来两倍的数组,保证新的容量仍为2的N次方,从而保证上述寻址方式仍然适用。扩容后将原来的数组从新插入到新的数组中。这个过程称为reHash。

单线程下的reHash(安全)
单线程下的reHash
扩容前:我们的HashMap初始容量为2,加载因子为1,需要向其中存入3个key,分别为5、9、11,放入第三个元素11的时候就涉及到了扩容。

  • 先创建一个二倍大小的数组,接下来把原来数组中的元素reHash到新的数组中,5插入新的数组,没有问题。
  • 将9插入到新的数组中,经过Hash计算,插入到5的后面。
  • 将11经过Hash插入到index为3的数组节点中。

多线程下的reHash
多线程下的reHash
我们假设有两个线程同时执行了put操作,并同时触发了reHash的操作,图示的上层的线程1,下层是线程2。

  • 线程1某一时刻执行完扩容,准备将key为5的元素的next指针指向9,由于线程调度分配的时间片被用完而停在了这一步操作
  • 线程2在这一刻执行reHash操作并执行完数据迁移的整个操作。

接下来线程1被唤醒继续操作。
线程1被唤醒

  • 执行上一轮的剩余部分,在处理key为5的元素时,将此key放在我们线程1申请的数组的索引1位置的链表的首部。理想状态是(线程1数组索引1)—> (Key=5) —> null
    在这里插入图片描述
  • 接着处理Key为9的元素,将key为9的元素插入在(索引1)与(key=5)之间,理想状态:(线程1数组索引1)—> (Key=9)—>(Key=5)—>null
    在这里插入图片描述
  • 但是在处理完key为9的元素之后按理说应该结束了,但是由于线程2已经处理过了key=9与key=5的元素,即真实情况为(线程2数组索引1 —>(key=9)—> (key=5)—> null)|(线程1数组索引1 —> (key=9)—> (key=5)—> null),这时让线程1误以为key=9后面的key=5是从原数组还没有进行数组迁移的,接着又处理key=5。尝试将key=5放在k=9的前边,所以key=9与key=5之间就出现了一个循环。不断的被处理,交换顺序。
  • key = 11的元素是无法插入到新数组中的。一旦我们去从新的数组中获取值得时候,就会出现死循环。

(4)HashMap的线程不安全原因二:fail-fast

如果在使用迭代器的过程中有其他线程修改了map,那么将抛出ConcurrentModificationException,这就是所谓fail-fast。
在每一次对HashMap进行修改的时候,都会变动类中的modCount域,即modCount变量的值。源码:

abstract class HashIterator {
    
    
        ...
        int expectedModCount;  // for fast-fail
        int index;             // current slot

        HashIterator() {
    
    
            expectedModCount = modCount;
            Node<K,V>[] t = table;
            current = next = null;
            index = 0;
            if (t != null && size > 0) {
    
     // advance to first entry
                do {
    
    } while (index < t.length && (next = t[index++]) == null);
            }
        }
        ...
}

在每次迭代的过程中,都会判断modCount跟expectedModCount是否相等,如果不相等代表有人修改HashMap。源码:

final Node<K,V> nextNode() {
    
    
    Node<K,V>[] t;
    Node<K,V> e = next;
    if (modCount != expectedModCount)
        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;
}

解决办法:可以使用Collections的synchronizedMap方法构造一个同步的map,或者直接使用线程安全的ConcurrentHashMap来保证不会出现fail-fast策略。

ConcurrentHashMap

ConcurrentHashMap原理分析(1.7与1.8)有源码分析

java7

Java7

  • Java7里面的ConcurrentHashMap的底层结构仍然是数组和链表,与HashMap和Hashtable 最大的不同在于:put和 get 两次Hash到达指定的HashEntry,第一次hash到达Segment,第二次到达Segment里面的Entry,然后在遍历entry链表
  • 当我们读取某个Key的时候它先取出key的Hash值,并将Hash值的高sshift位与Segment的个数取模,决定key属于哪个Segment。接着像HashMap一样操作Segment。
  • 为了保证不同的Hash值保存到不同的Segment中,ConcurrentHashMap对Hash值也做了专门的优化。
  • Segment继承自J.U.C里的ReetrantLock,所以可以很方便的对Segment进行上锁。即分段锁
//源码
	//Segment的初始化容量是16;HashEntry最小的容量为2
	private static final int DEFAULT_CONCURRENCY_LEVEL = 16;
    private void writeObject(java.io.ObjectOutputStream s)
        throws java.io.IOException {
    
    
        // For serialization compatibility
        // Emulate segment calculation from previous version of this class
        int sshift = 0;
        int ssize = 1;
        while (ssize < DEFAULT_CONCURRENCY_LEVEL) {
    
    
            ++sshift;
            ssize <<= 1;//最大16位2进制
        }
        int segmentShift = 32 - sshift;
        int segmentMask = ssize - 1;
   		.........省略
    }

put操作 Segment实现了ReentrantLock,也就带有锁的功能,当执行put操作时,会进行第一次key的hash来定位Segment的位置,如果该Segment还没有初始化,即通过CAS操作进行赋值,然后进行第二次hash操作,找到相应的HashEntry的位置,这里会利用继承过来的锁的特性,在将数据插入指定的HashEntry位置时(链表的尾端),会通过继承ReentrantLock的tryLock()方法尝试去获取锁,如果获取成功就直接插入相应的位置,如果已经有线程获取该Segment的锁,那当前线程会以自旋的方式去继续的调用tryLock()方法去获取锁,超过指定次数就挂起,等待唤醒(美团面试官问道的,多个线程一起put时候,currentHashMap如何操作)
size操作
计算ConcurrentHashMap的元素大小是一个有趣的问题,因为他是并发操作的,就是在你计算size的时候,他还在并发的插入数据,可能会导致你计算出来的size和你实际的size有相差(在你return size的时候,插入了多个数据),要解决这个问题,JDK1.7版本用两种方案

1、第一种方案他会使用不加锁的模式去尝试多次计算ConcurrentHashMap的size,最多三次,比较前后两次计算的结果,结果一致就认为当前没有元素加入,计算的结果是准确的(3次获取比较值)

2、第二种方案是如果第一种方案不符合,他就会给每个Segment加上锁,然后计算ConcurrentHashMap的size返回(美团面试官的问题,多个线程下如何确定size)【所有segment加锁

java8

java8
Java8 ConcurrentHashMap结构基本上和Java8的HashMap一样,但其保证线程安全性。

Java8废弃了Java7中ConcurrentHashMap中分段锁的方案,并且不使用Segment,转为使用大的数组。同时为了提高Hash碰撞下的寻址做了性能优化(内部大量采用CAS操作)。
Node:保存key,value及key的hash值的数据结构。其中value和next都用volatile修饰,保证并发的可见性。

class Node<K,V> implements Map.Entry<K,V> {
    
    
    final int hash;
    final K key;
    volatile V val;
    volatile Node<K,V> next;
    //... 省略部分代码
}
  • Java8在列表的长度超过了一定的值(默认8)时,将链表转为红黑树实现。寻址的时间复杂度从O(n)转换为Olog(n)。

Java7vsJava8

其实可以看出JDK1.8版本的ConcurrentHashMap的数据结构已经接近HashMap,相对而言,ConcurrentHashMap只是增加了同步的操作来控制并发,从JDK1.7版本ReentrantLock+Segment+HashEntry,到JDK1.8版本中synchronized+CAS+HashEntry+红黑树。

1.数据结构:取消了Segment分段锁的数据结构,取而代之的是数组+链表+红黑树的结构。
2.保证线程安全机制:JDK1.7采用segment的分段锁机制实现线程安全,其中segment继承自ReentrantLock。JDK1.8采用CAS+Synchronized保证线程安全。
3.锁的粒度:原来是对 需要进行数据操作的Segment加锁,现调整为对每个数组元素加锁【Node(HashEntry在1.8中称为Node)】。
4.链表转化为红黑树:定位结点的hash算法简化会带来弊端,Hash冲突加剧,因此在链表节点数量大于8时,会将链表转化为红黑树进行存储。
5.查询时间复杂度:从原来的遍历链表O(n),变成遍历红黑树O(logN)。

HashMap与ConcurrentHashMap对比

  • HashMap非线程安全、ConcurrentHashMap线程安全
  • HashMap允许Key与Value为空,ConcurrentHashMap不允许
  • HashMap不允许通过迭代器遍历的同时修改,ConcurrentHashMap允许。并且更新可见

高并发编程系列:ConcurrentHashMap的实现原理(JDK1.7和JDK1.8)
阿里P8架构师谈:深入探讨HashMap的底层结构、原理、扩容机制
JDK1.8为什么使用synchronized来代替重入锁ReentrantLock

  • 因为粒度降低了,在相对而言的低粒度加锁方式,synchronized并不比ReentrantLock差,在粗粒度加锁中ReentrantLock可能通过Condition来控制各个低粒度的边界,更加的灵活,而在低粒度中,Condition的优势就没有了
  • JVM的支持 JVM的开发团队从来都没有放弃synchronized,而且基于JVM的synchronized优化空间更大,使用内嵌的关键字比使用API更加自然
  • 减少内存开销 在大量的数据操作下,对于JVM的内存压力,基于API的ReentrantLock会开销更多的内存,虽然不是瓶颈,但是也是一个选择依据

猜你喜欢

转载自blog.csdn.net/eluanshi12/article/details/86680623