简介
Java为数据结构中的映射定义了一个接口 java.util.Map,此接口主要有四个常用的实现类,分别是HashMap、Hashtable、LinkedHashMap 和 TreeMap,类继承关系如下图所示:
各个实现类的特点比较:
- HashMap:它根据键的 hashCode 值存储数据,底层为数组加链表(1.8 引入红黑树),具有很快的访问速度,但遍历顺序却是不确定的。 HashMap 最多只允许一条记录的键为 null,允许多条记录的值为 null。HashMap非线程安全,即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。如果需要满足线程安全,可以用 Collections.synchronizedMap 进行包装或者使用 ConcurrentHashMap。
- Hashtable:与 HashMap 类似,不同的是它承自Dictionary类,并且是线程安全的,任一时间只有一个线程能写 Hashtable,并发性不如 ConcurrentHashMap,因为 ConcurrentHashMap 引入了分段锁。Hashtable不建议在新代码中使用,不需要线程安全的场合可以用 HashMap 替换,需要线程安全的场合可以用 ConcurrentHashMap 替换。
- LinkedHashMap:LinkedHashMap 是 HashMap 的一个子类,保存了记录的插入顺序,在用 Iterator 遍历LinkedHashMap 时,先得到的记录肯定是先插入的,也可以在构造时带参数,按照访问次序排序。
- TreeMap:TreeMap 实现 SortedMap 接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,当用 Iterator 遍历 TreeMap 时,得到的记录是排过序的。如果使用排序的映射,建议使用 TreeMap。在使用 TreeMap 时,key必须实现 Comparable 接口或者在构造 TreeMap 传入自定义的 Comparator,否则会在运行时抛出 java.lang.ClassCastException。
对于上述四种 Map 类型的类,要求映射中的 key 是不可变对象。不可变对象是该对象在创建后它的哈希值不会被改变。如果对象的哈希值发生变化,Map 对象很可能就定位不到映射的位置了。
内部实现
从结构实现来讲,HashMap 是数组+链表+红黑树(JDK1.8增加了红黑树部分)实现的,如下所示。
数据实际上以 Node 结点(实现了 Map.Entry 接口)进行存储,采用链地址法来解决哈希冲突。
1 | static class Node<K,V> implements Map.Entry<K,V> { |
通过好的 Hash 算法和扩容机制,来减少哈希碰撞的概率和减小哈希表空间。
哈希方法
JDK 1.8 的 hash方法本质上就是三步:取 key 的 hashCode 值、高位运算、取模运算。
1 | static final int hash(Object key) { |
对比一下 JDK1.7的 HashMap 的 hash 方法源码.
1 | static int hash(int h) { |
put 方法
put 方法大致可以分为以下 6 步:
- 判断键值对数组 table[i] 是否为 null 或长度为 0,满足则 resize() 进行扩容;
- 根据 key 计算得到数组下标,如果 table[i] 直接创建节点添加,转向 6;
- 判断 table[i] 的首个元素是否和 key 一样,如果相同直接覆盖value,否则转向 4,相等指的是 hashCode 和 equals 相等;
- 判断 table[i] 是否为 TreeNode,如果是红黑树,则直接在树中插入键值对,否则转向 5;
- 遍历table[i],判断链表长度是否大于 8,大于 8 的话把链表转换为红黑树,在红黑树中执行插入操作,否则进行链表的插入操作;遍历过程中若发现 key 已经存在直接覆盖 value 即可;
- 判断 size 是否超过了阈值 threshold,如果超过,进行扩容。
源码如下
1 | public V put(K key, V value) { |
扩容机制
进行扩容,会伴随着一次重新hash分配,并且会遍历hash表中所有的元素,是非常耗时的。在编写程序中,要尽量避免resize。JDK 1.7 采用头插法,JDK 1.8 采用尾插法。
每次扩容成 2 倍大小,所以,元素要么是在原位置,要么是在原位置加上原容量的位置。
因此,我们在扩充HashMap的时候,不需要像JDK1.7的实现那样重新计算hash,只需要看看原来的hash值新增的那个 bit 是 1 还是 0 就好了,是 0 的话索引没变,是 1 的话索引变成“原索引+oldCap”。
这个设计非常巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是0还是1可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。
1 | final Node<K,V>[] resize() { |