HashMap集合
1. HashMap集合简介
- HashMap基于哈希表的Map接口实现,是以key-value存储形式存在,即主要用来存放键值对。hashMap的实现不是同步的,这就意味着它不是线程安全的。它的key、value都可以为null。此外,HashMap中的映射不是有序的。
JDK1.8之前的HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突(两个对象调用的hashCode方法计算的哈希码值一致导致计算的数组索引值相同)而存在的。
JDK1.8以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(或者红黑树的边界值,默认为8)并且当前数组的长度大于64时,此时索引位置上的所有数据改为使用红黑树存储。
补充:将链表转换成红黑树前会判断,即使阈值大于8,但是数组长度小于64,此时并不会将链表变成红黑树。而是选择进行数组扩容。这样做的目的时因为数组比较小,尽量避开红黑树结构,这种情况下变成红黑树结构,反而会降低效率,因为红黑树需要进行左旋、右旋,变色这些操作来保持平衡。同时数组长度小于64,搜索时间相对要快一些。所以为了提高性能和减少搜索时间,底层在阈值大于8并且数组大于64时,链表才会转为红黑树。具体可以参考treeifyBin方法。
虽然增加了红黑树作为底层数据结构,结构变得复杂了,但是阈值大于8并且数组长度大于64时,链表转换为红黑树时,效率也变得更加高效。
2.HashMap集合底层的数据结构
存储过程:
HashMap<String,Integer> hashMap=new HashMap();
-
当创建HashMap集合对象的时候,在jdk8前,构造方法中创建了一个默认长度是16的Entry[ ]table 用来存储键值对数据的。在jdk8以后不是在HashMap的构造方法底层创建数组了,是在第一次调用put方法时创建数组的,Node [ ]table用来存储键值对的数据。
-
假设想=向哈希表中存储“张三-18”的数据,根据张三调用String类中重写之后的hashCode()方法计算出哈希值,然后结合数组长度采用某种算法计算出Node数组中存储数据空间的索引值。如果计算出的索引空间没有数据,则直接将“张三-18”存储到数组中。
-
向哈希表中存储数据“李四-20”数据,假设李四计算出的hashCode方法结合数长度计算出的索引值与张三一致,那么此时数组空间不是null,此时底层会比较张三和李四的hash值是否一直,如果不一致,则在此空间上画出一个节点来存储键值对数据"李四-20"。
-
假设向哈希表中存储数据“张三-20”,那么首先根据张三调用的hashCode方法结合数组长度计算出索引肯定与张三一致此时比较存储的数据颤三和已经存储的数据的hash值是否相等,如果相等,此时发生哈希冲突。那么底层就会调用张三所属类String中的equals方法比较两个内容是否相等?若相等,则将后添加的数据覆盖之前的value。若不相等,那么继续向下和其他的数据的key进行比较,如果都不相等,在画出一个节点存储数据。
哈希表底层采用何种算法计算hash值?还有哪些算法可以计算出hash值?
底层采用的Key的hashCode方法的值结合数组长度进行无符号右移(>>>)、按位异或(^)、按位与(&)计算出索引,还可以采用:平方取中法、取余数、伪随机数法(这些计算方式效率比较低,而位运算效率要高)
3. HashMap继承关系
说明
- Cloneable 克隆接口,表示可以克隆。创建并返回HashMap对象的一个副本。
- Serializable 序列化接口。属于标记性接口。HashMap对象可以被序列化和反序列化。
- AbstractMap 父类提供了Map实现接口。以最大限度地减少实现此接口所需的工作。
补充:通过上述继承关系我们发现了一个很奇怪的现象,就是HashMap已经继承了AbstractMap 而AbstractMap类实现了Map接口,那么为什么HashMap还要在实现Map接口呢?同样在在ArrayList中LinkedList中都是这种结构。
- 据Java集合框架的创始人Josh Bloch描述,这样的写法是一个失误。 在Java集合框架中,类似这样的写法很多,最开始写Java集合框架的时候,认为这样写,在某些地方是有价值的,后来他意识到错了。显然的,JDK的维护者,不认为这个小小的失误值得去修改,所以就这样保存下来了。
4.HashMap成员变量
- 序列化版本号
private static final long serialVersionUID = 362498820763181265L;
- 集合的初始化容量(必须是2的n次幂)
//默认的初始容量是16相 1<<4相当于1*2的4次方
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
问题:为什么必须是2的n次幂呢,如果不是2的n次幂会怎么样?
- HashMap的构造方法还可以指定集合的初始化容量大小;
HashMap(int initialCapacity) 构造一个带指定容量和默认加载因子(0.75)的空HashMap.
- 当向HashMap中添加一个元素的时候,要根据key的hash值,去确定其在数组中的具体位置。HashMap为了存取高效,要尽量把数据分配均匀,每个链表长度大致相同,这个实现就在把数据存在哪个链表的算法。这个算法实际上就是去模,hash%length,计算机中直接求余效率不如位移运算,所以源码中做了优化,使用hash&(length-1),而实际上hash%length等于hash&(length-1)的前提就是length是2的n次幂。
为什么这样能均匀减少碰撞呢?2的n次方实际上就是1后面呢跟n个0,2的n次方-1实际上就是n个1;
例如长度是8的时候,3&(8-1)=3 2&(8-1)=2,不同位置,不冲突
长度为8的时候,8是2的三次方,二进制是1000;
1000
- 1
-----------------
111
如果创建HashMap对象时,输入的数组长度是10,不是2的次幂,HashMap会通过位运算和或运算得到2的幂次数,并且是大于且最接近的那个数。
//创建HashMap集合的时候,数组长度设置为10(不是2的幂)
HashMap hashmap=new HashMap(10);
public HashMap(int initialCapacity) {//initialCapacity=10
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap(int initialCapacity, float loadFactor) {//initialCapacity
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);
}
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;
}
由此可见,当在实例化HashMap实例时,如果给定了initialCapacity(假设是10),由于HAshMap的Capacity必须是2的幂,因此这个方法用于找到大于等于initialCapacity的最小2的幂。(如果initialCapacity就是2的幂,则不变)
下面分析这个算法
- 为什么要对cap做减1操作。(int n =cap-1;)
这是为了防止,cap已经是2的幂,如果cap已经是2的幂,有没有执行这个减一操作,则执行后面几条无符号右移的操作之后,返回的Capacity僵尸这个cap的2倍。 - 如果n这个时候为0(经过了cap-1之后),则经过后面的几次无符号右移仍然是0,最后返回的Capacity是1(最后有个n+1);
- 注意:|(按位或运算):运算规则:相同的二进制位上,都是0的时候结果为0,否则为1;
第一次右移
第二次右移
第三次右移
这次把已经有的高位中的连续的4个1,右移4位,在做或操作,这样n的二进制表示的高位中正常会有8个连续的1。如0000 1111 1111 xxxx。以此类推。注意,容量最大也就是32bit的整数,因此最后n|=n>>>16;最多也就是32个1(但这已经是负数了。在执行tableSizeFor之前,对initialCapacity做了判断,如果大于MAXIMUM_CAPACITY(230),则取MAXIMUM_CAPACITY。如果等于MAXIMUM_CAPACITY,会执行移位操作,所以这里面的移位操作之后,最大30个1,不会大于等于MAXIMUM_CAPACITY。30个1加1之后得到230次方)
- 默认的加载因子,默认值是0.75
static final float DEFAULT_FACTOR = 0.75f;
- 集合最大容量
//集合最大容量的上限是:2的30次方
static final float MAXIMUM_CAPACITY = 1 << 30;
- 当链表的值超过8则会转为红黑树(JDK1.8新增)
//当桶(bucket)上的结点个数大于这个值时会转成红黑树
static final float TREEIFY_THRESHOLD = 8;
- 当链表的值小于6则会从红黑树转回链表
//当桶(bucket)上的结点小于这个值时树转链表
static final int UNTREEIFY_THRESHOLD = 6;
- 当Map里面的数量超过这个值时,表中的桶才能进行树形化,否则桶内元素太多会扩容,而不是树形化,为了避免惊醒扩容、树形化选择的冲突,这个值不能小于4*TREEIFY_THRESHOLD (8)
//桶中结构转化为红黑树对应数组长度的最小值
static final int MIN_TREEIFY_CAPACITY= 64;
- ** table用来初始化(必须是2的n次幂)**
//存储元素的数组
transient Node<K,V>[] table;
table在JDK1. 8中我们了解到HashMap是由数组加链表加红黑树来组成的结构其中table就是HashMap中的数组,jdk8之前数组类型是Entry<K,V>类型。从jdk1 .8之后是Node<K,V>类型。只是换了个名字,都实现了一样的接口: Map.Entry<K,V> 。负责存储键值对数据的。
- 用来存放缓存
//存放具体元素的集合
transient Set<Map.Entry<K,V>> entrSet;
- HashMap中存放元素的个数
//存放元素的个数,注意这个不等于数组的长度
transient int size;
//size为HashMap中K-V的实时数量,不是数组table的长度。
- 用来记录HashMap的修改次数
//每次扩容和更改map结构的计数器
transient int modCount;
- 用来调整大小下一个容量的值计算方式为(容量*负载因子)
// 临界值 当实际大小(容量*负载因子)超过临界值时,会进行扩容
int threshold;
- 哈希表的加载因子
//加载因子
final float loadFactor;
//loadFactor加载因子,时用来衡量HashMap满的程度,表示HashMap的疏密程度,影响hash操作到同一个数组位置的概率。计算HashMap的实时加载因子的方法为:size/capacity 而不是占用桶的数量去除以capacity。(capacity时桶的个数,也就是table的长度length。)
//loadFactor 太大会导致查找元素效率低,太小岛主数组的利用率低,存放数据会很分散。loadFactor的默认值为0.75,是官方给出的一个比较好的临界值。
//当HashMap里面容纳的元素已经达到HashMap数组长度的75%时,表示HashMap太急了,需要扩容,而扩容这个过程涉及到rehash、复制数据等操作,非常消耗性能,所以在开发中尽量减少扩容的次数,可以通过创建HashMap集合对象时指定初始容量来尽量避免。
//同时在HashMap的构造器中可以定制loadFactor。
//构造方法
HashMap(int initialCapacity,float loadFactor)
//构造一个代指定容量和加载因子的空HashMap
构造方法
HashMap中重要的构造方法,它们分别是
- 构造一个空的HashMap,默认初始容量为(16),默认负载因子(0.75).
public HashMap(){
this.loadFactor = DEFAULT_LOAD_FACTOR; // 将默认的加载因子0.75赋值给loadFactor,并没有创建数组
}
- 构造一个具有指定的初始容量和迷人负载因子(0.75)HashMap。
// 指定“容量大小”的构造函数
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
- 构造一个具有初始容量和负载因子的HashMap.
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);
}
- 包含另一个“Map”的构造函数
//构造一个映射关系与指定map相同的新HashMap。
public HashMap(Map<? extends K, ? extends V> m) {
//负载因子loadFactor变成默认的负载因子0.75;
this.loadFactor = DEFAULT_LOAD_FACTOR;
putMapEntries(m, false);
}
最后调用了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;
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
if (t > threshold)
threshold = tableSizeFor(t);
}
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);
}
}
}
注意: float ft = (float)s / loadFactor) + 1.0F;这一行代码中为什么要加1.0F ?
s/loadFactor的结果是小数,加1.0F与(int)ft相当于 是对小数做一个向, 上取整以尽可能的保证更大容量,更大的容量能够减少resize的调用次数。所以+ 1.0F是为了获取更大的容量。
例如:原来集合的元素个数是6个,那么6/0.75是8, 是2的n次幕,那么新的数组大小就是8了。然后原来数组的数据就会存储到长度是8的新的数组中了,这样会导致在存储元素的时候,容量不够,还得继续扩容,那么性能降低了,而如果+1呢,数组长度直接变为16了,这样可以减少数组的扩容。
成员方法
- 增加方法
put方法是比较复杂的,实现步骤大致如下:
1)先通过hash值计算出key映射到哪个桶;
2)如果桶上没有碰撞冲突,则直接插入;
3)如果出现碰撞冲突了,则需要处理冲突:
a:如果该桶使用红黑树处理冲突,则调用红黑树的方法插入数据; .
b:否则采用传统的链式方法插入。如果链的长度达到临界值,则把链转变为红黑树;
4)如果桶中存在重复的键,则为该键替换新值value;
5)如果size大于阈值threshold, 则进行扩容;
具体的方法如下:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
` ``
```java
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
2.putVal()方法
现在看putVal()方法,看看它到底做了什么。
主要参数:
●hash key的hash值
●key 原始Key
●value要存放的值
●onlylfAbsent如果true代表不更改现有的值
●evict 如果为false表示table为创建状态
putVal()方法源代码如下所示: .
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
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;
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;
}
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = 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;
}
扩容方法resize
●1.什么时候才需要扩容
●2.HashMap的扩容是什么
1.什么时候才需要扩容
当HashMap中的元素个数超过数组大(数组长度)*loadFactor(负载因子)时,就会进行数组扩容,loadFactor的默认值(DEFAULT_ LOAD_ FACTOR)是0.75,这是一个折中的取值。也就是说,默认情况下,数组大小为16,那么当HashMap中的元素个数超过16x0.75=12(这个值就是阈值或者边界值threshold值)的时候,就把数组的大小扩展为2x16=32,即扩大一倍,然后重新计算每个元素在数组中的位置,而这是一个非常耗性能的操作,所以如果我们已经预知HashMap中元素的个数,那么预知元素的个数能够有效的提高HashMap的性能。
补充:
当HashMap中的其中一个链表的对象个数如果达到了8个, 此时如果数组长度没有达到64,那么HashMap会先扩容解决,如果已经达到了64,那么这个链表会变成红黑树,节点类型由Node变成TreeNode类型。当然,如果映射关系被移除后,下次执行resize方法时判断树的节点个数低于6,也会再把树转换为链表。
HashMap的扩容是什么?
- 进行扩容,会伴随着一次重新hash分配,并且会遍历hash表中所有的元素,是非常耗时的,在编写程序中要尽量避免resize。
- HashMap在进行扩容时,使用的rehash方式非常巧妙,因为每次扩容都是翻倍,与原来的(n-1)&hash的结果相比,只是多了一个bit位,所以节点要么就在原来的位置,要么就被分配到“原位置+旧容量”这个位置。
源码
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) {
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
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;
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;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
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;
}
为什么要引入红黑树?
JDK1.8以前的HashMap的是现实数组+链表,即使哈希函数取得再好,也很难达到元素百分百均匀分布。当HashMap中有大量元素都存放在同一个桶中时,这个桶下有一条长长的链表,这个时候HashMap就相当于一个单链表,假如单链表有n个元素,便利的时间复杂度就是o(n),完全失去了它的优势。针对这种情况,JDk1.8中引入了红黑树(查找时间复杂度为o(logn))来优化这个问题。当链表长度很小的时候,即使遍历,速度也非常快,但是当链表长度不断边长,肯定会对查询性能有一定的影响,所以才需要转成树。
为什么阈值是8?
8这个阈值定义在HashMap中,针对这个成员变量,在源码的注释中只说明了8是bin(bin就是bucket(桶))从;i按表转成树的阈值,但是并未说明为什么是8但是在HashMap中有一段注释说明:我们继续往下看:
Because TreeNodes are about twice the size of regular nodes, we
* use them only when bins contain enough nodes to warrant use
* (see TREEIFY_THRESHOLD). And when they become too small (due to
* removal or resizing) they are converted back to plain bins. In
* usages with well-distributed user hashCodes, tree bins are
* rarely used. Ideally, under random hashCodes, the frequency of
* nodes in bins follows a Poisson distribution
* (http://en.wikipedia.org/wiki/Poisson_distribution) with a
* parameter of about 0.5 on average for the default resizing
* threshold of 0.75, although with a large variance because of
* resizing granularity. Ignoring variance, the expected
* occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
* factorial(k)). The first values are:
因为树节点的大小大约是普通节点的两倍,所以我们只在箱子包含足够的节点时才使用树节点。当他们变得太小(由于删除或调整大小)时,就会被转换回普通的桶。在使用分布良好的用户hashcode时,很少使用树箱。理想情况下箱子中节点的频率服从泊松分布总之在权衡空间和时间的复杂度之下,确定阈值为8。