HashMap,HashTable源码解析

HashMap源码解读

1、概述:是Map接口的非同步实现,允许使用null值和null健,对象是无序排列的这点和list接口相反。HashMap中有且只有一个key为null(key不能重复)。HashMap用到了几个类?Entry,keySet,entrySetHashIterator(keyIterator和valueIterator都是继承了这个类)。Hashmap1.8用到了红黑树,当数组一个位置的链表长度大于8,就由链表变为红黑树。红黑树查找效率为O(logn),为什么用红黑树而不是平衡二叉树?前者追求大致平衡,插入操作每次最多三次旋转就能达到平衡,实现简单一些。后者追求绝对平衡,插入操作较复杂,旋转次数不好预估

2、数据结构:java编程语言中最基本的结构是两种,数组和模拟指针(引用),所有的数据结构都可以用这两个基本结构来构造,HashMap是一个链表散列的数据结构,即数组和链表的结合体。HashMap底层是一个数组,数组中每一项是个链表。当新建HashMap时,就会初始化一个数组

源码如上:可以看出,Entry就是数组中的元素,每个Map.Entry就是一个key-value对,它持有指向下一个元素的引用,这就构成了链表。 

3、HashMap的存储实现:

如果key是null的话,则把该entry放到table[0]中,如果找到了key为null的entry,则覆盖,没找到,则把entry加到链表的表头。

我们往HashMap中put元素的时候,先根据key的hashcode重新计算hash值,根据hash值得到这个元素在数组中的位置(下标),如果数组该位置上已经存放有其他元素,那么在这个位置上的元素将以链表的形式存放,新加入的放在链表头,最先加入的放在链表尾。如果数组该位置没有元素,就直接把该元素放在数组中的该位置上。addEntry根据计算出的hash值,将k-v对放在数组table的i索引处,addEntry是HashMap提供的一个包访问权限方法。代码如下

当系统决定存储HashMap中k-v对时,没有考虑Entry中的value,仅仅根据key来计算并决定每个Entry的存储位置,我们完全可以把Map集合中的value当成key的附属,当系统决定了key的存储位置之后,value随之保存在那里即可。

 hash(int h)方法根据key的hashcode重新计算一次散列。hashcode()函数返回int型散列值,int范围-2^31~2^31-1(-2147483648-2147483647),加起来有40亿个值,如果直接拿散列值为下标访问HashMap主数组,40亿长度的数组内存放不下。所以这个散列值不能直接用。而要如下处理

这样处理之后,达到的效果如下:

右移16位,正好是32bit的一半,自己的高半区和低半区异或,就是混合原始hashcode的高低位,加大低位随机性。

在HashMap中要找到某个元素,需要根据key的hash值求得对应数组中的位置。Hash算法就是计算这个位置。我们希望HashMap里面的元素位置尽量的分布均匀些,尽量使得每个位置上的元素只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的而不用再去遍历链表,这样就大大优化了查询的效率。对于给定的对象,只要他们的hashCode()返回值相同,那么程序调用hash(int h)方法锁计算得到的hash码总是相同的。在HashMap中,调用indexFor(int h, int length)计算该对象应该保存在table数组的哪个索引处,indexFor()如下所示:

这个方法很巧妙,用h&(table.length-1)来得到该对象的保存位,而HashMap底层数组长度总是2的n次方,这是HashMap在速度上的优化。在HashMap构造器中有如下代码

 

这段代码保证初始化时HashMap的容量是2的n次方,即底层数组的长度是2的n次方h&(length-1)相当于length取摸,也就是h%length,&运算的效率更高。假设数组长度分别是15和16,hash码分别是8和9,&运算后结果如下

 

当数组长度为15,8和9产生了相同的结果,也就是会定位到数组中同一个位置上,会产生碰撞,两者形成链表,查询时候要遍历这个链表这就降低了查询效率。数组长度为15时,hash会与15-1(1110)&运算,最后一位永远是0,像0001,0011等位置永远不能放元素,浪费空间。当数组长度16,2^n-1每个位上都是1,所以hash&时得到的和原hash相同,不同key得到相同index的几率较小,数据在数组上分布更均匀。查询效率更高。根据put方法源码,如果两个Entry的key的hashCode()返回值相同,则它们的存储位置相同。如果两个Entry的value通过equals比较返回true,新Entry的value会覆盖原有Entry的value。如果两个Entry的key通过equals比较返回false,新添加的Entry会与原有Entry形成Entry链,而且新添加的Entry位于Entry链的头部。

