后端学习文档

Java基础

接口和抽象类的区别

  1. 抽象类允许包含某些方法的实现,而接口是不允许的;从设计级别看,为了实现由抽象类定义的类型,一个类必须定义为一个抽象类的子类,这限制了它在类图中的层次,但是接口没有这个层次的限制。

  2. 在抽象类中提供成员方法的实现细节,该方法只能在接口中为public abstract修饰,也就是抽象方法。

  3. 抽象类中的成员变量可以实现多个权限 public private protected final等,接口中只能用 public static final修饰。

String字符串提取第N个值

  1. charAt()方法

    1
    2
    String str = "Hello World!";
    System.out.printLn(str.charAt(0)) // H // 为了得到第n个字符的字符串,只需调用charAt(n)上String,在那里n是你想检索字符的索引
  2. subString 截取字符串

    substring(0,2)这个只含开头不含结尾,因此截取是截取两个字符,从第一个到第二个字符,不包含第三个。

    substring(2)这个表示截掉前两个,得到后边的新字符串。

JDK、JRE、JVM之间的区别

  • JDK(Java SE Development Kit),Java标准开发包,它提供了编译运行Java程序所需的各种工具和资源,包括Java编译器Java运行时环境,以及常用的Java类库
  • JRE(Java Runtime Eviroment),Java运行环境,用于运行Java的字节码文件。JRE中包括了JVM以及JVM工作所需要的类库,普通用户而只需要安装JRE来运行JAVA程序,而程序开发者必须安装JDK来编译、调试程序。
  • JVM(Java Virtual Mechinal),Java虚拟机,是JRE的一部分,它是整个java实现跨平台的最核心的部分,负责运行字节码文件。

我们写Java代码,用txt就可以写,但是写出来的Java代码,要想运行,需要先编译成字节码,那就需要编译器,而JDK中就包含了编译器javac,编译之后的字节码,想要运行,就需要一个可执行字节码的程序,这个程序就是JVM(Java虚拟机),专门用来执行Java字节码的。

如果我要开发Java程序,那就需要JDK,因为要编译Java源文件。

如果我们执行运行已经编译好的Java字节码文件,也就是*.class文件,那么就只需要JRE。

JDK中包含了JRE,JRE中包含了JVM。

另外,JVM在执行Java字节码时,需要把字节码解释为机器指令,而不同操作系统的机器指令是可能不一样的,所以就导致不同操作系统上的JVM是不一样的,所以我们在安装JDK时需要选择操作系统。

另外,JVM是用来执行Java字节码的,所以凡是某个代码编译之后是Java字节码,那就能在JVM上运行,比如Apache Groovy,Scala,Kotlin等等。

什么是字节码?采用字节码的好处是什么?

编译器(javac)将Java源文件(*.java)编译成为字节码文件(.class),可以做到一次编译到处运行,windows上编译好的class文件,可以直接在linux上运行,通过这种方式做到跨平台,不过Java的跨平台有一个前提条件,就是不同的操作系统上安装的JDK或JRE是不一样的,虽然字节码时通用的,但是需要把字节码解释成各个操作系统的机器码是需要不同的解释器的,所以针对各个操作系统需要有各自的JDK或JRE

采用字节码的好处,一方面实现了跨平台,另一方面也提高了代码执行的性能,编译器在编译源代码时可以做一些编译期的优化,比如锁消除、标量替换、方法内联等。

hashCode()与equals()之间的关系

在Java中,每个对象都可以调用自己的hashCode()方法得到自己的哈希值(hashCode),相当于对象的指纹信息,通常说世界上没有完全相同的两个指纹,但是在Java中做不到这么绝对,但是我们仍然可以利用hashCode来做一些提前的判断,比如:

* 如果两个对象的hashCode不相同,那么这两个对象肯定不同的两个对象
* 如果两个对象的hashCode相同,不代表这两个对象一定是同一个对象,也可能是两个对象。
* 如果两个对象相等,那么他们的hashCode就一定相同。

在Java的一些集合的实现中,在比较两个对象是否相等时,会根据上面的原则,会先调用对象的hashCode()方法得到hashCode进行比较,如果hashCode不相同,就可以直接认为这两个对象不相同,如果hashCode相同,那么就会进一步调用equals()方法进行比较。而equals()方法,就是用来最终确定两个对象是不是相等的,通常equals()方法的实现会比较重,逻辑较多,而hashCode()主要就是得到一个哈希值,实际上就是一个数字,相对而言比较轻,所以在比较两个对象时,通常都会先根据hashCode相比较一下。

所以我们就要注意,如果我们重写了equals()方法,那么就要注意hashCode()方法。

重载和重写的区别

重载:发生在同一个类中,方法名必须相同,参数类型不同、个数不同、顺序不同、方法返回值和访问修饰符可以不同,发生在编译时。

重写:发生在父子类中,方法名、参数列表必须相同,返回值范围小于等于父类,抛出的异常范围小于等于父类,访问修饰符范围大于等于父类;如果父类方法范围修饰符为private则子类就不能重写该方法。

1
2
3
public int add(int a, String b)
public String add(int a, String b)
//编译报错

volatile是什么,实现原理,可以实现原子性吗?

volatile 是Java中的关键字,用于修饰变量。使用 volatile 关键字修饰的变量具有以下特点:

  1. 可见性(Visibility):

    • 当一个线程修改了被 volatile 修饰的变量的值,其他线程能够立即看到这个修改。
  2. 禁止指令重排序(Ordering):

    • volatile 关键字禁止了指令重排序,保证了代码的执行顺序与程序中的顺序一致。

然而,volatile 并不能保证操作的原子性,即不能保证多个线程同时对该变量进行读-改-写操作的原子性。例如,i++ 操作是一个复合操作,包括读取当前值、加1、写回新值。在多线程环境下,如果多个线程同时执行 i++,可能会导致竞态条件(Race Condition)。

volatile 的实现原理涉及到底层的内存模型,主要有两个方面:

  1. 内存屏障(Memory Barriers):

    • volatile 关键字使用内存屏障来防止指令重排序。在写 volatile 变量之后,会插入写屏障,确保写操作对其他线程可见。在读 volatile 变量之前,会插入读屏障,确保读操作的执行不会受到之前指令的影响。
  2. 禁止缓存优化:

    • volatile 变量的值会被直接写入主内存,并且读取时也会直接从主内存中读取。这样可以保证不同线程之间的可见性。

虽然 volatile 能够保证可见性和禁止指令重排序,但它并不能保证复合操作的原子性。要实现原子性操作,可以使用 synchronized 关键字或者 java.util.concurrent 包提供的原子类(Atomic Classes),例如 AtomicIntegerAtomicLong 等。

总的来说,volatile 是一种轻量级的同步机制,适用于状态标志位的读写等场景,但对于复合操作的原子性仍需使用其他手段来保证。

String、StringBuffer、StringBuilder的区别

  1. String是不可变的,如果尝试去修改,会新生成一个字符串对象,StringBuffer和StringBuilder是可变的。
  2. StringBuffer是线程安全的,StringBuilder是线程不安全的,所以单线程环境下StringBuilder效率会更高。

泛型中extends和super的区别

  1. <? extends T>表示包括T在内的任何T的子类
  2. <? super T>表示包括T在内的任何父类

==和equals方法的区别

  • ==:如果是基本数据类型,比较的是值,如果是引用类型,比较的是引用地址
  • equals:具体看各个类重写equals方法之后的比较逻辑,比如String类,虽然是引用类型,但是String类中重写了equals方法,方法内部比较的是字符串中的各个字符是否全部相等。

If和Switch的区别

现代 CPU 都支持分支预测 (branch prediction) 和指令流水线 (instruction pipeline),这两个结合可以极大提高 CPU 效率。对于像简单的 if 跳转,CPU 是可以比较好地做分支预测的。但是对于 switch 跳转,CPU 则没有太多的办法。 switch 本质上是根据索引,从地址数组里取地址再跳转。

List和Set的区别

  • List:有序,按对象进入的顺序保存对象,可重复,允许多个Null元素对象,可以使用Iterator取出所有元素,再逐一遍历,还可以使用get(int index)获取指定下标的元素
  • Set:无序,不可重复,最多允许有一个Null元素对象,取元素时只能用Iterator接口取得所有元素,再逐一遍历各个元素

在数据结构上,栈和队列有什么区别

栈(Stack)和队列(Queue)是两种基本的数据结构,它们在数据的存储和访问方式上有一些明显的区别。

  1. 数据结构定义:

    • 栈: 栈是一种后进先出(Last In, First Out,LIFO)的数据结构。在栈中,最后入栈的元素最先出栈。
    • 队列: 队列是一种先进先出(First In, First Out,FIFO)的数据结构。在队列中,最先入队的元素最先出队。
  2. 操作:

    • 栈: 栈主要支持两种基本操作,即入栈(Push)和出栈(Pop)。元素只能从栈顶添加或移除。
    • 队列: 队列支持入队(Enqueue)和出队(Dequeue)操作。元素从队尾入队,从队头出队。
  3. 典型应用:

    • 栈: 栈常用于需要后进先出顺序的场景,如表达式求值、递归函数的调用栈、浏览器的前进和后退按钮实现等。
    • 队列: 队列常用于需要先进先出顺序的场景,如任务调度、广度优先搜索算法、打印队列等。
  4. 实现方式:

    • 栈: 栈可以通过数组或链表实现。使用数组时需要考虑栈的大小,而使用链表时可以动态地分配内存。
    • 队列: 队列也可以通过数组或链表实现。使用数组时需要考虑队列的大小,而使用链表时可以动态地分配内存。
  5. 常见变体:

    • 栈的变体:
      • 双端栈:支持从两端进行入栈和出栈操作。
      • 最小栈:在栈的基础上支持常数时间内获取栈中的最小元素。
    • 队列的变体:
      • 双端队列:支持在两端进行入队和出队操作。
      • 优先队列:按照元素的优先级进行出队,而不是按照先进先出的原则。

总的来说,栈和队列是两种基础的数据结构,它们分别适用于不同的问题场景。选择栈或队列取决于问题的要求,例如需要先进先出还是后进先出的顺序。在实际应用中,也有一些基于栈和队列的变体,以满足特定的需求。

在JVM层面,数组和链表有什么区别

在JVM层面,数组(Array)和链表(Linked List)是两种不同的数据结构,它们在内存分配、访问方式、性能等方面有一些显著的区别。

  1. 内存分配:

    • 数组: 数组是一块连续的内存空间,元素的内存地址是通过索引计算得到的。这使得数组的随机访问非常高效,因为可以通过索引直接计算内存地址。
    • 链表: 链表的节点分布在内存的不同位置,每个节点都包含了指向下一个节点的引用。这使得链表的内存分配不是连续的,需要通过指针来遍历。
  2. 访问效率:

    • 数组: 数组支持随机访问,可以通过索引直接访问元素,时间复杂度为O(1)。但在插入和删除元素时,需要移动其他元素,时间复杂度为O(n)。
    • 链表: 链表的访问效率取决于遍历的方式。在单链表中,如果需要遍历到第 k 个元素,时间复杂度为O(k)。但在插入和删除元素时,由于不需要移动其他元素,时间复杂度可以是O(1)。
  3. 空间复杂度:

    • 数组: 数组的空间复杂度主要受到数组长度的影响,空间是连续分配的。如果数组长度为n,则空间复杂度为O(n)。
    • 链表: 链表的空间复杂度与节点数量成正比,不受节点之间的物理位置关系限制,空间复杂度也是O(n)。
  4. 扩容:

    • 数组: 在数组满时进行扩容可能会导致新数组的内存空间无法一次性分配,需要分配新的内存空间并将原数组的元素复制到新数组中。
    • 链表: 链表在插入或删除节点时不需要移动其他节点,因此扩容过程相对较简单。
  5. 缓存性能:

    • 数组: 由于数组的元素是连续存储的,可以更好地利用缓存,提高访问性能。
    • 链表: 由于链表节点分散存储,可能导致在遍历时缓存未命中,性能相对较差。

在实际应用中,选择数组还是链表取决于具体的使用场景和操作。如果需要频繁随机访问元素,数组可能更合适。如果需要频繁插入或删除元素,并且对随机访问的性能要求不高,链表可能更合适。

ArrayList和LinkedList区别

  1. 首先,他们的底层数据结构不同,ArrayList底层是基于数组实现的,LinkedList是基于链表实现的
  2. 由于底层数据结构不同,他们所适用的场景也不同,ArrayList更适合随机查找,LinkedList更适合删除和添加,查询、添加、删除的时间复杂度不同。
  3. 另外ArrayList和LinkedList都实现了List接口,但是LinkedList还额外实现了Deque接口,所以LinkedList还可以当做队列来适用。
  4. ArrayList和LinkedList都不是线程安全的,如果需要在多线程环境中操作集合,可以使用线程安全集合类,比如Vector或者CopyOnWriteArrayList,它们可以在多线程环境下安全的操作“读操作”、“写操作”
  5. 在特殊情况下要在多线程环境里使用ArrayList需要使用Collections.synchronizedList(List list)进行包装
    1
    2
    3
    4
    5
    6
    7
    8
    List list = Collections.synchronizedList(new ArrayList());
    ...
    synchronized (list) {
    Iterator i = list.iterator(); // 必须在同步块中
        while (i.hasNext())
         foo(i.next());
    }
    // 遍历操作需要自己加锁,而add之类的方法不需要

    ArrayList是如何扩容的

    在 Java 中,ArrayList 是一个动态数组,它能够根据需要动态地增长或缩小。为了实现动态增长,ArrayList 在内部维护了一个数组,并使用一个变量来跟踪数组的当前容量。当数组的容量不足以容纳新元素时,ArrayList 会触发扩容操作。

ArrayList 的扩容机制如下:

  1. 初始容量:

    • 当创建一个新的 ArrayList 对象时,它会具有一个初始容量。默认情况下,初始容量为 10。
  2. 数组容量不足时的扩容策略:

    • 当向 ArrayList 添加新元素时,如果当前元素数量超过了当前数组容量,ArrayList 会触发扩容。
    • 扩容通常是通过创建一个新的更大容量的数组来实现的,然后将原数组的元素复制到新数组中。
  3. 扩容大小的计算:

    • 在扩容时,ArrayList 通常会选择一种策略来计算新的数组容量。常见的策略是将当前容量乘以一个固定的因子,例如 1.5 或 2。这样可以确保新数组足够大,同时避免过度浪费内存。
  4. 实际扩容操作:

    • 创建新数组:根据计算得到的新容量,创建一个新的数组。
    • 数据复制:将原数组中的元素复制到新数组中。
    • 更新引用:ArrayList 更新内部引用,指向新的数组。
    • 旧数组回收:在不再被引用时,原数组会被垃圾回收。

这种动态扩容机制使得 ArrayList 具有较好的性能,同时避免了在每次添加元素时都进行数组的重新分配。由于扩容涉及数组复制操作,频繁的插入操作可能会导致性能开销。因此,当知道 ArrayList 的元素数量可能会很大时,最好在创建 ArrayList 时指定一个合适的初始容量,以减少扩容操作的频率。

ArrayList扩容时是如何将旧数组复制到新数组中去

ArrayList 在扩容时,会通过 Arrays.copyOfSystem.arraycopy 等方法将旧数组的元素复制到新数组中。这个过程可以概括为以下几个步骤:

  1. 计算新数组容量:

    • 根据 ArrayList 的扩容策略,计算新数组的容量。
  2. 创建新数组:

    • 使用新的容量创建一个新的数组,用于存储扩容后的元素。
  3. 复制元素:

    • 使用 Arrays.copyOfSystem.arraycopy 等方法,将旧数组中的元素逐个复制到新数组中。
    • Arrays.copyOfArrays 工具类提供的一种复制数组的方法,它底层使用 System.arraycopy 实现。
    1
    2
    // 使用 Arrays.copyOf 复制数组
    Object[] newElements = Arrays.copyOf(elements, newCapacity);

    或者

    1
    2
    3
    // 使用 System.arraycopy 复制数组
    Object[] newElements = new Object[newCapacity];
    System.arraycopy(elements, 0, newElements, 0, size);

    这两种方法都可以实现数组的快速复制,System.arraycopy 是一种底层效率较高的复制方法。

  4. 更新引用:

    • ArrayList 的内部引用指向新数组。
1
2
3
4
5
6
7
8
9
10
11
12
// 示例:ArrayList 扩容时的部分代码
private void grow(int minCapacity) {
// overflow-conscious code
int oldCapacity = elementData.length;
int newCapacity = oldCapacity + (oldCapacity >> 1); // 扩容策略通常是当前容量的 1.5 倍
if (newCapacity - minCapacity < 0)
newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0)
newCapacity = hugeCapacity(minCapacity);
// minCapacity is usually close to size, so this is a win:
elementData = Arrays.copyOf(elementData, newCapacity);
}

上述代码是 ArrayListgrow 方法的一部分,它负责在扩容时进行数组的复制。通过这样的方式,ArrayList 实现了动态扩容,并且通过调整扩容策略,可以在性能和内存消耗之间做出平衡。

CopyOnWriteArrayList的底层原理

  1. 首先CopyOnWriteArrayList内部也是用过数组来实现的,在向CopyOnWriteArrayList添加元素时,会复制一个新的数组,写操作在新数组上进行,读操作在原数组上进行
  2. 并且,写操作会加锁,防止出现并发写入丢失数据的问题
  3. 写操作结束之后会把原数组指向新数组
  4. CopyOnWriteArrayList允许在写操作时来读取数据,大大提高了读的性能,因此适合读多写少的引用场景,但是CopyOnWriteArrayList会比较占用内存,同时可能读到的数据不是实时最新的数据,所以不适合实时性要求很高的场景
  5. 在add操作上加了Lock锁做同步处理,内部拷贝了原数组,并在新数组上进行添加操作,最后将新数组替换原数组
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public boolean add(E e) {
    final ReentrantLock lock = this.lock;
    lock.lock();
    try {
    Object[] elements = getArray();
    int len = elements.length;
    Object[] newElements = Arrays.copyOf(elements, len + 1);
    newElements[len] = e;
    setArray(newElements);
    return true;
    } finally {
    lock.unlock();
    }
    }

    HashMap

HashMap在JDK1.7、1.8中发生了什么变化(底层)

  1. 1.7中底层是数组+链表,1.8中底层是数组+链表+红黑树,加红黑树的目的是提高HashMap插入和查询整体效率
  2. 1.7中链表插入使用的是头插法,1.8中链表插入使用的是尾插法,因为1.8中插入key和value时需要判断链表元素个数,所以需要遍历链表统计链表元素个数,所以正好就直接使用尾插法
  3. 1.7中哈希算法比较复杂,存在各种右移与异或运算,1.8中进行了简化,因为复杂的哈希算法的目的就是提高散列性,来提供HashMap的整体效率,而1.8中新增了红黑树,所以可以适当的简化哈希算法,节省CPU资源

HashMap中链表和红黑树如何转换

在 Java 的 HashMap 中,当链表的长度超过一定阈值时,会将链表转换为红黑树,以提高查找、插入等操作的性能。这个阈值的默认值是8,即链表长度超过8时,会触发链表到红黑树的转换。

下面是简要的转换过程:

  1. 链表转红黑树:

    • 当链表长度达到阈值时,HashMap 会判断是否需要进行链表到红黑树的转换。
    • 如果需要转换,首先会将链表中的节点按照键的哈希值重新排序,以提高树的查找效率。
    • 然后,将排序后的节点逐个插入到红黑树中,构建一颗平衡的红黑树。
  2. 红黑树转链表:

    • 当红黑树的节点数量减少到一定程度时(小于等于6),HashMap 会考虑将红黑树转换回链表。
    • 转换的过程是将红黑树的节点按照哈希值排序,然后逐个插入到链表中,得到一个有序的链表。

这个转换的过程是在 TreeNode 类和 TreeNode 对应的方法中完成的。TreeNode 是专门用于存储红黑树节点的类。

需要注意的是,这种转换的机制是为了在不同场景下保持 HashMap 数据结构的高效性。在数据量较小或者哈希冲突较少的情况下,链表更适合;而在数据量较大或者哈希冲突较多的情况下,红黑树能够更高效地进行查找操作。

HashMap的Put方法

HashMap的Put方法的流程:

  1. 根据Key通过哈希算法与运算得出数组下标

  2. 如果数组下标元素为空,则将key和value封装为Entry对象(1.7中是Entry对象,1.8中是Node对象)并放入该位置

  3. 如果数组下标位置元素不为空,则要分情况讨论

    a. 如果是JDK1.7,则先判断是否需要扩容,如果要扩容就进行扩容,如果不用扩容就生成Entry对象,并使用头插法添加到当前位置的链表中

    b. 如果是JDK1.8,则会先判断当前位置上的Node类型,看是红黑树Node还是链表Node

    ​ i . 如果是红黑树Node,则将key和value封装为一个红黑树节点并添加到红黑树中去,在这个过程中会判断红黑树中是否存在当前key,如果存在则更新value

    ​ ii . 如果此配置上的Node对象是链表节点,则将key和value封装为一个链表Node并通过尾插法插入到链表的最后位置去,因为是尾插法,所以需要遍历链表,在遍历链表的过程中会判断是否存在当前key,如果存在则更新value,当遍历完链表后,将新链表Node插入到链表中,插入到链表后,会看当前链表的节点个数,如果大于等于8,那么则会将该链表转成红黑树

    ​ iii. 将key和value封装为Node插入到链表或红黑树中后,再判断是否需要进行扩容,如果需要就扩容,如果不需要就结束PUT方法

HashMap的扩容机制原理

1.7版本

  1. 先生成新数组
  2. 遍历老数组中的每个位置上的链表上的每个元素
  3. 取每个元素的Key,并基于新数组的长度,计算出每个元素在新数组中的下标
  4. 将元素添加到新数组中去
  5. 所有元素转移完了之后,将新数组赋值给HashMap对象的table属性

1.8版本

  1. 先生成新数组

  2. 遍历老数组中的每个位置上的链表或红黑树

  3. 如果是链表,则直接将链表中的每个元素重新计算下标,并添加到新数组中去

  4. 如果是红黑树,则先遍历红黑树,先计算出红黑树中每个元素对应在新数组中的下标位置

    ​ a. 统计每个下标位置的元素个数

    ​ b. 如果该位置下的元素个数超过了8,则生成一个新的红黑树,并将根节点的添加到新数组的对应位置

    ​ c. 如果该位置下的元素个数没有超过8,那么则生成一个链表,并将链表的头节点添加到新数组的对应位置

  5. 所有元素转移完成了之后,将新数组赋值给HashMap对象的table属性

ConcurrentHashMap的扩容机制

1.7版本:

  1. 1.7版本的ConcurrentHashMap是基于Segment分段实现的
  2. 每个Segment相当一个小型的HashMap
  3. 每个Segment内部会进行扩容,和HashMap的扩容逻辑类似
  4. 先生成新的数组,然后转移元素到新数组中
  5. 扩容的判断也是每个Segment内部单独判断的,判断是否超过阈值。

1.8版本:

  1. 1.8版本的ConcurrentHashMap不再基于Segment实现
  2. 当某个线程进行put时,如果发现ConcurrentHashMap正在进行扩容那么该线程一起进行扩容
  3. 如果某个线程put时,发现没有正在进行扩容,则将key-value添加到ConcurrentHashMap中,然后判断是否超过阈值,超过了则进行扩容
  4. ConcurrentHashMap是支持多个线程同时扩容的
  5. 扩容之前也先生成一个新的数组
  6. 在转移元素时,先将原数组分组,将每组分给不同的线程来进行元素的转移,每个线程负责一组或多组的元素转移工作

深拷贝和浅拷贝

深拷贝和浅拷贝是指对象的拷贝,一个对象中存在两种类型的属性,一种是基本数据类型,一种是实例对象的引用。

  1. 浅拷贝是指,只会拷贝基本数据类型的值,以及实例对象的引用地址,并不会复制一份引用地址所指向的对象,也就是浅拷贝出来的对象,内部的类属性指向的是同一个对象
  2. 深拷贝是指,既会拷贝基本数据类型的值,也会针对实例对象的引用地址所指向的对象进行复制,深拷贝出来的对象,内部的属性指向的不是同一个对象。

Java中的异常体系

  • Java中所有异常的顶级父类是Throwable
  • Throwable下有两个子类Exception、Error
  • Error表示非常严重的错误,比如Java.lang.StachOverFlowError,Java.lang.OutOfMemmoryError,通常这些错误出现时,仅仅想靠程序自己是解决不了的,可能是虚拟机、磁盘、操作系统层面出现了问题,所以通常也不建议在代码中去捕获这些Error,因为捕获的意义不大,因为程序可能已经根本运行不了了。
  • Exception表示异常,表示程序出现Exception时,是可以考程序自己来解决的,比如NullPointException、IllegalAccessException等,我们可以捕获这些异常来做特殊处理。
  • Exception的子类通常又可以分为RuntimeException和非RuntimeException两类
  • RuntimeException表示运行期异常,表示这个异常是在代码运行过程中抛出的,这些异常时非检查异常,程序中可以选择捕获处理,也可以不处理。这些异常一般是由程序逻辑错误引起的,程序应该从逻辑角度尽可能避免出现这类异常,比如NullPointException、IndexOutOfBoundsException等。
  • 非RuntimeException表示运行期异常,也就是我们常说的检查异常,是必须进行处理的异常,如果不处理,程序就不能检查异常通过。如IOException、SQLException等以及用户自定义的Exception异常

在Java的异常处理机制中,什么时候应该抛出异常,什么时候捕获异常?

异常相当于一种提示,如果我们抛出异常,将相当于告诉上层方法,我抛了一个异常,交给你来处理,而对于上层方法来说,它也需要决定自己能不能处理这个异常,是否也需要交给它的上层。

所以我们在写方法时,我们需要考虑的就是,本方法能否合理的处理该异常,如果处理不了就继续向上抛出异常,包括本方法中在调用另外一个方法时,发现出现了异常,如果这个异常应该由自己来处理,那就捕获该异常并进行处理。

面向对象

