阅读文章需要的知识点
- 位运算:源码、反码、补码,与(&)、或(|)、非(!)、异或(^)的运算、左移(<<)、右移(>>)、无符号右移(>>>)
开始
从HashMap的基本使用来逐步分析:
HashMap map = new HashMap();
map.put("name", "chen");
默认初始化大小
首先是初始化,HashMap在初始化的时候如果不指定容量大小,那么默认就是16,从HashMap源码中:
// The default initial capacity - MUST be a power of two.
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
为什么要写成1 << 4的形式,而不是直接写成 = 16?
准备测试代码:
public static void main(String[] args) {
int a = 16;
int b = 1 << 4;
System.out.println(a);
System.out.println(b);
}
编译成class文件后再看,内容是:
public static void main(String[] var0) {
byte var1 = 16;
byte var2 = 16;
System.out.println(var1);
System.out.println(var2);
}
看class可以发现赋值在编译期就已经完成,所以1<<4和直接赋值16都是一样的
结合源码注释,写成1<<4就是为了提醒读者HashMap的容量是2的幂,扩容方式也是2的幂。
最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
为什么是1 << 30,而不是1 << 31或者1 << 32?
1 << 32,因为在Java中int是32位的,所以可以左移的范围是0-31,左移32相当于左移0,即没有移位,所以1 << 32 = 1 << 0 = 1。最大容量设为1很明显不合理,所以不行。
1 << 31,通过分析可以知道结果是-2147483648,所以也不行。
那就只有 1 << 30 = 1073741824了。
为什么非要用左移来获取最大容量呢
那就涉及到为什么HashMap的容量为什么一定要是2的幂了,后面会说到。
HashMap添加元素的底层实现详解
现在看第二行,将新的元素put进 HashMap 里面,先看源码:
/**
* 涉及到的全局变量
*/
// 存放元素的节点数组,在首次使用时初始化,在需要时调整大小,并且大小总是2的幂
// transient关键字:被修饰的变量,在序列化对象的时候,不会序列化该变量
transient Node<K,V>[] table;
// 键值对的数量,即元素数量
transient int size;
// 记录HashMap结构被修改的次数
// 涉及到Fail-Fast机制,即快速失败机制,是Java集合中的一种错误检查机制
transient int modCount;
// 当HashMap的size大于threshold时会执行resize即扩容
// threshold = capacity * loadFactor
int threshold;
// 扩容因子,默认等于DEFAULT_LOAD_FACTOR,即0.75
final float loadFactor;
// 当节点的链表长度超过多少时就转成红黑树
static final int TREEIFY_THRESHOLD = 8;
/**
* 函数说明
*/
// 添加元素进来
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
// 计算key的hash
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
// tab:当前桶
// p:point,哈希计算后桶在该索引上的元素
// n:桶的容量
// i:哈希计算后的索引
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 { // 索引位置上已经有值了,即哈希冲突,这里做处理
// e:声明一个元素节点node;k:接收p的key值
Node<K,V> e; K k;
// 哈希冲突,并且key值相等不为null,就赋值给e,后面根据这个e不为空做是否覆盖旧值的处理
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);
// 如果链表长度大于TREEIFY_THRESHOLD,即8,就转换成红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 在遍历链表的时候找到相同的key,即已经有相同key的元素了,就直接break
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
// 改变p的指针,让循环可以继续,即遍历的条件
p = e;
}
}
// e != null,说明存在相同的key,要覆盖该元素的值
if (e != null) { // existing mapping for key
V oldValue = e.value;
// onlyIfAbsent:如果当前位置已存在一个值,是否替换,false替换,true不替换
if (!onlyIfAbsent || oldValue == null)
e.value = value;
// 后面说(搜索关键字:afterNode)
afterNodeAccess(e);
return oldValue;
}
}
// 记录集合的修改次数,涉及到Fail-Fast机制
++modCount;
// 当HashMap的size满足扩容条件后扩容
if (++size > threshold)
resize();
// evict:表是否正在初始化,false表示是在初始化
// 后面说(搜索关键字:afterNode)
afterNodeInsertion(evict);
return null;
}
HashMap中的三个方法
在上面HashMap添加元素的源码中有这三个方法:
// Callbacks to allow LinkedHashMap post-actions
void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }
void afterNodeRemoval(Node<K,V> p) { }
这三个方法是表示在访问/插入/删除某个节点之后会进行一些处理,在HashMap中是空方法没有处理,但是在LinkedHashMap中实现了该方法,LinkedHashMap正是通过重写这三个方法来保证了链表的插入/删除的有序性,请自行阅读源码。
hash方法中的(h = key.hashCode()) ^ (h >>> 16)是什么意思
HashMap在添加元素的时候,需要先计算出该元素的索引位置,那么 key.hashCode() 不够用吗,为什么还需要做一步这样的操作:
(h = key.hashCode()) ^ (h >>> 16)
首先在putVal方法中看获取索引的代码: if ((p = tab[i = (n - 1) & hash]) == null),注:n为HashMap的容量
为什么是 (n - 1) & hash 呢?假设HashMap容量是16,那么索引范围就是:0 - 15,要计算hash在的索引位置,就要用hash值对15做取余运算,因为这样计算出来的结果一定在0 - 15以内,不会有索引越界的情况,即:
hash % (n - 1)
但是在Java中可以用位运算(&)来代替取余运算(%),因为位运算直接对内存数据进行操作,不需要转成十进制,所以速度特别快:
a % b = a & (b - 1)
这就是 (n - 1) & hash 的目的。
然后回到hash方法中,key.hashCode()已经可以获取到哈希值了,为什么还要做异或无符号右移16位呢?
首先是异或的原因,我们来看这样的例子
// 与运算,都为1,结果才为1,否则为0
0 & 1 = 0
1 & 0 = 0
0 & 0 = 0
1 & 1 = 1
// 或运算,有一个1就为1,两个0才是0
0 | 1 = 1
1 | 0 = 1
0 | 0 = 0
1 | 1 = 1
// 异或运算,相同为0,不同为1
0 ^ 1 = 1
1 ^ 0 = 1
0 ^ 0 = 0
1 ^ 1 = 0
我们发现,&运算的值会偏向0,|运算的值会偏向1,异或运算的值比较平均,在计算hash获取索引上,我们期望能够有更加散列的结果,尽可能的减少哈希冲突,所以对hashCode进行异或运算正是这个目的。
然后是无符号右移16位,我们从上面的位运算可以知道,HashMap是通过i = (n - 1) & hash获取索引的,即将 (n - 1) 和 hash 做一次与运算,我们以示例来说明:
假设HashMap容量是16,元素的key是hello world:
String str = "hello world";
System.out.println(Integer.toBinaryString(str.hashCode()));
元素key的hashCode是:
01101010 11101111 11100010 11000100
(n - 1) = 16 - 1 = 15,15的hashCode是:
00000000 00000000 00000000 00001111
直接拿来计算索引,即与运算的话:
01101010 11101111 11100010 11000100
00000000 00000000 00000000 00001111 &(与运算,都为1,结果才为1,否则为0)
-----------------------------------
先不论结果,可以发现在HashMap的容量较小时,hash的前16位甚至更多都是没有用到的,这样不充分的计算容易导致哈希冲突的产生,于性能不利。
我们对hashCode进行无符号右移16位,再和hashCode本身进行异或运算:
00000000 00000000 01101010 11101111 (hashCode >>> 16)
01101010 11101111 11100010 11000100 (hashCode)
----------------------------------- ^(异或运算,相同为0,不同为1)
01101010 11101111 10001000 00101011
得到的结果高16位是不变的,新值的低16位是把原来的高16位和低16位都利用了进来通过异或运算,相比仅通过hashCode & (n - 1),能得到更加散列的值,让索引更加均匀的分布,尽可能减少哈希冲突的发生
这样当HashMap的容量较小,处于低16位时,能够与hashCode的高低位都进行计算,
当HashMap的容量足够大,处于高16位时同理,
主要的目的就是尽量充分的和hashCode的每一个位都进行计算,这样得到的索引值才能尽可能的理想。
注:假如高16位都是0会怎么样?因为0异或任何数都等于任何数本身,所以不会影响。
知识点
- 位运算
什么是Fail-Fast
集合Collection中的一种错误机制,集合类中有一个modCount属性,当集合进行add/remove等操作时会发生改变,集合通过迭代器遍历元素时会检查这个元素,当遍历时modCount发生了改变,就会抛出ConcurrentModificationException异常。
如何避免Fail-Fast
-
在迭代过程中使用迭代器的remove()方法,但是这种方式有局限性,只能删除当前遍历的元素
-
使用Java并发包(java.util.concurrent)中的类来代替ArrayList或者HashMap。比如使用CopyOnWriteArrayList代替ArrayList,它是写时复制的容器,在读写时是线程安全的,在进行add/remove等操作时,并不是在原数组上进行修改,而是将原数组拷贝一份,在新数组上修改,待完成后才将指向旧数组的引用指向新数组,所以对于CopyOnWriteArrayList在迭代过程并不会发生fail-fast。但CopyOnWriteArrayList容器只能保证数据的最终一致性,不能保证数据的实时一致性。
ConcurrentHashMap代替HashMap,它采用了锁机制,是线程安全的。在迭代中,当iterator被创建后集合再发生改变就不再是抛出ConcurrentModificationException,而是在改变时new新的数据从而不影响原有的数据,iterator完成后再将头指针替换为新的数据,这样iterator线程可以使用原来老的数据,而写线程也可以并发的完成改变,但同样不能保证数据的实施一致性。
知识点
- CopyOnWriteArrayList
- ConcurrentHashMap
- 数据的最终一致性和实施一致性
Fail-Fast总结
- 在集合进行增删改查或其他修改集合元素的方法时,会记录修改次数即modCount的值
- 在调用迭代器时,会将这个modCount值赋予给迭代器中的expectedModCount
- 在调用迭代器的过程中,每次执行next()都会检测expectedModCount和modCount是否相等
- 在迭代过程中调用迭代器的remove()方法不会造成ConcurrentModificationException异常
- 在迭代过程中调用集合的remove()方法会造成ConcurrentModificationException异常
扩容因子为什么默认为0.75
HashMap在达到扩容因子条件时扩容,扩容因子默认为0.75,即当HashMap中元素达到容量的75%就进行扩容。由此可以分析:
- 假设loadFactor为1,初始容量为16,那么元素数量为16的时候HashMap才会扩容。首先要知道HashMap在put元素的时候会对元素key进行hash取值,计算出存放的索引位置,而哈希冲突是无法避免的,所以当容量满了才扩容,很可能会出现集合内同一个索引位置上元素过多,形成了链表或者红黑树,导致查询效率降低。不过好处也有,就是空间利用率高了,基本不会有内存空间被浪费。
- 假设loadFactor为0.5,初始容量为16,那么元素数量为8的时候HashMap就会扩容,此时相比loadFactor为1,哈希冲突会减少很多,因为有足够的位置可以存放元素,自然也不会形成太多的链表或者红黑树,查询效率高了,但是带来的问题也很明显,就是内存利用率不高,本来1M的数据需要2M的空间来存放。
由此可以知道为什么loadFactor默认为0.75,这正是在空间和时间上做的一个妥协。
知识点
- 哈希冲突
- HashMap计算哈希获取索引位置
为什么HashMap的容量是2的幂
我们知道索引的计算方法是(n - 1) & hash,上面已经说了这里就不罗嗦了,然后2的幂有2,4,8,16,32等,那么(n - 1)的二进制有:
00000000 00000000 00000000 00000001
00000000 00000000 00000000 00000011
00000000 00000000 00000000 00000111
00000000 00000000 00000000 00001111
00000000 00000000 00000000 00011111
与运算是都为1结果才是1,否则是0,那么上面的数与hash进行与运算的时候,当hash的位上为1时结果为1,为0时结果为0,是比较均匀的。
现在假设容量不是2的幂,而是2的幂 +1,就有3,5,9,17,33,他们的(n - 1)的二进制有:
00000000 00000000 00000000 00000010
00000000 00000000 00000000 00000100
00000000 00000000 00000000 00001000
00000000 00000000 00000000 00010000
00000000 00000000 00000000 00100000
这时候和hash进行与运算的时候,会出现大量的0,极为容易出现哈希冲突,所以要用2的幂。
为什么默认容量是16
既然是2的幂,为什么不是4,8,32,会是16呢,我认为这是根据经验推出来的,我做了一个测试:
long start = 0;
long count = 1000000;
{
Map map = new HashMap();
start = System.currentTimeMillis();
for (int i = 0; i < count; i++) {
map.put(String.valueOf(i), i);
}
System.out.println("不指定size的时间:" + (System.currentTimeMillis() - start));
}
{
Map map = new HashMap(4);
start = System.currentTimeMillis();
for (int i = 0; i < count; i++) {
map.put(String.valueOf(i), i);
}
System.out.println("指定size为4的时间:" + (System.currentTimeMillis() - start));
}
{
Map map = new HashMap(8);
start = System.currentTimeMillis();
for (int i = 0; i < count; i++) {
map.put(String.valueOf(i), i);
}
System.out.println("指定size为8的时间:" + (System.currentTimeMillis() - start));
}
{
Map map = new HashMap(16);
start = System.currentTimeMillis();
for (int i = 0; i < count; i++) {
map.put(String.valueOf(i), i);
}
System.out.println("指定size为16的时间:" + (System.currentTimeMillis() - start));
}
{
Map map = new HashMap(32);
start = System.currentTimeMillis();
for (int i = 0; i < count; i++) {
map.put(String.valueOf(i), i);
}
System.out.println("指定size为32的时间:" + (System.currentTimeMillis() - start));
}
结果为:
不指定size的时间:188
指定size为4的时间:148
指定size为8的时间:101
指定size为16的时间:129
指定size为32的时间:670
可以看到连续存放一百万条数据的时候,效率最高的是size为8的,其次是16
我想在实际使用中设置为8容易造成扩容,所以就以16这个不大不小的值作为默认值了。
链表和红黑树的数据结构
这部分内容比较多,后面再更。