前言

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

Hashtable源码分析

我们通过在控制台中实例化Hashtable并添加键值对实例代码来分析背后究竟做了哪些操作,如下:

  1. public static void main(String[] args) {
  2.  
  3. Hashtable hashtable = new Hashtable();
  4. hashtable.put(-100, "first");
  5. }

接下来我们来看看在我们初始化Hashtable时,背后做了哪些准备工作呢?

  1. public class Hashtable<K,V>
  2. extends Dictionary<K,V>
  3. implements Map<K,V>, Cloneable, java.io.Serializable {
  4.  
  5. //存储键值对数据
  6. private transient Entry<?,?>[] table;
  7.  
  8. //存储数据大小
  9. private transient int count;
  10.  
  11. //阈值:(int)(capacity * loadFactor).)
  12. private int threshold;
  13.  
  14. //负载因子: 从时间和空间成本折衷考虑默认为0.75。因为较高的值虽然会减少空间开销,但是增加查找元素的时间成本
  15. private float loadFactor;
  16.  
  17. //指定容量和负载因子构造函数
  18. public Hashtable(int initialCapacity, float loadFactor) {
  19. if (initialCapacity < 0)
  20. throw new IllegalArgumentException("Illegal Capacity: "+
  21. initialCapacity);
  22. if (loadFactor <= 0 || Float.isNaN(loadFactor))
  23. throw new IllegalArgumentException("Illegal Load: "+loadFactor);
  24.  
  25. if (initialCapacity==0)
  26. initialCapacity = 1;
  27.  
  28. this.loadFactor = loadFactor;
  29.  
  30. table = new Entry<?,?>[initialCapacity];
  31.  
  32. //默认阈值为8
  33. threshold = (int)Math.min(initialCapacity * loadFactor, MAX_ARRAY_SIZE + 1);
  34. }
  35.  
  36. //指定容量构造函数
  37. public Hashtable(int initialCapacity) {
  38. this(initialCapacity, 0.75f);
  39. }
  40.  
  41. //默认无参构造函数(初始化容量为11,负载因子为0.75f)
  42. public Hashtable() {
  43. this(11, 0.75f);
  44. }
  45.  
  46. private static class Entry<K,V> implements Map.Entry<K,V> {
  47. final int hash;
  48. final K key;
  49. V value;
  50. Entry<K,V> next;
  51.  
  52. protected Entry(int hash, K key, V value, Entry<K,V> next) {
  53. this.hash = hash;
  54. this.key = key;
  55. this.value = value;
  56. this.next = next;
  57. }
  58. }
  59. }

Hashtable内部通过Entry数组存储数据,通过Entry结构可看出采用链地址法解决哈希冲突,当初始化Hashtable未指定容量和负载因子时,默认初始化容量为11,负载因子为0.75,阈值为8,若容量小于0则抛出异常,若容量等于0则容量为1且阈值为0,否则阈值以指定容量*0.75计算或者以指定容量*指定负载因子计算为准。

通过如上源代码和变量定义我们很快能够得出如上结论,这点就不必我们再进行过多讨论,接下来我们再来看看当我们如上添加如上键值对数据时,内部是如何做的呢?

  1. public synchronized V put(K key, V value) {
  2. if (value == null) {
  3. throw new NullPointerException();
  4. }
  5.  
  6. Entry<?,?> tab[] = table;
  7.  
  8. int hash = key.hashCode();
  9.  
  10. int index = (hash & 0x7FFFFFFF) % tab.length;
  11.  
  12. Entry<K,V> entry = (Entry<K,V>)tab[index];
  13.  
  14. for(; entry != null ; entry = entry.next) {
  15. if ((entry.hash == hash) && entry.key.equals(key)) {
  16. V old = entry.value;
  17. entry.value = value;
  18. return old;
  19. }
  20. }
  21.  
  22. addEntry(hash, key, value, index);
  23.  
  24. return null;
  25. }

