Java集合Map

来源:互联网 发布:吃金针菇拉金针菇知乎 编辑:程序博客网 时间:2024/05/20 07:16

       Map是Java里很常用的一个集合类型,用于存储键值对,其中每个键映射到一个值。常用的实现有:HashMap、TreeMap、ConcurrentHashMap、LinkedHashMap等,下面分别介绍它们的实现原理(本文基于JDK8)。

 

HashMap

       HashMap应该是用的最多的一个通用Map实现,它是非线程安全,遍历无序的。它最重要的一个成员变量为:Node<K,V>[]table,可见它是基于数组来存储数据。实际上几乎所有通用 Map都使用哈希映射,并且大多都使用数组来存储其数据。之所以说它遍历无序,是因为遍历的时候是直接从头到尾遍历table里的元素。

       HashMap(包括所有其它基于数组的Map)在创建的时候并不会初始化数组,而是当第一次添加数据的时候才分配内存,当添加数据时如果数组为空则进行数组的初始化,采用延迟加载的机制是为了减少空Map占用内存。

       哈希映射技术,这是一种将元素映射到数组的机制,由于内部采用数组存储,因此必然存在一个用于确定任意键访问数组的索引。在向Map中添加数据时,会先对Key计算哈希值(基于Key的hashCode方法),其后根据哈希值及数组长度计算出Key对应的数组索引,该索引必须小于数组的长度。

       这里会出现一种映射冲突的情况,两个不同的键很有可能计算出来的索引是同一个。数组中的每项元素其实是一个单向链表,每个结点都包含了一个指向下个结点的引用,这样就解决了映射冲突,当冲突发生时只需把新添加的元素放到链表的最后即可。

       这里还会出现某一个位置上的链表太长的情况,这样每次获取时都要遍历一次该链表,效率会较低。在HashMap中当添加元素时会判断链表长度,如果链表长度大于等于8(表示冲突较多),并且数组长度大于64则把该链表转换成一颗红黑树,如果数组长度小于等于64(数据量小时容量容易到瓶颈并且扩容成本小)则进行数组扩容并重新进行所有数据的哈希映射。红黑树是一种自平衡二叉树,性能非常好。这样数组里就出现了两种数据结构,一种是链表一种是红黑树,当向红黑树的位置插入元素时需要进行旋转。

       前面讲到过当某个位置上的链表大于8的时候可能会进行扩容,除此之外在插入元素后,如果元素总数与数组长度之比达到loadFactor时,也会进行数组扩容并重新进行所有数据的哈希映射。如果碰到是红黑树的位置还需进行特殊处理,因为有可能重新映射后不需要红黑树了,也有可能冲突还是较多,还需要红黑树。

       HashMap查找就比较简单了,先定位到数组位置然后遍历该位置上的集合。因为HashMap内部使用数组做存储,所以它的缺点在于插入数据时有可能引起数组扩容以及扩容后重新计算所有数据的哈希映射(为了减少扩容次数,每次扩容是成倍的增加数组长度,扩容的大体思想就是遍历、复制的过程)。它的优点也很明显索引快(但也并非只索引一次就能找到数据),在没有触发扩容的时候插入数据也是非常快的。

 

TreeMap

       TreeMap顾名思义,它的内部存储结构是树,并且是红黑树(Java集合中大量用到了红黑树),它还有一个重要的特征就是有序,TreeMap也不是线程安全的。因为它使用树做存储,所以TreeMap不存在扩容的问题,它的缺点就是每次添加一个元素的时候都要多次比较并且添加后还要做二叉树的平衡操作。因为它使用的是红黑树,所以索引速度倒也很快,相比HashMap来说平均是要慢些。

       插入元素的时候默认会按Key值排好序,如果没指定Comparable实现则Key必须实现Comparable接口,也因此不需要Key覆写hashCode方法和equals方法,就可以排除掉重复的key,TreeMap 不允许插入null 键,因为null无法和其他的Key比较。像String、Integer、Long等等常用的类都已实现Comparable接口,我们一般用String做Key比较多,它默认是按字典排序。既然内部存储是红黑树,每次插入后自然是要做旋转来平衡树了。

       树左边的值小于右边的值,索引也就是通过比较树结点的Key。遍历的话是从树最左边的叶子结点开始从小到大做中序遍历(先访问左边的结点,再访问中间的结点,最后访问右边的结点)。HashMap的操作的效率更高,一般情况下使用HashMap存储键值对,TreeMap的额外操作是有时间代价的,只有需要有序列的操作时,才考虑使用TreeMap。

 

LinkedHashMap

       LinkedHashMap继承自HashMap,特点是保持了插入顺序,同样是非线程安全。它通过扩展了HashMap元素结构,增加了指向前后结点的引用,为了实现这个双向链表,重写了相关操作的回调方法(HashMap在插入、删除等后会有一个空的回调方法)。如果需要输出的顺序和输入的相同,那么LinkedHashMap是一种比较好的实现。

       LinkedHashMap默认是保持插入顺序,它可以通过在创建的时候修改accessOrder参数来实现保持访问循序,如果是保持访问顺序则每次调用get的时候都要修改引用以保持顺序结构。accessOrder为true的时候,LinkedHashMap在遍历的时候不保证插入顺序,会将最后访问的元素放在链表的最后,迭代的时候最后输出。

 

ConcurrentHashMap

       ConcurrentHashMap第一个特点就是它是线程安全的,可以在多线程环境下放心使用,为了实现这个线程安全的同时又要保持高性能,JDK在多个版本中重写了好几次实现算法,jdk8中采用了CAS原子操作+Synchronized来保证并发更新的安全。它沿用了HashMap的思想,底层依然由数组+链表+红黑树的方式,但其类成员变量大多都是volatile,这个保证了内存一致性,其次其使用Unsafe类来实现同步,加上大多数操作都使用了循环来进行重试。下面来分别介绍下其重要的属性。

       sizeCtl,它是一个控制标识符,在很多地方都有用到,负数代表正在进行初始化或扩容操作,-1代表正在初始化,-N表示有N-1个线程正在进行扩容操作,正数或0代表hash表还没有被初始化,这个数值表示初始化或下一次进行扩容的大小,它的值是当前ConcurrentHashMap容量的0.75倍,这与loadfactor是对应的。

       Node,保存了键值对,与HashMap中的定义相似,不同的是它的value及next属性都标记了volatile,并且不允许调用setValue方法直接改变Node的value,它增加了find方法辅助get方法。

       TreeNode,当链表长度过长的时候,会转换为TreeNode,但是与HashMap不相同的是,它并不是直接转换为红黑树,而是把这些结点包装成TreeNode放在TreeBin对象中,由TreeBin完成对TreeNode红黑树的包装。

       TreeBin,它代替了TreeNode的根节点,也就是说在实际的ConcurrentHashMap数组中,存放的是TreeBin对象,而不是TreeNode对象,这是与HashMap的区别,另外这个类还带有了读写锁。

       ForwardingNode,一个用于连接两个table的节点类,它包含一个nextTable指针,用于指向正在扩容的新表。

       Unsafe,在ConcurrentHashMap中,随处可以看到U,大量使用了U.compareAndSwapXXX的方法,这个方法是利用一个CAS算法实现无锁化的修改值的操作,他可以大大降低锁的性能消耗,基本思想就是不断地去比较当前内存中的变量值与你指定的一个变量值是否相等,如果相等,则接受你指定的修改的值,否则拒绝你的操作。它是通过字段偏移量来修改内存中对应字段值,所以事先要通过反射并调用Unsafe.objectFieldOffset来获取字段偏移量。

       ConcurrentHashMap和HashMap一样是在第一次添加元素的时候才分配内存,在第一次初始化的时候主要应用了关键属性sizeCtl,如果它小于0,表示还有其它线程正在进行初始化操作,此时就会调用Thread.yield()放弃操作,初始化是包含在一个while中的,所以该线程会不断的通过比较sizeCtl的值来判断初始化是否已完成。yield方法作用是暂停当前正在执行的线程,并执行其他线程,换句话说就是让当前运行线程回到可运行状态,但是该线程也有可能被线程调度程序再次选中。ConcurrentHashMap的初始化只能由一个线程完成,如果获得了初始化权限,就用CAS方法(Unsafe.compareAndSwapInt)将sizeCtl置为-1,防止其他线程进入,初始化数组后,将sizeCtl的值改为0.75*n。

       添加元素之前首先是要计算出索引位置,找到位置之后会获取该位置上的元素以做判断,这个获取的方法采用Unsafe.getObjectVolatile来获取,为什么不直接通过table[index]来获取呢,在java内存模型中,我们已经知道每个线程都有一个工作内存,里面存储着table的副本,虽然table是volatile修饰的,但里面的元素并不是volatile,不能保证线程每次都拿到table中的最新元素,Unsafe.getObjectVolatile可以直接获取指定内存的数据,保证了每次拿到数据都是最新的。

       如果获取的元素为null,说明table中这个位置第一次插入元素,利用Unsafe.compareAndSwapObject方法插入Node节点。如果CAS成功,说明Node节点插入成功,跳转出循环。如果CAS失败,说明有其它线程提前插入了节点,循环重新尝试在这个位置插入节点。

       如果获取的元素不为null,则判断获取到的元素hash值是否为-1,如果为-1则表示当前获取到的是ForwardingNode节点,意味这个位置已经转移,有其它线程正在扩容,则协助一起进行扩容操作。整个扩容分为两部分:构建一个nextTable,大小为table的两倍,但使用容量为1.5倍;把table的数据复制到nextTable中。因为ConcurrentHashMap是线程安全的,所以扩容操作自然也要考虑并发的出现,而且ConcurrentHashMap在扩容的时候大部分操作并没有加锁。

       构建nextTable这个操作只能是单线程完成,扩容的核心方法就是transfer,当完成nextTable初始化之后,首先根据运算得到需要遍历的次数i,然后利用tabAt方法获得i位置的元素。如果这个位置为空,就在原table中的i位置放入ForwardingNode节点,代表正在扩容。如果这个位置不为空,则先使用synchronized锁住获取到的对象,因为扩容的同时是可以向非转移的位置插入元素的,所以这里加了锁,无论是扩容线程还是插入线程,阻塞的一方等锁拥有者完成操作后可以自旋进行重试,这也表明了ConcurrentHashMap为什么要使用TreeBin对树再包装一层,这是为了方便锁住同一个对象,因为红黑树是会旋转的。

       如果这个位置是Node节点(fh>=0),就构造两个链表(一正一反),把他们分别放在nextTable的i和i+n的位置上,这里就体现出ConcurrentHashMap里设计的hash函数的巧妙了,扩容后该位置上的结点hash之后要么在新数组里还是原来的位置,要么就是在第i+n的位置上。如果这个位置是TreeBin节点,就构造两个TreeNode树,构造完之后判断是否需要untreeify,把处理的结果分别放在nextTable的i和i+n的位置上。遍历过所有的节点以后就完成了复制工作,这时让nextTable作为新的table,并且更新sizeCtl为新容量的0.75倍,完成扩容。多线程遍历节点,处理了一个节点,就把对应点的值设置为forward,另一个线程看到forward,就向后继续遍历,再加上给节点上锁的机制,就完成了多线程的控制,transferIndex记录复制的进度下标,这样交叉就完成了复制工作,而且还很好的解决了线程安全的问题。

       添加元素操作依然沿用HashMap的思想,先计算出位置,如果位置上是空的,通过CAS放进去,否则进行判断,如果位置上是树节点,按照树的方式插入新的节点,否则把元素插入到链表的末尾。有一个最重要的不同点就是ConcurrentHashMap不允许key为null值。另外由于涉及到多线程,如果多个线程正在对ConcurrentHashMap进行扩容操作,当前线程也要进入扩容的操作中。这个扩容的操作之所以能被检测到,是因为transfer方法中在空结点和转移的结点上插入forward节点,如果检测到需要插入的位置被forward节点占有,就帮助进行扩容。如果检测到要插入的节点是非空且不是forward节点,就对这个节点加锁,这样就保证了线程安全。


原创粉丝点击