HashMap 进阶篇那些唬人的名词: Resize, Capacity, bucket,Load factor

上篇文章我们通过部分源码和结构图了解了HashMap的数据结构,感兴趣的小伙伴看这里HashMap实现原理基础篇
这一篇,我们基于HashMap的实现原理,了解HashMap重新调整大小(Resize)这个过程。

Resize前言

还记得上篇HashMap实现原理基础篇中提到,HashMap的内部结构实际上链表的数组,底层是数组,数组的每一项储是链表,而链表中的存储的是Entry对象,Entry是一个静态内部类,其重要属性有key、 value 和 next。 Entry[]的长度一定后,随着Map里面数据的越来越长,会影响HashMap的性能。于是HashMap在此做了优化,随着map的size越来越大,就要resize(重新调整Entry[]的大小)。 Entry[]会以一定的规则加长长度。这个resize的过程,简单的说就是把Entry扩充为2倍,之后重新计算index,再把节点再放到新的Entry中。

想要了解这个resize的详细过程,思考如下几个问题:
  • 1.为什么resize要将Entry扩充为原来的2倍? 这个过程是怎样的? 这样做有什么好处?
  • 2.那些唬人的名词: Capacity, bucket,Load factor 都是什么?
    回答这些问题,并不容易,若无头绪,为何不看源码?
/**
     * Constructs an empty <tt>HashMap</tt> with the specified initial
     * capacity and load factor.
     *
     * @param  initialCapacity the initial capacity
     * @param  loadFactor      the load factor
     * @throws IllegalArgumentException if the initial capacity is negative
     *         or the load factor is nonpositive
     */
    public HashMap(int initialCapacity, float loadFactor) {
        //初始容量不能<0
        if (initialCapacity < 0)
            throw new IllegalArgumentException("Illegal initial capacity: " +
                                               initialCapacity);
        //初始容量不能 > 最大容量值,HashMap的最大容量值为2^30
        if (initialCapacity > MAXIMUM_CAPACITY)
            initialCapacity = MAXIMUM_CAPACITY;
        //负载因子不能 <=0
        if (loadFactor <= 0 || Float.isNaN(loadFactor))
            throw new IllegalArgumentException("Illegal load factor: " +
                                               loadFactor);

        // 计算出大于 initialCapacity 的最小的 2 的 n 次方值。
        int capacity = 1;
        while (capacity < initialCapacity)
            capacity <<= 1;

        this.loadFactor = loadFactor;
       //设置HashMap的阈值
        threshold = (int)(capacity * loadFactor);
        table = new Entry[capacity];
        init();
    }
由以上代码段,我们知道,当系统开始初始化 HashMap 时,系统会创建一个长度为 capacity 的 Entry数组
public V put(K key, V value) {
        if (key == null)
            //null总是放在数组的第一个链表中
            return putForNullKey(value); 
        int hash = hash(key.HashCode());
        int i = indexFor(hash, table.length);
        //遍历链表
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            //如果key在链表中已存在,则替换为新value
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }
        modCount++;
        addEntry(hash, key, value, i);
        return null;
    }
 
/**  
 * HashMap 添加节点  
 *  
 * @param hash        当前key生成的hashcode  
 * @param key         要添加到 HashMap 的key  
 * @param value       要添加到 HashMap 的value  
 * @param 要添加的结点对应到数组的位置下标  
 */ 
void addEntry(int hash, K key, V value, int bucketIndex) {
    Entry<K,V> e = table[bucketIndex];
    //参数e, 是Entry.next
    table[bucketIndex] = new Entry<K,V>(hash, key, value, e); 
    // threshold=load factor*current capacity
    //如果size大于等于阈值 threshold,则扩充table大小。
    if ((size >= threshold) && (null != table[bucketIndex])) {    
        //扩容之后,数组长度变了 
        resize(2 * table.length);   
       //hash值是根据key与数组长度取模算的,长度变了,当然要重新计算hash值
        hash = (null != key) ? hash(key) : 0;    
        //数组长度变了,数组的下标与长度有关,需重算。    
        bucketIndex = indexFor(hash, table.length);
    }    
    createEntry(hash, key, value, bucketIndex);    
} 
/**  
 * 是否为链表
 * 1.   原来数组bucketIndex处为null,则直接插入 
 * 2.   原来数组bucketIndex有值,则根据Entry的构造函数,
         把新的结点存于数组中,原来数据用新结点的next指向
 */    
