Hash:
一般翻译做"散列”,也有直接音译为"哈希的,就是把任意长度的输入(又叫做预映射pre-image )通过散列算法变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来确定唯一的输入值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。常用HASH函数:直接取余法、乘法取整法、平方取中法。
Hash的优点:
先分类再查找,通过计算缩小范围,加快查找速度。
Hash冲突:
Hash通过Hash函数,将Key值映射为地址,Address = F[key];
假设hash表的大小为9(即有9个槽),现在要把一串数据存到表里:5,28,19,15,20,33,12,17,10
简单计算一下:hash(5)=5, 所以数据5应该放在hash表的第5个槽里;hash(28)=1,所以数据28应该放在hash表的第1个槽里;hash(19)=1,也就是说,数据19也应该放在hash表的第1个槽里——于是就造成了碰撞(也称为冲突,collision)。
不管选用何种散列函数,不可避免的都会产生不同Key值对应同一个Hash地址的情况,这种情况叫做哈希冲突。
哈希冲突的处理
当冲突发生时,我们需要想办法解决冲突,一般常用的方法有:
- 开放定址法: 当冲突发生时,探测其他位置是否有空地址 (按一定的增量逐个的寻找空的地址),将数据存入。
- 再散列法
- 连地址法:
连地址法:
将散列到同一个位置的所有元素依次存储在单链表中,或者也有存储在栈中。具体实现根据实际情况决定这些元素的数据存储结构。
首页Hash地址为 i 的结点,均插入到以 T[i] 为头指针的单链表中。T 中各分量的初值均应为空指针。
在拉链法中,装填因子 α 可以大于 1,但一般均取 α ≤ 1。
装填因子(载荷因子)
散列表的载荷因子定义为:
α = 填入表中的元素个数 / 散列表的长度.
α 是散列表装满程度的标志因子。由于表长是定值,α 与“填入表中的元素个数”成正比,所以,α 越大,表明填入表中的元素越多,产生冲突的可能性就越大;反之,α 越小,标明填入表中的元素越少,产生冲突的可能性就越小。实际上,散列表的平均查找长度是载荷因子α 的函数,只是不同处理冲突的方法有不同的函数。
对于开放定址法,荷载因子是特别重要因素,应严格限制在0.7-0.8以下。超过0.8,查表时的CPU缓存不命中(cache missing)按照指数曲线上升。因此,一些采用开放定址法的hash库,如Java的系统库限制了荷载因子为0.75,超过此值将resize散列表。
位运算:
直接贴个我觉得写的很通俗易懂的博客链接: 通俗易懂位运算
哪里可以用到位运算:
- Java中的类修饰符,成员变量修饰符,方法修饰符
- Java容器中的HashMap和ConcurrentHashMap的实现
- 权限控制或商品属性
- 简单可逆加密(1^1=0; 0^1=1)
将位运算用在权限控制和商品属性上:可以节省很多代码量,同时效率高,而且属性变动影响小,但是代码变得不直观.
HashMap中死循环分析(JDK1.7)
在JDK1.7版本中的HashMap会在扩容时发生死循环, 形成一个环形数据结构,会导致CPU的利用率飙升到100% 。
HashMap的扩容过程:
1.取当前的table的2倍作为新table的大小
2. 根据算出的新table大小new出一个新的entry数组来,名为newTable
3.轮询原table上的位置,将每个位置上连接的Entry算出在newTable上的位置,并以链表的形式连接
4.原table上的所有Entry全部轮询完毕之后,意味着原table上面的所有Entry已经移到了newTable上,HashMap中的table只想newTable.
在单线程的情况下扩容并不会发生死循环,但是多线程的情况则会生成环形链表.
因为table的扩容,原table中的数据需要从新进行散列, table容量增加,则散列的计算便会有不同的
地址,newTable通过轮询原table会将数据从新分配.
上个单线程扩容例子.:
现在 HashMap 中有三个元素,Hash 表的 size=2, 所以 key = 3, 7, 5,在 mod 2 以后都冲突在 table[1]这里了。
按照方法中的代码
对 table[1]中的链表来说,进入 while 循环,此时 e=key(3),那么 next=key(7), 经过计算重新定位 e=key(3)在新表中的位置,并把 e=key(3)挂在 newTable[3]的位 置
然而多线程并发扩容的情况:
初始HashMap还是
我们现在假设有两个线程并发操作,都进入了扩容操作,
于是,在线程 1,2 看来,就应该是这个样子
接下来,线程 1 被调度回来执行:
1)
2)
3)
4)
5) 6)
7)
总结:
HashMap 之所以在并发下的扩容造成死循环,是因为,多个线程并发进行 时,因为一个线程先期完成了扩容,将原 Map 的链表重新散列到自己的表中, 并且链表变成了倒序,后一个线程再扩容时,又进行自己的散列,再次将倒序链 表变为正序链表。于是形成了一个环形链表,当 get 表中不存在的元素时,造成 死循环。
ConcurrentHashMap
这个我也直接上链接,还是大神黄猿的博客. 依旧是细节拉满,诚意十足.
提问解答:
Q: HashMap与Hashtable的区别?
A:
- HashMap 是线程不安全的,HashTable 是线程安全的;
- 由于线程安全,所以 HashTable 的效率比不上 HashMap;
- HashMap 最多只允许一条记录的键为 null,允许多条记录的值为 null,
- 而 HashTable 不允许;
- HashMap 默认初始化数组的大小为 16,HashTable 为 11,前者扩容时,
- 扩大两倍,后者扩大两倍+1;
- HashMap 需要重新计算 hash 值,而 HashTable 直接使用对象的
- hashCode
Q: Java 中的另一个线程安全的与 HashMap 极其类似的类是什么?同样是 线程安全,它与 HashTable 在线程同步上有什么不同?
A:
- ConcurrentHashMap 类(是 Java 并发包 java.util.concurrent 中提供的一 个线程安全且高效的 HashMap 实现)。
- HashTable 是使用 synchronize 关键字加锁的原理(就是对对象加锁), 而针对 ConcurrentHashMap,在 JDK 1.7 中采用分段锁的方式;JDK 1.8 中 直接采用了 CAS(无锁算法)+ synchronized,也采用分段锁的方式并大大缩小了 锁的粒度。
Q: 为什么ConcurrentHashMap比Hashlable效率要高?
A:
HashTable 使用一把锁(锁住整个链表结构)处理并发问题,多个线程 竞争一把锁,容易阻塞;
ConcurrentHashMap JDK 1.7 中使用分段锁(ReentrantLock + Segment + HashEntry),相当于把一 个 HashMap 分成多个段,每段分配一把锁,这样支持多线程访问。锁粒度:基 于 Segment,包含多个 HashEntry。 JDK 1.8 中使用 CAS + synchronized + Node + 红黑树。锁粒度:Node(首结 点)(实现 Map.Entry<K,V>)。锁粒度降低了。
Q: HashMap & ConcurrentHashMap 的区别?
A:
除了加锁,原理上无太大区别. 另外,HashMap 的键值对允许有 null,但是 ConCurrentHashMap 都不允许。 在数据结构上,HashMap是基于Entry的, ConcurrentHashMap是他内部定义的node.
Q: 针对 ConcurrentHashMap 锁机制具体分析(JDK 1.7 VS JDK 1.8)?
A:
JDK 1.7 中,采用分段锁的机制,实现并发的更新操作,底层采用数组+链表 的存储结构,包括两个核心静态内部类 Segment 和 HashEntry。
1. Segment 继承 ReentrantLock(重入锁) 用来充当锁的角色,每个 Segment 对象守护每个散列映射表的若干个桶;
2.HashEntry 用来封装映射表的键-值对;
3.每个桶是由若干个 HashEntry 对象链接起来的链表。
JDK 1.8 中,采用 Node + CAS + Synchronized 来保证并发安全。取消类 Segment,直接用 table 数组存储键值对;当 HashEntry 对象组成的链表长度超 过 TREEIFY_THRESHOLD 时,链表转换为红黑树,提升性能。底层变更为数组 + 链表 + 红黑树。
Q: ConcurrentHashMap 在 JDK 1.8 中,为什么要使用内置锁 synchronized 来代替重入锁 ReentrantLock?
A:
1、JVM 开发团队在 1.8 中对 synchronized 做了大量性能上的优化,而且基 于 JVM 的 synchronized 优化空间更大,更加自然。
2、在大量的数据操作下,对于 JVM 的内存压力,基于 API 的 ReentrantLock 会开销更多的内存。
Q: ConcurrentHashMap 简单介绍?
A:
1. 重要的常量: private transient volatile int sizeCtl; 当为负数时,-1 表示正在初始化,-N 表示 N - 1 个线程正在进行扩容; 当为 0 时,表示 table 还没有初始化; 当为其他正数时,表示初始化或者下一次进行扩容的大小。
2. 数据结构: Node 是存储结构的基本单元,继承 HashMap 中的 Entry,用于存储数据; TreeNode 继承 Node,但是数据结构换成了二叉树结构,是红黑树的存储 结构,用于红黑树中存储数据; TreeBin 是封装 TreeNode 的容器,提供转换红黑树的一些条件和锁的控制。
3. 存储对象时(put() 方法): 1.如果没有初始化,就调用 initTable() 方法来进行初始化; 2.如果没有 hash 冲突就直接 CAS 无锁插入; 3.如果需要扩容,就先进行扩容; 4.如果存在 hash 冲突,就加锁来保证线程安全,两种情况:一种是链表形 式就直接遍历到尾端插入,一种是红黑树就按照红黑树结构插入; 5.如果该链表的数量大于阀值 8,就要先转换成红黑树的结构,break 再一 次进入循环 6.如果添加成功就调用 addCount() 方法统计 size,并且检查是否需要扩容。
4. 扩容方法 transfer():默认容量为 16,扩容时,容量变为原来的两倍。 helpTransfer():调用多个工作线程一起帮助进行扩容,这样的效率就会更高。
5. 获取对象时(get()方法): 1.计算 hash 值,定位到该 table 索引位置,如果是首结点符合就返回; 2.如果遇到扩容时,会调用标记正在扩容结点 ForwardingNode.find()方法, 查找该结点,匹配就返回; 3.以上都不符合的话,就往下遍历结点,匹配就返回,否则最后就返回 null。
Q: ConcurrentHashMap 的并发度是什么?
A:
1.7 中程序运行时能够同时更新 ConccurentHashMap 且不产生锁竞争的 最大线程数。默认为 16,且可以在构造函数中设置。当用户设置并发度时, ConcurrentHashMap 会使用大于等于该值的最小 2 幂指数作为实际并发度(假如 用户设置并发度为 17,实际并发度则为 32)。