高效编程之hashmap你不看就会忘记的知识点

以前菜得不能看的时候看Java的招聘要求:Java基础扎实,熟悉常用集合类,多线程,IO,网络编程,经常会疑惑,集合类不就ArrayList,HashMap会用,熟悉下API不就好了么,知道得越多才会发觉不知道的还有好多!
本文使用的源码是jdk1.7的源码,hashmap每个版本都发生了改变,源码都不同,但原理都是一样的,可以参考一下;

读下文前可以先思考以下这几个问题,带着问题去阅读,收获更大;

1、平时我为什么要用hashmap?key和value是以什么样的形式存在的?

2、我了解hashmap的内部结构和实现原理吗?

3、hashmap构造方法的参数有哪些,有什么用?

4、用hashmap的时候需不需要给他一个初始化大小?如果要该怎么定义?

5、不起眼的hashcode和equals方法为什么在hashmap中至关重要?

6、什么是哈希冲突?发生哈希冲突好还是不好?不好该怎么解决?

7、hashmap有什么缺点?它跟hashtable有什么性能区别?

从下面的两张图可以看到,hashmap是由数组和链表组成的;

数组:数组存储区间是连续的,占用内存严重,故空间复杂的很大。但数组的二分查找时间复杂度小,为O(1);数组的特点是:寻址容易,插入和删除困难;

链表:链表存储区间离散,占用内存比较宽松,故空间复杂度很小,但时间复杂度很大,达O(N)。链表的特点是:寻址困难,插入和删除容易;

哈希表:这时候该引出哈希表了,哈希表寻址容易,插入删除也容易,同事还满足了数据的查找方便,同时不占用太多的内容空间,使用也十分方便!简直牛逼啊~

先盗两张图...因为我不会画...


image.png

hashmap是基于Map接口实现、允许null键/值、非同步这个大家应该都是知道的... 这个时候应该对hashmap有个感性的认识了

那我们从API里面来看看hashmap的构造方法,初始一下 initialCapacity 和 loadFactor 两个名词:

HashMap() 构造一个具有默认初始容量 (16) 和默认加载因子 (0.75) 的空HashMap。

HashMap(int initialCapacity) 构造一个带指定初始容量和默认加载因子 (0.75) 的空HashMap。

HashMap(int initialCapacity, float loadFactor) 构造一个带指定初始容量和加载因子的空HashMap。

initialCapacity是哈希表的数组的初始大小,loadFactor是加载因子;

HashMap的默认大小是16,那么他的数组长度就是0-15;默认加载因子是0.75;

为什么是16呢不是其他的数字呢?为什么默认的加载因子是0.75呢? 留个悬念哈~后面会提到的!

我们可以看到hashmap里面存放的是一个一个的Entry对象,(下文中的entry/entry对象即为此处的Entry对象)

我们看看看看hashmap是如何定义的Entry对象;

 static class Entry<K,V> implements Map.Entry<K,V> {
        final K key;
        V value;
        Entry<K,V> next;
        int hash;
 
        /**
         * Creates new entry.
         */
        Entry(int h, K k, V v, Entry<K,V> n) {
            value = v;
            next = n;
            key = k;
            hash = h;
        }

上面这是Entry对象的部分代码;

final K key :表示key是不能改变的 final关键字!

value:这没啥好说的;

Entry<k,v> next 指向下一个节点;

int hash:这个hash 是一个散列码是这样得到的:int hash = hash(key.hashcode());下文会有详细讲解~

image.png

重点部分来了!

HashMap的put方法:

put方法大致的思路是:
1、对key的hashCode()做hash,然后再计算存到数组里的下标;
2、如果没发生冲突直接放到数组里;
3、如果冲突了,以链表的形式存在对应的数组[ i ]中 ;
4、如果冲突导致链表过长(大于等于TREEIFY_THRESHOLD),就把链表转换成红黑树;
5、如果节点已经存在就替换old value(保证key的唯一性)
6、如果数组满了(超过load factor*current capacity),就要resize(扩容)。`

public V put(K key, V value) {
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key);
        int i = indexFor(hash, table.length);
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            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;
    }

这是jdk1.7的源码,1.8只是多了一个新特性,当链表的长度>7的时候,链表转换为红黑树提高查询的效率;

代码有注释,我这里再分析一次;首先通过key.hashcode()出哈希码,哈希码拿去做hash运算算出一个散列值,散列值(hash)跟数组的长度做indexFor运算,就得到了一个entry对象要存到数组的下标,这里有一个要点! 就是这个hash运算的算法设计,因为就算你拿不同的key去调用hashcode方法得到不同的值拿去做hash运算都会得到一个相同的值,然后把相同的散列值拿去做indexFor运算就会得到相同的 i ,这就发生了哈希表的冲突~~ 发生冲突了怎么办呢?

这里解释源码里的 if 中的判断,因为hash(散列值)是会算出重复的(冲突嘛~),如果这个Entry对象的hash(散列值)和你拿进来的key算的散列值(hash=hash(key))是一样的并且key也相等(==),那么你放进来的这个entry对象是同一个对象,hashmap允许你的key为空,但是key不能相同,所以新进来的会覆盖旧的;或者key.equals(k),如果这两个对象通过hashmap重写的equals方法判断是一样的,那也说明是同一个entry对象嘛!怎么办? 老办法,新的覆盖旧的!其他发生冲突的情况就把新加入的entry对象放到对应数组下标位置的表头,早进来的entry对象后移一个位置,这就形成了一个链表~~!

好的,上面说的挺啰嗦的,下面来个1.8版本的总结,1.8跟1.7差不多~,1.8只是多了一个新特性,当链表的长度>=8的时候,链表转换为红黑树提高查询的效率;

 1.8 链表转红黑树的源码
static final int TREEIFY_THRESHOLD = 8;
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
         treeifyBin(tab, hash);
              break;

扩容的源码:

添加entry对象的时候如果会时刻判断需不需要扩容数组

void addEntry(int hash, K key, V value, int bucketIndex) {
        if ((size >= threshold) && (null != table[bucketIndex])) {
            resize(2 * table.length);
            hash = (null != key) ? hash(key) : 0;
            bucketIndex = indexFor(hash, table.length);
        }
 
        createEntry(hash, key, value, bucketIndex);
    }

threshold是一个域值:是由现在的数组容量加载因子;比如初始容量是16,加载因子是0.75;那么threshold=160.75=12; 当数组.size达到12的时候就会扩容了,

会把数组的大小从16扩容到32,那么这时的情况就是,哈希表的数组有第12个元素时,数组就会扩容到32;

  void resize(int newCapacity) {
        Entry[] oldTable = table;
        int oldCapacity = oldTable.length;
        if (oldCapacity == MAXIMUM_CAPACITY) {
            threshold = Integer.MAX_VALUE;
            return;
        }
 
        Entry[] newTable = new Entry[newCapacity];
        boolean oldAltHashing = useAltHashing;
        useAltHashing |= sun.misc.VM.isBooted() &&
                (newCapacity >= Holder.ALTERNATIVE_HASHING_THRESHOLD);
        boolean rehash = oldAltHashing ^ useAltHashing;
        transfer(newTable, rehash);
        table = newTable;
        threshold = (int)(newCapacity * loadFactor);
    }

new Capacity 就是那个两倍的数组对象,(上面的2*table.length);
transf(newTable,rehash)这个方法是把原来那个未扩容前数组里的所有entry对象复制到现在这个新数组中来;

此时的域值 threshold 的大小就是新的数组大小 * 0.75 ;

说完put,说get~

 public V get(Object key) {
        if (key == null)
            return getForNullKey();
        int hash = hash(key.hashCode());
        //先定位到数组元素,再遍历该元素处的链表
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k)))
                return e.value;
        }
        return null;
}

分析完put,get就容易理解多了~

key==null,这个情况不用讲了吧~

还是先算出散列码的值,然后通过散列码定位到数组(table)下标,也就是常说的通过key就可以拿到value,hashmap底层都是通过key拿到entry对象,然后直接从entry对象里拿到value的;

接着说:如果通过key找到了entry对象,进入if判断,第一种情况:如果entry对象的散列码和 传进来key的散列码是相等(都是int嘛,直接判断是否相等)并且entry对象的key和你传进来的key如果也相等那么就认为是你的key跟你要找的key是同一个,那么就直接return你要的value; 哦对了,这个Object k,是为了去接收entry对象的key(然后赋值给object的k)然后才好和你传进来的key做比较,因为人家不知道你传进来的是什么类型的嘛,用object比就不会有类型转换的错误;
第二种情况:如果e.hash == hash 然后你传进来的key和entry对象的key连重写后的equals后都一样,那肯定就是同一个key了嘛;

不知道我这个菜鸟分析得怎么样,不过可以通过以下几个问题来加深对HashMap的理解;

1. 什么时候会使用HashMap?他有什么特点?

是基于Map接口的实现,存储键值对时,它可以接收null的键值,是非同步的线程不安全,HashMap存储着Entry(hash, key, value, next)对象。

2. 你知道HashMap的工作原理吗?
通过hash的方法,通过put和get存储和获取对象。存储对象时,我们将key传给put方法时,它调用hashCode计算hash从而得到储存的数组下标位置,进一步存储,HashMap会根据当前数组的占用情况自动调整容量(超过域值时则resize为原来的2倍)。获取对象时,我们将key传给get方法,它调用hashCode计算hash从而得到数组下标位置,并进一步调用equals()方法确定键值对。如果发生冲突的时候,Hashmap通过链表将产生碰撞冲突的元素组织起来,在Java 8中,如果一个数组中碰撞冲突的元素超过某个限制(默认是8),则使用红黑树来替换链表,从而提高速度。

3.为什么要重写hashcode和equals方法?

1、因为要得到散列码(hash)的时候要通过key.hashcode()去得到key的哈希码才可以做hash运算;不论是put和get方法,都要使用equals方法,equals方法是object的一个方法,直接调用是比较内存(object.equals源码是用==做比较);

2、Java约定在用集合类中要重写hashcode和equals方法; Effective java 这本书第二章有详细的解释为什么要定义这样的公约,感兴趣的可以去看看;

4、 为什么HashMap的初始大小是16?为什么默认的加载因子是0.75?HashMap的大小超过了加载因子定义的容量,怎么办?

16是2的4次方,hashmap在put的时候会发生冲突,hashmap发生冲突是不可避免的,也是我们想要的,但是我们不想hashmap傻不拉几的发生冲突,因为最坏的情况,hashmap所有的冲突都发生在同一个数组下标的话,这个位置的链表过长,而其他数组位置确实空的,这样又hashmap还扩容不了,链表的查询效率可想而知,这样的话hashmap还有那么牛逼吗?把数组的长度设计成为2的n次方,加载因子设计为0.75,会极大的优化冲突,使每个数组的链表都差不多长(也不一定,概率问题);

至于为什么? 这是数据结构相关的东西,当时是数学家设计的啊,你问我怎么知道的? 不记得什么时候看了篇论文还是博客,上面是这样说的我真的印象很深的记得我看到过,如果你不信也没办法 反正是有这么回事~
如果超过了负载因子,则会重新resize一个原来长度两倍的HashMap。

5、如果两个键的hashcode相同,你如何获取对象的值?

获取对象的值,那么就是get方法咯,两个key的hashcode相同说明 散列码(hash)相同,

如果散列码都相同了,那么就会调用key.equals()去判断在该散列码得到的这个数组下标的链表里的entry对象是否有相同的对象;

如果有,那么好,你直接拿这个entry对象的值即可;

6、用hashmap的时候需不需要给他一个初始化大小?如果要该怎么定义?

此问题是博主自己想出来的,答案也是博主自己思考的一个结果,不一定对,提供一个思路!如果你有更好的回答,可以留言给我一起探讨,谢谢啦~

最好是需要的,因为我们知道hashmap的数组长度超过了他的域值会扩容,扩容的时候会把hashmap中所有的entry对象再计算一次他们在新数组中的下标;可以想象一下,如果一个hashmap里有10万个entry对象了,如果要扩容,这10万个entry对象的位置都要发生变化,这会有多影响系统性能?不优化一下吗? 如何定义这个我也回答不了...因为我们只能初始化数组的大小,并不会知道每个数组元素的链表会有多长,我看同事他们创建hashmap的时候好像都没有给参数,那么如果这10万条数据放到一个大小为16的hashmap里,如果不扩容的话10万条数据只放在数组的11个元素中,那平均每个链表长度有接近1W,肯定不合理吗,链表的查询速度那么慢,所以我们判断必定会扩容,好!我们知道会扩容,但不知道会扩容几次啊,这里就是这个问题难的地方了,因为我们无法知道hashmap会扩容多少次,我们能做的就是减少他扩容的次数,同时又不让hashmap占太多内存~ 那我们就大胆的预测吧,比如有10万条数据,我觉得至少hashmap数组长度应该给1W吧,这样我们就可以把hashmap的初始大小定义为2的14次方 16384,这样数组的长度我们就定义了1.6W,就算用了1W个,也不会扩容,也没浪费更多的资源;如果你觉得10万条数据可能用不到1W个数组这么长,那你就把hashmap的初始大小定义为2的13次方,或者2的12次方;我们开始就定义一个大小总比我们不定义好(毕竟默认只有16)对吧?

7、我们可以用自定义的对象作为hashmap的key吗?如果可以,你会自定义对象当做key吗?如果不行说明原因。

可以使用自定义的对象作为hashmap的key,只要重写hashcode和equals方法就可以了,原因见第三个问题的回答;

但我一般不会把自定义对象作为key,因为有Integer跟string给我用,没必要使用自定义对象了,复杂,麻烦~

8、那你平时使用hashmap的时候一般用什么当做key,为什么?

Integer跟string呀,因为用他们用起来简单啊,Entry对象的四个属性中key是被final关键字修饰的,而Integer和string都是不可变的,直接使用Integer和string岂不美哉

9、hashmap并不能保证线程安全,如果要保证线程安全怎么办?

hashtable能保证线程安全,但有一种更高效的方式就是使用CocurrentHashMap来保证线程安全,同时效率又高;

hashtable是锁住整个数组,一次只能有一个线程去hashtable里面拿,而CocurrentHashMap是只锁数组[ i ] ,所以CocurrentHashMap的效率会比hashtable快很多,又比hashmap安全性要高,实在是居家旅行之首选~

10、那你还知道什么类似hashmap的东西吗?

有个安卓的大牛告诉我有 SarseArray , 安卓内部特有的一个API,因为hashmap用entry来保存key和value,这里entry涉及自动装箱和拆箱,其实挺占内存的;在key为Integer的场景可以使用SarseArray;(后半段来自搜索的解释)假设现在有这样的情况,我们定义了一个长度为100的数组,虚拟机为我们开辟了100个单位的内存空间,但是我们只使用了很少(假设是5个)的一些单元,这样就造成了内存空间的浪费。而 SparseArray 是一个优化的数组,它的 key 是 Integer 类型而不是其他类型,是因为这个数组是按照 key 值的大小来排序的。按照 key 值大小排序的好处是查找的时候,可以使用二分查找,而不是蛮力的遍历整个数组。这也是为什么 SparseArray 适合替换 HashMap<Integer,<E>>,而不是任何 HashMap 的原因了。在这种情况下,原本需要100个单位内存空间而 SparseArray 只占用了5个单位的内存(实际比5个单位要大一些,因为还有一些逻辑控制的内存消耗)。key 值被当成了数组下标的功能来使用了。 安卓用SarseArray 很大一部分原因就是因为 SarseArray 比较节约内存;

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 143,048评论 1 300
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 61,330评论 1 256
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 94,541评论 0 213
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 41,042评论 0 176
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 48,821评论 1 255
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 38,795评论 1 176
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 30,413评论 2 271
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 29,173评论 0 167
  • 想象着我的养父在大火中拼命挣扎,窒息,最后皮肤化为焦炭。我心中就已经是抑制不住地欢快,这就叫做以其人之道,还治其人...
    爱写小说的胖达阅读 28,994评论 6 229
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 32,525评论 0 213
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 29,327评论 2 215
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 30,648评论 1 230
  • 白月光回国,霸总把我这个替身辞退。还一脸阴沉的警告我。[不要出现在思思面前, 不然我有一百种方法让你生不如死。]我...
    爱写小说的胖达阅读 24,248评论 0 31
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 27,142评论 2 214
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 31,521评论 3 204
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,622评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,025评论 0 166
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 33,543评论 2 230
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 33,636评论 2 231

推荐阅读更多精彩内容