JDK 1.7中使用的是数组+链表,JDK 1.8中,HashMap底层的数据结构是:数组+链表+红黑树。其中当链表的长度大于等于 8 时,链表会转化成红黑树,当红黑树的大小小于等于 6 时,红黑树会转化成链表,整体的数据结构如下:
图中左边竖着的是 HashMap 的数组结构,数组的元素可能是单个 Node,也可能是个链表, 也可能是个红黑树。
换一张图,HashMap内部的结构,它可以看作是数组(Node[] table)和链表结合组成的复合结构,数组被分为一个个桶(bucket),通过哈希值决定了键值对在这个数组的寻址;哈希值相同的键值对,则以链表形式存储你可以参考下面的示意图。这里需要注意的是,如果链表大小超过阈值(TREEIFY_THRESHOLD, 8),图中的链表就会被改造为红黑树。
一、类注释
1、允许 null 值,不同于 HashTable,是线程不安全的;
2、load factor(负载因子) 默认值是 0.75,是均衡了时间和空间损耗算出来的值,较高的值会减少空间开销(扩容减少,数组大小增长速度变慢),但增加了查找成本(hash 冲突增加,链表长度变长),较低的值会引起频繁的扩容,扩容操作是非常消耗性能的;
3、扩容的条件:数组容量* load factor <需要的数组大小 ;
4、如果有很多数据需要储存到 HashMap 中,建议 HashMap 的容量一开始就设置成足够的大小,这样可以防止在其过程中不断的扩容,影响性能。
二、常见属性
//初始容量为16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;//aka 16
//最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
//负载因子默认值
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//桶上的链表长度大于等于8时,链表转化成红黑树
static final int TREEIFY_THRESHOLD = 8;
//桶上的红黑树大小小于等于6时,红黑树转化成链表
static final int UNTREEIFY_THRESHOLD = 6;
//当数组容量大于 64 时,链表才会转化成红黑树
static final int MIN_TREEIFY_CAPACITY = 64;
//记录迭代过程中 HashMap 结构是否发生变化,如果有变化,迭代时会 fail-fast
transient int modCount;
//HashMap 的实际大小,可能不准(因为当你拿到这个值的时候,可能又发生了变化)
transient int size;
//存放数据的数组
transient Node<K,V>[] table;
// 扩容的门槛,有两种情况
// 如果初始化时,给定数组大小的话,通过 tableSizeFor 方法计算,数组大小永远接近于 2 的幂次方(有一个公式的)
// 如果是通过 resize 方法进行扩容,实际使用长度 = 数组容量 * 0.75
int threshold;
//链表的节点
static class Node<K,V> implements Map.Entry<K,V>
//红黑树的节点
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V>
三、新增节点
新增的步骤如下:
1、空数组有没有初始化,没有的话需要初始化;
2、通过 key 的 hash,然后结合数组长度,计算得数组下标;
3、如果 key 的 hash 值在 HashMap 中不存在,则执行插入,若存在,则发生碰撞;
4、如果 K 的 hash 值在 HashMap 中存在,且它们两者 equals 返回 true,则更新键值对;
5、如果 K 的 hash 值在 HashMap 中存在,且它们两者 equals 返回 false,则插入链表的尾部(尾插法)或者红黑树中(树的添加方式)。
6、如果链表长度超过阈值,就把链表转化成红黑树;
7、判断是否需要扩容,需要扩容进行扩容,结束。
(JDK 1.7 之前使用头插法、JDK 1.8 使用尾插法)(注意:当碰撞导致链表大于 TREEIFY_THRESHOLD = 8 时,就把链表转换成红黑树)。
hashCode 是定位的,存储位置;equals 是定性的,比较两者是否相等。
总结:要把键值对<key,value>放进去,首先会根据key计算出它的hash值,应该放到哪个位置,如果一开始数组是空的,那就会先resize初始化数组,初始化完后,就要去放键值对了,找到计算出的位置,如果那个索引位置是空的,就会直接新建Node,那就直接放在索引位置上,如果有值了,发生hash冲突了,那就要进行处理,判断是以红黑树还是链表的方式新增,递增完要更新版本号,并且如果HashMap的大小大于阈值了,就要resize,源码如下:
put()方法:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
上面的代码里,进行了 2 步操作,先通过 hash() 函数对 key 求 hash 码,然后再进一步调用 putVal()。那么先来分析 hash() 函数吧。
static final int hash(Object key) {
int h;
// 如果为 null 则返回的就是 0,否则就是 hashCode 异或上 hashCode 无符号右移 16 位
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
注释里有简要说明了 hash 值的产生方法,得到的结果就是 hashCode 的高 16 位不变,低 16 位与高 16 位做一个异或。这样做的目的是同时把高 16 位和低 16 位的影响都考虑进来以减少小容量 HashMap 的散列冲突。当然,这也与 HashMap 中计算散列后的 index 的方法有关。计算散列 index 的实现在 putVal() 方法里,不防先来看一看。
i = (n - 1) & hash
可以看到,HashMap 并没有采用 %(取余) 这种简单粗暴的实现,而是使用 &(按位与) 来分布散列 index 的生成,其主要目的当然是尽量减少碰撞冲突。相比较来说 % 的碰撞冲突应该是非常高的。再来说上面的为什么要同时考虑到高 16 位与低 16 位的影响。capacity 的容量大小是 2 的 n 次幂,试想一下如果不做异或,而只是用原 hashcode ,那么在小 map 中,能起作用的就永远只是低位,虽然 hashCode 的生成已经分布的很平衡了,但相比较而前,同时考虑到高位与低位的影响,最后计算出的散列 index 发生碰撞的冲突肯定要小的多。关于 hash() 方法的实现,其实设计者也作了比较详尽的解释,比如其还提到,没有采用更复杂的生成 hash 方法,也是出于效率考虑。而对于大的 map 发生的散列冲突,其采用了红黑树来提高了查询的效率。感兴趣的可以看看原设计者的注释。
接下来看看 putVal() 方法
// 入参 hash:通过 hash 算法计算出来的值。
// 入参 onlyIfAbsent:false 表示即使 key 已经存在了,仍然会用新值覆盖原来的值,默认为 false
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
// n 表示数组的长度,i 为数组索引下标,p 为 i 下标位置的 Node 值
Node<K,V>[] tab; Node<K,V> p; int n, i;
//如果数组为空,使用 resize 方法初始化
if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length;
// 如果当前索引位置是空的,直接生成新的节点在当前索引位置上
if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null);
// 如果当前索引位置有值(hash 冲突了),如何解决 hash 冲突
else {
// e为当前节点的临时变量
Node<K,V> e; K k;
// 如果 key 的 hash 和值都相等,直接把当前下标位置的 Node 值赋值给临时变量
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果是红黑树,使用红黑树的方式新增
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 否则是个链表,把新节点放到链表的尾端
else {
//自旋操作
for (int binCount = 0; ; ++binCount) {
// e = p.next 表示从头开始,遍历链表
// p.next == null 表明 p 是链表的尾节点
if ((e = p.next) == null) {
// 把新节点放到链表的尾部
p.next = newNode(hash, key, value, null);
// 当链表的长度大于等于 8 时,链表转红黑树
if (binCount >= TREEIFY_THRESHOLD - 1)
// -1 for 1st
treeifyBin(tab, hash);
break;
}
// 链表遍历过程中,发现有元素和新增的元素相等,结束循环
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break;
//更改循环的当前元素,使 p 在遍历过程中,一直往后移动。
p = e;
}
}
// 说明新节点的新增位置已经找到了
if (e != null) {
// existing mapping for key
V oldValue = e.value;
// 当 onlyIfAbsent 为 false 时,才会覆盖值
if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e);
// 返回老值
return oldValue;
}
}
// 记录 HashMap 的数据结构发生了变化
++modCount;
//如果 HashMap 的实际大小大于扩容的门槛,开始扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
1、链表新增节点
链表的新增比较简单,就是把当前节点追加到链表的尾部,和 LinkedList 的追加实现一样的。当链表长度大于等于 8 时,此时的链表就会转化成红黑树,转化的方法是treeifyBin(treeify是树化的意思),此方法有一个判断,当链表长度大于等于 8,并且整个数组大小大于 64 时,才会转成红黑树,当数组大小小于 64 时,只会触发扩容,不会转化成红黑树。
可能面试的时候,有人问你为什么是 8,这个答案在源码中注释有说,中文翻译过来大概的意思是:
链表查询的时间复杂度是 O (n),红黑树的查询复杂度是 O (log (n))。在链表数据不多的时候, 使用链表进行遍历也比较快,只有当链表数据比较多的时候,才会转化成红黑树,但红黑树需要的占用空间是链表的 2 倍(源码注释中写的),考虑到转化时间和空间损耗,所以要定义出这个转化的边界值:
在设计 8 这个值的时候,参考了泊松分布概率函数,由泊松分布中得出结论,链表各个长度的命中概率为:
当链表的长度是 8 的时候,出现的概率是 0.00000006,不到千万分之一,所以说正常情况下,链表的长度不可能到达 8 ,而一旦到达 8 时,肯定是 hash 算法出了问题,所以在这种情况下,为了让 HashMap 仍然有较高的查询性能,所以让链表转化成红黑树,我们正常写代码,使用 HashMap 时,几乎不会碰到链表转化成红黑树的情况,毕竟概念只有千万分之 一。
2、红黑树新增节点
首先判断新增的节点在红黑树上是不是已经存在,判断手段有如下两种:
1、如果节点没有实现 Comparable 接口,使用 equals 进行判断;
2、如果节点自己实现了 Comparable 接口,使用 compareTo 进行判断。
新增的节点如果已经在红黑树上,直接返回;不在的话,判断新增节点是在当前节点的左边还是右边,左边值小,右边值大;
自旋递归 1 和 2 步,直到当前节点的左边或者右边的节点为空时,停止自旋,当前节点即为我们新增节点的父节点;
把新增节点放到当前节点的左边或右边为空的地方,并于当前节点建立父子节点关系;
进行着色和旋转,结束。
源码如下:
//入参h为hash值
final TreeNode<K,V> putTreeVal(HashMap<K,V> map, Node<K,V>[] tab,int h, K k, V v) {
Class<?> kc = null;
boolean searched = false;
//找到根节点
TreeNode<K,V> root = (parent != null) ? root() : this;
//自旋
for (TreeNode<K,V> p = root;;) {
int dir, ph; K pk;
// p hash 值大于 h,说明 p 在 h 的右边(左小右大)
if ((ph = p.hash) > h)
dir = -1;
// p hash 值小于 h,说明 p 在 h 的左边
else if (ph < h)
dir = 1;
// 要放进去key在当前树中已经存在了(equals来判断)
else if ((pk = p.key) == k || (k != null && k.equals(pk)))
return p;
// 自己实现的Comparable的话,不能用hashcode比较了,需要用compareTo
else if ((kc == null &&
// 得到key的Class类型,如果key没有实现Comparable就是null
(kc = comparableClassFor(k)) == null) ||
//当前节点pk和入参k不等
(dir = compareComparables(kc, k, pk)) == 0) {
if (!searched) {
TreeNode<K,V> q, ch;
searched = true;
if (((ch = p.left) != null && (q = ch.find(h, k, kc)) != null) || ((ch = p.right) != null && (q = ch.find(h, k, kc)) != null))
return q;
}
dir = tieBreakOrder(k, pk);
}
TreeNode<K,V> xp = p;
//找到和当前hashcode值相近的节点(当前节点的左右子节点其中一个为空即可)
if ((p = (dir <= 0) ? p.left : p.right) == null) {
Node<K,V> xpn = xp.next;
//生成新的节点
TreeNode<K,V> x = map.newTreeNode(h, k, v, xpn);
//把新节点放在当前子节点为空的位置上
if (dir <= 0)
xp.left = x;
else
xp.right = x;
//当前节点和新节点建立父子,前后关系
xp.next = x;
x.parent = x.prev = xp;
if (xpn != null)
((TreeNode<K,V>)xpn).prev = x;
//balanceInsertion 对红黑树进行着色或旋转,以达到更多的查找效率
//着色:新节点总是为红色;如果新节点的父亲是黑色,则不需要重新着色
//如果父亲是红色,则只能是黑色
//旋转: 父亲是红色,叔叔是黑色时,进行旋转
//如果当前节点是父亲的右节点,则进行左旋 //如果当前节点是父亲的左节点,则进行右旋
//moveRootToFront 方法是把算出来的root放到根节点上
moveRootToFront(tab, balanceInsertion(root, x));
return null;
}
}
}
一般只会问到新增节点到红黑树上大概是什么样的一个过程,着色和旋转的细节不会问,因为很难说清楚,但我们要清楚着色指的是给红黑树的节点着上红色或黑色,旋转是为了让红黑树更加平衡,提高查询的效率,总的来说都是为了满足红黑树的 5 个原则:
1、根节点是黑色的
2、叶子节点都是黑色
3、节点是红色或者黑色
4、从任一节点到它每个叶子的所有简单路径都包含相同数目的黑色节点
5、从每个叶子到根的所有路径上不能有两个连续的红色节点(如果一个节点是红色的,那么它的孩子节点都是黑色的)
resize() 我们还是要深入了解一下的,体会一下扩容的代价到底有多大。
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
// 超过最大值就不再扩充 table,但并不表示不能插入了,只是后面的只能碰撞冲突了
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 没超过最大值,就扩充为原来的 2 倍。主要是容量以及阈值都为原来的 2倍。容量和阈值本身就都必须是 2 的幂,所以扩容的倍数必须是2的倍数,那么扩2倍就非常合理了。
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 计算新的resize阈值
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
// 重新分配内存
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
if (oldTab != null) {
// 把原来 tables 中的每个节点都移动到新的 tables 中
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)// 没有冲突,那重新计算下位置
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)// 冲突的是一棵树节点,分裂成 2 个树,或者如果树很小就转成链表
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order,冲突构成的是链表
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 索引不变
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 原索引+oldCap
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 原索引放到 tables 里
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 原索引+oldCap放到 tables 里
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
resize() 里面关键做了2个耗时耗力的事情:一是分配了 2 倍的 tables 的空间,最糟糕的情况是扩容完后不再有插入了。二是将旧的 tables 放入新的 tables 中,这里就包括了 index 的重新计算,链表冲突重新分布,tree 冲突分裂或者转化成链表。
四、查找
HashMap 的查找主要分为以下三步:
- 根据 hash 算法定位数组的索引位置,equals 判断当前节点是否是我们需要寻找的 key,是的话直接返回,不是的话往下。
- 判断当前节点有无 next 节点,有的话判断是链表类型,还是红黑树类型。
- 分别走链表和红黑树不同类型的查找方法。
链表查找关键源码如下:
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 命中
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
// 直接命中了 tables 中的 node
return first;
// 未命中 tables 中的 node
if ((e = first.next) != null) {// 存在碰撞冲突的情况
// 如果是红黑树
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 否则认为是链表
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
// 未命中返回 null
return null;
}
红黑树查找代码思路:
- 从根节点递归查找;
- 根据 hashcode,比较查找节点,左边节点,右边节点之间的大小,根本红黑树左小右大的特性进行判断;
- 判断查找节点在第 2 步有无定位节点位置,有的话返回,没有的话重复 2,3 两步;
- 一直自旋到定位到节点位置为止。
如果红黑树比较平衡的话,每次查找的次数就是树的深度。
五、删除
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
// 第一步:先查找
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
// 命中
Node<K,V> node = null, e; K k; V v;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 在 tables 中就命中了
node = p;
else if ((e = p.next) != null) {
if (p instanceof TreeNode)
// 在红黑树中找
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
// 在链表中找
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
// 第二步:删除
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
// 找到了就要删除掉
if (node instanceof TreeNode)
// 从树中移除
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
else if (node == p)
// 从tables节点中删除
tab[index] = node.next;
else
// 从链表中删除
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
// 未命中直接返回 null
return null;
}
remove() 的实现分了两个大步骤进行的,先查找再删除,而且查找的过程与 get() 实现非常类似。
五、其他问题
(1)初始容量为什么必须是2的幂次方?能不能传别的数进去?
哈希桶的个数为2的幂次方, 在JDK1.7中,如果还没初始化它就会进行初始化,如果输入其他数字,它初始化的时候会调用一个roundUpToPowerOf2方法,就是把它调整为2的幂。
- 我们将一个键值对插入HashMap中,通过将Key的hash值与length-1进行&运算,实现了当前Key的定位,2的幂次方可以减少冲突(碰撞)的次数,提高HashMap查询效率;
- 如果length为2的幂次方,则length-1 转化为二进制必定是11111……的形式,在与h的二进制与操作效率会非常的快,而且空间不浪费;
- 如果length不是2的幂次方,比如length为15,则length-1为14,对应的二进制为1110,在与h与操作,最后一位都为0,而0001,0011,0101,1001,1011,0111,1101这几个位置永远都不能存放元素了,空间浪费相当大,更糟的是这种情况中,数组可以使用的位置比数组长度小了很多,这意味着进一步增加了碰撞的几率,减慢了查询的效率!这样就会造成空间的浪费。
总的来说2的N次幂有助于减少碰撞的几率,空间利用率比较大
(2)当两个对象的hashcode相同会发生什么?
hashcode相同,说明两个对象HashMap数组的同一位置上,接着HashMap会遍历链表中的每个元素,通过key的equals方法来判断是否为同一个key,如果是同一个key,则新的value会覆盖旧的value,并且返回旧的value。如果不是同一个key,则存储在该位置上的链表的链头。
(3)如果两个键的hashcode相同,你如何获取值对象?
遍历HashMap链表中的每个元素,并对每个key进行hash计算,最后通过get方法获取其对应的值对象
(4)你了解重新调整HashMap大小存在什么问题吗?
当多线程的情况下,可能产生条件竞争。当重新调整HashMap大小的时候,确实存在条件竞争,如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在链表中的元素的次序会反过来,因为移动到新的数组位置的时候,HashMap并不会将元素放在LinkedList的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了。
(5)为什么HashMap不用LinkedList,而选用数组?
因为用数组效率最高! 在HashMap中,定位桶的位置是利用元素的key的哈希值对数组长度取模得到。此时,我们已得到桶的位置。显然数组的查找效率比LinkedList大。
(6)那ArrayList,底层也是数组,查找也快啊,为啥不用ArrayList?
因为采用基本数组结构,扩容机制可以自己定义,HashMap中数组扩容刚好是2的次幂,在做取模运算的效率高。 而ArrayList的扩容机制是1.5倍扩容。
(7)为什么扩容是2的次幂?
HashMap为了存取高效,要尽量较少碰撞,就是要尽量把数据分配均匀,每个链表长度大致相同,这个实现就在把数据存到哪个链表中的算法;
这个算法实际就是取模,hash%length。 但是,大家都知道这种运算不如位移运算快。
因此,源码中做了优化hash&(length-1)。 也就是说hash%length==hash&(length-1)
那为什么是2的n次方呢?
因为2的n次方实际就是1后面n个0,2的n次方-1,实际就是n个1。 例如长度为8时候,3&(8-1)=3 2&(8-1)=2 ,不同位置上,不碰撞。 而长度为5的时候,3&(5-1)=0 2&(5-1)=0,都在0上,出现碰撞了。 所以,保证容积是2的n次方,是为了保证在做(length-1)的时候,每一位都能&1 ,也就是和1111……1111111进行与运算。
(8)hash方法怎么实现的?为什么要用异或^运算符?
JDK 1.8 中,是通过 hashCode() 的高 16 位异或低 16 位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度,功效和质量来考虑的,减少系统的开销,使用异或保证了对象的 hashCode 的 32 位值只要有一位发生改变,整个 hash() 返回值就会改变,也不会造成因为高位没有参与下标的计算,从而引起的碰撞。这样能尽可能减少碰撞。
(9)你还知道哪些hash算法?
先说一下hash算法干嘛的,Hash函数是指把一个大范围映射到一个小范围。把大范围映射到一个小范围的目的往往是为了节省空间,使得数据容易保存。 比较出名的有MurmurHash、MD4、MD5等等。
(10)说说String中hashcode的实现?(此题频率很高)
public int hashCode() {
int h = hash;
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
h = 31 * h + val[i];
}
hash = h;
}
return h;
}
String类中的hashCode计算方法还是比较简单的,就是以31为权,每一位为字符的ASCII值进行运算,用自然溢出来等效取模。
哈希计算公式可以计为s[0]*31^(n-1) + s[1]*31^(n-2) + … + s[n-1]
那为什么以31为质数呢?
主要是因为31是一个奇质数,所以31i=32i-i=(i<<5)-i,这种位移与减法结合的计算相比一般的运算快很多。
(11)为什么hashmap的在链表元素数量超过8时改为红黑树?
为什么在解决hash冲突的时候,不直接用红黑树?而选择先用链表,再转红黑树? 因为红黑树需要进行左旋,右旋,变色这些操作来保持平衡,而单链表不需要。 当元素小于8个当时候,此时做查询操作,链表结构已经能保证查询性能。当元素大于8个的时候,此时需要红黑树来加快查询速度,但是新增节点的效率变慢了。因此,如果一开始就用红黑树结构,元素太少,新增效率又比较慢,无疑这是浪费性能的。
我不用红黑树,用二叉查找树可以么? 可以。但是二叉查找树在特殊情况下会变成一条线性结构(这就跟原来使用链表结构一样了,造成很深的问题),遍历查找会非常慢。
(12)知道jdk1.8中hashmap改了啥么?
1、在java 1.8中,如果链表的长度超过了8,那么链表将转换为红黑树。(桶的数量必须大于64,小于64的时候只会扩容);
2、优化了高位运算的hash算法:h^(h>>>16);
3、发生hash碰撞时,java 1.7 会在链表的头部插入,而java 1.8会在链表的尾部插入;
4、在java 1.8中,Entry被Node替代(换了一个马甲)。
(13)当链表转为红黑树后,什么时候退化为链表?
为6的时候退转为链表。中间有个差值7可以防止链表和树之间频繁的转换。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。
(14)HashMap在并发编程环境下有什么问题啊?
(1)多线程扩容,引起的死循环问题
(2)多线程put的时候可能导致元素丢失
(3)put非null元素后get出来的却是null
(15)在jdk1.8中还有这些问题么?
在jdk1.8中,死循环问题已经解决。其他两个问题还是存在。
你一般怎么解决这些问题的?
比如ConcurrentHashmap,Hashtable等线程安全等集合类。
(16)你一般用什么作为HashMap的key?
String和Interger这样的包装类很适合做为HashMap的键,因为他们是final类型的类,而且重写了equals和hashCode方法,避免了键值对改写,有效提高HashMap性能。
为了计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashCode的话,那么就不能从HashMap中找到你想要的对象。
(1)因为字符串是不可变的,所以在它创建的时候hashcode就被缓存了,不需要重新计算。这就使得字符串很适合作为Map中的键,字符串的处理速度要快过其它的键对象。这就是HashMap中的键往往都使用字符串。
(2)因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的,这些类已经很规范的覆写了hashCode()以及equals()方法。
(17)我用可变类当HashMap的key有什么问题?
hashcode可能发生改变,导致put进去的值,无法get出。
(18)如果让你实现一个自定义的class作为HashMap的key该如何实现?
此题考察两个知识点
重写hashcode和equals方法注意什么?
如何设计一个不变类
针对问题一,记住下面四个原则即可
(1)两个对象相等,hashcode一定相等 (2)两个对象不等,hashcode不一定不等 (3)hashcode相等,两个对象不一定相等 (4)hashcode不等,两个对象一定不等
针对问题二,记住如何写一个不可变类
(1)类添加final修饰符,保证类不被继承。 如果类可以被继承会破坏类的不可变性机制,只要继承类覆盖父类的方法并且继承类可以改变成员变量值,那么一旦子类以父类的形式出现时,不能保证当前类是否可变。
(2)保证所有成员变量必须私有,并且加上final修饰 通过这种方式保证成员变量不可改变。但只做到这一步还不够,因为如果是对象成员变量有可能再外部改变其值。所以第4点弥补这个不足。
(3)不提供改变成员变量的方法,包括setter 避免通过其他接口改变成员变量的值,破坏不可变特性。
(4)通过构造器初始化所有成员,进行深拷贝(deep copy) 如果构造器传入的对象直接赋值给成员变量,还是可以通过对传入对象的修改进而导致改变内部变量的值。
(19)你知道 hash 的实现吗?为什么要这样实现?
JDK 1.8 中,是通过 hashCode() 的高 16 位异或低 16 位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度,功效和质量来考虑的,减少系统的开销,也不会造成因为高位没有参与下标的计算,从而引起的碰撞。
(20)为什么要用异或运算符?
保证了对象的 hashCode 的 32 位值只要有一位发生改变,整个 hash() 返回值就会改变。尽可能的减少碰撞。
(21)数组扩容的过程?
创建一个新的数组,其容量为旧数组的两倍,并重新计算旧数组中结点的存储位置。结点在新数组中的位置只有两种,原下标位置或原下标+旧数组的大小。
(22)HashMap & TreeMap & LinkedHashMap 使用场景?
一般情况下,使用最多的是 HashMap。
HashMap:在 Map 中插入、删除和定位元素时;
TreeMap:在需要按自然顺序或自定义顺序遍历键的情况下;
LinkedHashMap:在需要输出的顺序和输入的顺序相同的情况下。
(23).HashMap,LinkedHashMap,TreeMap 有什么区别?
HashMap 参考其他问题;
LinkedHashMap 保存了记录的插入顺序,在用 Iterator 遍历时,先取到的记录肯定是先插入的;遍历比 HashMap 慢;
TreeMap 实现 SortMap 接口,能够把它保存的记录根据键排序(默认按键值升序排序,也可以指定排序的比较器)。
(24)HashMap 和 HashTable 有什么区别?
①、HashMap 是线程不安全的,HashTable 是线程安全的;
②、由于线程安全,所以 HashTable 的效率比不上 HashMap;
③、HashMap最多只允许一条记录的键为null,允许多条记录的值为null,而 HashTable不允许;
④、HashMap 默认初始化数组的大小为16,HashTable 为 11,前者扩容时,扩大两倍,后者扩大两倍+1;
⑤、HashMap 需要重新计算 hash 值,而 HashTable 直接使用对象的 hashCode。
(25)Java 中的另一个线程安全的与 HashMap 极其类似的类是什么?同样是线程安全,它与 HashTable 在线程同步上有什么不同?
ConcurrentHashMap 类(是 Java并发包 java.util.concurrent 中提供的一个线程安全且高效的 HashMap 实现)。
HashTable 是使用 synchronize 关键字加锁的原理(就是对对象加锁);
而针对 ConcurrentHashMap,在 JDK 1.7 中采用 分段锁的方式;JDK 1.8 中直接采用了CAS(无锁算法)+ synchronized。
(26)为什么 ConcurrentHashMap 比 HashTable 效率要高?
HashTable 使用一把锁(锁住整个链表结构)处理并发问题,多个线程竞争一把锁,容易阻塞;
而ConcurrentHashMap在JDK 1.7 中使用分段锁(ReentrantLock + Segment + HashEntry),相当于把一个 HashMap 分成多个段,每段分配一把锁,这样支持多线程访问。锁粒度:基于 Segment,包含多个 HashEntry。
JDK 1.8 中使用 CAS + synchronized + Node + 红黑树。锁粒度:Node(首结点)(实现 Map.Entry<K,V>)。锁粒度降低了。
(27)JDK7中HashMap的问题?
在多线程环境下,JDK 7中数组+链表的HashMap实现,有可能出现成环,然后就死循环,并且很难重现。
JAVA 7它是头插法,啥意思呢,比如我们本来有个hash表 某个节点上它是下面这样的:
在这里插入代码
Node1 -> key:1,value:1 -> key:2,value:2 -> key:3,value:3 经过扩容rehash后,变成了
Node1 -> key:3,value:3
Node2 -> key:2,value:2 -> key:1,value:1
因为它是头插法,我先把key=1的摘下来,插进一个新的节点,然后再把2摘下来,头插刀新的节点,2就变到了1的前面了!那这样一来顺序就反了。在多线程的情况下,线程调度本来就随机的嘛。原先是1->2,扩容后变为2->1,这样就可能出现死锁,出现环形链表。正常情况查找元素是找到头,往下挨个找,变成环,就有死循环了,线上系统cpu 100%。
(28)JDK 8中对HashMap的改进?
扩容时插入顺序的改进,JDK 1.7是头插法,1.8改成了尾插把,特别加了个注释preserve order,保持顺序。
之前讲了数组长度为什么是2的幂次方,这里扩容的时候,比如把24扩容成25,就是由1111变为11111,那么hashCode和它与运算,最高位要么多个1,要么多个0,后面四位不变的,那么它针对hash桶上某个index上对应的链表,这个链表会拆分成两部分,一部分留在原地,另一部分被移走了,这边就把这条链表拆分成了高位和低位,判断哪一部分是要移动的,并且移动的时候顺序是保持的,不会再像JDK 7中出现环,完事后,把高位链表和低位链表赋到新的hash桶中的index下去。
(29)Hashmap为什么用红黑树而不用AVL树?
红黑树更加通用,在添加,删除,查找方面表现比较好,AVL树查找更快,代价是添加、删除速度慢。
(30)hash冲突还知道哪些解决办法?
拉链法 (HashMap使用的方法),比较出名的有四种(1)开放定址法(2)链地址法(3)再哈希法(4)公共溢出区域法。