Java集合面试常见问题

171 阅读6分钟

常见问题

  • hashmap如何解决hash冲突,为什么hashmap中的链表需要转成红黑树?
  • hashmap什么时候会触发扩容?
  • jdk1.8之前并发操作hashmap时为什么会有死循环的问题?
  • hashmap扩容时每个entry需要再计算一次hash吗?
  • hashmap的数组长度为什么要保证是2的幂?
  • 如何用LinkedHashMap实现LRU?
  • 如何用TreeMap实现一致性hash?

hashmap如何解决hash冲突,为什么hashmap中的链表需要转成红黑树?

当 HashMap 中有大量的元素都存放到同一个桶中时,这个桶下有一条长长的链表,这个时候 HashMap 就相当于一个单链表,假如单链表有 n 个元素,遍历的时间复杂度就是 O(n),如果 hash 冲突严重,由这里产生的性能问题尤为突显。 JDK 1.8 中引入了红黑树,当链表长度 >= TREEIFY_THRESHOLD(8) & tab.length >= MIN_TREEIFY_CAPACITY(64)时,链表就会转化为红黑树,它的查找时间复杂度为 O(logn),以此来优化这个问题。

关于为什么是6和8?

因为红黑树的平均查找长度是log(n),长度为8的时候,平均查找长度为3,如果继续使用链表,平均查找长度为8/2=4,这才有转换为树的必要。链表长度如果是小于等于6,6/2=3,虽然速度也很快的,但是转化为树结构和生成树的时间并不会太短。

还有选择6和8,中间有个差值7可以有效防止链表和树频繁转换。假设一下,如果设计成链表个数超过8则链表转换成树结构,链表个数小于8则树结构转换成链表,如果一个HashMap不停的插入、删除元素,链表个数在8左右徘徊,就会频繁的发生树转链表、链表转树,效率会很低。

hashmap什么时候会触发扩容?

当向容器添加元素的时候,会判断当前容器的元素个数,如果大于等于阈值—即当前数组的长度乘以加载因子的值的时候,就要自动扩容啦。

jdk1.8之前并发操作hashmap时为什么会有死循环的问题?

在 rehash 时,链表以倒序进行重新排列。

void transfer(Entry[] newTable, boolean rehash) {
 int newCapacity = newTable.length;
 for (Entry<K,V> e : table) {
     while(null != e) {
         Entry<K,V> next = e.next;
         if (rehash) {
             e.hash = null == e.key ? 0 : hash(e.key);
         }
         int i = indexFor(e.hash, newCapacity);
         e.next = newTable[i];
         newTable[i] = e; 	// 每次都将节点放在链表头,导致最终链表顺序和原链表顺序相反
         e = next;
     }
 }
}

在JDK1.8后,这个问题得到了优化:

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)
         ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
     else { // preserve order
         Node<K,V> loHead = null, loTail = null;		// 1、
         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;				// 2、
                 loTail = e;
             }
             else {
                 if (hiTail == null)
                     hiHead = e;
                 else
                     hiTail.next = e;				// 2、
                 hiTail = e;
             }
         } while ((e = next) != null);
         if (loTail != null) {
             loTail.next = null;
             newTab[j] = loHead;						// 3、
         }
         if (hiTail != null) {
             hiTail.next = null;
             newTab[j + oldCap] = hiHead;
         }
     }
 }
}

hashmap扩容时每个entry需要再计算一次hash吗?

不用计算;

hashmap的数组长度为什么要保证是2的幂?

源码中他们采用了延迟初始化操作,也就是table只有在用到的时候才初始化,如果你不对他进行put等操作的话,table的长度永远为"零"。

  • 分布均匀:如果不是2的n次方,那么有些位置上是永远不会被用到;

    • table长度如果不是2的幂,则table.length的二进制有些位上会出现0,在计算index(=HashCode(Key) & (Length - 1))时,就会出现有些index结果的出现几率会更大,而有些index结果永远不会出现。
  • 方便计算,扩容后计算新位置,非常方便

    • 当容量一定是2^n时,h & (length - 1) == h % length;
  • 计算索引需要

    • hash&(newTable.length-1) == hash&(oldTable.length-1)+hash&oldTable.length
    • 因为table的长度一定是2的n次方,也就是oldCap 一定是2的n次方,也就是说 oldCap有且只有一位是1,而且这个位置在最高位;所以hash&oldTable.length==oldTable.length
    • 扩容代码中,使用 e 节点的 hash 值跟 oldCap 进行位与运算,以此决定将节点分布到 “原索引位置” 或者 “原索引 + oldCap 位置” 上。

tableSizeFor的功能(不考虑大于最大容量的情况)

  • 返回大于输入参数且最近的2的整数次幂的数

  • static final int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }
    

如何用LinkedHashMap实现LRU?

LinkedHashMap介绍

从LinkedHashMap的定义里面可以看到它单独维护了一个双向链表,用于记录元素插入的顺序。这也是为什么我们打印LinkedHashMap的时候可以按照插入顺序打印的支撑。而accessOrder属性则指明了进行遍历时是按照什么顺序进行访问,我们可以通过它的构造方法自己指定顺序。

当accessOrder=true

在插入的时候LinkedHashMap复写了HashMap的newNode以及newTreeNode方法,并且在方法内部更新了双向链表的指向关系。

同时插入的时候调用了afterNodeAccess()方法以及afterNodeInsertion()方法,在HashMap中这两个方法是空实现,而在LinkedHashMap中则有具体实现,这两个方法也是专门给LinkedHashMap进行回调处理的。

afterNodeAccess()方法中如果accessOrder=true时会移动节点到双向链表尾部。当我们在调用map.get()方法的时候如果accessOrder=true也会调用这个方法,这就是为什么访问顺序输出时访问到的元素移动到链表尾部的原因。

afterNodeInsertion(boolean evict)

// evict如果为false,则表处于创建模式,当我们new HashMap(Map map)的时候就处于创建模式
void afterNodeInsertion(boolean evict) { // possibly remove eldest
LinkedHashMap.Entry<K,V> first;

// removeEldestEntry 总是返回false,所以下面的代码不会执行。
if (evict && (first = head) != null && removeEldestEntry(first)) {
   K key = first.key;
   removeNode(hash(key), key, null, false, true);
}
}

实现LRU

public class LRUCache<K,V> extends LinkedHashMap<K,V> {
 
private int cacheSize;

public LRUCache(int cacheSize) {
   super(16,0.75f,true);
   this.cacheSize = cacheSize;
}

/**
   * 判断元素个数是否超过缓存容量
   */
  @Override
  protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
      return size() > cacheSize;
  }
}

如何用TreeMap实现一致性hash?

普通的 hash 算法通常都是对机器数量进行取余.

当使用负载均衡的时候,负载均衡器根据对象的 key 对机器进行取余,这个时候,原有的 key 取余现有的机器数 4 就找不到那台机器了!笨一点的办法,就是在增加机器的时候,清除所有缓存,但这会导致缓存击穿甚至缓存雪崩,严重情况下引发 DB 宕机。

一致性hash

可以假设有一个 2 的 32 次方的环形,缓存节点通过 hash 落在环上。而对象的添加也是使用 hash,但很大的几率是 hash 不到缓存节点的。怎么办呢?找离他最近的那个节点。 比如顺时针找前面那个节点。

一致性 hash 有什么问题呢?

集群环境负载不够均衡。

原因是:如果缓存节点分布不均匀。

怎么办?

可以在不均的地方给他弄均匀。在空闲的地方加入 虚拟节点,这些节点的数据映射到真实节点上,就可以了。