封装:封装的意义,在于明确标识出允许外部使用的所有成员函数和数据项

内部细节对外部调用透明,外部调用无需修改或者关心内部实现

  1. javabean的属性私有,提供get set对外访问,因为属性的赋值或者获取逻辑只能由javabean本身决定。而不能由外部胡乱修改

    1
    2
    3
    4
    5
    private String name;
    public void setName(String name) {
    this.name = "tuling_"+name;
    }
    //该name由自己的命名规则,明显不能由外部直接赋值
  2. orm框架

    操作数据库,我们不需要关心链接是如何建立的、sql是如何执行的,只需要引入mybatis,调方法即可

继承:继承基类的方法,并作出自己的改变和/或扩展

子类共性的方法或者属性直接使用父类的,而不需要自己再定义,只需要扩展自己个性化的

多态:基于对象所属类的不同,外部对同一个方法的调用,实际执行的逻辑不同

继承,方法重写,父类引用指向子类对象

1
2
父类类型 变量名 = new 子类对象;
变量名.方法名();

无法调用子类特有的功能。

Java中有哪些类加载器

JDK自带的有三个类加载器:bootstrapClassLoader、ExtClassLoader、AppClassLoader

  • BootStrapClassLoader是ExtClassLoader的父类加载器,默认负责加载%JAVA_HOME%/lib下的jar包和class文件
  • ExtClassLoader是AppClassLoader的父类加载器,负责加载%JAVA_HOME%/lib/ext文件夹下的jar包和class类
  • AppClassLoader是自定义加载器的父类,负责加载classpath下的类文件

说说类加载器双亲委派模型

JVM中存在三个默认的类加载器

  1. BootstrapClassLoader
  2. ExtClassLoader
  3. AppClassLoader

AppClassLoader的父加载器是ExtClassLoader,ExtClassLoader的父加载器是BootstrapClassLoader

JVM在加载一个类时,会调用AppClassLoader的loadClass方法来加载这个类,不过在这个方法中,会先试用ExtClassLoader的loadClass方法来加载类,同样ExtClassLoader的loadClass方法中会先试用BootstrapClassLoader来加载类,如果BootstrapClassLoader加载到了就直接成功,如果BootstrapClassLoader没有加载到,那么ExtClassLoader就会自己尝试加载该类,如果没有加载到,那么则会有AppClassLoader来加载这个类。

所以双亲委派指得是,JVM在加载类时,会委派给Ext和BootStrap进行加载,如果没加载到才由自己进行加载。

JDK1.8 新特性

Lambda表达式

Lambda允许把函数作为一个方法的参数(函数作为参数传递进方法中)。使用 Lambda表达式可以使代码变的更加简洁紧凑。

方法引用

方法引用提供了非常有用的语法,可以直接引用已有Java类或对象(实例)的方法或构造器。与lambda联合使用,方法引用可以使语言的构造更紧凑简洁,减少冗余代码。

默认方法

默认方法就是一个在接口里面有了一个实现的方法。

新工具

新的编译工具,如:Nashorn引擎 jjs、 类依赖分析器jdeps。

StreamApi

新添加的Stream API(java.util.stream) 把真正的函数式编程风格引入到Java中。

DateTimeApi

加强对日期与时间的处理。

Optional类

Optional 类已经成为 Java 8 类库的一部分,用来解决空指针异常。

Nashorn,JavaScript引擎

JDK1.8提供了一个新的Nashorn javascript引擎,它允许我们在JVM上运行特定的javascript应用。

并发编程

sleep()和wait()的区别

sleep()wait() 是在 Java 中用于线程控制的两个方法,它们有一些重要的区别:

  1. 调用方式:

    • sleep() sleep() 方法是 Thread 类的静态方法,可以通过 Thread.sleep() 的方式调用。它不需要获取对象的锁。
    • wait() wait() 方法是 Object 类的实例方法,需要在同步的块或方法内部调用,通常与 synchronized 关键字一起使用。在调用 wait() 之前,线程必须先获得对象的锁。
  2. 使用的类:

    • sleep() sleep() 方法是 Thread 类的方法,可以在任何对象上调用。
    • wait() wait() 方法是 Object 类的方法,只能在实现了 Object 的类的实例上调用。
  3. 释放锁的情况:

    • sleep() 在调用 sleep() 的过程中,线程不会释放其持有的锁。
    • wait() 在调用 wait() 之后,线程会释放它所持有的对象的锁,使得其他线程可以获得该锁。
  4. 唤醒机制:

    • sleep() sleep() 方法会在指定的时间后自动唤醒,或者可以通过 interrupt() 方法手动中断。
    • wait() wait() 方法需要等待其他线程调用相同对象上的 notify()notifyAll() 方法才能被唤醒。
  5. 使用场景:

    • sleep() 通常用于模拟时间的流逝,或者在一些简单的定时任务中使用。
    • wait() 通常用于多线程之间的协调,等待条件的满足或者等待其他线程的通知。

