前言

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

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源码,感谢您的阅读,下节见。

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. Linux服务器可以ping,但是telnet端口超时,网站wget超时,访问超时的解决办法

    最近无法通过SSH连接Linux服务器,访问该服务器上的HTTP服务也出现异常.可以ping,但是telnet端口超时,网站wget超时,访问超时. 最后排查是内核配置问题 原来是 net.ipv4. ...

  2. Docker常用命令-全

    原文内容来自于LZ(楼主)的印象笔记,如出现排版异常或图片丢失等问题,可查看当前链接: https://app.yinxiang.com/shard/s17/nl/19391737/9f6bf39e- ...

  3. 流分析 Stream Analytics-实时数据流式处理,可处理来自数百万台 IoT 设备的数据

    典型的物联网架构中,有实时数据分析的需求,在Azure中,流分析(stream analytics)就是这样的服务,它可以存在云中或者部署到边缘设备上. 流分析的基本概念: https://v.qq. ...

  4. Sqlite—修改语句(Update)

    SQLite 的 UPDATE 语句用于修改表中已有的记录.可以使用带有 WHERE 子句的 UPDATE 查询来更新选定行,否则所有的行都会被更新. 基本语法:UPDATE table_name S ...

  5. IEEE754 32位浮点数表示范围

    6.1浮点数的数值范围 根据上面的探讨,浮点数可以表示-∞到+∞,这只是一种特殊情况,显然不是我们想要的数值范围. 以32位单精度浮点数为例,阶码E由8位表示,取值范围为0-255,去除0和255这两 ...

  6. C#开发微信小程序(五)

    导航:C#开发微信小程序系列 关于小程序项目结构,框架介绍,组件说明等,请查看微信小程序官方文档,关于以下贴出来的代码部分我只是截取了一些片段,方便说明问题,如果需要查看完整源代码,可以在我的项目库中 ...

  7. 极客时间-vue开发实战学习(ant-design vue作者)

    vue基础 属性 事件 插槽 指令(Directives) 生命周期 底层原理 vue生态 路由管理器vue Router 状态管理器vuex 同构Nuxt vue实战 实战项目-ant-desing ...

  8. Prometheus学习系列(九)之Prometheus 存储

    前言 本文来自Prometheus官网手册 和 Prometheus简介 存储 Prometheus是一个本地磁盘时间序列数据库,但也可选择与远程存储系统集成,其本地时间序列数据库以自定义格式在磁盘上 ...

  9. 设置tomcat为自动启动

    第一步:设置tomcat为服务启动项 进入dos窗口,输入service.bat install,启动服务, 这里要注意的是,如果直接在cmd输入报错,需要你进入到tomcat的目录下执行cmd 第二 ...

  10. c#实现SharedMatting抠图算法

    内容简介 将Alpha Matting抠图算法由c++ 版本移植至c#环境. 主要采用OpenCV的C#版本Emgu取代c++支撑的OpenCV. 参考资料 http://www.inf.ufrgs. ...