HashMap的工作方式
HashMap 在Map.Entry 静态内部类实现中存储key-value 对HashMap 使用哈希算法,在put() 和get() 方法中,使用了hashCode() 和equals() 方法
通过传递key-value 对调用put() 方法时 ,HashMap 使用key hashCode() 和哈希算法找到存储key-value 对的索引 .Entry 存储在LinkedList 中,如果存在Entry, 会使用equals() 方法来检查传递的key 是否存在.如果存在,就会覆盖value. 如果不存在,就会创建一个新的Entry 来保存 通过传递key 调用get() 方法时,再次使用key hashCode() 方法来找到数组中的索引,然后使用equals() 方法找出正确的Entry 并返回Entry 的值
HashMap的实现原理
Java 中的数据结构映射定义了接口java.util.Map, 接口有以下四个常用的实现类:
HashMap:
HashMap 根据键的hashCode 值存储数据,通常可以根据键的hashCode 值直接定位到键对应的值,从而具有很快的访问速度HashMap 中遍历的顺序是不确定的HashMap 中最多只允许一条数据的键为null, 可以允许多条数据的值为null HashMap 是线程不安全的.在同一时刻允许多个线程同时写入HashMap, 这样就会导致数据的不一致HashMap 如果想要满足线程安全,可以使用Collections 的synchronizedMap() 方法使得HashMap 具有线程安全性,或者使用ConcurrentHashMap Hashtable:
Hashtable 和HashMap 类似,只是Hashtable 继承自Dictionary 类Hashtable 中的键和值都不能为null Hashtable 是线程安全的.在同一时刻只允许一个线程写入Hashtable. 但是Hashtable 的并发性不如引入了分段锁的ConcurrentHashMap
Hashtable 是通过为方法添加synchronized 锁实现线程安全的ConcurrentHashMap 是由Segment 数据结构和HashEntry 数据结构组成的
Segment 是一种可重入锁ReentrantLock, 在ConcurrentHashMap 中作为锁HashEntry 用于存储键值对数据
一个ConcurrentHashMap 包含一个Segment 数组 .Segment 的数据结构和HashMap 类似,是一种数组和链表的结构 一个Segment 中包含一个HashEntry 数组.每个HashEntry 是一个链表结构的元素 一个Segment 守护一个HashEntry 数组中的元素 HashEntry中的数据进行修改时,必须首先获得HashEntry 对应的Segment 锁 分段锁:
分段锁的含义就是用到哪一部分就锁定哪一部分 分段锁就是将整个Map 划分成N 个Segment, 在进行put() 和get() 操作时,根据键的hashCode 值寻找到应该使用哪个Segment. 这个Segment 做到了类似HashTable 的线程安全 ConcurrentHashMap 中的键和值都不能为null 不建议使用HashTable, 在不需要线程安全的场景中,可以使用HashMap. 在需要线程安全的场景中,可以使用ConcurrentHashMap LinkedHashMap:
LinkedHashMap 是HashMap 的一个子类LinkedHashMap 中保存了数据的插入顺序.使用Iterator 遍历LinkedHashMap 时,首先得到的数据一定是首先插入的LinkedHashMap 中可以使用带参的构造函数来按照访问次序进行排序 TreeMap:
TreeMap 实现了SortedMap 接口TreeMap 可以将保存的数据按照键来进行排序.默认是按照键值进行升序排序,也可以指定排序的比较器.使用Iterator 遍历TreeMap 时,得到的数据时排序后的数据如果需要使用排序的映射,建议使用TreeMap. 使用TreeMap 时,键必须实现Comparable 接口或者是在构造TreeMap 时传入自定义的Comparator, 否则会在运行时抛出java.lang.ClassCastException 异常 Map类都是要求映射中的键key是不可变对象:
不可变对象就是这个对象创建后,对象的hashCode 值不会改变 如果对象的hashCode 值发生改变,就很可能无法定位到映射的位置
HashMap的数据结构
HashMap使用数组+链表+红黑树 的数据结构存储数据的 HashMap 的内部数据结构是一个桶数组
每一个桶中存放着一个单链表的头节点 每一个节点中存储着一个键值对Entry HashMap 采用拉链法解决存在的Hash 冲突问题
拉链法: 也就是链地址法,是数组和链表的结合.在每个数组元素上都有一个链表结构,当数据进行Hash之后,得到数组的下标,然后将数据存放到对应下标元素的链表上 Node:
Node 是HashMap 的一个内部类,实现了Map.Entry 接口,本质上就是一个键值对映射
HashMap构造函数
HashMap 中有三个构造函数 : 通常情况下,使用默认的无参构造函数.在能够预估到数据的容量时推荐使用指定容量大小的构造函数
public HashMap ( ) ;
public HashMap ( int initialCapacity) ;
public HashMap ( int initialCapacity, float loadFactor) ;
构造函数中只是设置了几个参数的值,没有对数组和链表进行初始化,在第一次put 操作时才调用resize() 方法初始化数组tab. 这样可以很好的节省空间 HashMap 函数构造过程:
首先,在数组Node[] table 中
length: 初始化长度,默认为16 loadFactor: 负载因子,默认为0.75
默认负载因子0.75是对空间和时间效率的平衡性的选择,不建议修改,只有在时间和空间比较特殊的情况下才需要修改:
内存较多但是对时间效率要求很高: 降低负载因子loadFactor 的值内存紧张但是对时间效率要求不高: 增加负载因子loadFactor 的值,这个值可以大于1 threshold: HashMap 中能够容纳的最大数据量的键值对Node 个数.
t
h
r
e
s
h
o
l
d
=
l
e
n
g
t
h
∗
l
o
a
d
F
a
c
t
o
r
threshold = length * loadFactor
t h r e s h o l d = l e n g t h ∗ l o a d F a c t o r : 定义好数组长度之后,负载因子越大,能够容纳的键值对个数越多
threshold 是对应的数组长度length 和负载因子loadFactor 允许的最大元素数量,超过这个数量HashMap 就会重新扩容resize. 扩容后的HashMap 容量是当前容量的两倍
HashMap重要方法
hash(K)
static final int hash ( Object key) {
int h;
return ( key == null ) ? 0 : ( h = key. hashCode ( ) ) ^ ( h >>> 16 ) ;
}
Java 的HashMap 中,没有直接使用hashcode() 作为HashMap 中的hash 值HashMap 中将hashcode() 的值无符号右移16 位得到一个新值,然后将hashcode() 的值和这个新值进行异或运算得到最终的hash 值保存在HashMap 中. 这样可以避免哈希碰撞
比如容量大小n 为16 时 ,n-1 为15(0x1111), 散列值真正生效的只是低4 位,此时新增的键的hashcode() 的值如果是2,18,34 这样以16 的倍数为差的等差数列时,就会产生大量的哈希碰撞 使用这样的方法,将高16 位和低16 位进行异或,因为大部分hashcode() 的值分布已经很均匀了,即使发生碰撞也用
O
(
l
o
g
n
)
O(logn)
O ( l o g n ) 时间复杂度的红黑树进行了优化.这样通过使用异或的方法,不仅减少了系统开销,也不会因为tab 长度较小时高位没有参与下标的运算引发哈希碰撞
put(K, V)
使用put(K, V) 操作时 ,HashMap 计算键值K的哈希值,然后将这个键值对Entry 放入到HashMap 中对应的桶bucket 上 然后寻找以当前桶为头结点的一个单链表,顺序遍历单链表找到某个节点的Entry 中的key 等于给定的参数K 如果找到则将旧的参数值V 替换为参数指定的V. 否则直接在链表的尾部插入一个新的Entry 节点
if ( e. hash == hash && ( ( k = e. key) == key || ( key != null && key. equals ( k) ) ) )
HashMap 中重写equals() 方法必须也要重写hashcode() 方法:
根据hash值,定位到数组某个位置后,向位置中后面的链表添加元素时,判断元素是否一样中,首先判断hash 值是否相等,然后再判断equals() 如果只对equals() 进行重写,不对hashcode() 进行重写时,依然会按照不同的两个对象处理,所以重写equals() 方法时必须也要重写hashcode() 方法 HashMap 中既要判断hash 值,也要使用equals() 方法判断:
HashMap 中链表结构进行遍历判断时,重写的equals() 方法判断对象是否相等的业务逻辑比较复杂,这样下来的循环遍历判断影响性能HashMap 中将hash 值的判断放在前面,只要hash 值不同,整个条件就是false, 不需要进行equals() 方法判断对象是否相等,提升了HashMap 的性能HashMap 中是根据hashcode() 的值定位到数组的位置的,同一个数组位置中后面的链表中元素的hashcode() 的值都相同.比较hashcode() 的值没有意义,因为必定相等 .HashMap 中没有直接使用hashcode() 的值,用的是对hashcode() 的值进行移位和异或运算后的hash 值,这里比较的是元素的hash 值
resize()
初始化HashMap 时,按照阈值threshold 分配内存 如果HashMap 中的数据记录超过HashMap 的阈值就会进行扩容 扩容时,数组会采用将数组容量大小的值左移一位的算法将将数组扩容至两倍 扩容时,根据数据的hash 值与数组长度进行逻辑与运算,根据运算结果是否为0 来决定数据是不动还是将数组索引位置变更为当前索引位置和原数组长度之和 扩容时不会重新计算hash 值 ,key 的hash 值会保存在数组位置的后面的node 节点元素中
treeifyBin()
数组中单个链表长度超过8, 数组的长度超过64 时才会进行链表结构到红黑树结构的转换,否则只是进行扩容操作 HashMap 中,使用红黑树结构占用空间大,尽可能不使用红黑树结构
get(K)
HashMap 通过计算键的哈希值,寻找到对应的桶bucket, 然后顺序遍历桶bucket 存放的单链表,通过比对Entry 的键找到对应的哈希值如果对应位置后面是红黑树结构就在红黑树结构中查找,如果是链表结构就遍历链表,查询需要找的对象
红黑树遍历的时间复杂度 :
O
(
l
o
g
n
)
O(logn)
O ( l o g n ) 链表遍历的时间复杂度 :
O
(
n
)
O(n)
O ( n )
Hash冲突
Hash冲突:
因为Hash 是一种压缩映射,这样每一个Entry 节点无法对应到一个只属于自身的桶bucket 必然会存在多个Entry 共用一个桶bucket, 拉成一个链条的情况.这种情况就是Hash 冲突 Hash冲突存在的问题:
在Hash 冲突的极端情况下,某一个桶bucket 后面挂着的链表会特别长,导致遍历的效率很低 Hash 冲突无法完全避免,为了提高HashMap 的性能,需要尽量缓解Hash 冲突来缩短每个桶的外挂链表的长度
当HashMap 中存储的Entry 较多时,需要对HashMap 扩容来增加桶bucket 的数量 这样对后续要存储的Entry 来讲,就会大大缓解Hash 冲突
HashMap总结
HashMap中MAXIMUM_CAPACITY设置为1<<30
MAXIUM_CAPACITY:
int 类型,表示HashMap 的最大容量使用 << 移位运算的结果不能超过int 类型表示的最大值 使用1 左移 << 运算时最大只能左移30 位,否则就会溢出 Java 中的int 类型占4 个字节,每个字节占用8 位,所以int 类型占用32 位Java 中的int 类型是有符号的,使用第1 位作为符号位,此时还有31 位,这时使用1 左移只能左移30 位
HashMap中容量设置为2的整数幂次方
通过限制一个数组长度length 为2 的整数幂次方的数,这样使得 (length - 1) & h 和 h % length 的结果是一致的 HashMap 中将容量设置为2 的整数幂次方主要就是为了在取模和扩容时做优化,同时减少冲突
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 &&
( ( k = first. key) == key || ( key != null && key. equals ( k) ) ) )
return first;
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 ;
}
tab[(n - 1) & hash] : 根据hash 值快速定位到数组的位置
数组tab 数组长度n 需要查找的key 对应的值hash
因为数组长度n 设置为2 的整数幂次方,这样初始情况下n-1 转换为2 进制时各个位上都是1 此时使用 & 与对应的值hash 进行运算时的结果就和hash 值一样,也就快速定位到了数组中的位置 使得数组中的数据更加分散,减少碰撞 如果数组长度不是设置为2 的整数幂次方:
数组长度在初始情况下使用n-1 转换为2 进制时,存在0 位,导致很多位置无法放置元素,造成空间浪费 数组的有效使用位置大量减少,增加了碰撞几率,减慢了查询速度
HashMap中的负载因子设置为0.75
泊松分布: Poisson 分布.描述某段时间内,事件具体的发生概率
P
(
X
=
k
)
=
λ
k
k
!
e
−
λ
,
k
=
0
,
1
,
…
(
λ
是
均
值
,
k
为
发
生
次
数
)
P(X=k)=\frac{\lambda^k}{k!}e^{-\lambda},k=0,1,…(\lambda是均值,k为发生次数)
P ( X = k ) = k ! λ k e − λ , k = 0 , 1 , … ( λ 是 均 值 , k 为 发 生 次 数 ) TreeNode 占用的空间是常规节点的两倍,所以只有当箱子bin (数组中的一个桶)中元素的数量超过TREEIFY_THRESHOLD 时才会需要使用TreeNode HashMap 中的hash 值分布比较均匀时,很少使用到TreeNode 在随机hashcode 情况下 ,bin 中节点出现的频率遵循泊松Poisson 分布,此时负载因子为 0.75, 均值
λ
{\lambda}
λ 为 0.5 如果调整负载因子的值,均值
λ
{\lambda}
λ 会出现较大偏差 HashMap 扩容到32 或者64 时,一个箱子bin 中存储8 个数据量的概率为0.00000006. 所以当一个箱子中节点数目大于等于8 个时,可以将HashMap 中桶中的数据从链表结构转换为树结构存储,效果是最好的
HashMap中的元素尽量使用迭代器Iterator遍历
在迭代器Iterator 中使用的fail-fast 策略,在遍历发生线程并发时,可以立即抛出异常 fail-fast 策略:
HashMap 是非线程安全的使用迭代器Iterator过程中,如果其余的线程同时也在修改HashMap, 就会立即抛出ConcurrentModificationException 异常 fail-fast 策略实现:
fail-fast 策略通过modCount 实现modCount 记录修改次数在迭代器初始化过程中将modCount 的值赋值迭代器的expectedModCount 在迭代器迭代过程中,判断modCount 和expectModCount 是否相等.如果不相等就说明有其余线程对HashMap进行了修改
Map < Integer , Integer > hashMap = new HashMap < Integer , Integer > ( ) ;
Iterator < Map. Entry < Integer , Integer > > entries = hashMap. entrySet ( ) . iterator ( ) ;
while ( entries. hasNext ( ) ) {
Map. Entry < Integer , Integer > entry = entries. next ( ) ;
System . out. println ( "KEY = " + entry. getKey ( ) + ", VALUE = " + entry. getValue ( ) ) ;
}
HashMap的使用特点
HashMap 中的扩容是一个特别损耗性能的操作,所以在初始化HashMap 时,应该估算HashMap 的大小,确定一个大致的数值,避免在使用HashMap 时频繁初始化HashMap 是线程不安全的,在并发的环境中建议使用ConcurrentHashMap Java 8 中引入红黑树极大程度地优化了HashMap 的性能.主要体现在哈希算法不均匀时,也就是拉链法中链表很长时,可以将链表转换为红黑树结构,此时算法复杂度由
O
(
n
)
O(n)
O ( n ) 下降为
O
(
l
o
g
n
)
O(logn)
O ( l o g n )