[JavaSE 源码分析] 关于HashMap的个人理解

Posted slowbird

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了[JavaSE 源码分析] 关于HashMap的个人理解相关的知识,希望对你有一定的参考价值。

HashMap是什么?

Map是Java常用的一种存储数据的Key-Value结构, 键值对
HashMap是Map结构, 底层采用Hash算法存取Key值

HashMap: 基于哈希表的 Map 接口的实现.

  1. 此实现提供所有可选的映射操作, 并允许使用 null 值和 null 键.
    (除了非同步和允许使用 null 之外, HashMap 类与 Hashtable 大致相同. )
  2. 此类不保证映射的顺序, 特别是它不保证该顺序恒久不变.
  3. 此实现假定哈希函数将元素适当地分布在各桶之间, 可为基本操作(get 和 put)提供稳定的性能.
  4. 迭代 collection 视图所需的时间与 HashMap 实例的“容量”(桶的数量)及其大小(键-值映射关系数)成比例. 所以, 如果迭代性能很重要, 则不要将初始容量设置得太高(或将加载因子设置得太低)

HashMap的底层数据结构是什么?

Entry是Map的辅助数据结构, Map的底层结构是Entry数组
但处理hash冲突使用链表法, 使用链表的方式存储冲突元素
当某位置上的冲突数据累计到一定程度上时, 会转化成红黑树结构

// 必定是2的倍数 
transient Node<K,V>[] table;

HashMap的底层数据结构是数组和链表 用于存放元素
当某位置上元素个数大于等于8(并且table大小大于64)时 链表转换成红黑树结构 TREEIFY_THRESHOLD=8
if (binCount >= TREEIFY_THRESHOLD - 1) treeifyBin(tab, hash)
元素个数小于6时 红黑树结构转换成链表结构 UNTREEIFY_THRESHOLD=6
if (lc <= UNTREEIFY_THRESHOLD) tab[index] = loHead.untreeify(map)

数组的特点是: 寻址容易, 插入删除困难
链表的特点是: 寻址困难, 插入删除容易
红黑树: 自平衡的二叉查找树, 查找效率是非常的高, 查找效率会从链表的o(n)降低为o(logn)

  1. 构造红黑树要比构造链表复杂, 在链表的节点不多的时候, 从整体的性能看来, 数组+链表+红黑树的结构可能不一定比数组+链表的结构性能高
  2. HashMap频繁的扩容, 会造成底部红黑树不断的进行拆分和重组, 这是非常耗时的.
    因此, 也就是链表长度比较长的时候转变成红黑树才会显著提高效率

table容量为什么必须是二的倍数?

为了更方便的存取数据, 通过位运算代替模运算
hash&(n-1)就是它的存放位置

// index 为元素在table数组中存放位置
// n = table.length
// hash 为key的hash
index = (n - 1) & hash;

// 有可能1都集中在前16位中 
// 而导致明明相差很大的数据 因为后16位相同而发生冲突
static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
// 右位移16位, 正好是32bit的一半, 自己的高半区和低半区做异或, 
// 就是为了混合原始哈希码的高位和低位, 以此来加大低位的随机性. 
// 而且混合后的低位掺杂了高位的部分特征, 这样高位的信息也被变相保留下来. 

table容量怎么做到二的倍数?

通过右移和或位运算的方式 得到大于等于输入参数的最小2的倍数

/**
* 返回大于等于给定参数的值(2的倍数)
* 首位为1 其余为0
* cap最大为: 1 << 30
*
* 先求全1 再加1 --> 1111 + 1 = 1 0000
*/
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;
}
  • 为什么要对cap做减1操作?
    这是为了防止, cap已经是2的幂. 如果cap已经是2的幂, 又没有执行这个减1操作, 则执行完后面的几条无符号右移操作之后, 返回的capacity将是这个cap的2倍.
  • 因为输入参数不为0 则必有1bit为1 最高位为1
    第一次右移: 最高位和次高位 最高位必为1
    第二次右移: 前两位和前三四位 前两位必为1
    第三次右移: 前四位和前5~8位 前四位必为1
    ...
    最高位以下全为1
  • 最终该值被赋予threshold 只是临时存储 在resize()初始化时
// initial capacity was placed in threshold
else if (oldThr > 0) 
    newCap = oldThr;

Entry是怎样的结构?

Entry是单个键值对, 提供简单的key, value的操作方式

interface Entry<K,V> {
    // 返回该实例存储的Key
    K getKey();

    // 返回该实例存储的Value
    V getValue();

    // 替换该实例存储的value 
    // 返回原有value值
    V setValue(V value);

    // 判断两实例相等的方法
    // 一般指定两者的Key, Value均要相等
    boolean equals(Object o);

    // 获取该实例的hashCode
    // 一般为该实例的唯一标识
    int hashCode();

    // 返回一个比较Entry key值的比较器
    public static <K extends Comparable<? super K>, V> Comparator<Map.Entry<K,V>> comparingByKey() {
        return (Comparator<Map.Entry<K, V>> & Serializable)
            (c1, c2) -> c1.getKey().compareTo(c2.getKey());
    }

    // 返回一个比较Entry value值的比较器
    public static <K, V extends Comparable<? super V>> Comparator<Map.Entry<K,V>> comparingByValue() {
        return (Comparator<Map.Entry<K, V>> & Serializable)
            (c1, c2) -> c1.getValue().compareTo(c2.getValue());
    }

    // 通过给进比较key值的比较器 来获得一个比较Entry的比较器
    public static <K, V> Comparator<Map.Entry<K, V>> comparingByKey(Comparator<? super K> cmp) {
        Objects.requireNonNull(cmp);
        return (Comparator<Map.Entry<K, V>> & Serializable)
            (c1, c2) -> cmp.compare(c1.getKey(), c2.getKey());
    }

    // 通过给进比较value值的比较器 来获得一个比较Entry的比较器
    public static <K, V> Comparator<Map.Entry<K, V>> comparingByValue(Comparator<? super V> cmp) {
        Objects.requireNonNull(cmp);
        return (Comparator<Map.Entry<K, V>> & Serializable)
            (c1, c2) -> cmp.compare(c1.getValue(), c2.getValue());
    }
}

Node: Entry在HashMap中的具体实现

Node是个链表节点结构, 主要用途在处理hash冲突时, 作为缓解手段
(计算hash得出的存储位置上已经有元素, 则将该元素作为上一元素的下一节点)

static class Node<K,V> implements Map.Entry<K,V> {
    // hash, key一般赋值后不能被修改
    final int hash;
    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;
    }

    public final K getKey()        { return key; }
    public final V getValue()      { return value; }
    public final String toString() { return key + "=" + value; }

    // 该Node实例的hashCode是key的hashCode和value的hashCode相异或
    public final int hashCode() {
        return Objects.hashCode(key) ^ Objects.hashCode(value);
    }

    public final V setValue(V newValue) {
        V oldValue = value;
        value = newValue;
        return oldValue;
    }

    // key,value都相等
    public final boolean equals(Object o) {
        if (o == this)
            return true;
        if (o instanceof Map.Entry) {
            Map.Entry<?,?> e = (Map.Entry<?,?>)o;
            if (Objects.equals(key, e.getKey()) &&
                Objects.equals(value, e.getValue()))
                return true;
        }
        return false;
    }
}

处理hash冲突的方法

  1. 开放定址法
    一旦发生了冲突, 就去寻找下一个空的散列地址, 只要散列表足够大, 空的散列地址总能找到, 并将记录存入
  2. 链地址法
    将哈希表的每个单元作为链表的头结点, 所有哈希地址为index的元素构成一个同义词链表.
    即发生冲突时就把该关键字链在以该单元为头结点的链表的尾部
  3. 再哈希法
    当哈希地址发生冲突用其他的函数计算另一个哈希函数地址, 直到冲突不再产生为止
  4. 建立公共溢出区
    将哈希表分为基本表和溢出表两部分, 发生冲突的元素都放入溢出表中

HashMap初始化或扩容 resize()

  1. HashMap直到调用它的时候才开始初始化, 在添加数据后才会开始判断是否需要扩容(通过阈值)
  2. 如果没有设置参数 map容量为默认的16 阈值为9 (在此时设置)
  3. 扩容时, 容量和阈值均翻倍
