面试题

面试题

java

基础

二分

问题1:128个元素最多二分几次找到目标

答:128 = 2^7,最多7次

问题2:100000000000个元素最多二分几次找到目标

答:

​ 公式:logb(a) = logc(a) / logc(b)

​ log2(100000000000) = log10(100000000000) / log10(2),向上取整

排序

经典排序算法

冒泡排序:每次相邻两个元素比较,一趟选出一个最大/最小的元素

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 优化过的冒泡,记录最后一次交换索引作为下轮冒泡的比较次数
public void bubble(int[] arr) {
int n = arr.length - 1; // 记录每次循环比较的次数
while (n > 0) {
int last = 0; // 记录最后一次交换的索引
for (int i = 0; i < n; ++i) {
if (arr[i] > arr[i + 1]) {
swap(arr, i, i + 1);
last = i;
}
}
n = last;
}
}

选择排序:每次选择一个最大/最小元素,放到已排序区域的末尾

示例代码

1
2
3
4
5
6
7
8
9
10
public void selection(int[] arr) {
for (int i = 0; i < arr.length - 1; ++i) {
int s = i; // 最小元素下标
for (int j = s + 1; j < arr.length; ++j) {
if (arr[s] > arr[j])
s = j;
}
swap(arr, s, i);
}
}

插入排序:遍历每个元素,插入到已排序区域中适合的位置

示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
public void insert(int[] arr) {
for (int i = 1; i < arr.length; ++i) {
int tmp = arr[i];
for (int j = i - 1; j >= 0; --j) {
if (arr[j] > tmp) {
arr[j + 1] = arr[j];
} else {
break;
}
}
arr[j + 1] = tmp;
}
}

希尔排序:开始选定一个间隙n,每轮排序间隔n的元素,之后减小n再排序,直到n=1。这么做大的元素会更快的往右边靠,减少之后的移动次数。

快速排序:每轮找一个基准点进行分区,比基准点小的放到基准点左边,大的放到基准点右边。直到分区内元素个数小于等于1。

  1. 单边循环快排

    1. 以最右边元素作为基准点

    2. i,j指针指向左边界的元素

    3. j往右移,当找到比基准点小的元素就和i交换,i++

    4. 当交换完后i和基准点交换,这样基准点左边的元素就比基准点小

    示例代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    public staic void main(String[] args) {
    int[] arr = {2, 3, 1};
    quick(arr, 0, arr.length - 1);
    }

    public void quickSort(int[] arr, int l, int r) {
    if (l >= r)
    return;
    int p = partition(arr, l, r);
    partition(arr, l, p - 1);
    partition(arr, p + 1, r);
    }

    // 分区,返回中间下标
    private int partition(int[] arr, int l, int r) {
    if (l >= r)
    return l;
    int pv = a[r]; // 基准点
    int i = l;
    for (int j = l; j < r; ++j) {
    if (arr[j] < pv) {
    swap(arr, i, j);
    ++i;
    }
    }
    swap(arr, r, i);
    return i;
    }
  2. 双边循环快排(更快一点)

    1. 以最左边元素作为基准点
    2. i指向左边界,j指向右边界,两边互找
    3. 当i,j相遇时吧基准点和i交换

    示例代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    public staic void main(String[] args) {
    int[] arr = {2, 3, 1};
    quick(arr, 0, arr.length - 1);
    }

    public void quickSort(int[] arr, int l, int r) {
    if (l >= r)
    return;
    int p = partition(arr, l, r);
    partition(arr, l, p - 1);
    partition(arr, p + 1, r);
    }

    // 分区,返回中间下标
    private int partition(int[] arr, int l, int r) {
    int pv = arr[l];
    int i = l, j = r;
    while (i < j) {
    // 这两个while不能交换
    while (i < j && arr[j] > pv)
    --j;
    while (i < j && arr[i] <= pv)
    ++i;
    swap(arr, i, j);
    }
    swap(arr, l, i);
    return i;
    }

集合

ArrayList
成员变量/构造函数
变量 说明
Object[] elementData 存放元素
int size 大小
static final int DEFAULT_CAPACITY = 10 初始容量
static final Object[] EMPTY_ELEMENTDATA = {}
static final Object[] DEFAULTCAPACITY_EMPTY_ELEMENTDATA = {}
构造函数 说明
public ArrayList(int initialCapacity) 若参数合法则根据参数来初始化此容量的集合
public ArrayList() elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA
public ArrayList(Collection<? extends E> c) 将c转换成数组,将数组的地址赋给elementData
扩容机制

初始化:无参构造刚创建出来的时候容量为0,有参则按传入的值或集合的大小作为容量

add方法

  1. 添加元素时会先判断添加元素后的容量是否超过当前容量
  2. 如果是无参构造,首次添加元素时会创建一个容量为DEFAULT_CAPACITY=10的数组,
  3. 之后每次添加元素容量不足时会按原数组容量的1.5倍(向下取整)创建新数组,把原数组的元素复制到新数组中,用新数组替换原数组。

addAll方法:没有元素时扩容为Math.max(10, 实际元素个数),有元素时为Math.max(原容量1.5倍, 实际元素个数)。

迭代器

当在使用迭代器时有其它线程修改数组时有两种策略

  1. fail-fast:在使用迭代器的线程立即报错
  2. fail-safe:发现有人修改了则采取一些对策,例如牺牲一些一致性来让整个遍历完成

原理

​ fail-fast是在创建迭代器时记录数组被修改的次数,每次获取下一个元素时判断现在数组的修改次数是否和之前记录的保持一致,如果不一致则报错(类似乐观锁)。

ArrayList使用的第一种策略,CopyOnWriteArrayList是第二种策略(对策是添加把原集合复制出来,在新集合上进行修改,修改好后指向新集合。创建迭代器时会指向当前集合,遍历的是旧集合不影响(读写分离));

数组和List之间的转换

数组转List(浅拷贝)

1
2
String[] strs = new String[]{"a", "b"};
List<String> list = Arrays.asList(strs);

List转数组(深拷贝)

1
2
3
List<String> list = new ArrayList<>();
list.add("a"); list.add("b");
String[] strs = list.toArray(new String[list.size()]);
LinkedList
与ArrayList的比较
  • 底层数据结构

    ArrayList基于动态数组,需要连续内存

    LinkedList基于双向链表,无需连续内存

  • 效率

    ArrayList随机访问快,尾部插入和插入性能可以,其它部分都会移动元素,性能低

    LinkedList随机访问慢,尾插入删除性能高,其他部分效率低

  • 占用空间

    ArrayList可以利用cpu缓存,局部性原理

    LinkedList占用内存多

  • 线程安全

    ArrayList和LinkedList都不是线程安全的,可以通过

    1
    2
    List<Object> list = Collections.synchronizedList(new ArrayList<>());
    List<Object> list = Collections.synchronizedList(new LinkedList<>());

    包装一下,变成线程安全的

