Map中的那些事
- Map中的那些事
- 拓展时间复杂度
- O(1):常数级
- O(logn):对数级
- O(n):线性级
- O(nlog n):线性对数级
- O(n²):平方级
- O(n³):立方级
- O(2的n次方):指数级
- hashMap
- hashMap基本知识
- 哈希冲突的定义
- hashMap的实现原理
- hashMap有四个构造器
- 具体问题
- HashMap的内部数据结构
- HashMap允许空键空值吗
- 影响HashMap性能的重要参数
- HashMap的工作原理
- HashMap中put()的工作原理
- HashMap的底层数组长度为何总是2的n次方
- JDK1.8中做了哪些优化?
- HashMap线程安全方面会出现什么问题
- HashMap线程安全方面会出现什么问题
- 为什么1.8改用红黑树
- 负载因子
- HashMap中的equals()和hashCode()的都有什么作用?
- HashMap和HashTable的区别有哪些?
- 使用场景
- ConcurrentHashMap
- CAS算法
- 源码实现
- ABA
- 开销
Map中的那些事
拓展时间复杂度
其实所谓的时间复杂度,空间复杂度都是数据结构的复杂度,反应程序执行时间随着输入规模的增大而增大的量级,很大程度上反应出算法性能的好坏,这个量级用大写的O加()表示
O(1):常数级
最低复杂程度,使用时间或使用空间与输入数据大小没有关系,无论输入数据多大,使用时间或者使用空间不变,哈希算法就是典型的常数级算法
O(logn):对数级
使用时间或空间随着输入数据增大,复杂度增大为log n倍,log n倍是n为2的几次方的上标值,二分查找就是对数级别算法
O(n):线性级
输入数据增大几倍,时间或空间增大几倍
大部分遍历就是线性级算法
O(nlog n):线性对数级
使用时间或空间随着输入数据增大,复杂度增大为nlogn倍,nlog n倍是n为2的几次方的上标值乘以n二分查找就是对数级算法
O(n²):平方级
输入数据增大几倍,时间或空间增大几的平方倍
冒泡排序就是平方级算法,不过复杂度是从O(n)->O(n²),冒泡排序在数据错位数量很小时适用
O(n³):立方级
输入数据增大几倍,时间或空间增大几的立方倍
O(2的n次方):指数级
输入数据增大几倍,时间或空间增大2的几次方倍
hashMap
我们都知道,要查找一个元素,如果是顺序查找,那么时间复杂度会达到O(n),比如:顺序表。也有可能达到O(logn),如:平衡树。那么要想更快,我们就可以考虑另外一种数据结构,他可以达到时间复杂度为O(1)。如HashMap
❓:什么是HashMap?
HashMap是一个key-value模型,具有映射关系,通过key值可以找到value值。并允许使用null值和null键,HashMap不保证映射的顺序。
hashMap基本知识
哈希冲突的定义
如果两个不同的元素,通过哈希函数得出的实际存储地址相同该如何❓
也就是我们对某个元素进行哈希运算,得到一个存储地址后,然后进行插入的时候,发现该位置已经被其他元素占用了,其实这就是所谓的哈希冲突,也叫哈希碰撞。由此可见哈希函数的设计至关重要,好的哈希函数会尽可能地保证计算简单和散列地址分布均匀,但是我们需要清楚的是,数组是一块连续的固定长度的内存空间,再好的哈希函数也不能保证得到的存储地址绝对不发生冲突
那么如何解决哈希冲突呢❓
哈希冲突的解决方案有多种:开放地址法(当发生冲突时,继续寻找下一块未被占用的存储地址,然后再放入元素即可),再散列函数法,链地址法,而hashMap即是采用了链地址法,也就是数组+链表的方式。
hashMap的实现原理
HashMap的主干是一个Entry数组。Entry是hashMap的基本组成单元,每个Entry包含一个key-value键值对。
🌵:HashMap的总体结构如下:
简单来说,HashMap由数组+链表组成的,数组是HashMap的主体,链表则是主要为了解决哈希冲突而存在的,如果定位到的数组位置不含链表(当前entry的next指向null),那么查找,添加等操作很快,仅需一次寻址即可;如果定位到的数组包含链表。对于添加操作,其时间复杂度为O(n),首先遍历链表,存在即覆盖,否则新增;对于查找操作来讲,仍需遍历链表,然后通过key对象的equals方法逐一比对查找。所以,性能考虑,HashMap中的链表出现的越少,性能才会越好。
hashMap有四个构造器
其他构造器如果用户没有传入initialCapacity和loadFactor这两个参数,会使用默认值:initialCapacity默认为16,loadFactory默认为0.75
具体问题
HashMap的内部数据结构
数组+链表/红黑树
HashMap允许空键空值吗
HashMap最多只允许一个键为Null(多条会覆盖),但允许多个值为Null
影响HashMap性能的重要参数
初始容量:创建哈希表时桶的数量(数组的大小),默认为16
负载因子:哈希表在其容量扩容之前可以达到一种尺度,默认为0.75
HashMap的工作原理
HashMap是基于hashing的原理,我们使用put(key,value)存储对象到HashMap中,使用get(key)从HashMap中获取对象
HashMap中put()的工作原理
HashMap的底层数组长度为何总是2的n次方
- 使用数据分布均匀、减少碰撞
- 当length为2的n次方时,hash&(length-1)就相当于对length取模,而且在速度效率上比直接取模要快的多
⛵️这里可以使用逆向思维来解释这个问题,我们计算桶的位置完全可以使用h%length,如果这个length是随便设定值的话当然也可以,但是如果对它进行研究,设计一个合理的值的话,那么将对HashMap的性能发生翻天覆地的变化
-
⚓️:当length为2的N次方的时候,h&(length-1)=h%length
为什么&效率更高呢❓
因为位运算直接对内存数据进行操作,不需要转成十进制,所以位运算要比取模运算的效率更高
-
⚓️:当length为2的N次方的时候,数据分布均匀,减少冲突
此时我们基于第一个原因进行分析,此时hash策略为h&(length-1)。
-
❗️当length为奇数时
-
❗️当length为偶数时
从上面的图表中我们可以看到,当 length 为15时总共发生了8次碰撞,同时发现空间浪费非常大,因为在 1、3、5、7、9、11、13、15 这八处没有存放数据。
这是因为hash值在与14(即 1110)进行&运算时,得到的结果最后一位永远都是0,那么最后一位为1的位置即 0001、0011、0101、0111、1001、1011、1101、1111位置处是不可能存储数据的。这样,空间的减少会导致碰撞几率的进一步增加,从而就会导致查询速度慢。
而当length为16时,length – 1 = 15, 即 1111,那么,在进行低位&运算时,值总是与原来hash值相同,而进行高位运算时,其值等于其低位值。所以,当 length=2^n 时,不同的hash值发生碰撞的概率比较小,这样就会使得数据在table数组中分布较均匀,查询速度也较快。
如果上面这句话大家还看不明白的话,可以多试一些数,就可以发现规律。当length为奇数时,length-1为偶数,而偶数二进制的最后一位永远为0,那么与其进行 & 运算,得到的二进制数最后一位永远为0,那么结果一定是偶数,那么就会导致下标为奇数的桶永远不会放置数据,这就不符合我们均匀放置,减少冲突的要求了。
JDK1.8中做了哪些优化?
- 数组+链表改成了数组+链表或红黑树
- 链表的插入方式从头插法改成了尾插法
- 扩容的时候1.7需要对原数组中的元素进行重新hash,定位在新数组中的位置,1.8采用更简单的判断逻辑,位置不变或索引+旧容量大小;
- 在插入时,1.7先判断是否需要扩容,再插入,1.8先进行插入,插入完成再判断是否需要扩容;
HashMap线程安全方面会出现什么问题
- 在jdk1.7中,在多线程环境下,扩容时会造成环形链或数据丢失
- 在jdk1.8中,在多线程环境下,会发生数据覆盖的情况
HashMap线程安全方面会出现什么问题
-
put的时候导致的多线程数据不一致,产生错误情况
比如有两个线程 A和 B,首先 A希望插入一个 key-valul 对到HashMap 中,首先计算记录所要落到的 hash 桶的索引坐标,然后获取到该桶里面的链表头结点,而此时刚好线程 A 的时间片用完了,此时线程 B 被调度得以执行,和线程 A一样的执行过程 ,只不过线程 B 成功的将记录插到了桶里面(假设线程 A 插入的记录计算出来的 hash 桶索引和线程 B 要插入的记录计算出来的 hash 桶索引是一样的)那么当线程 B 成功插入之后, 线程 A 再次被调度运行时,它依然持有过期的链表头但是它对此一无所知(头插法),以至于它认为它应该继续采用头插法插入数据(它拿到的头节点刚好是刚刚线程B插入的节点的下一个节点),如此一来就覆盖了线程 B 插入的记录,这样线程 B 插入的记录就凭空消失了,造成了数据不一致的行为。
-
resize引起死循环
这种情况发生在 HashMap 自动扩容时,当 2 个线程同时检测到元素个数超过 数组大小 ×负载因子 的时候。此时 2 个线程会在 put() 方法中调用resize() ,那么此时若两个线程同时修改一个链表结构会产生一个循环链表,接下来再想通过get()获取某一个元素,就会出现死循环。
为什么1.8改用红黑树
比如某些人通过找到你的hash碰撞值,来让你的HashMap不断地产生碰撞,那么相同key位置的链表就会不断增长,当你需要对这个HashMap的相应位置进行查询的时候,就会去循环遍历这个超级大的链表,性能及其地下。java8使用红黑树来替代超过8个节点数的链表后,查询方式性能得到了很好的提升,从原来的是O(n)到O(logn)。
负载因子
HashMap的默认负载因子为0.75,如果超过0.75,会重新resize一个原来长度两倍的HashMap,并且重新调用hash方法,对原来的数据进行重新的hash。
负载因子=存储的元素个数/数组的长度
HashMap中的equals()和hashCode()的都有什么作用?
通过key.hashCode()进行hashing,并计算下标(n-1&hash),从而获得buckets的位置。如果产生碰撞,则利用key.equals()方法去链表或树中去查找对应的节点,另外也是为了保持数据一致性
HashMap和HashTable的区别有哪些?
- HashTable是线程安全的,HashMap是非线程安全的
- HashMap可以使用null作为key,不过建议还是尽量避免这样使用。HashMap以null作为key时,总是存储在table数组的第一个节点上。而HashTable则不允许null作为key
- HashMap的初始容量为16,HashTable初始容量为11
- HashTable计算hash是直接使用key的hashCode对Table数组的长度直接进行取模,HashMap计算hash对key的hashCode进行了二次hash。以获得更好的散列值,然后对table数组长度取模。
使用场景
- 非并发场景使用HashMap,并发场景可以使用Hashtable,但是推荐使用CocurrentHashMap(锁粒度更低、效率更高)。
- 另外在使用HashMap时要注意null值的判断,HashTable也要注意防止put null key 和null value。
ConcurrentHashMap
在包java.util.concurrent下,和HashMap不同的是专门处理,但是在很多地方是类似的,比如底层都是数组+链表+红黑树、数组大小都是2的幂次方等…
CAS算法
CAS可以看做是乐观锁的一种实现方式,Java原子类中的递增操作就是通过CAS自旋实现的
CAS全称Compare and Swap(比较与交换),是一种无锁算法。在不使用锁(没有线程被阻塞)的情况下实现多线程之间的变量同步。
CAS涉及到三个属性:
- 需要读写的内存位置V
- 需要进行比较的预期值A
- 需要写入的新值U
CAS具体执行时,当且仅当预期值A符合内存地址V中存储的值时,就用新值U替换掉旧值,并写入到内存地址V中。否则不做更新。
源码实现
CAS在JDK中是基于Unsafe类实现的,它是个跟底层硬件CPU指令通讯的复制工具类,源码如下:
public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5);
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6);
在JDK中使用CAS比较多的应该是AtomicXXX相关的类,我们以AtomicInteger原子整型类为例,分析CAS底层实现机制:
AtomicInteger atomicInteger = new AtomicInteger();
atomicInteger.incrementAndGet();
incrementAndGet是Unsafe类中的方法,它以原子方式对当前值加1,源码如下:
//以原子方式对当前值加1,返回的是更新后的值
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
- this指的是当前AtomicInteger对象
- valueOffset是value的内存偏移量(new AtomicInteger()默认value值为0)
- 1指的就是对value加1
Ps:最后还要+1是因为getAndAddInt方法返回的是更新的值,而我们要的是更新后的值
==valueOffset内存偏移量就是用来获取value的,==如下所示:
//获取unfase对象
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
private volatile int value;//实际变量的值
static {
try {// 获得value在AtomicInteger中的偏移量
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
value是有volatile关键字修饰的,为了保证在多线程下内存可见性。
从static代码块可以看到valueOffset在类加载时就已经进行初始化了。
//var1-对象、var2-内存偏移量、var4-增加的值
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;//期望值
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
//根据偏移量获取value
public native int getIntVolatile(Object var1, long var2);
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
源码也不是很复杂,主要搞懂各个参数的意思,getIntVolatite方法获取到期望值value后调用compareAndSwapInt方法,失败则进行重试。
public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);
对于上面的参数可以换个理解方式
unsafe.compareAndSwapInt(this, valueOffset, expect, update)
- This:Unsafe对象本身,需要通过这个类来获取value的内存偏移地址。
- valueOffset:value变量的内存偏移地址。
- expect:期望更新的值
- update:需要更新的最新值
如果原子变量中的value值等于expect,则使用update值更新该值并返回true,否则返回false。
ABA
CAS也不是万能的,它也存在着一些问题,比如ABA,我们来看看什么是ABA?
A–>B—>A 问题,假设有一个变量 A ,修改为B,然后又修改为了 A,实际已经修改过了,但 CAS 可能无法感知,造成了不合理的值修改操作。
为了解决这个 ABA 的问题,我们可以引入版本控制,例如,每次有线程修改了引用的值,就会进行版本的更新,虽然两个线程持有相同的引用,但他们的版本不同,这样,我们就可以预防 ABA 问题了。Java 中提供了 AtomicStampedReference 这个类,就可以进行版本控制了。
开销
CAS算法需要不断地自旋来读取最新的内存值,当长时间读取不到就会造成不必要的CPU开销。
Java 8推出了一个新的类LongAdder,他就是尝试使用分段CAS以及自动分段迁移的方式来大幅度提升多线程高并发执行CAS操作的性能!
简单来说就是如果发现并发更新的线程数量过多,就会开始施行分段CAS的机制,也就是内部会搞一个Cell数组,每个数组是一个数值分段。这时,让大量的线程分别去对不同Cell内部的value值进行CAS累加操作,这样就把CAS计算压力分散到了不同的Cell分段数值中了!