前言:笔者参考了JavaGuide、三分恶等博主的八股文,结合Chat老师和自己的理解,整理了一篇关于Java集合的八股文。希望对各位读者有所帮助~~
引言
常见集合有哪些?
Java集合相关类和接口都在java.util
包中,按照其存储结构集合可以分为两大类:单列集合 Collection 和双列集合 Map。Collection派生出了三个子接口:List、Set、Queue,因此Java集合大致也可分成List、Set、Queue、Map四种接口体系。
单列集合Collection
List
:元素有序、可重复,这里所谓的有序意思是:元素的存入顺序和取出顺序一致。例如,存储元素的顺序是 11、22、33,那么我们从 List 中取出这些元素的时候也会按照 11、22、33 这个顺序。List
接口的常用实现类有:ArrayList
:底层数据结构是数组,线程不安全;LinkedList
:底层数据结构是链表,线程不安全;
Set
:元素不可重复,不能通过整数索引来访问,并且元素无序。所谓无序也就是元素的存入顺序和取出顺序不一致。其常用实现类有:HashSet
:底层基于HashMap
实现,采用HashMap
来保存元素;LinkedHashSet
:LinkedHashSet
是HashSet
的子类,并且其底层是通过LinkedHashMap
来实现的;
Queue
:先进先出的队列。其常用实现类有:PriorityQueue
双列集合Map
Map:元素是成对存在的。每个元素由键(key)与值(value)两部分组成,通过键可以找对所对应的值。显然这个双列集合解决了数组无法存储映射关系的痛点。另外,需要注意的是,Map
不能包含重复的键,值可以重复;并且每个键只能对应一个值。
需要注意的是,这些容器都只能存储对象引用类型,也就是说当我们需要装载的数据是诸如 int
、float
等基本数据类型的时候,必须把它们转换成对应的包装类。
线程安全的集合有哪些?线程不安全的呢?
线程安全的:
Hashtable
:比HashMap多了个线程安全。ConcurrentHashMap
:是一种高效但是线程安全的集合。Vector
:比Arraylist多了个同步化机制。Stack
:栈,也是线程安全的,继承于Vector。
线程不安全的:
HashMap
ArrayList
LinkedList
HashSet
TreeSet
TreeMap
List
ArrayList和LinkedList有什么区别?
- ArrayList和LinkedList都是线程不安全的;
- ArrayList 的实现是基于数组,LinkedList 的实现是基于双向链表;
- ArrayList 采用数组存储,所以插入和删除元素受元素位置的影响;LinkedList采用链表存储,所以插入和删除元素不受元素位置的影响;
- ArrayList 支持随机访问,而 LinkedList 不支持。ArrayList 可以根据下标以 O ( 1 ) O(1) O(1)时间复杂度对元素进行随机访问,而 LinkedList 的每一个元素都依靠地址指针和它后一个元素连接在一起,查找某个元素的时间复杂度是 O ( n ) O(n) O(n);
- LinkedList 比 ArrayList 更占内存。因为 LinkedList 的节点除了存储数据,还存储了两个引用,一个指向前一个元素,一个指向后一个元素;
讲讲ArrayList的扩容机制?
ArrayList是基于数组的集合,数组的容量是在定义的时候确定的,如果数组满了再插入,就会数组溢出。所以在插入时候,会先检查是否需要扩容,如果当前容量+1超过数组长度,就会进行扩容。
扩容发生在啥时候?那肯定是我们往数组中新加入一个元素但是发现数组满了的时候。没错,我们去 add
方法中看看 ArrayList
是怎么做扩容的:
ensureExplicitCapacity
判断是否需要进行扩容,很显然,grow
方法是扩容的关键:
别的都不用看了,看上面图中的黄色框框就知道 ArrayList
是怎么扩容的了:扩容后的数组长度 = 当前数组长度 + 当前数组长度 / 2(也就是【1.5 倍】)。最后使用 Arrays.copyOf
方法直接把原数组中的数组 copy 过来,需要注意的是,Arrays.copyOf
方法会创建一个新数组然后再进行拷贝。
扩容操作需要调用 Arrays.copyOf()(底层 System.arraycopy())需要把原数组整个复制到新数组中,这个操作代价很高,因此最好在创建 ArrayList 对象时就指定大概的容量大小,减少扩容操作的次数。
举个栗子:
Arraylist 扩容的数组长度为什么是原数组长度的 1.5 倍?
这种扩容方式能够有效地平衡内存空间的使用和程序性能之间的关系。如果每次扩容时都将数组长度翻倍,可能会导致数组长度过大,浪费内存空间,而如果每次扩容时只增加一个固定的长度,可能会导致频繁扩容,影响程序性能。因此,1.5倍左右的扩容因子是一个比较理想的选择。
ArrayList怎么序列化的知道吗? 为什么用transient修饰数组?
ArrayList的序列化不太一样,它使用 transient 修饰存储元素的 elementData 的数组, transient 关键字的作用是让被修饰的成员属性不被序列化。
为什么最ArrayList不直接序列化元素数组呢?
出于效率的考虑,数组可能长度100,但实际只用了50,剩下的50不用其实不用序列化,这样可以提高序列化和反序列化的效率,还可以节省内存空间。
那ArrayList怎么序列化呢?
ArrayList通过两个方法readObject
、writeObject
自定义序列化和反序列化策略,实际直接使用两个流ObjectOutputStream
和ObjectInputStream
来进行序列化和反序列化。
什么是CopyOnWriteArrayList?
CopyOnWriteArrayList就是线程安全版本的ArrayList。它的名字叫 CopyOnWrite ——写时复制,已经明示了它的原理。
CopyOnWriteArrayList采用了一种读写分离的并发策略。CopyOnWriteArrayList容器允许并发读,读操作是无锁的,性能较高。至于写操作,比如向容器中添加一个元素,则首先将当前容器复制一份,然后在新副本上执行写操作,结束之后再将原容器的引用指向新容器。
ArrayList 与Vector区别?
- 实现:都实现了List接口,底层都使用 Object[]数组存储。
- 线程安全: Vector使用了Synchronized 来实现线程同步,是线程安全的,而 ArrayList是非线程安全的。
- 扩容: Vector 每次扩容请求其大小的 2 倍(也可以通过构造函数设置增长的容量),而 ArrayList 是 1.5 倍。
- 性能: ArrayList在性能方面要优于Vector。
Map
HashMap 有什么特点?
HashMap 基于哈希表的 Map 接口实现,是以 key-value 存储形式存在,主要用来存放键值对。
- HashMap 的实现不是同步的,这意味着它不是线程安全的;
- key 是唯一不重复的,底层的哈希表结构,依赖 hashCode 方法和 equals 方法保证键的唯一;
- key、value 都可以为null,但是 key 位置只能是一个null;
- HashMap 中的映射不是有序的,即存取是无序的;
- key 要存储的是自定义对象,需要重写 hashCode 和 equals 方法,防止出现地址不同内容相同的 key;
- JDK1.8 之前 HashMap 由 数组+链表 组成
- 数组是 HashMap 的主体;
- 链表则是为了解决哈希冲突而存在的(拉链法解决冲突),拉链法就是头插法,两个对象调用的 hashCode 方法计算的哈希码值(键的哈希)一致导致计算的数组索引值相同;
- JDK1.8 以后 HashMap 由 数组+链表 +红黑树数据结构组成
- 解决哈希冲突时有了较大的变化;
- 当链表长度超过阈值(或者红黑树的边界值,默认为 8)并且当前数组的长度大于等于 64 时,此索引位置上的所有数据改为红黑树存储;
- 即使哈希函数取得再好,也很难达到元素百分百均匀分布。当 HashMap 中有大量的元素都存放到同一个桶中时,就相当于一个长的单链表,假如单链表有 n 个元素,遍历的时间复杂度是 O ( n ) O(n) O(n),所以 JDK1.8 中引入了 红黑树(查找时间复杂度为 O ( l o g n ) O(logn) O(logn))来优化这个问题,使得查找效率更高
HashMap的底层数据结构是什么?
在JDK1.7 中,由“数组+链表”组成:数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的。
在JDK1.8 中,由“数组+链表+红黑树”组成:当链表过长,则会严重影响 HashMap 的性能,红黑树搜索时间复杂度是
O
(
l
o
g
n
)
O(logn)
O(logn),而链表是糟糕的
O
(
n
)
O(n)
O(n)。因此,JDK1.8 对数据结构做了进一步的优化,引入了红黑树。链表和红黑树在达到一定条件会进行转换。
数据结构示意图如下:
其中,桶数组是用来存储数据元素,链表是用来解决冲突,红黑树是为了提高查询的效率。
- 数据元素通过映射关系,也就是散列函数,映射到桶数组对应索引的位置;
- 如果发生冲突,从冲突的位置拉一个链表,插入冲突的元素;
- 如果链表长度>8&数组大小>=64,链表转为红黑树;
- 如果红黑树节点个数<6 ,转为链表;
了解红黑树嘛?
红黑树本质上是一种二叉查找树,为了保持平衡,它又在二叉查找树的基础上增加了一些规则:
- 每个节点要么是红色,要么是黑色;
- 根节点永远是黑色的;
- 所有的叶子节点都是黑色的(注意这里说叶子节点其实是图中的 NULL 节 点);
- 每个红色节点的两个子节点一定都是黑色;
- 从任一节点到其子树中每个叶子节点的路径都包含相同数量的黑色节点;
为什么HashMap不使用二叉树/平衡树/B树、B+树呢?
之所以不用二叉树:
红黑树是一种不严格的平衡二叉树,插入、删除、查找的最坏时间复杂度都为 O(logn),避免了二叉树最坏情况下的O(n)时间复杂度。
之所以不用平衡二叉树:
平衡二叉树是比红黑树更严格的平衡树,是一种高度平衡的二叉树,查询效率高。但是为了维持这种高度的平衡,需要旋转的次数更多,每次插入、删除都要做调整,就比较复杂、耗时,也就是说平衡二叉树保持平衡的效率更低。因此,红黑树的查询性能略微逊色于二叉平衡树, 但是红黑树在插入和删除上优于二叉平衡树。总体来说,红黑树的插入、删除、查找各种操作性能都比较稳定。
之所以不用B树、B+树:
- B+树在数据库中被应用的原因就是B+树比B树更加“矮胖”,B+树的非叶子结点不存储数据,所以每个结点能存储的关键字更多。所以B+树更能应对大量数据的情况。如果用B+树的话,在数据量不是很多的情况下,数据都会“挤在”一个结点里面。这个时候遍历效率就退化成了链表;
- B和B+树主要用于数据存储在磁盘上的场景,比如数据库索引就是用B+树实现的。这两种数据结构的特点就是树比较矮胖,每个结点存放一个磁盘大小的数据,这样一次可以把一个磁盘的数据读入内存,减少磁盘转动的耗时,提高效率。而红黑树多用于内存中排序,也就是内部排序。
红黑树怎么保持平衡的知道吗?
红黑树有两种方式保持平衡:旋转(左旋和右旋)和染色。
HashMap的put流程知道吗?
流程图如下:
- 首先判断数组是否为空,为空则调用 resize 进行初始化;
- 根据 key 的值计算 hash 值,找到该元素在数组中存储的下标;
- 如果没有哈希冲突直接放在对应的数组下标里;
- 如果冲突了,且 key 已经存在,就覆盖掉 value;
- 如果冲突后,发现该节点是红黑树,就将这个节点挂在树上;
- 如果冲突后是链表,判断该链表是否大于 8 ,如果大于 8 并且数组长度小于 64,就进行扩容;如果链表节点大于 8 并且数组长度大于 64,则将这个结构转换为红黑树;否则,链表插入键值对,若 key 存在,就覆盖掉 value。
HashMap怎么查找元素的呢?
先看流程图:
HashMap的查找流程:
- 使用扰动函数,获取新的哈希值;
- 计算数组下标,获取节点;
- 当前节点和key匹配,直接返回;
- 否则,当前节点是否为树节点,查找红黑树;
- 否则,遍历链表查找。
HashMap的哈希/扰动函数是怎么设计的?
HashMap的哈希函数是先拿到 key 的hashcode,是一个32位的int类型的数值,然后让hashcode的高16位和低16位进行异或操作。
static final int hash(Object key) {
int h;
// key的hashCode和key的hashCode右移16位做异或运算
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
这么设计是为了降低哈希碰撞的概率。
你还知道哪些哈希函数的构造方法呢?
HashMap里哈希构造函数的方法叫除留取余法 :H(key) = key%p(p<=N)
,关键字除以一个不大于哈希表长度的正整数p,所得余数为地址,当然HashMap里进行了优化改造,效率更高,散列也更均衡。
除此之外,还有这几种常见的哈希函数构造方法:
- 直接定址法:直接根据 key 来映射到对应的数组位置,例如1232放到下标1232的位置。
- 数字分析法:取 key 的某些数字(例如十位和百位)作为映射的位置。
- 平方取中法:取 key 平方的中间几位作为映射的位置。
- 折叠法:将 key 分割成位数相同的几段,然后把它们的叠加和作为映射的位置。
解决哈希冲突有哪些方法呢?
解决Hash冲突方法有:开放寻址法、再哈希法、链地址法(拉链法)、建立公共溢出区。HashMap中采用的是链地址法 。
- 开放寻址法:开放寻址法也称为再散列法,基本思想就是如果
p = H(key)
出现冲突时,则以p为基础,再次hash,p1 = H(p)
,直到找到一个不冲突的哈希地址。 因此开放定址法所需要的hash表的长度要大于等于所需要存放的元素,而且因为存在再次hash,所以只能在删除的节点上做标记,而不能真正删除节点。 - 再哈希法:再哈希法(双重散列,多重散列),提供多个不同的hash函数,当
R1 = H1(key1)
发生冲突时,再计算R2 =H2(key1)
,直到没有冲突为止。 这样做虽然不易产生堆集,但增加了计算的时间。 - 链地址法(拉链法):在冲突的位置拉一个链表,把冲突的元素放进去。链地址法适用于经常进行插入和删除的情况。
- 建立公共溢出区:再建一个数组,把冲突的元素放进去。
为什么HashMap链表转红黑树的阈值为8呢?
源码的注释也给出了答案:
这和统计学有关。理想情况下,使用随机哈希码,链表里的节点符合泊松分布,出现节点个数的概率是递减的,节点个数为8的情况,发生概率仅为0.00000006 。至于红黑树转回链表的阈值为什么是6,而不是8呢?这是因为如果这个阈值也设置成8,那么假如发生碰撞,节点增减刚好在8附近,则会发生链表和红黑树的不断转换,导致资源浪费。
**扩容在什么时候呢 **
为了减少哈希冲突发生的概率,当当前HashMap的元素个数达到一个临界值的时候,就会触发扩容,把所有元素rehash之后再放在扩容后的容器中,这是一个相当耗时的操作。
临界值threshold
就是由加载因子和当前容器的容量大小来确定的,假如采用默认的构造方法:
临界值(threshold )= 默认容量(DEFAULT_INITIAL_CAPACITY) * 默认扩 容因子(DEFAULT_LOAD_FACTOR)
那就是大于 16 × 0.75 = 12 16\times 0.75=12 16×0.75=12时,就会触发扩容操作。
为什么选择了0.75作为HashMap的默认加载因子呢?
简单来说,这是对空间成本和时间成本平衡的考虑。
在HashMap中有这样一段注释:
我们都知道,HashMap的散列构造方式是Hash取余,负载因子决定元素个数达到多少时候扩容。
假如我们设的比较大,元素比较多,空位比较少的时候才扩容,那么发生哈希冲突的概率就增加了,查找的时间成本就增加了。
假设我们设的比较小的话,元素比较少,空位比较多的时候就扩容了,发生哈希碰撞的 概率就降低了,查找时间成本降低,但是就需要更多的空间去存储元素,空间成本就增加了。
总结,主要是在时间成本和空间成本上做的折衷:
- 如果负载因子太高,确实可以减少空间开销,但会增加查找复杂度(哈希冲突的概率增加,链表上的元素也就越多,查询的效率也就越低);
- 如果负载因子太低,就会频繁触发 resize 导致 rehash 操作;
具体到底为什么精确到 0.75 这个数值,可以看这篇文章的解释:博客 。
HashMap和Hashtable的区别?
- 线程安全:Hashtable方法sychonized修饰,线程安全;
- 效率方面:由于Hashtable方法被sychonized修饰,效率比HashMap低;
- 底层数据结构:HashMap jdk8当链表长度>8并且数组长度>=64链表会转红黑树,Hashtable没有这样机制;
- 初始容量: 默认初始量:Hashtable为11,HashMap为16;若指定初始量: Hashtable用指定的值,HashMap会扩充为2的幂次方大小;
- 扩容:Hashtable容量变为原来2n+1倍,HashMap变为2倍;
- 对Null key与Null value支持: HashMap支持一个Null key多个Null value,Hashtable不支持Null key 和 Null value,否则报错空指针异常;
HashMap jdk8与jdk7区别?
- 数据结构:1.7中是数组+链表,1.8中是数组+链表 或 数组 + 链表 + 红黑树。原因:发生哈希冲突,元素会存入链表,链表过长转为红黑树,将时间复杂度由 O(n) 降为 O(logn)。
- 链表插入方式:1.7是头插法,1.8是尾插法。原因:因为1.7头插法扩容时,头插法会使链表发生反转,多线程环境下会产生环。
- 扩容rehash:1.7 需要对原数组中的元素进行重新 hash 定位在新数 组的位置,1.8不需要重新通过哈希函数计算位置,新的位置不变或索引 + 旧的数组容量大小。原因:提高扩容的效率,更快地扩容。
- 扩容时机:1.7是先判断是否需要扩容然后再插入,1.8是先进行插入,插入完成再判断是否需要扩容;
- 散列函数:1.7 做了四次移位和四次异或,jdk1.8只做一次。原因:做四次的话,边际效用也不大,改为一次,提升效率。
HashMap 是线程安全的吗? 多线程下会有什么问题?
HashMap不是线程安全的,可能会发生这些问题:
- 多线程下扩容死循环。JDK1.7 中的 HashMap 使用头插法插入元素,在多线程的环境下,扩容的时候有可能导致环形链表的出现,形成死循环。因此,JDK1.8 使用尾插法插入元素,在扩容时会保持链表元素原本的顺序,不会出现环形链表的问题。
- 多线程的 put 可能导致元素的丢失。多线程同时执行 put 操作,如果计算出来的索引位置是相同的,那会造成前一个 key 被后一个 key 覆盖,从而导致元素的丢失。此问题在 JDK 1.7 和 JDK 1.8 中都存在。
- put 和 get 并发时,可能导致 get 为 null。线程 1 执行 put 时,因为元素个数超出 threshold 而导致 rehash,线程 2 此时执行 get,有可能导致这个问题。这个问题在 JDK 1.7 和 JDK 1.8 中都存在。
有什么办法能解决HashMap线程不安全的问题呢?
Java 中有 HashTable、Collections.synchronizedMap、以及 ConcurrentHashMap 可以实现线程安全的 Map。
- HashTable 是直接在操作方法上加 synchronized 关键字,锁住整个table数组,粒度比较大;
- Collections.synchronizedMap 是使用 Collections 集合工具的内部类,通过传入 Map 封装出一个 SynchronizedMap 对象,内部定义了一个对象锁,方法内通过对象锁实现;
- ConcurrentHashMap 在jdk1.7中使用分段锁,在jdk1.8中使用CAS+synchronized。
HashMap 内部节点是有序的吗?
HashMap是无序的,根据 hash 值随机插入。如果想使用有序的Map,可以使用 LinkedHashMap 或者 TreeMap。
LinkedHashMap 怎么实现有序的?
LinkedHashMap维护了一个双向链表,有头尾节点,同时 LinkedHashMap 节点 Entry 内部除了继承 HashMap 的 Node 属性,还有 before 和 after 用于标识前置节点和后置节点。可以实现按插入的顺序或访问顺序排序。
TreeMap 怎么实现有序的?
TreeMap 是按照 Key 的自然顺序或者 Comprator 的顺序进行排序,内部是通过红黑树来实现。所以要么 key 所属的类实现 Comparable 接口,或者自定义一个实现了Comparator 接口的比较器,传给 TreeMap 用于 key 的比较。
Set
讲讲HashSet的底层实现?
HashSet 底层就是基于 HashMap 实现的。(HashSet的源码非常少,因为除了clone()、writeObject()、readObject()是HashSet自己不得不实现之外,其他方法都是直接调用HashMap中的方法。)
HashSet的add方法,直接调用HashMap的put方法,将添加的元素作为key,new一个 Object作为value,直接调用HashMap的put方法,它会根据返回值是否为空来判断是否插入元素成功。
public boolean add(E e) {
return map.put(e, PRESENT) == null;
}
而在HashMap的putVal方法中,进行了一系列判断,最后的结果是,只有在key在table数组中不存在的时候,才会返回插入的值。
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null) {
e.value = value;
}
afterNodeAccess(e);
return oldValue;
}