下面是一个简单的例子,展示了 sleep()wait() 的基本用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class SleepAndWaitExample {

public static void main(String[] args) {
final Object lock = new Object();

// sleep 示例
new Thread(() -> {
synchronized (lock) {
System.out.println("Thread 1: Acquired lock, sleeping for 2 seconds.");
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread 1: Woke up, releasing lock.");
}
}).start();

// wait 示例
new Thread(() -> {
synchronized (lock) {
System.out.println("Thread 2: Acquired lock, waiting for notification.");
try {
lock.wait(); // 等待其他线程的通知
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("Thread 2: Got notified, releasing lock.");
}
}).start();
}
}

在这个例子中,Thread 1 使用 sleep() 方法等待了2秒,而 Thread 2 使用 wait() 方法等待其他线程的通知。

Synchronized和Lock锁底层实现原理

  • Synchronized是语义级支持
    • JVM层面
    • 锁升级过程
  • LOCK锁是AQS
    • JDK层面
    • CAS(Compare and Swap - 比较并交换)
      • CAS 操作包含三个操作数 —— 内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值V与预期原值A相匹配,那么处理器会自动将该位置值V更新为新值B,否则,处理器不做任何操作,整个操作保证了原子性,即在对比V==A后、设置V=B之前不会有其他线程修改V的值。

线程池

  • 工作流程
    • 线程先提交进来,进入核心池里面
    • 如果线程数大于coreSize,就会进入一个阻塞队列
    • 阻塞队列满了之后会新建一些线程,进入到最大的池
    • 当线程数maxSize超出了之后,会执行一个拒绝策略
      • JDK自带的拒绝策略有4种
        • 一种是直接丢弃
        • 一种是抛出异常
        • 一种是丢弃阻塞队列里面等待时间最长的一个线程
        • 一种是由调用者在调用者的线程里执行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
graph TD

A[开始]-->B[提交Runable];

B-->C{线程数 < corePoolSize};

C-->|是|D[创建新线程将当前Runable作为线程要执行的第一个任务];

C-->|否|E[尝试将Runnable加入到workQueue中];


E-->|否|F[入队等待被执行];
E-->|是|G{线程数 < maximumPoolSize}

G-->|否|H[拒绝任务]
G-->|是|I[创建新线程,将当前Runnable作为线程要执行的第一个任务];

实现多线程的两种方式

  • 继承Thread类

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    public class MyThread extends Thread {
    String name;

    @Override
    public void run() {
    for (int i=1; i<10; i++) {
    System.out.println(name + 1);
    }
    }
    }

    public class Test{
    public static void main() {
    new MyThread("A").start();
    new MyThread("B").start();
    new MyThread("C").start();
    }
    }
  • 实现Runable接口

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    public class MyThread implements Runnable {
    @Override
    public void run() {
    ....
    }
    }

    public class Hello {
    public static void main() {
    Thread thread = new Thread(new MyThread);
    thread.start();
    }
    }
  • 通过Callable和Future创建线程

    (1)创建Callable接口的实现类,并实现call()方法,该call()方法将作为线程执行体,并且有返回值。

    (2)创建Callable实现类的实例,使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值。

    (3)使用FutureTask对象作为Thread对象的target创建并启动新线程。

    (4)调用FutureTask对象的get()方法来获得子线程执行结束后的返回值

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    public class CallableThreadTest implements Callable<Integer> {
    @Override
    public Integer call() throws Exception {
    int i =0;
    for(;i<100;i++) {
    System.out.println(Thread.currentThread().getName() + " " + i);
    }
    return i;
    }

    public static void main(String[] args) {
    CallableThreadTest ctt = new CallableThreadTest();
    FutureTask<Integer> ft = new FutureTask<>(ctt);
    for (int i = 0; i<100; i++) {
    System.out.println(Thread.currentThread().getName() + " 的循环变量i的值" + i);
    if (i==20) {
    new Thread(ft, "有返回值的线程").start();
    }
    }
    try {
    System.out.println("子线程的返回值: " + ft.get());
    } catch (InterruptedException e) {
    e.printStackTrace();
    } catch (ExcutionException e) {
    e.printStackTrace();
    }
    }
    }

说说对线程安全的理解

线程安全指得是,我们写的某段代码,在多个线程同时执行这段代码时,不会产生混乱,依然能够得到正常的结果,比如i++,i的初始化值为0,那么两个线程来同时执行这行代码,如果代码是线程安全的,那么最终的结果应该就是一个线程的结果为1,一个线程的结果为2,如果出现了两个线程的结果都为1,则表示这段代码时线程不安全。

所以线程安全,主要指得是一段代码在多个线程同时执行的情况下,能否得到正确的结果。

对守护线程的理解

线程分为用户线程和守护线程,用户线程就是普通线程,守护线程就是JVM的后台线程,比如垃圾回收线程就是一个守护线程,守护线程会在其他普通线程都停止运行之后自动关闭。我们可以通过设置thread.setDaemon(true)来把一个线程设置为守护线程。

ThreadLocal的底层原理

  1. ThreadLocal是JAVA中所提供的线程本地存储机制,可以利用该机制将数据缓存在某个线程内部,该线程可以在任意时刻、任意方法中获取缓存的数据

  2. ThreadLocal底层是通过ThreadLocalMap来实现的,每个Thread对象(注意不是ThreadLocal对象)中都存在一个ThreadLocalMap,Map的key为ThreadLocal对象,Map的value为需要缓存的值

  3. 如果在线程池中使用ThreadLocal会造成内存泄漏,因为当ThreadLocal对象使用完之后,应该要把设置的key,value,也就是Entry对象进行回收,但线程池中的线程不会回收,而线程对象是通过强引用指向ThreadLocalMap,ThreadLocalMap也是通过强引用指向Entry对象,线程不被回收,Entry对象也就不会被回收,从而出现内存泄漏,解决办法是,在使用了ThreadLocal对象之后,手动调用ThreadLocal的remove方法,手动清楚Entry对象。

  4. ThreadLocal经典的应用场景就是连接管理(一个线程持有一个连接,该连接对象可以在不同的方法之间进行传递,线程之间不共享同一个连接

并发、并行、串行之间的区别

  1. 串行:一个任务执行完,才能执行下一个任务
  2. 并行:两个任务同时执行
  3. 并发:两个任务整体看上去是同时执行,在底层,两个任务被拆分成了很多份,然后一个一个执行,站在更高的角度看来两个任务是同时执行的

Java死锁如何避免

造成死锁的几个原因:

  1. 一个资源每次只能被一个线程使用
  2. 一个线程在阻塞等待某个资源时,不释放已占有资源
  3. 一个线程已经获得的资源,在未使用完之前,不能被强行剥夺
  4. 若干线程形成头尾相接的循环等待资源关系

这是造成死锁必须要达到的4个条件,如果要避免死锁,只需要不满足其中某一个条件即可。而其中前3个条件是作为锁要符合的条件,所以要避免死锁就需要打破谛哥条件,不出现循环等待锁的关系。

在开发过程中:

  1. 要注意加锁的顺序,保证每个线程按同样的顺讯进行加锁
  2. 要注意加锁的时限,可以针对所设置一个超时时间
  3. 要注意死锁检查,这是一种防御机制,确保在第一时间发现死锁并进行解决

乐观锁与悲观锁

乐观锁

CAS是乐观锁的一种实现方式,是一种轻量级锁,JUC中很多工具类的实现就是基于CAS的

CAS是如何实现线程安全的?

线程在读取数据时不进行加锁,在准备写回数据时,先去查询原值,操作的时候比较原值是否修改,若未被其他线程修改则写回,若已被修改,则重新执行读取流程。

悲观锁

synchronized就是悲观锁的一种实现。

synchronized,代表这个方法加锁,相当于不管哪一个线程,运行到这个方法时,都要检查有没有其他线程正在用这个方法,有的话要等正在使用synchronized方法的线程运行完这个方法后再运行此线程,没有的话,锁定调用者,然后直接运行。

JAVA WEB

什么是Servlet

Servlet是Java Servlet的简称,称为小服务程序或服务连接器,用Java编写的服务器端程序,具有独立于平台和协议的特性,是一个Java类。

JavaWeb中servlet主要功能是承载网络连接,业务逻辑处理,比如一些编码格式的转换、登录拦截等。

狭义的Servlet是指Java语言实现的一个接口,广义的Servlet是指任何实现了这个Servlet接口的类,一般情况下,人们将Servlet理解为后者。Servlet运行于支持Java的应用服务器中。从原理上讲,Servlet可以响应任何类型的请求,但绝大多数情况下Servlet只用来扩展基于HTTP协议的Web服务器。

JSP与Sevlet的区别

Jsp是Servlet的一种简化,使用Jsp只需要完成程序员需要输出到客户端的内容,Jsp中的Java脚本如度何镶嵌到一个类中,由Jsp容器完成。

1、jsp经编译后就变成了Servlet。

2、jsp更擅长表现于页面显示,servlet更擅长于逻辑控制。

3、Servlet中没有内置对象,Jsp中的内置对象都是必须通过HttpServletResponse对象以及HttpServlet对象得到。

4、而Servlet则是个完整的Java类,这个类的Service方法用于生成对客户端的响应

5、Servlet的应用逻辑是在Java文件中,并且完全从表示层中的HTML里分离开来。

6、而JSP的情况是Java和HTML可以组合成一个扩展名为.jsp的文件。

7、JSP侧重于视图,Servlet主要用于控制逻辑

8、Servlet更多的是类似于一个Controller,用来做控制。

JVM

  1. jvm的位置
  2. JVM的体系结构
  3. 类加载器
  4. 双亲委派机制
  5. 沙箱安全机制
  6. Native
  7. PC寄存器
  8. 方法区
  9. 三种JVM
  10. 新生区、老年区
  11. 永久区
  12. 堆内存调优
  13. GC
    1. 常用算法
  14. JMM

JVM探究

  • 对JVM的理解?java8虚拟机和之前的变化、更新?
    • JVM(Java Virtual Mechinal),Java虚拟机,是JRE的一部分,它是整个java实现跨平台的最核心的部分,负责运行字节码文件。
  • 什么是OOM?什么是栈溢出StackOverFlowError? 怎么分析
  • JVM的常用调优参数有哪些?
  • 内存快照如何抓取?怎么分析Dump文件?
  • 谈谈JVM中,类加载器你的认识?

JVM中哪些是线程共享区

堆区和方法区是所有线程共享的,栈、本地方法栈、程序计数器是每个线程独有的

![jvm运行时数据区.drawio (1)](C:\Users\oveng\Downloads\jvm运行时数据区.drawio (1).png)

JVM中一次完整的GC过程

Java虚拟机(JVM)的垃圾回收(Garbage Collection)是管理和释放不再使用的对象的过程。JVM的垃圾回收过程可以分为以下几个阶段:

  1. 标记(Mark):

    • 在这个阶段,垃圾收集器标记所有仍然被引用的对象。它从根对象(如堆栈、静态变量等)开始遍历对象图,标记所有与根对象直接或间接相连的可达对象。标记过程通常使用深度优先搜索或广度优先搜索算法。
  2. 清除(Sweep):

    • 在标记完成后,垃圾收集器清除所有未被标记的对象。这些未被标记的对象被认为是不再被引用的,可以安全地回收它们的内存。清除阶段的操作就是将这些未被标记的对象所占用的内存释放掉。
  3. 整理(Compact):

    • 在清除阶段后,可能会出现堆内存中出现了不连续的内存空间。为了避免后续分配内存时发生内存碎片,垃圾收集器可能会进行内存整理操作。整理的过程将存活的对象移动到一端,从而产生一块连续的空闲内存。这有助于提高后续对象的分配效率。
  4. 细粒度收集(Optional: Fine-grained Collection):

    • 一些垃圾收集器可能会在上述阶段之后进行一些额外的收集操作,以进一步优化性能。例如,G1垃圾收集器可以执行细粒度的收集,只针对某些区域进行收集,而不是整个堆。

需要注意的是,并不是所有的垃圾收集器都执行这些阶段,而且不同的垃圾收集器可能采用不同的算法和策略。常见的垃圾收集器有串行收集器、并行收集器、CMS收集器和G1收集器等。每个收集器都有其自己的优点和适用场景。

此外,Java 9 引入了一种新的垃圾收集器,即垃圾优先垃圾回收器(G1 GC)。G1 GC 的设计目标是在有限的时间内获得更好的吞吐量,并且具有更可预测的停顿时间。

如何排查JVM问题

对于还在正常运行的系统:

  1. 可以使用jmap来查看jvm中各个区域的使用情况
  2. 可以通过jstack来查看线程的运行情况,比如哪些线程阻塞、是否出现了死锁
  3. 可以通过jstat命令来查看垃圾回收的情况,特别是fullgc,如果发现fullgc比较频繁,那么就得进行调优了
  4. 通过各个命令的结果,或者jvisualvm等工具来进行分析
  5. 首先,初步猜测频繁发送fullgc的原因,如果频繁发生fullgc但是又一直没有出现内存溢出,那么表示fullgc实际上是回收了很多对象了,所以这些对象最好能在younggc过程中就直接回收掉,避免这些对象进入到老年代,对于这种情况,就要考虑这些存活时间不长的对象是不是比较大,导致年轻代放不下,直接进入到了老年代,尝试加大年轻代的大小,如果改完之后,fullgc减少,则证明修改有效
  6. 同时,还可以找到占用cpu最多的线程,定位到具体的方法,优化这个方法的执行,看是否能避免某些对象的创建,从而节省内存

对于已经发生了OOM的系统:

  1. 一般生产系统中都会设置当前系统发生了OOM时,生成当时的dump文件(-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/usr/local/base)
  2. 我们可以利用jsisualvm等工具来分析dump文件
  3. 根据dump文件找到异常的实例对象,和异常的线程(占用CPU高),定位到具体的代码
  4. 然后在进行详细的分析和调试

总之,调优就不是一蹴而就的,需要分析、推理、实践、总结、再分析,最终定位到具体的问题

GC垃圾回收

一个对象从加载到JVM,再到被GC清除,都经历了什么过程?

  1. 首先把字节码文件内容加载到方法区
  2. 然后再根据类信息在堆区创建对象
  3. 对象首先会分配在堆区中年轻代的Eden区,经过一次Minor GC后,对象如果存活,就会进入Suvivor区。在后续的每次Minor GC中,如果对象一直存活,就会在Suvivor区来回拷贝,每移动一次,年龄加1
  4. 当年龄超过
  5. 如果经过FullGC,被标记为垃圾对象,那么就会被GC线程清理掉

JVM中一次完整的GC过程

Java虚拟机(JVM)的垃圾回收(Garbage Collection)是管理和释放不再使用的对象的过程。JVM的垃圾回收过程可以分为以下几个阶段:

  1. 标记(Mark):

    • 在这个阶段,垃圾收集器标记所有仍然被引用的对象。它从根对象(如堆栈、静态变量等)开始遍历对象图,标记所有与根对象直接或间接相连的可达对象。标记过程通常使用深度优先搜索或广度优先搜索算法。
  2. 清除(Sweep):

    • 在标记完成后,垃圾收集器清除所有未被标记的对象。这些未被标记的对象被认为是不再被引用的,可以安全地回收它们的内存。清除阶段的操作就是将这些未被标记的对象所占用的内存释放掉。
  3. 整理(Compact):

    • 在清除阶段后,可能会出现堆内存中出现了不连续的内存空间。为了避免后续分配内存时发生内存碎片,垃圾收集器可能会进行内存整理操作。整理的过程将存活的对象移动到一端,从而产生一块连续的空闲内存。这有助于提高后续对象的分配效率。
  4. 细粒度收集(Optional: Fine-grained Collection):

    • 一些垃圾收集器可能会在上述阶段之后进行一些额外的收集操作,以进一步优化性能。例如,G1垃圾收集器可以执行细粒度的收集,只针对某些区域进行收集,而不是整个堆。

需要注意的是,并不是所有的垃圾收集器都执行这些阶段,而且不同的垃圾收集器可能采用不同的算法和策略。常见的垃圾收集器有串行收集器、并行收集器、CMS收集器和G1收集器等。每个收集器都有其自己的优点和适用场景。

此外,Java 9 引入了一种新的垃圾收集器,即垃圾优先垃圾回收器(G1 GC)。G1 GC 的设计目标是在有限的时间内获得更好的吞吐量,并且具有更可预测的停顿时间。

怎么确定一个对象到底是不是垃圾?

  1. 引用计数算法:这种方式是给堆内存当中的每个对象记录一个引用个数,引用个数为0的就认为是垃圾。这是早期JDK中使用的方式。引用计数无法解决循环引用的问题。
  2. 可达性算法:这种方式是在内存中,从根对象向下一只找引用,找到的对象就不是垃圾,没找到的对象就是垃圾。

JVM的垃圾回收算法

  1. 标记清除算法:

    a. 标记阶段:把垃圾内存标记出来

    b. 清除阶段:直接将垃圾内存回收

    c. 这种算法比较简单的,但是有一个很严重的问题,就是会产生大量的内存碎片

  2. 复制算法:为了解决标记清除算法的内存碎片问题,就产生了复制算法。复制算法将内存分为大小想等的两半,每次只使用其中的一般。垃圾回收时,将当前这一块的存活对象全部拷贝到另一半,然后当前这一半内存就可以直接清除。这种算法没有内存碎片,但是他的问题就在于浪费空间。而且,他的效率跟存活对象的个数有关。

  3. 标记压缩算法:为了解决复制算法的缺陷,就提出了标记压缩算法。这种算法在标记阶段跟标记清除算法是一样的。但是在完成标记之后,不是直接清理垃圾内存,而是将存活对象往一端移动,然后将边界以外的所有内存直接清除。

什么是STW

STW: Stop-The-World,是在垃圾回收算法执行过程中,需要将JVM内存冻结的一种状态。在STW状态下,JAVA的所有线程都是停止执行的-GC线程除外,native方法可以执行,但是,不能与JVM交互。GC公众算法优化的重点,就是减少STW,同时这也是JVM调优的重点。

常用的JVM参数

JVM参数大致可以分为三类:

  1. 标注指令: -开头,这些是所有的HotSpot都支持的参数。可以用java -help 打印出来
  2. 非标准指令: -X开头,这些指令通常试试跟特定的HotSpot版本对应的。可以用java -X打印出来。
  3. 不稳定参数: -XX开头,这一类参数是跟特定HotSpot版本对应的,并且变化非常大。

JVM调优

JVM调优原则

JVM调优应该是调优的最后一步,应该在应用层、数据库层、框架层都进行调优后,系统还有性能问题时再进行JVM调优。

JVM调优的时机

  • Heap内存(老年代)持续上涨达到设置的最大内存值
  • Full GC次数频繁
  • GC停顿时间过长(超过1s)
  • 应该出现OutOfMemory等内存异常
  • 应用中有使用本地缓存且占用大量内存空间
  • 系统吞吐量与响应性能不高或下降

JVM调优的目标

吞吐量、延迟、内存占用三者类似CAP,构成了一个不可能三角,只能选择其中两个进行调优,不可三者兼得,

  • 延迟:GC低停顿和GC低频率
  • 低内存占用;
  • 高吞吐量

选择了其中两个,必然会以牺牲另一个为代价。

注意:不同应用的JVM调优量化目标是不一样的。

JVM调优的步骤

  • 分析系统运行情况:分析GC日志和dump文件,判断是否需要进行优化,确定瓶颈问题点;
  • 确定JVM调优量化目标
  • 确定JVM调优参数(根据历史JVM参数来调整)
  • 依次确定调优内存、延迟、吞吐量等指标
  • 对比观察调优前后的差异
  • 不断的分析和调整,知道找到合适的JVM参数配置;
  • 找到最合适的参数,将这些参数应用到所有服务器,并进行后续跟踪

一般是从满足程序的内存使用需求开始的,之后是时间延迟的要求,最后才是吞吐量的要求。

img

栈内存溢出的情形(StackOverflowError),如何解决

栈内存溢出(StackOverflowError)通常是由于递归调用深度过大导致的。每次调用一个方法,Java 虚拟机会在栈中分配一帧来存储方法的局部变量、操作数栈、返回地址等信息。当递归调用层次过深,栈空间被用尽时,就会抛出 StackOverflowError。

解决栈内存溢出的方法通常包括:

  1. 检查死循环或意外递归:

    • 确保代码中没有死循环或者不正确的递归调用,这可能导致栈的快速增长。
  2. 优化递归算法:

  • 如果代码中存在递归调用,考虑是否能够通过迭代或其他非递归方式来实现相同的功能。递归调用深度过大可能会导致栈溢出。
  1. 增加栈大小:
  • 可以通过 -Xss 参数来增加栈的大小,例如:

    1
    java -Xss2m YourClassName

    这会将栈的大小增加到2MB。但是要注意,过度增加栈的大小可能导致操作系统无法分配足够的内存,从而导致程序无法运行。

  1. 减少局部变量的使用:
  • 局部变量会占用栈空间,减少方法中局部变量的使用可以减小每一帧的大小,从而降低栈的使用量。
  1. 使用尾递归优化:
  • 尾递归是一种特殊的递归形式,即递归调用是方法的最后一个操作。某些编译器和运行时环境(尤其是函数式编程语言)可能对尾递归进行优化,从而不会导致栈溢出。但是需要注意的是,Java 并没有针对尾递归进行优化。

根据实际情况选择适当的解决方案,通常建议优化递归算法或者使用非递归方式解决问题。增加栈的大小可能会解决问题,但是这并不是一个长期的可持续解决方案,因为栈的大小是有限的。

数据结构

数组

数组(Array)是一种常见的数据结构,用于存储相同类型的元素集合。数组中的每个元素都有一个唯一的索引,通过索引可以访问或修改数组中的元素。数组在编程中被广泛应用,是一种简单而有效的数据结构。

以下是一些关于数组的基本概念和操作:

  1. 数组的声明和初始化:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    // 声明一个整型数组
    int[] intArray;

    // 声明并初始化一个整型数组
    int[] intArray = new int[5]; // 创建长度为5的整型数组

    // 初始化数组元素
    intArray[0] = 1;
    intArray[1] = 2;
    // ...

    // 使用数组字面值初始化数组
    int[] intArray = {1, 2, 3, 4, 5};
  2. 数组的特点:

    • 数组是固定长度的,一旦创建后,长度不能改变。
    • 数组中的元素类型必须相同。
    • 数组的索引从0开始,依次递增。
  3. 数组的访问和修改:

    1
    2
    3
    4
    5
    // 访问数组元素
    int value = intArray[2]; // 获取索引为2的元素值

    // 修改数组元素
    intArray[2] = 10; // 将索引为2的元素值修改为10
  4. 数组的长度:

    1
    int length = intArray.length; // 获取数组的长度
  5. 遍历数组:

    1
    2
    3
    4
    5
    6
    7
    8
    for (int i = 0; i < intArray.length; i++) {
    System.out.println(intArray[i]);
    }

    // 使用增强for循环遍历数组
    for (int num : intArray) {
    System.out.println(num);
    }
  6. 多维数组:

    1
    2
    3
    4
    5
    // 二维数组
    int[][] twoDArray = {{1, 2, 3}, {4, 5, 6}};

    // 访问二维数组元素
    int value = twoDArray[0][1]; // 获取第一行第二列的元素值
  7. 动态数组:
    Java中的 ArrayList 是动态数组的实现,它可以根据需要动态增长或缩小。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    import java.util.ArrayList;

    // 创建动态数组
    ArrayList<Integer> dynamicArray = new ArrayList<>();

    // 添加元素
    dynamicArray.add(1);
    dynamicArray.add(2);
    // ...

    // 获取元素
    int value = dynamicArray.get(0);

链表

链表(Linked List)是一种常见的线性数据结构,它由一系列节点(Node)组成,每个节点包含数据和一个指向下一个节点的引用(指针或链接)。相比于数组,链表的结构更加灵活,可以在运行时动态地分配和释放内存。

链表分为单向链表和双向链表两种主要类型:

  1. 单向链表(Singly Linked List): 每个节点包含数据和指向下一个节点的指针。最后一个节点的指针通常指向空值(null),表示链表的结束。

    1
    节点1  -> 节点2  -> 节点3  -> null
  2. 双向链表(Doubly Linked List): 每个节点包含数据、指向下一个节点的指针以及指向前一个节点的指针。这使得在双向链表中,可以在两个方向上遍历。

    1
    null <- 节点1 <-> 节点2 <-> 节点3 -> null

    链表的主要优点之一是在插入和删除操作方面更为高效,因为只需要调整指针而无需移动大量的数据。然而,它的缺点是访问元素时需要遍历链表,而数组可以通过索引直接访问元素。选择使用链表还是数组取决于特定的应用需求和操作的频率。

队列

队列(Queue)是一种常见的数据结构,它遵循先进先出(First-In-First-Out,FIFO)的原则。在队列中,新元素只能在队尾添加,而只有队头的元素可以被移除。这就好比排队买票,先到先得,先来的人先被服务。

队列有两个主要操作:

  1. 入队(Enqueue): 将元素添加到队列的末尾。
  2. 出队(Dequeue): 从队列的头部移除元素。

另外,队列通常还包括一个查看队头元素但不移除的操作,这个操作叫做 Peek。

队列的应用场景很广泛,例如:

  • 任务调度: 在操作系统中,进程的任务调度通常使用队列来管理等待执行的任务。
  • 广度优先搜索(BFS): 在图算法中,队列被广泛用于实现广度优先搜索,用于遍历图的层次结构。
  • 打印队列: 打印任务通常按照到达的顺序排队,先到的任务先执行。
  • 消息队列: 在软件开发中,消息队列被用于实现异步通信,组织消息的传递顺序。

队列可以用数组或链表来实现。数组实现的队列可能需要考虑队列满的情况,而链表实现的队列可以更加灵活,但在某些场景下可能需要更多的内存。选择数组还是链表取决于具体的应用需求。

平衡二叉树

B树

B+树

B+树(B Plus Tree)是一种自平衡树数据结构,常被用于数据库和文件系统的索引结构。B+树具有许多优点,包括高效的搜索、插入和删除操作,以及稳定的性能。以下是B+树的主要特征和性质:

  1. 节点结构:

    • B+树的节点分为内部节点和叶子节点。
    • 内部节点存储关键字和子树的指针,而叶子节点存储关键字和对应的数据记录。
  2. 有序性:

    • B+树的叶子节点形成一个有序链表,便于范围查询和遍历。
    • 内部节点的关键字按照升序排列,用于快速定位。
  3. 平衡性:

    • B+树是一种平衡树,确保从根节点到叶子节点的路径长度基本相等。
    • 维持平衡性的操作包括节点的分裂和合并。
  4. 自适应性:

    • B+树能够自适应地调整树的结构,以适应数据的动态插入和删除。
  5. 查找操作:

    • 从根节点开始,通过内部节点的关键字进行二分查找,找到对应的子树。
    • 重复这个过程直到叶子节点,然后在叶子节点中进行二分查找,找到目标关键字对应的数据。
  6. 插入操作:

    • 从根节点开始,通过内部节点找到合适的叶子节点。
    • 在叶子节点中插入关键字,并确保有序性。如果插入后叶子节点的关键字数量超过阈值,可能触发节点的分裂操作。
  7. 删除操作:

    • 从根节点开始,通过内部节点找到目标关键字所在的叶子节点。
    • 在叶子节点中删除关键字,并确保有序性。如果删除后叶子节点的关键字数量低于阈值,可能触发节点的合并操作。
  8. 范围查询:

    • 由于B+树的有序性,范围查询变得更为高效。可以通过遍历叶子节点链表来获取有序的查询结果。
  9. 适用场景:

    • B+树适用于磁盘存储等场景,因为它的节点关键字较多,每次I/O操作能够读取更多的数据,减少磁盘I/O次数。

B+树在数据库索引和文件系统中得到广泛应用,例如MySQL数据库的聚簇索引就是基于B+树实现的。B+树的平衡性和有序性使得它在范围查询和高效插入、删除等操作上具有较好的性能。

实现一个完整的B+树是一个相对复杂的任务,涉及到节点的分裂、合并,插入和删除的逻辑,以及范围查询等。下面是一个简化版的B+树的Java实现,用于演示基本概念。请注意,实际生产环境中,建议使用已有的库或者数据库提供的索引功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
import java.util.ArrayList;
import java.util.List;

class BPlusTree {
private Node root;
private int order;

static class Node {
List<Integer> keys;
List<Node> children;
boolean isLeaf;

Node() {
keys = new ArrayList<>();
children = new ArrayList<>();
isLeaf = true;
}
}

BPlusTree(int order) {
this.root = new Node();
this.order = order;
}

void insert(int key) {
if (root.keys.size() == order - 1) {
Node newRoot = new Node();
newRoot.children.add(root);
split(newRoot, 0);
root = newRoot;
}
insertNonFull(root, key);
}

private void insertNonFull(Node node, int key) {
int index = findInsertIndex(node, key);
if (node.isLeaf) {
node.keys.add(index, key);
} else {
Node child = node.children.get(index);
if (child.keys.size() == order - 1) {
split(node, index);
if (key > node.keys.get(index)) {
index++;
}
}
insertNonFull(node.children.get(index), key);
}
}

private void split(Node parent, int index) {
Node node = parent.children.get(index);
Node newNode = new Node();

parent.keys.add(index, node.keys.get(order / 2 - 1));
parent.children.add(index + 1, newNode);

newNode.keys.addAll(node.keys.subList(order / 2, order - 1));
node.keys.subList(order / 2 - 1, order - 1).clear();

if (!node.isLeaf) {
newNode.children.addAll(node.children.subList(order / 2, order));
node.children.subList(order / 2, order).clear();
}
}

private int findInsertIndex(Node node, int key) {
int index = 0;
while (index < node.keys.size() && key > node.keys.get(index)) {
index++;
}
return index;
}

void print() {
printTree(root, 0);
}

private void printTree(Node node, int level) {
System.out.print("Level " + level + ": ");
for (int i = 0; i < node.keys.size(); i++) {
System.out.print(node.keys.get(i) + " ");
}
System.out.println();

if (!node.isLeaf) {
for (int i = 0; i < node.children.size(); i++) {
printTree(node.children.get(i), level + 1);
}
}
}
}

public class Main {
public static void main(String[] args) {
BPlusTree bPlusTree = new BPlusTree(3);
bPlusTree.insert(1);
bPlusTree.insert(3);
bPlusTree.insert(7);
bPlusTree.insert(10);
bPlusTree.insert(11);
bPlusTree.insert(13);
bPlusTree.insert(14);
bPlusTree.insert(15);
bPlusTree.insert(18);
bPlusTree.insert(16);
bPlusTree.insert(19);
bPlusTree.insert(24);
bPlusTree.insert(25);
bPlusTree.insert(26);
bPlusTree.insert(21);
bPlusTree.insert(4);
bPlusTree.insert(5);
bPlusTree.insert(20);
bPlusTree.print();
}
}

这是一个简单的B+树实现,仅用于演示基本思想。在实际应用中,可能需要更加复杂的逻辑来处理分裂、合并等情况,并提供更多的功能和性能优化。此实现中,树的阶(order)为3,可以根据实际需求进行调整。

算法

红黑树

冒泡排序

冒泡排序是一种简单的排序算法,它通过多次遍历待排序的序列,依次比较相邻的元素,并将逆序的元素交换位置。每一轮遍历都会使一个最大的元素移到最后的位置,因此需要多轮遍历直到整个序列有序为止。以下是 Java 中冒泡排序的简单实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class BubbleSort {
public static void main(String[] args) {
int[] array = {64, 34, 25, 12, 22, 11, 90};

System.out.println("原始数组:");
printArray(array);

bubbleSort(array);

System.out.println("\n排序后的数组:");
printArray(array);
}

// 冒泡排序算法
static void bubbleSort(int[] arr) {
int n = arr.length;
for (int i = 0; i < n - 1; i++) {
for (int j = 0; j < n - i - 1; j++) {
if (arr[j] > arr[j + 1]) {
// 交换 arr[j] 和 arr[j + 1] 的位置
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
}
}
}
}

// 打印数组
static void printArray(int[] arr) {
for (int value : arr) {
System.out.print(value + " ");
}
}
}

在这个简单的冒泡排序实现中,bubbleSort 方法负责对传入的数组进行冒泡排序。在每一轮遍历中,通过比较相邻元素的大小来实现元素的交换。printArray 方法用于打印数组的内容,便于查看排序前后的效果。

冒泡排序的时间复杂度为 O(n^2),因此对于大规模数据集并不是最优的排序算法。在实际应用中,更复杂但更高效的排序算法,如快速排序或归并排序,通常更受青睐。

快速排序

快速排序(Quick Sort)是一种高效的排序算法,它采用分治法(Divide and Conquer)的思想。快速排序的基本步骤包括选择一个基准元素,将数组分成两个子数组,其中小于基准元素的放在左边,大于基准元素的放在右边,然后对左右两个子数组分别进行递归快速排序。

以下是 Java 中快速排序的简单实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
public class QuickSort {
public static void main(String[] args) {
int[] array = {64, 34, 25, 12, 22, 11, 90};

System.out.println("原始数组:");
printArray(array);

quickSort(array, 0, array.length - 1);

System.out.println("\n排序后的数组:");
printArray(array);
}

// 快速排序算法
static void quickSort(int[] arr, int low, int high) {
if (low < high) {
// 获取分区点索引,arr[p] 已经在正确的位置
int p = partition(arr, low, high);

// 对分区点左侧和右侧的子数组进行递归排序
quickSort(arr, low, p - 1);
quickSort(arr, p + 1, high);
}
}

// 分区函数,返回分区点的索引
static int partition(int[] arr, int low, int high) {
// 选择最右侧的元素作为基准元素
int pivot = arr[high];
int i = low - 1; // 小于基准元素的最右侧索引

for (int j = low; j <= high - 1; j++) {
// 如果当前元素小于等于基准元素,则交换 arr[i+1] 和 arr[j] 的位置
if (arr[j] <= pivot) {
i++;
swap(arr, i, j);
}
}

// 将基准元素放到正确的位置,交换 arr[i+1] 和 arr[high] 的位置
swap(arr, i + 1, high);

// 返回分区点的索引
return i + 1;
}

// 交换数组中两个元素的位置
static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}

// 打印数组
static void printArray(int[] arr) {
for (int value : arr) {
System.out.print(value + " ");
}
}
}

在这个简单的实现中,quickSort 方法实现了快速排序的逻辑,而 partition 方法用于确定分区点的索引。通过选择基准元素并通过分区操作,将数组分成两个部分。然后,对左右两个子数组分别进行递归快速排序。

快速排序的平均时间复杂度为 O(n log n),是一种效率较高的排序算法。

Spring

什么是Spring框架

Spring是一种轻量级开发框架,旨在提高开发人员的开发效率以及系统的可维护性。

Spring是很多模块的集合:核心容器、数据访问/集成、Web、AOP、工具、消息和测试模块

Core Container中的Core组件是Spring所有组件的核心,Beans组件和Context组件是实现IOC和依赖注入的基础,AOP组件用来实现面向切面编程。

Spring的6个特征:

  • 核心技术: 依赖注入,AOP、事件(events)、资源、i18n、验证、数据绑定、类型转换、SpEL
  • 测试:模拟对象、TestContext框架、Spring MVC测试、WebTestClient
  • 数据访问:事务、DAO支持、JDBC、ORM、编组XML
  • Web支持:Spring MVC和Spring WebFlux Web框架
  • 集成:远程处理、JMS、JCA、JMX、电子邮件、任务、调度、缓存。
  • 语言:Kotlin、Groovy、动态语言

Spring的优点

IoC-Inversion of Control

控制翻转

控制反转就是把创建和管理 bean 的过程转移给了第三方。而这个第三方,就是 Spring IoC Container,对于 IoC 来说,最重要的就是容器

容器负责创建、配置和管理 bean,也就是它管理着 bean 的生命,控制着 bean 的依赖注入。

通俗点讲,因为项目中每次创建对象是很麻烦的,所以我们使用 Spring IoC 容器来管理这些对象,需要的时候你就直接用,不用管它是怎么来的、什么时候要销毁,只管用就好了。

Bean 其实就是包装了的 Object,无论是控制反转还是依赖注入,它们的主语都是 object,而 bean 就是由第三方包装好了的 object。(想一下别人送礼物给你的时候都是要包装一下的,自己造的就免了。

Bean 是 Spring 的主角,有种说法叫 Spring 就是面向 bean 的编程(Bean Oriented Programming, BOP)。

IOC容器

spring如何设计容器的?

使用 ApplicationContext,它是 BeanFactory 的子类,更好的补充并实现了 BeanFactory 的。

BeanFactory 简单粗暴,可以理解为 HashMap:

  • Key - bean name
  • Value - bean object

但它一般只有 get, put 两个功能,所以称之为“低级容器”。

ApplicationContext 多了很多功能,因为它继承了多个接口,可称之为“高级容器”。

ApplicationContext 的里面有两个具体的实现子类,用来读取配置配件的:

  • ClassPathXmlApplicationContext - 从 class path 中加载配置文件,更常用一些;

  • FileSystemXmlApplicationContext - 从本地文件中加载配置文件,不是很常用,如果再到 Linux 环境中,还要改路径,不是很方便。

    ClassPathXmlApplicationContext 并不是直接继承ApplicationContext的,它有很多层的依赖关系,每层的子类都是对父类的补充实现。

最上层的Class是BeanFactory

Spring中还有个FactoryBean,两者并没有特别的关系,只是名字比较接近。

深入理解IOC

使用class Rectangle举例:

  • 两个变量:长和宽
  • 自动生成set()方法和toString()方法

注意 ⚠️:一定要生成 set() 方法,因为 Spring IoC 就是通过这个 set() 方法注入的;toString() 方法是为了我们方便打印查看。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class Rectangle {
private int width;
private int length;

public Rectangle() {
System.out.println("Hello World!");
}


public void setWidth(int widTth) {
this.width = widTth;
}

public void setLength(int length) {
this.length = length;
}

@Override
public String toString() {
return "Rectangle{" +
"width=" + width +
", length=" + length +
'}';
}
}

然后在test文件中手动用set()方法给变量赋值

其实就是解耦的过程

1
2
3
4
5
6
7
8
9
public class MyTest {
@Test
public void myTest() {
Rectangle rect = new Rectangle();
rect.setLength(2);
rect.setWidth(3);
System.out.println(rect);
}
}

其实这就是IoC给属性赋值的实现方法,我们把「创建对象的过程」转移给了set()方法,而不是靠自己去new,就不是自己创建的了。

这里自己创建,指的是直接在对象内部来new,是程序主动创建对象的正向的过程;这里使用set()方法,是别人(test)给我的;而IoC是用它的容器来创建、管理这些对象的,其实也是用的这个set()方法。

依赖注入 - dependency injection

什么是依赖注入?依赖什么?

程序运行需要依赖外部的资源,提供程序内对象的所需要的数据、资源。

什么是注入?注入什么?

配置文件把资源从外部注入到内部,容器加载了外部的文件、对象、数据,然后把这些资源注入给程序内的对象,维护了程序内外对象之间的依赖关系。

控制翻转是通过依赖注入实现的。

  • IoC是设计思想,DI是具体的实现方式
  • IOC是理论,DI是实践
代码中如何进行依赖注入
  1. 构造函数注入

    通过将依赖关系作为构造函数参数传递来实现依赖注入。

    1
    2
    3
    4
    5
    6
    7
    public class Foo {
    private readonly IBar _bar;

    public Foo(IBar bar) {
    _bar = bar;
    }
    }
  1. 属性注入

    通过将依赖关系作为公共属性设置来实现依赖注入。

    1
    2
    3
    public class Foo {
    public IBar Bar { get; set; }
    }
  1. 方法注入

    通过将依赖关系作为方法参数传递来实现依赖注入。这种方式通常用于注入方法执行期间需要的依赖项,而不是注入类的构造函数中需要的依赖项。

    1
    2
    3
    4
    5
    6
    public class Foo {
    public void DoSomething(IBar bar) {
    // ...
    }
    // ...
    }
  1. 服务定位器模式

    通过使用服务定位器来解析依赖项。这种方式通常是通过在全局上下文中注册依赖项,并在需要依赖项时使用服务定位器来检索它们。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public class Foo {
    private readonly IServiceLocator _serviceLocator;

    public Foo(IServiceLocator serviceLocator) {
    _serviceLocator = serviceLocator;
    }

    public void DoSomething() {
    var bar = _serviceLocator.Resolve<IBar>();
    }
    }

为什么要用IoC这种思想?IoC能给我们带来什么好处

解耦

它把对象之间的依赖关系转成配置文件来管理,由Spring IoC Container来管理

在项目中,底层的实现都是由很多个对象组成的,对象之间彼此合作实现项目的业务逻辑。但是,很多很多对象紧密结合在一起,一旦有一方出问题了,必然会对其他对象有所影响,所以才有了解藕的这种设计思想。

AOP

AOP(Aspect-Oriented Programming:面向切面编程)能够将那些与业务无关,却为业务模块所共同调用的逻辑或责任(例如事务处理、日志管理、权限控制等)封装起来,便于减少系统的重复代码降低模块间的耦合度,并有利于未来的可拓展性和可维护性

Spring AOP就是基于动态代理的,如果要代理的对象,实现了某个接口,那么Spring AOP会使用JDK Proxy,去创建代理对象,而对于没有实现接口的对象,就无法使用 JDK Proxy 去进行代理了,这时候Spring AOP会使用Cglib ,这时候Spring AOP会使用 Cglib 生成一个被代理对象的子类来作为代理

核心的概念:

  • 切面(Aspect):指关注点模块化,这个关注点可能会横切多个对象。事务管理是企业级Java应用中有关横切关注点的列子。在Spring AOP中,切面可以使用通用类基于模式的方式(schema-based approach)或者在普通类中以@Aspect注解(@AspectJ注解方式)来实现
  • 连接点(Join point):在程序执行过程中某个特定的点,例如某个方法调用的时间点或者处理异常的时间点,在Spring AOP中,一个连接点总是代表一个方法执行。
  • 通知(Advice):在切面的某个特定的连接点上执行的动作。通知有多种类型,包括“arround”,”before”,”after”等等。许多AOP框架,包括Spring在内,都是以拦截器来做通知模型的,并维护着一个以连接点为中心的拦截器链。
  • 切点(Pointcut):匹配连接点的断言。通知和切点表达式相关联,并在满足这个切点的连接点上运行(例如,当执行某个特定名称的方法时)。切点表达式如何和连接点匹配是AOP的核心:Spring默认使用AspectJ切点语义。
  • 引入(Introduction):声明二外的方法或者某个类型的字段。Spring允许引入新的接口(以及一个对应的实现)到任何被通知的对象上。例如,可以使用引入来使bean实现IsModified接口,以便简化存储机制(在AspectJ社区,引入也被称为内部类型声明(inter))。
  • 目标对象(Target object):被一个或者多个切面所通知的对象。也被乘坐被通知(advised)对象。既然Spring AOP是通过运行时代理实现的,那么这个对象永远是一个被代理(proxied)的对象
  • AOP代理(AOP proxy): AOP框架创建的对象,用来实现切面契约(aspect contract)(包括通知方法执行等功能)。在Spring中,AOP代理可以是JDK动态dialing或CGLIB代理。
  • 织入(weaving):把切面连接到其他的应用程序类型或者对象上,并创建一个被通知的对象的过程。这个过程可以在编译时(例如使用AspectJ编译器)、类加载时或运行时中完成。Spring和其他纯Java AOP框架一样,是在运行时完成织入的。

任何一个系统都是由不同的组件组成的,每个组件负责一块特定的功能,当然会存在很多组件是跟业务无关的,例如日志、事务、权限等核心服务组件,这些核心服务组件经常融入到具体的业务逻辑中,如果我们为每个具体业务逻辑操作都添加这样的代码,很明显代码冗余太多,因此我们需要将这些公共的代码逻辑抽象出来变成一个切面,然后注入到目标对象(具体业务)中去,AOP正是基于这样的一个思路实现的,而不需要修改原有业务的逻辑代码,只需要在原来的业务逻辑基础之上做一些增强功能即可。

Spring AOP 和 AspectJ AOP 有什么区别?

Spring AOP 属于运行时增强,而 AspectJ 是编译时增强。 Spring AOP 基于代理(Proxying),而 AspectJ 基于字节码操作(Bytecode Manipulation)。

Spring AOP 已经集成了 AspectJ ,AspectJ 应该算的上是 Java 生态系统中最完整的 AOP 框架了。AspectJ 相比于 Spring AOP 功能更加强大,但是 Spring AOP 相对来说更简单,

如果我们的切面比较少,那么两者性能差异不大。但是,当切面太多的话,最好选择 AspectJ ,它比Spring AOP 快很多。

Spring Bean的注入过程

Spring 中的 bean 的作用域有哪些?

  • singleton : 唯一 bean 实例,Spring 中的 bean 默认都是单例的。
  • prototype : 每次请求都会创建一个新的 bean 实例。
  • request : 每一次HTTP请求都会产生一个新的bean,该bean仅在当前HTTP request内有效。
  • session : 每一次HTTP请求都会产生一个新的 bean,该bean仅在当前 HTTP session 内有效。
  • global-session:全局session作用域,仅仅在基于portlet的web应用中才有意义,Spring5已经没有了。Portlet是能够生成语义代码(例如:HTML)片段的小型Java Web插件。它们基于portlet容器,可以像servlet一样处理HTTP请求。但是,与 servlet 不同,每个 portlet 都有不同的会话

Spring中的Bean创建的生命周期有哪些步骤

Spring中的一个Bean的创建大概分为以下几个步骤:

  1. 推断构造方法
  2. 实例化
  3. 填充属性,也就是依赖注入
  4. 处理Aware回调
  5. 初始化前,处理@PostConstruct注解
  6. 初始化,处理InitializingBean接口
  7. 初始化,进行AOP

TODO 生命周期图

Spring 中的单例 bean 的线程安全问题了解吗?

大部分时候我们并没有在系统中使用多线程,所以很少有人会关注这个问题。单例 bean 存在线程问题,主要是因为当多个线程操作同一个对象的时候,对这个对象的非静态成员变量的写操作会存在线程安全问题。

常见的有两种解决办法:

  1. 在Bean对象中尽量避免定义可变的成员变量(不太现实)。
  2. 在类中定义一个ThreadLocal成员变量,将需要的可变成员变量保存在 ThreadLocal 中(推荐的一种方式)。

Spring事务

@Transactional注解

就是在方法上使用@Transcational,当该方法报异常时则回滚事务,没有发生异常则提交事务

开启事务最核心的两件事:

  1. 创建一个数据库连接
  2. 把AutoCommit属性改成false

@Transcational注解常用属性

  1. propagation

    该属性用于设置事务的传播行为。

    例如:@Transactional(propagation=Propagation.NOT_SUPPORTED,readOnly=true)

  2. value

    事务管理器

  3. readOnly

    用户设置当前事务是否为只读,设置为True的时候表示只读,false表示可读写,默认值为false

  4. rollbackFor

    用户设置需要进行回滚的异常类数组,当方法中抛出指定异常数组中的异常时,则进行事务回滚。例如:

    指定单一异常类:@Transcational(rollbackFor=RuntimeException.class)

    指定多个异常类:@Transcational(rollbackFor=RuntimeException.class,Exception.class)

  5. rollbackForClassName

    用于设置需要进行回滚的异常类名称数组,当方法中抛出指定异常名称数组中的异常时,则进行事务回滚。

    指定单一异常异常类名:@Transcational(rollbackForClassName="RuntimeException")

    指定多个异常类名:@Transcational(rollbackForClassName={"RuntimeException","Exception"})

  6. noRollbackFor

    该属性用于设置不需要进行回滚的异常类数组,当方法中抛出指定异常数组中的异常时,不进行事务回滚。例如:

    指定单一异常类:@Transactional(noRollbackFor=RuntimeException.class)

    指定多个异常类:@Transactional(noRollbackFor={RuntimeException.class, Exception.class})

  7. noRollbackForClassName
    该属性用于设置不需要进行回滚的异常类名称数组,当方法中抛出指定异常名称数组中的异常时,不进行事务回滚。例如:

    指定单一异常类名称:@Transactional(noRollbackForClassName=“RuntimeException”)

    指定多个异常类名称:

    @Transactional(noRollbackForClassName={“RuntimeException”,“Exception”})

  8. isolation
    该属性用于设置底层数据库的事务隔离级别,事务隔离级别用于处理多事务并发的情况,通常使用数据库的默认隔离级别即可,基本不需要进行设置

  9. timeout
    该属性用于设置事务的超时秒数,默认值为-1表示永不超时

    如果我们在一个业务类的前边加上@Transactional,那么这个类的所有方法需要事务管理,当执行每一个业务方法开始时都会打开一个事务。

Spring中的事务是如何实现的

  1. Spring事务底层是基于数据库事务和AOP机制
  2. 首先对于使用了@Transactional注解的Bean,Spring会创建一个代理对象作为Bean
  3. 当调用代理对象的方法时,会先判断该方法上是否加了@Transactional注解
  4. 如果加了,那么则会利用事务管理器创建一个数据库连接
  5. 并且修改数据库连接的autocommit属性为false,禁止此连接的自动提交,这是Spring事务非常重要的一步
  6. 然后执行当前方法,方法中会执行SQL
  7. 执行完当前方法后,如果没有出现异常就直接提交事务
  8. 如果出现了异常,并且这个异常是需要回滚的就会回滚事务,否则仍然提交事务
  9. Spring事务的隔离级别对应的就是数据库的隔离级别
  10. Spring事务的传播机制是Spring事务自己实现的,也是Spring事务中最复杂的
  11. Spring事务的传播机制是基于数据库连接来做的,一个数据库连接一个事务,如果传播机制配置为需要新开一个事务,那么实际上就是先建立一个数据库连接,在此新数据库连接上执行SQL

Spring事务传播机制

多个事务方法互相调用时,事务如何在这些方法间传播,方法A是一个事务的方法,方法A执行过程中调用了方法B,那么方法B有无事务以及方法B对事务的要求不同都会对方法A的事务具体执行造成影响,同时方法A的事务对方法B的事务执行也有影响,这种影响具体是什么就由两个方法所定义的事务传播类型所决定。

  1. REQUIRED(Spring默认的事务传播类型):如果当前没有事务,则自己新建一个事务,如果当前存在事务,则加入这个事务

  2. SUPPORTS:当前存在事务,则加入当前事务,如果当前没有事务,就以非事务方法执行

  3. MANDATORY:当前存在事务,则加入当前事务,如果当前事务不存在,则抛出异常

  4. REQUIRES_NEW:创建一个新事务,如果存在当前事务,则挂起该事务。

  5. NOT_SUPPORTED:以非事务方式执行,如果当前存在事务,则挂起当前事务

  6. NEVER:不使用事务,如果当前事务存在,则抛出异常

  7. NESTED:如果当前事务存在,则在嵌套事务中执行,否则REQUIRED的操作一样(开启一个事务)

Spring事务什么时候会失效

Spring事务的原理是AOP,进行了切面增强,那么失效的根本原因是这个AOP不起作用了!

常见情况有如下几种:

  1. 发生自调用,类里面使用this调用本类的方法(this通常省略),此时这个this对象不是代理类,而是UserService对象本身
  2. 方法不是public的:@Transactional只能用于public的方法上,否则事务不会失效,如果要用来非public方法上,可以开启AspectJ代理模式。
  3. 数据库不支持事务
  4. 没有被Spring管理
  5. 异常被吃掉,事务不会回滚(或者抛出的异常没有被定义,默认为RuntimeException)

Spring中的循环依赖

什么是循环依赖

图片

如图

在代码中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Component
public class A {
@Autowired
private B b;
}

@Component
public class B {
@Autowired
private A a;
}

// 比较特殊的循环依赖
@Component
public class A {
@Autowired
private A a;
}

什么情况下循环依赖可以被处理

Spring解决循环依赖是有前置条件的:

  1. 出现循环依赖的Bean必选要是单例

  2. 依赖注入的方式不能全是构造器注入的方式

    1
    2
    3
    4
    5
    6
    7
    8
    9
    @Component
    public class A {
    public A(B b) {}
    }

    @Component
    public class B {
    public B(A a) {}
    }

    在上述代码中,A中注入B的方式是通过构造器,B中注入A的方式也是通过构造器,这个时候循环依赖是无法被解决的,如果项目中有两个这样相互依赖的Bean,在启动的时候就会报出以下错误

    Caused by: org.springframework.beans.factory.BeanCurrentlyInCreationException: Error creating bean with name 'a': Requested bean is currently in creation: Is there an unresolvable circular reference?

依赖情况 依赖注入方式 循环依赖是否被解决
AB相互依赖(循环依赖) 均采用setter方法注入
AB相互依赖(循环依赖) 均采用构造器注入
AB相互依赖(循环依赖) A中注入B的方式为setter方法,B中注入A的方式为构造器
AB相互依赖(循环依赖) B中注入A的方式为setter方法,A中注入B的方式为构造器

Spring是如何解决循环依赖的

关于循环依赖的解决方式应该要分两种情况来讨论:

1. 简单的循环依赖(没有AOP)
1
2
3
4
5
6
7
8
9
10
11
12
13
@Component
public class A {
// A中注入了B
@Autowired
private B b;
}

@Component
public class B {
// B中也注入了A
@Autowired
private A a;
}

Spring在创建Bean的时候默认是按照自然排序来进行创建的,所以第一步Spring会去创建A

与此同时,Spring在创建Bean的过程中分为三步:

  1. 实例化,对应方法:AbstractAutowireCapableBeanFactory中的createBeanInstance方法, 简单理解就是new了一个对象

  2. 属性注入,对应方法:AbstractAutowireCapableBeanFactorypopulateBean方法, 为实例化中new出来的对象填充属性

  3. 初始化,对应方法:AbstractAutowireCapableBeanFactoryinitializeBean, 执行aware接口中的方法,初始化方法,完成AOP代理

    图片

    解读整个循环依赖处理的过程,整个流程应该以A的创建为起点。

    创建A的过程实际上就是调用getBean方法,这个方法有两层含义:

    1. 创建一个新的Bean
    2. 从缓存中获取已经被创建的对象

    我们现在分析的是第一层含义,因为这个时候缓存中还没有A。

    首先是调用getSingleton(a)方法,这个方法又会调用getSingleton(beanName, true)

    1
    2
    3
    public Object getSingleton(String beanName) {
    return getSingleton(beanName, true);
    }
    true)```这个方法实际上就是到缓存中尝试去获取Bean,整个缓存分为三级:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53

    1. ```singletonObject```,一级缓存,存储的是所有创建好了的单例Bean
    2. ```earlySingletonObjects```,完成实例化,但是还未进行属性注入及初始化的对象
    3. ```singletonFactories```,提前暴露的一个单例工厂,二级缓存中存储的就是从这个工厂中获取到的对象

    因为A是第一次被创建,所以不管哪个缓存中必然都是没有的,因此会进入```getSingleton```的另外一个重载方法```getSingleton(beanName, singletonFactory)```

    这个方法就是用来创建Bean的,起源码如下

    ```java
    public Object getSingleton(String beanName, ObjectFactory<?> singletonFactory) {
    Assert.notNull(beanName, "Bean name must not be null");
    synchronized (this.singletonObjects) {
    Object singletonObject = this.singletonObjects.get(beanName);
    if (singletonObject == null) {

    // ....
    // 省略异常处理及日志
    // ....

    // 在单例对象创建前先做一个标记
    // 将beanName放入到singletonsCurrentlyInCreation这个集合中
    // 标志着这个单例Bean正在创建
    // 如果同一个单例Bean多次被创建,这里会抛出异常
    beforeSingletonCreation(beanName);
    boolean newSingleton = false;
    boolean recordSuppressedExceptions = (this.suppressedExceptions == null);
    if (recordSuppressedExceptions) {
    this.suppressedExceptions = new LinkedHashSet<>();
    }
    try {
    // 上游传入的lambda在这里会被执行,调用createBean方法创建一个Bean后返回
    singletonObject = singletonFactory.getObject();
    newSingleton = true;
    }
    // ...
    // 省略catch异常处理
    // ...
    finally {
    if (recordSuppressedExceptions) {
    this.suppressedExceptions = null;
    }
    // 创建完成后将对应的beanName从singletonsCurrentlyInCreation移除
    afterSingletonCreation(beanName);
    }
    if (newSingleton) {
    // 添加到一级缓存singletonObjects中
    addSingleton(beanName, singletonObject);
    }
    }
    return singletonObject;
    }
    }

    上面的代码我们主要抓住一点,通过createBean方法返回的Bean最终被放到了一级缓存,也就是单例池中。

    那么到这里就可以得出一个结论:一级缓存中存储的是已经完全创建好了的单例Bean

    调用addSingletonFactory方法

    图片

    在完成Bean的实例化后,属性注入之前Spring将Bean包装成一个工厂添加进了三级缓存中,对应代码如下

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    // 这里传入的参数也是一个lambda表达式,() -> getEarlyBeanReference(beanName, mbd, bean)
    protected void addSingletonFactory(String beanName, ObjectFactory<?> singletonFactory) {
    Assert.notNull(singletonFactory, "Singleton factory must not be null");
    synchronized (this.singletonObjects) {
    if (!this.singletonObjects.containsKey(beanName)) {
    // 添加到三级缓存中
    this.singletonFactories.put(beanName, singletonFactory);
    this.earlySingletonObjects.remove(beanName);
    this.registeredSingletons.add(beanName);
    }
    }
    }

    这里只是添加了一个工厂,通过这个工厂(ObjectFactory)的getObject方法可以得到一个对象,而这个对象实际上就是通过getEarlyBeanReference这个方法创建的。那么,什么时候会去调用这个工厂的getObject方法呢?这个时候就要创建B的流程了

    当A完成了实例化并添加进了三级缓存后,就要开始为A进行属性注入了,在注入时发现A依赖了B,那么这个时候Spring又会去图片

    因为B需要注入A,所以在创建B的时候,又会去调用getBean(a),这个时候就又回到了之前的流程了,但是不同的是,之前的getBean是为了创建Bean,而此时在调用getBean不是为了创建了,而是要从缓存中获取,因为之前A在实例化后已经将其放入了三级缓存singletonFactories中,所以此时getBean(a)的流程就是这样子了

    图片

    从这里我们可以看出,注入到B中的A是通过getEarlyBeanReference方法提前暴露出去的一个对象,还不是一个完整的Bean,那么getEarlyBeanReference到底干了啥,我们看下它的源码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    protected Object getEarlyBeanReference(String beanName, RootBeanDefinition mbd, Object bean) {
    Object exposedObject = bean;
    if (!mbd.isSynthetic() && hasInstantiationAwareBeanPostProcessors()) {
    for (BeanPostProcessor bp : getBeanPostProcessors()) {
    if (bp instanceof SmartInstantiationAwareBeanPostProcessor) {
    SmartInstantiationAwareBeanPostProcessor ibp = (SmartInstantiationAwareBeanPostProcessor) bp;
    exposedObject = ibp.getEarlyBeanReference(exposedObject, beanName);
    }
    }
    }
    return exposedObject;
    }

    它实际上就是调用了后置处理器的getEarlyBeanReference,而真正实现了这个方法的后置处理器只有一个,就是通过@EnableAspectJAutoProxy注解导入的AnnotationAwareAspectJAutoProxyCreator.也就是说如果在不考虑AOP的情况下,上面的代码等价于:

    1
    2
    3
    4
    protected Object getEarlyBeanReference(String beanName, RootBeanDefinition mbd, Object bean) {
    Object exposedObject = bean;
    return exposedObject;
    }

    也就是说这个工厂啥都没干,直接将实例化阶段创建的对象返回了!所以说在不考虑AOP的情况下三级缓存没有用

    B中提前注入了一个没有经过初始化的A类型对象不会有问题吗?

    不会

    创建A这个Bean的流程图:

    图片

    从上图中我们可以看到,虽然在创建B时会提前给B注入一个还未初始化的A对象,但是在创建A的流程中一直使用的是注入到B中的A对象的引用,之后会根据这个引用对A进行初始化,所以没有问题。

2. 结合了AOP的循环依赖

在普通的循环依赖的情况下,三级缓存没有任何作用。三级缓存实际上跟Spring中AOP相关

getEarlyBeanReference的代码:

1
2
3
4
5
6
7
8
9
10
11
12
protected Object getEarlyBeanReference(String beanName, RootBeanDefinition mbd, Object bean) {
Object exposedObject = bean;
if (!mbd.isSynthetic() && hasInstantiationAwareBeanPostProcessors()) {
for (BeanPostProcessor bp : getBeanPostProcessors()) {
if (bp instanceof SmartInstantiationAwareBeanPostProcessor) {
SmartInstantiationAwareBeanPostProcessor ibp = (SmartInstantiationAwareBeanPostProcessor) bp;
exposedObject = ibp.getEarlyBeanReference(exposedObject, beanName);
}
}
}
return exposedObject;
}

如果在开启AOP的情况下,那么就是调用到AnnotationAwareAspectJAutoProxyCreatorgetEarlyBeanReference方法,对应代码:

1
2
3
4
5
6
public Object getEarlyBeanReference(Object bean, String beanName) {
Object cacheKey = getCacheKey(bean.getClass(), beanName);
this.earlyProxyReferences.put(cacheKey, bean);
// 如果需要代理,返回一个代理对象,不需要代理,直接返回当前传入的这个bean对象
return wrapIfNecessary(bean, beanName, cacheKey);
}

我们对A进行了AOP代理的话,那么此时getEarlyBeanReference将返回一个代理后的对象,而不是实例化阶段创建的对象,这样就意味着B中注入的A僵尸一个代理对象而不是A的实例化阶段创建后的对象。

图片

  1. 在给B注入的时候为什么需要注入一个代理对象?

    当我们对A进行了AOP代理时,说明我们希望从容器中获取到的就是A代理后的对象而不是A本身,因此把A当做依赖进行注入时也要注入它的代理对象

  2. 明明初始化的时候是A对象,那么Spring是在哪里讲代理对象放入到容器中的呢?

    图片

    在完成初始化后,Spring又调用了一次getSingleton方法,这一次传入的参数又不一样了,false可以理解为禁用三级缓存,前面途中已经提到过了,在为B中注入A时已经将三级缓存中的工厂取出,并从工厂中获取到了一个对象放入到了二级缓存中,所以这里getSingleton方法做的时间就是从二级缓存中获取到这个代理后的A对象。exposedObject == bean可以认为是必定成立的,除非你非要在初始化阶段的后置处理器中替换掉正常流程中的Bean,例如增加一个后置处理器:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    @Component
    public class MyPostProcessor implements BeanPostProcessor {
    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
    if (beanName.equals("a")) {
    return new A();
    }
    return bean;
    }
    }
  3. 初始化的时候是对A对象本身进行初始化,而容器中以及注入到B中的都是代理对象,这样不会有问题吗?

    不对,这是因为不管是cglib代理还是jdk动态代理生成的代理类,内部都持有一个目标类的引用,当调用代理对象的方法时,实际回去调用目标对象的方法,A完成初始化相当于代理对象自身也完成了初始化。

  4. 三级缓存为什么要使用工厂而不是直接使用引用?换言之,为什么需要这个三级缓存,直接通过二级缓存暴露一个引用不行吗?

    这个工厂的目的在于延迟对实例化阶段生成的对象的代理,只有真正发生以来循环的时候,才回提前生成代理对象,否则只会创建一个工厂并将其放入到三级缓存中,但是不会去通过这个工厂去真正创建对象

    以单独创建A为例,假设AB之间现在没有以来关系,但是A被代理了,这个时候A完成实例化后还是会进入下面这段代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // A是单例的,mbd.isSingleton()条件满足
    // allowCircularReferences:这个变量代表是否允许循环依赖,默认是开启的,条件也满足
    // isSingletonCurrentlyInCreation:正在在创建A,也满足
    // 所以earlySingletonExposure=true
    boolean earlySingletonExposure = (mbd.isSingleton() && this.allowCircularReferences &&
    isSingletonCurrentlyInCreation(beanName));
    // 还是会进入到这段代码中
    if (earlySingletonExposure) {
    // 还是会通过三级缓存提前暴露一个工厂对象
    addSingletonFactory(beanName, () -> getEarlyBeanReference(beanName, mbd, bean));
    }

    即使没有循环依赖,也会将其添加到三级缓存中,而且是不得不添加到三级缓存中,因为到目前为止Spring也不能确定这个Bean有没有跟别的Bean出现循环依赖。

    假设我们在这里直接使用二级缓存的话,那么就意味着所有的Bean在这一步都要完成AOP代理,这不仅没必要,而且违背了Spring在结合AOP跟Bean的声明周期的设计!Spring结合AOP跟Bean的生命周期本身就是通过AnnotationAwareAspectJAutoProxyCreator这个后置处理器完成的,在这个后置处理器的postProcessAfterInitialization方法中对初始化后的Bean完成AOP代理。如果出现了循环依赖,那么没有办法,只有给Bean先创建代理,但是没有出现循环依赖的情况下,设计之初就是让Bean在声明周期的最后一步完成代理而不是在实例化后就立马完成代理。

    3. 三级缓存真的提高了效率吗?
    1. 没有进行AOP的Bean间的循环依赖

      从上文分析可以看出,这种情况下三级缓存根本没用,所以不会存在提高了效率的说法

    2. 进行了AOP的Bean间的循环依赖

      如果A被AOP代理,那么我们先分析下使用了三级缓存的情况下,A、B的创建流程

      图片

      假设不适用三级缓存,直接放在二级缓存中

      图片

      上面两个流程的唯一区别在于为A对象创建代理的时机不同,在使用了三级缓存的情况下为A创建代理的时机是在B中需要注入A的时候,而不使用三级缓存的话再A实例化后就需要马上为A创建代理然后放入到二级缓存中去。

      对于A、B的整个创建过程而言,消耗的时间是一样的。

    所以,三级缓存提高了效率这种说法是错误的。