我们一步步来分析,首先若添加的值为空则抛出异常,紧接着获取添加键的哈希值,重点来了,如下代码片段的作用是什么呢?

  1. 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。如果是键的哈希值为正值那就不存在这个问题,也就是说通过逻辑与操作得到的哈希值就是原值。接下来获取对应索引在数组中的位置,然后进行循环,问题来了为何要循环数组呢?也就是如下代码片段:

  1. for(; entry != null ; entry = entry.next) {
  2. if ((entry.hash == hash) && entry.key.equals(key)) {
  3. V old = entry.value;
  4. entry.value = value;
  5. return old;
  6. }
  7. }

上述是为了解决相同键值将对应的值进行覆盖,还是不能理解?我们在控制台再加上一行如下代码:

  1. public static void main(String[] args) {
  2.  
  3. Hashtable hashtable = new Hashtable();
  4.  
  5. hashtable.put(-100, "first");
  6.  
  7. hashtable.put(-100, "second");
  8. }

如上我们添加的键都为-100,通过我们对上述循环源码的分析,此时将如上第一行的值first替换为second,换言之当我们添加相同键时,此时会发生后者的值覆盖前者值的情况,同时我们也可以通过返回值得知,若返回值为空说明没有出现覆盖的情况,否则有返回值,说明存在相同的键且返回被覆盖的值。我们通过如下打印出来Hashtable中数据可得出,这点和C#操作Hashtable不同,若存在相同的键则直接抛出异常。

  1. Enumeration keys = hashtable.keys();
  2.  
  3. while (keys.hasMoreElements()) {
  4.  
  5. Object key = keys.nextElement();
  6.  
  7. String values = (String) hashtable.get(key);
  8. System.out.println(key + "------>" + values);
  9. }

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

  1. private void addEntry(int hash, K key, V value, int index) {
  2. modCount++;
  3.  
  4. //定义存储数据变量
  5. Entry<?,?> tab[] = table;
  6.  
  7. //若数组中元素超过或等于阈值则扩容数组
  8. if (count >= threshold) {
  9. rehash();
  10.  
  11. tab = table;
  12. hash = key.hashCode();
  13. index = (hash & 0x7FFFFFFF) % tab.length;
  14. }
  15.  
  16. //将键值对以及哈希值添加到存储数组中
  17. Entry<K,V> e = (Entry<K,V>) tab[index];
  18. tab[index] = new Entry<>(hash, key, value, e);
  19. count++;
  20. }

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

  1. protected void rehash() {
  2.  
  3. //获取存储数组当前容量
  4. int oldCapacity = table.length;
  5. Entry<?,?>[] oldMap = table;
  6.  
  7. // 新容量 = 当前容量*2 + 1
  8. int newCapacity = (oldCapacity << 1) + 1;
  9.  
  10. //判断是否新容量是否超过最大数组大小,超过那么最大容量为定义的最大数组大小
  11. if (newCapacity - MAX_ARRAY_SIZE > 0) {
  12. if (oldCapacity == MAX_ARRAY_SIZE)
  13. return;
  14. newCapacity = MAX_ARRAY_SIZE;
  15. }
  16. Entry<?,?>[] newMap = new Entry<?,?>[newCapacity];
  17.  
  18. modCount++;
  19.  
  20. //重新计算阈值
  21. threshold = (int)Math.min(newCapacity * loadFactor, MAX_ARRAY_SIZE + 1);
  22.  
  23. //扩容后的存储数组
  24. table = newMap;
  25.  
  26. //循环将当前存储的数组数据更新到扩容后的存储数组里
  27. for (int i = oldCapacity ; i-- > 0 ;) {
  28. for (Entry<K,V> old = (Entry<K,V>)oldMap[i] ; old != null ; ) {
  29. Entry<K,V> e = old;
  30. old = old.next;
  31.  
  32. int index = (e.hash & 0x7FFFFFFF) % newCapacity;
  33. e.next = (Entry<K,V>)newMap[index];
  34. newMap[index] = e;
  35. }
  36. }
  37. }

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

  1. public static void main(String[] args) {
  2.  
  3. Hashtable hashtable = new Hashtable();
  4.  
  5. hashtable.put(-100, "first");
  6.  
  7. hashtable.put(-100, "second");
  8.  
  9. hashtable.put("Aa", "third");
  10. hashtable.put("BB", "fourth");
  11.  
  12. Enumeration keys = hashtable.keys();
  13.  
  14. while (keys.hasMoreElements()) {
  15.  
  16. Object key = keys.nextElement();
  17.  
  18. String values = (String) hashtable.get(key);
  19. System.out.println(key + "------>" + values);
  20. }
  21. }

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

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

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

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

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

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

  1. hashtable.remove("Aa");

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

  1. public synchronized V remove(Object key) {
  2.  
  3. //定义存储数组变量
  4. Entry<?,?> tab[] = table;
  5.  
  6. //计算键哈希值
  7. int hash = key.hashCode();
  8.  
  9. //获取键索引
  10. int index = (hash & 0x7FFFFFFF) % tab.length;
  11.  
  12. //获取键索引存储数据
  13. Entry<K,V> e = (Entry<K,V>)tab[index];
  14.  
  15. for(Entry<K,V> prev = null ; e != null ; prev = e, e = e.next) {
  16.  
  17. //若删除数据在单链表头部则进入该语句,否则继续下一循环
  18. if ((e.hash == hash) && e.key.equals(key)) {
  19.  
  20. modCount++;
  21.  
  22. //若删除数据不在单链表头部则进入该语句
  23. if (prev != null) {
  24. prev.next = e.next;
  25. } else {
  26. //若删除数据在存储数组索引头部则进入该语句
  27. tab[index] = e.next;
  28. }
  29.  
  30. //数组大小减1
  31. count--;
  32.  
  33. //返回删除值
  34. V oldValue = e.value;
  35.  
  36. //要删除值置为空
  37. e.value = null;
  38.  
  39. return oldValue;
  40. }
  41. }
  42. return null;
  43. }

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

