先问几个问题然后我们在一个一个解答这些问题
- hashmap的构造方法可不可以传值?可以传几个值,传入的值是是指那些
- hashmap里面用那些运算符来实现里面的算法,为什么要用他们实现里面的算法
- hashmap在哪里初始化数组长度
- 假如传入的值是10默认数值的长度是多少
- hashmap的put方法里面的key能不能传入null
我们先来看看hashmap的几个属性
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//他指的是hashmap的初始容量1<<4;等于2的4次方所以初始容量是16
static final int MAXIMUM_CAPACITY = 1 << 30;
//他是指数组的最的长度2的30次方这是个很大的数也不可能放这么多东西在hashmap里面,因为东西多了会存到数据库里面
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//他指的是默认的加载因子
static final int TREEIFY_THRESHOLD = 8;
//链表长度的阈值,当超过8时,会进行树化(不绝对,如下面源码分析)
static final int UNTREEIFY_THRESHOLD = 6;
//当树中只有6个或以下,转化为链表
static final int MIN_TREEIFY_CAPACITY = 64;
//这个64就是是否会进行树化的关键
transient int size; //HashMap的大小
int threshold; //判断是否扩容的阈值
再来看看hashmap的数据结构
接下来我们来看hashmap的构造方法
//这个是接收参数的构造方法,还有不接受参数的构造方法,用那些构造方法的话会默认使用默认值
public HashMap(int initialCapacity, float loadFactor) {
//initialCapacity这个参数是指传入的初始容量
//loadFactor这个参数是指传入的加载因子
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
//当传入参数initialCapacity<0会抛出异常
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//当传入参数>定义的最大数组长度的时候就是那个2的30次方会默认用2的30次方
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
//先来看看这个Float的isNaN(float v) 如果指定的数字是非数字(NaN)值,则返回 true false false。 当传入的加载因子是小于等于0或者不是数字值则会抛出异常
this.loadFactor = loadFactor;
//将传入的加载因子赋值给自身的加载因子属性
this.threshold = tableSizeFor(initialCapacity);
//将传入的数组长度经过tableSizeFor方法处理后赋值给自己的扩容的阈值
//从这可以得出在构造方法中并没有对数组进行初始化在Java1.8中在put方法中才会初始化数组长度(后面会讲到,面试很可以会问,千万别答在调用构造方法得时候初始化了数组)
}
现在我们来看看tableSizeFor方法是怎么处理传入的初始容量
//这是方法里面用来大量得位运算符来处理,因为位运算符效率是最高的
static final int tableSizeFor(int cap) {
int n = cap - 1;
//首先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是传入的容量值咱们默认他为10来看看他是怎么处理的
//首先看看这两个位移的区别因为jdk1.7用的是>> n>>1是带符号右移;
//n>>>1是无符号右移
//10转2进制为 0000 1010;运算这段代码n |= n >>> 1;得 0000 0101
0000 1010
0000 0101 做或(|)运算得
0000 1111 然后再位移n >>> 2 得 0000 0011
0000 0011 做或(|)运算得
0000 1111 后面也是一样得 你们也可以用其他得数字来进行计算
所以最终得 0000 1111 转10进制为 15
//然后咱们在来看看这段代码
//return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
显然n不小于0返回 (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1 然后也不大于最大得2的30次方所以返回 15+1就是16
总结:所以通过这个算法计算出如果你传入的初始容量为10
最后也会通过处理得到一个比10大的2的次方数为什么一定是2的次方数咱等会在说
但是如果咱传入的值就是16怎么办?那岂不是得到一个比16大的2的次方数32了吗?
所以这个代码就起了作用了
//int n = cap - 1;先给你减1在进行计算
咱搞懂了构造方法咱肯定要调put方法往里面添加键值对那咱们来看看put方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
看到put方法先调用了hash方法计算出key的hashcode码然后得到返回值后再调用了putVal方法
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
从这段代码就可以得到上面的那个问题的答案了在hsahmap里面key是可以为null的,
但是在hashtable里面key是不能为空的因为hashtable源码里面写了key = null直接报异常
(h = key.hashCode()) ^ (h >>> 16)这段代码得到key得hashcode值后又进行了
咱来看看这个^运算符的作用比如:1110 ^ 1000=0110 所以他在用key的hashcode值
和key的hashcode向右移动16位后进行了取反操作然后得到返回值
然后来看看最终实现添加键值对的方法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;
//tab是数组对象,p是声明用来接这个插入节点的,n是数组长度,i是数组下标
if ((tab = table) == null || (n = tab.length) == 0)
//首先把table的值赋给tab然后判断是否为null如果是第一次插入肯定为null,
//然后不会执行(n = tab.length) == 0)
n = (tab = resize()).length;
//然后调用resize方法给这个tab初始化,并将数组长度传给n
//(咱先不管resize怎么实现的咱就当他已经初始化了有长度了)
if ((p = tab[i = (n - 1) & hash]) == null)
//此时不管判断为true还是false这段代码都把tab[i]这个节点赋值给了p
//然后i = (n - 1) & hash这段代码和之前hash方法里面的这段代码
//(h = key.hashCode()) ^ (h >>> 16)是为了减少hash碰撞增强散列性
//,毕竟咱需要充分利用数组的空间不让他们都往一个数组节点里面插这里
//也可以回答为什么数组长度非要是2的次方我在这个方法下面来解答一下
//最后得到这个节点赋值给p然后判断是否为null
tab[i] = newNode(hash, key, value, null);
//如果为null然后将调用newNode方法把参数传进并赋值给这个null数组节点
else {
//如果这个节点不为null
Node<K,V> e; K k;
//e是数组上面得链表节点,k是用来存传入得key的
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//存储的节点的key的已存在,直接进行替换
e = p;
else if (p instanceof TreeNode)
//存储的节点的key的不存在,判断是否为树节点(是不是已经转化为红黑树)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//如果是存到将节点存到红黑树上
else {//即不存在。也不是树节点,
for (int binCount = 0; ; ++binCount) {
if ((e = p.next) == null) {
//遍历到尾节点还没找到hash值和key相同的对象直接找到链表的尾部,直接插入,
//插入后可能达到转换为红黑树的条件
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 存在映射的key,覆盖原值,将原值返回
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
//然后长度+1当hashmap的容量大于阈值扩容
resize();
afterNodeInsertion(evict);
return null;
}
注:写底层代码的人喜欢把代码简化,一般把很多情况把赋值语句和判断语句写在一起了。
解答p = tab[i = (n - 1) & hash]是怎么减少哈希碰撞增强散列性的
p = tab[i = (n - 1) & hash]
首先n是数组的长度但是咱们知道在构造方法中的算法处理
不管你传入的长度是多少最终都会在算法处理变成比你传入
参数大的2的次方数
所以如果传入的参数是10那么数组长度n的值为16
然后我们来计算
(n - 1) & hash 这里的hash是之前经过hash方法处理的
key.hashCode()) ^ (h >>> 16)得到得值
15&hash
如下图
我们看到putval方法在判断如果是第一次调用或者hashmap的容量大于阈值的时候都会执行这个resize()方法对数组进行扩容那我们现在仔细看看这个resize()方法
resize第一步:确定扩容还是初始化,确定最新容量与阈值
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
//首先进入方法声明一个oldTab是老的节点数组
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//声明一个oldCap表示数组大小如果老的节点数组等于null那么为0否则为老的节点数组的长度
int oldThr = threshold;
//声明一个oldThr接收老的阈值
int newCap, newThr = 0;
//声明一个newCap表示新的数组大小,和newThr表示新的阈值
if (oldCap > 0) {
//如果老的数组大小大于0说明不是第一次用put方法添加键值对进入方法
if (oldCap >= MAXIMUM_CAPACITY) {
//如果老的数组大小大于hashmap最大允许的大小进入此方法
threshold = Integer.MAX_VALUE;
//将int数据类型的最大取值数赋值给阈值
return oldTab;
//返回这个老的节点数组
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
//如如果老的数组大小经过左移一位赋值给新的数组大小(左移一位相当于乘2)后判断小于hashmap最大允许的大小并且不小于默认的数组长度16时进入此方法
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
//将老的阈值也乘2赋值给新的阈值
}
else if (oldThr > 0) // initial capacity was placed in threshold
//反之说明时第一次调用put方法如果老的阈值大于0
newCap = oldThr;
//将老的阈值赋值给新的数组长度(说明创建hashmap的时候传参数了)
else { // zero initial threshold signifies using defaults
//如果老的阀值不大于0说明创建hashmap对象的时候没有传参数
newCap = DEFAULT_INITIAL_CAPACITY;
//那么将默认的16传给这个newCap(前面说过newCap表示数组大小)
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
//并且用默认的数组大小×默认的阈值作为newThr(16*0.75)
}
if (newThr == 0) {
//如果新的阈值等于0(创建hashmap的时候传参数了在此处进行扩容)
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
//新的阈值等于(float)newCap * loadFactor
}
threshold = newThr;
//扩容完成后newThr传给threshold
resize第二步:扩容
//根据新的容量重新定义一个数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
//将新建立数组赋值到HashMap成员变量
table = newTab;
//若存在旧数据则进行后续数据迁移
if (oldTab != null) {
//因为HashMap是数组+链表或者数组+红黑树(根据数组下标找寻到相应链表或者红黑树)遍历原本数组,找到相应的链表或者红黑树进行操作
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
//数组[j]有数据,进行后续操作
//将数组[j]下代表的链表或者红黑树的头结点(根节点)赋予 e
if ((e = oldTab[j]) != null) {
//将旧数组[j]处置为空
//我认为这两步的操作就是将原本数组下标处的数据从原本位置挪出,接下来操作此链表即可
oldTab[j] = null;
if (e.next == null)
//就只有一个头结点(根节点)直接就可按照e.hash & (newCap - 1)的计算法,计算出相应在新table里的位置进行插入
//与put方法里面的操作一致
newTab[e.hash & (newCap - 1)] = e;
//这里先不讲红黑树的部分了,后续有时间会继续发博客讲解
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else {
//首先定义五个变量(Tail单词意思是尾部的意思)所以我们可以这么理解
//loHead lo头部 loTail lo尾部
//hiHead hi头部 hiTail hi尾部(暂且这么记忆)
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;
}
//整个do while(不看具体实现),我认为就是遍历整个链表
//内部相关解释看内部源码注释
do {
next = e.next;
//咱们先不管 (e.hash & oldCap) == 0 这个条件,反正就是符合这个条件就进行lo相关变量的操作,不符合进行hi相关变量的操作
if ((e.hash & oldCap) == 0) {
if (loTail == null)//这部分很像将一个节点插入链表所需要做的操作(下方图1右侧是只有一个数据时,下列代码最终形成的状态,左侧是多个数据时的最终形成的状态)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)//同lo相关操作
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
//综合看来,根据(e.hash & oldCap) == 0 这个条件进行筛选,将原本的一个链表划分成两个链表
} while ((e = next) != null);
HashMap是头插还是尾插?
JDK1.8之前是头插,JDK1.8以及JDK1.8之后是尾插。
1.8以前作者可能觉得后面插入得数据被get得可能性更高所以使用来头插法
3、HashMap 扩容(resize)在多线程情况下不安全
以上过程单线程下不会出现问题,但是当两个线程同时触发 resize 的时候就有可能出现问题