文章目录
前言
今天是更新第 6 天,今天的主要内容还是 Java 基础
正文
ArrayList相关
扩容机制
三种构造器
1.ArrayList()
会使用长度为0的数组
// 无参构造器
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
public ArrayList() {
// 初始化为空数组
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
2.ArrayList(int 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);
}
}
3.public ArrayList(Collection<? extends E> c)
会使用c的大小作为数组容量
// 传入集合的构造器
public ArrayList(Collection<? extends E> c) {
Object[] a = c.toArray();
if ((size = a.length) != 0) {
if (c.getClass() == ArrayList.class) {
elementData = a;
} else {
elementData = Arrays.copyOf(a, size, Object[].class);
}
} else {
// replace with empty array.
elementData = EMPTY_ELEMENTDATA;
}
}
无参构造器的扩容机制:
-
扩容是懒惰式的,即没有添加元素前,即使指定了容量,也不会真正创建数组
-
对于add(Object o)方法:首次添加元素扩容为10,再次扩容为上次容量的1.5倍
扩容1.5倍的实现方式是位运算:当前容量 + (当前容量 >> 1)
-
对于addAll(Collection c)方法:
没有元素时,扩容为
Math.max(10,实际元素个数)
,有元素时为Math.max(原容量1.5倍,实际元素个数)
// 当前数组容量为 0 List<Integer> list = new ArrayList<>(); // 添加元素时触发扩容,实际扩容容量为 Math.max(10, 4) list.addAll(Arrays.asList(1,2,3,4)); System.out.println(list);
// 当前数组容量为 10 List<Integer> list = new ArrayList<>(Arrays.asList(1,2,3,4,5,6,7,8,9,10)); // 添加元素时触发扩容,实际扩容容量为 Math.max(15, 17) list.addAll(Arrays.asList(1,2,3,4,5,6,7)); System.out.println(list);
ArrayList 、Vector 和 LinkedList 的区别
ArrayList
- 基于数组,需要连续内存
- 随机访问快(指根据下标访问)
- 尾部插入、删除性能可以,其它部分插入、删除都会移动数据,因此性能会低
LinkedList
- 基于双向链表,无需连续内存
- 随机访问慢(要沿着链表遍历)
- 头尾插入删除性能高
- 占用内存多
详细:
-
ArrayList 、Vector 和 LinkedList 都实现了 List 接口
-
ArrayList:基于可变数组,连续内存存储,适合下标访问(随机访问),数组的缺点是每个元素之间不能有间隔,当数组大小不满足时需要增加存储能力,就要将已经有数组的数据复制到新的存储空间中。尾部插入删除元素性能良好,当从ArrayList 的中间位置插入或者删除元素时,需要对数组进行复制、移动,会降低性能。
// JDK 中的定义 public class ArrayList<E> extends AbstractList<E> implements List<E>, RandomAccess, Cloneable, java.io.Serializable public class LinkedList<E> extends AbstractSequentialList<E> implements List<E>, Deque<E>, Cloneable, java.io.Serializable
ArrayList 实现了 RandomAccess 接口,而 LinkedList 没有实现,而根据索引遍历时比迭代器效率要高的
即连续内存存储可以利用起始地址 + 数组元素大小 * n 来快速定位到目标索引的元素,效率比较高
-
Vector 与ArrayList 一样,也是通过数组实现的,不同的是它支持线程的同步,即某一时刻只有一个线程能够写Vector,避免多线程同时写而引起的不一致性,但实现同步需要很高的花费,因此,访问它比访问ArrayList 慢。
-
LinkedList:基于双向链表,可以存储在分散的内存中,在头尾数据插入及删除操作效率高,随机访问和遍历速度比较慢(需要从头遍历)。和ArrayList相比它实现了Deque接口,可以当作栈、队列和双向队列使用。
LinkedList 在中间插入/删除元素的性能是非常低的,远不如ArrayList,虽然插入/删除操作只需要修改指针即可,但是找到要找到插入/删除的位置需要遍历整个链表,效率非常低,综合来看LinkedList不如ArrayList
List 和 Set 的区别
-
List:有序,按对象存入时的顺序保存对象,可重复,允许多个Null元素对象,可以使用
get(int index)
获取指定下标的元素 -
Set:无序,不可重复,最多允许有一个Null元素对象,取元素时只能用Iterator接口取得所有元素,再逐一遍历各个元素,不能通过下标访问
迭代器Iterator
FailFast
一旦发现遍历的同时集合内容发生了修改,则立刻抛异常
ArrayList是fail-fast的典型代表,遍历的同时不能修改
源码分析
// 在进入for循环时会先初始化一个迭代器对象,并进行赋值
// modCount:当前 List 集合的修改次数
int expectedModCount = modCount;
// 每次迭代前会调用 hasNext() 方法来判断是否到了末尾
public boolean hasNext() {
return cursor != size;
}
@SuppressWarnings("unchecked")
public E next() {
// 每次调用 next() 方法会先调用 checkForComodification 方法来进行判断
checkForComodification();
int i = cursor;
if (i >= size)
throw new NoSuchElementException();
Object[] elementData = ArrayList.this.elementData;
if (i >= elementData.length)
throw new ConcurrentModificationException();
cursor = i + 1;
return (E) elementData[lastRet = i];
}
// 判断当前的遍历前的修改次数和当前的修改次数是否一致,如果不一致则抛出异常
final void checkForComodification() {
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
FailSafe
发现遍历的同时其它人来修改,应当能有应对策略,例如牺牲一致性来让整个遍历运行完成
CopyOnWriteArrayList是fail-safe的典型代表,遍历的同时可以修改,原理是读写分离
在遍历开始前会将当前的数组记录下来,遍历的时候遍历的就是记录的这个数组,对于遍历过程中发生的修改数组实际上修改的是复制出来的新数组,对当前正在遍历的数组不会产生影响。
HashMap相关
HashMap 根据键的 hashCode 值存储数据,大多数情况下可以直接定位到它的值,因而具有很快的访问速度,遍历顺序是无序的
一些参数:
-
table:底层维护的数组
-
capacity:当前数组容量,始终保持 2^n,可以扩容,扩容后数组大小为当前的 2 倍。
-
loadFactor:加载因子,默认为 0.75。
-
threshold:扩容的阈值,等于 capacity * loadFactor
-
MIN_TREEIFY_CAPACITY:最小树化容量
-
TREEIFY_THRESHOLD:树化的阈值
HashMap 和 HashTable 区别
- HashMap 最多只允许一条记录的键为null,允许多条记录的值为null;Hashtable不允许键和值为 null
- HashMap 非线程安全,即同一时刻可以有多个线程同时写HashMap,可能会导致数据的不一致;Hashtable 是线程安全的,同一时刻只有一个线程能写Hashtable。
- Hashtable 不建议使用,不需要线程安全的场合可以用 HashMap 替换,需要线程安全的场合可以用ConcurrentHashMap 替换。(Hashtable并发性不如ConcurrentHashMap,因为ConcurrentHashMap 引入了分段锁。)
HashMap的初始容量
JDK1.7的时候初始容量是16
JDK1.8的时候初始化HashMap的时候并没有指定容量大小,而是在第一次执行put方法时才初始化容量。
// 负载因子大小
final float loadFactor;
// 默认负载因子大小
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 初始化方法 执行new HashMap()方法初始化的时候,只指定了负载因子的大小。
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR;
}
HashMap 底层实现和扩容机制
-
JDK1.7采用的是数组+链表,数组可以通过下标访问,实现快速查询,链表用来解决哈希冲突。
-
链表的查询时间复杂度是O(n),性能较差,所以JDK1.8做了优化,引入了红黑树,查询时间复杂度是O(logn)。
JDK1.8采用的是数组+链表+红黑树的结构,当某一条链表长度大于等于8,并且数组长度大于等于64时,该链表才需要转换成成红黑树。
扩容机制:
1. HashMap底层维护的是一个数组,默认为null
2. 当创建 `new HashMap()` 时,将加载因子`loadfactor`初始化为0.75
3. 第1次添加元素,则需要扩容table容量为16,临界值(threshold)为12。以后再扩容,则需要扩容table容量为原来的2倍,临界值为原来的2倍,即24,依次类推
4. 添加完元素后会检查容量是否超过阈值,一旦超过走扩容的逻辑(用数组容量大小乘以加载因子得到一个值,一旦数组中存储的元素个数超过该值就会调用rehash方法进行扩容)
5. 在Java8中,如果一条链表的元素个数大于等于TREEIFY_THRESHOLD(默认是8),并且table的大小 >= MIN_TREEIFY_CAPACITY(默认64),该条链表就会进行树化(转成红黑树),如果 table 的大小 < 64,暂时不树化,而是继续进行扩容。
6. 在扩容时如果 `hash & 旧的数组容量 = 0`的元素留在原来位置,否则`新位置 = 旧位置 + 旧的数组容量`。
为什么要用红黑树,为何一上来不树化?
红黑树用来防止链表超长时性能下降,树化应当是偶然情况
hash 表的查找,更新的时间复杂度是O(1),而红黑树的查找,更新的时间复杂度是O(log2 n),TreeNode占用空间也比普通Node的大,如非必要,尽量还是使用链表。
树化阈值为何是8
hash值如果足够随机,则在 hash 表内按泊松分布,在负载因子0.75的情况下,长度超过8的链表出现概率是0.00000006(亿分之六),选择8就是为了让树化几率足够小
何时会树化,何时会退化为链表?
-
树化两个条件:链表长度超过树化阈值;数组容量>=64
-
退化情况1:在扩容时会进行树的拆分,树元素个数<=6则会退化链表
退化情况2: 删除树节点前,若待删除节点的root、root.left、root.right、root.left.left有一个为null,也会再删除后退化为链表
索引/桶下标的计算方式
-
首先调用 Object 类的 hashCode() 方法计算哈希值
-
然后调用 Map 的 hash() 方法进行二次哈希
-
最后再和数组容量进行取模运算,求出key所在的数组索引(桶下标)
取模用位运算实现:索引值 = 二次哈希值 & (数组容量 - 1)
为什么要进行二次哈希
为了综合高位数据,让哈希分布更为均匀
数组容量为何是2的n次幂?
-
计算索引时,如果是2的n次幂可以使用与运算代替取模,效率更高;
-
在扩容时
hash值 & 旧的数组容量 = 0
的元素留在原来位置,否则新位置 = 旧位置 + 旧的数组容量
以上优化手段都建立在数组容量为2的n次幂时,二次哈希也是针对数组容量为 2 的n次幂进行的(因为使用 2 的n次幂哈希分布不是很平均)。
-
数组容量不使用 2 的 n 次幂(习惯上使用质数)的话哈希分布可能会更均匀,但是求取数组的索引速度会变慢。
例如Hashtable的容量就不是2的n次幂,并不能说哪种设计更优,应该是设计者综合了各种因素,最终选择了使用2的n次幂作为容量
put方法流程及1.7与1.8有何不同
流程
- 计算索引(桶下标)
- 判断该索引处是否有元素,如果没有元素直接添加并返回
- 如果该索引处有元素,继续判断该元素的key是否和准备加入的key相等,如果相等,则直接替换value
- 如果不相等需要判断是树结构还是链表结构,做出相应处理。如果链表长度超过树化阈值,走树化逻辑
- 返回前检查容量是否超过阈值,一旦超过走扩容的逻辑(先添加元素,再检查是否超过)
不同点
- 链表插入节点时,1.7是头插法,1.8是尾插法
- 1.7是大于等于阈值且没有空位时才扩容,而1.8是大于阈值就扩容
- 1.8在扩容计算Node索引时,会优化
加载因子为何默认是0.75f
- 在空间占用与查询时间之间取得较好的权衡
- 大于这个值,空间节省了,但链表就会比较长影响性能
- 小于这个值,冲突减少了,但扩容就会更频繁,空间占用多
多线程下操作HashMap会出现的问题
- 扩容死链(1.7)
- 数据错乱(1.7,1.8)
key能否为null,作为key的对象有什么要求?
- HashMap 的key可以为null,但Map的其他实现则不然
- 作为key的对象,必须实现hashCode和equals,并且key的内容不能修改(不可变)
String对象的hashCode()如何设计的,为什么每次乘的是31
- 目标是达到较为均匀的散列效果,每个字符串的hashcode足够独特
- 字符串中的每个字符都可以表现为一个数字,称为Si,其中i的范围是0~n - 1,散列公式相当于求原字符串的
三十一进制表示
- 31代入公式有较好的散列特性,并且31 * h 可以被位运算优化为
32 * h - h
,2^5 * h - h
,进一步优化为h << 5 - h
TreeMap 和 LinkedHashMap 区别
TreeMap 实现 SortedMap 接口,能够把它保存的记录根据键排序,默认是按键值的升序排序,也可以自己指定排序规则,当用Iterator 遍历TreeMap 时,得到的记录是排过序的。
在使用TreeMap 时,key 必须实现 Comparable 接口并且重写相应的compareTo()函数或者在构造 TreeMap 时传入自定义的Comparator,否则会在运行时抛出java.lang.ClassCastException 类型的异常。
LinkedHashMap 是HashMap 的一个子类,保存了记录的插入顺序,在用Iterator 遍历LinkedHashMap 时,先得到的记录肯定是先插入的。
LinkedHashMap 如何保证插入元素的顺序和遍历时的顺序相同:每个节点有 before 和 after 属性,相当于维护了一个双向链表
ConcurrentHashMap 相关
ConcurrentHashMap支持并发操作。整个 ConcurrentHashMap 由一个个 Segment(分段锁) 组成,每个Segment可以看作是一个HashMap。
简单理解就是,ConcurrentHashMap 是一个 Segment 数组,Segment 通过继承 ReentrantLock 来进行加锁,所以每次需要加锁的操作锁住的是一个 segment,这样只要保证每个 Segment 是线程安全的,也就实现了全局的线程安全。
concurrencyLevel:并行级别、并发数、Segment 数,默认是 16,也就是说 ConcurrentHashMap 有 16 个 Segments,所以理论上,这个时候,最多可以同时支持 16 个线程并发写,只要它们的操作分别分布在不同的 Segment 上。这个值可以在初始化的时候设置为其他值,但是一旦初始化以后,它是不可以扩容的。