整体介绍
HashMap实现Map接口,用于存储key-value结构,能够根据其key快速查找其value。底层实现为采用一个table数组的hash表,数组中的每一项为一个链表结构。对于每个key,先计算其hash值,然后根据hash值计算其在table数组中的位置,若该位置没有元素,则直接将其放置在该位置,否则,则出现hash冲突,需要遍历查看其所在bucket是否已经有该key了(通过hash和key进行比较),若有了直接替换该key对应的value,否则在链表头部插入。
需要注意的是如果一个桶中元素大于某个阈值,在JDK8中会将其右链表转换为红黑树。而且对于哈希表(table数组)太满时(大于负载因子),需要对其进行再散列,负载因子默认为0.75,如果表中超过了75%的位置已经填入了元素,那么这个表就会用双倍的桶数自动进行再散列。
源码解析
1. 成员变量
主要有以下几个成员变量
/*静态常量*/
//初始容量:16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//负载因子:0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//链表转换为红黑树的阈值:大于8
static final int TREEIFY_THRESHOLD = 8;
//红黑数转换为链表的阈值:小于6
static final int UNTREEIFY_THRESHOLD = 6;
/*成员变量*/
//哈希表数组
transient Node<K,V>[] table;
//存储HashMap中的key-value对
transient Set<Map.Entry<K,V>> entrySet;
//元素实际个数
transient int size;
//是否重新散列的阈值
int threshold;
//负载因子
final float loadFactor;
2. 存储结构
主要存储结构为Node<K,V>
的结点,用于表示链表结构。
还有用于表示红黑树结构的TreeNode<K,V>
3. 构造函数
可以看到,构造函数里并没有对table数组初始化,JDK8的初始化是放在第一次添加的时候进行的。
4. put方法
这是HashMap里的最核心的方法了。
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
首先计算key的hash值,然后调用putValue方法。计算hash值的代码如下
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
hash函数
这里有个问题:为什么这样不是直接计算hashCode呢,还要与高16位做异或运算?
这里的hash函数相当于是一个打扰函数,最终是减少碰撞。因为我们是要根据hash值来计算其在table数组中的位置,也就是后面的tab[i = (n - 1) & hash]
,这里(n-1) & hash
相当于取模运算,但比取模更高效,因为table的长度始终是2的n次方(初始容量16,后面扩容时也始终<<1),所以其低位相当于全是1,高位全是0,最终&运算只保留hash的低位。所以在table容量较小时,如n-1为15(1111),hash和其相与后真正参与运算的也就是低4位,高位都为0了,这样可能会增加碰撞。所以在计算hash函数中将其和右移16位的值(高位16位变成0,低16位为之前的高16位)进行异或,由于右移始终为0,所以异或后原来的高位保持不变(原来是1的还是1,0的为0),低位变成低位与高位的异或,这样增加了低位的随机性,混合了高位和低位,高位的信息也被保留在低位中了。
参考:
putVal方法
上述有几处需要注意的地方:
- 1 首先是判断table是否为空,即初次添加,是则调用
resize()
函数进行初始化 - 2 当没有发生碰撞时,即
tab[(n-1) & hash]
位置为null,直接添加元素 - 3 如果发生碰撞,则首先判断该位置的hash以及key是否相等,如相等则记录下来(后续直接替换)
- 4 判断该位置结点是否是红黑树结构,是的话就执行红黑树的插入方法
- 5 遍历找是否有和待插入元素相等的key,找到则替换,没有的话则直接在尾部插入(JDK8以前是在头部插入),插入后如果发现其大于转换为红黑树的阈值,则将其转换为红黑树结构
resize方法
5. get方法
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
其调用的是getNode方法,根据key的hash和key找到该值。
6. remove方法
也是和get方法相似,根据key的hash值找到对应位置,然后分情况删除。
HashMap总结
- 底层实现:数组+链表+红黑树,允许key为null
- 负载因子的默认值是0.75:初始值大了,可减少哈希表的再散列(扩容的次数),但同时会导致散列冲突的可能性变大。初始值小了,可以减小散列冲突的可能性,但同时扩容的次数可能就会变多。
- 初始容量的默认值是16:初始容量过大,遍历时速度就会受影响,初始容量过小,散列表再散列(扩容的次数)可能就变得多
- HashMap在计算hash值并不是直接根据key的hashCode,而是将其和高16位进行异或,增加其随机性。
- 并不是桶子上有8位元素的时候它就能变成红黑树,它得同时满足我们的散列表容量大于64才行.
线程安全的HashMap
由于HashMap是线程不安全的,即多个线程可以同时put、get等,这在多线程环境下会出现问题,所以java又提供了线程安全的HashMap。
1. Hashtable
与HashMap存储结构基本相同,底层实现是数组+链表,其是线程安全的,实现方式是对整个Hashtable加锁(基本在所有操纵Hashtable的方法上都加了sysynchronized进行同步,所以同一时间只允许一个线程操作),且不允许key和value为null,但是其实现效率较低,不需要线程安全的场合可以用HashMap替换,需要线程安全的场合可以用ConcurrentHashMap替换。Hashtable默认初始容量为11,扩容方式为原始容量x2 + 1.
2. ConcurrentHashMap
与Hashtable一样都是用来实现线程安全的HashMap,但是却比Hashtable效率高很多。主要是因为其采用了锁分段的机制,将数据分段存储,每一段数据配一把锁,当一个线程占用锁访问其中一个段数据的时候,其他段的数据也能被其他线程访问,所以可以供多个线程同时访问,而不是像Hashtable那样锁住整个表,同一时刻只能有一个线程访问。
ConcurrentHashMap从JDK1.5开始随java.util.concurrent包一起引入JDK中,主要为了解决HashMap线程不安全和Hashtable效率不高的问题。
补充:JDK8和7实现差异
- JDK7中使用的是分段锁机制,即将整个table分成多个Segment,对每个Segment加锁,即有一个Segment数组,每一个Segment都是一个单独的哈希表。在执行put操作时首先根据hash算法定位到元素属于哪个Segment,然后对该Segment加锁即可
- JDK8对其进行了优化,底层采用和Node数组+链表+红黑树(和HashMap类似),并发控制使用Synchronized和CAS来操作,其与HashMap更接近。
volatile
volatile是java提供的一个关键字,用volatile修饰的成员变量可保证在多线程环境中的可见性。什么是可见性呢?这和java的内存模型有关了(JMM),java虚拟机有自己的内存模型(对底层硬件内存模型的抽象),分为主内存和本地私有内存,主内存是所有线程共享的,共享变量存放在这里,同时每个线程都有自己的本地内存(工作内存),保存了线程使用到的主内存变量中的副本。
假设有被volatile修饰的成员变量value,线程A修改了它,实际修改的是线程A工作内存里的value(主内存中的副本),还没有同步到主内存中,因此线程B读取的value还是原来的value,这样就不同步了。而采用volatile修饰的变量当某个线程修改了这个值时,会立即刷新到主内存中,同时通知其他线程对该变量的操作需要从主内存去重新读取该变量的值(而不是已经在工作内存中的缓存值)。
在ConcurrentHashMap
中就是用了volatile来修饰一些成员变量:
- 针对table数组的可见性
- 针对table中数组元素的可见性(Node中的val和next)
主内存和工作内存示意图:
硬件内存模型:
CAS
ConcurrentHashMap使用了CAS和Synchronized来进行同步。这两者有什么区别呢,CAS是一种乐观锁机制,即假设最好的情况,每次去访问数据的时候都认为没有修改,所以不会上锁,但是在更新的时候会去判断在此期间有没有别的线程更新这个数据,所以适用于读多写少的情况。Synchronized采用是一种悲观锁机制,即每次访问资源都会上锁,其他线程想访问这个资源就好阻塞,适用于写多读少的情况。
CAS即Compare And Swap(比较与交换),涉及到三个操作数:
- 需要读写的内存值V
- 进行比较的值A(期待值)
- 拟写入的值B
只有当内存值和期待值相等时,才会用新的值B来更新内存中的值,否则不会执行任何操作,是一种原子操作(volatile不能提供原子操作)。一般情况下是一个自旋操作,即不断的重试。
主要通过Unsafe类中的compareAndSwapInt
、compareAndSwapLong
等方法来实现:
public final native boolean compareAndSwapInt(java.lang.Object arg0, long arg1, int arg2, int arg3);
是一个本地方法实现的,arg0表示对象(obj),arg1表示地址(offset),arg2表示期望值(expect),arg3表示更新值(update),如果obj内的value和expect相等,就证明没有其他线程改变过这个变量,那么就更新它为update。
参考:
HashMap原理:
- https://www.cnblogs.com/chenssy/p/3521565.html
- https://mp.weixin.qq.com/s/ubwe-2U19Y7GQsIByYTWng
- https://www.cnblogs.com/NathanYang/p/9427456.html
ConcurrentHashMap:
CAS原理: