这时我参与青训营-后端场的第2篇笔记。
什么是HashMap
\
java7时
java中的HashMap是数组+链表实现的,常用于通过key查找value:
- 数组:数组采用的是一段连续的存储单元来存储数据的集合
- 链表:对链表执行新增、删除等操作时,时间复杂度为O(1)
\
数组中每个地方都存储key-value这样的实例:java7中称为Entry,java8中称为Node:
\
\
java8之后
HashMap改成了数组+链表,然后在链表长度到达阈值之后转化成红黑树,主要为了提升在hash冲突严重的查找性能,使用红黑树的时间复杂度是O(logn)
\
\
链表和成环问题
\
为什么map需要链表
HashMap加入元素时会根据key的hashcode去计算一个index值,而由于数组长度是有限的,在优先的长度中我们使用哈希,哈希本身就存在概率性,就会产生哈希冲突。java中采用的是链地址法解决哈希冲突,如果key哈希之后的值相同,就使用equals比较value值,如果不相同就加入链表中
\
\
头插法和尾插法
链表采用的是头插法还是尾插法?
java8之前是头插法,因为开发者认为新加入的值被查找的可能性较大,所以使用头插法提升查找速度。java8之后改为了尾插法
\
为什么改为尾插法
这需要知道HashMap的扩容机制,由于数组容量是有限的,数据多次插入到达一定的数量就会进行扩容,也就是resize,resize有两个因素:Capacity(当前长度)和LoadFactor(负载因子,默认0.75),也就是当容量是100时,存入第76个的时候判断发现需要进行resize,但是HashMap的扩容也不是简单地将容量扩大
HashMap扩容分为两步:
- 扩容:创建一个新的Entry空数组,长度是原来数组的两倍
- ReHash:遍历原来Entry数组,将所有的Entry重新Hash到新数组
在多线程环境下:
- 不同线程插入了A、B、C三个元素
\
\
- 因为resize是采用头插法,也就是同一个位置上的新元素总是会被放在链表的头部位置:
\
- 一旦几个线程都调整完成就可能出现环形链表
\
\
因此java7在多线程操作时可能引起死循环,原因就是扩容后链表顺序倒置,在转移过程中修改了原来链表中节点的引用关系,java8在同样的前提下并不会引起死循环,原因是扩容转移后前后链表顺序不变,保持之前节点的引用关系
为什么重写equals方法的同时也要重写hashCode方法
在java中所有对象都是继承于Object类,Object类中有两个方法equals、hashCode,这两个方法都是用来比较两个对象是否相等。如果我们不重写这两个方法,equals是比较两个对象的内存地址(补充:在java中==对于值对象比较的是两个对象的值,对于引用对象比较的是两个对象的地址)
在HashMap中寻找value时,是先通过key去进行hash之后找到index,然后通过equals比较,相同即找到指定value,所以我们需要重写hashCode()以保证相同的对象返回相同的hash值,不同的对象返回不同的hash值
HashMap线程安全
java8之后的HashMap虽然解决了多线程下的死循环问题,但是HashMap仍然是线程不安全类,源码中的put/get方法没有加同步锁,多线程情况下并不能保证get的时候是原值,所以线程安全无法保证
解决HashMap线程安全问题
java中提供了多种线程安全类:
- 使用Collections.synchronizedMap(map)创建线程安全的集合
- 使用Hashtable
- ConcurrentHashMap
不过出于线程并发度的原因,建议使用ConcurrentHashMap,它的性能和效率都比较高
Collections.synchronizedMap(map)
SynchronizedMap内部维护了一个普通对象Map,还有排斥锁mytex
\
我们在调用这个方法时需要传入一个map,可以看到有两个构造器,如果你传入了mutex参数,则将作为排斥锁对象,如果没有则将对象排斥锁赋值为this,即调用synchronizedMap的对象,就是上面的Map
Hashtable
跟HashMap相比Hashtable是线程安全的,但是因为它对数据操作时都会上锁,而且上的是方法锁,所以效率低下
\
Collections.synchronizedMap()和Hashtable
- Hashtable锁级别是方法级别的,Collections.synchronizedMap()是代码块级别锁的方法,并且可以锁对象
- 两者性能相近,但是Collections.synchronizedMap()允许null作为key或者value,这个时候只允许同时存在一个操作
Hashtable和HashMap其他不同点
Hashtable是不允许键或者值为null的,HashMap的键和值都可以为null。Hashtable在put空值时会直接抛出空指针异常,但是HashMap做了特殊处理:
Hashtable使用的是安全失败机制,这种机制会使得此次读到的数据不一定是最新的数据,如果使用的是null值,就会使得无法判断当前这个值是空值还是没有被映射过的值,ConcurrentHashMap这种并发安全类同理
ConcurrentHashMap
ConcurrentHashMap底层是基于数组+链表实现的,但是在7和8中有些不同。
1.7中:
原理上讲,ConcurrentHashMap使用了分段锁技术,其中Segment继承于ReentrantLock,不会像Hashtable那样使用synchronized直接锁方法,也就是每当一个线程占用锁访问一个Segment时不会影响到其他的Segment,所以理论上ConcurrentHashMap支持Segment数量的线程并发
ConcurrentHashMap的put:
- 尝试自旋获取锁
- 如果重试的次数达到了MAX_SCAN_RETRIES则改为阻塞锁获取,保证能够获取成功
ConcurrentHashMap的get:
get的逻辑比较简单,只需要讲key通过hash之后定位到具体的Segment,再通过一次hash定位到具体的元素上。由于HashEntry中的value值使用volatile修饰所以每次看到的都是最新值
1.8中:
java1.8中的ConcurrentHashMap抛弃了Segment机制,采用了CAS+synchronized来保证并发安全性。跟HashMap很像,也将之前的HashEntry改成了Node,但是作用不变把值和next采用了volatile去修饰,保证了可见性,并且引入了红黑树
ConcurrentHashMap存取过程
- 根据key计算出hashCode
- 判断是否需要进行初始化
- 即为当前key定位出的Node,如果为空表示当前位置可以写入数据,利用CAS尝试写入,失败则自旋保证成功
- 如果当前位置的hashCode==MOVED==-1,则需要扩容
- 如果都不满足,则利用synchronized锁写入数据
- 如果数量大于TREEIFY_THRESHOLD则要转换为红黑树
什么是CAS
CAS是乐观锁的一种实现方式,是一种轻量级锁,JUC中很多工具类都是基于CAS的。CAS操作的流程就是线程在读取数据时不进行加锁,在准备写回数据时,比较原值是否修改,如果未被修改则修改写回,如果已经被修改则重新执行读取流程
\
\
CAS的ABA问题
ABA就是说一个线程把值从A改成B之后又改成了A,对于判断的线程来说就好像没被改动过一样。这种问题的解决方式有:
- 版本号
- 时间戳
为什么jdk1.8之后使用了synchronized
synchronized之前一直是重量级锁,但是后来使用了锁升级优化,先使用偏向锁,再变成CAS锁,如果失败就会短暂自旋,防止线程被系统挂起,如果以上都失败就升级成重量级锁
安全失败和快速失败
安全失败
采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问,而是先赋值原有的集合内容,在拷贝的内容上进行遍历。由于迭代时是对拷贝进行遍历,所以在遍历过程中对原集合所作出的修改并不能被迭代器检测到,所以不会触发异常
快速失败
在使用迭代器遍历一个集合对象时,如果遍历过程中对集合对象的内部进行了修改,则会抛出异常。这时因为迭代器在遍历时是直接访问集合内容的,并且维护了一个modCount变量,如果在遍历期间内容发生了变化就会修改modCount的值,而每当迭代器使用了hasNext()或者next()时都会检测modCount是否为expectmodCount的值,是的话就返回遍历,否则抛出异常