Map中的一些算法与数据结构简析

一、Hash算法

1、 什么是Hash

Hash散列,将任一长度的输入,通过一种算法,变成固定长度的输出。可以理解为压缩的映射。

MD5、SHA、取余都属于散列算法。譬如将1W个数据映射到10个区域,每个区域平均会有1000个数据,这叫Hash碰撞,映射是否够均衡是衡量一种散列算法好坏的重要依据。

2、HashCode与equals

例如内存中有这样的位置
0 1 2 3 4 5 6 7
  而有个类,这个类有个字段叫ID,要把这个类存放在以上8个位置之一,如果不用HashCode而任意存放,那么当查找时就需要到这八个位置里挨个去找,或者用二分法一类的算法。但如果用HashCode那就会使效率提高很多。
我们这个类中有个字段叫ID,那么我们就定义我们的hashcode为ID%8,然后把我们的类存放在取得得余数那个位置。比如我们的ID为9,9除8的余数为1,那么我们就把该类存在1这个位置,如果ID是13,求得的余数是5,那么我们就把该类放在5这个位置。这样,以后在查找该类时就可以通过ID除8求余数直接找到存放的位置了。
  但是如果两个类有相同的hashcode怎么办那(我们假设上面的类的ID不是唯一的),例如9除以8和17除以8的余数都是1,那么这是不是合法的,回答是:可以这样。那么如何判断呢?在这个时候就需要定义 equals了。
也就是说,我们先通过 hashcode来判断两个类是否存放某个桶里,但这个桶里可能有很多类,那么我们就需要再通过 equals 来在这个桶里找到我们要的类。
那么,重写了equals(),为什么还要重写hashCode()呢?
想想,你要在一个桶里找东西,你必须先要找到这个桶啊,你不通过重写hashcode()来找到桶,光重写equals()有什么用啊!

简单归纳一下:
hashCode是用于查找使用的,而equals是用于比较两个对象的是否相等的。一个正确覆写这两个方法的类,equals()相同,hashCode一定相同;hashCode()相同,equals不一定相同

二、HashMap简析

1、实现原理

在JDK1.8之前,HashMap采用数组+链表实现,即使用链表处理hash值冲突,同一hash值的节点都存储在一个链表里。
但是当位于一个桶中的元素较多,即hash值相等的元素较多时,通过key值依次查找的效率较低。

Map中的一些算法与数据结构简析_第1张图片
数组+链表.jpg

而JDK1.8中,HashMap采用数组+链表+红黑树实现,当链表长度超过阈值(8)时,将链表转换为红黑树,这样大大减少了查找时间。具体参看 HashMap的源码分析

Map中的一些算法与数据结构简析_第2张图片
数组+列表+红黑树.png

2、并发下使用HashMap的问题

  在多线程环境下,使用HashMap进行put操作如果同时有多个线程扩容数组时,会引起死循环,导致CPU利用率接近100%。因为其会导致形成环形数据结构,Entry的next节点永远不为空,就会产生死循环获取Entry。
  HashTable容器使用synchronized来保证线程安全,但在线程竞争激烈的情况下HashTable的效率非常低下。一个线程会阻塞其他线程的get、put方法。
  当然HashMap有个方法原子性的实现了防止并发写相同的功能,

putIfAbsent(key,value)
如果key对应的value不存在,则put进去,返回null。否则不put,返回已存在的value

  JDK1.8后,除了对Hashmap增加红黑树结果外,对原有造成死锁的关键原因点(新table复制在头端添加元素)改进为依次在末端添加新的元素。虽然JDK1.8后添加红黑树改进了链表过长查询遍历慢问题和resize时出现导致put死循环的bug,但还是非线性安全的,比如数据丢失等等。因此多线程情况下还是建议使用ConcurrentHashMap。

三、ConcurrentHashMap简析

  JDK1.7中使用分段锁的设计思想。
  ConcurrentHashMap是由Segment数组结构和HashEntry数组结构组成。Segment实际是一种可重入锁(ReentrantLock),HashEntry则用于存储键值对数据。一个ConcurrentHashMap里包含一个Segment数组。Segment的结构和HashMap类似,是一种数组和链表结构。一个Segment里包含一个HashEntry数组,每个HashEntry是一个链表结构的元素,每个Segment守护着一个HashEntry数组里的元素,当对HashEntry数组的数据进行修改时,必须首先获得与它对应的Segment锁。


Map中的一些算法与数据结构简析_第3张图片
JDK1.7中的Segment.png

  ConcurrentHashMap初始化方法是通过initialCapacity、loadFactor和concurrencyLevel(参数concurrencyLevel是用户估计的并发级别,就是说你觉得最多有多少线程共同修改这个map,根据这个来确定Segment数组的大小concurrencyLevel默认是DEFAULT_CONCURRENCY_LEVEL = 16)。
  ConcurrentHashMap完全允许多个读操作并发进行,读操作并不需要加锁。ConcurrentHashMap实现技术是保证HashEntry几乎是不可变的。HashEntry代表每个hash链中的一个节点,可以看到其中的对象属性要么是final的,要么是volatile的。

JDK1.8中
改进一:取消segments字段,直接采用transient volatile HashEntry[] table保存数据,采用table数组元素作为锁,从而实现了对每一行数据进行加锁,进一步减少并发冲突的概率。
改进二:即HashMap在1.8中改进。将原先table数组+单向链表的数据结构,变更为table数组+单向链表+红黑树的结构。
具体参看ConcurrentHashMap源码分析

四、ConcurrentSkipListMap中的跳表

  简单的说ConcurrentSkipListMap是TreeMap的并发实现,ConcurrentSkipListSet是TreeSet的并发实现。
这里要分析的是它们使用了一种SkipList(跳表)的数据结构。

什么是跳表?
跳表就是将链表的某些节点向上建立多级索引,提高查询效率。即空间换时间的思想。

比如下图插入节点3,插入本身很容易,关键查找其位置必须逐一比较。数据量一大就很费时间


Map中的一些算法与数据结构简析_第4张图片
链表的查找很慢.jpeg

于是我们打算给某些关键节点建立索引,譬如插入节点4,只需先比较索引节点就能很快定位到底层链表位置。整整快了一倍有木有。


Map中的一些算法与数据结构简析_第5张图片
一级索引.jpeg

如果要更快呢?那就在一级索引基本上再选出二级索引,二级索引基础上选出三级索引,直到最上面的索引层只有两个关键节点为止。
Map中的一些算法与数据结构简析_第6张图片
多级索引.jpeg

那么哪些节点可以提拔为索引呢?我们使用“投硬币”50%的随机概率来决定。因为跳跃表的删除和添加节点是不可预测的,很难又一种算法保证索引部分的均衡,随机抛硬币虽然不能保证索引绝对分布均衡,但却可以让大体趋于均匀。


Map中的一些算法与数据结构简析_第7张图片
随机抛币选取多级索引.jpeg

Map中的一些算法与数据结构简析_第8张图片
随机抛币选取多级索引.jpeg

五、总结

让我回顾一下整篇文章:
  ArrayList以数组形式实现,顺序插入、查找快,插入、删除较慢;
  LinkedList以链表形式实现,顺序插入、查找较慢,插入、删除方便;
  有没有什么数据结构能够结合上面的两者的优点呢?有,即HashMap,它实际上是一个“链表散列”的数据结构,即数组和链表的结合体,后来发现当同一个数组下的链表节点越来越长时,查询还是慢了,故JDK1.8中HashMap又引入了红黑二叉树。
  二分查找要求元素可以随机访问,所以决定了需要把元素存储在连续内存。这样查找确实很快,但是插入和删除元素的时候,为了保证元素的有序性,就需要大量的移动元素了。如果需要的是一个能够进行二分查找,又能快速添加和删除元素的数据结构,首先就是二叉查找树,二叉查找树在最坏情况下可能变成一个链表。于是,就出现了平衡二叉树(AVL树),根据平衡算法的不同有AVL树,B-Tree,B+Tree,红黑树等,但是AVL树实现起来比较复杂,平衡操作较难理解。
  这时候就可以用SkipList跳跃表结构,它是实现简单,查找与增删也都达到了趋于AVL树的性能。目前开源软件 Redis(Sorted-set结构) 和 Lucence都有用到它。

你可能感兴趣的:(Map中的一些算法与数据结构简析)