java HashMap

100 阅读7分钟

这时我参与青训营-后端场的第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的值,是的话就返回遍历,否则抛出异常