散列表
HashMap的核心是散列表,散列表是一个基于数组实现实现的数据结构,使用的是数组支持随机下标访问的特性,通过将一个key通过hash函数转化为数组下标。
但是不同的key算出来的hash值可能相同,这就导致了hash冲突(也叫hash碰撞)
对于hash碰撞有两种解决方法(目前学到的,如果有后续再补充)
- 开放寻址法
开放寻址法是指,当发生hash冲突的时候(存入数组时,发现这个位置已经被占用了),那么就向后寻找,到了末尾还没有找到,再从头开始向后遍历,如果遍历到了空位,就将该数据插入进去,但是散列表中空闲位置不多时,hash冲突的概率还是大,所以就有负载因子(load factor),负载因子=已有元素数量/散列表长度,用来表示空位的多少
如图:蓝色表示已有数据
- 链表法
而链表法则是指,当发生hash冲突时,以当前位置的数组中对象作为链表中的节点,将要存储的对象作为链表节点存入
jdk1.7的底层数据结构
jdk1.7的底层就是通过数组+链表来实现的
HashMap默认长度为16
负载因子默认为0.75
通过Entry对象封装key,value作为节点
在put新数据的时候,才初始化数组
1.7的扩容:当HashMap的数据数量超过了阈值(容量x负载因子)且待插入数据的数组位不为null时(也就是待插入数据将作为节点插入链表),就扩容为原容量的2倍
为什么是2倍呢,而不是其他倍数?这和后面写的HashMap的位运算有关
插入的方式为头插法,这里应该是考虑的后插入的数据可能会被先使用,所以每次插入的数据位置,都是链表的头结点,也就是在数组内
jdk1.8的底层数据结构
1.8的底层较1.7发生了很大的变化
数组生成和1.7一样,调用构造方法时不会生成数组,而是在put的时候才对数组初始化,HashMap的默认数据也和1.7的一样,不过多了关于红黑树的参数
1.8中数据的插入方式变成了尾插法
1.8的底层数据结构是数组+链表+红黑树,当发生hash冲突时,时,首先采用数组+链表的结构,当链表节点数超过8个,且HashMap容量超过64时,链表就转为红黑树
1.8的扩容:当数据数量超过阈值时,就进行扩容
当红黑树节点小于等于6的时候,红黑树又会转变回链表结构
红黑树是一种弱平衡二叉树:
- 节点可以是红色或者黑色
- 根节点必须是黑色
- 叶子节点必须是黑色
- 红色节点的两个子节点必须是黑色
- 一个节点到任意节点的路径上,黑色节点数一定相同
红黑树的插入和查找都是O(logn)
为什么选择8为转变值呢?
官方说法是考虑了泊松分布,因为取到8的几率并不高,所以并不需要经常树化,虽然红黑树的效率高,但是链表和红黑树之间的转换也是很消耗空间和时间的,在节点数量少的情况下,遍历链表的操作更方便
* 0: 0.60653066
* 1: 0.30326533
* 2: 0.07581633
* 3: 0.01263606
* 4: 0.00157952
* 5: 0.00015795
* 6: 0.00001316
* 7: 0.00000094
* 8: 0.00000006
* more: less than 1 in ten million
HashMap中的一些位运算
hash值的计算
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
传入一个key,调用Object类中的hashcode方法,这是一个native方法
然后将获取到的hash值无符号右移16位再进行异或运算(相同为1,不同为0),因为int类型32位,最高为符号位,所以无符号右移16位也就是将高位16位变成低位,再与原来进行异或运算,相当于是高位和低位进行异或运算
h:0000 1101 1010 0100 0000 1010 0100 0001
无符号右移16位后:0000 0000 0000 0000 0000 1101 1010 0100
高低位异或运算: 0000 1101 1010 0100 0000 0101 1110 0101
也就是高位不变,再hash,降低hash碰撞
数组容量
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;
}
这个方法保证了,无论传入容量参数的是否是2的幂次方,都会找到大于传入参数的的最小二次幂数。
比如传传入一个13:
n=13: 0000 0000 0000 0000 0000 0000 0000 1101
>>>1: 0000 0000 0000 0000 0000 0000 0000 0110
|= : 0000 0000 0000 0000 0000 0000 0000 1111
n等于15了,之后异或运算也不会发生改变了
int n = cap - 1;
为了避免传入的就是二次幂,进行运算后导致翻倍,所以,先减1,最后再加1
让31位都无符号右移,又通过或运算(有1为1),保障了最后所得到的值是2n-1,最后再加1,得到2n
为什么必须要2n,其他数不行吗?这就是之前提到的为什么要扩容为2倍。关系到数组下标的计算
数组下标的计算
在1.7中是作为方法使用,而1.8直接取消了这个方法,但是下标计算的核心还是没有变
index = (n - 1) & hash
数组容量-1的值与得到的hash值进行与运算(都是1才为1),相当于取余操作,假设n=16,n-1=15
n-1: 0000 0000 0000 0000 0000 0000 0000 1111
hash: 0000 1101 1010 0100 0000 0101 1110 0101
& : 0000 0000 0000 0000 0000 0000 0000 0101
最后得到的值为5,也就是tab[5]