目录
1.集合基础概念
1.1 集合
1.2 单例集合
1.2.1 List系列
1、ArrayList
2、LinkedList
3、Voctor编辑
1.2.2 Set系列
1、HashSet 集合
2、LinkedHashSet 集合
3、TreeSet集合
1.3 双例集合
1.3.1 HashMap
1.3.2 LinkedHashMap
1.3.3 TreeMap
1.4 快速失败
2.十道常见面试题
2.1 ArrayList和LinkedList的区别?
2.2 ArrayList和Vector的区别是什么?
2.3 不使用Vector来解决ArrayList的线程安全问题,还有其他的解决方案吗?
2.4 对HashSet的理解?
2.5 对LinkedHashSet的理解?
2.6 对TreeSet的理解?
2.7 HashMap,HashSet和HashTable的区别?
2.8 对HashMap的理解?
2.9 原来1.7的头插法为什么在1.8中改为尾插法?
2.10 如果想使用线程安全的HashMap怎么办?
1.集合基础概念
1.1 集合
集合简称集,是用于存储多个元素的容器,程序中的容器用来容纳和管理数据。
Java中集合主要分为两大类:
- 单例集合(Collection):如List,将数据一个一个进行存储
- 双例集合(Map:key,value):如HashMap,将数据一对一对进行存储
Ps:数组与集合的对比
- 集合的长度可变,数组长度不可变
- Array能够存储基本数据类型和对象类型,而ArrayList只能存储对象类型
- Array存储的是同一类型的数据,ArrayList可以存储多种类型,但只能是对象类型
Ps:使用集合的好处
- 集合的容量自增长,无需手动扩容
- 提供了高性能的数据结构与算法,使编码更轻松,提高程序速度和质量
- 允许不同的API之间相互操作,API之间可以来回传递集合
- 可以方便的扩展或改写集合,提高代码的复用性和可操作性
- JDK自带的集合类,实现接口中常见的操作方法,统一规范,降低代码的维护和学习API的成本
1.2 单例集合
单例集合指实现Collection接口的结构,有两类,分别是List和Set。
- List接口的特点是:存储有序,有下标,存储的元素可重复
- Set接口的特点是:存储是无序的,没有下标,存储的元素不可重复
1.2.1 List系列
List集合特点:有序,可重复,有索引。
1、ArrayList
ArrayList是List接口的具体实现,底层是基于数组实现的,根据下标查询元素快,增删相对慢。
优点:查询速度相当快。而且ArrayList是顺序存储的,所以添加顺序添加元素非常方便。
缺点:删除元素的时候,需要做一次元素的复制操作,如果复制的元素很多,效率比较低。非顺序的新增/修改操作同理。
2、LinkedList
LinkedList也是List接口的具体实现,底层基于双链表实现的,查询元素慢,增删首尾元素非常快。LinkedList特性:
- 实现了List接口,允许null元素
- 元素存取有序
- 允许重复
- 不是线程安全的,在多线程环境下需要额外同步手段
- 适合频繁的插入和删除操作
- 实现了Cloneable接口,能被克隆
3、Voctor
- Vector 是矢量队列,继承于AbstractList,实现了List, RandomAccess, Cloneable这些接口。 它的底层是一个队列,支持相关的添加、删除、修改、遍历等功能。
- Vector 实现了RandmoAccess接口,即提供了随机访问功能。RandmoAccess是java中用来被List实现,为List提供快速访问功能的。
- 在Vector中,我们即可以通过元素的序号快速获取元素对象,这就是快速随机访问;
- Vector 实现了Cloneable接口,即实现clone()函数。它能被克隆;
- 和ArrayList不同,Vector中的操作是线程安全的。
1.2.2 Set系列
Set系列特点:无序、不重复、无索引
1、HashSet 集合
JDK8之前的版本,哈希表底层使用数组+链表组成:
JDK8开始后,新元素挂老元素下面,哈希表底层采用数组+链表+红黑树组成:
JDK1.8及以上版本,当挂在元素下面的数据过多时,查询性能降低,从JDK8开始后,当链表长度超过8的时候,自动转换为红黑树。
2、LinkedHashSet 集合
特点:有序、不重复、无索引。有序指的是保证存储和取出的元素顺序一致。
原理:底层数据结构是依然哈希表,只是每个元素又额外的多了一个双链表的机制记录存储的顺序。
3、TreeSet集合
特点:排序、不重复、无索引。可排序:按照元素的大小默认升序排序。
原理:底层是基于纯红黑树的数据结构实现排序的,增删改查性能都较好。
1.3 双例集合
1.3.1 HashMap
HashMap 最早出现在 JDK 1.2中,底层基于散列算法实现,它是一个key-value结构的容器,key不重复。不保证键值的顺序,可以存入null值,非线程安全,多线程环境下可能存在问题,继承了AbstractMap,实现了Map接口,提供了key,value结构格式访问的方法。
JDK1.7 版本的HashMap,底层数据采用的是数组+链表的结构实现。
JDK1.8 版本的HashMap,底层数据使用数组 + 链表/红黑树实现。
- 链表是为了解决hash碰撞问题。
- 红黑树是为了解决链表中的数据较多(满足链表长度超过8且数组长度大于64,才会将链表替换成红黑树才会树化)时效率下降的问题。
存储结构:
1.3.2 LinkedHashMap
特点:有序、不重复、无索引。有序指的是保证存储和取出的元素顺序一致
底层实现:底层数据结构是依然哈希表,只是每个键值对元素又额外的多了一个双链表的机制记录存储的顺序。
1.3.3 TreeMap
特点由键决定特性:不重复、无索引、可排序。
可排序:按照键数据的大小默认升序(由小到大)排序,且只能对键排序。
底层实现:TreeMap跟TreeSet一样的,基于红黑树。
1.4 快速失败
快速失败fast-fast,是Java集合的一种错误检测机制(util包下集合类都是快速失败,不能在多线程下并发修改)。
现象:当多个线程对集合进行结构上的改变操作时,有可能会产生fast-fast。
原理:迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个modCount变量。集合在被遍历期间如果结构方式了变化,就会改变modCount变量的值。每当迭代器使用hashNext()或next()方法遍历下一个元素之前,都会检测modCount变量是否为expectedmodCount值,是的话就返回遍历,否则抛出异常,终止遍历。
2.十道常见面试题
2.1 ArrayList和LinkedList的区别?
首先ArrayList和LinkedList都是List接口的实现类,而且他们俩都是不同步的,也就是说线程不安全的。两者之间的区别:
- 数据结构:ArrayList底层数据结构是数组,LinkedList的数据结构是双向列表
- 随机访问效率:由于底层数据结构就能看出来,ArrayList在随机下标访问时更快速,而LinkedList因为链表时线性结构,每次查询都需要指针依次往后遍历,所以查询效率会低一点
- 增加和删除效率:非首尾的位置元素增删时,LinkedList效率比ArrayList更高,因为ArrayList涉及到数组元素的复制和移动
- 内存空间:LinkedList比ArrayList更占内存,因为每个节点还存储了前后节点的指针。
综上,在查询频繁的场景更适合使用ArrayList,在增删频繁的场景适合用LinkedList。
2.2 ArrayList和Vector的区别是什么?
首先ArrayList和Vector都是List接口的实现类,且他们都是有序集合。But Vector是线程安全的,看下源码,是在每个方法上简单粗暴的加上了synchronized关键字来保证线程安全。
然后回到问题,对比两者之间的区别:
- 线程安全:ArrayList线程不安全,Vector线程安全
- 性能:ArrayList性能要优于Vector,因为Vector在实现线程安全时产生了额外开销
- 扩容:ArrayList扩容时每次是增加50%,Vector每次扩容是直接增加一倍
从性能上看,如果没有特别要求线程安全的情况下更建议使用ArrayList。
2.3 不使用Vector来解决ArrayList的线程安全问题,还有其他的解决方案吗?
如果不建议使用Vector,且需要线程安全。还有一个包java.util.concurrent(JUC)包,它下面有一个类CopyOnWriteArrayList也是线程安全的。它也是List的一个实现类,对比add方法源码可以看出来和Vector实现不同:
它的add方法用Lock锁来解决并发问题,其中在进行添加数据的时候,用了copyOf方法,也就是复制了一份,然后再set进去。
CopyOnWriteArrayList底层也是用的数组,但是它的数组是用volatile修饰了,主要是保证了数据的可见性,get操作时,并没有加锁,因为volatile保证了数据的可见性,当数据被修改的时候,读操作能立刻知道。
2.4 对HashSet的理解?
HashSet是Set接口的实现类,因此它也是无序、不可重复的。
HashSet的特点:
- 底层数据结构:HashSet 的底层数据结构是基于 HashMap 实现的,实际上HashSet的元素就是作为HashMap的key存储的。
- 存储对象:根据数据结构可以看出它不允许存储重复元素,即集合中不会包含相同的元素。当向HashSet中添加元素时,会根据元素的 hashCode() 方法和 equals() 方法来判断元素是否重复。
- null 值问题:HashSet 允许存储一个 null 元素。
- 线程安全问题:HashSet中没有对应同步的操作,因此是线程不安全的。
存储元素的过程:
往Haset添加元素的时候,HashSet会先调用元素的hashCode方法得到元素的哈希值 ,然后通过元素的哈希值经过移位等运算,就可以算出该元素在哈希表中的存储位置。
- 如果算出的元素存储的位置目前没有任何元素存储,那么该元素可以直接存储在该位置上;
- 如果算出的元素的存储位置目前已经存在有其他的元素了,那么还会调用该元素的equals方法;
- 与该位置的元素再比较一次,如果equals方法返回的是true,那么该位置上的元素视为重复元素,不允许添加,如果返回的是false,则允许添加。
HashSet扩容过程:
由于基于HashMap实现的,默认构造函数是构建一个初始容量为16,负载因子为0.75 的HashMap。封装了一个 HashMap 对象来存储所有的集合元素,所有放入HashSet中的集合元素实际上由HashMap的key来保存,而HashMap的value则存储了一个PRESENT,它是一个静态的Object对象。
线程安全的HashSet:JUC包下的CopyOnWriteArraySet
2.5 对LinkedHashSet的理解?
LinkedHashSet是具有可预知迭代顺序的Set接口的哈希表和链接列表实现。此实现与HashSet的不同之处在于,它维护着一个运行于所有条目的双重链接列表,此链接列表定义了迭代顺序,该迭代顺序可为插入顺序或是访问顺序。看1.8中源码对于它的定义:
可以看出对于LinkedHashSet而言,它继承与HashSet、又基于LinkedHashMap来实现的。
LinkedHashSet底层使用LinkedHashMap来保存所有元素,它继承与HashSet,其所有的方法操作上又与HashSet相同,因此LinkedHashSet 的实现上非常简单,只提供了四个构造方法,并通过传递一个标识参数,调用父类的构造器,底层构造一个LinkedHashMap来实现,在相关操作上与父类HashSet的操作相同,直接调用父类HashSet的方法即可
2.6 对TreeSet的理解?
TreeSet也是Set接口的实现类,但底层数据结构使用的是红黑树,因此它是有序的、不可重复的。
TreeSet 的主要特点:
- 元素有序:TreeSet 中的元素按照自然顺序排列,即使用 Comparable 接口的 compareTo 方法进行比较。
- 无重复元素:TreeSet 不允许存在重复的元素,如果尝试添加重复元素,则会被自动过滤。
- 快速查找:TreeSet 提供了快速的查找操作,如二分查找,可以在 O(log n) 时间复杂度内完成。
插入操作步骤:
- 根据元素的自然顺序或 Comparator 对象的 compare 方法,确定插入位置。
- 将新元素插入到确定的位置。
- 从插入位置开始,向上走到根节点,检查每个节点是否满足红黑树的性质。
- 如果不满足性质,则进行旋转或颜色调整,以恢复性质。
使用场景:需要快速查找、有序且不重复元素的场景
Ps:红黑树是一种自平衡二叉搜索树,具有以下特点
- 每个节点或红色或黑色。
- 根节点是黑色的。
- 所有叶子节点都是黑色的。
- 从任何节点到其后代叶子节点的所有路径都包含相同数量的黑色节点。
- 两个连续节点的颜色不能都是红色。
2.7 HashMap,HashSet和HashTable的区别?
HashMap | HashTable | HashSet |
---|---|---|
|
|
|
2.8 对HashMap的理解?
2.8 对HashMap的理解?
HashMap的特点:
- HashMap是一种散列表,采用key/value(数组 + 链表 + 红黑树)的存储结构
- HashMap是非线程安全的容器,且不保证元素的存储顺序
- HashMap查找添加元素的时间复杂度都为O(1),查询和修改的速度都很快
- HashMap的默认初始容量为16(1
- HashMap扩容时每次容量变为原来的两倍
- 当桶的数量小于64时不会进行树化,只会扩容
- 当桶的数量大于64且单个桶中元素的数量大于8时,进行树化
- 当单个桶中元素数量小于6时,进行反树化
底层数据结构:
- 1.7 数组 + 链表:元素个数达到一定长度后,链表的查询性能会下降,所以后来进行树化
- 1.8 数组 + 链表 | 红黑树:链表树化需要同时满足两个条件: 链表长度大于8,并不是大于等于8 + 数组长度达到64,如果数组长度不够64,会优先进行resize()扩容。
put方法的流程:
- HashMap 是懒惰创建数组的,首次使用才创建数组(1.7初始化默认为16(最大为2的30次方);1.8默认是null并没有实例化)
- 计算索引(桶下标)
- 如果桶下标还没人占用,创建 Node 占位返回
- 如果桶下标已经有人占用
- 已经是 TreeNode 走红黑树的添加或更新逻辑
- 是普通Node,走链表的添加或更新逻辑,如果链表长度超过树化阈值,走树化逻辑
- 返回前检查容量是否超过阈值,一旦超过进行扩容(2倍扩容)
put流程1.7中和1.8的区别:
- 链表插入节点时,1.7 是头插法,1.8 是尾插法
- 1.7 是大于等于阈值且没有空位时才扩容,而 1.8 是大于阈值就扩容
- 1.8 在扩容计算 Node 索引时,会优化
2.9 原来1.7的头插法为什么在1.8中改为尾插法?
首先需要理解HashMap的扩容,分为两步:
- 扩容:创建一个新的Entry空数组,长度是原数组的2倍;
- 再哈希:遍历原Entry数组,把所有的Entry重新Hash到新数组。
假设使用头插法,同一位置上新元素总会被放在链表的头部位置,在旧数组中同一条Entry链上的元素,通过重新计算索引位置后,有可能被放到了新数组的不同位置上。接下来考虑并发的场景,多个线程有的在插入元素有的的扩容,就有可能产生以下循环:
新插入元素B,指针指向后面的A:
但扩容后新的hash道中位置变化,可能有元素A指向元素B:
一旦几个线程都调整完成,就可能出现环形链表,如果这个时候去取值就出现了无限循环的状态。
简单来说使用头插会改变链表的上的顺序,但是如果使用尾插,在扩容时会保持链表元素原本的顺序,就不会出现链表成环的问题。
2.10 如果想使用线程安全的HashMap怎么办?
HashTable是线程安全的,但是HashTable只是单纯的在put()方法上加上synchronized,保证插入时阻塞其他线程的插入操作,虽然安全,但因为设计简单所以性能低下。而在JUC包里提供了一个ConcurrentHashMap,是线程安全的,ConcurrentHashMap并非锁住整个方法,而是通过原子操作和局部加锁的方法保证了多线程的线程安全,且尽可能减少了性能损耗。所以线程安全的使用HashMap场景更建议使用ConcurrentHashMap。
ConcurrentHashMap结构(JDK7和JDK8的结构有很大不同):
- JDK1.7采用:Segment数组 + HashEntry数组 + 链表的锁分段技术
- JDK1.8采用:Node数组 + 链表/红黑树
(1)在JDK1.7中,Segments数组中每一个元素就是一个段,每个元素里面又存储了一个Enter数组,这个enter数组就相当于是一个HashMap,如图:
所以在高并发场景下,每次修改内容时只会锁住segment数组的每个元素,多个元素之间各自负责自己的锁,分段后,多个段(元素)之间的插入修改不会有任何影响,既做到了并发,又提升了效率(按照默认的并发级别 concurrentLevel 来说 ,默认是16,所以理论上支持同时16个线程并发操作,并且还互不冲突。)
(2)在JDK1.8中,对ConcurrentHashMap的结构做了一些改进,其中最大的区别就是jdk1.8抛弃了Segments数组,摒弃了分段锁的方案,而是改用了和HashMap一样的结构操作,也就是数组 + 链表 + 红黑树结构,如图:
比JDK1.7中的ConcurrentHashMap提高了效率,在并发方面,使用了CAS + synchronized的方式保证数据的一致性;因为去掉了分段锁,所以在高并发时锁住的就是数组的节点了,使得结构更加简单。
Ps:JDK 1.8中的ConcurrentHashMap最复杂的就是扩容机制了,因为它不是一个个地扩容,它可以并发扩容,也就是同时进行多个节点的扩容。流程图:
在默认情况下,每个cpu可以负责16个元素的长度进行扩容,比如node数组的长度为32,那么线程A负责0-16下标的数组扩容, 线程B负责17-31下标的扩容,并发扩容在transfer方法中进行,这样,2个线程分别负责高16位和低16位的扩容,不管怎样都不会产生冲突,提升了效率。
此问题参考链接:ConcurrentHashMap1.7和1.8区别_concurrenthashmap1.7和1.8的区别-CSDN博客 这个博主写的非常清晰详细,推荐!