基础入门
数组的优势/劣势
采用一段连续的存储单元来存储数据
优势:
- 随机访问性强
- 查找速度快
劣势:
- 插入和删除效率低
- 可能浪费内存
- 内存空间要求高,必须有足够的连续内存空间。
- 数组大小固定,不能动态拓展
适合读操作多、写操作少的场景
链表的优势/劣势
优势:
- 插入删除速度快
- 内存利用率高,不会浪费内存
- 大小没有固定,拓展很灵活
劣势:
- 不能随机查找,必须从第一个开始遍历,查找效率低
散链表
松散链表:是链表的一种变形或者改良,它的每个节点由一数组来存储元素,节点数组的容量是固定的。
- 插入操作: 根据下标找到位置后插入元素。如果当前插入节点数组已满则创建一个新节点,并且将当前节点一半的元素移至新节点的数组中,最后插入元素。
- 删除操作: 根据下标删除元素。删除元素后的节点可能需要其临近节点作合并操作。
优点:
- 松散链表同时具有数组随机访问的优点和链表高效的插入删除特性。
- 由于每个节点会承载多个元素,所以松散链表的节点空间开销更少
哈希 Hash
介绍:Hash也称散列、哈希
基本原理:把任意长度的输入,通过Hash算法变成固定长度的输出。而原始数据映射后的二进制串就是哈希值
特点:
- 从hash值不可以反向推导出原始的数据
- 输入数据的微小变化会得到完全不同的hash值,相同的数据会得到相同的值
- 哈希算法的执行效率要高效,长的文本也能快速地计算出哈希值
- hash算法的冲突概率要小
hash冲突
由于hash的是将输入空间的值映射成hash空间内,而hash值的空间远小于输入的空间。根据抽屉原理,一定会存在不同的输入被映射成相同输出的情况。
抽屉原理:有十个苹果,要把十个苹果放到九个抽屉里,至少会有一个抽屉里面放不少于两个苹果。
如何解决Hash冲突
- 开放寻址法:开放寻址法是一种解决碰撞的方法,对于开放寻址冲突解决方法,比较经典的有线性探测方法(Linear Probing)、二次探测(Quadratic probing)和 双重散列(Double hashing)等方法。
- 链表法:链表法是一种更加常用的散列冲突解决办法,相比开放寻址法,它要简单很多。在散列表中,每个位置对应一条链表,所有散列值相同的元素都放到相同位置对应的链表中。HashMap解决Hash冲突用的就是链表法。
HashMap的集合类的成员
成员变量
- 序列化版本号
private static final long serialVersionUID = 362498820763181265L;
- 集合的初始化容量(必须是2的n次幂)
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // 默认16
- HashMap构造方法还可以指定集合的初始化容量大小:
HashMap(int initialCapacity) //构造一个带指定初始容量和默认加载因子(0.75)的空HashMap。
- 默认加载因子(0.75),用于扩容(table.length()*0.75=12)
static final float DEFAULT_LOAD_FACTOR = 0.75f;
- 集合最大容量(最大2^30)
static final int MAXIMUM_CAPACITY = 1 << 30;
- 当链表的长度超过8则会转红黑树(1.8新增)
static final int TREEIFY_THRESHOLD = 8;
- 当链表的值小于6则会从红黑树转回链表
static final int UNTREEIFY_THRESHOLD = 6;
中间有个差值7可以防止链表和树之间频繁的转换。
- 桶中结构转化为红黑树对应的数组长度最小的值
static final int MIN_TREEIFY_CAPACITY = 64;
PS:就是链表转换成红黑树的额外条件
- table用于初始化(必须是2的n次幂)
transient Node<K,V>[] table; //存储元素数组
Node节点实现了Map接口,存放键值对
- entrySet用于存放缓存
transient Set<Map.Entry<K,V>> entrySet; //存放具体的元素集合
- HashMap中存放元素的个数,不同于容量capaticy(可存放的大小)
transient int size;
- HashMap扩容修改次数
transient int modCount;
- 扩容的计算方法
int threshold; //table.length()*0.75=12
- 哈希表的加载因子
final float loadFactor; //默认0.75
是用来衡量HashMap满的程度(疏密程度),size/capacity
构造方法
- 无参构造
HashMap()
默认数组容量16,加载因子0.75 - 指定一个容量的构造方法
HashMap(int initialCapacity)
- 指定容量和加载因子的构造方法
HashMap(int initialCapacity, float loadFactor)
HashMap 的数据结构(重点)
数组 + (链表或红黑树)
- HashMap由 数组 + 链表 +红黑树 构成的,数组是HashMap的主体,以键值对的方式存储
什么时候创建数组/链表
HashMap<String,String> hashMap = new HashMap<String,String>();
形成数组和链表的时间
数组—— 查找时间复杂度 O(1)
-
在jdk7,构造方法中创建一个长度是16的 Entry[]table 用来存储键值对数据的。
在jdk8以后,是在第一次调用
put()
方法时创建的数组
链表—— 解决hash冲突,O(n)
-
发生hash冲突时形成链表,新元素存放在最后
所以当数组容量使用超过 16*0.75=12 的时候就会进行数组扩容,减少hash冲突提高查询效率
红黑树 —— 提高查询效率 O(logn)
-
jdk8之后,当链表长度大于8,数组容量大于64时会根据当前链表形成红黑树
当红黑树中节点小于6时会转换回链表
为什么要在 put 的时候创建数组
-
数组在内存中是一段连续的内存空间(存放在堆中)
-
在 put() 时创建可以避免内存的浪费
HashMap存储过程
- 根据key计算一个hash值
- 在 put 的时候创建默认容量为 16 的数组
怎么确定键值对在数组中的下标位置:
hash&(length-1)
目标:尽量减少碰撞,把数据分配均匀
需要用到 hash 值和数组最大索引值,两者的与运算&
- 可以通过hashcode值和数组长度取模
hash%length
定位到要存储的下标,- 但是直接求余效率不如位移运算。所以源码中做了优化,使用
hash&(length-1)
,而实际上hash%length == hash&(length-1)
的前提——length是2的n次幂
- 但是直接求余效率不如位移运算。所以源码中做了优化,使用
确定索引位置后有三种情况
- 该位置为空,直接创建 Node节点, 将元素放进去
- 该位置有值(有可能形成链表)
- 判断链表的长度,是否需要转换为红黑树
- 链表的长度大于8
- 数组的容量大于64(如果小于64,只会进行数组扩容)
- 通过 equal() 比较,如果两者key相等则直接覆盖,如果不等则在最后存储该元素(由数组结构变成了数组+链表结构)
- 判断链表的长度,是否需要转换为红黑树
- 该位置为红黑树
- 以 Node 的方式存入
jdk8 加入红黑树的目的
目的:可以提高查找效率,避免在jdk7 的时候可能发生的循环链表
- 空间:树节点所占空间是普通节点的两倍,所以只有当节点足够多的时候,才会使用树节点。
- 节点的分布频率会遵循泊松分布,链表长度达到8个元素的概率为0.00000006,几乎是不可能事件.
- 时间:遍历的时间复杂度:链表O(n) < 红黑树O(logn)
- 链表平均查找长度为8/2=4,树查找长度为log(8)=3,这才有转换成树的必要
面试题 1、扩容什么都是2的次幂
面试题:数组长度为什么必须是2的n次幂?如果输入值不是2的幂比如10会怎么样?
hash&(length-1)
hash值和数组的最大索引值进行与运算
问题1:数组长度为什么是2的n次幂
- 为了数据的的均匀分布,减少hash冲突
- 如果数组长度不是2的n次幂,计算出的索引特别容易相同,及其容易发生hash碰撞,导致其余数组空间很大程度上并没有存储数据,链表或者红黑树过长,效率降低
2的n次方实际就是1后面n个0,2的n次方-1实际就是n个1;
假设长度为8(2^3)
2&(8-1)
0000 0010
0000 0111
----------
0000 0010 索引:2
3&(8-1)
0000 0011
0000 0111
----------
0000 0011 索引:3
假设修改长度为9
2&(9-1)
0000 0010
0000 1000
----------
0000 0000 索引:0
3&(9-1)
0000 0011
0000 1000
----------
0000 0000 索引:0
小结
- 当我们根据key的hash确定其在数组的位置时,如果n为2的幂次方,可以保证数据的均匀插入,如果n不是2的幂次方,可能数组的一些位置永远不会插入数据,浪费数组的空间,加大hash冲突。
- 一般会通过
%
求余来确定位置,只不过性能不如按位与&
运算。而且当n是2的幂次方时:hash&(length-1)==hash%length
- 因此,HashMap 容量为2次幂的原因,就是为了数据的的均匀分布,减少hash冲突,毕竟hash冲突越大,代表数组中一个链的长度越大,这样的话会降低hashmap的性能
问题2:输入值为10时,会发生什么?
通过HashMap构造方法指定数组大小为n,会通过移位和或运算,找比n大的最小2 的幂次
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
-
cap - 1
防止给定的数恰好是2的幂次 -
说明:int 四个字节,32位 int n = cap - 1; n=10-1=9 00000000 00000000 00000000 00001001 -->9 n |= n >>> 1; 00000000 00000000 00000000 00001001 00000000 00000000 00000000 00000100 右移1位 00000000 00000000 00000000 00001101 或操作 -->13 n |= n >>> 2; 00000000 00000000 00000000 00001101 -->13 00000000 00000000 00000000 00000011 右移2位 00000000 00000000 00000000 00001111 或操作-->15 n |= n >>> 4; //后续都为15 n |= n >>> 8; n |= n >>> 16;
-
容量最大也就是32bit的正数,因此最后n=n>>>16;最多也就32个1(最高位符号位,为负数),恰好给定最大值230,已经提前做了判断,结果不会大于230次
面试题 2、HashMap的线程安全问题发生在哪个阶段
线程不安全的原因:多个线程同时访问一个资源
很多位置都会出现线程安全问题,主要问题都是出现在,HashMap底层在操作每个数组位置时都是将节点头拿下来进行操作,操作后再将节点头放回去这样就会导致两个线程同时获取相同的节点头先放上去节点头的线程被后放上去的覆盖导致线程安全问题,在添加时也会出现同时获取到最后一个元素先添加的next节点被后添加的覆盖导致线程安全问题。
面试题3、HashMap线程不安全
- 在jdk1.7中,在多线程环境下,扩容时会造成环形链(死循环)或数据丢失。
- 在put时,触发扩容rehash(),会重新计算每个元素在新数组中的位置,
- 在多线程的环境下,存在同时其他的元素也在进行put操作,如果hash值相同,可能出现同时在同一数组下用链表表示,造成闭环,导致在get时会出现死循环
- 在jdk1.8中,在多线程环境下,会发生数据覆盖的情况
- 在put的时候,由于put操作不是原子性的,线程A在计算好链表位置后,挂起,线程B正常执行put操作,之后线程A恢复,会直接替换掉线程b put的值 所以依然不是线程安全的