Java集合框架只会Hashmap?应付面试都不够吧
写在前面
之前,我写过一篇关于Java集合的博客,但那篇大部分是在写hashmap,毕竟面试常问的就是那个了。那篇博客讲有关Hashmap底层知识写的很详尽,不管面试什么样的公司都足够应付,有兴趣可以看看。Java集合高频面试题
但是,总问Hashmap总有些精神疲劳,很多面试官知道求职者会着力准备Hashmap相关的东西,所以,现在问Hashmap已经不足以区分人才,重点就在list和set上。
Java 集合概览
从下图可以看出,在 Java 中除了以 Map 结尾的类之外, 其他类都实现了 Collection 接口。并且,以 Map 结尾的类都实现了 Map 接口。
说说 List,Set,Map 三者的区别?
- List (对付顺序的好帮手): 存储的元素是有序的、可重复的。
- Set (注重独一无二的性质): 存储的元素是⽆序的、不可重复的。
- Map (用 Key 来搜索的专家): 使⽤键值对(kye-value)存储,类似于数学上的函数 y=f(x),“x”代表 key,"y"代表 value,Key 是⽆序的、不可重复的,value 是⽆序的、可重复的,每个键最多映射到⼀个值。
集合框架底层数据结构总结
List
- Arraylist : Object[] 数组
- Vector : Object[] 数组
- LinkedList : 双向链表(JDK1.6 之前为循环链表,JDK1.7 取消了循环)
Set
- HashSet (⽆序,唯⼀): 基于 HashMap 实现的,底层采⽤ HashMap 来保存元素
- LinkedHashSet(有序,唯⼀) : LinkedHashSet 是 HashSet 的子类,并且其内部是通过LinkedHashMap 来实现的
- TreeSet (有序,唯⼀): 红黑树(自平衡的排序⼆叉树)
Map
- HashMap : JDK1.8 之前 HashMap 由数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间
- LinkedHashMap : LinkedHashMap 继承⾃ HashMap ,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap 在上面结构的基础上,增加了⼀条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑。
- Hashtable : 数组+链表组成的,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的
- TreeMap : 红黑树(自平衡的排序⼆叉树)
有哪些集合是线程不安全的?怎么解决呢?
我们常用的 Arraylist, LinkedList , Hashmap , HashSet , TreeSet , TreeMap , PriorityQueue 都不是线程安全的。解决办法很简单,可以使用线程安全的集合来代替。
如果你要使用线程安全的集合的话, java.util.concurrent 包中提供了很多并发容器供你使用:
- ConcurrentHashMap : 可以看作是线程安全的 HashMap。
- CopyOnWriteArrayList :可以看作是线程安全的 ArrayList ,在读多写少的场合性能非常好,远远好于 Vector 。
- ConcurrentLinkedQueue :高效的并发队列,使用链表实现。可以看做⼀个线程安全的LinkedList ,这是⼀个非阻塞队列。
- BlockingQueue : 这是⼀个接口,JDK 内部通过链表、数组等方式实现了这个接口。表示阻塞队列,非常适合用于作为数据共享的通道。
- ConcurrentSkipListMap :跳表的实现。这是⼀个 Map ,使用跳表的数据结构进行快速查找。
Collection 子接口之 List
Arraylist 和 Vector 的区别?
- ArrayList 是 List 的主要实现类,底层使用 Object[ ]存储,适用于频繁的查找工作,线程不安全 ;
- Vector 是 List 的古老实现类,底层使用 Object[ ]存储,线程安全的。
Arraylist 与 LinkedList 区别?
- 是否保证线程安全: ArrayList 和 LinkedList 都是不同步的,也就是不保证线程安全;
- 底层数据结构: Arraylist 底层使用的是 Object 数组; LinkedList 底层使用的是 双向链表 数据结构(JDK1.6 之前为循环链表,JDK1.7 取消了循环。)
- 插⼊和删除是否受元素位置的影响: ① ArrayList 采用数组存储,所以插入和删除元素的时间复杂度受元素位置的影响。 比如:执行 add(E e) 方法的时候, ArrayList 会默认在将指定的元素追加到此列表的末尾,这种情况时间复杂度就是 O(1)。但是如果要在指定位置 i插入和删除元素的话( add(int index, E element) )时间复杂度就为 O(n-i)。因为在进行上述操作的时候集合中第 i 和第 i 个元素之后的(n-i)个元素都要执行向后位/向前移⼀位的操作。 ② LinkedList 采用链表存储,所以对于 add(E e) 方法的插入,删除元素时间复杂度不受元素位置的影响,近似 O(1),如果是要在指定位置 i 插入和删除元素的话( (add(intindex, E element) ) 时间复杂度近似为 o(n)) 因为需要先移动到指定位置再插入。
- 是否支持快速随机访问: LinkedList 不支持高效的随机元素访问,而ArrayList 支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于 get(int index) 方法)。
- 内存空间占用: ArrayList 的空间浪费主要体现在在 list 列表的结尾会预留⼀定的容量空间,而 LinkedList 的空间花费则体现在它的每⼀个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。
说⼀说 ArrayList 的扩容机制吧
先从 ArrayList 的构造函数说起
ArrayList有三种方式来初始化,构造方法源码如下:
/**
* 默认初始容量大小
*/
private static final int DEFAULT_CAPACITY = 10;
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
/**
*默认构造函数,使用初始容量10构造一个空列表(无参数构造)
*/
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
/**
* 带初始容量参数的构造函数。(用户自己指定容量)
*/
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {//初始容量大于0
//创建initialCapacity大小的数组
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {//初始容量等于0
//创建空数组
this.elementData = EMPTY_ELEMENTDATA;
} else {//初始容量小于0,抛出异常
throw new IllegalArgumentException("Illegal Capacity: "+
initialCapacity);
}
}
/**
*构造包含指定collection元素的列表,这些元素利用该集合的迭代器按顺序返回
*如果指定的集合为null,throws NullPointerException。
*/
public ArrayList(Collection<? extends E> c) {
elementData = c.toArray();
if ((size = elementData.length) != 0) {
// c.toArray might (incorrectly) not return Object[] (see 6260652)
if (elementData.getClass() != Object[].class)
elementData = Arrays.copyOf(elementData, size, Object[].class);
} else {
// replace with empty array.
this.elementData = EMPTY_ELEMENTDATA;
}
}
细心的同学一定会发现 :以无参数构造方法创建 ArrayList 时,实际上初始化赋值的是一个空数组。当真正对数组进行添加元素操作时,才真正分配容量。即向数组中添加第一个元素时,数组容量扩为10。
一步一步分析 ArrayList 扩容机制
这里以无参构造函数创建的 ArrayList 为例分析
1先来看 add 方法
/**
* 将指定的元素追加到此列表的末尾。
*/
public boolean add(E e) {
//添加元素之前,先调用ensureCapacityInternal方法
ensureCapacityInternal(size + 1); // Increments modCount!!
//这里看到ArrayList添加元素的实质就相当于为数组赋值
elementData[size++] = e;
return true;
}
2再来看看 ensureCapacityInternal() 方法
可以看到 add 方法 首先调用了ensureCapacityInternal(size + 1)
//得到最小扩容量
private void ensureCapacityInternal(int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
// 获取默认的容量和传入参数的较大值
minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity);
}
ensureExplicitCapacity(minCapacity);
}
当 要 add 进第1个元素时,minCapacity为1,在Math.max()方法比较后,minCapacity 为10。
3ensureExplicitCapacity() 方法
如果调用 ensureCapacityInternal() 方法就一定会执行这个方法,下面我们来研究一下这个方法的源码!
//判断是否需要扩容
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// overflow-conscious code
if (minCapacity - elementData.length > 0)
//调用grow方法进行扩容,调用此方法代表已经开始扩容了
grow(minCapacity);
}
我们来仔细分析一下:
- 当我们要 add 进第1个元素到 ArrayList 时,elementData.length 为0 (因为还是一个空的 list),因为执行了 ensureCapacityInternal() 方法 ,所以 minCapacity 此时为10。此时,minCapacity - elementData.length > 0 成立,所以会进入 grow(minCapacity) 方法。
- 当add第2个元素时,minCapacity 为2,此时elementData.length(容量)在添加第一个元素后扩容成 10 了。此时,minCapacity - elementData.length > 0 不成立,所以不会进入 (执行)grow(minCapacity) 方法。
- 添加第3、4···到第10个元素时,依然不会执行grow方法,数组容量都为10。
直到添加第11个元素,minCapacity(为11)比elementData.length(为10)要大。进入grow方法进行扩容。
4grow() 方法
/**
* 要分配的最大数组大小
*/
private static final int MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
/**
* ArrayList扩容的核心方法。
*/
private void grow(int minCapacity) {
// oldCapacity为旧容量,newCapacity为新容量
int oldCapacity = elementData.length;
//将oldCapacity 右移一位,其效果相当于oldCapacity /2,
//我们知道位运算的速度远远快于整除运算,整句运算式的结果就是将新容量更新为旧容量的1.5倍,
int newCapacity = oldCapacity + (oldCapacity >> 1);
//然后检查新容量是否大于最小需要容量,若还是小于最小需要容量,那么就把最小需要容量当作数组的新容量,
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
// 如果新容量大于 MAX_ARRAY_SIZE,进入(执行) `hugeCapacity()` 方法来比较 minCapacity 和 MAX_ARRAY_SIZE,
//如果minCapacity大于最大容量,则新容量则为`Integer.MAX_VALUE`,否则,新容量大小则为 MAX_ARRAY_SIZE 即为 `Integer.MAX_VALUE - 8`。
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
int newCapacity = oldCapacity + (oldCapacity >> 1),所以 ArrayList 每次扩容之后容量都会变为原来的 1.5 倍左右(oldCapacity为偶数就是1.5倍,否则是1.5倍左右)! 奇偶不同,比如 :10+10/2 = 15, 33+33/2=49。如果是奇数的话会丢掉小数.
5hugeCapacity() 方法
从上面 grow() 方法源码我们知道: 如果新容量大于 MAX_ARRAY_SIZE,进入(执行) hugeCapacity() 方法来比较 minCapacity 和 MAX_ARRAY_SIZE,如果minCapacity大于最大容量,则新容量则为Integer.MAX_VALUE,否则,新容量大小则为 MAX_ARRAY_SIZE 即为 Integer.MAX_VALUE - 8。
private static int hugeCapacity(int minCapacity) {
if (minCapacity < 0) // overflow
throw new OutOfMemoryError();
//对minCapacity和MAX_ARRAY_SIZE进行比较
//若minCapacity大,将Integer.MAX_VALUE作为新数组的大小
//若MAX_ARRAY_SIZE大,将MAX_ARRAY_SIZE作为新数组的大小
//MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8;
return (minCapacity > MAX_ARRAY_SIZE) ?
Integer.MAX_VALUE :
MAX_ARRAY_SIZE;
}
Collection 子接口之 Set
comparable 和 Comparator 的区别
- comparable 接口实际上是出自 java.lang 包,它有⼀个 compareTo(Object obj) 方法用来排序;
- comparator 接口实际上是出自 java.util 包,它有⼀个 compare(Object obj1, Object obj2) 方法用来排序。
⼀般我们需要对⼀个集合使用自定义排序时,我们就要重写 compareTo() 方法或 compare() 方法,当我们需要对某⼀个集合实现两种排序方式,比如⼀个 song 对象中的歌名和歌⼿名分别采用⼀种排序方法的话,我们可以重写 compareTo() 方法和使用自制的 Comparator 方法或者以两个 Comparator来实现歌名排序和歌星名排序,第⼆种代表我们只能使用两个参数版的 Collections.sort() .
比较 HashSet、LinkedHashSet 和 TreeSet 三者的异同
- HashSet 是 Set 接口的主要实现类 ,HashSet 的底层是 HashMap,线程不安全的,可以存储 null值;
- LinkedHashSet 是 HashSet 的子类,能够按照添加的顺序遍历;
- TreeSet 底层使用红⿊树,能够按照元素的值的顺序进行遍历,排序的方式有自然排序和定制排序。
Map 接口
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 的幂次方大小。
- 底层数据结构: JDK1.8 以后的 HashMap 在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。
ConcurrentHashMap
- 底层数据结构: JDK1.7 的 ConcurrentHashMap 底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟 HashMap1.8 的结构⼀样,数组+链表/红黑二叉树。
- 实现线程安全的方式(重要): ① 在 JDK1.7 的时候,ConcurrentHashMap(分段锁) 对整个桶数组进行了分割分段(Segment),每⼀把锁只锁容器其中⼀部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。 到了 JDK1.8 的时候已经摒弃了Segment 的概念,而是直接用 Node 数组+链表+红黑树的数据结构来实现,并发控制使用synchronized 和 CAS 来操作。(JDK1.6 以后 对 synchronized 锁做了很多优化) 整个看起来就像是优化过且线程安全的HashMap,虽然在 JDK1.8 中还能看到 Segment 的数据结构,但是已经简化了属性,只是为了兼容旧版本;
JDK1.7 的 ConcurrentHashMap:
JDK1.8 的 ConcurrentHashMap:
ConcurrentHashMap 线程安全的具体实现方式/底层具体实现
JDK1.7(上面有示意图)
首先将数据分为⼀段⼀段的存储,然后给每⼀段数据配⼀把锁,当⼀个线程占用锁访问其中⼀个段数据时,其他段的数据也能被其他线程访问。
ConcurrentHashMap 是由 Segment 数组结构和 HashEntry 数组结构组成。
Segment 实现了 ReentrantLock,所以 Segment 是⼀种可重入锁,扮演锁的角色。HashEntry 用于存储键值对数据。
static class Segment<K,V> extends ReentrantLock implements Serializable
{}
⼀个 ConcurrentHashMap ⾥包含⼀个 Segment 数组。Segment 的结构和 HashMap 类似,是⼀种数组和链表结构,⼀个 Segment 包含⼀个HashEntry 数组,每个 HashEntry 是⼀个链表结构的元素,每个 Segment 守护着⼀个 HashEntry 数组⾥的元素,当对 HashEntry 数组的数据进行修改时,必须首先获得对应的 Segment 的锁。
JDK1.8 (上面有示意图)
ConcurrentHashMap 取消了 Segment 分段锁,采用 CAS 和 synchronized 来保证并发安全。数据结构跟 HashMap1.8 的结构类似,数组+链表/红黑⼆叉树。Java 8 在链表长度超过⼀定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))。synchronized 只锁定当前链表或红黑⼆叉树的首节点,这样只要 hash 不冲突,就不会产生并发,效率又提升 N 倍。