前言
上一节我们实现了散列算法并对冲突解决我们使用了开放地址法和链地址法两种方式,本节我们来详细分析源码,看看源码中对于冲突是使用的哪一种方式以及对比我们所实现的,有哪些可以进行改造的地方。
Hashtable源码分析
我们通过在控制台中实例化Hashtable并添加键值对实例代码来分析背后究竟做了哪些操作,如下:
public static void main(String[] args) { Hashtable hashtable = new Hashtable(); hashtable.put(-100, "first"); }
接下来我们来看看在我们初始化Hashtable时,背后做了哪些准备工作呢?
public class Hashtable<K,V> extends Dictionary<K,V> implements Map<K,V>, Cloneable, java.io.Serializable { //存储键值对数据 private transient Entry<?,?>[] table; //存储数据大小 private transient int count; //阈值:(int)(capacity * loadFactor).) private int threshold; //负载因子: 从时间和空间成本折衷考虑默认为0.75。因为较高的值虽然会减少空间开销,但是增加查找元素的时间成本 private float loadFactor; //指定容量和负载因子构造函数 public Hashtable(int initialCapacity, float loadFactor) { if (initialCapacity < 0) throw new IllegalArgumentException("Illegal Capacity: "+ initialCapacity); if (loadFactor <= 0 || Float.isNaN(loadFactor)) throw new IllegalArgumentException("Illegal Load: "+loadFactor); if (initialCapacity==0) initialCapacity = 1; this.loadFactor = loadFactor; table = new Entry<?,?>[initialCapacity]; //默认阈值为8 threshold = (int)Math.min(initialCapacity * loadFactor, MAX_ARRAY_SIZE + 1); } //指定容量构造函数 public Hashtable(int initialCapacity) { this(initialCapacity, 0.75f); } //默认无参构造函数(初始化容量为11,负载因子为0.75f) public Hashtable() { this(11, 0.75f); } private static class Entry<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Entry<K,V> next; protected Entry(int hash, K key, V value, Entry<K,V> next) { this.hash = hash; this.key = key; this.value = value; this.next = next; } } }
Hashtable内部通过Entry数组存储数据,通过Entry结构可看出采用链地址法解决哈希冲突,当初始化Hashtable未指定容量和负载因子时,默认初始化容量为11,负载因子为0.75,阈值为8,若容量小于0则抛出异常,若容量等于0则容量为1且阈值为0,否则阈值以指定容量*0.75计算或者以指定容量*指定负载因子计算为准。
通过如上源代码和变量定义我们很快能够得出如上结论,这点就不必我们再进行过多讨论,接下来我们再来看看当我们如上添加如上键值对数据时,内部是如何做的呢?
public synchronized V put(K key, V value) { if (value == null) { throw new NullPointerException(); } Entry<?,?> tab[] = table; int hash = key.hashCode(); int index = (hash & 0x7FFFFFFF) % tab.length; Entry<K,V> entry = (Entry<K,V>)tab[index]; for(; entry != null ; entry = entry.next) { if ((entry.hash == hash) && entry.key.equals(key)) { V old = entry.value; entry.value = value; return old; } } addEntry(hash, key, value, index); return null; }
我们一步步来分析,首先若添加的值为空则抛出异常,紧接着获取添加键的哈希值,重点来了,如下代码片段的作用是什么呢?
int index = (hash & 0x7FFFFFFF) % tab.length;
因为数组索引不可能为负值,所以这里通过逻辑与操作将键的哈希值转换为正值,也就是本质上是为了保证索引为正值,那么 int index = (hash & 0x7FFFFFFF) % tab.length; 是如何计算的呢?0x7FFFFFFF的二进制就是1111111111111111111111111111111,由于是正数所以符号为0即01111111111111111111111111111111,而对于我们添加的值为-100,则二进制为11111111111111111111111110011100,将二者转换为二进制进行逻辑加操作,最终结果为01111111111111111111111110011100,转换为十进制结果为2147483548,这是我们讲解的原理计算方式,实际上我们通过十进制相减即可,上述0x7FFFFFFF的十进制为2147483647,此时我们直接在此基础上减去(100-1)即99,最终得到的也是2147483548。最后取初始容量11的模结果则索引为为1。如果是键的哈希值为正值那就不存在这个问题,也就是说通过逻辑与操作得到的哈希值就是原值。接下来获取对应索引在数组中的位置,然后进行循环,问题来了为何要循环数组呢?也就是如下代码片段:
for(; entry != null ; entry = entry.next) { if ((entry.hash == hash) && entry.key.equals(key)) { V old = entry.value; entry.value = value; return old; } }
上述是为了解决相同键值将对应的值进行覆盖,还是不能理解?我们在控制台再加上一行如下代码:
public static void main(String[] args) { Hashtable hashtable = new Hashtable(); hashtable.put(-100, "first"); hashtable.put(-100, "second"); }
如上我们添加的键都为-100,通过我们对上述循环源码的分析,此时将如上第一行的值first替换为second,换言之当我们添加相同键时,此时会发生后者的值覆盖前者值的情况,同时我们也可以通过返回值得知,若返回值为空说明没有出现覆盖的情况,否则有返回值,说明存在相同的键且返回被覆盖的值。我们通过如下打印出来Hashtable中数据可得出,这点和C#操作Hashtable不同,若存在相同的键则直接抛出异常。