常见的集合框架
Java集合框架可以分为两大的支线:
①、Collection,主要由List、Set、Queue组成:
- List代表有序、可重复的集合,典型代表就是封装了动态数组的ArrayList和封装了链表的LinkedList
- Set代表无序、不可重复的集合,典型代表就是HashSet和TreeSet;
- Queue代表队列,典型代表就是双端队列ArrayDeque,以及优先级队列PriorityQueue。
②、Map,代表键值对的集合,典型代表就是HashMap。
List
ArrayList和LinkedList区别
ArrayList 和 LinkedList 的区别主要体现在数据结构、用途、是否支持随机访问、内存占用等方面。
**数据结构:**ArrayList是基于数组实现,LinkedList基于链表实现
**用途:**多数情况下,ArrayList更利于查找,LinkedList更利于增删
**是否支持随机访问:**①、ArrayList是基于数组的,也实现了RandomAccess
接口,所以它支持随机访问,可以通过下标直接获取元素。
②、LinkedList是基于链表的,所以它没法根据下标直接获取元素,不支持随机访问,所以它也没有实现RandomAccess接口。
**内存占用:**ArrayList是基于数组的,是一块连续的内存空间,所以它的内存占用是比较紧凑的;但如果涉及到扩容,就会重新分配内存,空间是原来的1.5倍,存在一定的空间浪费。
LinkedList是基于链表的,每个节点都有一个指向下一个节点和上一个节点的引用,于是每个节点占用的内存空间稍微大一点。
使用场景不同:
ArrayList 适用于:
- 随机访问频繁:需要频繁通过索引访问元素的场景。
- 读取操作远多于写入操作:如存储不经常改变的列表。
- 末尾添加元素:需要频繁在列表末尾添加元素的场景。
LinkedList 适用于:
- 频繁插入和删除:在列表中间频繁插入和删除元素的场景。
- 不需要快速随机访问:顺序访问多于随机访问的场景。
- 队列和栈:由于其双向链表的特性,LinkedList 可以高效地实现队列(FIFO)和栈(LIFO)。
ArrayList 的扩容机制
ArrayList 确切地说,应该叫做动态数组,因为它的底层是通过数组来实现的,当往 ArrayList 中添加元素时,会先检查是否需要扩容,如果当前容量+1 超过数组长度,就会进行扩容。
扩容后的新数组长度是原来的 1.5 倍,然后再把原数组的值拷贝到新数组中。
ArrayList 怎么序列化的?
ArrayList 的序列化不太一样,它使用transient
修饰存储元素的elementData
的数组,transient
关键字的作用是让被修饰的成员属性不被序列化。
为什么最 ArrayList 不直接序列化元素数组呢?
出于效率的考虑,数组可能长度 100,但实际只用了 50,剩下的 50 其实不用序列化,这样可以提高序列化和反序列化的效率,还可以节省内存空间。
那 ArrayList 怎么序列化呢?
ArrayList 通过两个方法readObject、writeObject自定义序列化和反序列化策略,实际直接使用两个流ObjectOutputStream
和ObjectInputStream
来进行序列化和反序列化。
有哪几种实现 ArrayList 线程安全的方法?
可以使用 Collections.synchronizedList()
方法,它将返回一个线程安全的 List。
SynchronizedList list = Collections.synchronizedList(new ArrayList());
内部是通过 synchronized
关键字加锁来实现的。
也可以直接使用CopyOnWriteArrayList
,它是线程安全的,遵循写时复制的原则,每当对列表进行修改(例如添加、删除或更改元素)时,都会创建列表的一个新副本,这个新副本会替换旧的列表,而对旧列表的所有读取操作仍然可以继续。
CopyOnWriteArrayList list = new CopyOnWriteArrayList();
通俗的讲,CopyOnWrite
就是当我们往一个容器添加元素的时候,不直接往容器中添加,而是先复制出一个新的容器,然后在新的容器里添加元素,添加完之后,再将原容器的引用指向新的容器。多个线程在读的时候,不需要加锁,因为当前容器不会添加任何元素。这样就实现了线程安全。
CopyOnWriteArrayList 了解多少?
CopyOnWriteArrayList 就是线程安全版本的 ArrayList。
它的名字叫CopyOnWrite
——写时复制,已经明示了它的原理。
CopyOnWriteArrayList 采用了一种读写分离的并发策略。CopyOnWriteArrayList 容器允许并发读,读操作是无锁的,性能较高。至于写操作,比如向容器中添加一个元素,则首先将当前容器复制一份,然后在新副本上执行写操作,结束之后再将原容器的引用指向新容器。
Set
HashSet 的底层实现
HashSet 其实是由 HashMap 实现的,只不过值由一个固定的 Object 对象填充,而键用于操作。
实际开发中,HashSet 并不常用,比如,如果我们需要按照顺序存储一组元素,那么 ArrayList 和 LinkedList 可能更适合;如果我们需要存储键值对并根据键进行查找,那么 HashMap 可能更适合。
HashSet 自动去重,因为它是用 HashMap 实现的,HashMap 的键是唯一的(哈希值),相同键的值会覆盖掉原来的值
HashSet 和 ArrayList 的区别
- ArrayList 是基于动态数组实现的,HashSet 是基于 HashMap 实现的。
- ArrayList 允许重复元素和 null 值,可以有多个相同的元素;HashSet 保证每个元素唯一,不允许重复元素,基于元素的 hashCode 和 equals 方法来确定元素的唯一性。
- ArrayList 保持元素的插入顺序,可以通过索引访问元素;HashSet 不保证元素的顺序,元素的存储顺序依赖于哈希算法,并且可能随着元素的添加或删除而改变。
无序性和不可重复性的含义是什么
- 无序性不等于随机性 ,无序性是指存储的数据在底层数组中并非按照数组索引的顺序添加 ,而是根据数据的哈希值决定的。
- 不可重复性是指添加的元素按照
equals()
判断时 ,返回 false,需要同时重写equals()
方法和hashCode()
方法。
比较 HashSet、LinkedHashSet 和 TreeSet 三者的异同
HashSet
、LinkedHashSet
和TreeSet
都是Set
接口的实现类,都能保证元素唯一,并且都不是线程安全的。HashSet
、LinkedHashSet
和TreeSet
的主要区别在于底层数据结构不同。HashSet
的底层数据结构是哈希表(基于HashMap
实现)。LinkedHashSet
的底层数据结构是链表和哈希表,元素的插入和取出顺序满足 FIFO。TreeSet
底层数据结构是红黑树,元素是有序的,排序的方式有自然排序和定制排序。- 底层数据结构不同又导致这三者的应用场景不同。
HashSet
用于不需要保证元素插入和取出顺序的场景,LinkedHashSet
用于保证元素的插入和取出顺序满足 FIFO 的场景,TreeSet
用于支持对元素自定义排序规则的场景。
Map
说一下 HashMap 的底层数据结构?
JDK 8 中 HashMap 的数据结构是数组
+链表
+红黑树
。
HashMap 的核心是一个动态数组(Node[] table
),用于存储键值对。这个数组的每个元素称为一个“桶”(Bucket),每个桶的索引是通过对键的哈希值进行哈希函数处理得到的。
当多个键经哈希处理后得到相同的索引时,会发生哈希冲突。HashMap 通过链表来解决哈希冲突——即将具有相同索引的键值对通过链表连接起来。
不过,链表过长时,查询效率会比较低,于是当链表的长度超过 8 时(且数组的长度大于 64),链表就会转换为红黑树。红黑树的查询效率是 O(logn),比链表的 O(n) 要快。数组的查询效率是 O(1)。
HashMap 的初始容量是 16,随着元素的不断添加,HashMap 的容量(也就是数组大小)可能不足,于是就需要进行扩容,阈值是capacity * loadFactor
,capacity 为容量,loadFactor 为负载因子,默认为 0.75。
扩容后的数组大小是原来的 2 倍,然后把原来的元素重新计算哈希值,放到新的数组中。
总的来说,HashMap 是一种通过哈希表实现的键值对集合,它通过将键哈希化成数组索引,并在冲突时使用链表或红黑树来存储元素,从而实现快速的查找、插入和删除操作。
HashMap扩容机制了解吗?
- 初始容量和负载因子: HashMap 在创建时会指定初始容量(默认为 16)和负载因子(默认为 0.75)。负载因子是一个控制哈希表空间利用率的参数,当 HashMap 中的元素个数超过
初始容量 * 负载因子
时,就会触发扩容。 - 扩容过程:
- 当 HashMap 中的元素个数达到阈值(
初始容量 * 负载因子
),就会开始扩容。 - 扩容过程是将哈希表的容量扩大为原来的两倍(即原来的容量乘以 2),同时重新计算每个元素的存储位置(通过重新哈希计算)。
- 扩容过程中,HashMap 将所有的键值对重新分布到新的更大的哈希表中。这个过程是比较消耗资源的,因为需要重新计算哈希值和重新分配存储空间。
- 当 HashMap 中的元素个数达到阈值(
- 重新哈希计算:
- 当 HashMap 扩容时,每个键值对的哈希值需要重新计算,因为扩容后哈希表的容量和哈希函数可能发生变化。
- Java 使用
(hash & (newCapacity - 1))
的位运算来确定元素在新表中的位置,这个公式通过利用新容量为2的幂的特性,快速计算元素的新位置。
- 扩容的性能影响:
- 扩容是 HashMap 中一个性能开销较大的操作,因为它涉及到重新计算哈希值、重新分配存储空间和复制数据。
- 在实际应用中,合理设置初始容量和负载因子可以减少扩容操作的频率,从而提升 HashMap 的性能。
解决哈希冲突有哪些方法呢?
①、再哈希法
准备两套哈希算法,当发生哈希冲突的时候,使用另外一种哈希算法,直到找到空槽为止。对哈希算法的设计要求比较高。
②、开放地址法
遇到哈希冲突的时候,就去寻找下一个空的槽。有 3 种方法:
- 线性探测:从冲突的位置开始,依次往后找,直到找到空槽。
- 二次探测:从冲突的位置 x 开始,第一次增加 12 个位置,第二次增加 22,直到找到空槽。
- 双重哈希:和再哈希法类似,准备多个哈希函数,发生冲突的时候,使用另外一个哈希函数。
③、拉链法
也就是所谓的链地址法,当发生哈希冲突的时候,使用链表将冲突的元素串起来。HashMap 采用的正是拉链法。
HashMap与TreeMap区别
①、HashMap 是基于数组+链表+红黑树实现的,put 元素的时候会先计算 key 的哈希值,然后通过哈希值计算出数组的索引,然后将元素插入到数组中,如果发生哈希冲突,会使用链表来解决,如果链表长度大于 8,会转换为红黑树。
get 元素的时候同样会先计算 key 的哈希值,然后通过哈希值计算出数组的索引,如果遇到链表或者红黑树,会通过 key 的 equals 方法来判断是否是要找的元素。
②、TreeMap 是基于红黑树实现的,put 元素的时候会先判断根节点是否为空,如果为空,直接插入到根节点,如果不为空,会通过 key 的比较器来判断元素应该插入到左子树还是右子树。
get 元素的时候会通过 key 的比较器来判断元素的位置,然后递归查找。
由于 HashMap 是基于哈希表实现的,所以在没有发生哈希冲突的情况下,HashMap 的查找效率是 O(1)。适用于查找操作比较频繁的场景。
而 TreeMap 是基于红黑树实现的,所以 TreeMap 的查找效率是 O(logn)。并且保证了元素的顺序,因此适用于需要大量范围查找或者有序遍历的场景。
HashMap与HashTable区别
线程是否安全: HashMap
是非线程安全的,Hashtable
是线程安全的,因为 Hashtable
内部的方法基本都经过synchronized
修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap
吧!);
效率: 因为线程安全的问题,HashMap
要比 Hashtable
效率高一点。另外,Hashtable
基本被淘汰,不要在代码中使用它;
对 Null key 和 Null value 的支持: HashMap
可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值,否则会抛出 NullPointerException
。
初始容量大小和每次扩充容量大小的不同: ① 创建时如果不指定容量初始值,Hashtable
默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap
默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍。② 创建时如果给定了容量初始值,那么 Hashtable
会直接使用你给定的大小,而 HashMap
会将其扩充为 2 的幂次方大小(HashMap
中的tableSizeFor()
方法保证,下面给出了源代码)。也就是说 HashMap
总是使用 2 的幂作为哈希表的大小,后面会介绍到为什么是 2 的幂次方。
底层数据结构: JDK1.8 以后的 HashMap
在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树),以减少搜索时间(后文中我会结合源码对这一过程进行分析)。Hashtable
没有这样的机制。
哈希函数的实现:HashMap
对哈希值进行了高位和低位的混合扰动处理以减少冲突,而 Hashtable
直接使用键的 hashCode()
值。
JDK 8 对 HashMap 主要做了哪些优化呢?为什么?
相比较 JDK 7,JDK 8 的 HashMap 主要做了四点优化:
①、底层数据结构由数组 + 链表改成了数组 + 链表或红黑树的结构。
原因:如果多个键映射到了同一个哈希值,链表会变得很长,在最坏的情况下,当所有的键都映射到同一个桶中时,性能会退化到 O(n),而红黑树的时间复杂度是 O(logn)。
②、链表的插入方式由头插法改为了尾插法。
原因:头插法虽然简单快捷,但扩容后容易改变原来链表的顺序。
③、扩容的时机由插入时判断改为插入后判断。
原因:可以避免在每次插入时都进行不必要的扩容检查,因为有可能插入后仍然不需要扩容。
HashMap 是线程安全的吗?多线程下会有什么问题?
HashMap 不是线程安全的,主要有以下几个问题:
①、多线程下扩容会死循环。JDK1.7 中的 HashMap 使用的是头插法插入元素,在多线程的环境下,扩容的时候就有可能导致出现环形链表,造成死循环。
不过,JDK 8 时已经修复了这个问题,扩容时会保持链表原来的顺序。
②、多线程的 put 可能会导致元素的丢失。因为计算出来的位置可能会被其他线程的 put 覆盖。本来哈希冲突是应该用链表的,但多线程时由于没有加锁,相同位置的元素可能就被干掉了。
③、put 和 get 并发时,可能导致 get 为 null。线程 1 执行 put 时,因为元素个数超出阈值而导致出现扩容,线程 2 此时执行 get,就有可能出现这个问题。
有什么办法能解决 HashMap 线程不安全的问题呢?
在 Java 中,有 3 种线程安全的 Map 实现,最常用的是ConcurrentHashMap
和Collections.synchronizedMap(Map)
包装器。
Hashtable 也是线程安全的,但它已经不再推荐使用,因为 ConcurrentHashMap 提供了更高的并发性和性能。
①、HashTable 是直接在方法上加synchronized 关键字,比较粗暴。
②、Collections.synchronizedMap
返回的是Collections工具类的内部类。
③、ConcurrentHashMap在 JDK 7 中使用分段锁,在 JKD 8 中使用了CAS(Compare-And-Swap)+ synchronized 关键字,性能得到进一步提升。
LinkedHashMap 怎么实现有序的?
LinkedHashMap 维护了一个双向链表,有头尾节点,同时 LinkedHashMap 节点 Entry 内部除了继承 HashMap 的 Node 属性,还有 before 和 after 用于标识前置节点和后置节点。
ConcurrentHashMap 和 Hashtable 的区别
ConcurrentHashMap
和 Hashtable
的区别主要体现在实现线程安全的方式上不同。
-
底层数据结构: JDK1.7 的
ConcurrentHashMap
底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟HashMap1.8
的结构一样,数组+链表/红黑二叉树。Hashtable
和 JDK1.8 之前的HashMap
的底层数据结构类似都是采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的; -
实现线程安全的方式(重要):
-
在 JDK1.7 的时候,
ConcurrentHashMap
对整个桶数组进行了分割分段(Segment
,分段锁),每一把锁只锁容器其中一部分数据(下面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 -
到了 JDK1.8 的时候,
ConcurrentHashMap
已经摒弃了Segment
的概念,而是直接用Node
数组+链表+红黑树的数据结构来实现,并发控制使用synchronized
和 CAS 来操作。(JDK1.6 以后synchronized
锁做了很多优化) 整个看起来就像是优化过且线程安全的HashMap
,虽然在 JDK1.8 中还能看到Segment
的数据结构,但是已经简化了属性,只是为了兼容旧版本; -
Hashtable
(同一把锁) :使用synchronized
来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
-