Java集合源码分析(一):HashMap

本文详细探讨了HashMap的源码,包括其特性、重要参数和操作方法如put、get。HashMap是无序、线程不安全的,采用拉链法解决哈希冲突,并在链表长度超过一定阈值时转为红黑树以优化查找效率。在JDK1.8中,使用尾插法和红黑树提高了性能。此外,文章还分析了HashMap的扩容机制和解决线程安全问题的方法,如ConcurrentHashMap和Hashtable。
摘要由CSDN通过智能技术生成

HashMap源码学习

特性

  • HashMap继承了AbstractMap类,实现了Map、Cloneable、Serializable接口。
  • HashMap无序,即不会记录数据插入顺序。
  • HashMap线程不安全。
  • HashMao最多允许一条数据的key为null
  • HashMap的查找修改时间复杂度为O(1)

六个重要参数

  • 数组初始化长度 DEFAULT_INITIAL_CAPACITY = 1 << 4(16)
  • 数组最大长度 MAXIMUM_CAPACITY = 1 << 30
  • 加载因子 DEFAULT_LOAD_FACTOR = 0.75f
  • 链表转化为红黑树的阈值 TREEIFY_THRESHOLD = 8
  • 红黑树转化为链表的阈值 UNTREEIFY_THRESHOLD = 6
  • 转化为红黑树时数组的最小长度 MIN_TREEIFY_CAPACITY = 64

PS:当前map中存储的节点数 > 加载因子 × 数组长度时,进行扩容,扩容倍数为2。加载因子选择0.75是基于泊松分布的考虑。若选择1,那么map会很"拥挤",若选择0.5,那么map空闲位置会很多,浪费空间。

几个重要方法

基于jdk1.8

put()

首先,看代码,可见put内部调用了putVal()以及hash()这两个方法。
在这里插入图片描述

hash()

先来看hash()方法(一般称之为哈希函数):key为空的情况下,值为0。否则令h等于key的哈希值,将h与h右移16位后的数进行异或运算。
PS:这步操作是为了减少哈希碰撞的概率。我们知道哈希值是一个int类型的数值,4个字节,32个bit位,而数组初始化长度为16,那么在参与与运算(后面会提到,这里简单说明一下:key的哈希值与数组长度进行与运算计算key存储的下标)的只有哈希值的低位,高位是没有起到任何作用的,所以这里的优化思路就是让哈希值的高位也参与运算,进一步降低哈希碰撞的概率,使得数据分布更平均,我们把这样的操作称为扰动,在JDK 1.8中的hash()函数如下:
在这里插入图片描述

putVal()
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
                   boolean evict) {
        // Node节点(每一对k-v就是一个Node节点),tab-Node数组,也称为哈希桶,n为数组长度
        Node<K,V>[] tab; Node<K,V> p; int n, i;
        // 若数组为空,先初始化
        if ((tab = table) == null || (n = tab.length) == 0)
            n = (tab = resize()).length;
        // 前面提到的与运算,将数组长度-1后与key的哈希值进行与运算得到key要存放的数组下标,并令p为数组中的第一个元素
        // 若p为空
        if ((p = tab[i = (n - 1) & hash]) == null)
        	// 将当前要put的k-v存进来
            tab[i] = newNode(hash, key, value, null);
        // 若p不为空
        else {
            Node<K,V> e; K k;
            // 如果节点p(首节点)的哈希值等于要存储节点的哈希值,并且储存节点的key.equals(p.key)两个条件都成立,说明p节点和要存储节点的key是一个对象
            // 这里是基于效率的考虑,若两个key的哈希值不想等,则说明key不是一个对象,不需要再用equals判断(效率慢),若哈希值相等,才需要继续用equals判断!
            if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
                // p赋给e
                e = p;
            // 若p的key和存储节点的key不是一个对象,判断p是不是树节点(判断当前结构是不是红黑树)
            else if (p instanceof TreeNode)
            	// 利用红黑树的方式添加元素
                e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
           // 若p的key和存储节点的key不是一个对象,且当前结构是链表
            else {
           		 // binCount 用来记录链表长度,大于8时需要进行树化(且数组长度大于64)
                for (int binCount = 0; ; ++binCount) {
                	// 若遍历到链表的尾部
                    if ((e = p.next) == null) {
                    	// jdk1.8用的是尾插法
                        p.next = newNode(hash, key, value, null);
                        // 树化
                        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
                            treeifyBin(tab, hash);
                        break;
                    }
                    // 遍历过程中会一一比对key是否是同一个对象
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        break;
                    //  p  = p.next,遍历链表
                    p = e;
                }
            }
            // 到了这一步说明在遍历过程中存在key与要存储节点的key是同一个对象的情况
            if (e != null) { // existing mapping for key
            	// 记录旧值的value
                V oldValue = e.value;
                if (!onlyIfAbsent || oldValue == null)
                    e.value = value;
                afterNodeAccess(e);
                // 返回旧值
                return oldValue;
            }
        }
        // modCount用来记录当前mao的所有元素个数
        ++modCount;
        // size = The number of key-value mappings contained in this map.
        if (++size > threshold)
        	// 扩容函数
            resize();
        afterNodeInsertion(evict);
        return null;
}
resize()

