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或者更低时,容量到达一半或者还不到一半的时候就开始扩容,看起来就有点浪费空间。负载因子的设定肯定是权衡了哈希冲突和容量大小。(个人推测,产生大量的对象放进容器,记录哈希值和冲突情况,测试不同负载因子耗费的时间和空间,再用数据分析的方法多方面考虑,选一个最佳的负载因子作为默认值)如果想要空间换时间,减小负载因子,减少哈希冲突

(2)容器容量为什么是2的幂次方?

先了解一下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

posted @ 2019-12-01 15:25  守林鸟  阅读(25665)  评论(2编辑  收藏  举报