文章详情

短信预约-IT技能 免费直播动态提醒

请输入下面的图形验证码

提交验证

短信预约提醒成功

HashMap详解

2023-09-22 08:50

关注

一、HashMap集合简介

JDK1.7中的HashMap

采用的是头部插入法:当计算得到的索引值相同时,即Hash碰撞,引入链表结构解决Hash冲突,新加入的值会插入到旧数据的头部。
在这里插入图片描述
链表的时间复杂度是O(n):
当要找的数据是“金莲”,那么在链表上就要遍历3次才能找到,时间复杂度是O(3)
在这里插入图片描述
HashMap中并不是用取模计算索引位置,而是用位运算
位运算的效率 > 取模

为什么HashMap的容量一定要转成2的指数次幂?
因为hash值是随机的值,在与数组长度做与运算时,只有两种结果

(1)h & length
h = 0001 0101 0111 0010 1110
l = 0000 0000 0000 0001 0000
这种情况得到的结果是要么0,要么16

(2)h & (length - 1)
h = 0001 0101 0111 0010 1110
l = 0000 0000 0000 0000 1111
这种情况得到的结果是散列随机的,0-15之间的随机值。从而减少hash碰撞。

扩容机制
(1)达到扩容条件后,则扩容为原数组长度的两倍
(2)将原来老的数据移入到新的数组中,在移植的过程中还要进行一次rehash()运算
(3)

为什么加载因子是0.75?【面试题】

这个值是根据空间和时间,通过泊松分布算法得到的一个折中的值。

jdk1.7中HashMap在多线程的情况下是线程不安全的

会在数据迁移的过程中导致链表形成一个环,导致整个HashMap无法使用。
建议在多线程环境下面,使用ConcurrentHashMap

二、HashMap 集合底层的数据结构

2.1存储数据的过程

示例代码:

HashMap<String, Integer> map = new HashMap<>();map.put("柳岩", 18);map.put("杨幂", 28);map.put("刘德华", 40);map.put("柳岩", 20);

输出结果:

{杨幂=28, 柳岩=20, 刘德华=40}

分析:

当创建 HashMap 集合对象的时候,HashMap 的构造方法并没有创建数组,而是在第一次调用 put 方法时创建一个长度是16 的数组 Node[] table (jdk1.8 之前是 Entry[] table)用来存储键值对数据。

假设向哈希表中存储 <柳岩,18> 数据,根据柳岩调用 String 类中重写之后的 hashCode() 方法计算出值,然后结合数组长度采用某种算法计算出向 Node 数组中存储数据的空间的索引值。如果计算出的索引空间没有数据,则直接将<柳岩,18>存储到数组中。(举例:计算出的索引是 3 )

向哈希表中存储数据 <刘德华,40>,假设算出的 hashCode() 方法结合数祖长度计算出的索引值也是3,那么此时数组空间不是 null,此时底层会比较柳岩和刘德华的 hash 值是否一致,如果不一致,则在空间上划出一个结点来存储键值对数据对 <刘德华,40>,这种方式称为拉链法

假设向哈希表中存储数据 <柳岩,20>,那么首先根据柳岩调用 hashCode() 方法结合数组长度计算出索引肯定是 3,此时比较后存储的数据柳岩和已经存在的数据的 hash 值是否相等,如果 hash 值相等,此时发生哈希碰撞。那么底层会调用柳岩所属类 String 中的 equals() 方法比较两个内容是否相等:

相等:将后添加的数据的 value 覆盖之前的 value。

不相等:继续向下和其他的数据的 key 进行比较,如果都不相等,则划出一个结点存储数据,如果结点长度即链表长度大于阈值 8 并且数组长度大于 64 则将链表变为红黑树。
在这里插入图片描述

在不断的添加数据的过程中,会涉及到扩容问题,当超出阈值(且要存放的位置非空)时,扩容。默认的扩容方式:扩容为原来容量的 2 倍,并将原有的数据复制过来

