HashMap之原理、jdk1.7和1.8的区别、常见操作(扩容、put、get)、equals和==、常见的hash冲突解决办法

版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。
本文链接: https://blog.csdn.net/u014454538/article/details/89433812

1. HashMap

① HashMap 的原理
  • JDK1.6JDK1.7中,HashMap采用位桶+链表实现,即使用链表处理冲突同一hash值的键值对都存储在一个链表里
  • 但是当位于一个桶中的元素较多,即hash值相等的元素较多时,通过key值依次查找的效率较低。而JDK1.8中,HashMap采用位桶+链表+红黑树实现,当链表长度超过阈值8时,将链表转换为红黑树,这样大大减少了查找时间
    在这里插入图片描述
  • HashMap是基于hashing的原理,使用put(key, value)存储对象到HashMap中,使用get(key)从HashMap中获取对象
  • 当我们给put()方法传递键和值时,我们先对键调用hashCode()方法,计算并返回的hashCode是用于找到Map数组的bucket位置来储存Node 对象。这里关键点在于指出,HashMap是在bucket中储存键对象和值对象,作为Map.Node 。
  • 其中Node对象有next元素,可以形成链表,即每个bucket下面的存储的是链表当链表长度超过8时,会转化为红黑树,这样大大提高了查找的效率。当红黑树数节点不超过6时,会转回链表。
  • HashMap的类图结构
public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable
  • HashMap 继承于AbstractMap,实现了MapCloneablejava.io.Serializable接口。其中Map接口定义了键映射到值的规则,而AbstractMap类提供 Map 接口的骨干实现,以最大限度地减少实现此接口所需的工作。
  • 注意事项:
  1. HashMap根据键的hashCode值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,但遍历顺序却是不确定的
  2. HashMap最多只允许一条记录key为null允许多条记录value为null
  3. HashMap的实现是非synchronized,意味着它是非线程安全的。即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。
  4. 如果需要满足线程安全,可以用 Collections的synchronizedMap方法使HashMap具有线程安全的能力,或者使用ConcurrentHashMap
  5. 链表的插入: 在jdk1.8之前是插入头部的,在jdk1.8中是插入尾部的。
类型 空间复杂度 时间复杂度 优缺点
数组 存储区间是连续,空间复杂度大 二分查找时间复杂度小,为O(1) 寻址容易,插入和删除困难
链表 存储区间离散,空间复杂度很小 时间复杂度很大,达O(N) 寻址困难,插入和删除容易
② 数据结构和重要属性
桶数组(node结构)
  • Node是HashMap的一个内部类,实现了Map.Entry接口本质是就是一个映射(键值对)
transient Node<k,v>[] table;//桶数组<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
    // 省略方法内容
    Node(int hash, K key, V value, Node<K,V> next) { ... }
    public final K getKey(){ ... }
    public final V getValue() { ... }
    public final String toString() { ... }
    public final int hashCode() { ... }
    public final V setValue(V newValue) { ... }
    //判断两个node是否相等,若key和value都相等,返回true。
    public final boolean equals(Object o) { ... }
}
红黑树
static final class TreeNode<K,V> extends LinkedHashMap.Entry<K,V> {// 红黑树节点
    TreeNode<K,V> parent;  // red-black tree links
    TreeNode<K,V> left;  //左子树
    TreeNode<K,V> right;  //右子树
    TreeNode<K,V> prev;    // needed to unlink next upon deletion
    boolean red;//颜色
  
    //Returns root of tree containing this node.
    TreeNode(int hash, K key, V val, Node<K,V> next) {
        super(hash, key, val, next);
    }
}
重要属性
  • Java中transient关键字的作用,简单地说,就是让某些被修饰的成员属性变量不被序列化
  • modCount 用来记录结构发生变化的次数。结构发生变化是指添加或者删除至少一个元素的所有操作,或者是调整内部数组的大小,仅仅只是设置元素的值不算结构发生变化
  • fail-fast迭代: 在进行序列化或者迭代等操作时,需要比较操作前后 modCount 是否改变,如果改变了需要抛出 ConcurrentModificationException
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 默认容量16,即桶的默认大小
static final int MAXIMUM_CAPACITY = 1 << 30;    // 最大容量
static final float DEFAULT_LOAD_FACTOR = 0.75f; // 默认负载因子0.75
static final int TREEIFY_THRESHOLD = 8; // 链表节点转换红黑树节点的阈值, 8个节点转
static final int UNTREEIFY_THRESHOLD = 6;   // 红黑树节点转换链表节点的阈值, 6个节点转
static final int MIN_TREEIFY_CAPACITY = 64; // 转红黑树时, table的最小长度
transient Node<k,v>[] table;//存储元素的数组  
transient Set<map.entry<k,v>> entrySet; 
transient int size;//存放元素的个数  
transient int modCount;//被修改的次数fast-fail机制  
int threshold;//临界值 当实际大小(容量*填充比)超过临界值时,会进行扩容   
final float loadFactor;//负载因子
③ HashMap的扩容
  • 关于扩容的重要参数
