java集合map

本文会对java集合框架中的对应实现HashMap和ConcurrentHashMap的实现原理及工作原理进行总结,用于整理和学习。

HashMap

jdk1.7 HashMap实现

HashMap的主干是一个Entry数组。Entry是HashMap的基本组成单元,每一个Entry包含一个key-value键值对。

Entry是HashMap中的一个静态内部类。Entry 包含四个属性: key, value, hash 值和用于单向链表的 next。

简单来说,HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前entry的next指向null),那么对于查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap中的链表出现越少,性能才会越好。

HashMap有几个重要属性

  • initialCapacity:当前数组容量,始终保持 2^n,默认为16;
  • loadFactory默认为0.75;
  • threshold:扩容的阈值,等于 capacity * loadFactor;

HashMap put操作逻辑:

首先,根据key值计算出哈希值,再计算出数组索引(即,该key-value在table中的索引)。然后,根据数组索引找到Entry(即,单向链表),再遍历单向链表,将key和链表中的每一个节点的key进行对比。若key已经存在Entry链表中,则用该value值取代旧的value值;若key不存在Entry链表中,则新建一个key-value节点,并将该节点插入Entry链表的表头位置。

jdk1.8 HashMap实现

结构变为:数组+链表+红黑树;当链表的深度达到8的时候,也就是默认阈值,就会自动扩容把链表转成红黑树的数据结构来把时间复杂度从O(n)变成O(logN)提高了效率

Java7 中使用Entry来代表每个HashMap中的数据节点,Java8中使用Node,基本没有区别,都是 key,value,hash 和 next 这四个属性,不过,Node 只能用于链表的情况,红黑树的情况需要使用 TreeNode。

Hashmap 的结构在JDK1.7和1.8有哪些区别(不同点)总结:

1、JDK1.7用的是头插法,而JDK1.8及之后使用的都是尾插法,那么他们为什么要这样做呢?因为JDK1.7是用单链表进行的纵向延伸,当采用头插法时会容易出现逆序且环形链表死循环问题。但是在JDK1.8之后是因为加入了红黑树使用尾插法,能够避免出现逆序且链表死循环的问题。

2、扩容后数据存储位置的计算方式也不一样:

1)在JDK1.7的时候是直接用hash值和需要扩容的二进制数进行&(这里就是为什么扩容的时候为啥一定必须是2的多少次幂的原因所在,因为如果只有2的n次幂的情况时最后一位二进制数才一定是1,这样能最大程度减少hash碰撞)(hash值 & length-1)

2)而在JDK1.8的时候直接用了JDK1.7的时候计算的规律,也就是扩容前的原始位置+扩容的大小值=JDK1.8的计算方式,而不再是JDK1.7的那种异或的方法。但是这种方式就相当于只需要判断Hash值的新增参与运算的位是0还是1就直接迅速计算出了扩容后的储存方式。

为什么HashMap的数组长度一定是2的次幂

好处一:会使得获得的数组索引index更加均匀

JDK1.8 put方法源码详解

学习HashMap的时候,高位运算、取模运算(h & (length-1))等等,一直有点懵,今天好好整理下。

hashmap的最理想的程度是:Hash值散列、均匀分布;为啥呢?因为HashMap是数组+链表的结构,如果定位到的数组位置不含链表(当前entry的next指向null),那么对于查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap中的链表出现越少,性能才会越好。

那么HashMap底层到底做了什么呢?

我们截取小段源码看下:

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

static final int hash(Object key) {
    int h;
    // h = key.hashCode() 首先 取hashCode值
    // h ^ (h >>> 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;
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
    //(n - 1) & hash 计算数组下标 不是直接的取模运算,由于n为2的幂次,所以可以使用“与”运算 (n - 1) & hash代替取模运算。这样速度更快。
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);

我们可以看到Hash算法经典三步:取key的hashCode值、高位运算、取模运算。

第一步:显而易见,直接取就是了。

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

第三步:不是 直接的取模运算,由于length为2的幂次,所以可以使用“与”运算 h & (length-1)代替取模运算。这样速度更快

哈希值 和 数组长度-1 进行 与运算,来获得数组下标 -------  我们上边说的“Hash值散列、均匀分布”,就是指的这个数组下标。。

因为hashMap 的数组长度都是2的n次幂 ,那么对于这个数再减去1,转换成二进制的话,就肯定是最高位为0,其他位全是1 的数。

那以数组长度为8为例(默认HashMap初始数组长度是16),那8-1 转成二进制的话,就是0111 。 那我们举一个随便的hashCode值,与0111进行与运算看看结果如何:

数字8减去1转换成二进制是0111,即下边的情况:

 1 第一个key:      hashcode值:10101001    
 2   与0111进行&运算        &      0111                                      
 3                                0001  (十进制为1)
 4   ------------------------------------------                           
 5 第二个key:      hashcode值:11101000    
 6   与0111进行&运算        &       0111      
 7                                 0000  (十进制为0)
 8--------------------------------------------               
 9 第三个key:      hashcode值:11101110    
10   与0111进行&运算        &       0111      
11                                 0110  (十进制为6)

这样得到的数,就会完整的得到原hashcode 值的低位值,不会受到与运算对数据的变化影响。

数字7减去1转换成二进制是0110,即下边的情况:

 1 第一个key:      hashcode值:10101001    
 2   与0111进行&运算        &       0110                                      
 3                                 0000  (十进制为0)
 4   ------------------------------------------                           
 5 第二个key:      hashcode值:11101000    
 6   与0111进行&运算        &       0110      
 7                                 0000  (十进制为0)
 8--------------------------------------------               
 9 第三个key:      hashcode值:11101110    
10   与0111进行&运算        &       0111      
11                                 0110  (十进制为6)

通过上边可以看到,当数组长度不为2的n次幂 的时候,hashCode 值与数组长度减一做与运算 的时候,会出现重复的数据,因为不为2的n次幂 的话,对应的二进制数肯定有一位为0 ,这样,不管你的hashCode 值对应的该位,是0还是1 ,最终得到的该位上的数肯定是0,这带来的问题就是HashMap上的数组元素分布不均匀,而数组上的某些位置,永远也用不到。如下图所示:
 

这将带来的问题就是你的HashMap 数组的利用率太低,并且链表可能因为上边的(n - 1) & hash 运算结果碰撞率过高,导致链表太深。(当然jdk 1.8已经在链表数据超过8个以后转换成了红黑树的操作,但那样也很容易造成它们之间的转换时机的提前到来),所以说HashMap的长度一定是2的次幂,否则会出现性能问题。
参考博客:HashMap的数组长度一定是2的次幂_银银熙的博客-CSDN博客_hashmap数组长度

好处二:减少了之前已经散列良好的老数组的数据位置重新调换

我们再看一段resize的代码,扩容代码:

// 如果扩容后,元素的index依然与原来一样,那么使用这个低位head和tail指针
HashMap.Node<K, V> loHead = null, loTail = null;
// 如果扩容后,元素的index=index+oldCap,那么使用这个高位head和tail指针
HashMap.Node<K, V> hiHead = null, hiTail = null;
// 下一个节点
HashMap.Node<K, V> next;
do {
		next = e.next;
		// 这个地方直接通过hash值与oldCap进行与操作得出元素在新数组的index
		// 看是否需要进行位置变化 新增位的值 不需要变化就放在原来的位置
		// 这里的判断需要引出一些东西:oldCap 假如是16,那么二进制为 10000,扩容变成 100000,也就是32.
		// 当旧的hash值 与运算 10000,结果是0的话,那么hash值的右起第五位肯定也是0,那么该于元素的下标位置也就不变。
		if ((e.hash & oldCap) == 0) {
				// 第一次进来时给链头赋值
				if (loTail == null)
						loHead = e;
				// 给链尾赋值
				else
						loTail.next = e;
				// 重置该变量
				loTail = e;
		}
		// 需要变化 就构建高位放置的链表
		// 如果不是0,那么就是1,也就是说,如果原始容量是16,那么该元素新的下标就是:原下标 + 16(10000b)
		else {
				// 第一次进来时给链头赋值
				if (hiTail == null)
						hiHead = e;
				// 给链尾赋值
				else
						hiTail.next = e;
				// 重置该变量
				hiTail = e;
		}
} while ((e = next) != null);

resize的时候,需要将原始数据逐个链表地遍历,扔到新的扩容后的数组中,我们的数组索引位置的计算是通过 对key值的hashcode进行hash扰乱运算后,再通过和 length-1进行位运算得到最终数组索引位置。

hashMap的数组长度一定保持2的次幂,比如16的二进制表示为 10000,那么length-1就是15,二进制为01111,同理扩容后的数组长度为32,二进制表示为100000,length-1为31,二进制表示为011111。从下图可以我们也能看到这样会保证低位全为1,而扩容后只有一位差异,也就是多出了最左位的1,这样在通过 h&(length-1)的时候,只要h对应的最左边的那一个差异位为0,就能保证得到的新的数组索引和老数组索引一致(大大减少了之前已经散列良好的老数组的数据位置重新调换)。