综上描述,当位于一个表中的元素较多,即 hash 值相等但是内容不相等的元素较多时,通过 key 值依次查找的效率较低。而 jdk1.8 中,哈希表存储采用数组+链表+红黑树实现,当链表长度(阈值)超过8且当前数组的长度大于64时,将链表转换为红黑树,这样大大减少了查找时间

简单的来说,哈希表是由数组+链表+红黑树(JDK1.8增加了红黑树部分)实现的。如下图所示:
在这里插入图片描述

8 HashMap结构图:
在这里插入图片描述

jdk1.8 中引入红黑树的进一步原因:

jdk1.8 以前 HashMap 的实现是数组+链表,即使哈希函数取得再好,也很难达到元素百分百均匀分布。当 HashMap 中有大量的元素都存放到同一个桶中时,这个桶下有一条长长的链表,这个时候 HashMap 就相当于一个单链表,假如单链表有n个元素,遍历的时间复杂度就是O(n),完全失去了它的优势。

针对这种情况,jdk1.8 中引入了红黑树(查找时间复杂度为 O(logn))来优化这个问题。当链表长度很小的时候,即使遍历,速度也非常快,但是当链表长度不断变长,肯定会对查询性能有一定的影响,所以才需要转成树。

总结:
在这里插入图片描述

说明:

2.2 面试题

1、HashMap 中 hash 函数是怎么实现的?还有哪些hash函数的实现方式?
答:对于 key 的 hashCode 做 hash 操作,无符号右移 16 位然后做异或运算。还有平方取中法,伪随机数法和取余数法。这三种效率都比较低。而无符号右移 16 位异或运算效率是最高的。

2、当两个对象的 hashCode 相等时会怎么样?
答:会产生哈希碰撞。若 key 值内容相同则替换旧的 value,不然连接到链表后面,链表长度超过阈值 8 就转换为红黑树存储。

3、什么是哈希碰撞,如何解决哈希碰撞?
答:只要两个元素的 key 计算的哈希码值相同就会发生哈希碰撞。jdk8 之前使用链表解决哈希碰撞。jdk8之后使用链表 + 红黑树解决哈希碰撞。

4、如果两个键的 hashCode 相同,如何存储键值对?
答:通过 equals 比较内容是否相同。相同:则新的 value 覆盖之前的 value。不相同:则将新的键值对添加到哈希表中。

三、HashMap继承关系

HashMap继承关系如下图所示:

image-20220308153346883

说明:

四、HashMap集合类的成员

4.1成员变量

4.1.1 serialVersionUID

序列化版本号

private static final long serialVersionUID = 362498820763181265L;
4.1.2 DEFAULT_INITIAL_CAPACITY

集合的初始化容量(必须是 2 的 n 次幂):16

// 默认的初始容量是161 << 4 相当于 1*2的4次方static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
4.1.3 DEFAULT_LOAD_FACTOR