4、HashMap的读取实现

 首先计算key的hashCode,找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。

 5、简单归纳:HashMap在底层k-v当成一个整体处理,这个整体就是一个Entry对象。HashMap底层采用一个Entry[]数组保存所有k-v对,当需要存储一个Entry对象时,根据hash算法决定其在数组中的位置,再根据equals方法决定其在该数组位置上的存储位置。当需要取出一个Entry时,也会根据hash算法找到其在数组中的存储位置,再根据equals方法从该位置上的链表中取出该Entry。

 6、HashMapresize:

 

当HashMap中元素越来越多hash冲突的几率越来越高,为了提高查询效率,就要对HashMap的数组扩容,原数组中的数据必须重新计算其在新数组中的位置,并放进去。这个操作很消耗性能。

什么时候resize呢?当HashMap中键值对数量超过数组大小*loadFactor时,就会扩容。loadFactor默认是0.75,默认情况下,数组大小16,那么当HashMap中元素超过16*0.75=12时,就把数组大小扩展为32。如果已知元素个数,那么预设元素个数以提高HashMap的性能。

为什么加载因子默认是0.75?如果加载因子过小,则hashmap会频繁扩容,浪费空间。如果加载因子过大,则hash冲突概率大,查找时间复杂度增大。0.75是由泊松分布计算得出的,可以再空间和时间之间找到平衡

 7、HashMap性能参数:

HashMap()构建一个初始容量16,负载因子0.75的HashMap

HashMap(int initialCapacity),负载因子0.75的HashMap,如果initialCapacity6,则初始容量是8

HashMap(int initialCapacity,float loadFactor)自己定义两者大小。

loadFactor衡量的是散列表空间使用程度,越大则HashMap装填程度越高,空间利用充分然而查找效率低。如果负载因子小,HashMap会过于稀疏。

8、Fail-Fast机制

  HashMap不是线程安全的,因此如果在使用迭代器的过程中有其他线程修改了map那么将抛出ConcurrentModificationException,这就是所谓的Fail-Fast策略。在源码中声明了modCount变量,用volatile修饰,代表多线程环境下访问modCount时,只modCount改变了,其他线程将读到最新的值。使用Iterator开始迭代时,会把modCount赋值给expectedModCount,在迭代过程中通过比较两者是否相等来判断HashMap是否在内部被其他线程修改。如果不一样说明有其他线程在修改HashMap的结构。HashMap的put,remove操作都有modCount++的计算。因此面对并发的修改,迭代器很快就会完全失败。在初始化迭代器的时候,会把modCount赋值给expectedModCount

fail-fast和fail-safe机制对比
fail-fast:集合迭代过程中,一旦发现容器中的数据被修改了,会抛出currentModificationException,导致遍历失败,例如ArrayList和HashMap等
fail-safe:线程安全的集合例如ConcurrentHashMap,遍历过程中修改集合,不会抛这个异常,原因:此类集合在迭代的时候不是在原来集合上进行的,而是先复制原来集合的内容,在拷贝的集合上进行遍历。所以迭代的时候对原集合进行修改,不会抛异常

 9、重新调整HashMap大小存在的问题:

如果两个线程都发现HashMap需要重新调整大小,它们会同时试着调整大小,调整大小过程中会产生循环链表,死循环。产生死循环的过程http://blog.csdn.net/xiaohui127/article/details/11928865

在调整大小的过程中,存储在链表中元素的次序会反过来,因为移动到新的位置时,hashmap不会将元素放在链表的尾部,而是放在头部。形成循环链表后,get方法会进入死循环,导致程序无法结束。

10、StringInteger这样的包装类最适合作为键,String类不可变,是final的,而且重写过equals()和hashCode()方法,其他的包装类也有这类特点。不可变性是必要的,因为要计算hashCode就要防止键值改变,如果键值在放入和获取时返回不同的hashcode,就不能从HashMap中得到想要的对象。获取对象的时候要用到equals()和hashCode()方法,如果两个不想等的对象返回不同的hashCode,那么碰撞的几率就会小一些,这样就能提高HashMap的性能。我们也可以使用自定义的对象作为键,只要遵守equals()和hashCode()方法的定义规则

