在之前的文章中,已经发布了常见的面试题,这里我花了点时间整理了一下对应的解答,由于个人能力有限,不一定完全到位,如果有解答的不合理的地方还请指点,在此谢过。
本文主要描述的是java面试中几乎必问的集合内容,java的集合是jdk里面重要的内容,也是我们平时开发过程中最常用到的,所以无论是否为了准备面试,我们都要掌握好集合相关的知识。既然是重点,那就意味着集合类在java面试中的题目会非常多,本文中点描述map,尤其会解析hashmap的基本操作和相关的源码解析,对于可能涉及到的多线程concurrenthashmap,会放到后续多线程里面说明。
说下HashMap的put和get流程?
我们直接看下代码,put流程:
//存入值
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)//如果hash未初始化
n = (tab = resize()).length;//扩容,返回大小
if ((p = tab[i = (n - 1) & hash]) == null)//(n - 1) & hash 是对hash值对n求余,代表该hash桶为空
tab[i] = newNode(hash, key, value, null);//该hash桶存入第一个元素
else {
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))//如果是同一个key,直接覆盖
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的下一个节点是空值
p.next = newNode(hash, key, value, null);//创建新的节点
if (binCount >= TREEIFY_THRESHOLD - 1) // 判断是否大于门限值
treeifyBin(tab, hash);//将链表转换为红黑树
break;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))//如果是当前这个节点
break;//直接退出
p = e;//否则,进行下一个节点循环
}
}
if (e != null) { // 找到 为该key的值
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)//判断是否更新
e.value = value;
afterNodeAccess(e);//回调获取到相同key的动作
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();//如果节点大于门限值,扩容
afterNodeInsertion(evict);//回调节点添加完成的动作
return null;
}
我们把上面的代码的基本流程图梳理如下:
Get方法的代码如下:
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) {//map中存在值,长度>0,且该hash桶中存在值,求hash桶的过程
if (first.hash == hash && // 判断第一个hash值是否相等
((k = first.key) == key || (key != null && key.equals(k))))//判断key是不是相等,且equeals是否相等
return first;
if ((e = first.next) != null) {//寻找同一个hash桶后面的数据
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);
}
}
return null;
}
从上面的流程我们可以看出,hashmap基本会找到hash桶的第一个值,立马判断,这样可以加快速度。
说说hashMap的hash函数的实现?
我们直接看下代码是怎么实现的:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这里有三个流程:求key的hashcode值h,h右移16位h’,h和h’进行异或。
为什么会有这么复杂,直接hashcode不行么?实际上,我们大概会有一个猜想就是hash函数的好坏决定了hash碰撞的次数。所以这样的设计很大程度上是为了减少hash碰撞。一个int值右移16位就变成了只有高16位。所以这个hash函数是原来hash值的高低16位进行异或。这样做是否能降低碰撞?上面我们在看put和get的过程时看到计算hash桶是n-1 &hash,n的取值是2^n(n>=4),(至于n的长度下面我们会说到)所以n-1基本就是15,31,63...对应的二进制就是:
在n不是很大的情况下,基本上高16位都是0,这个如果直接和key.hash进行&运算的话,那就会导致key的hash高16位全部为0.如果我们将key.hash高低位进行异或就能把高位的信息也保留下来。这样就大大减少hash碰撞的可能了。
说说hashMap的扩容过程?啥时候会发生扩容?
Hashmap的初始容量是16。这个在代码中定义如下:
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
然后看下具体的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) {
if (oldCap >= MAXIMUM_CAPACITY) {//如果旧的容量大于等于最大值
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)//老的容量*2
newThr = oldThr << 1; // double threshold门限值*2
}
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);//默认初始值
}
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];//初始化扩容后hash桶数组
table = newTab;
if (oldTab != null) {//以下数据迁移
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;//e.hash & (newCap - 1)为对应的新的hash桶的位置
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);//红黑树迁移
else { // 链表迁移,将被分成两个链表,高位和低位,这个是基于扩容为2倍,计算新的时候就是最高位的0或1来决定
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) {//为0是低位
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {//1是高位
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;//低位还是在原来的位置
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;//高位的被分离出去,移到(j + oldCap)位置
}
}
}
}
}
return newTab;
}
整个扩容过程:
这里有两点是需要注意的:
- 每次扩容是2的倍数的原因在于:使用2的倍数可以将%转换为&。试想一下,一般的hash桶计算就是对长度%,如果长度为2的倍数的时候,%可以转换为&。比如:21%16 == 21&(16-1)
- 在进行数据迁移的时候,如果hash&oldCap ==0 放入原来的(old)桶位置,而==1 放入(old+oldCap)桶中,这个是什么意思呢?我们看一个示意图:假设原来的16,扩容后32,原来的hash值为15,31都存在第16号桶,扩容后就会变成15在原来的16号桶,31到了扩容后的32号桶。
说完了扩容的过程,我们看下啥时候扩容,这里有个概念需要提一下,负载因子,hashmap的默认负载因子是0.75(至于为什么是0.75,源码的解释是基于时间和空间的考虑)当以下条件时会发生扩容
- 当存入的个数大于hash桶长度的0.75倍时
- 当转换为红黑树的时候,发现长度比红黑树的最小值(64)还小的时候,会进行扩容。
说说hashMap的数据结构?
说说hashMap中使用红黑树的作用?为什么不一直使用红黑树?什么时候进行转换?
当hash桶某个节点的数量太多时,如果一直使用链表的话,会导致查询的速度也很慢,为了提高查询的数据,在某个hash桶中的数量大于等于8的时候,会发生转换,将链表转换为红黑树。在扩容时候,红黑树的节点数少于等于6个的时候,红黑树会退化成链表。
红黑树为了达到重平衡的功能,需要进行左右旋转和换色操作,在节点数较少时使用红黑树反而导致效率变低,所以在节点数过少的时候,采用链表。
用最简单的语言描述下红黑树,以及其涉及的三个操作,换色,左旋和右旋?
红黑树有几个重要的特性:
1)节点不是黑就是红
2)叶子节点是黑色
3)叶子节点到根节点之间的黑色节点数一样多(保证一定的查找速率)
4)红色节点的子节点是黑色
5)根节点是黑节点
在描述操作之前,我们需要知道所有插入的点为红色,
换色条件:当前节点的父亲节点和叔叔节点也是红色节点
操作:
- 父节点和叔叔节点设置为黑色
- 祖父节点设置为红色
- 移动指针到祖父节点
左旋:父节点为红色,叔叔节点为黑色,且当前节点是右子树
操作:以父节点进行左旋
右旋:父亲节点是红色,叔叔节点是黑色,在左边的时候
操作:
- 把父亲节点变为黑色
- 把祖父节点变为红色
- 以祖父节点右旋
下面看下左右旋转的示意图:
hashMap、hashtable和concurrentHashMap的区别?
Concurrent系列是java线程安全的集合类,在这里不做详细说明。只是简单的对比一下几个map的特性。可以认为hashtable是jdk一段时间的产物,在concurrenthashmap出现之后,基本就被完全替换了。Hashtable和concurrenthashmap都是线程安全的,而concurrenthashmap基本和hashmap实现一致,除了线程安全之外。具体的区别如下:
| Hashmap | Hashtable | ConcurrenthashMap |
初始容量 | 16 | 11 | 16 |
Key、value是否可为空 | 可以 | 不可以 | 不可以 |
线程是否安全 | 不安全 | 安全 | 安全 |
Hash桶的计算 | hash&(tab.length-1) | (hash&0x7FFFFFFF) % tab.length | hash&(tab.length-1) |
Hashmap、treemap、linkedhashmap的区别?
linkedHashMap 底层是hashmap+双向链表的结构,其作用是使用双向链表记录放入的顺序性,而hashmap默认存入是无序的。Treemap底层使用的是红黑树结构存储,其排列的先后顺序是按照key的顺序来排列的。所以基本上我们认为如果一个map想按照某个顺序排列,会使用treemap,如果想要记录插入的先后顺序会使用linkedhashmap。(更多关于linkedhashmap和treemap的源码知识,可以观看公众号里面jdk源码系列文章)
设计实现一个LRU?
LRU(Last recent used)是我们所说的最近最少访问,这是缓存替换的一个常用算法。由于linkedhashmap记录了先后顺序,所以我们一般会使用它来实现。
import java.util.LinkedHashMap;
import java.util.Map;
public class LRU<K, V> {
private final float loadFactory = 0.75f;
private LinkedHashMap<K, V> map;
public LRU(int maxCacheSize) {
int capacity = (int)Math.ceil(maxCacheSize / this.loadFactory) + 1;
map = new LinkedHashMap<K, V>(capacity, loadFactory, true) {
//核心在于重写该方法,当容量超过maxCacheSize会移除first
@Override
protected boolean removeEldestEntry(Map.Entry eldest) {
return size() > maxCacheSize;
}
};
}
public void put(K key, V value) {
map.put(key, value);
}
public boolean contain(K key) {
return map.containsKey(key);
}
public V remove(K key) {
return map.remove(key);
}
public V get(K key) {
return map.get(key);
}
}
再次强调无论是我面试别人或者被其他人面试,hashmap的都是面试的重点内容,其设计的优美让绝大部分面试官都对这里面的细节很感兴趣。所以务必重点关注本节内容。
本文的内容就这么多,如果你觉得对你的学习和面试有些帮助,帮忙点个赞或者转发一下哈,谢谢。
想要了解更多java内容(包含大厂面试题和题解)可以关注公众号,也可以在公众号留言,帮忙内推阿里、腾讯等互联网大厂哈