令人头疼的HashMap

HashMap简介

JDK1.8之前,HashMap的底层是数组+链表,JDK1.8之后底层是由数组+链表+红黑树组成,当链表长度到达一定时,会转化为红黑树,查找的效率由之前的链表查找O(n) 变为红黑树O(log n),查询元素的效率有所提高

HashMap中的成员变量
//默认初始化数组容量16,必须是2的整数次方(为什么是2的整数次方后续会有讲解)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;

//HashMap最大容量,不能超过2^30
static final int MAXIMUM_CAPACITY = 1 << 30;

//HashMap的加载因子,默认是0.75,无法改变
static final float DEFAULT_LOAD_FACTOR = 0.75f;

//HashMap树化的阈值
static final int TREEIFY_THRESHOLD = 8;

//HashMap链化的阈值
static final int UNTREEIFY_THRESHOLD = 6;

//HashMap最小树化容量,会结合树化阈值联合使用,当链表长度>=8并且节点个数>=64时转化为红黑树,否则数组直接扩容
static final int MIN_TREEIFY_CAPACITY = 64;

//用来存储数据的节点数组,也成为哈希桶
transient Node<K,V>[] table;

//键值对
transient Set<Map.Entry<K,V>> entrySet;

//数据个数
transient int size;

//modifyCount,修改次数
transient int modCount;

//扩容阈值,当size>threshold时,会触发扩容机制
int threshold;

//负载因子,threshold = capacity * loadFactor
final float loadFactor;

通过内部内Node节点来包装数据
static class Node<K,V> implements Map.Entry<K,V> {
//存储键的hash值
final int hash;
//存储键
final K key;
//存储值
V value;
//指向下一个节点
Node<K,V> next;
}

接下来聊一聊HashMap的构造方法

先对源码进行分析


public HashMap() {
    this.loadFactor = DEFAULT_LOAD_FACTOR; //默认装载因子0.75
}

public HashMap(int initialCapacity) {
	//调用最后一个的构造方法
    this(initialCapacity, DEFAULT_LOAD_FACTOR);
}

public HashMap(Map<? extends K, ? extends V> m) {
    this.loadFactor = DEFAULT_LOAD_FACTOR;
    //调用该方法put元素
    putMapEntries(m, false);
}


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;
    //此处得theshold并不是用于存放扩容阈值,而是用来存放初始化容量,而table[]会在第一次put()时进行初始化,避免不必要的空间浪费
    this.threshold = tableSizeFor(initialCapacity);
}

tableSizeFor()方法,该方法是数组默认初始化容量必须是2的整数次方的直接因素,间接因素后续在put方法时讲解

static final int tableSizeFor(int cap) {
		//cap=10
		//n=9  --->0000 1001
        int n = cap - 1; 
        n |= n >>> 1;	//    n    = 0000 1001  
        				// n >>> 1 = 0000 0100
        				//    n    = 0000 1101
        n |= n >>> 2;	// n >>> 2 = 0000 0011
						//    n    = 0000 1111
        n |= n >>> 4;	// n >>> 4 = 0000 0000
        				//    n    = 0000 1111
        n |= n >>> 8;	// n >>> 8 = 0000 0000
        				//    n    = 0000 1111 
        n |= n >>> 16;	// n >>> 16= 0000 0000
		//n = 0000 1111; 15
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
        // return 16;
    }

我们拿10举例当传入的初始化容量为10的时候,构造器执行会调用tableSizeFor方法,方法原理是将"容量值10"无符号右移后再和移动前的值进行“或”操作,得到一个2的整数次方的数。
假如传入容量为10 ,该方法的n = 9,9的二进制表示1001,接下来对1001进行无符号右移 或操作
在这里插入图片描述
下一步n = 1101 和 右移一位的0110 继续进行“或”操作,直到方法结束。

**方法结束说明有参构造器执行完毕可能会有疑惑 为什么不是数组默认初始容量是16而是扩容阈值是16,这与HashMap的扩容有关,初始new HashMap()时,是不会初始化table[ ],当第一次put时进行扩容,进行阈值和初始容量的转化,后续HashMap扩容时讲解 **

HashMap的put方法

源码解析

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

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

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
               boolean evict) {
    Node<K,V>[] tab; Node<K,V> p; int n, i;
    
    //第一次添加,table为null,初始化table数组。
    if ((tab = table) == null || (n = tab.length) == 0)
        n = (tab = resize()).length;
        
    //判断对应下标是否有元素,如果没有,则直接new一个节点放置该下标处
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    else {
    	//有则说明发生了hash冲突,同时p为该桶的第一个元素,临时的元素节点为e
        Node<K,V> e; K k;
        //计算p的hash值是否等于新的key的hash值,且((用==判断key是否位置相同)或(key不为null且调用equals方法相等))
        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 (int binCount = 0; ; ++binCount) {
             //此处利用尾插法插入链表尾部
                if ((e = p.next) == null) {
                    p.next = newNode(hash, key, value, null);
                    //如果循环了7次,说明该桶中节点数>=8,则会调用树化的逻辑方法判断是否需转化成红黑树,并跳出循环
                    if (binCount >= TREEIFY_THRESHOLD - 1) 
                        treeifyBin(tab, hash);
                    break;
                }
                
                //链表中也要进行节点对应key的hash判断,上面的判断是该下标位置第一个元素的判断,而这是链表中的元素的判断
                if (e.hash == hash &&
                    ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }

//如果e不为null,则说明存在相同key的映射,用新的值替换旧的值并返回旧的值
        if (e != null) { 
            V oldValue = e.value;
            //此处onlyIfAbsent为false,覆盖旧值
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;          
            afterNodeAccess(e);//Do Nothing!
            return oldValue;
        }
    }

    ++modCount;
    //size>threshold则进行扩容。
    if (++size > threshold)
        resize();
    afterNodeInsertion(evict);	//Do Nothing!
    return null;
}

分析完put()源码之后对put方法的整体的一个步骤进行总结

1 通过hash()方法计算出key得hash值
2 判断数组table[]是否为空,为空则进行resize()初始化.
3 利用(table.length - 1) & hash 计算出对应得数组下标
4 如果该数组下标没有冲突(数组下标对应位置没有节点),则直接在对应下标处插入节点
5 发生冲突,判断key是否相等,相等直接将该节点进行覆盖,不相等判断是否是树结构,
6 是直接添加在树上,不是则为链表结构,遍历链表如果链表上某个节点的key和待插入节点的key进行equals()方法比较相同,将新value代替原来的旧value,并且返回原来的旧value,如果没有找到与当前key equals()相同的key则将新节点尾插到链表尾部,并根据链表的节点个数判断是否需要树化
7 最后一步判断size是否大于扩容阈值(thrshold) 如此当前key-value的插入就已经完成啦

put方法总结完成之后我们接着来讨论为什么数组默认初始容量必须是2的整数次方

前面提及到tableSizeFor()方法会将new HashMap是传入的初始容量更改为靠近2的整数次方的值,也是直接因素。

另一个因素:当我们在插入新的节点的时候发现HashMap是根据(table.length - 1) & hash计算出数组的下标,并不是简单的 (hash % length)取模运算。计算机进行二进制计算是非常快速的,而运用 与(&)运算也主要是提升计算机性能 , 另外当数组长度是2的整数次方的时候length - 1的低位全部是1,为之后的扩容也做好充分准备。如果不是2的整数次方,hash冲突的概率也会明显增大【此处就不再证明,原理也比较简单,根据(table.length - 1) & hash 感兴趣的小伙伴们可以自己证明一下哦】

有的小伙伴会有疑惑HashMap计算hash值得时候为什么不直接使用祖先类hashCode()方法方法返回得哈希值,而需要在此基础上在进行右移16位使用高16位进行计算呢?

当我们在使用低位进行计算的时候可能会出现一些低位二进制相同的值,此时再与table.length - 1 相与有概率出现冲突,而是用高位进行计算很可能降低哈希冲突

那么什么时候put时会出现链表转红黑树的情况呢?

转为红黑树呢有两个条件:一是链表的节点 >= 8; 二是HashMap的size >= 64 如果不能满足此两个条件,则是对数组进行扩容。详见treeifyBin方法


final void treeifyBin(Node<K,V>[] tab, int hash) {
    int n, index; Node<K,V> e;
    //如果tab为null或tab.length<64时,调用resize()初始化或扩容
    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);
    }
}

