HashMap之面试必备

HashMap概述

HashMap 是 Map 接口的实现,HashMap 允许空的 key-value 键值对,HashMap 是 Hashtable 的增强版,HashMap 是一个非线程安全的容器,如果想构造线程安全的 Map 考虑使用 ConcurrentHashMap。HashMap 是无序的,因为 HashMap 无法保证内部存储的键值对的有序性。

HashMap 的底层数据结构是数组 + 链表的集合体,数组在 HashMap 中又被称为桶(bucket)。遍历 HashMap 需要的时间损耗为 HashMap 实例桶的数量 + (key - value 映射) 的数量。

HashMap 有两个重要的因素,初始容量和负载因子,初始容量指的是 hash 表桶的数量,负载因子是一种衡量哈希表填充程度的标准,当哈希表中存在足够数量的 entry,以至于超过了负载因子和当前容量,这个哈希表会进行 rehash 操作,内部的数据结构重新 rebuilt。

注意 HashMap 不是线程安全的,如果多个线程同时影响了 HashMap ,并且至少一个线程修改了 HashMap 的结构,那么必须对 HashMap 进行同步操作。可以使用 Collections.synchronizedMap(new HashMap) 来创建一个线程安全的 Map。

HashMap 会导致除了迭代器本身的 remove 外,外部 remove 方法都可能会导致 fail-fast 机制,因此尽量要用迭代器自己的 remove 方法。如果在迭代器创建的过程中修改了 map 的结构,就会抛出 ConcurrentModificationException 异常。

重要属性

初始容量(16)

HashMap 的默认初始容量是由 DEFAULT_INITIAL_CAPACITY 属性管理的。

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

最大容量

扫描二维码关注公众号,回复: 12376586 查看本文章
static final int MAXIMUM_CAPACITY = 1 << 30;

默认负载因子

static final float DEFAULT_LOAD_FACTOR = 0.75f;

扩容机制的原则是当 HashMap 中存储的数量 > HashMap 容量 * 负载因子时,就会把 HashMap 的容量扩大为原来的二倍。

修改次数

在 HashMap 中,使用 modCount 来表示修改次数,主要用于做并发修改 HashMap 时的快速失败 fail-fast 机制。

扩容阈值

在 HashMap 中,使用 threshold 表示扩容的阈值,也就是 初始容量 * 负载因子的值。

负载因子

loadFactor 表示负载因子,它表示的是 HashMap 中的密集程度。

HashMap 的数据结构

JDK1.7 中,HashMap 采用数组+链表的实现,即使用链表来处理冲突,同一 hash 值的链表都存储在一个数组中。但当位于一个数组中的元素较多,即 hash 值相等的元素较多时,通过 key 值依次查找的效率较低。

所以,与 JDK 1.7 相比,JDK 1.8 在底层结构方面做了一些优化,当每个数组中元素大于 8 的时候,会转变为红黑树,目的就是优化查询效率。

Node 接口

Node节点是用来存储HashMap的一个个实例,它实现了 Map.Entry接口,我们先来看一下 Map中的内部接口 Entry 接口的定义

Map.Entry

// 一个map 的entry 链,这个Map.entrySet()方法返回一个集合的视图,包含类中的元素,// 这个唯一的方式是从集合的视图进行迭代,获取一个map的entry链。这些Map.Entry链只在// 迭代期间有效。interface Entry<K,V> {
   
     K getKey();  V getValue();  V setValue(V value);  boolean equals(Object o);  int hashCode();}

Node 节点会存储四个属性,hash值,key,value,指向下一个Node节点的引用

 // hash值final int hash;// 键final K key;// 值V value;// 指向下一个Node节点的Node类型Node<K,V> next;

因为Map.Entry 是一条条entry 链连接在一起的,所以Node节点也是一条条entry链。构造一个新的HashMap实例的时候,会把这四个属性值分为传入

Node(int hash, K key, V value, Node<K,V> next) {
   
     this.hash = hash;  this.key = key;  this.value = value;  this.next = next;}

KeySet 内部类

keySet 类继承于 AbstractSet 抽象类,它是由 HashMap 中的 keyset() 方法来创建 KeySet 实例的,旨在对HashMap 中的key键进行操作

// 返回一个set视图,这个视图中包含了map中的key。public Set<K> keySet() {
   
     // // keySet 指向的是 AbstractMap 中的 keyset  Set<K> ks = keySet;  if (ks == null) {
   
       // 如果 ks 为空,就创建一个 KeySet 对象    // 并对 ks 赋值。    ks = new KeySet();    keySet = ks;  }  return ks;}

Values 内部类

Values 类的创建其实是和 KeySet 类很相似,不过 KeySet 旨在对 Map中的键进行操作,Values 旨在对key-value 键值对中的 value 值进行使用

public Collection<V> values() {
   
     // values 其实是 AbstractMap 中的 values  Collection<V> vs = values;  if (vs == null) {
   
       vs = new Values();    values = vs;  }  return vs;}

