数据结构(HashMap)

散列表也叫哈希表,是一种通过键值对直接访问的数据结构,哈希能快速的插入、删除、查找操作

HashMap的结构#

hashMap采用的链地址法,主要采用数据+链表(1.8 后转红黑树)的数据结构。

HashMap 的存储数据#

HashMap 是使用哈希表来存储数据的。哈希表为了解决冲突,一般有两种方案:开放地址法链地址法

开放地址法:哈希完后如果有冲突,则按照某种规则找到空位插入

HashMap 采用的便是链地址法,即在数组的每个索引处都是一个链表结构,这样就可以有效解决 hash 冲突。

当两个 key 的 hash 值相同时,则会将他们至于数组的同一个位置处,并以链表的形式呈现。

我们都知道数组的时间复杂度的O(1),所以我们在存储的时候尽量将数据散列到数组中,即减少哈希碰撞。提升查询效率,所以一个好的hash算法是提升效率的关键

static final int hash(Object key) {
	int h;
	return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

在JDK1.8的实现中,优化了高位运算的算法,通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在数组table的length比较小的时候,也能保证考虑到高低Bit都参与到Hash的计算中,同时不会有太大的开销

hash运算的原理不必深究,关乎数学和散列学原理

hashMap的默认参数

// 默认的初始容量为 16 (PS:aka 应该是 as know as)
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;

这些默认参数也是经过计算后和框架设计的最优值。比如默认容量,2的n次幂,链表转红黑树的阈值这些(短链表效率会比红黑树要高)

重要参数

// Map 中存储的数据量,即 key-value 键值对的数量
transient int size;

// HashMap 内部结构发生变化的次数,即新增、删除数据的时候都会记录,
// 注意:修改某个 key 的值,并不会改变这个 modCount
transient int modCount;

// 重点,代表最多能容纳的数据量
// 即最多能容纳的 key-value 键值对的数量
int threshold;

// 负载因子,默认为 0.75
// 注意,这个值是可以大于 1 的
final float loadFactor;

其中有两个参数需要注意一下,一个是 threshold,还有一个是 loadFactor

threshold 代表最多能容纳的 Node 数量,一般 threshold = length * loadFactor,也就是说要想 HashMap 能够存储更多的数据(即获得较大的 threshold),有两种方案,一种是扩容(即增大数组长度 length),另一种便是增大负载因子。

threshold 和数组长度不是一回事哦

0.75 这个默认的负载因子的值是基于时间和空间考虑而得的一个比较平衡的点,所以负载因子我们一般不去调整,除非有特殊的需求:

1、比如 以空间换时间,意思是如果内存比较大,并且需要有较高的存取效率,则可以适当降低负载因子,这样做的话,就会减小哈希碰撞的概率。

2、再比如 以时间换空间,意思是如果内存比较小,并且接受适当减小存取效率,则可以适当调大负载因子,哪怕大于 1,这样做的话,就会增大哈希碰撞的概率。

HashMap的put方法#

从流程图上其实很清晰的知道HashMap存储的整个流程,先判断数组节点是否包含值,如果不包含则resize()初始化扩容,否则判断key值,如果存在则生成链表的方式,判断阈值转红黑树操作

重点还是如何高效率的计算key和resize()方法

jdk8通过 (p = tab[i = (n - 1) & hash] 的方式计算下标,而jdk7通过取模的方式 h & (length-1),在计算机运算中 与运算效率比取模运算高

HashMap 的长度 length 始终是 2 的幂次方,这个是关键,所以才会有这种结果,简单分析见下图:

HashMap 的扩容机制#

什么时候需要扩容?

当 HashMap 中的元素个数超过数组长度 loadFactor(负载因子)时,就会进行数组扩容,loadFactor 的默认值是 0.75,这是一个折中的取值。也就是说,默认情况下,数组大小为 16,那么当 HashMap 中的元素个数超过 16×0.75=12(这个值就是阈值)的时候,就把数组的大小扩展为 2×16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常耗性能的操作,所以如果我们已经预知 HashMap 中元素的个数,那么预知元素的个数能够有效的提高 HashMap 的性能。

怎么进行扩容的?

HashMap 在进行扩容时使用 resize() 方法,计算 table 数组的新容量和 Node 在新数组中的新位置,将旧数组中的值复制到新数组中,从而实现自动扩容。因为每次扩容都是翻倍,与原来计算的 (n-1)&hash 的结果相比,只是多了一个 bit 位,所以节点要么就在原来的位置,要么就被分配到"原位置+旧容量"这个位置。

因此,我们在扩充 HashMap 的时候,不需要重新计算 hash,只需要看看原来 hash 值新增的那个 bit 是 1 还是 0 就可以了,是 0 的话索引没变,是 1 的话索引变成“原索引+oldCap(原位置+旧容量)”。这里不再详细赘述,可以看看下图为 16 扩充为 32 的 resize 示意图:

总结#

整体来说HashMap的源码较少,主要还是要理解hash表的设计思想。利用2的n次幂来做数组容量,方便后续扩容和hash运算,提升性能。hash碰撞采用链地址法和红黑树的方式来提升查询性能。

当整体容量 > threshold 则需要扩容,避碰出现大量hash碰撞,导致性能下降;

Q&A#

1、既然HashMap是通过hash运算来赋值的,这肯定是无序的,那LinkedHashMap是如何保证顺序的

  • LinkedHashMap 内部维护了一个双向链表,解决了 HashMap 不能随时保持遍历顺序和插入顺序一致的问题
  • LinkedHashMap 元素的访问顺序也提供了相关支持,也就是我们常说的 LRU(最近最少使用)原则
/**
* 该引用始终指向双向链表的头部
*/
transient LinkedHashMap.Entry<K,V> head;

/**
* 该引用始终指向双向链表的尾部
*/
transient LinkedHashMap.Entry<K,V> tail;


static class Entry<K,V> extends HashMap.Node<K,V> {
   Entry<K,V> before, after;
   Entry(int hash, K key, V value, Node<K,V> next) {
       super(hash, key, value, next);
   }
}

内部维护了两个节点

// HashMap newNode 中实现
Node<K,V> newNode(int hash, K key, V value, Node<K,V> next) {
    return new Node<>(hash, key, value, next);
}

// LinkedHashMap newNode 的实现
Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
    LinkedHashMap.Entry<K,V> p =
        new LinkedHashMap.Entry<K,V>(hash, key, value, e);
    // 将 Entry 接在双向链表的尾部
    linkNodeLast(p);
    return p;
}

在newNode时将Entry 接在双向链表的尾部,在iterator() 遍历时优先从head节点取值遍历

https://juejin.cn/post/6844903590159450120

2、LinkedHashMap和TreeMap的区别

  1. 内部数据结构:
    • TreeMap使用红黑树(Red-Black Tree)来存储键值对,因此它内部的键值对是有序的,按照键的自然顺序或者自定义比较器的顺序进行排序。
    • LinkedHashMap使用双向链表来维护插入顺序或者访问顺序,因此它可以保持键值对的插入顺序或者访问顺序。
  2. 性能:
    • TreeMap的插入、删除和查找操作的时间复杂度为O(log n),因为它使用红黑树来维护数据结构。
    • LinkedHashMap的插入、删除和查找操作的时间复杂度为O(1),因为它使用了哈希表来存储键值对,同时又通过双向链表来维护插入顺序或者访问顺序。
  3. 内存占用:
    • TreeMap在内存使用方面通常比LinkedHashMap更高,因为红黑树的结构相对复杂。
    • LinkedHashMap在内存使用方面通常比TreeMap更低,因为它只需要额外存储双向链表的指针。
  4. 应用场景:
    • 如果需要按照键的自然顺序或者自定义顺序进行遍历和操作,可以选择TreeMap。
    • 如果需要保持插入顺序或者访问顺序,可以选择LinkedHashMap。

3、entrySet() 的获取元素原理

public Set<Map.Entry<K,V>> entrySet() {
	Set<Map.Entry<K,V>> es;
	return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;
}

当我们foreach遍历map时,其实调用EntrySet的iterator()迭代器方法

public final Iterator<Map.Entry<K,V>> iterator() {
    return new EntryIterator();
}

https://www.cnblogs.com/kukufan/p/15937813.html

3、HashMap是线程不安全的,体现在哪

Jdk7 链地址法采用的头插法,在多线程扩容中会性能环形链表

Jdk8 采用的尾插法避免了环形链表的问题,但还是会在插值时会被覆盖

4、JDK7和JDK8的实现区别

  • 计算hash的方式不同
  • resize()的方式不同
  • JDK7只有链表,JDK8引入了红黑树
  • JDK7采用头插法,JDK8采用尾插法
  • JDK7 节点对象时Entry,JDK8改为Node

5、HashMap和HashSet的区别

private static final Object PRESENT = new Object();

public boolean add(E e) {
	return map.put(e, PRESENT)==null;
}

内部还是通过map实现,value为默认值

源码了解更多

https://juejin.cn/post/6844904111817637901

https://tech.meituan.com/2016/06/24/java-hashmap.html

https://xie.infoq.cn/article/01538faefd4816128ede4212a

posted @   糯米๓  阅读(13)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· Obsidian + DeepSeek:免费 AI 助力你的知识管理,让你的笔记飞起来!
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
more_horiz
keyboard_arrow_up dark_mode palette
选择主题
menu
点击右上角即可分享
微信分享提示