总结

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

Java入门系列之集合Hashtable源码分析(十一)的更多相关文章

  1. Java入门系列之集合LinkedList源码分析(九)

    前言 上一节我们手写实现了单链表和双链表,本节我们来看看源码是如何实现的并且对比手动实现有哪些可优化的地方. LinkedList源码分析 通过上一节我们对双链表原理的讲解,同时我们对照如下图也可知道 ...

  2. Java入门系列之集合HashMap源码分析(十四)

    前言 我们知道在Java 8中对于HashMap引入了红黑树从而提高操作性能,由于在上一节我们已经通过图解方式分析了红黑树原理,所以在接下来我们将更多精力投入到解析原理而不是算法本身,HashMap在 ...

  3. Java入门系列之集合ArrayList源码分析(七)

    前言 上一节我们通过排队类实现了类似ArrayList基本功能,当然还有很多欠缺考虑,只是为了我们学习集合而准备来着,本节我们来看看ArrayList源码中对于常用操作方法是如何进行的,请往下看. A ...

  4. 并发-HashMap和HashTable源码分析

    HashMap和HashTable源码分析 参考: https://blog.csdn.net/luanlouis/article/details/41576373 http://www.cnblog ...

  5. 史上最简单的的HashTable源码分析

    HashTable源码分析 1.前言 Hashtable 一个元老级的集合类,早在 JDK 1.0 就诞生了 1.1.摘要 在集合系列的第一章,咱们了解到,Map 的实现类有 HashMap.Link ...

  6. 一步步实现windows版ijkplayer系列文章之六——SDL2源码分析之OpenGL ES在windows上的渲染过程

    一步步实现windows版ijkplayer系列文章之一--Windows10平台编译ffmpeg 4.0.2,生成ffplay 一步步实现windows版ijkplayer系列文章之二--Ijkpl ...

  7. JAVA的HashTable源码分析

    Hashtable简介 Hashtable同样是基于哈希表实现的,同样每个元素是一个key-value对,其内部也是通过单链表解决冲突问题,容量不足(超过了阀值)时,同样会自动增长.Hashtable ...

  8. MySql轻松入门系列————第一站 从源码角度轻松认识mysql整体框架图

    一:背景 1. 讲故事 最近看各大技术社区,不管是知乎,掘金,博客园,csdn基本上看不到有小伙伴分享sqlserver类的文章,看样子这些年sqlserver没落了,已经后继无人了,再写sqlser ...

  9. JAVA ArrayList集合底层源码分析

    目录 ArrayList集合 一.ArrayList的注意事项 二. ArrayList 的底层操作机制源码分析(重点,难点.) 1.JDK8.0 2.JDK11.0 ArrayList集合 一.Ar ...