EntrySet 内部类

对 key-value 键值对进行操作的内部类

// 返回一个 set 视图,此视图包含了 map 中的key-value 键值对public Set<Map.Entry<K,V>> entrySet() {
   
     Set<Map.Entry<K,V>> es;  return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;}

HashMap 的 put 过程

首先会使用 hash 方法计算对象的哈希码,根据哈希码来确定在 数组中存放的位置,如果数组中没有 Node 节点则直接进行 put,如果对应数组已经有 Node 节点,会对链表长度进行分析,判断长度是否大于 8,如果链表长度小于 8 ,在 JDK1.7 前会使用头插法,在 JDK1.8 之后更改为尾插法。如果链表长度大于 8 会进行树化操作,把链表转换为红黑树,在红黑树上进行存储。

//JDK1.8final V putVal(int hash, K key, V value, boolean onlyIfAbsent,                   boolean evict) {
   
     Node<K,V>[] tab; Node<K,V> p; int n, i;  // 如果table 为null 或者没有为 table 分配内存,就resize一次  if ((tab = table) == null || (n = tab.length) == 0)    n = (tab = resize()).length;  // 指定hash值节点为空则直接插入,这个(n - 1) & hash才是表中真正的哈希  if ((p = tab[i = (n - 1) & hash]) == null)    tab[i] = newNode(hash, key, value, null);  // 如果不为空  else {
   
       Node<K,V> e; K k;    // 计算表中的这个真正的哈希值与要插入的key.hash相比    if (p.hash == hash &&        ((k = p.key) == key || (key != null && key.equals(k))))      e = p;    // 若不同的话,并且当前节点已经在 TreeNode 上了    else if (p instanceof TreeNode)      // 采用红黑树存储方式      e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);    // key.hash 不同并且也不再 TreeNode 上,在链表上找到 p.next==null    else {
   
         for (int binCount = 0; ; ++binCount) {
   
           if ((e = p.next) == null) {
   
             // 在表尾插入          p.next = newNode(hash, key, value, null);          // 新增节点后如果节点个数到达阈值,则进入 treeifyBin() 进行再次判断          if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st            treeifyBin(tab, hash);          break;        }        // 如果找到了同 hash、key 的节点,那么直接退出循环        if (e.hash == hash &&            ((k = e.key) == key || (key != null && key.equals(k))))          break;        // 更新 p 指向下一节点        p = e;      }    }    // map中含有旧值,返回旧值    if (e != null) { // existing mapping for key      V oldValue = e.value;      if (!onlyIfAbsent || oldValue == null)        e.value = value;      afterNodeAccess(e);      return oldValue;    }  }  // map调整次数 + 1  ++modCount;  // 键值对的数量达到阈值,需要扩容  if (++size > threshold)    resize();  afterNodeInsertion(evict);  return null;}

HashMap 的遍历方式

HashMap 遍历的基类是 HashIterator,它是一个 Hash 迭代器,它是一个 HashMap 内部的抽象类,它的构造比较简单,只有三种方法,hasNext 、 remove 和 nextNode 方法,其中 nextNode 方法是由三种迭代器实现的,这三种迭代器就是

  • KeyIterator ,对 key 进行遍历

  • ValueIterator,对 value 进行遍历

  • EntryIterator, 对 Entry 链进行遍历

他们的遍历顺序都是一样的,都是使用 HashIterator 中的 nextNode 方法进行遍历

final class KeyIterator extends HashIterator        implements Iterator<K> {
   
           public final K next() { return nextNode().key; }    }final class ValueIterator extends HashIterator  implements Iterator<V> {
   
     public final V next() { return nextNode().value; }}final class EntryIterator extends HashIterator  implements Iterator<Map.Entry<K,V>> {
   
     public final Map.Entry<K,V> next() { return nextNode(); }}

HashIterator 中的遍历方式

abstract class HashIterator {
   
     Node<K,V> next;        // 下一个 entry 节点  Node<K,V> current;     // 当前 entry 节点  int expectedModCount;  // fail-fast 的判断标识  int index;             // 当前槽  HashIterator() {
   
       expectedModCount = modCount;    Node<K,V>[] t = table;    current = next = null;    index = 0;    if (t != null && size > 0) { // advance to first entry      do {} while (index < t.length && (next = t[index++]) == null);    }  }  public final boolean hasNext() {
   
       return next != null;  }  final Node<K,V> nextNode() {
   
       Node<K,V>[] t;    Node<K,V> e = next;    if (modCount != expectedModCount)      throw new ConcurrentModificationException();    if (e == null)      throw new NoSuchElementException();    if ((next = (current = e).next) == null && (t = table) != null) {
   
         do {} while (index < t.length && (next = t[index++]) == null);    }    return e;  }  public final void remove() {...}}

next 和 current 分别表示下一个 Node 节点和当前的 Node 节点,HashIterator 在初始化时会遍历所有的节点

HashMap 线程不安全

HashMap 不是一个线程安全的容器,不安全性体现在多线程并发对 HashMap 进行 put 操作上。如果有两个线程 A 和 B ,首先 A 希望插入一个键值对到 HashMap 中,在决定好桶的位置进行 put 时,此时 A 的时间片正好用完了,轮到 B 运行,B 运行后执行和 A 一样的操作,只不过 B 成功把键值对插入进去了。如果 A 和 B 插入的位置(桶)是一样的,那么线程 A 继续执行后就会覆盖 B 的记录,造成了数据不一致问题。还有一点在于 HashMap 在扩容时,因 resize 方法会形成环,造成死循环,导致 CPU 飙高。

HashMap 是如何处理哈希碰撞的

HashMap 底层是使用位桶 + 链表实现的,位桶决定元素的插入位置,位桶是由 hash 方法决定的,当多个元素的 hash 计算得到相同的哈希值后,HashMap 会把多个 Node 元素都放在对应的位桶中,形成链表,这种处理哈希碰撞的方式被称为链地址法。

其他处理 hash 碰撞的方式还有 开放地址法、rehash 方法、建立一个公共溢出区。

HashMap 是如何 get 元素的

首先会检查 table 中的元素是否为空,然后根据 hash 算出指定 key 的位置。然后检查链表的第一个元素是否为空,如果不为空,是否匹配,如果匹配,直接返回这条记录;如果不匹配,再判断下一个元素的值是否为 null,为空直接返回,如果不为空,再判断是否是 TreeNode 实例,如果是 TreeNode 实例,则直接使用 TreeNode.getTreeNode 取出元素,否则执行循环,直到下一个元素为 null 位置。

HashMap 和 HashTable 有什么区别

相同点

HashMap 和 HashTable 都是基于哈希表实现的,其内部每个元素都是 key-value 键值对,HashMap 和 HashTable 都实现了 Map、Cloneable、Serializable 接口。

不同点

  • 父类不同:HashMap 继承了 AbstractMap 类,而 HashTable 继承了 Dictionary 类

  • 空值不同:HashMap 允许空的 key 和 value 值,HashTable 不允许空的 key 和 value 值。HashMap 会把 Null key 当做普通的 key 对待。不允许 null key 重复。 

  • 线程安全性:HashMap 不是线程安全的,如果多个外部操作同时修改 HashMap 的数据结构比如 add 或者是 delete,必须进行同步操作,仅仅对 key 或者 value 的修改不是改变数据结构的操作。可以选择构造线程安全的 Map 比如 Collections.synchronizedMap 或者是 ConcurrentHashMap。而 HashTable 本身就是线程安全的。

  • 性能方面:虽然 HashMap 和 HashTable 都是基于单链表的,但是 HashMap 进行 put 或get操作,可以达到常数时间的性能;而 HashTable 的 put 和 get 操作都是加了 synchronized 锁的,所以效率很差。

  • 初始容量不同:HashTable 的初始长度是11,之后每次扩充容量变为之前的 2n+1(n为上一次的长度)

    而 HashMap 的初始长度为16,之后每次扩充变为原来的两倍。创建时,如果给定了容量初始值,那么HashTable 会直接使用你给定的大小,而 HashMap 会将其扩充为2的幂次方大小。

HashMap 和 HashSet 的区别

HashSet 继承于 AbstractSet 接口,实现了 Set、Cloneable,、java.io.Serializable 接口。HashSet 不允许集合中出现重复的值。HashSet 底层其实就是 HashMap,所有对 HashSet 的操作其实就是对 HashMap 的操作。所以 HashSet 也不保证集合的顺序。

HashMap 是如何扩容的

HashMap 中有两个非常重要的变量,一个是 loadFactor ,一个是 threshold ,loadFactor 表示的就是负载因子,threshold 表示的是下一次要扩容的阈值,当 threshold = loadFactor * 数组长度时,数组长度扩大为原来的两倍,来重新调整 map 的大小,并将原来的对象放入新的 bucket 数组中。

HashMap 的长度为什么是 2 的幂次方

为什么 length%hash == (n - 1) & hash,因为 HashMap 的长度是 2 的幂次方,所以使用余数来判断在桶中的下标。如果 length 的长度不是 2 的幂次方,小伙伴们可以举个例子来试试

例如长度为 9 时候,3 & (9-1) = 0,2 & (9-1) = 0 ,都在 0 上,碰撞了;

这样会增大 HashMap 碰撞的几率。

HashMap 线程安全的实现有哪些

因为 HashMap 不是一个线程安全的容器,所以并发场景下推荐使用 ConcurrentHashMap ,或者使用线程安全的 HashMap,使用 Collections 包下的线程安全的容器,比如说

Collections.synchronizedMap(new HashMap());

还可以使用 HashTable ,它也是线程安全的容器,基于 key-value 存储,经常用 HashMap 和 HashTable 做比较就是因为 HashTable 的数据结构和 HashMap 相同。

猜你喜欢

转载自blog.csdn.net/feikillyou/article/details/112725411