void createEntry(int hash, K key, V value, int bucketIndex) {    
    HashMap.Entry<K, V> e = table[bucketIndex];    
    table[bucketIndex] = new HashMap.Entry<>(hash, key, value, e);    
    size++;    
}  
addEntry方法可看出resize的条件与扩充大小
最后我们分析下resize的源码,鉴于JDK1.8引入红黑树,较复杂,为了便于理解我们仍然使用JDK1.7的代码,本质上区别不大。
    void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        //扩容前的数组大小如果已经达到最大(2^30)了
        if (oldCapacity == MAXIMUM_CAPACITY) {
            //修改阈值为int的最大值(2^31-1),这样以后就不会扩容了  
            threshold = Integer.MAX_VALUE;
            return;
        }

        //初始化一个新的Entry数组
        Entry[] newTable = new Entry[newCapacity];
        //将数据转移到新的Entry数组里  
        transfer(newTable);
        //更新table为最新newTable
        table = newTable;
        //更新阈值
        threshold = (int)(newCapacity * loadFactor);
    }
生成一个新的table数组(entry数组),然后根据新的Capacity和负载因子去生成新的临界值
void transfer(Entry[] newTable) {  
    //src引用了旧的Entry数组  
    Entry[] src = table;                  
    int newCapacity = newTable.length; 
   //遍历旧的Entry数组   
    for (int j = 0; j < src.length; j++) { 
        //取得旧Entry数组的每个元素  
        Entry<K, V> e = src[j];            
        if (e != null) { 
            //释放旧Entry数组的对象引用 
            src[j] = null; //(for循环后,旧的Entry数组不再引用任何对象)  
            do {  
                //此处采用链表的头插法,新结点插入数组中
                //而原始结点作为新结点的next指向
                Entry<K, V> next = e.next; 
                //重新计算每个元素在数组中的位置   
                int i = indexFor(e.hash, newCapacity); 
                e.next = newTable[i]; 
                newTable[i] = e;      //将元素放在数组上  
                e = next;             //访问下一个Entry链上的元素  
            } while (e != null);  
        }  
    }  
}  
//按位与运算,都为真时才为真
static int indexFor(int h, int length) {  
    return h & (length - 1);  
}  

看完源码,再了解这些概念就容易多了:

Capacity(容量):当系统开始初始化 HashMap 时,系统会创建一个长度为 capacity 的 Entry数组。

bucket(桶):基于Entry数组,这个数组里可以存储元素的位置被称为bucket,每个 bucket 都有其指定索引,系统可以根据其索引快速访问该 bucket 里存储的元素。

Load factor(负载因子):Load factor就是Entry集合填满程度的最大比例 eg: 0.75
resize(重新调整大小): 那么当Entry的数目大于capacity*load factor时就需要调整Entry的大小,这个过程成为resize,resize为当前Entry数组的2倍。

bucket的概念,结合上篇文章的链表结构图,在此标注下:


bucket.png
关于Resize,查阅API(其实看过源码已经很清楚了):

Initial capacity The capacity is the number of buckets in the hash table, The initial capacity is simply the capacity at the time the hash table is created。
Load factor The load factor is a measure of how full the hash table is allowed to get before its capacity is automatically increased。

翻译过来,简单的说:Capacity(容量)就是bucket(桶)的总长度,Load factor(负载因子)就是bucket填满程度的最大比例eg: 0.75。如果对迭代性能要求很高的话不要把capacity设置过大,也不要把load factor设置过小。当bucket中的entries的数目大于capacity * load factor时就需要调整bucket的大小为当前的2倍。

举个栗子:Capacity为16,Load factor为0.75,那么在桶的填充总数大于12(16 * 0.75=12)时,resize这个HashMap,新的长度为32(16 * 2 = 32)

回顾上文: 随着map的size越来越大,(超过load factor*current capacity),就要resize。 那么Entry[]会以一定的规则加长长度。简单的说就是把Entry扩充为2倍

Initializes or doubles table size。 If null, allocates in accord with initial capacity target held in field threshold。 Otherwise, because we are using power-of-two expansion, the elements from each bin must either stay at same index, or move with a power of two offset in the new table。

当超过限制的时候会resize,因为我们使用的是2次幂的扩展(指长度扩为原来2倍),那么元素的位置要么是在原位置,要么是在原位置再移动2次幂的位置。例如我们从16扩展为32时,具体的变化如下所示:
移位.png

因此元素在重新计算hash之后,n变为之前的2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:
移位1.png

那么我们在扩充HashMap的时候,不需要重新计算hash,只需要看看原来的hash值新增的那个bit是1还是0就好了,是0的话索引没变,是1的话索引变成“原索引+"oldCap"。可以看看下图为16扩充为32的resize示意图:
eg1.png
这个设计确实非常的巧妙,既省去了重新计算hash值的时间,而且同时,由于新增的1bit是 0 还是 1 可以认为是随机的,因此resize的过程,均匀的把之前的冲突的节点分散到新的bucket了。这一块就是JDK1.8新增的优化点。

有一点注意区别,JDK1.7中rehash的时候,旧链表迁移新链表的时候,如果在新表的数组索引位置相同,则链表元素会倒置,但是从上图可以看出,JDK1.8不会倒置。有兴趣的同学可以研究下JDK1.8的resize源码。
了解了以上概念与resize的过程,我们复习下关于HashMap的面试题:

1.HashMap的结构与特点?
HashMap是数组的链表,底层是数组结构,数组的每一项又是链表。HashMap存储着Entry对象,其内部
结构Entry(hash, key, value, next)对象。是基于Hash表的Map接口的非同步实现,存储键值对时,它可以
接收null键与null值
2.了解HashMap的工作原理吗?
2.通过hash的方法,使用put和get方法存储和获取对象。存储对象时,我们将K/V传给put方法时,它调用
hashCode计算hash从而得到数组位置index,进一步存储,HashMap会根据当前bucket的占用情况自动调
整容量(超过Load Facotr则resize为原来的2倍)。获取对象时,我们将K传给get,它调用hashCode计算
hash从而得到bucket位置,并进一步调用equals()方法确定键值对。如果发生碰撞的时候,可以使用链地址
法解决冲突,将新的元素插入到数组的index位置,并将之前
链表中的元素用next指向。
3.你知道hash的实现吗?为什么要这样实现?
在Java 1.8的实现中,是通过hashCode()的高16位异或低16位实现的 (h = k.hashCode()) ^ (h >>> 16)
,主要是从速度、功效、质量来考虑的,这么做可以在bucket的n比较小的时候,也能保证考虑到高低bit
都参与到hash的计算中,同时不会有太大的开销。
4.如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?
4.如果超过了负载因子(默认0.75),则会重新resize一个原来长度两倍的HashMap,并且重新调用hash方法。
5.HashMap与HashTable的区别
关于HashMap:在官方文档中是这样描述HashMap的:

Hash table based implementation of the Map interface. This implementation provides all of the optional map operations, and permits null values and the null key. (The HashMap class is roughly equivalent to Hashtable, except that it is unsynchronized and permits nulls.) This class makes no guarantees as to the order of the map; in particular, it does not guarantee that the order will remain constant over time.

翻译: Hashtable 实现了Map接口,此实现提供了可选择的操作,允许null值,但不允许null键, (HashMap几乎可以等价于Hashtable,除了HashMap是非synchronized的,并可以接受null(HashMap可以接受为null的键值(key)和值(value),而Hashtable则不行) 此类不保证有序,特别是不保证顺序持久不变.

几个关键的信息:基于Map接口实现、允许null键/值、非同步、不保证有序(比如插入的顺序)、也不保证序不随时间变化。(由于HashMap允许null键,所以通过if(key == null))的方式,并不能判断某个key键是否存在,而是用containesKey(key)的方式)

1.线程安全与性能:
Hashtable是线程安全的,而HashMap是线程不安全的。Hashtable的安全由同步锁synchronized来保证,所
以,在单线程环境下,Hashtable比HashMap要慢。如果只需要单一线程,使用HashMap性能比Hashtable
要好。Java 5提供了ConcurrentHashMap,它是HashTable的替代,比HashTable的扩展性更好。      
ConcurrentHashMap使用了线程锁分段技术,每次访问只允许一个线程修改哈希表的映
射关系,所以是线程安全的。
2.对null值的处理不同
HashTable不允许null值(key和value都不可以)如果设置了null值会报空指针的错误. 而HashMap允许null
(key和value都可以).可以有一个或者多个键对应的值为null.因此,在HashMap中不能由get()方法来判断
HashMap中是否存在某个键,而应该用containsKey()方法来判断.
3.HashMap不能保证随着时间的推移Map中的元素次序是不变的。

本篇是HashMap进阶篇,基于<HashMap实现原理基础篇>基础之上,详细描述了resize的过程,以及这个过程中涉及到的一些重要概念。最后以面试题的形式进行收尾,将这些知识点串联在一起。
参考文档:http://www.importnew.com/18633.html

喜欢学习,乐于分享,不麻烦的话,给个❤鼓励下吧!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342

推荐阅读更多精彩内容

  • 实际上,HashSet 和 HashMap 之间有很多相似之处,对于 HashSet 而言,系统采用 Hash 算...
    曹振华阅读 2,508评论 1 37
  • 1.什么是HashMap 基于哈希表的Map接口的非同步实现 此实现提供所有可选的映射操作,并允许使用null值和...
    苍賢阅读 511评论 0 1
  • HashMap 是 Java 面试必考的知识点,面试官从这个小知识点就可以了解我们对 Java 基础的掌握程度。网...
    野狗子嗷嗷嗷阅读 6,650评论 9 107
  • 中国有句古话叫养儿防老,还说多子多福,特别是在农村,没儿子的都会被人瞧不起,非要生出儿子才罢休。有些人为了生儿子避...
    理财人生阅读 551评论 1 0
  • 原文:There are two properties that can be assigned to speci...
    橡果阅读 485评论 0 1