Java集合(5)一 HashMap与HashSet

时间:2021-02-12 13:20:49

目录

Java集合(1)一 集合框架

Java集合(2)一 ArrayList 与 LinkList

Java集合(3)一 红黑树、TreeMap与TreeSet(上)

Java集合(4)一 红黑树、TreeMap与TreeSet(下)

Java集合(5)一 HashMap与HashSet

引言

HashMap<K,V>和TreeMap<K,V>都是从键映射到值的一组对象,不同的是,HashMap<K,V>是无序的,而TreeMap<K,V>是有序的,相应的他们在数据结构上区别也很大。

HashMap<K,V>在键的数据结构上采用了数组,而值在数据结构上采用了链表或红黑树这两种数据结构。

Java集合(5)一 HashMap与HashSet

HashSet<K,V>同HashMap<K,V>的关系与TreeSet<E>同TreeMap<K,V>的关系类似,在内部实现上也是使用了HashMap<K,V>的键集,这点我们同样通过HashSet<K,V>的构造函数可以发现。所以在文章中只会详细解说HashMap<K,V>,对HashSet<K,V>就不做分析。

public HashSet() {
map = new HashMap<>();
} public HashSet(int initialCapacity) {
map = new HashMap<>(initialCapacity);
}

框架结构

HashMap<K,V>在继承结构上和TreeMap<K,V>类似,都继承自AbstractMap<K,V>,同时也都实现了Map<K,V>接口,所以在功能上区别不大,不同的是实现功能的底层数据结构。同时由于HashMap<K,V>是无序的,没有继承自SortedMap<K,V>,相应的少了一些根据顺序查找的功能。

Java集合(5)一 HashMap与HashSet

Java集合(5)一 HashMap与HashSet

哈希

在分析HashMap<K,V>的具体实现之前,先来看下什么是哈希?

哈希又叫“散列”,是将任意对象或者数据根据指定的哈希算法运算之后,输出一段固定长度的数据,通过这段固定长度的数据来作为这个对象或者数据的特征,这就是哈希。这句话可能比较绕口,举个例子。

在一篇文章中有10000个单词,需要查找这10000个单词中是否存在“hello”这个单词,最直观的办法当然是遍历这个数组,每个单词跟“hello”进行比较,最坏的情况下可能要比较10000次才能找到需要的结果,如果这个数组无限大,那要比较的次数就会无限上升。那有没有更快速的查找途径呢?

答案就是哈希表。首先将这10000个单词根据一种指定的哈希算法计算出每个单词的哈希值,然后将这些哈希值映射到一个长度为100的数组内,如果映射足够均匀的话大概数组的每个值对应100个单词,这样我们在查找的时候只需要计算出“hello”的哈希值对应在数组中的索引,然后遍历这个位置中对应的100个单词即可。当映射的数组足够大,比如10000,哈希算法足够好,映射一对一,每个哈希值都不相同,这样理论上最优可以在一次查找就得道想到的结果,最坏的查找次数就是数组的每个位置所对应的单词数。这样相比较直接遍历数组要快速的多。

哈希可以大大提高查找指定元素的效率,但受限于哈希算法的好坏。一个好的哈希算法可以将元素均匀分布在固定长度的数组中,相应的如果算法不够好,对性能就会产生很大影响。

那有没有一个算法可以让任意一个给定的元素,都输出一个唯一的哈希值呢?答案是暂时没有发现这样的算法。如果不能每个元素都对应到一个唯一的哈希值,就会产生多个元素对应到一个哈希值的情况,这种情况就叫“哈希冲突”。

哈希冲突

下图中通过一个简单的哈希算法,每个单词取首字母哈希时,air和airport哈希值一样就产生了哈希冲突。

Java集合(5)一 HashMap与HashSet

还是用之前的例子,当10000个单词存放于一个长度为100的数组中时,如果哈希算法足够好,单词分布的足够均匀,每个哈希值就会对应100个左右的元素,也就是每个位置会发生100次左右的哈希冲突。尽管我们可以通过提高数组长度来减小冲突的概率,比如将100变为10000,这样有可能会一个元素对应一个哈希值。但如果需要存储的单词量足够大的情况下,无论数组多大都可能不够用,同时很多时候内存或者硬盘也不可能无限扩大。哈希算法也不能保证2个不同元素的哈希值一定不相同,这时哈希冲突就不可避免,就需要想办法来解决哈希冲突。

一般解决哈希冲突有两种通用的办法:拉链法和开放定址法。

拉链法顾名思义就是将同一位置出现冲突的所有元素组成一个链表,每出现一次冲突,就将新的元素放置在链表末尾。当通过元素的哈希值查找到指定位置时会返回一个链表,再通过循环链表来查找完全相等的元素。

开放定址法就是当冲突出现时,直接去寻找下一个空的散列地址,将值存入其中即可。当散列数组足够大,总会有空的地址,空地址不够用时,可以扩大数组容量。

在HashMap<K,V>中使用的是第一种的拉链法。

