哈希 哈希函数 哈希表
Hash
Hash又称散列,散列是与排序相反的一种操作,排序是将集合中的元素按照某种方式比如字典顺序排列在一起,而散列通过计算哈希值,打破元素之间原有的关系,使集合中的元素按照散列函数的分类进行排列。
哈希 其实是随机存储的一种优化,先进行分类,然后查找时按照这个对象的分类去找。 哈希通过一次计算大幅度缩小查找范围,自然比从全部数据里查找速度要快。
为什么要有Hash?
通常使用数组或者链表来存储元素,一旦存储的内容数量特别多,需要占用很大的空间,而且在查找某个元素是否存在的过程中,数组和链表都需要挨个循环比较,而通过 哈希 计算,可以大大减少比较次数。
哈希函数
散列技术是在记录的存储位置和它的关键字之间建立一个确定的对应关系f,使得每个关键字key对应一个存储位置f(key)。把这种对应关系f称为散列函数(哈希函数)。可表示为:address = H [key]
采用散列技术将记录存储在一块连续的存储空间中,这块连续的存储空间称为散列表或哈希表。
采用散列方法进行查找时必须解决的两个问题:
- 如何构造恰当的hash函数,使得结点“分布均匀”,尽量少的产生冲突
- 一旦发生冲突,如何处理
哈希函数的构造方法
- 直接定址法
取关键字或关键字的某个线性函数值为散列地址 类似H(key) = key 或 H(key) = a*key + b,其中a和b为常数。 - 数字分析法
关键字的位数比较大,对关键字的各位分布进行分析,选出分布均匀的任意几位作为散列地址。 - 平方取中法
先计算出关键字值的平方,然后取平方值中间几位作为散列地址。适合于不知道关键字的分布,而位数又不是很大的情况。 - 折叠法
将关键字分为位数相同的几部分,将这几部分叠加求和,并按散列表表长,取后几位作为散列地址。(可将其部分再进行反转),它不需要知道关键字的分布,适合关键字位数较多的情况 - 除留余数法
取关键字被某个不大于散列表长度 m 的数 p 求余,得到的作为散列地址。 H(key) = key % p(通常p为小于或等于表长的最小质数或不包含小于20质因子的合数) - 随机数法
取关键字的随机函数值为它的散列地址。H(key) = random(key),适合关键字的长度不等
解决hash冲突的方法
- 开放定址法
有三种,线性探测再散列,二次探测再散列,伪随机探测再散列
线性探测的公式:fi(key) = (f(key)+di) mod m (di=1,2,3,…,m-1) - 再散列函数法
- 链地址法
将所有关键字为同义词的记录存储在一个单链表中,我们称这种表为同义词子表,在散列表中只存储所有同义词子表的头指针 - 公共溢出区法
HashMap源码
几种Map集合的区别:
- HashMap
由数组+链表+红黑树组成,没有顺序且线程不安全。HashMap允许null值,key和value都可以。HashMap允许key值只能由一个null值 - HashTable
与HashMap实现方式一样,对外提供的函数几乎都是同步的(synchronized关键字修饰),线程安全。.Hashtable不允许null值,key和value都不可以 - TreeMap
按key的大小顺序排序,结构是红黑树 - LinkedHashMap
在HashMap的基础上多了一个双向链表来维持顺序(可按插入或读取顺序),遍历速度比HashMap慢
成员变量
两个重要的参数:容量和负载因子,Capacity:就是bucket(每个链表一个桶)的大小;LoadFactor:就是bucket填满程度的最大比列;如果对迭代性能要求很高的话,不要把capacity设置过大,也不要把load factor设置过小。当bucket中的entries的数目大于capacity*loadfactor时就需要调整bucket的大小为当前的2倍。
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
private static final long serialVersionUID = 362498820763181265L;
// 默认的初始容量16,必须为2的指数幂
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// 最大容量2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认的加载因子0.75,当数量达到 容量 * 负载因子(16*0.75=12) 时,
// 则扩充当前HashMap的容量为当前的2倍,把所有元素rehash再放到扩容后的容器中,这是一个非常耗时的操作
// 加载因子越大,填充的元素越多,空间利用率就越高,但是冲突就越多
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 Node<K,V>[] table;
// 缓存的键值对集合
transient Set<Map.Entry<K,V>> entrySet;
// 键值对的数量
transient int size;
// 修改次数
transient int modCount;
// 阈值,用于判断是否需要调整HashMap的容量(threshold = 容量*加载因子)
// 另外,如果还没有分配表数组,则字段保留初始数组容量,或零表示默认初始容量
int threshold;
// 哈希表的加载因子
final float loadFactor;
}
HashMap的loadFactor为什么是0.75?
HashMap源码中这些常量的设计目的
HashMap的结点
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;
}
}
构造函数
创建一个空的哈希表,指定容量和加载因子
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);
}
// 初始容量为16
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
// ②
putMapEntries(m, false);
}
tableSizeFor()
根据指定的容量设置阈值,经过无数次无符号右移(高位通通补0)、按位或运算,最终的结果是大于等于 cap最小的2的指数幂(1,2,4,…)
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
为什么cap要是2的指数幂
HashMap中存储数据的下标是由key的hash值决定的,我们一般会采用取余来达到数据均匀分布,以避免哈希冲突。计算下标我们一般用:
index = e.hash & (newCap - 1)
那为什么这么做呢?
因为e.hash & (newCap - 1)等价于e.hash % newCap,这个公式成立的条件是除数(即newCap)是2的幂次方
link
putMapEntries()
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();
if (s > 0) {
// 数组为空
if (table == null) { // pre-size
float ft = ((float)s / loadFactor) + 1.0F;
// float转int舍小数
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
// 当计算得到的容量大于threshold,重新计算threshold
if (t > threshold)
threshold = tableSizeFor(t);
}
// 数组不为空(调用putAll()),超过阈值扩容
else if (s > threshold)
// ①
resize();
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
//
putVal(hash(key), key, value, false, evict);
}
}
}
扩容机制
resize()
因为容量总是2^n,扩容总是把长度扩为原来的2倍,所以n-1的二进制数在高位多一位。我们在扩充HashMap的时候,不需要重新计算hash,只需要看原来的hash值新增的那个位是1还是0,是0下标就没变,是1的话下标就变成“原下标+oldCap”
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
if (oldCap > 0) {
// 超过最大值就不再扩充了 2^30
if (oldCap >= MAXIMUM_CAPACITY) {
// 2^31-1
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 没超过最大值,就扩充为原来的两倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
// 旧数组为空,初始容量设置为阈值(阈值大于0)
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
// 旧数组为空,零初始阈值表示使用默认值
else { // zero initial threshold signifies using defaults
// newCap=16,newThr=12
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 计算新的resize上限(针对第二种情况?)
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;
/*
上面一顿操作,最后总结就是:
如果是最开始还没有元素的情况:
1、如果初始化的时候带了参数
(HashMap(int initialCapacity, float loadFactor)),
那么newCap就是你的initialCapacity参数(转换为2^n)
threshold就是 (int)(initialCapacity*loadFactor)
2、否则就按默认的算 initialCapacity = 16,threshold = 12
如果已经有元素了,那么直接扩容2倍,如果
oldCap >= DEFAULT_INITIAL_CAPACITY了,那么threshold也扩大两倍
*/
if (oldTab != null) {
// 将原来map中非null元素rehash之后再放到newTab里
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
// 如果这个oldTab[j]就一个元素,就直接放到newTab中
if (e.next == null)
// 重新计算下标
newTab[e.hash & (newCap - 1)] = e;
// 如果原来这个节点已经转化为红黑树了
// 那么我们去将树上的节点rehash之后根据hash值放到新地方
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;
// 检测到hash值新增的那个bit是0
// 所以还是原下标
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
// 检测到hash值新增的那个bit是1
// 原下标+oldCap
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;
}
链表的头有值
put()
hashcode:1、Object类的hashCode.返回对象的内存地址经过处理后的结构,由于每个对象的内存地址都不一样,所以哈希码也不一样。
2、String类的hashCode.根据String类包含的字符串的内容,根据一种特殊算法返回哈希码,只要字符串所在的堆空间相同,返回的哈希码也相同。(字符串相同)
3、Integer类,返回的哈希码就是Integer对象里所包含的那个整数的数值,例如Integer i1=new Integer(100),i1.hashCode的值就是100 。由此可见,2个一样大小的Integer对象,返回的哈希码也一样。
hashcode
public V put(K key, V value) {
// 对key的hashCode()做hash ①
return putVal(hash(key), key, value, false, true);
}
// onlyIfAbsent为false
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// table为空则调用resize()创建
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 如果该位置的数组为空,就创建一个
if ((p = tab[i = (n - 1) & hash]) == null)
// next=null
tab[i] = newNode(hash, key, value, null);
// 如果该位置已经有元素了
else {
Node<K,V> e; K k;
// 如果节点存在,用e记下这个节点
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 该链为树
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
// 该链为链表
else {
for (int binCount = 0; ; ++binCount) {
// 如果是最后一个元素,就插到最后
if ((e = p.next) == null) {
p.next = newNode(hash, key, value, null);
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
// 树化①
treeifyBin(tab, hash);
break;
}
// 如果找到key值相同的,e记下这个结点
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 替换e的值
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;
}
put函数大致的思路为:
- 对key的hashCode()做hash,然后再计算index;
- 如果没碰撞直接放到bucket里;
- 如果碰撞了,以链表的形式存在buckets后;
- 如果碰撞导致链表过长(大于等于TREEIFY_THRESHOLD= 8),就把链表转换成红黑树;长度低于6,就把红黑树转回链表
- 如果节点已经存在就替换old value(保证key的唯一性)
- 如果bucket满了(超过load factor*current capacity),就要resize。
get()
get()思路如下:
- bucket里的第一个节点,直接命中;
- 如果有冲突,则通过key.equals(k)去查找对应的entry
若为树,则在树中通过key.equals(k)查找,O(logn);
若为链表,则在链表中通过key.equals(k)查找,O(n)。
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) {
// 如果头就是要找的节点
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 往后找
if ((e = first.next) != null) {
// 在树中get
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
// 在链表中get
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
在重写equals的方法的时候,必须注意重写hashCode方法,要不然定位不准位置
hash()
计算下标时,先对hashCode进行hash操作,然后再通过hash值计算下标
static final int hash(Object key) {
int h;
// 对key=null进行了判断
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
高16bit不变,hashCode低16bit和高16bit做了一个异或。
一般长度都是2^n,计算下标用的这个公式:
(n - 1) & hash
当n-1为15(0x1111)时,其实散列真正生效的只是低4位的有效位,所以容易碰撞。
仅仅异或一下,既减少了系统的开销,也不会造成的因为高位没有参与下标的计算(table长度比较小时),从而引起的碰撞。
为什么在计算数组下标前,需对哈希码进行二次处理:扰动处理?
加大哈希码低位的随机性,使得分布更均匀,从而提高对应数组存储下标位置的随机性 & 均匀性,最终减少Hash冲突
为什么采用 哈希码 与运算(&) (数组长度-1) 计算数组下标?
根据HashMap的容量大小(数组长度),按需取 哈希码一定数量的低位 作为存储的数组下标位置,从而 解决 “哈希码与数组大小范围不匹配” 的问题
问题
1. 什么时候会使用HashMap?他有什么特点?
是基于Map接口的实现,存储键值对时,它可以接收null的键值,是非同步的,HashMap存储着Entry(hash, key, value, next)对象。
2.为什么用HashMap?
- HashMap 是一个散列桶(数组和链表),它存储的内容是键值对 key-value 映射
- HashMap 采用了数组和链表的数据结构,能在查询和修改方便继承了数组的线性查找和链表的寻址修改
- HashMap 是非 synchronized,所以 HashMap 很快
- HashMap 可以接受 null 键和值(e.hash == hash &&((k = e.key) == key),而 Hashtable 则不能(原因就是 equlas() 方法需要对象,因为 HashMap 是后出的 API 经过处理才可以
3. 你知道HashMap的工作原理吗?
(重要参数)通过hash的方法,通过put和get存储和获取对象。存储对象时,我们将K/V传给put方法时,它调用hashCode计算hash从而得到bucket位置,进一步存储Node对象(键对象和值对象),HashMap会根据当前bucket的占用情况自动调整容量(超过Load Facotr则resize为原来的2倍)。获取对象时,我们将K传给get,它调用hashCode计算hash从而得到bucket位置,并进一步调用equals()方法确定键值对。如果发生碰撞的时候,Hashmap通过链表将产生碰撞冲突的元素组织起来,在Java 8中,如果一个bucket中碰撞冲突的元素超过某个限制(默认是8),则使用红黑树来替换链表,从而提高速度。
4. 你知道get和put的原理吗?equals()和hashCode()的都有什么作用?
通过对key的hashCode()进行hashing,并计算下标( (n-1) & hash),从而获得buckets的位置。如果产生碰撞,则利用key.equals()方法去链表或树中去查找对应的节点
5. 你知道hash的实现吗?为什么要这样实现?
在Java 1.8的实现中,是通过hashCode()的高16位异或低16位实现的:(h = k.hashCode()) ^ (h >>> 16),主要是从速度、功效、质量来考虑的,这么做可以在bucket的n比较小的时候,也能保证考虑到高低bit都参与到hash的计算中,同时不会有太大的开销。
6. HashMap线程安全吗?为什么?如何在线程安全的前提下使用 HashMap?
线程不安全,并发时可能出现的问题主要是两方面:一方面如果多个线程同时使用put方法添加元素,而且假设正好存在两个 put 的 key 发生了碰撞(根据 hash 值计算的 bucket 一样),那么根据 HashMap 的实现,这两个 key 会添加到数组的同一个位置,这样最终就会发生其中一个线程的 put 的数据被覆盖。
另一方面如果多个线程同时检测到元素个数超过数组大小* loadFactor ,这样就会发生多个线程同时对 Node 数组进行扩容,都在重新计算元素位置以及复制数据,但是最终只有一个线程扩容后的数组会赋给 table,也就是说其他线程的都会丢失,并且各自线程 put 的数据也丢失。
HashMap 在并发执行扩容操作时会引起死循环,导致 CPU 利用率接近100%。因为多线程会导致 HashMap 的 Node 链表形成环形数据结构,一旦形成环形数据结构,Node 的 next 节点永远不为空,就会在获取 Node 时产生死循环。(1.7的版本用的头插所以造成此问题还没有红黑树,1.8版本不会(尾插))
如何线程安全的使用 HashMap?
下面这三种方式:
//Hashtable
Map<String, String> hashtable = new Hashtable<>();
//synchronizedMap
Map<String, String> synchronizedHashMap = Collections.synchronizedMap(new HashMap<String, String>());
//ConcurrentHashMap
Map<String, String> concurrentHashMap = new ConcurrentHashMap<>();
- Hashtable
HashTable 源码中是使用 synchronized 来保证线程安全的
public synchronized V get(Object key) {
// 省略实现
}
public synchronized V put(K key, V value) {
// 省略实现
}
- ConcurrentHashMap
性能最好,1.8版本抛弃了原有的 Segment 分段锁,采用了CAS + synchronized 来保证并发安全性。其中的 val next 都用了 volatile 修饰,保证了可见性。
原来的将数据分为多个segment(默认16个),然后每次操作对一个segment 加锁,HashTable 在竞争激烈的并发环境下表现出效率低下的原因是,由于所有访问HashTable的线程都必须竞争同一把锁,而ConcurrentHashMap 将数据分到多个segment 中(默认16,也可在申明时自己设置,不过一旦设定就不能更改,扩容都是扩充各个segment 的容量),每个segment 都有一个自己的锁,只要多个线程访问的不是同一个segment 就没有锁争用,就没有堵塞,也就是允许16个线程并发的更新而尽量没有锁争用。
ConcurrentHashMap 的segment 就类似一个HashTable,但比HashTable 更加优化,前面说过HashTable对get,put,remove 方法都会使用锁,而ConcurrnetHashMap 中get 方法是不涉及到锁的
一个ConcurrentHashMap实例中包含由若干个Segment实例组成的数组,而一个Segment实例又包含由若干个桶,每个桶中都包含一条由若干个 HashEntry 对象链接起来的链表
- Synchronized Map
在 SynchronizedMap 类中使用了 synchronized 同步关键字来保证对 Map 的操作是线程安全的。
// synchronizedMap方法
public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) {
return new SynchronizedMap<>(m);
}
// SynchronizedMap类
private static class SynchronizedMap<K,V>
implements Map<K,V>, Serializable {
private static final long serialVersionUID = 1978198479659022715L;
private final Map<K,V> m; // Backing Map
final Object mutex; // Object on which to synchronize
SynchronizedMap(Map<K,V> m) {
this.m = Objects.requireNonNull(m);
mutex = this;
}
SynchronizedMap(Map<K,V> m, Object mutex) {
this.m = m;
this.mutex = mutex;
}
public int size() {
synchronized (mutex) {return m.size();}
}
public boolean isEmpty() {
synchronized (mutex) {return m.isEmpty();}
}
public boolean containsKey(Object key) {
synchronized (mutex) {return m.containsKey(key);}
}
public boolean containsValue(Object value) {
synchronized (mutex) {return m.containsValue(value);}
}
public V get(Object key) {
synchronized (mutex) {return m.get(key);}
}
public V put(K key, V value) {
synchronized (mutex) {return m.put(key, value);}
}
public V remove(Object key) {
synchronized (mutex) {return m.remove(key);}
}
// 省略其他方法
}
7. 如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?
如果超过了负载因子(默认0.75),则会重新resize一个原来长度两倍的HashMap,并且重新计算下标。
8. 为什么 String、Integer 这样的 wrapper 类适合作为键?
因为 String 是 final,而且已经重写了 equals() 和 hashCode() 方法了。不可变性是必要的,因为为了要计算 hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的 hashcode 的话,那么就不能从 HashMap 中找到你想要的对象。