默认的负载因子(默认值 0.75

static final float DEFAULT_LOAD_FACTOR = 0.75f;
4.1.4 MAXIMUM_CAPACITY

集合最大容量

static final int MAXIMUM_CAPACITY = 1 << 30; // 2的30次幂
4.1.5 TREEIFY_THRESHOLD

当链表的值超过8并且数组长度大于64,则会转为红黑树(jdk1.8新增)

// 当桶(bucket)上的结点数大于这个值时会转为红黑树static final int TREEIFY_THRESHOLD = 8;

问题:为什么 Map 桶中结点个数超过 8 才转为红黑树?

4.1.6 UNTREEIFY_THRESHOLD

当链表的值小于 6 则会从红黑树转回链表

// 当桶(bucket)上的结点数小于这个值,树转为链表 static final int UNTREEIFY_THRESHOLD = 6;
4.1.7 MIN_TREEIFY_CAPACITY

当 Map 里面的数量超过这个值时,表中的桶才能进行树形化,否则桶内元素太多时会扩容,而不是树形化为了避免进行扩容、树形化选择的冲突,这个值不能小于4*TREEIFY_THRESHOLD(8)

// 桶中结构转化为红黑树对应的数组长度最小的值 static final int MIN_TREEIFY_CAPACITY = 64;
4.1.8 table

table 用来初始化(必须是二的n次幂)(重点)

// 存储元素的数组 transient Node<K,V>[] table;

在 jdk1.8 中我们了解到 HashMap 是由数组加链表加红黑树来组成的结构,其中 table 就是 HashMap 中的数组,jdk8 之前数组类型是 Entry 类型。从 jdk1.8 之后是 Node 类型。只是换了个名字,都实现了一样的接口:Map.Entry。负责存储键值对数据的。

4.1.9 entrySet

用来存放缓存

// 存放具体元素的集合transient Set<Map.Entry<K,V>> entrySet;
4.1.10 size

HashMap 中存放元素的个数(重点)

// 存放元素的个数,注意这个不等于数组的长度 transient int size;

size 为 HashMap 中 K-V 的实时数量,不是数组 table 的长度。

4.1.11 modCount

用来记录 HashMap 的修改次数

// 每次扩容和更改 map 结构的计数器 transient int modCount;  
4.1.12 threshold

用来调整大小下一个容量的值计算方式为(容量*负载因子)

// 临界值 当实际大小(容量*负载因子)超过临界值时,会进行扩容int threshold;
4.1.13 loadFactor

哈希表的负载因子(重点),默认是0.75f

// 负载因子final float loadFactor;

说明:

// 构造方法,构造一个带指定初始容量和负载因子的空HashMapHashMap(int initialCapacity, float loadFactor);

如果希望链表尽可能少些,要提前扩容。有的数组空间有可能一直没有存储数据,负载因子尽可能小一些。

举例:

例如:负载因子是0.4。 那么16*0.4--->6 如果数组中满6个空间就扩容会造成数组利用率太低了。 负载因子是0.9。 那么16*0.9--->14 那么这样就会导致链表有点多了,导致查找元素效率低。

所以既兼顾数组利用率又考虑链表不要太多,经过大量测试 0.75 是最佳方案。

这个值是当前已占用数组长度的最大值。当 Size >= threshold 的时候,那么就要考虑对数组的 resize(扩容),也就是说,这个的意思就是 衡量数组是否需要扩增的一个标准扩容后的 HashMap 容量是之前容量的两倍

4.2 构造方法

HashMap 中重要的构造方法,它们分别如下:

4.2.1 HashMap()

构造一个空的HashMap,默认初始容量(16)和默认负载因子(0.75)。

public HashMap() {   this.loadFactor = DEFAULT_LOAD_FACTOR; // 将默认的负载因子0.75赋值给loadFactor,并没有创建数组}
4.2.2 HashMap(int initialCapacity)

构造一个具有指定的初始容量和默认负载因子(0.75)HashMap 。

 // 指定“容量大小”的构造函数public HashMap(int initialCapacity) {    this(initialCapacity, DEFAULT_LOAD_FACTOR);}
4.2.3 HashMap(int initialCapacity, float loadFactor)

构造一个具有指定的初始容量和负载因子的 HashMap。

public HashMap(int initialCapacity, float loadFactor) {    // 判断初始化容量initialCapacity是否小于0        if (initialCapacity < 0)            // 如果小于0,则抛出非法的参数异常IllegalArgumentException            throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);    // 判断初始化容量initialCapacity是否大于集合的最大容量MAXIMUM_CAPACITY        if (initialCapacity > MAXIMUM_CAPACITY)            // 如果超过MAXIMUM_CAPACITY,会将MAXIMUM_CAPACITY赋值给initialCapacity            initialCapacity = MAXIMUM_CAPACITY;    // 判断负载因子loadFactor是否小于等于0或者是否是一个非数值        if (loadFactor <= 0 || Float.isNaN(loadFactor))            // 如果满足上述其中之一,则抛出非法的参数异常IllegalArgumentException            throw new IllegalArgumentException("Illegal load factor: " + loadFactor);     // 将指定的负载因子赋值给HashMap成员变量的负载因子loadFactor        this.loadFactor = loadFactor;        this.threshold = tableSizeFor(initialCapacity);    }// 最后调用了tableSizeFor,来看一下方法实现:         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;    }

说明:

对于 java this.threshold = tableSizeFor(initialCapacity); 疑问解答:

tableSizeFor(initialCapacity)判断指定的初始化容量是否是2的n次幂,如果不是那么会变为比指定初始化容量大的最小的2的n次幂。但是注意,在tableSizeFor方法体内部将计算后的数据返回给调用这里了,并且直接赋值给threshold边界值了。有些人会觉得这里是一个bug,应该这样书写:this.threshold = tableSizeFor(initialCapacity) * this.loadFactor;这样才符合threshold的意思(当HashMap的size到达threshold这个阈值时会扩容)。但是请注意,在jdk8以后的构造方法中,并没有对table这个成员变量进行初始化,table的初始化被推迟到了put方法中,在put方法中会对threshold重新计算。

4.3 成员方法

4.3.1 增加方法 put()

put方法是比较复杂的,实现步骤大致如下:

​ 1、先通过 hash 值计算出 key 映射到哪个桶;

​ 2、如果桶上没有碰撞冲突,则直接插入;

​ 3、如果出现碰撞冲突了,则需要处理冲突:

​ a 如果该桶使用红黑树处理冲突,则调用红黑树的方法插入数据;

​ b 否则采用传统的链式方法插入。如果链的长度达到临界值,则把链转变为红黑树;

​ 4、如果桶中存在重复的键,则为该键替换新值 value;

​ 5、如果 size 大于阈值 threshold,则进行扩容;

具体方法如下:

public V put(K key, V value) {return putVal(hash(key), key, value, false, true);}

说明:

​ 1、HashMap 只提供了 put 用于添加元素,putVal 方法只是给 put 方法调用的一个方法,并没有提供给用户使用。 所以我们重点看 putVal 方法。

​ 2、我们可以看到在 putVal 方法中 key 在这里执行了一下 hash 方法,来看一下 hash 方法是如何实现的。

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

从上面可以得知 HashMap 是支持 key 为空的,而 HashTable 是直接用 Key 来获取hashCode 所以 key 为空会抛异常。

解读上述hash方法:

我们先研究下 key 的哈希值是如何计算出来的。key 的哈希值是通过上述方法计算出来的。

这个哈希方法首先计算出 key 的 hashCode 赋值给 h,然后与 h 无符号右移 16 位后的二进制进行按位异或得到最后的 hash 值。计算过程如下所示:

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

在 putVal 函数中使用到了上述 hash 函数计算的哈希值:

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {...if ((p = tab[i = (n - 1) & hash]) == null) // 这里的n表示数组长度16...}

计算过程如下所示:

说明:

key.hashCode();返回散列值也就是 hashcode,假设随便生成的一个值。

n 表示数组初始化的长度是 16。
3、&(按位与运算):运算规则:相同的二进制数位上,都是 1 的时候,结果为 1,否则为0。

^(按位异或运算):运算规则:相同的二进制数位上,数字相同,结果为 0,不同为 1。
在这里插入图片描述

4.3.2 扩容方法 resize()

扩容机制

​ 1、什么时候才需要扩容

​ 当 HashMap 中的元素个数超过数组大小(数组长度)*loadFactor(负载因子)时,就会进行数组扩容,loadFactor 的默认值是 0.75。

4.3.3 删除方法 remove()

删除方法就是首先先找到元素的位置,如果是链表就遍历链表找到元素之后删除。如果是用红黑树就遍历树然后找到之后做删除,树小于 6 的时候要转链表。

删除 remove() 方法:

// remove方法的具体实现在removeNode方法中,所以我们重点看下removeNode方法public V remove(Object key) {        Node<K,V> e;        return (e = removeNode(hash(key), key, null, false, true)) == null ?            null : e.value;    }

removeNode() 方法:

final Node<K,V> removeNode(int hash, Object key, Object value,                           boolean matchValue, boolean movable) {    Node<K,V>[] tab; Node<K,V> p; int n, index;// 根据hash找到位置 // 如果当前key映射到的桶不为空    if ((tab = table) != null && (n = tab.length) > 0 &&        (p = tab[index = (n - 1) & hash]) != null) {        Node<K,V> node = null, e; K k; V v;        // 如果桶上的结点就是要找的key,则将node指向该结点        if (p.hash == hash &&            ((k = p.key) == key || (key != null && key.equals(k))))            node = p;        else if ((e = p.next) != null) {            // 说明结点存在下一个结点            if (p instanceof TreeNode)                // 说明是以红黑树来处理的冲突,则获取红黑树要删除的结点                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);            else {                // 判断是否以链表方式处理hash冲突,是的话则通过遍历链表来寻找要删除的结点                do {                    if (e.hash == hash &&                        ((k = e.key) == key ||                         (key != null && key.equals(k)))) {                        node = e;                        break;                    }                    p = e;                } while ((e = e.next) != null);            }        }        // 比较找到的key的value和要删除的是否匹配        if (node != null && (!matchValue || (v = node.value) == value || (value != null && value.equals(v)))) {            // 通过调用红黑树的方法来删除结点            if (node instanceof TreeNode)                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);            else if (node == p)                // 链表删除                tab[index] = node.next;            else                p.next = node.next;            // 记录修改次数            ++modCount;            // 变动的数量            --size;            afterNodeRemoval(node);            return node;        }    }    return null;}
4.3.5 查找元素方法 get()

查找方法,通过元素的 key 找到 value。

代码如下:

public V get(Object key) {    Node<K,V> e;    return (e = getNode(hash(key), key)) == null ? null : e.value;}

get 方法主要调用的是 getNode 方法,代码如下:

final Node<K,V> getNode(int hash, Object key) {    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;    // 如果哈希表不为空并且key对应的桶上不为空    if ((tab = table) != null && (n = tab.length) > 0 &&        (first = tab[(n - 1) & hash]) != null) {                if (first.hash == hash && // always check first node            ((k = first.key) == key || (key != null && key.equals(k))))            return first;        // 如果不是第一个元素,判断是否有后续结点        if ((e = first.next) != null) {            // 判断是否是红黑树,是的话调用红黑树中的getTreeNode方法获取结点            if (first instanceof TreeNode)                return ((TreeNode<K,V>)first).getTreeNode(hash, key);            do {                // 不是红黑树的话,那就是链表结构了,通过循环的方法判断链表中是否存在该key                if (e.hash == hash &&                    ((k = e.key) == key || (key != null && key.equals(k))))                    return e;            } while ((e = e.next) != null);        }    }    return null;}

小结:

  1. get 方法实现的步骤:

    a. 通过 hash 值获取该 key 映射到的桶
    b. 桶上的 key 就是要查找的 key,则直接找到并返回
    c. 桶上的 key 不是要找的 key,则查看后续的结点:

    1、如果后续结点是红黑树结点,通过调用红黑树的方法根据 key 获取 value2、如果后续结点是链表结点,则通过循环遍历链表根据 key 获取 value

上述红黑树结点调用的是 getTreeNode 方法通过树形结点的 find 方法进行查找:

 final TreeNode<K,V> getTreeNode(int h, Object k) {            return ((parent != null) ? root() : this).find(h, k, null); }final TreeNode<K,V> find(int h, Object k, Class<?> kc) {    TreeNode<K,V> p = this;    do {        int ph, dir; K pk;        TreeNode<K,V> pl = p.left, pr = p.right, q;        if ((ph = p.hash) > h)            p = pl;        else if (ph < h)            p = pr;        else if ((pk = p.key) == k || (k != null && k.equals(pk)))            return p; // 找到之后直接返回        else if (pl == null)            p = pr;        else if (pr == null)            p = pl;        else if ((kc != null ||                  (kc = comparableClassFor(k)) != null) &&                 (dir = compareComparables(kc, k, pk)) != 0)            p = (dir < 0) ? pl : pr;        // 递归查找        else if ((q = pr.find(h, k, kc)) != null)            return q;        else            p = pl;    } while (p != null);    return null;}

查找红黑树,由于之前添加时已经保证这个树是有序的了,因此查找时基本就是折半查找,效率更高。

这里和插入时一样,如果对比结点的哈希值和要查找的哈希值相等,就会判断key是否相等,相等就直接返回。不相等就从子树中递归查找。

若为树,则在树中通过key.equals(k)查找,O(logn)。若为链表,则在链表中通过key.equals(k)查找,O(n)。

4.3.6 遍历 HashMap 集合几种方式

分别遍历Key和Value

for (String key : map.keySet()) {System.out.println(key);}for (Object vlaue : map.values() {System.out.println(value);}

使用 Iterator 迭代器迭代(推荐使用)

Iterator<Map.Entry<String, Object>> iterator = map.entrySet().iterator();while (iterator.hasNext()) {    Map.Entry<String, Object> mapEntry = iterator.next();    System.out.println(mapEntry.getKey() + "---" + mapEntry.getValue());}

通过 get 方式(不建议使用)

Set<String> keySet = map.keySet();for (String str : keySet) {System.out.println(str + "---" + map.get(str));}

五、设计 HashMap 的初始化容量

5.1 问题描述

如果我们确切的知道我们有多少键值对需要存储,那么我们在初始化 HashMap 的时候就应该指定它的容量,以防止 HashMap 自动扩容,影响使用效率。

默认情况下 HashMap 的容量是 16,但是,如果用户通过构造函数指定了一个数字作为容量,那么 Hash 会选择大于该数字的第一个 2 的幂作为容量(3->4、7->8、9->16)。这点我们在上述已经进行过讲解。

5.2 《阿里巴巴Java开发手册》的建议

在这里插入图片描述

关于设置 HashMap 的初始化容量

我们上面介绍过,HashMap 的扩容机制,就是当达到扩容条件时会进行扩容。HashMap 的扩容条件就是当 HashMap 中的元素个数(size)超过临界值(threshold)时就会自动扩容。所以,如果我们没有设置初始容量大小,随着元素的不断增加,HashMap 会有可能发生多次扩容,而 HashMap 中的扩容机制决定了每次扩容都需要重建 hash 表,是非常影响性能的

但是设置初始化容量,设置的数值不同也会影响性能,那么当我们已知 HashMap 中即将存放的 KV 个数的时候,容量设置成多少为好呢?

关于设置 HashMap 的初始化容量大小

可以认为,当我们明确知道 HashMap 中元素的个数的时候,把默认容量设置成 initialCapacity/ 0.75F + 1.0F 是一个在性能上相对好的选择,但是,同时也会牺牲些内存。

而 Jdk 并不会直接拿用户传进来的数字当做默认容量,而是会进行一番运算,最终得到一个 2 的幂。

来源地址:https://blog.csdn.net/java123456111/article/details/123378463

阅读原文内容投诉

免责声明:

① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。

② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341

软考中级精品资料免费领

  • 历年真题答案解析
  • 备考技巧名师总结
  • 高频考点精准押题
  • 2024年上半年信息系统项目管理师第二批次真题及答案解析(完整版)

    难度     801人已做
    查看
  • 【考后总结】2024年5月26日信息系统项目管理师第2批次考情分析

    难度     348人已做
    查看
  • 【考后总结】2024年5月25日信息系统项目管理师第1批次考情分析

    难度     311人已做
    查看
  • 2024年上半年软考高项第一、二批次真题考点汇总(完整版)

    难度     432人已做
    查看
  • 2024年上半年系统架构设计师考试综合知识真题

    难度     220人已做
    查看

相关文章

发现更多好内容

猜你喜欢

AI推送时光机
位置:首页-资讯-后端开发
咦!没有更多了?去看看其它编程学习网 内容吧
首页课程
资料下载
问答资讯