/**
* 初始化或数组容量翻倍
*/
final Node<K, V>[] resize() {
    // 获得原有全局表 table
    Node<K, V>[] oldTab = table;
    // 获得原有表的容量 
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    // 获得原有表的阈值 容量*负载因子
    // 如果设置了初始容量 threshold等于设置的初始容量(大于等于输入参数的最小的二倍数)
    // 如果没有则为0
    int oldThr = threshold;
    // 设置新表的容量和阈值
    int newCap, newThr = 0;
    // 如果原有表不为空 
    if (oldCap > 0) {
        // 如果原有表的容量大于等于最大容量 不用扩展
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;
        // 如果旧表的容量 大于16 翻倍后小于最大容量
        } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
            // 新表的阈值也是旧表阈值的两倍
            newThr = oldThr << 1; 
    // 如果旧表为空 并且事先设置了参数 threshold不为空
    } else if (oldThr > 0) 
        // 使用初始化的旧表阈值做新表的容量
        newCap = oldThr;
    // 旧表为空 没有设置参数 threshold为空
    else {
        // 新表容量 使用默认初始容量 16
        newCap = DEFAULT_INITIAL_CAPACITY;
        // 新表阈值为 16*0.75 = 12
        newThr = (int) (DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
    }
    // 以上就处理完 新表的参数 容量newCap
    // 此时 旧表为空 并且设置了参数 threshold不为空
    if (newThr == 0) {
        // 使用新表的容量计算新表的阈值
        float ft = (float) newCap * loadFactor;
        // 新表的容量小于最大容量 计算的新表阈值也小于最大容量 则获得新表阈值
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float) MAXIMUM_CAPACITY ?
                (int) ft : Integer.MAX_VALUE);
    }
    // 以上就处理完 新表的参数 容量newCap和阈值newThr
    threshold = newThr;

    @SuppressWarnings({"rawtypes", "unchecked"})
    Node<K, V>[] newTab = (Node<K, V>[]) new Node[newCap];
    table = newTab;
    // 旧表不为空 开始扩容
    if (oldTab != null) {
        // 遍历旧表
        for (int j = 0; j < oldCap; ++j) {
            Node<K, V> e;
            // 获取旧表不为空元素
            if ((e = oldTab[j]) != null) {
                // 将该位置置为空
                oldTab[j] = null;
                // 如果该位置只有一个Node 没有下一Node
                if (e.next == null)
                    // 通过indexFor存入新表中
                    newTab[e.hash & (newCap - 1)] = e;
                
                // 判断该位置Node的链接Node是什么结构?
                // 树形结构 红黑树
                else if (e instanceof TreeNode)
                    ((TreeNode<K, V>) e).split(this, newTab, j, oldCap);
                // 链状结构 链表
                else {
                    // head 指向链表头部 tail 构建整个链表
                    Node<K, V> loHead = null, loTail = null;
                    Node<K, V> hiHead = null, hiTail = null;
                    Node<K, V> next;
                    do {
                        // 先取到该位置的下一节点
                        next = e.next;
                        // e.hash & oldCap = 0 则该Node不需要移位
                        if ((e.hash & oldCap) == 0) {
                            if (loTail == null)
                                loHead = e;
                            else
                                loTail.next = e;
                            loTail = e;
                        } else {
                            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;
                        newTab[j + oldCap] = hiHead;
                    }
                }
            }
        }
    }
    return newTab;
}

HashMap计算元素的hash

hashCode和自己高16位相异或
如此使得hash更离散 使得元素分布的更均匀
一般我们的容量都比较小, 2^16
而计算元素key值位置是hash&(n-1) 基本只有低几位信息是有效的 如此的hash冲突比较大
如果将高位的信息也参与运算 可以一定程度上缓解冲突

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

设计者将key的哈希值的高位也做了运算(与高16位做异或运算, 使得在做&运算时, 此时的低位实际上是高位与低位的结合), 这就增加了随机性, 减少了碰撞冲突的可能性

