本文分析HashMap的实现原理。html
HashMap是一个散列表(也叫哈希表),用来存储键值对(key-value)映射。散列表是一种数组和链表的结合体,结构图以下:算法
简单来讲散列表就是一个数组(上图纵向),数组的每一个元素是一个链表(上图横向),相似二维数组。链表的每一个节点就是咱们存储的key-value数据(源码中将key和value封装成Entry对象做为链表的节点)。数组
对于散列表,不论是存值仍是取值,都须要经过Key来定位散列表中的一个具体的位置(即某个链表的某个节点),计算这个位置的方法就是哈希算法。数据结构
大概过程是这样的:code
例如一个key-value对要存到上图的散列表里,假设key的哈希值是17,由图可知(纵向)数组长度是16,那么17对16取余结果是1,数组中索引1位置的链表是 1->337->353 ,因此这个key-value对存储到这个链表里面(插到头仍是尾可能不一样Java版本不同)。若是是取值,就遍历这个链表,因为这个链表每一个节点的key的哈希值都同样,因此根据equals方法来肯定具体是哪一个节点。htm
经过上面的哈希算法,能够有以下结论:对象
哈希算法主要分两步操做:1.经过哈希值定位一个链表; 2.遍历链表,经过equals方法找到具体节点。为了使哈希算法效率最高,应该尽可能让数据在哈希表中均匀分布,由于那样能够避免出现过长的链表,也就下降了遍历链表的代价。
如何保证均匀分布?前面的哈希算法说到,经过取余操做将Key的哈希值转换成数组下标,这样能够认为是均匀的。可是,源码中并无直接用%操做符取余,而是使用了更高效的与运算,源码以下:blog
/** * Returns index for hash code h. */ static int indexFor(int h, int length) { // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2"; return h & (length-1); }
这样就多了一些限制,由于只有当length是2的整数次幂的时候,h & (length-1) = h % length才成立。固然,若是length不是2的整数次幂,h & (length-1)的结果也必定比length小,将Key转换成数组下标也没什么问题,可是,这样会致使元素分布不均匀严重影响散列表的访问效率。看下面的一个示例代码:排序
解释一下图中的代码,随机生成一组Key,而后利用与运算,把key所有转换成一个数组容量的索引,这样就获得一组索引值,这组索引中不相同的值越多,说明分布越均匀,输出结果的result就是 “这组索引中不相同的值的数量”。
从运行结果来看,容量是64的时候相比于其余几个容量大小,分布是最均匀的。容量是65的时候,每次结果都是2,缘由很简单,当容量是65的时候,下标=h&64,64的二进制是1000000,很明显,与它进行与运算的结果只有两种状况,0和64,也就是说,若是HashMap大小被指定成65,对于任意Key,只会存储到散列表数组的第0个或第64个链表中,浪费了63个空间,同时也致使0和64两个链表过长,取值的时候遍历链表的代价很高。容量66和67的结果是4同理。若是容量是64,那么下标=h&63,63的二进制是111111,每一位都是1,好处就是对于任意Key,与63作与运算的结果多是1-63的任意数,不少Key的话天然就能分布均匀。
经过这个示例代码的分析就能够找到一个规律了,容量length=2^n 是分布最均匀,由于length-1的二进制每一位都是1;相反的length=2^n+1是分布最不均匀的,由于length-1的二进制中的1数量最少。索引
结论:HashMap大小是2的整数次幂的时候效率最高,由于这个时候元素在散列表中的分布最均匀。
从上面的分析来看,使用与运算虽然效率高了,可是增长了使用限制,若是用%取余的作法,那么对于任何大小的容量都能作到均匀分布,能够把图中代码int a = keySet[j] & (c - 1);
改为 int a = keySet[j] % c;
试一下。
经过上面的分析,容量是2的整数次幂的时候效率最高,那么很容易想到,若是随着数据量的增加,HashMap须要扩容的时候是2倍扩容,区别于ArrayList的1.5倍扩容。
那么何时扩容呢?首先说明一下,咱们所说的HashMap的容量是指散列表中数组的大小,这个大小不能决定HashMap能存多少数据,由于只要链表足够长,存多少数据都没问题。可是,数据量很大的时候,若是数组过小,就会致使链表很长,get元素的效率就会下降,因此咱们应该在适当的时候扩容。源码默认的作法是,当数据量达到容量的75%的时候扩容,这个值称为负载因子,75%应该是大量实验后统计获得的最优值,没有特殊状况不要经过构造方法指定为其余值。
扩容是有代价了,会致使全部已存的数据从新计算位置,因此,和ArrayList同样,当知道大概的数据量的时候,能够指定HashMap的大小尽可能避免扩容,指定大小要注意75%这个负载因子,好比数据量是63个的话,HashMap的大小应该是128而不是64。
对于容量的计算,源码已经封装好了一个方法
/** * Returns a power of two size for the given target capacity. */ 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; }
此方法在HashMap的构造方法中被调用,因此指定容量的时候无需本身计算,好比数据量是63,直接new HashMap<>(63)
便可。
前面提到一点,散列表中的链表的节点是Entry对象,经过Entry对象能够获得Key和Value。HashMap的遍历方法有不少,大概能够分为3种,分别是经过map.entrySet()、map.keySet()、map.values()三种方式遍历。比较效率的话,map.values()方式没法获得key,这里不考虑。比较map.entrySet()和map.keySet()的话,结合散列表的结构特色,很明显map.entrySet()直接遍历Entry集合(全部链表节点)取出Key和Value便可(一次循环),map.keySet()遍历的是Key,获得Key以后在经过Key去遍历相应的链表找到具体的节点(多个循环),因此前者效率高。
对于LinkedHashMap的理解,我以为一张图就够了:
在散列表的基础上加上了双向循环链表(图中黄色箭头和绿色箭头),因此能够拆分红一个散列表和一个双向链表,双向链表以下:
而后使用散列表操做数据,使用双向循环链表维护顺序,就实现了LinkedHashMap。
LinkedHashMap有一个属性能够设置两种排序方式:
private final boolean accessOrder;
false表示插入顺序,true表示最近最少使用次序,后者就是LruCatch的实现原理。
LinkedHashMap和LruCatch的具体实现细节这里就不分析了。