谈及树化的阈值以及树化的最小容量时必然和链化阈值紧密相连,当链表树化之后,之后进行删除节点,节点个数到达6之后,又会转为链表,而不是节点个数小于8立即转化,主要考虑到性能问题,毕竟红黑树和链表之间的转化也会造成性能的损耗。

HashMap的get方法

源码分析

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不为空且table.length>0且数组下标处有节点
    if ((tab = table) != null && (n = tab.length) > 0 &&
        (first = tab[(n - 1) & hash]) != null) {

        //如果数组对应节点的hash与传入hash相等,且key也相同,则返回该节点
        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);
             
            //是链表则进行循环判断,一直比较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;
}

分析完get源码之后我们对get方法的整体的一个步骤进行总结

1 首先根据传入的key计算处hash值并且转换位对应数组下标,如果数组下标没有节点节点返回null。数组下标处有节点,并且节点的key的hash以及equals比较值相等,返回该节点对应的value,
2 如果之后hash相等但是equals不相等,出现冲突,再判断该节点是树结构还是链表结构,再对hash和equals进行判断最终返回key对应的value

HashMap扩容

源码分析

final Node<K,V>[] resize() {
    Node<K,V>[] oldTab = table;
    //先判断table是否为null
    int oldCap = (oldTab == null) ? 0 : oldTab.length;
    //threshold按不同的构造方法有不同的值
    int oldThr = threshold;
    int newCap, newThr = 0;
    
    //扩容时进入此判断条件
    if (oldCap > 0) {
        if (oldCap >= MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return oldTab;

//一般会来到这,oldCap左移一位,扩容为原来容量的2倍,oldThr也会左移一位
        }else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&oldCap >= DEFAULT_INITIAL_CAPACITY)
            	newThr = oldThr << 1; 
    }
    
//当new HashMap<>(10),threshold、oldThr 都为16,则newCap为16,但newThr为0,进入下面那个if
    else if (oldThr > 0) 
        newCap = oldThr;

//new HashMap<>()则进入此判断
    else {               
        newCap = DEFAULT_INITIAL_CAPACITY;	//16
        newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);	//12
    }

//扩容后也会来到这,判断newThr不为0
    if (newThr == 0) {
    	//newCap为16,loadFactor为0.75,ft为12
        float ft = (float)newCap * loadFactor;
        //重新赋值newThr
        newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
                  (int)ft : Integer.MAX_VALUE);
    }

//开始初始化table,初始化后oldTab还是null,不走if,直接返回
//初始化或扩容,都讲newThr赋值给threshold
    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) {
            	//赋值为null
                oldTab[j] = null;
                //判断该桶位置是否是链表或树,为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 { 
                    Node<K,V> loHead = null, loTail = null;
                    Node<K,V> hiHead = null, hiTail = null;
                    Node<K,V> next;
                    do {
                        next = e.next;
                        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;
}

关于如何初始化问题

第一次put时,如果使用的是不容的构造方法resize()扩容时会进入不同的条件判断

  • new HashMap<>() 经过构造方法,threshold为0,进入扩容条件时,走最后一个else分支,===>newCap = 16, newThr = 12;对应数组初始容量为16,扩容阈值为12
  • new HashMap<>(10经过构造方法,threshold为16,进入扩容时,oldThr = 16进入else if 分支 执行 newCap = oldThr;此时数组容量为16,再计算最新的扩容阈值

扩容

当table.size() > threshold 扩容阈值,会触发HashMap的扩容机制,数组扩容为原来的2倍,每一次扩容的过程都伴随着new Node[] 数组的过程,因此当我们需要存储过多元素的话,尽可能的在new HashMap()时传入初始容量

扩容的前提时数组的长度 > 扩展阈值 threchold,而threchold = capacity * loadFactor,默认的加载因子是0.75,加载因子过于大,会减少空间上的消耗,但是hash冲突的概率随之增加,过于小hash冲突降低随之造成空间上的浪费,可以说0.75的取值完全是时间和空间成本上寻求的一种折衷选择

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值