java集合复习和提高
java 集合面试
一.各类集合框架的数据结构总结
1.Collection接口下的集合
1.1List接口
Arraylist
:Object[]
数组Vector
:Object[]
数组LinkedList
: 双向链表(JDK1.6 之前为循环链表,JDK1.7 取消了循环)
1.2Set接口
HashSet
(无序,唯一): 基于HashMap
实现的,底层采用HashMap
来保存元素LinkedHashSet
:LinkedHashSet
是HashSet
的子类,并且其内部是通过LinkedHashMap
来实现的。有点类似于我们之前说的LinkedHashMap
其内部是基于HashMap
实现一样,不过还是有一点点区别的TreeSet
(有序,唯一): 红黑树(自平衡的排序二叉树)
2.Map接口下的集合
2.1HashMap
- JDK1.8 之前
HashMap
由数组+链表组成的,数组是HashMap
的主体,链表则是主要为了解决哈希冲突而存在的(“拉链法”解决冲突)。JDK1.8 以后在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间
2.2LinkedHashMap
LinkedHashMap
:LinkedHashMap
继承自HashMap
,所以它的底层仍然是基于拉链式散列结构即由数组和链表或红黑树组成。另外,LinkedHashMap
在上面结构的基础上,增加了一条双向链表,使得上面的结构可以保持键值对的插入顺序。同时通过对链表进行相应的操作,实现了访问顺序相关逻辑
2.3TreeMap
TreeMap
: 红黑树(自平衡的排序二叉树)
3.集合的选用
3.1为什么使用集合
-
当我们需要保存一组类型相同的数据的时候,我们应该是用一个容器来保存,这个容器就是数组,但是,使用数组存储对象具有一定的弊端。
(数组和集合的区别)
- 数组只能存放一种数据类型的数据,且数组存储的数据是有序的、可重复的,特点单一
- 集合提高了数据存储的灵活性,Java 集合不仅可以用来存储不同类型不同数量的对象,还可以保存具有映射关系的数据。
- 数组长度固定,集合长度不固定
- 数组可以存储基本类型和引用类型,集合只能存储引用类型
3.2如何选用集合
-
主要根据集合的特点来选用,比如我们需要根据键值获取到元素值时就选用
Map
接口下的集合,需要排序时选择TreeMap
,不需要排序时就选择HashMap
,需要保证线程安全就选用ConcurrentHashMap
。- 对于在Map中插入、删除和定位元素这类操作,HashMap是最好的选择。然而,假如你需要对一个有序的key集合进行遍历,TreeMap是更好的选择。
当我们只需要存放元素值时,就选择实现
Collection
接口的集合,需要保证元素唯一时选择实现Set
接口的集合比如TreeSet
或HashSet
,不需要就选择实现List
接口的比如ArrayList
或LinkedList
,然后再根据实现这些接口的集合的特点来选用。
二.Collection的子接口List
1.ArrayList、Vector、LinkedList
1.1ArrayList、Vector区别
- ArrayList、Vector存储结构都是数组,ArrayList是线程不安全的,Vector是线程安全的
1.2.ArrayList、LinkedList区别
- 都是线程不安全
- .ArrayList存储结构是数组,LinkedList存储结构是链表
- 是否支持快速随机访问:
LinkedList
不支持高效的随机元素访问,而ArrayList
支持。快速随机访问就是通过元素的序号快速获取元素对象(对应于get(int index)
方法)。 - 内存空间占用: ArrayList 的空 间浪费主要体现在在 list 列表的结尾会预留一定的容量空间,而 LinkedList 的空间花费则体现在它的每一个元素都需要消耗比 ArrayList 更多的空间(因为要存放直接后继和直接前驱以及数据)。
- 链表:
- 增删快是因为,可以直接改变指针指向,如可以让B.next---->D,D.previous----->B从而达到删除C的效果
- 查询慢是因为,链表开辟的空间不是连续的,不能像数组一样定位,只能通过指针指到需要的数据 。(链表没有下标的概念, 只能是对总数遍历,然后取循环下标的值)
- 链表:
2.ArrayList的源码分析
-
默认容量DEFAULT_CAPACITY=10
-
存放元素的数组elementDate
-
以无参数构造方法创建 ArrayList 时,实际上初始化赋值的是一个空数组。当真正对数组进行添加元素操作时,才真正分配容量。即向数组中添加第一个元素时,数组容量扩为 10。
-
每次扩容的大小为原来的1.5倍
三.Collection的子接口Set
1.HashSet和TreeSet区别
-
HashSet类
- 基于HashCode计算元素存放的位置
- 当存入的元素的哈希码值相同时,会调用equals进行确认,如结果为true,则拒绝后者进入,元素不重复
- 存储结构:哈希表(数组+链表)
-
TreeSet类
-
基于排列顺序实现元素不重复
-
实现了SortedSet接口
-
元素对象的类型必须实现Comparable接口,然后根据CompareTo()方法指定排序规则
- 或者不实现Comparable接口,使用Comparator:实现定制比较器,来创建集合,并指定比较规则
-
通过CompareTo方法确定是否为重复元素
-
存储结构:红黑树
-
TreeSet集合的存储结构是红黑树,存放数据需要比较然后排序,然后才放入集合中,需要告诉其怎么进行排序。数据能放入集合、要实现排序必须要用到Comparable接口
若compareTo()返回值为0--->则为重复,不能放入集合
-
2.无序性和不可重复性的含义是什么
-
什么是无序性?
- 无序性不等于随机性 ,无序性是指存储的数据在底层数组中并非按照数组索引的顺序添加 ,而是根据数据的哈希值决定的(HashSet)或者根据CompareTo()方法制定的排序规则决定的(TreeSet)
-
什么是不可重复性?
- 在使用 HashSet时,不可重复性是指添加的元素按照 equals()判断时 ,返回 false,代表重复,无法加入集合,很多时候都需要同时重写 equals()方法和 HashCode()方法。
- 在使用 TreeSet时,不可以重复性是指添加的元素根据CompareTo(),返回值为0,代表重复,无法加入集合
四.Map接口下的集合
1.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 倍。- 需要注意:当容量达到默认加载因子所要求的阈值(16*0.75)时,就扩容,每次为上一次的2倍
-
②创建时如果给定了容量初始值,那么 Hashtable 会直接使用你给定的大小,而
HashMap
会将其扩充为 2 的幂次方大小(HashMap
中的tableSizeFor()
方法保证,下面给出了源代码)。也就是说HashMap
总是使用 最接近该容量初始值的2 的幂作为哈希表的大小import java.util.HashMap; public class HashMapDemo { //实现了把一个数变为最接近的2的n次方 public static void main(String[] args) { int cap=5; int result=tableSizeFor(cap);//8 System.out.println(result); } static int tableSizeFor(int cap) { int n = cap - 1; // >>>:无符号右移。无论是正数还是负数,高位通通补0。 n |= n >>> 1;// 00000100 | 00000010 = 00000110 6 n |= n >>> 2; // 00000110 | 00000011 = 00000111 7 n |= n >>> 4; // 00000111 | 00000000 = 00000111 7 n |= n >>> 8; // 00000111 | 00000000 = 00000111 7 n |= n >>> 16;// 00000111 | 00000000 = 00000111 7 return n + 1;// 00001000 =8 //原来是00000100,变成了00000111,最后加1,就变成2的整数次方数00001000 } }
-
-
底层数据结构: JDK1.8 以后的
HashMap
在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。Hashtable 没有这样的机制。
2.HashMap和HashSet的区别
HashSet
底层就是基于HashMap
实现的,!!HashSet其实就是用了HashMap的key!!-
HashMap
HashSet
实现了 Map
接口实现 Set
接口存储键值对 仅存储对象 调用 put()
向 map 中添加元素调用 add()
方法向Set
中添加元素HashMap
使用键(Key)计算hashcode
值HashSet
使用成员对象来计算hashcode
值,对于两个对象来说hashcode
可能相同,所以equals()
方法用来判断对象的相等性
3.HashMap和TreeMap
3.1HashMap和TreeMap的区别
-
TreeMap
和HashMap
都继承自AbstractMap
-
但是需要注意的是
TreeMap
它还实现了NavigableMap
接口和SortedMap
接口。-
实现
NavigableMap
接口让TreeMap
有了对集合内元素的搜索的能力。 -
实现
SortMap
接口让TreeMap
有了对集合中的元素根据键排序的能力。默认是按 key 的升序排序,不过我们也可以指定排序的比较器。(可以使用Comparator:实现定制比较器,来创建集合,并指定比较规则,如果key值为对象,也可以让该对象的类实现Comparable接口,利用CompareTo()方法来制定比较规则)
-
3.2HashMap和TreeMap的选用
- 对于在Map中插入、删除和定位元素这类操作,HashMap是最好的选择。然而,假如你需要对一个有序的key集合进行遍历,TreeMap是更好的选择。
4.HashMap的底层实现
4.1JDK1.8之前
-
JDK1.8 之前
HashMap
底层是 数组和链表 结合在一起使用也就是 链表散列。HashMap 通过 key 的 hashCode 经过扰动函数处理过后得到 hash 值,然后通过 (n - 1) & hash 判断当前元素存放的位置(这里的 n 指的是数组的长度),如果当前位置存在元素的话,就判断该元素与要存入的元素的 hash 值以及 key 是否相同,如果相同key相同(hash值也相同)的话,将键值对的value直接覆盖,key不相同,但是hash值相同,就通过拉链法解决冲突。-
为什么HashMap的数组长度为2的n次方
-
①刚开始是想通过对hash值取余(%)操作,判断元素的位置,后来发现采用二进制位操作 &,相对于%能够提高运算效率,而 hash%length==hash&(length-1)的前提是 length 是 2 的 n 次方
-
②需要通过(n - 1) & hash 判断当前元素存放的位置(这里的 n 指的是数组的长度),对该数组长度n-1转出二进制可以得到,最高位为0,低位都为1,然后与hash的位相与,就可以完整得到hash的低位值,不会受到与运算对数据变化的影响。
-
③数组长度n不为
2的n次幂
的话,n-1对应的二进制数肯定有一位为0
,这样,不管你的hashCode
值对应的该位,是0
还是1
,最终得到的该位上的数肯定是0
,这带来的问题就是HashMap
上的数组元素分布不均匀,而数组上的某些位置,永远也用不到,这也就会造成了碰撞率增高,哈希冲突增加,导致链表加深(比如8-1转化为二进制为0111,再比如6-1转化为二进制为0101)
-
-
扰动函数指的就是 HashMap 的 hash 方法。使用 hash 方法也就是扰动函数是为了防止一些实现比较差的 hashCode() 方法,得到足够散列的Hash值 换句话说使用扰动函数之后可以减少碰撞。
-
static final int hash(Object key) { int h; // key.hashCode():返回散列值也就是hashcode // ^ :按位异或 // >>>:无符号右移,忽略符号位,空位都以0补齐 return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }
-
-
若两个不相等的
key
产生了相等的哈希值
,这时则需要采用 哈希冲突。 -
拉链法 的实现比较简单,将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。
-
4.2JDK1.8之后
HashMap
在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)时,将链表转化为红黑树,以减少搜索时间。
5.HashMap常见的遍历方式
5.1遍历分类
- HashMap 遍历从大的方向来说,可分为以下 4 类:
- 迭代器(Iterator)方式遍历;
- For Each 方式遍历;
- Lambda 表达式遍历(JDK 1.8+);
- Streams API 遍历(JDK 1.8+)。
5.2迭代器和for Each方式遍历
// 迭代器 entrySet
Iterator<Map.Entry<Integer, String>> iterator = map.entrySet().iterator();
while (iterator.hasNext()) {
Map.Entry<Integer, String> entry = iterator.next();
//hasNext();判断有没有下一个元素
//next();获取下一个元素
System.out.print(entry.getKey());
System.out.print(entry.getValue());
}
//迭代器 KeySet
Iterator<Integer> iterator = map.keySet().iterator();
while (iterator.hasNext()) {
Integer key = iterator.next();
System.out.print(key);
System.out.print(map.get(key));
}
// for each entrySet
for (Map.Entry<Integer, String> entry : map.entrySet()) {
System.out.print(entry.getKey());
System.out.print(entry.getValue());
}
// for each keySet
for (Integer key : map.keySet()) {
System.out.print(key);
System.out.print(map.get(key));
}
- 通过迭代器循环和
for
循环的遍历的entrySet
最终生成的代码是一样的,他们都是在循环中创建了一个遍历对象Entry
- 通过迭代器循环和
for
循环的遍历的keySet
最终生成的代码是一样的,他们都是在循环中创建了一个map集合中key的类型对象
5.3.Lambda表达式和Streams API 遍历
//Lambda表达式遍历
map.forEach((key, value) -> {
System.out.print(key);
System.out.print(value);
});
//Streams API遍历
map.entrySet().parallelStream().forEach((entry) -> {
System.out.print(entry.getKey());
System.out.print(entry.getValue());
});
5.4遍历方式的比较
- 1)性能分析:并行循环的
parallelStream
性能比极高之外(多线程方式性能肯定比较高),其他方式的遍历方法在性能方面几乎没有任何差别。- 但从简洁性和优雅性上来看,Lambda 和 Stream 无疑是最适合的遍历方式。
- 2)安全性能分析:在遍历中使用集合
map.remove()
来删除数据,这是非安全的操作方式- 应该使用迭代器提供的
iterator.remove()
方法来进行删除,这种方式是安全的在遍历中删除集合的方式,或者使用 Stream 中的filter
过滤掉要删除的数据再进行循环,也是安全的操作方式。 - 可以使用 Lambda 中的
removeIf
来在遍历前提前删除数据,也可以在for
循环(遍历)前删除数据,这样在遍历时也是线程安全的。
- 应该使用迭代器提供的
6.ConcurrentHashMap 和 Hashtable 的区别
-
1)底层数据结构不同
- JDK1.7 的
ConcurrentHashMap
底层采用 分段的数组(Segment 数组 + HashEntry 数组 )+链表 实现 - JDK1.8 采用的数据结构跟
HashMap1.8
的结构一样,数组+链表/红黑二叉树。 Hashtable
采用 数组+链表 的形式,数组是 HashMap 的主体,链表则是主要为了解决哈希冲突而存在的;
- JDK1.7 的
-
2)实现线程安全的方式不同
-
①
-
在 JDK1.7 的时候,
ConcurrentHashMap
(分段锁) 对整个桶数组进行了分割分段(Segment
),每一把锁只锁容器其中一部分数据,多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。-
ConcurrentHashMap
是由Segment
数组结构和HashEntry
数组结构组成。 -
Segment 实现了
ReentrantLock
(可重入锁),所以Segment
是一种可重入锁,扮演锁的角色。同时它存放元素 HashEntry,HashEntry
用于存储键值对数据。
static class Segment<K,V> extends ReentrantLock implements Serializable { }
- 一个
ConcurrentHashMap
里包含一个Segment
数组。Segment
的结构和HashMap
类似,是一种数组和链表结构,一个Segment
包含一个HashEntry
数组,每个HashEntry
是一个链表结构的元素,每个Segment
守护着一个HashEntry
数组里的元素,当对HashEntry
数组的数据进行修改时,必须首先获得对应的Segment
的锁。
-
-
到了 JDK1.8 的时候已经摒弃了
Segment
的概念,而是直接用Node
数组+链表+红黑树的数据结构来实现,并发控制使用synchronized
和 CAS 来操作。
将锁的级别控制在了更细粒度的哈希桶数组元素级别,也就是说只需要锁住这个链表头节点(红黑树的根节点),就不会影响其他的哈希桶数组元素的读写,大大提高了并发度。
在链表长度超过一定阈值(8)时将链表(寻址时间复杂度为 O(N))转换为红黑树(寻址时间复杂度为 O(log(N)))
-
JDK1.8 中为什么使用内置锁 synchronized替换 可重入锁 ReentrantLock?
- (JDK1.6 以后 对
synchronized
锁做了很多优化) 整个看起来就像是优化过且线程安全的HashMap
,而且synchronized 有多种锁状态,会从无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁一步步转换。 - 减少内存开销 。假设使用可重入锁来获得同步支持,那么每个节点都需要通过继承 AQS 来获得同步支持。但并不是每个节点都需要获得同步支持的,只有链表的头节点(红黑树的根节点)需要同步,这无疑带来了巨大内存浪费。
- (JDK1.6 以后 对
-
-
②
Hashtable
(同一把锁) :使用synchronized
来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
-
7.ConcurrentHashMap的底层分析
7.1 JDK1.7 ConcurrentHashMap的put方法的流程
-
1)计算要 put 的 key 的位置,获取指定位置的 Segment。
-
利用key的hash值通过一系列操作(取key的hash值的高位与segment的段掩码相与,得到指定位置的segment)
-
如果指定位置的 Segment 为空,则初始化这个 Segment.
- 初始化 Segment 流程:
- ①检查计算得到的位置的 Segment 是否为null.
- ②如果为 null 则对该位置的segment继续初始化,利用新创建的Segment[0] 的容量、负载因子和扩容阀值(容量*负载因子) 创建一个 HashEntry 数组。
- 初始化 segments[0],默认大小为 2,负载因子 0.75,扩容阀值是 2*0.75=1.5,插入第二个值时才会进行扩容。
- ③再次检查计算得到的指定位置的 Segment 是否为null.(再次检查 u 位置的 Segment 是否为null,因为这时可能有其他线程进行了操作)
- ④ (使用创建的 HashEntry 数组初始化这个 Segment.????)
- ⑤ 自旋判断计算得到的指定位置的 Segment 是否为null,使用 CAS 在为这个位置 Segment赋值
- 初始化 Segment 流程:
-
-
2)Segment.put 插入 key,value 值。
-
由于 Segment 继承了 ReentrantLock,所以 Segment 内部可以很方便的获取锁,put 流程就用到了这个功能。
-
利用tryLock() 来获取独占锁,获取不到则使用
scanAndLockForPut
方法继续获取。scanAndLockForPut
方法就是:不断的自旋tryLock()
获取锁。当自旋次数大于指定次数时,使用lock()
阻塞直到获取锁。(在自旋时,顺表获取 hash 位置的 HashEntry。)
-
计算 put 的数据要放入的 index 位置,然后获取这个位置上的 HashEntry 。
- 和HashMap得到位置的方式相同:(tab.length - 1) & hash(tab.length为HashEntry数组的长度)
-
遍历 put 新元素,为什么要遍历?因为这里获取的 HashEntry 可能是一个空元素,也可能是链表已存在,所以要区别对待。
-
默认HashEntry数组的长度(容量)为2,每一次扩容*2
-
如果这个位置上的 HashEntry 不存在:
①如果当前容量大于扩容阀值,小于最大容量,进行扩容。
②直接头插法插入。
- 如果这个位置上的 HashEntry 存在:
①判断链表当前元素 Key 和 hash 值是否和要 put 的 key 和 hash 值一致。一致则替换值
②不一致(出现哈希冲突),获取链表下一个节点,直到发现相同进行值替换,或者链表表里完毕没有相同的。
- 如果当前容量大于扩容阀值,小于最大容量,进行扩容。
- 直接链表头插法插入。
-
-
如果要插入的位置之前已经存在,替换后返回旧值,否则返回 null.
-
-
7.2 JDK1.7 ConcurrentHashMap的get方法的流程
-
计算得到 key 的存放位置。(根据上面的put方法的步骤)
-
遍历指定位置查找相同 key 的 value 值,利用equals方法。(由于底层结构是Segment+HashEntry数组+链表,链表查询需要遍历)
ConcurrentHashMap的get方法需不需要加锁?
-
注意:由于 HashEntry 涉及到的共享变量(value、指针next)都使用 volatile 修饰,volatile 可以保证内存可见性,所以每次获取时都是最新值,不需要加锁。
7.3 JDK1.8 ConcurrentHashMap的put方法的流程
-
根据 key 计算出 hashcode (定位存入数组桶的位置)。
-
判断是否需要进行初始化。(数组桶为空,初始化数组桶(自旋+CAS))
-
定位到 Node,拿到首节点 f,判断首节点 f:
-
如果为 null ,说明当前位置可以写入数据,则通过 CAS 的方式尝试添加;
-
如果为 f.hash = MOVED = -1 ,说明其他线程在扩容,参与一起扩容;
-
如果都不满足 ,利用synchronized 锁住 f 节点,判断是链表还是红黑树,然后写入数据
- 如果数量大于
TREEIFY_THRESHOLD
(默认为8) 则要转换为数组扩容或者红黑树
- 如果数量大于
-
7.4 JDK1.8 ConcurrentHashMap的get方法的流程
-
根据 hash 值计算位置。
-
查找到指定位置,如果头节点就是要找的,直接返回它的 value.
-
如果头节点 hash 值小于 0 ,说明正在扩容或者是链表转换成红黑树,查找之。
-
如果是链表,遍历查找之。
-
如果是红黑树,直接在里面查找
get 方法不需要加锁。因为 Node 的元素 value 和指针 next 是用 volatile 修饰的,在多线程环境下线程A修改节点的 value 或者新增节点的时候是对线程B可见的。