总结

  1. Spring如何解决循环依赖?

Spring通过三级缓存解决了循环依赖,其中一级缓存为单例池(singletonObjects),二级缓存为早期曝光对象earlySingletonObjects,三级缓存为早期曝光对象工厂(singletonFactories)。

当A、B两个类发生循环引用时,在A完成实例化后,就使用实例化后的对象去创建一个对象工厂,并添加到三级缓存中,如果A被AOP代理,那么通过这个工厂获取到的就是A代理后的对象,如果A没有被AOP代理,那么这个工厂获取到的就是A实例化的对象。

当A进行属性注入时,会去创建B,同时B又依赖了A,所以创建B的同时又会去调用getBean(a)来获取需要的依赖,此时的getBean(a)会从缓存中获取:

第一步,先获取到三级缓存中的工厂;

第二步,调用对象工工厂的getObject方法来获取到对应的对象,得到这个对象后将其注入到B中。紧接着B会走完它的生命周期流程,包括初始化、后置处理器等。

当B创建完后,会将B再注入到A中,此时A再完成它的整个生命周期。至此,循环依赖结束!

  1. 为什么要使用三级缓存呢?二级缓存能解决循环依赖吗?

如果要使用二级缓存解决循环依赖,意味着所有Bean在实例化后就要完成AOP代理,这样违背了Spring设计的原则,Spring在设计之初就是通过AnnotationAwareAspectJAutoProxyCreator这个后置处理器来在Bean生命周期的最后一步来完成AOP代理,而不是在实例化后就立马进行AOP代理。

SpringMVC

Spring MVC的工作流程

  1. 用户向服务器发送请求,请求被Spring 前端控制Servelt DispatcherServlet(中央处理器)捕获;

  2. DispatcherServlet对请求URL进行解析,得到请求资源标识符(URI)。然后根据该URI,调用HandlerMapping获得该Handler配置的所有相关的对象(包括Handler对象以及Handler对象对应的拦截器),最后以HandlerExecutionChain对象的形式返回给DispatcherServlet(中央处理器);

    1. DispatcherServlet 根据获得的Handler,选择一个合适的HandlerAdapter。(附注:如果成功获得HandlerAdapter后,此时将开始执行拦截器的preHandler(…)方法)
    2. 提取Request中的模型数据,填充Handler入参,开始执行Handler(Controller)。 在填充Handler的入参过程中,根据你的配置,Spring将帮你做一些额外的工作:
      • HttpMessageConveter: 将请求消息(如Json、xml等数据)转换成一个对象,将对象转换为指定的响应信息数据转换:对请求消息进行数据转换。如String转换成Integer、Double等数据根式化:对请求消息进行数据格式化。
      • 如将字符串转换成格式化数字或格式化日期等数据验证: 验证数据的有效性(长度、格式等),验证结果存储到BindingResult或Error中.
    3. Handler执行完成后,向DispatcherServlet 返回一个ModelAndView对象;
    4. 根据返回的ModelAndView,选择一个适合的ViewResolver(必须是已经注册到Spring容器中的ViewResolver)返回给DispatcherServlet ;
    5. ViewResolver 结合Model和View,来渲染视图
    6. 将渲染结果返回给客户端。

Spring Boot

Spring Boot常用注解及底层实现

  1. @SpringBootApplication 注解: 这个注解标识了一个SpringBoot工程,他实际上是另外三个注解的组合:

    a. @SpringBootConfiguration: 这个注解实际上就是一个@Configuration,表示启动类也是一个配置类

    b. @EnableAutoConfiguration: 向Spring容器中导入了一个Selector,用来加载ClassPath下SpringFactories中所定义的自动配置类,将这些自动加载为配置Bean

    c. @ComponentScan: 标识扫描路径,因为默认是没有配置实际扫描路径,所以SpringBoot扫描的路径是启动类所在的当前目录

  2. @Bean注解: 用来定义Bean,类似XML中的标签,Spring在启动时,会对加了@Bean注解的方法进行解析,将方法的名字作为BeanName,并通过执行方法得到bean对象

  3. @Controller、@Service、@RequestBody、@AutoWired、@Resource、@PostMapping

SpringBoot自动装配原理

  • .springboot自动装配主要是基于注解编程,和预定优于配置的思想来进行设计的

    自动装配就是自动地把其他组件中的Bean装载到IOC容器中,不需要开发人员再去配置文件中添加大量的配置,

    我们只需要在springboot的启动类上添加一个SptingBootApplication的一个注解,这样就可以开启自动装配

  • @SptingBootApplication这个注解是暴露给用户使用的一个入口,它的底层其实是由@EnableAutoConfiguration这个注解来实现的,

    自动装配的实现,归纳为以下三个核心的步骤:

    第一步:

    启动依赖组件的时候

    组件中必须要包含@Configuration的配置类,在这个配置类里面声明为Bean注解,然后将方法的返回值或者是属性注入到IOC容器中

    第二步:

    第三方jar包,SpringBoot会采用SPI机制,在/META-INF/目录下增加spring.factories文件,然后SpringBoot会自动根据约定,自动使用SpringFactoriesLoader来加载配置文件中的内容

    第三步:

    Spring获取到第三方jar中的配置以后会调用ImportSelector接口来完成动态加载,

    这样设计的好处,在于大幅度减少了臃肿的配置文件,而各模块之间的依赖,也深度的解耦,

    比如我们使用Spring创建Web程序的时候需要引用非常多的Maven依赖,而SpringBoot中只需要引用一个Maven依赖就可以来创建Web程序

    并且SpringBoot把我们常用的依赖都放在了一起,,我们只需要去引入spring-boot-starter-web这个依赖就可以去完成一个简单的Web应用

    以前我们使用Spring的时候需要xml文件来配置开启一些功能,现在使用SpringBoot就不需要xml文件了,

    只需要一个加了@Configuration注解的类,或者是实现了对因接口的配置类就可以了

    SpringBoot自动装配是Spring的完善和扩展,就是为了我们便捷开发,方便测试和部署,提高效率而诞生的框架技术。

SpringBoot的starter

  • spring-boot-starter
  • spring-boot-starter-web
  • spring-boot-starter-aop
  • spring-boot-starter-amqp
  • spring-boot-starter-cache
  • spring-boot-starter-data-elasticsearch
  • spring-boot-starter-data-jpa
  • spring-boot-starter-data-mongodb
  • spring-boot-starter-data-solr
  • spring-boot-starter-jdbc
  • spring-boot-starter-security
  • spring-boot-starter-test
  • spring-boot-starter-log4j
  • spring-boot-starter-tomcat

自定义注解

创建一个注解叫做MyAnnotation,在类的上面有四个源注解。

①、枚举类:enum,指的是常量的集合

