您的位置:首页 > 编程语言 > Java开发

Java容器—— 「实现自己的HashMap」

2018-01-27 17:00 309 查看

一、前言

上一篇文章实现了自己的ArrayMap,但是对于Key-Value使用单纯数组进行存储,那么性能实在是惨不忍睹。此种情况下哈希表的数据结构是比较合适的解决方案。

哈希表就是一种以 键-值(key-indexed) 存储数据的结构,我们只要输入待查找的值即key,即可查找到其对应的值。

哈希的思路很简单,如果所有的键都是整数,那么就可以使用一个简单的无序数组来实现:将键作为索引,值即为其对应的值,这样就可以快速访问任意键的值。这是对于简单的键的情况,我们将其扩展到可以处理更加复杂的类型的键。

使用哈希查找有两个步骤:

1. 使用哈希函数将被查找的键转换为数组的索引。

在理想的情况下,不同的键会被转换为不同的索引值,但是在有些情况下我们需要处理多个键被哈希到同一个索引值的情况。所以哈希查找的第二个步骤就是处理冲突

2. 处理哈希碰撞冲突。

有很多处理哈希碰撞冲突的方法,如拉链法和线性探测法。HashMap中使用单向链表来解决冲突问题。

哈希表是一个在时间和空间上做出权衡的经典例子。如果没有内存限制,那么可以直接将键作为数组的索引。那么所有的查找时间复杂度为O(1);如果没有时间限制,那么我们可以使用无序数组并进行顺序查找,这样只需要很少的内存。哈希表使用了适度的时间和空间来在这两个极端之间找到了平衡。只需要调整哈希函数算法即可在时间和空间上做出取舍。

本文尝试按照哈希表的设计思路,设计一个SelfHashMap。(文章编写顺序是按照对哈希表的理解递进,目的为了更好的掌握jdk的HashMap)

二、设计思路

1.hash算法:对于原值不确定的情况下,使用取余法最为简单,因此就确定使用除留余数法,将余数作为数组的索引。

假设哈希表长为m,p为小于等于m的最大素数,则哈希函数为

h(k)=k % p ,其中%为模p取余运算

对于SelfHashMap而言,键不一定为整数,因此需要获取键的HashCode然后再除留取余得到数组下标。同时由于hashCode可能为负数,取其绝对值。

2.冲突解决方法:对于不确定的长度的HashMap,使用拉链法更有优势,因此确定使用单向链表的方式。

3.数组扩容(resize):当Map中的元素很多的时候,必然会出现很多Hash冲突的情况(很多h(k)都定位到数组的相同位置),这时查询和存储的效率就开始大幅度的下降,因此就要进行扩容和再哈希。

不过何时进行扩容这是一个值得思考的问题。扩容的太晚意味着在扩容之前的查询效率很低,扩容的太早则意味着存储空间的浪费。此处设置一个影响因子(Hashmap中的loadFactor),当map中元素的数量达到loadFactor*数组长度时进行扩容。

4.null对象的处理:直接把null对象放在数组[0]的位置。(如果拒绝则更加简单)

三、代码实现

1.链表实现

class Node<K, V> implements Entry<K, V> {
private K key;
private V value;
/**
* 链表的下一个节点
*/
private Node<K, V> next;
/**
* key的hash值,考虑到如果再hash就不用重新算一遍hash值
*/
private int hashCode;

public Node(K key, V value, int hashCode, Node<K, V> next) {
this.key = key;
this.value = value;
this.hashCode = hashCode;
this.next = next;
}
@Override
public K getKey() {
return key;
}
@Override
public V getValue() {
return value;
}
@Override
public V setValue(V value) {
this.value = value;
return value;
}
@Override
public String toString() {
return key + "=" + value;
}
}


2.Hash算法

int hashCode = hash(key);
int index = indexForArray(hashCode, arrayLength);

