参考视频:满神Java面试专题
笔记的整体结构依据视频编写,并随着学习的深入补充了很多知识
1. ArrayList
要求
- 掌握 ArrayList 扩容规则
扩容规则
- ArrayList() 会使用长度为零的数组

- ArrayList(int initialCapacity) 会使用指定容量的数组

- public ArrayList(Collection<? extends E> c) 会使用 c 的大小作为数组容量

- add(Object o) 首次扩容为 10,再次扩容为上次容量的 1.5 倍
- 第一次添加数组,数组为0,发生第一次扩容
- 第一个元素放入新数组索引0,新数组替换旧数组(首次为0)

- 再次扩容为上次容量的 1.5 倍,完成扩容并添加元素,新数组替换旧数组,旧数组没人引用就会被垃圾回收

- 以后扩容按上次容量+上次容量右移1位

- 此方式为调用的add方法得到的扩容结果

- addAll(Collection c) 没有元素时,扩容为 Math.max(10, 实际元素个数),有元素时为 Math.max(原容量 1.5 倍, 实际元素个数)

- 第一次空数组扩容(元素个数<10)

- 第一次空数组扩容(10<元素个数<15,此时会拿当前元素个数与当前容量下次扩容的容量二者之间的较大值作为当前容量)

- 第一次非空数组扩容(规则一样)


其中第 4 点必须知道,其它几点视个人情况而定
提示
- 测试代码见
day01.list.TestArrayList,这里不再列出 - 要注意的是,示例中用反射方式来更直观地反映 ArrayList 的扩容特征,但从 JDK 9 由于模块化的影响,对反射做了较多限制,需要在运行测试代码时添加 VM 参数
--add-opens java.base/java.util=ALL-UNNAMED方能运行通过,后面的例子都有相同问题
代码说明
- day01.list.TestArrayList#arrayListGrowRule 演示了 add(Object) 方法的扩容规则,输入参数 n 代表打印多少次扩容后的数组长度
补充
addAll扩容机制:添加元素后的元素个数与下一次扩容数作比较 0->10:3个->10,11->11;10->15:13->15,16->16
2. Iterator
要求
- 掌握什么是 Fail-Fast、什么是 Fail-Safe
Fail-Fast 与 Fail-Safe
- ArrayList 是 fail-fast 的典型代表,遍历的同时不能修改,尽快失败
- fail-fast 一旦发现遍历的同时其它人来修改,则立刻抛异常

- fail-fast实现原理

- CopyOnWriteArrayList 是 fail-safe 的典型代表,遍历的同时可以修改,原理是读写分离
- fail-safe 发现遍历的同时其它人来修改,应当能有应对策略,例如牺牲一致性来让整个遍历运行完成

- fail-safe实现原理

提示
测试代码见
day01.list.FailFastVsFailSafe,这里不再列出第一次笔记:
fail-fast:
1.调用迭代器Iterator构造方法并初始化成员变量
2.检查:expectedModCount(迭代一开始记录的修改次数)与modCount(外面list的修改次数)做对比,不等则抛异常
fail-safe:
- list.add中每次调用add方法时都会把旧的数组拷贝一份,并让长度加一,把新加的元素放到扩容数组(新数组)最后一个位置,即添加和遍历的数组不同,互不干扰
第二次笔记:
failfast:
- 增强for循环开始前的修改次数(crud)modCount
- expectedModCount初始值=modCount
- 每次遍历元素前(迭代)都检查以上的值是否相等,在遍历期间并发修改了,等遍历下个元素时就会抛出异常
failsafe:
- iterator遍历集合,list.add方法每次添加元素,都会拷贝一份原集合并让容量加一,在并发修改时迭代器是不知道,遍历的还是原集合,即添加是一个集合,遍历是另一个集合
3. LinkedList VS ArrayList
要求
- 能够说清楚 LinkedList 对比 ArrayList 的区别,并重视纠正部分错误的认知

- 两者查询元素内容的时间复杂度都是O(n),都不太适合用来查询,查询可以选择更高效的数据结构,如HashMap、TreeMap等
LinkedList
- 基于双向链表,无需连续内存

- 随机访问慢(要沿着链表遍历)

- 头尾插入删除性能高
- 占用内存多
ArrayList
- 基于数组,需要连续内存

- 随机访问快(指根据下标访问)

- 尾部插入、删除性能可以,其它部分插入、删除都会移动数据,因此性能会低

- 可以利用 cpu 缓存,局部性原理(将读取变量和其相邻的变量也一次读到CPU缓存中)
代码说明
- day01.list.ArrayListVsLinkedList#randomAccess 对比随机访问性能
- day01.list.ArrayListVsLinkedList#addMiddle 对比向中间插入性能
- day01.list.ArrayListVsLinkedList#addFirst 对比头部插入性能
- day01.list.ArrayListVsLinkedList#addLast 对比尾部插入性能
- day01.list.ArrayListVsLinkedList#linkedListSize 打印一个 LinkedList 占用内存
- day01.list.ArrayListVsLinkedList#arrayListSize 打印一个 ArrayList 占用内存
CPU进行计算的数据还是来自内存中的,比如进行加法运算,首先将内存中a的变量的读取到CPU的寄存器中,然后将b的值读到另一个寄存器中,数据都有了就进行接下来的加法运算,计算后的结果也会写到内存中的c变量,这样就完成了一次加法运算的计算过程,在这个计算过程里瓶颈在于内存变量的读和写上,因为内存的读写效率是非常低的,读一次、写一次大约需要花几百纳秒,对于CPU来讲这个时间是非常漫长的,CPU执行一次计算是少于纳秒级别的,也许花了不到1纳秒就完成了,但等待数据读进来写出去就要花费成百倍的时间,显然是不可接受的;
因此需要在CPU与内存之间加入CPU缓存,这个缓存的读写性能就比内存高很多了,CPU缓存又分为1级、2级、3级缓存,速度快的能达到10纳秒,速度慢的也能到达几十纳秒,对比内存的几百纳秒就快了很多
- 局部性原理
- 往缓存中读数据时的一种规则,一种优化措施
- 将某个数据以及它相邻的数据都读取到缓存中,它会出于这样一种假设,当读取某个变量时,它相邻的变量也会有很大几率被访问到,即拿到某个元素后,有很大几率会遍历数组,即可以直接在缓存中遍历了,无需再到内存中遍历
- 链表的局部性原理就不可行,因为第一个元素与第二个元素相邻的有点远
- CPU缓存的空间也是有限的,如果将2和3也读到缓存,就会把之前的数据清空掉了,因此链表就不能很好的配合CPU缓存(局部性原理)来提升性能
4. HashMap
要求
- 掌握 HashMap 的基本数据结构
- 掌握树化
- 理解索引计算方法、二次 hash 的意义、容量对索引计算的影响
- 掌握 put 流程、扩容、扩容因子
- 理解并发使用 HashMap 可能导致的问题
- 理解 key 的设计
1)基本数据结构
- 1.7 数组 + 链表
- 1.8 数组 + (链表 | 红黑树)
更形象的演示,见资料中的 hash-demo.jar,运行需要 jdk14 以上环境,进入 jar 包目录,执行下面命令
java -jar --add-exports java.base/jdk.internal.misc=ALL-UNNAMED hash-demo.jar
2)树化与退化
树化意义
- 红黑树用来避免 DoS 攻击,防止链表超长时性能下降,树化应当是偶然情况,是保底策略
- hash 表的查找,更新的时间复杂度是 ,而红黑树的查找,更新的时间复杂度是 ,TreeNode 占用空间也比普通 Node 的大,如非必要,尽量还是使用链表
- hash 值如果足够随机,则在 hash 表内按泊松分布,在负载因子 0.75 的情况下,长度超过 8 的链表出现概率是 0.00000006,树化阈值选择 8 就是为了让树化几率足够小
树化规则
- 当链表长度超过树化阈值 8 时,先尝试扩容来减少链表长度,如果数组容量已经 >=64,才会进行树化
退化规则
- 情况1:在扩容时如果拆分树时,树元素个数 <= 6 则会退化链表
- 情况2:remove 树节点时,若 root、root.left、root.right、root.left.left 有一个为 null ,也会退化为链表
3)索引计算
前置知识
- 与运算(&)
java参加运算的两个数据,按二进制位进行“与”运算。 运算规则:0&0=0; 0&1=0; 1&0=0; 1&1=1; 即:两位同时为“1”,结果才为“1”,否则为0 例如:3&5 即 0000 0011 & 0000 0101 = 0000 0001 因此,3&5的值得1。 例如:9&5 即 0000 1001 (9的二进制补码)&00000101 (5的二进制补码) =00000001 (1的二进制补码)可见9&5=1。或运算(|)
参加运算的两个对象,按二进制位进行“或”运算。 运算规则:0|0=0; 0|1=1; 1|0=1; 1|1=1; 即 :参加运算的两个对象只要有一个为1,其值为1。 例如:3|5 即 0000 0011 | 0000 0101 = 0000 0111 因此,3|5的值得7。 例如:9|5可写算式如下: 00001001|00000101 =00001101 (十进制为13)可见9|5=13异或运算(^)
java参加运算的两个数据,按二进制位进行“异或”运算。 运算规则:0^0=0; 0^1=1; 1^0=1; 1^1=0; 即:参加运算的两个对象,如果两个相应位为“异”(值不同),则该位结果为1,否则为0。 例如:9^5可写成算式如下: 00001001^00000101=00001100 (十进制为12)可见9^5=12java01010(10) 10000(16) 11010(26) ----------(10&16 = 0) ----------(26&16 = 16) newHash = 16+10
8421码指的是四位二进制数,从0000~1001,分别代表十进制0~9,其每位的权分别为$2^3$(8)、$2^2$(4)、$2^1$(2)、$2^0$(1)。除了8421码外,类似的还有5421码。- 索引如何计算?hashCode都有了,为何还要提供hash()方法?数组容量为何是2的n次幂?
索引计算方法(为了配合容量是 2 的 n 次幂的优化手段 )
首先,计算对象的 hashCode()
再进行调用 HashMap 的 hash() 方法进行二次哈希
二次 hash() 是为了综合高位数据,让哈希分布更为均匀;计算hashCode的值越随机,hashCode分布得越均匀,链表就越不会有过长的情况
- HashMap1.8二次hash

- HashMap1.7二次hash

hashCode足够均匀

- hashCode选取不够好,不够随机


- 进行二次hash扰动函数,使得hashCode分布更均匀,防止超长链表的产生


最后 & (capacity – 1) 得到索引(容量capacity必须为2的n次幂)

数组容量为何是 2 的 n 次幂

- 计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算代替取模
- 扩容时重新计算索引效率更高: hash & oldCap == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap = 10 + 16

注意
- 二次 hash 是为了配合 容量是 2 的 n 次幂 这一设计前提,如果 hash 表的容量不是 2 的 n 次幂,则不必二次 hash,即想要更好的hash分布性,容量值就选择质数

- 容量是 2 的 n 次幂 这一设计计算索引效率更好(追求性能),但 hash 的分散性就不好,需要二次 hash 来作为补偿,没有采用这一设计的典型例子是 Hashtable,以下为 Hashtable 的扩容规律

4)put 与扩容
put 流程
- 1.8put流程

- HashMap 是懒惰创建数组的,首次使用才创建数组
- 计算索引(桶下标)
- 如果桶下标还没人占用,创建 Node 占位返回
- 如果桶下标已经有人占用
- 已经是 TreeNode 走红黑树的添加或更新逻辑
- 是普通 Node,走链表的添加或更新逻辑,如果链表长度超过树化阈值,走树化逻辑
- 返回前检查容量是否超过阈值,一旦超过进行扩容
1.7 与 1.8 的区别
链表插入节点时
- 1.7 是头插法

- 1.8 是尾插法

1.7 是大于等于阈值且没有空位时才扩容

而 1.8 是大于阈值就扩容
1.8 在扩容计算 Node 索引时,会优化
- hash & oldCap == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap = 10 + 16
扩容(加载)因子为何默认是 0.75f
在空间占用与查询时间之间取得较好的权衡
大于这个值,空间节省了,但链表就会比较长影响性能

小于这个值,冲突减少了,但扩容就会更频繁,空间占用也更多

计算桶下标:hash()→
(hashcode >>> 16 ^ hashcode )&(capacity-1)capacity为2的n次幂,计算索引效率更高,但哈希分布不均匀;capacity为质数时哈希分布均匀(hashtable)。 hashmap1.8:超过容量扩容阈值(>¾),先创建扩容新数组再将数据迁移到新数组;链表插入方式为尾插法 1.7:链表插入方式为头插法;
5)并发问题
1.7链表迁移过程(a和b迁移前后都是同一个对象,只是改变了它们的一些引用地址,并没有发生对象的创建)

扩容死链(1.7 会存在)
1.7 源码如下:
void transfer(Entry[] newTable, boolean rehash) {
int newCapacity = newTable.length;
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
e.hash = null == e.key ? 0 : hash(e.key);
}
int i = indexFor(e.hash, newCapacity);
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}- e 和 next 都是局部变量,用来指向当前节点和下一个节点
- 线程1(绿色)的临时变量 e 和 next 刚引用了这俩节点,还未来得及移动节点,发生了线程切换,由线程2(蓝色)完成扩容和迁移

- 线程2 扩容完成,由于头插法,链表顺序颠倒。但线程1 的临时变量 e 和 next 还引用了这俩节点,还要再来一遍迁移

第一次循环
- 循环接着线程切换前运行,注意此时 e 指向的是节点 a,next 指向的是节点 b
- e 头插 a 节点,注意图中画了两份 a 节点,但事实上只有一个(为了不让箭头特别乱画了两份)

- 当循环结束是 e 会指向 next 也就是 b 节点

第二次循环
- 第二轮开始,next 指向了节点 a
- e 头插节点 b

- 当循环结束时,e 指向 next 也就是节点 a

第三次循环
- next 指向了 null

- e 头插节点 a,a 的 next 指向了 b(之前 a.next 一直是 null),b 的 next 指向 a,死链已成

- 当循环结束时,e 指向 next 也就是 null,因此第四次循环时会正常退出

数据错乱(1.7,1.8 都会存在)
- 代码参考
day01.map.HashMapMissData,具体调试步骤参考视频

补充代码说明
- day01.map.HashMapDistribution 演示 map 中链表长度符合泊松分布
- day01.map.DistributionAffectedByCapacity 演示容量及 hashCode 取值对分布的影响
- day01.map.DistributionAffectedByCapacity#hashtableGrowRule 演示了 Hashtable 的扩容规律
- day01.sort.Utils#randomArray 如果 hashCode 足够随机,容量是否是 2 的 n 次幂影响不大
- day01.sort.Utils#lowSameArray 如果 hashCode 低位一样的多,容量是 2 的 n 次幂会导致分布不均匀
- day01.sort.Utils#evenArray 如果 hashCode 偶数的多,容量是 2 的 n 次幂会导致分布不均匀
- 由此得出对于容量是 2 的 n 次幂的设计来讲,二次 hash 非常重要
- day01.map.HashMapVsHashtable 演示了对于同样数量的单词字符串放入 HashMap 和 Hashtable 分布上的区别
6)key 的设计
key 的设计要求
HashMap 的 key 可以为 null,但 Map 的其他实现则不然
作为 key 的对象,必须实现 hashCode 和 equals,并且 key 的内容不能修改(不可变)
- 重写hashCode方法是为了让我们的key在整个HashMap中有更好的分布性,提高查询性能
- 重写equals方法是为了将来如果计算两个对象的key的索引都一样,进一步需要用equals进行比较,看看是不是两个相同的对象
- 两个对象的hashCode相等,equals不一定相等;两个对象的equals相等,hashCode一定相等
key 的 hashCode 应该有良好的散列性
如果 key 可变,例如修改了 age 会导致再次查询时查询不到,因此平时用整数、字符串等作为key,这些类的内容不可变

public class HashMapMutableKey {
public static void main(String[] args) {
HashMap<Student, Object> map = new HashMap<>();
Student stu = new Student("张三", 18);
map.put(stu, new Object());
System.out.println(map.get(stu));
stu.age = 19;
System.out.println(map.get(stu));
}
static class Student {
String name;
int age;
public Student(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Student student = (Student) o;
return age == student.age && Objects.equals(name, student.name);
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
}
}String 对象的 hashCode() 设计
- 目标是达到较为均匀的散列效果,每个字符串的 hashCode 足够独特
- 字符串中的每个字符都可以表现为一个数字,称为 ,其中 i 的范围是 0 ~ n - 1
- 散列公式为:
- 31 代入公式有较好的散列特性,并且 31 * h 可以被优化为
- 即
- 即
- 即

4.1. HashMap-快速查找
- ArrayList查找效率较低

- HashMap通过索引计算直接找到元素(hash运算),无链表情况查找时间复杂度为O(1),有链表情况就看链表长度了,时间复杂度为O(n)

4.2. HashMap-链表过长的解决方案
- 形成链表:keys相同的固定hash;keys的hash%容量相同
4.2.1.扩容
- 正常情况



- 极端情况,只能树化为红黑树了

4.2.2. 树化
- 链表->红黑树:当容量为16,在某个桶下标形成链表的8个元素添加元素,仅进行扩容为32,再添加扩容到64才会树化


- 满足树化条件(容量>=64且链表长度>8),红黑树父节点左侧都是比它小的元素,右侧都是比它大的元素,子节点同理,hash码相同时才比较,按照key的字符串值比较,查找的时间复杂度为O(log2(n))

- 链表长度是可能出现超过8的情况

红黑树的意义-树化阈值
为何要用红黑树,为何一上来不树化,树化阈值为何是8,何时会树化,何时会退化为链表?
因为链表较长时会影响整个HashMap的性能,1.8之后引入红黑树,即使链表较长也不会对性能有太大的影响
链表->红黑树:当容量为16,在某个桶下标形成链表的8个元素添加元素,仅进行扩容为32,再添加扩容到64才会树化
链表短的时候,链表性能大于红黑树,链表长时性能才远远不如红黑树,且红黑树占用内存比链表大得多,非必要不树化
红黑树是一种非正常情况,下图为23W多个正常单词的hash分布情况,若没有刻意构造hash码,在负载因子0.75的情况下,链表出现8的概率非常低,为0.00000006

- 红黑树用来避免 DoS 攻击,防止链表超长时性能下降,树化应当是偶然情况,是保底策略
树退化链表-情况1
- 在扩容时如果拆分树时,树元素个数 <= 6 则会退化链表

- 在扩容时如果拆分树时,树元素个数 > 6 则不会退化链表

树退化链表-情况2
- remove 树节点时,若 root(爷)、root.left(左孩子)、root.right(右孩子)、root.left.left(左孙子) 有一个为 null ,也会退化为链表

- 例子2

5. 单例模式
要求
- 掌握五种单例模式的实现方式
- 理解为何 DCL 实现时要使用 volatile 修饰静态变量
- 了解 jdk 中用到单例的场景
饿汉式
- 提前创建单例对象

- 实现Serializable接口利用反射破坏单例

- 反序列化破坏单例

public class Singleton1 implements Serializable {
private Singleton1() {
if (INSTANCE != null) {
throw new RuntimeException("单例对象不能重复创建");
}
System.out.println("private Singleton1()");
}
private static final Singleton1 INSTANCE = new Singleton1();
public static Singleton1 getInstance() {
return INSTANCE;
}
public static void otherMethod() {
System.out.println("otherMethod()");
}
public Object readResolve() {
return INSTANCE;
}
}- 构造方法抛出异常是防止反射破坏单例

readResolve()是防止反序列化破坏单例

- unsafe破坏单例

枚举饿汉式
- 枚举类一加载并初始化,就会把枚举对象创建出来


public enum Singleton2 {
INSTANCE;
private Singleton2() {
System.out.println("private Singleton2()");
}
@Override
public String toString() {
return getClass().getName() + "@" + Integer.toHexString(hashCode());
}
public static Singleton2 getInstance() {
return INSTANCE;
}
public static void otherMethod() {
System.out.println("otherMethod()");
}
}- 枚举饿汉式能天然防止反射、反序列化破坏单例



- unsafe依旧可以破坏枚举单例


懒汉式
- 第一次调用getInstance方法时才创建单例对象(非线程安全)

public class Singleton3 implements Serializable {
private Singleton3() {
System.out.println("private Singleton3()");
}
private static Singleton3 INSTANCE = null;
// Singleton3.class synchronized加在方法上,性能不是特别好,因为单例对象创建好以后,其他线程来访问该同步方法时无需再加锁了,后续的操作不用再进行同步和互斥保护了,不然就影响性能了,只有首次创建单例是才进行线程安全的保护
public static synchronized Singleton3 getInstance() {
if (INSTANCE == null) {
INSTANCE = new Singleton3();
}
return INSTANCE;
}
public static void otherMethod() {
System.out.println("otherMethod()");
}
}- 其实只有首次创建单例对象时才需要同步,但该代码实际上每次调用都会同步
- 因此有了下面的双检锁改进
双检锁懒汉式
- 少了内层判断还是会重复创建对象

public class Singleton4 implements Serializable {
private Singleton4() {
System.out.println("private Singleton4()");
}
private static volatile Singleton4 INSTANCE = null; // 可见性,有序性
public static Singleton4 getInstance() {
if (INSTANCE == null) {
synchronized (Singleton4.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton4();
}
}
}
return INSTANCE;
}
public static void otherMethod() {
System.out.println("otherMethod()");
}
}