②、注解类

  1. 最基本的注解

    1
    2
    3
    4
    5
    public @interface MyAnotation {
    public string name();
    int age();
    String sex() default "女";
    }
  2. 常用的元注解

    元注解:专门修饰注解的注解
    @Target
    @Target是专门用来限定某个自定义注解(如上面的@interface MyAnnotation)能够被应用在哪些Java元素上面的。枚举类型。
    因此,我们可以在使用@Target时指定注解的使用范围

    1
    2
    3
    4
    5
    6
    7
    //@MyAnotation 被限定只能使用在(类、接口)Type或(方法)METHOD上面
    @Target(value = {ElementType.Method, ElementType.TYPE})
    public @interface MyAnotation {
    public string name();
    int age();
    String sex() default "女";
    }

    @Retention注解,用来修饰自定义注解的生命力。
    a.如果一个注解被定义为RetentionPolicy.SOURCE,则它将被限定在Java源文件中,那么这个注解即不会参与编译也不会在运行期起任何作用,这个注解就和一个注释是一样的效果,只能被阅读Java文件的人看到;
      b.如果一个注解被定义为RetentionPolicy.CLASS,则它将被编译到Class文件中,那么编译器可以在编译时根据注解做一些处理动作,但是运行时JVM(Java虚拟机)会忽略它,我们在运行期也不能读取到,是默认的;
      c.如果一个注解被定义为RetentionPolicy.RUNTIME,那么这个注解可以在运行期的加载阶段被加载到Class对象中。那么在程序运行阶段,我们可以通过反射得到这个注解,并通过判断是否有这个注解或这个注解中属性的值,从而执行不同的程序代码段。我们实际开发中的自定义注解几乎都是使用的RetentionPolicy.RUNTIME。

    使用此注解修饰自定义注解生命力的示例如下:

    1
    2
    3
    4
    5
    6
    7
    //设置注解的生命力在运行期
    @Retention(RetentionPolicy.RUNTIME)
    public @interface MyAnotation {
    public string name();
    int age();
    String sex() default "女";
    }

    @Inherited注解

    是指定某个自定义注解如果写在了父类的声明部分,那么子类(继承关系)的声明部分也能自动拥有该注解。该注解只对@Target被定义为ElementType.TYPE的自定义注解起作用。

    @Documented

    该注解表明被注解信息是否被添加在Javadoc中。

Struts2与SpiringMVC的区别

1
2
3
4
5
6
7
8
9
(1)联系
1)它们都是表现层框架,都是基于mvc模型编写的
2)它们的底层实现都离不开servletAPI
3)它们的处理请求机制都是一个核心控制器
(2)区别
1)springMVC的入口是servlet,Struts的入口是filter;
2)springMVC是基于方法的,Struts是基于类的,Struts每次执行都会创建一个动作
3)springMVC使用更加简洁,同时还支持JSR303,处理ajax请求更方便
4)Struts2的OGNL表达式使页面的开发效率相对于springMVC要更高一些,但是执行的效率没有比JSTL提升,尤其是Struts的表单远没有html执行效率高。

Spring Cloud

SpringCloud由什么组成

  • Spring Cloud Eureka: 服务注册与发现
  • Spring Cloud Zuul: 服务网关
  • Spring Cloud Ribbon: 客户端负载均衡
  • Spring Cloud Feign: 生命性的web服务客户端
  • Spring Cloud Hystrix: 断路器
  • Spring Cloud Config: 分布式统一配置管理

SpringCloudAlibaba 五大组件

  1. 服务注册与发现组件(Nacos)

    Nacos是一个用于实现服务注册和发现的组件。它提供了一个简单易用的界面来注册、发现和管理服务实例,同时还支持动态配置、服务路由和流量管理等功能。Nacos支持多种注册中心的选择,如ZooKeeper、Eureka和Consul等。

  2. 服务经济与熔断组件(Sentinel)

    Sentinel是一个用于实现服务降级和熔断的组件。它可以在服务出现故障或不可用时,自动切换到备用方案,以确保系统的稳定运行。Sentinel支持多种熔断规则的定制和配置,可以根据具体需求来进行灵活调整。

  3. 分布式配置中心组件(Nacos Config)

    Nacos Config是一个用于实现分布式配置管理的组件。它可以集中管理应用程序的配置信息,并将其动态地推送到所有相关的服务实例。Nacos Config支持多种配置参数的管理和监控,可以帮助开发人员更好地管理和调试分布式系统。

  4. 消息驱动组件(RocketMq)

    RocketMQ是一个用于实现消息驱动的组件。它提供了可靠的消息传递机制,支持高吞吐量和低延迟的消息处理。RocketMQ支持多种消息模式的选择,如点对点模式和发布订阅模式,可以根据应用程序的需求来进行灵活调整。

  5. 分布式任务调度组件(SofaJob)

    SofaJob是一个用于实现分布式任务调度的组件。它可以根据应用程序的需求,对任务进行灵活调度和管理。SofaJob支持多种任务调度模式的选择,如定时触发模式和依赖触发模式,可以帮助开发人员更好地管理和优化任务的执行效率

Nacos和Consul之间有什么不同

Nacos和Consul都是服务注册与发现的工具

不同点:

  • Nacos提供了更丰富的功能,包括配置管理和分布式事务等。它是一个全面的平台,而不仅仅是服务注册和发现。
  • Nacos有更强大的可扩展性和集成性,特别是在SpringCloudAlibaba生态系统中
  • Consul是HashiCorp的产品,而Nacos是Alibaba的开源项目

Nacos注册中心原理

1.注册机制

Nacos的注册机制是其服务发现和配置管理的基础。当一个服务启动时,它会自动将自己的信息注册到Nacos注册中心。这些信息包括服务的IP地址、端口、运行状态以及其他原数据。Nacos使用一个开源的分布式一致性协议-Raft,来保证注册中心的高可用性和数据一致性。

Raft协议
概述

复制状态机(Replicated state machines)

  • raft 引入了复制状态机的概念(Replicated state machines), 将集群中的每个服务器看做一个状态机, 它们接收外部的指令, 进行状态的改变, 例如机器的存储器就可以看做是一个状态机, 它们接收来自外部的指令, 进行数据的写入和更新, 假定内存 A 地址有一个值为 3 的变量, 在没有接收到新的外部指令的时候, A 地址的值一直保持为 3, 当接收到例如 A←5 的指令后, 状态机(内存)进行状态的改变, 其 A 地址的值变更为 5, 不考虑硬件错误, 这样的状态机是一个确定型状态机, 即只要初始状态和接收到的指令是确定的, 那么它任意时刻的状态也是确定的, 这样以来, 所谓保持分布式一致性即是保证集群中所有状态机的状态一致性

领导人(leader)、跟随者(follower) 和 候选人(candidate)

  • 在 raft 协议中, 所有服务器结点的地位不是对等的, raft 将所有服务器结点划分为 3 个互不相交的子集, 任何一个结点都隶属于某一个集合, 其中地位最高的结点称为领导人(leader), 它负责接收来自客户端的调用, 组织日志复制给其它结点, 统筹管理整个计算机集群, raft 保证集群中在任意时刻至多有一个领导人, 跟随者(follower)接收来自领导人(leader)的复制日志, 按照领导人(leader)的要求进行相应的动作, 跟随者持续地向集群中的所有其它结点发送心跳包, 向集群中的所有结点宣示他的权威, 以维护自己的统治地位, 一旦跟随者(follower)在给定的时间内没有收到来自领导人(follower)的心跳包, 它便认为领导人出了故障, 于是转变自己的身份为候选人(candidate)进行领导人的竞选
  • Leader Election(领导者选举):
    • 在Raft中,节点通过Leader Election来选举一个领导者。任何一个节点都有可能成为候选者,但最终只有一个节点成为领导者。
    • 当一个节点认为自己可能是领导者时,它会向其他节点发送投票请求。其他节点在收到请求后,会检查自己是否已经投过票,如果没有,则投票给该候选者。
  • Follower State(跟随者状态):
    • 节点可以处于领导者、候选者或跟随者状态。领导者负责发起选举和复制日志,跟随者则接收并响应来自领导者的请求。
    • 如果一个跟随者在一段时间内没有收到领导者的心跳或日志复制请求,它可以转变成候选者并发起选举。

任期(term)

  • raft 将系统时间划分为一个个逻辑段, 每个逻辑段的时间长度是不一致的, 可以是任意长度, 每一个逻辑段称为一个任期(term), raft 对每一个任期都设置一个整型编号, 称为任期号, 每一个任期可以进一步划分为两个子段, 其中第一个子段是选举期, 第二个子段是任职期, 选举期将竞选产生集群的领导人, 若领导人选举成功, 则进入了任职期, 在任职期内只要领导人持续保持健康状态(即持续不间断地向其他跟随者发送心跳包), 则这个时期可以无限期地持续, 当然在 raft 中选举不一定都是成功的, 可能存在某个 term 中的选举期没有任何候选人胜出, 这样 raft 会进行下一个 term, 重新进行选举, 直到有新的领导人胜出, 从而进入任职期, 下面我们将看到 raft 采用了特别的机制来尽可能地避免一个 term 中没有任何候选人竞选成功的情形出现

日志(log)与日志复制(log replication)

  • 在 1 中我们提到, raft 将集群中的所有服务器看做若干个状态机, 状态机接收指令进行状态的变更, 在 raft 协议中, 指令是以日志的形式的传递的, 虽然集群中有 N 个结点, 但只有一个结点(领导人)接收客户端的请求, 其它所有结点接收来自领导人的复制日志(Replicated log), 进行解析、和执行, 从而进行状态的变更, 所有服务器结点按序执行相同的日志(指令), 从而保持状态一致性
  • Log Replication(日志复制):
    • 一旦选举出领导者,领导者负责接收客户端的请求,并将这些请求添加到日志中。
    • 领导者向其他节点发送日志条目,其他节点接收后复制到自己的日志中。只有当一个日志条目被大多数节点复制后,它才能被认为是已提交的。
    • 领导者定期发送心跳消息,以保持其领导地位。

日志提交(commit)

  • 领导人在接收到客户端请求之后, 会产生一个相应的日志项(log entry), 日志项中包含了指令, 领导人不会立即执行这个指令, 它首先会进行日志项复制, 当日志项被成功地复制到集群中的大多数结点 (≥n2+1)(≥n2+1) 后, 领导人会提交(commit)这个日志项, 并执行其中的指令(即将该日志应用(apply)到状态机中)

Safety(安全性):

  • Raft确保在网络分区、节点故障或其他异常情况下,只有一个领导者。这有助于避免数据的不一致。
  • Raft保证如果一个日志条目被大多数节点提交,那么所有已提交的条目在其任期内都是相同的。

2.发现机制

基于注册信息,Nacos提供了服务发现机制,客户端可以查询Nacos注册中心,获取与其查询条件匹配的服务实例信息。这些信息可用于负载均衡、服务路由、故障转移等场景。Nacos的服务发现机制是实时的,保证了服务的动态变化能够迅速反映到客户端。

3.配置管理

Nacos的配置管理功能使得对运行中的服务进行配置修改变得简单高效。当管理员对服务配置进行修改后,Nacos会实时地将新的配置推送到所有相关服务实例,确保了配置的一致性。此外,Nacos还提供了强大的权限管理功能,确保配置的修改不会误操作。

4.服务监控

Nacos的监控功能提供了对服务的全方位视图。它收集并存储了服务实例的运行时数据,如CPU使用率、内存使用量、请求响应时间等。这些数据可以通过Nacos自带的监控界面进行查看,也可以通过开放API提供给地方监控工具。

5.高可用性

为了确保Nacos注册中心的高可用性,它采用了集群部署模式。在集群中,各个节点之间保持数据同步,一个节点出现故障时,其他节点可以接管其工作。此外,Nacos还提供了数据持久化功能,即使在节点重启或宕机的情况下,数据也不会丢失。

设计模式

单例模式

单例有如下几个特点:

  • 在Java应用中,单例模式能保证在一个JVM中,该对象只有一个实例存在
  • 构造器必须是私有的,外部类无法通过调用构造器方法创建该实例
  • 没有公开的set方法,外部类无法调用set方法创建该实例
  • 提供一个公开的get方法获取唯一的这个实例

那单例模式有什么好处呢?

  • 某些类创建比较频繁,对于一些大型的对象,这是一笔很大的系统开销
  • 省去了new操作符,降低了系统内存的使用频率,减轻GC压力
  • 系统中某些类,如spring里的controller,控制着处理流程,如果该类可以创建多个的话,系统完全乱了
  • 避免了对资源的重复占用

饿汉式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Singleton {
// 创建一个实例对象
private static Singleton instance = new Singleton();
/**
* 私有构造方法,防止被实例化
*/
private Singleton(){}
/**
* 静态get方法
*/
public static Singleton getInstance(){
return instance;
}
}

提前把对象new出来,这样别人哪怕是第一次获取这个类对象的时候直接就存在这个类了,省去了创建类这一步的开销。

懒汉式

线程不安全的模式

1
2
3
4
5
6
7
8
9
10
11
public class Singleton {  
private static Singleton instance;
private Singleton (){}

public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}

别人第一次调用的时候他发现自己的实例是空的,然后去初始化了,再赋值,后面的调用就和饿汉没区别了。

图片

在运行过程中可能存在这种情况:多个线程去调用getInstance方法来获取Singleton的实例,那么就有可能发生当第一个线程在执行if(instance==null)时,此时instance是为null的进入语句,在还没有执行instance = new Singleton()时(此时instance为null),第二个语句也进入了if(instance == null)这个语句,因为之前进入这个语句的线程中还没有执行instance=new Singleton(),所以它会执行instance = new Singleton()来实例化Singleton对象,因为第二个线程也进入了if语句所以它会实例化Singleton对象。

这就导致了实例化了两个对象,可以使用加锁进行解决

加锁后代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Singleton {
private static Singleton instance = null;
/**
* 私有构造方法,防止被实例化
*/
private Singleton(){}
/**
* 静态get方法
*/
public static synchronized Singleton getInstance(){
if(instance == null){
instance = new Singleton();
}
return instance;
}
}

这种加锁方法是时间换空间的写法,严重降低了系统的处理速度,可以使用双检锁做两次判断:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Singleton {
private static Singleton instance = null;
private Singleton(){}
public static Singleton getInstance(){
//先检查实例是否存在,如果不存在才进入下面的同步块
if(instance == null){
//同步块,线程安全的创建实例
synchronized (Singleton.class) {
//再次检查实例是否存在,如果不存在才真正的创建实例
if(instance == null){
instance = new Singleton();
}
}
}
return instance;
}
}

将synchronized关键字加在了内部,也就是说当调用的时候是不需要加锁的,只有在instance为null,并创建对象的时候才需要加锁,性能有一定的提升。

但是该代码instance = new Singleton()不是原子操作,会有问题:

  1. A、B线程同时进入了第一个if判断

  2. A首先进入synchronized块,由于instance为null,所以它执行instance = new Singleton();

  3. 由于JVM内部的优化机制,JVM先画出了一些分配给Singleton实例的空白内存,并赋值给instance成员(注意此时JVM没有开始初始化这个实例),然后A离开了synchronized块。

    图片image-20201212010622553

  4. B进入synchronized块,由于instance此时不是null,因此它马上离开了synchronized块并将结果返回给调用该方法的程序。

  5. 此时B线程打算使用Singleton实例,却发现它没有被初始化,于是错误发生了。

解决办法:加上volatile修饰Singleton:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Singleton {
private volatile static Singleton instance = null;
private Singleton(){}
public static Singleton getInstance(){
//先检查实例是否存在,如果不存在才进入下面的同步块
if(instance == null){
//同步块,线程安全的创建实例
synchronized (Singleton.class) {
//再次检查实例是否存在,如果不存在才真正的创建实例
if(instance == null){
instance = new Singleton();
}
}
}
return instance;
}

但是volatile关键字可能会屏蔽掉虚拟机中有一些必要的代码优化,可以通过静态内部类来优化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Singleton {  

/* 私有构造方法,防止被实例化 */
private Singleton() {
}

/* 此处使用一个内部类来维护单例 */
private static class SingletonFactory {
private static Singleton instance = new Singleton();
}

/* 获取实例 */
public static Singleton getInstance() {
return SingletonFactory.instance;
}

/* 如果该对象被用于序列化,可以保证对象在序列化前后保持一致 */
public Object readResolve() {
return getInstance();
}
}

使用内部类来维护单例的实现,JVM内部的机制能够保证当一个类被加载的时候,这个类的加载过程是线程互斥的。

这样当我们第一次调用getInstance的时候,JVM能够帮我们保证instance只被创建一次,并且会保证把赋值给instance的内存初始化完毕, 这样我们就不用担心上面的问题。

同时该方法也只会在第一次调用的时候使用互斥机制,这样就解决了低性能问题。这样我们暂时总结一个完美的单例模式。

还有更完美的写法吗,通过枚举:

1
2
3
4
5
6
public enum Singleton {
/**
* 定义一个枚举的元素,它就代表了Singleton的一个实例。
*/
Instance;
}

使用枚举来实现单实例控制会更加简洁,而且JVM从根本上提供保障,绝对防止多次实例化,是更简洁、高效、安全的实现单例的方式。

工厂模式

工厂模式主要可以分为三大类:

  • 简单工厂模式
  • 工厂方法模式
  • 抽象工程模式

简单工厂模式

工厂模式主要是用于对实现逻辑的封装,并且通过对公共的接口提供对象的实列画的服务,在我添加新的类时不需大动干戈,只要修改一点点就好。

举例:在电商平台创建商品时:

图片

在这个简单工厂里面,如果要创建活动商品1 以及活动商品2,我们要创建商品的时候只要调用简单工厂里面的创建商品方法,根据类型创建出不同的商品然后实列化返回就可以了。

简单工厂几种实现方式:

静态工厂模式
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class SimpleFactory {
public static Share createProduce(EnumProductType type) {
if (EnumProductType.activityOne.equals(type)) {
return new ProDuctOne();
} else if (EnumProductType.activityTwo.equals(type)) {
return new ProDuctTwo();
}
return null;
}

public enum EnumProductType {
activityOne, activityTwo;
}
}

这种模式在我每增加一种类型都要去修改一次createProduct方法,违背了开闭原则

可以使用以下两种方法进行优化:

  • 使用反射机制
  • 直接注册商品对象,添加一个Type类型方法,根据type类型返回自身相同类型的方法

反射方法的事项:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class SimpleFactoryReflection {
private static final Map<EnumProductType, Class> activityIdMap = new HashMap<>();
public static void addProductKey(EnumProductType EnumProduct, Class product) {
activityIdMap.put(EnumProduct, product);
}

public static activityOne product(EnumProductType type) throws IllegalAccessException, InstantiationException {
Class productClass = activityIdMap.get(type);
return (activityOne) productClass.newInstance();
}

public static void main(String[] args) throw InstantiationException, IllegalAccessException {
addProductKey(EnumProductType.activityOne, activityOne.class);
activityOne product = product(EnumProductType.activityOne);
System.out.println(product.toString());
}

public static class Product {
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

public static class activityOne extends Product {
private String stock;
@Override
public String toString() {
return "activityOne{" +
"stock='" + stock + '\'' +
'}';
}
}

public class activityTwo extends Product {
private String stock;
}

public enum EnumProductType {
activityOne, activityTwo;
}
}

在一些特定的情况下,并不适用,而且在某些特定的情况下是无法实现的,而且反射机制也会降低程序的运行效果,在对性能要求很高的场景下应该避免这种实现。

这里还有一个问题适用反射不当是容易导致线上机器出问题的,因为我们反射创建的对象属性是被SoftReference软引用的,所以当-XX:SoftRefLRUPolicyMSPerMB 没有设置好的话会一直让机器CPU很高。

当然他的默认值是1000,也就根据大家的情况而定吧,反正就是注意一下这点。

直接注册商品对象

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public class SimpleFactoryReflection {
private static final Map<EnumProductType, Product> activityIdMap = new HashMap<>();

public static void addProductKey(EnumProductType EnumProduct, Product product) {
activityIdMap.put(EnumProduct, product);
}

public static activityOne product(EnumProductType type) throws IllegalAccessException, InstantiationException {
Product product = activityIdMap.get(type);
return (activityOne) product;
}

public static void main(String[] args) throws IllegalAccessException, InstantiationException {
addProductKey(EnumProductType.activityOne, new activityOne());
activityOne product = product(EnumProductType.activityOne);
System.out.println(product.toString());
}

public static class Product {
private String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}

public static class activityOne extends Product {
private String stock;
@Override
public String toString() {
return "activityOne{" +
"stock='" + stock + '\'' +
'}';
}
}

public class activityTwo extends Product {
private String stock;
}

public enum EnumProductType {
activityOne, activityTwo;
}
}

工厂方法模式

工厂方法模式是对静态工厂模式的上的一种改进,我们的工厂类直接被抽象化,需要具体特定化的逻辑代码转移到实现抽象方法的子类中,这样我们就不要再去修改工厂类(即:不用再去做什么if else 修改)这也是我们当前比较常用的一种方式。

以创建商品为例:

图片

代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public abstract class FactoryMethod {
protected abstract Product createProduct(String name);

public Product product(String activity, String name) {
Product product = createProduct(activity);
product.setProductName(name);
return product;
}

public static class Product {
public String productName;

public String getProductName() {
return productName;
}

public void setProductName(String productName) {
this.productName = productName;
}

}

public enum EnumProductType {
activityOne("one"),
activityTwo("two");

private String name;

EnumProductType(String name) {
this.name = name;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
}

先创建一个抽象工厂方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ProductFactory extends FactoryMethod{
@Override
protected Product createProduct(String activityId) {
if (EnumProductType.activityOne.getName().equals(activityId)) {
// 这里可以处理我们自己的想要的业务逻辑代码
return new OneProduct();
} else if (EnumProductType.activityTwo.getName().equals(activityId)) {
return new OneProduct();
}
return null;
}

public static class OneProduct extends Product {}

public static class TwoProduct extends Product {}

public static void main(String[] args) {
FactoryMethod factoryMothod = new ProductFactory();
Product product = factoryMothod.Product("one", "one");
System.out.println(product.getProductName());
}
}

再创建一个商品工厂去继承抽象工厂方法。

抽象工厂模式

抽象工厂模式是工厂模式的一个延伸。工厂方法类中只有一个抽象方法,要想实现多种不同的类对象,只能去创建不同的具体工厂方法的子类来实例化,而抽象工厂则是让一个工厂负责创建多个不同类型的对象。

图片

抽象工厂的组成:

  • 抽象工厂类
  • 具体工厂类
  • 抽象类

代理模式

定义

代理模式可以分为多种类型

  • 远程代理:就是将工作委托给远程对象(不同的服务器,或者不同的进程)来完成。常见的是用在web Service中。还有就是我们的RPC调用也可以理解为一种远程代理。
  • 保护代理:该模式主要进行安全/权限检查。(接触很少)
  • 缓存代理:这个很好理解,就是通过存储来加速调用,比如Sping中的@Cacheable方法,缓存特定的参数获取到的结果,当下次相同参数调用该方法,直接从缓存中返回数据。
  • 虚拟代理:这种代理主要是为方法增加功能,比如记录一些性能指标等,或进行延迟初始化

图片

  • Subject(共同接口): 客户端使用的现有接口
  • RealSubject(真实对象):真实对象的类
  • ProxySubject(代理对象):代理类

目的:提供一个实际代理对象,以便更好的控制实际对象。

代码举例
1
2
3
4
public interface Subject {
// 共同的接口
void doSomething();
}

定义一个共同的接口:

1
2
3
4
5
6
7
public class RealSubject implements Subject {
// 真实对象
@Override
public void doSomething() {
System.out.println(".....")
}
}

构建一个真实对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class ProxySubject implements Subject {
private RealSubject realSubject;

public ProxySubject(RealSubject realSubject) {
this.realSubject = realSubject;
}

public ProxySubject() throws ClassNotfoundException, IllegalAccessException, InstantiationException {
this.realSubject = (RealSubject) this.getClass().getClassLoader().loadClass("com.ao.bing.demo.proxyPattern.RealSubject").newInstance();
}

@Override
public void doSomething() {
realSubject.doSomething();
}

public static void main(String[] args) {
try {
// 第一种方式
new ProxySubject().doSomething();
// 打印结果
} catch (Exception e) {
// 异常情况,代理失败
}

// 第二种方式
new ProxySubject(new RealSubject()).doSomething();
}
}
  • 第一种:采用类加载器形式,去加载实列对象,这样我们就不同关心到底什么时候需要真实的实列化对象

  • 第二种:通过传值的形式,把实列化对象传过来。(理解为装饰器模式了)

    这里大家要区别一下,代理模式是提供完全相同的接口,而装饰器模式是为了增强接口

静态代理、动态代理和cglib代理分析
静态代理

上面的例子就是静态代理。缺点比较明显,静态代理需要为每一个对象都创建一个代理类,增加了维护成本以及开发成本。

动态代理

动态代理合理的避免了静态代理的那种方式,不用事先为代理的类而构建好代理类,而是在运行时通过反射机制创建。

在写动态代理时需要理解两个东西:Proxy可以理解为就是调度器,InvocationHandler增强服务接口可以理解为代理器。

可以理解为:动态代理其实就是一种行为的监听

代码举例:螳螂捕蝉,通过螳螂监听到蝉的动作

1
2
3
4
5
6
7
8
9
10
public interface BaseService {
void mainService();
}

public class Cicada implements BaseService {
@Override
public void mainService() {
System.out.println("。。。。。");
}
}

创建共同接口,以及真实对象蝉:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class PrayingMantis implements IvocationHandler {
private BaseService baseService;

// 这里采用的是构建传参,可以用反射
publci PrayingMantis(BaseService baseService) {
this.baseService = baseService;
}

// 螳螂主要业务,也就是监听对象
@Override
public Object invoke(Object listener, Method method, Object[] args) throws Throwable {
method.invoke(baseService, args);
secondaryMain();
return null;
}

// 这里理解增强业务,即我们可以在实现InvocationHandler里面添加其他的业务,比如日志等等
private void secondaryMain() {
System.out.println("");
}
}

创建螳螂类,监听着蝉的类的动作www

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class BeanFactory {
public static BaseService newInstanc(Class classFile) {
// 1.创建蝉,真实类对象
BaseService trueCicada = new Cicada();
// 2.创建代理类 螳螂
InvocationHanler prayingManties = new PrayingMantis(trueCicada);
// 3.向jvm索要代理对象,其实就是监听对象
Class classArray[] = {BaseService.class};
BaseService baseService = (BaseService) Proxy.newProxyInstance(classFile.getClassLoader(), classArray, prayingMantis);
return baseService;
}

public static void main(String[] args) {
BaseService baseService = new Instanc(Cicada.class);
baseService.mainService();

}
}

代理模式的组成:

  • 接口:声明需要被监听行为
  • 代理实现类:次要业务,次要业务和主要业务绑定执行
  • 代理对象(监听对象)
cglib动态代理

cglib动态代理其实和jdk的动态代理是很相似的,都是要去实现代理器接口完成。

具体代买如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class ParyingMantis implements MethodInterceptor {
private Cicada cicada; // 代理对象

public Cicada getInstance(Cicada cicada) {
this.cicada = cicada;
Enhancer enhancer = new Enhancer();
enhancer.setSuperclass(this.cicada.getClass());
enhancer.setCallback(this);
return (Cicada) enhancer.create();
}

@Override
public Object intercept(Object o, Method method, Object[] objects, MethodProxy methodProxy) throws Throwable {
Object object = methodProxy.invokeSuper(o, object);
secondaryMain();
return object;
}

private void secondaryMain() {
System.out.println("");
}

public static void main(String[] args) {

}

}

cglib无需通过接口来实现,它是通过实现子类的方式来完成调用的。

Enhancer 对象把代理对象设置为被代理类的子类来实现动态代理的。因为是采用继承方式,所以代理类不能加final修饰,否则会报错

final类:类不能被继承,内部的方法和变量都变成final类型

JDK和cglib的区别

jdk动态代理是利用反射机制生成一个实现代理接口的匿名类,在调用具体方法前嗲用InvokeHanlder来处理

cglib动态代理时利用ASM开源包,对呗代理对象类的class文件加载进来,通过修改其字节码生成子类来处理

ASM:一个Java字节码操控框架。它能被用来动态生成类或者增强既有

多级动态代理实现
总结

Linux

说说常用的Linux基本操作命令

  1. cd

  2. ls

    1
    2
    3
    4
    5
    -l 列出长数据串,包含文件的属性与权限数据等
    -a 列出全部文件,连同隐藏文件
    -d 仅列出目录本身,而不是列出目录的文件数据
    -h:将文件容量以较易读的方式(GB,kB等)列出来
    -R:连同子目录的内容一起列出(递归列出),等于该目录下的所有文件都会显示出来
  3. grep

  4. find

  5. cp

  6. mv

  7. rm

  8. ps

  9. kill

  10. file

  11. tar

  12. cat

  13. chgrp

  14. chown

  15. chmod

数据库

SQL注入

  1. 对输入进行严格的转义和过滤

    可以使用正则表达式匹配安全的字符串

    在拼接 SQL 语句之前就要进行校验,验证其有效性。比如对于某个传入的值,如果可以确定是整型,则要判断它是否为整型,在浏览器端(客户端)和服务器端都需要进行验证

  2. 使用参数化(Parameterized):目前有很多ORM框架会自动使用参数化解决注入问题,但其也提供了”拼接”的方式,所以使用时需要慎重!

    Mybatis中要使用#作为参数,$不会有类型限制

  3. PDO预处理 (Java、PHP防范推荐方法:)

MySQL索引类型

存储方式区分

1) B-树索引

B-树索引又称为 BTREE 索引,目前大部分的索引都是采用 B-树索引来存储的。

B-树索引是一个典型的数据结构,其包含的组件主要有以下几个:

  • 叶子节点:包含的条目直接指向表里的数据行。叶子节点之间彼此相连,一个叶子节点有一个指向下一个叶子节点的指针。
  • 分支节点:包含的条目指向索引里其他的分支节点或者叶子节点。
  • 根节点:一个 B-树索引只有一个根节点,实际上就是位于树的最顶端的分支节点。

基于这种树形数据结构,表中的每一行都会在索引上有一个对应值。因此,在表中进行数据查询时,可以根据索引值一步一步定位到数据所在的行。

B-树索引可以进行全键值、键值范围和键值前缀查询,也可以对查询结果进行 ORDER BY 排序。但 B-树索引必须遵循左边前缀原则,要考虑以下几点约束:

  • 查询必须从索引的最左边的列开始。
  • 查询不能跳过某一索引列,必须按照从左到右的顺序进行匹配。
  • 存储引擎不能使用索引中范围条件右边的列。
为什么用B树不用B+树
  1. 范围查询和排序: B-tree索引支持范围查询和排序操作,因为B-tree中的每个节点都包含键值和对应的数据,而不仅仅是叶子节点。这使得B-tree更适合支持多种查询操作,包括范围查询和排序。

  2. 非叶子节点存储数据: 在B-tree中,非叶子节点和叶子节点都包含键值和对应的数据。这与B+树不同,B+树的非叶子节点只包含键值,而数据只存储在叶子节点上。在MySQL中,这样的设计可以减少在执行范围查询时需要访问的节点数,提高查询效率。

  3. 适应于磁盘存储: B-tree的节点结构更适合在磁盘上进行存储,因为它们的结构允许更容易地进行顺序访问。在数据库系统中,磁盘I/O是一个关键的性能因素,而B-tree索引在这方面有一些优势。

  4. 支持覆盖索引: B-tree索引的叶子节点包含了所有的数据,因此,当查询的字段都在索引中时,可以通过覆盖索引(Covering Index)避免额外的数据读取,提高查询性能。

什么是覆盖索引

覆盖索引(Covering Index)是指一个索引包含了查询所需的所有列,而不仅仅是用于筛选和排序的列。通过使用覆盖索引,数据库引擎能够直接从索引中获取所有必要的数据,而无需额外地查找表中的数据行。这可以带来一些性能上的优势,因为减少了磁盘I/O和内存的使用。

以下是覆盖索引的主要优势:

  1. 减少磁盘I/O操作: 数据库查询通常涉及到从磁盘读取数据到内存。如果使用了覆盖索引,查询可以直接从索引中获取所需的数据,而不必额外地访问表中的数据行。这减少了磁盘I/O操作,提高了查询性能。
  2. 减少内存使用: 覆盖索引使得查询需要的所有数据都在索引结构中,不需要额外的内存来存储表中的数据。这对于内存有限的系统来说,可以降低内存的使用量,提高整体性能。
  3. 减少锁竞争: 在某些情况下,使用覆盖索引可以减少锁的竞争。由于覆盖索引的查询只涉及到索引结构,而不需要访问表中的数据行,可以减小对表的锁定范围,降低了锁的竞争情况。
  4. 提高查询性能: 通过避免访问表中的数据行,覆盖索引通常能够加速查询操作,特别是对于那些只需要检索少量列的查询。

使用覆盖索引时,需要注意以下几点:

  • 覆盖索引通常对那些只需要查询特定列而不需要检索整个数据行的查询效果更好。
  • 在设计索引时,要考虑到查询的需求,选择合适的列加入索引,以确保覆盖索引的有效性。
  • 覆盖索引并不适用于所有类型的查询,具体的性能影响需要根据实际情况评估。

总体而言,覆盖索引是一个有益的优化手段,可以通过减少I/O和内存的使用来提高查询性能。

2) 哈希索引

哈希(Hash)一般翻译为“散列”,也有直接音译成“哈希”的,就是把任意长度的输入(又叫作预映射,pre-image)通过散列算法变换成固定长度的输出,该输出就是散列值。

哈希索引也称为散列索引或 HASH 索引。MySQL 目前仅有 MEMORY 存储引擎和 HEAP 存储引擎支持这类索引。其中,MEMORY 存储引擎可以支持 B-树索引和 HASH 索引,且将 HASH 当成默认索引。

HASH 索引不是基于树形的数据结构查找数据,而是根据索引列对应的哈希值的方法获取表的记录行。哈希索引的最大特点是访问速度快,但也存在下面的一些缺点:

  • MySQL 需要读取表中索引列的值来参与散列计算,散列计算是一个比较耗时的操作。也就是说,相对于 B-树索引来说,建立哈希索引会耗费更多的时间。
  • 不能使用 HASH 索引排序。
  • HASH 索引只支持等值比较,如“=”“IN()”或“<=>”。
  • HASH 索引不支持键的部分匹配,因为在计算 HASH 值的时候是通过整个索引值来计算的。

MyISAM和InnoDB的区别

  • MyISAM

    • 是非聚集索引
    • 他的索引直接指向的是数据项
    • MyISAM只支持表锁
  • InnoDB

    • 用的是追溯索引
    • 他的索引是指向他的主键索引,他主键索引采取相同的数据项
    • InnoDB支持redo log日志
    • InnoDB支持事务
    • InnoDB支持行锁

Mysql最多可以支持16个索引

explain关注哪些字段

在MySQL中,EXPLAIN是一个用于查询优化的关键字,它用于获取MySQL执行查询语句时的执行计划。执行计划提供了关于MySQL数据库引擎如何执行查询的详细信息,可以帮助开发人员和数据库管理员分析查询性能和进行优化。

EXPLAIN的输出结果中包含一系列字段,其中一些常见的字段有:

  1. id:

    • 表示查询中每个操作的唯一标识符。如果查询中包含子查询,每个子查询都会有一个唯一的id。
  2. select_type:

    • 描述了查询的类型。可能的值包括:
      • SIMPLE:简单查询,不包含子查询或者UNION
      • PRIMARY:最外层的查询。
      • SUBQUERY:子查询中的第一个SELECT。
      • DERIVED:在FROM子句中包含的SELECT,作为子查询的一部分。
  3. table:

    • 显示了查询操作涉及的表的名称。
  4. type:

    • 表示访问表的方式,是最重要的一个字段之一。常见的值有:
      • ALL:全表扫描。
      • index:通过索引进行扫描。
      • range:通过索引进行范围查找。
      • ref:使用非唯一索引或唯一性索引查找。
      • eq_ref:使用唯一性索引查找。
      • const:使用常量值进行匹配。
  5. possible_keys:

    • 显示可能用于查询的索引,但并不一定会使用。
  6. key:

    • 表示实际用于查询的索引。如果为NULL,则表示没有使用索引。
  7. key_len:

    • 表示索引字段的长度。
  8. ref:

    • 显示了索引的哪一列或常数值与索引一起被使用。
  9. rows:

    • 表示MySQL认为必须检查的行数,越小越好。
  10. Extra:

    • 提供了关于执行计划的额外信息,如是否使用了临时表、使用了文件排序等。

通过分析EXPLAIN的输出结果,可以了解查询语句的执行计划,找到潜在的性能问题,并进行优化。注意,这些字段的具体含义可能会根据MySQL的版本和使用的存储引擎而有所不同。

逻辑区分

1) 普通索引

普通索引是 MySQL 中最基本的索引类型,它没有任何限制,唯一任务就是加快系统对数据的访问速度。

普通索引允许在定义索引的列中插入重复值和空值。

创建普通索引时,通常使用的关键字是 INDEX 或 KEY。

例 1

下面在 tb_student 表中的 id 字段上建立名为 index_id 的索引。

CREATE INDEX index_id ON tb_student(id);

2) 唯一索引

唯一索引与普通索引类似,不同的是创建唯一性索引的目的不是为了提高访问速度,而是为了避免数据出现重复。

唯一索引列的值必须唯一,允许有空值。如果是组合索引,则列值的组合必须唯一。

创建唯一索引通常使用 UNIQUE 关键字。

例 2

下面在 tb_student 表中的 id 字段上建立名为 index_id 的索引,SQL 语句如下:

CREATE UNIQUE INDEX index_id ON tb_student(id);

其中,id 字段可以有唯一性约束,也可以没有。

3) 主键索引

顾名思义,主键索引就是专门为主键字段创建的索引,也属于索引的一种。

主键索引是一种特殊的唯一索引,不允许值重复或者值为空。

创建主键索引通常使用 PRIMARY KEY 关键字。不能使用 CREATE INDEX 语句创建主键索引。

4) 空间索引

空间索引是对空间数据类型的字段建立的索引,使用 SPATIAL 关键字进行扩展。

创建空间索引的列必须将其声明为 NOT NULL,空间索引只能在存储引擎为 MyISAM 的表中创建。

空间索引主要用于地理空间数据类型 GEOMETRY。对于初学者来说,这类索引很少会用到。

例 3

下面在 tb_student 表中的 line 字段上建立名为 index_line 的索引,SQL 语句如下:

CREATE SPATIAL INDEX index_line ON tb_student(line);

其中,tb_student 表的存储引擎必须是 MyISAM,line 字段必须为空间数据类型,而且是非空的。

5) 全文索引

全文索引主要用来查找文本中的关键字,只能在 CHAR、VARCHAR 或 TEXT 类型的列上创建。在 MySQL 中只有 MyISAM 存储引擎支持全文索引。

全文索引允许在索引列中插入重复值和空值。

不过对于大容量的数据表,生成全文索引非常消耗时间和硬盘空间。

创建全文索引使用 FULLTEXT 关键字。

例 4

在 tb_student 表中的 info 字段上建立名为 index_info 的全文索引,SQL 语句如下:

CREATE FULLTEXT INDEX index_info ON tb_student(info);

其中,index_info 的存储引擎必须是 MyISAM,info 字段必须是 CHAR、VARCHAR 和 TEXT 等类型。

什么字段创建索引比较好

主键自动建立唯一索引

频繁作为查询条件的字段

外键关系字段

参与排序的字段

分组依据的字段

组合查询的字段

不创建索引:

在表中出现次数少的字段

经常增删改的字段

where几乎不参与的字段

过滤性不好的字段

索引命中

最左匹配原则

1、先定位该sql的查询条件,有哪些,那些是等值的,那些是范围的条件。

2、等值的条件去命中索引最左边的一个字段,然后依次从左往右命中,范围的放在最后。

mysql索引失效

  1. like查询以“%”开头;当like前缀没有%,后缀有%时,索引有效

    如果要查询的字段都建立过索引,那么引擎会直接在索引表中查询而不会访问原始数据(否则只要有一个字段没有建立索引就会做全表扫描),这叫索引覆盖,因此我们需要尽可能的在select后只写必要的查询字段,以增加索引覆盖的几率。

    因为,LIKE查询以%开头使用了索引的原因就是使用了索引覆盖。
    针对二级索引MySQL提供了一个优化技术。即从辅助索引中就可以得到查询的记录,就不需要回表再根据聚集索引查询一次完整记录。使用索引覆盖的一个好处是辅助索引不包含整行记录的所有信息,故其大小要远小于聚集索引,因此可以减少大量的IO操作,但是前提是要查询的所有列必须都加了索引。

    LIKE查询以%开头会导致全索引扫描或者全表扫描,如果没有索引覆盖的话,查询到的数据会回表,多了一次IO操作,当MySQL预估全表扫描或全索引扫描的时间比走索引花费的时间更少时,就不会走索引。有了索引覆盖就不需要回表了,减少了IO操作,花费的时间更少,所以就使用了索引。

    总结:就是说如果查询的结果中只包含主键和索引字段则会使用索引,反之则不会。
    那有人就会说了,为什么我在设计表的时候,不把所有的字段全设置为索引呢,*这里值得注意的是不要想着为每个字段建立索引,因为优先使用索引的优势就在于其体积小 *

  2. or语句前后没有同时使用索引;

  3. 组合索引中不是使用第一列索引;

  4. 在索引列上使用“IS NULL”或“IS NOT NULL”操作;

  5. 如果列类型是字符串,那一定要在条件中将数据使用引号引用起来,否则不使用索引

  6. 在索引字段上使用“not”,“<>”,“!=”等等。

  7. 对索引字段进行计算操作、字段上使用函数。

  8. 当全表扫描速度比索引速度快时,mysql会使用全表扫描,此时索引失效

Mysql调优

  1. 排除缓存干扰
    • MySQL 8.0 之前数据库是存在索引的情况
    • 因为SQL有缓存,所以线下执行会很快,但是线上会遇到索引失效的问题
    • 所以需要我们测试的时候加上SQL NOCACHE去跑SQL
    • 缓存失效的原因就是一对表进行更新,那这个表的所有缓存都会被清空
    • MySQL 8.0不用担心这个问题
  2. Explain
  3. 覆盖索引
  4. 联合索引
  5. 最左匹配原则
  6. 索引下推
  7. 唯一索引普通索引难题
  8. 前缀索引
  9. 条件字段函数操作
  10. 隐式字符编码转换
  11. flush

MySQL和Oracle的区别

本质区别

  • Oracle数据库是一个对象关系数据库管理系统(收费)
  • MySQL是一个开源的关系数据库管理系统(免费)

数据库的安全性

  • MySQL使用三个参数来验证用户,即用户名、密码和位置
  • Oracle使用了更多的安全功能,如用户名、密码、配置文件、本地身份验证、外部身份验证、高级安全增强功能

权限

MySQL的权限系统是通过继承形式的分层结构。权限授于高层时,其他低层隐式继承被授予的权限,当然低层也可改写这些权限

按授权范围不同,MySQL有以下种授权方式:

  1. 全局
  2. 基于每个主机;
  3. 基于表
  4. 基于表列

每一级在数据库中都有一个授权表。当进行权限检查时,MySQL从高到低检查每一张表,低范围授权优于高范围授权。

与Oracle不同,MySQL没有角色的概念。也就是说,如果对一组用户授予同样的权限,需要对每一个用户分别授权。

模式迁移

模式包含表、视图、索引、用户、约束、存储过程、触发器和其他数据库相关的概念。多数关系型数据库都有相似的概念。

包含内容如下:

  1. 模式对象的相似性;
  2. 模式对象的名称;
  3. 表设计时的关注点;
  4. 多数据库整合;
  5. MySQL模式整合的关注点

模式对象的相似性

就模式对象,Oracle和MySQL存储诸多的相似,但也有一些不同

img

模式对象的名称

Oracle是大小写不敏感的,并且模式对象是以在写时进行存储。在Oracle的世界中,列、索引、存储过程、触发器以及列别名都是大小写不敏感,并且在所有平台都是如此。MySQL是大小写敏感的,如数据库相对的存储路径、表对应的文件都是如此

当把关键字用引号引起来时,Oracle和MySQL都允许把这些关键字用于模式对象。但MySQL对于一些关键字,不加引号也行。

表设计的关注点

  1. 字符数据的类型;
  2. 列默认值

多数据库迁移

如果多个MySQL数据库位于同一个数据库服务商,支持迁移

数据存储概念

MySQL的数据库对于应用于服务器上数据目录内的了目录,这一数据存储方式与多数据数据库不同,也包括Oracle。数据库中的表对应一个或者多个数据库目录下的文件,并取表存储时的存储引擎。

一个Oracle数据库包含一个或者多个表空间。表空间对应数据在磁盘上的物理存储。表空间是从一个或者多个数据文件开始构建的。数据文件是文件系统中的文件或者原始存储的一块空间。

语法上的区别

主键:
  • MySQL一般使用自动增长类型,在创建表的时候指定表的主键为auto increment,主键就会自动增长。
  • Oracle中没有自动增长,主键一般使用序列,插值时一次赋值即可
引号问题:
  • Oracle不使用双引号,会报错
  • MySQL对双引号没有限制
分页查询:
  • MySQL分页查询使用关键字Limit来实现
  • Oracle没有实现分页查询的关键字,实现起来比较复杂,在每个结果集中只有一个rownum字段标明它的位置,并且只能用rownum<=某个数字,不能用rownum>=某个数,因为ROWNUM是伪列,在使用时所以需要为ROWNUM取一个别名,变成逻辑列,然后来操作

数据类型

  • MySQL中的整型:int() 字符类型: varchar()
  • Oracle中的整型: number() 字符类型: varchar2()

MySql事务隔离

MySQL事务隔离级别是指在并发执行的事务之间,各个事务所见数据的一致性和隔离程度。MySQL提供了四种事务隔离级别,分别是:

  1. READ UNCOMMITTED(读未提交):

    • 允许一个事务读取另一个事务未提交的数据。可能会导致脏读、不可重复读和幻读。
  2. READ COMMITTED(读提交):

    • 一个事务只能读取另一个事务已经提交的数据。可以避免脏读,但仍可能出现不可重复读和幻读。
  3. REPEATABLE READ(可重复读):

    • 确保一个事务在执行期间多次读取相同的行时,会得到相同的数据。可以避免脏读和不可重复读,但仍可能出现幻读。
  4. SERIALIZABLE(串行化):

    • 最高的隔离级别,确保一个事务的更改在另一个事务开始之前完成。避免了所有类型的并发问题,但性能开销较大。

在MySQL中,可以通过以下方式设置事务的隔离级别:

  • 在启动时设置:

    • 在启动MySQL服务器时,可以通过--transaction-isolation选项来指定默认的事务隔离级别。
  • 在会话中设置:

    • 可以在会话中通过SET TRANSACTION ISOLATION LEVEL语句来设置事务隔离级别。
1
2
-- 设置当前会话的事务隔离级别为读提交
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

各个隔离级别对应的并发问题如下:

  • 脏读(Dirty Read):

    • 一个事务读取了另一个事务未提交的数据。
  • 不可重复读(Non-Repeatable Read):

    • 在同一事务中,两次读取相同的行,但得到不同的结果。
  • 幻读(Phantom Read):

    • 一个事务在两次查询之间,另一个事务插入了新的行,导致第一个事务第二次查询时发现了新插入的行。

选择合适的事务隔离级别需要根据具体的业务场景和性能需求进行权衡。通常情况下,使用默认的隔离级别(REPEATABLE READ)即可满足大多数应用的需求。

Oracle事务隔离

Oracle数据库也支持多种事务隔离级别,类似于MySQL。Oracle默认的事务隔离级别是READ COMMITTED(读提交),但也可以设置为SERIALIZABLE(串行化)。

以下是Oracle数据库中的两种主要事务隔离级别:

  1. READ COMMITTED(读提交):

    • 这是Oracle数据库的默认事务隔离级别。在该级别下,一个事务只能读取已经提交的数据,避免了脏读。但仍然可能出现不可重复读和幻读的问题。
  2. SERIALIZABLE(串行化):

    • 在串行化隔离级别下,Oracle会确保一个事务的更改在另一个事务开始之前完成。这可以避免脏读、不可重复读和幻读,但相应地,性能开销也会更大。

Oracle数据库中可以使用以下语句设置事务隔离级别:

1
2
3
4
5
-- 设置当前事务的隔离级别为读提交
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 设置当前事务的隔离级别为串行化
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;

需要注意的是,Oracle数据库没有像MySQL那样提供REPEATABLE READ(可重复读)的隔离级别,但是READ COMMITTED和SERIALIZABLE可以满足大多数应用的需求。

在选择事务隔离级别时,仍然需要根据应用的具体需求和性能要求进行权衡。串行化级别能够提供最高的隔离性,但可能牺牲一些性能。在实际应用中,大多数情况下READ COMMITTED是一个合理的默认选择。

分库分表

分库分表是一种数据库水平切分的策略,用于处理大规模数据的存储和查询。这种方式可以将一个大型数据库分割成多个小型数据库,每个小型数据库叫做一个分库,而每个分库可以再分割成多个小型表,每个小型表叫做一个分表。分库分表有助于提高数据库的性能和可伸缩性,同时降低了单一数据库的负担。

以下是分库分表的一些基本概念和常见策略:

分库分表的基本概念:

  1. 分库(Sharding):

    • 将整个数据库分为多个独立的数据库实例。每个分库独立运行,可以部署在不同的物理机器上。
  2. 分表(Sharding):

    • 在每个分库中,将单一的大表分割成多个小表,每个小表只包含部分数据。这样每个小表的数据量变小,提高了查询性能。
  3. 分片键(Sharding Key):

    • 选择用于分片的字段,可以是一个或多个字段。分片键的选择非常重要,它决定了如何将数据分布到不同的分库和分表中。
  4. 数据迁移:

    • 在数据量增大或分库分表策略变更时,可能需要进行数据迁移,将数据从一个库或表移动到另一个库或表。

常见的分库分表策略:

  1. 水平分库:

    • 将数据按照某个条件(例如用户ID、地理位置等)划分到不同的数据库中。每个数据库独立存储一部分数据,从而降低单一数据库的压力。
  2. 水平分表:

    • 在同一数据库中,将单一的大表按照某个条件(例如时间范围、业务类型等)划分成多个小表。每个小表只包含一部分数据,减少了单表的数据量。
  3. 垂直分库分表:

    • 将数据库表按照业务功能划分,存储在不同的数据库中。每个数据库中包含一组关联的表,减少了表的复杂性。
  4. 一主多从(Master-Slave):

    • 通过主从复制,将写操作集中在主库,读操作分散到多个从库。这种方式实现了读写分离,提高了数据库的读取性能。
  5. 分片算法:

    • 选择合适的分片算法对数据进行分片,确保数据分布均匀。常见的分片算法包括哈希分片、范围分片、取模分片等。
  6. 分库分表中间件:

    • 使用分库分表中间件,如MyCAT、ShardingSphere等,简化了分库分表的实现和管理,提供了自动化的数据路由和负载均衡功能。

分库分表需要谨慎设计分片键,避免热点问题,同时需要考虑跨库事务、数据一致性等挑战。在选择分库分表的方案时,需要根据具体业务需求和数据库规模做出权衡。

分库分表的实现

实现分库分表涉及到数据库的设计、查询路由、数据迁移等多个方面。以下是一些通用的步骤和考虑事项,具体的实现方式可能会根据业务需求和数据库系统而有所不同。

步骤和考虑事项:
  1. 分片键选择:

    • 选择适当的分片键是分库分表设计的关键。分片键决定了如何将数据分布到不同的库和表中。通常选择具有均匀分布特性的字段,例如用户ID、时间戳等。
  2. 数据库设计:

    • 设计分库分表的数据库结构。每个分库包含若干个分表,每个分表存储部分数据。表结构在各个分表之间要保持一致,以便查询时可以在多个分表中进行联合查询。
  3. 分库分表中间件:

    • 使用分库分表中间件简化实现。中间件可以自动进行数据路由、负载均衡、事务处理等。常见的分库分表中间件有MyCAT、ShardingSphere等。
  4. 数据迁移:

    • 在分库分表的初始阶段或者在业务需要的时候,可能需要进行数据迁移。数据迁移可以通过ETL工具、自定义脚本等方式进行,将现有数据按照分片键重新分布。
  5. 查询路由:

    • 设计查询路由逻辑,确保查询时能够正确路由到相应的分库和分表。这可能需要在业务代码中添加一些逻辑或者借助中间件提供的功能。
  6. 事务处理:

    • 跨库事务可能会面临一些挑战,因为不同的数据库实例之间无法使用本地事务。在设计业务逻辑时,需要考虑使用柔性事务、两阶段提交等方式来处理跨库事务。
  7. 分库分表的扩容和缩容:

    • 随着业务的增长,可能需要对分库分表进行扩容。扩容涉及到添加新的分库或分表,并将现有数据按照新的规则进行重新分布。缩容则是相反的过程,可能需要将某些库或表中的数据迁移到其他地方。
  8. 监控和维护:

    • 引入分库分表后,需要建立一套监控体系,监控每个分库分表的性能、负载、空间使用等情况。及时发现并处理潜在问题。
  9. 容灾和备份:

    • 分库分表的环境可能涉及到多个数据库实例,要确保容灾和备份策略,以保障数据的可用性和安全性。

总的来说,分库分表的实现涉及到数据库设计、数据路由、事务处理等多个方面,需要仔细考虑业务需求,选择合适的工具和方案。分库分表中间件可以大大简化这个过程,提供了一些通用的解决方案。

Mybatis

Mybatis #和$的区别

#传入的参数在SQL中显示为字符串,$传入的参数在SqL中直接显示为传入的值.

例:使用以下SQL

select id,name,age from student where id =#{id}
当我们传递的参数id为 “1” 时,上述 sql 的解析为:

select id,name,age from student where id ="1"
$传入的参数在SqL中直接显示为传入的值

例:使用以下SQL

select id,name,age from student where id =${id}
当我们传递的参数id为 “1” 时,上述 sql 的解析为:

select id,name,age from student where id =1
2、#可以防止SQL注入的风险(语句的拼接);但$无法防止Sql注入。

3、$方式一般用于传入数据库对象,例如传入表名。

4、大多数情况下还是经常使用#,一般能用#的就别用$;但有些情况下必须使用$,例:MyBatis排序时使用order by 动态参数时需要注意,用$而不是#。

Redis

Redis持久化机制

Redis 提供了两种主要的持久化机制,分别是快照(Snapshot)和追加式文件(Append-Only File,AOF)。

  1. 快照(Snapshot)持久化:

    • 快照持久化是通过定期将内存中的数据生成一个数据快照,保存到磁盘上的一个文件中。这个文件通常以 “dump.rdb” 命名。

    • 配置方式:在 redis.conf 配置文件中,通过设置 save 参数来指定快照的触发条件,例如:

      1
      2
      3
      save 900 1     # 在900秒(15分钟)内,如果发生至少1次写操作,则生成快照
      save 300 10 # 在300秒(5分钟)内,如果发生至少10次写操作,则生成快照
      save 60 10000 # 在60秒内,如果发生至少10000次写操作,则生成快照
    • 手动触发:可以使用 SAVEBGSAVE 命令手动触发生成快照。

    • 优点:快照持久化对于备份和恢复数据非常高效,生成的快照文件是一个二进制文件,非常紧凑。

    • 缺点:如果发生突然停机,可能会导致数据丢失,因为最后一次快照生成之后的修改都会丢失。

    • 文件的结构:

      RDB 文件是一个二进制文件,包含了当前 Redis 服务器在某个时间点上的所有数据快照。它包括了数据库中所有的键值对、数据类型、过期时间等信息。

  2. 追加式文件(Append-Only File,AOF)持久化:

    • AOF 持久化记录每个写操作(命令)到一个追加的文件中,该文件以文本形式记录 Redis 服务器接收到的写命令。
    • 配置方式:在 redis.conf 配置文件中,通过设置 appendonly 参数为 yes 开启 AOF 持久化。
      1
      appendonly yes
    • AOF 文件的重写:为了防止 AOF 文件过大,Redis 支持 AOF 文件的周期性重写,可以通过配置 auto-aof-rewrite-percentageauto-aof-rewrite-min-size 来指定重写的触发条件。
    • 优点:相对于快照持久化,AOF 提供了更好的数据安全性,因为 AOF 文件记录了每个写操作,可以通过重放 AOF 文件来还原数据。
    • 缺点:AOF 文件相对于快照文件更大,恢复数据的时间可能较长。

默认情况下,Redis 同时启用了快照持久化和 AOF 持久化。可以根据实际需求选择其中一种或两种持久化方式。在 Redis 4.0 版本以后,还提供了混合持久化(Mixed Persistence)的方式,可以同时使用快照和 AOF 文件。

Redis如何进行数据存取的

redis的最底层存储结构实质上就是HashMap,也就是数组和链表实现的.

他通过哈希函数来确定插入位置,但数组就这么大,键值对总会有冲突,因此我们通过链表的形式来解决,实现纵向拓展。随着数据更大,不断增长的链表会拖慢我们的速度,这个时候就需要扩容,也就有了我们rehash的方式,我们会重新申请一个是原来两倍的空间把数据复制过去。

我们的数据存储与数据类型没有关系,所有的数据都存放在value里面,那么数据类型影响什么呢,会影响我们在value里面的存储方式,这也就是不同数据类型的数据结构带来的不同。但他们本质上都是在hashmap上存储。

开发选型为什么选型Redis

  1. 复杂数据结构,选择Redis更合适

    value是哈希、列表、集合、有序集合这类复杂的数据结构时,会选择redis,因此memcache无法满足这些需求

  2. 持久化,选择redis更合适

    mc无法满足持久化的需求

  3. 高可用

    redis天然支持集群功能,可以实现主动复制,读写分离。

  4. 存储的内容比较大,选择redis更合适

    mc的value存储最大为1m。

Redis集群为什么使用两主两从

使用两主两从的 Redis 高可用架构主要考虑了系统的可用性、性能和数据安全。在这种架构中,每个主节点都有对应的从节点,以提高系统的冗余度和容错能力。以下是一些原因:

  1. 高可用性:

    • 通过使用两个主节点,系统在某个主节点发生故障时,仍然有另一个主节点可以继续提供服务。每个主节点都有一个对应的从节点,当主节点宕机时,从节点可以升级为新的主节点,继续提供服务。
  2. 读写分离:

    • 两主两从的架构可以实现读写分离,提高读操作的性能。主节点负责写操作,从节点负责读操作,这样可以分担主节点的负载,提高系统的整体性能。
  3. 数据冗余:

    • 每个主节点都有一个对应的从节点,从而实现了数据的冗余备份。在某个主节点宕机时,可以通过其对应的从节点来保证数据的可用性。这种冗余设计有助于防止数据丢失和提高数据安全性。
  4. 容错能力:

    • 当一个主节点不可用时,系统可以快速切换到另一个主节点,从而减少因节点故障而导致的服务中断时间。这提高了系统的容错能力,增加了对节点故障的快速恢复能力。
  5. 水平扩展:

    • 两主两从的架构是相对简单且可扩展的。如果业务负载增加,可以通过增加主节点和相应的从节点来水平扩展系统。
  6. 故障转移:

    • 当主节点发生故障时,系统可以通过从节点升级为新的主节点,实现故障转移。这样可以确保系统在主节点故障时能够快速恢复。

需要注意的是,选择 Redis 高可用架构的具体配置应该根据实际业务需求、数据规模和性能要求来进行权衡。两主两从是一种常见的配置方案,但在某些情况下,也可以根据具体需求考虑其他配置,如三主三从或多主多从的架构。

Redis单线程为什么执行速度这么快

(1):纯内存操作,避免大量访问数据库,减少直接读取磁盘数据,redis将数据存储在内存里,读写数据的时候都不会硬盘I/O速度的限制,所以速度快

(2):单线程操作,避免了不必要的上下文切换和竞争条件,也不存在多进程或者多线程导致的切换而消耗cpu,不用去考虑各种锁的问题,不存在加锁释放锁的操作,没有因为可能出现死锁而导致的性能消耗

(3):采用了非阻塞I/O多路复用机制

Redis数据结构底层实现

String:

(1)Simple dynamic string (SDS)的数据结构

1
2
3
4
5
6
7
8
9
struct sdshdr {
// 记录buf数组 中已使用字节的数量
// 等于SDS保存字符串的长度
int len;
// 记录buf数组中未使用字节的数量
int free;
// 字节数组,用于保存字符串
char buff[];
}

它的优点:

(1)不会出现字符串变更造成的内存溢出问题

(2)获取字符串长度时间复杂度为1

(3)空间预分配, 惰性空间释放free字段,会默认留够一定的空间防止多次重分配内存

应用场景:String 缓存结构体用户信息,计数

Hash:

数组+链表的基础上,进行了一些rehash优化;1.Reids的Hash采用链地址法来处理冲突,然后它没有使用红黑树优化。

2.哈希表节点采用单链表结构。

3.rehash优化 (采用分而治之的思想,将庞大的迁移工作量划分到每一次CURD中,避免了服务繁忙)

应用场景:保存结构体信息可部分获取不用序列化所有字段

List:

应用场景:(1):比如twitter的关注列表,粉丝列表等都可以用Redis的list结构来实现

(2):list的实现为一个双向链表,即可以支持反向查找和遍历

Set:

内部实现是一个 value为null的HashMap,实际就是通过计算hash的方式来快速排重的,这也是set能提供判断一个成员 是否在集合内的原因。应用场景:去重的场景,交集(sinter)、并集(sunion)、差集(sdiff),实现如共同关注、共同喜好、二度好友等功能

Zset:

内部使用HashMap和跳跃表(SkipList)来保证数据的存储和有序,HashMap里放的是成员到score的映射,而跳跃表里存放的是所有的成员,排序依据是HashMap里存的score,使用跳跃表的结构可以获得比较高的查找效率,并且在实现上比较简单。跳表:每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的 应用场景:实现延时队列

Spring Redis是如何存储session的

img

用户访问服务器端,服务器端会判断请求对象cookie中是否包含session的键值对

  1. 如果 cookie 不包含 Session 键值对会创建 Session 对象,对象中包含:

    1. creationTime创建时间、
    2. lastAccessedTime 最后一次访问时间、
    3. maxInactiveInterval 有效时间。
  2. 如果 cookie 包含 Session 键值对,根据 session 键值对的值拼接 spring:session:value

  3. 从redis 中取出 session 对象。首先会判断 Session 对象是否过期。如果过期了,需要重新创建。执行步骤 2

  4. 如果没有过期,刷新 lastAccessedTime。刷新后重新放入到 redis 中。

Redis事务

(1):Multi开启事务

(2):Exec执行事务块内命令

(3):Discard 取消事务

(4):Watch 监视一个或多个key,如果事务执行前key被改动,事务将打断

Redis事务的实现特征

(1):所有命令都将会被串行化的顺序执行,事务执行期间,Redis不会再为其它客户端的请求提供任何服务,从而保证了事物中的所有命令被原子的执行

(2):Redis事务中如果有某一条命令执行失败,其后的命令仍然会被继续执行

(3):在事务开启之前,如果客户端与服务器之间出现通讯故障并导致网络断开,其后所有待执行的语句都将不会被服务器执行。然而如果网络中断事件是发生在客户端执行EXEC命令之后,那么该事务中的所有命令都会被服务器执行

(4):当使用Append-Only模式时,Redis会通过调用系统函数write将该事务内的所有写操作在本次调用中全部写入磁盘。

然而如果在写入的过程中出现系统崩溃,如电源故障导致的宕机,那么此时也许只有部分数据被写入到磁盘,而另外一部分数据却已经丢失。

Redis服务器会在重新启动时执行一系列必要的一致性检测,一旦发现类似问题,就会立即退出并给出相应的错误提示。此时,我们就要充分利用Redis工具包中提供的redis-check-aof工具,该工具可以帮助我们定位到数据不一致的错误,并将已经写入的部分数据进行回滚。修复之后我们就可以再次重新启动Redis服务器了

缓存雪崩以及处理办法

原理:同一时刻大量缓存失效;

处理方法:

(1):缓存数据增加过期标记

(2):设置不同的缓存失效时间

(3):双层缓存策略C1为短期,C2为长期

(4):定时更新策略

缓存击穿原因以及处理办法

原理:频繁请求查询系统中不存在的数据导致;

处理方法:

(1):cache null策略,查询反馈结果为null仍然缓存这个null结果,设置不超过5分钟过期时间

(2):布隆过滤器,所有可能存在的数据映射到足够大的bitmap中 google布隆过滤器:基于内存,重启失效不支持大数据量,无法在分布式场景 redis布隆过滤器:可扩展性,不存在重启失效问题,需要网络io,性能低于google

bgsave的原理是什么?

fork和cow。fork是指redis通过创建子进程来进行bgsave操作,cow指的是copy on write,子进程创建后,父子进程共享数据段,父进程继续提供读写服务,写进的页面数据会逐渐和子进程分离开来。

RDB与AOF区别

(1):R文件格式紧凑,方便数据恢复,保存rdb文件时父进程会fork出子进程由其完成具体持久化工作,最大化redis性能,恢复大数据集速度更快,只有手动提交save命令或关闭命令时才触发备份操作;

(2):A记录对服务器的每次写操作(默认1s写入一次),保存数据更完整,在redis重启是会重放这些命令来恢复数据,操作效率高,故障丢失数据更少,但是文件体积更大;

主从、Cluster和哨兵的区别

  1. 主从模式 可以实现读写分离,数据备份。但是并不是「高可用」的
  2. 哨兵模式 可以看做是主从模式的「高可用」版本,其引入了 Sentinel 对整个 Redis 服务集群进行监控。但是由于只有一个主节点,因此仍然有写入瓶颈。
  3. Cluster 模式 不仅提供了高可用的手段,同时数据是分片保存在各个节点中的,可以支持高并发的写入与读取。当然实现也是其中最复杂的。

Redis bigkey的处理

什么是bigkey

简单来说,如果一个key对应的value所占用的内存比较大,那这个key就可以看做是bigkey。

  • String类型的value超过1MB
  • 复合类型(List、Hash、Set、Sorted Set等)的value包含的元素超过5000个

bigkey是怎么产生的?有什么危害

bigkey通常是由于以下这些原因产生的:

  • 程序设计不当,比如直接使用String类型存储较大的文件对应的二进制数据。
  • 对于业务的数据规模考虑不周到,比如使用集合类型的时候没有考虑到数据量的快速增长
  • 未及时清理垃圾数据,比如哈希中冗余了大量的无用键值对

bigkey除了会消耗更多的内存空间和贷款,还对性能造成比较大的影响。

bigkey还会造成阻塞问题:

  • 客户端超时阻塞:由于Redis执行命令是单线程处理,然后再操作大key时会比较耗时,那么就会阻塞Redis,从客户端这一视角看,就是很久很久没有响应。
  • 网络阻塞:每次获取大Key产生的网络流量较大,如果一个key的大小是1MB,每秒访问量为1000,那么每秒会产生1000MB的流量,这对于普通千兆网卡的服务器来说是灾难性的。
  • 工作线程阻塞:如果使用del删除大key时,会阻塞工作线程,这样就没办法处理后续的命令。
    大key造成的阻塞问题还会进一步影响到主从同步和集群扩容。

如何发现bigkey?

  1. 使用Redis自带的–bigkeys参数来查找

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    # redis-cli -p 6379 --bigkeys

    # Scanning the entire keyspace to find biggest keys as well as
    # average sizes per key type. You can use -i 0.1 to sleep 0.1 sec
    # per 100 SCAN commands (not usually needed).

    [00.00%] Biggest string found so far '"ballcat:oauth:refresh_auth:f6cdb384-9a9d-4f2f-af01-dc3f28057c20"' with 4437 bytes
    [00.00%] Biggest list found so far '"my-list"' with 17 items

    -------- summary -------

    Sampled 5 keys in the keyspace!
    Total key length in bytes is 264 (avg len 52.80)

    Biggest list found '"my-list"' has 17 items
    Biggest string found '"ballcat:oauth:refresh_auth:f6cdb384-9a9d-4f2f-af01-dc3f28057c20"' has 4437 bytes

    1 lists with 17 items (20.00% of keys, avg size 17.00)
    0 hashs with 0 fields (00.00% of keys, avg size 0.00)
    4 strings with 4831 bytes (80.00% of keys, avg size 1207.75)
    0 streams with 0 entries (00.00% of keys, avg size 0.00)
    0 sets with 0 members (00.00% of keys, avg size 0.00)
    0 zsets with 0 members (00.00% of keys, avg size 0.00

    从这个命令的运行结果,我们可以看出:这个命令会扫描(Scan) Redis 中的所有 key ,会对 Redis 的性能有一点影响。并且,这种方式只能找出每种数据结构 top 1 bigkey(占用内存最大的 String 数据类型,包含元素最多的复合数据类型)。然而,一个 key 的元素多并不代表占用内存也多,需要我们根据具体的业务情况来进一步判断。

    在线上执行该命令时,为了降低对 Redis 的影响,需要指定 -i 参数控制扫描的频率。redis-cli -p 6379 –bigkeys -i 3 表示扫描过程中每次扫描后休息的时间间隔为 3 秒。

  2. 使用Redis自带的scan命令
    SCAN 命令可以按照一定的模式和数量返回匹配的 key。获取了 key 之后,可以利用 STRLEN、HLEN、LLEN等命令返回其长度或成员数量。
    数据结构命令复杂度结果(对应 key)StringSTRLENO(1)字符串值的长度HashHLENO(1)哈希表中字段的数量ListLLENO(1)列表元素数量SetSCARDO(1)集合元素数量Sorted SetZCARDO(1)有序集合的元素数量
    对于集合类型还可以使用 MEMORY USAGE 命令(Redis 4.0+),这个命令会返回键值对占用的内存空间。

  3. 借助开源工具分析RDB文件
    通过分析 RDB 文件来找出 big key。这种方案的前提是你的 Redis 采用的是 RDB 持久化。
    网上有现成的代码/工具可以直接拿来使用:

  • redis-rdb-tools[2]:Python 语言写的用来分析 Redis 的 RDB 快照文件用的工具
  • rdb_bigkeys[3] : Go 语言写的用来分析 Redis 的 RDB 快照文件用的工具,性能更好
  1. 借助公有云的Redis分析服务
    如果你用的是公有云的 Redis 服务的话,可以看看其是否提供了 key 分析功能(一般都提供了)。

    这里以阿里云 Redis 为例说明,它支持 bigkey 实时分析、发现,文档地址:https://www.alibabacloud.com/help/zh/apsaradb-for-redis/latest/use-the-real-time-key-statistics-feature

如何处理bigkey

bigkey 的常见处理以及优化办法如下(这些方法可以配合起来使用):

  • 分割 bigkey:将一个 bigkey 分割为多个小 key。例如,将一个含有上万字段数量的 Hash 按照一定策略(比如二次哈希)拆分为多个 Hash。
  • 手动清理:Redis 4.0+ 可以使用 UNLINK 命令来异步删除一个或多个指定的 key。Redis 4.0 以下可以考虑使用 SCAN 命令结合 DEL 命令来分批次删除。
  • 采用合适的数据结构:例如,文件二进制数据不使用 String 保存、使用 HyperLogLog 统计页面 UV、Bitmap 保存状态信息(0/1)。
  • 开启 lazy-free(惰性删除/延迟释放) :lazy-free 特性是 Redis 4.0 开始引入的,指的是让 Redis 采用异步方式延迟释放 key 使用的内存,将该操作交给单独的子线程处理,避免阻塞主线程。

如何解决Reids的并发竞争问题

在使用 Redis 时,由于其单线程的特性,可能会面临并发竞争的问题。下面是一些常见的解决 Redis 并发竞争问题的方法:

  1. 使用事务:

    • Redis 支持事务操作,可以使用 MULTI、EXEC、DISCARD 和 WATCH 等命令来进行事务控制。
    • WATCH 命令用于监视一个或多个键,当这些键被其他客户端修改时,事务将被打断。
  2. 使用乐观锁(Optimistic Locking):

    • 通过数据版本号或时间戳等机制实现乐观锁。在读取数据时,获取版本号或时间戳,然后在更新数据时比对版本号或时间戳。
    • 在 Redis 中,可以使用 SET 命令的 NX(不存在时设置)和 XX(存在时设置)选项以及条件命令如 WATCH 配合实现乐观锁。
  3. 使用分布式锁:

    • 如果在分布式环境中,可以使用分布式锁来避免并发问题。常见的分布式锁实现包括基于 Redis 的 SETNX 命令、RedLock 算法、ZooKeeper 等。
    • SETNX 命令可用于在键不存在时设置键值,通过这一特性可以实现分布式锁。
  4. 使用 Lua 脚本:

    • Lua 脚本可以在 Redis 服务器端原子地执行一系列操作,从而避免了多个命令的非原子性。
    • 通过 EVAL 命令执行 Lua 脚本,可以实现一些复杂的原子性操作,减少竞争条件的发生。
  5. 限制请求频率:

    • 通过在客户端或服务端限制请求的频率,可以减少并发竞争的可能性。
    • 在客户端可以使用令牌桶(Token Bucket)算法或漏桶(Leaky Bucket)算法来实现限流。
  6. 使用 Redis 事务的 WATCH 命令:

    • 在 Redis 事务中,使用 WATCH 命令可以监视一个或多个键,当这些键被其他客户端修改时,事务将被打断。
    • 在事务执行前,执行 WATCH 命令监视相应的键,如果键的值在事务执行期间发生变化,事务将不会执行。
  7. 合理设置过期时间:

    • 对于一些需要控制过期时间的数据,合理设置过期时间,以便及时释放锁或清理不再需要的数据。

选择合适的方法取决于具体的业务场景和需求。在高并发场景下,可以根据业务逻辑采用多种手段的组合来解决并发竞争问题。

Redis分布式锁的实现原理

分布式锁在 Redis 中可以通过不同的实现方式,其中一种常见的方式是基于 SETNX(SET if Not eXists)命令的原子性操作。以下是 Redis 分布式锁的简单实现原理:

  1. 基本思想:

    • 利用 SETNX 命令的原子性质,在一个键上设置锁标志,如果该键不存在,则设置成功,即获取锁。
    • 如果键已经存在,说明锁已经被其他客户端获取,获取锁失败。
  2. 获取锁的操作:

    • 客户端执行 SETNX 命令,尝试在指定的键上设置锁标志,设置成功表示获取锁。
    • 如果 SETNX 返回 1(设置成功),则表示获取锁成功。
  3. 释放锁的操作:

    • 客户端在完成任务后,通过 DEL 命令删除锁标志,释放锁。

    • 为了确保释放锁的原子性,可以使用 Lua 脚本执行 DEL 命令,这样就能够保证释放锁的操作是原子的。
      在使用 Redis 实现分布式锁时,为了确保释放锁的原子性,可以使用 Lua 脚本。以下是一个简单的 Redis Lua 脚本,用于释放分布式锁:

      1
      2
      3
      4
      5
      if redis.call("GET", KEYS[1]) == ARGV[1] then
      return redis.call("DEL", KEYS[1])
      else
      return 0
      end

      在这个 Lua 脚本中,KEYS[1] 表示锁的键,ARGV[1] 表示锁的值(一般是客户端标识,用于区分不同的加锁请求)。脚本首先检查当前锁的值是否等于传入的值,如果相等,则执行 DEL 命令删除锁,并返回 1 表示释放成功;如果不相等,则返回 0 表示释放失败。

      在使用该 Lua 脚本时,可以通过 Redis 的 EVAL 命令来调用:

      1
      EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end" 1 lockKey clientId

      其中,lockKey 是锁的键,clientId 是加锁时设置的值。这样,Lua 脚本会在 Redis 中原子地检查并释放锁。

      使用 Lua 脚本可以确保释放锁的操作是原子的,避免了在多个命令之间发生竞态条件。这对于分布式系统中的锁管理非常重要。

  4. 设置锁的过期时间:

    • 为了避免死锁情况,可以为 SETNX 设置一个过期时间(TTL),即使在获取锁之后发生异常或者忘记释放锁,锁也会在一定时间内自动释放。
    • 可以使用 SETEX 命令或者 SET 命令的 EXPIRE 选项来设置过期时间。
  5. 解决竞态条件:

    • 为了避免竞态条件,获取锁的客户端需要在设置锁之前使用 WATCH 命令监视键。如果在获取锁的过程中有其他客户端修改了键,WATCH 将引发一个事务执行失败。
    • 获取锁的客户端在 WATCH 之后,使用 MULTI 命令开启事务,在事务中使用 SETNX 来设置锁,然后通过 EXEC 命令提交事务。
  6. 考虑可重入性:

    • 如果业务需要支持可重入锁,需要考虑客户端在持有锁的情况下是否可以再次获取同一把锁。这通常需要在锁的值中记录持有者信息。

实现分布式锁的关键在于使用原子性操作,保证在高并发情况下的正确性。需要注意的是,虽然 SETNX 是原子性的,但在设置过期时间时,可能存在 SETNX 成功但设置过期时间失败的情况。因此,建议使用 Redis 2.6.12 版本引入的 SET 命令的 NX 和 EX 选项的组合来实现分布式锁。

Redis集群如何同步数据

在 Redis 集群中,数据的同步主要通过以下几个机制来实现:

  1. 复制(Replication):

    • Redis 集群中的每个节点都可以配置为一个主节点或一个从节点。主节点负责接收写操作,而从节点负责复制主节点的数据。
    • 当主节点接收到写操作时,它会将写操作同步到所有连接的从节点。从节点接收到写操作后,会按照写操作的顺序来执行,从而保持主节点和从节点的数据一致性。
    • 复制机制实现了读写分离,提高了系统的吞吐量和可用性。
  2. 主从切换:

    • 当主节点发生故障或不可用时,Redis 集群可以通过自动或手动方式进行主从切换。这时会选择一个从节点晋升为新的主节点,继续提供服务。
    • 在主从切换过程中,新的主节点会继承原主节点的数据,并且其他从节点会重新连接到新的主节点。
  3. 集群间数据同步:

    • 如果 Redis 集群中有多个主节点,那么它们之间也会进行数据同步,保持整个集群的一致性。
    • 集群间的数据同步主要通过节点之间的相互复制来实现。
  4. 快照和持久化:

    • Redis 集群中的每个节点可以配置持久化机制,包括 RDB 快照和 AOF 日志。
    • RDB 快照是通过定期生成整个数据集的快照,可以用于备份和还原数据。AOF 日志记录了每个写操作,用于在服务器重启时重放写操作。
    • 持久化机制可以帮助集群在重启后恢复数据。

需要注意的是,Redis 集群的节点间通信和同步是通过 Redis Cluster Bus(集群总线)来实现的。集群总线是一个集群内部的发布/订阅系统,用于节点之间的消息传递和集群的状态更新。当一个节点的状态发生变化时,它会通过集群总线通知其他节点,从而实现集群中节点状态的同步。

ELK

ES常用API

索引操作

  1. 查看索引健康状态: GET _cluster/health?level=-indices
  2. 创建索引:PUT users
  3. 删除索引: DELETE users
  4. 查看索引设置: GET users/_settings

文档操作

  1. 创建文档

    1
    2
    3
    4
    5
    6
    PUT users/user/1
    {
    "name": "zhangsan",
    "age": 20,
    "sex": 1
    }
  2. 批量插入文档

    1
    2
    3
    4
    5
    POST books/name/_bulk
    {"index": {"_id":1}}
    {"name": "《Java编程思想》"}
    {"index": {"_id": 2}}
    {"name": "《代码简洁之道》"}
  3. 查看一个索引的所有文档

    1
    GET books/_search
  4. 查看一个索引的所有文档

    1
    GET books/_search
  5. 查看指定id的文档

    1
    GET books/book/2
  6. 修改文档

    1. POST方式:

      1
      2
      3
      POST books/name/_bulk
      {"index": {"_id": "3"}}
      {"update": "hello"}
    2. PUT方式:

      1
      2
      PUT books/name/3
      {"name": "java"}
  7. 删除文档

    1
    DELETE books/name/3
  8. 文档查询

    单条件搜索:

    1
    GET bank/account/_search?q=firstname:Virginia

    多条件搜索:

    1
    2
    3
    4
    5
    6
    7
    8
    GET student/_search 
    {
    "query": {
    "match": {
    "sex": "女"
    }
    }
    }

    ES是如何存储数据的

    Elasticsearch 是一个分布式的全文搜索和分析引擎,它的数据存储方式采用倒排索引(Inverted Index)的结构。这种存储结构使得 Elasticsearch 非常适合处理大规模的文本数据,支持快速的全文搜索、复杂的查询和聚合操作。

以下是 Elasticsearch 存储数据的主要组成部分:

  1. 索引(Index):

    • Elasticsearch 的数据存储单元是索引,每个索引由一个或多个分片(Shard)组成。每个分片是一个独立的 Lucene 索引,用于存储一部分数据。
  2. 分片(Shard):

    • 为了支持水平扩展和提高并发性能,Elasticsearch 将索引分成多个分片。每个分片都是一个独立的 Lucene 索引,可以独立地存储和搜索数据。
  3. 文档(Document):

    • 数据以文档的形式存储在 Elasticsearch 中。一个文档是一个 JSON 格式的数据对象,包含了一个或多个字段(Field)。文档属于特定的索引和类型。
  4. 字段(Field):

    • 文档中的字段是数据的基本单元,可以包含文本、数字、日期等不同类型的数据。每个字段都有一个映射类型(Mapping Type),定义了字段的数据类型和如何索引、存储、搜索。
  5. 倒排索引(Inverted Index):

    • Elasticsearch 使用倒排索引来加速全文搜索。倒排索引是一种将文档中的每个词汇映射到包含该词汇的所有文档的结构。这使得 Elasticsearch 能够快速地定位包含特定词汇的文档。
  6. 分词器(Tokenizer)和过滤器(Filter):

    • 在建立倒排索引之前,Elasticsearch 对文本数据进行分词和处理。分词器负责将文本分成词汇,而过滤器则负责对词汇进行处理,例如小写转换、去除停用词等。
  7. 映射(Mapping):

    • 映射定义了索引中的文档如何被存储和索引。它包含了每个字段的数据类型、分析器等信息。映射由用户定义或者通过自动映射生成。

总体来说,Elasticsearch 的存储结构允许它高效地进行全文搜索和聚合操作。通过分片和倒排索引的设计,Elasticsearch 能够在大规模数据集上快速响应各种查询。

ES的分片

Elasticsearch 的分片设置是在创建索引的时候进行的,分片的合理设置对于 Elasticsearch 集群的性能和扩展性都有很大的影响。以下是一些建议和常见的考虑事项:

  1. 默认分片数:

    • Elasticsearch 默认为每个索引创建 5 个主分片。默认分片数在索引创建后无法更改。这个默认值在大多数情况下是合理的,但在某些特殊场景中,可能需要根据实际情况进行调整。
  2. 分片数量的选择:

    • 分片的数量决定了索引的并发能力和扩展性。一般来说,分片数不是越多越好,因为每个分片都会占用集群的一部分资源。一般建议每个节点上的分片数不要超过 20 个。如果你的集群中有 3 个节点,那么一个索引的总分片数不宜超过 60。
  3. 主分片和副本分片:

    • 每个主分片都有 0 个或多个副本分片。副本分片用于提高高可用性和容错性。一般建议每个索引的总分片数是主分片数与副本分片数之和。例如,如果你希望每个索引有 3 个主分片和 1 个副本分片,那么索引的总分片数就是 6。
  4. 集群规模和硬件配置:

    • 集群的规模和硬件配置也会影响分片的设置。如果你的集群很大,可能需要更多的主分片和副本分片。另外,如果硬件性能较好,可能可以使用更多的分片。
  5. 热点分片问题:

    • 在分片数量较少的情况下,可能会出现某个分片成为热点(Hot Shard)的情况,即该分片的负载远高于其他分片。这会导致性能不均衡。为了避免热点分片问题,可以尽量均匀分布数据,或者采用一些分片路由的策略。
  6. 分片大小:

    • 避免设置过小的主分片,因为这可能会导致过多的小分片而降低性能。另外,分片的大小也会影响查询的性能,通常来说,更大的分片可以提高查询性能。
  7. 动态调整:

    • Elasticsearch 支持动态调整索引的主分片和副本分片数量。如果在创建索引后发现性能问题,你可以考虑动态调整分片数量。

在创建索引时,可以通过以下方式设置主分片数和副本分片数:

1
2
3
4
5
6
7
PUT /index_name
{
"settings": {
"number_of_shards": 3,
"number_of_replicas": 1
}
}

以上示例中,number_of_shards 设置了主分片数,number_of_replicas 设置了副本分片数。根据实际需求,进行合理的设置。

ES的倒排索引是什么

传统的我们的检索是通过文章,逐个遍历找到对应关键词的位置。

而倒排索引,是通过分词策略,形成了词和文章的映射关系表,这种词典+映射表即为倒排索引。

有了倒排索引,就能实现O(1)时间复杂度的效率检索文章了,极大的提高了检索效率。

倒排索引的底层实现是基于:FST(Finite State Transducer)数据结构。

FST有两个优点:

1、 空间占用小。通过对词典中单词前缀和后缀的重复利用,压缩了存储空间;

2、 查询速度快。O(len(str))的查询时间复杂度。

ES在部署时,对Linux的设置有哪些优化

  1. 关闭缓存swap
  2. 堆内存设置:Min(节点内存/2,32GB);
  3. 设置最大文件句柄数
  4. 线程池+队列大小根据业务需要做调整
  5. 磁盘存储raid方式–存储有条件使用RAID10,增减单节点性能以及避免单节点存储故障

详细描述一下ES索引文档过程

这里的索引文档应该理解为文档写入ES,创建索引的过程。

文档写入包含:单文档写入和批量bulk写入。

单文档写入流程

img

  1. 第一步:客户写集群某节点写入数据,发送请求

  2. 第二部:节点1接受到请求后,使用文档_id来确定文档属于分片0.请求会被转到另外的节点,假定节点3,。因此分片0的主分片分配到节点3上

    文档获取分片过程?

    会借助路由算法获取,路由算法就是根据路由和文档id计算目标的分片id的过程

    shard = hash(_routing) % (num_of_primary_shards)

  3. 第三部:节点3在主分片上执行写操作,如果成功,则将请求并行转发到节点1和节点2的副本分片上,等待结果返回。所有的副本分片都报告成功,节点3将想协调节点(节点1)报告成功,节点1箱请求客户端报告写入成功

批量bulk写入流程

MQ

Kafka

Kafka是什么

Kafka是一种高吞吐量、分布式、基于发布/订阅的消息系统,最初由LinkedIn公司开发,使用Scala语言编写,目前是Apache的开源项目。

broker: Kafka服务器,负责消息存储和转发

topic:消息类别,Kafka按照topic来分类消息

partition: topic的分区,一个topic可以包含多个partition, topic 消息保存在各个partition上.

offset:消息在日志中的位置,可以理解是消息在partition上的偏移量,也是代表该消息的唯一序号

Producer:消息生产者

Consumer:消息消费者

Consumer Group:消费者分组,每个Consumer必须属于一个group

Zookeeper:保存着集群 broker、 topic、 partition等meta 数据;另外,还负责broker故障发现, partition leader选举,负载均衡等功能

zookeeper对于kafka的作用是什么?

Zookeeper 主要用于在集群中不同节点之间进行通信,在 Kafka 中,它被用于提交偏移量,因此如果节点在任何情况下都失败了,它都可以从之前提交的偏移量中获取,除此之外,它还执行其他活动,如: leader 检测、分布式同步、配置管理、识别新节点何时离开或连接、集群、节点实时状态等等。

Kafka的哪些设计让它有如此高的性能?

  1. kafka是分布式的消息队列
  2. 对log文件进行了segment,并对segment创建了索引
  3. (对于单节点)使用了顺序读写,速度能够达到600m/s
  4. 使用了zeror拷贝,在os系统就完成了读写操作

Java发送消息给kafka

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class ProducerTest {

public static void main(String[] args) {
Properties props = new Properties();
// 必须
props.put("bootstrap.servers","121.5.240.148:9092");
// 被发送到broker的任何消息的格式都必须是字节数组
props.put("key.serializer","org.apache.kafka.common.serialization.StringSerializer");
props.put("value.serializer","org.apache.kafka.common.serialization.StringSerializer");
// 非必须参数配置
// acks=0表明producer完全不管发送结果;
// acks=all或-1表明producer会等待ISR所有节点均写入后的响应结果;
// acks=1,表明producer会等待leader写入后的响应结果
props.put("acks","-1");
// 发生可重试异常时的重试次数
props.put("retries",3);
// producer会将发往同一分区的多条消息封装进一个batch中,
// 当batch满了的时候,发送其中的所有消息,不过并不总是等待batch满了才发送消息;
props.put("batch.size",323840);
// 控制消息发送延时,默认为0,即立即发送,无需关心batch是否已被填满。
props.put("linger.ms",10);
// 指定了producer用于缓存消息的缓冲区大小,单位字节,默认32MB
// producer启动时会首先创建一块内存缓冲区用于保存待发送的消息,然后由另一个专属线程负责从缓冲区中读取消息执行真正的发送
props.put("buffer.memory",33554432);
// 设置producer能发送的最大消息大小
props.put("max.request.size",10485760);
// 设置是否压缩消息,默认none
props.put("compression.type","lz4");
// 设置消息发送后,等待响应的最大时间
props.put("request.timeout.ms",30);

Producer<String,String> producer = new KafkaProducer<String, String>(props);
for(int i = 0;i<5;i++){
producer.send(new ProducerRecord<>("my-replicated-topic","key"+i,"value"+i));
}

producer.close();

}
}

Kafka是如何存储数据的

Apache Kafka 是一款分布式的流处理平台,主要用于构建实时数据流应用。Kafka 使用一种高效而可扩展的方式来存储数据,其主要存储模型包括主题(Topic)、分区(Partition)和日志(Log)。

以下是 Kafka 存储数据的基本原理:

  1. 主题(Topic):

    • Kafka 中的数据按照主题进行组织,主题是数据流的逻辑容器。
    • 主题可以理解为一个具体的数据类别或者主要关注的事件类型。
  2. 分区(Partition):

    • 每个主题可以分成一个或多个分区,分区是数据存储和处理的基本单元。
    • 分区的作用包括提高并发性、实现水平扩展、保证数据的有序性等。
  3. 日志(Log):

    • 每个分区都对应一个日志文件,也称为分区日志(Partition Log)。
    • 分区日志以追加写入(Append-only)的方式存储消息,即一旦消息被写入,就不再修改。这种特性提供了高吞吐量的写入操作。
  4. 消息索引:

    • 为了支持高效的消息查找和消费,Kafka 使用消息索引(Offset Index)来记录每条消息的位置。
    • 索引文件中包含每个消息的偏移量(offset)和其在日志文件中的物理位置。
  5. 段文件(Segment Files):

    • 分区日志实际上是由多个段文件组成的,每个段文件存储一定数量的消息。
    • 当一个段文件达到一定大小(通过配置参数控制)或者时间间隔时,Kafka 会创建一个新的段文件。
  6. 数据保留策略:

    • Kafka 支持根据时间或者数据大小来设置数据保留策略。过期的数据将被删除,以便释放存储空间。
    • 这种机制保证了 Kafka 可以持续存储和处理大量的数据。

总体来说,Kafka 的数据存储模型基于日志的设计,它将所有的消息追加写入到分区日志中,支持高吞吐的写入操作。这种设计保证了 Kafka 具有高可靠性、持久性和水平扩展性。同时,通过分区和索引的机制,Kafka 提供了高效的消息查找和消费能力。

Web基础

如何保持会话一致性

  1. 客户端存储法:

    将session存储到浏览器cookie中,每个端只要存储一个用户的数据

    优点:服务端不需要存储

    缺点:

    • 每次HTTP请求都携带Session,占外网带宽
    • 数据存储在端上,并在网络传输,存在泄漏、篡改、窃取等安全隐患
    • session存储的数据大小受cookie限制
  1. 后端统一存储:

    将session存储在服务器后端的存储层,数据库或者缓存

    优点:

    • 没有安全隐患
    • 可以水平扩展,数据库/缓存水平切分即可
    • web-server重启或者扩容都不会有session丢失

问题排查

线上慢Sql如何优化

优化线上慢 SQL 的过程通常涉及到识别性能瓶颈、分析查询执行计划、调整索引、优化查询语句等多个方面。以下是一些常见的优化手段:

  1. 使用索引:

    • 确保数据库表中的关键字段上有合适的索引。通过分析查询执行计划,可以确定是否在关键字段上使用了索引。
    • 避免在查询中使用不必要的通配符或者使用 OR 条件,这可能导致索引失效。
  2. 避免全表扫描:

    • 尽量避免全表扫描,特别是在大表上。确保查询条件涉及到的列上有索引,以便使用索引进行快速定位。
  3. 合理使用 JOIN:

    • JOIN 操作可能会导致性能问题,尤其是在大表上。确保 JOIN 操作的字段有索引,考虑是否可以使用更有效率的连接方式,如 INNER JOIN、LEFT JOIN 等。
  4. 分析查询执行计划:

    • 使用数据库提供的工具或者 EXPLAIN 命令来分析查询执行计划。检查是否存在表扫描、排序、临时表等耗时操作。
  5. 合理使用数据库连接池:

    • 避免创建过多的数据库连接,使用连接池来管理连接。过多的连接可能导致数据库性能下降。
  6. 优化查询语句:

    • 确保查询语句的写法是合理的,避免不必要的子查询、冗余的代码等。有时候,通过重写查询语句,可以使其更为高效。
  7. 分析慢查询日志:

    • 启用数据库的慢查询日志功能,分析其中的查询,找出执行时间较长的 SQL 语句,然后进行优化。
  8. 适时进行表结构优化:

    • 考虑表的分区、归档等方式,对于一些历史数据可以进行归档,减轻查询压力。
    • 对于大表,可以考虑使用分区表或者垂直拆分表等方式来优化性能。
  9. 缓存查询结果:

    • 对于一些相对静态的数据,可以考虑使用缓存来减轻数据库的压力,提高查询速度。
  10. 硬件升级:

    • 在一些情况下,可能需要考虑升级硬件,包括增加内存、更快的磁盘、更强大的 CPU 等,来提升数据库服务器性能。

在进行任何优化之前,务必先了解具体的性能瓶颈在哪里,通过性能测试和监控工具来定位问题。每个系统的情况都有所不同,因此需要根据具体情况采用不同的优化策略。

线上OOM如何排查

在线上出现内存溢出(OOM)的情况时,需要进行仔细的排查和分析,以找到问题的根本原因。以下是一些建议的排查步骤:

  1. 查看日志和堆栈跟踪:

    • 查看应用程序的日志,寻找内存溢出的错误信息。通常会包含异常信息、出错的类和方法,以及堆栈跟踪。
    • 在 Java 中,可以通过查看 Java 进程的 stdout、stderr 日志或者通过监控工具获取相关信息。
  2. 使用内存分析工具:

    • 使用内存分析工具(如Eclipse Memory Analyzer、VisualVM、MAT等)来检查内存中的对象分布和引用关系。
    • 这些工具能够帮助你找到哪些对象占用了大量内存,从而更容易定位问题。
  3. 检查代码和数据结构:

    • 审查应用程序的代码,特别关注那些可能导致内存泄漏的部分。例如,未关闭的资源、长生命周期的对象引用等。
    • 检查数据结构的使用,确保没有不必要的大数据集或者数据集过期没有及时清理的情况。
  4. 查看内存使用情况:

    • 监控应用程序的内存使用情况,包括堆内存、非堆内存、元空间等。观察内存的分配和释放情况。
    • 使用 Java 的内置工具(如 jstat、jconsole、VisualVM 等)来实时查看内存使用情况。
  5. 检查GC日志:

    • 分析应用程序的垃圾回收日志,了解垃圾回收的频率、耗时和效果。GC(垃圾回收)相关的调优可以帮助减少内存压力。
    • 启用 GC 日志,例如通过添加 JVM 参数 -Xloggc:gc.log -XX:+PrintGCDetails
  6. 检查内存泄漏工具:

    • 使用专业的内存泄漏检测工具,例如 LeakCanary、MAT(Memory Analyzer Tool)等,来帮助检测内存泄漏问题。
    • 这些工具可以在运行时检测对象引用的情况,帮助找出未及时释放的对象。
  7. 升级和优化:

    • 确保使用的框架和库是最新版本,因为一些旧版本可能存在内存泄漏或性能问题。
    • 优化代码,避免不必要的大对象创建、循环引用等情况。
  8. 考虑增加堆内存:

    • 如果内存溢出是由于堆内存不足引起的,可以考虑增加堆内存的大小。但这仅仅是缓解问题的方法,不是解决根本原因的方法。

通过以上步骤,你应该能够识别并解决导致内存溢出的问题。需要注意的是,内存溢出可能是复杂的问题,可能需要结合多个步骤来全面分析。

CPU 与内存使用率过高问题

-p pid```命令查看这个java进程的情况。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115

> **VIRT** 表示使用的虚拟内存数量,**RES** 表示使用的物理内存数量,**SHR** 表示使用的共享内存数量,这三者可以从内存角度看该进程的资源占用情况。
>
> **S** 表示进程的状态,下面的值 **S** 表示睡眠,**D** 表示不可中断睡眠,**R** 表示运行,基本知道这三个就够了。
>
> 后面两个值是百分比,**%CPU** 自然就是 CPU 使用率,**%MEM** 自然就是内存使用率,看这俩值可以一目了然看谁占用的资源过高了。
>
> **TIME** 表示累计 cpu 使用时长。

如果再细化到线程,可以加个 -H 参数,**top -p 19063 -H**

当然,top 命令已经可以分析内存了,如果想单独分析下内存,可以用小而美的命令,**free -h**

可以用 **swapon** 命令来看下交换区的使用情况。

#### Java 程序的问题分析

用 **jmap -dump** 分析堆内存中的快照

用 **jmap -heap** 查看堆内存设置与当前使用情况

用 **jstack** 查看 jvm 线程运行信息,上传到 **fastthread.io** 这个网站,直观地看一下,一看线程有点多。





## 高并发业务情景

#### 整点抢购,优化业务接口

整点抢购场景是一种高并发、短时间内集中请求的情形,为了提高系统的性能和稳定性,可以考虑以下一些优化策略:

1. **限流:**
- 实施请求限流机制,防止短时间内过多的请求导致系统崩溃。可以使用令牌桶算法、漏桶算法等方式来控制请求的并发数量。

2. **缓存:**
- 使用缓存来减轻数据库的压力。在整点抢购场景中,商品信息和库存信息可能是相对稳定的,可以考虑在缓存中预加载这些数据,减少数据库查询次数。

3. **异步处理:**
- 将一些非实时、可以延迟处理的任务异步化。例如,订单的创建、库存的扣减等操作,可以通过消息队列等异步处理,降低前端请求的响应时间。

4. **分布式锁:**
- 使用分布式锁来保证同一时刻只有一个用户能够成功抢购。这可以避免超卖的问题,确保库存的正确性。

5. **水平扩展:**
- 通过增加服务器实例来进行水平扩展,提高系统的并发处理能力。负载均衡器可以将请求分发到不同的服务器,减轻单一服务器的压力。

6. **数据库优化:**
- 针对瓶颈点进行数据库的性能优化。可以考虑对数据库的查询语句、索引、表结构等进行优化,提高数据库的读写性能。

7. **预热:**
- 在抢购开始前进行系统预热,提前加载商品信息、初始化缓存等,以确保系统在抢购开始时能够迅速响应用户请求。

8. **降级处理:**
- 在高并发情况下,考虑一些降级策略。例如,当系统压力较大时,可以暂时关闭一些不太重要的功能,以保障核心功能的正常运作。

9. **队列缓冲:**
- 使用消息队列等机制,将请求进行缓冲。例如,用户发起抢购请求时,可以先将请求放入消息队列中,然后由后台异步处理,避免瞬时高峰的请求冲击。

10. **CDN加速:**
- 使用 CDN 加速静态资源的传输,减轻服务器的带宽压力。这对于大量用户同时下载静态资源的场景有帮助。

在整点抢购这样的场景中,综合考虑上述策略,可以有效提高系统的稳定性和性能。然而,需要根据具体的业务和系统状况选择合适的优化手段。



### 分布式锁

分布式锁是在分布式系统中用于协调多个节点对共享资源或临界区的访问的一种机制。分布式锁的目的是确保在分布式环境中,多个节点不会同时修改共享资源,防止数据不一致性等问题。以下是一些实现分布式锁的常用方式:

1. **基于数据库的分布式锁:**
- 使用数据库的事务和唯一约束来实现分布式锁。通过在数据库中创建一张表,表中有一个唯一约束的字段,每个节点尝试插入唯一值,成功即获取锁,释放锁时删除对应记录。

2. **基于缓存的分布式锁:**
- 利用分布式缓存如Redis或Memcached,在缓存中存储一个标志位,代表锁的状态。节点尝试设置这个标志位,成功即获取锁,释放锁时删除对应的标志位。

3. **基于ZooKeeper的分布式锁:**
- 使用ZooKeeper分布式协调服务。ZooKeeper提供了有序节点和临时节点的特性,可以利用这些特性实现分布式锁。每个节点创建一个有序的临时节点,最小的节点获得锁。

4. **基于分布式锁框架:**
- 使用一些专门的分布式锁框架,如Curator的`InterProcessMutex`,它是基于ZooKeeper实现的分布式可重入锁。

5. **基于数据库乐观锁的分布式锁:**
- 利用数据库的乐观锁机制,通过在数据库中的记录上加版本号,每次修改时检查版本号,从而实现分布式锁。

6. **基于文件的分布式锁:**
- 可以使用共享文件系统,通过在文件系统中创建一个文件作为锁的标志,节点尝试创建这个文件,成功即获取锁。

7. **基于Redisson的分布式锁:**
- Redisson是一个基于Redis实现的Java框架,提供了多种分布式锁的实现,如可重入锁、公平锁、红锁等。

在选择分布式锁的实现方式时,需要根据具体业务需求和系统环境来进行选择。不同的实现方式有各自的优劣势,需要权衡锁的性能、可靠性、复杂性等因素。



#### MySql分布式锁

MySQL并不直接提供分布式锁的功能,但可以通过一些策略和技术来实现分布式锁。下面介绍两种常见的方法:

1. **使用表行级锁实现分布式锁:**

利用MySQL的行级锁机制,可以通过在一个表中创建一条记录来实现分布式锁。例如,创建一个表 `distributed_lock`,表中有一个唯一的标识字段 `lock_key`,通过插入一条记录来获取锁,删除记录来释放锁。

```sql
-- 创建表
CREATE TABLE distributed_lock (
lock_key VARCHAR(50) PRIMARY KEY
);

-- 获取锁
INSERT INTO distributed_lock (lock_key) VALUES ('your_lock_key') ON DUPLICATE KEY UPDATE lock_key = lock_key;

-- 释放锁
DELETE FROM distributed_lock WHERE lock_key = 'your_lock_key';

这种方式利用了唯一键约束的原子性来确保同一时刻只有一个节点能够插入成功,从而实现分布式锁的效果。

  1. 使用ZooKeeper实现分布式锁:

    ZooKeeper是一个分布式协调服务,可以利用其提供的有序节点和临时节点特性实现分布式锁。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // 使用ZooKeeper的Java客户端Curator
    InterProcessMutex lock = new InterProcessMutex(curatorFramework, "/your_lock_path");

    try {
    if (lock.acquire(10, TimeUnit.SECONDS)) {
    // 成功获取锁,执行业务逻辑
    // ...
    } else {
    // 获取锁失败
    // ...
    }
    } finally {
    // 释放锁
    lock.release();
    }

    这里使用了Curator框架提供的InterProcessMutex实现,它基于ZooKeeper实现了分布式可重入锁。

请注意,在使用MySQL或ZooKeeper实现分布式锁时,要考虑到锁的超时、重试机制以及出现异常时的处理等情况,以确保分布式锁的可靠性和稳定性。