注:本文是以Android中的HashMap进行讲解
问题:
1、HashMap采用的是什么数据结构?
2、HashMap默认容量多少?
3、HashMap最大容量多大?
4、HashMap每次扩容多大?
5、HashMap扩容的阈值是多少?默认阈值加载因子是多少?
6、HashMap的hash函数是怎么实现的?还有哪些实现方式?
7、HashMap get的工作原理是什么?
8、HashMap put的工作原理是什么?
9、HashMap如果解决冲突的?
10、HashMap是否是线程安全?如果不是该如何实现线程安全?
11、HashMap使用怎么类型作为key更优?
下面我们带着这些问题,借助源码一一解析HashMap。
问题1:HashMap采用的是什么数据结构?
HashMap基于哈希表的原理,采用数组+链表的数据结构实现的。
哈希表:hashtable,也叫散列表,是根据键而直接访问在内存存储位置的数据结构。它通过计算一个关于键值的函数,将所需查询的数据映射到表中的一个位置来访问记录。这个映射行数叫散列函数,存放记录的数组叫散列表。
简单的理解哈希表就是一种存储键-值对的数据结构,根据键可以快速访问到对应的值。
我们看下HashMap中关于数据结构的代码:
// Node数组表示哈希表
transient Node[] table;
// Node类数据结构
static class Node implements Map.Entry {
final int hash;
final K key;
V value;
Node next;
//...省略
}
通过上面的代码验证了HashMap采用的是数组+链表的数据结构,其中数组时Node数组,而Node采用的单向链表结构。
可以想一想为什么采用数组+链表的数据结构?后面我们会解答此问题。
问题2:HashMap默认容量多少?
HashMap默认容量是16,我们直接看代码:
/**
* The default initial capacity - MUST be a power of two.
*/
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
定义了默认的容量为16,且其注释中还明确了必须是2的多次方。
问题3:HashMap最大容量多大?
HashMap实际最大容量是Integer.MAX_VALUE,我们看下其源码的对最大容量的定义:
/**
* 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;
明明人家定义最大值是2^30,为什么你说是Integer.MAX_VALUE?我们来看下当HashMap扩容时的一段代码:
final Node[] resize() {
//...省略
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//...省略
}
上述代码已经很明确了,如果容量大于MAXIMUM_CAPACITY,那么是允许扩容到Integer.MAX_VALUE的。
问题4:HashMap每次扩容多大?
我们先看下HashMap中每次扩容的实现部分的代码:
//HashMap扩容实现方法
final Node[] resize() {
//当前散列表
Node[] oldTab = table;
//当前散列表容量大小
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//当前散列表扩容阈值
int oldThr = threshold;
//分别是新的容量大小和新的扩容阈值
int newCap, newThr = 0;
if (oldCap > 0) {
//如果当前容量大于0
if (oldCap >= MAXIMUM_CAPACITY) {
//如果当前容量大于MAXIMUM_CAPACITY值直接扩容到 Integer.MAX_VALUE
threshold = Integer.MAX_VALUE;
return oldTab;
}
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//正常情况下,扩容到原来的1倍;阈值也增加到原来的1倍
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
// 如果当前容量为0,则容量=阈值
newCap = oldThr;
else { // zero initial threshold signifies using defaults
/** 如果容量为0,并且阈值为0,则设置容量为默认容量16,阈值=默认容量*默认加载因子=0.75*16
*/
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
if (newThr == 0) {
/**
如果新的阈值为0,则设置为容量*加载因子
*/
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
Node[] newTab = (Node[])new Node[newCap];
table = newTab;
//...省略
}
通过上面的代码可以知道,HashMap每次扩容可能是不一样的,总结为:
- 如果当前容量的大于预设最大值MAXIMUM_CAPACITY ,则取值为Integer.MAX_VALUE
- 如果DEFAULT_INITIAL_CAPACITY<当前容量2
2 - 如果当前容量为0并且当前阈值大于0,则新的容量=当前阈值
- 如果当前容量为0并且当前阈值也未0,则新的容量=默认容量的值=16
同时,通过上面的代码可以知道每次扩容,阈值的值均为重新设置为新容量加载因子,默认情况下即新容量0.75
问题5:HashMap扩容的阈值是多少?默认阈值加载因子是多少?
通过问题4中列出的源码知道,扩容的阈值=新容量加载因子,默认情况下即新容量0.75。阈值用于判断是否进行扩容,当散列表的容量大于阈值或者容量为0时就进行扩容操作。默认的阈值加载因子=0.75,我来看默认加载因子的代码定义:
/**
* The load factor used when none specified in constructor.
*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
问题6:HashMap的hash函数是怎么实现的?还有哪些实现方式?
hash函数就是散列函数,用于计算键相关的函数,可以理解计算为记录的映射地址。看源码实现:
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
如果key为空,则hash的值为0;如果不为0则值为key的hashCode值亦或上key的hashCode右移16位之后的值。
HashMap 散列函数属于直接定址法,散列函数的实现方式还有:数字分析法、折叠法、随机数法、除留余数法。下面对这几中方式做个简单的说明:
- 直接定址法:取关键字或关键字的某个线性函数值为散列地址。即H(key)=key或H(key) = a·key + b,其中a和b为常数(这种散列函数叫做自身函数)。若其中H(key)中已经有值了,就往下一个找,直到H(key)中没有值了,就放进去。
- 数字分析法:分析一组数据,比如一组员工的出生年月日,这时我们发现出生年月日的前几位数字大体相同,这样的话,出现冲突的几率就会很大,但是我们发现年月日的后几位表示月份和具体日期的数字差别很大,如果用后面的数字来构成散列地址,则冲突的几率会明显降低。因此数字分析法就是找出数字的规律,尽可能利用这些数据来构造冲突几率较低的散列地址。
- 平方取中法:当无法确定关键字中哪几位分布较均匀时,可以先求出关键字的平方值,然后按需要取平方值的中间几位作为哈希地址。这是因为:平方后中间几位和关键字中每一位都相关,故不同关键字会以较高的概率产生不同的哈希地址。
- 折叠法:将关键字分割成位数相同的几部分,最后一部分位数可以不同,然后取这几部分的叠加和(去除进位)作为散列地址。数位叠加可以有移位叠加和间界叠加两种方法。移位叠加是将分割后的每一部分的最低位对齐,然后相加;间界叠加是从一端向另一端沿分割界来回折叠,然后对齐相加。
- 随机数法:选择一随机函数,取关键字的随机值作为散列地址,通常用于关键字长度不同的场合。
- 除留余数法:取关键字被某个不大于散列表表长m的数p除后所得的余数为散列地址。即 H(key) = key MOD p,p<=m。不仅可以对关键字直接取模,也可在折叠、平方取中等运算之后取模。对p的选择很重要,一般取素数或m,若p选的不好,容易产生同义词。
问题7:HashMap get的工作原理是什么?
首先我们先看下HashMap中get的实现代码:
public V get(Object key) {
Node e;
//通过getNode的方式实现取值
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
/**
hash: 经过散列函数计算key得到值
key: 键值
*/
final Node getNode(int hash, Object key) {
Node[] tab; Node first, e; int n; K k;
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// tab即散列表,通过(n - 1) & hash计算出映射的地址,即下标
//
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
// 如果查出的第一个记录hash值相等并且key地址相等或者值相等,则直接放回记录中的值
return first;
// 遍历链表,知道找到匹配的hash或者key
if ((e = first.next) != null) {
if (first instanceof TreeNode)
return ((TreeNode)first).getTreeNode(hash, key);
do {
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
// 如果散列表为空或者根据hash查找到的记录为空,则返回空
return null;
}
通过上述源码,get的实现是:
1、通过hash函数算出键的hash值
2、接着对hash进行(n - 1) & hash位运算,得出散列表的下标
3、通过散列表的下标得到链表表头
4、在链表中遍历查找,直到找到匹配的记录,最后返回找到的记录的值。匹配查找的条件是:hash值相等并且key地址相等或者值相等
问题8:HashMap put的工作原理是什么?
我们也是先看put的源码,借助源码来了解其实现原理:
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
/**
hash: 对键进行hash()操作之后的值
key:键
value: 键对应的值
onlyIfAbsent:true: 原来的值不为空时,则不改变值
evict:false: 散列表处于创建模式,HashMap用不到,LinkedHashMap才有用到,此处不做说明
*/
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
//当前散列表
Node[] tab;
//匹配的链表对象
Node p;
//分别是散列表的大小、匹配的散列表的下标值
int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
// 如果散列表为空,则进行扩容处理
n = (tab = resize()).length;
// 通过(n - 1)& hash取得链表所在散列表的下标
if ((p = tab[i = (n - 1) & hash]) == null)
// 如果匹配的链表为空,则创建链表
tab[i] = newNode(hash, key, value, null);
else {
// 如果匹配的链表不为空,则遍历链表,找到链表中匹配的节点,然后替换其中的值
// e:链表中的节点;k: 节点的键
Node e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
// 链表的表头节点等于要找的节点
e = p;
else if (p instanceof TreeNode)
// 如果链表是树形结构,则使用树形结构的方式实现put,此处不做这块的说明
e = ((TreeNode)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);
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
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
// 返回旧值
return oldValue;
}
}
// HashMap修改次数加1
++modCount;
// HashMap大小加1,如果HashMap的大小大于阈值则进行扩容操作
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
根据put源码,我们可以总结HashMap的put操作的原理为:
1、如果散列表为空,则先创建默认容量的散列表
2、通过(n - 1) & hash 计算的散列表的下标,根据下标获取散列表中的链表
3、如果链表为空,代表插入新的节点,则创建新的链表(节点)插入散列表中
4、如果链表不为空,则遍历链表,判断是否已经存在节点:如果链表中不存在节点,则创建新节点插入链表的表尾;如果链表中已经存在节点则更改原来节点的值
5、如果是插入节点操作,HashMap的大小进行加1处理;如果是更新节点值操作,则不加1处理,并返回旧值
6、如果HashMap的大小加1了,判断HashMap的大小是否大于阈值,如果大于,则进行扩容处理
问题9:HashMap如果解决哈希表冲突的?
首先我们先看下哈希表冲突是什么鬼:对于不同的key经过散列函数计算之后,得到同一散列地址,这种现象称为冲突。试想一些如果哈希表采用的是一维数组,key经过hash()操作之后的值作为下标,value作为数组的值,那么就会出现key不同而下标相同,就会导致前面添加的键值对被后面添加的键值对覆盖的现象。
搞懂了什么是哈希表冲突,那么HashMap是怎么解决这个问题的呢?认真阅读的你,会在前面的问题中找到答案。
没错,通过问题8中put操作的源码,知道当key经过hash()操作,再经过(n -1) & hash得到散列表的下标,即便key计算得到的下标下同,是可以通过链表得到解决的。
总结来说HashMap解决哈希表冲突的方案是链址法,即使用一维数组作为散列表,单链表作为数组的值,当出现冲突时,将值插入链表中即可。
问题10:HashMap是否是线程安全?如果不是该如何实现线程安全?
答案可定不是线程安全的,明显通过上述get和put的源码可以看到,读写操作没有任务的同步、加锁操作,所以HashMap并非线程安全。
那么怎么实现线程安全呢?
1、使用HashTable
2、使用Collections.synchronizeMap处理Hashmap:
Map m = Collections.synchronizeMap(hashMap);
3、使用ConcurrentHashMap
1和2性能较差,推荐使用第三种,ConcurrentHashMap使用的是CAS轻量级锁,性能更好。至于为什么后面为用专门的章节来讲解。
问题10:HashMap使用怎么数据类型作为key更优?
这个是涉及HashMap优化处理的部分,没有做过这方面优化的同学估计一脸懵逼,还有这讲究?
首先我们知道哈希表存在冲突的情况,较少冲突碰撞的概率,提高查找效率;再者get和put的操作会出现频繁的调用key的hashcode()、equal。所以对key进行优化就变得更会重要,首先要解决的较少冲突、接着提高hashcode()和equal的操作。哪些数据类型先天有这方面的优势呢?答案是String和基本数据的wrapper对象(比如Integer、Short、Long等)。为什么呢,看过源码的同学应该能很快知道答案,原因是:
1、这些类是不可变的,都是final,不可继承,没有子类可以改变其实现,而且这些类的值都是不可变的,都有final类型修饰。不可变性是必要的,因为为了要计算hashCode(),就要防止键值改变,如果键值在放入时和获取时返回不同的hashcode的话,那么就不能从HashMap中找到你想要的对象
3、这些类都重写了hashcode()和equal方法,可以确保不同对象返回的hashcode值不一样,减低冲突概率;其中String还对hash进行的缓存处理,提高hashCode的效率。重写equal方法能区别键值的正确性。