HashMap
问题
  • 链表过长时的解决方法

    桶数组是用来存储数据元素,链表是用来解决冲突,红黑树是为了提高查询的效率

    1. 数组扩容
      1. put元素后检查,当元素总数大于阈值(阈值=容量*负载因子)时会发生一次扩容
      2. 每次扩容都是之前容量的2倍
      3. 扩容之后会创建一个新的数组,把老数组中的元素迁移过去
        • 没有hash冲突的节点,直接e.hash & (newCap - 1)计算索引下标
        • 红黑树则走红黑树逻辑
        • 如果是链表,则需要遍历链表,可能需要拆分链表,判断e.hash & oldCap == 0是否成立,如果成立则仍在这个位置,否则移动到原始位置+oldCap
    2. 树化(jdk1.8):若数组容量小于64时会先尝试扩容,当数组容量超过64且链表长度大于8(阈值)时会树化
  • 底层数据结构,1.7和1.8有何不同?

    • 1.7为数组+链表,1.8为数组+(链表 | 红黑树)
    • 1.7为头插法,1.8为尾插法
    • 1.7先扩容后插入,1.8先插入后扩容(减少频繁转换的概率)
  • 为何要用红黑树,为何不一上来就树化,树化阈值为何是8,何时会树化,何时会退化成链表

    1. 大部分情况下链表长度是不会超过8的,只有在像Dos攻击时被注入大量key相同的元素时链表才会过长。红黑树用来防止链表过长时的性能下降。平衡二叉树是比红黑树更严格的平衡树,所以平衡二叉树插入和删除的效率 比红黑树要低,故用红黑树。

    2. hash表查找和更新的时间复杂度为O(1),红黑树为O(log2(n)),且红黑树的TreeNode比链表的Node占用内存大,所以尽量使用链表

    3. 选择8是为了让树化几率足够小;红黑树再转回链表的阈值是6,目的是防止节点数在8附近徘徊导致频繁发生转换

    4. 树化条件:链表长度>=阈值8 且 数组容量>=64

    5. 退化情况1:扩容时树被拆分,树元素个数<=6会退化成链表;

      退化情况2:remove节点时,检查若root,root.left,root.right,root.left.left中有一个为null,则退化

  • 索引是如何计算的?hashCode都有了为何还要hash()方法?数组容量为何是2^n?

    1
    2
    3
    4
    5
    6
    static final int hash(Object key) {
    int h;
    // key的hashCode和key的hashCode右移16位做异或运算
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
    }

    • 调用对象的hashCode()方法获得原始hash,再调用HashMap的hash()方法二次哈希,最后tab[i = (n - 1) & hash]获取到对应的位置,n为table的长度
    • 为了提高综合高位数据,让哈希分布更加均匀,减少冲突
    • 计算索引时,如果是2的n次幂可以使用位与运算代替取模,提高效率;扩容时hash & oldCap == 0的元素留在原处,否则新位置=旧位置 + oldCap
  • 介绍一下put方法流程,1.7和1.8的有何不同?

    1. 判断table是否为null,是的话执行resize()扩容(懒惰初始化)
    2. 计算索引(hashCode() –> hash() –> 和桶大小求模)
    3. 如果桶下标没人占用,则创建Node占位返回
    4. 如果有人占用了
      1. 如果是TreeNode走红黑树逻辑
      2. 如果是普通Node走链表逻辑,插入新节点后,如果需要树化则走树化逻辑
      3. 遍历链表或红黑树时发现key已存在则覆盖原先的值
    5. 返回前检查容量是否超过阈值,超过则扩容

    不同:

    1. 1.7是头插法,1.8是尾插法
    2. 1.7是元素数量>=阈值且没有空位时才扩容,1.8是>阈值时就扩容
    3. 1.8在扩容计算索引时有优化
  • 介绍一下get方法流程

    1. 先判断table是否非空或大小为0,是的话直接返回null
    2. 判断第一个元素的key是否和要找的key相同,是则返回
    3. 否则判断节点的类型
      1. 如果是树节点,则走红黑树的逻辑
      2. 否则遍历链表来找对应key的节点
  • 加载因子为何默认0.75?

    在空间占用和查询时间之间取得较好的平衡

    大于0.75,节省空间,但链表长了影响性能

    小于0.75,冲突减少,但扩容频繁空间占用更多

  • 多线程下会有什么问题?

    1. 扩容死链(1.7):1.7扩容是采用头插法,当两个线程同时进行扩容操作,若一个线程先扩容完,另一个线程再去扩容,会使得链表变成循环链表,造成死链
    2. 数据错乱(1.7,1.8):put逻辑是先判断是否为空,后插入数据。并发条件下可能出现两个线程都判断一个位置为空,后都往一个位置插入数据,造成数据丢失
  • key能否为null,作为key的对象有什么要求?

    1. HashMap的key可以为null。但Map的其它实现则不然
    2. 作为key的对象,必须实现hashCode()(为了key在整个HashMap中有更好的分布性)和equals()(当key相同时判断是否为同一个对象),并且key的内容不可以修改
  • 初始化时传入的容量不是2的倍数会怎样?

    会向上寻找离得最近的2的倍数

hashCode和equals

为什么重写 equals 时必须 重写hashCode 方法?

hashCode() 的作⽤是获取哈希码,也称为散列码,主要在哈希表这类集合映射的时候用到

  • 如果两个对象相等,则 hashcode ⼀定也是相同的

  • 如果两个对象有相同的 hashcode 值,它们也不⼀定是相等的

设计模式

单例模式

5种实现方式
1.饿汉式

要点:构造方法私有、静态成员变量、静态方法

1
2
3
4
5
6
7
8
9
10
11
public class Singleton1 implements Serializable {
private Singleton1() {

}

private static final Singleton1 INSTANCE = new Singleton1();

public static Singleton1 getInstance() {
return INSTANCE;
}
}

破坏单例的场景:1.反射获取私有构造方法,2.反序列化破坏单例,3,unsafe破坏单例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 1.反射获取私有构造方法
// 预防方法:在构造方法里判断对象是否已创建
Class<Singleton1> clazz = Singleton1.class;

Constructor<Singleton1> constructor = clazz.getDeclaredConstructor();// 获取构造方法
constructor.setAccessible(true);// 设置私有构造方法也可以使用
constructor.newInstance();// 反射创建实例

// 2.反序列化破坏单例
// 把对象写到输出流
// 用输入流读取这个对象,还原出来的对象为新的对象
// 预防方法:重写readResolve()方法
public Object readResolve() {
return INStANCE;
}

// 3.unsafe破坏单例
// unsafe可以分配内存、释放内存。通过Singleton1的字节码对象可以创建一个新的
2.枚举类
1
2
3
enum Singleton2 {
INSTANCE;
}

反射和反序列化不会破坏单例

3.懒汉式

需要考虑线程安全问题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Singleton3 implements Serializable {
private Singleton3() {

}

private static final Singleton3 INSTANCE = null;

public synchronized static Singleton3 getInstance() {
if (INSTANCE == null) {
INSTANCE = new Singleton3();
}
return INSTANCE;
}
}
:star:4.DCL懒汉式

double check lock,第三种的优化

volatile:解决共享变量的有序性和可见性。

​ 这里使用volatile主要解决有序性问题:new对象时主要步骤为分配内存、调用构造、给静态变量赋值。jvm可能会对创建对象步骤进行重排优化,导致先给景泰变量赋值再调用构造方法,别的线程会获得一个没有初始化完的对象。volatile修饰后则不会发送重排。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class Singleton4 implements Serializable {
private Singleton4() {

}

private static volatile final Singleton4 INSTANCE = null;

public static Singleton4 getInstance() {
if (INSTANCE == null) {
synchronized (Singleton4.class) {
if (INSTANCE == null) {
INSTANCE = new Singleton3();
}
}
}
return INSTANCE;
}
}
5.内部类式

懒汉式的,推荐

1
2
3
4
5
6
7
8
9
10
11
12
13
public class Singleton5 implements Serializable {
private Singleton5() {

}

private static class Holder {
static Singleton5 INSTANCE = new Singleton5();
}

public synchronized static Singleton5 getInstance() {
return Holder.INSTANCE;
}
}
jdk中用到单例的地方
  1. Runtime类:饿汉式。在System的exit()和gc()方法中有使用到

  2. Console类:DCL式。控制台的抽象

  3. Collections中用到一些空集合时,这个空的集合用到了单例,如EmptyNavigableSet,为内部类式

并发

线程有哪些状态

  • java线程分成六种状态

    1. NEW-新建:线程刚刚创建出来,还没有跟操作系统底层的线程关联起来
    2. RUNNABLE-可运行:java线程调用start()后,跟操作系统的线程相关联,代码由操作系统交给CPU执行
    3. TERMINATED-终结:代码执行完毕
    4. BLOCKED-阻塞:线程没获取到锁时进入阻塞状态
    5. WAITING-等待:获取到锁了,但发现运行所需条件不满足,调用wait()进入等待状态,会释放锁;当条件满足时,由其他线程调用notify()唤醒线程
    6. TIMED_WAITING-有时限等待:是等待状态的有时限版。还有调用sleep()也是进入有时限等待状态

  • 操作系统层面分为五种状态

    1. 运行:分到CPU时间片
    2. 就绪:可以分到CPU时间片
    3. 阻塞:分不到CPU时间片

    Java中的RUNNABLE涵盖就绪、运行、阻塞I/O

线程池的核心参数

线程池:管理一组线程,执行提交给线程池的任务

线程池主要参数

  1. corePollSize 核心线程数
    • 最多保留的线程数
  2. maximumPoolSize 最大线程数
    • 核心线程 + 救急线程
  3. keepAliveTime 生存时间
    • 针对救急线程,阻塞队列满时创建救急线程
  4. unit 时间单位
    • 针对救急线程
  5. workQueue 阻塞队列
    • 没有空闲的核心线程时任务的暂存区
  6. threadFactory 线程工厂
    • 可以给线程起名
  7. handler 拒绝策略
    • 没有空闲线程且阻塞队列满的时候的策略,四种

拒绝策略

  1. AbortPolicy (默认)
    • 丢弃任务,抛出异常
  2. CallerRunsPolicy
    • 由提交任务的线程执行任务
  3. DiscardPolicy
    • 丢弃任务
  4. DiscardOldPolicy
    • 丢弃阻塞队列中放的最久的任务,把新的加入进去

sleep & wait

共同点

  • wait()、wait(long)、sleep(long) 都是让线程放弃CPU的使用权,进入阻塞状态

不同点

  • 方法归属不同
    1. sleep(long)是Thread的静态方法
    2. wait()、wait(long)是Object的成员方法
  • 醒来时机不同
    1. sleep(long)和wait(long)在等够时间后被唤醒
    2. wait()和wait(long)还可以被notify()/notifyAll()唤醒
    3. 都可以通过interrupt()打断来唤醒(唤醒,抛出异常)
  • 锁特性不同
    1. 调用wait()方法必须先获取到锁
    2. wait()方法执行后会释放锁
    3. 而sleep()和synchronized代码块一起使用则不会释放锁

Lock & synchronized

语法层面

  • synchronized是关键字(C++实现),Lock是接口
  • synchronized退出代码块时会自动释放锁,Lock要手动调用unLock()方法

功能层面

  • 都是悲观锁,都具备互斥、同步、锁重入功能

  • Lock有更多的功能,如获取等待状态、公平锁、可打断、可超时、多条件变量

    公平锁:按照入阻塞队顺序获取锁

    非公平锁:还未入队的线程可能先获取到锁(插队)

    条件变量:多个等待队列,调用Condition.await()进入

  • Lock有适合不同场景的实现,如ReentrantLock(可重入锁),ReentrantReadWriteLock(适合读多写少锁)

性能层面

  • 在竞争少时synchronized做了很多优化,性能好
  • 竞争多时Lock性能更好

volatile能否保证线程安全

线程安全要考虑三个方法:

  1. 可见性:对共享变量的修改其他线程能看到最新结果
  2. 有序性:代码按编写顺序执行
  3. 原子性:多行代码以一个整体运行,期间不能有其他线程插队

可见性问题:线程频繁读取一个同一个值的变量时JIT编译器会对这段代码进行优化,把代码编译成机器码,这时修改内存中的变量对于这个线程来说是不可见的。

加volatile关键字JIT就不会对其优化

有序性:jvm会通过指令重排来进行优化。

加volatile会使用写屏障保证上面的代码不到当前变量的下面,使用读屏障保证下面的代码不到上面来

volatile只能保证可见性和有序性

乐观锁 & 悲观锁

  1. 悲观锁的代表是synchronized和Lock
    1. 核心是只有占有了锁才能操作共享变量
    2. 线程从运行->阻塞->唤醒涉及上下文切换,频繁的话影响性能
    3. 获取锁时判断已被上锁,会尝试重试几次,减少阻塞的机会(自旋)
  2. 乐观锁的代表是AtomicInteger(原子整数),使用cas保证原子性
    1. 核心是无需加锁,每次只有一个线程成功修改共享变量,其他线程不断重试直至成功(CAS的赋值操作是原子性的)
    2. 没有阻塞,不涉及上下文切换
    3. 需要多核cpu支持,且线程数不应超过cpu核数(超过了仍然会出现上下文切换)

Hashtable vs ConcurrentHashMap

  1. 都是线程安全的Map集合
  2. Hashtable并发度低,整个Hashtable只有一把锁,同时只能一个线程操作它
  3. 1.8开始ConcurrentHashMap将每个数组的头结点作为锁,多个线程访问不同的头结点不会产生冲突

ConcurrentHashMap

  1. 结构:数组+链表|红黑树

  2. 到达容量的0.75就会扩容

  3. 属性:

    • capacity:假定capacity为要放进来的元素的个数,通过capacity来计算数组的长度。(HashMap的capacity意思为初始数组长度)
    • factor:扩容的阈值,只在初始化时使用,之后都按0.75计算
  4. 因为每个数组的头结点都分别有一把锁,故并发度高

  5. 扩容流程:

    • 从最后一个数组节点开始扩容;
    • 链表迁移是通过复制新的链表元素到新数组中,防止迁移过程中链表的next指针发生变动造成的影响影响;
    • 扩容完后给原数组节点的头结点置forwardingNode,表示已经扩容完毕。
    • 必须等整个map扩容完才能执行put操作,如果来put的线程发现map正在扩容,可以来帮忙扩容。

谈谈对ThreadLocal的理解

作用:可以实现资源对象线程间的隔离,线程内的共享

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Utils{
// 线程间使用不同的资源,线程间隔离
// 线程内共享同一个资源
private static final ThreadLocal<Connection> t1 = new Thread<>();
public static Connection getConnection() {
Connection conn = t1.get();
if (conn == null) {
conn = innerGetConnection(); // 创建新的连接对象
t1.set(conn);
}
return conn;
}

private static Connection innerGetConnection() {
return DriverManager.getConnection("jdbc:mysql://localhost:3306/test?userSSL=false", "root", "123456");
}
}

原理

  • 每个线程有一个ThreadLocalMap,用来存储资源
  • 调用ThreadLocal的set()方法时,就是把ThreadLocal作为key,资源对象作为value存入到map中
  • 调用ThreadLocal的get()方法时,就是去map中找以当前ThreadLocal为key的资源
  • 调用ThreadLocal的remove()方法时, 就是去map中删除以当前ThreadLocal为key的资源
  • 如果产生hash冲突解决方法是开放寻址法
  • 垃圾回收:
    • ThreadLocalMap的key是弱引用(如果对于对象的引用中有强引用,则不能被回收,如果都是弱引用,则可以被回收),用于解决当其它地方都没有使用这个资源时,这个资源的key可以被垃圾回收
    • 在get()时发现key为null,value就会被释放;
    • 在set()时发现key为null,会把周围的null key也顺手释放
    • 在remove()时会释放value

快速失败(fail-fast)和安全失败(fail-safe)

快速失败

  • java.util包下的集合类都是快速失败的,如ArrayList
  • 迭代器中使用modCount记录原集合被修改的次数,每遍历一个元素时,都会和原数组中的modCount值进行比较,不一致直接抛出Concurrent Modification Exception异常

安全失败

  • java.util.concurrent包下的容器都是安全失败,如CopyOnWriteArrayList
  • 遍历时不是直接在集合内容上访问的,而是先 复制原有集合内容,在拷贝的集合上进行遍历
  • 缺点:迭代器遍历的是开始遍历那一刻拿 到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的

CopyOnWriteArrayList采用了一种读写分离的并发策略。CopyOnWriteArrayList容器 允许并发读,读操作是无锁的,性能较高。至于写操作,比如向容器中添加一个元 素,则首先将当前容器复制一份,然后在新副本上执行写操作,结束之后再将原容 器的引用指向新容器。

JVM

JVM内存结构

  • Java Source:源代码
  • Java Class:字节码
  • 类加载子系统:把字节码文件加载到方法区,存放类的信息
  • Heap堆:分配对象的内存,存放对象的信息
  • JVM Stacks虚拟机栈:从虚拟机栈中给线程分配内存,存放方法内的局部变量、对象的引用
  • PC程序计数器:存放当前线程执行到了第几行代码
  • Interpreter解释器:逐行解释执行代码,效率低
  • JIT Compiler 即时编译器:字节码编译为机器码,效率高

方法区与永久代、元空间的关系
  1. 方法区是JVM规范中定义的一块内存区域,用来存储类元数据、方法字节码、即时编译器需要的信息等
  2. 永久代是Hotspot虚拟机对JVM规范的实现(1.8之前)
  3. 元空间是Hotspot虚拟机对JVM规范的实现(1.8之后)
  4. 永久代和元空间最大的区别是元空间使用本地内存作为信息的存储空间
元空间加载、释放类信息

加载:当第一次使用类的时候把字节码加载到元空间

释放:当类的实例、Class类不再被使用,且加载它的类加载器也不再使用(类加载器加载的所有类都不再被使用)的时候才会被释放

内存参数

例题-Xmx10260m -Xms10240m -Xmn5120m -XX:SurvivorRatio=3,其最小内存和Survivor区总大小分别是?

-Xmx:虚拟机最大内存

-Xms:虚拟机最小内存

-Xmn:新生代内存大小

-Xss:虚拟机栈中每个线程最大内存

-XX:SurvivorRatio:新生代伊甸区和fromto区的比例

​ 所以这题最小内存为10G,Survivor区(from+to)大小为2G

垃圾回收算法

  1. 标记清除

    1. 标记:一定不会被垃圾回收的对象被作为根对象,沿着根对象的引用进行标记,被标记的对象不能被垃圾回收

    2. 清除:把没有被标记的对象进行内存释放

    3. 缺点:对象不是连续的,容易造成内存碎片,已经没有回收器在使用这个算法

  2. 标记整理

    1. 整理:清除后移动对象往一端靠拢

    2. 缺点:移动对象造成效率低

  3. 标记复制

    1. 复制:标记后把存活对象复制到一块内存中,清空原来的那片内存

    2. 缺点:占用的内存较多

新生代适合标记复制法,老年代适合标记整理

GC和分代回收算法
  • GC的目的在于自动释放无用内存,减少内存碎片,加快分配速度
  • GC要点
    1. 回收区域是堆内存,虚拟机栈内存在方法调用结束会自动释放
    2. 使用可达性算法三色标记法标记存活对象,释放未标记对象
    3. GC大都在用分代回收思想,将回收区域分成新生代和老年代,应用不同的策略
    4. 格局GC规模可以分成MinorGCMixedGCFullGC
  • 分代回收
    1. 伊甸区eden:分配新对象
    2. 幸存区survivor:分成from和to,存放幸存对象
    3. 老年代old,存放扛过多次回收后晋升的对象(幸存区内存不足或大对象会导致提前晋升)
  • GC规模
    1. MinorGC发生在新生代的垃圾回收,时间短
    2. MixedGC为新生代+部分老年代的垃圾回收,G1收集器特有
    3. FullGC为新生代+老年代,回收时间长
三色标记与并发漏标
  • 用三种颜色标记对象的标记状态

    1. 黑色 - 已标记
    2. 灰色 - 标记中
    3. 白色 - 未标记

  • 漏标问题

    1. 垃圾回收线程在标记时用户线程并行执行,导致对象间的引用关系发生改变,从而影响标记的过程
    2. 解决方案:
      1. Incremental Update:记录引用关系被修改的对象(黑->灰),在最后stop the world时重新标记这些对象
      2. Snapshot At The Beginning:记录新加入的对象和被删除引用关系的对象,在最后stop the world时重新标记这些对象

垃圾回收器

  • (并行回收器)Parallel GC
    1. enden内存不足时发生MinorGC,标记复制STW
    2. old内存不足时发生FullGC,标记整理STW
    3. 多线程进行垃圾回收,注重吞吐量
  • (并发标记整理)Concurrent Mark Sweep GC
    1. old并发标记,重新标记时需要STW,并发清除
    2. Failback Full GC 并发失败时触发Full GC
    3. 大部分时间和用户线程并发,注重响应时间
  • G1 GC
    1. 兼顾响应时间和吞吐量
    2. 划分多个区域,每个区域都可以充当eden,survivor,old,humongous
    3. 过程:
      1. 新生代回收:eden标记复制STW
      2. 并发标记:old并发标记,重新标记时需STW
      3. 混合收集:eden + 优先收集回收价值高的old,复制时STW
      4. Failback Full GC:兜底

内存溢出

除了程序计数器,其他都有可能产生溢出

类型

OutOfMemoryError:

  1. 堆内存耗尽
  2. 方法区内存耗尽
  3. 虚拟机栈累积 - 每个线程最多占用1M内存,线程个数太多导致溢出

StackOverflowError:

  1. 虚拟机栈内部 - 方法调用过多导致线程的内存溢出
溢出情况

情况1:误用固定大小线程池

  • 使用Executors.newFixedThreadPool()创建的线程池的任务队列是无界队列(链表),可以放无数的任务,任务对象太多导致堆内存溢出
  • 解决方式:不使用newFixedThreadPool(),而是自己new ThreadPoolExecutor使用有界队列

情况2:误用带缓冲线程池

  • 使用Executors.newCacheThreadPool()创建的线程池只有救急线程,可以创建无数的线程导致虚拟机栈溢出
  • 解决方式:自己new ThreadPoolExecutor

情况3:一次查询太多数据

  • 数据量太大把物理内存挤爆了
  • 解决方式:给查询限制最大条数

情况4:动态类太多

  • 不断加载新的Class到方法区,把方法区挤爆了
  • 解决方式:降低类的声明周期,如不使用static等

类加载过程,双亲委派机制

类加载方式

  1. 隐式装载:通过new等方式生成对象时,隐式调用类加载器加载对应的类到JVM中
  2. 显示装载:Class.forName(className)ClassLoad.loadClass(className)

类加载器种类

  1. BootStrap Loader(引导类加载器),加载系统类
  2. ExtClass Loader(扩展类加载器),加载扩展类
  3. AppClass Loader(应用类加载器),加载应用类

类加载过程

  1. 加载
    1. 通过类的全限定名来获取到class文件,将字节码文件载入方法区,并创建.class对象(堆内)
    2. 类的父类没有加载,先加载父类
    3. 加载是懒惰执行
  2. 链接
    1. 验证-验证类是否符合Class规范
    2. 准备-为static变量分配空间,设置默认值(零值);将类的元信息加载进元空间
    3. 解析-将常量池的符号引用解析为直接引用
    4. 给final变量赋值,如果调用final变量不会触发初始化
  3. 初始化
    1. 从上往下给非final静态变量的赋值和执行静态代码块
    2. 初始化是懒惰执行

类初始化方法

  • 把静态变量、final修饰的引用类型和静态代码块合成一个static方法,在类的初始化时调用

解析过程

  • 方法内还没有使用到类的时候,常量池里的类为符号引用
  • 当使用到这个类时,这个类被加载到堆中,原先调用这个类的常量池中的符号引用也就被替换为真实的地址

双亲委派

  • 指优先委派上级类加载器进行加载
  • 当上级类加载器找不到要加载的类,下级类加载器才有资格执行加载
  • 上级类加载器加载的类对下级可见
  • 目的:让类加载有优先次序,保证核心类优先加载

对象引用类型

  1. 强引用
    1. A a = new A()就为强引用
    2. 通过GC Root的引用链能找到对象a就不会被回收
  2. 软引用
    1. SoftReference a = new SoftReference(new A())就为软引用
    2. 如果只有软引用一个对象,首次垃圾回收不会回收该对象,如果内存仍然不足,再次回收时才会释放对象
    3. 软引用自身需要配合引用队列来释放
    4. 例子:反射获得的数据是软引用
  3. 弱引用
    1. WeakReference a = new WeakReference(new A())就为弱引用
    2. 如果只有弱引用一个对象,只要垃圾回收就会回收该对象
    3. 弱引用自身需要配合引用队列来释放
    4. 例子:ThreadLocalMap中的Entry对象使用了弱引用
  4. 虚引用
    1. PhantomReference a = new PhatomReference(new A())就为虚引用
    2. 必须配合队列一起使用,当新引用引用的对象被回收时,会将虚引用对象入队,配合Reference Handler线程释放其关联的外部资源(非java的资源)

finalize

  • 对象被垃圾回收时会调用的方法

  • 由FinalizerThread调用finalize方法,而这个线程是守护线程,可能没来得及调用完方法就结束了

  • finalize有异常不会抛出异常

  • 影响性能:垃圾回收时第一次不能释放内存,要等线程调用完finalize方法后下次垃圾回收才能释放

Spring

refresh创建IOC容器

Spring 容器启动时会创建IOC容器,在创建容器时,会调用 refresh()方法,整个容器就是通过该方法,完成所有的 bean 的创建以及初始化

重要的类/对象

  • ClassPathXmlApplicationContext:使用配置文件的容器
  • AnnotationConfigApplicationContext:使用配置类的容器
  • BeanDefinition:Bean的相关信息。如是否是单例的,Bean的类型,是否是懒加载,依赖哪些类,自动装配的模型
  • beanDefinitionMap:存放bean对应的BeanDefinition
  • beanDefinitionNames:存放所有bean的名字
  • BeanFactoryPostProcessor:BeanFactory的后置处理器
  • BeanPostProcessor:Bean的后置处理器,通过它可以实现AOP、Bean的创建

重要步骤

BeanFactory初始化

invokeBeanFactoryPostProcessors()会执行所有BeanFactoryPostProcessor

  • Spring内置的BeanFactory后处理器:ConfigurationClassPostProcessor,会解析出所有交由Spring管理的Bean并解析成BeanDefinition
  • 自定义的BeanFactory后处理器:实现了BeanFactoryPostProcessor接口或BeanDefinitionRegistryPostProcessor接口。会先执行后者的实现类

准备上下文

registerBeanPostProcessors()会找到并实例化所有的BeanPostProcessor,并放到beanPostProcessors集合中

finishBeanFactoryInitialization()完成了所有非懒加载的单例Bean的实例化和初始化,属性的填充以及解决了循环依赖等问题

12个步骤

概览

  • 1:准备工作
  • 2~6:创建准备BeanFactory的各项功能
  • 7~12:准备ApplicationContext
1.prepareRefresh()-开启容器,加载键值
  • 创建和准备了Environment对象

  • Environment对象的作用:加载了一些键值信息:

    • jvm提供的键值
    • 操作系统系统里的键值
    • 配置文件里提供的键值信息
  • Environment的作用之一是为后续@Value注入值的时候提供信息

2.obtainFreshBeanFactory()-获取或创建Bean工厂
  • 创建了BeanFactory对象

  • BeanDefinitionMap为BeanFactory的成员对象,存储了BeanDefinition

    • BeanDefinition作为bean的设计蓝图,规定了bean的特征,如单例多例、依赖关系、初始化销毁方法等
    • BeanDefinition的来源可以通过xml获取、配置类获得、组件扫描或编程添加等
  • BeanFactory的作用是负责bean的创建、依赖注入和初始化

3.prepareBeanFactory()-完善Bean工厂
  • 创建用来解析SpEL的beanExpressionResolver

  • 创建执行类型转换的propertyEditorRegistrars

  • 创建特殊bean注入的resolvableDependencies

  • 扩展bean的创建功能的beanPostProcessors

  • StandardBeanExpressionResolver来解析SpEL

  • ResourceEditorRegister会注释类型转换器,并应用ApplicationContext提供的Environment来完成${}解析

  • 特殊bean指beanFactory以及ApplicationContext,通过registerResolvableDependency来注册它们

4.postProcessBeanFactory()-子类扩展Bean工厂
  • 后处理可以理解为添加功能。
  • 默认为空实现,留个子类扩展
  • 一般Web环境的ApplicationContext都要利用它注册新的Scope,完善Web下的BeanFactory
5.invokeBeanFactoryPostProcessors()-后处理器扩展Bean工厂
  • 预先定义好的后处理,通过处理器的方式添加BeanFactory的功能
  • 后置处理器可以扩展beanFactory的功能,用来补充和修改BeanDefinition
  • ConfigurationClassPostProcessor-解析@Configuration、@Bean、@Import等
6.registerBeanPostProcessors()-注册Bean后处理器
  • bean后后处理器充当bean的扩展点,可以工作在bean的实例化、依赖注入、初始化阶段
  • 如有解析@Autowired、@Value、@Resource等注解的后处理器
7.initMessageSource()-国际化
  • 初始化messageSource,用来做国际化功能

8.initApplicationEventMulticaster()-初始化事件多播器
  • 这个类会持有所有的事件监听器(ApplicationListener),当有ApplicationEvent事件发布时,该事件监听器能根据事件类型,检索到对该事件感兴趣的ApplicationListener

9.onRefresh()-初始化子容器中的bean
  • 执行其他的初始化操作,例如和SpringMVC整合时,需要初始化一些其他的bean,但是对于纯Spring工程来说,onRefresh()方法是一个空方法。
10.registerListeners()-注册监听器
  • 接收事件
11.finishBeanFactoryInitialization()- 初始化所有剩下的单例 bean
  • conversionService也是一套转换机制,作为对PropertyEditor的补充
  • 内嵌值解析器用来解析@Value中的${},借用的是Environment的功能
  • 单例池用来缓存所有单例对象
  • 对象创建分为三个阶段:创建、依赖注入、初始化,每个阶段都有不同的bean处理器参与扩展

12.finishRefresh()-准备声明周期管理器,发布ContextRefresh事件
  • lifecycleProcessor用来控制容器内需要生命周期管理的bean

Bean的作用域

  • Singleton:默认,在IOC容器中只有一个实例
  • Prototype:可以有多个实例
  • Request:每次http请求都会创建一个Bean
  • Session:一个Session中对应一个Bean对应一个实例
  • Global-session:全局Session中,一个Bean对应一个实例

Bean的生命周期

7个阶段

Bean为懒惰初始化,生命周期从doGetBean()方法开始

概览

  • 阶段1:处理名称,检查缓存
  • 阶段2:检查父工厂
  • 阶段3:检查DependsOn
  • 阶段4:按Scope创建bean
  • 阶段5:创建bean
  • 阶段6:类型转换
  • 阶段7:销毁bean
阶段1
  • 先把别名解析为实际名称,再进行后续处理
  • 若要FactoryBean本身,需要使用&名称获取
  • 检查缓存里是否有:
    • 一级缓存是singletonObjects,放单例成品对象
    • 二级缓存是earlySingletonObjects,放单例工厂的产品,可称为提前单例对象
    • 三级缓存是singletonFactories,放单例工厂
阶段2
  • 缓存中没有bean回去父容器中找
  • 父容器中的bean和子容器中的bean名称可以重复
阶段3
  • 如果A显式依赖于B,则B会被先创建
  • DependsOn用在非显式依赖的bean的创建顺序控制
阶段4
  • 常见scope:
    • singleton scope表示从单例池范围内获取bean
      • 容器创建时创建,容器销毁时销毁
    • prototype scope表示从不缓存bean,每次创建新的
      • 用时创建,不用时手动销毁
    • request scope表示从request对象范围内获取bean
      • 在request域中创建,request销毁时销毁
阶段5
  • 创建阶段:
    • AutowiredAnnotationBeanPostProcessor选择构造:优先使用带@Autowired的构造方法来创建bean,如果只有一个有参构造,则用这个有参构造创建bean
    • 默认构造:保底使用无参构造来创建
  • 依赖注入阶段:
    • 后处理器扩展功能:按注解匹配要注入的bean
    • 根据名字、类型去容器中找bean
    • xml精确指定bean
  • 初始化阶段:
    • 处理Aware接口,如BeanNameAware、BeanFactoryAware,BeanFactory会把相应的资源传递进来
    • 调用初始化方法,初始化方法定义有@PostConstruct、实现InitalizingBean接口等
    • 创建aop代理
  • 注册可销毁bean
    • 将有销毁方法的bean保存起来,销毁时调用方法

阶段6
  • 如果getBean的requiredType参数与实际得到的对象类型不同,会尝试类型转换
阶段7
  • 根据scpoe来销毁bean

事务的传播行为

级别 描述
Required(默认) 支持当前事务,没有事务会创建一个
Supports 支持当前事务,没有事务则以非事务的方式执行
Mandatory 支持当前事务,没有事务会抛出异常
Requires_new 创建新事务并挂起当前事务
Not_supported 以非事务方式执行,会挂起当前事务
Never 以非事务方式执行,有事务会抛出异常
Nested 嵌套事务

事务失效场景

场景1:检查异常

需要try..catchthrow的异常都是检查异常

运行时异常是非检查异常

抛出检查异常导致事务不能正常回滚

原因:spring默认只会回滚非检查异常

解决方式:在注解上加上@Transactional(rollbackFor=Exception.class)设置需要回滚的异常

场景2:错误try catch

原因:事务是通过代理实现的,如果在被代理方法内catch了异常,外层调用者是不知道发生了异常的,从而事务失效

解决方式1:把异常抛出去

解决方式2:手动设置事务状态TransactionStatus.setRollbackOnly()

场景3:aop切面顺序不对

原因:在自定义切面里catch了异常,导致更上层的事务切面不知道发生了异常

解决方式1:同场景2

解决方式2:使自定义切面优先级比事务的高@Order(Ordered.LOWEST.PRECEDENCE - 1)

场景4:加在非公共方法上

原因:Spring为方法创建代理、添加事务通知、前提条件都是方法是public的

场景5:父子容器导致的事务失效

原因:子容器扫描范围过大,把未加事务配置的service扫描进来

解决方式1:容器各自扫各自的包

解决方式2:不用父子容器

场景6:调用本类方法导致传播行为失效

原因:本类方法调用没有经过代理增强

解决方式:依赖注入自身

场景7:锁失效

原因:@Transactional没有保证原子行为,在方法上加锁只能保证方法内的原子性,提交事务时锁失效

解决方式1:synchronized范围扩大至代理方法调用

解决方式2:sql语句select ... for update替换select

SpringMVC执行流程

  1. 初始化阶段

    • 第一次用到DispatcherServlet时,会创建其对象并执行init方法
    • 容器初始化后,会将初始化好的重要组件赋值给DispatcherServlet的成员变量

  2. 匹配阶段

    • 用户请求到达DispatcherServlet,由其遍历所有的HandlerMapping,找到与路径匹配的处理器
    • 将HandlerMethod连同匹配到的拦截器,生成调用链对象返回
    • 遍历HandlerAdapter,找到能处理HandlerMethod的适配器,开始调用

  3. 执行阶段

    • 执行拦截器的preHandle
    • 调用HandlerMethod,返回ModelAndView
    • 执行拦截器的postHandle
    • 解析ModelAndView
    • 执行拦截器的afterCompletion

注解

Spring常见注解

SpringMVC常见注解

SpringBoot常见注解

SpringBoot自动配置

@SpringBootApplication主要由三个注解构成

  • @SpringBootConfiguration
    • @Configuration可以多个,@SpringBootConfiguration只能一个,用于表示当前类为SpringBoot配置类
  • @EnableAutoConfiguration
    • @AutoConfigurationPackage:记录标注了这个注解的包名
    • @Import(AutoConfigurationImportSelector.class):导入自动配置相关的bean,加载META-INF/spring.factories中的配置类
  • @ComponentScan
    • 属性excludeFilters用于排除一些类不注册为bean

循环依赖

切面

通过代理对象来关联ProxyFactory实现切面,SpringBoot通过自动代理后处理器来识别相关注解

术语

  • Advisor:最基础的切面,把一个Advice和一个Pointcut组合起来

  • Advice:增强

  • Pointcut:切点

  • Aspect:切面

  1. 最基本的切面是Advisor,一个Aspect切面对应一到多个Advisor

  2. 最基本的Advice是MethodInterceptor,其他Advice最终都会适配为MethodInterceptor

  3. 创建代理的方式有:实现了用户自定义接口,采用jdk动态代理;没有实现用户自定义接口,采用cglib代理;设置了setProxyTargetClass(True),统一采用cglib代理

  4. 切面、切点、通知等都不会被代理

  5. 自动代理后处理器调用时机:创建阶段、依赖注入阶段初始化阶段

注解

  • @Aspect:表示为切面类
  • @Around、@Before、@After等:通知方法,可以加切点表达式

三级缓存

作用
  • 一级缓存作用:限制bean在beanFactory中只存一份

只有一级缓存不能解决循环依赖问题

  • 二级缓存作用(Spring中的三级缓存):存还未装载成员变量的空对象,解决循环依赖,在初始化完后把二级缓存中的空对象清除掉

    二级缓存不能解决循环依赖中有代理的情况

  • 三级缓存作用(Spring中的二级缓存):解决循环依赖中代理创建过晚的问题,通过工厂判断是否需要代理,需要则提前创建代理对象,否则返回本身空对象

    三级缓存不能解决构造函数中有循环依赖:构造不出来,无法放到缓存中

    解决方式1:加@Lazy注解,会先创建代理对象注入,待用到时再注入

    解决方式2:通过ObjectFactory<B> b 来先注入工厂,使用时在创建bean

单例Bean是线程安全的吗

不是线程安全的。@Scope注解默认是sington单例的。如果spring的bean中注入的都是无状态的对象,是没有线程安全问题的,如果在bean中定义了可以修改的成员变量,是要考虑线程安全问题的,可以使用多例或加锁来解决问题

AOP

面向切面编程,用于将那些与业务无关,但对多个对象产生影响的公共行为或逻辑,抽出并封装成一个可重用的模块

常见AOP使用场景

  • 记录操作日志

  • 缓存处理

  • 事务

    编程式事务:使用TransactionTemplate,对代码有侵入性

    声明式事务:使用@Transactional注解,通过AOP功能,对方法进行拦截

自动装配

@SpringBootApplication中包含的@EnableAutoConfiguration中用@Import导入了配置选择器

内部读取了该项目和引用的Jar包的classpath路径下META-INF/spring.factories文件中的所配置的类的全类名,根据这些配置类中定义的Bean所指定的条件来决定是否注入到Spring容器中

Redis

基础

redis默认端口号:6379

redis默认有16个库

redis五种基本数据类型:

  1. string(字符串)
  2. hash(哈希)
  3. list(列表)
  4. set(集合)
  5. sortedSet(有序集合)

使用场景

缓存(穿透、击穿、雪崩、双写一致、持久化、数据过期、淘汰策略)、

分布式锁(setnx、redisson)

计数器(浏览量、点赞数)

排行榜

缓存穿透

定义:查询一个不存在的数据时,mysql查询不到数据也不会直接写入缓存,就会导致每次请求都查询数据库

解决方式

  1. 缓存空数据(消耗内存,可能发送不一致的问题)

  2. 布隆过滤器拦截不存在的数据(内存占用小,存在误判的风险,实现复杂)

    布隆过滤器:通过位图检索一个元素是否在一个集合中,hash冲突可能造成误判

缓存击穿

定义:某个key过期的时候,恰好有大量的并发请求在同一时间发过来,所有的请求都打到了数据库上,如果重建缓存时间太长,数据库就有可能崩溃

解决方式

  1. 互斥锁(强一致,性能差)

    第一个没查到缓存的线程获取锁去重建缓存,其他线程等待

  2. 逻辑过期(弱一致,高可用)

    添加逻辑过期字段,第一个发现过期的线程获取互斥锁,新开一个线程去重建缓存,其他线程返回旧数据

缓存雪崩

定义:同一时段大量key同时过期或Redis服务宕机,导致大量请求打到数据库

解决方式

  1. 给key设置不同的TTL
  2. 搭建集群
  3. 添加降级限流规则
  4. 添加多级缓存

双写一致性

定义:指的是mysql的数据和redis的数据保持一致

读操作:缓存命中,直接返回;未命中查询数据库,设置超时时间写入缓存

写操作:

  1. 延迟双删(弱一致,先更新数据库再删除缓存,延迟一段时间后再删一次)
  2. 加锁后写数据(强一致)
  3. 通过消息队列,数据库更新后通知redis(弱一致)

不延时双删

先删缓存再更新数据库:删除缓存后又有线程重建了缓存,造成数据不一致

先更新数据库再删缓存:此时key过期,另一个线程查到旧数据,待删除缓存后又重建缓存,造成数据不一致

持久化

RDB:Redis Database Backup file,Redis数据备份文件,把所有数据都备份到磁盘

执行原理

主进程使用页表映射内存中的地址,子进程备份数据时拷贝一份页表来进行备份;

主进程写操作时,先拷贝一份要修改数据到另一块内存,修改完数据后修改页表指向的内存地址(copy-on-write),保证备份时的数据一致性

AOF:Append Only File,追加文件,记录每一个写操作。需要去配置文件里打开此功能

比较

  • 完整性比RDB高,文件大小会比RDB大,可以通过bgrewriteaof来重写AOF以减少大小
  • rdb和aop都不能保证恢复所有数据,如果在写rdb和aop文件时出现故障,或由于压缩rdb、重写aop都有可能导致数据丢失

过期策略

惰性删除:使用时发现key过期了才删除,占内存

定期删除:每隔一段时间检查一批key,把过期的删除

Redis的过期删除策略是两者结合

淘汰策略

内存占用太多时删掉一些数据

  • noeviction:内存满时不淘汰数据,而是不允许写入数据(默认)

  • volatile-ttl:淘汰TTL小的数据

  • allkeys-random:对所有数据随机淘汰

  • volatile-random:对设置了过期时间的数据随机淘汰

  • allkeys-lru:(Least Recently Used,最近最少使用:最近最少使用的key)

  • volatile-lru

  • allkeys-lfu:(Least Frequently Used,最少频率使用:一定时间内使用频率最少的key)

  • volatile-lfu

分布式锁

Redisson实现分布式锁

加锁、设置过期时间都是通过Lua脚本实现的

看门狗机制(Watch Dog):每隔一定时间(releaseTime/3)做一次续期

重试机制:在一定次数内通过while循环尝试加锁,增加分布式锁的性能

可重入:根据线程id判断是否是同一线程

主从一致性

  • 如果一个线程在主节点加锁后,主节点还没有和从节点同步数据就宕机了,此时另一个线程也来加锁,就会导致同一把锁被两个线程持有。
  • redisson解决方式1:RedLock(红锁):在多个redis实例上创建锁(n / 2 + 1),性能差

集群

主从复制:搭建主从节点,实现读写分离,提高并发能力

主节点写操作,从节点读操作

主从同步

全量同步

增量同步

  • 通过replid判断主从节点数据的版本号,如果从节点没有replid表示第一次同步数据,主节点需要发送RDB文件(全量同步)
  • 如果不是第一次同步数据,则通过offset偏移量判断需要从repl_backlog中发送多少数据来更新从节点数据(增量同步)

哨兵模式:监控、故障恢复、通知

监控:心跳检测,监控主从节点是否能正常工作

故障恢复:主节点挂了选择offset大的从节点提升为主节点

通知:当集群发生故障转移时,通知Redis客户端

脑裂问题:主节点没有挂,而是由于网络延迟导致哨兵节点检测不到,从而又选了个主节点,原先的主节点强制变为从节点,而主节点里这段时间的写数据就丢失了。解决方式:设置主节点要有一定数量的从节点时才能接收写数据、设置主从同步延迟时间的上限

分片集群

解决海量数据存储和高并发写的问题

特点:

  • 集群中有多个master、每个master保存不同的数据
  • 每个master都可以有多个slave节点
  • master之间通过ping检测彼此健康状态

数据读写:

  • Redis集群有16384个哈希槽,每个key通过哈希运算后决定放到哪个槽中。
  • 每个节点负责一部分hash槽

Redis为什么快

  • Redis是基于内存的
  • 采用单线程,避免不必要的上下文切换
  • 使用I/O多路复用模型,非阻塞IO(实现高效的网络请求)

I/O多路复用模型

  • Redis客户端建立连接:

    Redis监听一个TCP端口来接受客户端的连接,客户端 socket 会被设置为非阻塞模式,然后创建事件来接受客户端的数据

  • 用户空间和内核空间:

    用户空间只能执行受限的命令,内核空间可以执行特权命令,调用一切系统资源

  • 阻塞IO、非阻塞IO和多路复用IO

    阻塞IO:用户请求数据,内核在准备数据和拷贝数据到用户空间这段时间,其他用户进程阻塞等待

    非阻塞IO:内核准备数据时客户端不断请求直到数据准备完成,拷贝到用户空间时阻塞等待

    阻塞/非阻塞IO的问题是Redis单线程同时只能服务一个客户端,其他客户端只能等待

    多路复用IO:其实是使用一个线程来检查多个 Socket 的就绪状态,当Socket有读写事件时才会去执行,不会一直阻塞在一个Socket上

MySQL

如何定位慢查询

方案一:开源工具

  • 调试工具:Arthas
  • 运维工具:Prometheus、Skywalking

方案二:MySQL慢日志

1
2
3
4
5
6
# my.cnf开启慢日志(在调试阶段才会开启,因为会损耗性能)
slow_query_log = 1
# 设置执行时间(s)多久视为慢查询
long_query_time = 2

# 在localhost-slow.log中记录了慢查询

慢查询优化

在查询语句前加EXPLAIN获取执行的信息

possible_key:可能使用的索引

key:实际使用的索引

key_len:索引占用的大小(可以判断索引是否失效)

Extra:额外的优化建议

Extra 解释
Using where; Using Index 使用了索引且不需要回表
Using index condition 使用了索引,但需要回表

type:sql的查询的类型,当为index或all时速度慢

类型 说明
system 查询系统中的表
const 根据主键查询
eq_ref 主键索引查询或唯一索引查询,只查询一条数据
ref 索引查询
range 范围查询
index 索引树扫描
all 全表扫描

索引

高效获取数据的数据结构,提高数据库的检索效率,降低数据库的IO成本

数据结构对比

二叉搜索数:最坏可能退化成链表

红黑树:数据量大时层数太大

B树:多叉路平衡查找树,非叶子节点存储数据,层级比B+树大

(mysql的)B+树:类似B树,只在叶子节点存数据(非叶子节点能存更多索引,层级更小),叶子节点形成一个循环链表(范围查询)

B+树优势:磁盘读写代价更低,查询效率稳定,便于扫库和范围查询

聚簇索引和非聚簇索引

也叫聚集索引和二级索引

分类 含义
聚簇索引(必须有且只有一个) 数据和索引保存在一块,索引结构的叶子节点保存了行数据
非聚簇索引(可以有多个) 数据和索引分开存储,叶子节点关联的是对应的主键

回表查询:走二级索引找到主键后走聚集索引

覆盖索引:查询使用了索引,并且需要返回的列,在该索引中已经全部能够找到

1
2
-- 如果非聚簇索引中包含了col1和col2,那就不需要回表查询
SELECT col1, col2 FROM mytable WHERE col1 = 123;

使用覆盖索引可以减少磁盘 I/O 操作和 CPU 开销,提高查询性能

超大分页

1
2
3
4
5
6
7
8
-- MySQL需要对整个查询结果集进行排序,当数据规模过大时,排序时间会明显增加
select * from tb_sku limit 900000, 10;

-- 使用覆盖索引+子查询来优化
select *
from tb_sku t,
(select id from tb_sku order by id limit 900000, 10) a
where t.id = a.id;

创建索引原则

  1. 对数据量大(> 10w)且查询频繁的表建立索引
  2. 对查询条件(where)、排序(order by)、分组(group by)操作的字段建立索引
  3. 选择区分度高的列作为索引
  4. 对字符串类型的字段,字段的长度较长,可以建前缀索引
  5. 使用联合索引来进行覆盖索引
  6. 索引数量适中
  7. 不能存NULL值时使用NOT NULL来约束

索引失效

  1. 违法最左前缀法则(跳过前面、中间的列),
  2. 范围查询,
  3. 运算操作,
  4. 字符串不加引号(发生了类型转换)
  5. 头部使用模糊查询

sql优化

表设计优化

  1. 选择合适的数值类型(tinyint、int、bigint)
  2. 选择合适的字符串类型(char、varchar)

SQL语句优化

  1. select语句避免使用select *(可能造成回表查询)
  2. 避免索引失效
  3. 尽量使用union all代替union(union会过滤重复数据,效率相对较低)
  4. 尽量用inner join 代替left join/right join,尽量小表驱动大表,减少数据库连接。inner join会优化把小表放到外边,大表放到里边
  5. 主从复制,读写分离

ACID

原子性(Atomicity):不可分割

一致性(Consistency):所有的数据都保持一致的状态

隔离性(Isolation):事务不受外部并发影响

持久性(Durability):事务提交或回滚,对数据库的影响是永久的

并发事务问题

脏读:一个事务读到另一个事务还没有提交的数据

不可重复读:事务先后两次读取同一条记录,但读到的数据不同

幻读:事务按照条件查询数据时没有对应数据,但插入时发现数据已经存在了

隔离级别

解决事务并发问题

读未提交

读已提交:解决脏读

可重复读:InnoDB默认隔离级别,解决不可重复读

串行化:性能低

日志文件

bin log:二进制日志文件,记录每一条DDL和DML,用于备份恢复和主从复制

redo log:重做日志,记录缓冲池里数据的变化。当刷新脏页到磁盘时发生了错误,就使用redo log进行数据恢复,保证持久性

undo log:回滚日志,记录被修改前的信息或反向操作。提供回滚和MVCC,保证一致性和持久性

MVCC

多版本并发控制,维护一个数据的多个版本,使得读写操作没有冲突

隐藏字段

  • DB_TRX_ID:当前的事务ID
  • DB_ROLL_PTR:指向这条记录的上一个版本

undo log版本链:记录的多个版本串成链表,链表头为最新的记录

readView

字段 含义
m_ids 当前还没提交的事务ID的集合
min_trx_id m_ids里面最小的事务ID
max_trx_id m_ids里面最大的事务ID+1
creator_trx_id ReadView创建时的事务ID

读已提交下每次读都会创建一个readView(当前读)

可重复读下在事务的第一次读时创建readView,后续都用这个(快照读)

主从复制

binLog日志记录主库的DML(数据操作语言)和DDL(数据定义语言)语句,从库去拉主库的binLog到自己的Relay Log中,再通过Relay Log同步数据

分库分表

单表数据量达1000w或20G

可以通过业务或字段或数据来分库分表

数据备份

导出备份sql文件

1
mysqldump -u [username] -p [database_name] > [backup_file].sql

恢复数据

1
mysql -u [username] -p [database_name] < [backup_file].sql

MyBatis

执行流程

  1. 加载配置文件(运行环境,映射文件)
  2. 构建SqlSessionFactory
  3. 通过SqlSessionFactory创建SqlSession对象(提供增删改查API)
  4. Executor核心作用是处理SQL请求、事务管理、维护缓存以及批处理等
  5. 把映射文件里的信息封装到Exector的MappedStatement中
  6. 把请求参数转化为mysql可以识别的数据类型
  7. 把返回值转化为java对象

延迟加载

在执行 SQL 查询时,只加载需要立即返回到 Java 对象中的数据,而将其余数据的加载推迟到访问时

MyBatis 中的延迟加载主要是针对一对多和多对多的关联关系中的关联对象进行的

通过fetchType = true开启延迟加载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- 延迟加载一对多关联关系 -->
<resultMap id="userMap" type="User">
<id column="id" property="id"/>
<result column="userName" property="userName"/>
<result column="password" property="password"/>
<collection property="posts" ofType="Post" column="id"
select="selectPostsByUserId" fetchType="lazy"/>
</resultMap>

<select id="selectUserById" resultMap="userMap">
SELECT * FROM user WHERE id = #{id}
</select>

<select id="selectPostsByUserId" resultType="Post">
SELECT * FROM post WHERE userId = #{id}
</select>

开启全局延迟加载

1
2
3
mybatis:
configuration:
default-lazy-loading-enabled: true

缓存

一级缓存:作用域为Session,当Session被flush()或者close()时缓存就会清空

二级缓存:作用域为整个MySQL实例,任何客户端都可以使用

  • 二级缓存需要缓存的数据要实现Serializable接口

  • 只有Session提交或关闭后,一级缓存中的数据才会转移到二级缓存中

  • 当进行了增删改操作后,一、二级缓存都会清空

  • 一级缓存默认开启,二级缓存要手动开启:

    1
    2
    3
    mybatis:
    configuration:
    cache-enabled: true
    1
    2
    3
    4
    5
    6
    7
    8
    // 在mapper上加上@CacheNamespace
    // 在方法上加上 @Options(useCache = true)
    @CacheNamespace
    public interface UserMapper {
    @Select(value = "select * from user where id = #{id}")
    @Options(useCache = true)
    User selectUserById(Integer id);
    }
    1
    // 如果是在xml中定义的sql语句,需要加上<cache/>

微服务

5大组件

注册中心(Eureka、Nacos、zookeeper等)

远程调用(Feign等)

负载均衡(Ribbon等)

服务熔断(Hystrix、Sentinel等)

网关(Getway、Zuul等)

注册中心

主要作用:服务注册和发现、服务健康监控

服务注册:服务提供者需要把自己的信息注册到注册中心中,如服务名称、ip和端口等

服务发现:消费者拉取服务列表信息,如果服务提供者有集群,则会利用负载均衡算法,选择一个发起调用

服务监控:服务提供者每隔一段时间向注册中心发送心跳,报告健康状态,如果长时间没有收到心跳,这从服务列表里剔除

负载均衡

Ribbon负载均衡策略:

  • RoundRobinRule:简单轮询
  • WeightedResponseTimeRule:按照权重来选择服务器,响应时间越长,权重越小
  • RandomRule:随机选择
  • BestAvailableRule:选择当前并发度低的服务器
  • RetryRule:重试机制的选择逻辑
  • AvailabilityFilteringRule:先过滤非健康的,再选择连接数较小的实例
  • ZoneAvoidanceRule:按区域就近选择服务器

自定义负载均衡策略:

  • 实现IRule接口(全局生效)
  • 通过配置文件给单独的服务选择负载均衡策略(局部)

服务雪崩

服务雪崩:一个服务失败,导致整条链路的服务都失败的情况

解决方式:

  • 服务降级

    在远程调用@FeignClient()中设置fallback

  • 服务熔断

    Hystrix熔断机制:如果10s内请求的失败率超过50%触发熔断机制,所有请求都会失败。熔断时间结束后尝试放行请求,如果成功则关闭断路器

  • 限流

监控

用于问题定位和性能分析

常见服务监控工具

  • Spring Boot admin
  • prometheus + Grafana
  • zipkin
  • skywalking

限流

为何要限流:

  • 并发大
  • 防止恶意刷接口

实现方式:

  • Nginx:漏桶算法(突发流量)、控制并发连接数

  • 网关:过滤器RequestRateLimiter(令牌桶)

  • 自定义拦截器

分布式系统理论

CAP定理

  1. Consistency:一致性,用户访问分布式系统中的任意节点,得到的数据必须一致

  2. Availability:可用性,访问节点必须得到响应

  3. Partition tolerance:分区容错性,出现分区时也要对外提供服务

    分区:由于网络故障等原因造成部分节点与其他节点失去联系

同时只能满足两个,如果要可用性则只能牺牲一致性,反之亦然

BASE理论

  1. Basically Available:基本可用,保证核心可用
  2. Soft State:软状态,允许出现临时的数据不一致
  3. Eventually Consistent:最终一致性,在软状态结束后,最终达到数据一致

接口幂等性

幂等:多次调用方法或者接口不会改变业务状态,可以保证重复调用的结果和单次调用的结果一致

如:点击提交订单,点击多次也只能有一次是成功的

请求方式 说明
GET 查询操作,天然幂等
POST 新增操作,不是幂等的
PUT 更新操作,如果是设置值,是幂等的;如果是在一个值上进行增量操作,不是幂等的
DELETE 删除操作,是幂等的

保证幂等的方式

token+redis

如点击购买,生成token存放到redis并返回给前端;之后提交订单则前端带着token来验证,验证成功则生成订单并删除redis中的token,之后的请求就不会重复生成订单

分布式锁

如第一次提交订单则加锁来生成订单,之后的请求就会由于抢不到锁而不重复生成订单

消息中间件

RabbitMQ

消息不丢失

消息可能在发送到交换机、队列和消费者宕机时丢失

RabbitMQ提供publisher confirm(生产者确认)机制来避免消息丢失。消息发送到MQ后,会返回一个结果给生产者。

消息丢失后处理方式

  • 立即重发
  • 记录日志
  • 保存发送失败的数据,定时一起重发

消息持久化:MQ默认是内存存储消息,开启持久化可以保证消息不丢失

  1. 交换机持久化

    1
    2
    3
    4
    5
    @Bean
    public DirectExchange simpleExchange() {
    // 交换机名称、是否持久化、没有queue绑定时是否自动删除
    return new DirectExchange("simple.direct", true, false);
    }
  2. 队列持久化

    1
    2
    3
    4
    @Bean
    public Queue simpleQueue() {
    return QueueBuilder.durable("simple.queue").build();
    }
  3. 消息持久化

    1
    2
    3
    4
    Message msg = MessageBuilder
    .withBody(message.getBytes())
    .setDelieryMode(MessageDelieryMode.PERSISTENT) // 持久化
    .build();

消费者确认:消费者收到消息返回ack,MQ收到ack后才会删除消息。如果多次获取消息失败,则将消息投递到异常交换机来处理

死信交换机/延迟队列

延迟队列:进入队列的消息会被延迟消费的队列,延迟队列=死信交换机+TTL(生存时间)

使用场景:超时订单取消、限时优惠、定时发布等

死信:过期消息、消费失败的消息、消息队列满了时最早的消息

死信交换机:接收死信的交换机

1
2
3
4
5
6
7
@Bean
public Queue ttlQueue() {
return QueueBuilder.durable("simple.queue")
.ttl(10000)
.deadLetterExchange("dl.direct") // 指定死信交换机
.build();
}

消息堆积

生产者生产消息的速度大于消费者消费消息的速度时,就会导致堆积

解决方式:

  • 增加消费者
  • 消费者开启线程池加快消息处理速度
  • 扩大队列容积
  • 惰性队列:消息接收后存到磁盘中,时效性低

高可用

普通集群

  • 集群中的节点共享交换机、队列信息,不包含队列中的消息
  • 当访问集群某节点时,如果队列不在该节点,会从数据所在节点传递到当前节点并返回
  • 队列所在节点宕机,队列中的消息就会丢失

镜像集群

  • 主从模式,镜像节点做备份,主节点完成操作,主节点宕机由镜像节点替代

仲裁队列

  • 主从模式,主从同步基于Raft协议,解决镜像节点主从同步时主节点宕机的问题

多线程

基础

进程和线程

进程:当一个程序被运行,从磁盘加载代码到内存,此时就开启了一个进程

线程:一个进程可以分为多个线程

比较

  1. 进程是正在运行的实例,进程包含线程,每个线程执行不同的任务
  2. 不同的进程使用不同的内存空间,一个进程的线程共享内存空间
  3. 线程上下文切换代价更低

并发和并行

并发:一个cpu同时处理多个线程

并行:多个线程同时执行

:star:创建线程的方式

  • 继承Thread类,重写run(),start()开启
  • 实现Runnable接口,重写run(),用Thread()包装,start()开启
  • 实现Callable接口,重写call(),用FutureTask()包装,再用Thread()包装,start()开启,get()获取返回值
  • 线程池创建线程,将任务submit提交给线程池

Runnable和Callable的区别:

  • Runnable接口的run方法没有返回值,Callable接口call方法有返回值
  • Callable接口的call方法允许抛出异常,Runnable的run只能catch

start()和run()的区别

start():启动线程,start方法只能调用一次

run():要被线程执行的代码

线程状态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public enum State{
// 新建
NEW,

// 就绪
RUNNABLE,

// 阻塞
BLOCKED,

// 等待
WAITING,

// 有限时等待
TIMED_WAITING,

// 终止
TERMINATED;
}

方法

Thread.join():等待线程执行完

Object.wait():放弃锁,在这个锁上等待

Object.notify():随机唤醒锁上等待的线程

Object.notifyAll():唤醒当前锁上的所有线程

Thread.sleep():抱着锁睡眠

Thread.interrupt():打断线程,被打断的线程如果在阻塞会抛异常;被打断线程没有阻塞,则自己通过interrupt变量判断是否要终止线程

并发安全

synchronized

互斥的对象锁,重量级锁,性能较低,一旦锁发生竞争都会升级为重量级锁

原理

synchronized(lock)中的lock会与Monitor关联(对象的MarkWord指向Monitor)

Monitor监视器:

  • WaitSet:在锁上wait的线程
  • EntryList:等待锁的线程队列
  • Owner:当前锁的持有者

轻量级锁:通过CAS来获取锁;锁对象的MarkWord指向锁记录地址,原来MarkWord的内容放在持有锁线程中

偏向锁:只是在MarkWord中记录一下持有锁线程的id,锁重入时不需要cas

内存模型

JMM(Java Memory Model),定义了共享内存多线程程序读写操作的行为规范

CAS

Compare And Swap,体现乐观锁的思想,在无锁状态下保证线程操作数据的原子性

1
2
3
4
5
6
7
8
9
// 伪代码
while (true) {
int 旧值A = 共享变量V;
int 新值B = 旧值A + 1;
if (compareAndSwap(旧值A, 新值B)) { // 比较V的值是否和旧值A相同
// 成功,退出循环
}
}
// cas失败则会重试一定次数(自旋)

特点

  • 没有加锁,线程不会阻塞,效率较高
  • 若竞争激烈,重试频繁,效率会受影响

底层

​ 依赖于Unsafe类来直接调用操作系统的CAS指令

使用场景

  • ReentrantLock、AtomicXXX类等

volatile

关键字,被修饰的变量可以保证可见性和防止指令重排序

共享变量不可见的情况:由于一个变量需要读很多次,而每次都读到的同一个值,JIT编译器就会做优化

1
2
3
4
5
6
boolean stop = false;
while(!stop) { // 这里!stop就可能被优化成true
i++;
}

// 通过 volatile boolean stop 可以不被优化

指令重排序的情况:编译器优化代码顺序

volatile通过向上的写屏障和向下的读屏障来防止指令重排

AQS

Abstract Queue Synchronized,抽象队列同步器

原理:通过state变量表示锁是否被占用(0 无锁,1 有锁),线程通过CAS操作来进行修改,没抢到锁的去FIFO队列等待

AQS常见的实现类

  • ReentrantLock 可重入锁
  • Semaphore 信号量
  • CountDownLatch 倒计时锁

线程池

使用场景


面试题
http://xwww12.github.io/2023/02/21/其他/面试题/
作者
xw
发布于
2023年2月21日
许可协议