为何必须加 volatile:
INSTANCE = new Singleton4()不是原子的,分成 3 步:创建对象、调用构造、给静态变量赋值,其中后两步可能被指令重排序优化,变成先赋值、再调用构造- 如果线程1 先执行了赋值,线程2 执行到第一个
INSTANCE == null时发现 INSTANCE 已经不为 null,此时就会返回一个未完全构造的对象

内部类懒汉式
public class Singleton5 implements Serializable {
private Singleton5() {
System.out.println("private Singleton5()");
}
private static class Holder {
static Singleton5 INSTANCE = new Singleton5();
}
public static Singleton5 getInstance() {
return Holder.INSTANCE;
}
public static void otherMethod() {
System.out.println("otherMethod()");
}
}
- 避免了双检锁的缺点
volatile修饰共享变量可以解决指令重排序(懒汉单例-DCL(双检索)) 给静态变量赋值肯定会放到静态代码块里执行,静态代码块里的代码由JVM来保证,则饿汉式不用考虑线程安全问题,枚举饿汉式同理
JDK 中单例的体现
- Runtime 体现了饿汉式单例

- Console 体现了双检锁懒汉式单例

- Collections 中的 EmptyNavigableSet 内部类懒汉式单例


- 其他内部类懒汉式例子


- ReverseComparator.REVERSE_ORDER 内部类懒汉式单例

- Comparators.NaturalOrderComparator.INSTANCE 枚举饿汉式单例

To Be Continued.








