1. HashMap:
1.1 Hashmap的底层结构
HahMap的底层通过散列表存储数据,该散列表是一个 Node<K,V>类型的数组,Node即是一个个节点,K和V是节点的键和值。
HashMap中,key和value都允许为null,key为null的键值对永远都放在以 table[0] 为头结点的链表中。
在每个哈希桶中,存放的许多节点以链表结构存储,哈希桶数组中存放的是链表的头节点。在JDK8之后,为了优化链表的查询效率,链表长度达到8之后,链表变为红黑树来存储节点。
Node<K,V>节点:
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
1.2 HashMap存储对象的细节
存储对象时,会先调用hashCode()方法计算出 key 的 哈希值(int),对哈希值进行运算得到哈希桶的位置。
观察HashMap中下面这段源码,取对象哈希值的高16位,并与哈希值进行异或后返回,之后再根据这个值计算出哈希桶。
这段源码看起来似乎有些多余,为什么不直接通过 hashCode() 计算得到的哈希值来计算哈希桶的?
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
再看下面这行源码,是为了计算出数组下标
(n - 1) & hash
如果 n 为16,(n-1)= 15,二进制为1111,&运算后,相当于取 hash 的低四位。如果想让&运算的结果更加随机,就得让 hash 的低四位更加随机。想让低四位更加随机,采用的方法便是与高位异或。
总结:
由于和(length-1)运算,length 绝大多数情况小于2的16次方。所以始终是hashcode 的低16位(甚至更低)参与运算。要是高16位也参与运算,会让得到的下标更加散列。
1.3 HashMap解决冲突
若哈希值冲突,则在每个哈希桶采用链表/红黑树结构进行存储,通过 equals() 判断 该对象与链表/红黑树的对象是否是同一对象,若是,则覆盖。
原则:如果两个对象相等,那么两个对象的hashcode也应当相等。因此,重写equals,就必须重写hashcode。
若两个对象的hashCode相等,不一定equals,反过来则一定。
发生哈希冲突时,HashMap采用散列表的链表法来解决哈希冲突,除此之外,散列表还有以下两种方法解决冲突:
1. 开放定址法,如果散列表地址发生冲突,就继续向下寻找空的地址。具体方式有线性探测法(F(i)=i)、二次探测法(F(i)=i^2)和双重散列法(使用多个散列函数)。
2. 再散列,当散列表中数据个数太多而引起多次发生冲突的情况,采用再散列的方式,建一个新的散列表,新散列表的容量是旧散列表的两倍,再将旧散列表的数据放进新散列表中。HashMap的扩容机制采用了再散列的方式来减少哈希冲突。
1.4 HashMap的扩容
散列表的初始容量为16,散列因子为0.75,即元素数量达到初始容量的0.75时,散列表便会自动扩容,散列表扩大为原来的两倍。
与Redis的扩容机制不同(Redis中map的扩容机制在以前文章中有提到),HashMap采用的是一“多线程协同式 rehash”,即在需要扩容时,将整个HashMap完全扩容完成后再进行读写。整个过程如下:(如果进行写操作,会有一瞬时的性能抖动)
线程A在扩容把数据从oldTable搬到到newTable,这时其他线程
进行get操作:这个线程知道数据存放在oldTable或是newTable中,直接取即可。
进行写操作:如果要写的桶位,已经被线程A搬运到了newTable。
那么这个线程知道正在扩容,它也一起帮着扩容,扩容完成后才进行put操作。
扩容的过程:
通过put添加元素时,会先判断哈希桶已使用的数量是否已达到阈值。如果已达到阈值,便将哈希桶的数量扩容两倍。
对原来的元素重新计算进行哈希桶的位置,放入新的散列表中。
jdk1.7和1.8的区别:
头插法改尾插法,避免扩容时链表逆序造成链表死循环。
1.7是先扩容后插入,1.8是先插入后扩容。至于原因是什么,我也没搞懂,不过我觉得应该是针对红黑树做出的调整。
为什么扩容是扩大两倍?因为哈希桶的数量必须为2的次幂,如果通过构造函数自定义哈希桶的数量,HashMap会将容量自动调整成2的次幂。
为什么容量必须为2的次幂呢?
通过 hashCode() 计算出key的哈希值之后,源码通过 以下一段程序计算出该键值对放在哪个索引下
(n - 1) & hash
n为散列表的长度,只有散列表的长度为 2 的整数次幂时,n-1 的二进制数才全为1。
如果不全为1,会增加碰撞的几率,因为哈希值不同的两个节点也可能会被放在同一个桶里。比如如果容量为15,(n-1)=14,即1110,8和9与1110的结果是相同的,会发生哈希碰撞。
有个数学规律,当 length = 2^n 时,X % length = X & (length - 1),也就是说,模运算可以转换为与运算。而且对于计算机来说,二进制数0,按位与,代表着翻转,降低了运算效率。
1.5 为什么HashMap线程不安全?
首先,集合在多线程情况下存元素的通病——元素覆盖。
其次,在JDK1.8之前,HashMap扩容是头插法扩容,头插法会导致链表反向,在多线程情况下扩容容易形成循环链表,造成死循环。JDK1.8之后,扩容改用尾插法,不会造成死循环。
2. HashSet:
HashSet实现了Set接口,不允许集合中出现重复的元素。
HashSet去除重复元素的原理:HashSet基于HashMap的结构进行存储,HashSet是一个 值都相等的但键不同 的HashMap,HashSet在存储键时,会根据equals去除重复的键。
HashSet中相同的 value:
// Dummy value to associate with an Object in the backing Map
private static final Object PRESENT = new Object();
为什么值为object类型,而不为null呢?
是因为 HashSet 的 add() 方法,调用的是 HashMap 的put方法,put方法又调用了putVal方法:
public boolean add(E e) {
return map.put(e, PRESENT)==null;
}
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
* @return previous value, or null if none
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict)
通过 putVal() 方法的@return可知,如果该key是第一次加入,则返回添加的value,如果以前添加过,则返回 null。
HashSet或HashMap在存储对象时,要重写 equals() 和 hashCode() 方法,hashCode()用来确定对象在散列表中的存储地址,equals()则保证了对象不重复。
3. HashTable:
Hashtable同样是基于哈希表实现的,同样每个元素是一个key-value对,其内部也是通过单链表解决冲突问题,容量不足(超过了阀值)时,同样会自动增长。与HashMap不同的是,Hashtable的key和value都不能为null。
HashTable 和 HashMap的区别在于:HashTable是线程安全的,能在多线程环境中使用。
HashTable中的方法是Synchronize的:虽然保证了线程安全,但所有的方法都共用同一把锁,效率极低。
public synchronized V put(K key, V value){}
public synchronized V remove(Object key){}
public synchronized V get(Object key) {}
public synchronized int size(){}
4. ConcurrentHashMap
为了解决多线程下HashMap不安全的情况,JUC提供了ConcurrentHashMap工具类。