HashMap添加/更新元素

  1. 先检查table是否被初始化 没有则先resize() 初始化
  2. 通过hash&(n-1)获取存放位置 如果该位置为空 则存储元素
  3. 发生hash冲突, 查找该位置的key值相等的元素 没有则添加元素到尾部/叶子节点
  4. 查找到key值相等的元素 更新其value值, 返回旧值
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
    Node<K,V>[] tab; 
    Node<K,V> p; 
    int n, i;
    // 如果当前表为空 还没创建 或者创建了 但表的大小为0
    if ((tab = table) == null || (n = tab.length) == 0)
        // 初始化表格
        n = (tab = resize()).length;
    // i = (n - 1) & hash 通过位运算得到需要存放的位置
    // 如果该位置为空 则直接存储
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    // 发生hash冲突
    else {
        // 获取存放位置的元素
        Node<K,V> e; 
        K k;
        // p 是已存放元素
        // 判断p是否与将要存放的元素key相等
        // 如果key相等 表示是更新元素
        if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // key不相等 并且p为树状结构节点
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // key不相等 并且p为链状结构节点
        else {
            for (int binCount = 0; ; ++binCount) {
                // 找到最后一个节点
                if ((e = p.next) == null) {
                    // 添加节点
                    p.next = newNode(hash, key, value, null);
                    // 如果该链表长度大于等于7 则转化为红黑树结构
                    if (binCount >= TREEIFY_THRESHOLD - 1)
                        treeifyBin(tab, hash);
                    break;
                }
                // 继续判断查询节点是否与将要存放的元素key相等
                if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        // 如果存放的位置不为空
        if (e != null) {
            V oldValue = e.value;
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    // 如果表格元素个数大于阈值 则扩容
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);
    return null;
}

HashMap取值

  1. 判断table是否为空 为空则返回null
  2. 检查hash&(n-1)的位置是否为空 为空则返回null
  3. 判断首位元素是否符合要求 满足则返回该元素
  4. 发生hash冲突, 查看该位置其他节点 找到就返回该元素 没有则返回null
public V get(Object key) {
    Node<K,V> e;
    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;
    // 当table不为空 并且hash位置上存有元素
    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;
        // 如果第一个元素 不符合查询条件
        // 则表示有可能是hash冲突 
        if ((e = first.next) != null) {
            // 如果首位元素是树节点
            if (first instanceof TreeNode)
                return ((TreeNode<K,V>)first).getTreeNode(hash, key);
            // 首位元素是链表节点 遍历
            do {
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    return e;
            } while ((e = e.next) != null);
        }
    }
    return null;
}

HashMap删除元素

  1. 判断table是否为空 为空则返回null
  2. 同getNode(hash, key)一样过程 先根据key值查找符合条件的元素
  3. 找到该元素再将其删除, 并返回该元素
public boolean remove(Object key, Object value) {
    return removeNode(hash(key), key, value, true, true) != null;
}

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;
    // 判断table是否为空 并且hash位置上存有元素
    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值 "getNode(hash, key)"
        // 首位元素
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
        // 发生hash冲突 该位置其他节点
        else if ((e = p.next) != null) {
            if (p instanceof TreeNode)
                node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
            else {
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key ||
                            (key != null && key.equals(k)))) {
                        node = e;
                        break;
                    }
                    p = e;
                } while ((e = e.next) != null);
            }
        }
        // 找打符合要求的元素node
        // matchValue 表示是否需要匹配value值 false表示不用 即使输入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;
}

HashMap为什么是非线程安全的?

因为里面的方法都不是线程安全的

HashMap在并发场景下可能存在哪些问题?

数据丢失 数据重复 死循环

  • Doug Lea writes:

通过上面Java7中的源码分析一下为什么会出现数据丢失, 如果有两条线程同时执行到这条语句 table[i]=null 时两个线程都会区创建Entry,这样存入会出现数据丢失.

如果有两个线程同时发现自己都key不存在, 而这两个线程的key实际是相同的, 在向链表中写入的时候第一线程将e设置为了自己的Entry,而第二个线程执行到了e.next, 此时拿到的是最后一个节点, 依然会将自己持有是数据插入到链表中, 这样就出现了数据重复.

通过put源码可以发现, 是先将数据写入到map中, 再根据元素到个数再决定是否做resize.
在resize过程中还会出现一个更为诡异的问题死循环.