扩容机制,暂留

treeifyBin()
final void treeifyBin(Node<K,V>[] tab, int hash) {
        int n, index; Node<K,V> e;
    	// 如果数组容量没有达到默认长度(64),即使链表元素超过8,也不会变化红黑树,而是先进行数组扩容,若数组扩容(64)之后的链表长度大于8,再进行转换!
    	// MIN_TREEIFY_CAPACITY = 64
        if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
            resize();
        else if ((e = tab[index = (n - 1) & hash]) != null) {
            TreeNode<K,V> hd = null, tl = null;
            do {
                TreeNode<K,V> p = replacementTreeNode(e, null);
                if (tl == null)
                    hd = p;
                else {
                    p.prev = tl;
                    tl.next = p;
                }
                tl = p;
            } while ((e = e.next) != null);
            if ((tab[index] = hd) != null)
                hd.treeify(tab);
        }
    }

get()

若key不存在返回null,存在直接返回value,内部调用了getNode()
在这里插入图片描述

getNode()

返回一个node

final Node<K,V> getNode(int hash, Object key) {
        Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    	// 判断数组是否为空,数组长度是否大于0,首元素是否为空,若有一个不满足,直接返回null
        if ((tab = table) != null && (n = tab.length) > 0 &&
            (first = tab[(n - 1) & hash]) != null) {
            // 若要get的key与首元素的key是同一个对象,那么直接返回首元素
            if (first.hash == hash && // always check first node
                ((k = first.key) == key || (key != null && key.equals(k))))
                return first;
            // 首元素的下一个元素不能为空,否则返回null
            if ((e = first.next) != null) {
                // 树结构
                if (first instanceof TreeNode)
                    return ((TreeNode<K,V>)first).getTreeNode(hash, key);
                // 遍历遍历,一一比对key,相同则返回元素
                do {
                    if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                        return e;
                } while ((e = e.next) != null);
            }
        }
        return null;
}

几个注意点

1. 初始化数组的长度必须是2的幂次方

创建HashMap时,可以赋一个初始化容量DEFAULT_INITIAL_CAPACITY,默认是16,当你传递参数10的时候,他会默认创建一个最小的大于10的2^n级数(即16)
数组容量(n)大小取2的幂次方的目的在于,当(n-1)& key.hashCode() 时,结果均匀分布在数组的下标范围内。
举个例子:16-1=15,任何数字与15进行与运算的结果都在0~15之间,如果17-1=16,任何数字与16进行与运算的结果只有16或者0,其他下标永远无法取到!这样可以保证最大的程度的散列hash值,否则,当有一位是0时,不管hash值对应位是1还是0,与运算后的结果都是0,会造成散列结果的重复。

PS:此问题同扩容倍数为什么是2。
PS:为什么不是取余操作,因为取余操作的速度很慢,所以是与运算,因为与运算是基于byte的,速度很快。

2. 头插法 & 尾插法

jdk1.7用的是头插法,1.8因为考虑到要树化,所以用的尾插法(方便记录链表的节点数)
PS:头插法效率更高,因为只需要直接修改节点的next指针为当前首元素即可。

3. 红黑树

为什么用红黑树

jdk1.8相较于1.7最大的改变就是在底层使用了红黑树,jdk1.7中用的是链表,我们知道链表的查找时间复杂度为O(n),效率很差,而红黑树的查找效率为O(logn)。

为什么不用BST(二叉搜索树)或AVL(二叉平衡树)

BST在极端情况下(节点1,2,3,4,…,)相当于链表,查找效率低
AVL相对于BST在树高上进行了优化,查询效率高,但是当添加或删除元素时,AVL为了保持平衡,可能需要大幅度调整树的结构(为了保持平衡,树要自旋)。因此使用红黑树。

红黑树的特性

  1. 节点非红即黑
  2. 根节点是黑色的空节点
  3. 不能有连续的红色节点
  4. 任一节点到根节点的任意路径上的黑色节点的个数相等(实现的是完美的黑色平衡)

PS:红黑树相对于AVL是一种折中的选择。红黑树既能提高插入和删除的效率,又能让树相对平衡从而有还不错的查询效率。

红黑树如何保持平衡

红黑树通过变色+旋转保证平衡在这里插入图片描述

4. HashMap解决哈希冲突的手段

  • 使用了拉链法(散列表)来链接拥有相同hash值的数据
  • 使用了一次hash函数,两次扰动函数(一次异或运算,一次与运算)来降低哈希冲突的概率,使得数据分布更平均

PS:在JDK 1.7中,有4次位运算,5次异或运算(9次扰动),在JDK1.8中,只进行了1次位运算和1次异或运算(2次扰动)这样就是加大哈希值低位的随机性,使得分布更均匀,从而提高对应数组存储下标位置的随机性&均匀性。两次就已经达到了高位低位同时参与运算的目的;

5. 解决HashMap不安全问题

  • ConcurrentHashMap(jdk1.7:segment分段锁;jdk1.8:node+cas+synchronized)
  • Hashtable(synchronized)
  • 使用Collections.synchronizedMap(Map)创建线程安全的map集合

个人理解,如有不对,欢迎批评指正^^

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值