jdk源码浅读-HashMap
在java语言中使用的最多的数据结构大概右两种,第一种是数组,比如array,arraylist,第二种链表,比如arraylinkedlist,基于数组的数据结构特点是查找速度很快,时间复杂度为 o(1),但是删除的速度比较慢,因为每次删除元素的时候需要把后面的所有的元素都要相应的往前移动一位,最坏的情况删除第一个元素,时间复杂度为o(n)。基于链表实现的数据结构的特点是删除的速度比较快,但是查找的速度比较慢,每次查找数据的时候都需要从链表头部开始往下遍历,链表查找最坏时间是o(n)。hashmap 就整合和了数组和链表的有点而设计出来的,它的查找速度为 o(1) + o(a),a为链表长度,事实上hashmap的hash算法能够很好的避免了在插入数据的碰撞问题,所以链表的长度基本不会很长,所以hashmap的查找速度还是很快的。一般地,我们平衡一种结构的性能是看平均时间复杂度的,在 jdk1.8以前hashmap在最糟糕的情况下查找的时间复杂度为 o(1) +o(n) ,n 为数据的大小。在jdk1.8时sun公司对hashmap进行了优化,hashmap的存储结构由原来的数组+链接的结构改成 数组+链表+红黑树的形式。时间复杂度由o(1) + o(n) 降为 o(1) + o(logn)。下面的源码都是基于jdk1.8的。
hashmap中几个重要的参数:
1、threshold : 数组的大小,默认长度为16,可以在构造函数中指定初始化大小,但是必须是2的n次方,具体原因在下面将会说到。注意:该值是指数组的大小,并不是指hashmap中已经存放了的数据量,存放的数据的大小总是小于等于 threshold * loadfactor。
2、loadfactor: 负载因子,默认值为0.75。当hashmap中存储的数据大于阈值(threshold * loadfactor)时,threshold会进行翻倍,执行resize方法,对原数组中所有的元素进行一次重新hash计算,根据hash计算得出的下表放在新的数组中。负载因子的设计是为了减少在put操作时发生的碰撞,因为当我们put的数据越来越多的时候,数组中空的位置也会越来越少,那么发生碰撞的概率也随之增大,碰撞的次数越多对性能由一定的影响。一般地我们不需要对这个值进行设置,使用默认值就可以了。
3、treeify_threshold:转换红黑树的阈值,默认值为8。即当数组中链表的长度达到这个值之后,链表就是转换成红黑树,以提高性能。
4、untreeify_threshold:红黑树转链表的阈值,默认值为6。
hashmap结构
hashmap是以key-value的形式存储数组中,将数据存在node节点中,每个node节点存储了一个key,对应的value和指向下一个node的指针。hashmap的结构为数组+链表(红黑树),链表为单向链表。结构如下:
或:
hashmap原理:
hashmap在进行put(key,value)操的时候,我们源码
/** * associates the specified value with the specified key in this map. * if the map previously contained a mapping for the key, the old * value is replaced. * * @param key key with which the specified value is to be associated * @param value value to be associated with the specified key * @return the previous value associated with <tt>key</tt>, or * <tt>null</tt> if there was no mapping for <tt>key</tt>. * (a <tt>null</tt> return can also indicate that the map * previously associated <tt>null</tt> with <tt>key</tt>.) */ public v put(k key, v value) { return putval(hash(key), key, value, false, true); }
/** * 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 * @param evict if false, the table is in creation mode. * @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; /** * 通过位与的方式来确定下标位置,判断当前下标位置是否为空,如果为空直接放入到该位置上 * 不为空则通过equals方法来寻找当前位置上面的元素,如果有相同的key,则将覆盖掉,如果没有则将node放置在对应 * 位置上面 */ 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))))//已存在相同的key的数据,将其覆盖 e = p; else if (p instanceof treenode)//当前位置是红黑树,将node节点放到红黑树中 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))))//覆盖相同key的node 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; }
当我们当hashmap中put数据的时候,首先会对传进来的key进行hash计算:
/** * computes key.hashcode() and spreads (xors) higher bits of hash * to lower. because the table uses power-of-two masking, sets of * hashes that vary only in bits above the current mask will * always collide. (among known examples are sets of float keys * holding consecutive whole numbers in small tables.) so we * apply a transform that spreads the impact of higher bits * downward. there is a tradeoff between speed, utility, and * quality of bit-spreading. because many common sets of hashes * are already reasonably distributed (so don't benefit from * spreading), and because we use trees to handle large sets of * collisions in bins, we just xor some shifted bits in the * cheapest possible way to reduce systematic lossage, as well as * to incorporate impact of the highest bits that would otherwise * never be used in index calculations because of table bounds. */ static final int hash(object key) { int h; return (key == null) ? 0 : (h = key.hashcode()) ^ (h >>> 16); }
jdk1.8开始hash的计算比之前的简单一些,就是对key的hashcode的高16位和低16位进行异或运算。这样做的目的是让key的hashcode 的高位也有计算参与运算,这样计算出来的hash值更加均匀,put数据时能够减少碰撞,提供性能。
第二步根据key计算出来的值获取到对应的下标,这里并不是使用取模的方式来确定,因为取模的方式相对于位与运算来说性能更低下。下标的计算公式为:当前数组的长度减一 按位与 hash值,得到下标,比如当前数组长度为 16,hash值:54707624,则计算如下:(注意:位与的运算规则为,当两个数均为1时结果才为1,否则结果为0)
从上面的运算结果,可以得到一个规律,能够参与有效运算的位只有与数组长度减一的位的长度,比如 数组长度为16,那么16-1的二进制为 1111,那么不管key的hash值有多大,最终参与运算的只有后4位,根据位与运算规则,运算结果的最大值为 1111,转换成十进制后即数组的长度减一,最小值为 0000,十进制为0,即结果的范围为 0 ~ size - 1,这个取模的结果是一致的。又因为数组的长度总是2的n次方,对应的二进制 为 1,11,111 ,11111等等,这也是为什么每次扩容时都要扩大至原来的两倍的原因。那么,另外一个问题又来了,为什么一定要时2的n次方呢?其他的值可以吗?下面我们来做一个实验:
public static void main(string[] args) { for (int i = 0; i < 30; i++) { system.out.print((i & 15) + " "); } } 运算结果: 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 0 1 2 3 4 5 6 7 8 9 10 11 12 13
当我们使用 2 的n次方-1来运算时,每个余数都有可能得到
public static void main(string[] args) { for (int i = 0; i < 30; i++) { system.out.print((i & 13) + " "); } } 运算结果: 0 1 0 1 4 5 4 5 8 9 8 9 12 13 12 13 0 1 0 1 4 5 4 5 8 9 8 9 12 13
当我们使用 非2的n次方运算时,看运算结果可以看到,有些值是不可能得到的,这样数组的某些位置就永远为空,不仅造成空间的浪费,同时也会大大的提高碰撞的概率。根据位与运算规则,很容易想到其中的原因,首先将13转成二进制:1101,在位与运算时,那个 0 位永远不参与运算,如上面的结果一样,2,3,6等数值是没有的。当且仅当二进制数字全为1的时候,才有可能所有的位都能计算,得到的结果才会更加均匀。这个很容易理解,想一下就明白了的。
第三步,根据生成的index去数组寻找位置,如果该位置为空直接将node放进去,如果不为空则调用equals方法判断key值是否一致,一致的话就替换成新值,否则寻找下个节点,最终在插入链表的时候会判断当前链表长度是否达到了转换成红黑树的条件(默认链表长度达到8时会转)。
第四步,数据put成功后判断当前存储的数据大小是否超过了 threshold * loadfactor 的值,超过了就会执行resize方法:
/** * 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. * * @return the table */ 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; }
进行扩容的时候将所有的node节点进行hash计算e.hash & (newcap - 1),这样的结果不是在原来的就是在 当前位置加原来threshold长度的位置。至此整个put操作结束。
get(object key)的原理:
弄懂了put操作之后,其实get就很容易理解了,首先根据传入key找到index,然后再对应的位置上获取就行了。
最后,我看了hashmap源码之后,自己也手写了一个hashmap,不同之处在于我没有用到红黑树,而是使用二叉树代替,经测试插入1千万条uuid所需时间都差粗多,都是在二十几秒左右。关于二叉树与红黑树的区别可以自行百度,红黑树最主要解决的问题是在极端的情况下二叉树只有一条路径,时间复杂度位o(n),红黑树为了避免这种情况,每次都会自动调节树的深度,将最坏的情况的时间复杂度降低到o(logn)。
因为完全是手写的,所以可能代码的可读性不是很好,但是基本的功能都能够实现了。如果大家有兴趣的话,可以下载过来看一下,也欢迎大家指出错误或提意见。项目地址: https://github.com/rainple1860/mycollection
上一篇: Java基础10道面试题
下一篇: JBoss EAP 中LOG的配置