c# chart 各个属性_JavaHashMap与C#Dictionary的源码实现分析

1 背景本文是Java HashMap与C# Dictionary的源码实现分析及比较,主要是想和大家分享一下两种语言中hash集合的实现原理及其背后的差异化逻辑。我们常用的Hash集合实现方式大同小异。常见实现方式是:存储一个桶数组,桶数组中每一个元素关联一个键值对来存储实际元素。对存储的元素进行hash函数散列处理,根据散列值将元素放在桶数组元素对应的数据集合中。其中核心的两点:良好...
摘要由CSDN通过智能技术生成

1 背景

本文是Java HashMap与C# Dictionary的源码实现分析及比较,主要是想和大家分享一下两种语言中hash集合的实现原理及其背后的差异化逻辑。 我们常用的Hash集合实现方式大同小异。常见实现方式是:存储一个桶数组,桶数组中每一个元素关联一个键值对来存储实际元素。对存储的元素进行hash函数散列处理,根据散列值将元素放在桶数组元素对应的数据集合中。其中核心的两点:
  • 良好的hash函数,保证元素在桶数组中的均匀分布;

  • 均衡的扩容机制,通过动态扩容,在空间利用率和操作耗时之间做合理取舍。

2 Java HashMap(jdk1.8)

2.1 数据结构

JKD1.8中 HashMap的数据结构由数组+链表/红黑树(当链表的长度达到 8时会转换成红黑树)组成。数据具体存储格式是:将所有链表(树)的第一个结点存放在数组 table中,通过结点的 next( left/right)值来找到下一个链表(树)结点。

c3a8f30a410535613930cf4bb7994860.png

深入查看源码前,我们先看一下 HashMap定义的一些基本属性:
static final int DEFAULT_INITIAL_CAPACITY = 16;  // 默认初始容量static final int MAXIMUM_CAPACITY = 1073741824;  // 最大容量static final float DEFAULT_LOAD_FACTOR = 0.75F;  // 默认负载因子;static final int TREEIFY_THRESHOLD = 8;  // 链表转红黑树的长度static final int UNTREEIFY_THRESHOLD = 6;  // 红黑树转链表的长度;static final int MIN_TREEIFY_CAPACITY = 64;  // 转红黑树的最小容量;transient HashMap.Node[] table;  // 用来存放每个链表(树)的第一个结点的数组,通过key的hashcode来判断该key应该存放在哪个链表中;transient int size;  // table数组中元素个数transient int modCount;  // 集合修改次数int threshold;  // 容量阈值(元素个数大于等于该值时自动扩容)final float loadFactor;  // 扩容的负载因子,默认0.75

这里我们事先明确一些名词定义,以便于上下文理解:

  • 桶数组:源码中声明的table数组变量,用来存放每个链表(树)第一个结点的数组。

  • 树化:链表转为红黑树操作。

2.2 初始化

说到初始化,那么我们就需要看构造方法了。构造方法源码如下图:
public HashMap(int initialCapacity, float loadFactor) {
        if (initialCapacity < 0)          throw new IllegalArgumentException("Illegal initial capacity: " +                                             initialCapacity);    if (initialCapacity > MAXIMUM_CAPACITY)          initialCapacity = MAXIMUM_CAPACITY;    if (loadFactor <= 0 || Float.isNaN(loadFactor))          throw new IllegalArgumentException("Illegal load factor: " +                                             loadFactor);    this.loadFactor = loadFactor;    this.threshold = tableSizeFor(initialCapacity);}public HashMap(int initialCapacity) { this(initialCapacity, DEFAULT_LOAD_FACTOR); }public HashMap() {this.loadFactor = DEFAULT_LOAD_FACTOR; }public HashMap(Map extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;    putMapEntries(m, false);}

源码中一共提供了4个构造方法。其中一个接收一个Map参数,将Map中的元素添加到新生成的HashMap中。另外三个构造方法仅对loadFactorthreshold属性进行了赋值操作。这里有两点需要仔细关注:

  1. 初始化数组。在实例化HashMap对象时并没有立即初始化桶数组,而是在第一次put元素时初始化桶数组。这种技巧称为惰性初始化,在编程实践中很常见,主要是为了节省不必要的内存开销。

  2. 容量。如果不指定初始容量大小,默认是16,负载因子是0.75, 如果指定容量initialCapacity,初始化大小为大于initialCapacity2^n(例如:initialCapacity传10,实际初始化大小为16)。

容量为什么是2的幂次方呢?概括来说,就是希望数据能够高效均匀地分布到HashMap内部的各个桶。定位某个Key具体落到哪个桶?我们最常用的方式是按HashMap的长度取模,HashMap内部源码实现是:tab[(n - 1) & hash],这里是将key进行hash操作后再和容量n-1进行按位与(&)操作,最终得到的值就是key所在桶的数组下标。采取这种实现方式有2个原因:

  1. hash % n 等价于(n-1) & hash,但(n-1) & hash位运算通常效率高于取模运算;

  2. n为2的幂次方时,能够确保Key均匀分布到每个HashMap的桶中,否则会出现桶的某个下标永远不会存在key的情况。我们可以简单分析一下为什么会导致这种情况?假设HashMap的容量是3,那么n-1就是2,对应的二进制数就是10,由于第1位是0,那么它和任意值的位运算结果的第1位也必然是0,最终得到的值只有10和00,永远不会得到01,那么对应的tab[1]永远不会填充数据,这样导致空间浪费的同时也增加了hash冲突几率。由于hash值的某一位没有参与运算,无法完整地体现出key的hash值特性。同理n-1的任意1位都不能是0,故n必须确保是2的幂次方。

2.3 插入元素

这里仅以 put方法为例,来讲解一下 HashMap插入元素的实现。我们将源码的逻辑转换成流程图,便于观看(对具体代码实现感兴趣的童鞋可自行查阅JDK源码)。

3f3a7a4fc24f07b2a99782fe47299600.png

插入过程中会出现扩容树化,这两点稍后详细讲解,我们先看链表元素的插入操作。

在JDK1.8之前,插入链表结点采用的是头插法,即链表的结点每次从头部插入。在多线程插入触发扩容的情况下会形成环形链导致死循环。下图是JDK1.8插入链表结点的源码,我们可以看到链表的插入是从尾部插入,这样避免了因环形链导致的死循环。但是多线程插入依旧会出现数据覆盖的情况。我们仔细分析一下,假设A、B两个线程同时进行插入操作,且插入的元素都分配到同一个hash桶。当线程A执行完下面代码片段中“Line 17”处的代码后挂
  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值