文章目录
一、什么是HashMap
1.1 Hash是什么
Hash,一般翻译做散列、杂凑,或音译为哈希,是把任意长度的输入(又叫做预映射pre-image)通过散列算法变换成固定长度的输出,该输出就是散列值(也可以称之为哈希值)。这种转换是一种压缩映射,也就是,散列值的空间通常远小于输入的空间,不同的输入可能会散列成相同的输出,所以不可能从散列值来确定唯一的输入值。简单的说就是一种将任意长度的消息压缩到某一固定长度的消息摘要的函数。
Hash算法也被称为散列算法,Hash算法虽然被称为算法,但实际上它更像是一种思想。Hash算法没有一个固定的公式,只要符合散列思想的算法都可以被称为是Hash算法。
所以hash算法并不是唯一,只要尽量满足hash规则一般都可以称之为hash算法。
比如:
Integer的hash函数:
public static int hashCode(int value) {
return value;
}
String的hash函数:
private int hash; //(全局变量)
public int hashCode() {
// eg1: hash=0 h=0
int h = hash; // Default to 0
// eg1: value={'k','1'} value.length=2
/** 只有第一次计算hash值时,才进入下面逻辑中。此后调用hashCode方法,都直接返回hash*/
if (h == 0 && value.length > 0) {
char val[] = value;
for (int i = 0; i < value.length; i++) {
// eg1: val[0]=107 val[1]=49
h = 31 * h + val[i];
}
// eg1: 31(31*0+107)+49=3366
hash = h;
}
return h;
}
可见,hash算法完全可以自己定义和实现。一些特定场合可能需要不同的规则来处理不同的情况。
需要注意的是:哈希算法和哈希函数不是一个东西,哈希函数是哈希算法的一种实现,以后说哈希函数就行。计算哈希值的过程就叫哈希。
1.2 Map是什么
Java中的map是一种依照键存储元素的容器。map就是用于存储键值对(<key,value>
)的集合类
,也可以说是一组键值对的映射(数学概念)。注意,我这里说的只是map的概念,是为了通俗易懂,面试时候方便记忆,但是你自己一定要明白,在Java中map是一个接口
,是和collection接口同一等级的集合根接口
。
map的存储结构是这样的:
看起来就像是数据库中的关系表,有两个字段(或者说属性),keyset(键的集合)和values(值的集合),每一条记录都是一个entry(一个键值对)。
Map的特点
- 没有重复的key。一方面,key用set保存,所以key必须是唯一,无序的;另一方面,map的取值基本上是通过key来获取value,如果有两个相同的key,计算机将不知道到底获取哪个对应值;这时候有可能会问,那为什么我编程时候可以用put()方法传入两个key值相同的键值对?那是因为源码中,传入key值相同的键值对,将作为覆盖处理。
- 每个key只能对应一个value,多个key可以对应一个value。(这就是映射的概念,最经典的例子就是射箭,一排射手,一排箭靶,一个射手只能射中一个箭靶,而每个箭靶可能被不同射手射中。这里每个射手只有一根箭,不存在三箭齐发还都中靶这种骚操作。将射手和射中的靶子连线,这根线加射手加靶子就是一个映射)
- key,value都可以是任何引用类型(包括null)的数据(只能是引用类型)
- 赋值的时候必须同时给key和value赋值(其实也不算特点,就放在这吧)
Map和Hash的结合
在将键值对存入数组之前,将key通过哈希算法计算出哈希值,把哈希值作为数组下标,把该下标对应的位置作为键值对的存储位置,通过该方法建立的数组就叫做哈希表
,而这个存储位置就叫做桶(bucket)。数组是通过整数下标直接访问元素,哈希表是通过字符串key直接访问元素,也就说哈希表是一种特殊的数组(关联数组),哈希表广泛应用于实现数据的快速查找(在map的key集合中,一旦存储的key的数量特别多,那么在要查找某个key的时候就会变得很麻烦,数组中的key需要挨个比较,哈希的出现,使得这样的比较次数大大减少。)
哈希表选用哈希函数计算哈希值时,可能不同的 key 会得到相同的结果,一个地址怎么存放多个数据呢?这就是哈希冲突(碰撞)。解决哈希冲突有两种方法,
拉链法(链接法)和开放定址法。
拉链法:将键值对对象封装为一个node结点,新增了next指向,这样就可以将碰撞的结点链接成一条单链表,保存在该地址(数组位置)中。
HashMap是用哈希表
(直接一点可以说数组加单链表
)+红黑树
实现的map类。
二、HashMap部分源码理解
接下来将介绍一下我对HashMap的put方法(也是关键)的一些理解。
2.1 关键变量
1.全局变量
//默认初始化容量:16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
//最大容量:2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
//默认加载因子:0.75
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//树化阈值
static final int TREEIFY_THRESHOLD = 8;
//非树化阈值(个人理解为不需要树化的最大阈值)
static final int UNTREEIFY_THRESHOLD = 6;
//最小树化容量
static final int MIN_TREEIFY_CAPACITY = 64;
// 存储Node数组,可以理解为哈希表(的数组部分)
transient Node<K, V>[] table;
// 数据大小(多少个数据(key、value键值对)put进来了)
transient int size;
// 改动次数
transient int modCount;
// 阈值 = capacity*loadFactor (容量*加载因子)
int threshold;
// 加载因子
final float loadFactor;
2.局部变量
// 存储数据的Node数组
Node<K, V>[] oldTab -> newTable
// 阈值 threshold
int oldThr -> newThr
// 容量 capacity
int oldCap -> newCap
3.Node对象
static class Node<K, V> implements Map.Entry<K, V> {
final int hash;
final K key;
V value;
Node<K, V> next;
Node(int hash, K key, V value, Node<K, V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
(……一些方法)
}
Node对象可以看成一个保存上下节点位置信息和本身内容的的一个节点!
创建一个HashMap其实只是创建一个空数组,没啥可说的。
这里记录一下transient
的作用。简单地说,就是让某些被修饰的成员属性变量不被序列化,这一看好像很好理解,就是不被序列化,那么反序列化的时候,使用transient关键字修饰的变量会使用默认值(如果存在)或者直接为null。为什么这样做呢?主要是为了节省存储空间,也有可能是某些字段需要进行重新计算才能得到具体值等。可以参照这篇文章来理解。
2.2 关键逻辑
往HashMap往里放一个元素的逻辑大致如下:
先计算key的哈希值
首先如果是空的table(数组),则初始化一个容量为16、阈值为12的新table。构建Node对象并计算应该对象存放的数组位置(下标、桶),然后把数据填入到该位置。
如果是非空table,构建Node对象并计算位置后,如果该位置为空,则把当前Node放在该位置;如果该位置被占用了,则进行链表操作。
链表操作:循环遍历该条链表,若有某个节点的key和当前Node的key相同,或者key相同且hash值也相同,则将该节点内容更新为最新的插入值;否则直接将该Node链接在该链表的最后。但是有可能进行**resize()**操作。
resize()扩容
①当某个链表的长度大于8时,那么链表就会试图变为红黑树,如果当前数组table的长度小于64,就只进行resize()不转红黑树。或者当map里的数据要大于阈值的时候,也会进行resize()。
②resize的过程:首先根据情况,调整新表的容量newCap和阈值newThr。一般情况下是将容量提高一倍,阈值提高一倍。若是老的表太大(>=
MAXIMUM_CAPACITY= 1 << 30
),那么将新的阈值设置为Integer.MAX_VALUE=2^31-1
,并且表不进行扩容了。然后进行第二步,根据newCap和newThr,构建新数组,构建新数组的过程中,有可能会进行hash计算然后位置重排,最后得到一个扩容好的新数组table。这里的重新排列规则是这样的:每个节点的新位置要么在table数组的原下标位置,要么在**<原下标+原容量>**的位置,位置被占用则往后拉链表。
红黑树因为本人不了解,等了解了再更新。有兴趣的小伙伴可以自行学习。
以上介绍就是put方法的大致逻辑,要特别注意初始化容量的大小、加载因子默认是多少、初始化阈值是多少,怎么扩容何时扩容扩容多少等。
2.3 关键细节
2.3.1 hash()
// egx: key="k1"
// eg1: key=0
static final int ha