/**
* 根据hash值以及数组长度获取数组下表
* 之后更改Hash算法可以直接更改此处
*
* @param hashCode
* @param arrayLength
* @return
*/
private int indexForArray(int hashCode, int arrayLength) {
int index = Math.abs(hashCode) % arrayLength;
return index;
}

/**
* 获取hash值
*
* @param key 传入的键
* @return key为null返回0,其他返回hashCode()的值
*/
public int hash(Object key) {
return key == null ? 0 : key.hashCode();
}


3.数组扩容







/**
* 扩容
* 1.得到新数组长度,创建新数组
* 2.将旧数组的数据转移到新数组
* 3.替换旧数组
*/
private void resize() {
int newLength = arrayLength * 2;
Node<K, V>[] newTables = new Node[newLength];
Set<Entry<K, V>> entrySet = entrySet();
int newSize = 0;
for (Entry entry : entrySet) {
Node<K, V> node = (Node<K, V>) entry;
node.next = null;
int index = indexForArray(node.hashCode, arrayLength);
Node<K, V> indexNode = newTables[index];
if (indexNode == null) {
newTables[index] = node;
} else {
while (indexNode.next != null) {
indexNode = indexNode.next;
}
indexNode.next = node;
}
}
tables = newTables;
arrayLength = newLength;
}


完整代码如下:

/**
* @author lzy
* @date 2018/1/18
*/
public class SelfHashMap<K, V> extends AbstractMap<K, V> implements Map<K, V>, Cloneable, Serializable {

/**
* 默认的负载因子
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/**
* 初始默认长度
*/
private static final int DEFAULT_LENGTH = 16;
/**
* tables的长度
*/
private int arrayLength;

/**
* map中的元素数量
*/
private int size;

private float loadFactor;

/**
* 存储节点的数组
*/
private Node<K, V>[] tables;

public SelfHashMap() {
this(DEFAULT_LENGTH, DEFAULT_LOAD_FACTOR);
}

/**
* @param length 数组初始化长度
*/
public SelfHashMap(int length) {
this(length, DEFAULT_LOAD_FACTOR);
}

/**
* @param length 数组初始化长度
* @param loadFactor 负载因子
*/
public SelfHashMap(int length, float loadFactor) {
if (length <= 0) {
throw new IllegalArgumentException("初始化长度必须大于0");
}
if (loadFactor <= 0) {
throw new IllegalArgumentException("负载因子必须大于0");
}

this.arrayLength = length;
this.loadFactor = loadFactor;
tables = new Node[length];
}

@Override
public V get(Object key) {
int index = indexForArray(hash(key), arrayLength);
Node<K, V> node = tables[index];
for (Node<K, V> n = node; n != null; n = n.next) {
if ((key == null && null == n.getKey()) || (key != null && key.equals(n.getKey()))) {
return n.value;
}
}
return null;
}

/**
* @param key 键
* @param value 值
* @return 替换的旧value,或者null
*/
@Override
public V put(K key, V value) {
int hashCode = hash(key);
int index = indexForArray(hashCode, arrayLength);
//如果当前位置为
Node<K, V> node = tables[index];
if (node == null) {
tables[index] = new Node(key, value, hashCode, null);
} else {
for (Node<K, V> n = node; n != null; n = n.next) {
// 如果该key已经存在,则覆盖并且返回
K nodeKey = n.getKey();
if ((key == null && null == nodeKey) || (key != null && key.equals(nodeKey))) {
V oldValue = n.getValue();
n.setValue(value);
return oldValue;
}
// 不存在该Key,判断到队列最后一个,则新建一个Node放在队列尾部
if (n.next == null) {
n.next = new Node<>(key, value, hashCode, null);
break;
}
}
}
//判断是否要扩容,如果只是替换value,不增加元素,则不会执行到此处
if (++size > arrayLength * loadFactor) {
resize();
}
return null;
}

@Override
public void clear() {
tables = new Node[arrayLength];
size = 0;
}

@Override
public int size() {
return size;
}

@Override
public Set<Entry<K, V>> entrySet() {
Set<Entry<K, V>> set = new HashSet<>();
for (Node<K, V> node : tables) {
while (node != null) {
set.add(node);
node = node.next;
}
}
return set;
}

/**
* 获取hash值
*
* @param key 传入的键
* @return key为null返回0,其他返回hashCode()的值
*/
public int hash(Object key) {
return key == null ? 0 : key.hashCode();
}

/**
* 根据hash值以及数组长度获取数组下表
* 之后更改Hash算法可以直接更改此处
*
* @param hashCode
* @param arrayLength
* @return
*/
private int indexForArray(int hashCode, int arrayLength) {
int index = Math.abs(hashCode) % arrayLength;
return index;
}

/** * 扩容 * 1.得到新数组长度,创建新数组 * 2.将旧数组的数据转移到新数组 * 3.替换旧数组 */ private void resize() { int newLength = arrayLength * 2; Node<K, V>[] newTables = new Node[newLength]; Set<Entry<K, V>> entrySet = entrySet(); int newSize = 0; for (Entry entry : entrySet) { Node<K, V> node = (Node<K, V>) entry; node.next = null; int index = indexForArray(node.hashCode, arrayLength); Node<K, V> indexNode = newTables[index]; if (indexNode == null) { newTables[index] = node; } else { while (indexNode.next != null) { indexNode = indexNode.next; } indexNode.next = node; } } tables = newTables; arrayLength = newLength; }

class Node<K, V> implements Entry<K, V> {
private K key;
private V value;
/**
* 链表的下一个节点
*/
private Node<K, V> next;
/**
* key的hash值
*/
private int hashCode;

public Node(K key, V value, int hashCode, Node<K, V> next) {
this.key = key;
this.value = value;
this.hashCode = hashCode;
this.next = next;
}

@Override
public K getKey() {
return key;
}

@Override
public V getValue() {
return value;
}

@Override
public V setValue(V value) {
this.value = value;
return value;
}

@Override
public String toString() {
return key + "=" + value;
}

}
}


