HDFS中LightWeightGSet与HashMap结构解析
看见HashMap我就想起了一道HashMap的面试题,先来瞅下这个面试题:
HashMap和HashTable的区别?
- HashMap是非线程安全的,HashTable是线程安全的。
- HashMap的键值都可以为null,HashTable则不行。
- HashMap是非线程安全的则效率会高于HashTable,这也是一般都用HashMap的原因。
深入的问题,Java中另一个线程安全的与HashMap类似的类是?
ConcurrentHashMap也是一个线程安全类,与HashTable不同的是提供的锁机制不一样(与SynchronizedMap也不一样)
HashTable中采用的锁机制是一次锁住整个hash表,从而同一时刻只能由一个线程对其进行操作;
ConcurrentHashMap是一次锁住一个桶,hash表默认是16个桶,诸如get、put、remove等操作只锁住当前需要的桶。
上面简单回忆了下HashMap,下面说下LightWeightGSet,LightWeightGSet是HDFS中的一个数据结构,类似HashMap,用来存储block和副本所在dn的映射,是Namenode中存储全部数据块信息类BlocksMap的一个重要的数据结构。
LightWeightGSet使用数组来存储元素,使用链表来解决冲突,非线程安全。这点与HashMap类似,但是LightWeightGSet中数组的大小不会发生变化,而HashMap会根据其内部空间利用率对数组的大小进行再分配,进行重新哈希分区。再者就是LightWeightGSet不能存null元素。
下面从源码角度对比一下LightWeightGSet和HashMap的区别。
LightWeightGSet和HashMap的初始化
接口实现
LightWeightGSet实现了GSet接口,HashMap实现了Map接口。其中GSet和Map接口分别定义了键映射到值的规则。
初始化
LightWeightGSet只有一个构造函数,初始化时传入数组的大小,在BlocksMap中初始化代码如下:blocks = new LightWeightGSet<Block, BlockInfo>(capacity)
HashMap提供了三个构造函数:
HashMap():构造一个具有默认初始容量(16)和默认loadFactor(0.75)的空HashMap。
HashMap(int initialCapacity):构造一个带指定初始容量和默认loadFactor(0.75)的空HashMap。
HashMap(int initialCapacity, float loadFactor):构造一个带指定初始容量和loadFactor的空HashMap。
在这里提到了两个参数:初始容量,loadFactor。这两个参数是影响HashMap性能的重要参数,其中容量表示哈希表中桶的数量,初始容量是创建哈希表时的容量,loadFactor是哈希表在其容量自动增加之前可以达到多满的一种尺度,它衡量的是一个散列表的空间的使用程度,loadFactor越大表示散列表的装填程度越高,反之愈小。对于使用链表法的散列表来说,查找一个元素的平均时间是O(1+a),因此如果负载因子越大,对空间的利用更充分,然而后果是查找效率的降低;如果负载因子太小,那么散列表的数据将过于稀疏,对空间造成严重浪费。系统默认负载因子为0.75,一般情况下我们是无需修改的。
数据结构
我们知道在Java中最常用的两种结构是数组和模拟指针(引用),几乎所有的数据结构都可以利用这两种来组合实现,HashMap和LightWeightGSet就是如此。
数组的特点:连续空间,寻址迅速,但是在删除或者添加元素的时候需要有较大幅度移动,所以查询速度快,增删较慢。
链表正好相反,由于空间不连续,寻址困难,增删元素只需修改指针,所以查询慢,增删快。
HashMap数据结构
实际上HashMap和LightWeightGSet都是一个“链表散列”,如下是HashMap数据结构:
从上图我们可以看出HashMap底层实现还是数组,只是数组的每一项都是一条链。其中参数initialCapacity就代表了该数组的长度。下面为HashMap构造函数的源码:
1 | public HashMap(int initialCapacity, float loadFactor) { |
从源码中可以看出,每次新建一个HashMap时,都会初始化一个table数组。table数组的元素为Entry节点。
1 | static class Entry<K,V> implements Map.Entry<K,V> { |
其中Entry为HashMap的内部类,它包含了键key、值value、下一个节点next,以及hash值(这个hash值是key的hash赋值的),这是非常重要的,正是由于Entry才构成了table数组的项为链表。
LightWeightGSet数据结构
先来看下LightWeightGSet的构造函数:
1 | public LightWeightGSet(final int recommended_length) { |
由代码可以看出LightWeightGSet的数据结构和HashMap类似,LightWeightGSet初始化时会创建一个LinkedElement类型的数组,LinkedElement是LightWeightGSet的内部接口,由此解决了元素的冲突问题,使冲突的元素形成一个链表。BlocksMap的结构中通过BlockInfo实现LinkedElement接口,通过nextLinkedElement连接下一个BlockInfo。
1 | public static interface LinkedElement { |
LightWeightGSet与HashMap类似,都是创建一个数组,LightWeightGSet的数组中存储LinkedElement类型的元素,HashMap的数组中存储Entry类型的元素,这些元素都会将key冲突的元素通过引用连成一个链表。
上面简单分析了LightWeightGSet和HashMap的数据结构,下面将探讨他们是如何实现快速存取的。
存储实现put
分别看下LightWeightGSet和HashMap的put方法LightWeightGSet.put
1 | // LightWeightGSet |
HashMap.put
1 | public V put(K key, V value) { |
两个put都是先对element进行校验,然后拿到key对应数组的index,其index都是对key取hashcode值与运算因子进行&操作得出的。两部分代码如下:
LightWeightGSet
1 | private int getIndex(final K key) { |
HashMap
1 | static int hash(int h) { |
这里重点说下运算因子,LightWeightGSet的运算因子是hash_mask,在构造函数中赋值,hash_mask = entries.length - 1
,是entries数组长度减一。HashMap的运算因子也是entry数组的长度减一。之所以这样是为了让数据在LightWeightGSet和HashMap中的分布尽量均匀(最好每项都只有一个元素,这样就可以直接找到),不能太紧也不能太松,太紧会导致查询速度慢,太松则浪费空间。
由于LightWeightGSet和HashMap底层数组的长度都是2的n次方,LightWeightGSet中数组的长度计算如下:
1 | private static int actualArrayLength(int recommended) { |
HashMap中数组的长度计算为capacity <<= 1
。则使用h&(length - 1)
对length取模。同时运算因子length-1
还有一个非常重要的责任:均匀分布table数据和充分利用空间。
当length为2的n次方时,h&(length - 1)就相当于对length取模(只有具备length为2的n次方的条件时,才成立),也就是h%length,而且速度比直接取模(%)快得多,这是HashMap在速度上的一个优化。
得到key对应的index之后,进行查重put,LightWeightGSet通过remove
方法进行查重,如果链表中的第一个元素不等于此值,则遍历该链表,查看是否有相同值,有则删除。如果等于链表中的第一个值则直接删除。LightWeightGSet的插入链表操作比较简单,直接调用setNext
方法指向下一个元素,并将此值放入index处就可以了(这里实际存储的是BlockInfo对象,BlockInfo实现了LightWeightGSet.LinkedElement
接口,并且声明了一个nextLinkedElement
属性,该属性是下一个元素的引用)。remove代码如下:
1 | private E remove(final int index, final K key) { |
HashMap是在put中实现,若该位置没有元素,则直接插入。否则迭代该处元素链表并依此比较其key的hash值。如果两个hash值相等且key值相等(e.hash == hash && ((k = e.key) == key || key.equals(k)))
,则用新的Entry的value覆盖原来节点的value。如果两个hash值相等但key值不等 ,则将该节点插入该链表的链头。具体的实现过程见addEntry方法,如下:
1 | void addEntry(int hash, K key, V value, int bucketIndex) { |
这里需要注意一点,LightWeightGSet不会根据数据的多少而进行二次扩容,而HashMap会有个临界点来触发扩容。该临界点在当HashMap中元素的数量等于table数组长度*加载因子。但是扩容是一个非常耗时的过程,因为它需要重新计算这些数据在新table数组中的位置并进行复制处理。所以如果我们已经预知HashMap中元素的个数,那么预设元素的个数能够有效的提高HashMap的性能。
读取实现get
get就比较简单,都是通过key的hash值找到在底层数组的index,然后遍历该链表找到key对应的value。LightWeightGSet.get
1 | public E get(final K key) { |
HashMap.get
1 | public V get(Object key) { |
在HashMap中能够根据key快速的取到value除了和HashMap的数据结构密不可分外,还和Entry有莫大的关系,在前面就提到过,HashMap在存储过程中并没有将key,value分开来存储,而是当做一个整体key-value来处理的,这个整体就是Entry对象。同时value也只相当于key的附属而已。在存储的过程中,系统根据key的hashcode来决定Entry在table数组中的存储位置,在取的过程中同样根据key的hashcode取出相对应的Entry对象。
在LightWeightGSet中,key和value都是BlockInfo对象。
附加
LightWeightGSet中底层数组的大小是在构造函数中固定的,并且其数组的大小不会扩容,则其数组的初始化就尤为重要,下面看下BlocksMap设置的LightWeightGSet数组的大小。代码如下:
1 | /** |