简介
这段时间利用空闲时间阅读了一下Java的经典作品Think in Java,尽管还是对书中部分地方有所疑惑,但还是有一种犹如醍醐灌顶的感觉,其中该书中最为被人津津乐道的就是对于集合(书中称之为“Container”,即容器)以及并发编程相关研究最为深入,所以重点读了一下容器章节,对容器的认识又更深了一步,其中HashMap算是简单理解了底层的原理以及设计思想,本文就结合书中内容以及本人的一些想法来简单分析一下HashMap。
一、理解Map
Map这一数据结构在计算机科学中更为通用的名称为“关联数组”(associative array),它的基本思想就是维护对数组中键-值对的关联,因此来实现由键对值的查找。标准java类库中关联数组的顶级接口为Map,并且提供了很多种实现,其中包括:HashMap,TreeMap,LineHashMap,WeakHashMap,ConcurrentHashMap,和IdentityHashMap。虽然它们都实现了Map接口具有同样的行为,但是它们之间的特性却各不相同,这主要表现在查询效率、键值对的保存以及呈现的次序、对象的保存周期、线程的安全性等方面。
二、性能
HashMap是我们最为常用的Map实现,它是的特点主要体现在性能方面,而且性能也是Map的最为重要的问题之一,当在get()中使用线性搜索的时候,执行效率会特别的地下,而这里也就是HashMap对查找性能提高的地方。HashMap使用了一个特殊的值作为关联数组的键,用于取代对键的缓慢搜索,这个值就是散列码,散列码是相对唯一的来代表对象的int值,它是根据对象的某些信息进行换算来生成的。
Java中所有类都直接或间接的继承Object类,其中就包括继承来的HashCode()方法,用以生成对象的相对唯一的Hash值,在计算机科学中这种方法一般被称之为散列函数。HashMap就是使用对象的HashCode()方法来进行快速查找的。可以试想一下,我们如果是java类库的设计者我们会怎么利用对象的Hash值来提升查找的性能?
三、猜想
由于对象的Hash值都是int整型值,所以其中有一种解决方案就是我们可以对插入的键值对按照键的散列码进行排序,然后检索时使用二分法进行检索,并且在Collections中有一个binarySearch()方法提供了二分法的查找实现,但是这种实现在容器内容比较多的时候明显性能会大大折扣,不仅对对象位置的查找很消耗性能包括如果出现散列冲突的情况则会出现又一次的长时间线性查询,并且由于散列冲突,无法再使用二分法查找,性能问题再一次加重,这种实现的弊端显而易见。
现在重新审视问题:对于存进容器里的对象我们可以根据对象的HashCode()方法来获取代表该对象的相对唯一的int值,如何根据对象的唯一值来获取对象在线性数据结构中的位置?
这个时候我们可以参考java容器类库中的其他的线性数据结构的实现,java标准类库中标准的线性数据结构的实现有List和Set的子类的多种实现,其中对于查询性能最好的莫过于ArrayList,这也是我们最常用的一个。
我们可以参考一下ArrayList的实现,打开ArrayList的源码就会发现其本质就是一个数组,ArrayList之所以查询性能高也就是因为数组的查询以及随机访问的高性能特性。
而作为java容器类的作者自然会考虑到这些,所以答案就是使用数组来保证查询的高性能,HashMap的get()其本质就可以看作是对数组的一次随机访问。
四、原理
HashMap就是使用数组来保存键的信息(注意,这里是键的信息,并不是键本身),但是因为数组本身不能调整容量,因此就会有一个问题:我们希望在Map中存放数量不确定的值,但是如果容器的数量被数组的容量被限制,该怎么办呢?
答案就是:数组中不保存键本身,而是通过键生成一个数字,把这个数字作为数组的下标,而这个数字就是对象的散列码,由定义在Object中、且可能由我们自己定义的类被覆盖的HashCode()方法生成。
由于不同的键可以生成相同的散列码也就是相同的下标,也就是说有可能会有冲突,这表示可能一个下标可能需要存放多个值,也就是一个键对应多个值,尽管有些键值对的键并不相同,但是因为他们的散列码相同它们就被看做相同的键,那么我们这里就需要一个类似于二维数组的数据结构,如果多个键的散列码相同则生成的下标也一定相同,那么就把它们归到一个下标里(如下图)。这样做同同时也解决了一个问题,由于可以存在下标相同的情况,那么每个键都可以在数组中有自己的位置,数组的大小就不再是问题了。
于是查询一个值的过程就是首先计算散列码,然后使用散列码查询数组。如果能保证生成的散列码绝对没有冲突(如果值的数量是固定的那就有可能),那就有了一个完美散列函数(perfect hashing function)(完美散列函数在EnumSet和EnumMap中得到了实现,因为Enum定义里数量固定的实例),但是这种情况只是特例。通常,冲突由外部链接来维护:数组不直接保存值,而是保存值的list。然后再对list中的值使用equals()方法进行线性查找,这部分的查询效率不会很高,但是,如果散列函数的实现比较好的话,数组的每一个位置元素都是均匀分布的,因此每次都会快速的跳到数组的某个位置进行线性查找。只会对很少的值进行比较。这便是HashMap会很快的原因。
五、实现
明白原理以后我们就可以自己来试着简单实现一个自己的散列Map了,下面这个例子是根据Think in Java中17.9.2中的demo的实现
public class SimpleHashMap<K, V> extends AbstractMap<K, V> {
// Choose a prime number for the hash table
// size, to achieve a uniform distribution:
// 选择素数作为哈希表的大小,实现均匀分布:
static final int SIZE = 997;
// You can’t have a physical array of generics,
// but you can upcast to one:
// 你不能有一个泛型的物理数组,但你可以向上转换为一个:
/**
* 这个就是我们用来存放键值对的容器,键值对被封装为一个内部类MapEntry
* 这里采用了LinkedList这种链表来实现
* java标准类库中的HashMap使用的实现了一个内部类Entry数组,也就是下方的MapEntry内部类实现的那个接口
* 这里直接对数组的大小进行初始化
*/
@SuppressWarnings("unchecked")
LinkedList<MapEntry<K, V>>[] buckets =
new LinkedList[SIZE];
public V put(K key, V value) {
V oldValue = null;
/**
* 直接调用所传入键的hashCode()方法生成散列码,然后取绝对值并对数组的大小进行取余获得下标
* 这样做可以避免出现下标超出数组的大小
* 这样直接取余是最简单的实现,弊端也很大,在HashMap中有优化过的极为优秀的实现
*/
int index = Math.abs(key.hashCode()) % SIZE;
// 查看该下标位置是否为空,为空则进行初始化
if (buckets[index] == null)
buckets[index] = new LinkedList<MapEntry<K, V>>();
LinkedList<MapEntry<K, V>> bucket = buckets[index];
// 把需要放入的键值对封装为一个内部类MapEntry对象中
MapEntry<K, V> pair = new MapEntry<K, V>(key, value);
// 遍历该下标位置所有的MapEntry对象,查看是否存在持有对应键的MapEntry对象
boolean found = false;
ListIterator<MapEntry<K, V>> it = bucket.listIterator();
while (it.hasNext()) {
/**
* 迭代取出每一个MapEntry对象并直接对值使用equals()方法进行比较
* 这也就是为什么Map的键必须要重写HashCode()和equals()方法的原因
*/
MapEntry<K, V> iPair = it.next();
if (iPair.getKey().equals(key)) {
oldValue = iPair.getValue();
it.set(pair); // 对新放入的值进行更新
found = true;
break;
}
}
// 如果没有该键,则直接放入刚才声明的MapEntry对象
if (!found)
buckets[index].add(pair);
return oldValue;
}
public V get(Object key) {
int index = Math.abs(key.hashCode()) % SIZE;// 计算下标
if (buckets[index] == null) return null;// 如果为空则直接返回null
// 遍历MapEntry的键和key使用equals()进行比较,找到相同的直接返回
for (MapEntry<K, V> iPair : buckets[index])
if (iPair.getKey().equals(key))
return iPair.getValue();
return null;
}
// 继承AbstractMap必须实现entrySet()方法,返回所有的Entry对象,这里通过协变返回类型返回的是MapEntry列表
public Set<Entry<K, V>> entrySet() {
Set<Map.Entry<K, V>> set = new HashSet<Entry<K, V>>();
for (LinkedList<MapEntry<K, V>> bucket : buckets) {
if (bucket == null) continue;
for (MapEntry<K, V> mpair : bucket)
set.add(mpair);
}
return set;
}
/**
* 该类实现了Map接口的内部类Entry,主要就是对存放键值对的封装
*
* @param <K>
* @param <V>
*/
static class MapEntry<K, V> implements Map.Entry<K, V> {
private K key;
private V value;
public MapEntry(K key, V value) {
this.key = key;
this.value = value;
}
public K getKey() {
return key;
}
public V getValue() {
return value;
}
public V setValue(V v) {
V result = value;
value = v;
return result;
}
public int hashCode() {
return (key == null ? 0 : key.hashCode()) ^
(value == null ? 0 : value.hashCode());
}
// 重写equals()
public boolean equals(Object o) {
if (!(o instanceof MapEntry)) return false;
MapEntry me = (MapEntry) o;
return
(key == null ?
me.getKey() == null : key.equals(me.getKey())) &&
(value == null ?
me.getValue() == null : value.equals(me.getValue()));
}
public String toString() {
return key + "=" + value;
}
}
public static void main(String[] args) {
SimpleHashMap<String, String> m =
new SimpleHashMap<String, String>();
System.out.println(m);
m.put("hello", "world");
m.put("key1", "value1");
System.out.println(m);
System.out.println(m.get("hello"));
System.out.println(m.entrySet());
for (Entry<String, String> entry : m.entrySet()) {
System.out.println("键: " + entry.getKey() + ", 值: " + entry.getValue());
}
}
}
/* output:
{}
{key1=value1, hello=world}
world
[key1=value1, hello=world]
键: key1, 值: value1
键: hello, 值: world
*/
值得一提的是,但是后来经过事实证明质数并不是最理想的数字,查看HashMap源码可以看到目前都是以2的整次幂作为容量,对于现代的处理器来说除法和取余是最慢的操作,使用2的整数次方作为数组的长度可以用掩码代替除法因为get()是使用做多的操作,求余又是开销最大的部分,使用2的整数次方可以消除此开销在HashMap源码中有对操作调过优的实现。
下一篇文章试着简单分析一下HashMap的源码,并和这个小demo做对比,就可以看出HashMap有很多优秀的实现
参考资料:
- Think in java.4th Edition (Containers in Depth.Understanding Maps)