参数 含义
capacity table 的容量大小,默认为 16。需要注意的是 capacity 必须保证为2^N
size HashMap的大小,它是HashMap保存的键值对的数量
threshold size 的临界值,当 size 大于等于 threshold 就必须进行扩容操作。threshold = capacity * loadFactor
loadFactor 装载因子,table 能够使用的比例,默认值DEFAULT_LOAD_FACTOR = 0.75f
  • size >= threshold时,就进行扩容操作。这个过程也叫作rehashing,因为它重建内部数据结构,并调用hash方法找到新的bucket位置。
  • resize()函数实现扩容,过程大致分两步:
  1. 扩容: 容量扩充为原来的两倍(2 * table.length);
  2. 移动: 对每个节点重新计算哈希值,重新计算每个元素在数组中的位置,将原来的元素移动到新的哈希表中。
  • 移动过程中需要重新计算桶下标。HashMap 使用了一个特殊的机制可以降低重新计算桶下标的操作
  1. 假设原数组长度 capacity 为 16,扩容之后 new capacity 为 32:
  2. 对于一个 Key,它的哈希值如果在第 5 位上为 0,那么取模得到的结果和之前一样
  3. 如果为 1,那么得到的结果为原来的结果 +16

capacity : 00010000
new capacity : 00100000

  • 总结: 扩容后桶下标的位置:一个是原下标的位置(第N位为0,新的capacity= 2 N 2^N ),另一种是在下标为 <原下标+原容量> 的位置(第N位为0)
④ put()操作(1.7、1.8均有)
  • jdk1.7中的put操作:
  1. key为null,调用 putForNullKey() 单独处理。因为无法调用 null 的 hashCode() 方法,也就无法确定该键值对的桶下标,只能通过强制指定一个桶下标来存放。HashMap 使用第 0 个桶存放键为 null 的键值对putForNullKey()方法中调用addEntry()实现键值对的插入
  2. 计算key的hash值,根据hash值、调用indexFor()计算桶下标
  3. 遍历桶中的链表,如果已经存在键为key的键值对,更新键值对的value值。(是否存在key的判断,需要判断hash值和key的equals)
  4. 没有已经存在的key,调用addEntry()使用头插法插入新键值对注意: addEntry()对size和threshold大小进比较,达到threshold时,会使用resize()对桶进行扩容
  • jdk1.7的源码

  • jdk1.8中put操作的具体步骤:
    ① 判断table是否为null或者空(长度为0),若是null或者空,执行resize()进行扩容;
    ② 根据键值key计算hash值得到插入的数组索引(桶下标)如果没有碰撞直接放入桶中(碰撞的意思是:该bucket中,没有键值对)。然后判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,执行resize()进行扩容
    如果发生碰撞,需要判断桶为红黑树还是链表。桶为红黑树直接在树中插入键值对。否则遍历链表,如果链表长度大于8,将其转为红黑树,在树中执行插入操作;在遍历过程中,发现某个key重复直接覆盖value即可
    ④ 完成插入操作后,判断实际存在的键值对数量size是否超多了最大容量threshold,如果超过,执行resize()进行扩容;

⑤ get()操作(jdk1.7为例)
  • jdk1.7中的get操作:
  1. key为null,调用getForNullKey()获取对应的value;
  2. 否者计算key的hash值,根据hash值调用indexFor()获取桶下标,然后遍历桶中的链表。
  3. 遍历过程中key相同,返回其对应的value(key相同要求hashCode和equals都相同);遍历结束没有找到对应的key,返回null。