图(a)表示扩容前的key1和key2两种key确定索引位置的示例,图(b)表示扩容后key1和key2两种key确定索引位置的示例,其中hash1是key1对应的哈希与高位运算结果。

可以看到, 此key 要么在原先的index上, 要么在 index+old_length 上,old_length为扩容前数组的长度。

高位运算究竟是什么意思

上边有提到,(h = k.hashCode()) ^ (h >>> 16),这一步操作是通过hashCode()的高16位异或低16位实现的,目的就是为了让结果更散列。

但怎么就是hash值的高16位异或低16位呢?首先我们知道h就是key的哈希值。

那我们先说高16位,h无符号右移16位(h >>> 16),就取出了h的高16位。

那么低16位就是 h.hashCode()。这明明就是原始哈希值,为什么是低16位呢?那我们得先再看下返回数组下标的与运算 h & (length-1),绝大多数情况下length一般都小于2^16即小于65536,之所以说是h的低16位,就是取决于length-1,比如:

length = 8 = 2^3;  (length-1) = 7;转换二进制为111,h和length-1做与运算的时候,由于length-1的有效位是3位,所有h参与运算的也就低3位;

length = 16 = 2^4;  (length-1) = 15;转换二进制为1111,h和length-1做与运算的时候,由于length-1的有效位是4位,所有h参与运算的也就低4位;

length =32 = 2^5;  (length-1) = 31;转换二进制为11111,h和length-1做与运算的时候,由于length-1的有效位是5位,所有h参与运算的也就低5位;

…………

length = 65536= 2^16;  (length-1) = 65535;h和length-1做与运算的时候,由于length-1的有效位是16位,所有h参与运算的也就低16位;

length=2^n,h参与运算的也就取决于低n位。

所以这样高16位是用不到的,如何让高16也参与运算呢。所以就有了异或运算。让他的hashCode()和自己的高16位^运算。所以(h >>> 16)得到他的高16位与hashCode()进行^运算。这样也就可以让低16位更加散列,最终让得到的结果更加散列。

 高位运算的意义啥子

我们看到,上面的&运算,高位是不会对结果产生影响的(hash函数采用各种位运算可能也是为了使得低位更加散列),我们只关注低位bit,如果低位全部为1,那么对于h低位部分来说,任何一位的变化都会对结果产生影响,也就是说,要得到index=21这个存储位置,h的低位只有这一种组合。这也是数组长度设计为必须为2的次幂的原因。

如果不是2的次幂,也就是低位不是全为1此时,要使得index=21,h的低位部分不再具有唯一性了,哈希冲突的几率会变的更大,同时,index对应的这个bit位无论如何不会等于1了,而对应的那些数组位置也就被白白浪费了。

ConcurrentHashMap

jdk1.7 ConcurrentHashMap实现

ConcurrentHashMap结构分为两部分:

  • segment数组,不可扩容;
  • segment中的内部数组和链表,内部数组可扩容

ConcurrentHashMap 是一个 Segment 数组, Segment 通过继承ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。

concurrencyLevel: 并行级别、并发数、Segment 数,默认值是16,也就是说 ConcurrentHashMap 有 16 个 Segments,所以理论上,这个时候,最多可以同时支持 16 个线程并发写,只要它们的操作分别分布在不同的 Segment 上。这个值可以在初始化的时候设置为其他值,但是一旦初始化以后,它是不可以扩容的。

jdk1.8 ConcurrentHashMap实现

JDK1.8之后ConcurrentHashMap结构和JDK1.8之后的HashMap基本上一样,也是保持着数组+链表+红黑树的结构,不同的是,ConcurrentHashMap需要保证线程安全性。

从JDK1.7版本的ReentrantLock+Segment+HashEntry,到JDK1.8版本中synchronized+CAS+HashEntry+红黑树:

  • JDK1.8的实现降低锁的粒度,JDK1.7版本锁的粒度是基于Segment的,包含多个HashEntry,而JDK1.8锁的粒度就是HashEntry(首节点)。
  • JDK1.8版本的数据结构变得更加简单,使得操作也更加清晰流畅,因为已经使用synchronized来进行同步,所以不需要分段锁的概念,也就不需要Segment这种数据结构了,由于粒度的降低,实现的复杂度也降低了。
  • JDK1.8使用红黑树来优化链表,基于长度很长的链表的遍历是一个很漫长的过程,而红黑树的遍历效率是很快的,代替一定阈值的链表,这样形成一个最佳拍档
     
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

木子松的猫

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值