文章目录
一、HashMap使用
Map接口是哈希表的基本接口。有以下特点:
- 底层数据结构是哈希表(数组+链表),哈希表存储键值对: key value
- 元素重复问题:key不能重复、value可以重复
- 元素有序问题:插入是无序的
- 元素是否可以为null:key和value都可以为null
- 默认大小:Map中哈希表的初始默认大小为16
- 扩容时机:按照2倍关系进行扩容
使用
- 新建一个HashMap对象
Map<String, Integer> map = new HashMap<>();
- put方法将键值对放入map集合中
map.put("zs", 10);
- 已知键,移除键值对
map.remove("zs");
,返回所要移除键所对应的值。 - 获取当前键所对应的值
map.get(zs);
- 获取键值对组数
map.size();
- 判断map集合是否为空
map.isEmpty()
- 判断是否包含键
map.containsKey("zs")
- 判断是否包含值
map.containsValue(10)
- 获取所有节点的集合
Set<Map.Entry<String, Integer>> entries = map.entrySet();
- 迭代器使用
HashMap当中所有的元素作为一个entry节点存在,所有节点封装为一个Set
while(iterator.hasNext()){
//判断是否含有下一个可迭代的元素
//获取下一个可迭代的元素
Map.Entry<String, Integer> next = iterator.next();
//分别获取键和值并打印
System.out.println(next.getKey()+":: "+next.getValue());
}
二、HashMap底层结构
1.概念
- 哈希表又被称为散列表,是根据关键码 key直接访问内存中存储位置的数据结构,即通过关于 key的函数,映射到一个地址用来来访问数据,这样加快查找速度。
- 数组:查找容易(index快速定位),删除和插入不易(需要移动当前节点之后的节点)
- 链表:查找不易(需要从头节点或尾节点开始遍历,直到找到目标节点),删除和插入容易(只要修改当前节点前后节点的next或prev即可)
- 哈希表则是对二者的综合,是一个查找容易、插入和删除也容易的数据结构。
index定位索引位置(数组) -> 遍历找到节点进行增、删、改操作(链表)
2.哈希冲突
哈希冲突简单说就是不同关键字得到相同的哈希地址。
- 解决哈希冲突的方法:
1)开放地址法
2)链地址法(数组+链表)
3.HashMap的数据结构
- HashMap本身处理海量数据,当位于同一个位置中的元素越来越多,hash值相等的元素越来越多,使用查找效率降低
- 某一个位置链表的长度超过阈值8时,会将链表的结构转为红黑树。
- 二叉排序树(AVL树、红黑树)
红黑树特性:
a.红黑树每个节点要么是黑色要么是红色
b.根节点是黑色
c.叶子节点是黑色
d,如果一个节点是红色,叶子节点必须是黑色
e.每个节点到叶子节点所经过的黑色节点的数目是一样的
4.HashMap的实现
hash函数类比 jdk中HashMap的hash函数,解决哈希冲突采用链地址法,即链表+数组实现。
下面是几个方法的自定义实现:
put(K key, V value)
实现逻辑:
- 根据 key获取当前 index(直接使用HashMap里的哈希函数),将key放入哈希函数中得到一个散列码(定义为h),通过 table.length-1 & h 得到 index。
- 要将当前 key,value键值对 放到 index位置。在这之前应该先判断:
判断 index当前位置是否存在值:
(1) 若不存在,直接将当前 key,value封装为一个Node,插入到该index位置;
(2) 若存在节点(保证HashMap中key不重复) ->
判断 key是否重复:
a. 如果key有重复,新值覆盖旧值;
b. 如果key没有重复,将当前 key,value封装为一个Node,尾插法插入当前 index位置。
代码实现:
public void put(K key, V value) {
int hash = hash(key);//通过key得到散列码
int index = table.length - 1 & hash;
//判断index位置是否存在节点
if (table[index] == null) {
//如果不存在,直接将当前key,value封装为一个Node,插入到该index位置
table[index] = new Node(hash, key, value);
size++;
} else {
//如果存在节点(保证HashMap中key不重复)
Node<K, V> firstNode = table[index];//获取该位置第一个节点
if (firstNode.key.equals(key)) {//当前位置存在节点 判断key是否重复
//相等则新值覆盖旧值
firstNode.value = value;
} else {
Node<K, V> tmp = firstNode;//定义一个临时引用,从头节点开始
//遍历当前链表,判断key是否重复
while (tmp.next != null && !tmp.key.equals(key)) {
//tmp一直跑,要么跑到最后一个节点,要么找到一个key与之相等的节点
tmp = tmp.next;
}
if (tmp.next == null) {
//跑到最后一个节点
if (tmp.key.equals(key)) {
//如果key有重复,考虑新值覆盖旧值
tmp.value = value;//替换最后一个节点的值
} else {
//如果key没有重复,将当前key,value封装为一个Node 尾插法 插入当前index位置
tmp.next = new Node(hash, key, value);
size++;//添加新节点则size++
}
} else {
//如果key有重复,新值覆盖旧值
tmp.value = value;
}
}
}
}
get(K key)
get方法就是已知一个key,将key作为该方法的一个参数,返回value值。
实现逻辑:
- 与put方法第一步相同,通过key得到一个散列码,由table.length-1 & h 得到 index。
- 在 index的所有节点中找与当前key相等的节点,得到该节点的 value并返回。
代码实现:
public V get(K key){
//获取key所对应的value
//key->index
int hash = hash(key);
int index = table.length - 1 & hash;
//在 index位置的所有节点中找与当前key相等的key
Node<K,V> firstNode = table[index];
//当前位置点是否存在节点
if(firstNode == null){
return null;
}
//判断第一个节点与要查找的key是否相等
if(firstNode.key.equals(key)){
return firstNode.value;
}else{
//遍历当前位置点的链表进行判断
Node<K,V> tmp = firstNode.next;
while (tmp != null && !tmp.key.equals(key)){
tmp = tmp.next;
}
if(tmp == null){
return null;
}else{
return tmp.value;//tmp.key.equals(key)
}
}
}
remove(K key)
实现逻辑:
- 与以上两个方法相同,由key得到散列码,进而得到 index。
- 判断该位置是否存在节点,不存在返回 false,存在则在链表中找到所要删除节点,删除并返回 true,遍历到最后一个节点也没找到则返回 false。
代码实现:
public boolean remove(K key){
//key->index
int hash = hash(key);
int index = table.length - 1 & hash;
//当前位置中寻找 当前key所对应的节点
Node<K,V> firstNode = table[index];
if(firstNode == null){
return false;//不存在该节点
}
if(firstNode.key.equals(key)){
table[index] = firstNode.next;//删除的是第一个节点
size--;
return true;//删除成功
}
while (firstNode.next != null){
if(firstNode.next.key.equals(key)){
firstNode.next = firstNode.next.next;//前一个节点和后一个节点相连
size--;
return true;
}else {
firstNode = firstNode.next;
}
}
return false;
}
resize()
实现逻辑:
- 对table以2倍的方式扩容(对数组扩容)
- 重哈希
代码实现:
public void resize(){
//HashMap扩容
//table进行扩容 2倍的方式 扩容数组
Node<K, V>[] newTable = new Node[table.length*2];
//index -> table.length-1 & hash
//重哈希
for(int i=0; i<table.length; i++){
rehash(i, newTable);
}
this.table = newTable;
}
//重哈希
public void rehash(int index,Node<K,V>[] newTable){
//相当于对原先哈希表中每一个有效节点 进行 重哈希的过程
//要么在原位置(低位位置)
//要么在 原位置+扩容后长度(高位位置)
Node<K,V> currentNode = table[index]; //获取当前节点
if(currentNode == null){//该位置没有节点
return;
}
Node<K,V> lowHead = null;//低位的头
Node<K,V> lowTail = null;//低位的尾
Node<K,V> highHead = null;//高位的头
Node<K,V> highTail = null;//高位的尾
while (currentNode != null){
//遍历index位置的所有节点
int newIndex = hash(currentNode.key) & (newTable.length-1);
if(newIndex == index){
//当前节点链到lowTail之后
if(lowHead == null){
lowHead = currentNode;
lowTail = currentNode;
}else{
lowTail.next = currentNode;
lowTail = lowTail.next;
}
}else{
if(highHead == null){
highHead = currentNode;
highTail = currentNode;
}else{
highTail.next = currentNode;
highTail = highTail.next;
}
}
currentNode = currentNode.next;
}
//要么在原位置 (低位位置)
if(lowHead != null){
newTable[index] = lowHead;
}
//要么跑到 原位置 + 扩容后前度 (高位位置)
if(highHead != null && highTail != null){
newTable[index + table.length] = highHead;
newTable[index + table.length] = highTail;
}
}
三、HashMap源码分析
(一)类的继承关系
- 实现Map接口,定义一些通用方法。一部分在HashMap中实现,一部分由AbstractMap实现。
- 实现Cloneable接口,表示当前HashMap中所有对象可被克隆,可被拷贝的。
- 实现Serializable接口,表示当前HashMap里面所有的对象都是可以被序列化的(即可以将当前对象转化为二进制,永久地保存到磁盘上)
- HashMap允许空值和空键,是非线程安全的,元素是无序的。(HashTable不允许为空 线程安全)
(二)类的属性
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;//16 默认初始容量,用来给table初始化
static final int MAXIMUM_CAPACITY = 1 << 30;//table最大容量
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;
transient Node<K,V>[] table;//哈希表中的 桶(数组)
transient Set<Map.Entry<K,V>> entrySet;//用于迭代器遍历时
transient int size;//键值对个数
transient int modCount;//集合结构的修改次数
int threshold;
final float loadFactor;
(三)类中的重要方法(构造函数)
主要函数: put()、remove()、 resize()
- 构造函数中并未给table进行初始化,在第一次put时对table进行初始化。
put方法:
主要调用了一个putVal方法,下面是对putVal方法内容的分析
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
if ((tab = table) == null || (n = tab.length) == 0)
//resize() 初始化 (扩容)
n = (tab = resize()).length;
if ((p = tab[i = (n - 1) & hash]) == null)
//表示当前位置不存在节点,创建一个新的节点放到该位置
tab[i] = newNode(hash, key, value, null);
else {
//当前位置存在节点,判断key是否重复
Node<K,V> e; K k;
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;//p为头节点
//判断第一个节点的key与所要插入的key是否相等
//hashCode表示将对象的地址转为一个32位的整型返回,不同对象的hashCode可能想等
//比较hash相比于使用equals更加高效
else if (p instanceof TreeNode)
//判断当前节点是否是红黑树节点
//是的话则按照红黑树插入逻辑实现
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
for (int binCount = 0; ; ++binCount) {
//比较当前for循环走过的每一个节点的值与当前的key是否相等
if ((e = p.next) == null) {//此处p为第二个节点
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;
//判断e是否是key重复的节点
p = e;
}
}
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
if (++size > threshold)
resize();//扩容
afterNodeInsertion(evict);
return null;
}
下面是put方法的流程图:
resize()调用时机
- table == null
- table需要扩容的时候
扩容过程
- table进行扩容
- table原先节点进行重哈希
- HashMap的扩容指的是数组的扩容,因为数组的空间内是连续的,所以需要数组的扩容即开辟一个更大空间的数组,将老数组上的元素全部转移到新数组上来。
- 在HashMap中扩容,先新建一个2倍原数组大小的新数组,然后遍历原数组的每一个位置,如果这个位置存在节点,则将该位置的链表转移到新数组。
- 在jdk1.8中,因为涉及到红黑树,jdk1.8实际上还会用到一个双向链表去维护一个红黑树中的元素,所以jdk1.8在转移某个位置的元素时,首先会判断该节点是否是一个红黑树解节点,然后遍历该红黑树所对应的双向链表,将链表中的节点放到新数组的位置当中。
- 最后元素转移完之后,会将新数组的对象赋值给HashMap中table属性,老数组将会被回收。
四、HashMap迭代器实现
特点:
- 由于哈希表数据分布是不连续的,所以在迭代器初始化的过程中需要找到第一个非空的位置点,避免无效的迭代。
- 当迭代器的游标到达当前某一个桶链表的末尾,迭代器的游标需要跳转到下一个非空的位置点。
代码实现:
class Itr implements Iterator{
private int cursor;//表示当前遍历到的元素
private Node<K,V> currentNode;//具体的元素节点
private Node<K,V> nextNode;//下一个元素节点
public Itr() {
//由于哈希表数据分布是不连续的,所以在迭代器初始化的过程中需要找到第一个非空的位置点,避免无效的迭代
if (MyHashMap.this.size <= 0) {
return;
}
for (int i = 0; i < table.length; i++) {
if (table[i] != null) {
cursor = i;
nextNode = table[i];
return;
}
}
}
@Override
public boolean hasNext(){
return nextNode != null;
}
@Override
public Node<K,V> next() {
//暂时保存需要返回的元素节点
currentNode = nextNode;
//nextNode往后走一个,如果没有到达末尾nextNode
nextNode = nextNode.next;
//当迭代器的游标到达某一个桶链表的末尾
if (nextNode == null) {
//迭代器的游标跳转到下一个非空的位置点
for (int j = cursor + 1; j < table.length; j++) {
if (table[j] != null) {
//table[j]表示该位置的第一个元素
nextNode = table[j];
cursor = j;
break;
}
}
}
return currentNode;
}
}
五、HashMap常见面试题分析
1.JDK1.7与JDK1.8HashMap有什么区别和联系?
- JDK1.7采用数组+链表形式解决哈子冲突。JDK1.8将数组+链表改成了数组+链表或红黑树;前者解决hash冲突的方式存在一定的问题: 定位到索引位置之后需要遍历找到节点,若链表过长,也就是hash冲突严重,这时候就有查找性能的问题,查找性能链表为O(n),红黑树为O(logn),使用红黑算法可以提高操作效率。
- 1.7头插法扩容时,会使链表发生反转,多线程环境下会产生环;1.8将表的插入方式从头插法改成了尾插法,简单说就是插入时,如果数组位置上已经有元素,1.7将新元素放到数组中,原始节点作为新节点的后继节点,1.8遍历链表,将元素放置到链表的最后;
- 在插入时,1.7先判断是否需要扩容,再插入,1.8先进行插入,插入完成再判断是否需要扩容
- 扩容的时候1.7需要对原数组中的元素进行重新hash定位在新数组的位置,1.8采用更简单的判断逻辑,位置不变或索引+旧容量大小;
2.说说HashMap的扩容过程
使用resize() 方法: 2的幂次方机制扩容
//table进行扩容 2倍的方式 扩容数组
Node<K, V>[] newTable = new Node[table.length*2];
//index -> table.length-1 & hash
//重哈希
for(int i=0; i<table.length; i++){
rehash(i, newTable);
}
this.table = newTable;
- HashMap的扩容指的是数组的扩容,因为数组的空间内是连续的,所以需要数组的扩容即开辟一个更大空间的数组,将老数组上的元素全部转移到新数组上来。
- 在HashMap中扩容,先新建一个2倍原数组大小的新数组,然后遍历原数组的每一个位置,如果这个位置存在节点,则将该位置的链表转移到新数组。
- 在jdk1.8中,因为涉及到红黑树,jdk1.8实际上还会用到一个双向链表去维护一个红黑树中的元素,所以jdk1.8在转移某个位置的元素时,首先会判断该节点是否是一个红黑树解节点,然后遍历该红黑树所对应的双向链表,将链表中的节点放到新数组的位置当中。
- 最后元素转移完之后,会将新数组的对象赋值给HashMap中table属性,老数组将会被回收。