java面试系列<2>——java容器
1、概览
容器主要包括Collection和Map两种,Collection存储着对象的集合,而map存储着键值对(两个对象)的映射表
Collection
1、set
- TreeSet:基于红黑树实现,支持有序操作,。查找效率不如HashSet,HashSet查找的时间复杂度为O(1),TreeSet则为O(logN)。
- HashSet:基于哈希表实现,支持快速查找,但不支持有序性操作。并且失去元素的插入顺序信息,即遍历HashSet时候得到的结果是不确定的。
- LinkeHashSet:具有HashSet的查找效率,并且内部使用双向链表维护元素的插入顺序。
2、List
- ArrayList:基于动态数组实现,支持随机访问
- Vector:和ArrayList类型,但它是线程安全的。
- LinkedList:基于双向链表实现,只能顺序访问,但是可以快速地在链表中间插入和删除元素。不仅如此,LinkedList还可以用作栈、队列和双向队列。
3、Queue
- LinkedList:可以用它来实现双向队列
- PriorityQueue:基于堆结构实现,可以用它来实现优先队列。
Map
- TreeMap:基于红黑树实现。
- HashMap:基于哈希表实现。
- HashTable:和HashMap类似,但它是线程安全的,这意味着同一时刻多个县城同时写入HashTable不会导致数据不一致。遗留类,不应该使用,而是使用 ConcurrentHashMap 来支持线程安全,ConcurrentHashMap 的效率会更高,因为 ConcurrentHashMap 引入了分段锁。
- LinkedHashMap:使用双向链表来维护元素的顺序,顺序为插入顺序或者最近最少使用(LRU)顺序。
2、设计模式
迭代器模式:
Collection继承了Iterable接口,其中的iterator()方法能够产生一个Iterator对象,通过这个对象就可以迭代遍历Collection中的元素。
适配器模式
java.Util.Arrays.asList()可以把数组类型转换为List类型。
应该注意的是 asList() 的参数为泛型的变长参数,不能使用基本类型数组作为参数,只能使用相应的包装类型数组。
3、源码分析
ArrayList
1、概述
因为ArrayList是基于数组实现的,所以支持快速随机访问。
数组的默认大小为10.
private static final int DEFAULT_CAPACITY = 10;
2、扩容
添加元素时候使用ensureCapacityInternal()方法来保证容量足够,需要使用grow()方法进行扩容,新容量的大小为(oldCapacity+oldCapacity/2)。因此,新容量大约是旧容量的1.5倍左右。 (oldCapacity 为偶数就是 1.5 倍,为奇数就是 1.5 倍-0.5)。
扩容时候,需要调用操作Arrays.copyOf()把原数组整个复制到新数组中,整个操作代价高,因此最好在创建ArrayList对象时候就指定大概的容量大小,减少扩容次数。
3、删除元素
需要调用System.arraycopy()将index+1后面的元素都复制到index位置上,该操作的时间复杂度是O(N),因此ArrayList删除元素的代价是很高的。
Vector
它的实现与ArrayList类似,但是使用了synchronized进行同步。
public synchronized boolean add(E e) {
modCount++;
ensureCapacityHelper(elementCount + 1);
elementData[elementCount++] = e;
return true;
}
public synchronized E get(int index) {
if (index >= elementCount)
throw new ArrayIndexOutOfBoundsException(index);
return elementData(index);
}
2、扩容
Vector的可以传入 capacityIncrement 参数,可以使在扩容时使容量增长 capacityIncrement 。如果这个参数值小于或等于0,扩容时每次扩容两倍。
3、与ArrayList的比较
- Vector是同步的,因此开销就比ArrayList大,访问速度更慢。最好使用ArrayList而不是Vector,因为同步操作可以由程序员控制。
- Vector每次扩容请求其大小2倍,而ArrayList是1.5倍。
4、替代方案
可以使用Collections.synchronizedList();得到一个线程安全的ArrayList
List<String> list = new ArrayList<>();
List<String> synList = Collections.synchronizedList(list);
List<String> list = new CopyOnWriteArrayList<>();
CopyOnWriteArrayList
1、读写分离
写操作在一个复制的数组上进行,读操作是在原始数组,读写分离,互不影响。
写操作需要加锁,防止并发写入时导致写入数据丢失
写操作结束后需要把原始数组指向新的复制数组
2、适用场景
CopyOnWriteArrayList 在写操作的同时允许读操作,大大提高了读操作的性能,因此很适合读多写少的应用场景。
缺陷
- 内存占用:在写操作的同时需要复制一个新的数组,内存占用为原来的两倍
- 数据不一致:读操作不能读取实时性的数据,因为部分写操作的数据还未同步到读数组中。
所以CopyOnWriteArrayList不适合内存敏感以及对实时性要求很高的场景。
LinkedList
基于双向链表实现,使用Node存储链表节点信息。
与ArrayList比较
ArrayList基于动态数组实现,LinkedList基于双向链表实现。ArrayList和LinkedList的区别可以归结为数组和链表的区别:
- 数组支持随机访问,但插入删除的代价很高,需要移动大量元素
- 链表不支持随机访问,但插入删除只需要改变指针。
HashMap
内部包含了一个 Entry 类型的数组 table。Entry 存储着键值对。它包含了四个字段,从 next 字段我们可以看出 Entry 是一个链表。即数组中的每个位置被当成一个桶,一个桶存放一个链表。HashMap 使用拉链法来解决冲突,同一个链表中存放哈希值和散列桶取模运算结果相同的 Entry。
2、拉链法工作原理
- 新建一个 HashMap,默认大小为 16;
- 插入 <K1,V1> 键值对,先计算 K1 的 hashCode 为 115,使用除留余数法得到所在的桶下标 115%16=3。
- 插入 <K2,V2> 键值对,先计算 K2 的 hashCode 为 118,使用除留余数法得到所在的桶下标 118%16=6。
- 插入 <K3,V3> 键值对,先计算 K3 的 hashCode 为 118,使用除留余数法得到所在的桶下标 118%16=6,插在 <K2,V2> 前面。
应该注意到链表的插入是以头插法方式进行的,例如上面的 <K3,V3> 不是插在 <K2,V2> 后面,而是插入在链表头部。
查找需要分成两步进行:
- 计算键值对所在的桶;
- 在链表上顺序查找,时间复杂度显然和链表的长度成正比。
HashMap 允许插入键为 null 的键值对。但是因为无法调用 null 的 hashCode() 方法,也就无法确定该键值对的桶下标,只能通过强制指定一个桶下标来存放。HashMap 使用第 0 个桶存放键为 null 的键值对。
3、扩容
设 HashMap 的 table 长度为 M,需要存储的键值对数量为 N,如果哈希函数满足均匀性的要求,那么每条链表的长度大约为 N/M,因此查找的复杂度为 O(N/M)。
为了让查找的成本降低,应该使 N/M 尽可能小,因此需要保证 M 尽可能大,也就是说 table 要尽可能大。HashMap 采用动态扩容来根据当前的 N 值来调整 M 值,使得空间效率和时间效率都能得到保证。
4、扩容-重新计算桶下标
在进行扩容时,需要把键值对重新计算桶下标,从而放到对应的桶上。在前面提到,HashMap 使用 hash%capacity 来确定桶下标。HashMap capacity 为 2 的 n 次方这一特点能够极大降低重新计算桶下标操作的复杂度。
假设原数组长度 capacity 为 16,扩容之后 new capacity 为 32:
对于一个 Key,它的哈希值 hash 在第 5 位:
- 为 0,那么 hash%00010000 = hash%00100000,桶位置和原来一致;
- 为 1,hash%00010000 = hash%00100000 + 16,桶位置是原位置 + 16。
5、链表转红黑树
从JDK1.8开始,一个桶存储的链表长度大于等于8时会将链表转为红黑树。
6、与Hashtable的比较
- Hashtable使用synchronized来进行同步
- HashMap可以插入键为null的Entry
- HashMap是无序的
ConcurrentHashMap
ConcurrentHashMap 和 HashMap 实现上类似,最主要的差别是 ConcurrentHashMap 采用了分段锁(Segment),每个分段锁维护着几个桶(HashEntry),多个线程可以同时访问不同分段锁上的桶,从而使其并发度更高(并发度就是 Segment 的个数)。
LinkedHashMap
存储结构
继承自HashMap,因此具有和HashMap一样的快速查找特性。
内部维护了一个双向链表,用来维护插入顺序或者 LRU 顺序。
LinkedHashMap 最重要的是以下用于维护顺序的函数,它们会在 put、get 等方法中调用。
void afterNodeAccess(Node<K,V> p) { }
void afterNodeInsertion(boolean evict) { }
afterNodeAccess()
当一个节点被访问时,如果 accessOrder 为 true,则会将该节点移到链表尾部。也就是说指定为 LRU 顺序之后,在每次访问一个节点时,会将这个节点移到链表尾部,保证链表尾部是最近访问的节点,那么链表首部就是最近最久未使用的节点。
afterNodeInsertion()
在 put 等操作之后执行,当 removeEldestEntry() 方法返回 true 时会移除最晚的节点,也就是链表首部节点 first。
evict 只有在构建 Map 的时候才为 false,在这里为 true。