11、HashMap的遍历:

通过entrySet()获取键值对的set集合,通过keySet()获取键的set集合,通过values()获取值set集合。通过Iterator遍历这些方法得到的集合

12、hashMap 1.7和1.8区别

1)1.7 由数组和单向链表实现。1.8:当数组一个位置的链表长度小于8,还是数组+链表;大于等于8,就由链表变为红黑树。把时间复杂度从O(n)变成O(logN)提高了效率。通常如果 hash 算法正常的话,那么链表的长度符合泊松分布,链表长度也不会很长,那么红黑树也不会带来明显的查询时间上的优势,反而会增加空间负担。通常情况下,并没有必要转为红黑树,所以就选择了概率非常小,小于千万分之一概率,也就是长度为 8 的概率,把长度 8 作为转化的默认阈值。

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

3)在JDK1.7的时候是先扩容后插入的,这样就会导致无论这一次插入是不是发生hash冲突都需要进行扩容,如果这次插入的并没有发生Hash冲突的话,那么就会造成一次无效扩容,但是在1.8的时候是先插入再扩容的,优点其实是因为为了减少无效的扩容,如果这次插入没有发生Hash冲突的话,就不会扩容。

 

 

HashTable源码解读

1、HashTable继承自Dictionary,Dictionary是声明了操作键值对”函数接口的抽象类。实现了Map接口,HashTable中的函数都是同步的,意味着它线程安全。它的keyvalue都不能是null。HashTable中的映射不是有序的。HashTable的实例有两个参数影响其性能:初始容量和加载因子。容量是哈希表中桶的数量,初始容量就是其创建时的容量。默认加载因子是0.75.

HashTable的构造函数

 

HashTable的数据存储数组

以上两个图是Entry类的代码,Entry实际上是个单向链表,可以看出value不能是null,两个Entry的keyvalue都相等,则认为他们相等。

 HashTable的主要对外接口:

contains()和containsValue():判断HashTable是否包含值:如果value是null则抛出异常,不是null则从后向前遍历table数组中的元素(Entry),对于每个Entry(单向链表),逐个遍历,判断节点的值是否等于value

containsKey():算出key的hash值,算出其索引值,在数组中找到对应位置,然后在该位置的链表中找出哈希值和键值都与key相等的元素。该方法的时间复杂度是O(1)。

 get():获取key对应的value,没有的话返回null

 

put(): value不能为null,如果key已经存在,则用新的value替换旧的value。要先计算出key的hash值,通过hash值算出在数组中的index,然后在该index的链表中找到对应的key。如果hashtable的实际容量大于阈值,则调整hashtable的大小为原来的两倍加1.该方法会创建个新的Entry节点,并且把这个新节点作为链表的表头。

rehash()方法:容量扩大为两倍+1,同时需要把原来的元素都复制到新的hashtable中,这个过程比较费时间,比如初始值11,加载因子0.75,此时阈值为8,当容器中元素达到8时,扩容为8*2+1=17,此时阈值变成17*0.75=13

 

remove()方法通过key的hash值找到该元素

size()方法:只有一句话return count,为什么还要做同步呢?如果不同步的话,可能此时A添加完元素,还没有对size++,如果给size加了同步后,意味着线程B调用size()方法只有在线程A调用put方法完毕后才可以调用。另外,java代码最后会被翻译成机器码执行,即使java代码只有一行,可能编译后生成的字节码有好多行,完全可能执行完第一行,线程就被切换了

HashMap和Hashtable的区别:

1:前者键值都可以是null,后者键值都不能是null

2:后者的方法是线程安全的,前者不是。

3. HashMap中hash数组的默认大小是16,而且一定是2的指数,HashTable中hash数组默认大小是11,增加的方式是old*2+1。

4. 计算index方法不同:HashTable直接利用key的hashcode()对数组长度取余得出,HashMap对key的hashcode高低位打散后对数组长度取余,key的打散效果更好

 

 

 

 

 

 

 
 
 
 
 
 
posted @ 2023-02-09 23:05  MarkLeeBYR  阅读(88)  评论(0)    收藏  举报