1、哈希表概述

在了解什么是HashMap之前,我们首先要了解哈希表(Hash table):

哈希表(hash table) 也叫散列表,是一种非常重要的数据结构,应用场景及其丰富,许多缓存技术(比如memcached)的核心其实就是在内存
存放一张大的哈希表.

首先,与哈希表进行区别,我们要了解一下其他数据结构在新增、查找等基础操作的执行性能.

数组:

数组采用一段连续的存储单元来进行数据存储,对于指定下标的查找,时间复杂度为O(1);通过给定值的查找,需要遍历数组,逐一比对给定关键字和数组元素,时间复杂度为O(n),对于有序数组当然可以采用二分查找、差值查找,斐波那契查找等方式,可以讲查找复杂度提高到O(logn);对于一般的插入、删除操作,涉及到数组元素的移动,平均复杂度为O(n)。

数组在内存中查找位置满足公式:

a[i]_address = base_address + i*data_type_size

这里的base_address代表数组头节点内存地址,i表示为下标,data_type_size表示每个节点的大小,所以通过这个公式计算,可以快速定位到指定下标的元素,这也是为什么数组下标第一个是0的原因了(减少一次减法运算,提高效率)。

线性链表:

对于链表的新增、删除等操作,只需要处理节点之间的引用即可,这样的话时间复杂度就是O(1),而查找操作需要遍历链表注意进行比对,复杂度为O(n)。

二叉树:

对于一颗相对平衡的有序二叉树,对其进行插入,查找,删除等操作,平均复杂度均为O(logn)。

哈希表:

相比于以上数据结构,在哈希表中进行添加、删除、查找等操作,性能十分高,在不考虑哈希冲突的情况下。仅需要一次定位即可完成,时间复杂度为O(1).

哈希表的主体是一个数组,一般被称为bucket数组,在这个bucket中存放着我们需要保存的数据元素,在我们进行保存的时候,通过对数据hash值与bucket数组长度之间进行运算,可以得到当前元素要存储的位置。也就是说通过某种函数映射,将元素映射到bucket中的特定位置上,有了这个位置我们就可以进行快速的数据查找定位。

2、HashMap的数据结构

数据结构

HashMap最早出现在JDK 1.2中,底层基于散列算法实现。HashMap允许null键和null值,在计算null键的哈希值时,null键哈希值为0。HashMap并不保证键值对的顺序,这意味着在进行某些操作之后,键值对的顺序可能会发生变化。另外,HashMap是非线程安全的类,在多线程环境下可能存在问题。

jdk1.8之前采用的是数组+链表的数据结构,每个元素都是一个Entry结点,包含key、value、hash值、指向下一个元素的next指针四个属性。jdk1.8之后采用的是数组+链表或者是红黑树的数据结构,每一个元素都是一个Node结点,Node实现了Entry接口,Node有一个子类TreeNode,代表树结点。




HashMap的工作原理(二):HashMap中的数据结构与原理_redis



散列算法

在进行增删查等操作时,首先要定位到元素在数组(bucket数组)中的位置,而定位这个位置的方法就是“散列算法”,也叫哈希函数。

散列算法:我们可以看到在hashmap中要找到某个元素,需要根据key的hash值来求得对应数组中的位置。如何计算这个位置就是hash算法。前面说过hashmap的数据结构是数组和链表的结合,所以我们当然希望这个hashmap里面的元素位置尽量的分布均匀些,尽量使得每个位置上的元素数量只有一个,那么当我们用hash算法求得这个位置的时候,马上就可以知道对应位置的元素就是我们要的,而不用再去遍历链表。

这个算法在HashMap的实现方法是用hashcode对数组长度进行取模运算,也就是:

index = hashcode % length(举例来说 定位元素35在桶数组中的位置:index = 35(取其hash)% 16(hashMap初始化长度)

图中的查询方法简单举例就是 index = 35 % 16 = 3 然后再在3号桶所指向的链表中继续查找,35就在链表中.

散列冲突

散列冲突也叫哈希碰撞、哈希冲突。正如万事无完美,如果两个不同的元素,通过散列算法计算出的存储地址相同该怎么办呢?也就是说,我们在对元素进行散列算法确定位置之后,发现当前位置上已经有了元素这就是我们所说的散列冲突。

解决方法:

1.开放寻址法开放寻址的思想就是,出现了散列冲突之后,我们就重新找到一个空闲位置将其插入,重新探测位置的方法有很多,比如,线性探测(Linear Probing)线性探测: 当我们往散列表中插入数据时,如果某个数据经过散列函数散列之后,存储位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。对于线性探测的查找有点儿类似插入过程。我们通过散列函数求出要查找元素的键值对应的散列值,然后比较数组中下标为散列值的元素和要查找的元素。如果相等,则说明就是我们要找的元素;否则就顺序往后依次查找。如果遍历到数组中的空闲位置,还没有找到,就说明要查找的元素并没有在散列表中。

二次探测(Quadratic probing)双重散列(Double hashing)

2.链表法 链表法是一种更加常用的散列冲突解决办法,相比开放寻址法,它要简单很多。我们来看这个图,在散列表中,每个“桶(bucket)”或者“槽(slot)”会对应一条链表,所有散列值相同的元素我们都放到相同槽位对应的链表中。插入的时候,我们只需要通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,所以插入的时间复杂度是 O(1)。当查找、删除一个元素时,我们同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除。 我们在HashMap中采用的正是链表法,而且在链表过长时还会转化成为红黑树,来保证效率。

3、HashMap中的数据结构实现

Bucket数组


/**
* The table, initialized on first use, and resized as
* necessary. When allocated, length is always a power of two.
* (We also tolerate length zero in some operations to allow
* bootstrapping mechanics that are currently not needed.)
*/
transient Node<K,V>[] table;


Bucket数组就是一个Node<K,V>类型的数组,使用了​​transient​​关键字修饰

1.一旦变量被transient修饰,变量将不再是对象持久化的一部分,该变量内容在序列化后无法获得访问。2.transient关键字只能修饰变量,而不能修饰方法和类。注意,本地变量是不能被transient关键字修饰的。变量如果是用户自定义类变量,则该类需要实现Serializable接口。3.一个静态变量不管是否被transient修饰,均不能被序列化。

在HashMap源码中,我们经常可以看到transient修饰其固有属性,大概是为了序列化的安全性。

Node<K, V>


/**
* Basic hash bin node, used for most entries. (See below for
* TreeNode subclass, and in LinkedHashMap for its Entry subclass.)
*/
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}


Node类是HashMap的一个静态内部类,实现了Map.Entry<K, V>接口。

从代码中我们可以看到,Node的属性有K的Hash值,具体的Key、Value以及指向下一个Node的Next指针。

同时,Node也对其父接口中的抽象方法进行了具体的实现.

EntrySet

通常我们在遍历一个Map的时候,通常直接使用使用它的​​entrySet()​​方法进行遍历,比如:


HashMap<String, String> hashMap = new HashMap<>();
//set sth into hashmap ...
for (Map.Entry<String, String> stringStringEntry : hashMap.entrySet()) {
System.out.println(stringStringEntry.getKey());
System.out.println(stringStringEntry.getValue());
}


在HashMap的源码中,我们可以看到entrySet()方法,如下:


/**
* Returns a {@link Set} view of the mappings contained in this map.
* The set is backed by the map, so changes to the map are
* reflected in the set, and vice-versa. If the map is modified
* while an iteration over the set is in progress (except through
* the iterator's own <tt>remove</tt> operation, or through the
* <tt>setValue</tt> operation on a map entry returned by the
* iterator) the results of the iteration are undefined. The set
* supports element removal, which removes the corresponding
* mapping from the map, via the <tt>Iterator.remove</tt>,
* <tt>Set.remove</tt>, <tt>removeAll</tt>, <tt>retainAll</tt> and
* <tt>clear</tt> operations. It does not support the
* <tt>add</tt> or <tt>addAll</tt> operations.
*
* @return a set view of the mappings contained in this map
*/
public Set<Map.Entry<K,V>> entrySet() {
Set<Map.Entry<K,V>> es;
return (es = entrySet) == null ? (entrySet = new EntrySet()) : es;
}


这里就不得不让人发觉一个问题,在单纯调用entrySet这个方法的时候,只是返回了一个新的entrySet,那么为什么我们仍然可以在entrySet中访问到HashMap的key和value值呢?

我们从EntrySet的源码进行分析:


final class EntrySet extends AbstractSet<Map.Entry<K,V>> {
public final int size() { return size; }
public final void clear() { HashMap.this.clear(); }
public final Iterator<Map.Entry<K,V>> iterator() {
return new EntryIterator();
}
public final boolean contains(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry<?,?> e = (Map.Entry<?,?>) o;
Object key = e.getKey();
Node<K,V> candidate = getNode(hash(key), key);
return candidate != null && candidate.equals(e);
}
public final boolean remove(Object o) {
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>) o;
Object key = e.getKey();
Object value = e.getValue();
return removeNode(hash(key), key, value, true, true) != null;
}
return false;
}
public final Spliterator<Map.Entry<K,V>> spliterator() {
return new EntrySpliterator<>(HashMap.this, 0, -1, 0, 0);
}
public final void forEach(Consumer<? super Map.Entry<K,V>> action) {
Node<K,V>[] tab;
if (action == null)
throw new NullPointerException();
if (size > 0 && (tab = table) != null) {
int mc = modCount;
for (int i = 0; i < tab.length; ++i) {
for (Node<K,V> e = tab[i]; e != null; e = e.next)
action.accept(e);
}
if (modCount != mc)
throw new ConcurrentModificationException();
}
}
}


首先我们要明确一个问题,EntrySet是继承自AbstractSet类的,也就是说,EntrySet本质上是一个Set集合,而对于一个Set集合,我们应当明白:

Set不提供Get方法,所以我们无法直接获取Set中的元素 .
对于一个Set集合,我们想要对他进行遍历,只有两个方法:
1.使用迭代器Iterator进行迭代2.使用增强for循环
而增强for循环,本质上就是使用了迭代器。

由于EntrySet只能被迭代器访问到其中元素,所以我们只需要关注Iterator方法中调用的new EntryIterator()


final class EntryIterator extends HashIterator implements Iterator<Map.Entry<K,V>> {
public final Map.Entry<K,V> next() { return nextNode(); }
}
final Node<K,V> nextNode() {
Node<K,V>[] t;
Node<K,V> e = next;
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
if (e == null)
throw new NoSuchElementException();
if ((next = (current = e).next) == null && (t = table) != null) {
do {} while (index < t.length && (next = t[index++]) == null);
}
return e;
}


EntryIterator迭代器的next()方法,是获取HashMap中的next元素,所以实际上在这里迭代到的,是HashMap的元素,这也就是为什么EntrySet是有值的原因.

以上!


原作者:刘常林


HashMap的工作原理(二):HashMap中的数据结构与原理_数据结构_02