java的java.util包中的集合是java库中非常重要的一部分,你如果选择java作为编程语言,集合的处理在程序中是必不可少的,因为集合就是数据的容器。学习集合库,首先你得了解java集合有哪些主要接口。java集合主要接口有Iterable、Collection、List、Set、SortedSet、Queue、Deque、Map、SortedMap、ConcurrentMap。当然java集合接口肯定不止这些,我们只要抓住整个集合框架主干,并在熟练运用中形成永久记忆,建立这样一个小的集合框架体系才算你基本掌握了java集合。整个java集合类都实现或间接实现这些接口。这些接口见也有继承关系,Collection继承了Iterable,List、Set、Queue又继承了Collection,Deque又继承了Queue,SortedSet又继承了Set,SortedMap和ConcurrentMap又继承了Map。整个java集合我理解就是分为两大系列,一个是Iterable迭代器系列,一个是Map键值对系列。下面就分别介绍这两个系列的实现,介绍具体的实现类之前先分析以下这些主要接口的设计目的和应该怎么去实现它们。
Iterable虽然不是java.util包里的,它是lang包里的,但是实现它就能获得Iterator接口,它通过遍历来获得集合数据,因此Iterator也曾是处理集合数据的最重要的手段,之后jdk8又提供了流的方式使得代码能更简洁、更高效的处理集合数据。List是最常用到的接口,List有序、可重复、长度能动态扩展,Set无序、不重复(集合的有序、无序指的数据取出的顺序是否一致,不是指集合数据是不是被排序),Map集合无序、key唯一。在集合的框架下还有一种重要的类抽象类,如果你自己要实现一个集合类,可以直接继承相应的抽象类,利用现有的方法,重写或添加自己的功能需求方法,简化代码并减少重复工作量。
java集合包类的详细继承关系可以参考这篇博客:https://blog.csdn.net/u010887744/article/details/50575735,这张图包含集合包中基本主要的类,关系画的还是比较清楚的。
下面先介绍Map系列,其中常用的重要的类有AbstractMap,HashMap、TreeMap、WeakHashMap、ConcurrentHashMap、LinkedHashMap
1、HashMap
HashMap使用内部类Entry(1.8为Node,均实现了Map接口的内部接口Entry)来存储一个键值对,HashMap由数组和链表组成,数组存储Entry,初始容量为16,负载因子0.75,存储键值对时,通过key的hash值确定存储元素在数组中的下标,当出现hash冲突后,使用链表存储在对应下标的数组中(1.8当链表长度超过8后转为红黑树存储hash冲突的Entry)。HashMap利用JDK本地方法hashCode去计算一个对象的hash值的(int值),那么怎么根据hash值在数组中存放entry对象呢?简单说就是hash值为被除数,数组长度是除数,取余数值为entry在数组中的位置,源码使用与运算取得余数,(n - 1)& hash,n为数组长度,那么这里问题就来了,一:hash冲突了怎么搞?二:HashMap是使用数组存放entry的,数组初始化长度是16,当HashMap存放的数据达到负载阈值时,是要扩容的,数组的长度会增加,hash值又是根据数组长度计算的,那就意味着之前存放在原数组的entry是要重新计算其hash值存放在新数组中,在多线程情况下,多个线程同时扩容会出现什么问题?这两个问题是学习HashMap必须要搞清楚的。
问题一,首先如果你觉得奇怪,map的key是不允许重复的,怎么hash冲突呢?这个就不解释了,了解java是怎么判断两个对象相等和hash计算就知道了。首先你要知道jdk的版本在不断的更新,java库源码也在不断新增和优化,因此学习java的API主要需要自己去看源代码,技术文章都有时效性,只是做参考,所以实际情况请以源码为准。在1.8之前hash冲突的entry是以链表的结构存放在数组的某个位置的,1.8这块的数据结构使用的是红黑树,当然不是一出现hash冲突,这些entry对象就以红黑树结构存放的,entry先还是使用链表存放,当链表元素超过了8个时,会将其转换成红黑树,之后hash冲突的数据就按照红黑数的结构存放了,所以这些细节的东西需要自己去看源码。关于红黑数不是本文介绍的重点,也不是几句话能说清楚的,红黑数是非常重要的一种数据结构,有时间我也会单独具体写它,应该这么说吧,你可以不去系统的学习算法,但是像array、queue、stack、list、heap、tree、graph这些基本的数据结构的基本和重要的实现你是需要掌握的,时间长了会忘,正常,但是需要重新去看。第一次学习这些会很慢,但是回忆再拾起来蛮快的。说真的,我没见任何一个大牛这些东西是不清楚的。
问题二:并发扩容造成的影响,首先说下扩容是怎么实现的,还是那句话,要自己看源码,我分享下我脑海里扩容的过程,定义扩充前数组为old , 扩充后数组为new(扩充方法 old.size << 1),遍历old数组每个元素(0 - old.size),old数组下标为i的元素如果就一个entry,好办,rehash,再存放到新的数组中,如果是个链表, 对该链表每个元素巧妙的利用位运算e.hash & (newCap - 1) == 0 , 如果等于0说明链表元素所在的old数组下标值等于该元素reHash后应存放于new数组的下标值,即为i,如果不等于0,则下标值肯定为i + old.size(为什么是这样,自己画画二进制的与运算就知道了),因此可以利用两个临时链表依次在末端添加元素,最后将两个链表对应赋值到下标为i和i+old.size的new数组中,完成old数组扩容(这是1.8的实现)。
扩容的最大影响就是rehash,原数组的数据需要重新存放到新数组,这个实现过程在jdk1.8和之前的版本其源码是不一样的,1.8之前的代码是利用transfer()方法去实现,这个方法有个特点,原数组的链表元素顺序在新数组中会被反转(这个结论需要你自己去琢磨去画一画,因为知识理论是一层一层的,如果我听不懂高水平人聊的技术,我就知道别人讨论的技术基础就是我还没理解的结论,所以要不断的去学习,去积累)。在transfer的方法中用的e和next都是Entry的临时变量,在while循环中不断指向后面的链表元素,在多线程情况下,这些代码没有被同步就很容易出问题,在某些情况下就出现了原数组中的链表在新数组中成了环形链表,这个分析过程可以参考这篇文章https://coolshell.cn/articles/9606.html,写的还是很清楚的,推荐关注一下这位作者,微博名叫左耳朵耗子,真名叫程浩,是个高手。在1.8的版本中这个问题又不存在了,因为1.8的扩容实现不一样,都没有transfer()方法了,它维护了两个链表,依次往末端添加新的元素,为什么是两个链表,上文其实已经说了,rehash后,链表元素在新数组中下标只有两种情况。使用这种实现在并发情况会出现重复操作,但是不会出现环形链表的问题。这里的描述我忽略了红黑树的部分,主要是说明它的实现思想,源码中Node是分开的,有判断Node是TreeNode还是链表Node。
1.8虽然解决了死循环问题,但是HashMap依旧是线程不安全的,在并发下,它还是有别的问题的,比如现在有两个线程同时往HashMap里插元素,正好这两个元素key经过hash计算,值相同,那么他们就落到数组的同一个位置,假如这个位置还没有存放元素,HashMap在存放数据之前会先判断这个位置有没有元素,由于并发这两个线程都通过了这个判断,此时两个线程就都执行直接向这个数组位置放数据,那么必然有一个线程数据会被覆盖掉,从而导致数据丢失,因此这个临界区就需要同步。在多线程环境中推荐使用ConcurrentHashMap。
2、ConcurrentHashMap
ConcurrentHashMap这类在并发包内,不在util包内,但是也可以纳入到集合中学习。还是那句话jdk的源码是不断变化的,这个类在1.8和1.7的实现上就有很大的区别。
在JDK1.5之前,你如果想用在多线程中安全的键值对容器,你大概会用HashTable,它使用Synchronized同步来确保线程安全,在Synchronzied没有被优化之前,在竞态激烈的时侯线程饥饿严重,因为所有所有线程都在竞争一把锁,所有Doug Lea在1.5设计的并发包中加入了采用锁分段技术的ConcurrentHashMap,想法也容易想到,就是容器数据分成多个部分,每个部分使用一把锁,一部分数据被锁住了,如果你操作非这部分的数据,那就不受这把锁的影响了,这样就从概率的宏观角度提高了并发效率。1.8就完全放弃了这种思路,利用CAS算法实现原子操作。下面就分别介绍这两种实现。
在1.8之前,ConcurrentHashMap由Segment数组和HashEntry数组组成,Segment和HashEntry都是ConcurrentHashMap的内部类,HashEntry用于存储键值对数据,HashEntry里还有个存储另一个HashEntry的属性,因此HashEntry是一个链表结构,而Segment数组里存放的就是HashEntry数组,HashEntry数组里存放的就是一个HashEntry链表,所以每个HashEntry数组就类似于HashMap了。当你要对HashEntry数组数据进行修改时,就需要先获得Segment对应的锁。Segment继承了ReentrantLock,通过ReentrantLock来获取锁。那么要存储一个key-value,怎么定位呢?首先要定位所要存的对象在segment数组的哪个位置。为了减少hash冲突,使存储元素均匀的分布在Segment上,先对key用本地方法hashCode进行计算得到hash值,然后把hash值的二进制数向右无符号移动28位,即(hash >>> segmentShift),segmentShift默认是28,这个过程也称为两次hash,最后将两次hash的值与segmentMask(segment数组大小减1)做与运算。为什么这样,我简单介绍以下,segmentMash的二进制数全是1,15就是4个1,hash值的低位只要一样,不管高位是什么,hash & segmentMash得到得值都是一样得,如果让高4位参与与运算,hash冲突就会大大降低,所以把hash值向右无符号移动28位。以上是segment的定位方式,当存储元素找到了在segment数组中的位置,接下来就是找元素最后存储的在segment数组某个位置的HashEntry数组的位置,其实寻找在HashEntry数组中的位置和HashMap的存储方式是类似的,但是也有不同。首先都是利用hash值与HashEntry数组大小减1的值做与运算得到数组下标位置,如果存储元素在HashEntry数组下标位置相同,就用链表来存放。这个思路和HashMap是一样的,但是链表的方式是不一样的,HashMap中hash冲突元素是往链表后面依次追加存放的,而HashEntry的链表正好相反,不断往链表头存放,为什么要这样呢,要先看下ConcurrentHashMap的内部类HashEntry的属性设计,HashEntry的一共有四个属性值,key、hash(int)、value、next(HashEntry类对象),其中value属性是被volatile修饰的,这个很好理解,volatile的语义之一就是value如果被更新后会立即刷回内存,这样能确保读操作能够看到最新的值,而key、hash、next都是final修饰,这就意味着这三个值是不能被修改的,因此next在类对象初始化时就被定义了,也就没有办法再被修改为指向下一个HashEntry了,因此只能往链表头部放,具体实现请自行阅读ConcurrntHashMap源码put操作,还有什么时候加锁,HashEntry数组的扩容等等源码都写得很清楚。因为HashEntry链表的特殊性,ConcurrentHashMap的remove操作、get操作和HashMap就有很大不同,主要就体现在链表上,所以主要就说链表的情况。
先说remove操作,如果要删除链表的一个存储元素,不能简单就把这个元素的上一个HashEntry的next就直接执行这个元素的下一个HashEntry,HashMap这样是可以,但这里不行,因为next属性的修饰符是final,所以源码就把要删除元素的前面所有HashEntry都复制一遍,然后接上删除元素后一个HashEntry,就这样去完成删除操作。get操作,java基础好的一听就懂,基础比较薄弱的,需要解释的细节就有点多了,我点到为止,需要自己思考、补充。首先Hashtable的get操作是加锁的,ConcurrentHashMap的get方法是不加锁的,但是ConcurrntHash的读操作(即整个get方法)确不一定不加锁读就能获得存储元素,在get方法中,首先判断以下count != 0,count变量是Segment中表示entry的个数,而且count变量是volatile修饰的,其值是在主内存中直接被修改,所以每次判断count的时候,即使有其它的线程改变了segment也会被体现出来。然后利用key值找到了segment的索引位置并在while循环里顺着链表找到了entry,接下来的操作就是get方法难理解的地方了,一个是得到的entry的value为什么要去判断它不为null(ConcurrentHashMap是不允许put操作的value值为null的),第二个是如果value==null,就执行readValueUnderLock()方法,也是ConcurrentHashMap为什么get操作在对外的API中不加锁的原因。还记得单例模式中双重检测锁(DCL)这个经典问题吗?利用双重检测锁实现的单例模式在高并发下可能会得到了一个未完成初始化的对象,其原因和这里的value会变成null的原因是一样的,而且这种情况只会发生在put操作new HashEntry
1.8版本的ConcurrentHashMap就放弃了分段锁的设计思想了,而是沿用了HashMap的思想,数组(Node
put操作和get操作是ConcurrentHashMap最常用的操作了,先介绍put操作。由于要考虑同步问题,所有数组的Node数组的初始化、扩容、元素的存放都是非常麻烦的,但是1.8的源码利用CAS的原子操作实现的十分巧妙,CAS不是本文介绍的重点,简单说下,在并发包中大量用到了Unsafe类,这个类的方法都是native的,其利用JNI调用CPU级别的原子指令来实现原子性操作和锁的效果,通过比较新值与内存中的预期值是否一致来修改内存值,所以也就是为什么ConcurrentHashMap中变量大都用volatile来修饰。在初始化数组的方法initTable中,为了防止多个线程同时初始化数组,利用CAS方法U.compareAndSwapInt将SIZECTL置为-1,防止其他线程进入,Node数组大小初始化默认16,扩容阈值为数组大小的0.75倍。ConcurrentHashMap还定义了三个原子性的方法来保证线程安全,它们分别是tabAt、casTabAt、setTabAt,tabAt能获取node数组指定位置的node节点,casTabAt和setTabAt都能设置指定位置的node值,不过他们之间有区别,casTabAt存在CAS算法ABA问题,这里我就不展开详细的说了,你要系统的学习并发的话,这个是非常重要的基础,所以casTabAt是和tabAt连在一起用的。不同1.8版本的HashMap,当链表长度过长时就转换成TreeNode,形成红黑树,ConcurrentHashMap有个treeifyBin方法将Node链表转成TreeNode后再用TreeBin类完成红黑树的包装,最后将TreeBin对象放在了Node数组对应位置。还有一个关键的环节:扩容,扩容大致分为两个步骤,第一步定义一个容量是原来两倍的Node数组nextTable,这个由单线程完成,第二步是将原来的table复制到nextTable,复制是在多线程下完成的,遍历整个数组,利用tabAt获取每个位置元素,如果这个位置为空,就在这个位置放入forwardNode,这个forwardNode用于连接两个table的节点类,因为它有个nextTable指针指向下一个table,它的hash值为-1,它的key、value、next均为null,其还定义了一个find方法可以从nextTable中查询节点。回到刚才复制的过程,如果这个位置是Node节点且是一个链表的头节点,那么就构造一个反序链表,然后分别放在新table的对应的两个位置,这个与HashMap类似,不解释了。如果这个位置是TreeBin节点,也做反序处理,并判断是否需要untreefi,也分别放在新table的两个位置。在这里是多线程遍历,如果遍历到的节点是forward节点,就继续向后遍历,非forward节点就先加锁,处理完后将这个节点置为forward,这样多线程交叉工作完成复制,不仅安全还效率高,真是一个巧妙的设计。这样上面的几点都是ConcurrentHashMap的put操作中关键的几步,这些环节弄懂了,就能对其有整体的把握。具体细节和实现需要自己去阅读源码,细心揣摩。ConcurrentHashMap的get操作象比较put操作就简单了,由key计算hash值,根据hash值定位,如果是个链表或树,就根据对应的数据结构查找。
3、Hashtable
Hashtable是一个不要再去使用它的集合类,JDK1.1就开始存在,Hashtable几乎可以等价于HashMap,除了Hashtable每个方法比HashMap多个同步synchronized,Hashtable的key、value均不能为null,而HashMap确都可以,还有个区别就是这两个集合的迭代器,HashMap的迭代器(Iterator)是fail-fast的,而Hashtable的迭代器enumerator不是fail-fast的。Hashtable现在有个更好的替代集合就是ConcurrentHashMap,之所以提及Hashtable,就是为了提示不要再使用这个集合类了。
4、TreeMap
我觉得TreeMap最核心的就是红黑树的实现,所以TreemMap的检索是非常快的,红黑树算法我想单独去写它,这里就不介绍了。其实这样TreeMap也就没啥好介绍了,其本质就是红黑树,理解了红黑树也就理解了TreeMap,因为红黑树,所以TreeMap是可以排序的,基本数据类型的包装类按自然顺序排序,其他类可实现Comparator接口自定义排序规则,在实例化TreeMap的构造方法中定义。因为红黑树,所以TreeMap的基本操作containsKey、get、put和remove等操作的时间复杂度就为log(n),最后需要注意的是TreeMap没有做线程同步处理,其迭代器是fail-fast的。
5、LinkedHashMap
LinkedHashMap继承了HashMap,所以你需要先搞清楚HashMap,上文有详细的介绍。其实LinkedHashMap就是在HashMap的基础上多了一个双向链表来存储顺序,这里的顺序指的是map数据插入和访问顺序,不是指数据自身的排序。LinkedHashMap默认是插入顺序,就是遍历的时候,先插入的元素先访问到。HashMap的存储是其内部类Entry,有四个属性key、value、next、hash,LinkedHashMap使用的也是自己的Entry类,而且继承了HashMap.Entry,并多了两个属性就是before、after,LinkedHashMap就是利用这两个元素,将所有元素串联成一个双向链表,所以LinkedHashMap就是HashMap加双向链表,故LinkedHashMap插入元素时还是按HashMap的规则插入,但是插完元素后需要去设置其before、after属性以形成链表供遍历使用,LinkedHashMap有个Entry类型的链表头header,它本身不存储数据,它就是连接第一个插入LinkedHashMap的entry元素。因为要维持双向链表特性,所以linkedHashMap元素的插入、删除、查询(如果是访问顺序)都需要重排序,这些操作既需要直接使用继承HashMap的对应方法,也需要覆写部分方法完成LinedHashMap有序的功能。只要搞清楚了HashMap,LinkedHashMap就比较简单了。
6、WeakHashMap
很多人可能WeakHashMap使用的很少,但是很多开源框架中都使用了WeakHashMap。WeakHashMap的存储结构类似于HashMap,但其key是弱键,所谓弱键,就是当key所指向的实例不再被其它任何对象引用,这个对象在虚拟机下一轮的GC中被回收的同时,WeakHashMap的key也会被存到一个ReferenceQueue队列中,当你下一次操作WeakHashMap时,WeakHashMap会先去同步WeakHashMap和ReferenceQueue,即把ReferenceQueue队列中的key在WeakHashMap中所对应的元素给删除掉。WeakHashMap存储元素的内部类Entry继承了WeakReference,所以实例化WeakHashMap.Entry之前需要先实例化WeakReference,实例化WeakReference时需要key和ReferenceQueue,这样就把key、WeakReference、ReferenceQueue三者给联系起来了,所以你也就知道了为什么WeakHashMap能自动删除弱键key了。WeekHashMap 的这个特点特别适用于需要缓存的场景。
下面我们将介绍Collection系列,其中主要实现类有ArrayList、LinkedList、HashSet、TreeSet、LinkedHashSet,个人觉得Collection系列比Map系列要简单不少。
1、ArrayList
ArraList比较简单,其实现List接口,是一个顺序集合容器,因为ArrayList底层就是一个数组,所以存入元素依次放入数组中,动态扩容,允许放入null元素。ArrayList是一个非线程安全集合。
2、LinkedList
LinkedList和ArrayList一样也是实现了List接口,故LinkedList也是一个顺序集合,相对于HashMap、ConcurrentHashMap来说,ArrayList、LinkedList都是比较简单的,涉及到的都是一些基础的数据结构。LinkedList底层就是通过双向列表实现的,由于双向列表的结构特性,LinkedList自然就可以被当作堆栈、列表、双向列表来使用,而LinkedList就特别适合需要快速插入、删除元素的场景。如果需要快速随机访问元素适合使用ArrayList。
3、HashSet、TreeSet、LinkedHashSet
如果HashMap、TreeMap、LinedHashMap你搞清楚了,那HashSet、TreeSet、LinkedHashSet你也就顺带看一眼就行了,因为HashSet与HashMap、TreeSet与TreeMap、LinkedHashSet与LinkedHashMap都有着相同的实现,前者操作都是利用后者去完成的,就是HashSet里有个HashMap,TreeSet里有个TreeMap,LinkedHashSet里面有个LinkedHashMap,当你往HashSet插入一个元素,实际上就是往HashMap里面put一个元素,key就是往HashSet插入的元素,value是Object实例对象,其它的操作都类似,TreeSet、LinkedHashSet也都如此。所以为什么set集合元素不重复,因为Map的key是唯一的嘛。