四、JdK1.7 HashMap分析

通过源码可以看到,1.7中HashMap实现的大致思路是一致的,一些细节设计上比SelfHashMap充分点。1.8中逻辑由于加入红黑树,存在链表和红黑树之间的转换。

总的而言,差异在以下几点:(源码只列HashMap)

1、数组初始化差异

HashMap:中的数组在第一次放入元素时才进行初始化,节省内容空间。

SelfHashMap:在一开始就进行初始化

public V put(K key, V value) {
//如果table数组为空数组{},进行数组填充(为table分配实际内存空间),入参为threshold,此时threshold为initialCapacity 默认是1<<4(=16)
if (table == EMPTY_TABLE) {
inflateTable(threshold);//分配数组空间
}
// 下面逻辑省略
}
private void inflateTable(int toSize) {
int capacity = roundUpToPowerOf2(toSize);//capacity一定是2的次幂
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);//此处为threshold赋值,取capacity*loadFactor和MAXIMUM_CAPACITY+1的最小值,capaticy一定不会超过MAXIMUM_CAPACITY,除非loadFactor大于1
table = new Entry[capacity];//分配空间
initHashSeedAsNeeded(capacity);//选择合适的Hash因子
}


2、扩容判断依据差异

HashMap:存在一个成员变量threshold(阈值),作为数组扩容的判断依据。在元素大于等于阈值&数组当前位置不为空(即又需要下挂一个链表节点),扩容为当前的两倍。先扩容再放入新的节点。

SelfHashMap:未使用threshold(阈值),通过数组的长度*loadFactor进行判断扩容。先加入新的节点,然后判断元素数量>数组的长度*loadFactor后进行扩容(resize)。

public V put(K key, V value) {
// 当判断出需要存入该key时,调用addEntry
addEntry(hash, key, value, i);//新增一个entry
return null;
}

