数据存储 中一般都是用 数组 或 链接来实现。
单纯的数组存储查询的时间复杂度的确很低,但空间复杂度会要求很高。
单纯的链接,通过链接的方式,空间复杂度非常低了,但时间复杂度变的非常高。
两者结合的哈希表,完美的结合了彼此的优点,的确是数据存储的最佳选择。这篇文章就以Java中常用的HashMap为例,通过底层的代码,深入的了解它的原理以及存储的结构方式。
用过HashMap都知道,它最重要的就两个方法get和put。
我准备通过简单介绍HashMap的结构,Entry对象,在接着put和get方法,在这些都全部了解的情况下,在研究下哈希冲突,一步一步的了解HashMap。
1. HashMap的结构
HashMap的结构,我借鉴了别人的图,做个简单介绍。一个Entry(用于存放key,value对,后面介绍)和指下下一个节点的指针组成一个整体,然后通过指针相互链接,实现链接的结构。由这个结构可以看出来,Hash表空间复杂度的确不高,采取的是链表的形式。那么怎么解决时间复杂度呢,请往下看。
谢谢作者:http://blog.youkuaiyun.com/vking_wang/article/details/14166593
解决查询的时间复杂度,解决方案HashMap中这一段源代码:
transient Entry<K,V>[] table;
HashMap 就是一头披着羊皮的狼,哦不对,是披着链表的数组。这就是HashMap的解决方案,采取链表和数组的长处,完美的融合。
2. Entry 对象
上面其实已经涉及到Entry对象了。先上源代码:
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
.....
}
这部分代码是HashMap的内部类Entry的源代码。 key 和 value 值很好理解,next 就是指向下一个对象的指针, hash 存的是对应的hash值。
由此可知,HashMap主类并没有存key和value,都是它的内部类来负责存储的。这也验证了上面的图,Entry + 指向下一个Entry对象的指针(后面单独说)构成HashMap。
3.Put 方法
继续通过源代码的方式学习:
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;
}
看似很长,我们分步骤来看一下:
1) if (key == null) return putForNullKey(value); 很好理解,当key是null时,返回将值存入到一个NullKey的地方。那么这是什么地方呢,继续看源代码
private V putForNullKey(V value) {
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(0, null, value, 0);
return null;
}
原来如此,是将值存入到HashMap中table[0]的位置,也就是这一句addEntry(0, null, value, 0);
如果这个位置已经有值了,怎么处理呢,也就是for (Entry<K,V> e = table[0]; e != null; e = e.next)
V oldValue = e.value;
e.value = value;
直接替换了值。
2) int hash = hash(key); 没什么好说的,取了key的hash值,取值干什么呢,go on
3) int i = indexFor(hash, table.length); 获取table中的indexFor位置,去看电影,找到座位了,找到座位干嘛呢
4) addEntry(hash, key, value, i); 坐下来了。 当然这个是理想状态,万一你去电影厅一看,发现自己的位置上有人怎么办呢
5)for (Entry<K,V> e = table[i]; e != null; e = e.next),Entry<K,V> e = table[i],说明e就是你的位置,但是e != null;别人已经占了你的位置了,
V oldValue = e.value;
e.value = value;
对的,你替换掉座位上的那个人即可。
由此可知:HashMap在put的时候,值都是存在Entry中的,而Entry存在HashMap的一个数组中。且key和value对是排斥的,即有冲突的时候会替换掉过去存的value的。这和接下来的hash冲突有所不同。
4.Get方法
还是继续上面学习的方法论,直接上源代码:
public V get(Object key) {
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
继续解读源代码:
1) if (key == null) return getForNullKey(); 这涉及到getForNullKey()方法,还是直接上源代码
private V getForNullKey() {
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null)
return e.value;
}
return null;
}
一看恍然大悟,当key=null的时候,则从HashMap中Entry数组的第一个对象开始遍历,当e.key 也是null时,返回value。如果找不到还是返回null。 由此可见HashMap的key可以是null值。
2) Entry<K,V> entry = getEntry(key); 这里涉及到 getEntry(key); 继续上源代码:
final Entry<K,V> getEntry(Object key) {
int hash = (key == null) ? 0 : hash(key);
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;
}
有几行,上面已经说过的就不继续说了。 for (Entry<K,V> e = table[indexFor(hash, table.length)]; 由这一行可以知道,根据key的hash值确定了table中数组的位置indexFor(hash, table.length)。
如同你去电影院看电影了,好友通过你的短信码(key的哈希值),找到了你的位置indexFor(hash, table.length)。从这里开始寻找你,结果可想而知。最终返回e 对象。
3) return null == entry ? null : entry.getValue(); 很简单了,直接获取上一步返回的对象e的value值。
5. 哈希冲突
了解了上面的内容后,就比较方便的了解哈希冲突了。Entry对象的源代码中有一个属性next,细心的人会发现很奇怪。Entry只是用来存Key和Value的,next的是什么Entry呢?
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
.....
}
HashMap 中有一行代码 int i = indexFor(hash, table.length); 最终的Entry对象会存到HashMap对象中的table数组中的第i的位置。
Object 对象中有类方法:hashCode
public int hashCode() {
return super.hashCode();
}
如果我将两个对象的hashCode 都重写,然后固定成一个值。那么hashcode就会存在冲突,导致HashMap的table中,对象严重分不均匀。例如数字中存了100个对象,但hashcode都相同,如果在重写equals也相同(e.hash == hash && ((k = e.key) == key || key.equals(k)) 源代码在这里),那么table的某个位置需要存100个对象(实际上是通过next),这无疑会严重影响性能,这就是hash冲突。
怎么解决这个问题呢,方案有多重,这里我就说一个最简单的解决方案,升级JDK到1.8,让这些hash冲突的对象以二叉树的形式保存,解决HashMap偶尔的性能问题。
综上所述:
HashMap中有一个内部类Entry,和一个叫table的数组。前者存着key和value值以及指向下一个Entry的对象的指针;后者就是一个Entry的数组。前者保证了链接的存储结构,满足降低空间复杂度;后者通过数组解决了时间的复杂度。
哈希冲突最佳解决方案是升级JDK到1.8.