没有伞的孩子必须努力奔跑!
推荐阅读:
《ConcurrentHashMap实现原理(JDK1.7和JDK1.8)》
酱子今天主要详细介绍集合的常用实现类
其中集合系列的HashMap和ConcurrentHashMap我有单独写文章,小伙伴们可以移步推荐阅读查看。
在本篇文章开始之前,先上图!
集合相关类和接口都在java.util中
Java集合主要由两个接口派生出来的:Collection 和 Map
Collection:用于存放单一元素
-
List存放有序可重复数据
-
Set存放无序不可重复数据
Map:用于存放key-value键值对数据
接下来就一一介绍其具体实现类
目录
一、ArrayList
-
如何实现
ArrayList实现了List接口、RandomAccess标记接口、Cloneable接口、Serializable接口,继承了AbstractList抽象类。源码如下
RandomAccess标记接口,这个接口内部是空的,标记“实现了这个接口的类支持快速随机访问”,即不需要遍历,通过下标(索引)就可以直接访问到内存地址。
Cloneable接口,表明ArrayList支持拷贝clone()
Serializable标记接口,这个接口内部也是空的,标记“实现了这个接口的类支持序列化”。
这里序列化指将对象转化成以字节序列的形式来表示。序列化后的对象可以被写到数据库、写到文件,也可用于网络传输。
-
结构
底层基于数组实现(ArraList基于数组,是一块连续的内存空间),并且实现了动态扩容。
数组的容量是在定义的时候确定的,如果数组满了再插入会数组溢出,所以在插入数据的时候会先检查是否需要扩容,扩容是按照原来数组的1.5倍扩容。
-
Get 查询操作
可以直接通过数组下标获取get(index)
-
Add / Remove 维护操作
ArrayList可以通过有参构造方法制定底层数组的大小,通过无参构造方法的方式,数组的容量为0,只有真正进行数据添加add时,才会分配默认DEFAULT_CAPACITY = 10 的初始容量。
ArrayList新增元素有两种情况,一种是指定位置新增,一种是直接新增。当我们存入基本数据类型int、long等时只能存储他们对应的包装类Integer、Long等。源码如下
直接新增,在新增之前会先是否需要扩容ensureCapacityInternal,然后将元素添加到末尾。
指定位置新增,先检查插入的位置是否在合理范围,如果不在合理范围会直接抛出角标越界异常,如果在合理范围则继续判断是否需要扩容,然后就是arraycopy将元素添加到指定位置。(这种性能会比较差)
arraycopy是效率慢的主要原因
比如我要add(3,"我"),它是从index=2的地址开始复制了一个数组,然后放在index=3的位置,给要添加的元素“我”腾出位置,让其放在index=2的位置上完成新增操作。
这里有个问题!
如果定义了new ArrayList<>(10);然后add(3,"我");会抛出角标越界异常,因为ArrayList不会初始化数组大小。虽然设置了初始大小,但是直接打印List大小的时候仍然是0。
ArrayList删除元素的方式也有两种,一种直接删除,一种指定index删除,两种性能都比较差,源码如下
直接删除,就是直接遍历数组,找到元素对应索引,然后删除。
指定index删除,则会先判断索引是否在合理范围,然后删除。原理同新增差不多,这里就不多介绍了。
-
扩容机制
实现方式比较简单,就是通过数组扩容的方式实现。
比如现有一个长度为10的数组,新增一个元素,发现满了,那就需要进行扩容,重新定义一个长度为 10+(10>>1) 即 15的数组,然后把原数组的数据原封不动的复制到新数组中,这个时候再把指向原数的地址换到新数组。就实现动态扩容。
动态扩容意味着实际大小可能永远无法被填满,总有多余出来空置的内存空间。
比如上面添加第11个数据的时候,扩容到了15,有4个内存空间是闲置的。序列化的时候,如果把整个数组都序列化,就多序列化了4个内存空间,元素数量越多,空闲的空间就可能越大,序列化耗费的时间就越多。
ArrayList的序列化不太一样,它使用transient
修饰存储元素的elementData
的数组,transient
关键字的作用是让被修饰的成员属性不被序列化
ArrayList内部提供了两个私有方法writeObject 和 readObject 来完成序列化和反序列化。序列化使用的是ArrayList的实际大小而不是数组的长度。
-
ArrayList是否是线程安全的?什么是线程安全的?
ArrayList不是线程安全的。
线程安全版本的数组容器是Vector,Vector的实现很简单,就是把所有的方法统统加上synchronized就完事了。
也可以不使用Vector,用Collections.synchronizedList把一个普通ArrayList包装成一个线程安全版本的数组容器,原理同Vector是一样的。
还有就是使用CopyOnWriteArrayList。
-
CopyOnWriteArrayList
CopyOnWriteArrayList采用了一种读写分离的并发策略,允许并发读,读操作是无锁的,性能较高。至于写操作,比如向容器中添加一个元素,则首先将当前容器复制一份,然后在新副本上执行写操作,结束之后再将原容器的引用指向新容器。
缺点:
CopyOnWriteArrayList是很耗费内存的,每次set()/add()都会复制一个数组出。
另外就是CopyOnWriteArrayList只能保证数据的最终一致性,不能保证数据的实时一致性。
对于线程不安全的类,并发情况下可能会出现fail-fast情况;而线程安全的类,可能出现fail-safe的情况。
-
快速失败(fail-fast)
快速失败是Java集合的一种错误检测机制
单线程:当在用增强for循环对一个集合进行遍历操作时,如果遍历的过程中集合的结构发生了变化(增加或者删除元素),就会抛出并发修改异常ConcurrentModificationException。(所以不要在 for-each 循环里进行元素的 remove/add 操作。可以使用Iterator,因为Iterator在remove的时候保证了expectedModCount 与 modCount 的同步。)
多线程:当一个线程在对一个集合进行遍历操作的时候,如果其他线程对这个集合的结构进行了修改,就会抛出并发修改异常ConcurrentModificationException。
原理:迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变modCount的值。每当迭代器使用hashNext()/next()遍历下一个元素之前,都会检测modCount变量是否为expectedmodCount值,是的话就返回遍历;否则抛出异常,终止遍历。这里异常的抛出条件是检测到 modCount!=expectedmodCount 这个条件。如果出现ABA的情况则不会抛出异常。
-
安全失败(fail-safe)
如 ConcurrentHashMap、CopyOnWriteArrayList等。其原理在于开始遍历之前会将原集合完整的复制一份,前者在复制的集合上进行遍历,在原集合上进行元素添加、删除操作,后者反之,这样就可以避免了ConcurrentModificationException异常。
二、LinkedList
-
如何实现
LinkedList实现了List接口,Deque接口,Cloneable接口,Serializable接口,继承了AbstractSequentialList抽象类,源码如下
AbstractSequentialList提供了一套基于顺序访问的接口。通过继承此类,子类仅需实现部分代码即可拥有完整的一套访问某种序列表的接口。
Deque接口继承自Queue接口,所以LinkedList就具备了队列的功能。
LinkedList支持序列化,并实现了writeObject方法按照自己的方式序列化,在序列化时只保留了元素的内容item,并没有保留元素的前驱和后继。这样就节省了很多内存空间。反序列化时,通过for循环中的linkLast方法,把链接重新链接起来,这样就恢复健康了链表序列化之前的顺序。
与ArrayList相比,LinkedList并没有实现RandomAccess接口,因为LinkedList存储数据的内存地址不是连续的,所以不支持随机访问。
-
结构
LinkedList是一个双向链表结构,可以作为堆栈、队列或双端队列进行操作。
链表是一种物理存储单元上非连续、非顺序的存储结构,数据元素的逻辑顺序是通过链表中的指针链接次序实现的。
双向链表的每个节点用内部类Node表示,LinkedList通过first和last引用分别指向链表的第一个和最后一个元素,当链表为空的时候first和last都指向null
内部定义了Node节点,包含三个部分:item元素内容,next后继(存储下一个节点地址),prev前驱(存储上一个节点地址)
-
get查询操作
根据index查询,会先校验index是否在合理范围,然后会调用node方法遍历LinkedList查找指定位置上的元素,如果插入的元素在前半段,就从队头first开始找,否则从队尾last开始找。所以插入的位置越靠中间,花费的时间就越多。
使用的是for循环,所以LinkedList在get的时候性能会非常差,所以在遍历LinkedList的时候,建议使用迭代器。
迭代器只会调用一次 node(int)
方法,在执行 list.iterator()
的时候:先调用 AbstractSequentialList 类的 iterator()
方法,再调用 AbstractList 类的 listIterator()
方法,再调用 LinkedList 类的 listIterator(int)
方法
-
add/remove更新操作
新增元素有两种情况,一种直接将元素添加到队尾,一种是将元素添加到指定位置。
直接将元素添加到队尾,源码如下
简单总结一下就是:
-
首先获取最后一个节点l,
-
然后创建一个新的节点newNode,前驱指向l,要保存的数据e,后继节点为null
-
然后将新节点作为最后一个节点last
-
在判断当前是否为第一次添加数据即l是否为null,如果 l 为 null,说明是第一次添加,所以 first 为新的节点;否则将新的节点赋给之前 last 的 next。
添加数据到指定位置,源码如下
简单总结一下就是:
-
先校验插入的位置是否在合理范围内,如果不合理会抛出异常。
-
判断插入的位置是否是队尾,如果是,直接调用linklast插入数据到队尾即可,否则调用linkBefore方法。
-
在执行linkBefore方法之前,会调用node方法遍历LinkedList查找指定位置上的元素,如果插入的元素在前半段,就从队头first开始找,否则从队尾last开始找。所以插入的位置越靠中间,花费的时间就越多。
-
linkBefore中,先将 succ 的前一个节点(prev)存放到临时变量 pred 中,然后生成新的 Node 节点(newNode),并将 succ 的前一个节点变更为 newNode,如果 pred 为 null,说明插入的是队头,所以 first 为新节点;否则将 pred 的后一个节点变更为 newNode。
删除也差不多的原理,大家有兴趣可以自己看一下源码,这里就不多介绍啦
三、ArrayList 和 LinkedList的比较
-
结构不同,ArrayList基于数组实现,LinkedList基于双向链表实现
-
多数情况下,ArrayList查询快增删慢,LinkedList增删更快查询慢。因为ArrayList基于数组实现,可以直接通过数组下标获取,所以查询比较快,插入删除的时候需要元素向前或向后移动,还有可能触发扩容,所以增删慢。LinkedList基于双向链表实现,需要遍历链表,所以查询慢,插入删除元素只需要改变前驱和后继不需要移动元素,所以增删快。(注:凡事没有绝对,比如ArrayList删除的元素就在第一位,也很快哦)
-
ArrayList基于数组需要一块连续的内存空间,LinkedList基于链表,内存空间可以不连续。
四、Vector
Vector底层结构是数组,用的比较少,相对于ArrayList它是线程安全的,所有公有方法全都是synchronized的,扩容时他是2倍扩容。
五、HashSet
HashSet实现Set接口,标志着内部元素是无序的,元素是不可以重复的。实现Cloneable接口,标识着可以它可以被复制。实现Serializable接口,标识着可被序列化。
源码如下
HashSet底层就是基于HashMap。底层使用HashMap来保存所有元素,因此HashSet 的实现比较简单,相关HashSet的操作,基本上都是直接调用底层HashMap的相关方法来完成:
定义一个虚拟的Object对象作为HashMap的value,使用HashMap的key保存HashSet的元素。HashMap允许key和value为null,所以HashSet也可以存null。
HashSet就是限制了功能的HashMap,所以了解HashMap的实现原理,这个HashSet自然就通;对于HashSet中保存的对象,主要要正确重写equals方法和hashCode方法,以保证放入Set对象的唯一性;
从底层代码看,HashSet执行add方法,底层调用的也是HashMap的put方法,如果key相同,值不同,则会覆盖value;HashSet看起来值没有覆盖,是因为HashSet底层是HashMap,他的值就是HashMap的key,而value则是一个固定的Object对象。在这,相当于key相同,value也相同,故不会替换。
总结
集合系列差不多就这些啦,面试经常会问到的就这些,还有一些比较少遇到,在本篇文章中就没有过多介绍,高频出现的HashMap系列酱子单独写了文章。
如果本篇文章有任何错误,请大家多多包涵批评指教,不胜感激!
我是酱子,感谢大家对本期文章的阅读,创作不易,各位的支持和认可是我最大的动力,如果觉得文章写的不错的话,就请各位点赞、在看、关注,我们下期见~