HashMap

867 阅读8分钟

HashMap可是java里非常经典的数据结构了。这里记一下常见的点,暂时先不深挖红黑树相关内容。

1、关键字段

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; 默认初始容量16
static final int MAXIMUM_CAPACITY = 1 << 30;  最大容量
static final float DEFAULT_LOAD_FACTOR = 0.75f;  默认负载因子
static final int TREEIFY_THRESHOLD = 8;  链表转红黑树的阈值
static final int UNTREEIFY_THRESHOLD = 6;  红黑树恢复链表的阈值
static final int MIN_TREEIFY_CAPACITY = 64;  允许链表转红黑树的最小容量
final float loadFactor;  负载因子,只有创建map时能设置,设置后不可更改。若不设置则使用DEFAULT_LOAD_FACTOR
int threshold;  扩容阈值,值为 当前容量*loadFactor
transient Node<K,V>[] table;  存储数据的数组

节点类
static class Node<K,V> implements Map.Entry<K,V> {
    final int hash;  存储的是hash(key)的值
    final K key;
    V value;
    Node<K,V> next;

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

1、HashMap内部实现原理

1. 数据结构

jdk 1.8之前是数组+链表,1.8开始优化为数组+链表+红黑树

2. 链表与红黑树转换条件

链表转树:如果链表的长度达到了TREEIFY_THRESHOLD,要再判断容量是否到达MIN_TREEIFY_CAPACITY,如果没到则扩容,到了才会转为红黑树

树转链表:当链表长度<=UNTREEIFY_THRESHOLD时转为链表

3. 为什么链表转树和树转链表阈值不同

为了避免链表长度在阈值范围上下浮动时出现频繁的链表与树之间的转换操作,浪费性能

2、容量相关

1. 容量的特点

容量只能是2的n次方,即使初始化时明确指定了容量,HashMap内部也会将容量设置为不小于且最接近指定容量的2的n次方数。比如指定容量为20,则实际容量为32。源码如下:

public HashMap(int initialCapacity, float loadFactor) {
    if (initialCapacity < 0)  
        throw new IllegalArgumentException("Illegal initial capacity: " +
                                           initialCapacity);
    if (initialCapacity > MAXIMUM_CAPACITY)  
        initialCapacity = MAXIMUM_CAPACITY;
    if (loadFactor <= 0 || Float.isNaN(loadFactor))
        throw new IllegalArgumentException("Illegal load factor: " +
                                           loadFactor);
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);  处理传入的指定容量
}

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;
    经过上述位运算后,最后得到的就是不小于且最接近指定容量的2的n次方数。这个算法非常巧妙,要我我肯定是想不到。人与人的差距啊,哎
}

2. 为什么容量要设计成上述那样

为了便于确定元素在数组中的存储位置

3. 扩容一次是扩多少

原来容量的二倍

3、 怎样确定元素在数组中的存储位置

存储下标i = (table.length - 1) & hash(key),其中table就是存储元素的数组,key就是元素的键。hash方法稍后说。

这里又是一个非常巧妙的设计,由于前面规定了容量(即数组的长度)必定是2的n次方,所以table.length - 1的二进制表示必定是连续的1。这样在和hash(key)进行 与 操作后,得到的值必定<= table.length - 1,不会发生计算出的下标值越界,而且具体的值是多少是由key决定的。例如:

假设容量为16,则 二进制表示的 table.length - 1 = 1111 
元素的hash(key) = 1101 1010 1100 1111 1110 0101 0111 1011
则存储位置为:

table.length - 1:0000 0000 0000 0000 0000 0000 0000 1111
hash(key)       :1101 1010 1100 1111 1110 0101 0111 1001
—————————————————————————————————————————————————————————  &运算
存储位置下标     :0000 0000 0000 0000 0000 0000 0000 1001  =  9

再来说hash(key)。这个方法就是对key的hashCode值进行扰动处理,源码如下:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    将key的hashCode值右移16位后再与其本身异或
}

为什么要这样处理?通过上面可知,以容量16为例,如果不进行扰动处理,则元素的存储位置只由key的hashCode值的最低四位决定,与前面28位是多少没有任何关系。这就增加了发生哈希冲突的概率。通过扰动处理,让hashCode的高16位也参与进来,这样元素的存储位置就与高位也有关了,减小了哈希冲突的概率。

4、添加数据

添加数据有两个方法:putputIfAbsent,前者是如果要添加的元素在map中存在,则将map中元素的value替换成新值。后者顾名思义只有元素在map中不存在时才会添加,如果存在就保持原值,不替换为新值。

记一下大致流程,不细抠代码实现,没意义:

  1. 判断table是否是null或者长度为0,如果是(此时表示map创建后首次添加元素)就执行扩容操作,否则往下进行

  2. 先计算出元素在数组中的存储位置,然后判断该位置是否有元素节点

    2.1. 如果没有节点,说明map里没有该元素,就创建一个节点放入该位置,步骤2结束

    2.2. 如果有节点,就从该节点开始,往下依次读取链表(或红黑树)中的节点元素的key,寻找与要添加元素的key相同(相同的标准为key和hash(key)都相同)的节点

    2.1.1. 如果链表(或红黑树)中所有节点都找完了,没有相同的,说明map里没有该元素,就创建一个节点,添加到链表(或红黑树)中。添加完后如果此时是链表,再判断是否达到转红黑树的条件,如果达到条件就转红黑树,否则步骤2结束

    2.2.2. 如果找到了相同的节点,如果本次添加元素是调用的put方法,就把该节点元素的value替换为新值;如果调用的是putIfAbsent,则保持原值不变(前提是value!=null,如果value为null,即使是用putIfAbsent添加的元素,也会将null替换为新值)。步骤2结束

  3. modCount自增,然后判断此时map中存储的元素数是否达到扩容阈值,如果达到则执行扩容操作。

5、获取数据

获取数据比较简单,不涉及扩容以及链表和红黑树的转换操作。

根据传入的key计算出在数组中的位置,如果该位置没有节点,说明没有要查询的数据,直接返回null;否则判断该节点的key与传入的key是否相同,如果相同,说明该节点就是要查询的数据,返回该节点元素的value;如果不相同,继续查找该节点下的链表(或红黑树)中是否有与传入的key相同的节点,如果有则返回节点元素的value,否则说明map中没有要查询的数据,返回null。

6、 删除数据

删除数据也有两个方法:remove(key)和remove(key,value),前者是删除map中与给定的key相同的元素,后者是只有指定key的元素的value和指定的value相同才删除。

同样记一下大致流程,不细抠代码实现:

如果table是null或者长度为0或者根据传入的key计算出的下标处无元素,说明集合里没有要移除的数据,直接返回null;否则就从下标位置的这条链中找有没有与传入的key相同的元素,如果有就移出并返回该元素,否则直接返回null

7、key和value都是否可空

key和value都可以是null

8、如何保证线程安全

  1. ConcurrentHashMap:官方提供的并发map,并发性能出色,最推荐。ConcurrentHashMap的演进:从Java 8之前到Java 17的实现原理深度剖析-腾讯云开发者社区-腾讯云 (tencent.com)
  2. Collections.synchronizedMap:其原理是给传入的map的所有方法都加上synchronized来保证线程安全,性能不如ConcurrentHashMap。
  3. 手动同步代码:在使用map的地方手动使用同步代码块来保证线程安全,需小心设计,避免性能问题甚至死锁。

9、jdk 1.7 死锁现象

死锁现象出现在多线程执行put操作时。具体一点是在多线程情况下,进行扩容操作时,有可能将链条形成一个环。形成环后如果不访问这个环,或者访问环时在环中能找到目标数据,就不会死锁,只有在环中找不到数据时,会顺着环一直无线循环查找下去,导致死锁。详细原因见java - 11张图让你彻底明白jdk1.7 hashmap的死循环是如何产生的 - 个人文章 - SegmentFault 思否