随机推荐

  1. CCF-CSP题解 201803-3 URL映射

    题目要求写一个简易的URL规则和URL地址匹配的程序. 说说我的思路. 将URL规则和地址都截成片段用结构体\(<type, str[]>\)存储.对于URL规则,\(type\)为0代表 ...

  2. JS正则表达式语法(含ES6)(表格简要总结)

    文章目录 JS正则表达式 1. JS中正则表达式定义 2. 直接量字符 3. 字符类 4. 重复字符 5. 选择,分组和引用 6. 指定匹配位置 7. 修饰符 8. String 方法 9. RegE ...

  3. linux之寻找男人的帮助,man和info,

    1.在linux下寻求帮助是一个很好的习惯,幸运的是系统提供了帮助的命令man和info,由于linux指令很多,记忆起来简直麻烦,比如以a开头的指令有100条,linux命令算起来得几千条,记忆却是 ...

  4. ubifs开销测试

    问题 在板子上观察到56M的ubi卷,挂载上ubifs之后,df -h显示可用空间约为50M. 如此计算开销超过了10%,那么这个开销随容量如何变化呢,是固定为10%吗还是有其他规律? 理论计算 简单 ...

  5. NPOI 获取单元格的值

    1.日期格式的坑 var cell = row.GetCell(i);//获取某一个单元格 var value = ""; if (cell != null) { if (cell ...

  6. HTML连载57-相对定位和绝对定位

    一.定位流 1.分类 (1)相对定位: (2)绝对定位 (3)固定定位 (4)静态定位 2.什么相对定位 相对定位就是相对于自己以前在标准流中的位置来移动. 例子: <style> div ...

  7. 如何快速将百度大脑AI技术内置智能小程序中

    实现效果: 该AI智能小程序目前集成了百度AI开放平台数十个AI服务产品功能,包括人脸识别.文字识别.表格识别.红酒识别.货币识别.地标识别.手势识别.商标识别.果蔬识别.菜品识别等图片识别功能,以及 ...

  8. conda pip 安装 dgl 并运行demo 出现:Segmentation fault (core dumped) 错误

    安装dgl 并运行的时候,出现了如上错误,很是郁闷:使用 gdb python; run train.py 进行调试,发现是torch的问题:我猜测估计是torch 安装的版本过于新:于是重新安装 1 ...

  9. 精通awk系列(16):gawk支持的正则表达式

    回到: Linux系列文章 Shell系列文章 Awk系列文章 gawk支持的正则 . # 匹配任意字符,包括换行符 ^ $ [...] [^...] | + * ? () {m} {m,} {m,n ...

  10. C++之结构体特点

    C++的结构体和C语言的结构体有什么不同 C++的结构体其实就是类的一种,只不过类成员默认访问权限是private,结构体默认访问权限是public. C语言的结构体是不能有函数的,而C++可以有. ...