Map
HashMap和HashTable区别
-
线程是否安全:HashMap线程不安全,HashTable线程安全。因为HashTable内部的方法都经过了synchronized关键字修饰。
HashMap线程不安全例子:如果两个线程都要往HashMap中插入数据,但是发生哈希冲突,(hash 函数计算出的插入下标是相同的)。其中一个线程刚执行完Hash冲突判断后,时间片到了,另一个线程执行,直到另一个线程操作完成,第一个线程才再次执行。由于已经判断完哈希冲突,所以直接按照原来的下标,向其中插入数据,这样就覆盖了第二个线程插入的数据,导致了数据覆盖问题。
HashTable用
synchronized
来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
-
底层数据结构:HashMap底层JDK1.8以后是数组/链表,红黑树。当链表长度大于阈值时,会进行判断。一般来说,如果长度大于64,会将链表转换为红黑树,从而减少搜索数据时间。
-
效率:因为线程安全的原因,HashTable的效率要比HashMap要低。
-
初始容量大小,和每次扩容的大小:
HashMap初始容量一般是16,并且每次扩充,容量都变成原来的2倍。如果我们给定了容量初始值,HashMap会变成你给定容量的2的幂次方。
HashTable初始容量一般是11,并且每次扩充,容量都变成原来的2n+1,如果我们给定了容量初始值,HashTable会变成你给定容量大小。
-
对 Null key 和 Null value 的支持:
HashMap
可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值,否则会抛出NullPointerException
。
HashSet与TreeMap
HashSet
HashSet实现接口是Set,向容器中添加数据利用的是add()方法。但是HashSet的底层是基于HashMap实现的。
HashSet方法是如何避免元素不重复
当把对象加入 HashSet 时,HashSet 会先计算对象的 hashcode 值来判断对象加入的位置,同时也会与其他加入的对象的 hashcode 值作比较,如果没有相符的 hashcode,HashSet 会假设对象没有重复出现,会将对象插入到相应的位置中。但是如果发现有相同 hashcode 值的对象,这时会调用对象的 equals() 方法来检查对象是否真的相同,如果相同,则 HashSet 就不会让重复的对象加入到 HashSet 中,这样就保证了元素的不重复。
为了更清楚的了解 HashSet 的添加流程,我们可以尝试阅读 HashSet 的具体实现源码,HashSet 添加方法的实现源码如下(以下源码基于 JDK 8):
// hashmap 中 put() 返回 null 时,表示操作成功
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
//从上述源码可以看出 HashSet 中的 add 方法,
实际调用的是 HashMap 中的 put,那么我们继续看 HashMap 中的 put 实现:
// 返回值:如果插入位置没有元素则返回 null,否则返回上一个元素
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
从上述源码可以看出,HashMap 中的 put() 方法又调用了 putVal() 方法,putVal() 的源码如下:
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K, V>[] tab;
Node<K, V> p;
int n, i;
//如果哈希表为空,调用 resize() 创建一个哈希表,并用变量 n 记录哈希表长度
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
/**
* 如果指定参数 hash 在表中没有对应的桶,即为没有碰撞
* Hash函数,(n - 1) & hash 计算 key 将被放置的槽位
* (n - 1) & hash 本质上是 hash % n 位运算更快
*/
if ((p = tab[i = (n - 1) & hash]) == null)
// 直接将键值对插入到 map 中即可
tab[i] = newNode(hash, key, value, null);
else {// 桶中已经存在元素
Node<K, V> e;
K k;
// 比较桶中第一个元素(数组中的结点)的 hash 值相等,key 相等
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 将第一个元素赋值给 e,用 e 来记录
e = p;
// 当前桶中无该键值对,且桶是红黑树结构,按照红黑树结构插入
else if (p instanceof TreeNode)
e = ((TreeNode<K, V>) p).putTreeVal(this, tab, hash, key, value);
// 当前桶中无该键值对,且桶是链表结构,按照链表结构插入到尾部
else {
for (int binCount = 0; ; ++binCount) {
// 遍历到链表尾部
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
// 检查链表长度是否达到阈值,达到将该槽位节点组织形式转为红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 链表节点的<key, value>与 put 操作<key, value>
// 相同时,不做重复操作,跳出循环
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 找到或新建一个 key 和 hashCode 与插入元素相等的键值对,进行 put 操作
if (e != null) { // existing mapping for key
// 记录 e 的 value
V oldValue = e.value;
/**
* onlyIfAbsent 为 false 或旧值为 null 时,允许替换旧值
* 否则无需替换
*/
if (!onlyIfAbsent || oldValue == null)
e.value = value;
// 访问后回调
afterNodeAccess(e);
// 返回旧值
return oldValue;
}
}
// 更新结构化修改信息
++modCount;
// 键值对数目超过阈值时,进行 rehash
if (++size > threshold)
resize();
// 插入后回调
afterNodeInsertion(evict);
return null;
}
从上述源码可以看出,当将一个键值对放入 HashMap 时,首先根据 key 的 hashCode() 返回值决定该 Entry 的存储位置。如果有两个 key 的 hash 值相同,则会判断这两个元素 key 的 equals() 是否相同,如果相同就返回 true,说明是重复键值对,那么 HashSet 中 add() 方法的返回值会是 false,表示 HashSet 添加元素失败。因此,如果向 HashSet 中添加一个已经存在的元素,新添加的集合元素不会覆盖已有元素,从而保证了元素的不重复。如果不是重复元素,put 方法最终会返回 null,传递到 HashSet 的 add 方法就是添加成功。
总结
HashSet 底层是由 HashMap 实现的,它可以实现重复元素的去重功能,如果存储的是自定义对象必须重写 hashCode 和 equals 方法。HashSet 保证元素不重复是利用 HashMap 的 put 方法实现的,在存储之前先根据 key 的 hashCode 和 equals 判断是否已存在,如果存在就不在重复插入了,这样就保证了元素的不重复
TreeMap
TreeMap 和 HashMap 都实现了 AbstractMap 接口,但是TreeMap多实现了 NavigableMap 接口和 SortedMap 接口。
实现 NavigableMap
接口让 TreeMap
有了对集合内元素的搜索的能力。NavigableMap
接口提供了丰富的方法来探索和操作键值对。而这些方法都是基于底层红黑树实现的。
底层结构
TreeMap、TreeSet 以及 JDK1.8 之后的 HashMap 底层都用到了红黑树。红黑树就是为了解决二叉查找树的缺陷,因为二叉查找树在某些情况下会退化成一个线性结构。
而在1.8前,HashMap是使用了数组+链表组成的链表散列。
HashMap多线程引起的死循环问题
JDK1.7 及之前版本的 HashMap
在多线程环境下扩容操作可能存在死循环问题,这是由于当一个桶位中有多个元素需要进行扩容时,多个线程同时对链表进行操作,头插法可能会导致链表中的节点指向错误的位置,从而形成一个环形链表,进而使得查询元素的操作陷入死循环无法结束。
为了解决这个问题,JDK1.8 版本的 HashMap 采用了尾插法而不是头插法来避免链表倒置,使得插入的节点永远都是放在链表的末尾,避免了链表中的环形结构。但是还是不建议在多线程下使用 HashMap
,因为多线程下使用 HashMap
还是会存在数据覆盖的问题。并发环境下,推荐使用 ConcurrentHashMap
。