public V get(Object key) {
    if (key == null)
        return getForNullKey();
    int hash = hash(key.hashCode());
    for (Entry<K,V> e = table[indexFor(hash, table.length)];
        e != null;
        e = e.next) {
        Object k;
        if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
            return e.value;
    }
    return null;
}
⑥ equals
  • ==用于比较引用和比较基本数据类型时具有不同的功能:
  1. 比较基本数据类型看二者的值是否相等。如果两个值相同,则结果为true。
  2. 比较引用类型时看二者是否指向内存中的同意对象。如果引用指向内存中的同一对象`,结果为true;
⑦ HashMap常见面试问题
1. HashMap 怎样解决冲突?抛开HashMap,有哪些方法可以解决hash冲突?
  • HashMap使用拉链法解决冲突:采用桶数组,每个桶中以链表的形式存放hashCode相同的键值对。
  • 解决hash冲突的其他方法:开放定址法、再hash法、建立一个公共溢出区。
    在这里插入图片描述
  • 开放定址法: 对某个值进行hash时,发现地址被占用,于是使用(hash(key)+di) % m去获取新的地址,直到新地址没被占用。增量di的三种取法:
    线性探测再散列: di = 1 , 2 , 3 , … , m-1
    平方探测再散列: di = 1 , -1 , 2, -2 , 3 , -3 , … , k , -k(取相应数的平方,1表示加 1 2 1^2 ,-1表示减 1 2 1^2 )
    随机探测再散列: di 是一组伪随机数列
  • 再hash法: 产生hash冲突时,再用其他的hash函数计算地址,直到不发生冲突为止。
  • 建立一个公共溢出区: 假设哈希函数的值域是[1,m-1],则设向量HashTable[0…m-1]为基本表,每个分量存放一个记录,另外设向量OverTable[0…v]为溢出表。一旦发生冲突,都填入溢出表。

参考链接:解决Hash冲突四种方法

2. 为什么String, Interger这样的类适合作为键?
  • String最为常,因为String对象是不可变的,而且已经重写了equals()和hashCode()方法
  • 不可变性是必要的,因为在HashMap中如果key的hashCode在存入和获取时不一致,那么就不能从HashMap中找到你想要的键值对。不可变性还有其他的优点,如线程安全
  • 获取对象的时候要用到equals()和hashCode()方法,key对象正确的重写这两个方法是非常重要的。因为,如果两个不相等的对象返回不同的hashCode的话,那么碰撞的几率就会小些,这样就能提高HashMap的性能
3. HashMap与HashTable的区别
  • extends的类不同: HashMap继承于AbstractMap,而Hashtable继承于陈旧的Dictionary。
  • 线程安全不同: HashTable 使用 synchronized 来进行同步,即HashTable是线程安全的,支持多线程。而HashMap不是同步的,即HashMap不是线程安全的。若要在多线程中使用HashMap,需要我们额外的进行同步处理。
  • null值: HashMap的key、value都可以为null。Hashtable的key、value都不可以为null。
  • 迭代器(Iterator)不一样::HashMap的迭代器(Iterator)是fail-fast迭代器,当有其它线程改变了HashMap的结构(增加或者移除元素),将会抛出ConcurrentModificationException。而Hashtable的enumerator迭代器不是fail-fast的。
  • 容量的初始值和增加方式都不一样: HashMap默认的容量大小是16;增加容量时,每次将容量变为 2 原始容量 * 2 。Hashtable默认的容量大小是11;增加容量时,每次将容量变为 2 + 1 原始容量 * 2 + 1
  • 添加key-value时的hash值算法不同: HashMap添加元素时,是使用自定义的哈希算法。Hashtable没有自定义哈希算法,而直接采用的key的hashCode()
  • 速度不同: 由于Hashtable是线程安全的,即是synchronized,所以在单线程环境下它比HashMap要慢。如果你不需要同步,只需要单一线程,那么使用HashMap性能要好过Hashtable。
4. 如何解决HashMap不是线程安全的问题?
  • HashMap可以通过下面的语句进行同步,实现线程安全:
Map m = Collections.synchronizedMap(hashMap);
  • 使用ConcurrentHashMap代替

参考链接:
【java集合】HashMap常见面试题

猜你喜欢

转载自blog.csdn.net/u014454538/article/details/89433812
今日推荐