基础
1、数组
内存连续,元素类型相同,长度确定
数组对象本身存储在堆中
优点:
按照索引查询元素速度快
能根据下标随机访问元素
缺点:
根据元素内容查找元素速度慢
插入和删除速度慢
数组的大小一经确定不能改变,不适合动态存储;
内存空间连续,在分配内存的时候需要分配一块连续的空间,所以数组不能定义的太大
2、链表
内存空间不连续,没有索引,查找只能从头遍历查找,每一个元素都包含了下一个元素的内存地址,增加删除元素只用改变相邻元素的地址指针
优点:
插入和删除元素速度快
节点个数可以按照需要进行增删,存储空间动态变化
内存利用率高,不会浪费内存
缺点:
不能随机访问元素,从第一个开始遍历,查找效率低
3、数组和链表区别
- 数组元素个数固定 , 链表节点个数不固定
- 数组内存在创建数组的时候定义 , 链表内存可以动态向系统申请
- 数组中元素顺序由下标决定 , 链表节点顺序由节点包含的指针来决定
- 数组有索引,查询方便,增删效率低,涉及元素的整体移动
- 链表没有索引,查询不方便,只能从头遍历,增删效率高,只用修改相邻节点的指针
4、散列表(哈希表)
又叫哈希表
基础是数组 , 数组元素中保存的数据是一串链表
5、哈希
核心理论: Hash也称散列、哈希,对应的英文都是Hash,基本原理就是把任意长度的输入,通过Hash算法变成固定长度的输出。将一个大数据转化成小数据,看可能会产生hash冲突。
这个映射的规则就是对应的Hash算法,而原始数据映射后的二进制串就是哈希值。
Hash特点:
1.从hash值不可以反向推导出原始的数据
2.输入数据的微小变化会得到完全不同的hash值,相同的数据会得到相同的值
3.哈希算法的执行效率要高效,长的文本也能快速地计算出哈希值
4.hash算法的冲突概率要小
可能会出现不同数据的Hash值相同的情况
HashMap原理
1、HashMap继承体系
最重要的是Map接口。
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
HashMap继承自AbstractMap抽象类
AbstractMap实现Map接口
2、Node数组数据结构分析
在HashMap类中有一个静态内部类Node,它继承了Map.Entry接口。Map.Entry接口中写了getKey、getValue、setValue方法。
添加进map中的数据都会封装成一个Node元素,然后再存到散列表中。
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;//存储key计算后的的Hash值
final K key; //key
V value; //value
Node<K,V> next;//Hash碰撞后存储在同一位置的链表结构
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
3、底层存储结构分析
底层结构其实就是数组+链表+红黑树
第一次初始化的时候Node数组的长度是16,
当发生冲突之后,会在数组中形成链表,
当链表长度超过8 达到9并且数组中所有元素大于64的时候,链表结构会升级为红黑树。
4、put数据原理分析
比如我们要使用put方法添加一个(“暴躁”,“小刘”)的key / value 数据。
- 获取key值“暴躁”字符串的hash值
- 经过hash值扰动函数 , 使hash’值更加散列
- 构造出Node对象,key = “暴躁” , value = “小刘”
- 经过路由算法, 计算出这个node元素应该存放在数组哪个位置。
路由算法: (数组长度 -1 ) & 刚刚计算的key的hash值
源码分析
1.HashMap核心属性分析(threshold, loadFactory, size, modCount)
在HashMap.java文件中有
基本常量
//设置常量作为数组的初始大小 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//数组的最大长度
static final int MAXIMUM_CAPACITY = 1 << 30;
//缺省的负载因子大小
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数组
static class Node<K,V> implements Map.Entry<K,V> {
}
HashMap中的重要参数
//用Node数组构建的散列表
transient Node<K,V>[] table;
//表示当前HashMap包含的键值对数量
transient int size:
//表示当前HashMap结构修改次数(插入或者删除元素)
transient int modCount:
//扩容阈值
//表示当前HashMap能够承受的最多的键值对数量,一旦超过这个数量HashMap就会进行扩容
int threshold:
//负载因子,用于计算扩容阈值。扩容阈值=数组长度*负载因子
final float loadFactor:
⒉.构造方法分析
做校验:数组长度必须大于0, 小于规定的最大长度 , 并且 负载因子 必须 大于0
// initialCapacity 数组长度 loadFactor 负载因子
public HashMap(int initialCapacity, float loadFactor) {
//数组长度<0
if (initialCapacity < 0)
//返回异常
throw new IllegalArgumentException("Illegal initial capacity: " + initialCapacity);
//大于设定的最大长度30
if (initialCapacity > MAXIMUM_CAPACITY)
//从新设置长度
initialCapacity = MAXIMUM_CAPACITY;
负载因子不能<0 , 不能不是数字
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " + loadFactor);
//赋值操作
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
3.HashMap put方法执行流程
-
在使用默认构造器去初始化一个HashMap对象时,在第一次put键值对的时候会计算key的hash值,根据计算得到的hash值来确定存储的位置。
-
紧接着调用了putVal方法,在刚刚初始化之后的table值为null(延迟初始化)因此程序会进入到resize()方法中。而resize方法就是用来进行扩容的(稍后提到)。扩容后得到了一个table的节点(Node)数组,接着根据传入的hash值去获得一个对应节点p并去判断是否为空,是的话就存入一个新的节点(Node)。反之如果当前存放的位置已经有值了就会进入到else中去。接着根据前面得到的节点p的hash值以及key跟传入的hash值以及参数进行比较,如果一样则替覆盖。如果存在Hash碰撞就会以链表的形式保存,把当前传进来的参数生成一个新的节点保存在链表的尾部(JDK1.7保存在首部)。而如果链表的长度大于8那么就会以红黑树的形式进行保存。
延迟初始化:
散列表是占用内存空间的, 有的时候我们创建了, 但是并不一定使用,. 如果这个时候就初始化, 就用占用内存空间, 所以要延迟到使用的时候再初始化。
要执行替换操作的情况:
1、当前table中存放的key与传入的key一样
2、在当前链表中找到了一个一样的key
1、在put方法内部调用了一个putVal方法,里面有一个hash扰动函数
// 第一次添加键值对,先调用hash方法来确定存储的位置
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
2、hash扰动函数,
作用:i让lkey的hash值的高16位也参与路由运算
// 作用:确定键值对存储的位置
static final int hash(Object key) {
int h;
//key = null , hash = 0
//
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
3、put底层调用了putVal()方法
final V putVal(int hash, K key, V value, boolean onlyIfAbsent, boolean evict) {
//tab: 引用当前hashMap的散列表'
//p: 表示当前散列表的元素
//n: 表示散列表数组的长度
//i: 表示路由寻址结果
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 由于刚开始是table值是null,程序会进入到resize()方法中,resize()方法就是用来扩容的。
if ((tab = table) == null || (n = tab.length) == 0)
// 创建一个table节点的数组
n = (tab = resize()).length;
// 根据hash值来确认存放的位置。如果当前位置是空直接添加到table中
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// 根据前面得到的节点p的hash值以及key跟传入的hash值以及参数进行比较,如果一样则替覆盖。
//e: 不为null的话,找到了一个与当前要插入的key-value一致的key,要执行替换操作
//k: 表示临时的一个key
Node<K,V> e; K k;
//确认当前table中存放键值对的Key是否跟要传入的键值对key一致,
//一致的话,后面就要进行替换操作
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//一样就把p赋值给e
e = p;
// 如果key不一样,并且是红黑树
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// 如果hash一样的两个不同的key,就会以链表的形式存在
//迭代链表
for (int binCount = 0; ; ++binCount) {
//如果将p的下一个节点赋值给e,并且下一个节点是null,
//说明整个链表都没有和它重复的
if ((e = p.next) == null) {
//将这个新元素添加到链表末尾
p.next = newNode(hash, key, value, null);
// 判断链表长度大于8
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//转化为红黑树
treeifyBin(tab, hash);
break;
}
//如果找到一个,hash一样、key一样、key!=null
//说明找到了一个一样的元素,就要执行替换操作
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//e!=null , 说明找到了一个key一样的元素,要执行替换操作
if (e != null) {
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
//散列表修改次数+1
++modCount;
//插入新元素,size+1
//如果当前HashMap的容量超过threshold则进行扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
put方法总结:
-
在put方法内部调用了一个putVal方法,里面有一个hash扰动函数。
-
在putVal方法中if 判断数组table是否为空或者长度为0,是就执行resize()方法,进行初始化扩容;
-
根据键值key计算hash值得到插入的数组索引下标 i ,然后将得到的节点赋值给p ,如果当前节点是null ,直接新建节点添加即可,不是空就进入else判断
-
设置一个临时的Node节点e,
-
根据前面得到的节点p的hash值以及key与传入的hash值以及参数进行比较,如果一样就把得到的节点p赋值给临时节点e 。后续执行替换。
-
如果key不一样,并且是红黑树,则直接插入键值对。
-
如果hash冲突,就会以链表的形式存在。遍历链表 ,
-
p的下一个节点是null, 说明已经遍历到最后了,都没有找到一样的,就将这个新元素添加到链表末尾。判断链表长度大于8,就转化成红黑树。(JDK1.8之前是添加在链表开始)
-
如果找到一个,hash一样、key一样、key!=null , 说明找到了一个一样的元素,就要执行替换操作。
-
散列表修改次数+1 ,数组长度size +1 , 如果size超过阈值则进行扩容
4.HashMap tesize扩容方法分析!!!!!!!
什么时候进行扩容:
当put方法执行的时候,如果table为空,则执行resize();方法扩容。默认长度为16;
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
当table中存储值的个数大于等于threshold的时候,进行扩容。容量为原来的2倍。
if (++size > threshold)
resize();
为什么要扩容:
让添加的数据特别多的时候,就会形成很长的树或链表,查找性能就会降低。所以需要对数组扩容,扩容后里面的数据就会分散,以空间换时间。
源码:
final Node<K,V>[] resize() {
//oldTab : 引用扩容之前的hash表
Node<K,V>[] oldTab = table;
// oldCap : 扩容之前的数组长度
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//oldThr : 扩容之前的扩容阈值
int oldThr = threshold;
//newCap ; 扩容之后的数组长度
//newThr : 扩容之后的新的阈值
int newCap, newThr = 0;
//开始计算 newCap, newThr 的值
//大于0 , 说明散列表已经初始化过了,下面要进行扩容操作
if (oldCap > 0) {
//如果数组长度已经大于设定的最大长度,就不能扩容了,
//设置阈值为Integer的最大值
if (oldCap >= MAXIMUM_CAPACITY) {
//把阈值改成Integer的最大值
threshold = Integer.MAX_VALUE;
//然后把表返回
return oldTab;
}
//将原数组长度左移一位,也就是翻倍,赋给新的数组长度
//如果新数组长度 小于 最大限制 , 并且老数组长度大于 16
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
//阈值翻倍
newThr = oldThr << 1;
}
//oldCap = 0 ,还没有初始化。
//如果老阈值大于0
else if (oldThr > 0)
//把老阈值 赋给 新数组长度
newCap = oldThr;
//oldCap = 0,oldThr = 0 ,说明要初始化,配置默认值
else {
newCap = DEFAULT_INITIAL_CAPACITY; //16
// 新的阈值 12
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//新阈值 = 0
if (newThr == 0) {
// 阈值 = 新数组长度 * 负载因子
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
threshold = newThr;
//结束计算 newCap, newThr 的值
// 计算出新的数组长度后赋给当前成员变量table
@SuppressWarnings({"rawtypes","unchecked"})
//新建hash桶数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;//将新数组的值复制给旧的hash桶数组
// 如果原先的数组没有初始化,那么resize的初始化工作到此结束,
//否则进入扩容元素重排逻辑,使其均匀的分散
if (oldTab != null) {
// 遍历新数组的所有桶下标
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e; //设置临时Node节点
// 旧数组的桶节点赋给临时节点e,
if ((e = oldTab[j]) != null) {
//并且解除旧数组中的引用,否则就数组无法被GC回收
oldTab[j] = null;
// 如果e.next==null,代表桶中就一个元素,不存在链表或者红黑树
if (e.next == null)
// 用同样的hash映射算法把该元素加入新的数组
newTab[e.hash & (newCap - 1)] = e;
// 如果e是TreeNode并且e.next!=null,那么处理树中元素的重排
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
// e是链表的头并且e.next!=null,那么处理链表中元素重排
else { // preserve order
// loHead,loTail 代表扩容后不用变换下标,见注1
Node<K,V> loHead = null, loTail = null;
// hiHead,hiTail 代表扩容后变换下标,见注1
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
// 遍历链表
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
// 初始化head指向链表当前元素e,e不一定是链表的第一个元素,初始化后loHead
// 代表下标保持不变的链表的头元素
loHead = e;
else
// loTail.next指向当前e
loTail.next = e;
// loTail指向当前的元素e
// 初始化后,loTail和loHead指向相同的内存,所以当loTail.next指向下一个元素时,
// 底层数组中的元素的next引用也相应发生变化,造成lowHead.next.next.....
// 跟随loTail同步,使得lowHead可以链接到所有属于该链表的元素。
loTail = e;
}
else {
if (hiTail == null)
// 初始化head指向链表当前元素e, 初始化后hiHead代表下标更改的链表头元素
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 遍历结束, 将tail指向null,并把链表头放入新数组的相应下标,形成新的映射。
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
总结:
- 在HashMap刚创建的时候,不会初始化,在第一次put的时候才会用resize方法进行初始化,也就是第一次扩容。容量为16、阈值为12
- 在resize方法中会先定义一些变量:扩容之前的hash表、扩容之前数组长度、扩容之前阈值、新的数组长度、新的阈值
- 开始计算 newCap, newThr 的值 ,
如果扩容前数组长度已经大于设定的最大长度,就不能扩容了,设置阈值为Integer的最大值。
如果没有达到最大长度,就将原数组长度左移一位,也就是翻倍,赋给新的数组长度。阈值也进行移位翻倍。
如果扩容前数组长度=0 ,说明还没有初始化,那么就进行赋值操作,新数组长度为16 , 阈值为12。
如果新阈值为0,那么就等于数组长度 * 负载因子
newCap, newThr 的值计算结束 - 然后根据扩容后数组长度新建一个Node节点数组
- 如果不是初始化操作,还要进行扩容元素重排逻辑,使其均匀的分散。
- 重排逻辑待续。。。。。。
HashMap为什么会在1.8中使用红黑树
- 当我们往hash表中添加一个对象时,会调用对象的hash code方法,根据hash算法算出对应的数组的索引值,再根据索引值查找数组,数组中是否存在对象,如果不存在对象直接存进去。
- 如果存在对象,则通过equals比较两个对象的key值是否相等,如果相等则覆盖value值。
- 如果不相等则形成链表结构,当链表中的元素越来越多时,由于链表的增删效率比较高,但是查询效率比较低,因此当链表达到一定长度之后,就会将他转换为红黑树,用来提高查询效率。
- 什么是红黑树?
- 红黑树是一个自平衡的二叉查找树,查找的效率非常高
- 节点是红色或者黑色
- 根节点是黑色
- 每个叶节点也是黑色的。
- 每个红色节点的两个子节点都是黑色
- 从任一节点到其每个叶子的所有路径都包含相同数目的黑色节点。
- 为什么要选择红黑树?
- 红黑树是一个自平衡的二叉查找树,查找的效率非常高
- 为什么不一下子将整个链表转换为红黑树
- 构造红黑树要比构造链表复杂,在链表的节点不多的时候,从整体的性能看来, 数组+链表+红黑树的结构可能不一定比数组+链表的结构性能高。就好比杀鸡焉用牛刀的意思。
- HashMap频繁的扩容,会造成底部红黑树不断的进行拆分和重组,这是非常耗时的。因此,也就是链表长度比较长的时候转变成红黑树才会显著提高效率。
HashMap的key常用String类型
- hashCode方法的一个重要因素就是同一个对象调用hashCode()方法应该产生相同的值;
- String对象的底层是一个final修饰的char类型的数组,内部已重写了equals()、hashCode等方法,不容易出现Hash值计算错误的情况;。
- 非String类型的对象在获得的value时需要首先保障散列码相同,并且经过equals()方法判断为true时才可以获得对象的value。
- 设计 hashCode() 时最重要的因素就是对同一个对象调用 hashCode() 都应该产生相同的值。String 类型的对象对这个条件有着很好的支持,因为 String 对象的 hashCode() 值是根据 String 对象的内容计算的,并不是根据对象的地址计算。
- 如果你想把自定义的对象作为 key,那也是可以的,你只需要重写 hashCode() 方法与 equals() 方法即可,因为每创建一个对象,内存地址都是不一样的。。
为什么HashMap中key只能是引用类型,不能为基本类型
- HashMap存储数据的特点是无序,无索引不能存储重复的元素,因此没存储一个对象都会调用其hashCode方法计算出hash值,如果相同就拒绝存储,如果不同还会调用equals方法进程比较,如果返回true,也会拒绝存储,
- 不能为基本类型的原因也正是因为基本类型中是没有hashCode和equals方法的。无法进行比较
HashMap 的长度为什么是2的幂次方
为了能让 HashMap 存取高效,尽量较少碰撞,也就是要尽量把数据分配均匀,每个链表/红黑树长度大致相同。这个实现就是把数据存到哪个链表/红黑树中的算法。
这个算法应该如何设计呢?
我们首先可能会想到采用%取余的操作来实现。但是,重点来了:“取余(%)操作中如果除数是2的幂次则等价于与其除数减一的与(&)操作(也就是说 hash%length==hash&(length-1)的前提是 length 是2的 n 次方;)。” 并且 采用二进制位操作 &,相对于%能够提高运算效率,这就解释了 HashMap 的长度为什么是2的幂次方。采用2的幂次方,可以进行移位操作。
那为什么是两次扰动呢?
答:这样就是加大哈希值低位的随机性,使得分布更均匀,从而提高对应数组存储下标位置的随机性&均匀性,最终减少Hash冲突,两次就够了,已经达到了高位低位同时参与运算的目的;
HashMap为什么不直接使用hashCode()处理后的哈希值直接作为table的下标?
答:hashCode()方法返回的是int整数类型,其范围为-(2 ^ 31)~(2 ^ 31 - 1),约有40亿个映射空间,而HashMap的容量范围是在16(初始化默认值)~2 ^ 30,HashMap通常情况下是取不到最大值的,并且设备上也难以提供这么多的存储空间,从而导致通过hashCode()计算出的哈希值可能不在数组大小范围内,进而无法匹配存储位置;