Java中常用的数据结构封装在java.util包下的一些类中,util包中有这样两个接口:
这里面都是日常开发所常用的容器,下面以Java7中HashMap源码为参考,分析HashMap的实现过程。
一、HashMap介绍
Hashmap是基于哈希表的Map接口的实现,以key-value的形式存在。在HashMap中,key-value总是会当做一个整体来处理,系统会根据hash算法来来计算key-value的存储位置,我们可以通过key快速地存、取value。
二、JDK中Hashmap的定义:
publicclass HashMap<K,V>
extends AbstractMap<K,V>
implements Map<K,V>,Cloneable, Serializable
{
...
}
HashMap实现了Map接口,继承AbstractMap。其中Map接口定义了键映射到值的规则,而AbstractMap类提供 Map 接口的骨干实现,以最大限度地减少实现此接口所需的工作,其实AbstractMap类已经实现了Map。
三、HashMap的构造函数
HashMap提供了三个构造函数:
HashMap():构造一个具有默认初始容量 (16) 和默认加载因子 (0.75) 的空 HashMap。
HashMap(int initialCapacity):构造一个带指定初始容量和默认加载因子 (0.75) 的空 HashMap。
HashMap(int initialCapacity, floatloadFactor):构造一个带指定初始容量和加载因子的空HashMap。
这里提到了两个参数:初始容量,加载因子。这两个参数是影响HashMap性能的重要参数,其中容量表示哈希表中桶的数量,初始容量是创建哈希表时的容量,加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度,它衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小。对于使用链表法的散列表来说,查找一个元素的平均时间是O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。系统默认负载因子为0.75,一般情况下我们是无需修改的。
四、HashMap的数据结构
我们知道在Java中最常用的两种结构是数组和模拟指针(引用),几乎所有的数据结构都可以利用这两种来组合实现,HashMap也是如此。实际上HashMap是基于Hash table来实现了,而此Hash Table的数据结构由“数组+链表”来组成。
从图中可以看出HashMap底层实现还是数组,只是数组的每一项都是一条链。其中构造函数中第一个参数initialCapacity就代表了该数组的长度。下面为HashMap构造函数的源码:
public HashMap(intinitialCapacity, floatloadFactor) {
//初始容量不能小于0
if (initialCapacity < 0)
thrownew IllegalArgumentException("Illegal initialcapacity: " + initialCapacity);
//初始容量不能大于最大容量(2^30)
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity= MAXIMUM_CAPACITY;
//负载因子不能小于0
if (loadFactor <= 0 || Float.isNaN(loadFactor))
thrownew IllegalArgumentException("Illegal loadfactor: " + loadFactor);
//算出一个最小的大于initialCapacity的2次幂,HashMap容量为这个2的幂
//initialCapacity默认值是16 2^4
//为什么要这样做下面再探讨
intcapacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
this.loadFactor = loadFactor;
intthreshold = (int)(capacity * loadFactor);
table = new Entry[capacity];
init();
}
从这段代码中可以看出,每次新建一个HashMap时,都会初始化一个table数组。table数组的元素为Entry节点。
Entry定义:
staticclass Entry<K,V>implements Map.Entry<K,V>{
finalK key;
V value;
Entry<K,V> next;
finalinthash;
/**
* Creates new entry.
*/
Entry(inth, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
.......
}
其中Entry为HashMap的内部类,它不仅包含了键key、值value还有下一个节点next,以及hash值,这是非常重要的,Entry才构成了table数组的链表。
五、存储的实现
Hashmap刚创建出来是空的,往hashmap中存值用到put(key,vlaue)方法。
public V put(K key, V value) {
/**
* 当key为null,调用putForNullKey方法,保存null在table第一个位置
* 中,这是HashMap允许为null的原因
*/
if (key == null)
return putForNullKey(value);
//计算key的hash值
inthash = hash(key.hashCode());
//计算key的hash值在table数组中的位置
inti = indexFor(hash, table.length);
//循环链表,找到key的保存位置
for(Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
//判断该条链上是否有hash值相同的(key相同)
//若存在相同,则直接覆盖value,返回旧value
if (e.hash == hash && ((k = e.key) ==key || key.equals(k))) {
VoldValue = e.value;
e.value= value;
e.recordAccess(this);
return oldValue;
}
}
//修改次数增加1
modCount++;
//将key、value添加至i位置处
addEntry(hash, key, value, i);
returnnull;
}
首先判断key是否为null,若为null,则直接调用putForNullKey方法。通过这段源码我们可以清晰看到HashMap保存数据的过程:
若key不为空则先计算key的hash值,然后根据hash值搜索在table数组中的索引位置。
如果table数组在该位置处有元素,则比较是否存在相同的key,若存在则覆盖原来key的value,否则将该元素保存在链头。
若table数组在该处没有元素,则直接保存。
效率问题:
我们知道对于HashMap的table而言,数据分布的越均匀越好(最好每项都只有一个元素,这样就可以直接找到),太紧会导致查询速度慢,太松则浪费空间。
来看一下Key在hashmap的table中怎么实现定位的
int hash = hash(key.hashCode());
inti = indexFor(hash, table.length);
staticintindexFor(inth, intlength) {
returnh & (length-1);
}
拿到hash值后是跟数组长度-1做了与运算操作,这是因为在计算机中做二进制位元算效率最高。没毛病。首先将key的hashcode经过一次散列运算得到一个hash值,得到hash值后调用indexFor方法,拿到的h&(length - 1)就是key的位置。
其次考虑为什么返回的这个值会跟其他数返回来值冲突的情况少。
假设初始长度为默认的16,也就是hash()后的值跟15做与运算,会发现永远是h的低四位在进行与操作,很容易发生hash冲突。
如果返回的h散列的跟均匀些,那么冲突的概率就会变小,所以hash方法应该是会使得hash值的位值在高低位上尽量均匀分布的算法。
通过一个例子来做说明:
两个key,调用Object的hashCode方法后值分别为:32,64,然后entry数组大小依旧为:16,不调hash()方法直接返回hashCode,即在调用indexFor时参数分别为[32,15],[64,15],这时分别对它们调用indexFor方法:
32计算过程:
100000 & 1111 => 000000 =>0
64计算过程如下:
1000000 & 1111 => 000000 =>0
可以看到indexFor在Entry数组大小不是很大时只会对低位进行与运算操作,高位值不参与运算,很容易发生hash冲突。
现在让32和64经过hash()算法:
原始h为32的二进制:100000
h>>>20:
000000
h>>>12:
000000
接着运算 h^(h>>>20)^(h>>>12):
结果:100000
然后运算: h^(h>>>7)^(h>>>4),
过程如下:
h>>>7: 000000
h>>>4: 000010
最后运算: h^(h>>>7)^(h>>>4),
结果: 100010,即十进制34
再调用indexFor方法:
100010 & 1111 => 2,即存放在Entry数组下标2的位置上
同样,64进过hash运算结果为:1000100,十进制值为68
再调用indexfor方法:
1000100 & 1111 => 4,即存放在Entry数组下标4的位置上
由此可见hash()的作用。
除此之外我们再假设h得到的为5、6、7,length为16(2^n)和15
length=16 | |||
h | length-1 | h&(length - 1) | |
5 | 15 | 0101&1111=00101 | 5 |
6 | 15 | 0110&1111=00110 | 6 |
7 | 15 | 0111&1111=00111 | 7 |
length=15 | |||
5 | 14 | 0101&1110=00101 | 5 |
6 | 14 | 0110&1110=00110 | 6 |
7 | 14 | 0111&1110=00110 | 6 |
这里做了个对比,因为在构造器有这样一步:
//算出一个最小的大于initialCapacity的2次幂,HashMap容量为2的幂
intcapacity = 1;
while (capacity < initialCapacity)
capacity<<= 1;
它的意义就是当length =2^n时,不同的hash值发生碰撞的概率比较小,这样就会使得数据在table数组中分布较均匀,查询速度相应较快。
另外,找到在table中的位置后,会迭代这个Entry,利用key.equals()方法判断是否存在hash值(key)冲突,如果冲突,用新value替换旧value,这里并没有处理key,这就解释了HashMap中没有两个相同的key。
这里也强调重写equals方法后要重写hashCode方法。
六、读取的实现
相对于HashMap的存而言,取就显得比较简单了。通过key的hash值找到在table数组中的索引处的Entry,然后返回该key对应的value即可。
public V get(Object key) {
// 若为null,调用getForNullKey方法返回相对应的value
if (key == null)
return getForNullKey();
// 根据该 key 的 hashCode 值计算它的 hash 码
inthash = hash(key.hashCode());
// 取出 table 数组中指定索引处的值
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next){
Object k;
//若搜索的key与查找的key相同,则返回相对应的value
if (e.hash== hash&& ((k = e.key) == key || key.equals(k)))
returne.value;
}
returnnull;
}
七、HashMap在Java1.7与1.8中的区别
在JDK1.7之前
使用一个Entry数组来存储数据,用hash()和indexFor(int h, intlength)来决定key会被放到数组里的位置,如果hashcode相同,或者hashcode取模后的结果相同(hash collision),那么这些key会被定位到Entry数组的同一个格子里,这些key会形成一个链表。
在hashcode特别差的情况下,比方说所有key的hashcode都相同,这个链表可能会很长,那么put/get操作都可能需要遍历这个链表,也就是说时间复杂度在最差情况下会退化到O(n)。
在JDK1.8中
使用一个Node数组来存储数据,但这个Node可能是链表结构,也可能是红黑树结构。如果插入的key的hashcode相同,那么这些key也会被定位到Node数组的同一个格子里。
如果同一个格子里的key不超过8个,使用链表结构存储。
如果超过了8个,那么会调用treeifyBin函数,将链表转换为红黑树。
那么即使hashcode完全相同,由于红黑树的特点,查找某个特定元素,也只需要O(log n)的开销,也就是说put/get的操作的时间复杂度最差只有O(log n)
可参考:http://blog.csdn.net/xs521860/article/details/59484291详解