void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {
resize(2 * table.length);//当size超过临界阈值threshold,并且即将发生哈希冲突时进行扩容,新容量为旧容量的2倍
hash = (null != key) ? hash(key) : 0;
bucketIndex = indexFor(hash, table.length);//扩容后重新计算插入的位置下标
}

//把元素放入HashMap的桶的对应位置
createEntry(hash, key, value, bucketIndex);
}
//创建元素
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];  //获取待插入位置元素
table[bucketIndex] = new Entry<>(hash, key, value, e);//这里执行链接操作,使得新插入的元素指向原有元素。
//这保证了新插入的元素总是在链表的头
size++;//元素个数+1
}


3、HashMap容量差异

HashMap数组容量需要为2的幂次,同时最大容量为1 << 30(2的30次方)。SelfHashMap和HashTable一样没有限制。

// 在inflateTable(int toSize)中调用
private static int roundUpToPowerOf2(int number) {
// assert number >= 0 : "number must be non-negative";
return number >= MAXIMUM_CAPACITY
? MAXIMUM_CAPACITY
: (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}


4、Null处理方式差异

HashMap: put中单独做判断处理

SelfHashMap:统一处理。

public V put(K key, V value) {
...
//如果key为null,存储位置为table[0]或table[0]的冲突链上
if (key == null)
return putForNullKey(value);
...
}
// 通过遍历table[0],然后替换key==null的Node的value。单独处理确实较少很多额外的判断
private V putForNullKey(V value) {
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
if (e.key == null) {
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++;
addEntry(0, null, value, 0);
return null;
}


5、链表数据存放差异

HashMap:在放入一个新值的时候,放在链表的头部,即数组的位置。放在头部逻辑上非常简单,效率更高。

SelfHashMap: 在放在链表的尾部。

//创建元素
void createEntry(int hash, K key, V value, int bucketIndex) {
Entry<K,V> e = table[bucketIndex];  //获取待插入位置元素
table[bucketIndex] = new Entry<>(hash, key, value, e);//这里执行链接操作,使得新插入的元素指向原有元素。
//这保证了新插入的元素总是在链表的头
size++;//元素个数+1
}


6、Hash算法处理差异

HashMap对key的hashcode进一步进行计算以及二进制位的调整等来保证最终获取的存储位置尽量分布均匀

//HashMap源码:用了很多的异或,移位等运算
final int hash(Object k) {
int h = hashSeed;
if (0 != h && k instanceof String) {//这里针对String优化了Hash函数,是否使用新的Hash函数和Hash因子有关
return sun.misc.Hashing.stringHash32((String) k);
}

h ^= k.hashCode();

h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}


7、位运算提高效率

HashMap:使用位运算来提高效率。例如下面代码等价与int index = h%length

//HashMap源码
//返回数组下标
static int indexFor(int h, int length) {
return h & (length-1);
}


h&(length-1)保证获取的index一定在数组范围内,举个例子,默认容量16,length-1=15,h=18,转换成二进制计算为:



最终计算出的index=2。此处的计算功能上等价于取模运算,不过位运算对计算机来说,性能更高一些。

Jdk HashMap的设计值得学习的地方很多,通过自己设计编写一个哈希表,加深了对源码的理解,也领会其设计之精妙。

五、性能测试

1.Get测试

A:ArrayMap

L:jdk1.7 LinkedHashMap

H:jdk1.7 HashMap

S:SelfHashMap

测试次数A(1k)L(1k)H(1k)S(1K)A(1w)L(1w)H(1w)S(1w)A(10w)L(10w)H(10w)S(10w)
1k14111714197111
1w721580111919111
10w81444109044497559712
2.Put测试

A:ArrayMap

L:jdk1.7 LinkedHashMap

H:jdk1.7 HashMap

S:SelfHashMap

测试次数A(1k)L(1k)H(1k)S(1K)A(1w)L(1w)H(1w)S(1w)A(10w)L(10w)H(10w)S(10w)
1k<1<1<1<18<1<1<1105<1<1<1
1w35111931211068211
10w6999816361021199311863191112
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: