JAVA常见面试题整理

JAVA集合

说说有哪些常见集合?

集合相关类和接口都在java.util中,主要分为3种:List(列表)、Map(映射)、Set(集)

其中Collection是集合List、Set的父接口,它主要有两个子接口:

  • List:存储的元素有序,可重复。
  • Set:存储的元素不无序,不可重复。
  • Map是另外的接口,是键值对映射结构的集合。

ArrayList和LinkedList有什么区别?

  1. 数据结构不同
  • ArrayList基于数组实现
  • LinkedList基于双向链表实现
  1. 多数情况下,ArrayList更利于查找,LinkedList更利于增删

ArrayList基于数组实现,get(int index)可以直接通过数组下标获取,时间复杂度是O(1);LinkedList基于链表实现,get(int index)需要遍历链表,时间复杂度是O(n);当然,get(E element)这种查找,两种集合都需要遍历,时间复杂度都是O(n)。

ArrayList增删如果是数组末尾的位置,直接插入或者删除就可以了,但是如果插入中间的位置,就需要把插入位置后的元素都向前或者向后移动,甚至还有可能触发扩容;双向链表的插入和删除只需要改变前驱节点、后继节点和插入节点的指向就行了,不需要移动元素。

  1. 是否支持随机访问
  • ArrayList基于数组,所以它可以根据下标查找,支持随机访问,当然,它也实现了RandmoAccess 接口,这个接口只是用来标识是否支持随机访问。
  • LinkedList基于链表,所以它没法根据序号直接获取元素,它没有实现RandmoAccess 接口,标记不支持随机访问。
  1. 存占用,ArrayList基于数组,是一块连续的内存空间,LinkedList基于链表,内存空间不连续,它们在空间占用上都有一些额外的消耗:
  • ArrayList是预先定义好的数组,可能会有空的内存空间,存在一定空间浪费
  • LinkedList每个节点,需要存储前驱和后继,所以每个节点会占用更多的空间

ArrayList的扩容机制

ArrayList是基于数组的集合,数组的容量是在定义的时候确定的,如果数组满了,再插入,就会数组溢出。所以在插入时候,会先检查是否需要扩容,如果当前容量+1超过数组长度,就会进行扩容。

ArrayList的扩容是创建一个1.5倍的新数组,然后把原数组的值拷贝过去。

ArrayList怎么序列化的知道吗? 为什么用transient修饰数组?

ArrayList的序列化不太一样,它使用transient修饰存储元素的elementData的数组,transient关键字的作用是让被修饰的成员属性不被序列化。

为什么最ArrayList不直接序列化元素数组呢?

出于效率的考虑,数组可能长度100,但实际只用了50,剩下的50不用其实不用序列化,这样可以提高序列化和反序列化的效率,还可以节省内存空间。

那ArrayList怎么序列化呢?

ArrayList通过两个方法readObject、writeObject自定义序列化和反序列化策略,实际直接使用两个流ObjectOutputStream和ObjectInputStream来进行序列化和反序列化。

快速失败(fail-fast)和安全失败(fail-safe)了解吗?

快速失败(fail—fast):快速失败是Java集合的一种错误检测机制

在用迭代器遍历一个集合对象时,如果线程A遍历过程中,线程B对集合对象的内容进行了修改(增加、删除、修改),则会抛出Concurrent Modification Exception。

原理:迭代器在遍历时直接访问集合中的内容,并且在遍历过程中使用一个 modCount 变量。集合在被遍历期间如果内容发生变化,就会改变modCount的值。每当迭代器使用hashNext()/next()遍历下一个元素之前,都会检测modCount变量是否为expectedmodCount值,是的话就返回遍历;否则抛出异常,终止遍历。

注意:这里异常的抛出条件是检测到 modCount!=expectedmodCount 这个条件。如果集合发生变化时修改modCount值刚好又设置为了expectedmodCount值,则异常不会抛出。因此,不能依赖于这个异常是否抛出而进行并发操作的编程,这个异常只建议用于检测并发修改的bug。
场景:java.util包下的集合类都是快速失败的,不能在多线程下发生并发修改(迭代过程中被修改),比如ArrayList 类。

安全失败(fail—safe)

采用安全失败机制的集合容器,在遍历时不是直接在集合内容上访问的,而是先复制原有集合内容,在拷贝的集合上进行遍历。

原理:由于迭代时是对原集合的拷贝进行遍历,所以在遍历过程中对原集合所作的修改并不能被迭代器检测到,所以不会触发Concurrent Modification Exception。
缺点:基于拷贝内容的优点是避免了Concurrent Modification Exception,但同样地,迭代器并不能访问到修改后的内容,即:迭代器遍历的是开始遍历那一刻拿到的集合拷贝,在遍历期间原集合发生的修改迭代器是不知道的。

场景:java.util.concurrent包下的容器都是安全失败,可以在多线程下并发使用,并发修改,比如CopyOnWriteArrayList类。

你能说一下HashMap的数据结构吗?

JDK1.8的数据结构是数组+链表+红黑树。

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

  • 数据元素通过映射关系,也就是散列函数,映射到桶数组对应索引的位置
  • 如果发生冲突,从冲突的位置拉一个链表,插入冲突的元素
  • 如果链表长度>8&数组大小>=64,链表转为红黑树
  • 如果红黑树节点个数<6 ,转为链表

HashMap的put流程

  1. 首先进行哈希值的扰动,获取一个新的哈希值。(key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);

  2. 判断tab是否位空或者长度为0,如果是则进行扩容操作。

if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
  1. 根据哈希值计算下标,如果对应小标正好没有存放数据,则直接插入即可否则需要覆盖。tab[i = (n - 1) & hash])

  2. 判断tab[i]是否为树节点,否则向链表中插入数据,是则向树中插入节点。

  3. 如果链表中插入节点的时候,链表长度大于等于8,则需要把链表转换为红黑树。treeifyBin(tab, hash);

  4. 最后所有元素处理完成后,判断是否超过阈值;threshold,超过则扩容

HashMap怎么查找元素的呢?

  1. 使用扰动函数,获取新的哈希值
  2. 计算数组下标,获取节点
  3. 当前节点和key匹配,直接返回
  4. 否则,当前节点是否为树节点,查找红黑树
  5. 否则,遍历链表查找

HashMap的哈希函数是怎么设计的?

HashMap 的哈希函数主要有两个部分,即哈希码的计算和哈希桶的选择。下面分别介绍这两个部分的设计。

  1. 哈希码的计算

HashMap 的哈希码计算主要是通过对键的 hashCode() 方法的返回值进行扰动得到的。扰动函数的设计是为了减少哈希冲突,提高 HashMap 的性能和效率。

HashMap 的扰动函数实现比较简单,主要是将 hashCode 值进行异或、移位和相加等操作,以使得不同键的哈希码分布更加均匀。在 Java 8 中,HashMap 的扰动函数实现如下:


static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

其中,h 是键的 hashCode 值,使用异或运算符将高位和低位的哈希码混合,使得哈希码更加均匀分布。

  1. 哈希桶的选择

HashMap 的哈希桶主要是通过对键的哈希码进行取模操作得到的。哈希桶的选择对于 HashMap 的性能和效率也有很大的影响。

bucketIndex = indexFor(hash, table.length);

static int indexFor(int h, int length) {
     return h & (length-1);
}

HashMap 的哈希桶数量是通过初始化时指定的或者自动扩容时计算得到的,一般选择一个素数作为哈希桶的数量可以使得哈希桶更加均匀地分布在数组中。在 Java 8 中,HashMap 的默认初始容量是 16,负载因子是 0.75,当 HashMap 的元素数量达到容量和负载因子的乘积时,就会自动扩容,将容量增加一倍,并重新计算每个元素的哈希码和哈希桶的位置。

总的来说,HashMap 的哈希函数的设计旨在使得不同键的哈希码均匀分布,并尽可能地减少哈希冲突,从而提高 HashMap 的性能和效率。。

如果初始化HashMap,传一个17的值new HashMap<>,它会怎么处理?

简单来说,就是初始化时,传的不是2的倍数时,HashMap会向上寻找离得最近的2的倍数,所以传入17,但HashMap的实际容量是32。

我们来看看详情,在HashMap的初始化中,有这样⼀段⽅法;

public HashMap(int initialCapacity, float loadFactor) {
 ...
 this.loadFactor = loadFactor;
 this.threshold = tableSizeFor(initialCapacity);
}

阀值 threshold ,通过⽅法 tableSizeFor 进⾏计算,是根据初始化传的参数来计算的。
同时,这个⽅法也要要寻找⽐初始值⼤的,最⼩的那个2进制数值。⽐如传了17,我应该找到的是32。

static final int tableSizeFor(int cap) {
 int n = cap - 1;
 n |= n >>> 1;
 n |= n >>> 2;
 n |= n >>> 4;
 n |= n >>> 8;
 n |= n >>> 16;
 return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; }
 

MAXIMUM_CAPACITY = 1 << 30,这个是临界范围,也就是最⼤的Map集合。
计算过程是向右移位1、2、4、8、16,和原来的数做|运算,这主要是为了把⼆进制的各个位置都填上1,当⼆进制的各个位置都是1以后,就是⼀个标准的2的倍数减1了,最后把结果加1再返回即可。

以17为例,看一下初始化计算table容量的过程:

HashMap扩容机制

HashMap是基于数组+链表和红黑树实现的,但用于存放key值的桶数组的长度是固定的,由初始化参数确定。

那么,随着数据的插入数量增加以及负载因子的作用下,就需要扩容来存放更多的数据。而扩容中有一个非常重要的点,就是jdk1.8中的优化操作,可以不需要再重新计算每一个元素的哈希值。

因为HashMap的初始容量是2的次幂,扩容之后的长度是原来的二倍,新的容量也是2的次幂,所以,元素,要么在原位置,要么在原位置再移动2的次幂。

看下这张图,n为table的长度,图a表示扩容前的key1和key2两种key确定索引的位置,图b表示扩容后key1和key2两种key确定索引位置。

元素在重新计算hash之后,因为n变为2倍,那么n-1的mask范围在高位多1bit(红色),因此新的index就会发生这样的变化:

所以在扩容时,只需要看原来的hash值新增的那一位是0还是1就行了,是0的话索引没变,是1的化变成原索引+oldCap,看看如16扩容为32的示意图

扩容节点迁移主要逻辑:

你能自己设计实现一个HashMap吗?

整体的设计:

  • 散列函数:hashCode()+除留余数法
  • 冲突解决:链地址法
  • 扩容:节点重新hash获取位置

HashMap 是线程安全的吗?多线程下会有什么问题?

HashMap 是非线程安全的,因为它不是同步的。在多线程环境下,多个线程同时对 HashMap 进行修改可能会导致数据不一致和死循环等问题。

当多个线程同时对 HashMap 进行修改时,可能会发生以下两种情况

  1. 冲突问题:多个线程同时修改同一个桶,导致链表或红黑树结构被破坏,无法正确访问元素。
  2. 扩容问题:如果多个线程同时对 HashMap 进行扩容,可能会导致元素被复制多次,而且某些元素可能会被漏掉或重复添加,导致数据丢失或重复。

为了解决这些问题,可以使用以下方法:

使用 ConcurrentHashMap:ConcurrentHashMap 是线程安全的,它通过synchronized和 CAS 操作来实现线程安全。

使用 Collections.synchronizedMap(Map) 方法将 HashMap 转换为线程安全的 Map。

使用并发安全的 Map 实现,如 ConcurrentHashMap、ConcurrentSkipListMap 等。

需要注意的是,虽然使用 synchronized 或者 ConcurrentHashMap 可以保证线程安全,但是在多线程高并发的场景下,由于锁竞争等因素,仍然可能会导致性能下降。因此,在设计多线程应用程序时,需要综合考虑性能和线程安全问题。

JAVA并发编程

说一下你对Java内存模型(JMM)的理解

Java内存模型(Java Memory Model,JMM),是一种抽象的模型,被定义出来屏蔽各种硬件和操作系统的内存访问差异。

JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。

Java内存模型的抽象图:

Java内存模型主要由以下三个方面组成:

主内存:主内存是所有线程共享的内存区域,它包含了所有的变量和对象实例。在主内存中,变量的值可以被任意一个线程读取和修改。

工作内存:工作内存是每个线程独立的内存区域,它包含了该线程所需要访问的变量和对象实例的副本。每个线程在执行时,必须先将需要访问的变量和对象实例从主内存中复制到工作内存中,然后在工作内存中进行操作。

内存屏障:内存屏障是一种同步机制,它可以保证在多线程环境下的内存访问顺序。在JMM中,内存屏障分为读屏障、写屏障和全屏障三种类型。读屏障可以保证一个线程在读取一个变量时,能够看到其他线程对该变量所做的修改;写屏障可以保证一个线程在修改一个变量时,能够将该变量的值立即刷入主内存中;全屏障可以保证一个线程在执行某个操作之前,先将其前面的所有内存访问操作都刷新到主内存中。

JMM规范定义了一些内存模型原则,例如顺序一致性、原子性、可见性、有序性等等。这些原则保证了Java程序在多线程环境下的正确性和可靠

说说你对顺序一致性、原子性、可见性、有序性的理解

顺序一致性(Sequential Consistency)是指在一个线程中,对共享变量的操作必须按照程序的顺序来执行,而在多线程中,所有的线程都会看到同一个操作执行的顺序,即每个线程看到的顺序是一致的。

原子性(Atomicity)指一个操作或者一组操作是不可分割的整体,要么全部执行成功,要么全部不执行,不能只执行部分操作。在Java中,synchronized、Lock等机制都可以保证代码块的原子性。

可见性(Visibility)指当一个线程修改了共享变量的值时,其他线程能够立即看到这个修改,即一个线程对共享变量的修改对其他线程是可见的。在Java中,volatile关键字、synchronized、Lock等机制都可以保证可见性。

有序性(Ordering)指程序执行的顺序必须按照代码的先后顺序来执行,但在不影响程序执行结果的前提下,指令的执行顺序可以进行优化、调整等操作。在Java中,volatile关键字、synchronized、Lock等机制都可以保证有序性。

说说什么是指令重排?

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型。

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应 机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序,如图:

我们比较熟悉的双重校验单例模式就是一个经典的指令重排的例子,Singleton instance=new Singleton();对应的JVM指令分为三步:分配内存空间-->初始化对象--->对象指向分配的内存空间,但是经过了编译器的指令重排序,第二步和第三步就可能会重排序。

JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

指令重排有限制吗?happens-before了解吗?

指令重排也是有一些限制的,有两个规则happens-before和as-if-serial来约束。

happens-before的定义:

  • 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
  • 两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照 happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么这种重排序并不非法

happens-before和我们息息相关的有六大规则:

  1. 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
public class ThreadExample {
    private int value = 0;

    public void setValue(int newValue) {
        value = newValue; // 操作1
    }

    public int getValue() {
        return value; // 操作2
    }

    public void incrementValue() {
        value++; // 操作3
    }

    public static void main(String[] args) {
        ThreadExample example = new ThreadExample();

        // 在同一个线程中执行操作1、2、3
        example.setValue(1); // 操作1
        int val = example.getValue(); // 操作2
        example.incrementValue(); // 操作3

        System.out.println(val); // 输出 1
        System.out.println(example.getValue()); // 输出 2
    }
}

在上面的示例中,setValue()、getValue() 和 incrementValue() 方法都属于同一个线程。在这个线程中,操作1 happens-before 于操作2,操作2 happens-before 于操作3,因此操作1 happens-before 于操作3。这意味着,在操作3 执行之前,操作1 所做的修改对于操作3 是可见的。在 main() 方法中,我们按照操作1、2、3 的顺序执行了这三个方法,最后打印了变量的值。结果表明,在操作1、2、3 执行完毕后,变量的值从 1 增加到了 2,这证明了每个操作都 happens-before 于该线程中的任意后续操作。

  1. 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。这意味着,任何后续对这个锁的加锁操作都能看到之前解锁操作所做的修改。这种规则的实现是基于锁的内存语义,即在释放锁之前所有对共享变量的修改都将被刷新到主内存中,随后获取锁的线程将从主内存中读取这些修改后的值,从而保证了解锁操作对于后续的加锁操作是可见的。

  2. volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。

  3. 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。

  4. start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的 ThreadB.start()操作happens-before于线程B中的任意操作。

Thread B = new Thread(()->{
  // 主线程调用 B.start() 之前
  // 所有对共享变量的修改,此处皆可见
  // 此例中,var==77
});
// 此处对共享变量 var 修改
var = 77;
// 主线程启动子线程
B.start();
  1. join()规则:如果线程 A 调用线程 B 的 join() 方法等待线程 B 完成,当线程 B 完成后,线程 A 能够看到线程 B 所做的修改。这是因为在线程 B 完成之前,所有的操作都 happens-before 线程 B 完成,而在线程 B 完成之后,线程 A 所做的操作 happens-after 线程 B 完成。

as-if-serial又是什么?单线程的程序一定是顺序的吗?

"as-if-serial"是Java虚拟机的一种优化技术,它允许Java虚拟机对代码进行优化,只要优化后的代码在单线程中的执行结果和没有优化前的代码一致,就可以被认为是合法的。

具体来说,"as-if-serial"的规则是这样的:如果一个Java程序在单线程中按照某种顺序执行,那么Java虚拟机可以对这个程序进行任意的指令重排序和优化,只要最终执行的结果和单线程下的执行结果一致即可。

这个规则的含义是,Java程序在单线程中的执行顺序是可以被优化的。这并不意味着单线程的程序一定是顺序执行的,因为程序中可能会包含一些I/O操作、sleep等等,这些操作都可能导致程序的执行顺序发生变化。但是,Java虚拟机保证在单线程中,程序按照某种顺序执行的结果是可预测的。

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。为了具体说明,请看下面计算圆面积的代码示例。

double pi = 3.14;   // A
double r = 1.0;   // B 
double area = pi * r * r;   // C

上面3个操作的数据依赖关系:

A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。因此在最终执行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的结果将会被改变)。但A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序。

所以最终,程序可能会有两种执行顺序:

volatile作用及实现原理

volatile有两个作用,保证可见性和有序性。

volatile怎么保证可见性的呢?

相比synchronized的加锁方式来解决共享变量的内存可见性问题,volatile就是更轻量的选择,它没有上下文切换的额外开销成本。

volatile可以确保对某个变量的更新对其他线程马上可见,一个变量被声明为volatile 时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存 当其它线程读取该共享变量 ,会从主内存重新获取最新值,而不是使用当前线程的本地内存中的值。

例如,我们声明一个 volatile 变量 volatile int x = 0,线程A修改x=1,修改完之后就会把新的值刷新回主内存,线程B读取x的时候,就会清空本地内存变量,然后再从主内存获取最新值。

volatile怎么保证有序性的呢

重排序可以分为编译器重排序和处理器重排序,valatile保证有序性,就是通过分别限制这两种类型的重排序。

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

  1. 在每个volatile写操作的前面插入一个StoreStore屏障

  2. 在每个volatile写操作的后面插入一个StoreLoad屏障

  3. 在每个volatile读操作的后面插入一个LoadLoad屏障

  4. 在每个volatile读操作的后面插入一个LoadStore屏障

  5. StoreStore屏障:保证在执行该屏障之前的所有写操作已经完成,而在执行该屏障之后的所有写操作都必须在之后进行。这个屏障可以用来避免写操作重排序。

  6. StoreLoad屏障:保证在执行该屏障之前的所有写操作已经完成,并且在执行该屏障之后的所有读操作都必须基于最新的值。这个屏障可以用来避免写操作和读操作重排序。

  7. LoadLoad屏障:保证在执行该屏障之前的所有读操作已经完成,而在执行该屏障之后的所有读操作都必须基于最新的值。这个屏障可以用来避免读操作重排序。

  8. LoadStore屏障:保证在执行该屏障之前的所有读操作已经完成,并且在执行该屏障之后的所有写操作都必须在之后进行。这个屏障可以用来避免读操作和写操作重排序。

synchronized如何使用?

synchronized主要有三种用法:

修饰实例方法: 作用于当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁

synchronized void method() {
  //业务代码
}

修饰静态方法:也就是给当前类加锁,会作⽤于类的所有对象实例 ,进⼊同步代码前要获得当前 class 的锁。因为静态成员不属于任何⼀个实例对象,是类成员( static 表明这是该类的⼀个静态资源,不管 new 了多少个对象,只有⼀份)。

如果⼀个线程 A 调⽤⼀个实例对象的⾮静态 synchronized ⽅法,⽽线程 B 需要调⽤这个实例对象所属类的静态 synchronized ⽅法,是允许的,不会发⽣互斥现象,因为访问静态 synchronized ⽅法占⽤的锁是当前类的锁,⽽访问⾮静态 synchronized ⽅法占⽤的锁是当前实例对象锁。

synchronized void staic method() {
 //业务代码
}

修饰代码块 :指定加锁对象,对给定对象/类加锁。 synchronized(this|object) 表示进⼊同步代码库前要获得给定对象的锁。 synchronized(类.class) 表示进⼊同步代码前要获得 当前 class 的锁

synchronized(this) {
 //业务代码
}

synchronized实现原理

synchronized是怎么加锁的呢?

  1. synchronized修饰代码块时,JVM采用monitorenter、monitorexit两个指令来实现同步,monitorenter 指令指向同步代码块的开始位置, monitorexit 指令则指向同步代码块的结束位置。

反编译一段synchronized修饰代码块代码,javap -c -s -v -l SynchronizedDemo.class,可以看到相应的字节码指令。

  1. synchronized修饰同步方法时,JVM采用ACC_SYNCHRONIZED标记符来实现同步,这个标识指明了该方法是一个同步方法。

同样可以写段代码反编译看一下。

synchronized锁住的是什么呢?

monitorenter、monitorexit或者ACC_SYNCHRONIZED都是基于Monitor实现的。

实例对象结构里有对象头,对象头里面有一块结构叫Mark Word,Mark Word指针指向了monitor。

所谓的Monitor其实是一种同步工具,也可以说是一种同步机制。在Java虚拟机(HotSpot)中,Monitor是由ObjectMonitor实现的,可以叫做内部锁,或者Monitor锁。

ObjectMonitor 存在于 Java 虚拟机(JVM)的堆内存中。具体来说在 Java 中,只有被 synchronized 关键字修饰的对象才会创建一个 ObjectMonitor 实例用来维护锁状态。而这个对象头中的Mark Word记录者与之相关联ObjectMonitor的内存地址。

ObjectMonitor 是 Java 中用来实现锁机制的核心数据结构之一,它的数据结构比较复杂。它是由以下几部分构成的:

  1. 锁状态(Lock State):用于表示锁的状态,包括锁的拥有者线程 ID、锁的计数器和锁的等待队列等信息。

  2. 等待队列(Wait Queue):用于存储正在等待锁的线程,以及它们在等待队列中的前驱和后继等信息。

  3. 条件队列(Condition Queue):用于存储等待在该对象上的条件等待队列,以及它们在条件队列中的前驱和后继等信息。

  4. 计数器(Count):用于表示该对象上的锁计数器的值,也就是一个线程持有该对象上的锁的次数。

  5. 持有者(Owner):用于表示当前持有该对象的锁的线程 ID。

  6. 递归计数器(Recursions):用于表示当前线程在该对象上持有锁的嵌套深度。

  7. 同步器(EntryList):用于存储该对象上已经获取到的锁的记录,以及它们在同步器中的前驱和后继等信息。

ObjectMonitor的工作原理:

  1. 当线程尝试获取一个对象的监视器锁时,它会进入 ObjectMonitor 的入口处(entry set),如果当前没有其他线程持有该锁,那么该线程可以立即获得锁并继续执行;否则,该线程就会被阻塞,直到锁被释放为止。

  2. 如果线程被阻塞,它会进入 ObjectMonitor 的等待队列(wait set),等待其他线程释放锁并唤醒它。在等待队列中的线程状态是 WAITING 或 TIMED_WAITING。

  3. 当锁被释放时,唤醒等待队列中的一个线程,该线程进入 ObjectMonitor 的阻塞队列(wait set),等待重新竞争锁。在阻塞队列中的线程状态是 BLOCKED。

  4. 当一个线程成功获取到锁并开始执行时,它将持有 ObjectMonitor 的计数器,计数器初始值为 0,每当该线程进入一次 synchronized 块时,计数器就会加 1,每当该线程退出一次 synchronized 块时,计数器就会减 1。当计数器的值变为 0 时,该线程释放锁并退出 ObjectMonitor。

synchronized原子性,可见性,有序性,可重入性怎么实现

  1. 原子性:synchronized 通过将代码块或方法加锁来保证其原子性。一个线程获取了锁之后,其他线程就不能再进入被加锁的代码块或方法,只有获取到锁的线程执行完毕后,其他线程才能进入该代码块或方法。这样就保证了被加锁的代码块或方法是原子性的,即不会被多个线程同时执行或干扰。

  2. 可见性:synchronized 可以通过在同步块进入和退出时刷新主内存和本地内存之间的数据,从而保证了可见性。当一个线程获取锁时,它会把本地内存中的数据刷新到主内存中,当释放锁时,它会把本地内存中的数据清空,这样其他线程在获取锁时就会从主内存中获取最新的数据,从而保证了数据的可见性。

  3. synchronized同步的代码块,具有排他性,一次只能被一个线程拥有,所以synchronized保证同一时刻,代码是单线程执行的。因为as-if-serial语义的存在,单线程的程序能保证最终结果是有序的,但是不保证不会指令重排。所以synchronized保证的有序是执行结果的有序性,而不是防止指令重排的有序性。

  4. synchronized 是可重入锁,也就是说,允许一个线程二次请求自己持有对象锁的临界资源,这种情况称为可重入锁。synchronized 锁对象的时候有个计数器,他会记录下线程获取锁的次数,在执行完对应的代码块之后,计数器就会-1,直到计数器清零,就释放锁了。之所以,是可重入的。是因为 synchronized 锁对象有个计数器,会随着线程获取锁后 +1 计数,当线程执行完毕后 -1,直到清零释放锁。

synchronized锁升级过程

了解锁升级,得先知道,不同锁的状态是什么样的。这个状态指的是什么呢?

Java对象头里,有一块结构,叫Mark Word标记字段,这块结构会随着锁的状态变化而变化。

64 位虚拟机 Mark Word 是 64bit,我们来看看它的状态变化:

Mark Word存储对象自身的运行数据,如哈希码、GC分代年龄、锁状态标志、偏向时间戳(Epoch) 等。

synchronized做了哪些优化?

在JDK1.6之前,synchronized的实现直接调用ObjectMonitor的enter和exit,这种锁被称之为重量级锁。从JDK6开始,HotSpot虚拟机开发团队对Java中的锁进行优化,如增加了适应性自旋、锁消除、锁粗化、轻量级锁和偏向锁等优化策略,提升了synchronized的性能。

  • 偏向锁:在无竞争的情况下,只是在Mark Word里存储当前线程指针,CAS操作都不做。

  • 轻量级锁:在没有多线程竞争时,相对重量级锁,减少操作系统互斥量带来的性能消耗。但是,如果存在锁竞争,除了互斥量本身开销,还额外有CAS操作的开销。

  • 自旋锁:减少不必要的CPU上下文切换。在轻量级锁升级为重量级锁时,就使用了自旋加锁的方式

  • 锁粗化:将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。

  • 锁消除:虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。

锁升级的过程是什么样的?

锁升级方向:无锁-->偏向锁---> 轻量级锁---->重量级锁,这个方向基本上是不可逆的。

我们看一下升级的过程:

偏向锁:

偏向锁的获取:

  1. 判断是否为可偏向状态--MarkWord中锁标志是否为‘01’,是否偏向锁是否为‘1’
  2. 如果是可偏向状态,则查看线程ID是否为当前线程,如果是,则进入步骤'5',否则进入步骤‘3’
  3. 通过CAS操作竞争锁,如果竞争成功,则将MarkWord中线程ID设置为当前线程ID,然后执行‘5’;竞争失败,则执行‘4’
  4. CAS获取偏向锁失败表示有竞争。当达到safepoint时获得偏向锁的线程被挂起,偏向锁升级为轻量级锁,然后被阻塞在安全点的线程继续往下执行同步代码块
  5. 执行同步代码

偏向锁的撤销:

  1. 偏向锁不会主动释放(撤销),只有遇到其他线程竞争时才会执行撤销,由于撤销需要知道当前持有该偏向锁的线程栈状态,因此要等到safepoint时执行,此时持有该偏向锁的线程(T)有‘2’,‘3’两种情况;
  2. 撤销----T线程已经退出同步代码块,或者已经不再存活,则直接撤销偏向锁,变成无锁状态----该状态达到阈值20则执行批量重偏向
  3. 升级----T线程还在同步代码块中,则将T线程的偏向锁升级为轻量级锁,当前线程执行轻量级锁状态下的锁获取步骤----该状态达到阈值40则执行批量撤销

轻量级锁:

轻量级锁的获取:

  1. 进行加锁操作时,jvm会判断是否已经时重量级锁,如果不是,则会在当前线程栈帧中划出一块空间,作为该锁的锁记录,并且将锁对象MarkWord复制到该锁记录中
  2. 复制成功之后,jvm使用CAS操作将对象头MarkWord更新为指向锁记录的指针,并将锁记录里的owner指针指向对象头的MarkWord。如果成功,则执行‘3’,否则执行‘4’
  3. 更新成功,则当前线程持有该对象锁,并且对象MarkWord锁标志设置为‘00’,即表示此对象处于轻量级锁状态
  4. 更新失败,jvm先检查对象MarkWord是否指向当前线程栈帧中的锁记录,如果是则执行‘5’,否则执行‘4’
  5. 表示锁重入;然后当前线程栈帧中增加一个锁记录第一部分(Displaced Mark Word)为null,并指向Mark Word的锁对象,起到一个重入计数器的作用。
  6. 表示该锁对象已经被其他线程抢占,则进行自旋等待(默认10次),等待次数达到阈值仍未获取到锁,则升级为重量级锁

大体上省简的升级过程:

完整的升级过程:

说说synchronized和ReentrantLock的区别

什么是AQS

AbstractQueuedSynchronizer 抽象同步队列,简称 AQS ,它是Java并发包的根基,并发包中的锁就是基于AQS实现的。

  • AQS是基于一个FIFO的双向队列,其内部定义了一个节点类Node,Node 节点内部的 SHARED 用来标记该线程是获取共享资源时被阻挂起后放入AQS 队列的, EXCLUSIVE 用来标记线程是 取独占资源时被挂起后放入AQS 队列
  • AQS 使用一个 volatile 修饰的 int 类型的成员变量 state 来表示同步状态,修改同步状态成功即为获得锁,volatile 保证了变量在多线程之间的可见性,修改 State 值时通过 CAS 机制来保证修改的原子性
  • 获取state的方式分为两种,独占方式和共享方式,一个线程使用独占方式获取了资源,其它线程就会在获取失败后被阻塞。一个线程使用共享方式获取了资源,另外一个线程还可以通过CAS的方式进行获取。
  • 如果共享资源被占用,需要一定的阻塞等待唤醒机制来保证锁的分配,AQS 中会将竞争共享资源失败的线程添加到一个变体的 CLH 队列中。

AQS 中的队列是 CLH 变体的虚拟双向队列,通过将每条请求共享资源的线程封装成一个节点来实现锁的分配:

AQS 中的 CLH 变体等待队列拥有以下特性:

  1. AQS 中队列是个双向链表,也是 FIFO 先进先出的特性
  2. 通过 Head、Tail 头尾两个节点来组成队列结构,通过 volatile 修饰保证可见性
  3. Head 指向节点为已获得锁的节点,是一个虚拟节点,节点本身不持有具体线程
  4. 获取不到同步状态,会将节点进行自旋获取锁,自旋一定次数失败后会将线程阻塞,相对于 CLH 队列性能较好

CAS是什么?存在什么问题?

CAS(Compare and Swap)是一种无锁的原子操作,用于实现多线程环境下的同步和数据更新。它的基本思想是:先比较目标内存与一个期望值,如果相等,则将目标内存值更新为新值,否则不做任何操作。

在CAS中,有三个操作数:内存位置(V)、期望值(A)和新值(B)。CAS指令执行时,当且仅当内存位置V的值与期望值A相等时,才会将内存位置V的值更新为新值B,否则什么都不做。

作为一条 CPU 指令,CAS 指令本身是能够保证原子性的 。

CAS操作通常认为是无锁操作,它是通过一个 cmpxchg指令来实现原子操作的。但是它并不是完全无锁的,在早期CPU架构中会通过锁定总线并且阻塞其他 CPU 的访问,以确保在该指令执行期间内存位置的独占性。这种行为通常被称为总线锁定(bus locking),可以有效地保证原子性,但同时也会降低系统的并发性能。然而,在现代的 x86 CPU 中,通常会使用更加高效的缓存一致性协议(cache coherence protocol)来实现原子操作。在这种情况下,cmpxchg 操作只会锁定特定的内存缓存行(cache line),而不会锁定总线。当一个 CPU 执行 cmpxchg 操作时,其他 CPU 可以继续访问其他缓存行而不会被阻塞,因此不会对系统的并发性能造成太大的影响。

CAS的经典三大问题:

ABA 问题

并发环境下,假设初始条件是A,去修改数据时,发现是A就会执行修改。但是看到的虽然是A,中间可能发生了A变B,B又变回A的情况。此时A已经非彼A,数据即使成功修改,也可能有问题。

怎么解决ABA问题?

加版本号。每次修改变量,都在这个变量的版本号上加1,这样,刚刚A->B->A,虽然A的值没变,但是它的版本号已经变了,再判断版本号就会发现此时的A已经被改过了。参考乐观锁的版本号,这种做法可以给数据带上了一种实效性的检验。

Java提供了AtomicStampReference类,它的compareAndSet方法首先检查当前的对象引用值是否等于预期引用,并且当前印戳(Stamp)标志是否等于预期标志,如果全部相等,则以原子方式将引用值和印戳标志的值更新为给定的更新值。

例如:

import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicStampedReference;

public class AtomicStampedReferenceExample {
    private static AtomicStampedReference<Integer> value = new AtomicStampedReference<>(100, 0);

    public static void main(String[] args) throws InterruptedException {
        // 线程1先将value的值从100变为200,然后再变回100
        Thread thread1 = new Thread(() -> {
            int stamp = value.getStamp();
            System.out.println(Thread.currentThread().getName() + " before update, stamp=" + stamp + ", value=" + value.getReference());
            value.compareAndSet(100, 200, stamp, stamp + 1);
            System.out.println(Thread.currentThread().getName() + " after first update, stamp=" + value.getStamp() + ", value=" + value.getReference());
            value.compareAndSet(200, 100, value.getStamp(), value.getStamp() + 1);
            System.out.println(Thread.currentThread().getName() + " after second update, stamp=" + value.getStamp() + ", value=" + value.getReference());
        }, "Thread 1");

        // 线程2在1秒后将value的值从100变为300
        Thread thread2 = new Thread(() -> {
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            int stamp = value.getStamp();
            System.out.println(Thread.currentThread().getName() + " before update, stamp=" + stamp + ", value=" + value.getReference());
            value.compareAndSet(100, 300, stamp, stamp + 1);
            System.out.println(Thread.currentThread().getName() + " after update, stamp=" + value.getStamp() + ", value=" + value.getReference());
        }, "Thread 2");

        thread1.start();
        thread2.start();
        thread1.join();
        thread2.join();
    }
}

在该示例中,线程1首先将value的值从100变为200,然后再变回100,线程2在1秒后将value的值从100变为300。由于线程1的第二次修改使得value的值变回了100,如果直接使用AtomicInteger进行CAS操作,可能会出现ABA问题。但是使用AtomicStampedReference的话,每次修改都会更新版本号,从而能够避免ABA问题的发生。在示例中,线程2虽然在1秒后将value的值从100变为300,但由于版本号已经发生了变化,所以CAS操作失败,从而保证了数据的正确性。

循环性能开销

自旋CAS,如果一直循环执行,一直不成功,会给CPU带来非常大的执行开销。

怎么解决循环性能开销问题?

在Java中,很多使用自旋CAS的地方,会有一个自旋次数的限制,超过一定次数,就停止自旋。

只能保证一个变量的原子操作

CAS 保证的是对一个变量执行操作的原子性,如果对多个变量操作时,CAS 目前无法直接保证操作的原子性的。

怎么解决只能保证一个变量的原子操作问题?

  1. 可以考虑改用锁来保证操作的原子性
  2. 可以考虑合并多个变量,将多个变量封装成一个对象,通过AtomicReference来保证原子性。

原子操作类了解多少?

当程序更新一个变量时,如果多线程同时更新这个变量,可能得到期望之外的值,比如变量i=1,A线程更新i+1,B线程也更新i+1,经过两个线程操作之后可能i不等于3,而是等于2。因为A和B线程在更新变量i的时候拿到的i都是1,这就是线程不安全的更新操作,一般我们会使用synchronized来解决这个问题,synchronized会保证多线程不会同时更新变量i。

其实除此之外,还有更轻量级的选择,Java从JDK 1.5开始提供了java.util.concurrent.atomic包,这个包中的原子操作类提供了一种用法简单、性能高效、线程安全地更新一个变量的方式。

因为变量的类型有很多种,所以在Atomic包里一共提供了13个类,属于4种类型的原子更新方式,分别是原子更新基本类型、原子更新数组、原子更新引用和原子更新属性(字段)。

Atomic包里的类基本都是使用Unsafe实现的包装类。

使用原子的方式更新基本类型,Atomic包提供了以下3个类:

  • AtomicBoolean:原子更新布尔类型。

  • AtomicInteger:原子更新整型。

  • AtomicLong:原子更新长整型。

通过原子的方式更新数组里的某个元素,Atomic包提供了以下4个类:

  • AtomicIntegerArray:原子更新整型数组里的元素。

  • AtomicLongArray:原子更新长整型数组里的元素。

  • AtomicReferenceArray:原子更新引用类型数组里的元素。

  • AtomicIntegerArray类主要是提供原子的方式更新数组里的整型

原子更新基本类型的AtomicInteger,只能更新一个变量,如果要原子更新多个变量,就需要使用这个原子更新引用类型提供的类。Atomic包提供了以下3个类:

  • AtomicReference:原子更新引用类型。

  • AtomicReferenceFieldUpdater:原子更新引用类型里的字段。

  • AtomicMarkableReference:原子更新带有标记位的引用类型。可以原子更新一个布尔类型的标记位和引用类型。构造方法是AtomicMarkableReference(V initialRef,boolean initialMark)。

如果需原子地更新某个类里的某个字段时,就需要使用原子更新字段类,Atomic包提供了以下3个类进行原子字段更新:

  • AtomicIntegerFieldUpdater:原子更新整型的字段的更新器。

  • AtomicLongFieldUpdater:原子更新长整型字段的更新器。

  • AtomicStampedReference:原子更新带有版本号的引用类型。该类将整数值与引用关联起来,可用于原子的更新数据和数据的版本号,可以解决使用CAS进行原子更新时可能出现的 ABA问题。

java线程有几种创建方式

  1. 继承Thread类:可以定义一个类继承Thread类,并重写run()方法,run()方法中包含线程需要执行的代码。创建该类的对象后,调用start()方法即可启动线程。

  2. 实现Runnable接口:定义一个类实现Runnable接口,并重写run()方法,run()方法中包含线程需要执行的代码。创建该类的对象后,将其作为参数传递给Thread类的构造方法中,创建Thread类的对象后,调用start()方法即可启动线程。

  3. 实现Callable接口:定义一个类实现Callable接口,并重写call()方法,call()方法中包含线程需要执行的代码。创建该类的对象后,将其作为参数传递给ExecutorService类的submit()方法中,submit()方法会返回一个Future对象,通过Future对象的get()方法可以获取call()方法的返回值。

  4. 使用线程池:使用ExecutorService类创建一个线程池,将需要执行的线程任务作为参数传递给线程池的submit()方法中,线程池会自动分配线程执行任务。

但是Java创建线程本质上只有一种方式,即使用 new Thread(),是因为Java中的其他方式最终都会通过创建Thread对象来实现线程的创建。

例如,使用实现Runnable接口的方式创建线程时,需要创建一个Thread对象,并将实现了Runnable接口的类的对象作为参数传递给Thread的构造函数。Thread类实现了Runnable接口,并在run()方法中调用了Runnable接口的run()方法,所以当启动Thread对象时,实际上是调用了实现了Runnable接口的类的run()方法。

同样地,使用实现Callable接口的方式创建线程时,需要通过ExecutorService类的submit()方法来执行Callable任务。submit()方法会返回一个Future对象,通过该对象的get()方法可以获取任务的返回值。但是在内部,ExecutorService实际上是创建了一个Thread对象,并将Callable任务作为Thread的Runnable参数传递给Thread对象来执行。

因此,无论使用哪种方式创建线程,都离不开创建Thread对象,并将需要执行的任务作为Thread的Runnable参数传递给Thread对象。因此,可以说Java创建线程本质上只有一种方式,就是 new Thread()。

线程有几种状态?

在Java中,线程共有六种状态:

状态 说明
NEW 初始状态:线程被创建,但还没有调用start()方法
RUNNABLE 运行状态:Java线程将操作系统中的就绪和运行两种状态笼统的称作“运行”
BLOCKED 阻塞状态:表示线程阻塞于锁
WAITING 等待状态:表示线程进入等待状态,进入该状态表示当前线程需要等待其他线程做出一些特定动作(通知或中断)
TIME_WAITING 超时等待状态:该状态不同于 WAITIND,它是可以在指定的时间自行返回的
TERMINATED 终止状态:表示当前线程已经执行完毕

线程在自身的生命周期中, 并不是固定地处于某个状态,而是随着代码的执行在不同的状态之间进行切换,Java线程状态变化如图示:

既然调用Thread.start()方法时会执行run()方法,那怎么不直接调用run()方法

在Java中,如果直接调用线程对象的run()方法,那么它会在当前线程中以普通方法的方式被调用,而不是以新的线程来执行。只有通过start()方法来启动线程,才能创建一个新的线程并在新线程中执行run()方法。

这是因为线程的启动需要一系列的步骤,包括初始化线程的数据结构、为线程分配资源、启动线程等。而这些步骤都是由start()方法来完成的。当调用start()方法时,它会启动一个新的线程,并在新线程中调用run()方法。此时,线程的状态会从NEW状态转变为RUNNABLE状态,并加入到线程调度器中,等待系统调度执行。

如果直接调用run()方法,它会在当前线程中被执行,不会创建新的线程,也不会加入到线程调度器中等待执行。因此,直接调用run()方法不能实现多线程的并发执行,只能在当前线程中以普通方法的方式执行run()方法。

因此,为了创建一个新的线程并启动它,我们必须调用start()方法。start()方法会完成线程的初始化工作、分配资源和启动线程,从而使得线程能够并发执行,达到多线程的目的。

什么是线程上下文切换?

线程上下文切换是指在多任务处理环境中,CPU需要在多个线程之间进行切换时,先保存当前线程的上下文信息(包括程序计数器、堆栈指针、寄存器等),然后加载新线程的上下文信息,使得新线程可以继续执行。当新线程执行一段时间后,又需要进行线程上下文切换,保存新线程的上下文信息,然后重新加载原线程的上下文信息,使得原线程可以继续执行。

线程上下文切换是一种非常耗费资源的操作,因为它需要保存和恢复线程的上下文信息,而且在切换过程中还需要更新CPU的缓存和TLB等信息,这些都会造成额外的CPU负担和延迟。

线程上下文切换的原因通常有以下几种:

  1. 时间片耗尽:操作系统将CPU时间分配给线程的时间是有限的,当一个线程占用CPU时间超过了分配给它的时间,操作系统就会将CPU时间切换给其他线程执行。

  2. 阻塞操作:当线程执行阻塞操作(如I/O操作)时,它会进入阻塞状态,此时操作系统可以将CPU时间切换给其他线程执行。

  3. 多线程协作:当多个线程需要协作完成某个任务时,它们需要相互等待对方完成某些操作,此时线程上下文切换是必须的。

虽然线程上下文切换是必须的,但是过多的上下文切换会导致系统的性能下降。因此,在编写多线程程序时,需要避免不必要的线程上下文切换,减少线程的阻塞和等待时间,提高系统的并发性能。

守护线程是什么?跟普通线程之间的区别?

Java中的守护线程(Daemon Thread)是一种特殊的线程,它的作用是为其他线程提供服务,如垃圾回收线程就是一个典型的守护线程。当JVM中只有守护线程时,JVM会退出,守护线程也会随之结束。

守护线程和普通线程之间的区别主要在以下两点:

  1. 生命周期:守护线程的生命周期受到普通线程的影响,只要所有的普通线程都结束了,守护线程就会自动结束。而普通线程的生命周期则不受守护线程的影响,只有执行完run()方法或者抛出异常才会结束。

  2. 优先级:守护线程的优先级比普通线程低,当JVM中只剩下守护线程时,守护线程的优先级没有作用,JVM会立即退出。

守护线程通常用于执行一些后台任务,如垃圾回收、定时任务等,这些任务对应用程序的正常运行并不是必需的,但是它们对于系统的稳定性和性能是很重要的。在使用守护线程时,需要注意以下几点:

  1. 守护线程需要在启动普通线程之前启动,否则它可能无法生效。

  2. 守护线程不能访问一些需要关闭操作的资源,如文件和数据库连接等,否则可能会导致资源无法正确关闭。

  3. 守护线程需要在run()方法中包含一个无限循环,以便在没有其他线程运行时仍然能够继续执行。

线程间有哪些通信方式?

  1. 共享变量:多个线程可以共享同一个变量,通过修改变量的值来进行通信。但是需要注意的是,共享变量需要进行同步操作,以避免并发问题。

  2. wait/notify:wait/notify是Java中基于监视器的线程通信机制,可以让线程在某些条件下等待或者通知其他线程。一个线程可以调用wait()方法进入等待状态,另一个线程可以调用notify()方法通知等待的线程继续执行。需要注意的是,wait/notify需要在同步块内部使用,并且调用wait()方法会释放锁,notify()方法会唤醒一个等待的线程。

  3. join:join方法可以让一个线程等待另一个线程执行完毕后再继续执行。当一个线程调用另一个线程的join()方法时,它会等待该线程执行完毕后才能继续执行。需要注意的是,join()方法会阻塞当前线程,直到被等待线程执行完毕。

  4. BlockingQueue:BlockingQueue是Java中提供的线程安全的队列,可以实现线程间的通信。一个线程可以把数据放入队列中,另一个线程可以从队列中取出数据。需要注意的是,当队列为空时,从队列中取出数据的操作会被阻塞,直到有数据放入队列中。

import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;

public class BlockingQueueDemo {
    public static void main(String[] args) throws InterruptedException {
        // 创建一个阻塞队列
        BlockingQueue<String> queue = new LinkedBlockingQueue<>();

        // 创建写线程并启动
        Thread writerThread = new Thread(new WriterTask(queue));
        writerThread.start();

        // 创建读线程并启动
        Thread readerThread = new Thread(new ReaderTask(queue));
        readerThread.start();

        // 等待两个线程执行完成
        writerThread.join();
        readerThread.join();
    }

    static class WriterTask implements Runnable {
        private BlockingQueue<String> queue;

        public WriterTask(BlockingQueue<String> queue) {
            this.queue = queue;
        }

        @Override
        public void run() {
            try {
                String message = "Hello, World!";
                queue.put(message);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }

    static class ReaderTask implements Runnable {
        private BlockingQueue<String> queue;

        public ReaderTask(BlockingQueue<String> queue) {
            this.queue = queue;
        }

        @Override
        public void run() {
            try {
                String message = queue.take();
                System.out.println("Received message: " + message);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

在上面的示例代码中,创建了一个LinkedBlockingQueue实例,作为线程间通信的队列。然后创建了一个写线程和一个读线程,分别使用BlockingQueue向队列中写入数据,并从队列中读取数据。由于BlockingQueue内部实现了线程同步机制,因此可以保证线程间通信的正确性和可靠性。

  1. Semaphore:Semaphore是一种计数信号量,用于控制线程的并发数量。可以通过acquire()方法获取许可证,release()方法释放许可证,从而控制同时执行的线程数量。

import java.util.concurrent.Semaphore;

public class SemaphoreDemo {
    public static void main(String[] args) {
        int threads = 5;
        Semaphore semaphore = new Semaphore(2);

        for (int i = 0; i < threads; i++) {
            new Thread(() -> {
                try {
                    // 获取许可证
                    semaphore.acquire();
                    System.out.println("Thread " + Thread.currentThread().getName() + " is running");
                    // 模拟线程执行
                    Thread.sleep(1000);
                    System.out.println("Thread " + Thread.currentThread().getName() + " finished");
                } catch (Exception e) {
                    e.printStackTrace();
                } finally {
                    // 释放许可证
                    semaphore.release();
                }
            }).start();
        }
    }
}

上面的代码中,我们创建了一个Semaphore实例,并将许可证数量设置为2。然后我们创建了5个线程,并在每个线程中执行了以下操作:

  • 获取许可证
  • 执行一些操作
  • 释放许可证

由于许可证数量为2,因此同时最多只能有两个线程执行。如果有第三个线程尝试获取许可证,它将会被阻塞,直到有一个线程释放了许可证为止。

类似地,Semaphore也可以用于控制多个线程对某个资源的访问顺序,例如限制数据库连接池中的线程数量,或者限制文件系统中的并发读写操作数量等。

  1. CyclicBarrier:CyclicBarrier是一种线程同步机制,可以让多个线程在同一时刻到达同一个屏障点。当所有线程都到达屏障点时,它们才能继续执行。

import java.util.concurrent.CyclicBarrier;

public class CyclicBarrierDemo {
    public static void main(String[] args) {
        int threads = 3;
        CyclicBarrier barrier = new CyclicBarrier(threads);

        for (int i = 0; i < threads; i++) {
            new Thread(() -> {
                try {
                    System.out.println("Thread " + Thread.currentThread().getName() + " is running");
                    // 等待所有线程都到达屏障点
                    barrier.await();
                    System.out.println("Thread " + Thread.currentThread().getName() + " continues to run");
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}

上面的代码中,我们创建了一个CyclicBarrier实例,并将屏障点的数量设置为3。然后我们创建了3个线程,并在每个线程中执行了以下操作:

  • 打印当前线程的名称
  • 等待所有线程都到达屏障点
  • 打印当前线程的名称

在上面的代码中,所有线程都会在屏障点等待,直到所有线程都到达屏障点后才能继续执行。这样可以确保所有线程在同一个时间点上执行某些操作,从而实现线程间的协调和通信。

  1. 管道输入/输出流:PipedInputStream和PipedOutputStream是一对用于线程间通信的类,它们可以通过管道进行数据的输入和输出。PipedInputStream负责从管道中读取数据,而PipedOutputStream负责向管道中写入数据。多个线程可以通过共享同一个PipedInputStream和PipedOutputStream实例,从而实现数据的传输和共享。
import java.io.IOException;
import java.io.PipedInputStream;
import java.io.PipedOutputStream;

public class PipeDemo {
    public static void main(String[] args) throws IOException {
        // 创建管道输入流和输出流
        PipedInputStream inputStream = new PipedInputStream();
        PipedOutputStream outputStream = new PipedOutputStream();

        // 将输入流和输出流连接起来
        inputStream.connect(outputStream);

        // 创建写线程并启动
        Thread writerThread = new Thread(new WriterTask(outputStream));
        writerThread.start();

        // 创建读线程并启动
        Thread readerThread = new Thread(new ReaderTask(inputStream));
        readerThread.start();
    }

    static class WriterTask implements Runnable {
        private PipedOutputStream outputStream;

        public WriterTask(PipedOutputStream outputStream) {
            this.outputStream = outputStream;
        }

        @Override
        public void run() {
            try {
                String message = "Hello, World!";
                outputStream.write(message.getBytes());
                outputStream.flush();
                outputStream.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    static class ReaderTask implements Runnable {
        private PipedInputStream inputStream;

        public ReaderTask(PipedInputStream inputStream) {
            this.inputStream = inputStream;
        }

        @Override
        public void run() {
            try {
                byte[] buffer = new byte[1024];
                int len = inputStream.read(buffer);
                String message = new String(buffer, 0, len);
                System.out.println("Received message: " + message);
                inputStream.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

什么是线程的中断?有哪些应用场景?

线程中断是指在一个线程运行的过程中,对该线程发出一个信号,通知它中断当前的执行状态,以便更快地退出。

Java提供了一个interrupt()方法用于中断线程,调用该方法会设置线程的中断状态,如果线程处于阻塞状态,就会抛出一个InterruptedException异常,从而中断线程的阻塞状态,使得线程可以更快地退出。线程可以通过isInterrupted()方法来检查自己是否被中断,也可以通过Thread.interrupted()方法来检查自己是否被中断,并且清除中断状态。

需要注意的是,线程中断并不会直接停止线程的执行,而是会让线程有机会自行处理中断,从而更安全、更可靠地退出线程的执行。

对于一些需要长时间执行的任务,可以通过定期检查中断状态的方式来实现中断,从而保证线程可以及时响应中断请求。例如,在线程的run()方法中,可以通过检查线程的中断状态来决定是否退出循环或者是否继续执行下去。

线程的中断机制可以应用在以下几个方面:

  1. 可以使用中断机制来取消线程的执行。在某些场景下,可能需要在运行的线程执行到某个时间点或者某个条件满足时,立即停止线程的执行,以避免浪费系统资源或者降低系统的响应速度。此时,可以通过中断机制来实现线程的取消操作,从而快速、安全地停止线程的执行。

  2. 可以使用中断机制来优雅地处理线程的异常情况。在多线程编程中,由于多个线程之间的交互和竞争,很容易出现各种各样的异常情况,例如死锁、资源竞争等等。此时,可以通过中断机制来优雅地处理线程的异常情况,从而避免程序的崩溃或者异常终止。

  3. 可以使用中断机制来优化线程的性能。在线程执行过程中,可能会出现一些阻塞操作,例如等待I/O操作完成、等待锁的释放等等。此时,可以通过中断机制来优化线程的性能,从而避免线程长时间地等待,浪费系统资源。

线程死锁了解吗?该如何避免?

死锁是指两个或两个以上的线程在执行过程中,因争夺资源而造成的互相等待的现象,在无外力作用的情况下,这些线程会一直相互等待而无法继续运行下去。

那么为什么会产生死锁呢? 死锁的产生必须具备以下四个条件

  1. 互斥条件:资源只能同时被一个线程占用,其他线程必须等待释放后才能获取该资源。
  2. 请求和保持条件:线程持有至少一个资源,并请求其他线程占用的资源。
  3. 不剥夺条件:线程已经获得的资源,在未使用完之前,不能被其他线程强行剥夺,只能由自己释放。
  4. 循环等待条件:系统中存在一个进程等待队列 {P1, P2, …, Pn},其中 P1 等待 P2 占用的资源,P2 等待 P3 占用的资源,……,Pn 等待 P1 占用的资源,形成了一个进程等待环路。

只有当以上四个条件同时满足时,才会发生死锁。为了避免死锁的产生,我们需要破坏其中任意一个条件,比如通过资源预先分配、限制资源的最大占用时间、引入超时机制等方式来破坏循环等待条件。

ThreadLocal是什么?

ThreadLocal是Java中的一个线程级别的变量,它允许在多个线程之间共享数据,而不需要使用同步机制来控制线程安全。每个ThreadLocal对象都会在每个线程中维护一个独立的副本,这样就可以确保每个线程都可以访问自己的变量副本,而不会受到其他线程的干扰。

ThreadLocal通常被用于实现线程内部的数据共享,例如在Web应用程序中,可以使用ThreadLocal来存储当前用户的信息,以便在多个层级的调用中进行访问,而无需显式地传递这些信息。

ThreadLocal类中有两个核心方法:

  1. set(T value):将当前线程的ThreadLocal变量设置为指定的值。
  2. get():返回当前线程的ThreadLocal变量的值。

ThreadLocal也提供了一些其他的方法,例如remove()方法可以删除当前线程的ThreadLocal变量,initialValue()方法可以指定ThreadLocal变量的初始值等。

ThreadLocal应用场景有哪些?

ThreadLocal的主要应用场景是需要在多个线程之间共享数据,但又不希望使用同步机制来保证线程安全的情况下。一些常见的应用场景包括:

  1. 线程安全的日期格式化:在多线程环境下,使用SimpleDateFormat等日期格式化类可能会导致线程安全问题。为了避免这个问题,可以使用ThreadLocal来存储每个线程的日期格式化对象。

  2. 用户会话管理:在Web应用程序中,可以使用ThreadLocal来存储当前用户的信息,以便在多个层级的调用中进行访问,而无需显式地传递这些信息。

  3. 数据库连接管理:在多线程环境下,为了确保线程安全,通常需要使用连接池来管理数据库连接。使用ThreadLocal可以将连接与每个线程关联起来,避免了多个线程共享同一个连接的问题。

  4. 随机数生成器:在多线程环境下,使用Java中的随机数生成器可能会导致线程安全问题。可以使用ThreadLocal来存储每个线程的随机数生成器对象,以便每个线程都可以独立地生成随机数。

需要注意的是,虽然ThreadLocal可以帮助我们避免一些线程安全问题,但是如果使用不当,也会导致一些问题。例如,如果过度使用ThreadLocal,可能会导致内存泄漏或者性能问题。因此,在使用ThreadLocal时需要仔细评估应用场景,以确保合理使用。

ThreadLocal怎么实现的呢?

我们看一下ThreadLocal的set(T)方法,发现先获取到当前线程,再获取ThreadLocalMap,然后把元素存到这个map中。

    public void set(T value) {
        //获取当前线程
        Thread t = Thread.currentThread();
        //获取ThreadLocalMap
        ThreadLocalMap map = getMap(t);
        //讲当前元素存入map
        if (map != null)
            map.set(this, value);
        else
            createMap(t, value);
    }
    

ThreadLocal实现的秘密都在这个ThreadLocalMap了,在Thread类中定义了一个类型为ThreadLocal.ThreadLocalMap的成员变量threadLocals。

public class Thread implements Runnable {
   //ThreadLocal.ThreadLocalMap是Thread的属性
   ThreadLocal.ThreadLocalMap threadLocals = null;
}

ThreadLocalMap既然被称为Map,那么毫无疑问它是<key,value>型的数据结构。我们都知道map的本质是一个个<key,value>形式的节点组成的数组,那ThreadLocalMap的节点是什么样的呢?

static class Entry extends WeakReference<ThreadLocal<?>> {
    /** The value associated with this ThreadLocal. */
    Object value;

    //节点类
    Entry(ThreadLocal<?> k, Object v) {
        //key赋值
        super(k);
        //value赋值
        value = v;
    }
}

这里的节点,key可以简单低视作ThreadLocal,value为代码中放入的值,当然实际上key并不是ThreadLocal本身,而是它的一个弱引用,可以看到Entry的key继承了 WeakReference(弱引用),再来看一下key怎么赋值的:

public WeakReference(T referent) {
    super(referent);
}

key的赋值,使用的是WeakReference的赋值。

  1. Thread类有一个类型为ThreadLocal.ThreadLocalMap的实例变量threadLocals,每个线程都有一个属于自己的ThreadLocalMap。
  2. ThreadLocalMap内部维护着Entry数组,每个Entry代表一个完整的对象,key是ThreadLocal的弱引用,value是ThreadLocal的泛型值。
  3. 每个线程在往ThreadLocal里设置值的时候,都是往自己的ThreadLocalMap里存,读也是以某个ThreadLocal作为引用,在自己的map里找对应的key,从而实现了线程隔离。
  4. ThreadLocal本身不存储值,它只是作为一个key来让线程往ThreadLocalMap里存取值。

ThreadLocal 内存泄露是怎么回事?

我们先来分析一下使用ThreadLocal时的内存,我们都知道,在JVM中,栈内存线程私有,存储了对象的引用,堆内存线程共享,存储了对象实例。

所以呢,栈中存储了ThreadLocal、Thread的引用,堆中存储了它们的具体实例。

ThreadLocalMap中使用的 key 为 ThreadLocal 的弱引用。

弱引用:只要垃圾回收机制一运行,不管JVM的内存空间是否充足,都会回收该对象占用的内存。

那么现在问题就来了,弱引用很容易被回收,如果ThreadLocal(ThreadLocalMap的Key)被垃圾回收器回收了,但是ThreadLocalMap生命周期和Thread是一样的,它这时候如果不被回收,就会出现这种情况:ThreadLocalMap的key没了,value还在,这就会造成了内存泄漏问题。

那怎么解决内存泄漏问题呢?

很简单,使用完ThreadLocal后,及时调用remove()方法释放内存空间。

ThreadLocal<String> localVariable = new ThreadLocal();
try {
    localVariable.set("鄙人变态萝卜”);
    ……
} finally {
    localVariable.remove();
}

那为什么key还要设计成弱引用?

key设计成弱引用同样是为了防止内存泄漏。

假如key被设计成强引用,如果ThreadLocal Reference被销毁,此时它指向ThreadLoca的强引用就没有了,但是此时key还强引用指向ThreadLoca,就会导致ThreadLocal不能被回收,这时候就发生了内存泄漏的问题。

CountDownLatch(倒计数器)了解吗?

CountDownLatch,倒计数器,有两个常见的应用场景:

场景1:协调子线程结束动作:等待所有子线程运行结束

CountDownLatch允许一个或多个线程等待其他线程完成操作。

例如,我们很多人喜欢玩的王者荣耀,开黑的时候,得等所有人都上线之后,才能开打。

CountDownLatch模仿这个场景:

创建大乔、兰陵王、安其拉、哪吒和铠等五个玩家,主线程必须在他们都完成确认后,才可以继续运行。

在这段代码中,new CountDownLatch(5)用户创建初始的latch数量,各玩家通过countDownLatch.countDown()完成状态确认,主线程通过countDownLatch.await()等待。

public static void main(String[] args) throws InterruptedException {
    CountDownLatch countDownLatch = new CountDownLatch(5);

    Thread 大乔 = new Thread(countDownLatch::countDown);
    Thread 兰陵王 = new Thread(countDownLatch::countDown);
    Thread 安其拉 = new Thread(countDownLatch::countDown);
    Thread 哪吒 = new Thread(countDownLatch::countDown);
    Thread 铠 = new Thread(() -> {
        try {
            // 稍等,上个卫生间,马上到...
            Thread.sleep(1500);
            countDownLatch.countDown();
        } catch (InterruptedException ignored) {}
    });

    大乔.start();
    兰陵王.start();
    安其拉.start();
    哪吒.start();
    铠.start();
    countDownLatch.await();
    System.out.println("所有玩家已经就位!");
}

场景2. 协调子线程开始动作:统一各线程动作开始的时机

王者游戏中也有类似的场景,游戏开始时,各玩家的初始状态必须一致。不能有的玩家都出完装了,有的才降生。

所以大家得一块出生,在这个场景中,仍然用五个线程代表大乔、兰陵王、安其拉、哪吒和铠等五个玩家。需要注意的是,各玩家虽然都调用了start()线程,但是它们在运行时都在等待countDownLatch的信号,在信号未收到前,它们不会往下执行。

public static void main(String[] args) throws InterruptedException {
    CountDownLatch countDownLatch = new CountDownLatch(1);

    Thread 大乔 = new Thread(() -> waitToFight(countDownLatch));
    Thread 兰陵王 = new Thread(() -> waitToFight(countDownLatch));
    Thread 安其拉 = new Thread(() -> waitToFight(countDownLatch));
    Thread 哪吒 = new Thread(() -> waitToFight(countDownLatch));
    Thread 铠 = new Thread(() -> waitToFight(countDownLatch));

    大乔.start();
    兰陵王.start();
    安其拉.start();
    哪吒.start();
    铠.start();
    Thread.sleep(1000);
    countDownLatch.countDown();
    System.out.println("敌方还有5秒达到战场,全军出击!");
}

private static void waitToFight(CountDownLatch countDownLatch) {
    try {
        countDownLatch.await(); // 在此等待信号再继续
        System.out.println("收到,发起进攻!");
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
}

CountDownLatch的核心方法也不多:

  • await():等待latch降为0;
  • boolean await(long timeout, TimeUnit unit):等待latch降为0,但是可以设置超时时间。比如有玩家超时未确认,那就重新匹配,总不能为了某个玩家等到天荒地老。
  • countDown():latch数量减1;
  • getCount():获取当前的latch数量。

CyclicBarrier(同步屏障)了解吗

CyclicBarrier的字面意思是可循环使用(Cyclic)的屏障(Barrier)。它要做的事情是,让一 组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会开门,所有被屏障拦截的线程才会继续运行。

它和CountDownLatch类似,都可以协调多线程的结束动作,在它们结束后都可以执行特定动作,但是为什么要有CyclicBarrier,自然是它有和CountDownLatch不同的地方。

不知道你听没听过一个新人UP主小约翰可汗,小约翰生平有两大恨——“想结衣结衣不依,迷爱理爱理不理。”我们来还原一下事情的经过:小约翰在亲政后认识了新垣结衣,于是决定第一次选妃,向结衣表白,等待回应。然而新垣结衣回应嫁给了星野源,小约翰伤心欲绝,发誓生平不娶,突然发现了铃木爱理,于是小约翰决定第二次选妃,求爱理搭理,等待回应。

我们拿代码模拟这一场景,发现CountDownLatch无能为力了,因为CountDownLatch的使用是一次性的,无法重复利用,而这里等待了两次。此时,我们用CyclicBarrier就可以实现,因为它可以重复利用。

运行结果:

CyclicBarrier最最核心的方法,仍然是await():

如果当前线程不是第一个到达屏障的话,它将会进入等待,直到其他线程都到达,除非发生被中断、屏障被拆除、屏障被重设等情况;
上面的例子抽象一下,本质上它的流程就是这样就是这样:

CyclicBarrier和CountDownLatch有什么区别?

两者最核心的区别:

  1. CountDownLatch是一次性的,而CyclicBarrier则可以多次设置屏障,实现重复利用;
  2. CountDownLatch中的各个子线程不可以等待其他线程,只能完成自己的任务;而CyclicBarrier中的各个线程可以等待其他线程
CyclicBarrier CountDownLatch
CyclicBarrier是可重用的,其中的线程会等待所有的线程完成任务。届时,屏障将被拆除,并可以选择性地做一些特定的动作。 CountDownLatch是一次性的,不同的线程在同一个计数器上工作,直到计数器为0.
CyclicBarrier面向的是线程数 CountDownLatch面向的是任务数
在使用CyclicBarrier时,你必须在构造中指定参与协作的线程数,这些线程必须调用await()方法 使用CountDownLatch时,则必须要指定任务数,至于这些任务由哪些线程完成无关紧要
CyclicBarrier可以在所有的线程释放后重新使用 CountDownLatch在计数器为0时不能再使用
在CyclicBarrier中,如果某个线程遇到了中断、超时等问题时,则处于await的线程都会出现问题 在CountDownLatch中,如果某个线程出现问题,其他线程不受影响

Semaphore(信号量)了解吗?

Semaphore(信号量)是用来控制同时访问特定资源的线程数量,它通过协调各个线程,以保证合理的使用公共资源。

听起来似乎很抽象,现在汽车多了,开车出门在外的一个老大难问题就是停车 。停车场的车位是有限的,只能允许若干车辆停泊,如果停车场还有空位,那么显示牌显示的就是绿灯和剩余的车位,车辆就可以驶入;如果停车场没位了,那么显示牌显示的就是绿灯和数字0,车辆就得等待。如果满了的停车场有车离开,那么显示牌就又变绿,显示空车位数量,等待的车辆就能进停车场。

Semaphore的本质就是协调多个线程对共享资源的获取

我们再来看一个Semaphore的用途:它可以用于做流量控制,特别是公用资源有限的应用场景,比如数据库连接。

假如有一个需求,要读取几万个文件的数据,因为都是IO密集型任务,我们可以启动几十个线程并发地读取,但是如果读到内存后,还需要存储到数据库中,而数据库的连接数只有10个,这时我们必须控制只有10个线程同时获取数据库连接保存数据,否则会报错无法获取数据库连接。这个时候,就可以使用Semaphore来做流量控制,如下:


public class SemaphoreTest {
    private static final int THREAD_COUNT = 30;
    private static ExecutorService threadPool = Executors.newFixedThreadPool(THREAD_COUNT);
    private static Semaphore s = new Semaphore(10);

    public static void main(String[] args) {
        for (int i = 0; i < THREAD_COUNT; i++) {
            threadPool.execute(new Runnable() {
                @Override
                public void run() {
                    try {
                        s.acquire();
                        System.out.println("save data");
                        s.release();
                    } catch (InterruptedException e) {
                    }
                }
            });
        }
        threadPool.shutdown();
    }
}

在代码中,虽然有30个线程在执行,但是只允许10个并发执行。Semaphore的构造方法 Semaphore(int permits)接受一个整型的数字,表示可用的许可证数量。Semaphore(10)表示允许10个线程获取许可证,也就是最大并发数是10。Semaphore的用法也很简单,首先线程使用 Semaphore的acquire()方法获取一个许可证,使用完之后调用release()方法归还许可证。还可以用tryAcquire()方法尝试获取许可证。

什么是Exchanger?

Exchanger是Java并发包中的一个工具类,用于两个线程之间进行数据交换。它提供一个同步点,在这个同步点,两个线程可以交换彼此的数据。

Exchanger类提供了一个exchange()方法,当一个线程调用exchange()方法时,它会被阻塞,直到另一个线程也调用了exchange()方法。当两个线程都到达这个同步点时,它们可以交换彼此的数据,并返回交换的数据。

Exchanger的应用场景比较广泛,例如:

数据流水线:Exchanger可以用于将多个线程处理的数据交换和合并,实现数据的流水线处理。

网络传输:Exchanger可以用于两个网络连接之间的数据传输,例如一个客户端线程从网络中读取数据,另一个客户端线程向网络中写入数据,两个线程可以通过Exchanger来交换数据。

协作计算:Exchanger可以用于多个线程之间协作完成某个复杂的计算任务,例如分布式计算中的数据分片和结果汇总。

总之,Exchanger可以帮助我们实现多个线程之间的数据交换和协作,从而简化复杂的并发编程任务。

import java.util.concurrent.Exchanger;

public class ExchangerExample {
    public static void main(String[] args) {
        Exchanger<String> exchanger = new Exchanger<>();

        new Thread(() -> {
            try {
                String data1 = "data from thread 1";
                System.out.println("Thread 1 is sending data: " + data1);
                String data2 = exchanger.exchange(data1);
                System.out.println("Thread 1 received data: " + data2);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();

        new Thread(() -> {
            try {
                String data2 = "data from thread 2";
                System.out.println("Thread 2 is sending data: " + data2);
                String data1 = exchanger.exchange(data2);
                System.out.println("Thread 2 received data: " + data1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

线程池主要参数有哪些?

线程池有七大参数,需要重点关注corePoolSizemaximumPoolSizeworkQueuehandler这四个。

corePoolSize

此值是用来初始化线程池中核心线程数,当线程池中线程池数< corePoolSize时,系统默认是添加一个任务才创建一个线程池。当线程数 = corePoolSize时,新任务会追加到workQueue中。

maximumPoolSize

maximumPoolSize表示允许的最大线程数 = (非核心线程数+核心线程数),当BlockingQueue也满了,但线程池中总线程数 < maximumPoolSize时候就会再次创建新的线程。

keepAliveTime

非核心线程 =(maximumPoolSize - corePoolSize ) ,非核心线程闲置下来不干活最多存活时间。

unit

线程池中非核心线程保持存活的时间的单位

TimeUnit.DAYS; 天
TimeUnit.HOURS; 小时
TimeUnit.MINUTES; 分钟
TimeUnit.SECONDS; 秒
TimeUnit.MILLISECONDS; 毫秒
TimeUnit.MICROSECONDS; 微秒
TimeUnit.NANOSECONDS; 纳秒

workQueue

线程池等待队列,维护着等待执行的Runnable对象。当运行当线程数= corePoolSize时,新的任务会被添加到workQueue中,如果workQueue也满了则尝试用非核心线程执行任务,等待队列应该尽量用有界的。

threadFactory

创建一个新线程时使用的工厂,可以用来设定线程名、是否为daemon线程等等。

handler

corePoolSize、workQueue、maximumPoolSize都不可用的时候执行的饱和策略

线程池的拒绝策略有哪些?

  • AbortPolicy :直接抛出异常,默认使用此策略
  • CallerRunsPolicy:用调用者所在的线程来执行任务
  • DiscardOldestPolicy:丢弃阻塞队列里最老的任务,也就是队列里靠前的任务
  • DiscardPolicy :当前任务直接丢弃

想实现自己的拒绝策略,实现RejectedExecutionHandler接口即可。

线程池有哪几种工作队列?

  • ArrayBlockingQueue:ArrayBlockingQueue(有界队列)是一个用数组实现的有界阻塞队列,按FIFO排序量。
  • LinkedBlockingQueue:LinkedBlockingQueue(可设置容量队列)是基于链表结构的阻塞队列,按FIFO排序任务,容量可以选择进行设置,不设置的话,将是一个无边界的阻塞队列,最大长度为Integer.MAX_VALUE,吞吐量通常要高于ArrayBlockingQuene;newFixedThreadPool线程池使用了这个队列
  • DelayQueue:DelayQueue(延迟队列)是一个任务定时周期的延迟执行的队列。根据指定的执行时间从小到大排序,否则根据插入到队列的先后排序。newScheduledThreadPool线程池使用了这个队列。
  • PriorityBlockingQueue:PriorityBlockingQueue(优先级队列)是具有优先级的无界阻塞队列
  • SynchronousQueue:SynchronousQueue(同步队列)是一个不存储元素的阻塞队列,每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQuene,newCachedThreadPool线程池使用了这个队列。

线程池提交execute和submit有什么区别?

  1. execute 用于提交不需要返回值的任务
threadsPool.execute(new Runnable() { 
    @Override public void run() { 
        // TODO Auto-generated method stub } 
    });
    
  1. submit()方法用于提交需要返回值的任务。线程池会返回一个future类型的对象,通过这个 future对象可以判断任务是否执行成功,并且可以通过future的get()方法来获取返回值
Future<Object> future = executor.submit(harReturnValuetask); 
try { Object s = future.get(); } catch (InterruptedException e) { 
    // 处理中断异常 
} catch (ExecutionException e) { 
    // 处理无法执行任务异常 
} finally { 
    // 关闭线程池 executor.shutdown();
}

线程池如何关闭?

可以通过调用线程池的shutdownshutdownNow方法来关闭线程池。它们的原理是遍历线程池中的工作线程,然后逐个调用线程的interrupt方法来中断线程,所以无法响应中断的任务可能永远无法终止。

shutdown() 将线程池状态置为shutdown,并不会立即停止:

  1. 停止接收外部submit的任务
  2. 内部正在跑的任务和队列里等待的任务,会执行完
  3. 等到第二步完成后,才真正停止

shutdownNow() 将线程池状态置为stop。一般会立即停止,事实上不一定:

  1. 和shutdown()一样,先停止接收外部提交的任务
  2. 忽略队列里等待的任务
  3. 尝试将正在跑的任务interrupt中断
  4. 返回未执行的任务列表
    shutdown 和shutdownnow简单来说区别如下:

shutdownNow()能立即停止线程池,正在跑的和正在等待的任务都停下了。这样做立即生效,但是风险也比较大。
shutdown()只是关闭了提交通道,用submit()是无效的;而内部的任务该怎么跑还是怎么跑,跑完再彻底停止线程池。

线程池的线程数应该怎么配置?

一般的经验,不同类型线程池的参数配置:

计算密集型一般推荐线程池不要过大,一般是CPU数 + 1,+1是因为可能存在页缺失(就是可能存在有些数据在硬盘中需要多来一个线程将数据读入内存)。如果线程池数太大,可能会频繁的 进行线程上下文切换跟任务调度。获得当前CPU核心数代码如下:

Runtime.getRuntime().availableProcessors();

IO密集型:线程数适当大一点,机器的Cpu核心数*2。

混合型:可以考虑根绝情况将它拆分成CPU密集型和IO密集型任务,如果执行时间相差不大,拆分可以提升吞吐量,反之没有必要。

有哪几种常见的线程池?

newFixedThreadPool (固定数目线程的线程池)

线程池特点:

  • 核心线程数和最大线程数大小一样
  • 没有所谓的非空闲时间,即keepAliveTime为0
  • 阻塞队列为无界队列LinkedBlockingQueue,可能会导致OOM

工作流程:

  • 提交任务
  • 如果线程数少于核心线程,创建核心线程执行任务
  • 如果线程数等于核心线程,把任务添加到LinkedBlockingQueue阻塞队列
  • 如果线程执行完任务,去阻塞队列取任务,继续执行。

使用场景

FixedThreadPool 适用于处理CPU密集型的任务,确保CPU在长期被工作线程使用的情况下,尽可能的少的分配线程,即适用执行长期的任务

newCachedThreadPool (可缓存线程的线程池)

线程池特点:

  • 核心线程数为0
  • 最大线程数为Integer.MAX_VALUE,即无限大,可能会因为无限创建线程,导致OOM
  • 阻塞队列是SynchronousQueue
  • 非核心线程空闲存活时间为60秒
  • 当提交任务的速度大于处理任务的速度时,每次提交一个任务,就必然会创建一个线程。极端情况下会创建过多的线程,耗尽 CPU 和内存资源。由于空闲 60 秒的线程会被终止,长时间保持空闲的 CachedThreadPool 不会占用任何资源。

工作流程:

  • 提交任务
  • 因为没有核心线程,所以任务直接加到SynchronousQueue队列。
  • 判断是否有空闲线程,如果有,就去取出任务执行。
  • 如果没有空闲线程,就新建一个线程执行。
  • 执行完任务的线程,还可以存活60秒,如果在这期间,接到任务,可以继续活下去;否则,被销毁。

适用场景

用于并发执行大量短期的小任务。

newSingleThreadExecutor (单线程的线程池)

线程池特点

核心线程数为1
最大线程数也为1
阻塞队列是无界队列LinkedBlockingQueue,可能会导致OOM
keepAliveTime为0

工作流程:

  • 提交任务
  • 线程池是否有一条线程在,如果没有,新建线程执行任务
  • 如果有,将任务加到阻塞队列
  • 当前的唯一线程,从队列取任务,执行完一个,再继续取,一个线程执行任务。

适用场景

适用于串行执行任务的场景,一个任务一个任务地执行。

newScheduledThreadPool (定时及周期执行的线程池)

线程池特点

  • 最大线程数为Integer.MAX_VALUE,也有OOM的风险
  • 阻塞队列是DelayedWorkQueue
  • keepAliveTime为0
  • scheduleAtFixedRate() :按某种速率周期执行
  • scheduleWithFixedDelay():在某个延迟后执行

工作机制

  • 线程从DelayQueue中获取已到期的ScheduledFutureTask(DelayQueue.take())。到期任务是指ScheduledFutureTask的time大于等于当前时间。
  • 线程执行这个ScheduledFutureTask。
  • 线程修改ScheduledFutureTask的time变量为下次将要被执行的时间。
  • 线程把这个修改time之后的ScheduledFutureTask放回DelayQueue中(DelayQueue.add())。

使用场景

周期性执行任务的场景,需要限制线程数量的场景

线程池异常怎么处理?

如何设计一个线程池

需要考虑的点

那线程池设计需要考虑的点:

线程池状态

  • 有哪些状态?如何维护状态?

线程

  • 线程怎么封装?线程放在哪个池子里?
  • 线程怎么取得任务?
  • 线程有哪些状态?
  • 线程的数量怎么限制?动态变化?自动伸缩?
  • 线程怎么消亡?如何重复利用?

任务

  • 任务少可以直接处理,多的时候,放在哪里?
  • 任务队列满了,怎么办?
  • 用什么队列?

如果从任务的阶段来看,分为以下几个阶段:

  • 如何存任务?
  • 如何取任务?
  • 如何执行任务?
  • 如何拒绝任务?

线程池状态

状态有哪些?如何维护状态?
状态可以设置为以下几种:

  • RUNNING:运行状态,可以接受任务,也可以处理任务
  • SHUTDOWN:不可以接受任务,但是可以处理任务
  • STOP:不可以接受任务,也不可以处理任务,中断当前任务
  • TIDYING:所有线程停止
  • TERMINATED:线程池的最后状态

各种状态之间是不一样的,他们的状态之间变化如下:

而维护状态的话,可以用一个变量单独存储,并且需要保证修改时的原子性,在底层操作系统中,对int的修改是原子的,而在32位的操作系统里面,对double,long这种64位数值的操作不是原子的。除此之外,实际上JDK里面实现的状态和线程池的线程数是同一个变量,高3位表示线程池的状态,而低29位则表示线程的数量。

这样设计的好处是节省空间,并且同时更新的时候有优势。

线程相关

线程怎么封装?线程放在哪个池子里?

线程,即是实现了Runnable接口,执行的时候,调用的是start()方法,但是start()方法内部编译后调用的是 run() 方法,这个方法只能调用一次,调用多次会报错。因此线程池里面的线程跑起来之后,不可能终止再启动,只能一直运行着。既然不可以停止,那么执行完任务之后,没有任务过来,只能是轮询取出任务的过程

线程可以运行任务,因此封装线程的时候,假设封装成为 Worker, Worker里面必定是包含一个 Thread,表示当前线程,除了当前线程之外,封装的线程类还应该持有任务,初始化可能直接给予任务,当前的任务是null的时候才需要去获取任务。

可以考虑使用 HashSet 来存储线程,也就是充当线程池的角色,当然,HashSet 会有线程安全的问题需要考虑,那么我们可以考虑使用一个可重入锁比如 ReentrantLock,凡是增删线程池的线程,都需要锁住。

private final ReentrantLock mainLock = new ReentrantLock();

线程怎么取得任务?

(1)初始化线程的时候可以直接指定任务,譬如Runnable firstTask,将任务封装到 worker 中,然后获取 worker 里面的 thread,thread.run()的时候,其实就是 跑的是 worker 本身的 run() 方法,因为 worker 本身就是实现了 Runnable 接口,里面的线程其实就是其本身。因此也可以实现对 ThreadFactory 线程工厂的定制化。

    private final class Worker
        extends AbstractQueuedSynchronizer
        implements Runnable
    {
        final Thread thread;
        Runnable firstTask;

        ...

        Worker(Runnable firstTask) {
            setState(-1); // inhibit interrupts until runWorker
            this.firstTask = firstTask;
            // 从线程池创建线程,传入的是其本身
            this.thread = getThreadFactory().newThread(this);
        }
    }

(2)运行完任务的线程,应该继续取任务,取任务肯定需要从任务队列里面取,要是任务队列里面没有任务,由于是阻塞队列,那么可以等待,如果等待若干时间后,仍没有任务,倘若该线程池的线程数已经超过核心线程数,并且允许线程消亡的话,应该将该线程从线程池中移除,并结束掉该线程。

取任务和执行任务,对于线程池里面的线程而言,就是一个周而复始的工作,除非它会消亡。

线程的数量怎么限制?动态变化?自动伸缩?

线程池本身,就是为了限制和充分使用线程资的,因此有了两个概念:核心线程数,最大线程数。

要想让线程数根据任务数量动态变化,那么我们可以考虑以下设计(假设不断有任务):

来一个任务创建一个线程处理,直到线程数达到核心线程数。
达到核心线程数之后且没有空闲线程,来了任务直接放到任务队列。
任务队列如果是无界的,会被撑爆。
任务队列如果是有界的,任务队列满了之后,还有任务过来,会继续创建线程处理,此时线程数大于核心线程数,直到线程数等于最大线程数。
达到最大线程数之后,还有任务不断过来,会触发拒绝策略,根据不同策略进行处理。
如果任务不断处理完成,任务队列空了,线程空闲没任务,会在一定时间内,销毁,让线程数保持在核心线程数即可。
由上面可以看出,主要控制伸缩的参数是核心线程数,最大线程数,任务队列,拒绝策略。

线程怎么消亡?如何重复利用?

线程不能被重新调用多次start(),因此只能调用一次,也就是线程不可能停下来,再启动。那么就说明线程复用只是在不断的循环罢了。

消亡只是结束了它的run()方法,当线程池数量需要自动缩容的,就会让一部分空闲的线程结束。

而重复利用,其实是执行完任务之后,再去去任务队列取任务,取不到任务会等待,任务队列是一个阻塞队列,这是一个不断循环的过程。

任务相关

任务少可以直接处理,多的时候,放在哪里?

任务少的时候,来了直接创建,赋予线程初始化任务,就可开始执行,任务多的时候,把它放进队列里面,先进先出。

任务队列满了,怎么办?

任务队列满了,会继续增加线程,直到达到最大的线程数。

用什么队列?

一般的队列,只是一个有限长度的缓冲区,要是满了,就不能保存当前的任务,阻塞队列可以通过阻塞,保留出当前需要入队的任务,只是会阻塞等待。同样的,阻塞队列也可以保证任务队列没有任务的时候,阻塞当前获取任务的线程,让它进入wait状态,释放cpu的资源。因此在线程池的场景下,阻塞队列其实是比较有必要的。

JVM

能说一下 JVM 的内存区域吗?

JVM 内存区域最粗略的划分可以分为堆和栈,当然,按照虚拟机规范,可以划分为以下几个区域:

JVM 内存分为线程私有区和线程共享区,其中方法区和堆是线程共享区,虚拟机栈、本地方法栈和程序计数器是线程隔离的数据区。

  1. 程序计数器

程序计数器(Program Counter Register)也被称为 PC 寄存器,是一块较小的内存空间。

它可以看作是当前线程所执行的字节码的行号指示器。

  1. Java 虚拟机栈

Java 虚拟机栈(Java Virtual Machine Stack)也是线程私有的,它的生命周期与线程相同。

Java 虚拟机栈描述的是 Java 方法执行的线程内存模型:方法执行时,JVM 会同步创建一个栈帧,用来存储局部变量表、操作数栈、动态连接等。

  1. 本地方法栈

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

Java 虚拟机规范允许本地方法栈被实现成固定大小的或者是根据计算动态扩展和收缩的。

  1. Java 堆

对于 Java 应用程序来说,Java 堆(Java Heap)是虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java 里“几乎”所有的对象实例都在这里分配内存。

Java 堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作“GC 堆”(Garbage Collected Heap,)。从回收内存的角度看,由于现代垃圾收集器大部分都是基于分代收集理论设计的,所以 Java 堆中经常会出现新生代、老年代、Eden空间、From Survivor空间、To Survivor空间等名词,需要注意的是这种划分只是根据垃圾回收机制来进行的划分,不是 Java 虚拟机规范本身制定的。

  1. 方法区

方法区是比较特别的一块区域,和堆类似,它也是各个线程共享的内存区域,用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。

它特别在 Java 虚拟机规范对它的约束非常宽松,所以方法区的具体实现历经了许多变迁,例如 jdk1.7 之前使用永久代作为方法区的实现。1.8及以后的版本中,永久代被移除了,并被一个新的区域取代,称为元空间(Metaspace)。

元空间是一个位于本地内存(Native Memory)的区域,用于存储类元数据信息。与永久代相比,元空间具有自动调整大小的能力,可以根据应用程序的需要进行动态调整。此外,元空间的垃圾回收也由Java虚拟机自动进行,而不需要手动设置。

为什么使用元空间替代永久代作为方法区的实现?

Java 虚拟机规范规定的方法区只是换种方式实现。有客观和主观两个原因。

  • 客观上使用永久代来实现方法区的决定的设计导致了 Java 应用更容易遇到内存溢出的问题(永久代有-XX:MaxPermSize 的上限,即使不设置也有默认大小,而 J9 和 JRockit 只要没有触碰到进程可用内存的上限,例如 32 位系统中的 4GB 限制,就不会出问题),而且有极少数方法 (例如 String::intern())会因永久代的原因而导致不同虚拟机下有不同的表现。

  • 主观上当 Oracle 收购 BEA 获得了 JRockit 的所有权后,准备把 JRockit 中的优秀功能,譬如 Java Mission Control 管理工具,移植到 HotSpot 虚拟机时,但因为两者对方法区实现的差异而面临诸多困难。考虑到 HotSpot 未来的发展,在 JDK 6 的 时候 HotSpot 开发团队就有放弃永久代,逐步改为采用本地内存(Native Memory)来实现方法区的计划了,到了 JDK 7 的 HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出,而到了 JDK 8,终于完全废弃了永久代的概念,改用与 JRockit、J9 一样在本地内存中实现的元空间(Meta-space)来代替,把 JDK 7 中永久代还剩余的内容(主要是类型信息)全部移到元空间中。

字符串常量池、运行时常量池、类常量池之间有什么区别,在jvm中分别存储在哪一块区域的?

在JVM中,字符串常量池、运行时常量池和类常量池是不同的概念,它们的存储位置和作用也不同。

  1. 字符串常量池
    字符串常量池是用于存储字符串常量的区域。在Java程序中,字符串常量是指直接用双引号引起来的字符串,例如:"hello world"。为了提高性能和节省内存,Java将所有相同的字符串常量都放在了字符串常量池中,如果程序中使用了相同的字符串常量,实际上是在使用同一个对象。字符串常量池在堆区(Heap)中,但它是一种特殊的数据结构,与一般的堆对象不同,因为它不会被垃圾回收。

  2. 运行时常量池
    运行时常量池是Java虚拟机在运行时动态生成的一块内存区域,用于存放编译期间生成的各种字面量(literal)和符号引用(symbolic reference)。在Java程序中,常量是指被final修饰的变量,以及在编译期间确定的常量表达式,例如:final int a = 10; 或 int b = 2 + 3; 这些常量会在编译期间被放入class文件的常量池中,而在类加载时,这些常量会被加载到运行时常量池中。运行时常量池在方法区(Method Area)中。

  3. 类常量池
    类常量池是在Java类中定义的一种常量池。类常量池中的常量是指被static final修饰的变量,以及被interface定义的常量。这些常量也会被编译器在编译期间放入class文件的常量池中,然后在类加载时被加载到运行时常量池中。类常量池也在方法区(Method Area)中。

综上所述,字符串常量池、运行时常量池和类常量池是不同的概念,并存储在不同的区域中。其中,字符串常量池是堆区的一个特殊数据结构,用于存储字符串常量;运行时常量池是方法区的一部分,用于存储字面量和符号引用;类常量池也在方法区中,用于存储被static final修饰的变量和被interface定义的常量。

对象创建的过程了解吗?

在 JVM 中对象的创建,我们从一个 new 指令开始:

  1. 首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用

  2. 检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,就先执行相应的类加载过程

  3. 类加载检查通过后,接下来虚拟机将为新生对象分配内存。

  4. 内存分配完成之后,虚拟机将分配到的内存空间(但不包括对象头)都初始化为零值。

  5. 接下来设置对象头,请求头里包含了对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。

这个过程大概图示如下:

什么是指针碰撞?什么是空闲列表?

内存分配有两种方式,指针碰撞(Bump The Pointer)、空闲列表(Free List)。

  • 指针碰撞:假设 Java 堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”。
  • 空闲列表:如果 Java 堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”。
  • 两种方式的选择由 Java 堆是否规整决定,Java 堆是否规整是由选择的垃圾收集器是否具有压缩整理能力决定的。

JVM 里 new 对象时,堆会发生抢占吗?JVM 是怎么设计来保证线程安全的?

会,假设 JVM 虚拟机上,每一次 new 对象时,指针就会向右移动一个对象 size 的距离,一个线程正在给 A 对象分配内存,指针还没有来的及修改,另一个为 B 对象分配内存的线程,又引用了这个指针来分配内存,这就发生了抢占。

有两种可选方案来解决这个问题:

  1. 采用 CAS 分配重试的方式来保证更新操作的原子性

  2. 每个线程在 Java 堆中预先分配一小块内存,也就是本地线程分配缓冲(Thread Local Allocation Buffer,TLAB),要分配内存的线程,先在本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。

能说一下对象的内存布局吗?

在 HotSpot 虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

对象头主要由两部分组成:

  1. 第一部分存储对象自身的运行时数据:哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,官方称它为 Mark Word,它是个动态的结构,随着对象状态变化。

  2. 第二部分是类型指针,指向对象的类元数据类型(即对象代表哪个类)。

  3. 此外,如果对象是一个 Java 数组,那还应该有一块用于记录数组长度的数据

实例数据用来存储对象真正的有效信息,也就是我们在程序代码里所定义的各种类型的字段内容,无论是从父类继承的,还是自己定义的。

对齐填充不是必须的,没有特别含义,仅仅起着占位符的作用

内存泄漏可能由哪些原因导致呢?

内存泄漏可能的原因有很多种:

静态集合类引起内存泄漏

静态集合的生命周期和 JVM 一致,所以静态集合引用的对象不能被释放。

public class OOM {
 static List list = new ArrayList();

 public void oomTests(){
   Object obj = new Object();

   list.add(obj);
  }
}

单例模式

和上面的例子原理类似,单例对象在初始化后会以静态变量的方式在 JVM 的整个生命周期中存在。如果单例对象持有外部的引用,那么这个外部对象将不能被 GC 回收,导致内存泄漏。

数据连接、IO、Socket 等连接

创建的连接不再使用时,需要调用 close 方法关闭连接,只有连接被关闭后,GC 才会回收对应的对象(Connection,Statement,ResultSet,Session)。忘记关闭这些资源会导致持续占有内存,无法被 GC 回收。

try {
    Connection conn = null;
    Class.forName("com.mysql.jdbc.Driver");
    conn = DriverManager.getConnection("url", "", "");
    Statement stmt = conn.createStatement();
    ResultSet rs = stmt.executeQuery("....");
  } catch (Exception e) {

  }finally {
    //不关闭连接
  }
  

变量不合理的作用域

一个变量的定义作用域大于其使用范围,很可能存在内存泄漏;或不再使用对象没有及时将对象设置为 null,很可能导致内存泄漏的发生。

public class Simple {
    Object object;
    public void method1(){
        object = new Object();
        //...其他代码
        //由于作用域原因,method1执行完成之后,object 对象所分配的内存不会马上释放
        object = null;
    }
}

hash 值发生变化

对象 Hash 值改变,使用 HashMap、HashSet 等容器中时候,由于对象修改之后的 Hah 值和存储进容器时的 Hash 值不同,所以无法找到存入的对象,自然也无法单独删除了,这也会造成内存泄漏。说句题外话,这也是为什么 String 类型被设置成了不可变类型。


import java.util.HashMap;

public class HashCodeChangeDemo {

    public static void main(String[] args) {

        // 创建一个HashMap,用于存储MyObject对象
        HashMap<MyObject, String> map = new HashMap<>();

        // 创建一个MyObject对象,并将其添加到HashMap中
        MyObject obj = new MyObject(1, "test");
        map.put(obj, "value");

        // 修改MyObject对象的属性,导致其哈希值发生变化
        obj.setId(2);

        // 由于MyObject对象的哈希值发生了变化,导致其在HashMap中的存储位置改变,
        // 但HashMap内部并没有将其从旧的位置中移除,因此obj仍然存在于HashMap中,
        // 但无法通过HashMap访问到它
        System.out.println(map.get(obj)); // 输出null

        // 此时,由于HashMap中仍然保留了对obj的引用,obj无法被JVM自动回收,
        // 导致内存泄漏
    }

    static class MyObject {

        private int id;
        private String name;

   		// 省略getter setter

     

        @Override
        public int hashCode() {
            // 计算哈希值时只考虑id属性
            return id;
        }

        @Override
        public boolean equals(Object obj) {
            if (this == obj) {
                return true;
            }
            if (obj == null || getClass() != obj.getClass()) {
                return false;
            }
            MyObject other = (MyObject) obj;
            return id == other.id;
        }
    }
}

ThreadLocal 使用不当

ThreadLocal 的弱引用导致内存泄漏也是个老生常谈的话题了,使用完 ThreadLocal 一定要记得使用 remove 方法来进行清除。

Java 中可作为 GC Roots 的对象有哪几种?

可以作为 GC Roots 的主要有四种对象:

  1. 虚拟机栈(栈帧中的本地变量表)中引用的对象
  2. 方法区中类静态属性引用的对象
  3. 方法区中常量引用的对象
  4. 本地方法栈中 JNI 引用的对象

说一下对象有哪几种引用?

Java 中的引用有四种,分为强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4 种,这 4 种引用强度依次逐渐减弱。

  1. 强引用是最传统的引用的定义,是指在程序代码之中普遍存在的引用赋值,无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
Object obj =new Object();
  1. 软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存, 才会抛出内存溢出异常。在 JDK 1.2 版之后提供了 SoftReference 类来实现软引用。
Object obj = new Object();
ReferenceQueue queue = new ReferenceQueue();
SoftReference reference = new SoftReference(obj, queue);
//强引用对象滞空,保留软引用
obj = null;

  1. 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在 JDK 1.2 版之后提供了 WeakReference 类来实现弱引用。
Object obj = new Object();
ReferenceQueue queue = new ReferenceQueue();
WeakReference reference = new WeakReference(obj, queue);
//强引用对象滞空,保留软引用
obj = null;

  1. 虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在 JDK 1.2 版之后提供了 PhantomReference 类来实现虚引用。
Object obj = new Object();
ReferenceQueue queue = new ReferenceQueue();
PhantomReference reference = new PhantomReference(obj, queue);
//强引用对象滞空,保留软引用
obj = null;

Java 堆的内存分区了解吗?

按照垃圾收集,将 Java 堆划分为新生代(Young Generation)老年代(Old Generation两个区域,新生代存放存活时间短的对象,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。

而新生代又可以分为三个区域,eden、from、to,比例是 8:1:1,而新生代的内存分区同样是从垃圾收集的角度来分配的。

垃圾收集算法了解吗?

垃圾收集算法主要有三种:

标记-清除算法

见名知义,标记-清除(Mark-Sweep)算法分为两个阶段:

  • 标记 : 标记出所有需要回收的对象
  • 清除:回收所有被标记的对象

标记-清除算法比较基础,但是主要存在两个缺点:

  1. 执行效率不稳定,如果 Java 堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低。
  2. 内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

标记-复制算法

标记-复制算法解决了标记-清除算法面对大量可回收对象时执行效率低的问题。

过程也比较简单:将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

这种算法存在一个明显的缺点:一部分空间没有使用,存在空间的浪费。

新生代垃圾收集主要采用这种算法,因为新生代的存活对象比较少,每次复制的只是少量的存活对象。当然,实际新生代的收集不是按照这个比例(8:2)

标记-整理算法

为了降低内存的消耗,引入一种针对性的算法:标记-整理(Mark-Compact)算法。

其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

标记-整理算法主要用于老年代,移动存活对象是个极为负重的操作,而且这种操作需要 Stop The World 才能进行,只是从整体的吞吐量来考量,老年代使用标记-整理算法更加合适。

说一下新生代的区域划分?

新生代的垃圾收集主要采用标记-复制算法,因为新生代的存活对象比较少,每次复制少量的存活对象效率比较高。

基于这种算法,虚拟机将内存分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次分配内存只使用 Eden 和其中一块 Survivor。发生垃圾收集时,将 Eden 和 Survivor 中仍然存活的对象一次性复制到另外一块 Survivor 空间上,然后直接清理掉 Eden 和已用过的那块 Survivor 空间。默认 Eden 和 Survivor 的大小比例是 8∶2。

Minor GC/Young GC、Major GC/Old GC、Mixed GC、Full GC 都是什么时候触发?

部分收集(Partial GC):指目标不是完整收集整个 Java 堆的垃圾收集,其中又分为:

  • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
  • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS 收集器会有单独收集老年代的行为。
  • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有 G1 收集器会有这种行为。

整堆收集(Full GC):收集整个 Java 堆和方法区的垃圾收集。

Minor GC/Young GC

新创建的对象优先在新生代 Eden 区进行分配,如果 Eden 区没有足够的空间时,就会触发 Young GC 来清理新生代。

Full GC

  1. Young GC 之前检查老年代:在要进行 Young GC 的时候,发现老年代可用的连续内存空间 < 新生代历次Young GC后升入老年代的对象总和的平均大小,说明本次 Young GC 后可能升入老年代的对象大小,可能超过了老年代当前可用内存空间,那就会触发 Full GC。
  2. Young GC 之后老年代空间不足:执行 Young GC 之后有一批对象需要放入老年代,此时老年代就是没有足够的内存空间存放这些对象了,此时必须立即触发一次 Full GC
  3. 老年代空间不足,老年代内存使用率过高,达到一定比例,也会触发 Full GC。
  4. 空间分配担保失败( Promotion Failure),新生代的 To 区放不下从 Eden 和 From 拷贝过来对象,或者新生代对象 GC 年龄到达阈值需要晋升这两种情况,老年代如果放不下的话都会触发 Full GC。
  5. System.gc()等命令触发:System.gc()、jmap -dump 等命令会触发 full gc。

对象什么时候会进入老年代?

长期存活的对象将进入老年代

在对象的对象头信息中存储着对象的迭代年龄,迭代年龄会在每次 YoungGC 之后对象的移区操作中增加,每一次移区年龄加一.当这个年龄达到 15(默认)之后,这个对象将会被移入老年代。

可以通过这个参数设置这个年龄值。

- XX:MaxTenuringThreshold

大对象直接进入老年代

有一些占用大量连续内存空间的对象在被加载就会直接进入老年代.这样的大对象一般是一些数组,长字符串之类的对。

HotSpot 虚拟机提供了这个参数来设置。

-XX:PretenureSizeThreshold

动态对象年龄判定

为了能更好地适应不同程序的内存状况,HotSpot 虚拟机并不是永远要求对象的年龄必须达到- XX:MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

空间分配担保

假如在 Young GC 之后,新生代仍然有大量对象存活,就需要老年代进行分配担保,把 Survivor 无法容纳的对象直接送入老年代。

知道有哪些垃圾收集器吗?

主要垃圾收集器如下,图中标出了它们的工作区域、垃圾收集算法,以及配合关系。

能详细说一下 CMS 收集器的垃圾收集过程吗?

CMS 收集齐的垃圾收集分为四步:

  1. 初始标记(CMS initial mark):单线程运行,需要 Stop The World,标记 GC Roots 能直达的对象。
  2. 并发标记((CMS concurrent mark):无停顿,和用户线程同时运行,从 GC Roots 直达对象开始遍历整个对象图。
  3. 重新标记(CMS remark):多线程运行,需要 Stop The World,标记并发标记阶段产生对象。
  4. 并发清除(CMS concurrent sweep):无停顿,和用户线程同时运行,清理掉标记阶段标记的死亡的对象。

Concurrent Mark Sweep 收集器运行示意图如下:

G1 垃圾收集器了解吗?

Garbage First(简称 G1)收集器是垃圾收集器的一个颠覆性的产物,它开创了局部收集的设计思路和基于 Region 的内存布局形式。

虽然 G1 也仍是遵循分代收集理论设计的,但其堆内存的布局与其他收集器有非常明显的差异。以前的收集器分代是划分新生代、老年代、持久代等。

G1 把连续的 Java 堆划分为多个大小相等的独立区域(Region),每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间,或者老年代空间。收集器能够对扮演不同角色的 Region 采用不同的策略去处理。

这样就避免了收集整个堆,而是按照若干个 Region 集进行收集,同时维护一个优先级列表,跟踪各个 Region 回收的“价值,优先收集价值高的 Region。

G1 收集器的运行过程大致可划分为以下四个步骤:

  1. 初始标记(initial mark),标记了从 GC Root 开始直接关联可达的对象。STW(Stop the World)执行。
  2. 并发标记(concurrent marking),和用户线程并发执行,从 GC Root 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象、
  3. 最终标记(Remark),STW,标记再并发标记过程中产生的垃圾。
  4. 筛选回收(Live Data Counting And Evacuation),制定回收计划,选择多个 Region 构成回收集,把回收集中 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。需要 STW。

有了 CMS,为什么还要引入 G1?

优点:CMS 最主要的优点在名字上已经体现出来——并发收集、低停顿。

缺点:CMS 同样有三个明显的缺点。

Mark Sweep 算法会导致内存碎片比较多
CMS 的并发能力比较依赖于 CPU 资源,并发回收时垃圾收集线程可能会抢占用户线程的资源,导致用户程序性能下降。
并发清除阶段,用户线程依然在运行,会产生所谓的理“浮动垃圾”(Floating Garbage),本次垃圾收集无法处理浮动垃圾,必须到下一次垃圾收集才能处理。如果浮动垃圾太多,会触发新的垃圾回收,导致性能降低。
G1 主要解决了内存碎片过多的问题。

垃圾收集器应该如何选择?你们公司用的是哪一种?

这里简单地列一下上面提到的一些收集器的适用场景:

Serial :如果应用程序有一个很小的内存空间(大约 100 MB)亦或它在没有停顿时间要求的单线程处理器上运行。
Parallel:如果优先考虑应用程序的峰值性能,并且没有时间要求要求,或者可以接受 1 秒或更长的停顿时间。
CMS/G1:如果响应时间比吞吐量优先级高,或者垃圾收集暂停必须保持在大约 1 秒以内。
ZGC:如果响应时间是高优先级的,或者堆空间比较大。

怎么说呢,虽然调优说的震天响,但是我们一般都是用默认。管你 Java 怎么升,我用 8,那么 JDK1.8 默认用的是什么呢?

可以使用命令:

java -XX:+PrintCommandLineFlags -version
可以看到有这么一行:

-XX:+UseParallelGC

UseParallelGC = Parallel Scavenge + Parallel Old,表示的是新生代用的Parallel Scavenge收集器,老年代用的是Parallel Old 收集器。

那为什么要用这个呢?默认的呗。

当然面试肯定不能这么答。

Parallel Scavenge 的特点是什么?

高吞吐,我们可以回答:因为我们系统是业务相对复杂,但并发并不是非常高,所以希望尽可能的利用处理器资源,出于提高吞吐量的考虑采用Parallel Scavenge + Parallel Old的组合。

当然,这个默认虽然也有说法,但不太讨喜。

还可以说:

采用Parallel New+CMS的组合,我们比较关注服务的响应速度,所以采用了 CMS 来降低停顿时间。

或者一步到位:

我们线上采用了设计比较优秀的 G1 垃圾收集器,因为它不仅满足我们低停顿的要求,而且解决了 CMS 的浮动垃圾问题、内存碎片问题。

对象一定分配在堆中吗?有没有了解逃逸分析技术?

对象一定分配在堆中吗? 不一定的。

随着 JIT 编译期的发展与逃逸分析技术逐渐成熟,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。其实,在编译期间,JIT 会对代码做很多优化。其中有一部分优化的目的就是减少内存堆分配压力,其中一种重要的技术叫做逃逸分析。

什么是逃逸分析?

逃逸分析是指分析指针动态范围的方法,它同编译器优化原理的指针分析和外形分析相关联。当变量(或者对象)在方法中分配后,其指针有可能被返回或者被全局引用,这样就会被其他方法或者线程所引用,这种现象称作指针(或者引用)的逃逸(Escape)。

通俗点讲,当一个对象被 new 出来之后,它可能被外部所调用,如果是作为参数传递到外部了,就称之为方法逃逸。

除此之外,如果对象还有可能被外部线程访问到,例如赋值给可以在其它线程中访问的实例变量,这种就被称为线程逃逸。

逃逸分析的好处

  1. 栈上分配
    如果确定一个对象不会逃逸到线程之外,那么久可以考虑将这个对象在栈上分配,对象占用的内存随着栈帧出栈而销毁,这样一来,垃圾收集的压力就降低很多。

  2. 同步消除
    线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争, 对这个变量实施的同步措施也就可以安全地消除掉。

  3. 标量替换
    如果一个数据是基本数据类型,不可拆分,它就被称之为标量。把一个 Java 对象拆散,将其用到的成员变量恢复为原始类型来访问,这个过程就称为标量替换。假如逃逸分析能够证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么可以不创建对象,直接用创建若干个成员变量代替,可以让对象的成员变量在栈上分配和读写。

有哪些常用的命令行性能监控和故障处理工具?

操作系统工具

  • top:显示系统整体资源使用情况
  • vmstat:监控内存和 CPU
  • iostat:监控 IO 使用
  • netstat:监控网络使用

JDK 性能监控工具

  • jps:虚拟机进程查看
  • jstat:虚拟机运行时信息查看
  • jinfo:虚拟机配置查看
  • jmap:内存映像(导出)
  • jhat:堆转储快照分析
  • jstack:Java 堆栈跟踪
  • jcmd:实现上面除了 jstat 外所有命令的功能

JVM 的常见参数配置知道哪些?

JVM 的参数配置很多,可以分为以下几类:

  1. 标准参数:标准参数是所有的JVM都支持的参数,比如-Xms、-Xmx、-Xss等。

  2. 非标准参数:非标准参数是某些JVM特有的参数,例如-XX:+PrintGCDetails、-XX:+UseG1GC等。

  3. 高级运行时参数:高级运行时参数用于调整JVM的性能和行为,例如-XX:MaxPermSize、-XX:PermSize等。

常见的JVM参数配置包括:

  1. 堆内存配置:-Xms、-Xmx参数用于设置堆内存的初始大小和最大大小。

  2. 线程堆栈大小:-Xss参数用于设置每个线程的堆栈大小。

  3. 垃圾收集器配置:-XX:+UseSerialGC、-XX:+UseParallelGC、-XX:+UseConcMarkSweepGC、-XX:+UseG1GC等参数用于选择不同的垃圾收集器以达到不同的性能和目标。

  4. 类加载配置:-XX:+TraceClassLoading、-XX:+TraceClassUnloading等参数用于跟踪类的加载和卸载情况。

  5. 内存溢出处理:-XX:+HeapDumpOnOutOfMemoryError、-XX:HeapDumpPath等参数用于在内存溢出时输出堆转储文件,便于分析问题。

  6. JIT编译器配置:-XX:+TieredCompilation、-XX:CompileThreshold等参数用于调整JIT编译器的行为和性能。

有做过 JVM 调优吗?

案例:电商公司的运营后台系统,偶发性的引发 OOM 异常,堆内存溢出。

  1. 因为是偶发性的,所以第一次简单的认为就是堆内存不足导致,单方面的加大了堆内存从 4G 调整到 8G -Xms8g。

  2. 但是问题依然没有解决,只能从堆内存信息下手,通过开启了-XX:+HeapDumpOnOutOfMemoryError 参数 获得堆内存的 dump 文件。

  3. 用 JProfiler 对 堆 dump 文件进行分析,通过 JProfiler 查看到占用内存最大的对象是 String 对象,本来想跟踪着 String 对象找到其引用的地方,但 dump 文件太大,跟踪进去的时候总是卡死,而 String 对象占用比较多也比较正常,最开始也没有认定就是这里的问题,于是就从线程信息里面找突破点。

  4. 通过线程进行分析,先找到了几个正在运行的业务线程,然后逐一跟进业务线程看了下代码,有个方法引起了我的注意,导出订单信息。

  5. 因为订单信息导出这个方法可能会有几万的数据量,首先要从数据库里面查询出来订单信息,然后把订单信息生成 excel,这个过程会产生大量的 String 对象。

  6. 为了验证自己的猜想,于是准备登录后台去测试下,结果在测试的过程中发现导出订单的按钮前端居然没有做点击后按钮置灰交互事件,后端也没有做防止重复提交,因为导出订单数据本来就非常慢,使用的人员可能发现点击后很久后页面都没反应,然后就一直点,结果就大量的请求进入到后台,堆内存产生了大量的订单对象和 EXCEL 对象,而且方法执行非常慢,导致这一段时间内这些对象都无法被回收,所以最终导致内存溢出。

  7. 知道了问题就容易解决了,最终没有调整任何 JVM 参数,只是做了两个处理:

在前端的导出订单按钮上加上了置灰状态,等后端响应之后按钮才可以进行点击
后端代码加分布式锁,做防重处理
这样双管齐下,保证导出的请求不会一直打到服务端,问题解决!

线上服务 CPU 占用过高怎么排查?

问题分析:CPU 高一定是某个程序长期占用了 CPU 资源。

  1. 所以先需要找出那个进程占用 CPU 高。

top 列出系统各个进程的资源占用情况。

  1. 然后根据找到对应进行里哪个线程占用 CPU 高。

top -Hp 进程 ID 列出对应进程里面的线程占用资源情况

  1. 找到对应线程 ID 后,再打印出对应线程的堆栈信息

printf "%x\n" PID 把线程 ID 转换为 16 进制。
jstack PID 打印出进程的所有线程信息,从打印出来的线程信息中找到上一步转换为 16 进制的线程 ID 对应的线程信息。

  1. 最后根据线程的堆栈信息定位到具体业务方法,从代码逻辑中找到问题所在。

查看是否有线程长时间的 watting 或 blocked,如果线程长期处于 watting 状态下, 关注 watting on xxxxxx,说明线程在等待这把锁,然后根据锁的地址找到持有锁的线程。

内存飙高问题怎么排查?

分析: 内存飚高如果是发生在 java 进程上,一般是因为创建了大量对象所导致,持续飚高说明垃圾回收跟不上对象创建的速度,或者内存泄露导致对象无法回收。

  1. 先观察垃圾回收的情况

jstat -gc PID 1000 查看 GC 次数,时间等信息,每隔一秒打印一次。
jmap -histo PID | head -20 查看堆内存占用空间最大的前 20 个对象类型,可初步查看是哪个对象占用了内存。
如果每次 GC 次数频繁,而且每次回收的内存空间也正常,那说明是因为对象创建速度快导致内存一直占用很高;如果每次回收的内存非常少,那么很可能是因为内存泄露导致内存一直无法被回收。

  1. 导出堆内存文件快照

jmap -dump:live,format=b,file=/home/myheapdump.hprof PID dump 堆内存信息到文件。

  1. 使用 visualVM 对 dump 文件进行离线分析,找到占用内存高的对象,再找到创建该对象的业务代码位置,从代码和业务场景中定位具体问题。

频繁 minor gc 怎么办?

优化 Minor GC 频繁问题:通常情况下,由于新生代空间较小,Eden 区很快被填满,就会导致频繁 Minor GC,因此可以通过增大新生代空间-Xmn来降低 Minor GC 的频率。

频繁 Full GC 怎么办?

Full GC 的排查思路大概如下:

  1. 清楚从程序角度,有哪些原因导致 FGC?
  • 大对象:系统一次性加载了过多数据到内存中(比如 SQL 查询未做分页),导致大对象进入了老年代。
  • 内存泄漏:频繁创建了大量对象,但是无法被回收(比如 IO 对象使用完后未调用 close 方法释放资源),先引发 FGC,最后导致 OOM.
  • 程序频繁生成一些长生命周期的对象,当这些对象的存活年龄超过分代年龄时便会进入老年代,最后引发 FGC. (即本文中的案例)
  • 程序 BUG
  • 代码中显式调用了 gc方法,包括自己的代码甚至框架中的代码。
  • JVM 参数设置问题:包括总内存大小、新生代和老年代的大小、Eden 区和 S 区的大小、元空间大小、垃圾回收算法等等。
  1. 清楚排查问题时能使用哪些工具
  • 公司的监控系统:大部分公司都会有,可全方位监控 JVM 的各项指标。
  • JDK 的自带工具,包括 jmap、jstat 等常用命令:

查看堆内存各区域的使用率以及GC情况
jstat -gcutil -h20 pid 1000

查看堆内存中的存活对象,并按空间排序
jmap -histo pid | head -n20

dump堆内存文件
jmap -dump:format=b,file=heap pid

可视化的堆内存分析工具:JVisualVM、MAT 等

  1. 排查指南

查看监控,以了解出现问题的时间点以及当前 FGC 的频率(可对比正常情况看频率是否正常)
了解该时间点之前有没有程序上线、基础组件升级等情况。
了解 JVM 的参数设置,包括:堆空间各个区域的大小设置,新生代和老年代分别采用了哪些垃圾收集器,然后分析 JVM 参数设置是否合理。
再对步骤 1 中列出的可能原因做排除法,其中元空间被打满、内存泄漏、代码显式调用 gc 方法比较容易排查。
针对大对象或者长生命周期对象导致的 FGC,可通过 jmap -histo 命令并结合 dump 堆内存文件作进一步分析,需要先定位到可疑对象。
通过可疑对象定位到具体代码再次分析,这时候要结合 GC 原理和 JVM 参数设置,弄清楚可疑对象是否满足了进入到老年代的条件才能下结论。

有没有处理过内存泄漏问题?是如何定位的?

内存泄漏是内在病源,外在病症表现可能有:

  1. 应用程序长时间连续运行时性能严重下降
  2. CPU 使用率飙升,甚至到 100%
  3. 频繁 Full GC,各种报警,例如接口超时报警等
  4. 应用程序抛出 OutOfMemoryError 错误
  5. 应用程序偶尔会耗尽连接对象

严重内存泄漏往往伴随频繁的 Full GC,所以分析排查内存泄漏问题首先还得从查看 Full GC 入手。主要有以下操作步骤:

  1. 使用 jps 查看运行的 Java 进程 ID

  2. 使用top -p [pid] 查看进程使用 CPU 和 MEM 的情况

  3. 使用 top -Hp [pid] 查看进程下的所有线程占 CPU 和 MEM 的情况

  4. 将线程 ID 转换为 16 进制:printf "%x\n" [pid],输出的值就是线程栈信息中的 nid。

例如:printf "%x\n" 29471,换行输出 731f。

  1. 抓取线程栈:jstack 29452 > 29452.txt,可以多抓几次做个对比。

在线程栈信息中找到对应线程号的 16 进制值,如下是 731f 线程的信息。线程栈分析可使用 Visualvm 插件 TDA。

"Service Thread" #7 daemon prio=9 os_prio=0 tid=0x00007fbe2c164000 nid=0x731f runnable [0x0000000000000000]
  java.lang.Thread.State: RUNNABLE
  
  1. 使用jstat -gcutil [pid] 5000 10 每隔 5 秒输出 GC 信息,输出 10 次,查看 YGC 和 Full GC 次数。通常会出现 YGC 不增加或增加缓慢,而 Full GC 增加很快。

或使用 jstat -gccause [pid] 5000 ,同样是输出 GC 摘要信息。

或使用 jmap -heap [pid] 查看堆的摘要信息,关注老年代内存使用是否达到阀值,若达到阀值就会执行 Full GC。

  1. 如果发现 Full GC 次数太多,就很大概率存在内存泄漏了

  2. 使用 jmap -histo:live [pid] 输出每个类的对象数量,内存大小(字节单位)及全限定类名。

  3. 生成 dump 文件,借助工具分析哪 个对象非常多,基本就能定位到问题在那了

使用 jmap 生成 dump 文件:

jmap -dump:live,format=b,file=29471.dump 29471
Dumping heap to /root/dump ...
Heap dump file created

  1. dump 文件分析

可以使用 jhat 命令分析:jhat -port 8000 29471.dump,浏览器访问 jhat 服务,端口是 8000。

通常使用图形化工具分析,如 JDK 自带的 jvisualvm,从菜单 > 文件 > 装入 dump 文件。

或使用第三方式具分析的,如 JProfiler 也是个图形化工具,GCViewer 工具。Eclipse 或以使用 MAT 工具查看。或使用在线分析平台 GCEasy。

注意:如果 dump 文件较大的话,分析会占比较大的内存。

  1. 在 dump 文析结果中查找存在大量的对象,再查对其的引用。

基本上就可以定位到代码层的逻辑了。

能说一下类的生命周期吗?

一个类从被加载到虚拟机内存中开始,到从内存中卸载,整个生命周期需要经过七个阶段:加载 (Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化 (Initialization)、使用(Using)和卸载(Unloading),其中验证、准备、解析三个部分统称为连接(Linking)。

类加载的过程知道吗?

加载是 JVM 加载的起点,具体什么时候开始加载,《Java 虚拟机规范》中并没有进行强制约束,可以交给虚拟机的具体实现来自由把握。

在加载过程,JVM 要做三件事情:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流。

  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。

  3. 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。

加载阶段结束后,Java 虚拟机外部的二进制字节流就按照虚拟机所设定的格式存储在方法区之中了,方法区中的数据存储格式完全由虚拟机实现自行定义,《Java 虚拟机规范》未规定此区域的具体数据结构。

类型数据妥善安置在方法区之后,会在 Java 堆内存中实例化一个 java.lang.Class 类的对象, 这个对象将作为程序访问方法区中的类型数据的外部接口。

类加载器有哪些?

主要有四种类加载器:

  1. 启动类加载器(Bootstrap ClassLoader)用来加载 java 核心类库,无法被 java 程序直接引用。

  2. 扩展类加载器(extensions class loader):它用来加载 Java 的扩展库。Java 虚拟机的实现会提供一个扩展库目录。该类加载器在此目录里面查找并加载 Java 类。

  3. 系统类加载器(system class loader):它根据 Java 应用的类路径(CLASSPATH)来加载 Java 类。一般来说,Java 应用的类都是由它来完成加载的。可以通过 ClassLoader.getSystemClassLoader()来获取它。

  4. 用户自定义类加载器 (user class loader),用户通过继承 java.lang.ClassLoader 类的方式自行实现的类加载器。

什么是双亲委派机制?

双亲委派模型的工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到最顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去完成加载。

为什么要用双亲委派机制?

这种机制可以避免重复加载类,并且能够保证类的唯一性和一致性,因为子加载器加载的类可以访问父加载器加载的类,反之则不行。

如何破坏双亲委派机制?

如果不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass()方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。而如果想打破双亲委派模型则需要重写 loadClass()方法。

历史上有哪几次双亲委派机制的破坏?

第一次破坏

双亲委派模型的第一次“被破坏”其实发生在双亲委派模型出现之前——即 JDK 1.2 面世以前的“远古”时代。

由于双亲委派模型在 JDK 1.2 之后才被引入,但是类加载器的概念和抽象类 java.lang.ClassLoader 则在 Java 的第一个版本中就已经存在,为了向下兼容旧代码,所以无法以技术手段避免 loadClass()被子类覆盖的可能性,只能在 JDK 1.2 之后的 java.lang.ClassLoader 中添加一个新的 protected 方法 findClass(),并引导用户编写的类加载逻辑时尽可能去重写这个方法,而不是在 loadClass()中编写代码。

第二次破坏

双亲委派模型的第二次“被破坏”是由这个模型自身的缺陷导致的,如果有基础类型又要调用回用户的代码,那该怎么办呢?

例如我们比较熟悉的 JDBC:

各个厂商各有不同的 JDBC 的实现,Java 在核心包\lib里定义了对应的 SPI,那么这个就毫无疑问由启动类加载器加载器加载。

但是各个厂商的实现,是没办法放在核心包里的,只能放在classpath里,只能被应用类加载器加载。那么,问题来了,启动类加载器它就加载不到厂商提供的 SPI 服务代码。

为了解决这个问题,引入了一个不太优雅的设计:线程上下文类加载器 (Thread Context ClassLoader)。这个类加载器可以通过 java.lang.Thread 类的 setContext-ClassLoader()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过的话,那这个类加载器默认就是应用程序类加载器。

JNDI 服务使用这个线程上下文类加载器去加载所需的 SPI 服务代码,这是一种父类加载器去请求子类加载器完成类加载的行为。

第三次破坏

双亲委派模型的第三次“被破坏”是由于用户对程序动态性的追求而导致的,例如代码热替换(Hot Swap)、模块热部署(Hot Deployment)等。

OSGi 实现模块化热部署的关键是它自定义的类加载器机制的实现,每一个程序模块(OSGi 中称为 Bundle)都有一个自己的类加载器,当需要更换一个 Bundle 时,就把 Bundle 连同类加载器一起换掉以实现代码的热替换。在 OSGi 环境下,类加载器不再双亲委派模型推荐的树状结构,而是进一步发展为更加复杂的网状结构。

你觉得应该怎么实现一个热部署功能?

我们已经知道了 Java 类的加载过程。一个 Java 类文件到虚拟机里的对象,要经过如下过程:首先通过 Java 编译器,将 Java 文件编译成 class 字节码,类加载器读取 class 字节码,再将类转化为实例,对实例 newInstance 就可以生成对象。

类加载器 ClassLoader 功能,也就是将 class 字节码转换到类的实例。在 Java 应用中,所有的实例都是由类加载器,加载而来。

一般在系统中,类的加载都是由系统自带的类加载器完成,而且对于同一个全限定名的 java 类(如 com.csiar.soc.HelloWorld),只能被加载一次,而且无法被卸载。

这个时候问题就来了,如果我们希望将 java 类卸载,并且替换更新版本的 java 类,该怎么做呢?

既然在类加载器中,Java 类只能被加载一次,并且无法卸载。那么我们是不是可以直接把 Java 类加载器干掉呢?答案是可以的,我们可以自定义类加载器,并重写 ClassLoader 的 findClass 方法。

想要实现热部署可以分以下三个步骤:

1)销毁原来的自定义 ClassLoader
2)更新 class 类文件
3)创建新的 ClassLoader 去加载更新后的 class 类文件。
到此,一个热部署的功能就这样实现了。

例如:


import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;

public class HotSwapClassLoader extends ClassLoader {
    // 要加载的类的路径
    private String classpath;

    public HotSwapClassLoader(String classpath) {
        this.classpath = classpath;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 查找类的字节码
        byte[] classData = getClassData(name);
        if (classData == null) {
            throw new ClassNotFoundException();
        } else {
            // 加载类
            return defineClass(name, classData, 0, classData.length);
        }
    }

    private byte[] getClassData(String name) {
        String path = classpath + File.separatorChar + name.replace('.', File.separatorChar) + ".class";
        InputStream is = null;
        ByteArrayOutputStream bos = null;
        try {
            is = new FileInputStream(path);
            bos = new ByteArrayOutputStream();
            byte[] buffer = new byte[1024];
            int length = -1;
            while ((length = is.read(buffer)) != -1) {
                bos.write(buffer, 0, length);
            }
            return bos.toByteArray();
        } catch (IOException e) {
            e.printStackTrace();
            return null;
        } finally {
            try {
                if (is != null) {
                    is.close();
                }
                if (bos != null) {
                    bos.close();
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}

在上面的代码中,我们定义了一个自定义类加载器 HotSwapClassLoader。它接受一个类路径参数,用于指定要加载的类所在的目录。在 findClass() 方法中,我们根据类名查找类的字节码,并使用 defineClass() 方法来加载类。getClassData() 方法用于从磁盘读取类的字节码。

下面是一个使用 HotSwapClassLoader 的例子,演示了如何实现热部署:

public class TestHotSwap {
    public static void main(String[] args) throws Exception {
        // 创建自定义类加载器
        HotSwapClassLoader classLoader = new HotSwapClassLoader("/path/to/classes");
        while (true) {
            // 加载要热部署的类
            Class<?> clazz = classLoader.loadClass("com.example.MyClass");

            // 调用 MyClass 中的方法
            Object obj = clazz.newInstance();
            Method method = clazz.getMethod("hello");
            method.invoke(obj);

            // 等待一段时间,以便可以更新 MyClass 类
            Thread.sleep(5000);
        }
    }
}

在上面的代码中,我们创建了一个 HotSwapClassLoader,然后使用它加载一个名为 com.example.MyClass 的类。在主循环中,我们不断地调用 MyClass 中的 hello() 方法,并在每次循环之后等待 5 秒,以便可以更新 MyClass 类。要更新 MyClass 类,只需要将新的 MyClass.class 文件复制到 /path/to/classes/com/example 目录中即可,HotSwapClassLoader 会自动重新加载新的 MyClass 类,并使用它来替换旧的 MyClass 类。

Tomcat 的类加载机制了解吗?

Tomcat 类加载器如下:

Tomcat 实际上也是破坏了双亲委派模型的。

Tomact 是 web 容器,可能需要部署多个应用程序。不同的应用程序可能会依赖同一个第三方类库的不同版本,但是不同版本的类库中某一个类的全路径名可能是一样的。如多个应用都要依赖 hollis.jar,但是 A 应用需要依赖 1.0.0 版本,但是 B 应用需要依赖 1.0.1 版本。这两个版本中都有一个类是 com.hollis.Test.class。如果采用默认的双亲委派类加载机制,那么无法加载多个相同的类。

所以,Tomcat 破坏了双亲委派原则,提供隔离的机制,为每个 web 容器单独提供一个 WebAppClassLoader 加载器。每一个 WebAppClassLoader 负责加载本身的目录下的 class 文件,加载不到时再交 CommonClassLoader 加载,这和双亲委派刚好相反。

Spring

Spring 是什么?特性?

一句话概括:Spring 是一个轻量级(现阶段已经不能算轻量了)、非入侵式的控制反转 (IoC) 和面向切面 (AOP) 的框架。

特性:

  1. IOC 和 DI 的支持
    Spring 的核心就是一个大的工厂容器,可以维护所有对象的创建和依赖关系,Spring 工厂用于生成 Bean,并且管理 Bean 的生命周期,实现高内聚低耦合的设计理念。

  2. AOP 编程的支持
    Spring 提供了面向切面编程,可以方便的实现对程序进行权限拦截、运行监控等切面功能。

  3. 声明式事务的支持
    支持通过配置就来完成对事务的管理,而不需要通过硬编码的方式,以前重复的一些事务提交、回滚的 JDBC 代码,都可以不用自己写了。

  4. 快捷测试的支持
    Spring 对 Junit 提供支持,可以通过注解快捷地测试 Spring 程序。

  5. 快速集成功能
    方便集成各种优秀框架,Spring 不排斥各种优秀的开源框架,其内部提供了对各种优秀框架(如:Struts、Hibernate、MyBatis、Quartz 等)的直接支持。

  6. 复杂 API 模板封装
    Spring 对 JavaEE 开发中非常难用的一些 API(JDBC、JavaMail、远程调用等)都提供了模板化的封装,这些封装 API 的提供使得应用难度大大降低。

Spring 中应用了哪些设计模式呢?

  1. 工厂模式 : Spring 容器本质是一个大工厂,使用工厂模式通过 BeanFactory、ApplicationContext 创建 bean 对象。
  2. 代理模式 : Spring AOP 功能功能就是通过代理模式来实现的,分为动态代理和静态代理。
  3. 单例模式 : Spring 中的 Bean 默认都是单例的,这样有利于容器对 Bean 的管理。
  4. 模板模式 : Spring 中 JdbcTemplate、RestTemplate 等以 Template 结尾的对数据库、网络等等进行操作的模板类,就使用到了模板模式。
  5. 观察者模式: Spring 事件驱动模型就是观察者模式很经典的一个应用。
  6. 适配器模式 :Spring AOP 的增强或通知 (Advice) 使用到了适配器模式、Spring MVC 中也是用到了适配器模式适配 Controller。
  7. 策略模式:Spring 中有一个 Resource 接口,它的不同实现类,会根据不同的策略去访问资源。

说说 BeanFactory 和 ApplicantContext?

可以这么形容,BeanFactory 是 Spring 的“心脏”,ApplicantContext 是完整的“身躯”。

  1. BeanFactory(Bean 工厂)是 Spring 框架的基础设施,面向 Spring 本身。
  2. ApplicantContext(应用上下文)建立在 BeanFactoty 基础上,面向使用 Spring 框架的开发者。

BeanFactory 是 Spring 框架的核心接口之一,它提供了一种基础的 Bean 容器,负责创建、装配和管理 Bean。BeanFactory 提供了基本的 Bean 容器功能,比如 Bean 的生命周期管理、依赖注入、AOP 等。由于 BeanFactory 是 Spring 框架的核心接口,因此所有的 Spring 容器都是以 BeanFactory 接口为基础实现的。

ApplicationContext 是 BeanFactory 的子接口,它是 Spring 框架中功能最强大的容器。除了 BeanFactory 的基本功能之外,ApplicationContext 还提供了更多的高级特性,比如国际化支持、事件发布机制、自动装配等。ApplicationContext 的实现类通常是以 XML 配置文件或注解方式配置的,它可以加载和管理多个 BeanFactory 实例,并提供了对事务的支持。

你知道 Spring 容器启动阶段会干什么吗?

Spring 的 IOC 容器工作的过程,其实可以划分为两个阶段:容器启动阶段和Bean 实例化阶段。

其中容器启动阶段主要做的工作是加载和解析配置文件,保存到对应的 Bean 定义中。

容器启动开始,首先会通过某种途径加载 Congiguration MetaData,在大部分情况下,容器需要依赖某些工具类(BeanDefinitionReader)对加载的 Congiguration MetaData 进行解析和分析,并将分析后的信息组为相应的 BeanDefinition。

最后把这些保存了 Bean 定义必要信息的 BeanDefinition,注册到相应的 BeanDefinitionRegistry,这样容器启动就完成了。

能说一下 Spring Bean 生命周期吗?

Bean 的生命周期大致分为四个阶段:实例化(Instantiation)、属性赋值(Populate)、初始化(Initialization)、销毁(Destruction)。

  1. 实例化:第 1 步,实例化一个 Bean 对象
  2. 属性赋值:第 2 步,为 Bean 设置相关属性和依赖
  3. 初始化:初始化的阶段的步骤比较多,5、6 步是真正的初始化,第 3、4 步为在初始化前执行,第 7 步在初始化后执行,初始化完成之后,Bean 就可以被使用了
  4. 销毁:第 8~10 步,第 8 步其实也可以算到销毁阶段,但不是真正意义上的销毁,而是先在使用前注册了销毁的相关调用接口,为了后面第 9、10 步真正销毁 Bean 时再执行相应的方法

简单总结一下,Bean 生命周期里初始化的过程相对步骤会多一些,比如前置、后置的处理。

最后通过一个实例来看一下具体的细节:

Spring 中的单例 Bean 会存在线程安全问题吗?

首先结论在这:Spring 中的单例 Bean不是线程安全的。

因为单例 Bean,是全局只有一个 Bean,所有线程共享。如果说单例 Bean,是一个无状态的,也就是线程中的操作不会对 Bean 中的成员变量执行查询以外的操作,那么这个单例 Bean 是线程安全的。比如 Spring mvc 的 Controller、Service、Dao 等,这些 Bean 大多是无状态的,只关注于方法本身。

假如这个 Bean 是有状态的,也就是会对 Bean 中的成员变量进行写操作,那么可能就存在线程安全的问题。

单例 Bean 线程安全问题怎么解决呢?

常见的有这么些解决办法:

  1. 将 Bean 定义为多例
    这样每一个线程请求过来都会创建一个新的 Bean,但是这样容器就不好管理 Bean,不能这么办。

  2. 在 Bean 对象中尽量避免定义可变的成员变量
    削足适履了属于是,也不能这么干。

  3. 将 Bean 中的成员变量保存在 ThreadLocal 中 (推荐)
    我们知道 ThredLoca 能保证多线程下变量的隔离,可以在类中定义一个 ThreadLocal 成员变量,将需要的可变成员变量保存在 ThreadLocal 里,这是推荐的一种方式。

Spring 怎么解决循环依赖的呢?

什么是循环依赖?

Spring 循环依赖:简单说就是自己依赖自己,或者和别的 Bean 相互依赖。

只有单例的 Bean 才存在循环依赖的情况,原型(Prototype)情况下,Spring 会直接抛出异常。原因很简单,AB 循环依赖,A 实例化的时候,发现依赖 B,创建 B 实例,创建 B 的时候发现需要 A,创建 A1 实例……无限套娃,直接把系统干垮。

Spring 可以解决哪些情况的循环依赖?

Spring 不支持基于构造器注入的循环依赖,但是假如 AB 循环依赖,如果一个是构造器注入,一个是 setter 注入呢?

第四种可以而第五种不可以的原因是 Spring 在创建 Bean 时默认会根据自然排序进行创建,所以 A 会先于 B 进行创建。

所以简单总结,当循环依赖的实例都采用 setter 方法注入的时候,Spring 可以支持,都采用构造器注入的时候,不支持,构造器注入和 setter 注入同时存在的时候,看天。

Spring 怎么解决循环依赖

我们都知道,单例 Bean 初始化完成,要经历三步:

注入就发生在第二步,属性赋值,结合这个过程,Spring 通过三级缓存解决了循环依赖:

一级缓存 : Map<String,Object> singletonObjects,单例池,用于保存实例化、属性赋值(注入)、初始化完成的 bean 实例
二级缓存 : Map<String,Object> earlySingletonObjects,早期曝光对象,用于保存实例化完成的 bean 实例
三级缓存 : Map<String,ObjectFactory< ? >> singletonFactories,早期曝光对象工厂,用于保存 bean 创建工厂,以便于后面扩展有机会创建代理对象。

我们来看一下三级缓存解决循环依赖的过程:

A 实例的初始化过程:

  1. 创建 A 实例,实例化的时候把 A 对象⼯⼚放⼊三级缓存,表示 A 开始实例化了,虽然我这个对象还不完整,但是先曝光出来让大家知道

  1. A 注⼊属性时,发现依赖 B,此时 B 还没有被创建出来,所以去实例化 B

  2. 同样,B 注⼊属性时发现依赖 A,它就会从缓存里找 A 对象。依次从⼀级到三级缓存查询 A,从三级缓存通过对象⼯⼚拿到 A,发现 A 虽然不太完善,但是存在,把 A 放⼊⼆级缓存,同时删除三级缓存中的 A,此时,B 已经实例化并且初始化完成,把 B 放入⼀级缓存。

  3. 接着 A 继续属性赋值,顺利从⼀级缓存拿到实例化且初始化完成的 B 对象,A 对象创建也完成,删除⼆级缓存中的 A,同时把 A 放⼊⼀级缓存

  4. 最后,⼀级缓存中保存着实例化、初始化都完成的 A、B 对象

所以,我们就知道为什么 Spring 能解决 setter 注入的循环依赖了,因为实例化和属性赋值是分开的,所以里面有操作的空间。如果都是构造器注入的化,那么都得在实例化这一步完成注入,所以自然是无法支持了。

为什么要三级缓存?⼆级不⾏吗?

不行,主要是为了⽣成代理对象。如果是没有代理的情况下,使用二级缓存解决循环依赖也是 OK 的。但是如果存在代理,三级没有问题,二级就不行了。

因为三级缓存中放的是⽣成具体对象的匿名内部类,获取 Object 的时候,它可以⽣成代理对象,也可以返回普通对象。使⽤三级缓存主要是为了保证不管什么时候使⽤的都是⼀个对象。

假设只有⼆级缓存的情况,往⼆级缓存中放的显示⼀个普通的 Bean 对象,Bean 初始化过程中,通过 BeanPostProcessor 去⽣成代理对象之后,覆盖掉⼆级缓存中的普通 Bean 对象,那么可能就导致取到的 Bean 对象不一致了。

@Autowired 的实现原理?

实现@Autowired 的关键是:AutowiredAnnotationBeanPostProcessor

在 Bean 的初始化阶段,会通过 Bean 后置处理器来进行一些前置和后置的处理。

实现@Autowired 的功能,也是通过后置处理器来完成的。这个后置处理器就是 AutowiredAnnotationBeanPostProcessor。

  1. Spring 在创建 bean 的过程中,最终会调用到 doCreateBean()方法,在 doCreateBean()方法中会调用 populateBean()方法,来为 bean 进行属性填充,完成自动装配等工作。

  2. 在 populateBean()方法中一共调用了两次后置处理器,第一次是为了判断是否需要属性填充,如果不需要进行属性填充,那么就会直接进行 return,如果需要进行属性填充,那么方法就会继续向下执行,后面会进行第二次后置处理器的调用,这个时候,就会调用到 AutowiredAnnotationBeanPostProcessor 的 postProcessPropertyValues()方法,在该方法中就会进行@Autowired 注解的解析,然后实现自动装配。

  3. postProcessorPropertyValues()方法的源码如下,在该方法中,会先调用 findAutowiringMetadata()方法解析出 bean 中带有@Autowired 注解、@Inject 和@Value 注解的属性和方法。然后调用 metadata.inject()方法,进行属性填充

public PropertyValues postProcessProperties(PropertyValues pvs, Object bean, String beanName) {
      //@Autowired注解、@Inject和@Value注解的属性和方法
      InjectionMetadata metadata = this.findAutowiringMetadata(beanName, bean.getClass(), pvs);

      try {
          //属性填充
          metadata.inject(bean, beanName, pvs);
          return pvs;
      } catch (BeanCreationException var6) {
          throw var6;
      } catch (Throwable var7) {
          throw new BeanCreationException(beanName, "Injection of autowired dependencies failed", var7);
      }
  }

说说你理解的 AOP

AOP:面向切面编程。简单说,就是把一些业务逻辑中的相同的代码抽取到一个独立的模块中,让业务逻辑更加清爽。

具体来说,假如我现在要 crud 写一堆业务,可是如何业务代码前后前后进行打印日志和参数的校验呢?

我们可以把日志记录数据校验可重用的功能模块分离出来,然后在程序的执行的合适的地方动态地植入这些代码并执行。这样就简化了代码的书写。

业务逻辑代码中没有参和通用逻辑的代码,业务模块更简洁,只包含核心业务代码。实现了业务逻辑和通用逻辑的代码分离,便于维护和升级,降低了业务逻辑和通用逻辑的耦合性。

AOP 可以将遍布应用各处的功能分离出来形成可重用的组件。在编译期间、装载期间或运行期间实现在不修改源代码的情况下给程序动态添加功能。从而实现对业务逻辑的隔离,提高代码的模块化能力。

AOP 的核心其实就是动态代理,如果是实现了接口的话就会使用 JDK 动态代理,否则使用 CGLIB 代理,主要应用于处理一些具有横切性质的系统级服务,如日志收集、事务管理、安全检查、缓存、对象池管理等。

AOP 有哪些核心概念?

  1. 切面(Aspect):类是对物体特征的抽象,切面就是对横切关注点的抽象

  2. 连接点(Joinpoint):被拦截到的点,因为 Spring 只支持方法类型的连接点,所以在 Spring 中连接点指的就是被拦截到的方法,实际上连接点还可以是字段或者构造器

  3. 切点(Pointcut):对连接点进行拦截的定位

  4. 通知(Advice):所谓通知指的就是指拦截到连接点之后要执行的代码,也可以称作增强

  5. 目标对象 (Target):代理的目标对象

  6. 织入(Weabing):织入是将增强添加到目标类的具体连接点上的过程。

    • 编译期织入:切面在目标类编译时被织入

    • 类加载期织入:切面在目标类加载到 JVM 时被织入。需要特殊的类加载器,它可以在目标类被引入应用之前增强该目标类的字节码。

    • 运行期织入:切面在应用运行的某个时刻被织入。一般情况下,在织入切面时,AOP 容器会为目标对象动态地创建一个代理对象。SpringAOP 就是以这种方式织入切面。

Spring 采用运行期织入,而 AspectJ 采用编译期织入和类加载器织入。

  1. 引介(introduction):引介是一种特殊的增强,可以动态地为类添加一些属性和方法

AOP 有哪些环绕方式?

AOP 一般有 5 种环绕方式:

  1. 前置通知 (@Before)
  2. 返回通知 (@AfterReturning)
  3. 异常通知 (@AfterThrowing)
  4. 后置通知 (@After)
  5. 环绕通知 (@Around)

说说你平时有用到 AOP 吗?
PS:这道题老三的同事面试候选人的时候问到了,候选人说了一堆 AOP 原理,同事就势来一句,你能现场写一下 AOP 的应用吗?结果——场面一度很尴尬。虽然我对面试写这种百度就能出来的东西持保留意见,但是还是加上了这一问,毕竟招人最后都是要撸代码的。

这里给出一个小例子,SpringBoot 项目中,利用 AOP 打印接口的入参和出参日志,以及执行时间,还是比较快捷的。

引入依赖:引入 AOP 依赖

        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>

自定义注解:自定义一个注解作为切点

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
@Documented
public @interface WebLog {
}

配置 AOP 切面:

@Aspect:标识切面

@Pointcut:设置切点,这里以自定义注解为切点,定义切点有很多其它种方式,自定义注解是比较常用的一种。

@Before:在切点之前织入,打印了一些入参信息

@Around:环绕切点,打印返回参数和接口执行时间

@Aspect
@Component
public class WebLogAspect {

    private final static Logger logger         = LoggerFactory.getLogger(WebLogAspect.class);

    /**
     * 以自定义 @WebLog 注解为切点
     **/
    @Pointcut("@annotation(cn.fighter3.spring.aop_demo.WebLog)")
    public void webLog() {}

    /**
     * 在切点之前织入
     */
    @Before("webLog()")
    public void doBefore(JoinPoint joinPoint) throws Throwable {
        // 开始打印请求日志
        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
        HttpServletRequest request = attributes.getRequest();
        // 打印请求相关参数
        logger.info("========================================== Start ==========================================");
        // 打印请求 url
        logger.info("URL            : {}", request.getRequestURL().toString());
        // 打印 Http method
        logger.info("HTTP Method    : {}", request.getMethod());
        // 打印调用 controller 的全路径以及执行方法
        logger.info("Class Method   : {}.{}", joinPoint.getSignature().getDeclaringTypeName(), joinPoint.getSignature().getName());
        // 打印请求的 IP
        logger.info("IP             : {}", request.getRemoteAddr());
        // 打印请求入参
        logger.info("Request Args   : {}",new ObjectMapper().writeValueAsString(joinPoint.getArgs()));
    }

    /**
     * 在切点之后织入
     * @throws Throwable
     */
    @After("webLog()")
    public void doAfter() throws Throwable {
        // 结束后打个分隔线,方便查看
        logger.info("=========================================== End ===========================================");
    }

    /**
     * 环绕
     */
    @Around("webLog()")
    public Object doAround(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
        //开始时间
        long startTime = System.currentTimeMillis();
        Object result = proceedingJoinPoint.proceed();
        // 打印出参
        logger.info("Response Args  : {}", new ObjectMapper().writeValueAsString(result));
        // 执行耗时
        logger.info("Time-Consuming : {} ms", System.currentTimeMillis() - startTime);
        return result;
    }

}

使用:只需要在接口上加上自定义注解

    @GetMapping("/hello")
    @WebLog(desc = "这是一个欢迎接口")
    public String hello(String name){
        return "Hello "+name;
    }

执行结果:可以看到日志打印了入参、出参和执行时间

说说 JDK 动态代理和 CGLIB 代理 ?

Spring 的 AOP 是通过动态代理来实现的,动态代理主要有两种方式 JDK 动态代理和 Cglib 动态代理,这两种动态代理的使用和原理有些不同。

JDK 动态代理

  • Interface:对于 JDK 动态代理,目标类需要实现一个 Interface。
  • InvocationHandler:InvocationHandler 是一个接口,可以通过实现这个接口,定义横切逻辑,再通过反射机制(invoke)调用目标类的代码,在次过程,可能包装逻辑,对目标方法进行前置后置处理。
  • Proxy:Proxy 利用 InvocationHandler 动态创建一个符合目标类实现的接口的实例,生成目标类的代理对象。

CgLib 动态代理

  • 使用 JDK 创建代理有一大限制,它只能为接口创建代理实例,而 CgLib 动态代理就没有这个限制。
  • CgLib 动态代理是使用字节码处理框架 ASM,其原理是通过字节码技术为一个类创建子类,并在子类中采用方法拦截的技术拦截所有父类方法的调用,顺势织入横切逻辑。
  • CgLib 创建的动态代理对象性能比 JDK 创建的动态代理对象的性能高不少,但是 CGLib 在创建代理对象时所花费的时间却比 JDK 多得多,所以对于单例的对象,因为无需频繁创建对象,用 CGLib 合适,反之,使用 JDK 方式要更为合适一些。同时,由于 CGLib 由于是采用动态创建子类的方法,对于 final 方法,无法进行代理。

我们来看一个常见的小场景,客服中转,解决用户问题:

接口

public interface ISolver {
    void solve();
}

JDK 动态代理实现:

目标类:需要实现对应接口

public class Solver implements ISolver {
    @Override
    public void solve() {
        System.out.println("疯狂掉头发解决问题……");
    }
}

动态代理工厂:ProxyFactory,直接用反射方式生成一个目标对象的代理对象,这里用了一个匿名内部类方式重写 InvocationHandler 方法,实现接口重写也差不多

public class ProxyFactory {

    // 维护一个目标对象
    private Object target;

    public ProxyFactory(Object target) {
        this.target = target;
    }

    // 为目标对象生成代理对象
    public Object getProxyInstance() {
        return Proxy.newProxyInstance(target.getClass().getClassLoader(), target.getClass().getInterfaces(),
                new InvocationHandler() {
                    @Override
                    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
                        System.out.println("请问有什么可以帮到您?");

                        // 调用目标对象方法
                        Object returnValue = method.invoke(target, args);

                        System.out.println("问题已经解决啦!");
                        return null;
                    }
                });
    }
}

客户端:Client,生成一个代理对象实例,通过代理对象调用目标对象方法

public class Client {
    public static void main(String[] args) {
        //目标对象:程序员
        ISolver developer = new Solver();
        //代理:客服小姐姐
        ISolver csProxy = (ISolver) new ProxyFactory(developer).getProxyInstance();
        //目标方法:解决问题
        csProxy.solve();
    }
}

Cglib 动态代理实现:

目标类:Solver,这里目标类不用再实现接口。

public class Solver {

    public void solve() {
        System.out.println("疯狂掉头发解决问题……");
    }
}

动态代理工厂:

public class ProxyFactory implements MethodInterceptor {

   //维护一个目标对象
    private Object target;

    public ProxyFactory(Object target) {
        this.target = target;
    }

    //为目标对象生成代理对象
    public Object getProxyInstance() {
        //工具类
        Enhancer en = new Enhancer();
        //设置父类
        en.setSuperclass(target.getClass());
        //设置回调函数
        en.setCallback(this);
        //创建子类对象代理
        return en.create();
    }

    @Override
    public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable {
        System.out.println("请问有什么可以帮到您?");
        // 执行目标对象的方法
        Object returnValue = method.invoke(target, args);
        System.out.println("问题已经解决啦!");
        return null;
    }

}

客户端:Client

public class Client {
    public static void main(String[] args) {
        //目标对象:程序员
        Solver developer = new Solver();
        //代理:客服小姐姐
        Solver csProxy = (Solver) new ProxyFactory(developer).getProxyInstance();
        //目标方法:解决问题
        csProxy.solve();
    }
}

Spring 事务的种类?

Spring 支持编程式事务管理声明式事务管理两种方式:

编程式事务
编程式事务管理使用 TransactionTemplate,需要显式执行事务。

声明式事务
声明式事务管理建立在 AOP 之上的。其本质是通过 AOP 功能,对方法前后进行拦截,将事务处理的功能编织到拦截的方法中,也就是在目标方法开始之前启动一个事务,在执行完目标方法之后根据执行情况提交或者回滚事务

优点是不需要在业务逻辑代码中掺杂事务管理的代码,只需在配置文件中做相关的事务规则声明或通过 @Transactional 注解的方式,便可以将事务规则应用到业务逻辑中,减少业务代码的污染。唯一不足地方是,最细粒度只能作用到方法级别,无法做到像编程式事务那样可以作用到代码块级别。

Spring 的事务隔离级别?

Spring 的接口 TransactionDefinition 中定义了表示隔离级别的常量,当然其实主要还是对应数据库的事务隔离级别:

  • ISOLATION_DEFAULT:使用后端数据库默认的隔离界别,MySQL 默认可重复读,Oracle 默认读已提交。
  • ISOLATION_READ_UNCOMMITTED:读未提交
  • ISOLATION_READ_COMMITTED:读已提交
  • ISOLATION_REPEATABLE_READ:可重复读
  • ISOLATION_SERIALIZABLE:串行化

Spring 的事务传播机制?

Spring 事务的传播机制说的是,当多个事务同时存在的时候——一般指的是多个事务方法相互调用时,Spring 如何处理这些事务的行为。

事务传播机制是使用简单的 ThreadLocal 实现的,所以,如果调用的方法是在新线程调用的,事务传播实际上是会失效的。

Spring 默认的事务传播行为是 PROPAFATION_REQUIRED,它适合绝大多数情况,如果多个 ServiceX#methodX()都工作在事务环境下(均被 Spring 事务增强),且程序中存在调用链 Service1#method1()->Service2#method2()->Service3#method3(),那么这 3 个服务类的三个方法通过 Spring 的事务传播机制都工作在同一个事务中。

说一下声明式事务实现原理是什么?

就是通过 AOP/动态代理。

在 Bean 初始化阶段创建代理对象:Spring 容器在初始化每个单例 bean 的时候,会遍历容器中的所有 BeanPostProcessor 实现类,并执行其 postProcessAfterInitialization 方法,在执行 AbstractAutoProxyCreator 类的 postProcessAfterInitialization 方法时会遍历容器中所有的切面,查找与当前实例化 bean 匹配的切面,这里会获取事务属性切面,查找@Transactional 注解及其属性值,然后根据得到的切面创建一个代理对象,默认是使用 JDK 动态代理创建代理,如果目标类是接口,则使用 JDK 动态代理,否则使用 Cglib。

在执行目标方法时进行事务增强操作:当通过代理对象调用 Bean 方法的时候,会触发对应的 AOP 增强拦截器,声明式事务是一种环绕增强,对应接口为MethodInterceptor,事务增强对该接口的实现为TransactionInterceptor,类图如下

事务拦截器TransactionInterceptor在invoke方法中,通过调用父类TransactionAspectSupport的invokeWithinTransaction方法进行事务处理,包括开启事务、事务提交、异常回滚。

声明式事务在哪些情况下会失效?

  1. @Transactional 应用在非 public 修饰的方法上

如果 Transactional 注解应用在非 public 修饰的方法上,Transactional 将会失效。

是因为在 Spring AOP 代理时,TransactionInterceptor (事务拦截器)在目标方法执行前后进行拦截,DynamicAdvisedInterceptor(CglibAopProxy 的内部类)的 intercept 方法 或 JdkDynamicAopProxy 的 invoke 方法会间接调用 AbstractFallbackTransactionAttributeSource 的 computeTransactionAttribute方法,获取 Transactional 注解的事务配置信息。

protected TransactionAttribute computeTransactionAttribute(Method method,
    Class<?> targetClass) {
        // Don't allow no-public methods as required.
        if (allowPublicMethodsOnly() && !Modifier.isPublic(method.getModifiers())) {
        return null;
}

此方法会检查目标方法的修饰符是否为 public,不是 public 则不会获取@Transactional 的属性配置信息。

  1. @Transactional 注解属性 propagation 设置错误

TransactionDefinition.PROPAGATION_SUPPORTS:如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务的方式继续运行。
TransactionDefinition.PROPAGATION_NOT_SUPPORTED:以非事务方式运行,如果当前存在事务,则把当前事务挂起。
TransactionDefinition.PROPAGATION_NEVER:以非事务方式运行,如果当前存在事务,则抛出异常。

  1. @Transactional 注解属性 rollbackFor 设置错误

rollbackFor 可以指定能够触发事务回滚的异常类型。Spring 默认抛出了未检查 unchecked 异常(继承自 RuntimeException 的异常)或者 Error 才回滚事务,其他异常不会触发回滚事务。

  1. 同一个类中方法调用,导致@Transactional 失效

开发中避免不了会对同一个类里面的方法调用,比如有一个类 Test,它的一个方法 A,A 再调用本类的方法 B(不论方法 B 是用 public 还是 private 修饰),但方法 A 没有声明注解事务,而 B 方法有。则外部调用方法 A 之后,方法 B 的事务是不会起作用的。这也是经常犯错误的一个地方。

那为啥会出现这种情况?其实这还是由于使用 Spring AOP 代理造成的,因为只有当事务方法被当前类以外的代码调用时,才会由 Spring 生成的代理对象来管理。


 //@Transactional
     @GetMapping("/test")
     private Integer A() throws Exception {
         CityInfoDict cityInfoDict = new CityInfoDict();
         cityInfoDict.setCityName("2");
         /**
          * B 插入字段为 3的数据
          */
         this.insertB();
        /**
         * A 插入字段为 2的数据
         */
        int insert = cityInfoDictMapper.insert(cityInfoDict);
        return insert;
    }

    @Transactional()
    public Integer insertB() throws Exception {
        CityInfoDict cityInfoDict = new CityInfoDict();
        cityInfoDict.setCityName("3");
        cityInfoDict.setParentCityId(3);

        return cityInfoDictMapper.insert(cityInfoDict);
    }

这种情况是最常见的一种@Transactional 注解失效场景


@Transactional
private Integer A() throws Exception {
    int insert = 0;
    try {
        CityInfoDict cityInfoDict = new CityInfoDict();
        cityInfoDict.setCityName("2");
        cityInfoDict.setParentCityId(2);
        /**
         * A 插入字段为 2的数据
         */
        insert = cityInfoDictMapper.insert(cityInfoDict);
        /**
         * B 插入字段为 3的数据
        */
        b.insertB();
    } catch (Exception e) {
        e.printStackTrace();
    }
}

如果 B 方法内部抛了异常,而 A 方法此时 try catch 了 B 方法的异常,那这个事务就不能正常回滚了,会抛出异常

Spring MVC 的工作流程?

  1. 客户端向服务端发送一次请求,这个请求会先到前端控制器 DispatcherServlet(也叫中央控制器)。
  2. DispatcherServlet 接收到请求后会调用 HandlerMapping 处理器映射器。由此得知,该请求该由哪个 Controller 来处理(并未调用 Controller,只是得知)
  3. DispatcherServlet 调用 HandlerAdapter 处理器适配器,告诉处理器适配器应该要去执行哪个 Controller
  4. HandlerAdapter 处理器适配器去执行 Controller 并得到 ModelAndView(数据和视图),并层层返回给 DispatcherServlet
  5. DispatcherServlet 将 ModelAndView 交给 ViewReslover 视图解析器解析,然后返回真正的视图。
  6. DispatcherServlet 将模型数据填充到视图中
  7. DispatcherServlet 将结果响应给客户端

SpringMVC Restful 风格的接口的流程是什么样的呢?

我们都知道 Restful 接口,响应格式是 json,这就用到了一个常用注解:@ResponseBody

    @GetMapping("/user")
    @ResponseBody
    public User user(){
        return new User(1,"张三");
    }

加入了这个注解后,整体的流程上和使用 ModelAndView 大体上相同,但是细节上有一些不同:

  1. 客户端向服务端发送一次请求,这个请求会先到前端控制器 DispatcherServlet

  2. DispatcherServlet 接收到请求后会调用 HandlerMapping 处理器映射器。由此得知,该请求该由哪个 Controller 来处理

  3. DispatcherServlet 调用 HandlerAdapter 处理器适配器,告诉处理器适配器应该要去执行哪个 Controller

  4. Controller 被封装成了 ServletInvocableHandlerMethod,HandlerAdapter 处理器适配器去执行 invokeAndHandle 方法,完成对 Controller 的请求处理

  5. HandlerAdapter 执行完对 Controller 的请求,会调用 HandlerMethodReturnValueHandler 去处理返回值,主要的过程:

  6. 调用 RequestResponseBodyMethodProcessor,创建 ServletServerHttpResponse(Spring 对原生 ServerHttpResponse 的封装)实例

  7. 使用 HttpMessageConverter 的 write 方法,将返回值写入 ServletServerHttpResponse 的 OutputStream 输出流中

  8. 在写入的过程中,会使用 JsonGenerator(默认使用 Jackson 框架)对返回值进行 Json 序列化

  9. 执行完请求后,返回的 ModealAndView 为 null,ServletServerHttpResponse 里也已经写入了响应,所以不用关心 View 的处理

SpringBoot 自动配置原理了解吗?

SpringBoot 开启自动配置的注解是@EnableAutoConfiguration ,启动类上的注解@SpringBootApplication是一个复合注解,包含了@EnableAutoConfiguration:

EnableAutoConfiguration 只是一个简单的注解,自动装配核心功能的实现实际是通过 AutoConfigurationImportSelector类

@AutoConfigurationPackage //将main同级的包下的所有组件注册到容器中
@Import({AutoConfigurationImportSelector.class}) //加载自动装配类 xxxAutoconfiguration
public @interface EnableAutoConfiguration {
    String ENABLED_OVERRIDE_PROPERTY = "spring.boot.enableautoconfiguration";

    Class<?>[] exclude() default {};

    String[] excludeName() default {};
}

AutoConfigurationImportSelector实现了ImportSelector接口,这个接口的作用就是收集需要导入的配置类,配合@Import()就可以将相应的类导入到 Spring 容器中

获取注入类的方法是 selectImports(),它实际调用的是getAutoConfigurationEntry,这个方法是获取自动装配类的关键,主要流程可以分为这么几步:

  1. 获取注解的属性,用于后面的排除
  2. 获取所有需要自动装配的配置类的路径:这一步是最关键的,从 META-INF/spring.factories 获取自动配置类的路径
  3. 去掉重复的配置类和需要排除的重复类,把需要自动加载的配置类的路径存储起来
 protected AutoConfigurationImportSelector.AutoConfigurationEntry getAutoConfigurationEntry(AnnotationMetadata annotationMetadata) {
        if (!this.isEnabled(annotationMetadata)) {
            return EMPTY_ENTRY;
        } else {
            //1.获取到注解的属性
            AnnotationAttributes attributes = this.getAttributes(annotationMetadata);
            //2.获取需要自动装配的所有配置类,读取META-INF/spring.factories,获取自动配置类路径
            List<String> configurations = this.getCandidateConfigurations(annotationMetadata, attributes);
            //3.1.移除重复的配置
            configurations = this.removeDuplicates(configurations);
            //3.2.处理需要排除的配置
            Set<String> exclusions = this.getExclusions(annotationMetadata, attributes);
            this.checkExcludedClasses(configurations, exclusions);
            configurations.removeAll(exclusions);
            configurations = this.getConfigurationClassFilter().filter(configurations);
            this.fireAutoConfigurationImportEvents(configurations, exclusions);
            return new AutoConfigurationImportSelector.AutoConfigurationEntry(configurations, exclusions);
        }
    }

Springboot 启动流程?

SpringApplication 这个类主要做了以下四件事情:

  1. 推断应用的类型是普通的项目还是 Web 项目
  2. 查找并加载所有可用初始化器 , 设置到 initializers 属性中
  3. 找出所有的应用程序监听器,设置到 listeners 属性中
  4. 推断并设置 main 方法的定义类,找到运行的主类

SpringBoot 启动大致流程如下 :

上图可见spring boot的整个启动流程及各组件的相互调用关系。

java程序由启动主类调用main()方法开始。
调用 SpringApplication的构造方法,实例一个Spirng应用对象。在构造方法里主要完成启动环境初始化工作,如,推断主类,spring应用类型,加载配置文件,读取spring.factories文件等。
调用run方法,所有的启动工作在该方法内完成,主要完成加载配置资源,准备上下文,创建上下文,刷新上下文,过程事件发布等。

1.启动入口 (SrpingApplication)

大家熟悉的springboot的启动类,@SpringBootApplication + psvm(main方法)+ new SpringApplication().run(XXXX.class, args)

@SpringBootApplication
public class SummaryApplication {
    public static void main(String[] args) {
        SpringApplication application = new SpringApplication(); // 2
        application.run(SummaryApplication.class, args); //3
//      SpringApplication.run(SummaryApplication.class, args);  也可简化调用静态方法
    }
}

1.1 @SpringBootApplication 注解

通过源码发现该注解只是@Configuration,@EnableAutoConfiguration,@ComponentScan 三个注解的组合,这是在springboot 1.5以后为这三个注解做的一个简写。接下来简单说下这三个注解的功能:

	...
@SpringBootConfiguration //1.1.1 注册为配置类
@EnableAutoConfiguration //1.1.2 配置可自动装配
@ComponentScan(excludeFilters = { //1.1.3 声明可扫描Bean 
  @Filter(type = FilterType.CUSTOM, classes = TypeExcludeFilter.class),
  @Filter(type = FilterType.CUSTOM, classes = AutoConfigurationExcludeFilter.class) })
public @interface SpringBootApplication {
  ...
}

1.1.1 @SpringBootConfiguration

该注解就是spirng ioc容器中java config 配置方式的@Configuration ,注册当前类为spring ioc容器的配置类。

搭配@bean注解创建一个简单spring ioc配置类

@Configuration    
 public class Conf {   
   @Bean  
   public Car car() {     
       Car car = new Car();   
       car.setWheel(wheel());     
       return car;    
   }  
   @Bean  
   public Wheel wheel() {     
        return new Wheel();   
   }  
 }

1.1.2 @EnableAutoConfiguration

	...
@AutoConfigurationPackage
@Import(AutoConfigurationImportSelector.class) //最为重要
public @interface EnableAutoConfiguration {

   String ENABLED_OVERRIDE_PROPERTY = "spring.boot.enableautoconfiguration";
   Class<?>[] exclude() default {};
   String[] excludeName() default {};

}

@EnableAutoConfiguration借助@Import的帮助,将所有符合自动配置条件的bean定义加载到IoC容器,会根据类路径中的jar依赖为项目进行自动配置,如:添加了spring-boot-starter-web依赖,会自动添加Tomcat和Spring MVC的依赖,Spring Boot会对Tomcat和Spring MVC进行自动配置。

最关键的要属@Import(EnableAutoConfigurationImportSelector.class) ,借助EnableAutoConfigurationImportSelector@EnableAutoConfiguration可以帮助SpringBoot应用将所有符合条件的@Configuration配置都加载到当前SpringBoot创建并使用的IoC容器。就像一只“八爪鱼”一样,借助于Spring框架原有的一个工具类:SpringFactoriesLoader的支持,@EnableAutoConfiguration可以智能的自动配置功效才得以大功告成!

1.1.3 @ComponentScan

@ComponentScan这个注解在Spring中很重要,它对应XML配置中的元素,@ComponentScan的功能其实就是自动扫描并加载符合条件的组件(比如@Component和@Repository等)或者bean定义,最终将这些bean定义加载到IoC容器中。
我们可以通过basePackages等属性来细粒度的定制@ComponentScan自动扫描的范围,如果不指定,则默认Spring框架实现会从声明@ComponentScan所在类的package进行扫描。
注:所以SpringBoot的启动类最好是放在root package下,因为默认不指定basePackages。

2.构造器(Constructor)


@SuppressWarnings({ "unchecked", "rawtypes" })
public SpringApplication(ResourceLoader resourceLoader, Class<?>... primarySources) {
   this.resourceLoader = resourceLoader;
   Assert.notNull(primarySources, "PrimarySources must not be null");
   this.primarySources = new LinkedHashSet<>(Arrays.asList(primarySources));
   //2.1 判断当前程序类型
   this.webApplicationType = WebApplicationType.deduceFromClasspath();
   //2.2 使用SpringFactoriesLoader 实例化所有可用的初始器
   setInitializers((Collection)                        getSpringFactoriesInstances(ApplicationContextInitializer.class));
   //2.3 使用SpringFactoriesLoader 实例化所有可用的监听器
  setListeners((Collection) getSpringFactoriesInstances(ApplicationListener.class));
   //2.4 配置应用主方法所在类
   this.mainApplicationClass = deduceMainApplicationClass();
}

2.1 判断当前程序类型
根据classpath里面是否存在某个特征类
(org.springframework.web.context.ConfigurableWebApplicationContext)来决定是否应该创建一个为Web应用使用的ApplicationContext类型。

3.启动方法(RUN)

初始化完成之后就进到了run方法,run方法完成了所有Spring的整个启动过程:

  • 准备Environment

  • 发布事件

  • 创建上下文、bean

  • 刷新上下文

  • 结束

public ConfigurableApplicationContext run(String... args) {
   //开启时钟计时
   StopWatch stopWatch = new StopWatch();
   stopWatch.start();
   //spirng 上下文
   ConfigurableApplicationContext context = null;
   //启动异常报告容器
   Collection<SpringBootExceptionReporter> exceptionReporters = new ArrayList<>();
   //开启设置,让系统模拟不存在io设备
   configureHeadlessProperty();
   // 3.1 初始化SpringApplicationRunListener 监听器,并进行封装
   SpringApplicationRunListeners listeners = getRunListeners(args);
   listeners.starting();
   try {
      ApplicationArguments applicationArguments = new DefaultApplicationArguments(args);
     //3.2 Environment 的准备 
      ConfigurableEnvironment environment = prepareEnvironment(listeners, applicationArguments);
      configureIgnoreBeanInfo(environment);
      Banner printedBanner = printBanner(environment); // 打印标语 彩蛋
     //3.3 创建上下文实例
      context = createApplicationContext();
     //异常播报器,默认有org.springframework.boot.diagnostics.FailureAnalyzers
      exceptionReporters = getSpringFactoriesInstances(SpringBootExceptionReporter.class,
            new Class[] { ConfigurableApplicationContext.class }, context);
     //3.4 容器初始化
      prepareContext(context, environment, listeners, applicationArguments, printedBanner);
     //3.5 刷新上下文容器 
      refreshContext(context);
     //给实现类留的钩子,这里是一个空方法。
      afterRefresh(context, applicationArguments);
      stopWatch.stop();
      if (this.logStartupInfo) {
         new StartupInfoLogger(this.mainApplicationClass).logStarted(getApplicationLog(), stopWatch);
      }
      listeners.started(context);
      callRunners(context, applicationArguments);
   }
   catch (Throwable ex) {
      handleRunFailure(context, ex, exceptionReporters, listeners);
      throw new IllegalStateException(ex);
   }

   try {
      listeners.running(context);
   }
   catch (Throwable ex) {
      handleRunFailure(context, ex, exceptionReporters, null);
      throw new IllegalStateException(ex);
   }
   return context;
}

3.1 SpringApplicationRunListener 的使用

首先通过getSpringFactoriesInstances 获取到所有实现SpringApplicationRunListener 接口的实例,默认情况下该接口的实现类只有 EventPublishingRunListener 他的主要作用是作为springboot 的一个广播器

public interface SpringApplicationRunListener {
  /**EventPublishingRunListener 前期采用 SimpleApplicationEventMulticaster.multicastEvent(ApplicationEvent) 进行广播
  **/
   default void starting() {} 
   default void environmentPrepared(ConfigurableEnvironment environment) {}
   default void contextPrepared(ConfigurableApplicationContext context) {}
   default void contextLoaded(ConfigurableApplicationContext context) {}
  /**
  EventPublishingRunListener 后期采用 context.publishEvent(ApplicationEvent)
  **/
   default void started(ConfigurableApplicationContext context) {}
   default void running(ConfigurableApplicationContext context) {}
   default void failed(ConfigurableApplicationContext context, Throwable exception) {}
}

3.2 prepareEnvironment

一般在写业务代码时使用的都是只读类型的接口Environment,该接口是对运行程序环境的抽象,是保存系统配置的中心,而在启动过程中使用的则是可编辑的ConfigurableEnvironment。接口的UML类图如下,提供了合并父环境、添加active profile以及一些设置解析配置文件方式的接口。
其中一个比较重要的方法MutablePropertySources getPropertySources();,该方法返回一个可编辑的PropertySources,如果有在启动阶段自定义环境的PropertySources的需求,就可以通过该方法设置。

private ConfigurableEnvironment prepareEnvironment(SpringApplicationRunListeners listeners,
      ApplicationArguments applicationArguments) {
   // Create and configure the environment
  //根据不同环境不同的Enviroment (StandardServletEnvironment,StandardReactiveWebEnvironment,StandardEnvironment)
   ConfigurableEnvironment environment = getOrCreateEnvironment();
  //填充启动类参数到enviroment 对象
   configureEnvironment(environment, applicationArguments.getSourceArgs());
  //更新参数
  ConfigurationPropertySources.attach(environment);
  //发布事件 
  listeners.environmentPrepared(environment);
  //绑定主类 
  bindToSpringApplication(environment);
   if (!this.isCustomEnvironment) {//转换environment的类型,但这里应该类型和deduce的相同不用转换
      environment = new EnvironmentConverter(getClassLoader()).convertEnvironmentIfNecessary(environment,
            deduceEnvironmentClass());
   }
  //将现有参数有封装成proertySources
   ConfigurationPropertySources.attach(environment);
   return environment;
}

3.3 创建springApplicationContext 上下文

继承的三个父类接口里,Closeable提供了关闭时资源释放的接口,Lifecycle是提供对生命周期控制的接口(start\stop)以及查询当前运行状态的接口,ApplicationContext则是配置上下文的中心配置接口,继承了其他很多配置接口,其本身提供查询诸如id、应用程序名等上下文档案信息的只读接口,以及构建自动装配bean的工厂。

EnvironmentCapable

提供Environment接口。

MessageSource

国际化资源接口。

ApplicationEventPublisher

事件发布器。

ResourcePatternResolver

资源加载器。

HierarchicalBeanFactory、ListableBeanFactory

这两个都继承了bean容器的根接口BeanFactory
简而言之就是根据Web容器类型的不同来创建不用的上下文实例。

3.4 上下文初始化

private void prepareContext(ConfigurableApplicationContext context, ConfigurableEnvironment environment,
      SpringApplicationRunListeners listeners, ApplicationArguments applicationArguments, Banner printedBanner) {
  //绑定环境
   context.setEnvironment(environment);
  //如果application有设置beanNameGenerator、resourceLoader就将其注入到上下文中,并将转换工具也注入到上下文中
  postProcessApplicationContext(context);
  //调用初始化的切面
   applyInitializers(context);
  //发布ApplicationContextInitializedEvent事件
   listeners.contextPrepared(context);
  //日志
   if (this.logStartupInfo) {
      logStartupInfo(context.getParent() == null);
      logStartupProfileInfo(context);
   }
   // Add boot specific singleton beans
   ConfigurableListableBeanFactory beanFactory = context.getBeanFactory();
   beanFactory.registerSingleton("springApplicationArguments", applicationArguments);
   if (printedBanner != null) {
      beanFactory.registerSingleton("springBootBanner", printedBanner);
   }
   if (beanFactory instanceof DefaultListableBeanFactory) {
     //如果bean名相同的话是否允许覆盖,默认为false,相同会抛出异常
      ((DefaultListableBeanFactory) beanFactory)
            .setAllowBeanDefinitionOverriding(this.allowBeanDefinitionOverriding);
   }
   if (this.lazyInitialization) {
      context.addBeanFactoryPostProcessor(new LazyInitializationBeanFactoryPostProcessor());
   }
   // Load the sources
  // 这里获取到的是BootstrapImportSelectorConfiguration这个class,而不是自己写的启动来,这个class是在之前注册的BootstrapApplicationListener的监听方法中注入的
   Set<Object> sources = getAllSources();
   Assert.notEmpty(sources, "Sources must not be empty");
  //加载sources 到上下文中
   load(context, sources.toArray(new Object[0]));
  //发布ApplicationPreparedEvent事件
   listeners.contextLoaded(context);
}

3.5 刷新上下文
AbstractApplicationContext

public void refresh() throws BeansException, IllegalStateException {
    synchronized (this.startupShutdownMonitor) {
        //记录启动时间、状态,web容器初始化其property,复制listener
        prepareRefresh();
        //这里返回的是context的BeanFactory
        ConfigurableListableBeanFactory beanFactory = obtainFreshBeanFactory();
        //beanFactory注入一些标准组件,例如ApplicationContextAwareProcessor,ClassLoader等
        prepareBeanFactory(beanFactory);
        try {
            //给实现类留的一个钩子,例如注入BeanPostProcessors,这里是个空方法
            postProcessBeanFactory(beanFactory);

            // 调用切面方法
            invokeBeanFactoryPostProcessors(beanFactory);

            // 注册切面bean
            registerBeanPostProcessors(beanFactory);

            // Initialize message source for this context.
            initMessageSource();

            // bean工厂注册一个key为applicationEventMulticaster的广播器
            initApplicationEventMulticaster();

            // 给实现类留的一钩子,可以执行其他refresh的工作,这里是个空方法
            onRefresh();

            // 将listener注册到广播器中
            registerListeners();

            // 实例化未实例化的bean
            finishBeanFactoryInitialization(beanFactory);

            // 清理缓存,注入DefaultLifecycleProcessor,发布ContextRefreshedEvent
            finishRefresh();
        }

        catch (BeansException ex) {
            if (logger.isWarnEnabled()) {
                logger.warn("Exception encountered during context initialization - " +
                        "cancelling refresh attempt: " + ex);
            }

            // Destroy already created singletons to avoid dangling resources.
            destroyBeans();

            // Reset 'active' flag.
            cancelRefresh(ex);

            // Propagate exception to caller.
            throw ex;
        }

        finally {
            // Reset common introspection caches in Spring's core, since we
            // might not ever need metadata for singleton beans anymore...
            resetCommonCaches();
        }
    }
}

至此spring 启动主要工作基本完成,接下来发布AppStartedEvent事件,回调ApplicationRunner,CommandLineRunner等runner,发布applicationReadyEvent事件,spring 正式启动开始运行。

MyBatis

请说说MyBatis的工作原理?

MyBatis 是一款优秀的持久层框架,它的工作原理主要包括以下几个步骤:

  1. 加载配置文件:MyBatis 启动时会加载 mybatis-config.xml 配置文件,这个配置文件包含了一些全局配置,例如数据源、事务管理器、缓存配置等。
  2. 解析配置文件:MyBatis 使用 XPathParser 解析配置文件,将配置文件解析成一个 Configuration 对象,其中包含了所有的配置信息。
  3. 创建 SqlSessionFactory 对象:MyBatis 使用 SqlSessionFactoryBuilder 根据 Configuration 对象创建 SqlSessionFactory 对象,这个对象是线程安全的,可以被多个线程共享。
  4. 创建 SqlSession 对象:每次操作数据库时,MyBatis 都会创建一个 SqlSession 对象,这个对象类似于 JDBC 中的 Connection 对象,它封装了一个数据库连接。
  5. 创建 Executor 对象:SqlSession 使用 ExecutorType 枚举类型创建一个 Executor 对象,它负责执行 SQL 语句,包括查询、插入、更新、删除等操作。
  6. 解析 Mapper 文件:MyBatis 使用 XMLMapperBuilder 解析 Mapper 文件,将 Mapper 文件解析成一个 MappedStatement 对象,其中包含了 SQL 语句、参数类型、返回类型等信息。
  7. 执行 SQL 语句:Executor 对象根据 MappedStatement 对象中的 SQL 语句、参数类型等信息执行 SQL 语句,执行结果通过 ResultSetHandler 处理并返回给调用方。
  8. 提交事务:如果执行的是更新操作,Executor 会根据配置决定是否自动提交事务,如果需要手动提交事务,则需要调用 SqlSession 的 commit() 方法手动提交事务。
  9. 关闭 SqlSession 对象:每次操作完成后,需要手动关闭 SqlSession 对象,可以通过 SqlSession 的 close() 方法关闭它,或者使用 try-with-resources 语句自动关闭它。

总的来说,MyBatis 的工作原理主要就是将配置文件解析成一个 Configuration 对象,创建一个 SqlSessionFactory 对象,通过它创建 SqlSession 对象,并使用 Executor 执行 SQL 语句,最后关闭 SqlSession 对象。在这个过程中,MyBatis 使用了很多设计模式,例如工厂模式、建造者模式、代理模式等。

Mybatis都有哪些Executor执行器?它们之间的区别是什么?

Mybatis 有三种基本的Executor 执行器:SimpleExecutor、ReuseExecutor 和 BatchExecutor。

SimpleExecutor 是最简单的 Executor 执行器,每执行一次 SQL 语句就会创建一个 Statement 对象和一个 ResultSet 对象,不支持二级缓存和批量处理。它的执行效率比较低,适用于执行一些简单的 SQL 语句,例如查询单个对象或者插入单条数据等。

ReuseExecutor 在 SimpleExecutor 的基础上增加了 Statement 和 ResultSet 的复用功能,即在同一个 SqlSession 中,如果执行多个相同的 SQL 语句,只会创建一个 Statement 和 ResultSet 对象,可以提高执行效率。但它仍然不支持二级缓存和批量处理,适用于执行多次相同的 SQL 语句。

BatchExecutor 是用于批量处理的 Executor 执行器,可以一次性执行多条 SQL 语句,使用 JDBC 的 addBatch() 和 executeBatch() 方法实现批量处理。它的缺点是不能使用二级缓存,且不能在事务中进行部分提交和回滚。但在一些需要批量插入或更新的场景下,可以大大提高执行效率。

CachingExecutor:CachingExecutor是一个Executor接口的装饰器,它为Executor对象增加了二级缓存的相关功能,委托的执行器对象可以是SimpleExecutor、ReuseExecutor、BatchExecutor中任一一个。执行 update 方法前判断是否清空二级缓存;执行 query 方法前先在二级缓存中查询,命中失败再通过被代理类查询

在Mybatis配置文件中,在设置(settings)可以指定默认的ExecutorType执行器类型,也可以手动DefaultSqlSessionFactory的创建SqlSession的方法传递ExecutorType类型参数,如SqlSession openSession(ExecutorType execType)

总的来说,这三种 Executor 执行器在功能和效率上有所差异,需要根据具体的业务场景选择合适的执行器。如果只是执行简单的 SQL 语句,可以选择 SimpleExecutor;如果有多次执行相同 SQL 语句的情况,可以选择 ReuseExecutor;如果需要批量处理,可以选择 BatchExecutor。

MyBatis 中的 resultMap 是什么?

resultMap 是 MyBatis 中用于定义如何将数据库查询结果映射到 Java 对象的标记。resultMap 中可以定义每个字段的映射关系,如将数据库表的字段名和 Java 对象的属性名进行映射,从而方便开发人员进行数据操作。

MyBatis 中的 SqlSession 是什么?它有什么作用?

MyBatis 中的 SqlSession 是表示与数据库进行一次会话的对象,它可以用来执行 SQL 语句、管理事务、获取映射器(Mapper)等。SqlSession 接口是 MyBatis 的核心接口之一,它是所有数据库操作的入口,可以通过它执行 SQL 语句,并获得执行结果。

SqlSession 接口的主要作用有:

  • 执行 SQL 语句:可以通过 SqlSession 接口执行 SQL 语句,包括增、删、改、查等操作。
  • 管理事务:可以通过 SqlSession 接口管理事务,包括事务的开启、提交、回滚等操作。
  • 获取映射器(Mapper):可以通过 SqlSession 接口获取映射器,从而调用 Mapper 中定义的方法,执行数据库操作。

MyBatis 中的一级缓存和二级缓存有什么区别?

MyBatis 的一级缓存是指在同一个 SqlSession 中执行相同 SQL 语句时,第一次查询结果会被缓存到内存中,后续的查询会直接从缓存中获取结果,从而提高查询性能。一级缓存默认是开启的,也可以通过 sqlSession.clearCache() 方法清空缓存。

MyBatis 的二级缓存是指多个 SqlSession 共享一个缓存区域,当多个 SqlSession 执行相同的 SQL 语句时,查询结果会被缓存到缓存区域中,后续的查询可以直接从缓存中获取结果。二级缓存可以提高查询性能,但也可能会带来数据不一致的问题,因此需要根据实际情况进行选择。默认情况下,二级缓存是关闭的,需要手动配置开启,并且需要在映射文件中设置 <cache> 标签。

MyBatis 中如何进行分页查询?

MyBatis 中可以使用 RowBounds 类进行分页查询。RowBounds 类包含了两个参数,一个是起始行数,一个是返回行数,它是针对ResultSet结果集执行的内存分页,而非物理分页。开发人员可以在 SQL 语句中使用 LIMIT 子句来进行分页查询。或者是编写插件来进行分页如PageHelper

MyBatis 中如何处理懒加载?

MyBatis 中可以使用懒加载来延迟加载关联对象的数据。在查询关联对象时,可以将关联对象的加载方式设置为懒加载,当需要使用关联对象时再进行加载。可以通过在配置文件中进行设置,或者在注解中。

MyBatis 中的 SQL 语句是如何进行解析的?

MyBatis 中的 SQL 语句首先会进行解析,解析后生成对应的 Statement 对象。在解析 SQL 语句时,MyBatis 会将 SQL 语句分为不同的片段,每个片段都是一个单独的节点。然后 MyBatis 会将这些节点组装成一个完整的 SQL 语句,并进行参数绑定和 SQL 拼接操作。

MyBatis 中的拦截器是如何实现的?

MyBatis 中的拦截器可以在 SQL 语句执行前后进行拦截和修改。拦截器的实现原理是使用了 JDK 动态代理技术,将拦截器和被代理对象绑定在一起,然后在代理对象执行 SQL 语句时,会自动触发拦截器的逻辑。

MyBatis 中如何进行动态 SQL 处理?

MyBatis 中的动态 SQL 可以根据条件动态生成 SQL 语句,可以使用 XML 配置文件或注解来实现。在 XML 配置文件中,可以使用 if、choose、when、otherwise 等标签来根据条件动态生成 SQL 语句;在注解中,可以使用 @if、@where、@set、@foreach 等注解来实现动态 SQL 处理。

MyBatis 中的 TypeHandler 是什么?

MyBatis 中的 TypeHandler 是用于处理 Java 对象和数据库类型之间的转换的类。在进行数据库操作时,MyBatis 会自动将 Java 对象转换为对应的数据库类型,或将数据库类型转换为 Java 对象。如果默认的 TypeHandler 不能满足需求,开发人员可以自定义 TypeHandler 实现特定类型的转换。

MyBatis 中的 Plugin 是什么?

MyBatis 中的 Plugin 是用于对 MyBatis 功能进行增强的拦截器。
开发人员可以在插件中对 MyBatis 的 SQL 语句进行拦截和修改,或者在执行 SQL 语句前后进行特定的操作。
MyBatis 的插件是通过实现 Interceptor 接口来实现的,插件可以拦截 Executor、StatementHandler、ParameterHandler 和 ResultSetHandler 四个对象的方法调用。

插件需要实现 Interceptor 接口中的三个方法:

  • intercept:拦截目标对象的方法调用。
  • plugin:将拦截器包装成目标对象的代理。
  • setProperties:设置插件属性。

例如:

@Intercepts({
  @Signature(type= Executor.class, method = "update", args = {MappedStatement.class, Object.class})
})
public class MyPlugin implements Interceptor {
  @Override
  public Object intercept(Invocation invocation) throws Throwable {
    // 拦截目标对象的方法调用
    Object result = invocation.proceed();
    // 对拦截的方法进行增强
    return result;
  }

  @Override
  public Object plugin(Object target) {
    // 将拦截器包装成目标对象的代理
    return Plugin.wrap(target, this);
  }

  @Override
  public void setProperties(Properties properties) {
    // 设置

MyBatis 的结果集映射原理是什么?

MyBatis 通过 Java 反射机制实现结果集映射,将数据库查询的结果集映射为 Java 对象。MyBatis 将结果集的列名和 Java 对象的属性名进行映射,当属性名与列名不一致时,可以通过 @Results 和 @Result 注解进行自定义映射。

例如:

@Results({
  @Result(column = "id", property = "userId"),
  @Result(column = "name", property = "userName")
})
public class User {
  private Integer userId;
  private String userName;
}

在这个例子中,数据库表中的 id 列会映射到 Java 对象的 userId 属性上,数据库表中的 name 列会映射到 Java 对象的 userName 属性上。

#{} 和${}的区别是什么?

{}是预编译处理,${}是字符串替换。

Mybatis在处理#{}时,会将sql中的#{}替换为?号,调用PreparedStatement的set方法来赋值;
Mybatis在处理{}替换成变量的值。
使用#{}可以有效的防止SQL注入,提高系统安全性

Mybatis如何执行批量操作

MyBatis 中可以通过 SqlSession 的 insert、update、delete 方法执行批量操作。其中,有两种方式可以执行批量操作:

使用 SqlSession 的 batch 方法,将多个 SQL 语句组合成一个批量操作,将多个操作的参数一次性传递给数据库执行。

示例代码如下:


try (SqlSession sqlSession = sqlSessionFactory.openSession(ExecutorType.BATCH)) {
  MyMapper myMapper = sqlSession.getMapper(MyMapper.class);
  for (MyEntity entity : entityList) {
    myMapper.insertEntity(entity);
  }
  sqlSession.commit();
}

在这个例子中,我们通过 batch 方法创建了一个支持批量操作的 SqlSession 对象,并且在循环中多次调用了 insertEntity 方法。当循环结束后,我们通过 commit 方法提交事务,这样 MyBatis 就会将这些 SQL 语句组合成一个批量操作,一次性传递给数据库执行。

使用 MyBatis 提供的 foreach 标签,将多个操作的参数拼接成一个 SQL 语句执行。这种方式可以在单个 SQL 语句中执行多个操作,比较适合操作数据较少的情况。

示例代码如下:


<insert id="batchInsert" parameterType="java.util.List">
  insert into my_table (column1, column2, column3)
  values
  <foreach collection="list" item="item" separator=",">
    (#{item.column1}, #{item.column2}, #{item.column3})
  </foreach>
</insert>

在这个例子中,我们定义了一个 batchInsert 方法,将一个 List 对象作为参数传入。使用 foreach 标签遍历 List 对象,将多个实体的属性拼接成一个 SQL 语句,一次性传递给数据库执行。这种方式可以在单个 SQL 语句中执行多个操作,相比使用 batch 方法更加高效。

Redis

Redis可以用来干什么?

  1. 缓存

  2. 计数器 Redis天然支持计数功能,而且计数性能非常好,可以用来记录浏览量、点赞量等等。

  3. 排行榜 Redis提供了列表和有序集合数据结构,合理地使用这些数据结构可以很方便地构建各种排行榜系统。

  4. 社交网络 赞/踩、粉丝、共同好友/喜好、推送、下拉刷新。

  5. 消息队列 Redis提供了发布订阅功能和阻塞队列的功能,可以满足一般消息队列功能。

  6. 分布式锁 分布式环境下,利用Redis实现分布式锁,也是Redis常见的应用。

Redis的应用一般会结合项目去问,以一个电商项目的用户服务为例:

  • Token存储:用户登录成功之后,使用Redis存储Token
  • 登录失败次数计数:使用Redis计数,登录失败超过一定次数,锁定账号
  • 地址缓存:对省市区数据的缓存
  • 分布式锁:分布式环境下登录、注册等操作加分布式锁

Redis 有哪些数据结构?

Redis有五种基本数据结构。

  1. string

字符串最基础的数据结构。字符串类型的值实际可以是字符串(简单的字符串、复杂的字符串(例如JSON、XML))、数字 (整数、浮点数),甚至是二进制(图片、音频、视频),但是值最大不能超过512MB。

字符串主要有以下几个典型使用场景:

  • 缓存功能
  • 计数
  • 共享Session
  • 限速
  • hash
  1. 哈希类型是指键值本身又是一个键值对结构。

哈希主要有以下典型应用场景:

  • 缓存用户信息
  • 缓存对象
  • list
  1. 列表(list)类型是用来存储多个有序的字符串。列表是一种比较灵活的数据结构,它可以充当栈和队列的角色

列表主要有以下几种使用场景:

  • 消息队列
  • 文章列表
  • set
  1. 集合(set)类型也是用来保存多个的字符串元素,但和列表类型不一 样的是,集合中不允许有重复元素,并且集合中的元素是无序的。

集合主要有如下使用场景:

  • 标签(tag)
  • 共同关注
  • sorted set
  1. 有序集合中的元素可以排序。但是它和列表使用索引下标作为排序依据不同的是,它给每个元素设置一个权重(score)作为排序的依据。

有序集合主要应用场景:

  • 用户点赞统计
  • 用户排序

Redis为什么快呢?

Redis的速度⾮常的快,单机的Redis就可以⽀撑每秒十几万的并发,相对于MySQL来说,性能是MySQL的⼏⼗倍。速度快的原因主要有⼏点:

  1. 完全基于内存操作
  2. 使⽤单线程,避免了线程切换和竞态产生的消耗
  3. 基于⾮阻塞的IO多路复⽤机制
  4. C语⾔实现,优化过的数据结构,基于⼏种基础的数据结构,redis做了⼤量的优化,性能极⾼

能说一下Redis的I/O多路复用吗?

  1. 一个 socket 客户端与服务端连接时,会生成对应一个套接字描述符(套接字描述符是文件描述符的一种),每一个 socket 网络连接其实都对应一个文件描述符。

  2. 多个客户端与服务端连接时,Redis 使用 「I/O 多路复用程序」 将客户端 socket 对应的 FD 注册到监听列表(一个队列)中。当客服端执行 read、write 等操作命令时,I/O 多路复用程序会将命令封装成一个事件,并绑定到对应的 FD 上。

  3. 「文件事件处理器」使用 I/O 多路复用模块同时监控多个文件描述符(fd)的读写情况,当 accept、read、write 和 close 文件事件产生时,文件事件处理器就会回调 FD 绑定的事件处理器进行处理相关命令操作。

  4. 整个文件事件处理器是在单线程上运行的,但是通过 I/O 多路复用模块的引入,实现了同时对多个 FD 读写的监控,当其中一个 client 端达到写或读的状态,文件事件处理器就马上执行,从而就不会出现 I/O 堵塞的问题,提高了网络通信的性能。

  5. Redis 的 I/O 多路复用模式使用的是 「Reactor 设置模式」的方式来实现。

Redis6.0使用多线程是怎么回事?

Redis6.0的多线程是用多线程来处理数据的读写和协议解析,但是Redis执行命令还是单线程的。

这样做的⽬的是因为Redis的性能瓶颈在于⽹络IO⽽⾮CPU,使⽤多线程能提升IO读写的效率,从⽽整体提⾼Redis的性能。

Redis持久化⽅式有哪些?有什么区别?

Redis持久化⽅案分为RDB和AOF两种

RDB

RDB持久化是把当前进程数据生成快照保存到硬盘的过程,触发RDB持久化过程分为手动触发和自动触发。

RDB⽂件是⼀个压缩的⼆进制⽂件,通过它可以还原某个时刻数据库的状态。由于RDB⽂件是保存在硬盘上的,所以即使Redis崩溃或者退出,只要RDB⽂件存在,就可以⽤它来恢复还原数据库的状态。

手动触发分别对应savebgsave命令:

  • save命令:阻塞当前Redis服务器,直到RDB过程完成为止,对于内存比较大的实例会造成长时间阻塞,线上环境不建议使用。

  • bgsave命令:Redis进程执行fork操作创建子进程,RDB持久化过程由子进程负责,完成后自动结束。阻塞只发生在fork阶段,一般时间很短。

以下场景会自动触发RDB持久化:

  1. 使用save相关配置,如“save m n”。表示m秒内数据集存在n次修改时,自动触发bgsave。

  2. 如果从节点执行全量复制操作,主节点自动执行bgsave生成RDB文件并发送给从节点

  3. 执行debug reload命令重新加载Redis时,也会自动触发save操作

  4. 默认情况下执行shutdown命令时,如果没有开启AOF持久化功能则自动执行bgsave。

AOF

AOF(append only file)持久化:以独立日志的方式记录每次写命令, 重启时再重新执行AOF文件中的命令达到恢复数据的目的。AOF的主要作用是解决了数据持久化的实时性,目前已经是Redis持久化的主流方式。

AOF的工作流程操作:命令写入 (append)、文件同步(sync)、文件重写(rewrite)、重启加载 (load)

流程如下:

  1. 所有的写入命令会追加到aof_buf(缓冲区)中。

  2. AOF缓冲区根据对应的策略向硬盘做同步操作。

  3. 随着AOF文件越来越大,需要定期对AOF文件进行重写,达到压缩 的目的。

  4. 当Redis服务器重启时,可以加载AOF文件进行数据恢复。

RDB 和 AOF 各自有什么优缺点?如何选择?

RDB | 优点

  1. 只有一个紧凑的二进制文件 dump.rdb,非常适合备份、全量复制的场景。
  2. 容灾性好,可以把RDB文件拷贝道远程机器或者文件系统张,用于容灾恢复。
  3. 恢复速度快,RDB恢复数据的速度远远快于AOF的方式

RDB | 缺点

  1. 实时性低,RDB 是间隔一段时间进行持久化,没法做到实时持久化/秒级持久化。如果在这一间隔事件发生故障,数据会丢失。

  2. 存在兼容问题,Redis演进过程存在多个格式的RDB版本,存在老版本Redis无法兼容新版本RDB的问题。

AOF | 优点

  1. 实时性好,aof 持久化可以配置 appendfsync 属性,有 always,每进行一次命令操作就记录到 aof 文件中一次。

  2. 通过 append 模式写文件,即使中途服务器宕机,可以通过 redis-check-aof 工具解决数据一致性问题。

AOF | 缺点

  1. AOF 文件比 RDB 文件大,且 恢复速度慢。

  2. 数据集大 的时候,比 RDB 启动效率低。

  • 一般来说, 如果想达到足以媲美数据库的 数据安全性,应该 同时使用两种持久化功能。在这种情况下,当 Redis 重启的时候会优先载入 AOF 文件来恢复原始的数据,因为在通常情况下 AOF 文件保存的数据集要比 RDB 文件保存的数据集要完整。
  • 如果 可以接受数分钟以内的数据丢失,那么可以 只使用 RDB 持久化。
  • 有很多用户都只使用 AOF 持久化,但并不推荐这种方式,因为定时生成 RDB 快照(snapshot)非常便于进行数据备份, 并且 RDB 恢复数据集的速度也要比 AOF 恢复的速度要快,除此之外,使用 RDB 还可以避免 AOF 程序的 bug。
  • 如果只需要数据在服务器运行的时候存在,也可以不使用任何持久化方式。

Redis集群的原理?

Redis集群通过数据分区来实现数据的分布式存储,通过自动故障转移实现高可用。

集群创建

数据分区是在集群创建的时候完成的。

设置节点 Redis集群一般由多个节点组成,节点数量至少为6个才能保证组成完整高可用的集群。每个节点需要开启配置cluster-enabled yes,让Redis运行在集群模式下。

节点握手 节点握手是指一批运行在集群模式下的节点通过Gossip协议彼此通信, 达到感知对方的过程。节点握手是集群彼此通信的第一步,由客户端发起命 令:cluster meet{ip}{port}。完成节点握手之后,一个个的Redis节点就组成了一个多节点的集群。

分配槽(slot) Redis集群把所有的数据映射到16384个槽中。每个节点对应若干个槽,只有当节点分配了槽,才能响应和这些槽关联的键命令。通过 cluster addslots命令为节点分配槽。

故障转移
Redis集群的故障转移和哨兵的故障转移类似,但是Redis集群中所有的节点都要承担状态维护的任务。

故障发现 Redis集群内节点通过ping/pong消息实现节点通信,集群中每个节点都会定期向其他节点发送ping消息,接收节点回复pong 消息作为响应。如果在cluster-node-timeout时间内通信一直失败,则发送节 点会认为接收节点存在故障,把接收节点标记为主观下线(pfail)状态。

当某个节点判断另一个节点主观下线后,相应的节点状态会跟随消息在集群内传播。通过Gossip消息传播,集群内节点不断收集到故障节点的下线报告。当 半数以上持有槽的主节点都标记某个节点是主观下线时。触发客观下线流程。

故障恢复

故障节点变为客观下线后,如果下线节点是持有槽的主节点则需要在它 的从节点中选出一个替换它,从而保证集群的高可用。

  1. 资格检查 每个从节点都要检查最后与主节点断线时间,判断是否有资格替换故障 的主节点。

  2. 准备选举时间 当从节点符合故障转移资格后,更新触发故障选举的时间,只有到达该 时间后才能执行后续流程。

  3. 发起选举 当从节点定时任务检测到达故障选举时间(failover_auth_time)到达后,发起选举流程。

  4. 选举投票 持有槽的主节点处理故障选举消息。投票过程其实是一个领导者选举的过程,如集群内有N个持有槽的主节 点代表有N张选票。由于在每个配置纪元内持有槽的主节点只能投票给一个 从节点,因此只能有一个从节点获得N/2+1的选票,保证能够找出唯一的从节点。

  5. 替换主节点 当从节点收集到足够的选票之后,触发替换主节点操作。

说说集群的伸缩?

Redis集群提供了灵活的节点扩容和收缩方案,可以在不影响集群对外服务的情况下,为集群添加节点进行扩容也可以下线部分节点进行缩容。

其实,集群扩容和缩容的关键点,就在于槽和节点的对应关系,扩容和缩容就是将一部分槽和数据迁移给新节点。

例如下面一个集群,每个节点对应若干个槽,每个槽对应一定的数据,如果希望加入1个节点希望实现集群扩容时,需要通过相关命令把一部分槽和内容迁移给新节点。

缩容也是类似,先把槽和数据迁移到其它节点,再把对应的节点下线。

什么是缓存击穿、缓存穿透、缓存雪崩?

缓存击穿

一个并发访问量比较大的key在某个时间过期,导致所有的请求直接打在DB上。

  1. 加锁更新,⽐如请求查询A,发现缓存中没有,对A这个key加锁,同时去数据库查询数据,写⼊缓存,再返回给⽤户,这样后⾯的请求就可以从缓存中拿到数据了。

  1. 将过期时间组合写在value中,通过异步的⽅式不断的刷新过期时间,防⽌此类现象

缓存穿透

缓存穿透指的查询缓存和数据库中都不存在的数据,这样每次请求直接打到数据库,就好像缓存不存在一样。

缓存穿透将导致不存在的数据每次请求都要到存储层去查询,失去了缓存保护后端存储的意义。

缓存穿透可能会使后端存储负载加大,如果发现大量存储层空命中,可能就是出现了缓存穿透问题。

缓存穿透可能有两种原因:

  1. 自身业务代码问题
  2. 恶意攻击,爬虫造成空命中

它主要有两种解决办法:

  1. 缓存空值/默认值
    一种方式是在数据库不命中之后,把一个空对象或者默认值保存到缓存,之后再访问这个数据,就会从缓存中获取,这样就保护了数据库。

缓存空值有两大问题:

1. 空值做了缓存,意味着缓存层中存了更多的键,需要更多的内存空间(如果是攻击,问题更严重),比较有效的方法是针对这类数据设置一个较短的过期时间,让其自动剔除。

2. 缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响。 例如过期时间设置为5分钟,如果此时存储层添加了这个数据,那此段时间就会出现缓存层和存储层数据的不一致。 这时候可以利用消息队列或者其它异步方式清理缓存中的空对象。
  1. 布隆过滤器 除了缓存空对象,我们还可以在存储和缓存之前,加一个布隆过滤器,做一层过滤。

布隆过滤器里会保存数据是否存在,如果判断数据不不能再,就不会访问存储。

两种解决方案的对比:

缓存雪崩

某⼀时刻发⽣⼤规模的缓存失效的情况,例如缓存服务宕机、大量key在同一时间过期,这样的后果就是⼤量的请求进来直接打到DB上,可能导致整个系统的崩溃,称为雪崩。

缓存雪崩是三大缓存问题里最严重的一种,我们来看看怎么预防和处理。

  • 提高缓存可用性

    1. 集群部署:通过集群来提升缓存的可用性,可以利用Redis本身的Redis Cluster或者第三方集群方案如Codis等。
    2. 多级缓存:设置多级缓存,第一级缓存失效的基础上,访问二级缓存,每一级缓存的失效时间都不同。
  • 过期时间

    1. 均匀过期:为了避免大量的缓存在同一时间过期,可以把不同的 key 过期时间随机生成,避免过期时间太过集中。
    2. 热点数据永不过期。
  • 熔断降级

    1. 服务熔断:当缓存服务器宕机或超时响应时,为了防止整个系统出现雪崩,暂时停止业务服务访问缓存系统。
    2. 服务降级:当出现大量缓存失效,而且处在高并发高负荷的情况下,在业务系统内部暂时舍弃对一些非核心的接口和数据的请求,而直接返回一个提前准备好的 fallback(退路)错误处理信息。

能说说布隆过滤器吗?

布隆过滤器,它是一个连续的数据结构,每个存储位存储都是一个bit,即0或者1, 来标识数据是否存在。

存储数据的时时候,使用K个不同的哈希函数将这个变量映射为bit列表的的K个点,把它们置为1。

我们判断缓存key是否存在,同样,K个哈希函数,映射到bit列表上的K个点,判断是不是1:

  • 如果全不是1,那么key不存在;
  • 如果都是1,也只是表示key可能存在。

布隆过滤器也有一些缺点:

  • 它在判断元素是否在集合中时是有一定错误几率,因为哈希算法有一定的碰撞的概率。
  • 不支持删除元素。

使用 Guava 实现布隆过滤器

import com.google.common.hash.BloomFilter;
import com.google.common.hash.Funnels;

// 初始化 BloomFilter
BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.stringFunnel(), 1000000, 0.01);

// 加入元素
bloomFilter.put("key1");
bloomFilter.put("key2");

// 查询元素
if (bloomFilter.mightContain("key1")) {
    // do something
}

其中,create 方法的参数依次是元素类型的 Funnel、预计元素个数和误判率。在上面的例子中,使用了 String 类型的 Funnel,预计元素个数为 1000000,误判率为 0.01。

使用 Redis 实现布隆过滤器
Redis 从版本 4.0 开始提供了布隆过滤器模块,使用方法如下:

# 安装布隆过滤器模块
redis-cli --cluster create IP1:PORT1 IP2:PORT2 IP3:PORT3 --cluster-replicas 1
> 1:OK
> 2:OK
> 3:OK
> ...

# 在 Redis 中创建布隆过滤器
BF.ADD myfilter key1
BF.ADD myfilter key2

# 查询元素是否存在
BF.EXISTS myfilter key1

其中,--cluster create 命令用于创建 Redis 集群,--cluster-replicas 参数用于设置每个主节点的从节点个数。在创建集群后,使用 BF.ADD 命令将元素加入布隆过滤器,使用 BF.EXISTS 命令查询元素是否存在。

需要注意的是,Redis 布隆过滤器模块的存储和查询操作都是基于 Redis 的,因此需要考虑 Redis 的内存和存储限制,并根据实际情况选择合适的存储大小和误判率。

如何保证缓存和数据库数据的⼀致性?

根据CAP理论,在保证可用性和分区容错性的前提下,无法保证一致性,所以缓存和数据库的绝对一致是不可能实现的,只能尽可能保存缓存和数据库的最终一致性。

选择合适的缓存更新策略

  1. 删除缓存而不是更新缓存

当一个线程对缓存的key进行写操作的时候,如果其它线程进来读数据库的时候,读到的就是脏数据,产生了数据不一致问题。

相比较而言,删除缓存的速度比更新缓存的速度快很多,所用时间相对也少很多,读脏数据的概率也小很多。

  1. 先更数据,后删缓存 先更数据库还是先删缓存?这是一个问题。

更新数据,耗时可能在删除缓存的百倍以上。在缓存中不存在对应的key,数据库又没有完成更新的时候,如果有线程进来读取数据,并写入到缓存,那么在更新成功之后,这个key就是一个脏数据。

毫无疑问,先删缓存,再更数据库,缓存中key不存在的时间的时间更长,有更大的概率会产生脏数据。目前最流行的缓存读写策略cache-aside-pattern就是采用先更数据库,再删缓存的方式。

缓存不一致处理

如果不是并发特别高,对缓存依赖性很强,其实一定程序的不一致是可以接受的。

但是如果对一致性要求比较高,那就得想办法保证缓存和数据库中数据一致。

缓存和数据库数据不一致常见的两种原因:

  1. 缓存key删除失败

  2. 并发导致写入了脏数据

  1. 消息队列保证key被删除 可以引入消息队列,把要删除的key或者删除失败的key丢尽消息队列,利用消息队列的重试机制,重试删除对应的key。

  2. 延时双删防止脏数据 还有一种情况,是在缓存不存在的时候,写入了脏数据,这种情况在先删缓存,再更数据库的缓存更新策略下发生的比较多,解决方案是延时双删。

简单说,就是在第一次删除缓存之后,过了一段时间之后,再次删除缓存。

  1. 设置缓存过期时间兜底

这是一个朴素但是有用的办法,给缓存设置一个合理的过期时间,即使发生了缓存数据不一致的问题,它也不会永远不一致下去,缓存过期的时候,自然又会恢复一致。

如何保证本地缓存和分布式缓存的一致?

在日常的开发中,我们常常采用两级缓存:本地缓存+分布式缓存。

所谓本地缓存,就是对应服务器的内存缓存,比如Caffeine,分布式缓存基本就是采用Redis。

那么问题来了,本地缓存和分布式缓存怎么保持数据一致?

Redis缓存,数据库发生更新,直接删除缓存的key即可,因为对于应用系统而言,它是一种中心化的缓存。

但是本地缓存,它是非中心化的,散落在分布式服务的各个节点上,没法通过客户端的请求删除本地缓存的key,所以得想办法通知集群所有节点,删除对应的本地缓存key。

可以采用消息队列的方式:

  1. 采用Redis本身的Pub/Sub机制,分布式集群的所有节点订阅删除本地缓存频道,删除Redis缓存的节点,同事发布删除本地缓存消息,订阅者们订阅到消息后,删除对应的本地key。 但是Redis的发布订阅不是可靠的,不能保证一定删除成功。
  2. 引入专业的消息队列,比如RocketMQ,保证消息的可靠性,但是增加了系统的复杂度。
  3. 设置适当的过期时间兜底,本地缓存可以设置相对短一些的过期时间。

怎么处理热key?

什么是热Key?

所谓的热key,就是访问频率比较的key。

比如,热门新闻事件或商品,这类key通常有大流量的访问,对存储这类信息的 Redis来说,是不小的压力。

假如Redis集群部署,热key可能会造成整体流量的不均衡,个别节点出现OPS过大的情况,极端情况下热点key甚至会超过 Redis本身能够承受的OPS。

怎么处理热key?

对热key的处理,最关键的是对热点key的监控,可以从这些端来监控热点key:

  1. 客户端 客户端其实是距离key“最近”的地方,因为Redis命令就是从客户端发出的,例如在客户端设置全局字典(key和调用次数),每次调用Redis命令时,使用这个字典进行记录。

  2. 代理端 像Twemproxy、Codis这些基于代理的Redis分布式架构,所有客户端的请求都是通过代理端完成的,可以在代理端进行收集统计。

Redis服务端 使用monitor命令统计热点key是很多开发和运维人员首先想到,monitor命令可以监控到Redis执行的所有命令。

只要监控到了热key,对热key的处理就简单了:

  1. 把热key打散到不同的服务器,降低压⼒

  2. 加⼊⼆级缓存,提前加载热key数据到内存中,如果redis宕机,⾛内存查询

缓存预热怎么做?

所谓缓存预热,就是提前把数据库里的数据刷到缓存里,通常有这些方法:

  1. 直接写个缓存刷新页面或者接口,上线时手动操作

  2. 数据量不大,可以在项目启动的时候自动进行加载

  3. 定时任务刷新缓存

热点key重建?问题?解决?

开发的时候一般使用“缓存+过期时间”的策略,既可以加速数据读写,又保证数据的定期更新,这种模式基本能够满足绝大部分需求。

但是有两个问题如果同时出现,可能就会出现比较大的问题:

  1. 当前key是一个热点key(例如一个热门的娱乐新闻),并发量非常大。

  2. 重建缓存不能在短时间完成,可能是一个复杂计算,例如复杂的 SQL、多次IO、多个依赖等。 在缓存失效的瞬间,有大量线程来重建缓存,造成后端负载加大,甚至可能会让应用崩溃。

怎么处理呢?

要解决这个问题也不是很复杂,解决问题的要点在于:

  1. 减少重建缓存的次数。
  2. 数据尽可能一致。
  3. 较少的潜在危险。

所以一般采用如下方式:

  1. 互斥锁(mutex key) 这种方法只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完,重新从缓存获取数据即可。
  2. 永远不过期 “永远不过期”包含两层意思:
    1. 从缓存层面来看,确实没有设置过期时间,所以不会出现热点key过期后产生的问题,也就是“物理”不过期。
    2. 从功能层面来看,为每个value设置一个逻辑过期时间,当发现超过逻辑过期时间后,会使用单独的线程去构建缓存。

Redis的过期数据回收策略有哪些?

Redis主要有2种过期数据回收策略:

惰性删除

惰性删除指的是当我们查询key的时候才对key进⾏检测,如果已经达到过期时间,则删除。显然,他有⼀个缺点就是如果这些过期的key没有被访问,那么他就⼀直⽆法被删除,⽽且⼀直占⽤内存。

定期删除

定期删除指的是Redis每隔⼀段时间对数据库做⼀次检查,删除⾥⾯的过期key。由于不可能对所有key去做轮询来删除,所以Redis会每次随机取⼀些key去做检查和删除。

Redis有哪些内存溢出控制/内存淘汰策略?

  1. noeviction:默认策略,不会删除任何数据,拒绝所有写入操作并返 回客户端错误信息,此 时Redis只响应读操作。
  2. volatile-lru:根据LRU算法删除设置了超时属性(expire)的键,直 到腾出足够空间为止。如果没有可删除的键对象,回退到noeviction策略。
  3. allkeys-lru:根据LRU算法删除键,不管数据有没有设置超时属性, 直到腾出足够空间为止。
  4. allkeys-random:随机删除所有键,直到腾出足够空间为止。
  5. volatile-random:随机删除过期键,直到腾出足够空间为止。
  6. volatile-ttl:根据键值对象的ttl属性,删除最近将要过期数据。如果 没有,回退到noeviction策略。

Redis阻塞?怎么解决?

Redis发生阻塞,可以从以下几个方面排查:

  • API或数据结构使用不合理

通常Redis执行命令速度非常快,但是不合理地使用命令,可能会导致执行速度很慢,导致阻塞,对于高并发的场景,应该尽量避免在大对象上执行算法复杂 度超过O(n)的命令。

对慢查询的处理分为两步:

  1. 发现慢查询: slowlog get{n}命令可以获取最近 的n条慢查询命令;
  2. 发现慢查询后,可以从两个方向去优化慢查询: 1)修改为低算法复杂度的命令,如hgetall改为hmget等,禁用keys、sort等命 令 2)调整大对象:缩减大对象数据或把大对象拆分为多个小对象,防止一次命令操作过多的数据。
  • CPU饱和的问题

单线程的Redis处理命令时只能使用一个CPU。而CPU饱和是指Redis单核CPU使用率跑到接近100%。

针对这种情况,处理步骤一般如下:

  1. 判断当前Redis并发量是否已经达到极限,可以使用统计命令redis-cli-h{ip}-p{port}--stat获取当前 Redis使用情况
  2. 如果Redis的请求几万+,那么大概就是Redis的OPS已经到了极限,应该做集群化水品扩展来分摊OPS压力
  3. 如果只有几百几千,那么就得排查命令和内存的使用
  • 持久化相关的阻塞

对于开启了持久化功能的Redis节点,需要排查是否是持久化导致的阻塞。

  1. fork阻塞 fork操作发生在RDB和AOF重写时,Redis主线程调用fork操作产生共享 内存的子进程,由子进程完成持久化文件重写工作。如果fork操作本身耗时过长,必然会导致主线程的阻塞。
  2. AOF刷盘阻塞 当我们开启AOF持久化功能时,文件刷盘的方式一般采用每秒一次,后台线程每秒对AOF文件做fsync操作。当硬盘压力过大时,fsync操作需要等 待,直到写入完成。如果主线程发现距离上一次的fsync成功超过2秒,为了 数据安全性它会阻塞直到后台线程执行fsync操作完成。
  3. HugePage写操作阻塞 对于开启Transparent HugePages的 操作系统,每次写命令引起的复制内存页单位由4K变为2MB,放大了512 倍,会拖慢写操作的执行时间,导致大量写操作慢查询。

大key问题了解吗?

Redis使用过程中,有时候会出现大key的情况, 比如:

  • 单个简单的key存储的value很大,size超过10KB
  • hash, set,zset,list 中存储过多的元素(以万为单位)

大key会造成什么问题呢?

  • 客户端耗时增加,甚至超时
  • 对大key进行IO操作时,会严重占用带宽和CPU
  • 造成Redis集群中数据倾斜
  • 主动删除、被动删等,可能会导致阻塞

如何找到大key?

  • bigkeys命令:使用bigkeys命令以遍历的方式分析Redis实例中的所有Key,并返回整体统计信息与每个数据类型中Top1的大Key
  • redis-rdb-tools:redis-rdb-tools是由Python写的用来分析Redis的rdb快照文件用的工具,它可以把rdb快照文件生成json文件或者生成报表用来分析Redis的使用详情。

如何处理大key?

  • 删除大key

    1. 当Redis版本大于4.0时,可使用UNLINK命令安全地删除大Key,该命令能够以非阻塞的方式,逐步地清理传入的Key。
    2. 当Redis版本小于4.0时,避免使用阻塞式命令KEYS,而是建议通过SCAN命令执行增量迭代扫描key,然后判断进行删除。
  • 压缩和拆分key

    1. 当vaule是string时,比较难拆分,则使用序列化、压缩算法将key的大小控制在合理范围内,但是序列化和反序列化都会带来更多时间上的消耗。
    2. 当value是string,压缩之后仍然是大key,则需要进行拆分,一个大key分为不同的部分,记录每个部分的key,使用multiget等操作实现事务读取。
    3. 当value是list/set等集合类型时,根据预估的数据规模来进行分片,不同的元素计算后分到不同的片。

使用Redis 如何实现异步队列?

我们知道redis支持很多种结构的数据,那么如何使用redis作为异步队列使用呢? 一般有以下几种方式:

  • 使用list作为队列,lpush生产消息,rpop消费消息

这种方式,消费者死循环rpop从队列中消费消息。但是这样,即使队列里没有消息,也会进行rpop,会导致Redis CPU的消耗。

消费者休眠的方式的方式来处理,但是这样又会又消息的延迟问题。

  • 使用list作为队列,lpush生产消息,brpop消费消息

brpop是rpop的阻塞版本,list为空的时候,它会一直阻塞,直到list中有值或者超时。

这种方式只能实现一对一的消息队列。

  • 使用Redis的pub/sub来进行消息的发布/订阅

发布/订阅模式可以1:N的消息发布/订阅。发布者将消息发布到指定的频道频道(channel),订阅相应频道的客户端都能收到消息。

但是这种方式不是可靠的,它不保证订阅者一定能收到消息,也不进行消息的存储。所以,一般的异步队列的实现还是交给专业的消息队列。

Redis 如何实现延时队列?

使用zset,利用排序实现

可以使用 zset这个结构,用设置好的时间戳作为score进行排序,使用 zadd score1 value1 ....命令就可以一直往内存中生产消息。再利用 zrangebysocre 查询符合条件的所有待处理的任务,通过循环执行队列任务即可

Redis 支持事务吗?

Redis提供了简单的事务,但它对事务ACID的支持并不完备。

multi命令代表事务开始,exec命令代表事务结束,它们之间的命令是原子顺序执行的:

127.0.0.1:6379> multi 
OK
127.0.0.1:6379> sadd user:a:follow user:b 
QUEUED 
127.0.0.1:6379> sadd user:b:fans user:a 
QUEUED
127.0.0.1:6379> sismember user:a:follow user:b 
(integer) 0
127.0.0.1:6379> exec 1) (integer) 1
2) (integer) 1

Redis事务的原理,是所有的指令在 exec 之前不执行,而是缓存在 服务器的一个事务队列中,服务器一旦收到 exec 指令,才开执行整个事务队列,执行完毕后一次性返回所有指令的运行结果。

因为Redis执行命令是单线程的,所以这组命令顺序执行,而且不会被其它线程打断。

Redis事务的注意点有哪些?

需要注意的点有:

  1. Redis 事务是不支持回滚的,不像 MySQL 的事务一样,要么都执行要么都不执行;

  2. Redis 服务端在执行事务的过程中,不会被其他客户端发送来的命令请求打断。直到事务命令全部执行完毕才会执行其他客户端的命令。

Redis 事务为什么不支持回滚?

Redis 的事务不支持回滚。

如果执行的命令有语法错误,Redis 会执行失败,这些问题可以从程序层面捕获并解决。但是如果出现其他问题,则依然会继续执行余下的命令。

这样做的原因是因为回滚需要增加很多工作,而不支持回滚则可以保持简单、快速的特性。

Redis的管道了解吗?

Redis 提供三种将客户端多条命令打包发送给服务端执行的方式:

Pipelining(管道) 、 Transactions(事务) 和 Lua Scripts(Lua 脚本) 。

Pipelining(管道

Redis 管道是三者之中最简单的,当客户端需要执行多条 redis 命令时,可以通过管道一次性将要执行的多条命令发送给服务端,其作用是为了降低 RTT(Round Trip Time) 对性能的影响,比如我们使用 nc 命令将两条指令发送给 redis 服务端。

Redis 服务端接收到管道发送过来的多条命令后,会一直执命令,并将命令的执行结果进行缓存,直到最后一条命令执行完成,再所有命令的执行结果一次性返回给客户端 。

Pipelining的优势

在性能方面, Pipelining 有下面两个优势:

  1. 节省了RTT:将多条命令打包一次性发送给服务端,减少了客户端与服务端之间的网络调用次数
  2. 减少了上下文切换:当客户端/服务端需要从网络中读写数据时,都会产生一次系统调用,系统调用是非常耗时的操作,其中设计到程序由用户态切换到内核态,再从内核态切换回用户态的过程。当我们执行 10 条 redis 命令的时候,就会发生 10 次用户态到内核态的上下文切换,但如果我们使用 Pipeining 将多条命令打包成一条一次性发送给服务端,就只会产生一次上下文切换。
import redis.clients.jedis.Jedis;
import redis.clients.jedis.Pipeline;

public class RedisPipelining {

    public static void main(String[] args) {
        Jedis jedis = new Jedis("localhost");

        Pipeline pipeline = jedis.pipelined();

        for (int i = 0; i < 10000; i++) {
            pipeline.incr("counter");
        }

        pipeline.sync();

        String counter = jedis.get("counter");
        System.out.println(counter);

        jedis.close();
    }
}

在上面的代码中,我们首先创建了一个Jedis实例,然后使用jedis.pipelined()方法创建了一个新的Pipeline对象。接着,我们循环执行了10000次incr命令,将其添加到Pipeline中。最后,我们使用pipeline.sync()方法一次性发送了所有命令,然后使用jedis.get方法获取计数器的值。

Redis实现分布式锁了解吗?

Redis是分布式锁本质上要实现的目标就是在 Redis 里面占一个“茅坑”,当别的进程也要来占时,发现已经有人蹲在那里了,就只好放弃或者稍后再试。

  1. setnx命令
    占坑一般是使用 setnx(set if not exists) 指令,只允许被一个客户端占坑。先来先占, 用完了,再调用 del

> setnx lock:fighter true
OK
... do something critical ...
> del lock:fighter
(integer) 1

但是有个问题,如果逻辑执行到中间出现异常了,可能会导致 del 指令没有被调用,这样就会陷入死锁,锁永远得不到释放。

  1. 锁超时释放
    所以在拿到锁之后,再给锁加上一个过期时间,比如 5s,这样即使中间出现异常也可以保证 5 秒之后锁会自动释放。

> setnx lock:fighter true
OK
> expire lock:fighter 5
... do something critical ...
> del lock:fighter
(integer) 1

但是以上逻辑还有问题。如果在 setnx 和 expire 之间服务器进程突然挂掉了,可能是因为机器掉电或者是被人为杀掉的,就会导致 expire 得不到执行,也会造成死锁。

这种问题的根源就在于 setnx 和 expire 是两条指令而不是原子指令。如果这两条指令可以一起执行就不会出现问题。

  • set指令

这个问题在Redis 2.8 版本中得到了解决,这个版本加入了 set 指令的扩展参数,使得 setnx 和expire 指令可以一起执行。

set lock:fighter3 true ex 5 nx OK ... do something critical ... > del lock:codehole

上面这个指令就是 setnx 和 expire 组合在一起的原子指令,这个就算是比较完善的分布式锁了。

当然实际的开发,没人会去自己写分布式锁的命令,因为有专业的轮子——Redisson

MySQL

MySQL 中 in 和 exists 的区别?

MySQL 中的 in 语句是把外表和内表作 hash 连接,而 exists 语句是对外表作 loop 循环,每次 loop 循环再对内表进行查询。我们可能认为 exists 比 in 语句的效率要高,这种说法其实是不准确的,要区分情景:

  1. 如果查询的两个表大小相当,那么用 in 和 exists 差别不大。
  2. 如果两个表中一个较小,一个是大表,则子查询表大的用 exists,子查询表小的用 in。
  3. not in 和 not exists:如果查询语句使用了 not in,那么内外表都进行全表扫描,没有用到索引;而 not extsts 的子查询依然能用到表上的索引。所以无论那个表大,用 not exists 都比 not in 要快。

一条 SQL 查询语句的执行顺序?

  1. FROM:对 FROM 子句中的左表<left_table>和右表<right_table>执行笛卡儿积(Cartesianproduct),产生虚拟表 VT1
  2. ON:对虚拟表 VT1 应用 ON 筛选,只有那些符合<join_condition>的行才被插入虚拟表 VT2 中
  3. JOIN:如果指定了 OUTER JOIN(如 LEFT OUTER JOIN、RIGHT OUTER JOIN),那么保留表中未匹配的行作为外部行添加到虚拟表 VT2 中,产生虚拟表 VT3。如果 FROM 子句包含两个以上表,则对上一个连接生成的结果表 VT3 和下一个表重复执行步骤 1)~步骤 3),直到处理完所有的表为止
  4. WHERE:对虚拟表 VT3 应用 WHERE 过滤条件,只有符合<where_condition>的记录才被插入虚拟表 VT4 中
  5. GROUP BY:根据 GROUP BY 子句中的列,对 VT4 中的记录进行分组操作,产生 VT5
  6. CUBE|ROLLUP:对表 VT5 进行 CUBE 或 ROLLUP 操作,产生表 VT6
  7. HAVING:对虚拟表 VT6 应用 HAVING 过滤器,只有符合<having_condition>的记录才被插入虚拟表 VT7 中。
  8. SELECT:第二次执行 SELECT 操作,选择指定的列,插入到虚拟表 VT8 中
  9. DISTINCT:去除重复数据,产生虚拟表 VT9
  10. ORDER BY:将虚拟表 VT9 中的记录按照<order_by_list>进行排序操作,产生虚拟表 VT10。11)
  11. LIMIT:取出指定行的记录,产生虚拟表 VT11,并返回给查询用户

说说 MySQL 的基础架构?

MySQL 逻辑架构图主要分三层:

  1. 客户端:最上层的服务并不是 MySQL 所独有的,大多数基于网络的客户端/服务器的工具或者服务都有类似的架构。比如连接处理、授权认证、安全等等。
  2. Server 层:大多数 MySQL 的核心服务功能都在这一层,包括查询解析、分析、优化、缓存以及所有的内置函数(例如,日期、时间、数学和加密函数),所有跨存储引擎的功能都在这一层实现:存储过程、触发器、视图等。
  3. 存储引擎层:第三层包含了存储引擎。存储引擎负责 MySQL 中数据的存储和提取。Server 层通过 API 与存储引擎进行通信。这些接口屏蔽了不同存储引擎之间的差异,使得这些差异对上层的查询过程透明。

一条 SQL 查询语句在 MySQL 中如何执行的

  1. 先检查该语句是否有权限,如果没有权限,直接返回错误信息,如果有权限会先查询缓存 (MySQL8.0 版本以前)。
  2. 如果没有缓存,分析器进行语法分析,提取 sql 语句中 select 等关键元素,然后判断 sql 语句是否有语法错误,比如关键词是否正确等等。
  3. 语法解析之后,MySQL 的服务器会对查询的语句进行优化,确定执行的方案。
  4. 完成查询优化后,按照生成的执行计划调用数据库引擎接口,返回执行结果。

InnoDB 和 MylSAM 主要有什么区别?

  1. 存储结构:每个 MyISAM 在磁盘上存储成三个文件;InnoDB 所有的表都保存在同一个数据文件中(也可能是多个文件,或者是独立的表空间文件),InnoDB 表的大小只受限于操作系统文件的大小,一般为 2GB。

  2. 事务支持:MyISAM 不提供事务支持;InnoDB 提供事务支持事务,具有事务(commit)、回滚(rollback)和崩溃修复能力(crash recovery capabilities)的事务安全特性。

  3. 最小锁粒度:MyISAM 只支持表级锁,更新时会锁住整张表,导致其它查询和更新都会被阻塞 InnoDB 支持行级锁。

  4. 索引类型:MyISAM 的索引为非聚簇索引,数据结构是 B 树;InnoDB 的索引是聚簇索引,数据结构是 B+树。

  5. 主键必需:MyISAM 允许没有任何索引和主键的表存在;InnoDB 如果没有设定主键或者非空唯一索引,就会自动生成一个 6 字节的主键(用户不可见),数据是主索引的一部分,附加索引保存的是主索引的值。

  6. 表的具体行数:MyISAM 保存了表的总行数,如果 select count() from table;会直接取出出该值; InnoDB 没有保存表的总行数,如果使用 select count() from table;就会遍历整个表;但是在加了 wehre 条件后,MyISAM 和 InnoDB 处理的方式都一样。

  7. 外键支持:MyISAM 不支持外键;InnoDB 支持外键。

MySQL 日志文件有哪些?分别介绍下作用?

MySQL 日志文件有很多,包括 :

  1. 错误日志(error log):错误日志文件对 MySQL 的启动、运行、关闭过程进行了记录,能帮助定位 MySQL 问题。
  2. 慢查询日志(slow query log):慢查询日志是用来记录执行时间超过 long_query_time 这个变量定义的时长的查询语句。通过慢查询日志,可以查找出哪些查询语句的执行效率很低,以便进行优化。
  3. 一般查询日志(general log):一般查询日志记录了所有对 MySQL 数据库请求的信息,无论请求是否正确执行。
  4. 二进制日志(bin log):关于二进制日志,它记录了数据库所有执行的 DDL 和 DML 语句(除了数据查询语句 select、show 等),以事件形式记录并保存在二进制文件中。

还有两个 InnoDB 存储引擎特有的日志文件:

  1. 重做日志(redo log):重做日志至关重要,因为它们记录了对于 InnoDB 存储引擎的事务日志。
  2. 回滚日志(undo log):回滚日志同样也是 InnoDB 引擎提供的日志,顾名思义,回滚日志的作用就是对数据进行回滚。当事务对数据库进行修改,InnoDB 引擎不仅会记录 redo log,还会生成对应的 undo log 日志;如果事务执行失败或调用了 rollback,导致事务需要回滚,就可以利用 undo log 中的信息将数据回滚到修改之前的样子。

binlog 和 redo log 有什么区别?

  1. bin log 会记录所有与数据库有关的日志记录,包括 InnoDB、MyISAM 等存储引擎的日志,而 redo log 只记 InnoDB 存储引擎的日志。
  2. 记录的内容不同,bin log 记录的是关于一个事务的具体操作内容,即该日志是逻辑日志。而 redo log 记录的是关于每个页(Page)的更改的物理情况。
  3. 写入的时间不同,bin log 仅在事务提交前进行提交,也就是只写磁盘一次。而在事务进行的过程中,却不断有 redo ertry 被写入 redo log 中。
  4. 写入的方式也不相同,redo log 是循环写入和擦除,bin log 是追加写入,不会覆盖已经写的文件。

一条更新语句怎么执行的了解吗?

更新语句的执行是 Server 层和引擎层配合完成,数据除了要写入表中,还要记录相应的日志。

  1. 执行器先找引擎获取 ID=2 这一行。ID 是主键,存储引擎检索数据,找到这一行。如果 ID=2 这一行所在的数据页本来就在内存中,就直接返回给执行器;否则,需要先从磁盘读入内存,然后再返回。
  2. 执行器拿到引擎给的行数据,把这个值加上 1,比如原来是 N,现在就是 N+1,得到新的一行数据,再调用引擎接口写入这行新数据。
  3. 引擎将这行新数据更新到内存中,同时将这个更新操作记录到 redo log 里面,此时 redo log 处于 prepare 状态。然后告知执行器执行完成了,随时可以提交事务。
  4. 执行器生成这个操作的 binlog,并把 binlog 写入磁盘。
  5. 执行器调用引擎的提交事务接口,引擎把刚刚写入的 redo log 改成提交(commit)状态,更新完成。

从上图可以看出,MySQL 在执行更新语句的时候,在服务层进行语句的解析和执行,在引擎层进行数据的提取和存储;同时在服务层对 binlog 进行写入,在 InnoDB 内进行 redo log 的写入。

不仅如此,在对 redo log 写入时有两个阶段的提交,一是 binlog 写入之前prepare状态的写入,二是 binlog 写入之后commit状态的写入。

那为什么要两阶段提交呢?

为什么要两阶段提交呢?直接提交不行吗?

我们可以假设不采用两阶段提交的方式,而是采用“单阶段”进行提交,即要么先写入 redo log,后写入 binlog;要么先写入 binlog,后写入 redo log。这两种方式的提交都会导致原先数据库的状态和被恢复后的数据库的状态不一致。

先写入 redo log,后写入 binlog:

在写完 redo log 之后,数据此时具有crash-safe能力,因此系统崩溃,数据会恢复成事务开始之前的状态。但是,若在 redo log 写完时候,binlog 写入之前,系统发生了宕机。此时 binlog 没有对上面的更新语句进行保存,导致当使用 binlog 进行数据库的备份或者恢复时,就少了上述的更新语句。从而使得id=2这一行的数据没有被更新。

先写入 binlog,后写入 redo log:

写完 binlog 之后,所有的语句都被保存,所以通过 binlog 复制或恢复出来的数据库中 id=2 这一行的数据会被更新为 a=1。但是如果在 redo log 写入之前,系统崩溃,那么 redo log 中记录的这个事务会无效,导致实际数据库中id=2这一行的数据并没有更新。

简单说,redo log 和 binlog 都可以用于表示事务的提交状态,而两阶段提交就是让这两个状态保持逻辑上的一致。

redo log 怎么刷入磁盘的知道吗?

redo log 的写入不是直接落到磁盘,而是在内存中设置了一片称之为redo log buffer的连续内存空间,也就是redo 日志缓冲区。

什么时候会刷入磁盘?

在如下的一些情况中,log buffer 的数据会刷入磁盘:

  1. log buffer 空间不足时
    log buffer 的大小是有限的,如果不停的往这个有限大小的 log buffer 里塞入日志,很快它就会被填满。如果当前写入 log buffer 的 redo 日志量已经占满了 log buffer 总容量的大约一半左右,就需要把这些日志刷新到磁盘上。

  2. 事务提交时
    在事务提交时,为了保证持久性,会把 log buffer 中的日志全部刷到磁盘。注意,这时候,除了本事务的,可能还会刷入其它事务的日志。

  3. 后台线程输入
    有一个后台线程,大约每秒都会刷新一次log buffer中的redo log到磁盘。

  4. 正常关闭服务器时

  5. 触发 checkpoint 规则

重做日志缓存、重做日志文件都是以块(block)的方式进行保存的,称之为重做日志块(redo log block),块的大小是固定的 512 字节。我们的 redo log 它是固定大小的,可以看作是一个逻辑上的 log group,由一定数量的log block 组成。

它的写入方式是从头到尾开始写,写到末尾又回到开头循环写。

其中有两个标记位置:

  • write pos是当前记录的位置,一边写一边后移,写到第 3 号文件末尾后就回到 0 号文件开头。
  • checkpoint是当前要擦除的位置,也是往后推移并且循环的,擦除记录前要把记录更新到磁盘。

当write_pos追上checkpoint时,表示 redo log 日志已经写满。这时候就不能接着往里写数据了,需要执行checkpoint规则腾出可写空间。

所谓的checkpoint 规则,就是 checkpoint 触发后,将 buffer 中日志页都刷到磁盘。

有哪些方式优化慢 SQL?

慢 SQL 的优化,主要从两个方面考虑,SQL 语句本身的优化,以及数据库设计的优化。

避免不必要的列

这个是老生常谈,但还是经常会出的情况,SQL 查询的时候,应该只查询需要的列,而不要包含额外的列,像slect * 这种写法应该尽量避免。

分页优化

在数据量比较大,分页比较深的情况下,需要考虑分页的优化。

  • 延迟关联

先通过 where 条件提取出主键,在将该表与原数据表关联,通过主键 id 提取数据行,而不是通过原来的二级索引提取数据行

例如:

select a.* from table a, 
 (select id from table where type = 2 and level = 9 order by id asc limit 190289,10 ) b
 where a.id = b.id
  • 书签方式

书签方式就是找到 limit 第一个参数对应的主键值,根据这个主键值再去过滤并 limit

例如:

  select * from table where id > (select * from table where type = 2 and level = 9 order by id asc limit 190289, 1) limit 10;

索引优化

合理地设计和使用索引,是优化慢 SQL 的利器。

  • 利用覆盖索引

InnoDB 使用非主键索引查询数据时会回表,但是如果索引的叶节点中已经包含要查询的字段,那它没有必要再回表查询了,这就叫覆盖索引

例如对于如下查询:

select name from test where city='上海'

我们将被查询的字段建立到联合索引中,这样查询结果就可以直接从索引中获取

alter table test add index idx_city_name (city, name);
  • 低版本避免使用 or 查询

在 MySQL 5.0 之前的版本要尽量避免使用 or 查询,可以使用 union 或者子查询来替代,因为早期的 MySQL 版本使用 or 查询可能会导致索引失效,高版本引入了索引合并,解决了这个问题。

避免使用 != 或者 <> 操作符

SQL 中,不等于操作符会导致查询引擎放弃查询索引,引起全表扫描,即使比较的字段上有索引

解决方法:通过把不等于操作符改成 or,可以使用索引,避免全表扫描

例如,把column<>’aaa’,改成column>’aaa’ or column<’aaa’,就可以使用索引了

  • 适当使用前缀索引

适当地使用前缀索引,可以降低索引的空间占用,提高索引的查询效率。

比如,邮箱的后缀都是固定的“@xxx.com”,那么类似这种后面几位为固定值的字段就非常适合定义为前缀索引

alter table test add index index2(email(6));

PS:需要注意的是,前缀索引也存在缺点,MySQL 无法利用前缀索引做 order by 和 group by 操作,也无法作为覆盖索引

  • 避免列上函数运算

要避免在列字段上进行算术运算或其他表达式运算,否则可能会导致存储引擎无法正确使用索引,从而影响了查询的效率

select * from test where id + 1 = 50;
select * from test where month(updateTime) = 7;
  • 正确使用联合索引

使用联合索引的时候,注意最左匹配原则。

JOIN 优化

  • 优化子查询

尽量使用 Join 语句来替代子查询,因为子查询是嵌套查询,而嵌套查询会新创建一张临时表,而临时表的创建与销毁会占用一定的系统资源以及花费一定的时间,同时对于返回结果集比较大的子查询,其对查询性能的影响更大

  • 小表驱动大表

关联查询的时候要拿小表去驱动大表,因为关联的时候,MySQL 内部会遍历驱动表,再去连接被驱动表。

比如 left join,左表就是驱动表,A 表小于 B 表,建立连接的次数就少,查询速度就被加快了。

 select name from A left join B ;
  • 适当增加冗余字段

增加冗余字段可以减少大量的连表查询,因为多张表的连表查询性能很低,所有可以适当的增加冗余字段,以减少多张表的关联查询,这是以空间换时间的优化策略

  • 避免使用 JOIN 关联太多的表

《阿里巴巴 Java 开发手册》规定不要 join 超过三张表,第一 join 太多降低查询的速度,第二 join 的 buffer 会占用更多的内存。

如果不可避免要 join 多张表,可以考虑使用数据异构的方式异构到 ES 中查询。

排序优化

利用索引扫描做排序

MySQL 有两种方式生成有序结果:其一是对结果集进行排序的操作,其二是按照索引顺序扫描得出的结果自然是有序的

但是如果索引不能覆盖查询所需列,就不得不每扫描一条记录回表查询一次,这个读操作是随机 IO,通常会比顺序全表扫描还慢

因此,在设计索引时,尽可能使用同一个索引既满足排序又用于查找行

例如:

--建立索引(date,staff_id,customer_id)
select staff_id, customer_id from test where date = '2010-01-01' order by staff_id,customer_id;

只有当索引的列顺序和 ORDER BY 子句的顺序完全一致,并且所有列的排序方向都一样时,才能够使用索引来对结果做排序

UNION 优化

条件下推

MySQL 处理 union 的策略是先创建临时表,然后将各个查询结果填充到临时表中最后再来做查询,很多优化策略在 union 查询中都会失效,因为它无法利用索引

最好手工将 where、limit 等子句下推到 union 的各个子查询中,以便优化器可以充分利用这些条件进行优化

此外,除非确实需要服务器去重,一定要使用 union all,如果不加 all 关键字,MySQL 会给临时表加上 distinct 选项,这会导致对整个临时表做唯一性检查,代价很高。

怎么看执行计划(explain),如何理解其中各个字段的含义?

explain 是 sql 优化的利器,除了优化慢 sql,平时的 sql 编写,也应该先 explain,查看一下执行计划,看看是否还有优化的空间。

直接在 select 语句之前增加explain 关键字,就会返回执行计划的信息。

  1. id 列:MySQL 会为每个 select 语句分配一个唯一的 id 值
  2. select_type 列,查询的类型,根据关联、union、子查询等等分类,常见的查询类型有 SIMPLE、PRIMARY。
  3. table 列:表示 explain 的一行正在访问哪个表。
  4. type 列:最重要的列之一。表示关联类型或访问类型,即 MySQL 决定如何查找表中的行。

性能从最优到最差分别为:system > const > eq_ref > ref > fulltext > ref_or_null > index_merge > unique_subquery > index_subquery > range > index > ALL

  • system

system:当表仅有一行记录时(系统表),数据量很少,往往不需要进行磁盘 IO,速度非常快

  • const

const:表示查询时命中 primary key 主键或者 unique 唯一索引,或者被连接的部分是一个常量(const)值。这类扫描效率极高,返回数据量少,速度非常快。

  • eq_ref

eq_ref:查询时命中主键primary key 或者 unique key索引, type 就是 eq_ref。

  • ref_or_null

ref_or_null:这种连接类型类似于 ref,区别在于 MySQL会额外搜索包含NULL值的行。

  • index_merge

index_merge:使用了索引合并优化方法,查询使用了两个以上的索引。

  • unique_subquery

unique_subquery:替换下面的 IN子查询,子查询返回不重复的集合。

  • index_subquery

index_subquery:区别于unique_subquery,用于非唯一索引,可以返回重复值。

  • range

range:使用索引选择行,仅检索给定范围内的行。简单点说就是针对一个有索引的字段,给定范围检索数据。在where语句中使用 bettween...and、<、>、<=、in 等条件查询 type 都是 range。

  • index

index:Index 与ALL 其实都是读全表,区别在于index是遍历索引树读取,而ALL是从硬盘中读取。

  • ALL

就不用多说了,全表扫描。

  1. possible_keys 列:显示查询可能使用哪些索引来查找,使用索引优化 sql 的时候比较重要。

  2. key 列:这一列显示 mysql 实际采用哪个索引来优化对该表的访问,判断索引是否失效的时候常用。

  3. key_len 列:显示了 MySQL 使用

  4. ref 列:ref 列展示的就是与索引列作等值匹配的值,常见的有:const(常量),func,NULL,字段名。

  5. rows 列:这也是一个重要的字段,MySQL 查询优化器根据统计信息,估算 SQL 要查到结果集需要扫描读取的数据行数,这个值非常直观显示 SQL 的效率好坏,原则上 rows 越少越好。

  6. Extra 列:显示不适合在其它列的额外信息,虽然叫额外,但是也有一些重要的信息:

    Using index:表示 MySQL 将使用覆盖索引,以避免回表
    Using where:表示会在存储引擎检索之后再进行过滤
    Using temporary :表示对查询结果排序时会使用一个临时表。

能简单说一下索引的分类吗?

从三个不同维度对索引分类:

例如从基本使用使用的角度来讲:

  • 主键索引: InnoDB 主键是默认的索引,数据列不允许重复,不允许为 NULL,一个表只能有一个主键。
  • 唯一索引: 数据列不允许重复,允许为 NULL 值,一个表允许多个列创建唯一索引。
  • 普通索引: 基本的索引类型,没有唯一性的限制,允许为 NULL 值。
  • 组合索引:多列值组成一个索引,用于组合搜索,效率大于索引合并

创建索引有哪些注意点?

索引虽然是 sql 性能优化的利器,但是索引的维护也是需要成本的,所以创建索引,也要注意:

  1. 索引应该建在查询应用频繁的字段

    在用于 where 判断、 order 排序和 join 的(on)字段上创建索引。

  2. 索引的个数应该适量

    索引需要占用空间;更新时候也需要维护。

  3. 区分度低的字段,例如性别,不要建索引。

    离散度太低的字段,扫描的行数降低的有限。

  4. 频繁更新的值,不要作为主键或者索引

    维护索引文件需要成本;还会导致页分裂,IO 次数增多。

  5. 组合索引把散列性高(区分度高)的值放在前面

    为了满足最左前缀匹配原则

  6. 创建组合索引,而不是修改单列索引。

    组合索引代替多个单列索引(对于单列索引,MySQL 基本只能使用一个索引,所以经常使用多个条件查询时更适合使用组合索引)

  7. 过长的字段,使用前缀索引。当字段值比较长的时候,建立索引会消耗很多的空间,搜索起来也会很慢。我们可以通过截取字段的前面一部分内容建立索引,这个就叫前缀索引。

  8. 不建议用无序的值(例如身份证、UUID )作为索引

    当主键具有不确定性,会造成叶子节点频繁分裂,出现磁盘存储的碎片化

索引哪些情况下会失效呢?

  1. 查询条件包含 or,可能导致索引失效
  2. 如果字段类型是字符串,where 时一定用引号括起来,否则会因为隐式类型转换,索引失效
  3. like 通配符可能导致索引失效。
  4. 联合索引,查询时的条件列不是联合索引中的第一个列,索引失效。
  5. 在索引列上使用 mysql 的内置函数,索引失效。
  6. 对索引列运算(如,+、-、*、/),索引失效。
  7. 索引字段上使用(!= 或者 < >,not in)时,可能会导致索引失效。
  8. 索引字段上使用 is null, is not null,可能导致索引失效。
  9. 左连接查询或者右连接查询查询关联的字段编码格式不一样,可能导致索引失效。
  10. MySQL 优化器估计使用全表扫描要比使用索引快,则不使用索引。

索引不适合哪些场景呢?

  1. 数据量比较少的表不适合加索引
  2. 更新比较频繁的字段也不适合加索引
  3. 离散低的字段不适合加索引(如性别)

索引是不是建的越多越好呢?

当然不是。

  1. 索引会占据磁盘空间
  2. 索引虽然会提高查询效率,但是会降低更新表的效率。比如每次对表进行增删改操作,MySQL 不仅要保存数据,还有保存或者更新对应的索引文件。

MySQL 索引用的什么数据结构了解吗?

MySQL 的默认存储引擎是 InnoDB,它采用的是 B+树结构的索引。

  • B+树:只有叶子节点才会存储数据,非叶子节点只存储键值。叶子节点之间使用双向指针连接,最底层的叶子节点形成了一个双向有序链表。

在这张图里,有两个重点:

  • 最外面的方块,的块我们称之为一个磁盘块,可以看到每个磁盘块包含几个数据项(粉色所示)和指针(黄色/灰色所示),如根节点磁盘包含数据项 17 和 35,包含指针 P1、P2、P3,P1 表示小于 17 的磁盘块,P2 表示在 17 和 35 之间的磁盘块,P3 表示大于 35 的磁盘块。真实的数据存在于叶子节点即 3、4、5……、65。非叶子节点只不存储真实的数据,只存储指引搜索方向的数据项,如 17、35 并不真实存在于数据表中。
  • 叶子节点之间使用双向指针连接,最底层的叶子节点形成了一个双向有序链表,可以进行范围查询。

聚簇索引与非聚簇索引的区别?

首先理解聚簇索引不是一种新的索引,而是而是一种数据存储方式。聚簇表示数据行和相邻的键值紧凑地存储在一起。我们熟悉的两种存储引擎——MyISAM 采用的是非聚簇索引,InnoDB 采用的是聚簇索引。

可以这么说:

  • 索引的数据结构是树,聚簇索引的索引和数据存储在一棵树上,树的叶子节点就是数据,非聚簇索引索引和数据不在一棵树上。
  • 一个表中只能拥有一个聚簇索引,而非聚簇索引一个表可以存在多个。
  • 聚簇索引,索引中键值的逻辑顺序决定了表中相应行的物理顺序;索引,索引中索引的逻辑顺序与磁盘上行的物理存储顺序不同。
  • 聚簇索引:物理存储按照索引排序;非聚集索引:物理存储不按照索引排序;

回表了解吗?

在 InnoDB 存储引擎里,利用辅助索引查询,先通过辅助索引找到主键索引的键值,再通过主键值查出主键索引里面没有符合要求的数据,它比基于主键索引的查询多扫描了一棵索引树,这个过程就叫回表。

例如:select \* from user where name = ‘张三’;

为了减少回表操作的次数,可以采用覆盖索引(Covering Index)或联合索引(Composite Index)等优化手段来尽可能地利用索引提供的信息,避免回表操作。

覆盖索引了解吗?

在辅助索引里面,不管是单列索引还是联合索引,如果 select 的数据列只用辅助索引中就能够取得,不用去查主键索引,这时候使用的索引就叫做覆盖索引,避免了回表。

比如,select name from user where name = ‘张三’;

MySQL 中有哪几种锁,列举一下?

如果按锁粒度划分,有以下 3 种:

  1. 表锁:开销小,加锁快;锁定力度大,发生锁冲突概率高,并发度最低;不会出现死锁。
  2. 行锁:开销大,加锁慢;会出现死锁;锁定粒度小,发生锁冲突的概率低,并发度高。
  3. 页锁:开销和加锁速度介于表锁和行锁之间;会出现死锁;锁定粒度介于表锁和行锁之间,并发度一般

如果按照兼容性,有两种,

  1. 共享锁(S Lock),也叫读锁(read lock),相互不阻塞。
  2. 排他锁(X Lock),也叫写锁(write lock),排它锁是阻塞的,在一定时间内,只有一个请求能执行写入,并阻止其它锁读取正在写入的数据。

说说 InnoDB 里的行锁实现?

我们拿这么一个用户表来表示行级锁,其中插入了 4 行数据,主键值分别是 1,6,8,12,现在简化它的聚簇索引结构,只保留数据记录。

InnoDB 的行锁的主要实现如下:

  • Record Lock 记录锁

记录锁就是直接锁定某行记录。当我们使用唯一性的索引(包括唯一索引和聚簇索引)进行等值查询且精准匹配到一条记录时,此时就会直接将这条记录锁定。例如select * from t where id =6 for update;就会将id=6的记录锁定

  • Gap Lock 间隙锁

间隙锁(Gap Locks) 的间隙指的是两个记录之间逻辑上尚未填入数据的部分,是一个左开右开空间。

间隙锁就是锁定某些间隙区间的。当我们使用用等值查询或者范围查询,并且没有命中任何一个record,此时就会将对应的间隙区间锁定。例如select * from t where id =3 for update;或者select * from t where id > 1 and id < 6 for update;就会将(1,6)区间锁定。

  • Next-key Lock 临键锁

临键指的是间隙加上它右边的记录组成的左开右闭区间。比如上述的(1,6]、(6,8]等。

临键锁就是记录锁(Record Locks)和间隙锁(Gap Locks)的结合,即除了锁住记录本身,还要再锁住索引之间的间隙。当我们使用范围查询,并且命中了部分record记录,此时锁住的就是临键区间。注意,临键锁锁住的区间会包含最后一个 record 的右边的临键区间。例如select * from t where id > 5 and id <= 7 for update;会锁住(4,7]、(7,+∞)。mysql 默认行锁类型就是临键锁(Next-Key Locks)。当使用唯一性索引,等值查询匹配到一条记录的时候,临键锁(Next-Key Locks)会退化成记录锁;没有匹配到任何记录的时候,退化成间隙锁。

间隙锁(Gap Locks)和临键锁(Next-Key Locks)都是用来解决幻读问题的,在已提交读(READ COMMITTED)隔离级别下,间隙锁(Gap Locks)和临键锁(Next-Key Locks)都会失效!

上面是行锁的三种实现算法,除此之外,在行上还存在插入意向锁。

  • Insert Intention Lock 插入意向锁

一个事务在插入一条记录时需要判断一下插入位置是不是被别的事务加了意向锁 ,如果有的话,插入操作需要等待,直到拥有 gap 锁 的那个事务提交。但是事务在等待的时候也需要在内存中生成一个 锁结构 ,表明有事务想在某个 间隙 中插入新记录,但是现在在等待。这种类型的锁命名为 Insert Intention Locks ,也就是插入意向锁 。

假如我们有个 T1 事务,给(1,6)区间加上了意向锁,现在有个 T2 事务,要插入一个数据,id 为 4,它会获取一个(1,6)区间的插入意向锁,又有有个 T3 事务,想要插入一个数据,id 为 3,它也会获取一个(1,6)区间的插入意向锁,但是,这两个插入意向锁锁不会互斥。

意向锁是什么知道吗?

意向锁是一个表级锁,不要和插入意向锁搞混。

意向锁的出现是为了支持 InnoDB 的多粒度锁,它解决的是表锁和行锁共存的问题。

当我们需要给一个表加表锁的时候,我们需要根据去判断表中有没有数据行被锁定,以确定是否能加成功。

假如没有意向锁,那么我们就得遍历表中所有数据行来判断有没有行锁;

有了意向锁这个表级锁之后,则我们直接判断一次就知道表中是否有数据行被锁定了。

有了意向锁之后,要执行的事务 A 在申请行锁(写锁)之前,数据库会自动先给事务 A 申请表的意向排他锁。当事务 B 去申请表的互斥锁时就会失败,因为表上有意向排他锁之后事务 B 申请表的互斥锁时会被阻塞。

MySQL 事务的四大特性说一下?

  • 原子性:事务作为一个整体被执行,包含在其中的对数据库的操作要么全部被执行,要么都不执行。
  • 一致性:指在事务开始之前和事务结束以后,数据不会被破坏,假如 A 账户给 B 账户转 10 块钱,不管成功与否,A 和 B 的总金额是不变的。
  • 隔离性:多个事务并发访问时,事务之间是相互隔离的,即一个事务不影响其它事务运行效果。简言之,就是事务之间是进水不犯河水的。
  • 持久性:表示事务完成以后,该事务对数据库所作的操作更改,将持久地保存在数据库之中。

那 ACID 靠什么保证的呢?

  1. 事务的隔离性是通过数据库锁的机制实现的。
  2. 事务的一致性由 undo log 来保证:undo log 是逻辑日志,记录了事务的 insert、update、deltete 操作,回滚的时候做相反的 delete、update、insert 操作来恢复数据。
  3. 事务的原子性和持久性由 redo log 来保证:redolog 被称作重做日志,是物理日志,事务提交的时候,必须先将事务的所有日志写入 redo log 持久化,到事务的提交操作才算完成。

事务的隔离级别有哪些?MySQL 的默认隔离级别是什么?

事务的四个隔离级别

  • 读未提交(Read Uncommitted)
  • 读已提交(Read Committed)
  • 可重复读(Repeatable Read)
  • 串行化(Serializable)

MySQL 默认的事务隔离级别是可重复读 (Repeatable Read)。

什么是幻读,脏读,不可重复读呢?

事务 A、B 交替执行,事务 A 读取到事务 B 未提交的数据,这就是脏读。
在一个事务范围内,两个相同的查询,读取同一条记录,却返回了不同的数据,这就是不可重复读。
事务 A 查询一个范围的结果集,另一个并发事务 B 往这个范围中插入 / 删除了数据,并静悄悄地提交,然后事务 A 再次查询相同的范围,两次读取得到的结果集不一样了,这就是幻读。

不同的隔离级别,在并发事务下可能会发生的问题:

隔离级别 脏读 不可重复读 幻读
Read Uncommited 读取未提交
Read Commited 读取已提交
Repeatable Read 可重复读
Serialzable 可串行化

事务的各个隔离级别都是如何实现的?

读未提交

读未提交,就不用多说了,采取的是读不加锁原理。

  • 事务读不加锁,不阻塞其他事务的读和写
  • 事务写阻塞其他事务写,但不阻塞其他事务读;

读取已提交&可重复读

读取已提交和可重复读级别利用了ReadView和MVCC,也就是每个事务只能读取它能看到的版本(ReadView)。

  • READ COMMITTED:每次读取数据前都生成一个 ReadView
  • REPEATABLE READ :在第一次读取数据时生成一个 ReadView

串行化

  • 串行化的实现采用的是读写都加锁的原理。

串行化的情况下,对于同一行事务,写会加写锁,读会加读锁。当出现读写锁冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行。

MVCC 了解吗?怎么实现的?

如何理解MySQL的MVCC多版本并发控制

数据库读写分离了解吗?

读写分离的基本原理是将数据库读写操作分散到不同的节点上,下面是基本架构图:

读写分离的基本实现是:

  1. 数据库服务器搭建主从集群,一主一从、一主多从都可以。
  2. 数据库主机负责读写操作,从机只负责读操作。
  3. 数据库主机通过复制将数据同步到从机,每台数据库服务器都存储了所有的业务数据。
  4. 业务服务器将写操作发给数据库主机,将读操作发给数据库从机。

主从复制原理了解吗?

  1. master 数据写入,更新 binlog
  2. master 创建一个 dump 线程向 slave 推送 binlog
  3. slave 连接到 master 的时候,会创建一个 IO 线程接收 binlog,并记录到 relay log 中继日志中
  4. slave 再开启一个 sql 线程读取 relay log 事件并在 slave 执行,完成同步
  5. slave 记录自己的 binglog

主从同步延迟怎么处理?

主从同步延迟的原因

一个服务器开放N个链接给客户端来连接的,这样有会有大并发的更新操作, 但是从服务器的里面读取 binlog 的线程仅有一个,当某个 SQL 在从服务器上执行的时间稍长 或者由于某个 SQL 要进行锁表就会导致,主服务器的 SQL 大量积压,未被同步到从服务器里。这就导致了主从不一致, 也就是主从延迟。

主从同步延迟的解决办法

解决主从复制延迟有几种常见的方法:

  1. 写操作后的读操作指定发给数据库主服务器

例如,注册账号完成后,登录时读取账号的读操作也发给数据库主服务器。这种方式和业务强绑定,对业务的侵入和影响较大,如果哪个新来的程序员不知道这样写代码,就会导致一个 bug。

  1. 读从机失败后再读一次主机

这就是通常所说的 "二次读取" ,二次读取和业务无绑定,只需要对底层数据库访问的 API 进行封装即可,实现代价较小,不足之处在于如果有很多二次读取,将大大增加主机的读操作压力。例如,黑客暴力破解账号,会导致大量的二次读取操作,主机可能顶不住读操作的压力从而崩溃。

  1. 关键业务读写操作全部指向主机,非关键业务采用读写分离

例如,对于一个用户管理系统来说,注册 + 登录的业务读写操作全部访问主机,用户的介绍、爰好、等级等业务,可以采用读写分离,因为即使用户改了自己的自我介绍,在查询时却看到了自我介绍还是旧的,业务影响与不能登录相比就小很多,还可以忍受。

一般是怎么分库、分表的呢?

  • 垂直分库:以表为依据,按照业务归属不同,将不同的表拆分到不同的库中。

  • 水平分库:以字段为依据,按照一定策略(hash、range 等),将一个库中的数据拆分到多个库中。

  • 垂直分表:以字段为依据,按照字段的活跃性,将表中字段拆到不同的表(主表和扩展表)中。

  • 水平分表:以字段为依据,按照一定策略(hash、range 等),将一个表中的数据拆分到多个表中。

水平分表有哪几种路由方式?

水平分表主要有三种路由方式:

  1. 范围路由:选取有序的数据列 (例如,整形、时间戳等) 作为路由的条件,不同分段分散到不同的数据库表中。

我们可以观察一些支付系统,发现只能查一年范围内的支付记录,这个可能就是支付公司按照时间进行了分表。

范围路由设计的复杂点主要体现在分段大小的选取上,分段太小会导致切分后子表数量过多,增加维护复杂度;分段太大可能会导致单表依然存在性能问题,一般建议分段大小在 100 万至 2000 万之间,具体需要根据业务选取合适的分段大小。

范围路由的优点是可以随着数据的增加平滑地扩充新的表。例如,现在的用户是 100 万,如果增加到 1000 万,只需要增加新的表就可以了,原有的数据不需要动。范围路由的一个比较隐含的缺点是分布不均匀,假如按照 1000 万来进行分表,有可能某个分段实际存储的数据量只有 1000 条,而另外一个分段实际存储的数据量有 900 万条。

  1. Hash 路由:选取某个列 (或者某几个列组合也可以) 的值进行 Hash 运算,然后根据 Hash 结果分散到不同的数据库表中。

同样以订单 id 为例,假如我们一开始就规划了 4 个数据库表,路由算法可以简单地用 id % 4 的值来表示数据所属的数据库表编号,id 为 12 的订单放到编号为 50 的子表中,id 为 13 的订单放到编号为 61 的字表中。

Hash 路由设计的复杂点主要体现在初始表数量的选取上,表数量太多维护比较麻烦,表数量太少又可能导致单表性能存在问题。而用了 Hash 路由后,增加子表数量是非常麻烦的,所有数据都要重分布。Hash 路由的优缺点和范围路由基本相反,Hash 路由的优点是表分布比较均匀,缺点是扩充新的表很麻烦,所有数据都要重分布。

  1. 配置路由:配置路由就是路由表,用一张独立的表来记录路由信息。同样以订单 id 为例,我们新增一张 order_router 表,这个表包含 orderjd 和 tablejd 两列 , 根据 orderjd 就可以查询对应的 table_id。

配置路由设计简单,使用起来非常灵活,尤其是在扩充表的时候,只需要迁移指定的数据,然后修改路由表就可以了。

配置路由的缺点就是必须多查询一次,会影响整体性能;而且路由表本身如果太大(例如,几亿条数据) ,性能同样可能成为瓶颈,如果我们再次将路由表分库分表,则又面临一个死循环式的路由算法选择问题。

不停机扩容怎么实现?

实际上,不停机扩容,实操起来是个非常麻烦而且很有风险的操作

  1. 第一阶段:在线双写,查询走老库

    • 建立好新的库表结构,数据写入久库的同时,也写入拆分的新库
    • 数据迁移,使用数据迁移程序,将旧库中的历史数据迁移到新库
    • 使用定时任务,新旧库的数据对比,把差异补齐

  1. 第二阶段:在线双写,查询走新库

    • 完成了历史数据的同步和校验
    • 把对数据的读切换到新库

  1. 第三阶段:旧库下线

    • 旧库不再写入新的数据
    • 经过一段时间,确定旧库没有请求之后,就可以下线老库

分库分表会带来什么问题呢?

从分库的角度来讲:

  1. 事务的问题

使用关系型数据库,有很大一点在于它保证事务完整性。

而分库之后单机事务就用不上了,必须使用分布式事务来解决。

  1. 跨库 JOIN 问题

在一个库中的时候我们还可以利用 JOIN 来连表查询,而跨库了之后就无法使用 JOIN 了。

此时的解决方案就是在业务代码中进行关联,也就是先把一个表的数据查出来,然后通过得到的结果再去查另一张表,然后利用代码来关联得到最终的结果。

这种方式实现起来稍微比较复杂,不过也是可以接受的。

还有可以适当的冗余一些字段。比如以前的表就存储一个关联 ID,但是业务时常要求返回对应的 Name 或者其他字段。这时候就可以把这些字段冗余到当前表中,来去除需要关联的操作。

还有一种方式就是数据异构,通过 binlog 同步等方式,把需要跨库 join 的数据异构到 ES 等存储结构中,通过 ES 进行查询。

从分表的角度来看:

  1. 跨节点的 count,order by,group by 以及聚合函数问题

只能由业务代码来实现或者用中间件将各表中的数据汇总、排序、分页然后返回。

  1. 数据迁移,容量规划,扩容等问题

数据的迁移,容量如何规划,未来是否可能再次需要扩容,等等,都是需要考虑的问题。

  1. ID 问题

数据库表被切分后,不能再依赖数据库自身的主键生成机制,所以需要一些手段来保证全局主键唯一。

  • 还是自增,只不过自增步长设置一下。比如现在有三张表,步长设置为 3,三张表 ID 初始值分别是 1、2、3。这样第一张表的 ID 增长是 1、4、7。第二张表是 2、5、8。第三张表是 3、6、9,这样就不会重复了。
  • UUID,这种最简单,但是不连续的主键插入会导致严重的页分裂,性能比较差。
  • 分布式 ID,比较出名的就是 Twitter 开源的 sonwflake 雪花算法

百万级别以上的数据如何删除?

关于索引:由于索引需要额外的维护成本,因为索引文件是单独存在的文件,所以当我们对数据的增加,修改,删除,都会产生额外的对索引文件的操作,这些操作需要消耗额外的 IO,会降低增/改/删的执行效率。

所以,在我们删除数据库百万级别数据的时候,查询 MySQL 官方手册得知删除数据的速度和创建的索引数量是成正比的。

  1. 所以我们想要删除百万数据的时候可以先删除索引
  2. 然后删除其中无用数据
  3. 删除完成后重新创建索引创建索引也非常快

百万千万级大表如何添加字段?

当线上的数据库数据量到达几百万、上千万的时候,加一个字段就没那么简单,因为可能会长时间锁表。

大表添加字段,通常有这些做法:

  • 通过中间表转换过去

创建一个临时的新表,把旧表的结构完全复制过去,添加字段,再把旧表数据复制过去,删除旧表,新表命名为旧表的名称,这种方式可能回丢掉一些数据。

  • 用 pt-online-schema-change

pt-online-schema-change是 percona 公司开发的一个工具,它可以在线修改表结构,它的原理也是通过中间表。

  • 先在从库添加 再进行主从切换

如果一张表数据量大且是热表(读写特别频繁),则可以考虑先在从库添加,再进行主从切换,切换后再将其他几个节点上添加字段。

RocketMQ

RocketMQ的消息模型

RocketMQ使用的消息模型是标准的发布-订阅模型,在RocketMQ的术语表中,生产者、消费者和主题,与发布-订阅模型中的概念是完全一样的。

RocketMQ本身的消息是由下面几部分组成

  • Message

Message(消息)就是要传输的信息。

一条消息必须有一个主题(Topic),主题可以看做是你的信件要邮寄的地址。
一条消息也可以拥有一个可选的标签(Tag)和额处的键值对,它们可以用于设置一个业务 Key 并在 Broker 上查找此消息以便在开发期间查找问题。

  • Topic

Topic(主题)可以看做消息的归类,它是消息的第一级类型。比如一个电商系统可以分为:交易消息、物流消息等,一条消息必须有一个 Topic 。

Topic 与生产者和消费者的关系非常松散,一个 Topic 可以有0个、1个、多个生产者向其发送消息,一个生产者也可以同时向不同的 Topic 发送消息。

一个 Topic 也可以被 0个、1个、多个消费者订阅。

  • Tag

Tag(标签)可以看作子主题,它是消息的第二级类型,用于为用户提供额外的灵活性。使用标签,同一业务模块不同目的的消息就可以用相同 Topic 而不同的 Tag 来标识。比如交易消息又可以分为:交易创建消息、交易完成消息等,一条消息可以没有 Tag 。

标签有助于保持你的代码干净和连贯,并且还可以为 RocketMQ 提供的查询系统提供帮助。

  • Group

RocketMQ中,订阅者的概念是通过消费组(Consumer Group)来体现的。每个消费组都消费主题中一份完整的消息,不同消费组之间消费进度彼此不受影响,也就是说,一条消息被Consumer Group1消费过,也会再给Consumer Group2消费。

消费组中包含多个消费者,同一个组内的消费者是竞争消费的关系,每个消费者负责消费组内的一部分消息。默认情况,如果一条消息被消费者Consumer1消费了,那同组的其他消费者就不会再收到这条消息。

  • Message Queue

Message Queue(消息队列),一个 Topic 下可以设置多个消息队列,Topic 包括多个 Message Queue ,如果一个 Consumer 需要获取 Topic下所有的消息,就要遍历所有的 Message Queue。

RocketMQ还有一些其它的Queue——例如ConsumerQueue。

  • Offset

在Topic的消费过程中,由于消息需要被不同的组进行多次消费,所以消费完的消息并不会立即被删除,这就需要RocketMQ为每个消费组在每个队列上维护一个消费位置(Consumer Offset),这个位置之前的消息都被消费过,之后的消息都没有被消费过,每成功消费一条消息,消费位置就加一。

也可以这么说,Queue 是一个长度无限的数组,Offset 就是下标。

RocketMQ的消息模型中,这些就是比较关键的概念了。画张图总结一下:

消息的消费模式有哪些?

消息消费模式有两种:Clustering(集群消费)和Broadcasting(广播消费)

默认情况下就是集群消费,这种模式下一个消费者组共同消费一个主题的多个队列,一个队列只会被一个消费者消费,如果某个消费者挂掉,分组内其它消费者会接替挂掉的消费者继续消费。

而广播消费消息会发给消费者组中的每一个消费者进行消费。

说一下RoctetMQ基本架构?

RocketMQ的基本架构:

RocketMQ 一共有四个部分组成:NameServer,Broker,Producer 生产者,Consumer 消费者,它们对应了:发现、发、存、收,为了保证高可用,一般每一部分都是集群部署的。

  • NameServer

NameServer 是一个无状态的服务器,角色类似于 Kafka使用的 Zookeeper,但比 Zookeeper 更轻量。

特点:

  1. 每个 NameServer 结点之间是相互独立,彼此没有任何信息交互。
  2. Nameserver 被设计成几乎是无状态的,通过部署多个结点来标识自己是一个伪集群,Producer 在发送消息前从 NameServer 中获取 Topic 的路由信息也就是发往哪个 Broker,Consumer 也会定时从 NameServer 获取 Topic 的路由信息,Broker 在启动时会向 NameServer 注册,并定时进行心跳连接,且定时同步维护的 Topic 到 NameServer。

功能主要有两个:

  1. 和Broker 结点保持长连接。
  2. 维护 Topic 的路由信息。
  • Broker

消息存储和中转角色,负责存储和转发消息。

Broker 内部维护着一个个 Consumer Queue,用来存储消息的索引,真正存储消息的地方是 CommitLog(日志文件)。

单个 Broker 与所有的 Nameserver 保持着长连接和心跳,并会定时将 Topic 信息同步到 NameServer,和 NameServer 的通信底层是通过 Netty 实现的。

  • Producer

消息生产者,业务端负责发送消息,由用户自行实现和分布式部署。

Producer由用户进行分布式部署,消息由Producer通过多种负载均衡模式发送到Broker集群,发送低延时,支持快速失败。

RocketMQ 提供了三种方式发送消息:同步、异步和单向

  1. 同步发送:同步发送指消息发送方发出数据后会在收到接收方发回响应之后才发下一个数据包。一般用于重要通知消息,例如重要通知邮件、营销短信。
  2. 异步发送:异步发送指发送方发出数据后,不等接收方发回响应,接着发送下个数据包,一般用于可能链路耗时较长而对响应时间敏感的业务场景,例如用户视频上传后通知启动转码服务。
  3. 单向发送:单向发送是指只负责发送消息而不等待服务器回应且没有回调函数触发,适用于某些耗时非常短但对可靠性要求并不高的场景,例如日志收集。
  • Consumer

消息消费者,负责消费消息,一般是后台系统负责异步消费。

Consumer也由用户部署,支持PUSH和PULL两种消费模式,支持集群消费和广播消费,提供实时的消息订阅机制。

  1. Pull:拉取型消费者(Pull Consumer)主动从消息服务器拉取信息,只要批量拉取到消息,用户应用就会启动消费过程,所以 Pull 称为主动消费型。
  2. Push:推送型消费者(Push Consumer)封装了消息的拉取、消费进度和其他的内部维护工作,将消息到达时执行的回调接口留给用户应用程序来实现。所以 Push 称为被动消费类型,但其实从实现上看还是从消息服务器中拉取消息,不同于 Pull 的是 Push 首先要注册消费监听器,当监听器处触发后才开始消费消息。

如何保证消息的可用性/可靠性/不丢失呢?

消息可能在哪些阶段丢失呢?可能会在这三个阶段发生丢失:生产阶段、存储阶段、消费阶段。

所以要从这三个阶段考虑

生产

在生产阶段,主要通过请求确认机制,来保证消息的可靠传递。

  1. 同步发送的时候,要注意处理响应结果和异常。如果返回响应OK,表示消息成功发送到了Broker,如果响应失败,或者发生其它异常,都应该重试。
  2. 异步发送的时候,应该在回调方法里检查,如果发送失败或者异常,都应该进行重试。
  3. 如果发生超时的情况,也可以通过查询日志的API,来检查是否在Broker存储成功。

存储

存储阶段,可以通过配置可靠性优先的 Broker 参数来避免因为宕机丢消息,简单说就是可靠性优先的场景都应该使用同步。

  1. 消息只要持久化到CommitLog(日志文件)中,即使Broker宕机,未消费的消息也能重新恢复再消费。
  2. Broker的刷盘机制:同步刷盘和异步刷盘,不管哪种刷盘都可以保证消息一定存储在pagecache中(内存中),但是同步刷盘更可靠,它是Producer发送消息后等数据持久化到磁盘之后再返回响应给Producer。
  3. Broker通过主从模式来保证高可用,Broker支持Master和Slave同步复制、Master和Slave异步复制模式,生产者的消息都是发送给Master,但是消费既可以从Master消费,也可以从Slave消费。同步复制模式可以保证即使Master宕机,消息肯定在Slave中有备份,保证了消息不会丢失。
# master 节点配置
flushDiskType = SYNC_FLUSH
brokerRole=SYNC_MASTER

# slave 节点配置
brokerRole=slave
flushDiskType = SYNC_FLUSH

上面这个配置含义是:

Producer发消息到Broker后,Broker的Master节点先持久化到磁盘中,然后同步数据给Slave节点,Slave节点同步完且落盘完成后才会返回给Producer说消息ok了。

消费

从Consumer角度分析,如何保证消息被成功消费?

Consumer保证消息成功消费的关键在于确认的时机,不要在收到消息后就立即发送消费确认,而是应该在执行完所有消费业务逻辑之后,再发送消费确认。因为消息队列维护了消费的位置,逻辑执行失败了,没有确认,再去队列拉取消息,就还是之前的一条。

consumer.registerMessageListener(new MessageListenerConcurrently() {
     @Override
     public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
         for (MessageExt msg : msgs) {
             String str = new String(msg.getBody());
             System.out.println(str);
         }
         // ack,只有等上面一系列逻辑都处理完后,到这步CONSUME_SUCCESS才会通知broker说消息消费完成,如果上面发生异常没有走到这步ack,则消息还是未消费状态。而不是像比如redis的blpop,弹出一个数据后数据就从redis里消失了,并没有等我们业务逻辑执行完才弹出。
         return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
     }
 });

如何处理消息重复的问题呢?

对分布式消息队列来说,同时做到确保一定投递和不重复投递是很难的,就是所谓的“有且仅有一次” 。RocketMQ择了确保一定投递,保证消息不丢失,但有可能造成消息重复。

处理消息重复问题,主要有业务端自己保证,主要的方式有两种:业务幂等和消息去重。

业务幂等:第一种是保证消费逻辑的幂等性,也就是多次调用和一次调用的效果是一样的。这样一来,不管消息消费多少次,对业务都没有影响。

消息去重:第二种是业务端,对重复的消息就不再消费了。这种方法,需要保证每条消息都有一个惟一的编号,通常是业务相关的,比如订单号,消费的记录需要落库,而且需要保证和消息确认这一步的原子性。

具体做法是可以建立一个消费记录表,拿到这个消息做数据库的insert操作。给这个消息做一个唯一主键(primary key)或者唯一约束,那么就算出现重复消费的情况,就会导致主键冲突,那么就不再处理这条消息。

怎么处理消息积压?

发生了消息积压,这时候就得想办法赶紧把积压的消息消费完,就得考虑提高消费能力,一般有两种办法:

  1. 消费者扩容:如果当前Topic的Message Queue的数量大于消费者数量,就可以对消费者进行扩容,增加消费者,来提高消费能力,尽快把积压的消息消费玩。
  2. 消息迁移Queue扩容:如果当前Topic的Message Queue的数量小于或者等于消费者数量,这种情况,再扩容消费者就没什么用,就得考虑扩容Message Queue。可以新建一个临时的Topic,临时的Topic多设置一些Message Queue,然后先用一些消费者把消费的数据丢到临时的Topic,因为不用业务处理,只是转发一下消息,还是很快的。接下来用扩容的消费者去消费新的Topic里的数据,消费完了之后,恢复原状。

顺序消息如何实现?

顺序消息是指消息的消费顺序和产生顺序相同,在有些业务逻辑下,必须保证顺序,比如订单的生成、付款、发货,这个消息必须按顺序处理才行。

顺序消息分为全局顺序消息和部分顺序消息,全局顺序消息指某个 Topic 下的所有消息都要保证顺序;

部分顺序消息只要保证每一组消息被顺序消费即可,比如订单消息,只要保证同一个订单 ID 个消息能按顺序消费即可。

部分顺序消息

部分顺序消息相对比较好实现,生产端需要做到把同 ID 的消息发送到同一个 Message Queue ;在消费过程中,要做到从同一个Message Queue读取的消息顺序处理——消费端不能并发处理顺序消息,这样才能达到部分有序。

发送端使用 MessageQueueSelector 类来控制 把消息发往哪个 Message Queue 。


public class OrderMessageQueueSelector implements MessageQueueSelector {
    @Override
    public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
        String orderId = (String) arg;
        int index = Math.abs(orderId.hashCode()) % mqs.size();
        return mqs.get(index);
    }
}


该方法接收三个参数:

  1. mqs:消息队列集合;
  2. msg:要发送的消息;
  3. arg:附加参数,可以用来传递业务相关的信息。

在send消息的时候传入MessageQueueSelector

消费端通过使用 MessageListenerOrderly 来解决单 Message Queue 的消息被并发处理的问题。MessageListenerOrderly接口继承自MessageListener,因此在实现该接口时需要实现onMessage方法。与MessageListener不同的是,MessageListenerOrderly接口的onMessage方法只会被一个线程执行,因此可以保证单个消息队列中的消息是顺序处理的。

全局顺序消息

RocketMQ 默认情况下不保证顺序,比如创建一个 Topic ,默认八个写队列,八个读队列,这时候一条消息可能被写入任意一个队列里;在数据的读取过程中,可能有多个 Consumer ,每个 Consumer 也可能启动多个线程并行处理,所以消息被哪个 Consumer 消费,被消费的顺序和写人的顺序是否一致是不确定的。

要保证全局顺序消息, 需要先把 Topic 的读写队列数设置为 一,然后Producer Consumer 的并发设置,也要是一。简单来说,为了保证整个 Topic全局消息有序,只能消除所有的并发处理,各部分都设置成单线程处理 ,这时候就完全牺牲RocketMQ的高并发、高吞吐的特性了。

延时消息了解吗?

RocketMQ是支持延时消息的,只需要在生产消息的时候设置消息的延时级别:

// 实例化一个生产者来产生延时消息
DefaultMQProducer producer = new DefaultMQProducer("ExampleProducerGroup");
// 启动生产者
producer.start();
int totalMessagesToSend = 100;
for (int i = 0; i < totalMessagesToSend; i++) {
    Message message = new Message("TestTopic", ("Hello scheduled message " + i).getBytes());
    // 设置延时等级3,这个消息将在10s之后发送(现在只支持固定的几个时间,详看delayTimeLevel)
    message.setDelayTimeLevel(3);
    // 发送消息
    producer.send(message);
}

RocketMQ通过临时存储+定时任务来实现延时消息

Broker收到延时消息了,会先发送到主题(SCHEDULE_TOPIC_XXXX)的相应时间段的Message Queue中,然后通过一个定时任务轮询这些队列,到期后,把消息投递到目标Topic的队列中,然后消费者就可以正常消费这些消息。

怎么实现事务型消息?

半消息:是指暂时还不能被 Consumer 消费的消息,Producer 成功发送到 Broker 端的消息,但是此消息被标记为 “暂不可投递” 状态,只有等 Producer 端执行完本地事务后经过二次确认了之后,Consumer 才能消费此条消息。

依赖半消息,可以实现分布式消息事务,其中的关键在于二次确认以及消息回查:

  1. Producer 向 broker 发送半消息
  2. Producer 端收到响应,消息发送成功,此时消息是半消息,标记为 “不可投递” 状态,Consumer 消费不了。
  3. Producer 端执行本地事务。
  4. 正常情况本地事务执行完成,Producer 向 Broker 发送 Commit/Rollback,如果是 Commit,Broker 端将半消息标记为正常消息,Consumer 可以消费,如果是 Rollback,Broker 丢弃此消息。
  5. 异常情况,Broker 端迟迟等不到二次确认。在一定时间后,会查询所有的半消息,然后到 Producer 端查询半消息的执行情况。
  6. Producer 端查询本地事务的状态
  7. 根据事务的状态提交 commit/rollback 到 broker 端。(5,6,7 是消息回查)
  8. 消费者段消费到消息之后,执行本地事务,执行本地事务。

死信队列知道吗?

死信队列用于处理无法被正常消费的消息,即死信消息。

当一条消息初次消费失败,消息队列 RocketMQ 会自动进行消息重试;达到最大重试次数后,若消费依然失败,则表明消费者在正常情况下无法正确地消费该消息,此时,消息队列 RocketMQ 不会立刻将消息丢弃,而是将其发送到该消费者对应的特殊队列中,该特殊队列称为死信队列。

死信消息的特点:

  • 不会再被消费者正常消费。
  • 有效期与正常消息相同,均为 3 天,3 天后会被自动删除。因此,需要在死信消息产生后的 3 天内及时处理。

死信队列的特点:

  • 一个死信队列对应一个 Group ID, 而不是对应单个消费者实例。
  • 如果一个 Group ID 未产生死信消息,消息队列 RocketMQ 不会为其创建相应的死信队列。
  • 一个死信队列包含了对应 Group ID 产生的所有死信消息,不论该消息属于哪个 Topic。

RocketMQ 控制台提供对死信消息的查询、导出和重发的功能。

如何保证RocketMQ的高可用?

NameServer因为是无状态,且不相互通信的,所以只要集群部署就可以保证高可用。

RocketMQ的高可用主要是在体现在Broker的读和写的高可用,Broker的高可用是通过集群和主从实现的。

Broker可以配置两种角色:Master和Slave,Master角色的Broker支持读和写,Slave角色的Broker只支持读,Master会向Slave同步消息。

也就是说Producer只能向Master角色的Broker写入消息,Cosumer可以从Master和Slave角色的Broker读取消息。

Consumer 的配置文件中,并不需要设置是从 Master 读还是从 Slave读,当 Master 不可用或者繁忙的时候, Consumer 的读请求会被自动切换到从 Slave。有了自动切换 Consumer 这种机制,当一个 Master 角色的机器出现故障后,Consumer 仍然可以从 Slave 读取消息,不影响 Consumer 读取消息,这就实现了读的高可用。

如何达到发送端写的高可用性呢?在创建 Topic 的时候,把 Topic 的多个Message Queue 创建在多个 Broker 组上(相同 Broker 名称,不同 brokerId机器组成 Broker 组),这样当 Broker 组的 Master 不可用后,其他组Master 仍然可用, Producer 仍然可以发送消息 RocketMQ 目前还不支持把Slave自动转成 Master ,如果机器资源不足,需要把 Slave 转成 Master ,则要手动停止 Slave 色的 Broker ,更改配置文件,用新的配置文件启动 Broker。

说一下RocketMQ的整体工作流程?

简单来说,RocketMQ是一个分布式消息队列,也就是消息队列+分布式系统。

作为消息队列,它是发-存-收的一个模型,对应的就是Producer、Broker、Cosumer;作为分布式系统,它要有服务端、客户端、注册中心,对应的就是Broker、Producer/Consumer、NameServer

所以我们看一下它主要的工作流程:RocketMQ由NameServer注册中心集群、Producer生产者集群、Consumer消费者集群和若干Broker(RocketMQ进程)组成:

  1. Broker在启动的时候去向所有的NameServer注册,并保持长连接,每30s发送一次心跳
  2. Producer在发送消息的时候从NameServer获取Broker服务器地址,根据负载均衡算法选择一台服务器来发送消息
  3. Conusmer消费消息的时候同样从NameServer获取Broker地址,然后主动拉取消息来消费

Broker是怎么保存数据的呢?

RocketMQ主要的存储文件包括CommitLog文件、ConsumeQueue文件、Indexfile文件。

消息存储的整体的设计:

  • CommitLog:消息主体以及元数据的存储主体,存储Producer端写入的消息主体内容,消息内容不是定长的。单个文件大小默认1G, 文件名长度为20位,左边补零,剩余为起始偏移量,比如00000000000000000000代表了第一个文件,起始偏移量为0,文件大小为1G=1073741824;当第一个文件写满了,第二个文件为00000000001073741824,起始偏移量为1073741824,以此类推。消息主要是顺序写入日志文件,当文件满了,写入下一个文件。

CommitLog文件保存于${Rocket_Home}/store/commitlog目录中,从图中我们可以明显看出来文件名的偏移量,每个文件默认1G,写满后自动生成一个新的文件。

  • ConsumeQueue:消息消费队列,引入的目的主要是提高消息消费的性能,由于RocketMQ是基于主题topic的订阅模式,消息消费是针对主题进行的,如果要遍历commitlog文件中根据topic检索消息是非常低效的。

Consumer即可根据ConsumeQueue来查找待消费的消息。其中,ConsumeQueue(逻辑消费队列)作为消费消息的索引,保存了指定Topic下的队列消息在CommitLog中的起始物理偏移量offset,消息大小size和消息Tag的HashCode值。

ConsumeQueue文件可以看成是基于Topic的CommitLog索引文件,故ConsumeQueue文件夹的组织方式如下:topic/queue/file三层组织结构,具体存储路径为:$HOME/store/consumequeue/{topic}/{queueId}/{fileName}。同样ConsumeQueue文件采取定长设计,每一个条目共20个字节,分别为8字节的CommitLog物理偏移量、4字节的消息长度、8字节tag hashcode,单个文件由30W个条目组成,可以像数组一样随机访问每一个条目,每个ConsumeQueue文件大小约5.72M;

  • IndexFile:IndexFile(索引文件)提供了一种可以通过key或时间区间来查询消息的方法。Index文件的存储位置是: {fileName},文件名fileName是以创建时的时间戳命名的,固定的单个IndexFile文件大小约为400M,一个IndexFile可以保存 2000W个索引,IndexFile的底层存储设计为在文件系统中实现HashMap结构,故RocketMQ的索引文件其底层实现为hash索引。

总结一下:RocketMQ采用的是混合型的存储结构,即为Broker单个实例下所有的队列共用一个日志数据文件(即为CommitLog)来存储。

RocketMQ的混合型存储结构(多个Topic的消息实体内容都存储于一个CommitLog中)针对Producer和Consumer分别采用了数据和索引部分相分离的存储结构,Producer发送消息至Broker端,然后Broker端使用同步或者异步的方式对消息刷盘持久化,保存至CommitLog中。

只要消息被刷盘持久化至磁盘文件CommitLog中,那么Producer发送的消息就不会丢失。正因为如此,Consumer也就肯定有机会去消费这条消息。当无法拉取到消息后,可以等下一次消息拉取,同时服务端也支持长轮询模式,如果一个消息拉取请求未拉取到消息,Broker允许等待30s的时间,只要这段时间内有新消息到达,将直接返回给消费端。

这里,RocketMQ的具体做法是,使用Broker端的后台服务线程—ReputMessageService不停地分发请求并异步构建ConsumeQueue(逻辑消费队列)和IndexFile(索引文件)数据。

说说RocketMQ怎么对文件进行读写的?

RocketMQ对文件的读写巧妙地利用了操作系统的一些高效文件读写方式——PageCache顺序读写零拷贝

  • PageCache、顺序读取

在RocketMQ中,ConsumeQueue逻辑消费队列存储的数据较少,并且是顺序读取,在page cache机制的预读取作用下,Consume Queue文件的读性能几乎接近读内存,即使在有消息堆积情况下也不会影响性能。而对于CommitLog消息存储的日志数据文件来说,读取消息内容时候会产生较多的随机访问读取,严重影响性能。如果选择合适的系统IO调度算法,比如设置调度算法为“Deadline”(此时块存储采用SSD的话),随机读的性能也会有所提升。

页缓存(PageCache)是OS对文件的缓存,用于加速对文件的读写。一般来说,程序对文件进行顺序读写的速度几乎接近于内存的读写速度,主要原因就是由于OS使用PageCache机制对读写访问操作进行了性能优化,将一部分的内存用作PageCache。对于数据的写入,OS会先写入至Cache内,随后通过异步的方式由pdflush内核线程将Cache内的数据刷盘至物理磁盘上。对于数据的读取,如果一次读取文件时出现未命中PageCache的情况,OS从物理磁盘上访问读取文件的同时,会顺序对其他相邻块的数据文件进行预读取。

  • 零拷贝

另外,RocketMQ主要通过MappedByteBuffer对文件进行读写操作。其中,利用了NIO中的FileChannel模型将磁盘上的物理文件直接映射到用户态的内存地址中(这种Mmap的方式减少了传统IO,将磁盘文件数据在操作系统内核地址空间的缓冲区,和用户应用程序地址空间的缓冲区之间来回进行拷贝的性能开销),将对文件的操作转化为直接对内存地址进行操作,从而极大地提高了文件的读写效率(正因为需要使用内存映射机制,故RocketMQ的文件存储都使用定长结构来存储,方便一次将整个文件映射至内存)。

说说什么是零拷贝?

在操作系统中,使用传统的方式,数据需要经历几次拷贝,还要经历用户态/内核态切换。

  1. 从磁盘复制数据到内核态内存;
  2. 从内核态内存复制到用户态内存;
  3. 然后从用户态内存复制到网络驱动的内核态内存;
  4. 最后是从网络驱动的内核态内存复制到网卡中进行传输。

所以,可以通过零拷贝的方式,减少用户态与内核态的上下文切换和内存拷贝的次数,用来提升I/O的性能。零拷贝比较常见的实现方式是mmap,这种机制在Java中是通过MappedByteBuffer实现的。

消息刷盘怎么实现的呢?

RocketMQ提供了两种刷盘策略:同步刷盘和异步刷盘

  • 同步刷盘:在消息达到Broker的内存之后,必须刷到commitLog日志文件中才算成功,然后返回Producer数据已经发送成功。
  • 异步刷盘:异步刷盘是指消息达到Broker内存后就返回Producer数据已经发送成功,会唤醒一个线程去将数据持久化到CommitLog日志文件中。

Broker 在消息的存取时直接操作的是内存(内存映射文件),这可以提供系统的吞吐量,但是无法避免机器掉电时数据丢失,所以需要持久化到磁盘中。

刷盘的最终实现都是使用NIO中的 MappedByteBuffer.force() 将映射区的数据写入到磁盘,如果是同步刷盘的话,在Broker把消息写到CommitLog映射区后,就会等待写入完成。

异步而言,只是唤醒对应的线程,不保证执行的时机,流程如图所示。

能说下RocketMQ的负载均衡是如何实现的?

RocketMQ中的负载均衡都在Client端完成,具体来说的话,主要可以分为Producer端发送消息时候的负载均衡和Consumer端订阅消息的负载均衡。

Producer的负载均衡

Producer端在发送消息的时候,会先根据Topic找到指定的TopicPublishInfo,在获取了TopicPublishInfo路由信息后,RocketMQ的客户端在默认方式下selectOneMessageQueue()方法会从TopicPublishInfo中的messageQueueList中选择一个队列(MessageQueue)进行发送消息。具这里有一个sendLatencyFaultEnable开关变量,如果开启,在随机递增取模的基础上,再过滤掉not available的Broker代理。

所谓的"latencyFaultTolerance",是指对之前失败的,按一定的时间做退避。例如,如果上次请求的latency超过550Lms,就退避3000Lms;超过1000L,就退避60000L;如果关闭,采用随机递增取模的方式选择一个队列(MessageQueue)来发送消息,latencyFaultTolerance机制是实现消息发送高可用的核心关键所在。

Consumer的负载均衡

在RocketMQ中,Consumer端的两种消费模式(Push/Pull)都是基于拉模式来获取消息的,而在Push模式只是对pull模式的一种封装,其本质实现为消息拉取线程在从服务器拉取到一批消息后,然后提交到消息消费线程池后,又“马不停蹄”的继续向服务器再次尝试拉取消息。如果未拉取到消息,则延迟一下又继续拉取。在两种基于拉模式的消费方式(Push/Pull)中,均需要Consumer端知道从Broker端的哪一个消息队列中去获取消息。因此,有必要在Consumer端来做负载均衡,即Broker端中多个MessageQueue分配给同一个ConsumerGroup中的哪些Consumer消费。

  • Consumer端的心跳包发送

在Consumer启动后,它就会通过定时任务不断地向RocketMQ集群中的所有Broker实例发送心跳包(其中包含了,消息消费分组名称、订阅关系集合、消息通信模式和客户端id的值等信息)。Broker端在收到Consumer的心跳消息后,会将它维护在ConsumerManager的本地缓存变量—consumerTable,同时并将封装后的客户端网络通道信息保存在本地缓存变量—channelInfoTable中,为之后做Consumer端的负载均衡提供可以依据的元数据信息。

  • Consumer端实现负载均衡的核心类—RebalanceImpl

在Consumer实例的启动流程中的启动MQClientInstance实例部分,会完成负载均衡服务线程—RebalanceService的启动(每隔20s执行一次)。

通过查看源码可以发现,RebalanceService线程的run()方法最终调用的是RebalanceImpl类的rebalanceByTopic()方法,这个方法是实现Consumer端负载均衡的核心。

rebalanceByTopic()方法会根据消费者通信类型为“广播模式”还是“集群模式”做不同的逻辑处理。这里主要来看下集群模式下的主要处理流程:

(1) 从rebalanceImpl实例的本地缓存变量—topicSubscribeInfoTable中,获取该Topic主题下的消息消费队列集合(mqSet);

(2) 根据topic和consumerGroup为参数调用mQClientFactory.findConsumerIdList()方法向Broker端发送通信请求,获取该消费组下消费者Id列表;

(3) 先对Topic下的消息消费队列、消费者Id排序,然后用消息队列分配策略算法(默认为:消息队列的平均分配算法),计算出待拉取的消息队列。这里的平均分配算法,类似于分页的算法,将所有MessageQueue排好序类似于记录,将所有消费端Consumer排好序类似页数,并求出每一页需要包含的平均size和每个页面记录的范围range,最后遍历整个range而计算出当前Consumer端应该分配到的的MessageQueue。

(4) 然后,调用updateProcessQueueTableInRebalance()方法,具体的做法是,先将分配到的消息队列集合(mqSet)与processQueueTable做一个过滤比对。

  • 上图中processQueueTable标注的红色部分,表示与分配到的消息队列集合mqSet互不包含。将这些队列设置Dropped属性为true,然后查看这些队列是否可以移除出processQueueTable缓存变量,这里具体执行removeUnnecessaryMessageQueue()方法,即每隔1s 查看是否可以获取当前消费处理队列的锁,拿到的话返回true。如果等待1s后,仍然拿不到当前消费处理队列的锁则返回false。如果返回true,则从processQueueTable缓存变量中移除对应的Entry;
  • 上图中processQueueTable的绿色部分,表示与分配到的消息队列集合mqSet的交集。判断该ProcessQueue是否已经过期了,在Pull模式的不用管,如果是Push模式的,设置Dropped属性为true,并且调用removeUnnecessaryMessageQueue()方法,像上面一样尝试移除Entry;
  • 最后,为过滤后的消息队列集合(mqSet)中的每个MessageQueue创建一个ProcessQueue对象并存入RebalanceImpl的processQueueTable队列中(其中调用RebalanceImpl实例的computePullFromWhere(MessageQueue mq)方法获取该MessageQueue对象的下一个进度消费值offset,随后填充至接下来要创建的pullRequest对象属性中),并创建拉取请求对象—pullRequest添加到拉取列表—pullRequestList中,最后执行dispatchPullRequest()方法,将Pull消息的请求对象PullRequest依次放入PullMessageService服务线程的阻塞队列pullRequestQueue中,待该服务线程取出后向Broker端发起Pull消息的请求。其中,可以重点对比下,RebalancePushImpl和RebalancePullImpl两个实现类的dispatchPullRequest()方法不同,RebalancePullImpl类里面的该方法为空。

消息消费队列在同一消费组不同消费者之间的负载均衡,其核心设计理念是在一个消息消费队列在同一时间只允许被同一消费组内的一个消费者消费,一个消息消费者能同时消费多个消息队列。

RocketMQ消息长轮询了解吗?

所谓的长轮询,就是Consumer 拉取消息,如果对应的 Queue 如果没有数据,Broker 不会立即返回,而是把 PullReuqest hold起来,等待 queue 有了消息后,或者长轮询阻塞时间到了,再重新处理该 queue 上的所有 PullRequest。

Netty

Netty是什么,它的主要特点是什么?

Netty是一个高性能、异步事件驱动的网络编程框架,它基于NIO技术实现,提供了简单易用的 API,用于构建各种类型的网络应用程序。其主要特点包括:

  • 高性能:Netty使用异步I/O,非阻塞式处理方式,可处理大量并发连接,提高系统性能。

  • 易于使用:Netty提供了高度抽象的API,可以快速构建各种类型的网络应用程序,如Web服务、消息推送、实时游戏等。

  • 灵活可扩展:Netty提供了许多可插拔的组件,可以根据需要自由组合,以满足各种业务场景。

Netty 应用场景了解么?

Netty 在网络编程中应用非常广泛,常用于开发高性能、高吞吐量、低延迟的网络应用程序,应用场景如下:

  • 服务器间高性能通信,比如RPC、HTTP、WebSocket等协议的实现

  • 分布式系统的消息传输,比如Kafka、ActiveMQ等消息队列

  • 游戏服务器,支持高并发的游戏服务端开发

  • 实时流数据的处理,比如音视频流处理、实时数据传输等

  • 其他高性能的网络应用程序开发

阿里分布式服务框架 Dubbo, 消息中间件RocketMQ都是使用 Netty 作为通讯的基础。

Netty 核心组件有哪些?分别有什么作用?

Netty的核心组件包括以下几个部分:

  • Channel:用于网络通信的通道,可以理解为Java NIO中的SocketChannel。

  • ChannelFuture:异步操作的结果,可以添加监听器以便在操作完成时得到通知。

  • EventLoop:事件循环器,用于处理所有I/O事件和请求。Netty的I/O操作都是异步非阻塞的,它们由EventLoop处理并以事件的方式触发回调函数。

  • EventLoopGroup:由一个或多个EventLoop组成的组,用于处理所有的Channel的I/O操作,可以将其看作是一个线程池。

  • ChannelHandler:用于处理Channel上的I/O事件和请求,包括编码、解码、业务逻辑等,可以理解为NIO中的ChannelHandler。

  • ChannelPipeline:由一组ChannelHandler组成的管道,用于处理Channel上的所有I/O 事件和请求,Netty中的数据处理通常是通过将一个数据包装成一个ByteBuf对象,并且通过一个 ChannelPipeline来传递处理,以达到业务逻辑与网络通信的解耦。

  • ByteBuf:Netty提供的字节容器,可以对字节进行高效操作,包括读写、查找等。

  • Codec:用于在ChannelPipeline中进行数据编码和解码的组件,如字符串编解码器、对象序列化编解码器等。

这些核心组件共同构成了Netty的核心架构,可以帮助开发人员快速地实现高性能、高并发的网络应用程序。

Netty的线程模型是怎样的?如何优化性能?

Netty的线程模型是基于事件驱动的Reactor模型,它使用少量的线程来处理大量的连接和数据传输,以提高性能和吞吐量。在Netty中,每个连接都分配了一个单独的EventLoop线程,该线程负责处理所有与该连接相关的事件,包括数据传输、握手和关闭等。多个连接可以共享同一个EventLoop线程,从而减少线程的创建和销毁开销,提高资源利用率。

为了进一步优化性能,Netty提供了一些线程模型和线程池配置选项,以适应不同的应用场景和性能要求。例如,可以使用不同的EventLoopGroup实现不同的线程模型,如单线程模型、多线程模型和主从线程模型等。同时,还可以设置不同的线程池参数,如线程数、任务队列大小、线程优先级等,以调整线程池的工作负载和性能表现。

在实际使用中,还可以通过优化网络协议、数据结构、业务逻辑等方面来提高Netty的性能。例如,可以使用零拷贝技术避免数据拷贝,使用内存池减少内存分配和回收的开销,避免使用阻塞IO和同步操作等,从而提高应用的吞吐量和性能表现。

EventloopGroup了解么?和 EventLoop 啥关系?

EventLoopGroup和EventLoop是 Netty 中两个重要的组件。

EventLoopGroup 表示一组EventLoop,它们共同负责处理客户端连接的I/O 事件。在 Netty 中,通常会为不同的 I/O 操作创建不同的 EventLoopGroup。

EventLoop 是 Netty 中的一个核心组件,它代表了一个不断循环的 I/O 线程。它负责处理一个或多个 Channel 的 I/O 操作,包括数据的读取、写入和状态的更改。一个EventLoop可以处理多个 Channel,而一个 Channel 只会被一个 EventLoop 所处理。

在 Netty 中,一个应用程序通常会创建两个 EventLoopGroup:一个用于处理客户端连接,一个用于处理服务器端连接。当客户端连接到服务器时,服务器端的EventLoopGroup会将连接分配给一个 EventLoop 进行处理,以便保证所有的 I/O 操作都能得到及时、高效地处理。

Netty 的零拷贝了解么?

零拷贝(Zero Copy)是一种技术,可以避免在数据传输过程中对数据的多次拷贝操作,从而提高数据传输的效率和性能。在网络编程中,零拷贝技术可以减少数据在内核空间和用户空间之间的拷贝次数,从而提高数据传输效率和降低 CPU 的使用率。

Netty 通过使用 Direct Memory 和 FileChannel 的方式实现零拷贝。当应用程序将数据写入 Channel 时,Netty 会将数据直接写入到内存缓冲区中,然后通过操作系统提供的 sendfile 或者 writev 等零拷贝技术,将数据从内存缓冲区中传输到网络中,从而避免了中间的多次拷贝操作。同样,当应用程序从 Channel 中读取数据时,Netty 也会将数据直接读取到内存缓冲区中,然后通过零拷贝技术将数据从内存缓冲区传输到用户空间。

通过使用零拷贝技术,Netty 可以避免在数据传输过程中对数据进行多次的拷贝操作,从而提高数据传输的效率和性能。特别是在处理大量数据传输的场景中,零拷贝技术可以大幅度减少 CPU 的使用率,降低系统的负载。

Netty 长连接、心跳机制了解么?

在网络编程中,长连接是指客户端与服务器之间建立的连接可以保持一段时间,以便在需要时可以快速地进行数据交换。与短连接相比,长连接可以避免频繁建立和关闭连接的开销,从而提高数据传输的效率和性能。

Netty 提供了一种长连接的实现方式,即通过 Channel 的 keepalive 选项来保持连接的状态。当启用了 keepalive 选项后,客户端和服务器之间的连接将会自动保持一段时间,如果在这段时间内没有数据交换,客户端和服务器之间的连接将会被关闭。通过这种方式,可以实现长连接,避免频繁建立和关闭连接的开销。

除了 keepalive 选项之外,Netty 还提供了一种心跳机制来保持连接的状态。心跳机制可以通过定期向对方发送心跳消息,来检测连接是否正常。如果在一段时间内没有收到心跳消息,就认为连接已经断开,并进行重新连接。Netty 提供了一个 IdleStateHandler 类,可以用来实现心跳机制。IdleStateHandler 可以设置多个超时时间,当连接空闲时间超过设定的时间时,会触发一个事件,可以在事件处理方法中进行相应的处理,比如发送心跳消息。

通过使用长连接和心跳机制,可以保证客户端与服务器之间的连接处于正常的状态,从而提高数据传输的效率和性能。特别是在处理大量数据传输的场景中,长连接和心跳机制可以降低建立和关闭连接的开销,减少网络负载,提高系统的稳定性。

Netty 服务端和客户端的启动过程了解么?

Netty 是一个基于 NIO 的异步事件驱动框架,它的服务端和客户端的启动过程大致相同,都需要完成以下几个步骤:

  1. 创建 EventLoopGroup 对象。EventLoopGroup 是Netty的核心组件之一,它用于管理和调度事件的处理。Netty 通过EventLoopGroup来创建多个EventLoop对象,并将每个 EventLoop 与一个线程绑定。在服务端中,一般会创建两个 EventLoopGroup 对象,分别用于接收客户端的连接请求和处理客户端的数据。

  2. 创建 ServerBootstrap 或 Bootstrap 对象。ServerBootstrap 和 Bootstrap 是 Netty 提供的服务端和客户端启动器,它们封装了启动过程中的各种参数和配置,方便使用者进行设置。在创建 ServerBootstrap 或 Bootstrap 对象时,需要指定相应的 EventLoopGroup 对象,并进行一些基本的配置,比如传输协议、端口号、处理器等。

  3. 配置Channel的参数。Channel 是Netty中的一个抽象概念,它代表了一个网络连接。在启动过程中,需要对 Channel 的一些参数进行配置,比如传输协议、缓冲区大小、心跳检测等。

  4. 绑定 ChannelHandler。ChannelHandler 是 Netty 中用于处理事件的组件,它可以处理客户端的连接请求、接收客户端的数据、发送数据给客户端等。在启动过程中,需要将 ChannelHandler 绑定到相应的 Channel 上,以便处理相应的事件。

  5. 启动服务端或客户端。在完成以上配置后,就可以启动服务端或客户端了。在启动过程中,会创建相应的 Channel,并对其进行一些基本的初始化,比如注册监听器、绑定端口等。启动完成后,就可以开始接收客户端的请求或向服务器发送数据了。

总的来说,Netty 的服务端和客户端启动过程比较简单,只需要进行一些基本的配置和设置,就可以完成相应的功能。通过使用 Netty,可以方便地开发高性能、高可靠性的网络应用程序。

Netty 的 Channel 和 EventLoop 之间的关系是什么?

在Netty中,Channel代表一个开放的网络连接,它可以用来读取和写入数据。而EventLoop则代表一个执行任务的线程,它负责处理Channel上的所有事件和操作。

每个Channel都与一个EventLoop关联,而一个EventLoop可以关联多个Channel。当一个Channel上有事件发生时,比如数据可读或者可写,它会将该事件提交给关联的EventLoop来处理。EventLoop会将该事件加入到它自己的任务队列中,然后按照顺序处理队列中的任务。

值得注意的是,一个EventLoop实例可能会被多个Channel所共享,因此它需要能够处理多个Channel上的事件,并确保在处理每个Channel的事件时不会被阻塞。为此,Netty采用了事件循环(EventLoop)模型,它通过异步I/O和事件驱动的方式,实现了高效、可扩展的网络编程。

什么是 Netty 的 ChannelPipeline,它是如何工作的?

在Netty中,每个Channel都有一个与之关联的ChannelPipeline,用于处理该Channel上的事件和请求。ChannelPipeline是一种基于事件驱动的处理机制,它由多个处理器(Handler)组成,每个处理器负责处理一个或多个事件类型,将事件转换为下一个处理器所需的数据格式。

当一个事件被触发时,它将从ChannelPipeline的第一个处理器(称为第一个InboundHandler)开始流经所有的处理器,直到到达最后一个处理器或者被中途拦截(通过抛出异常或调用ChannelHandlerContext.fireXXX()方法实现)。在这个过程中,每个处理器都可以对事件进行处理,也可以修改事件的传递方式,比如在处理完事件后将其转发到下一个处理器,或者直接将事件发送回到该Channel的对端。

ChannelPipeline的工作方式可以用以下三个概念来描述:

  • 入站(Inbound)事件:由Channel接收到的事件,例如读取到新的数据、连接建立完成等等。入站事件将从ChannelPipeline的第一个InboundHandler开始流动,直到最后一个InboundHandler。

  • 出站(Outbound)事件:由Channel发送出去的事件,例如向对端发送数据、关闭连接等等。出站事件将从ChannelPipeline的最后一个OutboundHandler开始流动,直到第一个OutboundHandler。

  • ChannelHandlerContext:表示处理器和ChannelPipeline之间的关联关系。每个ChannelHandler都有一个ChannelHandlerContext,通过该对象可以实现在ChannelPipeline中的事件流中向前或向后传递事件,也可以通过该对象访问Channel、ChannelPipeline和其他ChannelHandler等。

通过使用ChannelPipeline,Netty实现了高度可配置和可扩展的网络通信模型,使得开发人员可以根据自己的需求选择和组合不同的处理器,以构建出高效、稳定、安全的网络通信系统。

Netty 中的 ByteBuf 是什么,它和 Java 的 ByteBuffer 有什么区别?

Netty 的 ByteBuf 是一个可扩展的字节容器,它提供了许多高级的 API,用于方便地处理字节数据。ByteBuf 与 Java NIO 的 ByteBuffer 相比,有以下区别:

  • 容量可扩展:ByteBuf的容量可以动态扩展,而 ByteBuffer 的容量是固定的。

  • 内存分配:ByteBuf 内部采用了内存池的方式,可以有效地减少内存分配和释放的开销。

  • 读写操作:ByteBuf 提供了多个读写指针,可以方便地读写字节数据。

  • 零拷贝:ByteBuf 支持零拷贝技术,可以减少数据复制的次数。

Netty 中的 ChannelHandlerContext 是什么,它的作用是什么?

在Netty中,ChannelHandlerContext表示连接到ChannelPipeline中的一个Handler上下文。在Netty的IO事件模型中,ChannelHandlerContext充当了处理I/O事件的处理器和ChannelPipeline之间的桥梁,使处理器能够相互交互并访问ChannelPipeline中的其他处理器。

每当ChannelPipeline中添加一个Handler时,Netty会创建一个ChannelHandlerContext对象,并将其与该Handler关联。这个对象包含了该Handler的相关信息,如所在的ChannelPipeline、所属的Channel等。在处理I/O事件时,Netty会将I/O事件转发给与该事件相应的ChannelHandlerContext,该上下文对象可以使Handler访问与该事件相关的任何信息,也可以在管道中转发事件。

总之,ChannelHandlerContext是一个重要的Netty组件,它提供了一种简单的机制,让开发者在处理网络I/O事件时可以更加灵活和高效地操作管道中的Handler。

什么是 Netty 的 ChannelFuture,它的作用是什么?

在Netty中,ChannelFuture表示异步的I/O操作的结果。当执行一个异步操作(如发送数据到一个远程服务器)时,ChannelFuture会立即返回,并在将来的某个时候通知操作的结果,而不是等待操作完成。这种异步操作的特点使得Netty可以在同时处理多个连接时实现高性能和低延迟的网络应用程序。

具体来说,ChannelFuture用于在异步操作完成后通知应用程序结果。在异步操作执行后,Netty将一个ChannelFuture对象返回给调用方。调用方可以通过添加一个回调(ChannelFutureListener)来处理结果。例如,当异步写操作完成时,可以添加一个ChannelFutureListener以检查操作的状态并采取相应的措施。

ChannelFuture还提供了许多有用的方法,如检查操作是否成功、等待操作完成、添加监听器等。通过这些方法,应用程序可以更好地控制异步操作的状态和结果。

总之,ChannelFuture是Netty中异步I/O操作的基础,它提供了一种简单而有效的机制,使得开发者可以方便地处理I/O操作的结果。

Netty 中的 ChannelHandler 是什么,它的作用是什么?

在 Netty 中,ChannelHandler是一个接口,用于处理入站和出站数据流。它可以通过实现以下方法来处理数据流:

  • channelRead(ChannelHandlerContext ctx, Object msg): 处理接收到的数据,这个方法通常会被用于解码数据并将其转换为实际的业务对象。

  • channelReadComplete(ChannelHandlerContext ctx): 读取数据完成时被调用,可以用于向远程节点发送数据。

  • exceptionCaught(ChannelHandlerContext ctx, Throwable cause): 发生异常时被调用,可以在这个方法中处理异常或关闭连接。

  • channelActive(ChannelHandlerContext ctx): 当连接建立时被调用。

  • channelInactive(ChannelHandlerContext ctx): 当连接关闭时被调用。

ChannelHandler 可以添加到 ChannelPipeline 中,ChannelPipeline 是一个用于维护 ChannelHandler 调用顺序的容器。在数据流进入或离开 Channel 时,ChannelPipeline 中的 ChannelHandler 会按照添加的顺序依次调用它们的方法来处理数据流。

ChannelHandler 的主要作用是将网络协议的细节与应用程序的逻辑分离开来,使得应用程序能够专注于处理业务逻辑,而不需要关注网络协议的实现细节。

Netty 中的各种 Codec 是什么,它们的作用是什么?

在 Netty 中,Codec 是一种将二进制数据与 Java 对象之间进行编码和解码的组件。它们可以将数据从字节流解码为 Java 对象,也可以将 Java 对象编码为字节流进行传输。

以下是 Netty 中常用的 Codec:

  • ByteToMessageCodec:将字节流解码为 Java 对象,同时也可以将 Java 对象编码为字节流。可以用于处理自定义协议的消息解析和封装。

  • MessageToByteEncoder:将 Java 对象编码为字节流。通常用于发送消息时将消息转换为二进制数据。

  • ByteToMessageDecoder:将字节流解码为 Java 对象。通常用于接收到数据后进行解码。

  • StringEncoder 和 StringDecoder:分别将字符串编码为字节流和将字节流解码为字符串。

  • LengthFieldPrepender 和 LengthFieldBasedFrameDecoder:用于处理 TCP 粘包和拆包问题。

  • ObjectDecoder和ObjectEncoder:将Java对象序列化为字节数据,并将字节数据反序列化为Java对象。

这些 Codec 组件可以通过组合使用来构建复杂的数据协议处理逻辑,以提高代码的可重用性和可维护性。

什么是 Netty 的 BootStrap,它的作用是什么?

Netty的Bootstrap是一个用于启动和配置Netty客户端和服务器的工具类。它提供了一组简单易用的方法,使得创建和配置Netty应用程序变得更加容易。

Bootstrap类提供了一些方法,可以设置服务器或客户端的选项和属性,以及为ChannelPipeline配置handler,以处理传入或传出的数据。一旦完成配置,使用Bootstrap启动客户端或服务器。

在Netty应用程序中,Bootstrap有两个主要作用:

  1. 作为Netty服务器启动的入口点:通过Bootstrap启动一个Netty服务器,可以在指定的端口上监听传入的连接,并且可以设置服务器的选项和属性。

  2. 作为Netty客户端启动的入口点:通过Bootstrap启动一个Netty客户端,可以连接到远程服务器,并且可以设置客户端的选项和属性。

Netty的IO模型是什么?与传统的BIO和NIO有什么不同?

Netty的IO模型是基于事件驱动的NIO(Non-blocking IO)模型。在传统的BIO(Blocking IO)模型中,每个连接都需要一个独立的线程来处理读写事件,当连接数过多时,线程数量就会爆炸式增长,导致系统性能急剧下降。而在NIO模型中,一个线程可以同时处理多个连接的读写事件,大大降低了线程的数量和切换开销,提高了系统的并发性能和吞吐量。

与传统的NIO模型相比,Netty的NIO模型有以下不同点:

  1. Netty使用了Reactor模式,将IO事件分发给对应的Handler处理,使得应用程序可以更方便地处理网络事件。

  2. Netty使用了多线程模型,将Handler的处理逻辑和IO线程分离,避免了IO线程被阻塞的情况。

Netty支持多种Channel类型,可以根据应用场景选择不同的Channel类型,如NIO、EPoll、OIO等。

如何在Netty中实现TCP粘包/拆包的处理?

在TCP传输过程中,由于TCP并不了解上层应用协议的消息边界,会将多个小消息组合成一个大消息,或者将一个大消息拆分成多个小消息发送。这种现象被称为TCP粘包/拆包问题。在Netty中,可以通过以下几种方式来解决TCP粘包/拆包问题:

  1. 消息定长:将消息固定长度发送,例如每个消息都是固定的100字节。在接收端,根据固定长度对消息进行拆分。

  2. 消息分隔符:将消息以特定的分隔符分隔开,例如以"\r\n"作为分隔符。在接收端,根据分隔符对消息进行拆分。

  3. 消息头部加长度字段:在消息的头部加上表示消息长度的字段,在发送端发送消息时先发送消息长度,再发送消息内容。在接收端,先读取消息头部的长度字段,再根据长度读取消息内容。

Netty如何处理大文件的传输?

在Netty中,可以通过使用ChunkedWriteHandler处理大文件的传输。ChunkedWriteHandler是一个编码器,可以将大文件切分成多个Chunk,并将它们以ChunkedData的形式写入管道,这样就可以避免一次性将整个文件读入内存,降低内存占用。

具体使用方法如下:

  1. 在服务端和客户端的ChannelPipeline中添加ChunkedWriteHandler。

  2. 在服务端和客户端的业务逻辑处理器中,接收并处理ChunkedData。

  3. 在客户端向服务端发送数据时,将需要传输的文件包装成ChunkedFile并写入管道。

在传输大文件时,还需要注意以下几点:

  1. 使用ChunkedFile时需要指定Chunk的大小,根据实际情况选择合适的大小,一般建议不要超过8KB。

  2. 为了避免大文件传输过程中对网络造成影响,可以在服务端和客户端的ChannelPipeline中添加WriteBufferWaterMark,限制写入缓冲区的大小。

如何使用Netty实现心跳机制?

在Netty中,可以通过实现一个定时任务来实现心跳机制。具体来说,就是在客户端和服务端之间定时互相发送心跳包,以检测连接是否仍然有效。

以下是使用Netty实现心跳机制的基本步骤:

  1. 定义心跳消息的类型。

  2. 在客户端和服务端的ChannelPipeline中添加IdleStateHandler,用于触发定时任务。

  3. 在客户端和服务端的业务逻辑处理器中,重写userEventTriggered方法,在触发定时任务时发送心跳包。

  4. 在客户端和服务端的业务逻辑处理器中,重写channelRead方法,接收并处理心跳包。

需要注意的是,由于心跳包不需要传输大量数据,因此建议使用Unpooled.EMPTY_BUFFER作为心跳包的内容。另外,心跳间隔的时间应根据实际情况设置,一般建议设置为连接的超时时间的一半。

NioEventLoopGroup 默认的构造函数会起多少线程?

默认情况下,NioEventLoopGroup 的构造函数会根据可用的处理器核心数 (availableProcessors()) 创建相应数量的线程。

具体来说,NioEventLoopGroup 的默认构造函数内部调用了另一个构造函数,其参数 nThreads 的默认值为 0,表示使用默认线程数。而默认线程数的计算方式就是调用 Runtime.getRuntime().availableProcessors() 方法获取当前机器可用的处理器核心数。

因此,如果你在一台四核的机器上创建了一个默认的 NioEventLoopGroup 实例,那么它就会使用四个线程。如果你想要修改线程数,可以调用 NioEventLoopGroup 的其他构造函数,并传入自定义的线程数。

如何使用Netty实现WebSocket协议?

在 Netty 中实现 WebSocket 协议,需要使用 WebSocketServerProtocolHandler 进行处理。WebSocketServerProtocolHandler 是一个 ChannelHandler,可以将 HTTP 升级为 WebSocket 并处理 WebSocket 帧。

Netty 高性能表现在哪些方面?

异步非阻塞 I/O 模型:Netty 使用基于NIO的异步非阻塞 I/O 模型,可以大大提高网络通信效率,减少线程的阻塞等待时间,从而提高应用程序的响应速度和吞吐量。

零拷贝技术:Netty 支持零拷贝技术,可以避免数据在内核和用户空间之间的多次复制,减少了数据拷贝的次数,从而提高了数据传输的效率和性能。

线程模型优化:Netty 的线程模型非常灵活,可以根据不同的业务场景选择不同的线程模型。例如,对于低延迟和高吞吐量的场景,可以选择 Reactor 线程模型,对于 I/O 操作比较简单的场景,可以选择单线程模型。

内存池技术:Netty 提供了一套基于内存池技术的 ByteBuf 缓冲区,可以重用已经分配的内存空间,减少内存的分配和回收次数,提高内存使用效率。

处理器链式调用:Netty 的 ChannelHandler 可以按照一定的顺序组成一个处理器链,当事件发生时,会按照处理器链的顺序依次调用处理器,从而实现对事件的处理。这种处理方式比传统的多线程处理方式更加高效,减少了线程上下文切换和锁竞争等问题。

Netty 和 Tomcat 的区别?

Netty 和 Tomcat 都是 Java Web 应用服务器,但是它们之间存在一些区别:

  • 底层网络通信模型不同:Tomcat 是基于阻塞的 BIO(Blocking I/O)模型实现的,而 Netty 是基于 NIO(Non-Blocking I/O)模型实现的。

  • 线程模型不同:Tomcat 使用传统的多线程模型,每个请求都会分配一个线程,而 Netty 使用 EventLoop 线程模型,每个 EventLoop 负责处理多个连接,通过线程池管理 EventLoop。

  • 协议支持不同:Tomcat 内置支持 HTTP 和 HTTPS 协议,而 Netty 不仅支持 HTTP 和 HTTPS 协议,还支持 TCP、UDP 和 WebSocket 等多种协议。

  • 代码复杂度不同:由于Tomcat支持的功能比较全面,所以其代码相对较为复杂,而 Netty 的代码相对比较简洁、精简。

  • 应用场景不同:Tomcat 适合于处理比较传统的 Web 应用程序,如传统的 MVC 模式Web应用程序;而 Netty 更适合于高性能、低延迟的网络应用程序,如游戏服务器、即时通讯服务器等。

服务端Netty的工作架构图

整个服务端 Netty 的工作架构图包括了以下几个部分:

  • ChannelPipeline:管道处理器,用于处理入站或出站事件,对数据进行编解码、处理业务逻辑等。

  • Channel:通道,对应底层的 Socket 连接,用于收发网络数据。

  • EventLoopGroup:事件循环组,包含了多个事件循环(EventLoop),每个事件循环负责处理多个通道上的事件。

  • EventLoop:事件循环,负责监听注册到该循环的多个通道上的事件,然后根据事件类型将事件派发给对应的处理器。

  • NioServerSocketChannel:NIO 服务端通道,用于接受客户端的连接。

  • NioSocketChannel:NIO 客户端通道,用于和服务端进行数据通信。

在服务端启动时,会创建一个或多个 EventLoopGroup。其中一个 EventLoopGroup 作为boss线程池,用于接受客户端的连接请求,并将连接请求分发给work线程池中的某个 EventLoop。work 线程池中的EventLoop负责处理已经连接的客户端的数据通信。每个 EventLoop 负责处理一个或多个 NioSocketChannel,并维护该通道的事件队列,当事件发生时,将事件添加到事件队列中,并将事件派发到管道处理器中进行处理。

简单聊聊:Netty的线程模型的三种使用方式?

Netty的线程模型有三种使用方式,分别是单线程模型、多线程模型和主从多线程模型。

  • 单线程模型:所有的I/O操作都由同一个线程来执行。虽然这种方式并不适合高并发的场景,但是它具有简单、快速的优点,适用于处理I/O操作非常快速的场景,例如传输小文件等。

  • 多线程模型:所有的I/O操作都由一组线程来执行,其中一个线程负责监听客户端的连接请求,其他线程负责处理I/O操作。这种方式可以支持高并发,但是线程上下文切换的开销较大,适用于处理I/O操作较为耗时的场景。

  • 主从多线程模型:所有的I/O操作都由一组NIO线程来执行,其中一个主线程负责监听客户端的连接请求,其他从线程负责处理I/O操作。这种方式将接受连接和处理I/O操作分开,避免了线程上下文切换的开销,同时又能支持高并发,适用于处理I/O操作耗时较长的场景。

Netty 是如何保持长连接的

  • 心跳机制:使用心跳机制可以定期向服务器发送一个简短的数据包,以保持连接处于活动状态。如果在一段时间内没有收到心跳包,就可以认为连接已经断开,从而及时重新建立连接。Netty提供了IdleStateHandler处理器,可以方便地实现心跳机制。

  • 断线重连机制:在网络不稳定的情况下,连接可能会不可避免地断开。为了避免因为网络异常导致应用程序不能正常工作,可以实现断线重连机制,定期检查连接状态,并在连接断开时尝试重新连接。Netty提供了ChannelFutureListener接口和ChannelFuture对象,可以方便地实现断线重连机制。

  • 基于HTTP/1.1协议的长连接:HTTP/1.1协议支持长连接,可以在一个TCP连接上多次发送请求和响应。在Netty中,可以使用HttpClientCodec和HttpObjectAggregator处理器,实现基于HTTP/1.1协议的长连接。

  • WebSocket协议:WebSocket协议也支持长连接,可以在一个TCP连接上双向通信,实现实时数据交换。在Netty中,可以使用WebSocketServerProtocolHandler和WebSocketClientProtocolHandler处理器,实现WebSocket协议的长连接。

Netty 发送消息有几种方式?

在 Netty 中,发送消息主要有以下三种方式:

  • Channel.write(Object msg) :通过 Channel 写入消息,消息会被缓存到 Channel 的发送缓冲区中,等待下一次调用 flush() 将消息发送出去。

  • ChannelHandlerContext.write(Object msg) :通过 ChannelHandlerContext 写入消息,与 Channel.write(Object msg) 相比,ChannelHandlerContext.write(Object msg) 会将消息写入到 ChannelHandlerContext 的发送缓冲区中,等待下一次调用 flush() 将消息发送出去。

  • ChannelHandlerContext.writeAndFlush(Object msg) :通过 ChannelHandlerContext 写入并发送消息,等同于连续调用 ChannelHandlerContext.write(Object msg) 和 ChannelHandlerContext.flush()。

在使用上述三种方式发送消息时,需要注意到写操作可能会失败或被延迟,因此需要在发送消息时进行一定的错误处理或者设置超时时间。另外,也可以使用 Netty 提供的 ChannelFuture 对象来监听操作结果或者进行异步操作。

Netty 支持哪些心跳类型设置?

在 Netty 中,可以通过以下几种方式实现心跳机制:

  • IdleStateHandler :Netty 内置的空闲状态检测处理器,支持多种空闲状态检测(如读空闲、写空闲、读写空闲)。

  • 自定义心跳检测机制 :可以通过自定义实现 ChannelInboundHandler 接口的处理器来实现心跳检测,例如可以通过计时器或者线程来定期发送心跳包,或者通过对远程端口的连接状态进行检测等方式实现。

  • 使用心跳应答 :在应用层面定义心跳请求和应答消息,通过 ChannelInboundHandler 处理器监听接收到的心跳请求消息,并返回心跳应答消息,来实现心跳检测。如果一段时间内未收到对方的心跳应答消息,则认为连接已经失效。

需要注意的是,为了避免因心跳机制导致的网络负载过大或者频繁的连接断开和重连,应该根据具体业务场景选择适合的心跳类型和频率。

Netty的内存管理机制是什么?

Netty 的内存管理机制主要是通过 ByteBuf 类实现的。ByteBuf 是 Netty 自己实现的一个可扩展的字节缓冲区类,它在 JDK 的 ByteBuffer 的基础上做了很多优化和改进。

Netty 的 ByteBuf 的内存管理主要分为两种方式:

  • 堆内存:ByteBuf 以普通的字节数组为基础,在 JVM 堆上分配内存。这种方式适用于小型数据的传输,如传输的是文本、XML 等数据。

  • 直接内存:ByteBuf 使用操作系统的堆外内存,由操作系统分配和回收内存。这种方式适用于大型数据的传输,如传输的是音视频、大型图片等数据。

对于堆内存,Netty 采用了类似于JVM 的分代内存管理机制,将缓冲区分为三种类型:堆缓冲区、直接缓冲区、复合缓冲区。Netty 会根据不同的使用场景和内存需求来决定使用哪种类型的缓冲区,从而提高内存利用率。

在使用 ByteBuf 时,Netty 还实现了一些优化和特殊处理,如池化缓冲区、零拷贝等技术,以提高内存的利用率和性能的表现。

Netty 中如何实现高可用和负载均衡?

Netty本身并没有提供高可用和负载均衡的功能,但可以结合其他技术来实现这些功能。下面介绍一些常用的方案:

  • 高可用:通过在多台服务器上部署同一个应用程序实现高可用。可以使用负载均衡器来将请求分配给不同的服务器,当某台服务器出现故障时,负载均衡器可以将请求转发给其他可用的服务器。常用的负载均衡器包括Nginx、HAProxy等。

  • 负载均衡:负载均衡是将请求分配给多台服务器的过程,常用的负载均衡算法包括轮询、随机、权重等。在Netty中可以使用多个EventLoop来处理请求,将请求分配给不同的EventLoop,从而实现负载均衡。另外,可以使用第三方框架,如Zookeeper、Consul等,来实现服务注册、发现和负载均衡。

  • 高可用与负载均衡的结合:可以使用多台服务器来实现高可用和负载均衡。在每台服务器上部署同一个应用程序,并使用负载均衡器来分配请求。当某台服务器出现故障时,负载均衡器可以将请求转发给其他可用的服务器,从而保证高可用和负载均衡。

Dubbo

Dubbo核心组件有哪些?

  • Provider:暴露服务的服务提供方
  • Consumer:调用远程服务消费方
  • Registry:服务注册与发现注册中心
  • Monitor:监控中心和访问调用统计
  • Container:服务运行容器

Dubbo都支持什么协议?

1、 Dubbo协议:Dubbo默认使用Dubbo协议。

  • 适合大并发小数据量的服务调用,以及服务消费者远大于提供者的情况
  • Hessian二进制序列化。
  • 缺点是不适合传送大数据包的服务。

2、rmi协议:采用JDK标准的rmi协议实现,传输参数和返回参数对象需要实现Serializable接口。使用java标准序列化机制,使用阻塞式短连接,传输数据包不限,消费者和提供者个数相当。

  • 多个短连接,TCP协议传输,同步传输,适用常规的远程服务调用和rmi互操作
  • 缺点:在依赖低版本的Common-Collections包,java反序列化存在安全漏洞,需升级commons-collections3 到3.2.2版本或commons-collections4到4.1版本。

3、 webservice协议:基于WebService的远程调用协议(Apache CXF的frontend-simple和transports-http)实现,提供和原生WebService的互操作多个短连接,基于HTTP传输,同步传输,适用系统集成和跨语言调用。

4、http协议:基于Http表单提交的远程调用协议,使用Spring的HttpInvoke实现。对传输数据包不限,传入参数大小混合,提供者个数多于消费者

  • 缺点是不支持传文件,只适用于同时给应用程序和浏览器JS调用

5、hessian:集成Hessian服务,基于底层Http通讯,采用Servlet暴露服务,Dubbo内嵌Jetty作为服务器实现,可与Hession服务互操作 通讯效率高于WebService和Java自带的序列化

  • 适用于传输大数据包(可传文件),提供者比消费者个数多,提供者压力较大
  • 缺点是参数及返回值需实现Serializable接口,自定义实现List、Map、Number、Date、Calendar等接口

6、thrift协议:对thrift原生协议的扩展添加了额外的头信息。使用较少,不支持传null值

7、memcache:基于memcached实现的RPC协议

8、redis:基于redis实现的RPC协议

Dubbo服务注册与发现的流程?

  • 服务容器Container负责启动,加载,运行服务提供者。
  • 服务提供者Provider在启动时,向注册中心注册自己提供的服务。
  • 服务消费者Consumer在启动时,向注册中心订阅自己所需的服务。
  • 注册中心Registry返回服务提供者地址列表给消费者,如果有变更,注册中心将基于长连接推送变更数据给消费者。
  • 服务消费者Consumer,从提供者地址列表中,基于软负载均衡算法,选一台提供者进行调用,如果调用失败,再选另一台调用。
  • 服务消费者Consumer和提供者Provider,在内存中累计调用次数和调用时间,定时每分钟发送一次统计数据到监控中心Monitor。

Dubbo有哪几种负载均衡策略?

Dubbo提供了4种负载均衡实现:

  1. RandomLoadBalance:随机负载均衡。随机的选择一个。是Dubbo的默认2. 负载均衡策略。
  2. RoundRobinLoadBalance:轮询负载均衡。轮询选择一个。
    LeastActiveLoadBalance:最少活跃调用数,相同活跃数的随机。活跃数指调用前后计数差。使慢的 Provider 收到更少请求,因为越慢的 Provider 的调用前后计数差会越大。
  3. ConsistentHashLoadBalance:一致性哈希负载均衡。相同参数的请求总是落在同一台机器上。
posted @ 2023-04-03 23:29  loveletters  阅读(2358)  评论(0编辑  收藏  举报