Java入门系列之集合Hashtable源码分析

前言

上一节我们实现了散列算法并对冲突解决我们使用了开放地址法和链地址法两种方式,本节我们来详细分析源码,看看源码中对于冲突是使用的哪一种方式以及对比我们所实现的,有哪些可以进行改造的地方。

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不同,若存在相同的键则直接抛出异常。

        Enumeration keys = hashtable.keys();

        while (keys.hasMoreElements()) {

            Object key =  keys.nextElement();

            String values = (String) hashtable.get(key);
            System.out.println(key + "------>" + values);
        }

还没完,我们继续往下分析如下代码,将键值对添加到数组中去:

private void addEntry(int hash, K key, V value, int index) {
        modCount++;
        
        //定义存储数据变量
        Entry<?,?> tab[] = table;
        
        //若数组中元素超过或等于阈值则扩容数组
        if (count >= threshold) {
            rehash();

            tab = table;
            hash = key.hashCode();
            index = (hash & 0x7FFFFFFF) % tab.length;
        }

        //将键值对以及哈希值添加到存储数组中
        Entry<K,V> e = (Entry<K,V>) tab[index];
        tab[index] = new Entry<>(hash, key, value, e);
        count++;
}

在添加数据到存储的数组中去时必然要判断是否已经超过阈值,说到底就是为了扩容哈希表,接下来我们看看具体实现是怎样的呢?

protected void rehash() {

        //获取存储数组当前容量
        int oldCapacity = table.length;
        Entry<?,?>[] oldMap = table;

        // 新容量 = 当前容量*2 + 1
        int newCapacity = (oldCapacity << 1) + 1;
        
        //判断是否新容量是否超过最大数组大小,超过那么最大容量为定义的最大数组大小
        if (newCapacity - MAX_ARRAY_SIZE > 0) {
            if (oldCapacity == MAX_ARRAY_SIZE)
                return;
            newCapacity = MAX_ARRAY_SIZE;
        }
Entry
<?,?>[] newMap = new Entry<?,?>[newCapacity]; modCount++; //重新计算阈值 threshold = (int)Math.min(newCapacity * loadFactor, MAX_ARRAY_SIZE + 1); //扩容后的存储数组 table = newMap; //循环将当前存储的数组数据更新到扩容后的存储数组里 for (int i = oldCapacity ; i-- > 0 ;) { for (Entry<K,V> old = (Entry<K,V>)oldMap[i] ; old != null ; ) { Entry<K,V> e = old; old = old.next; int index = (e.hash & 0x7FFFFFFF) % newCapacity; e.next = (Entry<K,V>)newMap[index]; newMap[index] = e; } } }

如上解释已经非常清晰明了,接下来我们再在控制台添加如下代码: 

public static void main(String[] args) {

        Hashtable hashtable = new Hashtable();

        hashtable.put(-100, "first");

        hashtable.put(-100, "second");

        hashtable.put("Aa", "third");
        hashtable.put("BB", "fourth");

        Enumeration keys = hashtable.keys();

        while (keys.hasMoreElements()) {

            Object key =  keys.nextElement();

            String values = (String) hashtable.get(key);
            System.out.println(key + "------>" + values);
        }
}

当我们添加如上两行代码,此时我们想想打印出的结果数据将是怎样的呢?如下:

 咦,好像发现一点问题,上述我们明明首先添加的键为Aa,难道首先打印出来的不应该是Aa吗?怎么是键BB呢?不仅让我们心生疑窦,主要是因为键Aa和键BB计算出来的哈希值一样导致,不信,我们可打印出二者对应的哈希值均为2112,如下:

 System.out.println("Aa".hashCode());
 System.out.println("BB".hashCode());

接下来我们再来看看最终存放到数组里面去时,具体是怎么操作的呢?我们摘抄上述代码片段,如下:

  Entry<K,V> e = (Entry<K,V>) tab[index];
  tab[index] = new Entry<>(hash, key, value, e);

问题就出在这个地方,在上一节我们讲解散列算法为解决冲突使用链地址法时,我们是将键计算出来的相同哈希值添加到单链表的尾部,在这里刚好相反,这里采取的是将后续添加的放到单链表头部,而已添加的则放到下一个引用。因为上述首先将已添加的键Aa对应的索引取出来,然后重新实例化存储键BB的数据时,它的下一个即(next)指向的是Aa,所以才有了上述打印结果,这里需要我们注意下。那么为何要这么做呢?对比上一节我们的实现,主要是数据结构定义不同,上一节我们采用循环遍历方式,但是在源码中采用构造函数中赋值下一引用的方式,当然源码的方式是性能最佳,因为免去了循环遍历。好了,接下来我们再来看看删除方法,我们在控制台继续添加如下代码:

hashtable.remove("Aa");

我们同时也对应看看源码中删除是如何操作的,源码如下:

public synchronized V remove(Object key) {

    //定义存储数组变量
    Entry<?,?> tab[] = table;
    
    //计算键哈希值
    int hash = key.hashCode();
    
    //获取键索引
    int index = (hash & 0x7FFFFFFF) % tab.length;
    
    //获取键索引存储数据
    Entry<K,V> e = (Entry<K,V>)tab[index];
    
    for(Entry<K,V> prev = null ; e != null ; prev = e, e = e.next) {
        
        //若删除数据在单链表头部则进入该语句,否则继续下一循环
        if ((e.hash == hash) && e.key.equals(key)) {
            
            modCount++;
            
            //若删除数据不在单链表头部则进入该语句
            if (prev != null) {
                prev.next = e.next;
            } else {
                //若删除数据在存储数组索引头部则进入该语句
                tab[index] = e.next;
            }
            
            //数组大小减1
            count--;
            
            //返回删除值
            V oldValue = e.value;
            
            //要删除值置为空
            e.value = null;
            
            return oldValue;
        }
    }
    return null;
}

通过上述对删除操作的分析,此时我们删除键Aa,此时单链表头部键为BB,所以会进行下一循环,最后进入上述第二个if语句,若是删除键BB,因为此时就存在单链表头部,所以prev为空,进入else语句进行元素删除操作。关于Hashtable源码的分析到此结束,至于其他比如获取键对应值或者键是否包含在存储数组中比较简单,这里就不再阐述。

总结

本节我们详细分析了Hashtable源码,Hashtable采用链地址法解决哈希冲突,同时当发生冲突时,将冲突数据存储在单链表头部,而已有数据作为头部下一引用,Hashtable不允许插入任何空的键和值,方法通过关键字synchronized修饰得知Hashtable是线程安全的,同时默认初始化容量为11,负载因子为0.75f,负载因子定为0.75f的原因在于:若冲突或碰撞产生非常频繁会减缓使用元素的操作,因为此时仅仅只知道索引是不够的的,此时需要遍历链表才能找到存储的元素,因此,减少碰撞次数非常重要, 数组越大,碰撞的机会就越小,负载因子决定了阵列大小和性能之间平衡,这意味着当75%的存储桶变为空时,数组大小会扩容,此操作由rehash()方法来执行。下一节我们进一步学习hashCode、equals以及hashCode计算原理,然后分析HashMap源码,感谢您的阅读,下节见。

posted @ 2019-09-19 00:59  Jeffcky  阅读(545)  评论(3编辑  收藏  举报