HashMap的组成结构
HashMap结构是由“数组”加“链表”相结合组成,其结构类似于下图的形式。
通过上面的图我们可以直观的看出来,我们要查找一个数据时,有如下步骤:
1. 首先要找到数组对应下标的头部元素,而这个头部元素就是我们的链表的头。
2. 然后我们再根据链表的头部元素往下一个个匹配直到找到我们的想要的数据,或者匹配完也没 找到对应的数据时就返回一个null。
使用数组和链表结合的的优势在哪?
先来说说数组:
优点:
1、按照索引查询元素速度快
2、能存储大量数据
3、按照索引遍历数组方便
缺点:
1、根据内容查找元素速度慢
2、数组的大小一经确定不能改变。
3、数组只能存储一种类型的数据
4、增加、删除元素效率慢
5、未封装任何方法,所有操作都需要用户自己定义。
由此可知,数组查找数据快,但插入数据却需要移动数据,而当数组长度较长时,插入数据的时候,就需要移动相当多的数据,效率极其低下(在尾部插入数据的时候效率高)。也正因为数组插入数据的效率低,所以这里就使用到了另外一个数据结构“链表”。
再来看看链表:
优点:
1、链表无需初始化大小。
2、不必在插入或删除元素后移位元素,我们只需要更新节点下一个指针中的地址。因此易于插入 和删除。
3、内存利用率高。由于链表的大小可以在运行时增加或减少,因此没有内存浪费。
缺点:
1、数组相比,在链表中存储元素需要更多内存。
2、遍历困难,不易于查询。想要访问位置n的节点,那么我们必须遍历它之前的所有节点
总结:
因为HashMap添加数据、修改数据的操作都比较频繁。所以即要保证查找元素的效率的同时也要保证插入元素的效率。
1.在查找数据的时候充分利用数组的优势,定位到数据在哪个下标的链表里。
2.在插入数据的时候充分利用链表的优势,每次插入数据时,把插入的数据放在链表的第一个节 点,其他的节点不需要做任何修改。最终综合两种数据结构的优势提升整体性能。
HashMap的初始化与扩容
存放数据的时候,由上面的结构图我们可知,首先得创建一个数组。而这里当我们没指定数组长度的时候,HashMap会默认给我们初始化一个默认长度为16的数组。(注:数组的长度应为2的倍数,下面有讲解。)
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
接着,我们不断添加往里面数据,可想而知,当数据越来越多的时候,如果数组的长度还是一成不变的话,它的查询,删除等操作效率就会逐渐变低,也就失去了作为它这种结构的优势。因此,得得扩容!!!
什么时候扩容呢?
到达临界值就扩容!
临界值(threshold) = 负载因子(loadFactor) * 容量(capacity)。
loadFactor 是装载因子,表示 HashMap 满的程度,默认值为 0.75,也就是说默认情况下,当 HashMap 中元素个数达到了容量的 3/4 的时候就会进行自动扩容。
/**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
那么为什么负载因子要设置成0.75呢,这个是经过了科学测试的。
设置得太小:导致HashMap空间利用率不高,扩容的频率也会更高,扩容的时候需要把数据重新计算哈希排列,这样会影响性能。
设置得太大:产生hash冲突(下面会介绍)的几率会很高,因为hash冲突的数据会放到同一个链表,这样会加长链表的长度,同样也会影响HashMap的性能。
所以设置为0.75就是最好的选择。
扩容到多少呢?
先给出答案:扩容到原来的一倍,也就是容量乘2(保证了数组的容量为2的次幂!至于原因,下面有答案!)。
当然,也不可能一直扩容,总得有个限制,源码如下:换算即为1073741824
/**
* The maximum capacity, used if a higher value is implicitly specified
* by either of the constructors with arguments.
* MUST be a power of two <= 1<<30.
*/
static final int MAXIMUM_CAPACITY = 1 << 30;
自定义HashMap的大小: 我们知道,我们在使用HashMap的时候是可以自定义其容量大小,比如new HashMap(10);而上面我们说过数组的大小为2的次幂,那么这个时候怎么处理的呢? 调用tableSizeFor()方法,即可得到大于输入参数且最近的2的整数次幂的数。源码如下:你可以拿一个数字自己试一试看是否如此!
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;
}
如何定位一个key会存储到数组的哪个位置?
怎么put元素?
假设现在初始化了一个长度为16的数组,首先我们整理put一个元素到HashMap中的过程: map.put("name","李四");
1、将作为Key的 "name" 经过hashCode()方法得到一个数字:
System.out.println("name".hashCode());
//结果为3373707
2、用这个数和数组的长度-1进行一个位与运算(后面还会改进),其实就相当于取余运算,只不过位与运算效率更高,得到一个1-15的数字。
例如3373707与15的位与运算: 1100110111101010001011 与 1111 位与运算结果为1011,转换为10进制就是11。到此我们就找到了"name"在数组中的位置。
3、最后把数据存储到下标为11的链表里面去,如果链表里面有相同的Key则替换,没有重复的则追加到链表的尾部。
为什么数组长度数组长度为2的倍数,且需要-1?
原因是:只有参与hash(key)的算法的(n-1)的值尽可能都是1的时候,得到的值才是离散的。假如我们当前的数组长度是16,二进制表示是10000,n-1之后是01111,使得n-1的值尽可能都是1,这样进行位与运算时,才保证数据的离散性。因为假如直接用10000来与key的hash值做位与运算,其实得到的结果与key的hash值关联性并不大,例如1101101与10000做位与运算,结果为1100000。而用01111与1101101做位与运算得到结果为1101101,可见得到的结果与key的hash值本身关联性很大。
所以只有当数组的容量长度是2的倍数的时候,且再-1,计算得到的hash(key)的值才有可能是相对离散的。
关于put元素时的hash冲突:
首先,明确什么是hash冲突:就是当多个不同的key经过hashCode()方法的运算后,得到了一样的数字,这就是hash冲突。
会有什么后果?
当运气不好时,hashCode()运算后,出现大量相同的hash值,而数组长度又不变,这就意味有着大量的元素放在了同一个链表下,以至于其他链表中没有放置或者放置很少的元素,这样将会严重影响HashMap的效率。如图
如何减少Hash冲突呢?
所谓减少哈希冲突其实就是让我们的Key能合理均匀的分布到我们数组的各个下标里面,避免元素过于集中。思考一下可知,既然不能对数组做文章,那么就对hash值做文章。看如下代码:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这里做的事就是将HashCode 与HashCode无符号右移16位的值进行异或处理。然后拿这个值来和数组长度-1做位与运算。
为什么这么做呢?
对于HashCode &(HashMap容量 -1),不难发现,进行运算时,其实我们只用到了HashCode的一小部分数据参与了运算。而参与匹配的维度越多,就越难出现哈希冲突,而HashMap中把HashCode 与HashCode无符号右移16位的值进行异或处理,刚好将其他没有使用到的数据也合理的利用起来参与到运算中,从而达到减少哈希冲突的结果。
扩容时的元素迁移
JDK7扩容时的元素迁移:
JDK7中,HashMap的内部数据保存的都是链表。因此逻辑相对简单:在准备好新的数组后,map会遍历数组的每个“桶”,然后遍历桶中的每个Entity,重新计算其hash值(也有可能不计算),找到新数组中的对应位置,以头插法插入新的链表。因为是头插法,因此新旧链表的元素位置会发生转置现象。
JDK8扩容时的元素迁移:
JDK8则因为巧妙的设计,性能有了大大的提升:由于数组的容量是以2的幂次方扩容的,那么一个Entity在扩容后的位置,新的位置要么在原位置,要么在原长度+原位置的位置。原因如下图:
数组长度变为原来的2倍,表现在二进制上就是多了一个高位参与数组下标确定。此时,一个元素通过hash转换坐标的方法计算后,恰好出现一个现象:最高位是0则坐标不变,最高位是1则坐标变为“10000+原坐标”,即“原长度+原坐标”。如下图:
因此,在扩容时,不需要对每个Entity重新计算元素的hash了,只需要判断Entity的hash最高位是1还是0,然后放进对应的位置就可以了。
注意:
- JDK8在迁移元素时是正序的,不会出现链表转置的发生。
- 如果某个桶内的元素超过8个,则会将链表转化成红黑树,加快数据查询效率。
链表和红黑树的转变
由上可知,虽然使用了一些方法尽量减少hash冲突,防止某个链表过长,但随着数据的添加变多,该来的总会来的。
怎么解决?
当链表过长时,为了防止查询效率降低,所以HashMap把这个链表结构转换成红黑树,这样通过树结构来来优化查询的算法,提高查询的性能。
什么时候链表会转为红黑树呢?
/**
* The bin count threshold for using a tree rather than list for a
* bin. Bins are converted to trees when adding an element to a
* bin with at least this many nodes. The value must be greater
* than 2 and should be at least 8 to mesh with assumptions in
* tree removal about conversion back to plain bins upon
* shrinkage.
*/
static final int TREEIFY_THRESHOLD = 8;
当链表长度达到8时,且数组节点数量达到阈值64的时候,就会转换为红黑树。
那红黑树又会不会转变为链表呢,什么时候转呢?
/**
* The bin count threshold for untreeifying a (split) bin during a
* resize operation. Should be less than TREEIFY_THRESHOLD, and at
* most 6 to mesh with shrinkage detection under removal.
*/
static final int UNTREEIFY_THRESHOLD = 6;
会!!当HashMap进行扩容后,元素重新分配,红黑树的节点可能会减少,当红黑树的节点数量减少到 6时,就会转换为链表。