这个原因主要是因为hashMap在resize过程中对链表进行了一次倒序处理.
假设两个线程同时进行resize, A->B 第一线程在处理过程中比较慢, 第二个线程已经完成了倒序编程了B-A 那么就出现了循环, B->A->B.这样就出现了就会出现CPU使用率飙升.

PS:在这个过程中可以发现, 之所以出现死循环, 主要还是在于对于链表对倒序处理, 在Java 8中, 已经不在使用倒序列表, 死循环问题得到了极大改善.

通过Debug来进一步理解HashMap

环境

  • IntelliJ IDEA 2018 专业版
  • Deepin Linux 15.9桌面版

过程

测试代码

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.Random;

/**
 * 通过debug查看HashMap存储数据时的结构
 *
 * @author lingsh
 * @version 1.0
 * @date 19-9-25 下午4:29
 */

public class TestHashMap {
    public static void main(String[] args) {
        // size 存放数据量
        // cap HashMap初始设置容量 10 --> 16
        int size = 10000, cap = 10;
        Map<THMString, Integer> map = new HashMap<>(cap);
        // 存放数据
        for (int i = 0; i < size; i++) {
            map.put(THMString.getRandomString(), i);
        }
        // 用于定点调试(只是该main函数的终止位置, 可以用于暂停查看map结构)
        System.out.println(map.size());
    }
}

/**
 * TestHashMapString 
 * hashCode分布极其集中的自定义类
 * 底层是LEN大小的字符串
 */
class THMString {
    /**
     * 底层真实数据
     */
    private String str;
    /**
     * SIZE 用于加强实例碰撞的可能性
     */
    private final static int SIZE = 1024;
    /**
     * 底层数据大小
     */
    private final static int LEN = 5;
    /**
     * 使用随机数来确定底层数据内容
     */
    private static Random random = new Random();

    public THMString(String str) {
        this.str = str;
    }

    @Override
    public int hashCode() {
        // 集中hashCode 通过不断的取余来加强碰撞可能
        return str.hashCode() % SIZE % SIZE % SIZE % SIZE % SIZE;
    }

    @Override
    public boolean equals(Object obj) {
        THMString thms = (THMString) obj;
        return Objects.equals(this.str, thms.str);
    }

    @Override
    public String toString() {
        return "[String:" + str + "\\thashCode:" + hashCode() + "]";
    }

    /**
     * 获取随机的实例作为HashMap的Key
     * 
     * @return 随机的实例
     */
    static THMString getRandomString() {
        char[] chars = new char[LEN];
        for (int i = 0; i < chars.length; i++) {
            int word = random.nextInt(\'z\' - \'a\');
            chars[i] = (char) (word + \'a\');
        }
        return new THMString(new String(chars));
    }
}

目标成果

底层数据结构
  • Node
  • TreeNode
扩容过程
  • 扩容开始时

  • 扩容时移动一个节点到新表

Debug技巧

关闭Debug时IDEA优化的类结构

当开启该选项时, IDEA将参数隐藏, 比如:next, newThr等

发现table存放jar路径

因为在源码debug时, 不仅我们个人在使用HashMap, 运行程序系统也会使用到它, 所以我们可能会捕捉到系统在初始化时调用HashMap存取值
所以, 建议

  • 先给自己程序设断点, 确定在开始自己的程序, 再到HashMap源码那设置断点

参考文章

  1. HashMap完全解读
  2. HashMap之resize详解
  3. HashMap源码注解 之 静态工具方法hash()、tableSizeFor()(四)
  4. HashMap就是这么简单【源码剖析】
  5. HashMap源码分析(jdk1.8, 保证你能看懂)
  6. hashmap的线程不安全体现在哪里?
  7. idea调试debug(HashMap,ArrayList等)开启/关闭集合类视图

以上是关于[JavaSE 源码分析] 关于HashMap的个人理解的主要内容,如果未能解决你的问题,请参考以下文章

JavaSE_坚持读源码_HashMap对象_get_Java1.7

2HashMap源码分析

HashSet源码分析

源码分析:HashMap

HashMap源码分析之面试必备

Java集合源码分析--HashMap