前奏一:HashMap面试中常见问题汇总
HashMap的工作原理是近年来常见的Java面试题,几乎每个Java程序员都知道HashMap,都知道哪里要用HashMap,知道HashTable和HashMap之间的区别,那么为何这道面试题如此特殊呢?是因为这道题考察的深度很深,关于HashMap的相关题目经常出现在java各层次(低级、中级、中高级或高级)面试中,甚至有些公司会要求你实现HashMap来考察你的编程能力。ConcurrentHashMap和其它同步集合的引入让这道题变得更加复杂!
说到HashMap,在这里首先得了解哈希表的结构:
看过了哈希表的结构之后,我们再回到HashMap,HashMap是基于哈希表【hash表可以理解为与hash值计算的一个相关的数组】实现的Map,所以我们首先要了解到,HashMap里面实现一个静态内部类Entry,其重要的属性有 key , value, next,hash,从属性key,value我们就能很明显的看出来Entry就是HashMap键值对实现的一个基础bean,我们上面说到HashMap的基础就是一个线性数组,这个数组就是Entry[],Map里面的内容都保存在Entry[]里面。接下来我们来回到HashMap面试题的这个问题上来!
1、“你用过HashMap吗?”
答:在这里,相信几乎所有人的回答都会是 yes!
2、 “HashMap的数据结构?”“什么是HashMap?你为什么用到它?”
答:
HashMap是基于哈希表的 Map 接口的实现。
此实现提供所有可选的映射操作,并允许使用 null 值和 null 键(除了非同步和允许使用 null 之外,HashMap 类与 Hashtable 大致相同),此类不保证映射的顺序,特别是它不保证该顺序恒久不变!
好,说到这,那么你为何使用HashMap呢,相信大多数人在这里都会回答HashMap的一些特性,诸如上面提到的HashMap可以允许null键值和value(这里切记,HashMap的key为null的情况只能出现一个,而value为null可以有多个),而hashtable不能;HashMap是非synchronized(非线程安全);HashMap很快;以及HashMap存储的是键值对等等(hashtable线程安全)。是的,回答上面这些关于HashMap和hashtable之间的区别就基本上够了,为什么用到,无外乎就是因为他提供了一些hashtable所没有的而已,好的,到这里,已经能够显示出你已经用过HashMap,而且对它相当的熟悉了。
那么好了,说了这么多,HashMap的数据结构又是怎样的呢?相信不少人对这个问题并没有深入的了解,要知道,在java编程语言中,最基本的结构就是两种,一个是数组,另外一个是引用(模拟指针),所有的数据结构都可以用这两个基本结构来构造,HashMap也不例外。Hashmap实际上是一个数组和链表的结合体(在数据结构中,一般称之为“链表散列“)【其实在jdk1.8以后加入了红黑树来提高查询效率,也就是说链表长度超过8以后后将链表改成红黑树来进行存储】。
当我们往HashMap中put元素的时候,先根据key的hash值得到这个元素在数组中的位置(即下标)【此处需要注意的是并不是根据存入的key来找下标,而是把key进行hash计算后找下标】,然后就可以把这个元素放到对应的位置中了。如果这个元素所在的位子上已经存放有其他元素了,那么在同一个位子上的元素将以链表的形式存放,新加入的放在链头,最先加入的放在链尾。从HashMap中get元素时,首先计算key的hashcode,找到数组中对应位置的某一元素,然后通过key的equals方法在对应位置的链表中找到需要的元素。从这里我们可以想象得到,如果每个位置上的链表只有一个元素,那么HashMap的get效率将是最高的。。。。
3、“你知道HashMap的工作原理吗?” “你知道HashMap的get()方法的工作原理吗?”
答:其实就这个问题,在上一题中已经有了简单的涉猎,好了,这里,我们大致可以做出这样的回答,HashMap是基于hashing的原理,我们使用put(key, value)存储对象到HashMap中,使用get(key)从HashMap中获取对象(value)。当我们给put()方法传递键和值时,我们使用 i = (n - 1) & hash 计算下标位置,其中hash的值通过(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16)进行计算;找到bucket位置来储存Entry对象。这里关键点在于指出,HashMap是在bucket中储存键对象和值对象,作为Map.Entry。嗯,如此回答,基本上算得上相当正确了,也显示出面试者确实知道hashing以及HashMap的工作原理,那么接下来我们还需要知道的是HashMap与别的Map之间的区别以及一些涉及场景的问题了!
在面试中有些水平的公司比较喜欢问HashMap原理,其中涉及的点比较多,而且大多能形成连环炮形式的问题。
4、“HashMap Hashtable LinkedHashMap 和 TreeMap?”
答:首先我们知道,java为数据结构中的映射定义了一个接口java.util.Map;它有四个实现类,分别是HashMap Hashtable LinkedHashMap 和 TreeMap.
Map主要用于存储健值对,根据键得到值,因此不允许键重复(重复了覆盖了),但允许值重复!
HashMap:
Hashmap 是一个最常用的Map,它根据键的HashCode值存储数据,根据键可以直接获取它的值,具有很快的访问速度,遍历时,取得数据的顺序是完全随机的【不是按输入的顺序尽心输出】。 HashMap最多只允许一条记录的键为Null,允许多条记录的值为 Null;HashMap不支持线程的同步,即任一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致。如果需要同步,可以用 Collections的synchronizedMap方法使HashMap具有同步的能力,或者使用ConcurrentHashMap。
Hashtable:
Hashtable与 HashMap类似,它继承自Dictionary类,不同的是:它不允许记录的键或者值为空【注意是键 和 值 都不能为null】;它支持线程的同步,即任一时刻只有一个线程能写Hashtable,因此也导致了 Hashtable在写入时会比较慢。(主要区别就是以上两点是相反的,HashMap进一步改进了)
LinkedHashMap:
LinkedHashMap 是HashMap的一个子类,保存了记录的插入顺序,在用Iterator遍历LinkedHashMap时,先得到的记录肯定是先插入的。也可以在构造时用带参数,按照应用次数排序。在遍历的时候会比HashMap慢,不过有种情况例外,当HashMap容量很大,实际数据较少时,遍历起来可能会比 LinkedHashMap慢,因为LinkedHashMap的遍历速度只和实际数据有关,和容量无关,而HashMap的遍历速度和他的容量有关。
TreeMap实现了SortMap接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以指定排序的比较器,当用Iterator 遍历TreeMap时,得到的记录是排过序的。
TreeMap:
一般情况下,我们用的最多的是HashMap,在Map 中插入、删除和定位元素,HashMap 是最好的选择。但如果您要按自然顺序或自定义顺序遍历键,那么TreeMap会更好。如果需要输出的顺序和输入的相同,那么用LinkedHashMap 可以实现,它还可以按读取顺序来排列。
5、“SynchronizedMap和ConcurrentHashMap的区别?”
答:在上面我们已经提到过,HashMap和hashtable之间的一大区别就是是否线程安全,而在上一题也说到过,如果HashMap需要同步,可以使用Collections类中提供的SynchronizedMap方法使HashMap具有同步的能力,或者也可以使用ConcurrentHashMap方法,好,既然我们知道可以用这两个方法实现,那么我们也应该了解到这两个方法的区别所在:
首先来看下java中Collections工具类中的SynchronizedMap方法:
public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) { return new SynchronizedMap<K,V>(m); }
该方法返回的是一个SynchronizedMap的实例。SynchronizedMap类是定义在Collections中的一个静态内部类,它实现了Map接口,并对其中的每一个方法实现,通过synchronized关键字进行了同步控制。(PS:hashtable容器就是使用的Synchronized方法进行同步控制来保证线程安全的,效率十分低下)
显而易见,在这个类中,需要对每个方法进行同步控制,当需要迭代时,这种操作效率无疑是十分低下的,所以我们不得不考虑别的方法了,于是就有了更好的选择 ConcurrentHashMap,对于这个具体不赘述,总结如下。
Collections.synchronizedMap()与ConcurrentHashMap主要区别是:Collections.synchronizedMap()和Hashtable一样,实现上在调用map所有方法时,都对整个map进行同步,而ConcurrentHashMap的实现却更加精细,它对map中的所有桶加了锁。所以,只要要有一个线程访问map,其他线程就无法进入map,而如果一个线程在访问ConcurrentHashMap某个桶时,其他线程,仍然可以访问其他未加锁的桶位置作。这样,ConcurrentHashMap在性能以及安全性方面,明显比Collections.synchronizedMap()更加有优势。另外,ConcurrentHashMap必然是个HashMap,而Collections.synchronizedMap()可以接收任意Map实例,实现Map的同步。
6、“在HashMap中,当两个不同的键对象的hashcode相同会发生什么?”
答:这里我们首先要知道的是,HashMap中有hashcode()和equals()两个方法,所以两个对象就算hashcode相同,但是它们可能并不相等,在HashMap的处理中,因为hashcode相同,所以它们的bucket位置相同,‘碰撞’就会发生。因为HashMap使用LinkedList存储对象,这个Entry(包含有键值对的Map.Entry对象)会存储在LinkedList中。【这里提供一个标准的回答,当两个不同的键对象的hashcode相同时,它们会储存在同一个bucket位置的LinkedList中。键对象的equals()方法可以用来找到键值对!】接着这个问题,面试官可能还会更一步问下去,“如果两个键的hashcode相同,你如何获取值对象?” 这里,我们可以尝试这样回答:当我们调用get()方法,HashMap会使用键对象的hashcode找到bucket位置,然后获取值对象。(面试官提醒他如果有两个值对象储存在同一个bucket),我们可以补充回答,在找到bucket位置之后,会调用keys.equals()方法去找到链表(LinkedList)中正确的节点,将会遍历链表直到找到值对象,最终找到要找的值对象!
7、“如果HashMap的大小超过了负载因子(loadfactor)定义的 容量,怎么办?”
答:首先看看java定义负载因子:
static final float DEFAULT_LOAD_FACTOR = 0.75F;
可以看出,默认的负载因子大小为0.75,也就是说,当一个map填满了75%的bucket时候,和其它集合类(如ArrayList等)一样,将会创建原来HashMap大小的两倍的bucket数组,来重新调整map的大小,并将原来的对象放入新的bucket数组中。这个过程叫作rehashing,因为它调用hash方法找到新的bucket位置。
8、“你了解重新调整HashMap大小存在什么问题吗?”
答:(当多线程的情况下,可能产生条件竞争(race condition))
当重新调整HashMap大小的时候,确实存在条件竞争,因为如果两个线程都发现HashMap需要重新调整大小了,它们会同时试着调整大小。在调整大小的过程中,存储在链表中的元素的次序会反过来【和自己看源码理解是一致的】,因为移动到新的bucket位置的时候,HashMap并不会将元素放在链表的尾部,而是放在头部,这是为了避免尾部遍历(tail traversing)。如果条件竞争发生了,那么就死循环了【可以理解为一个线程在不断的重新调整位置,马上调整完的时候,另外一个线程也在调整,把顺序调整过了,原先的线程会继续调整一遍,形成死循环】。这个时候,你可以质问面试官,为什么这么奇怪,要在多线程的环境下使用HashMap呢?
最后再摘抄一些网络上比较多的相关题目放在这里,也供自己参考:
为什么String, Interger这样的wrapper类适合作为键?
String, Interger这样的wrapper类作为HashMap的键是再适合不过了,而且String最为常用。因为String是不可变的,也是final的,而且已经重写了equals()和hashCode()方法了。其他的wrapper类也有这个特点。不可变性是必要的,因为为了要计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象。不可变性还有其他的优点如线程安全。如果你可以仅仅通过将某个field声明成final就能保证hashCode是不变的,那么请这么做吧。因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的。如果两个不相等的对象返回不同的hashcode的话,那么碰撞的几率就会小些,这样就能提高HashMap的性能。
我们可以使用自定义的对象作为键吗? 这是前一个问题的延伸。当然你可能使用任何对象作为键,只要它遵守了equals()和hashCode()方法的定义规则,并且当对象插入到Map中之后将不会再改变了。如果这个自定义对象是不可变的,那么它已经满足了作为键的条件,因为当它创建之后就已经不能改变了。
我们可以使用CocurrentHashMap来代替HashTable吗?这是另外一个很热门的面试题,因为ConcurrentHashMap越来越多人用了。我们知道HashTable是synchronized的,但是ConcurrentHashMap同步性能更好,因为它仅仅根据同步级别对map的一部分进行上锁。ConcurrentHashMap当然可以代替HashTable,但是HashTable提供更强的线程安全性。
**能否让HashMap同步?**HashMap可以通过下面的语句进行同步:
Map m = Collections.synchronizeMap(hashMap);
结束语:关于HashMap的问题当然不是这么一篇小小的随笔和汇总能够说清楚的,更多的相关知识还需要我们不停的在实践中使用,去比较才能发现,这里关于List和Set并没有做过多的涉猎,因为在之前面试的总结中有基本内容的涉及,大家可以稍微借鉴,当然如果要阅读源代码的同道可以自己查看相关源代码即可了,这里最后对HashMap的工作原理稍微做个简单的总结:
HashMap是基于hashing原理的,它是一种数组和链表的结合体,在实现对象的存取时,我们通过put()和get()方法储存和获取对象。当我们将键值对传递给put()方法时,它调用键对象的hashCode()方法来计算hashcode,然后找到bucket位置来储存值对象。当获取对象时,通过键对象的equals()方法找到正确的键值对,然后返回值对象。HashMap使用LinkedList来解决碰撞问题,当发生碰撞了,对象将会储存在LinkedList的下一个节点中。 HashMap在每个LinkedList节点中储存键值对对象。
在最后再补充一个问题:
什么是hash,什么是碰撞,什么是equals ?
Hash:是一种信息摘要算法,它还叫做哈希,或者散列。我们平时使用的MD5,SHA1都属于Hash算法,通过输入key进行Hash计算,就可以获取key的HashCode(),比如我们通过校验MD5来验证文件的完整性。
碰撞:好的Hash算法可以出计算几乎出独一无二的HashCode,如果出现了重复的hashCode,就称作碰撞;就算是MD5这样优秀的算法也会发生碰撞,即两个不同的key也有可能生成相同的MD5。
HashCode,它是一个本地方法,实质就是地址取样运算;
==是用于比较指针是否在同一个地址;
equals与==是相同的。
如何减少碰撞?
使用不可变的、声明作final的对象,并且采用合适的equals()和hashCode()方法的话,将会减少碰撞的发生,提高效率。不可变性使得能够缓存不同键的hashcode,这将提高整个获取对象的速度,使用String,Interger这样的wrapper类作为键是非常好的选择
一般连环炮,一环不知道后面试官也就不问了,但是低层连环没连上,恭喜扣分是大大的,连到比较深的时候,说不知道还好点,比如:
关于集合的:
1.1Hashmap是不是有序的? 不是
1.2有没有有顺序的Map? TreeMap LinkedHashMap
1.3它们是怎么来保证顺序的? 一般都要说到其源码,要不说不清为么有序
1.4答两个有序或以上的 继续 你觉得它们有序的区别,那个比较好,在什么场景用哪个好?
1.4答一个也可以问上面的场景 继续
1.5你觉得有没有更好或者更高效的实现方式?有
1.6 答有 这个时候说起来可能就要跑到底层数据结构上去了
数据结构继续衍生 到 算法等等。。。
就这一个遇到大佬问你,能把很多人连到怀疑人生
关于hash的:
1.1 hashmap基本的节点结构? Node 键值对
1.2 键是什么样的,我用字符串a那键就是a嘛? 不是会进行hash
1.3 如何hash的 这样hash有什么好处? 源码hashmap的hash算法
1.4 Hash在java中主要作用是什么?
1.5 Hashcode equal相关 需要同时重写?原因?
1.6 equal引出的对象地址、string带有字符串缓冲区、字符串常量池
等等。。。
3.关于线程安全问题、到concurrent包等
原理二:源码解析
1、HashMap使用以及遍历方式
Map<String, String> map = new HashMap<String, String>(); map.put("1", "11"); map.put("2", "22"); map.put("3", "33"); System.out.println(map.get("1")); System.out.println(map.get("3"));
//遍历 //方法一 : 将键和值同时取出 放入到Map的内部类Entry中 /* * entrySet是java中的一个对象,一般可以通过map.entrySet()得到。 * 1,entrySet实现了Set接口,里面存放的是entry结构,该结构包含键值对。 * 2.因为该对象是一个Set集合,存储了键值对,那通过Iterator可以拿到内部的每一个简直对 * 3.拿到entry后,取出键和值 */ Set set = map.entrySet(); Iterator iter = set.iterator(); while (iter.hasNext()) { Map.Entry entry = (Map.Entry) iter.next(); Object key = entry.getKey(); Object val = entry.getValue(); System.out.println("key: " + key + " value: "+ val); } //遍历 //方法二: 将键放在Set中 先取出键 然后根据键再取出对应的value值 /* * keySet()方法可以得到存储键的Set集合 * 然后使用Iterator得到每一个key值 * 通过key值获得value值 * */ Set set2 = map.keySet(); Iterator iter2 = set2.iterator(); while (iter2.hasNext()) { Object key = iter2.next(); Object val = map.get(key); System.out.println("key: " + key + " value: "+ val); }
2、HashMap的原理分析
一、什么是哈希表
在讨论哈希表之前,我们先大概了解下其他数据结构在新增,查找等基础操作执行性能
数组:采用一段连续的存储单元来存储数据。对于指定下标的查找,时间复杂度为O(1);通过给定值进行查找,需要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为O(n),当然,对于有序数组,则可采用二分查找,插值查找,斐波那契查找等方式,可将查找复杂度提高为O(logn);对于一般的插入删除操作,涉及到数组元素的移动,其平均复杂度也为O(n)
线性链表:对于链表的新增,删除等操作(在找到指定操作位置后),仅需处理结点间的引用即可,时间复杂度为O(1),而查找操作需要遍历链表逐一进行比对,复杂度为O(n)
二叉树:对一棵相对平衡的有序二叉树,对其进行插入,查找,删除等操作,平均复杂度均为O(logn)。
哈希表:相比上述几种数据结构,在哈希表中进行添加,删除,查找等操作,性能十分之高,不考虑哈希冲突的情况下,仅需一次定位即可完成,时间复杂度为O(1),接下来我们就来看看哈希表是如何实现达到惊艳的常数阶O(1)的。
我们知道,数据结构的物理存储结构只有两种:顺序存储结构和链式存储结构(像栈,队列,树,图等是从逻辑结构去抽象的,映射到内存中,也这两种物理组织形式),而在上面我们提到过,在数组中根据下标查找某个元素,一次定位就可以达到,哈希表利用了这种特性,哈希表的主干就是数组。
比如我们要新增或查找某个元素,我们通过把当前元素的关键字 通过某个函数映射到数组中的某个位置,通过数组下标一次定位就可完成操作。
存储位置 = f(关键字)
其中,这个函数f一般称为哈希函数,这个函数的设计好坏会直接影响到哈希表的优劣。举个例子,比如我们要在哈希表中执行插入操作:
查找操作同理,先通过哈希函数计算出实际存储地址,然后从数组中对应地址取出即可。
哈希冲突
然而万事无完美,如果两个不同的元素,通过哈希函数得出的实际存储地址相同怎么办?也就是说,当我们对某个元素进行哈希运算,得到一个存储地址,然后要进行插入的时候,发现已经被其他元素占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞。前面我们提到过,哈希函数的设计至关重要,好的哈希函数会尽可能地保证 计算简单和散列地址分布均匀, 但是,我们需要清楚的是,数组是一块连续的固定长度的内存空间,再好的哈希函数也不能保证得到的存储地址绝对不发生冲突。那么哈希冲突如何解决呢?哈希冲突的解决方案有多种:开放定址法(发生冲突,继续寻找下一块未被占用的存储地址)【好像Redis的hash结构就是采用的这种开放地址法】,再散列函数法,链地址法,而HashMap即是采用了链地址法,也就是数组+链表的方式,
二、HashMap实现原理
HashMap的主干是一个Entry数组。Entry是HashMap的基本组成单元,每一个Entry包含一个key-value键值对。
//HashMap的主干数组,可以看到就是一个Entry数组,初始值为空数组{},主干数组的长度一定是2的次幂,至于为什么这么做,后面会有详细分析。
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
Entry是HashMap中的一个静态内部类。代码如下
static class Entry<K,V> implements Map.Entry<K,V> { final K key; V value; Entry<K,V> next;//存储指向下一个Entry的引用,单链表结构 int hash;//对key的hashcode值进行hash运算后得到的值,存储在Entry,避免重复计算 /** * Creates new entry. */ Entry(int h, K k, V v, Entry<K,V> n) { value = v; next = n; key = k; hash = h; }
所以,HashMap的整体结构如下
简单来说,HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前entry的next指向null),那么对于查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表,对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap中的链表出现越少,性能才会越好。
其他几个重要字段
//实际存储的key-value键值对的个数 transient int size; //阈值,当table == {}时,该值为初始容量(初始容量默认为16);当table被填充了,也就是为table分配内存空间后,threshold一般为 capacity*loadFactory。HashMap在进行扩容时需要参考threshold,后面会详细谈到 int threshold; //负载因子,代表了table的填充度有多少,默认是0.75 final float loadFactor; //用于快速失败,由于HashMap非线程安全,在对HashMap进行迭代时,如果期间其他线程的参与导致HashMap的结构发生变化了(比如put,remove等操作),需要抛出异常ConcurrentModificationException transient int modCount;
HashMap有4个构造器,其他构造器如果用户没有传入initialCapacity 和loadFactor这两个参数,会使用默认值
initialCapacity默认为16,loadFactory默认为0.75
我们看下其中一个
public HashMap(int initialCapacity, float loadFactor) {
//此处对传入的初始容量进行校验,最大不能超过MAXIMUM_CAPACITY = 1<<30
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; threshold = initialCapacity;
init();//init方法在HashMap中没有实际实现,不过在其子类如 linkedHashMap中就会有对应实现 }
从上面这段代码我们可以看出,在常规构造器中,没有为数组table分配内存空间(有一个入参为指定Map的构造器例外),而是在执行put操作的时候才真正构建table数组
OK,接下来我们来看看put操作的实现吧
public V put(K key, V value) { //如果table数组为空数组{},进行数组填充(为table分配实际内存空间),入参为threshold,此时threshold为initialCapacity 默认是1<<4。 16
if (table == EMPTY_TABLE) { inflateTable(threshold); //第一次table表空的时候 此处已经创建table表空间 }
//如果key为null,存储位置为table[0]或table[0]的冲突链上 if (key == null) return putForNullKey(value); int hash = hash(key);//对key的hashcode进一步计算,确保散列均匀 int i = indexFor(hash, table.length);//获取在table中的实际位置 for (Entry<K,V> e = table[i]; e != null; e = e.next) { //如果该对应数据已存在,执行覆盖操作。用新value替换旧value,并返回旧value 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++;//保证并发访问时,若HashMap内部结构发生变化,快速响应失败 addEntry(hash, key, value, i);//新增一个entry return null; }
先来看看inflateTable这个方法
private void inflateTable(int toSize) {
//roundUpToPowerOf2这个方法是用来返回大于等于最接近number的2的冪数 int capacity = roundUpToPowerOf2(toSize);//capacity一定是2的次幂 所以此处 当tosize为16时 返回的是16 threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);//此处为threshold赋值,取capacity*loadFactor和MAXIMUM_CAPACITY+1的最小值,capaticy一定不会超过MAXIMUM_CAPACITY,除非loadFactor大于1 table = new Entry[capacity]; initHashSeedAsNeeded(capacity); }
inflateTable这个方法用于为主干数组table在内存中分配存储空间,通过roundUpToPowerOf2(toSize)可以确保capacity为大于或等于toSize的最接近toSize的二次幂,比如toSize=13,则capacity=16;to_size=16,capacity=16;to_size=17,capacity=32.
private static int roundUpToPowerOf2(int number) {
// assert number >= 0 : "number must be non-negative";
return number >= MAXIMUM_CAPACITY ? MAXIMUM_CAPACITY : (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}
roundUpToPowerOf2中的这段处理使得数组长度一定为2的次幂,Integer.highestOneBit是用来获取最左边的bit(其他bit位为0)所代表的数值.
hash函数
//这是一个神奇的函数,用了很多的异或,移位等运算,对key的hashcode进一步进行计算以及二进制位的调整等来保证最终获取的存储位置尽量分布均匀 final int hash(Object k) { int h = hashSeed; if (0 != h && k instanceof String) { return sun.misc.Hashing.stringHash32((String) k); } h ^= k.hashCode(); //在jdk1.8以后 h = key.hashCode()) ^ (h >>> 16) h ^= (h >>> 20) ^ (h >>> 12); return h ^ (h >>> 7) ^ (h >>> 4); }
以上hash函数计算出的值,通过indexFor进一步处理来获取实际的存储位置
/** * 返回数组下标 */ static int indexFor(int h, int length) { return h & (length-1); }
h&(length-1)保证获取的index一定在数组范围内,举个例子,默认容量16,length-1=15,h=18,转换成二进制计算为
1 0 0 1 0 & 0 1 1 1 1 __________________ 0 0 0 1 0 = 2
最终计算出的index=2。有些版本的对于此处的计算会使用 取模运算,也能保证index一定在数组范围内,不过位运算对计算机来说,性能更高一些(HashMap中有大量位运算)
所以最终存储位置的确定流程是这样的:
再来看看addEntry的实现:
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);//当size超过临界阈值threshold,并且即将发生哈希冲突时进行扩容
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);
}
createEntry(hash, key, value, bucketIndex);
}
通过以上代码能够得知,当发生哈希冲突并且size大于阈值的时候,需要进行数组扩容,扩容时,需要新建一个长度为之前数组2倍的新的数组,然后将当前的Entry数组中的元素全部传输过去,扩容后的新数组长度为之前的2倍,所以扩容相对来说是个耗资源的操作。
三、为何HashMap的数组长度一定是2的次幂?
我们来继续看上面提到的resize方法
void resize(int newCapacity) { Entry[] oldTable = table; //先将table表的引用复制给oldTable 不破坏原来的数据 int oldCapacity = oldTable.length; if (oldCapacity == MAXIMUM_CAPACITY) { //当原表的大小达到最大值后不再进行扩容 threshold = Integer.MAX_VALUE; return; } Entry[] newTable = new Entry[newCapacity]; transfer(newTable, initHashSeedAsNeeded(newCapacity)); table = newTable; threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1); //扩容后重新计算 threshold }
如果数组进行扩容,数组长度发生变化,而存储位置 index = h&(length-1),index也可能会发生变化,需要重新计算index,我们先来看看transfer这个方法
void transfer(Entry[] newTable, boolean rehash) { int newCapacity = newTable.length;
//for循环中的代码,逐个遍历链表,重新计算索引位置,将老数组数据复制到新数组中去(数组不存储实际数据,所以仅仅是拷贝引用而已)和 arraylist 或者 linkedlist 中的clone方法是一样的 都是浅拷贝关系 foreach (Entry<K,V> e : table) { while(null != e) { Entry<K,V> next = e.next; if (rehash) { e.hash = null == e.key ? 0 : hash(e.key); } int i = indexFor(e.hash, newCapacity);
//将当前entry的next链指向新的索引位置,newTable[i]有可能为空,有可能也是个entry链,如果是entry链,直接在链表头部插入。 e.next = newTable[i]; newTable[i] = e; e = next; } } }
这个方法将老数组中的数据逐个链表地遍历,扔到新的扩容后的数组中,我们的数组索引位置的计算是通过 对key值的hashcode进行hash扰乱运算后,再通过和 length-1进行位运算得到最终数组索引位置。
hashMap的数组长度一定保持2的次幂,比如16的二进制表示为 10000,那么length-1就是15,二进制为01111,同理扩容后的数组长度为32,二进制表示为100000,length-1为31,二进制表示为011111。从下图可以我们也能看到这样会保证低位全为1,而扩容后只有一位差异,也就是多出了最左位的1,这样在通过 h&(length-1)的时候,只要h对应的最左边的那一个差异位为0,就能保证得到的新的数组索引和老数组索引一致(大大减少了之前已经散列良好的老数组的数据位置重新调换),个人理解。
还有,数组长度保持2的次幂,length-1的低位都为1,会使得获得的数组索引index更加均匀,比如:
我们看到,上面的&运算,高位是不会对结果产生影响的(hash函数采用各种位运算可能也是为了使得低位更加散列),我们只关注低位bit,如果低位全部为1,那么对于h低位部分来说,任何一位的变化都会对结果产生影响,也就是说,要得到index=21这个存储位置,h的低位只有这一种组合。这也是数组长度设计为必须为2的次幂的原因。
如果不是2的次幂,也就是低位不是全为1此时,要使得index=21,h的低位部分不再具有唯一性了,哈希冲突的几率会变的更大,同时,index对应的这个bit位无论如何不会等于1了,而对应的那些数组位置也就被白白浪费了。
get方法
public V get(Object key) {
//如果key为null,则直接去table[0]处去检索即可。 if (key == null) return getForNullKey(); Entry<K,V> entry = getEntry(key); return null == entry ? null : entry.getValue(); }
get方法通过key值返回对应value,如果key为null,直接去table[0]处检索。我们再看一下getEntry这个方法
final Entry<K,V> getEntry(Object key) {
if (size == 0) {
return null;
}
//通过key的hashcode值计算hash值
int hash = (key == null) ? 0 : hash(key); //hash方法中调用了hashCode方法
//indexFor (hash&length-1) 获取最终数组索引,然后遍历链表,通过equals方法比对找出对应记录
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 != null && key.equals(k))))
return e;
}
return null;
}
可以看出,get方法的实现相对简单,key(hashcode)-->hash-->indexFor-->最终索引位置,找到对应位置table[i],再查看是否有链表,遍历链表,通过key的equals方法比对查找对应的记录。要注意的是,有人觉得上面在定位到数组位置之后然后遍历链表的时候,e.hash == hash这个判断没必要,仅通过equals判断就可以。其实不然,试想一下,如果传入的key对象重写了equals方法却没有重写hashCode,而恰巧此对象定位到这个数组位置,如果仅仅用equals判断可能是相等的,但其hashCode和当前对象不一致,这种情况,根据Object的hashCode的约定,不能返回当前对象,而应该返回null,后面的例子会做出进一步解释。
四、重写equals方法需同时重写hashCode方法
关于HashMap的源码分析就介绍到这儿了,最后我们再聊聊老生常谈的一个问题,各种资料上都会提到,“重写equals时也要同时覆盖hashcode”,我们举个小例子来看看,如果重写了equals而不重写hashcode会发生什么样的问题
/**
* Created by chengxiao on 2016/11/15.
*/
public class MyTest {
private static class Person{
int idCard;
String name;
public Person(int idCard, String name) {
this.idCard = idCard;
this.name = name;
}
@Override
public boolean equals(Object o) {
if (this == o) { //地址相同一定是相等的
return true;
}
if (o == null || getClass() != o.getClass()){ //不是一个类类型
return false;
}
Person person = (Person) o;
//两个对象是否等值,通过idCard来确定
return this.idCard == person.idCard;
}
}
public static void main(String []args){
HashMap<Person,String> map = new HashMap<Person, String>();
Person person = new Person(1234,"乔峰");
map.put(person,"天龙八部");
//get取出,从逻辑上讲应该能输出“天龙八部”
System.out.println("结果:"+map.get(new Person(1234,"乔峰")));
}
}
实际输出结果:
结果:null
如果我们已经对HashMap的原理有了一定了解,这个结果就不难理解了。尽管我们在进行get和put操作的时候,使用的key从逻辑上讲是等值的(通过equals比较是相等的),但由于没有重写hashCode方法,所以put操作时,key(hashcode1)-->hash-->indexFor-->最终索引位置 ,而通过key取出value的时候 key(hashcode1)-->hash-->indexFor-->最终索引位置,由于hashcode1不等于hashcode2,导致没有定位到一个数组位置而返回逻辑上错误的值null(也有可能碰巧定位到一个数组位置,但是也会判断其entry的hash值是否相等,上面get方法中有提到。)
所以,在重写equals的方法的时候,必须注意重写hashCode方法,同时还要保证通过equals判断相等的两个对象,调用hashCode方法要返回同样的整数值。而如果equals判断不相等的两个对象,其hashCode可以相同(只不过会发生哈希冲突,应尽量避免)。
五、JDK1.8中的源码分析
一.HashMap类加载
1.只有一些静态属性会进行赋值,具体每个值什么用,暂时不管
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16 static final int MAXIMUM_CAPACITY = 1 << 30; static final float DEFAULT_LOAD_FACTOR = 0.75f; static final int TREEIFY_THRESHOLD = 8; static final int UNTREEIFY_THRESHOLD = 6; static final int MIN_TREEIFY_CAPACITY = 64;
2.没有静态的代码块,不会直接运行
二.开始使用,第一步我们肯定是初始化方法,先从默认的构造方法开始学习
public HashMap() { this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted } 1.AbstractMap父类,构造方法也没干事不谈 2.只是赋值loadFactor 0.75f 没干别的事 3.static final float DEFAULT_LOAD_FACTOR = 0.75f; 4.loadFactor属性 作用先放着后面用到再看 5.没干别的事了
三.一般我们的使用第二步就是put了
先看常用的put键值对,这个学完了,那么其他的put方法就没什么问题了,比如putAll、putIfAbsent、putMapEntries
同时put弄明白了 取值就是一个反向就简单了
public V put(K key, V value) { return putVal(hash(key), key, value, false, true); }
1.先对key进行hash计算,学一下
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
1.1 看出key是可以空的 hash为0
1.2 (h = key.hashCode()) ^ (h >>> 16) 第一步取key的hashcode值 关于更底层的hashcode是什么 有兴趣再看
h ^ (h >>> 16) 第二步 高位参与运算
这个hash值的重要性就不说了,这里这么干是出于性能考虑,底层的移位和异或运算肯定比加减乘除取模等效率好
hashcode是32位的,无符号右移16位,那生成的就是16位0加原高位的16位值, 就是对半了,异或计算也就变成了高16位和低16位进行异或,原高16位不变。这么干主要用于当hashmap 数组比较小的时候所有bit都参与运算了,防止hash冲突太大,
所谓hash冲突是指不同的key计算出的hash是一样的,比如a和97,这个肯定是存在的没毛病
2.putVal
/** * Implements Map.put and related methods * * @param hash hash for key * @param key the key * @param value the value to put * @param onlyIfAbsent if true, don't change existing value 相同key是不是覆盖值 * @param evict if false, the table is in creation mode. 在hashmap中没用 * @return previous value, or null if none */ final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; 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); if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict); return null; } 2.1 执行顺序 第一句 Node<K,V>[] tab; Node<K,V> p; int n, i; 申明变量 Node是啥,学习一下: static class Node<K,V> implements Map.Entry<K,V> { 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; } public final int hashCode() { return Objects.hashCode(key) ^ Objects.hashCode(value); } public final V setValue(V newValue) { V oldValue = value; value = newValue; return oldValue; } 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; } } 是内部的一个静态类,看看就明白了,明显是一个带有3个值,hash、key、value和另一个Node对象引用的HashMap子元素结构,即我们装的每个键值对就用一个Node对象存放 第二句 if ((tab = table) == null || (n = tab.length) == 0) 这句 tab = table赋值,table现在是null的,so n = tab.length不运行了 运行这个if的代码块 第三句 n = (tab = resize()).length; 从下面的执行知道 n=16 调用resize(),返回Node数组,这个resize是一个非常重要的方法,我们就依现在的对象状态去看这个方法,不带入其他状态,认真研究学习下 final Node<K,V>[] resize() { Node<K,V>[] oldTab = table; int oldCap = (oldTab == null) ? 0 : oldTab.length; int oldThr = threshold; int newCap, newThr = 0; if (oldCap > 0) { if (oldCap >= MAXIMUM_CAPACITY) { threshold = Integer.MAX_VALUE; return oldTab; } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) newThr = oldThr << 1; // double threshold } else if (oldThr > 0) // initial capacity was placed in threshold newCap = oldThr; else { // zero initial threshold signifies using defaults newCap = DEFAULT_INITIAL_CAPACITY; newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); } if (newThr == 0) { float ft = (float)newCap * loadFactor; newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); } 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; 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 { // preserve order 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; } resize 1.Node<K,V>[] oldTab = table; 在上面知道table是null的,so oldTab也是null 2.int oldCap = (oldTab == null) ? 0 : oldTab.length; oldCap=0 3.int oldThr = threshold; threshold我们没赋值过,int初始0 , oldThr=threshold=0 4.int newCap, newThr = 0; 不谈 5.if (oldCap > 0) { oldCap=0 if不运行 6.else if (oldThr > 0) oldThr=0 if也不运行 7.else { newCap = DEFAULT_INITIAL_CAPACITY; DEFAULT_INITIAL_CAPACITY静态成员变量,初始 static final int DEFAULT_INITIAL_CAPACITY = 1 << 4 so newCap=16 newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY); static final float DEFAULT_LOAD_FACTOR = 0.75f; 0.75*16=12 newThr=12 } 8. if (newThr == 0) { newThr=12 if不运行 9. threshold = newThr; threshold = newThr=12 10. Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap] 申明一个16个大小的Node数组 11. table = newTab; 看出来了吧,table是成员变量,也就表明,HashMap初始数据结构是一个16的Node数组 12. if (oldTab != null) { oldTab是1中赋值的null,if不运行 13. return newTab; 返回16大小的node数组 总结,这一波调用是初次调用其实没干别的事,就是定义了基本的数据结构是16个Node数组,但是这个方法不简单,因为一些if没走 第四句 if ((p = tab[i = (n - 1) & hash]) == null) n=16 15&hash 结果肯定是0-15,这里就看出,这是在计算一个key应该在整个数据结构16的数组中的索引了,并赋值给i变量,后面不管整体结构n变多大,这种计算key所在的索引是非常棒的设计。 现在的状态是初始的 肯定是null的吧 if运行 第五句 tab[i] = newNode(hash, key, value, null); new一个节点Node,放在数组里,i是第四句计算的索引 第六句 else { 不运行 第七句 ++modCount; transient int modCount; 根据注释可以看出,这个是记录数据结构变动次数的,put值肯定是变了的 第八句 if (++size > threshold) size=1 threshold在调用resize时赋值12 if不运行 第九句 afterNodeInsertion(evict); 没干事 第十句 return null; 不谈
3.putVal 再回头详走,第一遍干了很多初始化的事有些东西还没研究到
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) { Node<K,V>[] tab; Node<K,V> p; int n, i; if ((tab = table) == null || (n = tab.length) == 0) n = (tab = resize()).length; if ((p = tab[i = (n - 1) & hash]) == null) tab[i] = newNode(hash, key, value, null); else { Node<K,V> e; K k; 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); if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; } if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k)))) break; p = e; } } if (e != null) { // existing mapping for key V oldValue = e.value; if (!onlyIfAbsent || oldValue == null) e.value = value; afterNodeAccess(e); return oldValue; } } ++modCount; if (++size > threshold) resize(); afterNodeInsertion(evict); return null; } 第一句 Node<K,V>[] tab; Node<K,V> p; int n, i; 申明变量不谈 第二句 if ((tab = table) == null || (n = tab.length) == 0) 这句 tab = table赋值,table现在是16数组 n=16 if不运行 第三句 if ((p = tab[i = (n - 1) & hash]) == null) 再看就知道了判断当前存的key计算出的索引位置是不是已经存过值了 没存过就新Node存 和上面一遍一样 我们当已经有值了 有值其实就意味着发生hash冲突了 比如key分别是a和97 hashCode都是97 冲突 因此这次我们主要看下一个else里面HashMap是怎么处理冲突的 第四句 else中内容 即冲突处理 p是冲突时数组该索引位置的元素 1. p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))) 判断新元素hash和key是不是都和p相同,相同表示存了一样的key 直接赋值给e 2. p instanceof TreeNode(红黑树,具体的红黑树算法这里就不详细写了,有兴趣可以去学习) 怎么猛然来个红黑树,再3里说 判断原来元素是不是 TreeNode 类型 TreeNode一样是静态内部类,再看看就是红黑树的节点,因此这个地方用到了红黑树 putTreeVal 向红黑树中添加元素 内部实现,存在相同key就返回赋值给e 不存在就添加并返回null 源码就是红黑树算法 3.key不同也不是红黑树 if ((e = p.next) == null) { p.next = newNode(hash, key, value, null); } 先不看再里面的那个if,这个一看就知道了吧,明显的链表啊,而且数据里的这个元素是链表头 整个循环,明显是在从头开始遍历链表,找到相同key或链表找完了新元素挂链表最后 但在其中还有这么个if if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st treeifyBin(tab, hash); break; 这是在链表找完了,且新元素已经挂在链表最后了有的一个判断 判断循环次数,其实就是链表长度,长度超过TREEIFY_THRESHOLD 默认8则运行treeifyBin(tab, hash); 就是这个方法把链表变成红黑树了,具体方法源码不谈了,学红黑树就可以了 最后判断e是不是空,上面的冲突方案看出e不是空就是表示有相同的key进行value覆盖就可以,e空就是无相同key且完成了数据挂载 总结这次再走一遍putVal就是为了学习HashMap的冲突处理方案,也看出内存结构是数组、链表、红黑树组成的,红黑树是java8新引进,是基于性能的考虑,在冲突大时,红黑树算法会比链表综合表现更好
4.resize 再详走 putVal最后一段size>threshold threshold初始12 ++size元素数量肯定会有超12个的时候,这里也就看出了threshold代表HashMap的容量,到上限就要扩容了,默认现在16数组,12元素上限
1.Node<K,V>[] oldTab = table; 16大小 2.int oldCap = (oldTab == null) ? 0 : oldTab.length; oldCap=16 3.int oldThr = threshold; 12 4.int newCap, newThr = 0; 不谈 5.if (oldCap > 0) { oldCap=16运行 oldCap是整体结构数组大小 if (oldCap >= MAXIMUM_CAPACITY) { 判断数组大小是不是已经到上限1<<30 threshold = Integer.MAX_VALUE; 到达上线 threshold 赋值最大值 然后返回 表示之后就不再干别的事了,随便存,随便hash冲突去,就这么大,无限增加红黑树节点了 return oldTab; } else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY) 赋值newCap为2倍数组大小,判断如果扩充2倍有没到上限,且不扩充时容量是否大于默认的16 newThr = oldThr << 1; // double threshold 满足则赋值 容量改为24 } 这段看出到threshold容量了就进行2倍扩容 6.if (newThr == 0) { 如果运行该if 0 表示5步中扩容2倍到上限或原数组大小小于16 float ft = (float)newCap * loadFactor; newCap现在是2倍原大小的*0.75 2倍数组大小时的容量 newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ? (int)ft : Integer.MAX_VALUE); 判断2倍数组大小和2倍后的容量是不是都小于最高值,是则赋值新容量,不是就用整形最大值 } 7. threshold = newThr; 把5 6两步算出的新容量赋值给HashMap 也说明要扩容了 8. Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap]; 和后面的循环主要就是把原数组中的元素,一个一个添加到新数组中,转移的一个过程 总结,这一波调用是了解HashMap的扩容方式,看下来就是2倍扩容直到上限
5.总结,到这put就比较详细了,也知道了基本结构是数组、链表、红黑树,链表到8个时转换成红黑树
同时每次进行2倍扩容和数据转移,扩容是用新结构的那显然减少扩容次数会有更好的性能
那就要求每次声明HashMap时最好是指定大小的
三、一些其他我们需要知道的
1.指定大小的初始化
public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); } 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; this.threshold = tableSizeFor(initialCapacity); } 第一个常用,第二个建议是不用,不去动0.75的这个容量比例,当然不绝对 这里tableSizeFor是一个很神奇的算法,我非常佩服的一个算法 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且最小2的幂 比如cap=1 结果 2 0次方 1 cap=2 2 cap=3 4 cap=9 16 分析下等于9 cap - 1 第一步结果8 00000000000000000000000000001000 8 00000000000000000000000000000100 右移1位 00000000000000000000000000001100 或运算 结果 00000000000000000000000000000011 右移2位 00000000000000000000000000001111 或运算 结果 00000000000000000000000000001111 右移 4 8 16没用全是0结果还是这个15 最终 +1 16 分析下等于大点 12345678 00000000101111000110000101001110 12345678 00000000101111000110000101001101 -1结果 12345677 00000000010111100011000010100110 右移1位 00000000111111100111000111101111 或运算 结果 00000000001111111001110001111011 右移2位 00000000111111111111110111111111 差不多了在移0就没了都是1了,+1不是肯定是2的倍数了 再说开始-1原因这是为了防止,cap已经是2的幂。 如果cap已经是2的幂, 又没有执行这个减1操作,则执行完后面的几条无符号右移操作之后,返回的capacity将是这个cap的2倍。如果不懂,要看完后面的几个无符号右移之后再回来看看
2.HashMap数组结构为什么用2的倍数
高速的索引计算,使用HashMap肯定是冲突越少越好,就要求分部均匀,最好的用取模 h % length,但是近一步如果用2的幂h & (length - 1) == h % length 是等价的,效率缺差却别非常大
综合衡量用空间换了时间,且是值得的
3.线程安全问题
线程不安全,就put来看全程没考虑线程问题,肯定不安全,现在随便并发一下resize会混乱吧,put链表,红黑树挂载基本都会出问题
3、ConcrrentHashMap 的实现原理
众所周知,哈希表是中非常高效,复杂度为O(1)的数据结构,在Java开发中,我们最常见到最频繁使用的就是HashMap和HashTable,但是在线程竞争激烈的并发场景中使用都不够合理。
HashMap :先说HashMap,HashMap是线程不安全的,在并发环境下,可能会形成环状链表(扩容时可能造成,具体原因自行百度google或查看源码分析),导致get操作时,cpu空转,所以,在并发环境中使用HashMap是非常危险的。
HashTable : HashTable和HashMap的实现原理几乎一样,差别无非是1.HashTable不允许key和value为null;2.HashTable是线程安全的。但是HashTable线程安全的策略实现代价却太大了,简单粗暴,get/put所有相关操作都是synchronized的,这相当于给整个哈希表加了一把大锁,多线程访问时候,只要有一个线程访问或操作该对象,那其他线程只能阻塞,相当于将所有的操作串行化,在竞争激烈的并发场景中性能就会非常差。
HashTable性能差主要是由于所有操作需要竞争同一把锁,而如果容器中有多把锁,每一把锁锁一段数据,这样在多线程访问时不同段的数据时,就不会存在锁竞争了,这样便可以有效地提高并发效率。这就是ConcurrentHashMap所采用的"分段锁"思想。
ConcurrentHashMap源码分析
ConcurrentHashMap采用了非常精妙的"分段锁"策略,ConcurrentHashMap的主干是个Segment数组。
final Segment<K,V>[] segments;
Segment继承了ReentrantLock,所以它就是一种可重入锁(ReentrantLock)。在ConcurrentHashMap,一个Segment就是一个子哈希表,Segment里维护了一个HashEntry数组,并发环境下,对于不同Segment的数据进行操作是不用考虑锁竞争的。
(就按默认的ConcurrentLeve为16来讲,理论上就允许16个线程并发执行,有木有很酷)
所以,对于同一个Segment的操作才需考虑线程同步,不同的Segment则无需考虑。
Segment类似于HashMap,一个Segment维护着一个HashEntry数组
transient volatile HashEntry<K,V>[] table;
HashEntry是目前我们提到的最小的逻辑处理单元了。一个ConcurrentHashMap维护一个Segment数组,一个Segment维护一个HashEntry数组。
static final class HashEntry<K,V> { final int hash; final K key; volatile V value; volatile HashEntry<K,V> next; //注意此处是使用voliate的 就是为了在get时候 如果有其他线程改变了该值 可以保证可见性 //其他省略 }
我们说Segment类似哈希表,那么一些属性就跟我们之前提到的HashMap差不离,比如负载因子loadFactor,比如阈值threshold等等,看下Segment的构造方法
Segment(float lf, int threshold, HashEntry<K,V>[] tab) { this.loadFactor = lf;//负载因子 this.threshold = threshold;//阈值 this.table = tab;//主干数组即HashEntry数组 }
我们来看下ConcurrentHashMap的构造方法
public ConcurrentHashMap(int initialCapacity,float loadFactor, int concurrencyLevel)
{ if (!(loadFactor > 0) || initialCapacity < 0 || concurrencyLevel <= 0) throw new IllegalArgumentException();
//MAX_SEGMENTS 为1<<16=65536,也就是最大并发数为65536 if (concurrencyLevel > MAX_SEGMENTS) concurrencyLevel = MAX_SEGMENTS;
//2的sshif次方等于ssize,例:ssize=16,sshift=4;ssize=32,sshif=5 int sshift = 0; //ssize 为segments数组长度,根据concurrentLevel计算得出 int ssize = 1; while (ssize < concurrencyLevel) { ++sshift; ssize << = 1; } //segmentShift和segmentMask这两个变量在定位segment时会用到,后面会详细讲 this.segmentShift = 32 - sshift; this.segmentMask = ssize - 1; if (initialCapacity > MAXIMUM_CAPACITY) initialCapacity = MAXIMUM_CAPACITY; //计算cap的大小,即Segment中HashEntry的数组长度,cap也一定为2的n次方. int c = initialCapacity / ssize; if (c * ssize < initialCapacity) ++c; int cap = MIN_SEGMENT_TABLE_CAPACITY; while (cap < c) cap <<= 1; //创建segments数组并初始化第一个Segment,其余的Segment延迟初始化 Segment<K,V> s0 = new Segment<K,V>(loadFactor, (int)(cap * loadFactor), (HashEntry<K,V>[])new HashEntry[cap]); Segment<K,V>[] ss = (Segment<K,V>[])new Segment[ssize]; UNSAFE.putOrderedObject(ss, SBASE, s0); this.segments = ss; }
初始化方法有三个参数,如果用户不指定则会使用默认值,initialCapacity为16,loadFactor为0.75(负载因子,扩容时需要参考),concurrentLevel为16。
从上面的代码可以看出来,Segment数组的大小ssize是由concurrentLevel来决定的,但是却不一定等于concurrentLevel,ssize一定是大于或等于concurrentLevel的最小的2的次幂。比如:默认情况下concurrentLevel是16,则ssize为16;若concurrentLevel为14,ssize为16;若concurrentLevel为17,则ssize为32。为什么Segment的数组大小一定是2的次幂?其实主要是便于通过按位与的散列算法来定位Segment的index。至于更详细的原因,有兴趣的话可以参考我的另一篇文章《HashMap实现原理及源码分析》,其中对于数组长度为什么一定要是2的次幂有较为详细的分析。
接下来,我们来看看put方法
public V put(K key, V value) {
Segment<K,V> s;
//concurrentHashMap不允许key/value为空
if (value == null)
throw new NullPointerException();
//hash函数对key的hashCode重新散列,避免差劲的不合理的hashcode,保证散列均匀
int hash = hash(key);
//返回的hash值无符号右移segmentShift位与段掩码进行位运算,定位segment
int j = (hash >>> segmentShift) & segmentMask;
if ((s = (Segment<K,V>)UNSAFE.getObject // nonvolatile; recheck
(segments, (j << SSHIFT) + SBASE)) == null) // in ensureSegment
s = ensureSegment(j);
return s.put(key, hash, value, false);
}
从源码看出,put的主要逻辑也就两步:1.定位segment并确保定位的Segment已初始化 2.调用Segment的put方法。
关于segmentShift和segmentMask
segmentShift和segmentMask这两个全局变量的主要作用是用来定位Segment,int j =(hash >>> segmentShift) & segmentMask。
segmentMask:段掩码,假如segments数组长度为16,则段掩码为16-1=15;segments长度为32,段掩码为32-1=31。这样得到的所有bit位都为1,可以更好地保证散列的均匀性
segmentShift:2的sshift次方等于ssize,segmentShift=32-sshift。若segments长度为16,segmentShift=32-4=28;若segments长度为32,segmentShift=32-5=27。而计算得出的hash值最大为32位,无符号右移segmentShift,则意味着只保留高几位(其余位是没用的),然后与段掩码segmentMask位运算来定位Segment。
get/put方法
get方法
public V get(Object key) { Segment<K,V> s; HashEntry<K,V>[] tab; int h = hash(key); long u = (((h >>> segmentShift) & segmentMask) << SSHIFT) + SBASE;
//先定位Segment,再定位HashEntry if ((s = (Segment<K,V>)UNSAFE.getObjectVolatile(segments, u)) != null && (tab = s.table) != null) { for (HashEntry<K,V> e = (HashEntry<K,V>) UNSAFE.getObjectVolatile (tab, ((long)(((tab.length - 1) & h)) << TSHIFT) + TBASE); e != null; e = e.next) { K k; if ((k = e.key) == key || (e.hash == h && key.equals(k))) return e.value; } } return null; }
get方法无需加锁,由于其中涉及到的共享变量都使用volatile修饰,volatile可以保证内存可见性,所以不会读取到过期数据。
来看下concurrentHashMap代理到Segment上的put方法,Segment中的put方法是要加锁的。只不过是锁粒度细了而已。
final V put(K key, int hash, V value, boolean onlyIfAbsent) { HashEntry<K,V> node = tryLock() ? null : scanAndLockForPut(key, hash, value);//tryLock不成功时会遍历定位到的HashEnry位置的链表(遍历主要是为了使CPU缓存链表),若找不到,则创建HashEntry。tryLock一定次数后(MAX_SCAN_RETRIES变量决定),则lock。若遍历过程中,由于其他线程的操作导致链表头结点变化,则需要重新遍历。 V oldValue; try { HashEntry<K,V>[] tab = table; int index = (tab.length - 1) & hash;//定位HashEntry,可以看到,这个hash值在定位Segment时和在Segment中定位HashEntry都会用到,只不过定位Segment时只用到高几位。 HashEntry<K,V> first = entryAt(tab, index); for (HashEntry<K,V> e = first;;) { if (e != null) { K k; if ((k = e.key) == key || (e.hash == hash && key.equals(k))) { oldValue = e.value; if (!onlyIfAbsent) { e.value = value; ++modCount; } break; } e = e.next; } else { if (node != null) node.setNext(first); else node = new HashEntry<K,V>(hash, key, value, first); int c = count + 1;
//若c超出阈值threshold,需要扩容并rehash。扩容后的容量是当前容量的2倍。这样可以最大程度避免之前散列好的entry重新散列,具体在另一篇文章中有详细分析,不赘述。扩容并rehash的这个过程是比较消耗资源的。 if (c > threshold && tab.length < MAXIMUM_CAPACITY) rehash(node); else setEntryAt(tab, index, node); ++modCount; count = c; oldValue = null; break; } } } finally { unlock(); } return oldValue; }
ConcurrentHashMap作为一种线程安全且高效的哈希表的解决方案,尤其其中的"分段锁"的方案,相比HashTable的全表锁在性能上的提升非常之大。本文对ConcurrentHashMap的实现原理进行了详细分析,并解读了部分源码,希望能帮助到有需要的童鞋。