一、摘要
HashMap是Java程序员使用频率最高的用于映射(键值对)处理的数据类型。随着JDK(Java Developmet Kit)版本的更新,JDK1.8对HashMap底层的实现进行了优化,例如引入红黑树的数据结构和扩容的优化等。本文结合JDK1.7和JDK1.8的区别,深入探讨HashMap的结构实现和功能原理。、
二、Map
Java为数据结构中的映射定义了一个接口java.util.Map,此接口主要有四个常用的实现类,分别是HashMap、Hashtable、LinkedHashMap和TreeMap,类继承关系如下图所示:
-
HashMap:它根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的。 HashMap最多只允许一条记录的键为null,允许多条记录的值为null。HashMap非线程安全,即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。如果需要满足线程安全,可以用 Collections的synchronizedMap方法使HashMap具有线程安全的能力,或者使用ConcurrentHashMap。
-
Hashtable:Hashtable是遗留类,很多映射的常用功能与HashMap类似,不同的是它承自Dictionary类,并且是线程安全的,任一时间只有一个线程能写Hashtable,并发性不如ConcurrentHashMap,因为ConcurrentHashMap引入了分段锁。Hashtable不建议在新代码中使用,不需要线程安全的场合可以用HashMap替换,需要线程安全的场合可以用ConcurrentHashMap替换。
-
LinkedHashMap:LinkedHashMap是HashMap的一个子类,保存了记录的插入顺序,在用Iterator遍历LinkedHashMap时,先得到的记录肯定是先插入的,也可以在构造时带参数,按照访问次序排序。使⽤双向链表来维护元素的顺序,顺序为插⼊顺序或者最近最少使⽤(LRU)顺序。
-
TreeMap:TreeMap实现SortedMap接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,当用Iterator遍历TreeMap时,得到的记录是排过序的。如果使用排序的映射,建议使用TreeMap。在使用TreeMap时,key必须实现Comparable接口或者在构造TreeMap传入自定义的Comparator,否则会在运行时抛出java.lang.ClassCastException类型的异常。
对于上述四种Map类型的类,要求映射中的key是不可变对象。不可变对象是该对象在创建后它的哈希值不会被改变。如果对象的哈希值发生变化,Map对象很可能就定位不到映射的位置了。
通过上面的比较,我们知道了HashMap是Java的Map家族中一个普通成员,鉴于它可以满足大多数场景的使用条件,所以是使用频度最高的一个。下文我们主要从Java7和Java8的区别入手讲解HashMap的工作原理。
三、HashMap数据结构
- HashMap是基于哈希表实现的,哈希表的主干是数组,要新增或查找某个元素,我们通过把当前元素的关键字 通过某个函数映射到数组中的某个位置,通过数组下标一次定位就可完成操作。
- 哈希冲突:哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址),再散列函数法,链地址法,而HashMap即是采用了链地址法,也就是数组+链表的方式。
- Java 7及以前HashMap的结构是数组+链表、Java 8及以后是数组+链表/红黑树,1.8中,当链表长度到达阈值的时候,会转化成红黑树。
四、HashMap内容和常用方法
众所周知,HashMap是一个用于存储Key-Value键值对的集合,每一个键值对也叫做Entry。这些个键值对(Entry)分散存储在一个数组当中,这个数组就是HashMap的主干。
HashMap数组每一个元素的初始值都是Null。
对于HashMap,我们最常使用的是两个方法:Get 和 Put。
- JAVA8常用方法
- put
- 计算 key 的 hash 值。计算方式是 (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
- 检查当前数组是否为空,为空需要进行初始化,初始化容量是 16 ,负载因子默认 0.75。
- 计算 key 在数组中的坐标。计算方式:(容量 - 1) & hash.因为容量总是2的次方,所以-1的值的二进制总是全1。方便与 hash 值进行与运算。
- 如果计算出的坐标元素为空,创建节点加入,put 结束。
- 如果当前数组容量大于负载因子设置的容量,进行扩容。
- 如果计算出的坐标元素有值。如果 next 节点为空,把要加入的值和 key 加入 next 节点。
- 如果 next 节点不为空,循环查看 next 节点。
- 如果发现有 next 节点的 key 和要加入的 key 一样,对应的值替换为新值。
- 如果循环 next 节点查找超过8层还不为空,把这个位置元素转换为红黑树。
- 如果坐标上的元素值和要加入的值 key 完全一样,覆盖原有值。
- 如果坐标上的元素是红黑树,把要加入的值和 key 加入到红黑树。
- 如果坐标上的元素和要加入的元素不同(尾插法增加)。
- get
- 计算 key 的 hash 值。
- 如果存储数组不为空,且计算得到的位置上的元素不为空。继续,否则,返回 Null。
- 如果获取到的元素的 key 值相等,说明查找到了,返回元素。
- 如果获取到的元素的 key 值不相等,查找 next 节点的元素。
- 如果元素是红黑树,在红黑树中查找。
- 不是红黑树,遍历 next 节点查找,找到则返回。
- put
五、1.7和1.8的区别
HashMap是我们开发中经常使用到的集合,jdk1.8相对于1.7底层实现发生了一些改变。1.8主要优化减少了Hash冲突 ,提高哈希表的存、取效率。
- jdk7 数组+单链表 jdk8 数组+单链表/红黑树
- jdk7 链表头插 jdk8 链表尾插
头插: resize后transfer数据时不需要遍历链表到尾部再插入
头插: 最近put的可能等下就被get,头插遍历到链表头就匹配到了
头插: resize后链表可能倒序; 并发resize可能产生循环链 - jdk7 先扩容再put jdk8 先put再扩容 (jdk7的无效扩容)
- jdk7 计算hash运算多 jdk8 计算hash运算少
- jdk7 受rehash影响 jdk8 调整后是(原位置)or(原位置+旧容量)
六、常见问题解答
-
HashMap特性:
- HashMap基于哈希表存储键值对,实现快速存取数据;允许null键/值;非同步;不保证有序;实现map接口。
-
HashMap的无效扩容:
- 1.7先扩容再插入,可能无效插入会造成无效扩容
-
为什么要进行链表转红黑树的优化:
- 比如某些人通过找到你的hash碰撞值,来让你的HashMap不断地产生碰撞,那么相同key位置的链表就会不断增长,当你需要对这个HashMap的相应位置进行查询的时候,就会去循环遍历这个超级大的链表,性能及其地下。java8使用红黑树来替代超过8个节点数的链表后,查询方式性能得到了很好的提升,从原来的是O(n)到O(logn)。
-
传统hashMap的缺点(为什么引入红黑树?):
- JDK 1.8 以前 HashMap 的实现是 数组+链表,即使哈希函数取得再好,也很难达到元素百分百均匀分布。当 HashMap 中有大量的元素都存放到同一个桶中时,这个桶下有一条长长的链表,这个时候 HashMap 就相当于一个单链表,假如单链表有 n 个元素,遍历的时间复杂度就是 O(n),完全失去了它的优势。针对这种情况,JDK 1.8 中引入了 红黑树(查找时间复杂度为 O(logn))来优化这个问题。
-
为什么链表转化为红黑树的门槛是8? 为什么为6时又变成链表?
- 通俗点将就是put进去的key进行计算hashCode时 只要选择计算hash值的算法足够好(hash碰撞率极低),从而遵循泊松分布,使得桶中挂载的bin的数量等于8的概率非常小,从而转换为红黑树的概率也小,反之则概率大。
- 提供一个缓冲区间,假设只有一个阈值8,现有一个桶有7个元素,若刚好不停的对这个桶进行增删增删增删那么这个桶就会不停的在链表和红黑树之间切换,十分影响效率
-
为什么装填因子是0.75?
- 选择0.75作为默认的加载因子,完全是时间和空间成本上寻求的一种折衷选择
-
为什么使用红黑树而不是AVL
- 理论上红黑树牺牲了一些查找性能但其本身并不是完全平衡的二叉树。因此插入删除操作效率略
高于AVL树. - AVL树用于自平衡的计算牺牲了插入删除性能,但是因为最多只有一层的高度差,查询效率会高一
些。- 红黑树的特性:
- 每个节点或者是黑色,或者是红色。
- 根节点是黑色。
- 每个叶子节点(NIL)是黑色。 [注意:这里叶子节点,是指为空(NIL或NULL)的叶子节点!]
- 如果一个节点是红色的,则它的子节点必须是黑色的。
- 从一个节点到该节点的子孙节点的所有路径上包含相同数目的黑节点。[这里指到叶子节点的路径]
- 红黑树的特性:
- 理论上红黑树牺牲了一些查找性能但其本身并不是完全平衡的二叉树。因此插入删除操作效率略
-
平时在使用HashMap时一般使用什么类型的元素作为Key?
- 选择Integer,String这种不可变的类型,像对String的一切操作都是新建一个String对象,对新的对象进行拼接分割等,这些类已经很规范的覆写了hashCode()以及equals()方法。作为不可变类天生是线程安全的。
-
hashmap和concurrenthashmap:
HashMap
的线程不安全主要体现在下面两个方面:- 在JDK1.7中,当并发执行扩容操作时会造成环形链和数据丢失的情况。
- 在JDK1.8中,在并发执行put操作时会发生数据覆盖的情况。
-
JDK1.8版本的ConcurrentHashMap的数据结构已经接近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使用红黑树来优化链表,基于长度很长的链表的遍历是一个很漫长的过程,而红黑树的遍历效率是很快的,代替一定阈值的链表,这样形成一个最佳拍档
-
JDK1.8为什么使用内置锁synchronized来代替重入锁ReentrantLock,我觉得有以下几点
- 因为粒度降低了,在相对而言的低粒度加锁方式,synchronized并不比ReentrantLock差,在粗粒度加锁中ReentrantLock可能通过Condition来控制各个低粒度的边界,更加的灵活,而在低粒度中,Condition的优势就没有了
- JVM的开发团队从来都没有放弃synchronized,而且基于JVM的synchronized优化空间更大,使用内嵌的关键字比使用API更加自然
- 在大量的数据操作下,对于JVM的内存压力,基于API的ReentrantLock会开销更多的内存,虽然不是瓶颈,但是也是一个选择依据