2023年面试题复习记录
记录来源:黑马
算法题
1、二分法
/**
* @Author Wangxuefeng
* @Date 2023/2/22 19:46
* @Description: 算法题
* @Email 963210738@qq.com
*/
public class AlgorithmNote {
/**
* 二分查找
*/
@Test
public void test1(){
int[] array = {1,5,8,11,19,22,31,38,42,53,62,88};
int target = 62;
int idx = binarySearch_3(array,target);
System.out.println("idx = " + idx);
}
/**
* 查找不到返回-1
* @return
*/
private static int binarySearch(int[] array,int target) {
int l = 0, r = array.length - 1, m;
while (l <= r) {
m = (r + l) / 2;
if (array[m] == target) {
return array[m];
} else if (target > array[m]) {
l = m + 1;
} else if (target < array[m]) {
r = m - 1;
}
}
return -1;
}
/**
* 查找不到返回-1
* 解决相加超过过大超出int数值问题
* (r + l) / 2 ==> r/2 + l/2 ==> r + (-r/2 + l/2) ==> r + (l - r) / 2
* @return
*/
private static int binarySearch_2(int[] array,int target) {
int l = 0, r = array.length - 1, m;
while (l <= r) {
m = r + (l - r) / 2;
if (array[m] == target) {
return array[m];
} else if (target > array[m]) {
l = m + 1;
} else if (target < array[m]) {
r = m - 1;
}
}
return -1;
}
/**
* 无符号右移,速度相对较快,不会出现溢出的问题
* 原因:之前相加过大除以2由于最高位符号位为1则变为负值
* 通过无符号右移解决符号位为负数,最后一位表示奇偶数,奇数舍去,偶数值为0
* 查找不到返回-1
* @return
*/
private static int binarySearch_3(int[] array,int target) {
int l = 0, r = array.length - 1, m;
while (l <= r) {
m = (r + l) >>> 1;
if (array[m] == target) {
return array[m];
} else if (target > array[m]) {
l = m + 1;
} else if (target < array[m]) {
r = m - 1;
}
}
return -1;
}
}
面试题
2、排序
2.1 冒泡排序
/**
* 冒泡排序
*/
@Test
public void test2() {
int[] a = {5, 9, 7, 4, 1, 3, 2, 8};
for (int j = 0; j < a.length - 1; j++) {
for (int i = 0; i < a.length - 1; i++) {
if (a[i] > a[i + 1]) {
swap(a, i, i + 1);
}
System.out.println("比较次数统计:" + i);
}
System.out.println("第"+ j + "轮排序: " + Arrays.toString(a));
}
}
/**
* 每轮比较不应该全部作比较因为每一轮比较
* 完成后最右边的值都是排好序的无需重复比较
*/
@Test
public void test3() {
int[] a = {5, 9, 7, 4, 1, 3, 2, 8};
for (int j = 0; j < a.length - 1; j++) {
for (int i = 0; i < a.length - 1 - j; i++) {
if (a[i] > a[i + 1]) {
swap(a, i, i + 1);
}
System.out.println("比较次数统计:" + i);
}
System.out.println("第"+ j + "轮排序: " + Arrays.toString(a));
}
}
/**
* 避免已经无需交换比较排序,顺序已经排好了,
* 还跑完所有比较的次数
*/
@Test
public void test4() {
int[] a = {5, 9, 7, 4, 1, 3, 2, 8};
for (int j = 0; j < a.length - 1; j++) {
boolean swapped = false;
for (int i = 0; i < a.length - 1 - j; i++) {
if (a[i] > a[i + 1]) {
swap(a, i, i + 1);
swapped = true;
}
System.out.println("比较次数统计:" + i);
}
System.out.println("第"+ j + "轮排序: " + Arrays.toString(a));
if (!swapped) {
break;
}
}
}
/**
* 找到最后一次交换的索引作为下次交换的次数,
* 因为最后一次交换的后面的比较不进行交换
* 说明已经排好序了无需在和前面的进行比较
* lastSwapIdx == 0则无需在进行排序
*/
@Test
public void test5() {
int[] a = {5, 2, 7, 4, 1, 3, 8, 9};
int lastSwapIdx = a.length - 1;
for (;;) {
int tempSwapIdx = 0;
for (int i = 0; i < lastSwapIdx; i++) {
if (a[i] > a[i + 1]) {
swap(a, i, i + 1);
tempSwapIdx = i;
}
System.out.println("比较次数统计:" + i);
}
lastSwapIdx = tempSwapIdx;
System.out.println("第轮排序: " + Arrays.toString(a));
if (lastSwapIdx == 0) {
break;
}
}
}
/**
* 交换
*
* @param a
* @param i
* @param j
*/
public static void swap(int[] a, int i, int j) {
int temp = a[i];
a[i] = a[j];
a[j] = temp;
}
2.2、选择排序
/**
* 选择排序
* 数组长度length
* 需要比较length - 1轮
* 每轮比较都将找到一个最小元素放在已排序区
*/
@Test
public void test6() {
int[] a = {5, 3, 7, 2, 1, 9, 8, 4};
selection(a);
}
/**
* 选择排序
*
* @param a
*/
private void selection(int[] a) {
for (int i = 0; i < a.length - 1; i++) {
// 用于记录当前轮比较获得的最小的元素索引
int s = i; // 默认当前最小索引为0
// int j = s + 1;从用当前最小索引为0开始直接比较下一个比较大小
// 每轮已排序区都会增加
for (int j = s + 1; j < a.length; j++) {
if (a[s] > a[j]) {
s = j;
}
}
// 如果当前索引在该轮没发生变化则说明当前索引就是该轮比较元素的最小值
if (s != i) {
swap(a, i, s);
}
System.out.println(Arrays.toString(a));
}
}
2.3、插入排序
public void test7() {
int[] a = {9, 3, 7, 2, 5, 8, 1, 4};
insert(a);
}
private void insert(int[] a) {
// i 为待插入元素的索引
for (int i = 1; i < a.length; i++) {
int t = a[i]; // 获取待插入元素的值
int j = i - 1; // 已排序区的索引
while (j >= 0) {
if (a[j] > t) {
a[j + 1] = a[j];
} else {
break;
}
j--;
}
// 此时j + 1 待插入元素插入已排序区的索引
a[j + 1] = t;
System.out.println(Arrays.toString(a));
}
}
3、布隆过滤器
布隆过滤器的优点:
时间复杂度低,增加和查询元素的时间复杂为O(N),(N为哈希函数的个数,通常情况比较小)
保密性强,布隆过滤器不存储元素本身
存储空间小,如果允许存在一定的误判,布隆过滤器是非常节省空间的(相比其他数据结构如Set集合)
布隆过滤器的缺点:
有点一定的误判率,但是可以通过调整参数来降低
无法获取元素本身
很难删除元素
布隆过滤器可以告诉我们 “某样东西一定不存在或者可能存在”,也就是说布隆过滤器说这个数不存在则一定不存,布隆过滤器说这个数存在可能不存在(误判,后续会讲),利用这个判断是否存在的特点可以做很多有趣的事情。
解决Redis缓存穿透问题(面试重点)
邮件过滤,使用布隆过滤器来做邮件黑名单过滤
对爬虫网址进行过滤,爬过的不再爬
解决新闻推荐过的不再推荐(类似抖音刷过的往下滑动不再刷到)
HBase\RocksDB\LevelDB等数据库内置布隆过滤器,用于判断数据是否存在,可以减少数据库的IO请求
布隆过滤器的原理
布隆过滤器它实际上是一个很长的二进制向量和一系列随机映射函数。以Redis中的布隆过滤器实现为例,Redis中的布隆过滤器底层是一个大型位数组(二进制数组)+多个无偏hash函数。
一个大型位数组(二进制数组):
多个无偏hash函数:
无偏hash函数就是能把元素的hash值计算的比较均匀的hash函数,能使得计算后的元素下标比较均匀的映射到位数组中。
如下就是一个简单的布隆过滤器示意图,其中k1、k2代表增加的元素,a、b、c即为无偏hash函数,最下层则为二进制数组。
在布隆过滤器增加元素之前,首先需要初始化布隆过滤器的空间,也就是上面说的二进制数组,除此之外还需要计算无偏hash函数的个数。布隆过滤器提供了两个参数,分别是预计加入元素的大小n,运行的错误率f。布隆过滤器中有算法根据这两个参数会计算出二进制数组的大小,以及无偏hash函数的个数k。
它们之间的关系比较简单:
- 错误率越低,位数组越长,控件占用较大
- 错误率越低,无偏hash函数越多,计算耗时较长
Bloom Filter Calculator (krisives.github.io)
增加元素
往布隆过滤器增加元素,添加的key需要根据k个无偏hash函数计算得到多个hash值,然后对数组长度进行取模得到数组下标的位置,然后将对应数组下标的位置的值置为1
-
通过k个无偏hash函数计算得到k个hash值
-
依次取模数组长度,得到数组索引
-
将计算得到的数组索引下标位置数据修改为1、
例如,key = Liziba,无偏hash函数的个数k=3,分别为hash1、hash2、hash3。三个hash函数计算后得到三个数组下标值,并将其值修改为1.
如图所示:
查询元素
布隆过滤器最大的用处就在于判断某样东西一定不存在或者可能存在,而这个就是查询元素的结果。其查询元素的过程如下:
- 通过k个无偏hash函数计算得到k个hash值
- 依次取模数组长度,得到数组索引
- 判断索引处的值是否全部为1,如果全部为1则存在(这种存在可能是误判),如果存在一个0则必定不存在
关于误判,其实非常好理解,hash函数在怎么好,也无法完全避免hash冲突,也就是说可能会存在多个元素计算的hash值是相同的,那么它们取模数组长度后的到的数组索引也是相同的,这就是误判的原因。
数据结构
设计模式
1、单例模式
要求
- 掌握五种单例模式的实现方式
- 理解为何 DCL 实现时要使用 volatile 修饰静态变量
- 了解 jdk 中用到单例的场景
饿汉式
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()
是防止反序列化破坏单例
枚举饿汉式
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()");
}
}
- 枚举饿汉式能天然防止反射、反序列化破坏单例
懒汉式
public class Singleton3 implements Serializable {
private Singleton3() {
System.out.println("private Singleton3()");
}
private static Singleton3 INSTANCE = null;
// Singleton3.class
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()");
}
}
- 避免了双检锁的缺点
JDK 中单例的体现
- Runtime 体现了饿汉式单例
- Console 体现了双检锁懒汉式单例
- Collections 中的 EmptyNavigableSet 内部类懒汉式单例
- ReverseComparator.REVERSE_ORDER 内部类懒汉式单例
- Comparators.NaturalOrderComparator.INSTANCE 枚举饿汉式单例
JAVA基础
1、ArrayList容量扩容
Java 1.8 使用空参构造器,默认数组容量为0,当add的时候才会初始化容量为10的数组。
// 使用空参构造器,默认数组容量为0
private static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {};
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA;
}
// 首次添加计算容量
private static final int DEFAULT_CAPACITY = 10;
private static int calculateCapacity(Object[] elementData, int minCapacity) {
// 此时数组为空条件成立
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
return Math.max(DEFAULT_CAPACITY, minCapacity);
}
return minCapacity;
}
(add)每次扩容为原数组的1.5倍扩容,并拷贝旧数据到新数组中,旧数据无引用指向则会被垃圾回收。
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
// 新数组容量为来数组的1.5倍 oldCapacity + (oldCapacity >> 1)
// (oldCapacity >> 1) 正数时候(oldCapacity >> 1)相当于处二向下取整
int newCapacity = oldCapacity + (oldCapacity >> 1);
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
// 复制旧数组到新数组中
elementData = Arrays.copyOf(elementData, newCapacity);
}
(addAll) 每次扩容会把默认扩容的容量 和 添加集合的容量做对比 选择最大的进行扩容
例如:第一次addAll 9条数据,则会选择10的初始容量,如果大于10条比如11条则会选择11为初始容量
以后每次添加如果添加的集合小于默认1.5倍的扩容就选择1.5倍的扩容容量,反之,如容量为10的时候,扩容1.5倍为15的容量,但是addAll 6条数据超过了15容量,则会选择扩容到16。
public boolean addAll(Collection<? extends E> c) {
Object[] a = c.toArray(); // 新数组
int numNew = a.length;
ensureCapacityInternal(size + numNew); // Increments modCount
System.arraycopy(a, 0, elementData, size, numNew);
size += numNew;
return numNew != 0;
}
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// overflow-conscious code
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
// 默认扩容
int newCapacity = oldCapacity + (oldCapacity >> 1);
// 如果默认扩容小于新数组.size + old.size则直接用新数组.size + old.size
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}
// minCapacity = 新数组.size + old.size
private static int calculateCapacity(Object[] elementData, int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
// 如果
return Math.max(DEFAULT_CAPACITY, minCapacity);
}
return minCapacity;
}
2、集合并发
ArrayList并发: 当遍历集合时候,其他线程向集合中追加元素
结果:java.util.ConcurrentModificationException
public void failFast(){
List<String> list = new ArrayList<>();
list.add("A");
list.add("B");
list.add("C");
list.add("D");
for (String s : list) {
System.out.println(s);
}
}
// 当使用for循环遍历,其实是使用Iterator
private class Itr implements Iterator<E>
// 源码
private class Itr implements Iterator<E> {
int expectedModCount = modCount;
Itr() {}
public boolean hasNext() {
return cursor != size;
}
@SuppressWarnings("unchecked")
public E next() {
// 遍历下一个元素会先判断是否有并发修改
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() {
// modCount集合个数 ,expectedModCount是Itr里面第一次
// 遍历记录的遍历个数,如果不相等则抛出并发修改异常
if (modCount != expectedModCount)
throw new ConcurrentModificationException();
}
}
**CopyOnWriteArrayList并发:**当遍历集合时候,其他线程向集合中追加元素
**结果:**不会抛出异常正常遍历完成,但是遍历的不是最新的数据。
CopyOnWriteArrayList采用读写分离,原理是在iterator遍历的时候会把旧的数组保存,遍历旧的数组,而add的时候则是获取旧数据通过复制并+1生成新的数组存起来。
public void failSafe(){
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
list.add("A");
list.add("B");
list.add("C");
list.add("D");
for (String s : list) {
System.out.println(s);
}
System.out.println("list = " + list);
}
// 遍历源码
public Iterator<E> iterator() {
// 遍历的时候会new COWIterator并获取getArray()当前数组的数据
return new COWIterator<E>(getArray(), 0);
}
static final class COWIterator<E> implements ListIterator<E> {
/** Snapshot of the array */
private final Object[] snapshot; // 保存遍历的数组
/** Index of element to be returned by subsequent call to next. */
private int cursor;
private COWIterator(Object[] elements, int initialCursor) {
cursor = initialCursor;
snapshot = elements; // 当前数组的数据保存到自己的snapshot,相当于一个快照
}
@SuppressWarnings("unchecked")
public E next() {
if (! hasNext())
throw new NoSuchElementException();
return (E) snapshot[cursor++]; // 遍历的数据是snapshot迭代器内部的数组
}
}
// add源码
public boolean add(E e) {
final ReentrantLock lock = this.lock;
lock.lock();
try {
Object[] elements = getArray(); // 获取旧数组
int len = elements.length;
// copy并创建len+1的数组
Object[] newElements = Arrays.copyOf(elements, len + 1);
newElements[len] = e; // 将新元素加到最后一个位置
setArray(newElements); // 重新设置集合
return true;
} finally {
lock.unlock();
}
}
3、LinkedList Vs ArrayList
随机访问快慢问题,数组是连续内存,只要知道了第一个元素索引的地址,每个元素的空间固定则可快速计算对应索引的地址。而链表在随机访问中,需要从头节点挨个指针引用去找。
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 表的查找,更新的时间复杂度是 O ( 1 ) O(1) O(1),而红黑树的查找,更新的时间复杂度是 O ( l o g 2 n ) O(log_2n ) O(log2n),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)索引计算
索引计算方法
- 首先,计算对象的 hashCode()
- 再进行调用 HashMap 的 hash() 方法进行二次哈希
- 二次 hash() 是为了综合高位数据,让哈希分布更为均匀
- 最后 & (capacity – 1) 得到索引
数组容量为何是 2 的 n 次幂
- 计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算代替取模
- 扩容时重新计算索引效率更高: hash & oldCap == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap
注意
- 二次 hash 是为了配合 容量是 2 的 n 次幂 这一设计前提,如果 hash 表的容量不是 2 的 n 次幂,则不必二次 hash
- 容量是 2 的 n 次幂 这一设计计算索引效率更好,但 hash 的分散性就不好,需要二次 hash 来作为补偿,没有采用这一设计的典型例子是 Hashtable
4)put 与扩容
put 流程
- HashMap 是懒惰创建数组的,首次使用才创建数组
- 计算索引(桶下标)
- 如果桶下标还没人占用,创建 Node 占位返回
- 如果桶下标已经有人占用
- 已经是 TreeNode 走红黑树的添加或更新逻辑
- 是普通 Node,走链表的添加或更新逻辑,如果链表长度超过树化阈值,走树化逻辑
- 返回前检查容量是否超过阈值,一旦超过进行扩容
1.7 与 1.8 的区别
-
链表插入节点时,1.7 是头插法,1.8 是尾插法
-
1.7 是大于等于阈值且没有空位时才扩容,而 1.8 是大于阈值就扩容
-
1.8 在扩容计算 Node 索引时,会优化
扩容(加载)因子为何默认是 0.75f
- 在空间占用与查询时间之间取得较好的权衡
- 大于这个值,空间节省了,但链表就会比较长影响性能
- 小于这个值,冲突减少了,但扩容就会更频繁,空间占用也更多
5)并发问题
扩容死链(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 的内容不能修改(不可变)
- key 的 hashCode 应该有良好的散列性
如果 key 可变,例如修改了 age 会导致再次查询时查询不到
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 足够独特
- 字符串中的每个字符都可以表现为一个数字,称为 S i S_i Si,其中 i 的范围是 0 ~ n - 1
- 散列公式为: S 0 ∗ 3 1 ( n − 1 ) + S 1 ∗ 3 1 ( n − 2 ) + … S i ∗ 3 1 ( n − 1 − i ) + … S ( n − 1 ) ∗ 3 1 0 S_0∗31^{(n-1)}+ S_1∗31^{(n-2)}+ … S_i ∗ 31^{(n-1-i)}+ …S_{(n-1)}∗31^0 S0∗31(n−1)+S1∗31(n−2)+…Si∗31(n−1−i)+…S(n−1)∗310
- 31 代入公式有较好的散列特性,并且 31 * h 可以被优化为
- 即 $32 ∗h -h $
- 即 2 5 ∗ h − h 2^5 ∗h -h 25∗h−h
- 即 h ≪ 5 − h h≪5 -h h≪5−h
并发
1. 线程状态
要求
- 掌握 Java 线程六种状态
- 掌握 Java 线程状态转换
- 能理解五种状态与六种状态两种说法的区别
六种状态及转换
分别是
- 新建
- 当一个线程对象被创建,但还未调用 start 方法时处于新建状态
- 此时未与操作系统底层线程关联
- 可运行
- 调用了 start 方法,就会由新建进入可运行
- 此时与底层线程关联,由操作系统调度执行
- 终结
- 线程内代码已经执行完毕,由可运行进入终结
- 此时会取消与底层线程关联
- 阻塞
- 当获取锁失败后,由可运行进入 Monitor 的阻塞队列阻塞,此时不占用 cpu 时间
- 当持锁线程释放锁时,会按照一定规则唤醒阻塞队列中的阻塞线程,唤醒后的线程进入可运行状态
- 等待
- 当获取锁成功后,但由于条件不满足,调用了 wait() 方法,此时从可运行状态释放锁进入 Monitor 等待集合等待,同样不占用 cpu 时间
- 当其它持锁线程调用 notify() 或 notifyAll() 方法,会按照一定规则唤醒等待集合中的等待线程,恢复为可运行状态
- 有时限等待
- 当获取锁成功后,但由于条件不满足,调用了 wait(long) 方法,此时从可运行状态释放锁进入 Monitor 等待集合进行有时限等待,同样不占用 cpu 时间
- 当其它持锁线程调用 notify() 或 notifyAll() 方法,会按照一定规则唤醒等待集合中的有时限等待线程,恢复为可运行状态,并重新去竞争锁
- 如果等待超时,也会从有时限等待状态恢复为可运行状态,并重新去竞争锁
- 还有一种情况是调用 sleep(long) 方法也会从可运行状态进入有时限等待状态,但与 Monitor 无关,不需要主动唤醒,超时时间到自然恢复为可运行状态
其它情况(只需了解)
- 可以用 interrupt() 方法打断等待、有时限等待的线程,让它们恢复为可运行状态
- park,unpark 等方法也可以让线程等待和唤醒
五种状态
五种状态的说法来自于操作系统层面的划分
- 运行态:分到 cpu 时间,能真正执行线程内代码的
- 就绪态:有资格分到 cpu 时间,但还未轮到它的
- 阻塞态:没资格分到 cpu 时间的
- 涵盖了 java 状态中提到的阻塞、等待、有时限等待
- 多出了阻塞 I/O,指线程在调用阻塞 I/O 时,实际活由 I/O 设备完成,此时线程无事可做,只能干等
- 新建与终结态:与 java 中同名状态类似,不再啰嗦
2. 线程池
要求
- 掌握线程池的 7 大核心参数
七大参数
- corePoolSize 核心线程数目 - 池中会保留的最多线程数
- maximumPoolSize 最大线程数目 - 核心线程+救急线程的最大数目
- keepAliveTime 生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放
- unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等
- workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
- threadFactory 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等
- handler 拒绝策略 - 当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略
- 抛异常 java.util.concurrent.ThreadPoolExecutor.AbortPolicy
- 由调用者执行任务 java.util.concurrent.ThreadPoolExecutor.CallerRunsPolicy
- 丢弃任务 java.util.concurrent.ThreadPoolExecutor.DiscardPolicy
- 丢弃最早排队任务 java.util.concurrent.ThreadPoolExecutor.DiscardOldestPolicy
代码说明
public class TestThreadPoolExecutor { public static void main(String[] args) throws InterruptedException { AtomicInteger c = new AtomicInteger(1); ArrayBlockingQueue<Runnable> queue = new ArrayBlockingQueue<>(2); ThreadPoolExecutor threadPool = new ThreadPoolExecutor( 2, 3, 0, TimeUnit.MILLISECONDS, queue, r -> new Thread(r, "myThread" + c.getAndIncrement()), new ThreadPoolExecutor.DiscardOldestPolicy()); showState(queue, threadPool); threadPool.submit(new MyTask("1", 3600000)); showState(queue, threadPool); threadPool.submit(new MyTask("2", 3600000)); showState(queue, threadPool); threadPool.submit(new MyTask("3")); showState(queue, threadPool); threadPool.submit(new MyTask("4")); showState(queue, threadPool); threadPool.submit(new MyTask("5", 3600000)); showState(queue, threadPool); threadPool.submit(new MyTask("6")); showState(queue, threadPool); } private static void showState(ArrayBlockingQueue<Runnable> queue, ThreadPoolExecutor threadPool) { try { Thread.sleep(300); } catch (InterruptedException e) { e.printStackTrace(); } List<Object> tasks = new ArrayList<>(); for (Runnable runnable : queue) { try { Field callable = FutureTask.class.getDeclaredField("callable"); callable.setAccessible(true); Object adapter = callable.get(runnable); Class<?> clazz = Class.forName("java.util.concurrent.Executors$RunnableAdapter"); Field task = clazz.getDeclaredField("task"); task.setAccessible(true); Object o = task.get(adapter); tasks.add(o); } catch (Exception e) { e.printStackTrace(); } } main.debug("pool size: {}, queue: {}", threadPool.getPoolSize(), tasks); } static class MyTask implements Runnable { private final String name; private final long duration; public MyTask(String name) { this(name, 0); } public MyTask(String name, long duration) { this.name = name; this.duration = duration; } @Override public void run() { try { LoggerUtils.get("myThread").debug("running..." + this); Thread.sleep(duration); } catch (InterruptedException e) { e.printStackTrace(); } } @Override public String toString() { return "MyTask(" + name + ")"; } } }
3. wait vs sleep
要求
- 能够说出二者区别
一个共同点,三个不同点
共同点
- wait() ,wait(long) 和 sleep(long) 的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态
不同点
-
方法归属不同
- sleep(long) 是 Thread 的静态方法
- 而 wait(),wait(long) 都是 Object 的成员方法,每个对象都有
-
醒来时机不同
- 执行 sleep(long) 和 wait(long) 的线程都会在等待相应毫秒后醒来
- wait(long) 和 wait() 还可以被 notify 唤醒,wait() 如果不唤醒就一直等下去
- 它们都可以被打断唤醒
-
锁特性不同(重点)
- wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制
- wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃 cpu,但你们还可以用)
- 而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(我放弃 cpu,你们也用不了)
4. lock vs synchronized
要求
- 掌握 lock 与 synchronized 的区别
- 理解 ReentrantLock 的公平、非公平锁
- 理解 ReentrantLock 中的条件变量
三个层面
不同点
- 语法层面
- synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现
- Lock 是接口,源码由 jdk 提供,用 java 语言实现
- 使用 synchronized 时,退出同步代码块锁会自动释放,而使用 Lock 时,需要手动调用 unlock 方法释放锁
- 功能层面
- 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
- Lock 提供了许多 synchronized 不具备的功能,例如获取等待状态、公平锁、可打断、可超时、多条件变量
- Lock 有适合不同场景的实现,如 ReentrantLock, ReentrantReadWriteLock
- 性能层面
- 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
- 在竞争激烈时,Lock 的实现通常会提供更好的性能
公平锁
- 公平锁的公平体现
- 已经处在阻塞队列中的线程(不考虑超时)始终都是公平的,先进先出
- 公平锁是指未处于阻塞队列中的线程来争抢锁,如果队列不为空,则老实到队尾等待
- 非公平锁是指未处于阻塞队列中的线程来争抢锁,与队列头唤醒的线程去竞争,谁抢到算谁的
- 公平锁会降低吞吐量,一般不用
条件变量
- ReentrantLock 中的条件变量功能类似于普通 synchronized 的 wait,notify,用在当线程获得锁后,发现条件不满足时,临时等待的链表结构
- 与 synchronized 的等待集合不同之处在于,ReentrantLock 中的条件变量可以有多个,可以实现更精细的等待、唤醒控制
代码说明
- TestReentrantLock 用较为形象的方式演示 ReentrantLock 的内部结构
5. volatile
要求
- 掌握线程安全要考虑的三个问题
- 掌握 volatile 能解决哪些问题
原子性
- 起因:多线程下,不同线程的指令发生了交错导致的共享变量的读写混乱
- 解决:用悲观锁或乐观锁解决,volatile 并不能解决原子性
可见性
- 起因:由于编译器优化、或缓存优化、或 CPU 指令重排序优化导致的对共享变量所做的修改另外的线程看不到
- 解决:用 volatile 修饰共享变量,能够防止编译器等优化发生,让一个线程对共享变量的修改对另一个线程可见
有序性
- 起因:由于编译器优化、或缓存优化、或 CPU 指令重排序优化导致指令的实际执行顺序与编写顺序不一致
- 解决:用 volatile 修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果
- 注意:
- volatile 变量写加的屏障是阻止上方其它写操作越过屏障排到 volatile 变量写之下
- volatile 变量读加的屏障是阻止下方其它读操作越过屏障排到 volatile 变量读之上
- volatile 读写加入的屏障只能防止同一线程内的指令重排
代码说明
- day02.threadsafe.AddAndSubtract 演示原子性
- day02.threadsafe.ForeverLoop 演示可见性
- 注意:本例经实践检验是编译器优化导致的可见性问题
- day02.threadsafe.Reordering 演示有序性
- 需要打成 jar 包后测试
- 请同时参考视频讲解
6. 悲观锁 vs 乐观锁
要求
- 掌握悲观锁和乐观锁的区别
对比悲观锁与乐观锁
-
悲观锁的代表是 synchronized 和 Lock 锁
- 其核心思想是【线程只有占有了锁,才能去操作共享变量,每次只有一个线程占锁成功,获取锁失败的线程,都得停下来等待】
- 线程从运行到阻塞、再从阻塞到唤醒,涉及线程上下文切换,如果频繁发生,影响性能
- 实际上,线程在获取 synchronized 和 Lock 锁时,如果锁已被占用,都会做几次重试操作,减少阻塞的机会
-
乐观锁的代表是 AtomicInteger,使用 cas 来保证原子性
- 其核心思想是【无需加锁,每次只有一个线程能成功修改共享变量,其它失败的线程不需要停止,不断重试直至成功】
- 由于线程一直运行,不需要阻塞,因此不涉及线程上下文切换
- 它需要多核 cpu 支持,且线程数不应超过 cpu 核数
代码说明
- day02.SyncVsCas 演示了分别使用乐观锁和悲观锁解决原子赋值
- 请同时参考视频讲解
7. Hashtable vs ConcurrentHashMap
要求
- 掌握 Hashtable 与 ConcurrentHashMap 的区别
- 掌握 ConcurrentHashMap 在不同版本的实现区别
更形象的演示,见资料中的 hash-demo.jar,运行需要 jdk14 以上环境,进入 jar 包目录,执行下面命令
java -jar --add-exports java.base/jdk.internal.misc=ALL-UNNAMED hash-demo.jar
Hashtable 对比 ConcurrentHashMap
- Hashtable 与 ConcurrentHashMap 都是线程安全的 Map 集合
- Hashtable 并发度低,整个 Hashtable 对应一把锁,同一时刻,只能有一个线程操作它
- ConcurrentHashMap 并发度高,整个 ConcurrentHashMap 对应多把锁,只要线程访问的是不同锁,那么不会冲突
ConcurrentHashMap 1.7
- 数据结构:
Segment(大数组) + HashEntry(小数组) + 链表
,每个 Segment 对应一把锁,如果多个线程访问不同的 Segment,则不会冲突 - 并发度:Segment 数组大小即并发度,决定了同一时刻最多能有多少个线程并发访问。Segment 数组不能扩容,意味着并发度在 ConcurrentHashMap 创建时就固定了
- 索引计算
- 假设大数组长度是 2 m 2^m 2m,key 在大数组内的索引是 key 的二次 hash 值的高 m 位
- 假设小数组长度是 2 n 2^n 2n,key 在小数组内的索引是 key 的二次 hash 值的低 n 位
- 扩容:每个小数组的扩容相对独立,小数组在超过扩容因子时会触发扩容,每次扩容翻倍
- Segment[0] 原型:首次创建其它小数组时,会以此原型为依据,数组长度,扩容因子都会以原型为准
ConcurrentHashMap 1.8
- 数据结构:
Node 数组 + 链表或红黑树
,数组的每个头节点作为锁,如果多个线程访问的头节点不同,则不会冲突。首次生成头节点时如果发生竞争,利用 cas 而非 syncronized,进一步提升性能 - 并发度:Node 数组有多大,并发度就有多大,与 1.7 不同,Node 数组可以扩容
- 扩容条件:Node 数组满 3/4 时就会扩容
- 扩容单位:以链表为单位从后向前迁移链表,迁移完成的将旧数组头节点替换为 ForwardingNode
- 扩容时并发 get
- 根据是否为 ForwardingNode 来决定是在新数组查找还是在旧数组查找,不会阻塞
- 如果链表长度超过 1,则需要对节点进行复制(创建新节点),怕的是节点迁移后 next 指针改变
- 如果链表最后几个元素扩容后索引不变,则节点无需复制
- 扩容时并发 put
- 如果 put 的线程与扩容线程操作的链表是同一个,put 线程会阻塞
- 如果 put 的线程操作的链表还未迁移完成,即头节点不是 ForwardingNode,则可以并发执行
- 如果 put 的线程操作的链表已经迁移完成,即头结点是 ForwardingNode,则可以协助扩容
- 与 1.7 相比是懒惰初始化
- capacity 代表预估的元素个数,capacity / factory 来计算出初始数组大小,需要贴近 2 n 2^n 2n
- loadFactor 只在计算初始数组大小时被使用,之后扩容固定为 3/4
- 超过树化阈值时的扩容问题,如果容量已经是 64,直接树化,否则在原来容量基础上做 3 轮扩容
8. ThreadLocal
要求
- 掌握 ThreadLocal 的作用与原理
- 掌握 ThreadLocal 的内存释放时机
作用
- ThreadLocal 可以实现【资源对象】的线程隔离,让每个线程各用各的【资源对象】,避免争用引发的线程安全问题
- ThreadLocal 同时实现了线程内的资源共享
原理
每个线程内有一个 ThreadLocalMap 类型的成员变量,用来存储资源对象
- 调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线程的 ThreadLocalMap 集合中
- 调用 get 方法,就是以 ThreadLocal 自己作为 key,到当前线程中查找关联的资源值
- 调用 remove 方法,就是以 ThreadLocal 自己作为 key,移除当前线程关联的资源值
ThreadLocalMap 的一些特点
- key 的 hash 值统一分配
- 初始容量 16,扩容因子 2/3,扩容容量翻倍
- key 索引冲突后用开放寻址法解决冲突
弱引用 key
ThreadLocalMap 中的 key 被设计为弱引用,原因如下
- Thread 可能需要长时间运行(如线程池中的线程),如果 key 不再使用,需要在内存不足(GC)时释放其占用的内存
内存释放时机
- 被动 GC 释放 key
- 仅是让 key 的内存释放,关联 value 的内存并不会释放
- 懒惰被动释放 value
- get key 时,发现是 null key,则释放其 value 内存
- set key 时,会使用启发式扫描,清除临近的 null key 的 value 内存,启发次数与元素个数,是否发现 null key 有关
- 主动 remove 释放 key,value
- 会同时释放 key,value 的内存,也会清除临近的 null key 的 value 内存
- 推荐使用它,因为一般使用 ThreadLocal 时都把它作为静态变量(即强引用),因此无法被动依靠 GC 回收
源码
// 当调用ThreadLocal的set方法设置当前线程的独有值
public void set(T value) {
// 获取当前操作线程
Thread t = Thread.currentThread();
// 查看当前线程的ThreadLocalMap是否为null
ThreadLocalMap map = getMap(t);
// 如果不为null则直接添加。
if (map != null)
// 这里添加的就是往当前线程Thread对象里面的ThreadLocalMap去put操作
// 把this,也就是当前ThreadLocal对象作为key,输入的值作为value
map.set(this, value);
else
// 如果为空则去创建当前线程的ThreadLocalMap,并put操作
createMap(t, value);
}
框架
一、Spring
1、Spring refresh 流程
要求
- 掌握 refresh 的 12 个步骤
Spring refresh 概述
refresh 是 AbstractApplicationContext 中的一个方法,负责初始化 ApplicationContext 容器,容器必须调用 refresh 才能正常工作。它的内部主要会调用 12 个方法,我们把它们称为 refresh 的 12 个步骤:
-
prepareRefresh
-
obtainFreshBeanFactory
-
prepareBeanFactory
-
postProcessBeanFactory
-
invokeBeanFactoryPostProcessors
-
registerBeanPostProcessors
-
initMessageSource
-
initApplicationEventMulticaster
-
onRefresh
-
registerListeners
-
finishBeanFactoryInitialization
-
finishRefresh
功能分类
1 为准备环境
2 3 4 5 6 为准备 BeanFactory
7 8 9 10 12 为准备 ApplicationContext
11 为初始化 BeanFactory 中非延迟单例 bean
1.1 prepareRefresh
-
这一步创建和准备了 Environment 对象,它作为 ApplicationContext 的一个成员变量
-
Environment 对象的作用之一是为后续 @Value,值注入时提供键值
-
Environment 分成三个主要部分
- systemProperties - 保存 java 环境键值
- systemEnvironment - 保存系统环境键值
- 自定义 PropertySource - 保存自定义键值,例如来自于 *.properties 文件的键值
1.2. obtainFreshBeanFactory
- 这一步获取(或创建) BeanFactory,它也是作为 ApplicationContext 的一个成员变量
- BeanFactory 的作用是负责 bean 的创建、依赖注入和初始化,bean 的各项特征由 BeanDefinition 定义
- BeanDefinition 作为 bean 的设计蓝图,规定了 bean 的特征,如单例多例、依赖关系、初始销毁方法等
- BeanDefinition 的来源有多种多样,可以是通过 xml 获得、配置类获得、组件扫描获得,也可以是编程添加
- 所有的 BeanDefinition 会存入 BeanFactory 中的 beanDefinitionMap 集合
1.3. prepareBeanFactory
- 这一步会进一步完善 BeanFactory,为它的各项成员变量赋值
- beanExpressionResolver 用来解析 SpEL,常见实现为 StandardBeanExpressionResolver
- propertyEditorRegistrars 会注册类型转换器
- 它在这里使用了 ResourceEditorRegistrar 实现类
- 并应用 ApplicationContext 提供的 Environment 完成 ${ } 解析
- registerResolvableDependency 来注册 beanFactory 以及 ApplicationContext,让它们也能用于依赖注入
- beanPostProcessors 是 bean 后处理器集合,会工作在 bean 的生命周期各个阶段,此处会添加两个:
- ApplicationContextAwareProcessor 用来解析 Aware 接口
- ApplicationListenerDetector 用来识别容器中 ApplicationListener 类型的 bean
1.4. postProcessBeanFactory
- 这一步是空实现,留给子类扩展。
- 一般 Web 环境的 ApplicationContext 都要利用它注册新的 Scope,完善 Web 下的 BeanFactory
- 这里体现的是模板方法设计模式
1.5. invokeBeanFactoryPostProcessors
- 这一步会调用 beanFactory 后处理器
- beanFactory 后处理器,充当 beanFactory 的扩展点,可以用来补充或修改 BeanDefinition
- 常见的 beanFactory 后处理器有
- ConfigurationClassPostProcessor – 解析 @Configuration、@Bean、@Import、@PropertySource 等
- PropertySourcesPlaceHolderConfigurer – 替换 BeanDefinition 中的 ${ }
- MapperScannerConfigurer – 补充 Mapper 接口对应的 BeanDefinition
1.6. registerBeanPostProcessors
此步骤只是注册了后置处理器
- 这一步是继续从 beanFactory 中找出 bean 后处理器,添加至 beanPostProcessors 集合中
- bean 后处理器,充当 bean 的扩展点,可以工作在 bean 的实例化、依赖注入、初始化阶段,常见的有:
- AutowiredAnnotationBeanPostProcessor 功能有:解析 @Autowired,@Value 注解
- CommonAnnotationBeanPostProcessor 功能有:解析 @Resource,@PostConstruct,@PreDestroy
- AnnotationAwareAspectJAutoProxyCreator 功能有:为符合切点的目标 bean 自动创建代理
1.7. initMessageSource
- 这一步是为 ApplicationContext 添加 messageSource 成员,实现国际化功能
- 去 beanFactory 内找名为 messageSource 的 bean,如果没有,则提供空的 MessageSource 实现
1.8. initApplicationContextEventMulticaster
- 这一步为 ApplicationContext 添加事件广播器成员,即 applicationContextEventMulticaster
- 它的作用是发布事件给监听器
- 去 beanFactory 找名为 applicationEventMulticaster 的 bean 作为事件广播器,若没有,会创建默认的事件广播器
- 之后就可以调用 ApplicationContext.publishEvent(事件对象) 来发布事件
1.9. onRefresh
Springboot内嵌tomcat就是在此处实现并启动的
- 这一步是空实现,留给子类扩展
- SpringBoot 中的子类在这里准备了 WebServer,即内嵌 web 容器
- 体现的是模板方法设计模式
1.10. registerListeners
- 这一步会从多种途径找到事件监听器,并添加至 applicationEventMulticaster
- 事件监听器顾名思义,用来接收事件广播器发布的事件,有如下来源
- 事先编程添加的
- 来自容器中的 bean
- 来自于 @EventListener 的解析
- 要实现事件监听器,只需要实现 ApplicationListener 接口,重写其中 onApplicationEvent(E e) 方法即可
JVM
数据库
Mysql
一、主键索引
innoDB 页 = 16kb。 查询数据的时候避免多次io,mysql每次io至少取出一页数据。
// a为主键,插入后自动按主键排序 e字段为varchar 其他为int类型
insert into t1(a,b,c,d,e) values(4,3,1,1'd');
insert into t1(a,b,c,d,e) values(8,8,8,8'h');
insert into t1(a,b,c,d,e) values(1,1,1,1'a');
insert into t1(a,b,c,d,e) values(2,2,2,2'b');
用户数据区域:存放数据并且按主键排序形成链表。页目录:是为了给数据分区,避免根据查询条件比较所有数据。如查询a = 2,1>a>4,则直接根据页目录可找到为1指向第一组的主键1,然后找到主键为2的数据。如果查询较大值也无需从头开始比较每个数据是否符合条件,提升查询效率。
假设一页数据最多能存4条数据,那么当再插入大于4条数据的时候这个时候就会在创建一页并且next指针指向下一页,比如这里插入主键a为3,5,6,7的数据的时候,结果如下图,你会发现依然会排好序a=3的那条数据还会存到第一页。其实这样就会需要把a=3放在a=4的前面,把a=8放到下一页,这个时候就可以理解innodb为什么提倡使用主键自增索引了,因为在建索引的时候只需要往后直接加数据就ok了。
而当数据页多的时候查询就会出现一个问题,根据查询条件不知道数据存放在那一页,要是全部遍历所有页去比较又会影响查询效率了,这个时候可以如下图所以再建一个管理主键的页,页里面的每个数据按排好序记录好每一页的最小主键索引的值以及数据页的地址。
此时就行成了B+tree
非叶子节点存储的都是主键索引,叶子点存放主键索引对应的数据,亦称为聚集索引。
范围查找
以上图索引图为例
- a > 6,走索引,先找到a=6的叶子节点数据,通过指针找到在a=6数据的右边的所有数据都返回
- a < 6,走索引,先找到a=6的叶子节点数据,通过指针找到在a=6数据的左边的所有数据都返回
- a != 6,无法走索引,比如在上图的索引的树的根节点,!= 6 无法判定往左还是往右走索引
由此可见B+tree叶子节点数据为什么使用双向链表指向了,方便使用范围查找!
如!=6则需要从叶子节点1开始的数据遍历全部数据,这就是所谓的 全表扫描
二、联合索引
现在对b,c,d三个字段建立联合索引
上图可见,联合索引不再在叶子节点存放全部的数据了,因为会和主键索引的数据重复,占用更多的空间,并且更新的时候需要维护多个索引树。 这里存放的是bcd三个字段的数据和主键的索引, 如果select * 则可以通过主键索引进行 回表 去主键索引树获取全部数据。
回表
覆盖索引
最左前缀匹配原则原理
-
select * from t1 where b=1 and c=1 and d=1,走索引,如下图,排序的时候就是先找bcd字段的顺序进行排序 的,先按b排序,如果b小则直接在左边,大则在右边,等于则比较c的值。而当前条件根据b=1就可比较出来走索引的路线。
-
select * from t1 where c=1 and d=1,无法走索引,如上图,红圈位置此时为(* 11),排序的时候就是先找bcd字段的顺序进行排序 的,先按b排序,如果b小则直接在左边,大则在右边,等于则比较c的值。而当前条件根据b=*无法判断走左边还是右边所以无法走索引。
-
select * from t1 where b=1 and d=1,走索引,如上图,红圈位置此时为(1 * 1),排序的时候就是先找bcd字段的顺序进行排序 的,先按b排序,如果b小则直接在左边,大则在右边,等于则比较c的值。而当前条件根据b=1可判断出走左边索引。select * from t1 where b=1 and c=1,圈位置此时为(11 *)同理也走索引。select * from t1 where b=1圈位置此时为(1 * * )同理也走索引。
-
select * from t1 where b > 1,其实不会走索引,虽然可以通过找到b = 1的索引,然后取右边的所有,但是这里是select *,每个数据都要通过主键再去主键索引树上面去找对应的数据,对应上图则会回表7次,会更慢,还不如全表扫描来的快。 但是这里如果b>6则会走索引。
-
select b from t1 where b>1,走索引,因为不需要回表,同样select b,c,d三个字段都不用回表都会走索引,而select b,c,d,a 同样会走索引 因为a是主键,联合索引树有存放主键的值不需要回表!(覆盖索引)
-
select b from t1 ,走联合索引,因为联合索引叶子节点存放只有bcd三个字段的数据,同样一页是16kb,主键索引叶子节点存放完整数据只能存放两条的话,联合索引可能能存放四条,则需要走的页数就会比走全表扫描要快!
联合索引排序
- select * from t1 order b,c,d ,不走索引,两种可能,一、走联合索引,好处不需要排序,因为叶子节点的顺序就是按bcd排序好的,但是select * 需要回表。二、走全表扫描,需要排序,好处不需要回表。
- select b from t1 order b,c,d 走索引,因为不需要回表。
类型转换问题
mysql 在与数值比较的时候例如 select 0 = ‘a’ 为1 说明,将字符串a转成0进行比较。对字符转数字会转成0,对’1’会转成数字1进行比较
explain select * from t1 where a=1; -- 走索引
explain select * from t1 where a='1'; -- 走索引
explain select * from t1 where e='1'; -- 走索引
explain select * from t1 where e=1; -- 不走索引 因为e是字符, 则与数字比较会把字符都转成数字,非数字字符都会转成0,则无法正确走索引
-- 只要对索引字段进行操作就会导致索引失效
explain select * from t1 where a+1=1 -- 索引失效