构造函数

在HashMap<K,V>中有几个重要字段。

Node<K,V>[] table,这个数组用来存储哈希值以及哈希值对应的元素,又叫哈希桶数组。

loadFactor是默认的填充因子,当哈希桶数组中存储的元素达到填充因子乘以哈希桶数组总大小时就需要扩大哈希桶数组的容量。比如桶数组长度为16当存储的数量达到16*0.75=12时则要扩大哈希桶数组的容量。一般取默认的填充因子DEFAULT_LOAD_FACTOR = 0.75,不需要更改。

public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable  {
//默认填充因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//哈希桶数组
transient Node<K,V>[] table; 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);
} //默认填充因子 threshold(第一次临界值为转换后的容量大小)
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
} //默认填充因子 threshold临界值为0
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
}

在构造函数中有个tableSizeFor方法,这个方法是用来将输入的容量转换为2的整数次幂,这样无论输入的数值是多少,我们都会得到一个2的整数次幂长度的哈希桶数组。比如输入13,返回16,输入120返回128。

static final int tableSizeFor(int cap) {
//避免出现输入8变成16这种情况
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
//低位全变为1之后,进行n + 1可以将低位全变为0,得到2的幂次方
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

通过对输入的参数001x xxxx xxxx xxxx位移1位后0001 xxxx xxxx xxxx与原值进行或运算,得到0011 xxxx xxxx xxxx,最高位的1与低一位都变为1。

位移2位后0000 11xx xxxx xxxx与原值0011 xxxx xxxx xxxx进行或运算,得到0011 11xx xxxx xxxx,最高2位的1与低2位都变为1。

位移4位后0000 0011 11xx xxxx与原值0011 11xx xxxx xxxx进行或运算,得到0011 1111 11xx xxxx,最高4位的1与低4位都变为1。

位移8位后0000 0000 0011 1111与原值0011 1111 11xx xxxx进行或运算,得到0011 1111 1111 1111,最高8位的1与低8位都变为1。

位移16位类似。结果就是从最高位开始所有后面的位都变为了1。然后n + 1,得到0100 0000 0000 0000。

可以看下面的例子:

当输入13时:

Java集合(5)一 HashMap与HashSet

当输入118时:

Java集合(5)一 HashMap与HashSet

这里要注意n = cap - 1,为什么要对输入参数减一,是为了避免输入2的幂次方时容量会翻倍,比如输入8时如果不进行减一的操作,最终会输出16,读者可以自行测试。

哈希值

那为什么一定要用2的整数次幂来初始化哈希桶数组的长度呢?这就要说到哈希值的计算问题。

在HashMap<K,V>中计算元素的哈希值代码如下:

static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

在这段代码就是用来获取哈希值的,其中首先获取了key的hashCode,这个hashCode如果元素有重新实现hashCode函数则会使用自己实现的hashCode,在没有自己实现时,hashCode函数大部分情况下会返回元素在内存中的地址,但也不是绝对的,需要根据各个JVM的内在实现来判断,但大部分实现就算没直接使用内存地址,也和内存地址存在一定的关联。

在获取到key的hashCode之后将hashCode的值的低16位和hashCode的高16位进行异或运算,这就是这个函数非常巧妙的地方,异或运算会同时采用高16位和低16位所有的特征,这样就大大增加了低位的随机性,在取索引的时候tab[(n - 1) & hash],将包含所有特征的哈希值和哈希桶长度减1进行与运行,可以得到哈希桶长度的低位值。

Java集合(5)一 HashMap与HashSet

使用2的整数次幂可以很方便的通过tab[(n - 1) & hash]获取到哈希桶所需要的低位值,由于低位和高位进行了异或运算,保留了高低位的特征,也就减少了哈希值冲突的可能性。这就是为什么这里会使用2的整数次幂来初始化哈希桶数组长度的原因。

添加元素

通过HashMap<K,V>在添加元素的过程,可以发现HashMap<K,V>使用了数组+链表+红黑树的方式来存储数据。

当添加元素过程中出现哈希冲突时会在冲突的位置采用拉链法生成一个链表来存储冲突的数据,如果同一位置冲突的数据量大于8则会将哈希桶数组扩容或将链表转换成红黑树来存储数据。同时,在每次添加完数据后,都会检查哈希桶数据的容量,超出临界值时会扩容。

对红黑树不太理解的可以查看前两篇文章。

public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
} final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//哈希桶数组为空时,通过resize初始化哈希桶数组
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
//哈希值所对应的位置为空,代表不会产生冲突,直接赋值即可
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
//产生哈希冲突
Node<K,V> e; K k;
//如果哈希值相等,并且key也相等,则直接覆盖值
if (p.hash == hash && ((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//p为红黑树 使用红黑树逻辑进行添加(可以查看TreeMap)
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//p为链表
else {
for (int binCount = 0; ; ++binCount) {
//查找到链表末尾未发现相等元素则直接添加到末尾
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
//链表长度大于8时,扩容哈希桶数组或将链表转换成红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//遍历链表过程中存在相等元素则直接覆盖value
if (e.hash == hash && ((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//覆盖value
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//哈希桶中的数据大于临界值时扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
} final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
//哈希桶数组小于64则扩容哈希桶数组
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
//将所有Node<K,V>节点类型的链表转换成TreeNode<K,V>节点类型的链表
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
//将TreeNode<K,V>链表转换成红黑树
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}

扩容

添加元素的过程中,以下2种情况会出现扩容:单个哈希桶存储超过8个元素会检查哈希桶数组,如果整个哈希桶数组容量小于64则会进行扩容;在每次添加完元素后也会检查整个哈希桶数组容量,超过临界值也会进行扩容。扩容源码分析如下:

final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
//哈希桶数组已经初始化 则直接向左位移1位 相当于扩容一倍
if (oldCap > 0) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//向左位移1位 扩容一倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY && oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//哈希桶数组未初始化 并且已经初始化容量
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
//哈希桶数组未初始化 并且未初始化容量 则使用默认容量DEFAULT_INITIAL_CAPACITY
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//初始化哈希桶数组容量以及临界值
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//初始化哈希桶数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
//将所有元素拷贝到新哈希桶数组中
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
//不存在哈希冲突,重新计算哈希值并拷贝
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
//冲突结构为红黑树
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
//冲突结构为链表
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
//扩容后最高位为0,则不需要移动到新的位置
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
//扩容后最高位为1,则需要移动
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
//对哈希值改变的节点移动到新的位置
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}

在扩容的过程中,有一个非常巧妙的地方,因为扩容后每个元素的哈希值需要重新计算并放入新的哈希桶数组中,在哈希值计算的过程中,由于是乘以2来扩容的,也就是整数次幂。

这样在每次扩容后会多使用一位特征,这样当多使用的这一位特征为0时((e.hash & oldCap) == 0),哈希值其实是没有变化的,就不需要移动,这一位特征为1时,只需要将位置移动旧的容量大小的即可(newTab[j + oldCap] = hiHead),这样就可以减少移动元素的次数。红黑树和链表结构都是如此。

Java集合(5)一 HashMap与HashSet

Java集合(5)一 HashMap与HashSet

查找元素

明白HashMap<K,V>的插入以及扩容原理,再来看查找就非常容易理解了,只是简单的通过在链表或者红黑树中查找到相等的值即可。

在查找中一个值是否是我们需要的值,首先是通过hash来判断,如果hash相等再通过==或者equals来来判断。

public V get(Object key) {
Node<K,V> e;
//计算哈希值
return (e = getNode(hash(key), key)) == null ? null : e.value;
} final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
//哈希值相等,并且key也相等,则返回查找到的值
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
//哈希值存在冲突,第一个不是要找的key
if ((e = first.next) != null) {
//冲突结构为红黑树
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
//冲突结构为链表
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}

删除元素

删除元素时首先查找到需要的元素,其次根据查找到元素的数据结构来分别进行删除。

public V remove(Object key) {
Node<K,V> e;
//计算哈希值
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
} final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
//哈希值相等,并且key也相等,则node查找到的节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
//哈希值存在冲突,第一个不是要找的key
else if ((e = p.next) != null) {
//冲突结构为红黑树
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
//冲突结构为链表
else {
do {
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
//节点为红黑树节点,按红黑树逻辑删除
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
//节点为桶中第一个节点
else if (node == p)
tab[index] = node.next;
//节点为后续节点
else
p.next = node.next;
++modCount;
--size;
afterNodeRemoval(node);
return node;
}
}
return null;
}

HashMap的Key

讲解完整个HashMap的实现,我们可以发现大部分情况下影响HashMap性能最核心的地方还是在哈希算法上面。尽管理论上HashMap在添加、删除和查找上的时间复杂度都可以达到O(1),但在实际应用过程中还受到很多因素影响,有时候时间复杂度为O(1)的HashMap可能比,时间复杂度为O(log n)的TreeMap性能更差,原因就在哈希算法上面。

如果使用一个对象默认的哈希算法,前面我们说过,大部分JVM哈希算法的实现都和内存地址有直接关系,为了减小碰撞的概率,可能哈希算法极其复杂,复杂到影响效率的程度。所以在实际使用过程中,需要尽量使用简单类型来作为HashMap的Key,比如int,这样在进行哈希时可以大大缩短哈希的时间。如果使用自己实现的哈希算法,在使用前需要先测试哈希算法的效率,减小对HashMap性能的影响。

总结

Java集合系列到这里就结束了,整个系列从集合整体框架说到了几个常用的集合类,当然还有很多没有说到的地方,比如Queue,Stack,LinkHashMap等等。虽然这是对自己Java学习过程中的总结,但也希望这个集合系列对大家理解Java集合有一定帮助,如果文章中有错误、疑问或者需要完善地方,希望大家不吝指出。接下来打算对java.util.concurrent包下的内容做一个系列进行系统总结,有什么建议也可以留言给我。