ArrayList
扩容规则:
ArrayList初始构造数组大小:
ArrayList的无参构造:
没有添加元素时容量为0:最开始的elementData数组为0
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
ArrayList(initialCapacity)如果是有参构造会直接使用指定数量作为数组大小。
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
this.elementData = EMPTY_ELEMENTDATA;
} else {
throw new IllegalArgumentException("Illegal Capacity: "+
initialCapacity);
}
}
ArrayList(Collection<? extends E> c):使用集合c 的大小作为数组容量
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.add()的扩容:
以无参构造来讲,第一次添加元素时扩容到10:
private static final int DEFAULT_CAPACITY = 10;
//minCapacity:the desired minimum capacity 最小容量
public void ensureCapacity(int minCapacity) {
int minExpand = (elementData != DEFAULTCAPACITY_EMPTY_ELEMENTDATA)? 0: DEFAULT_CAPACITY;
if (minCapacity > minExpand) {
ensureExplicitCapacity(minCapacity);
}
}
后面的扩容都是原容量的1.5倍:乘1.5倍会出现小数,这怎么扩容?
扩容规则:
1.原容量>>1 得到原容量的一半。
2.原容量与右移一位后的结果相加。
如果是有参构造,那第一次扩容就是原来容量的1.5倍。
ArrayList.addAll()的扩容:
扩容时取Math.max(实际容量,扩容容量)
举例:
容器中没有元素,要往容器中添加11个元素,实际容量:0+11 > 扩容容量 10 ——所以实际扩容到11。
容器中有10个元素,要往容器中添加3个元素,实际容量:10+3 <扩容容量 15 ——所以实际扩容到15。
总结:
容器中没有元素时,扩容为Math.max(10,实际元素个数),
容器中有元素时,扩容为Math.max(原容量的1.5倍,实际元素个数)。
Iterator迭代器:
fail-fast:遍历时发现有人修改,则马上抛出异常
ArrayList就是这种fail-fast,如果遍历时容器内容修改过,遍历完当前轮次后会抛出并发修改异常。
//初始化
public Iterator<E> iterator() {
return new Itr();
}
private class Itr implements Iterator<E> {
//增删都是修改
//expectedModCount:记载遍历最初时容器的修改次数
//modCount:容器中修改次数,是个实时值
int expectedModCount = modCount;
//往下遍历时调用checkForComodification();
public E next() {
checkForComodification();
...
}
//每次遍历都会判断集合是否发生变化,发生变化就抛出并发修改异常
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
fail-safe:遍历时发现有人修改,有应对策略来完成遍历
CopyOnWriteArrayList是这种fail-safe,遍历容器发现内容修改过时会牺牲一致性,当前遍历取到的依旧是没修改前的旧内容,无法获取修改过的新内容。
遍历旧数组,添加到新数组。
static final class COWIterator<E> implements ListIterator<E> {
private final Object[] snapshot;
//把遍历时的数组保存到snapshot里
private COWIterator(Object[] elements, int initialCursor) {
cursor = initialCursor;
snapshot = elements;
}
...
}
所以遍历的时候使用的是旧数组,添加的元素不在这。
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray();
int len = elements.length;
//添加时复制了原来的数组,开了个更大的数组并把它作为新数组
Object[] newElements = Arrays.copyOf(elements, len + 1);
//添加新元素到新数组中
newElements[len] = e;
setArray(newElements);
return true;
} finally {
lock.unlock();
}
}
LinkedList vs ArrayList
随机访问:
ArrayList实现了RandomAccess,可以直接下标找,LinkedList没实现所以要用迭代器找。
增删性能:
ArrayList头插:所有元素后移,再将头部元素更换。所以除了尾部的插入和删除,其余部分的删除和插入都会受到影响。
LinkedList双向链表有头尾指针,所以插入和删除会快,但是如果是中间位置,指针要遍历过去较消耗时间。(链表插入和删除当然快,是指针过去慢)
局部性原理:
被访问的内存附近的内容很大可能被用到,数组连续内存的特点可以配合cpu缓存,充分利用局部性原理。
LinkedList内存占用大:其中的节点,pre和next指针等都比数组耗费空间。
二者区别
主要是底层数据结构造成的区别:
底层数据结构的不同:
ArrayList的底层数据结构是数组,需要连续的内存,随机访问快,但是除了尾部插入和删除之外,其他的插入和删除操作都要移动数据所以性能低。ArrayList可以配合cpu缓存充分利用局部性原理。
LinkedList的底层数据结构是双向链表,不需要连续内存,随机访问慢,只能沿着链表遍历,但是头部和尾部插入和删除性能很不错。LinkedList内存占用大。
HashMap
面试题:
HashMap的底层数据结构在1.7和1.8有什么不同?
1.7时,HashMap的底层数据结构是数组+链表,
1.8时,底层数据结构是数组+(链表||红黑树)。链表长度超过8时会转换成红黑树,红黑树中元素减少也可能会退化成链表。
为什么用红黑树?
1.7中链表过长会影响查找性能,1.8在链表过长时用红黑树换掉链表能够提高查询性能。
什么时候树化?
满足两个条件:数组长度大于等于64 且 链表长度大于8
为什么最初不用红黑树而是链表?
链表元素少时,查找性能高于红黑树,就没必要花大力气用红黑树,而且用红黑树还耗内存;
链表元素多时,其性能远不如红黑树,所以才换成红黑树。
树化的阈值为什么是8?
红黑树用于避免dos攻击,防止链表过长性能下降,链表正常情况下不会超过8。
红黑树占用的空间比链表大,非必要不要使用红黑树。
hash值足够随机时,负载因子0.75的情况下链表长度超过8的概率很低,最大可能降低树化的概率。
什么时候退化成链表?
1.扩容时桶下标重新计算导致树的拆分,树元素数量<=6时退化成链表。
2.remove树节点前,有根节点,左右孩子或者左孙子为null时,退化成链表。
索引怎么计算?
过程:
桶下标 = 哈希码% 数组容量
只要保证数组容量是2 的n次幂,就可以有更快的计算方式:桶下标 = 哈希码 & (数组容量-1)
结果:
计算对象的hashcode(),再使用HashMap的hash()二次哈希,最后&(capacity-1)得到索引。
有了hashcode为什么还有hash()方法?/ 为什么要二次哈希?
做出扰动,综合高位数据,让哈希分布的更均匀。
数组容量为什么是2的n次幂?
计算索引时,可以使用按位与操作来代替取模,可以提高效率。
扩容时,二次哈希值hash & 数组原容量oldCapacity == 0 ?留在原位 :新位置 = 旧位置 + oldCapacity。
如果为了追求更好的哈希分布性,数组容量取质数更好。
如果为了追求更高效率,数组容量取2的n次幂更好。显然HashMap更追求性能,以上的索引与计算,二次哈希以及扩容时位置计算都是配合数组容量为2的n次幂做出的优化。
1.7和1.8中,put方法的流程有什么不同?
流程:
1.HashMap懒惰创建数组,首次使用put方法时才创建数组;
2.计算索引(桶下标):得到对象的key,得到对象的hashcode,二次哈希后&(数组容量-1)得到索引。
3.桶下标是否占用?
3.1无人占用,创建node占位返回
3.2有人占用,如果里面时TreeNode就用红黑树逻辑添加;如果是node就走链表的逻辑添加;如果链表长度超过8,就走红黑树逻辑(还是看树化的两个条件)。
4.添加元素后检查数组容量是否超过阈值?超过就扩容。
注意:添加时判断是否需要树化,是否需要扩容。
不同:
链表插入方式:1.7是头插法,1.8是尾插法
扩容时计算索引方式:1.8会优化:旧位置+oldCapacity
扩容细节:1.7 大于等于阈值且无空位才扩容,1.8大于阈值就扩容。
1.7 大于等于阈值但是有空位的情况:接着往里加,不扩容:
加载因子为什么是0.75?
因为0.75在空间占用(扩容)和查询时间(哈希冲突,链表长度是否过长)之间取得了较好的平衡。
大于0.75节省空间,但是链表过长会影响性能。
小于0.75冲突减少,但是扩容频繁,空间占用多。
多线程下HashMap会有什么问题?
1. 1.7的情况下:并发丢数据:
put方法流程中,获取桶下标后会判断此时是否有人占用,假如有两个线程t1,t2同时向map中添加元素,且桶下标一样还同时做完了是否有人占用当前桶下标的判断,且同时还没有添加元素,此时t1,t2都认为没人占用当前桶下标,因此都把数据写进桶下标,导致t1写的内容被t2修改或t2写的内容被t1修改,应该有两个元素写入map但最后只有一个元素写入map,造成了数据的丢失。
2.;数据错乱:1.7头插法可能导致多线程时出现链表死链的情况。
key可以为null吗?作为key有什么要求?
只有HashMap的key可以作为Null,其他的map都不行。
作为key的对象要重写hashcode()和equals(),且key不能修改,变了用key再找hashcode可能就不一样了,导致找不到对象。
String对象的hashCode()如何设计?
目的是让每个字符串的哈希值足够独特,分布的比较均匀。
把String中每个字符作为一个数字Si,i属于[0,n-1],使用散列公式:
为什么乘的31的x次幂?
31带入散列公式有较好的散列特性,而且31X可以优化为: X>>5 -X
31X = 32X-X = 2^5X-X = X>>5 -X
对以上面试题的部分解释
快速查找:
元素做两次哈希,将哈希码和容量取模后得到的余数(桶下标)即为数组中位置。然后再在数组中当前链表下遍历查找。由于这样的计算会让查找非常快速。
链表过长解决方法:
找了数组又找链表,如果链表过长那么查找也不快速了。
1.扩容:
map中元素到达 3/4(临界点) 数组就扩容为原来的两倍,此时会重新计算桶下标,如果哈希码不一样那么重新计算的桶下标也会不一样,因此链表长度减少。
但是如果链表过长的哈希码都一样,那么即使数组扩容了桶下标重新计算也还是一样,因此链表长度不会减少。
元素数量到达临界点或链表长度大于8都会扩容。
2.红黑树树化: 万不得已才树化
达成两个条件才树化:.数组长度大于等于64 且 链表长度大于8
如果链表长度大于8,会看能不能用数组扩容解决链表过长的问题,如果可以解决就认为没必要树化。
退化发生的情况:
1.扩容拆分树时,桶下标重新计算,如果之后的树元素个数<=6,则会退化成链表
2.remove树节点前,如果root,root.right,root.left,root.left.left 有一个为null,也会退化成链表链表。
注意:是移除之前检查,如果真的此次要移除以上四个节点中的一个,由于在移除之前四个节点都在,已经检查过认为都不为null,所以即使移除了四个中的任意一个,移除过后仍然没有退化。
举例子:
数据错乱:
1.7 头插法导致的线程死链的问题:
线程2完成操作后:线程1 的操作如下: