HashMap的数据结构
JDK1.7 数据结构为:数组+链表的数据结构
构造方法
一般我们常用的是第一种,无参构造,这时默认初始容量就是16,默认负载因子是0.75。
这里我们只讨论上面的三个构造,通过查看源码得知,前两个构造方法内部都使用的是第三个构造第三个构造内部首先对传进来的容量和负载因子做判断,主要是看数据是否合规,此时,让扩容阈值等于了初始容量。注意这个时候仅仅是给一些必要的参数赋值,并没有在此时初始化HashMap,没有分配空间。
- put方法
public V put(K key, V value) {
if (table == EMPTY_TABLE) { //判断是否内容为空,这里的table是一个键值对数组,默认是空的。
inflateTable(threshold); //如果内容为空,则首先初始化
}
if (key == null) //如果传入的key是null,则调用下面的方法
return putForNullKey(value);
int hash = hash(key); //如果key是存在的,那么计算key的hash
int i = indexFor(hash, table.length);//计算完哈希后,根据哈希和数组长度去计算对应的索引,也就是这个key应该在数组里哪里存储
for (Entry<K,V> e = table[i]; e != null; e = e.next) {//数组中的每个元素是一个链表,已经计算除了索引,所以遍历该索引位置上的链表,目的是为了检查传进来的key是否已经存在在表里
Object k;
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {//如果发现链表上的元素和传进来的key各方面都一样(hash,地址,内容)那么就说明表里已经有了,那么就保持key不变,更新value,并返回旧的value
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue;
}
}
modCount++; //被修改次数加一,这个值主要是为了线程安全考虑,和本方法的业务无关
addEntry(hash, key, value, i); //如果是一个新key,则添加元素。
return null;
}
1.初始化具体做了哪些东西“inflateTable(threshold);”
private void inflateTable(int toSize) {
// Find a power of 2 >= toSize
int capacity = roundUpToPowerOf2(toSize);//这里可以看到,初始化时,无论传进来的初始容量是多少,都会向上取整为2的幂。也就是说,虽然构造方法中可以让用户自定义容量大小,但是进来后也会向上转成2的幂。当然,如果本身就是2的幂,那么就不会转换了,这里看源码,会发现他做了一个-1操作,目的就是为了防止把正确容量也翻一倍。比如你传进来的是15,那么向上取整为16.如果传进来的是16,向上取整就成了32,这是不合理的,所以任何数在取整时都先-1.
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);//计算扩容阈值
table = new Entry[capacity];//初始化
initHashSeedAsNeeded(capacity);
}
2.如果key为null怎么办“ return putForNullKey(value);”
这里可以看到,如果key为null,则默认放在数组的索引为0的位置,换句话说,如果遍历这个hashmap,key为null会出现在前面。首先是上面的循环,我们发现,如果发现链表里还有null,那么就替换value,也就是说,key可以为null,但是只能有一个。
如果说是第一次加入key为null的元素,那么直接保存在索引为0的位置。
3.计算hash时都做了哪些操作?
从上图可以看出,计算hash时不仅仅是得到hash这么简单,还做了一系列复杂的运算,其目的是通过各种右移能够让高位也参与运算,最大化的避免高位相同低位不同分到同一个索引。这里的注释也说明了这样操作的原因。
4.计算索引?
5.添加元素时做了哪些操作“ addEntry(hash, key, value, i);”
void addEntry(int hash, K key, V value, int bucketIndex) {
if ((size >= threshold) && (null != table[bucketIndex])) {//当当前的元素个数大于等于扩容阈值的时候,并且分配给新元素的这个位置以及有值,则扩容
resize(2 * table.length);//扩容为原来的数组长度乘2
hash = (null != key) ? hash(key) : 0;//如果key=null,则hash为0
bucketIndex = indexFor(hash, table.length);//根据新数组长度重新计算索引
}
createEntry(hash, key, value, bucketIndex);//插入该节点
}
首先我们从数据结构的角度来看:HashMap是:数组+链表+红黑树(JDK1.8增加了红黑树部分)的数据结构,如下所示:
1.核心成员
- 默认初始容量(数组默认大小):16,2的整数次方static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
- 最大容量static final int MAXIMUM_CAPACITY = 1 << 30;
- 默认负载因子static final float DEFAULT_LOAD_FACTOR = 0.75f;
- 装载因子用来衡量HashMap满的程度,表示当map集合中存储的数据达到当前数组大小的75%则需要进行扩容
- 链表转红黑树边界static final int TREEIFY_THRESHOLD = 8;
- 红黑树转离链表边界static final int UNTREEIFY_THRESHOLD = 6;
- 哈希桶数组transient Node<K,V>[] table; 实际存储的元素个数transient int size;
- 当map里面的数据大于这个threshold就会进行扩容int threshold 阈值 = table.length * loadFacto
Hash是什么
Hash,一般翻译做散列、杂凑,或音译为哈希,是把任意长度的输入(又叫做预映射pre-image)通过散列算法变换成固定长度的输出,该输出就是散列值。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来确定唯一的输入值。
Hash的特点
- 任意长度的输入,得到固定长度的输出
- 不可逆,可以把原文计算成密文,但是不能倒推回去
解释:比如“我爱你”计算后为“aaa",但是你无法通过”aaa"推导出他的原文- 算法不固定,只要满足hash的思想就是hash算法。加密领域的常见摘要算法有md5,sha256等。
- 可以用来快速检索:比如比较一篇文章和其他一万篇文章是否相同,一行一行去看太慢了,做个哈希转换成某些数字去比较会更快。
- 防篡改:密码学里的主要用途,因为只能加密不能解密,所以发送数据时会把原文加密后把原文和密文一起发给对方,对方收到后,先对原文做个加密,如果密文和收到的一样说明内容没被改过。常见的比如用迅雷下载时,一般会带一个md5文件,如果下载完成后提示文件不安全,那可能就是源文件被修改过和提供的密文不一致。
- 密码保存。注册密码都是加密后保存在数据库的,好处就是数据库维护人员无法直接看到用户的密码,并且无法倒推。用户登录时,输入密码,计算hash,和数据库里存的去比较。
HashMap采用哈希表来存储数据。
哈希表为:数组和链表的组合。即一个一维数组,但是数组中的每个元素是一个链表。
哈希表(Hash table,也叫散列表),是根据关键码值(Key value)而直接进行访问的数据结构,只要输入待查找的值即key,即可查找到其对应的值。
哈希表其实就是数组的一种扩展,由数组演化而来。可以说,如果没有数组,就没有散列表。
为什么要用哈希表?
简单来说,为了在保存数据的时候更方便,达到空间和时间的一个平衡。
向哈希表里存数据时,这里以s代表数据,要先计算s的hash值,然后根据这个hash值去计算它应该对应哪个索引,也就是放在数组的哪一个单元格内。
什么是哈希冲突
我们知道hash是不同长度的输入对应固定长度的输出,也就是说肯定会有某些数据计算出的hash是一样的,这个就叫做hash冲突或者碰撞。
1.如何减少哈希冲突?
- 除留取余法
取关键字被某个不大于哈希表长m的数p除后所得余数为哈希地址
- 直接定址法
直接定址法是指取关键字或关键字的某个线性函数值为哈希地址。
- 数字分析法
假设关键字是以为基的数(如以10为基的十进制数),并且哈希表中可能出现的关键字都是 事先知道的,则可以选取关键字的若干位数组成哈希表。
当然,除了上边列举的几种方法,还有很多种选取哈希函数的方法,就不一一列举了。我们只要知道,选取合适的哈希函数可以有效减少哈希冲突即可。
如何处理哈希冲突
1.开放定址法
开放定址法是指当发生地址冲突时,按照某种方法继续探测哈希表中的其他存储单元,直到找到空位置为止。
2.再哈希法
再哈希法即选取若干个不同的哈希函数,在产生哈希冲突的时候计算另一个哈希函数,直到不再发生冲突为止。
3.建立公共溢出区
专门维护一个溢出表,当发生哈希冲突时,将值填入溢出表。
4.链地址法
链地址法是指在碰到哈希冲突的时候,将冲突的元素以链表的形式进行存储。也就是凡是哈希地址为i的元素都插入到同一个链表中,元素插入的位置可以是表头(头插法),也可以是表尾(尾插法)。我们以仍然以[19,24,6,33,51,15,25,72] 这一组数据为例,用链地址法来进行哈希冲突的处理
哈希表的扩容与Rehash
在哈希表长度不变的情况下,随着哈希表中插入的元素越来越多,发生哈希冲突的概率会越来越大,相应的查找的效率就会越来越低。这意味着影响哈希表性能的因素除了哈希函数与处理冲突的方法之外,还与哈希表的装填因子大小有关。
我们将哈希表中元素数与哈希表长度的比值称为装填因子。装填因子 α={哈希表中元素数}除以{哈希表长度}
为了减少哈希冲突,就需要对哈希表进行扩容操作。比如我们可以将哈希表的长度扩大到原来的2倍。
这里我们应该知道,扩容并不是在原数组基础上扩大容量,而是需要申请一个长度为原来2倍的新数组。因此,扩容之后就需要将原来的数据从旧数组中重新散列存放到扩容后的新数组。这个过程我们称之为Rehash。
扩容,很多人理解就是装不下了,所以要扩容。但是这里扩容的判断并不是达到数组最大值,而是数组长度 * 扩容因子,对于初始容量来说就是16 * 0.75 = 12 ,当hashmap里保存的元素个数达到12时就满足了扩容条件。为什么定0.75,看类的注释,可以发现,0.75是经过开发者测试得到的,达到这个值后出现碰撞的概率比较大啊,所以定了这个数。以下是原文,可以清楚的看到是为了达到空间和时间的一个平衡。
同理,初始容量默认是16,应该也是一个经验值,规定容量必须是2的n次方,初始太小了要频繁扩容,太大了又浪费,所以为了平衡选择16这里需要注意,很多博客都说扩容的条件是达到元素个数达到扩容阈值,这其实不全面。通过看代码可以发现,if里的判断条件是两个,在元素个数达到阈值的同时,如果发现分配给新元素的这个索引已经被占用了,才扩容。单纯达到阈值是不扩容的。简单总结,就是元素个数达到了临界值并且新元素碰撞了,才扩容。
因为规定容量是2的幂,所以扩容时把原容量乘2.
扩容后为什么要重新计算hash?
你想想为啥要扩容,为啥达到容量乘扩容因子扩容?是放不下吗?是为了减少碰撞啊。
想一下你的原数组里,可能某一个索引位置上的链表已经很长了,这些元素可能都是高位不同低位相同的,新数组的容量更大,转换成2进制是不是相当于高位也有了值,这时重新计算哈希,不就把原来一个索引位置下链表里的内容给分散开了吗。
为什么要引入红黑树
为了提高HashMap的性能,之前是链表过长导致索引慢的的问题。
当没有冲突的时候放在数组中,当冲突<8放在链表中,当>8的时候放在红黑树中
时间复杂读从o(n)降到了o(logn)
面试常见题目
-
HashMap的底层数据结构?
-
如何存入元素
-
如何取值
-
jdk7和8的区别
-
为什么线程不安全
-
有线程安全的替代类吗
-
默认初始化大小?为什么?为什么大小是2的幂?
-
扩容方式?负载因子?为什么?
-
主要参数有哪些?
-
如何处理hash碰撞的?
-
索引是如何算出来的?
-
哈希表初始化时机?
-
我用LinkedList代替数组结构可以么
-
哈希冲突的解决方法?
- 开放地址法,链地址法,公共溢出区,再哈希法。具体参考下面博客
- https://www.cnblogs.com/higerMan/p/11907117.html
-
为什么不用Hashtable而用ConcurrentHashMap?
-
8中对HashMap做了哪些修改?
- 由数组+链表的结构改为数组+链表+红黑树。
- 优化了高位运算的hash算法:h^(h>>>16)
- 扩容后,元素要么是在原位置,要么是在原位置再移动2次幂的位置,且链表顺序不变。
- 不会在出现死循环问题。
-
为什么在解决hash冲突的时候,不直接用红黑树?而选择先用链表,再转红黑树?
- 因为红黑树需要进行左旋,右旋,变色这些操作来保持平衡,而单链表不需要。 当元素小于8个当时候,此时做查询操作,链表结构已经能保证查询性能。当元素大于8个的时候,此时需要红黑树来加快查询速度,但是新增节点的效率变慢了。
-
不用红黑树,用二叉树可以吗?
- 可以,但是在一些极端情况下,会退化成一条线性结构
-
一般用什么作为key?
- 一般用Integer、String这种不可变类当HashMap当key,而且String最为常用。
- 因为字符串是不可变的,所以在它创建的时候hashcode就被缓存了,不需要重新计算。这就使得字符串很适合作为Map中的键,字符串的处理速度要快过其它的键对象。这就是HashMap中的键往往都使用字符串。
- 因为获取对象的时候要用到equals()和hashCode()方法,那么键对象正确的重写这两个方法是非常重要的,这些类已经很规范的覆写了hashCode()以及equals()方法。
-
可变类作为key可能发生什么问题
- 取不出来值
enter description here
-
如何实现自定义类作为key?
- 只要搞定两个问题即可:重写hashcode和equals,类不可变
enter description here
linkedhashmap:https://www.jianshu.com/p/a2c5397e9b22
参考面试题: HashMap面试指南 - 知乎
你似乎来到了没有知识存在的荒原 - 知乎