HashMap源码阅读笔记

HashMap简介

平日不练习,面试挑灯读!从源码的角度解析HashMap的关键操作,愿开卷有益!
后面会从架构设计、逻辑等角度再写一篇,届时将链接放过来,展现一个更裸感的HashMap…^_^

HashMap是最常用的集合类之一,也是面试时的高频考察点。其本质上K-V结构,使用多个数组和链表来存储数据及关联关系。所有数据以K的哈希值作为哈希表中的位置索引,再以K的equals函数得出两个值是否相同,若相同则覆盖旧的value,不相同则追加到当前哈希值对应的单链表或红黑树中。

HashMap在jdk2.0时由官方引入到jre,采用数组+单链表;
在jdk8时增加红黑树,超过8个时单链接变红黑树,低于6个时红黑树变单链表。

写在前面:红黑树部分还未深入研究,下述内容不会涉及红黑树较多的细节,争取尽快补充进来

类图

HashMap Diagram

图1,HashMap Diagram

存储结构示意

在这里插入图片描述

图2,存储结构。源文件

通过K计算出的hash座位散列表的索引,决定存储位置。

元素先以单链表结构存储(如3和6),当同一个槽位的元素增长达到8个时(容器长度大于64),将单链表转为红黑树(如12);
但当元素减少达到6个时,会转为单链表(如3);
7个为中间态,保持当前结构不变

  • 6和8的设计,是在大量尝试下平衡空间与时间的最优解。这样可以增加一个缓冲带,防止同一个槽位的频繁增删,导致频繁转换(每次装换都需要消耗很多性能)。
  • 而且根据数据统计超过8个的概率非常小,容器中hash槽的使用分布遵循泊松分布。这应该得益于容器的扩容和元素数量挂钩,而不是和使用的槽位挂钩的设计。即使只有一个槽位在用,只要元素数量超过阈值,一样会扩容。
  • 容量大于64才开启红黑树,是因为红黑树占用空间的大小约是普通节点的两倍,

主要特点

  • K-V结构,且K和V都可为空,K为空时哈希值为0
    • K为数组,V为单链表或红黑树(n增长达到8个转红黑树,n减少达到6个转单链表,6~8之前未中间态,保持原结构)
  • 无序。

观源码后个人认为,应该还是有序的,不会出现没有写操作情况下,两次遍历出来的顺序不同。
因为从图2可知,顺序由hash和插入顺序这两个因素决定。可尝试控制hash值和链表顺序来达到 有序 目的。`

  • 线程不安全。效率高,但不可控
  • 数组自动扩容。初始为0,默认初始大小16,扩容系数0.75,每次扩容当前大小的2倍。

解释下0和16。在不指定大小时初始为0,当第一次写操作时会触发扩容,扩容后大小就是默认初始大小16

  • 结尾加一个解释:HashMap中所有最大最小阈值等,都不包含等于指定的值(开区间。如超过8个转红黑树,而不是达到8个)

了解主要变量、函数、方法

(此处作为前置概念的了解,相关代码在下面可能还会出现,可略过直接看下一段源码逻辑解析)

属性及常量

重点关注

/**
 * 散列表(或称hash表),也可称为HashMap的容器,用来存储put进来的hash值的数组
 * 当一个对象put进来,计算其hash对应的值作为索引插入对应位置
 * 
 * **************** 疑惑待解:假设插入20个相同hash的元素,table实际只用了1个位置,但为什么也要从16扩容到32?
 */
transient Node<K,V>[] table;

/**
 * 元素集合,Node的集合对象
 * 链表时为Node类对象,红黑树时为TreeNode类对象
 */
transient Set<Map.Entry<K,V>> entrySet;

容量相关

/**
 * 默认初始容量 - 容量大小必须是2的倍数
 * 默认16,可通过构造函数传入
 */
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

/**
 * 容器的最大容量,如果初始化指定的超过该值,则以该值为准
 * 最大值:1073741824
 */
static final int MAXIMUM_CAPACITY = 1 << 30;

/**
 * 默认的扩容系数,put时当前size+插入元素数量超过该值时触发扩容。
 * 每次容器大小变化后都会重新计算并将阈值赋值给threshold,之后每次与threshold对比判断即可
 * 默认0.75,可通过构造函数传入
 * 
 * *********** 疑惑待解:当向同一个槽位插入第9个元素时就扩容到32,第10个时扩到了64,……。扩容前threshold没有变成9或10
 */
static final float DEFAULT_LOAD_FACTOR = 0.75f;
int threshold;
/**
 * 扩容系数。默认为DEFAULT_LOAD_FACTOR(0.75),可通过HashMap的构造函数自定义
 */
final float loadFactor;

链表与红黑树

/**
 * 链表转红黑树阈值
 * 链表中元素数量大于等于8时,链表转红黑树
 */
static final int TREEIFY_THRESHOLD = 8;

/**
 * 红黑树转链表阈值
 * 红黑树中元素数量小于等于6时,红黑树转为链表
 */
static final int UNTREEIFY_THRESHOLD = 6;

/**
 * 红黑树可用的最小哈希表长度,只有table.length大于64是,才会解锁红黑树功能
 */
static final int MIN_TREEIFY_CAPACITY = 64;

关键内部类

class Node<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(int hash, K key, V value, Node<K,V> next) {……}
}
class TreeNode<K, V>

在这里插入图片描述

// LinkedHashMap.java文件,TreeNode的父类完整代码
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); }
}
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;
    TreeNode(int hash, K key, V val, Node<K,V> next) {……}
}
  • 红黑树 -> 单链表
final Node<K,V> untreeify(HashMap<K,V> map) {
    Node<K,V> hd = null, tl = null;
    for (Node<K,V> q = this; q != null; q = q.next) {
        Node<K,V> p = map.replacementNode(q, null); //假设当前的q就是最后一个元素,创建一个尾节点
        if (tl == null)
        	//第一次循环时p实际是root节点,且tl为null。此时将代表root节点的对象p赋值给hd存储起来
            hd = p;
        else
            tl.next = p;
        tl = p;// 把当前节点当做下一个的父节点
    }
    return hd;
}

HashMap构造函数

  • 指定初始属性方式初始化
/**
 * 全部使用默认配置
 * 实例化时容器为空,第一次写时扩容到16
 * 指定扩容系数为默认值0.75
 */
public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}

/**
 * initialCapacity:自定义容器大小,有2个注意点:
 * 1. 不是2的幂数则向上找最近的幂数作为初始大小,规则见tableSizeFor(initialCapacity)
 * 2. 如果放入大量数据,避免发生多次扩容,建议用n/0.75作为初始值
 * loadFactor:指定扩容系数,默认0.75
 */
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;
    // 此变量为扩容阈值(如容量16则阈值12),但因HashMap没有存大小的变量,所以用阈值暂时记录
    // 实际的阈值,会在第一次写操作时通过resize()计算出来,代码见下
    this.threshold = tableSizeFor(initialCapacity);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    if ((tab = table) == null || (n = tab.length) == 0)
    	// 因为table还未初始化,所以首次会进来先初始化操作
        n = (tab = resize()).length;
    ……
}
final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    int oldThr = threshold;
    // 初始时newThr=0
    int newCap, newThr = 0;
    if (oldCap > 0) {……}
    else if (oldThr > 0) // initial capacity was placed in threshold
    	// 此时将构造函数中的初始容量作为容器大小
        newCap = oldThr;
    else {……}
    if (newThr == 0) {
    	// 计算扩容阈值
        float ft = (float)newCap * loadFactor;
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE);
    }
    // 将阈值赋值给成员变量
    threshold = newThr;
    ……
}
  • 克隆方式
public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    // clone、putAll等克隆类型的操作,都使用的putMapEntries函数
    putMapEntries(m, false);
}

源码逻辑解析

put(不含红黑树部分)

先说一下put的几个特点

  • 尾插法。与jdk7的头插法不同,尾插法可最大化保证数据结构安全,在多线程中头插法有可能导致整个链表丢失,二尾插法最多只会丢失自身
  • 当前槽位元素数量>=8时,转红黑树
  • 当前槽位元素数量<=6时,转单链表
  • 日常开发中,建议使用String、Integer这样的类作为key
    final修饰的类,具有不可变性,保障了key的不可变更,避免因作为key的对象计算hash值依赖的变量变化,导致脏数据出现

接下来上代码,相关的说明,都写在对应的代码位置了

public V put(K key, V value) {
	return putVal(hash(key), key, value, false, true);
}
// 先计算key的hash
static final int hash(Object key) {
    int h;
    // 如果key为null,则hash=0
    // 用key对象的hashCode()获取到hash值
    // h >>> 16 取出hash的高16位
    // 用hash异或其高16位作为最终的hash值
    // 高低16位异或运算是为了打散key的位置(扰动处理),使其尽可能分散(补充一句:所有的操作,都是为了提高分布的随机性和均匀性,减少hash冲突)
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
	// tab:即成员变量table。hash组成的数组,也称作散列表。此处用局部变量,可防止多线程中扩容时数据混乱,但可能造成此次put的元素丢失
	// p:当前hash对应的单链表的root对象
	// n:hash表的长度
	// i:当前hash对应的索引值
	Node<K,V>[] tab; Node<K,V> p; int n, i;
	// 首次写入,先初始化容器和属性值
	if ((tab = table) == null || (n = tab.length) == 0)
		// 默认参数下,table长度16,扩容系数0.75,扩容阈值threshold=16*0.75=12
        n = (tab = resize()).length;
    
    // 计算出table的索引位置赋值给p。假设此时哈希表长度16,假设hash=1010101010101010
    // 1010101010101010(43690)
    // &		   1111(15)
    // -----------------------
    // 0000000000001010(10)
    // 如果索引10的位置是null
    if ((p = tab[i = (n - 1) & hash]) == null)
    	// 实例化一个Node对象放入10的位置
    	// 这个元素此后将作为链表的root节点
        tab[i] = newNode(hash, key, value, null);
    else { // 已有与当前hash相同的元素了
    	// e:临时存储要插入的元素的Node实例
        Node<K,V> e; K k;
        // 如果hash相等,key.equals(o)也为true,则表示新值覆盖旧值
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        else if (p instanceof TreeNode)
        	// 红黑树结构处理,后面单独讲
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        else { // 当前为单链表,可能需要追加(尾插法)
        	// 此处使用for循环,是为了遍历单链表,找到尾节点插入到它后面
            for (int binCount = 0; ; ++binCount) {
                if ((e = p.next) == null) {
                	// 先将新元素追加到单链表,再判断是否需要转红黑树
                    p.next = newNode(hash, key, value, null);
                    if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                        treeifyBin(tab, hash);
                    break;
                }
                // 忽略。与前面判断是否相等一样,此处只是安全保障,正常情况下不会走进来
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break;
                p = e;
            }
        }
        // 是否覆盖旧值,一般都是覆盖。只有通过putIfAbsent放入时不覆盖旧值
        // 注意,如果是覆盖旧值,改变的只是Node中的value,而不是重新构建整个Node
        if (e != null) {…… e.value = value; ……}
    }
    ++modCount; // ???注释中说用于快速失败,后续再研究
    if (++size > threshold)
    	// 如果增加了这个元素后,超过扩容阈值,则进行扩容
        resize();
    // ???插入后处理。LinkedHashMap时才用到,似乎是头插法
    afterNodeInsertion(evict);
    return null;
}

小结

用key的hash值,通过高低16位异或运算尽量使插入的元素打散平均,再取到当前哈希表的索引。
如果key的hash和equals同时相等则表示覆盖已有元素,否则追加到链表尾部。
插入链表是通过遍历找到末尾节点实现,也就意味着要遍历整个链表。
之后判断是否要转红黑树。
put(红黑树)-暂略

resize(不含红黑树部分)

  • 先插入,后扩容
  • 扩容时先扩容器,再放元素。
    • 尽可能避免了并发时容器重复扩容的问题,但此时另一个线程可能获取不到数据
    • 且扩容中别的线程put的数据会丢失
  • 槽位计算中是否移位。这一段特别说明下,取自代码if ((e.hash & oldCap) == 0)newTab[j] = loHead;newTab[j + oldCap] = hiHead;
/* 此处是否移位,通过if ((e.hash & oldCap) == 0)控制,true表示原位不动
 * 举例说明:原容器长度16,hash=5和37都对应槽位5,扩容到32后就对应两个槽位了
-- 5 & 16 = 5
	00101
&	10000
	00000 = 0
-- 而21&16 = 16
	10101
&	10000
	10000 = 16
由此可知,原先小于旧容器长度的,或运算后为0,其它的位置向后移原容器长度位置

final Node<K,V>[] resize() {
	Node<K,V>[] oldTab = table;// 旧哈希表数组对象
    intoldCap = (oldTab == null) ? 0 : oldTab.length;// 当前数组长度(不是元素个数)
    int oldThr = threshold; // 旧的扩容阈值
    int newCap, newThr = 0; // 新的容器大小(哈希表数组长度),和新的扩容阈值

	// 如果当前容量已超过最大值(Integer的最大值的一半1073741824),则不再扩容。
    if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;// 将扩容阈值设到最大,以示不再触发扩容
            return oldTab;
        }
        else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
            newThr = oldThr << 1;// 计算扩容后的阈值(不是容器大小,HashMap不记录容器大小,只记录扩容阈值)。左位移一位比乘以2更快
    } else if (oldThr > 0) // 如果当前容器是空的,则使用构造函数传入的初始大小
        newCap = oldThr;
    else { // 当前容器是空的,且没有指定初始大小,则使用默认值
        newCap = DEFAULT_INITIAL_CAPACITY;// 16
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);// 0.75 * 16 = 12
    }
    // *** 将新的阈值生效
    threshold = newThr;
    // 创建新的容器,并赋值给局部变量方便操作。至于原先的容器,由于没有了引用会被自动回收
    Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
    // 注意::此时容器是空的,get操作找不到数据
    table = newTab;
    if (oldTab != null) {
    	for (int j = 0; j < oldCap; ++j) {
    		Node<K,V> e;
    		if ((e = oldTab[j]) != null) {// 此处e表示当前槽位的root节点
    			oldTab[j] = null;
                if (e.next == null) // 只有一个节点时
                    newTab[e.hash & (newCap - 1)] = e;
                else if (e instanceof TreeNode)
                	// 当前槽位是红黑树
                	// 红黑树转链表,也在此函数中实现
                    ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
                else {
                	
                	// loHead和loTail,分别表示原位置不变的root、后续节点
                	Node<K,V> loHead = null, loTail = null;
                	// 表示需要移到新位置的节点
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;// 首次进来e为root节点,后面由do-while(...)重新将next赋值给自己
                        // 是否需要移到高位判断,详细可见看resize中的描述部分
                        if ((e.hash & oldCap) == 0) {
                        	// 不移位。假如原先容器16个,hash=5,现在仍然5
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        }
                        else {
                        	// 移位。假如原先容器16个,hash=21,槽位从5改到21
                            if (hiTail == null)
                                hiHead = e;
                            else
                                hiTail.next = e;
                            hiTail = e;
                        }
                    } while ((e = next) != null);
                    if (loTail != null) {
                        loTail.next = null;
                        newTab[j] = loHead;
                    }
                    if (hiTail != null) {
                        hiTail.next = null;
                        // 和上面需要移位的相配合。j + oldCap = 5 + 16 = 21
                        newTab[j + oldCap] = hiHead;
                    }
                }
    		}
    	}
    }
}

resize - 链表转红黑树-暂略

java.util.HashMap.TreeNode#split

get

先从整体梳理一下get的实现思路:

  1. 通过hash确定table的索引位置
  2. 判断第一个节点是不是目标元素(hash相等且key.equals为true)
  3. 使用Node的next指针获取第二个节点。从此后开始,单链表与红黑树的获取方法不一样了
    1. 先判断是不是红黑树,如果是则使用TreeNode的getTreeNode函数(后面单独讲)
    2. 不是红黑树的话,循环单链表。要么hash相等且key.equals为true,要么循环到最后返回null
    • 说明下,从第二个元素处开始判断是否为TreeNode类型,并不是第2个节点才是TreeNode的实例,而是第一个节点前两个节点无论Node还是TreeNode,都可以当做Node来操作。但第三个节点的获取开始红黑树就出现岔口,不能再用一种玩法了
public V get(Object key) {
    Node<K,V> e;
    // hash在put中已讲过
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    if ((tab = table) != null && (n = tab.length) > 0 && (first = tab[(n - 1) & hash]) != null) {
        if (first.hash == hash && ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) { // 此时first不是root节点,而是第二个节点了
            if (first instanceof TreeNode)// 红黑树有自己封装的方法,后面讲
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            do { // 循环寻找元素,要么找到后return直接返回结果,要么没有next了退出循环
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

remove(不含红黑树)

  • 如果红黑树的数量小于等于6,在split中会通过untreeify函数降级为单链表
  • 注意:remove不会触发resize,即使全部移除(clean()),也不会使table缩小
public V remove(Object key) {
    Node<K,V> e;
    return (e = removeNode(hash(key), key, null, false, true)) == null ?
        null : e.value;
}
/**
 * matchValue:是否value也要相等才移除
 * movable:移除时不移动其它节点。红黑树专用,后面再讲
 */
final Node<K,V> removeNode(int hash, Object key, Object value, boolean matchValue, boolean movable) {
	// 特别说明下p:目标节点的父节点(单链表中next指针挂着目标节点的那个节点)
    Node<K,V>[] tab; Node<K,V> p; int n, index;
    if (……) {
    	// 获取逻辑省掉了,此处说明下这几个变量
    	// node:key对应的Node节点对象
    	// e:node.next
    	// k和v,即我们存入的键与值
        Node<K,V> node = null, e; K k; V v;
        // 获取逻辑,和get思路相同,不再赘述
        ……
        // 判断是否获取到了目标节点,同时matchValue控制是否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) // 要移除的是root节点,则直接将第二个元素提到首位
                tab[index] = node.next;
            else
            	// 如果移除的是中间节点,将目标节点的链接断开,使其成为“无主状态”
            	// 如果移除的是尾节点,那么父节点指向null(目标节点的next=null)
                p.next = node.next;
            ++modCount;// 记录修改的次数
            --size;// 元素数量-1
            afterNodeRemoval(node);
            return node;
        }
    }
    return null;
}

remove(红黑树)-暂略

面试题收集

与jdk7异同

差异项jdk 8jdk 7描述
插入位置头插尾插多线程中头插法可能导致整个链表丢失,或者循环链表
扩容时机先插入后判断先判断并扩容,而后插入
初始化resize()inflateTable()啥区别?
存储结构单链表+红黑树单链表数结构占用空间多,但搜索效率高
key=null仅使其hash=0通过putForNullKey函数特殊处理,相当于创建了一个对象
hash扰动次数2次(1次位运算+1次异或运算)9次(4次位运算+5次异或)
hash获取方式对象本身的hashCode()String类型自己算hash

参考引用
HashMap 在 JDK1.7 和 JDK1.8 中有哪些区别?

扩展阅读


  • hash冲突的解决方法
    答:开放定址法、链地址法、再哈希法、建立公共溢出区。

待补充

一点疑惑

  1. 原先k的哈希变了后,脏数据如何处理?
    通过resize函数可知,脏数据会一直存在。只有clean时才能清理
  2. 为什么移出元素时不缩小容器?
  3. get时,红黑树应该在root节点就两分了,那么root.next是谁?
  4. 初始默认配置状态下,向同一个槽位插入第9个元素时会扩容到32,第10个时扩到64

红黑树部分

致谢

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值