Java HashMap用法与实现
为了做题用Java语法替代C++map的常用语法,记录一下,剖析原理以后再补上。
1.import java.util.HashMap;//导入;
2.HashMap<K, V> map=new HashMap<K, V>();//定义map,K和V是类,不允许基本类型;
3.void clear();//清空
4.put(K,V);//设置K键的值为V
5.V get(K);//获取K键的值
6.boolean isEmpty();//判空
7.int size();//获取map的大小
8.V remove(K);//删除K键的值,返回的是V,可以不接收
9.boolean containsKey(K);//判断是否有K键的值
10.boolean containsValue(V);//判断是否有值是V
11.Object clone();//浅克隆,类型需要强转;如HashMap<String , Integer> map2=(HashMap<String, Integer>) map.clone();
12.遍历
for (Map.Entry<String, String> entry : map.entrySet()) { System.out.println("key= " + entry.getKey() + " and value= " + entry.getValue()); }
1.继承与实现
继承AbstractMap<K,V>,实现Map<K,V>, Cloneable, Serializable
2.基本属性
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; //默认初始化大小 16 static final float DEFAULT_LOAD_FACTOR = 0.75f; //负载因子0.75 static final Entry<?,?>[] EMPTY_TABLE = {}; //初始化的默认数组 transient int size; //HashMap中元素的数量 int threshold; //判断是否需要调整HashMap的容量
3.实现方式
jdk1.7是数组+链表,jdk1.8是数组+链表+红黑树。
二叉查找树、自平衡二叉查找树(AVL树)、红黑树的概念
二叉查找树,值唯一,在建树的时候判断插入的节点,如果比根节点小就插到左边,比根节点大就插入到右边,查找的时候通过判断选择正确的方向找下去,而不用遍历一整棵树,效率高。但如果插入值的时候是按顺序插入的,一直加在左边或者右边形成一条链,查找和插入的效率就很慢,所以有了自平衡二叉查找树。
自平衡二叉查找树,左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵自平衡二叉查找树,并且满足二叉查找树特点。通过左旋右旋各种旋实现的,具体就不清楚了。这种旋转就避免了二叉查找树退化成链表导致查找效率过低的情况。但是严格控制高度的绝对值之差又导致在插入的时候频繁地旋转,浪费时间,所以有了红黑树。
红黑树,在每个节点加一个存储为表示节点的颜色,非红即黑。通过对任何一条从根到叶子的路径上各个节点着色的方式的限制,红黑树确保没有一条路径会比其它路径长出两倍,在子树高度差上没有那么严格,旋转的次数比较少。因此,红黑树是一种弱的自平衡二叉查找树。
4.了解一下hashCode
(一直以为hashCode是唯一的,错得离谱啊)
Java中的hashCode方法就是根据一定的规则将与对象相关的信息(比如对象的存储地址,对象的字段等)映射成一个数值,这个数值称作为散列值。对象在jvm上的内存位置是唯一的,但是不同对象的hashcode可能相同,它还要包括其他内容,再根据一定的算法去算出一个值,算出来的可能一样,这就是哈希冲突。
5.哈希冲突
HashMap存的是对象,那就有一个哈希值,如果哈希值一样,用链表解决哈希冲突,先定位到数组下标,再去链表里查找。
1.7是链表,头插,我猜测头插的理由是:新加入的值应该比旧的值更有可能用到,定位到数组节点时,在头部能更快找到。不论头插还是尾插,都需要把整条链表遍历一遍,确定key在不在链表里。1.7版本中,产生哈希冲突时,遍历一条链表查找对象,时间复杂度时O(n),随着链表越来越长,查找的时间越来越大。
为了提高这个冲突的查找效率,1.8在链表长度超过8时,把链表转变成红黑树,大大减少查找时间。为了防止链表或红黑树巨大,需要了解扩容这个概念。
6.扩容机制与负载因子
初始容器容量是16,负载因子默认0.75,最大容量230。意思就是当前容量到达12(16*0.75=12)的时候,会触发扩容机制。数据结构就是为了省时间省空间,扩容机制和负载因子的设定肯定也是为了效率。
(1)为什么负载因子是0.75?
如果负载因子太大,例如1时,只有当数组全部填充才会扩容,意味着会有大量的哈希冲突,红黑树变大变复杂,不利于添加查找。如果负载因子太小,例如0.5或者更低时,容量到达一半或者还不到一半的时候就开始扩容,看起来就有点浪费空间。负载因子的设定肯定是权衡了哈希冲突和容量大小。(个人推测,产生大量的对象放进容器,记录哈希值和冲突情况,测试不同负载因子耗费的时间和空间,再用数据分析的方法多方面考虑,选一个最佳的负载因子作为默认值)如果想要空间换时间,减小负载因子,减少哈希冲突。
先了解一下put方法的流程:
- 先检查大小,如果需要扩容就先扩容;
- 在hashCode()的基础上重新计算key的哈希值(hash = key.hashCode()) ^ (hash >>> 16)(两次计算哈希值,防止第一次哈希函数太烂),定位到数组中的下标;
- 如果位置上没有元素就直接插入,结束;
- 如果有元素就用equal检查key是否相同,如果相同就把新value替换旧value
- key不同就往链表里继续找,没找到key就插入,找得到就替换旧value。
定位到数组中的下标,最简单的方法就是对容量求模index=hash%n,然而源码的计算方法是index=(n-1)&hash。
n是2的幂次方,n-1的二进制全是1,按位与和求模结果差不多,但是位运算是直接对内存数据进行操作,不需要转成十进制,快。
那么每次扩容也要是2的幂次方才能保证n-1的二进制全是1,如果不全是1计算出来的index不均匀。扩容总不会扩4倍8倍,所以是2倍。
7.线程不安全
在接近临界点时,若此时两个或者多个线程进行put操作,都会进行resize(扩容)和reHash(为key重新计算所在位置),而reHash在并发的情况下可能会形成链表环
。总结来说就是在多线程环境下,使用HashMap进行put操作会引起死循环,导致CPU利用率接近100%,所以在并发情况下不能使用HashMap。为什么在并发执行put操作会引起死循环?是因为多线程会导致HashMap的Entry链表形成环形数据结构,一旦形成环形数据结构,Entry的next节点永远不为空,就会产生死循环获取Entry。jdk1.7的情况下,并发扩容时容易形成链表环,此情况在1.8时就好太多太多了。
因为在1.8中当链表长度达到阈值(默认长度为8)时并且数组节点总数>=64时,链表会被改成树形(红黑树)结构。如果删剩节点变成7个并不会退回链表,而是保持不变,删剩6个时就会变回链表,7不变是缓冲,防止频繁变换。
在JDK1.7中,当并发执行扩容操作时会造成环形链和数据丢失的情况。
在JDK1.8中,在并发执行put操作时会发生数据覆盖的情况。
8.哈希碰撞拒绝服务攻击
用哈希碰撞发起拒绝服务攻击(DOS,Denial-Of-Service attack),常见的场景是攻击者可以事先构造大量相同哈希值的数据,然后以JSON数据的形式发送给服务器,服务器端在将其构建成为Java对象过程中,通常以Hashtable或HashMap等形式存储,哈希碰撞将导致哈希表发生严重退化,算法复杂度可能上升一个数据级,进而耗费大量CPU资源。
9.和HashTable的异同
(1)继承和实现
HashMap
是继承自AbstractMap
类,而HashTable
是继承自Dictionary
类。不过它们都同时实现了Map、Cloneable(可复制)、Serializable(可序列化)这三个接口。存储的内容是基于key-value的键值对映射,不能有重复的key,而且一个key只能映射一个value。HashSet底层就是基于HashMap实现的。
(2)key-value
HashMap支持key-value、null-value、key-null、null-null这4种方式,但HashTable只支持key-value。
HashMap不能用get()方法来判断HashMap中是否存在某个键, 而应该用containsKey()来判断,因为使用get()的时候,当返回null时,你无法判断到底是不存在这个key,还是这个key就是null,还是key存在但value是null。
(3)扩容
HashMap:默认初始容量是16,严格要求是2的幂次方,每次扩容到原来的2倍
HashTable:默认初始容量是11,不要求是2的幂次方,每次扩容到原来的2倍+1
(4)求索引index
HashMap求索引时用&运算,index=(n-1)&hash
HashTable求索引用模运算,index = (hash & 0x7FFFFFFF) % n
(5)线程安全方面
HashMap线程不安全,在并发包Java.util.concurrent的作用下它有一个对应的线程安全类ConcurrentHashMap
HashTable是线程安全的,它的一些方法加了synchronized。
(6)HashTable废弃
HashTable的设计本身就有问题,相比HashMap效率上看着就比较慢,是JDK1.0的产品,之所以保留是为了兼容旧版本,现在线程安全都用ConcurrentHashMap。HashTable后续没有优化的原因应该是:这个类很少人用,有替代品,改动的价值不大,也可能是作者懒得改。
10.了解一下LinkedHashMap
从Linked这个名字可以知道肯定和链表有关,它的数据结构附加了双向链表,弥补HashMap无序的缺点。
HashMap在存入的时候通过&计算索引,这个索引不是有序的,所以在遍历HashMap的时候,无法获得插入时的顺序。而LinkedHashMap把插入的节点用链表连接起来,通过链表来遍历,可以获得插入时的顺序。(在不知道这个东西的情况下,要我获取HashMap的插入顺序的话,我会开两个ArrayList或者LinkedList来记录顺序,并且一一对应key和value)。线程不安全。
11.了解一下HashSet
Map是映射,那就是key-value。Set是集合,无序不重复,存的只是key,不是两个对象组成的键值对key-value。底层数据结构是HashMap,它存的对象放在key里。线程不安全。
12.了解一下TreeMap
底层数据结构是裸的红黑树,保证元素有序,没有比较器Comparator的情况按照key的自然排序,可自定义比较器。线程不安全。
参考:https://yuanrengu.com/2020/ba184259.html