Java八股文总结

Java基础

Java语言有哪些特点

  • 简单性:Java语言的语法简洁,易于学习和使用。
  • 跨平台性:Java语言的程序可以在不同的操作系统和硬件平台上运行,只需编写一次,到处运行。
  • 面向对象:Java语言支持封装、继承、多态等面向对象的特性,可以提高代码的复用性和可维护性。
  • 安全性:Java语言提供了多种安全机制,如字节码验证、沙箱模型、访问控制等,可以防止恶意代码的侵入和破坏。
  • 多线程性:Java语言支持多线程编程,可以充分利用多核处理器的优势,提高程序的并发性能和响应速度。
  • 分布性:Java语言支持网络编程,可以开发分布式系统和Web应用程序。
  • 可移植性:Java语言的数据类型和运算符都有明确的定义,不受具体平台的影响,保证了程序的一致性和可移植性。
  • 解释型:Java语言的程序需要经过编译器编译成字节码,然后由虚拟机解释执行,这样可以实现跨平台的特点,也方便了程序的调试和修改。
  • 高性能:Java语言的虚拟机采用了高效的垃圾回收机制和即时编译技术,可以提高程序的运行效率和内存管理。
  • 动态性: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
// Java程序演示浅拷贝
class Student implements Cloneable { // 实现Cloneable接口,表示该类支持浅拷贝
int id; // 学生的id
String name; // 学生的名字
Course course; // 学生所选的课程

public Student(int id, String name, Course course) { // 构造方法,初始化学生对象
this.id = id;
this.name = name;
this.course = course;
}

// 重写clone()方法,创建一个对象的浅拷贝。
protected Object clone() throws CloneNotSupportedException {
return super.clone(); // 调用Object类的默认clone方法
}
}

class Course { // 课程类
String subject1; // 第一门课程
String subject2; // 第二门课程
String subject3; // 第三门课程

public Course(String sub1, String sub2, String sub3) { // 构造方法,初始化课程对象
this.subject1 = sub1;
this.subject2 = sub2;
this.subject3 = sub3;
}
}

public class ShallowCopyExample {

public static void main(String[] args) {
Course science = new Course("Physics", "Chemistry", "Biology"); // 创建一个科学课程对象
Student student1 = new Student(111, "John", science); // 创建一个学生对象,并将科学课程作为参数传递给它
Student student2 = null;

try {
// 创建student1的浅拷贝,并赋值给student2
student2 = (Student) student1.clone();
} catch (CloneNotSupportedException e) {
e.printStackTrace();
}

// 打印student1的第三门课程
System.out.println(student1.course.subject3); // 输出 : Biology

// 改变student2的第三门课程为Maths
student2.course.subject3 = "Maths";

// 这个改变会影响原始对象student1和它的副本student2,因为它们共享相同的内存引用。
System.out.println(student1.course.subject3); // 输出 : Maths
System.out.println(student2.course.subject3); // 输出 : Maths
}
}

深拷贝:

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
// Java程序演示深拷贝 
class Student implements Cloneable { // 实现Cloneable接口,表示该类支持深拷贝
int id; // 学生的id
String name; // 学生的名字
Course course; // 学生所选的课程

public Student(int id, String name, Course course) { // 构造方法,初始化学生对象
this.id = id;
this.name = name;
this.course = course;
}

// 重写clone()方法,创建一个对象的深拷贝。
protected Object clone() throws CloneNotSupportedException {
Student student= (Student) super.clone();

// 复制course对象也通过创建一个新对象,并将其赋值给新创建副本中相应字段。这样就实现了对引用类型字段递归地复制。
student.course= new Course(course.subject1,course.subject2,course.subject3);

return student ;
}
}

class Course { // 课程类
String subject 1 ; // 第一门课程
String subject 2 ; // 第二门课程
String subject 3 ; // 第三门课

public Course(String sub1, String sub2, String sub3) {
this.subject1 = sub1;
this.subject2 = sub2;
this.subject3 = sub3;
}
}

public class DeepCopyExample {

public static void main(String[] args) {
Course science = new Course("Physics", "Chemistry", "Biology");
Student student1 = new Student(111, "John", science);
Student student2 = null;

try {
// 创建student1的深度副本并将其分配给student2
student2= (Student)student1.clone();
} catch (CloneNotSupportedException e) {
e.printStackTrace();
}

System.out.println(student1.course.subject3); //输出 : Biology

// 改变student2的第三门课程为Maths
student2.course.subject3= "Maths";

System.out.println(student1.course.subject3); //输出 : Biology
}
}

八种基本数据类型的大小以及包装类

基本类型 大小(字节) 默认值 封装类
byte 1 (byte)0 Byte
short 2 (short)0 Short
int 4 0 Integer
long 8 0L Long
float 4 0.0f Float
double 8 0.0d Double
boolean - false Boolean
char 2 \u0000(null) Character

为什么需要包装类

因为泛型类包括预定义的集合,使用参数都是对象类型,无法直接使用基本数据类型。所以Java又提供了这些基本数据类型的包装类。

基本数据类型和包装类的不同:

  • 基本数据类型按值传递,包装类按引用传递。
  • 基本类型会在栈中创建,而对于包装类引用类型对象在堆中创建,对象的引用在栈中。

值传递和引用传递的区别

  • 值传递是指在调用函数时,将实际参数复制一份传递到函数中,这样在函数中对参数进行修改,就不会影响到原来的实际参数。例如,基本数据类型的变量就是值传递。

  • 引用传递是指在调用函数时,将实际参数的地址直接传递到函数中,这样在函数中对参数进行修改,就会影响到原来的实际参数。例如,对象类型的变量就是引用传递。

instanceof关键字的作用

instanceof严格来说是Java中的一个双目运算符,用来测试一个对象是否为一个类的实例。

1
boolean result = obj instanceof Class

其中obj为一个对象,Class表示一个类或者一个接口,当obj为Class的对象,或者是其直接或间接子类,或者是其接口的实现类,结果result都返回true,否则返回false。

Tips:编译器会检查obj是否能转换成右边的Class类型,如果不能转换则直接报错。

Java自动装箱与拆箱

装箱就是自动将基本数据类型转换为包装器类型.调用方法:Integer的valueOf(int)方法。

拆箱就是自动将包装器类型转换为基本数据类型。调用方法:Integer的intValue方法。

面试题:

1
2
3
4
5
6
7
8
9
10
11
public class Test {
public static void main(String[] args) {
Integer i1 = 100;
Integer i2 = 100;
Integer i3 = 200;
Integer i4 = 200;

System.out.println(i1==i2); // true
System.out.println(i3==i4); // false
}
}

在通过ValueOf方法创建Integer对象的时候,如果数值在[-128, 127]之间, 便返回指向IntegerCache.cache中已经存在的对象的引用,否则创建一个新的Integer对象。

重载和重写的区别

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

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

final有哪些用法

  • final修饰的类不可以被继承。
  • final修饰的方法不可以被重写。
  • final修饰的变量不可以被改变,如果修饰引用,那么表示引用不可变,引用指向的内容可变。

static有哪些用法

static关键字有两个基本的用法:静态变量和静态方法。也就是被static所修饰的变量或方法都属于类的静态资源,类实例所共享。

static也用于静态块,多用于初始化操作:

1
2
3
4
5
public calss PreCache{
static{
//执行相关操作
}
}

此外static也多用于修饰内部类,此时称之为静态内部类。

最后一种用法就是静态导包,是JDK1.5之后引入的新特性,可以用 来指定导入某个类中的静态资源,并且不需要使用类名,可以直接使用资源名:

1
2
3
4
5
6
7
import static java.lang.Math.*;
public class Test{
public static void main(String[] args){
//System.out.println(Math.sin(20));传统做法
System.out.println(sin(20));
}
}

Array的理解

Array(数组)是基于索引index的数据结构,它使用索引在数组中搜索和读取数据是很快的。

Array获取数据的时间复杂度是O(1),但是要删除数据却是开销很大,因为这需要重排数组中的所有数据(因为删除数据以后,需要把后面所有的元素前移 )。

缺点:数组初始化必须指定初始化的长度,否则报错。

1
2
int[] a = new int[4]; // 推荐使用int[] 这种方式初始化
int c[] = {23, 43, 56, 78}; // 长度:4,索引范围:[0,3]

equals和==的区别

==比较的栈内存中的值,基本数据类型是比较的变量值,引用类型是比较地址值,用来判断两个对象的地址是否相同,即是否指向同一个对象。

equals方法再Object中默认返回的是两个对象的==比较,通常会重写Object对象的equals方法,比如在String类中的equals方法比较的是两个字符串的内容是否相等。

Hashcode的作用

Java的集合有两类,一类是List,还有一类是Set。前者有序可重复,后者无序不重复。当我们在Set中插入的时候怎么判断是否已经存在该元素呢,可以通过equals方法。但是如果元素太多,用这样的方法效率会比较低。

于是有人发明了哈希算法来提高集合中查找元素的效率。 这种方式将集合分成若干个存储区域,每个对象可以计算出一个哈希码,可以将哈希码分组,每组分别对应某个存储区域,根据一个对象的哈希码就可以确定该对象应该存储的那个区域。

hashCode方法可以这样理解:它返回的就是根据对象的内存地址换算出的一个值。这样一来,当集合要添加新的元素时,先调用这个元素的hashCode方法,就一下子能定位到它应该放置的物理位置上。如果这个位置上没有元素,它就可以直接存储在这个位置上,不用再进行任何比较了;如果这个位置上已经有元素了,就调用它的equals方法与新元素进行比较,相同的话就不存了,不相同就散列其它的地址。这样一来实际调用equals方法的次数就大大降低了。

Hash冲突怎么解决

  • 拉链法:每个哈希表节点都有一个next指针,多个哈希表节点可以用next指针构成一个单向链表,被分配到同一个索引上的多个节点可以用这个单向链表进行存储。
  • 开放定址法:一旦发生了冲突,就去寻找下一个空的散列地址,只要散列表足够大,空的散列地址总能找到,并将记录存入。
  • 再哈希:有多个不同的hash函数,当发生冲突时,使用第二个,第三….等哈希函数计算地址,直到无冲突。

String、StringBuffer和Stringbuilder的区别

String是只读字符串,它并不是基本数据类型,而是一个对象。从底层源码来看是final类型的字符数组,所引用的字符串不能被改变,一经定义,无法再增删改。每次对字符串的操作都会生成新的String对象。

1
private final char value[];

每次+操作 : 隐式在堆上new了一个跟原字符串相同的StringBuffer对象,再调用append方法拼接+后面的字符。

StringBuffer和Stringbuilder他们两都继承了AbstractStringBuilder抽象类,从AbstractStringBuilder抽象类中我们可以看到:

1
2
3
4
/**
* The value is used for character storage.
*/
char[] value;

他们的底层都是可变的字符数组,所以在进行频繁的字符串操作时,建议使用StringBuffer和Stringbuilder来进行操作。 另外Stringbuilder对方法加了同步锁或者对调用的方法加了同步锁,所以是线程安全的。

创建对象有几种方式

1、new关键字。

1
Employee emp = new Employee();

2、反射机制,使用Class类的newInstance方法,需要有一个无参的构造方法,这个newInstance方法调用无参的构造函数创建对象。类名.calss.newInstance( )。

1
Employee emp = Employee.class.newInstance();

3、Constructor.newInstance( ),该方法就是反射机制,事实上Class的newInstance方法内部调用Constructor的newInstance方法。
Class类的newInstance只能触发无参数的构造方法创建对象,而构造器类的newInstance能触发有参数或者任意参数的构造方法来创建对象。

1
2
Constructor<Employee> constructor = Employee.class.getConstructor();
Employee emp = constructor.newInstance(1, 张三, 18);

4、clone方法,用clone方法创建对象并不会调用任何构造函数,要使用clone方法,我们需要先实现Cloneable接口并实现其定义的clone方法。

1
Employee emp2 = (Employee) emp.clone();

5、反序列化,Java序列化是指把Java对象转换为字节序列的过程,而Java反序列化是指把字节序列恢复为Java对象的过程;
使用反序列化:当我们序列化和反序列化一个对象,jvm会给我们创建一个单独的对象。在反序列化时,jvm创建对象并不会调用任何构造函数。为了反序列化一个对象,我们需要让我们的类实现Serializable接口。

封装、继承、多态的理解

  • 封装:封装的意义,在于明确标识出允许外部使用的所有成员函数和数据项,内部细节对外部调用透明,外部调用无需修改或者关心内部实现。

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

1
2
3
4
5
private int age;
public void setAge(int age){
if(age>0 && age<=300) // 过滤用户设置的不合法数据
this.age = age;
}
  • 继承:继承基类的方法,并做出自己的改变或扩展,子类共性的方法或者属性直接使用父类的,而不需要自己再定义,只需扩展自己个性化的。
  • 多态:多态是同一个行为具有多个不同表现形式或形态的能力,也就是同一方法可以根据发送对象的不同而采用多种不同的行为方式。
    • 多态产生的三个必要条件:继承,重写,父类引用指向子类对象。
    • 实际开发的过程中,父类类型作为方法形式参数,传递子类对象给方法,进行方法的调用,更能体现出多态的扩展性与便利。

对多态的理解

多态是Java中的一个重要的概念,它指的是同一个行为在不同的对象上有不同的表现形式。多态可以提高代码的可扩展性和复用性,也可以实现动态绑定。

要实现多态,需要满足以下三个条件:

  • 继承:必须有子类继承父类。
  • 重写:子类必须重写父类中的某些方法。
  • 向上转型:父类的引用变量必须指向子类的对象。

当使用多态时,需要注意以下几个原则:

  • 成员变量:编译看左边(父类),运行看左边(父类)。
  • 成员方法:编译看左边(父类),运行看右边(子类)。
  • 静态方法:编译看左边(父类),运行看左边(父类)。
  • 只有非静态的成员方法才具有多态性,其他都没有。

多态的使用场景

Java多态的使用场景有很多,主要是为了提高代码的灵活性和可维护性。以下是一些常见的例子:

  • 定义方法参数时,可以使用父类类型,这样就可以接受任意子类类型的对象。例如,java.util.Collections类中的sort方法,它接受一个List类型的参数,但是可以对任何实现了List接口的类进行排序。
  • 定义方法返回值时,也可以使用父类类型,这样就可以返回不同子类类型的对象。例如,java.sql.DriverManager类中的getConnection方法,它返回一个Connection类型的对象,但是根据不同的数据库驱动,它可能返回不同实现了Connection接口的类。
  • 使用接口或抽象类作为引用变量时,可以指向不同实现了接口或继承了抽象类的对象。例如,在Servlet开发中,HttpServletRequest和HttpServletResponse都是接口类型,但是在运行时会指向不同服务器提供商实现的具体类。
  • 使用多态可以实现策略模式,即根据不同情况选择不同算法或行为。例如,在Java中有很多排序算法(如冒泡排序、快速排序、归并排序等),我们可以定义一个Sorter接口,并让每种排序算法都实现这个接口。然后我们可以定义一个Context类来封装Sorter对象,并提供一个sort方法来调用Sorter对象的sort方法。这样我们就可以在运行时根据需要选择不同的Sorter对象来进行排序。

定义一个Sorter接口,它有一个sort方法,用于对一个int数组进行排序。

1
2
3
public interface Sorter {
void sort(int[] arr);
}

实现不同的排序算法类,如选择排序、冒泡排序、快速排序等,它们都实现了Sorter接口,并重写了sort方法。

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
// 选择排序
public class SelectionSort implements Sorter {
@Override
public void sort(int[] arr) {
for (int i = 0; i < arr.length - 1; i++) {
int minIndex = i;
for (int j = i + 1; j < arr.length; j++) {
if (arr[j] < arr[minIndex]) {
minIndex = j;
}
}
if (minIndex != i) {
int temp = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = temp;
}
}
}
}

// 冒泡排序
public class BubbleSort implements Sorter {
@Override
public void sort(int[] arr) {
for (int i = 0; i < arr.length - 1; i++) {
boolean swapped = false;
for (int j = 0; j < arr.length - 1 - i; j++) {
if (arr[j] > arr[j + 1]) {
int temp = arr[j];
arr[j] = arr[j + 1];
arr[j + 1] = temp;
swapped = true;
}
}
if (!swapped) break;
}
}
}

// 快速排序
public class QuickSort implements Sorter {
@Override
public void sort(int[] arr) {
quickSort(arr, 0, arr.length - 1);
}

private void quickSort(int[] arr, int left, int right) {
if (left >= right) return;
int pivotIndex = partition(arr, left, right);
quickSort(arr, left, pivotIndex - 1);
quickSort(arr, pivotIndex + 1, right);
}

private int partition(int[] arr, int left, int right) {
int pivot = arr[left];
int i = left + 1;
int j = right;
while (true) {
while (i <= right && arr[i] < pivot) i++;
while (j >= left && arr[j] > pivot) j--;
if (i >= j) break;
swap(arr, i++, j--);
}
swap(arr, left, j);
return j;
}

private void swap(int[]arr ,int a ,int b){
int temp=arr[a];
arr[a]=arr[b];
arr[b]=temp;
}
}

定义一个Context类来封装Sorter对象,并提供一个sort方法来调用Sorter对象的sort方法。这样我们就可以在运行时根据需要选择不同的Sorter对象来进行排序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Context {

private Sorter sorter;

public Context(Sorter sorter){
this.sorter=sorter;
}

public void setStrategy(Sorter sorter){
this.sorter=sorter;
}

public void sort(int[]arr){
sorter.sort(arr);
}
}

测试类:

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

public static void main(String[] args){

int [] array={9,-16,-5,-2,-3,-4};

Context context=new Context(new SelectionSort());
context.sort(array);

System.out.println("使用选择排序后的结果:");
for(int element:array){
System.out.print(element+" ");
}

context.setStrategy(new BubbleSort());
context.sort(array);

System.out.println("\n使用冒泡排序后的结果:");
for(int element:array){
System.out.print(element+" ");
}
}
}

普通类和抽象类的区别

  • 普通类不能包含抽象方法,抽象类可以包含抽象方法。
  • 抽象类不能直接实例化,普通类可以直接实例化。

接口和抽象类的区别

  • 抽象类使用extends来继承,接口使用implements来实现。
  • 抽象类可以有构造函数,接口不能。
  • 类可以实现多个接口,抽象类只能继承一个。
  • 抽象类可以存在普通成员函数,而接口中只能存在public abstract 方法。
  • 抽象类中的成员变量可以是各种类型的,而接口中的成员变量只能是public static final类型的。

对反射的理解

Java反射是一种在运行时动态获取和操作类或对象的属性和方法的机制。它可以让我们在不知道具体类型的情况下,创建对象,调用方法,修改属性等。

Java反射的核心是Class对象,它代表了一个类的结构信息,包括类名,构造器,字段,方法等。我们可以通过Class对象来获取这些信息,并且利用反射API来创建对象或者调用方法。

反射的应用场景

  • 框架底层:Spring框架会根据配置文件或注解中指定的类名和属性名,使用反射机制创建Bean对象并注入依赖。
  • 动态代理:使用反射机制实现动态代理模式,在运行时动态创建代理对象,并指定其代理方法。
  • 配置文件:通过配置文件来指定需要创建的对象和执行的方法,在运行时通过反射机制来实现。
  • Tomcat服务器:Tomcat服务器会根据web.xml中配置的servlet类名,使用反射机制创建servlet对象,并调用其service方法。
  • JUnit框架:JUnit框架会根据测试类中标注了@Test注解的方法名,使用反射机制执行测试方法。

new创建对象和反射创建对象的区别

  • new创建对象是在编译期就确定对象,而反射创建对象是在运行时动态地获取类的信息和实例。
  • new创建对象只能访问公有的构造方法,而反射创建对象可以通过setAccessible()方法访问私有的构造方法。
  • new创建对象的效率高于反射创建对象,因为反射需要进行类加载和安全检查等额外的操作。

exception和error的区别

  • Exception和Error都继承自Throwable父类。

  • Error:Java程序运行中不可预料的异常情况,这种异常发生以后,会直接导致JVM不可处理或者不可恢复的情况。所以这种异常不可能抓取到,比如OutOfMemoryError、NoClassDefFoundError等。

  • Exception:分为检查性异常(CheckedException)和运行时异常(RunTimeException)。两个根本的区别在于检查性异常必须在编写代码时,使用try catch捕获(比如:IOException异常)。常常发生在程序运行过程中,会导致程序当前线程执行失败。(比如:ArrayIndexOutOfBoundsException)。

throw和throws的区别

  • 位置不同:throws用在函数上,后边跟的是异常类,可以跟多个异常类。throw用在函数内,后面跟的是异常对象。
  • 功能不同:throws表示出现异常的一种可能性,并不一定会发生这些异常,throw则是抛出了异常,执行throw则一定抛出了某种异常对象。

Java元注解有哪些

Java元注解是用于对其他注解进行说明的注解。Java 5 定义了 4 个元注解,分别是 @Documented、@Target、@Retention 和 @Inherited。Java 8 又增加了 @Repeatable 和 @Native 两个元注解。每个元注解都有自己的作用和使用方式,你可以参考以下的简介:

  • @Documented:表示该注解会被 javadoc 工具记录在文档中。
  • @Target:表示该注解可以用在哪些地方,如类、方法、字段等。
  • @Retention:表示该注解在什么级别保留,如源码、字节码或运行时。
  • @Inherited:表示该注解可以被子类继承。
  • @Repeatable:表示该注解可以在同一个地方重复使用多次。
  • @Native:表示该注解可以用于标记常量字段为本地方法接口中使用的常量值。

IO流分类和体系

流的分类:

  • 按操作数据单位不同分为:字节流(8 bit),字符流(16 bit)
  • 按数据流的流向不同分为:输入流,输出流
  • 按流的角色的不同分为:节点流,处理流
抽象基类 字节流 字符流
输入流 InputStream Reader
输出流 OutputStream Writer

IO流体系:

字节流和字符流有什么区别

Java字节流和字符流的区别主要有以下几点:

  • 字节流操作的基本单元是字节,可以处理任意类型的数据,如图片、音频、视频等;字符流操作的基本单元是Unicode码元,通常只用于处理文本数据,如txt、xml等。
  • 字节流默认不使用缓冲区,与文件直接交互;字符流使用缓冲区,需要调用close或flush方法才能将数据输出到文件。
  • 字节流采用ASCII编码,字符流采用Unicode编码。字节流在读取或写入时不会进行编码或解码操作;字符流在读取或写入时会根据指定的编码进行转换。

Java集合

ArrayList底层原理

  • JDK7:ArrayList 像饿汉式,直接创建一个初始容量为 10 的数组。

  • JDK1.8:ArrayList 像懒汉式,一开始创建一个长度为 0 的数组,当添加第一个元素时再创建一个始容量为 10 的数组,延迟了数组的创建,节省内存。

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
//无参构造器初始化elementData为{}
public ArrayList() {
this.elementData = DEFAULTCAPACITY_EMPTY_ELEMENTDATA; //{}
}
//调用add方法添加数据,ensureCapacityInternal会去确保集合容量
public boolean add(E e) {
ensureCapacityInternal(size + 1); // Increments modCount!!
elementData[size++] = e;
return true;
}
//ensureCapacityInternal调用ensureExplicitCapacity方法
private void ensureCapacityInternal(int minCapacity) {
ensureExplicitCapacity(calculateCapacity(elementData, minCapacity));
}
//calculateCapacity返回DEFAULT_CAPACITY=10
private static int calculateCapacity(Object[] elementData, int minCapacity) {
if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) {
return Math.max(DEFAULT_CAPACITY, minCapacity); //10
}
return minCapacity;
}
//ensureExplicitCapacity判断是否需要扩容
private void ensureExplicitCapacity(int minCapacity) {
modCount++;
// overflow-conscious code
if (minCapacity - elementData.length > 0)
grow(minCapacity);
}
//grow对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);
}

总结:如果此次对 ArraList 集合添加数据导致底层 elementData 数组容量不够,则扩容。默认情况下,扩容为原来的容量的 1.5 倍,同时需要将原有数组中的数据复制到新的数组中。

LinkedList底层原理

  • 双向链表,内部没有声明数组,而是定义了 Node 类型的 first 和 last, 用于记录首末元素。
  • 定义内部类 Node,作为 LinkedList 中保存数据的基本结构。Node 除了保存数据,还定义了两个变量:
    • prev 变量记录前一个元素的位置
    • next 变量记录下一个元素的位置
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
//用于记录链表首元素
transient Node<E> first;
//用于记录链表末元素
transient Node<E> last;
//底层定义Node内部类,为链表结构
private static class Node<E> {
//数据
E item;
//后指针
Node<E> next;
//后指针
Node<E> prev;

Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}
//add方法调用linkLast方法
public boolean add(E e) {
linkLast(e);
return true;
}
//linkLast方法对对链表进行组装
void linkLast(E e) {
final Node<E> l = last;
final Node<E> newNode = new Node<>(l, e, null);
last = newNode;
if (l == null)
first = newNode;
else
l.next = newNode;
size++;
modCount++;
}

总结:LinkedList 底层使用双向链表存储,对于频繁的插入、删除操作,使用此类效率比 ArrayList 高。

HashMap底层原理

HashMap 判断两个 key 相等的标准是:两个 key 哈希值相等,通过 equals () 方法返回 true。

HashMap 判断两个 value 相等的标准是:两个 value 通过 equals () 方法返回 true。

HashMap 的底层 JDK 7 和 JDK 8 的区别:

  • JDK 7:数组+链表,JDK 8:数组+链表+红黑树

    • 当数组的某一个索引位置上的元素以链表形式存在的数据个数 > 8 且当前数组的长度 > 64 时,此时此索引位置上的所数据改为使用红黑树存储。
  • JDK 7 是头插法,JDK 8 为尾插法。

  • JDK8在实例化以后,底层没创建一个长度为 16 的数组,而是在首次添加元素的时候创建。

  • JDK 8 底层的数组是:Node [],而非 Entry []。

底层实现:

  • 计算key的hash值,对应到数组下标。
  • 如果没有产生hash冲突(下标位置没有元素),则直接创建Node存入数组。
  • 如果产生hash冲突,先进行equal比较,相同则取代该元素。
  • 不同则判断链表高度插入链表,链表高度达到8,并且数组长度到64则转变为红黑树,长度低于6则将红黑树转回链表。
  • key为null,存在下标0的位置。
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
//首先调用hash方法计算出key的哈希值,然后调用putVal方法
public V put(K key, V value) {
return putVal(hash(key), key, value, false, true);
}
//主要逻辑代码,resize方法扩容,treeifyBin方法转化成红黑树
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//判断底层数组是否为空
if ((tab = table) == null || (n = tab.length) == 0)
//创建长度为16的数组
n = (tab = resize()).length;
//通过函数映射得出索引位置,并判断此位置是否为空
if ((p = tab[i = (n - 1) & hash]) == null)
//创建Node结点,添加到当前索引位置
tab[i] = newNode(hash, key, value, null);
else {
Node<K,V> e; K k;
//判断key与此索引位置的key是否相同
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
//用于红黑树的添加
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//用于遍历数组索引位置的链表
else {
//循环遍历此索引位置的Node结点
for (int binCount = 0; ; ++binCount) {
//判断下一个结点是否为空
if ((e = p.next) == null) {
//创建Node结点,以链表方法追加
p.next = newNode(hash, key, value, null);
//判断是否需要转化成红黑树
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
//判断key与遍历到此Node的key是否相同
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
//如果key与已存在的Node结点的key相同则e不为空
if (e != null) { // existing mapping for key
V oldValue = e.value;
if (!onlyIfAbsent || oldValue == null)
//替换原来的value
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
++modCount;
//判断键值对的数量是否大于扩容的临界值
if (++size > threshold)
//扩容
resize();
afterNodeInsertion(evict);
return null;
}
//Node结点
static class Node<K,V> implements Map.Entry<K,V> {
final int hash;
final K key;
V value;
//用于指向下一个元素
Node<K,V> next;
}

总结:底层也是数组,初始容量为 16,当使用率超过 0.75,(16*0.75=12) 且要存放的位置非空时,就会扩大容量为原来的 2 倍。

HashMap底层扩容机制

对红黑树的理解

红黑树是一种特殊的二叉搜索树,它的每个节点都有一个颜色属性,要么是红色,要么是黑色。红黑树需要满足以下五个性质:

  • 根节点是黑色的。
  • 每个叶子节点(空节点)是黑色的。
  • 如果一个节点是红色的,那么它的两个子节点都是黑色的。
  • 从任意一个节点到其每个叶子节点的所有路径上包含相同数目的黑色节点。
  • 任何一个空节点到根节点的路径上都有相同数目的黑色节点。

这些性质保证了红黑树在插入和删除操作后能够自动调整结构,使得没有一条路径会比最短路径长出两倍,从而实现了较好的平衡性。

Java中使用红黑树来实现TreeMap和TreeSet类,以提高查询效率。Java中使用null来代表空节点,并且不显示在遍历结果中。

Set、Map、List三种集合对比

Set、Map、List是Java中常用的三种集合类型,它们的区别和应用场景如下¹²:

  • List:有序,可重复,可以通过索引位置访问元素。适合存储有序且可能重复的数据,如学生名单、购物车等。
  • Set:无序,不可重复,不能通过索引位置访问元素。适合存储无需排序且不允许重复的数据,如身份证号、手机号等。
  • Map:无序,键不可重复,值可重复,通过键来访问值。适合存储具有映射关系的键值对数据,如学号和姓名、用户名和密码等。

以下是一些具体的例子:

  • List:
    • ArrayList:基于数组实现,查询快,增删慢。适合频繁查询的场景。
    • LinkedList:基于链表实现,查询慢,增删快。适合频繁增删的场景。
    • Vector:基于数组实现,线程安全,效率低。适合多线程环境下的场景。
  • Set:
    • HashSet:基于哈希表实现,元素无序且唯一。适合存储不需要排序的数据。
    • TreeSet:基于红黑树实现,元素有序且唯一。适合存储需要排序的数据。
    • LinkedHashSet:基于链表和哈希表实现,元素按插入顺序排列且唯一。适合存储需要保持插入顺序的数据。
  • Map:
    • HashMap:基于哈希表实现,键值对无序且键唯一。适合存储不需要排序的映射关系。
    • TreeMap:基于红黑树实现,键值对按键排序且键唯一。适合存储需要按键排序的映射关系。
    • LinkedHashMap:基于链表和哈希表实现,键值对按插入顺序排列且键唯一。适合存储需要保持插入顺序的映射关系。

Array和ArrayList的区别

  • Array可以存储基本数据类型和对象,ArrayList只能存储对象。
  • Array创建时需指定大小,而ArrayList不需要,会自动扩容。

ArrayList和LinkedList的区别

List是一个有序的集合,可以包含重复的元素,提供了按索引访问的方式,它继承Collection。

List有两个重要的实现类:ArrayList和LinkedList。

  • ArrayList的实现是基于数组,LinkedList的实现是基于双向链表。
  • 对于随机访问,ArrayList优于LinkedList,ArrayList可以根据下标以O(1)时间复杂度对元素进行随机访问。而LinkedList的每一个元素都依靠地址指针和它后一个元素连接在一起,在这种情况下,查找某个元素的时间复杂度是O(n)。
  • 对于插入和删除操作,LinkedList优于ArrayList,因为当元素被添加到LinkedList任意位置的时候,不需要像ArrayList那样重新计算大小或者是更新索引。
  • LinkedList比ArrayList更占内存,因为LinkedList的节点除了存储数据,还存储了两个引用,一个指向前一个元素,一个指向后一个元素。

HashMap和HashTable的区别

  • HashTable方法使用synchronized修饰,时线程安全的,HashMap线程不安全。
  • HashMap允许key和value为null,而HashTable不允许。

Collection和Collections的区别

Collection是集合类的上级接口,子接口有Set、List、LinkedList、ArrayList、Vector等。

Collections是集合类的一个工具类, 它包含有各种有关集合操作的静态多态方法,用于实现对各种集合的搜索、排序、线程安全化等操作。此类不能实例化,就像一个工具类,服务于Collection框架。

Java多线程

进程和线程的区别

进程是一个“执行中的程序”,是系统进行资源分配和调度的一个独立单位。

线程是进程的一个实体,一个进程中拥有多个线程,线程之间共享地址空间和其它资源(所以通信和同步等操作线程比进程更加容易)。

线程的生命周期,线程有几种状态

线程通常有五种状态,创建,就绪,运行、阻塞和死亡状态。

  • 新建状态(New):新创建了一个线程对象。

  • 就绪状态(Runnable):线程对象创建后,其他线程调用了该对象的start方法。该状态的线程位于可运行线程池中,变得可运行,等待获取CPU的使用权。

  • 运行状态(Running):就绪状态的线程获取了CPU,执行程序代码。

  • 阻塞状态(Blocked):阻塞状态是线程因为某种原因放弃CPU使用权,暂时停止运行。直到线程进入就绪状态,才有机会转到运行状态。

  • 死亡状态(Dead):线程执行完了或者因异常退出了run方法,该线程结束生命周期。

阻塞有哪几种情况

阻塞分为三种:

  • 等待阻塞:运行的线程执行wait方法,该线程会释放占用的所有资源,JVM会把该线程放入“等待 池”中。进入这个状态后,是不能自动唤醒的,必须依靠其他线程调用notify或notifyAll方法才能被唤醒。
  • 同步阻塞:运行的线程在获取对象的同步锁时,若该同步锁被别的线程占用,则JVM会把该线程放 入“锁池”中。
  • 其他阻塞:运行的线程执行sleep或join方法,或者发出了I/O请求时,JVM会把该线程置为阻塞状态。当sleep状态超时、join等待线程终止或者超时、或者I/O处理完毕时,线程重新转入就绪状态。

实现多线程有哪些方法

  • 继承Thread类
  • 实现Runnable接口
  • 实现callable接口(JDK1.5新增)
  • 使用线程池

Callable和Runnable有什么不同

实现Runnable接口需要实现run方法,实现Callable接口需要实现call方法。

  • 相比run方法,call方法可以有返回值。
  • 方法可以抛出异常。
  • 支持泛型的返回值。
  • 需要借助FutureTask类,获取返回结果。

如何停止一个正在运行的线程

  • 使用退出标志,使线程正常退出,也就是当run方法完成后线程终止。
  • 使用stop方法强行终止,但是不推荐这个方法,因为stop和suspend及resume一样都是过期作废的方法。
  • 使用interrupt方法中断线程。

notify()和notifyAll()有什么区别

notify可能导致死锁,而notifyAll不会。

notifyAll可以唤醒所有处于wait状态的线程,使其重新进入锁的争夺队列中,而notify只能唤醒一个。

sleep()和wait()有什么区别

  • sleep属于Thread类,wait属于Object类。
  • sleep方法使程序暂停指定的时间后执行,当时间到并且得到CPU的调度又会恢复运行状态。在调用sleep 方法的过程中,线程不会释放对象锁。
  • 当调用wait方法的时候,线程会放弃对象锁,进入等待此对象的等待锁定池,只有针对此对象调用notify方法或者notifyAll方法后本线程才进入对象锁定池准备,获取对象锁进入运行状态。

Thread类中的start()和run()方法有什么区别

start 方法被用来启动新创建的线程,而且start 方法内部调用了run 方法,这和直接调用run方法的效果不一样。当你调用run 方法的时候,只会是在原来的线程中调用,没有新的线程启动,start方法才会启动新线程。

Thread类中yield方法有什么作用

yield方法可以暂停当前正在执行的线程对象,让其它有相同优先级的线程执行。它是一个静态方法,而且只保证当前线程放弃CPU占用,而不能保证使其它线程一定能占用CPU,执行yield的线程有可能在进入到暂停状态后马上又被执行。

Thread类中join方法有什么作用

join方法是将指定的线程加入到当前线程,通过调用其他线程的join方法来“插队”执行,此时当前线程处于阻塞状态,直到join线程的生命周期结束。

对于synchronized关键字的了解

synchronized关键字解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

什么是线程安全

线程安全是指多线程访问同一段代码,不会产生不确定的结果,也就是说如果你的代码在多线程下执行和在单线程下执行永远都能获得一样的结果,那么你的代码就是线程安全的。

线程安全需要保证几个基本特征

  • 原子性:简单说就是相关操作不会中途被其他线程干扰,一般通过同步机制实现。
  • 可见性:是一个线程修改了某个共享变量,其状态能够立即被其他线程知晓,通常被解释为将 、线程本地状态反映到主内存上,volatile就是负责保证可见性的。
  • 有序性:是保证线程内串行语义,避免指令重排等。

哪些集合类是线程安全的

线程安全的集合有Vector、HashTable、ConcurrentHashMap、Stack、ArrayBlockingQueue、ConcurrentLinkedQueue等。

volatile关键字的作用以及和synchronied的区别

一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:

  • 保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
  • 禁止进行指令重排序。
  • volatile本质是在告诉JVM当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;synchronied则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住。
  • volatile仅能使用在变量级别;synchronied则可以使用在变量、方法、和类级别的。
  • volatile仅能实现变量的修改可见性,并不能保证原子性;synchronied则可以保证变量的修改可见性和原子性。
  • volatile不会造成线程的阻塞;synchronied可能会造成线程的阻塞。
  • volatile标记的变量不会被编译器优化;synchronied标记的变量可以被编译器优化。

锁的优化机制

从JDK1.6版本之后,synchronied本身也在不断优化锁的机制,有些情况下他并不会是一个很重量级的锁了。优化机制包括自适应锁、自旋锁、锁消除、锁粗化、轻量级锁和偏向锁。

产生死锁的四个必要条件

死锁是指多个进程因争夺资源而造成的一种僵局,导致进程无法继续执行。死锁产生的四个必要条件是:

  • 互斥条件:资源是独占的且排他使用,即任意时刻一个资源只能给一个进程使用。

  • 不可剥夺条件:进程所获得的资源在未使用完之前不能被其他进程强行剥夺,只有当进程自己释放资源时才可被其他进程使用。

  • 请求与保持条件:进程已经占有了至少一个资源,但又提出了新的资源请求,而该资源已被其他进程占有,此时请求进程阻塞,但又对自己已获得的其它资源保持不放。

  • 循环等待条件:存在一种循环等待关系,即若干个进程形成一个首尾相接的环形链,使得每个进程都在等待下一个进程所占有的资源。

如何避免死锁

避免死锁是指在资源分配过程中预防死锁的发生,通常有以下几种方法:

  • 破坏互斥条件:使用非独占的资源,比如打印机可以共享给多个进程使用。
  • 破坏不可剥夺条件:允许进程在使用完资源后主动释放,或者允许其他进程强行剥夺已分配的资源。
  • 破坏请求与保持条件:要求进程在申请资源时一次性申请所有需要的资源,或者在申请新资源时先释放已占有的资源。
  • 破坏循环等待条件:规定所有进程按照某种顺序申请资源,或者对所有资源进行编号,要求进程按照编号递增的顺序申请。

ThreadLocal的原理和使用场景

每一个 Thread 对象均含有一个 ThreadLocalMap 类型的成员变量 threadLocals ,它存储本线程中所有ThreadLocal对象及其对应的值。ThreadLocalMap 由一个个 Entry 对象构成。Entry 继承自 WeakReference<ThreadLocal<?>> ,一个 Entry 由 ThreadLocal 对象和 Object 构成。由此可见, Entry 的key是ThreadLocal对象,并且是一个弱引用。当没指向key的强引用后,该 key就会被垃圾收集器回收。

当执行set方法时,ThreadLocal首先会获取当前线程对象,然后获取当前线程的ThreadLocalMap对象。再以当前ThreadLocal对象为key,将值存储进ThreadLocalMap对象中。

get方法执行过程类似。ThreadLocal首先会获取当前线程对象,然后获取当前线程的ThreadLocalMap 对象。再以当前ThreadLocal对象为key,获取对应的value。

由于每一条线程均含有各自私有的ThreadLocalMap容器,这些容器相互独立互不影响,因此不会存在线程安全性问题,从而也无需使用同步机制来保证多条线程访问容器的互斥性。

使用场景:

  • 在进行对象跨层传递的时候,使用ThreadLocal可以避免多次传递,打破层次间的约束。
  • 线程间数据隔离。
  • 进行事务操作,用于存储线程事务信息。
  • 数据库连接,Session会话管理。

Tips:Spring框架在事务开始时会给当前线程绑定一个Jdbc Connection,在整个事务过程都是使用该线程绑定的 connection来执行数据库操作,实现了事务的隔离性。Spring框架里面就是用的ThreadLocal来实现这种隔离。

什么是多线程中的上下文切换

在上下文切换过程中,CPU会停止处理当前运行的程序,并保存当前程序运行的具体位置以便之后继续运行。从这个角度来看,上下文切换有点像我们同时阅读几本书,在来回切换书本的同时我们需要记住每本书当前读到的页码。

在程序中,上下文切换过程中”页码“信息是保存在进程控制块(PCB)中的。PCB还经常被称”切换帧“(switchframe)。”页码“信息会一直保存到CPU的内存中,直到他们被再次使用。

上下文切换是存储和恢复CPU状态的过程,它使得线程执行能够从中断点恢复执行。上下文切换是多任务操作系统和多线程环境的基本特征。

对守护线程的理解

守护线程为所有非守护线程提供服务的线程,当所有的非守护线程结束时,程序也就终止了,同时会杀死进程中的所有守护线程。反过来 说, 只要有任何非守护线程还在运行,程序就不会终止。

Tips:JVM的垃圾回收线程就是守护线程,Finalizer也是守护线程。

对进程切换的理解

  • 进程切换会暂停当前运行的进程,使其从运行状态转为就绪等其他状态。
  • 进程切换要保存当前进程的上下文。
  • 进程切换要恢复下一个进程的上下文。

多线程的使用场景

  • 常见的浏览器、Web服务,例如处理用户的请求和响应。
  • servlet多线程,例如在Tomcat中为每个请求创建一个线程。
  • FTP下载,多线程操作文件,例如同时下载多个文件或分段下载一个大文件。
  • 数据库用到的多线程,例如执行批量操作或并行查询。
  • 分布式计算,例如利用多台机器进行数据处理或算法运算。
  • 后台任务,例如定时执行一些任务或处理一些耗时的操作。
  • 异步处理,例如在不影响主流程的情况下执行一些任务或回调函数。
  • 页面异步处理,例如在Swing编程中使用多线程来更新界面或响应用户事件。

什么是分段锁

分段锁(Segment Lock)是一种并发控制机制,用于提高并发性能。它将一个数据结构分成多个段(Segment),每个段都有自己的锁。这样,在并发访问时,不同的线程可以同时访问不同的段,从而减少了竞争,提高了并发度。

Java中的ConcurrentHashMap就是使用了分段锁的数据结构。ConcurrentHashMap是线程安全的哈希表实现,它将整个数据结构分成多个段(默认为16个段),每个段都有自己的锁。这样,在并发访问时,不同的线程可以同时访问不同的段,从而提高了并发性能。

分段锁适用于以下场景:

  1. 当多个线程同时访问一个大型数据结构时,可以将其分成多个段,每个段都有自己的锁,从而提高并发度。
  2. 当数据结构的读操作远远多于写操作时,可以使用分段锁来提高读操作的并发性能,减少写操作的竞争。

需要注意的是,虽然分段锁可以提高并发性能,但在某些情况下,由于锁的竞争和同步开销,可能会导致性能下降。因此,在选择使用分段锁时,需要根据具体的场景和性能需求进行评估和测试。

线程池

什么是线程池

线程池其实就是一种多线程处理形式,处理过程中可以将任务添加到队列中,然后在创建线程后自动启动这些任务。这里的线程就是我们前面学过的线程,这里的任务就是我们前面学过的实现了Runnable或Callable接口的实例对象;

为什么使用线程池

使用线程池最大的原因就是可以根据系统的需求和硬件环境灵活的控制线程的数量,且可以对所有线程进行统一的管理和控制,从而提高系统的运行效率,降低系统运行运行压力;当然了,使用线程池的原因不仅仅只有这些,我们可以从线程池自身的优点上来进一步了解线程池的好处;

使用线程池有哪些优势

  • 降低资源消耗:通过重复利用现有的线程来执行任务,避免多次创建和销毁线程。

  • 提高响应速度:因为省去了创建线程这个步骤,所以在任务来的时候,可以立刻开始执行。

  • 提高线程的可管理性:线程池进行统一的分配、调优和监控。

  • 提供更多更强大的功能:线程池的可拓展性使得我们可以自己加入新的功能,比如说定时、延时来执行某些线程。

创建线程池有哪几种方式

在 Java 语言中,并发编程都是通过创建线程池来实现的,而线程池的创建方式也有很多种,每种线程池的创建方式都对应了不同的使用场景,总体来说线程池的创建可以分为以下两类:

  • 通过 ThreadPoolExecutor 手动创建线程池。
  • 通过 Executors 执行器自动创建线程池。

而以上两类创建线程池的方式,又有 7 种具体实现方法,这 7 种实现方法分别是:

  • Executors.newFixedThreadPool:创建一个固定大小的线程池,可控制并发的线程数,超出的线程会在队列中等待。
  • Executors.newCachedThreadPool:创建一个可缓存的线程池,若线程数超过处理所需,缓存一段时间后会回收,若线程数不够,则新建线程。
  • Executors.newSingleThreadExecutor:创建单个线程数的线程池,它可以保证先进先出的执行顺序。
  • Executors.newScheduledThreadPool:创建一个可以执行延迟任务的线程池。
  • Executors.newSingleThreadScheduledExecutor:创建一个单线程的可以执行延迟任务的线程池。
  • Executors.newWorkStealingPool:创建一个抢占式执行的线程池(任务执行顺序不确定)【JDK 1.8 添加】。
  • ThreadPoolExecutor:手动创建线程池的方式,它创建时最多可以设置 7 个参数。

ThreadPoolExecutor参数解析

1
2
3
4
5
6
7
8
9
10
11
构造方法:
public ThreadPoolExecutor(int corePoolSize, //核心线程数量
int maximumPoolSize,// 最大线程数
long keepAliveTime, // 最大空闲时间
TimeUnit unit, // 时间单位
BlockingQueue<Runnable> workQueue, // 任务队列
ThreadFactory threadFactory, // 线程工厂
RejectedExecutionHandler handler // 饱和处理机制
)
{ ... }

  • corePoolSize用于设置核心(Core)线程池数量。
    • 线程池接收到新任务,当前工作线程数少于corePoolSize, 即使有空闲的工作线程,也会创建新的线程来处理该请求,直到线程数达到corePoolSize。
  • maximumPoolSize用于设置最大线程数量。
    • 当前工作线程数多于corePoolSize数量,但小于maximumPoolSize数量,那么仅当任务排队队列已满时才会创建新线程。
    • maximumPoolSize被设置为无界值(如Integer.MAX_VALUE)时,线程池可以接收任意数量的并发任务。
  • BlockingQueue(阻塞队列)的实例用于暂时接收到的异步任务,如果线程池的核心线程都在忙,那么所接收到的目标任务缓存在阻塞队列中。
  • keepAliveTime空闲线程存活时间,当前的线程数量大于corePoolSize,那么在指定的时间后,这个空闲的线程将被销毁,这个指定的时间就是keepAliveTime。
  • unit空闲线程存活时间单位
  • threadFactory线程工厂,创建新线程的时候使用的工厂,可以用来指定线程名等等。

线程池的工作流程

线程池中线程复用原理

线程池将线程和任务进行解耦,线程是线程,任务是任务,摆脱了之前通过 Thread 创建线程时的 一个线程必须对应一个任务的限制。 在线程池中,同一个线程可以从阻塞队列中不断获取新任务来执行,其核心原理在于线程池对 Thread 进行了封装,并不是每次执行任务都会调用 Thread.start() 来创建新线程,而是让每个线程去执行一个“循环任务”,在这个“循环任务”中不停检查是否有任务需要被执行,如果有则直接执行,也就是调用任务中的 run 方法,将 run 方法当成一个普通的方法执行,通过这种方式只使用固定的线程就将所有任务的 run 方法串联起来。

JVM

JVM内存模型

JVM内存区域总共分为两种类型

  1. 线程私有区域:程序计数器、本地方法栈和虚拟机栈
  2. 线程共享区域:堆(heap)和方法区

特征

线程私有区域:依赖用户的线程创建而创建、销毁而销毁,因用户每次访问都会独立开启一个线程,跟本地的线程相对应(用白话文讲就是同生共死或朝生夕死);

线程共享区域:它是随着虚拟机的开启而创建,关闭而销毁;

名词解释

程序计数器:用户每次访问都会独立开启一个线程,程序计数器会记录每次当前执行代码的行号指示器。

本地方法栈:本地方法栈是用来区别虚拟机调用外部的执行方法,而本地方法栈则为Native修饰,那么该方法是一个C栈,但 HotSpot VM蒋本地的方法区和虚拟机栈合二为一。

虚拟机栈:当我们执行这个方法时同时会创建一个栈帧(Stack Frame)用来存储局部变量表、操作数帧、动态链接、方法出口信息;每一个方法从调用直到执行完成的过程,就对应着一个栈帧入栈到出栈的过程。

堆(heap):创建的数组和对象都放入java堆中,当然也是垃圾收集器重要回收的地方, VM主要采用分代收集算法,主要产生在新生代和老年代。

方法区:用来存储于类、运行常量池、静态的变量和编译编译后的代码等数据,java VM会把这些信息收集到方法区,即用java堆的永久代来实现方法区,这样就可以实现VM 像堆内存一样管理方法区的内存。

类加载器简介

我们编写的.java扩展名的源代码文件中存储着要执行的程序逻辑,这些文件需要经过java编译器编译成.class文件,.class文件中存放着编译后虚拟机指令的二进制信息。当需要某个类时,虚拟机将会加载它,并在内存中创建对应的Class对象,这个过程称之为类的加载。

  • 加载:将字节码文件通过IO流读取到JVM的方法区,并同时在堆中生成Class对象。
  • 验证:校验字节码文件的正确性。
  • 准备:为类的静态变量分配内存,并初始化为默认值;对于final static修饰的变量,在编译时就已经分配好内存了。
  • 解析:将类中的符号引用转换为直接引用。
  • 初始化:对类的静态变量初始化为指定的值,执行静态代码。

Java类加载器

JDK自带有三个类加载器:bootstrap ClassLoader、ExtClassLoader、AppClassLoader。

BootStrapClassLoader是ExtClassLoader的父类加载器,默认负责加载**%JAVA_HOME%/lib**下的jar包和 class文件。

ExtClassLoader是AppClassLoader的父类加载器,负责加载**%JAVA_HOME%/lib/ext**文件夹下的jar包和 class类。

AppClassLoader是自定义类加载器的父类,负责加载classpath下的类文件。系统类加载器,线程上下文加载器继承ClassLoader实现自定义类加载器。

双亲委托模型

类加载器查找class的方式叫做双亲委托模式,基本方法是:

  1. 自己先查缓存,验证类是否已加载,如果缓存中没有则向上委托父加载器查询。
  2. 父加载器接到委托也是查自己的缓存,如果没有再向上委托。
  3. 直到最顶级的BootstrapClassLoader也没在缓存中找到该类,则Bootstrap ClassLoader从他自己的加载路径中查找该类,如果找不到则返回下一级加载器。
  4. 下一级加载器也从他自己的加载路径中查找该类,如果找不到则返回下一级加载器,直到返回最开始的加载器。

简单来说,就是从下往上查缓存,然后从上往下扫描路径。如果在其中任何一步发现已经加载了该类,都会立刻返回,不再进行后面的查找。

双亲委派模型的好处:

  • 主要是为了安全性,避免用户自己编写的类动态替换 Java的一些核心类,比如 String。
  • 同时也避免了类的重复加载,因为 JVM中区分不同类,不仅仅是根据类名,相同的 class文件被不同的 ClassLoader加载就是不同的两个类。

GC如何判断对象可以被回收

  • 引用计数法:每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收。
  • 可达性分析法:从 GC Roots 开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的,那么虚拟机就判断是可回收对象。

Tips:引用计数法,可能会出现A 引用了 B,B 又引用了 A,这时候就算他们都不再使用了,但因为相互引用计数器=1 永远无法被回收。

GC回收算法有哪些

标记清除算法、复制算法、标记整理算法、分代收集算法。

标记清除算法:分为标记清除两个阶段。

  • 标记出所有需要回收的对象。
  • 回收被标记的对象所占用的内存空间。

总结:算法效率较低,会产生较多内存碎片,可能会导致大对象找不到可利用空间问题。

复制算法:按照内存容量将内存划分为相等大小的两块内存区域。每次使用时只使用其中的一块,当这一块内存满后将存活的对象复制到另一块内存区域上去,将剩余的已使用的内存回收。

总结:这种算法实现简单,内存效率高,不易产生内存碎片,但是内存使用率被压缩到了以前的一半。当存货对象增多时,复制算法的效率会大大降低。

标记整理算法:标记阶段和标记清除算法相同,但标记后不是清理对象,而是将存活的对向移至内存的一端,然后清除边界外的对象。

分代收集算法:目前JVM大部分采用的都是分代收集算法,是根据对象存活的不同生命周期将内存划分为不同的域。

总结:主要将GC堆内存划分为**新生代(Young Generation)老年代(Tenured/Old Generation)**。新生代的特点是每次垃圾回收都要大量垃圾被回收,采用的是复制算法,老年代的特点是每次进行垃圾回收时只有少数对象需要回收。

JVM对字节码文件做了什么

JVM是Java虚拟机的缩写,它是一个能够执行Java字节码的平台无关的运行时环境。Java字节码是Java编译器生成的一种中间指令集,它存储在.class文件中,包含了类的元数据和代码。JVM在执行.class文件之前,会先对字节码进行验证,以确保它是有效和安全的。然后,JVM会将字节码解释或编译为机器级别的汇编指令,并执行。

引用类型有哪些,有什么区别

引用类型主要分为强软弱虚四种:

  • 强引用指的就是代码中普遍存在的赋值方式,比如A a = new A() 这种。强引用关联的对象,永远不会被GC回收。
  • 软引用可以用SoftReference来描述,指的是那些有用但是不是必须要的对象。系统在发生内存溢出前会对这类引用的对象进行回收。
  • 弱引用可以用WeakReference来描述,他的强度比软引用更低一点,弱引用的对象下一次GC的时候一定会被回收,而不管内存是否足够。
  • 虚引用也被称作幻影引用,是最弱的引用关系,可以用PhantomReference来描述,他必须和ReferenceQueue 一起使用,同样的当发生GC的时候,虚引用也会被回收。可以用虚引用来管理堆外内存。

MySQL

MySQL中如何定位慢查询

mysql慢查询的原因:

  • 聚合查询
  • 多表查询
  • 表数据量过大查询
  • 深度分页查询

定位慢查询方式:

  • 使用慢查询日志(Slow Query Log):MySQL提供了一个慢查询日志功能,可以记录执行时间超过指定阈值的查询语句。可以通过修改MySQL配置文件中的slow_query_log参数来启用慢查询日志,并设置long_query_time参数来定义查询执行时间的阈值。慢查询日志记录了慢查询语句的执行时间、执行次数、访问时间等信息,通过分析日志可以找到慢查询的具体语句和执行时间。
1
2
3
4
# 开启MySQL慢日志查询开关
slow_query_log=1
# 设置慢日志的时间为2秒,SQL语句执行时间超过2秒,就会视为慢查询,记录慢查询日志
long_query_time=2
  • 使用MySQL性能分析工具:MySQL提供了一些性能分析工具,如Explain、Profiler等,可以分析查询的执行计划和性能瓶颈。使用EXPLAIN关键字可以获取查询语句的执行计划,包括表的访问顺序、索引使用情况等信息。Profiler工具可以用于分析查询的执行时间、CPU消耗等性能指标。
  • 使用第三方监控工具:除了MySQL自带的性能分析工具,还有一些第三方监控工具可以监控和分析MySQL的性能。例如,Percona Toolkit和pt-query-digest工具可以分析慢查询日志,找出慢查询的原因和优化建议。

SQL语句执行很慢如何分析

如果一条sql执行很慢的话,我们通常会使用mysql自动的执行计划explain来去查看这条sql的执行情况。

比如在这里面可以通过key和key_len检查是否命中了索引,如果本身已经添加了索引,也可以判断索引是否有失效的情况,第二个,可以通过type字段查看sql是否有进一步的优化空间,是否存在全索引扫描或全盘扫描,第三个可以通过extra建议来判断,是否出现了回表的情况,如果出现了,可以尝试添加索引或修改返回字段来修复。

什么是索引

索引在项目中还是比较常见的,它是帮助MySQL高效获取数据的数据结构,主要是用来提高数据检索的效率,降低数据库的IO成本,同时通过索引列对数据进行排序,降低数据排序的成本,也能降低了CPU的消耗。

索引的底层数据结构

MySQL的默认的存储引擎InnoDB采用的B+树的数据结构来存储索引,选择B+树的主要的原因是:第一阶数更多,路径更短,第二个磁盘读写代价B+树更低,非叶子节点只存储指针,叶子阶段存储数据,第三是B+树便于扫库和区间查询,叶子节点是一个双向链表。

对B+树的理解

B+树是一种树数据结构,通常用于数据库和操作系统的文件系统中。它的特点是能够保持数据稳定有序,其插入与修改拥有较稳定的对数时间复杂度。B+树的中间节点只用来索引,不保存数据,而所有的数据都保存在叶子节点中。叶子节点之间还有指针相连,方便顺序访问。

B+树的实现是基于B树的。B+树的每个节点可以有多个子节点,每个子节点对应一个索引范围。B+树的所有数据都存储在叶子节点中,叶子节点通过指针相连,形成一个有序链表。B+树的插入、删除和查找都是从根节点开始,沿着索引范围找到对应的叶子节点,然后进行相应的操作。

B+树的优点

B+树的优点有以下几个:

  • B+树可以存储更多的节点元素,因此树的高度更低,查找效率更高。
  • B+树的所有叶子节点都通过指针连接在一起,便于区间查找和遍历。
  • B+树的非叶子节点只存储索引,不存储数据,这样可以减少磁盘的读写次数。

B树和B+树的区别

  • B树和B+树都是多路搜索树。

  • 在B树中非叶子节点和叶子节点都会存放数据,而B+树的所有的数据都会出现在叶子节点,在查询的时候,B+树查找效率更加稳定。

  • 在进行范围查询的时候,B+树效率更高,因为B+树都在叶子节点存储,并且叶子节点是一个双向链表。

什么是聚簇索引和非聚簇索引

  • 聚簇索引主要是指数据与索引放到一块,B+树的叶子节点保存了整行数据,有且只有一个,一般情况下主键在作为聚簇索引的。
  • 非聚簇索引值的是数据与索引分开存储,B+树的叶子节点保存对应的主键,可以有多个,一般我们自己定义的索引都是非聚簇索引。

什么是回表查询

回表的意思就是通过二级索引(非聚簇索引)找到对应的主键值,然后再通过主键值找到聚集索引中所对应的整行数据,这个过程就是回表。

由于非聚簇索引只包含索引键和对应的聚簇索引键值,而不包含完整的行数据,因此在回表查询时,需要再次访问聚簇索引来获取完整的行数据。这个过程就称为回表查询。

回表查询会增加额外的IO操作,因为需要多次访问磁盘或内存来获取完整的行数据。如果查询的结果集较大或者频繁进行回表查询,可能会对性能产生一定的影响。因此,在设计数据库时,需要综合考虑索引的选择和查询需求,以减少回表查询的次数,提高查询性能。

什么是覆盖索引

覆盖索引是指select查询语句使用了索引,在返回的列,必须在索引中全部能够找到,如果我们使用id查询,它会直接走聚集索引查询,一次索引扫描,直接返回数据,性能高。

如果按照二级索引查询数据的时候,返回的列中没有创建索引,有可能会触发回表查询,尽量避免使用select *,尽量在返回的列中都包含添加索引的字段。

MySQL超大分页怎么处理

在数据量比较大时,如果进行limit分页查询,在查询时,越往后,分页查询效率越低。因为,当在进行分页查询时,如果执行 limit 9000000,10 ,此时需要MySQL排序前9000010 记录,仅仅返回 9000000 - 9000010 的记录,其他记录丢弃,查询排序的代价非常大 。我们可以采用覆盖索引和子查询来解决。

先分页查询数据的id字段,确定了id之后,再用子查询来过滤,只查询这个id列表中的数据就可以了,因为查询id的时候,走的覆盖索引,所以效率可以提升很多。

1
select * from tb_sku t, (select id from tb_sku order by id limit 9000000,10) a where t.id = a.id;

索引创建原则有哪些

  1. 针对于数据量较大,且查询比较频繁的表建立索引。一般表中的数据要超过10万以上。

  2. 针对于常作为查询条件(where)、排序(order by)、分组(group by)操作的字段建立索引。

  3. 尽量选择区分度高的列作为索引,尽量建立唯一索引,区分度越高,使用索引的效率越高。

  4. 如果是字符串类型的字段,字段的长度较长,可以针对于字段的特点,建立前缀索引。

  5. 尽量使用联合索引,减少单列索引,查询时,联合索引很多时候可以覆盖索引,节省存储空间,避免回表,提高查询效率。

  6. 要控制索引的数量,索引并不是多多益善,索引越多,维护索引结构的代价也就越大,会影响增删改的效率。

  7. 如果索引列不能存储NULL值,请在创建表时使用NOT NULL约束它。当优化器知道每列是否包含NULL值时,它可以更好地确定哪个索引最有效地用于查询。

什么情况下索引会失效

  1. 违反最左前缀法则
  2. 范围查询右边的列,不能使用索引
  3. 不要在索引列上进行运算操作, 索引将失效
  4. 字符串不加单引号,造成索引失效。(类型转换)
  5. 以%开头的Like模糊查询,索引失效

索引失效原理

SQL的优化的经验

sql的优化经验主要体现以下五个方面:

  • 表的设计优化

    • 比如设置合适的数值(tinyint int bigint),要根据实际情况选择
    • 比如设置合适的字符串类型(char和varchar)char定长效率高,varchar可变长度,效率稍低
  • 索引优化

    • 避免发生索引失效的问题
    • 参考索引的创建原则
  • SQL语句优化

    • SELECT语句务必指明字段名称(避免直接使用select * )
    • SQL语句要避免造成索引失效的写法
    • 尽量用union all代替union union会多一次过滤,效率低
    • 避免在where子句中对字段进行表达式操作,会产生索引失效的问题
    • Join优化 能用inner join 就不用left join right join,如必须使用 一定要以小表为驱动,内连接会对两个表进行优化,优先把小表放到外边,把大表放到里边。left join 或 right join,不会重新调整顺序
  • 主从复制、读写分离

    • 如果数据库的使用场景读的操作比较多的时候,为了避免写的操作所造成的性能影响 可以采用读写分离的架构。

      读写分离解决的是,数据库的写入,影响了查询的效率。

  • 分库分表

    • 水平分库分表
    • 垂直分库分表

事务的基本特性

事务基本特性ACID分别是:

  • 原子性:指的是一个事务中的操作要么全部成功,要么全部失败。
  • 一致性:指的是数据库总是从一个一致性的状态转换到另外一个一致性的状态。比如A转账给B100块钱, 假设A只有90块,支付之前我们数据库里的数据都是符合约束的,但是如果事务执行成功了,我们的数据库数据就破坏约束了,因此事务不能成功,这里我们说事务提供了一致性的保证。
  • 隔离性:指的是一个事务的修改在最终提交前,对其他事务是不可见的。
  • 持久性:指的是一旦事务提交,所做的修改就会永久保存到数据库中。

示例:A向B转账500,转账成功,A扣除500元,B增加500元,原子操作体现在要么都成功,要么都失败。

​ 在转账的过程中,数据要一致,A扣除了500,B必须增加500。

​ 在转账的过程中,隔离性体现在A像B转账,不能受其他事务干扰。

​ 在转账的过程中,持久性体现在事务提交后,要把数据持久化(可以说是落盘操作)。

并发事务带来的问题

  • 脏读(Drity Read):是指一个事务在读取了另一个事务尚未提交的数据时发生的读取操作。换句话说,脏读是指在一个事务中读取了另一个事务未完成的、可能会回滚的数据。

  • 不可重复读(Non-repeatable read):不可重复读是指在一个事务内,同一个查询语句在不同时间点执行,得到的结果不一致。这是因为在这个时间段内,其他事务修改了查询的数据行。例如,一个事务读取了某一行的数据,然后另一个事务修改了该行的数据并提交,第一个事务再次读取同一行时,数据已经发生了变化,导致了不一致的结果。

  • 幻读(Phantom Read):幻读是指在一个事务内,同一个查询语句在不同时间点执行,得到的结果集不一致。这是因为在这个时间段内,其他事务插入了新的数据行。例如,一个事务查询某个范围内的数据行,然后另一个事务在该范围内插入了新的数据行并提交,第一个事务再次查询同一范围时,发现结果集多了新插入的数据行,导致了不一致的结果。

事务的隔离级别

隔离性有4个隔离级别,分别是:

  • read uncommit 读未提交,可能会读到其他事务未提交的数据,也叫做脏读。
    • 用户本来应该读取到id=1的用户age应该是10,结果读取到了其他事务还没有提交的事务,结果读取结果age=20,这就是脏读。
  • read commit 读已提交,两次读取结果不一致,叫做不可重复读。
    • 不可重复读解决了脏读的问题,他只会读取已经提交的事务。
    • 用户开启事务读取id=1用户,查询到age=10,再次读取发现结果=20,在同一个事务里同一个查询读取到不同的结果叫做不可重复读。
  • repeatable read 可重复读,这是mysql的默认隔离级别,就是每次读取结果都一样,但是有可能产生幻读。
  • serializable 串行化,数据库事务隔离级别中的最高级别,也称为”Serializable”隔离级别。在这个隔离级别下,数据库会确保每个事务都在独立的时间段内执行,从而避免了并发事务之间的任何交叉操作。一般是不会使用的,他会给每一行读取的数据加锁,会导致大量超时和锁竞争的问题。

四种隔离级别分别可能会产生的问题:

隔离级别 脏读 不可重复读 幻读
Read uncommitted 未提交读
Read committed 读已提交 ×
Repeatable Read(默认) 可重复读 × ×
Serializable 串行化 × × ×

Undo Log和Redo Log的区别

undo log 和 redo log 是数据库管理系统中的两种日志记录机制,用于确保事务的原子性、持久性和一致性。

  1. Undo Log(回滚日志): Undo log用于记录事务执行过程中的数据变更,以便在回滚(Rollback)事务时可以撤销已经做出的修改。当一个事务执行更新或删除操作时,数据库会将原始数据的副本记录在undo log中,而不是直接覆盖或删除原始数据。这样,在事务回滚时,可以通过undo log还原数据,将数据库恢复到事务开始时的状态。Undo log保证了事务的原子性和一致性。
  2. Redo Log(重做日志): Redo log用于记录事务执行过程中对数据的修改,以便在崩溃恢复时可以重新执行这些修改操作,从而恢复到事务提交后的状态。当一个事务执行更新或插入操作时,数据库会将数据的变更记录在redo log中,然后将数据写入磁盘。如果在事务提交前发生崩溃,数据库可以使用redo log重新执行那些尚未写入磁盘的数据修改,确保数据的持久性。

事务中的隔离性是如何保证的(你解释一下MVCC)

事务的隔离性是由锁和mvcc实现的。

MVCC原理的核心思想是在事务读取数据时,不会直接读取数据库中的原始数据,而是读取数据的一个历史版本。这样,事务可以读取到一致性的数据视图,而不会受到其他并发事务的修改干扰。

其中MVCC全称 Multi-Version Concurrency Control 多版本并发控制。指维护一个数据的多个版本,使得读写操作没有冲突,它的底层实现主要是分为了三个部分,第一个是隐藏字段,第二个是undo log日志,第三个是readView读视图

  • 隐藏字段:在mysql中给每个表都设置了隐藏字段,有一个是trx_id(事务id),记录每一次操作的事务id,是自增的;另一个字段是roll_pointer(回滚指针),指向上一个版本的事务版本记录地址。
隐藏字段 含义
DB_TRX_ID 最近修改事务ID,记录插入这条记录或最后一次修改该记录的事务ID。
DB_ROLL_PTR 回滚指针,指向这条记录的上一个版本,用于配合undo log,指向上一个版本。
DB_ROW_ID 隐藏主键,如果表结构没有指定主键,将会生成该隐藏字段。
  • undo log主要的作用是记录回滚日志,存储老版本数据,在内部会形成一个版本链,在多个事务并行操作某一行记录,记录不同事务修改数据的版本,通过roll_pointer指针形成一个链表。

  • readView解决的是一个事务查询选择版本的问题,在内部定义了一些匹配规则和当前的一些事务id判断该访问那个版本的数据,不同的隔离级别快照读是不一样的,最终的访问的结果不一样。
    • 如果是读已提交隔离级别,每一次执行快照读时生成ReadView。
    • 如果是可重复读隔离级别仅在事务中第一次执行快照读时生成ReadView,后续复用。

ReadView中包含了四个核心字段:

字段 含义
m_ids 当前活跃的事务ID集合
min_trx_id 最小活跃事务ID
max_trx_id 预分配事务ID,当前最大事务ID+1(因为事务ID是自增的)
creator_trx_id ReadView创建者的事务ID

版本链数据访问规则:

读已提交隔离级别:

可重复读隔离级别:

MySQL主从同步原理

二进制日志(BINLOG)记录了所有的 DDL(数据定义语言)语句和 DML(数据操纵语言)语句,但不包括数据查询(SELECT、SHOW)语句。

MySQL主从复制的核心就是二进制日志,它的步骤是这样的:

  • 第一:主库在事务提交时,会把数据变更记录在二进制日志文件 Binlog 中。

  • 第二:从库读取主库的二进制日志文件 Binlog ,写入到从库的中继日志 Relay Log 。

  • 第三:从库重做中继日志中的事件,将改变反映它自己的数据。

MySQL的分库分表原理

分库分表的时机:

  1. 项目业务数据逐渐增多,或业务发展比较迅速,单表的数据量达1000W20G以后。

  2. 优化已解决不了性能问题(主从读写分离、查询索引…)

  3. IO瓶颈(磁盘IO、网络IO)、CPU瓶颈(聚合查询、连接数太多)

垂直拆分:

  • 垂直分库:以表为依据,根据业务将不同表拆分到不同库中。我们开发的微服务项目,每个微服务都对应了一个数据库,它是根据业务进行拆分的,这个其实就是垂直拆分。

    • 特点:
      1. 按业务对数据分级管理、维护、监控、扩展
      2. 在高并发下,提高磁盘IO和数据量连接数
  • 垂直分表:以字段为依据,根据字段属性将不同字段拆分到不同表中。

    • 拆分规则:

      1. 把不常用的字段单独放在一张表

      2. 把text,blob等大字段拆分出来放在附表中

    • 特点:

      1. 冷热数据分离

      2. 减少IO过渡争抢,两表互不影响

水平拆分:

  • 水平分库:将一个库的数据拆分到多个库中。

    • 特点:

      1. 解决了单库大数量,高并发的性能瓶颈问题

      2. 提高了系统的稳定性和可用性

    • 路由规则:

      1. 根据id节点取模

      2. 按id也就是范围路由,节点1(1-100万 ),节点2(100万-200万)

  • 水平分表:将一个表的数据拆分到多个表中(可以在同一个库内)。

    • 特点:

      1. 优化单一表数据量过大而产生的性能问题

      2. 避免IO争抢并减少锁表的几率

分库分表带来的问题和解决方案

分库之后的问题:

  • 分布式事务一致性问题
  • 跨节点关联查询
  • 跨节点分页、排序函数
  • 主键避重

解决方案:使用分库分表中间件

  • sharding-sphere

  • mycat

MySQL的存储引擎有哪些

MylSAM、InnoDB、BDB、MEMORY等。

InnoDB和BDB提供事务安全表,其他存储引擎都是非事务安全表。

简述MyISAM和InnoDB的区别

MyISAM:

  • 不支持事务,但是每次查询都是原子的;
  • 支持表级锁,即每次操作是对整个表加锁;
  • 不支持外键
  • 不支持MVCC(多版本并发控制)
  • 非聚簇索引
  • 存储表的总行数;
  • 一个MYISAM表有三个文件:索引文件、表结构文件、数据文件;
  • 采用非聚集索引,索引文件的数据域存储指向数据文件的指针。辅索引与主索引基本一致,但是辅索引不用保证唯一性。

InnoDb:

  • 支持ACID的事务,支持事务的四种隔离级别;
  • 支持行级锁及外键约束,因此可以支持写并发;
  • 支持MVCC
  • 聚簇索引
  • 不存储总行数;

不适合为表创建索引的情况

  • 表内数据较少。
  • 在查询数据时很少使用的列或字段。
  • 频繁更新的字段。
  • where条件中用不到的字段。
  • 有大量重复数据的列上。
  • 不建议用无序的值作为索引。
  • 包括大量nll值得字段。

MySQL索引的类型

  • 主键索引是一种唯一索引,它要求主键列的值不能重复,也不能为NULL。主键索引可以保证数据的完整性和唯一性,也可以提高查询效率。
  • 唯一索引是一种普通索引,它要求索引列的值不能重复,但可以为NULL。唯一索引可以防止数据出现重复的情况,也可以加快查询速度。
  • 普通索引是最基本的索引类型,它没有任何限制。普通索引可以加快查询速度,但是对于插入、删除、更新操作会有一定的影响。
  • 全文索引是一种特殊的索引类型,它只支持MyISAM存储引擎,并且只能创建在CHAR、VARCHAR或TEXT类型的列上。全文索引可以对文本内容进行分词,并根据分词结果建立倒排索引。全文索引适用于进行文本搜索的场景。
  • 组合索引是指由多个列组成的一个索引,它可以根据查询条件匹配其中部分或全部列。组合索引遵循最左前缀原则,即查询条件必须包含最左边的列才能利用到组合索引。

MySQL的锁有哪些

按照锁的粒度分,有以下三种:

  • 全局锁:锁定数据库中的所有表,一般用于备份或者升级数据库。
  • 表级锁:每次操作锁住整张表,包括表锁和元数据锁。
  • 行级锁:每次操作锁住对应的行数据,是最细粒度的锁,也是最常用的锁。

按照锁的级别分,有以下三种:

  • 共享锁:又称读锁,允许多个事务同时读取同一数据,但不能修改或删除。
  • 排他锁:又称写锁,只允许一个事务对数据进行修改或删除,其他事务不能读取或修改。
  • 意向锁:是一种表级别的锁,用于在行级锁之前声明事务对表的意向,以避免死锁。

除此之外,MySQL还有一些特殊的锁,如自增锁、间隙锁、记录锁、临键锁等。

数据范式

数据库范式是一种规范化数据库设计的方法,目的是减少数据冗余和异常,提高数据的完整性和一致性。

目前关系型数据库有六种范式,分别是第一范式(1NF)、第二范式(2NF)、第三范式(3NF)、巴斯-科德范式(BCNF)、第四范式(4NF)和第五范式(5NF)。

一般来说,设计数据库时只需要满足前三种范式就可以了,后面的范式较为复杂,很少使用。

第一范式要求每个字段都是不可分解的原子值,也就是说每个字段只能存储一个值,不能存储多个值或者复合值。

第二范式要求在第一范式的基础上,每个非主键字段都完全依赖于主键,而不是部分依赖。也就是说每个表只能保存一种数据,不能把多种数据混在一起。

第三范式要求在第二范式的基础上,每个非主键字段都直接依赖于主键,而不是间接依赖。也就是说每个表中不能有传递依赖关系,如果有的话,就要拆分成多个表。

SQL语句执行顺序

  1. SELECT
  2. DISTINCT
  3. FROM
  4. JOIN
  5. ON
  6. WHERE
  7. GROUP BY
  8. HAVING
  9. ORDER BY
  10. 10.LIMIT

SQL优化策略

SQL优化策略适用于数据量较大的场景下,如果数据量较小,没必要以此为准,以免画蛇添足。

一、避免不走索引的场景

  • 尽量避免在字段开头模糊查询,会导致数据库引擎放弃索引进行全表扫描。如下:
1
SELECT * FROM t WHERE username LIKE '%陈%'

优化方式:尽量在字段后面使用模糊查询。如下:

1
SELECT * FROM t WHERE username LIKE '陈%'
  • 尽量避免使用in 和not in,会导致引擎走全表扫描。如下:
1
SELECT * FROM t WHERE id IN (2,3)

优化方式:如果是连续数值,可以用between代替。如下:

1
SELECT * FROM t WHERE id between 2 and 3

JavaWeb

Web请求在Tomcat请求中的请求流程是怎样的

  1. 浏览器输入URL地址
  2. 查询本机hosts文件寻找IP
  3. 查询DNS服务器寻找真实IP
  4. 向改IP发送HTTP请求
  5. Tomcat容器解析主机名
  6. Tomcat容器解析Web应用
  7. Tomcat容器解析资源名称
  8. Tomcat容器获取资源
  9. Tomcat响应浏览器

Tomcat容器是如何创建Servlet类实例

当容器启动时,会读取在webapps目录下所有的web应用中的web.xml文件,然后对xml文件进行解析,并读取servlet注册信息。然后将每个应用中注册的servlet类都进行加载,并通过反射方式实例化。(有时候也是在第一次请求时实例化)在servlet注册时加上如果为正数,则在一开始就实例化,如果不写或为负数,则第一次请求实例化。

Redis

为什么要用缓存

使用缓存的目的就是提升读写性能。而实际业务场景下,更多的是为了提升读性能,带来更好的性能,带来更高的并发量。 Redis 的读写性能比 Mysql 好的多,我们就可以把 Mysql 中的热点数据缓存到 Redis 中,提升读取性能,同时也减轻了 Mysql 的读取压力。

为什么Redis需要把所有数据放到内存中

  • 实现最快的对数据读取,如果数据存储在硬盘中,磁盘I/O会严重影响Redis的性能。
  • Redis提供了数据持久化功能,不用担心服务器重启对内存中数据的影响。
  • 现在硬件越来越便宜的情况下,Redis的使用也被应用得越来越多, 使得它拥有很大的优势。

使用Redis有哪些好处

  • 读取速度快,因为数据存在内存中,所以数据获取快;
  • 支持多种数据结构,包括字符串、列表、集合、有序集合、哈希等;
  • 支持事务,且操作遵守原子性,即对数据的操作要么都执行,要么都不支持;
  • 还拥有其他丰富的功能,队列、主从复制、集群、数据持久化等功能。

Redis为什么设计成单线程

多线程处理会涉及到锁,并且多线程处理会涉及到线程切换而消耗CPU。采用单线程,避免了不必要的上下文切换和竞争条件。其次CPU不是Redis的瓶颈,Redis的瓶颈最有可能是机器内存或者网络带宽。

为什么Redis单线程模型效率也能这么高

  • C语言实现,效率高
  • 纯内存操作
  • 基于非阻塞的IO复用模型机制
  • 单线程的话就能避免多线程频繁的上下文切换问题

例如:bgsave 和 bgrewriteaof 都是在后台执行操作,不影响主线程的正常使用,不会产生阻塞

解释一下I/O多路复用模型

I/O多路复用是指利用单个线程来同时监听多个Socket ,并在某个Socket可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。目前的I/O多路复用都是采用的epoll模式实现,它会在通知用户进程Socket就绪的同时,把已就绪的Socket写入用户空间,不需要挨个遍历Socket来判断是否就绪,提升了性能。

其中Redis的网络模型就是使用I/O多路复用结合事件的处理器来应对多个Socket请求,比如,提供了连接应答处理器、命令回复处理器,命令请求处理器。

在Redis6.0之后,为了提升更好的性能,在命令回复处理器使用了多线程来处理回复事件,在命令请求处理器中,将命令的转换使用了多线程,增加命令转换速度,在命令执行的时候,依然是单线程。

Redis的数据结构

Redis支持五种数据类型:string(字符串),hash(哈希),list(列表),set(无序集合)及zset(有序集合)。除此之外还支持stream(流)和地理坐标等。

Redis和MySQL如何保证数据一致性

Redis和MySQL是两种不同的数据库,它们之间的数据一致性问题是指,当MySQL中的数据发生变化时,如何保证Redis中的缓存数据与MySQL中的数据保持一致。

有以下几种常见的方案:

  • 缓存过期:当MySQL中的数据发生变化时,删除或更新Redis中对应的缓存数据,让其过期失效。这样,下次访问时,就会从MySQL中读取最新的数据,并重新缓存到Redis中。这种方案简单易实现,但是可能存在短暂的不一致性,即MySQL更新后到Redis过期之间的时间窗口。
  • 双写同步:当MySQL中的数据发生变化时,同时更新或删除Redis中对应的缓存数据。这样,可以保证Redis中的数据始终与MySQL中的数据一致。这种方案需要保证双写操作的原子性和顺序性,否则可能出现数据不一致的情况。
  • 消息队列:当MySQL中的数据发生变化时,将变化的数据写入到一个消息队列中,然后由一个消费者线程从消息队列中读取数据,并更新或删除Redis中对应的缓存数据。这样,可以解决双写同步方案中的原子性和顺序性问题,但是也会增加系统的复杂度和延迟。

Redis有什么优点和缺点

优点:

  • 读写速度非常快:数据存储在内存中。
  • 支持丰富的数据结构:如String、List、Set、Sorted Set、Hash等。
  • 持久化存储:Redis提供RDB和AOF两种数据的持久化存储方案,解决了内存数据库宕机数据丢失问题。
  • 高可用:内置Redis Sentinel,提供高可用方案,实现主从故障自动转移。内置Redis Cluster,提供集群方案等。

缺点:

  • 由于 Redis 是内存数据库,所以,单台机器,存储的数据量,跟机器本身的内存大小。虽然 Redis 本身有Key过期策略,但是还是需要提前预估和节约内存。如果内存增长过快,需要定期删除数据。
  • 如果进行完整重同步,由于需要生成 RDB 文件,并进行传输,会占用主机的CPU,并会消耗网络的带宽。
  • 修改配置文件,进行重启,将硬盘中的数据加载进内存,时间比较久。在这个过程中, Redis 不能提供服务。

Redis持久化方式有哪些

Redis 提供两种持久化机制 RDB 和 AOF 机制:

  • RDB (Redis DataBase)持久化方式:在指定的时间间隔内将内存中的数据集快照写入磁盘,实际操作过程是fork一个子进程,先将数据集写入临时文件,写入成功后,再替换之前的文件,用二进制压缩存储。
  • AOF(Append only file) 持久化方式:以日志的形式记录服务器所处理的每一个写、删除操作(类似于MySQL的binglog),查询操作不会记录,以文本的方式记录,可以打开文件看到详细的操作记录。为了解决RDB不能实时持久化的问题,AOF来搞定。

RDB和AOF的优缺点

RDB优点:

  • 只有一个文件dump.rdb,方便持久化。
  • 容灾性好,方便备份。
  • Redis加载RDB恢复数据的速度远快于AOF方式。

RDB缺点:

  • RDB没法做到实时的持久化,Redis意外终止,会丢失一段时间内的数据。
  • RDB需要fork()创建子线程,属于重量级操作,可能导致Redis卡顿。

AOF优点:

  • AOF提供了三种保存策略:每秒保存、跟系统策略、每次操作保存。实时性比较高,一般来说会选择每秒保存,因此意外发生时顶多丢失一秒的数据。
  • 文件追加写形式,即使中途服务器宕机也不会破坏已经存在的内容,可以通过redis-check-aof工具解决数据一致性问题。
  • AOF机制的rewrite模式。定期对AOF文件进行重写,以达到压缩的目的。

AOF缺点:

  • AOF文件比RDB文件大,且恢复速度慢。
  • 数据集大的时候,比RDB启动效率低。
  • 运行效率没有RDB高。

Redis的过期键的删除策略

Redis是key-value数据库,我们可以设置Redis中缓存的key的过期时间。Redis的过期策略就是指当 Redis中缓存的key过期了,Redis如何处理。

  • 惰性过期:只有当访问一个key时,才会判断该key是否已过期,过期则清除。该策略可以最大化地节省CPU资源,却对内存非常不友好。极端情况可能出现大量的过期key没有再次被访问,从而不会被清除,占用大量内存。
  • 定期过期:每隔一定的时间,会扫描一定数量的数据库的expires字典中一定数量的key,并清除其中已过期的key。该策略是一个折中方案。通过调整定时扫描的时间间隔和每次扫描的限定耗时, 可以在不同情况下使得CPU和内存资源达到最优的平衡效果。

expires字典会保存所有设置了过期时间的key的过期时间数据,其中,key是指向键空间中的某个键的指针,value是该键的毫秒精度的UNIX时间戳表示的过期时间。键空间是指该Redis集群中保存的所有键。Redis中同时使用了惰性过期和定期过期两种过期策略。

什么是缓存雪崩、缓存穿透、缓存击穿

缓存雪崩:是指缓存在同一时间大面积的失效,所以,后面的请求都会落到数据库上,造成数据库短时间内承受大量请求而崩掉。

解决方案:

  • 缓存数据的过期时间设置随机,防止同一时间大量数据过期现象发生。
  • 给每一个缓存数据增加相应的缓存标记,记录缓存是否失效,如果缓存标记失效,则更新数据缓存。
  • 加锁( 最多的解决方案)或者队列的方式保证来保证不会有大量的线程对数据库一次性进行读写,从而避免失效时大量的并发请求落到底层存储系统上。
  • 缓存预热,系统上线后,将相关的缓存数据直接加载到缓存系统。这样就可以避免在用户请求的时候,先查询数据库,然后再将数据缓存的问题,用户直接查询事先被预热的缓存数据。

缓存穿透:是指缓存和数据库中都没有的数据,导致所有的请求都落到数据库上,造成数据库短时间内承受大量请求而崩掉。

解决方案:

  • 接口层增加校验,如用户鉴权校验,id做基础校验,id<=0的直接拦截等。
  • 从缓存取不到的数据,在数据库中也没有取到,这时也可以将key-value对写为key-null,缓存有 效时间可以设置短点,如30秒(设置太长会导致正常情况也没法使用)。这样可以防止攻击用户反复用同一个id暴力攻击。
  • 采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的 bitmap 中,一个一定不存在的数据 会被这个 bitmap 拦截掉,从而避免了对底层存储系统的查询压力。

缓存击穿:是指缓存中没有但数据库中有的热点数据(一般是缓存时间到期),这时由于并发用户特别多,同时读缓存没读到数据,又同时去数据库去取数据,引起数据库压力瞬间增大,造成过大压力。和缓存雪崩不同的是,缓存击穿指并发查同一条数据,缓存雪崩是不同数据都过期了,很多数据都查不到从而查数据库。

解决方案:

  • 设置热点数据永远不过期。
  • 加互斥锁。

什么是布隆过滤器

1970年有布隆提出,它实际上是一个很长的二进制向量和一系列随机映射函数。布隆过滤器可以用于检索一个元素是否在一个集合中。他的优点是空间效率和查询时间都比一般的算法要好很多,缺点是有一定的误识别率和删除困难。

Tips:当判断一定存在时,可能会误判,当判断不存在时,就一定不存在。

Redis集群的策略

主从模式:主库可以读写,并且和从库进行同步数据更新,集群可能受到某台机器的内存容量限制,所以不能支持特大数据量。

哨兵模式:这种模式在主从模式上新增了哨兵节点,一旦哨兵发现主库宕机了,就会在从库中选择一个从库作为主库,另外哨兵也可以集群,也不能解决容量上限问题。

Cluster模式:它支持多主多从,利用这种模式可以使得整个集群支持更大的容量,每个主节点可以拥有自己的多个从节点,如果主节点宕机,会从他的从节点选举新的主节点。

什么是CAP

CAP 原则指的是计算机科学中的三个基本属性:一致性(Consistency)、可用性(Availability)和分区容忍性(Partition tolerance)。这些属性是分布式系统设计中的关键考量。

  • 一致性(Consistency) 指的是在分布式系统中所有节点看到的数据都是一致的。无论客户端连接到哪个节点,都应该能够获得相同的数据副本,系统保证数据的一致性。
  • 可用性(Availability) 意味着系统应该对用户请求保持响应,并在有限的时间内返回有效的响应或结果。系统应该尽可能保持可用状态,即使部分组件出现故障。
  • 分区容忍性(Partition tolerance) 指的是系统在面对网络分区(网络中某些节点之间的通信受阻)的情况下仍能够继续运行。分布式系统的组件在网络故障或分区时能够继续运作,并且在分区解除后能够合并数据。

CAP 原则指出,在分布式系统设计中,无法同时满足这三个属性的完美状态,需要在设计中做出权衡。例如,在网络分区时要权衡是保持一致性还是保证可用性。系统设计者需要根据特定场景和需求来选择最为重要的属性。

Redis分布式锁是如何实现的

Redis 分布式锁是通过使用 Redis 的原子性操作来实现的。实现一个基本的分布式锁可以使用 Redis 的 SETNX(SET if Not eXists)命令,即当指定的键不存在时设置它的值,这个操作是原子性的。

为了防止死锁和确保锁的安全释放,需要考虑设置锁的超时时间(过期时间),避免因为持有锁的客户端异常退出而导致锁无法被释放。

实现一个健壮的分布式锁还需要考虑很多方面,例如处理锁超时、避免误解锁、锁的可重入性等情况。

我们一般会使用现有的redisson框架来实现分布式锁,底层主要是采用的是 SETNXlua脚本(保证命令集的原子性)来实现的。

Redisson实现分布式锁是如何合理的控制锁的有效时长的

在redisson的分布式锁中, 提供了一个WatchDog(看门狗)机制,一个线程获取锁成功之后(默认设置的有效时长为30秒),WatchDog会给持有锁的线程续期(默认是每隔10秒续期一次)。

在redisson的分布式锁中看门狗是如何实现的

在 Redisson 中,看门狗(Watchdog)是用于续约分布式锁的一种机制。它负责定期延长持有的分布式锁的过期时间,防止锁在持有者未及时释放的情况下过期失效。

看门狗的实现原理大致如下:

  1. 续约机制: 当客户端成功获取分布式锁后,会启动一个看门狗线程。这个线程会定期(默认是锁过期时间的1/3)向 Redis 发送续约命令,延长锁的过期时间。
  2. 心跳保持: 这个续约过程就像一个心跳保持机制,确保持有锁的客户端仍然存活并持续管理着锁。如果因为某些原因(例如客户端宕机或网络故障)导致续约失败,那么锁最终会在过期时间到达时自动释放。
  3. 自动释放锁: 如果锁的持有者未能在超时时间内完成操作并释放锁,那么即使续约操作失败,锁也会在超时后自动释放,避免长时间的死锁。

Redisson 的看门狗是通过后台线程实现的,它定期发送续约命令到 Redis 服务器,以确保持有锁的客户端能够持续更新锁的过期时间,保证分布式锁在正常情况下能够被正确地管理和释放。

Redisson分布式锁可以重入吗

可以重入,多个锁冲入需要判断是否为当前线程,在redis中进行存储的时候使用的hash结构来存储线程信息和重入的次数。

Redisson锁能解决主从数据一致性的问题吗

不能解决,但是可以使用redisson提供的红锁来解决,但是这样的性能相对来说就比较低了,如果业务非要保证数据的强一致性,建议采用zookeeper实现的分布式锁。

Redis集群有哪些方案

在Redis中提供的集群方案总共有三种:主从复制、哨兵模式、Redis分片集群

介绍一下Redis主从同步

单节点Redis的并发能力是有上限的,要进一步提高Redis的并发能力,可以搭建主从集群,实现读写分离。一般都是一主多从,主节点负责写数据,从节点负责读数据,主节点写入数据之后,需要把数据同步到从节点中。

主从同步数据的流程

主从同步分为了两个阶段,一个是全量同步,一个是增量同步。

全量同步是指从节点第一次与主节点建立连接的时候使用全量同步,流程是这样的:

  1. 从节点请求主节点同步数据,其中从节点会携带自己的replication id和offset偏移量。
  2. 主节点判断是否是第一次请求,主要判断的依据就是,主节点与从节点是否是同一个replication id,如果不是,就说明是第一次同步,那主节点就会把自己的replication id和offset发送给从节点,让从节点与主节点的信息保持一致。
  3. 同时主节点会执行bgsave,生成rdb文件后,发送给从节点去执行,从节点先把自己的数据清空,然后执行主节点发送过来的rdb文件,这样就保持了一致。
  4. 在rdb期间,如果有其他写的操作,主节点会以命令的方式记录到缓冲区(日志文件)。
  5. rdb结束之后,主节点把生成之后的命令日志发送给从节点进行同步。

增量同步

  1. 从节点请求主节点同步数据,主节点会判断不是第一次请求,就直接获取从节点的offset值。
  2. 主节点从命令日志中获取offset值之后的数据,发送给从节点进行同步。

怎么保证Redis的高并发高可用

首先可以搭建主从集群,再加上使用redis中的哨兵模式。

哨兵模式可以实现主从集群的自动故障恢复,里面就包含了对主从服务的监控、自动故障恢复、通知。

如果master故障,Sentinel会将一个slave提升为master。当故障实例恢复后也以新的master为主。

同时Sentinel也充当Redis客户端的服务发现来源,当集群发生故障转移时,会将最新信息推送给Redis的客户端,所以一般项目都会采用哨兵的模式来保证redis的高并发高可用。

Redis集群脑裂该怎么解决

由于网络等原因可能会出现脑裂的情况。

就是说,由于redis master节点和redis salve节点和sentinel处于不同的网络分区,使得sentinel没有能够心跳感知到master,所以通过选举的方式提升了一个salve为master,这样就存在了两个master,就像大脑分裂了一样。

这样会导致客户端还在old master那里写入数据,新节点无法同步数据,当网络恢复后,sentinel会将old master降为salve,这时再从新master同步数据,这会导致old master中的大量数据丢失。

解决:在redis的配置中可以设置:

  • 第一可以设置最少的salve节点个数,比如设置至少要有一个从节点才能同步数据。
  • 第二个可以设置主从数据复制和同步的延迟时间,达不到要求就拒绝请求,就可以避免大量的数据丢失。

Redis的分片集群有什么作用

分片集群主要解决的是,海量数据存储的问题。

集群中有多个master,每个master保存不同数据。

并且还可以给每个master设置多个slave节点,就可以继续增大集群的高并发能力。

同时每个master之间通过ping监测彼此健康状态,就类似于哨兵模式了。

当客户端请求可以访问集群任意节点,最终都会被转发到正确节点。

Redis分片集群中数据是怎么存储和读取的

Redis 集群引入了哈希槽的概念,有 16384个哈希槽,集群中每个主节点绑定了一定范围的哈希槽范围。

根据key的有效部分计算hash值,有效部分为如果key前面有大括号,大括号中的内容就是key的有效部分,如果没有,则key本身就是有效部分。

key通过 CRC16 校验后(key的hash值)对 16384 取模来决定放置哪个槽,通过槽找到对应的节点进行存储,取值的逻辑是一样的。

Spring

如何实现一个IOC容器

  • 配置文件中指定需要扫描的包路径定义一些注解,分别表示访问控制层、业务层、数据持久层、依赖注入、获取配置文件注解等。
  • 从配置文件中获取需要扫描的包路径,获取到当前路径下的文件信息及文件夹信息,我们将当前路径下所有以.class结尾的文件添加到一个Set集合中进行存储。
  • 遍历这个Set集合,获取在类上有指定注解的类,并将其交给IOC容器,定义一个安全的Map用来存储这些对象。
  • 遍历这个IOC容器,获取到每一个类的实例,判断里面是有有依赖其他的类的实例,然后进行递归注入。

什么是Bean

在Spring中,bean是一个被实例化,组装,并由Spring IoC容器管理的对象。 IoC容器是一个负责创建和管理bean的组件,它可以根据配置元数据来控制bean的生命周期和依赖关系。

你可以通过不同的方式来定义和配置bean,例如:

  • 使用XML文件中的<bean>标签,指定bean的类名,属性,构造器参数等。
  • 使用Java类中的@Bean注解,标注一个方法,返回一个bean对象,并指定bean的名称,作用域,初始化方法等。
  • 使用@Component注解,标注一个类,让Spring自动扫描并注册为一个bean,并指定bean的名称,作用域等。

Bean的生命周期

Bean的生命周期指的是Bean从创建到初始化再到销毁的过程,这个过程由IoC容器管理。一个Bean的生命周期主要有以下几个步骤:

  • 通过BeanDefinition获取bean的定义信息
  • 在创建bean的时候,第一步是调用构造函数实例化bean
  • 第二步是bean的依赖注入,比如一些set方法注入,像平时开发用的@Autowire都是这一步完成
  • 第三步是处理Aware接口,如果某一个bean实现了Aware接口就会重写方法执行(BeanNameAware、BeanFactoryAware、ApplicationContextAware)
  • 第四步是bean的后置处理器BeanPostProcessor,这个是前置处理器
  • 第五步是初始化方法,比如实现了接口InitializingBean或者自定义了方法init-method标签或@PostContruct
  • 第六步是执行了bean的后置处理器BeanPostProcessor,主要是对bean进行增强,有可能在这里产生代理对象
  • 最后一步是销毁bean

在这些步骤中,还可以通过实现一些特殊的接口或注解,来让Bean参与到其他Bean的生命周期中,例如:

  • BeanNameAware:让Bean获取自己在容器中的名称。
  • BeanFactoryAware:让Bean获取自己所属的容器。
  • ApplicationContextAware:让Bean获取自己所属的应用上下文。
  • BeanPostProcessor:让Bean在初始化前后执行一些额外的操作。

Spring容器在进行实例化时,会将xml配置的<bean>的信息封装成一个BeanDefinition对象,Spring根据BeanDefinition来创建Bean对象,里面有很多的属性用来描述Bean。

声明周期流程图:

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
54
55
56
57
58
import org.springframework.beans.BeansException;
import org.springframework.beans.factory.BeanFactory;
import org.springframework.beans.factory.BeanFactoryAware;
import org.springframework.beans.factory.BeanNameAware;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import javax.annotation.PreDestroy;

@Component
public class User implements BeanNameAware, BeanFactoryAware, ApplicationContextAware, InitializingBean { // 实现Aware接口

public User() {
System.out.println("User的构造方法执行了.........");
}

private String name ;

@Value("张三")
public void setName(String name) {
System.out.println("setName方法执行了.........");
}

@Override // BeanNameAware
public void setBeanName(String name) {
System.out.println("setBeanName方法执行了.........");
}

@Override // BeanFactoryAware
public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
System.out.println("setBeanFactory方法执行了.........");
}

@Override // ApplicationContextAware
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
System.out.println("setApplicationContext方法执行了........");
}

@PostConstruct
public void init() {
System.out.println("init方法执行了.................");
}

@Override // InitializingBean
public void afterPropertiesSet() throws Exception {
System.out.println("afterPropertiesSet方法执行了........");
}

@PreDestroy
public void destory() {
System.out.println("destory方法执行了...............");
}

}

BeanPostProcessor示例:

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
package net.hncj.lifecycle;

import org.springframework.beans.BeansException;
import org.springframework.beans.factory.config.BeanPostProcessor;
import org.springframework.cglib.proxy.Enhancer;
import org.springframework.cglib.proxy.InvocationHandler;
import org.springframework.stereotype.Component;

import java.lang.reflect.Method;

@Component
public class MyBeanPostProcessor implements BeanPostProcessor {

@Override // 前置
public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
// 判断bean是否为User
if (beanName.equals("user")) {
System.out.println("postProcessBeforeInitialization方法执行了->user对象初始化方法前开始增强....");
}
return bean;
}

@Override // 后置,一般用于AOP实现,创建Bean的代理对象
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
if (beanName.equals("user")) {
System.out.println("postProcessAfterInitialization->user对象初始化方法后开始增强....");
//cglib代理对象
Enhancer enhancer = new Enhancer();
//设置需要增强的类
enhancer.setSuperclass(bean.getClass());
//执行回调方法,增强方法
enhancer.setCallback(new InvocationHandler() {
@Override
public Object invoke(Object o, Method method, Object[] objects) throws Throwable {
//执行目标方法
return method.invoke(method,objects);
}
});
//创建代理对象
return enhancer.create();
}
return bean;
}

}

测试方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import cn.lrwjz.lifecycle.User;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;

@Configuration
@ComponentScan("cn.lrwjz.lifecycle") // 扫描发现并注册当前包下@Component相关注解类
public class SpringConfig {
public static void main(String[] args) {
ApplicationContext ctx = new AnnotationConfigApplicationContext(SpringConfig.class); // 加载当前配置类到Spring容器
User user = ctx.getBean(User.class); // 获取User的Bean
System.out.println(user);
}
}

输出:

1
2
3
4
5
6
7
8
9
10
11
User的构造方法执行了.........
setName方法执行了.........
setBeanName方法执行了.........
setBeanFactory方法执行了.........
setApplicationContext方法执行了........
postProcessBeforeInitialization方法执行了->user对象初始化方法前开始增强....
init方法执行了.................
afterPropertiesSet方法执行了........
postProcessAfterInitialization->user对象初始化方法后开始增强....
User的构造方法执行了.........
public java.lang.String java.lang.Object.toString()

Spring中什么是循环依赖,如何解决

循环依赖其实就是循环引用,也就是两个或两个以上的bean互相持有对方,最终形成闭环。比如A依赖于B,B依赖于A。

循环依赖在spring中是允许存在,spring框架依据三级缓存已经解决了大部分的循环依赖:

  1. 一级缓存(singletonObjects):单例池,缓存已经经历了完整的生命周期,已经初始化完成的bean对象
  2. 二级缓存(earlySingletonObjects):缓存早期的bean对象(生命周期还没走完)
  3. 三级缓存(singletonFactories):缓存的是ObjectFactory,表示对象工厂,用来创建某个对象的

具体流程:

  1. 先实例A对象,同时会创建ObjectFactory对象存入三级缓存singletonFactories
  2. A在初始化的时候需要B对象,这个走B的创建的逻辑
  3. B实例化完成,也会创建ObjectFactory对象存入三级缓存singletonFactories
  4. B需要注入A,通过三级缓存中获取ObjectFactory来生成一个A的对象同时存入二级缓存,这个是有两种情况,一个是可能是A的普通对象,另外一个是A的代理对象,都可以让ObjectFactory来生产对应的对象,这也是三级缓存的关键
  5. B通过从通过二级缓存earlySingletonObjects 获得到A的对象后可以正常注入,B创建成功,存入一级缓存singletonObjects
  6. 回到A对象初始化,因为B对象已经创建完成,则可以直接注入B,A创建成功存入一次缓存singletonObjects
  7. 二级缓存中的临时对象A清除

构造方法出现了循环依赖怎么解决

原因:A依赖于B,B依赖于A,注入的方式是构造函数,由于bean的生命周期中构造函数是第一个执行的,spring框架的三级缓存并不能解决构造函数的的依赖注入。

解决:使用@Lazy进行懒加载,什么时候需要对象再进行bean对象的创建。

1
2
3
4
public A(@Lazy B b){
System.*out*.println("A的构造方法执行了...");
this.b = b ;
}

Spring框架中的单例bean是线程安全的吗

在多用户同时请求一个服务时,Spring 容器会为每个请求分配一个线程,多个线程会并发执行对应的业务逻辑。如果单例 Bean 中存在对其成员属性的修改,就需要考虑线程同步问题。

对于不可变状态的 Bean,例如 Service 类和 DAO 类,它们通常不涉及共享的可变状态,因此在一定程度上可以被认为是线程安全的。

线程不安全解决办法:

  • 但对于那些具有可变状态的 Bean,开发人员需要自行确保线程安全,这可能需要使用 synchronized 关键字、Locks、并发容器等方式来实现。

  • 或将多态 Bean 的作用从 “singleton” 变更为 “prototype” ,也是一个有效的方法,因为每次请求都会创建一个新的实例,从而避免了线程安全问题。但需要注意,这样做可能会带来额外的资源消耗,因为每个请求都会创建一个新的对象。

什么是不可变状态的 Bean

不可变状态的 Bean 是指在其生命周期内,其成员属性(状态)不会发生变化的 Spring Bean 实例。这意味着一旦 Bean 实例被创建并初始化后,它的属性值将不会被修改。

不可变状态的 Bean 具有以下特点:

  1. 成员属性不可修改: 一旦 Bean 被创建并初始化,其成员属性的值将保持不变,无法通过公共方法进行修改。
  2. 线程安全: 由于不可变状态的 Bean 无法在初始化后修改其属性值,因此多个线程可以同时访问它,而无需考虑线程安全性问题。
  3. 简化设计和测试: 不可变状态的 Bean 更容易设计和测试,因为您不需要考虑属性值在多线程环境下的修改和同步问题。

在 Spring 框架中,通常将 Service 类和 DAO(数据访问对象)类设计为不可变状态的 Bean。这是因为这些类通常负责业务逻辑和数据访问,它们的属性值一般在初始化后不会随着请求的处理而发生变化。通过使用不可变状态的 Bean,可以提高代码的可维护性和线程安全性。

需要注意的是,并非所有的 Bean 都适合被设计为不可变状态的。某些场景下需要动态的属性变化,这时应该选择适当的线程安全机制来处理共享的可变状态。

@autowried和@resource有什么区别

  • @autowrited是Spring提供的,@resource是J2EE提供的。
  • @autowrited默认按照类型(byType)进行装配,@resource默认按照名称(byName)进行装配。
  • @autowrited要求依赖对象必须存在,除非设置required属性为false,@resource允许依赖对象不存在。
  • @autowrited可以和@qualifier配合使用,指定注入的bean的名称,@resource可以通过name属性指定注入的bean的名称。

Spring 框架中都用到了哪些设计模式

  1. 工厂模式:BeanFactory就是简单工厂模式的体现,用来创建对象的实例;
  2. 单例模式:Bean默认为单例模式。
  3. 代理模式:Spring的AOP功能用到了JDK的动态代理和CGLIB字节码生成技术;
  4. 模板方法:用来解决代码重复的问题。比如: RestTemplate, JmsTemplate, JpaTemplate。
  5. 观察者模式:定义对象键一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都会得到通知被制动更新,如Spring中listener的实现–ApplicationListener。

Spring中选择使用哪种动态代理的原则是什么

选择代理的原则:

  • 如果被代理的对象实现了至少一个接口,那么就使用JDK动态代理。被代理对象实现的所有接口都会被代理。
  • 如果被代理的对象没有实现任何接口,那么就使用CGLIB动态代理。被代理对象的子类会被创建并作为代理。

总结:这样做的原因是为了提高性能和兼容性。

  • 使用JDK动态代理可以避免引入额外的依赖,而且可以利用接口的多态性来实现灵活的代理。
  • 使用CGLIB动态代理可以避免修改原始类的代码,而且可以对没有实现接口的类进行代理。

Tips:尽管Java是单继承的,但Spring通过使用CGLIB动态代理技术,可以在运行时生成目标类的子类来实现动态代理,从而实现对目标对象的增强。

JDK动态代理和CGLIB动态的区别

JDK动态代理和CGLIB动态代理的区别主要有以下几点:

  • JDK动态代理要求被代理的对象实现一个或多个接口,而CGLIB动态代理不要求被代理的对象实现接口。
  • JDK动态代理是通过实现接口来进行代理的,而CGLIB动态代理是通过继承类来进行代理的。
  • JDK动态代理是JDK自带的功能,不需要引入外部库,而CGLIB动态代理是一个第三方库,需要引入依赖。
  • JDK动态代理只能对方法进行拦截,而CGLIB动态代理可以对方法和属性进行拦截。

JDK动态代理的原理

JDK动态代理是一种设计模式,它可以在运行时创建代理对象,来增加或修改已有类的某些功能。

  • 通过反射机制,根据指定的接口列表,动态生成一个代理类,该类实现了所有的接口。
  • 通过反射机制,创建一个代理实例,并指定一个调用处理器(InvocationHandler)。
  • 当调用代理实例的任何方法时,都会转发到调用处理器的invoke方法,并传入被代理的对象和方法参数。
  • 在调用处理器中,可以根据需要对被代理的对象和方法进行增强或修改。

什么是AOP

aop是面向切面编程,在spring中用于将那些与业务无关,但却对多个对象产生影响的公共行为和逻辑,抽取公共模块复用,降低耦合,一般比如可以做为公共日志保存,事务处理等。

项目中有没有使用到AOP

我们当时在后台管理系统中,就是使用aop来记录了系统的操作日志。还有防重复提交。

主要思路是这样的,使用aop中的环绕通知+切点表达式,这个表达式就是要找到要记录日志的方法,然后通过环绕通知的参数获取请求方法的参数,比如类信息、方法信息、注解、请求方式等,获取到这些参数以后,保存到数据库。

Spring中的事务是如何实现的

spring实现的事务本质就是aop完成,对方法前后进行拦截,在执行方法之前开启事务,在执行完目标方法之后根据执行情况提交或者回滚事务。

Spring中事务失效的场景有哪些

  1. 异常捕获处理,自己处理了异常,没有抛出。解决:手动抛出异常
  2. 抛出检查异常。解决:@Transactional注解配置rollbackFor属性为Exception
  3. 非public方法导致事务失效。解决:事务方法改为public修饰

SpringMVC

SpringMVC的执行流程

SpringMVC的执行流程是这样的(视图版本,JSP):

  1. 用户通过浏览器发起一个HTTP请求,请求被DispatcherServlet(前端控制器)拦截;
  2. DispatcherServlet调用HandlerMapping(处理器映射器);
  3. HandlerMapping根据请求的URL找到具体的Handler,并返回一个执行链(包括Handler对象和拦截器)给DispatcherServlet;
  4. DispatcherServlet调用HandlerAdapter(处理器适配器,主要解析请求参数和方法返回值)根据Handler信息执行相应的Handler(Controller);
  5. Handler执行完毕后返回给HandlerAdapter一个ModelAndView对象(包含数据模型和视图信息);
  6. HandlerAdapter将ModelAndView对象返回给DispatcherServlet;
  7. DispatcherServlet调用ViewResolver(视图解析器)根据视图信息找到对应的View(视图对象);
  8. DispatcherServlet将数据模型传给View,由View渲染出最终的页面并返回给用户。

前后端分离版本:

  1. 用户通过浏览器发起一个HTTP请求,请求被DispatcherServlet(前端控制器)拦截;
  2. DispatcherServlet调用HandlerMapping(处理器映射器);
  3. HandlerMapping根据请求的URL找到具体的Handler,并返回一个执行链(包括Handler对象和拦截器)给DispatcherServlet;
  4. DispatcherServlet调用HandlerAdapter(处理器适配器,主要解析请求参数和方法返回值)根据Handler信息执行相应的Handler(Controller);
  5. Handler(Controller)方法上加了@ResponseBody注解;
  6. 通过HttpMessageConverter来将结果转化为JSON并返回。

SpringMVC注解开发流程

使用注解开发SpringMVC的大致步骤如下:

  1. 在pom.xml中导入spring-webmvc等相关依赖;
  2. 在web.xml中配置DispatcherServlet(前端控制器)和ContextLoaderListener(上下文加载监听器),并指定SpringMVC的配置文件位置;
  3. 在SpringMVC的配置文件中,开启mvc注解驱动,自动扫描包,配置视图解析器等;
  4. 在Controller类上使用@Controller注解,表示该类是一个后端控制器;
  5. 在Controller类中的方法上使用@RequestMapping注解,表示该方法处理对应的请求路径;
  6. 在Controller类中的方法参数上使用@RequestParam、@PathVariable、@RequestBody等注解,表示该参数接收请求中的数据;
  7. 在Controller类中的方法返回值上使用@ResponseBody或者@ModelAttribute等注解,表示该返回值是响应体或者模型属性;
  8. 创建视图层页面,根据模型数据和视图信息渲染出最终的页面。

如何在Controller类中进行数据校验

在Controller类中进行数据校验的一种常用方法是使用@Validated注解:

  1. 在Controller类上添加@Validated注解,表示该类需要进行数据校验;
  2. 在Controller类中的方法参数上添加@Valid或者@Validated注解,表示该参数需要进行数据校验;
  3. 在参数对象的属性上添加相应的校验注解,如@NotBlank、@Min、@Max等,表示该属性需要满足一定的条件;
  4. 在全局异常处理器中捕获MethodArgumentNotValidException或者BindException异常,获取校验失败的信息,并返回给前端。

SpringMVC中用到的注解有哪些

Spring MVC中用到了很多注解,常见的有以下几种:

  • @Controller:标注一个类是控制器,可以接收用户的请求和返回响应。
  • @RequestMapping:定义URL请求和控制器方法之间的映射关系,可以用在类或方法上。
  • @RequestParam:绑定请求参数到控制器方法的参数上,可以指定参数名、默认值、是否必须等属性。
  • @ModelAttribute:绑定请求参数到模型对象上,可以用在方法或参数上。
  • @RequestBody:将请求体中的数据绑定到控制器方法的参数上,一般用于处理JSON或XML格式的数据。
  • @ResponseBody:将控制器方法返回的对象转换为指定格式的数据(如JSON或XML),并通过响应体返回给客户端。
  • @PathVariable:绑定路径变量到控制器方法的参数上,一般用于RESTful风格的URL。

Mybatis

什么是Mybatis

  • Mybats是一个半ORM(对象关系映射)框架,它内部封装了JDBC,开发时只需要关注SQL语句本身,不需要花费精力去处理加载驱动、创建连接、创建statement等繁杂的过程。程序员直接编写原生态SQL,可以严格控制SQL执行性能,灵活度高。

  • Mybatis可以使用XML或注解来配置和映射原生信息,将实体类映射成数据库中的记录,避免了几乎所有的JDBC代码和手动设置参数以及获取结果集。

  • 通过XML文件或注解的方式将要执行的各种statement配置起来,并通过java对象和statement中SQL的动态参数进行映射生成最终执行的SQL语句,最后由Mybatis框架执行SQL并将结果 =映射为java对象并返回。(从执行SQL到返回result的过程)。

Mybatis的优缺点

优点:

  • 基于 SQL 语句编程,相当灵活,不会对应用程序或者数据库的现有设计造成任何影响。
  • SQL 写在 XML 里,解除 sql 与程序代码的耦合,便于统一管理。
  • 提供 XML 标签, 支持编写动态 SQL 语句, 并可重用。
  • 消除了 JDBC 大量冗余的代码,不需要手动开关连接。
  • 很好的与各种数据库兼容( 因为 MyBatis 使用 JDBC 来连接数据库,所以只要JDBC 支持的数据库 MyBatis 都支持)。
  • 能够与 Spring 很好的集成。
  • 提供映射标签, 支持对象与数据库的 ORM 字段关系映射; 提供对象关系映射标签, 支持对象关系组件维护。

缺点:

  • SQL 语句的编写工作量较大, 尤其当字段多、关联表多时, 对开发人员编写SQL 语句的功底有一 定要求。
  • SQL 语句依赖于数据库,导致数据库移植性差,不能随意更换数据库。

#{}和${}的区别

#{}是预编译处理、是占位符, ${}是字符串替换、是拼接符。

Mybatis 在处理#{}时,会将 sql 中的#{}替换为?号,调用 PreparedStatement 来赋值。

Mybatis 在处理${}时, 就是把${}替换成变量的值,调用 Statement 来赋值。

#{} 的变量替换是在DBMS 中、变量替换后,#{} 对应的变量自动加上单引号。

​${} 的变量替换是在 DBMS 外、变量替换后,${} 对应的变量不会加上单引号。

使用#{}可以有效的防止 SQL 注入, 提高系统安全性。

当实体类中的属性名与数据库表中的字段名不一致怎么处理

  • 通过在查询的SQL语句中定义字段名的别名,让字段名的别名和实体类的属性名一致。
1
2
3
<select id=”selectorder” parametertype=”int” resultetype=”me.gacl.domain.order”>
select order_id id, order_no orderno ,order_price price form orders where order_id=#{id};
</select>
  • 通过resultMap映射将字段名和实体类属性名一一对应。
1
2
3
4
5
6
7
8
9
10
<select id="getOrder" parameterType="int" resultMap="orderresultmap">
select * from orders where order_id=#{id}
</select>
<resultMap type=”me.gacl.domain.order” id=”orderresultmap”>
<!–用id属性来映射主键字段–>
<id property=”id” column=”order_id” />
<!–用result属性来映射非主键字段,property为实体类属性名,column为数据表中的属性–>
<result property = “orderno” column =”order_no”/>
<result property=”price” column=”order_price” />
</resultMap>

Mybatis如何将SQL执行结果封装为目标对象并返回

通过在SQL语句设置别名对应实体类属性名或通过标签逐一映射,有了映射关系后,Mybatis通过反射创建对象,同时使用反射给对象的属性逐一赋值并返回,那些找不到映射关系的属性,是无法赋值的。

Mybatis中mapper层定义的是接口,为什么没有实现类还能调用

使用JDBC动态代理MapperProxy。本质上调用的是MapperProxy的invoke方法。

Mybatis动态SQL

Mybatis动态SQL可以在XML映射文件内,以标签的形式编写动态SQL,完成逻辑判断和动态拼接SQL的功能。

Mybatis是如何进行分页的

  • Mybatis使用RowBounds对象进行分页,也可以直接编写SQL实现分页,也可以使用Mybatis的分页插件。
  • 分页插件的原理:实现Mybatis提供的接口,实现自定义插件,在插件的拦截方法内拦截执行的SQL,然后重写SQL。

Mybatis的执行流程

  1. 读取MyBatis配置文件:mybatis-config.xml加载运行环境和映射文件
  2. 构造会话工厂SqlSessionFactory
  3. 会话工厂创建SqlSession对象(包含了执行SQL语句的所有方法)
  4. 操作数据库的接口,Executor执行器,同时负责查询缓存的维护
  5. Executor接口的执行方法中有一个MappedStatement类型的参数,封装了映射信息
  6. 输入参数映射
  7. 输出结果映射

Mybatis是否支持延迟加载

延迟加载的意思是:就是在需要用到数据时才进行加载,不需要用到数据时就不加载数据。

Mybatis支持一对一关联对象和一对多关联集合对象的延迟加载

在Mybatis配置文件中,可以配置是否启用延迟加载lazyLoadingEnabled=true|false,默认是关闭的

延迟加载的底层原理

延迟加载在底层主要使用的CGLIB动态代理完成的

第一是,使用CGLIB创建目标对象的代理对象,这里的目标对象就是开启了延迟加载的mapper

第二个是当调用目标方法时,进入拦截器invoke方法,发现目标方法是null值,再执行sql查询

第三个是获取数据以后,调用set方法设置属性值,再继续查询目标方法,就有值了

Mybatis一二级缓存

MyBatis 的一级缓存和二级缓存是两种不同的缓存机制,它们的区别如下:

  • 一级缓存是 SqlSession 级别的缓存,它是默认开启的,在操作数据库时需要构造 SqlSession 对象,在对象中有一个数据结构(HashMap)用于存储缓存数据。不同的是 SqlSession 之间的缓存数据区(HashMap)是互相不影响,只在一个 SqlSession 内部有效。当执行相同的 SQL 查询时,会直接从一级缓存中获取结果,而不会再次发送 SQL 命令。当 SqlSession 提交、关闭或者执行更新操作时,一级缓存会清空。
  • 二级缓存是 mapper 级别的缓存,它需要手动开启,在多个 SqlSession 之间共享。当执行相同的 SQL 查询时,如果一级缓存中没有结果,会从二级缓存中获取结果,并将结果放入一级缓存中。多个 SqlSession 去操作同一个 Mapper 的 SQL 语句,多个 SqlSession 可以共用二级缓存,二级缓存是跨 SqlSession 的。当 SqlSession 提交、关闭或者执行更新操作时,二级缓存不会清空,但会根据配置进行刷新。

对sqlsession的理解

SqlSession是MyBatis的关键对象,它是执行持久化操作的独享,类似于JDBC中的Connection。它是应用程序与持久层之间执行交互操作的一个单线程对象,也是MyBatis执行持久化操作的关键对象。SqlSession对象完全包含以数据库为背景的所有执行SQL操作的方法,它的底层封装了JDBC连接,可以用SqlSession实例来直接执行被映射的SQL语句。

Mapper接口则是一种更高级别、更方便、更面向对象的方式来使用MyBatis。Mapper接口可以通过SqlSession获取,并且可以直接调用定义在Mapper接口中的方法来执行SQL语句。

两者都可以用来发送SQL语句,但笔者建议采用SqlSession获取Mapper接口的方式。这样做可以消除SqlSession带来的功能性代码,并且使得代码更加清晰易读。

  • sqlsession对象是mybatis框架中的一个核心对象,它是一个接口,定义了操作数据库的基本方法,如查询、插入、更新和删除等。
  • sqlsession对象由SqlSessionFactory对象创建,每个线程都应该有自己的sqlsession实例,并且在使用完毕后及时关闭。
1
2
3
4
5
6
7
8
9
10
11
// 获取SqlSession对象
SqlSession session = sqlSessionFactory.openSession();
try {
// 获取Mapper接口
BlogMapper mapper = session.getMapper(BlogMapper.class);
// 执行查询操作
Blog blog = mapper.selectBlog(101);
} finally {
// 关闭SqlSession
session.close();
}

可以通过SqlSession的getMapper方法来获取一个Mapper接口,然后调用它的方法来执行SQL语句。

MyBatis会根据XML文件或者接口注解定义的SQL,通过“类的全限定名+方法名”查找对应的SQL语句,并启用对应的SQL进行运行,并返回结果。

JDBC编程有哪些步骤

  • 装载相应的数据库的JDBC驱动并进行初始化
1
Class.forName("com.mysql.jdbc.Driver");
  • 建立JDBC和数据库之间的Connection连接
1
Connection c = DriverManager.getConnection("jdbc:mysql://127.0.0.1:3306/test?characterEncoding=UTF-8", "root", "123456");
  • 创建Statement或者PreparedStatement对象,执行SQL语句。
  • 处理和显示结果。
  • 释放资源。

SpringBoot

SpringBoot、SpringMVC 和 Spring 有什么区别

  • spring是一个IOC容器,用来管理Bean,使用依赖注入实现控制反转,可以很方便的整合各种框架,提供AOP机制弥补OOP的代码重复问题、更方便将不同类不同方法中的共同处理抽取成切面、自动注入给方法执行,比如日志、异常等。

  • springmvc是spring对web框架的一个解决方案,提供了一个总的前端控制器Servlet,用来接收请求, 然后定义了一套路由策略(url到handle的映射)及适配执行handle,将handle结果使用视图解析技术生成视图展现给前端。

  • springboot是spring提供的一个快速开发工具包,让程序员能更方便、更快速的开发spring+springmvc 应用,简化了配置(约定了默认配置),整合了一系列的解决方案(starter机制)、redis、 mongodb、es,可以开箱即用。

什么是嵌入式服务器

在传统的JavaWeb或SSM项目应用需要打包成war包,然后放到Tomcat的webapp目录下运行,需要在下载Tomcat服务器才能运行。

在SpringBoot项目中,只需要打包成jar包,安装了 Java 的虚拟机就可以直接在上面部署应用程序了, springboot已经内置了tomcat.jar,运行main方法时会去启动tomcat,并利用tomcat的spi机制加载 springmvc。

SpringBoot的缺点

SpringBoot是一个基于Spring框架的开发工具,它可以快速构建和运行Spring应用程序,简化了配置和依赖管理。SpringBoot的缺点有:

  • 将现有或传统的Spring项目转换为Spring Boot应用程序是一个非常困难和耗时的过程,它仅适用于全新Spring项目。
  • 集成度较高,使用过程中不太容易了解底层框架的复杂性。

SpringBoot自动配置原理

在SpringBoot项目中的启动类上有一个注解@SpringBootApplication,这个注解是对三个注解进行了封装,分别是:

  • @SpringBootConfiguration:该注解与 @Configuration 注解作用相同,用来声明当前也是一个配置类。
  • @ComponentScan:组件扫描,默认扫描当前启动类所在包及其子包。
  • @EnableAutoConfiguration:SpringBoot实现自动化配置的核心注解。

该注解通过@Import注解导入对应的配置选择器。关键的是内部就是读取了该项目和该项目引用的Jar包的的classpath路径下META-INF/spring.factories文件中的所配置的类的全类名。

在这些配置类中所定义的Bean会根据条件注解所指定的条件来决定是否需要将其导入到Spring容器中。

一般条件判断会有像@ConditionalOnClass这样的注解,判断是否有对应的class文件,如果有则加载该类,把这个配置类的所有的Bean放入spring容器中使用。

例:RedisAotuConfiguration

Spring、SpringMvc、SpringBoot常用注解

Spring相关注解:

注解 说明
@Component、@Controller、@Service、@Repository 使用在类上用于实例化Bean
@Autowired 使用在字段上用于根据类型依赖注入
@Qualifier 结合@Autowired一起使用用于根据名称进行依赖注入
@Scope 标注Bean的作用范围
@Configuration 指定当前类是一个 Spring 配置类,当创建容器时会从该类上加载注解
@ComponentScan 用于指定 Spring 在初始化容器时要扫描的包
@Bean 使用在方法上,标注将该方法的返回值存储到Spring容器中
@Import 使用@Import导入的类会被Spring加载到IOC容器中
@Aspect、@Before、@After、@Around、@Pointcut 用于切面编程(AOP)

SpringMvc相关注解:

注解 说明
@RequestMapping 用于映射请求路径,可以定义在类上和方法上。用于类上,则表示类中的所有的方法都是以该地址作为父路径
@RequestBody 注解实现接收http请求的json数据,将json转换为java对象
@RequestParam 指定请求参数的名称
@PathViriable 从请求路径下中获取请求参数(/user/{id}),传递给方法的形式参数
@ResponseBody 注解实现将controller方法返回对象转化为json对象响应给客户端
@RequestHeader 获取指定的请求头数据
@RestController @Controller + @ResponseBody

SpringBoot相关注解:

注解 说明
@SpringBootConfiguration 组合了- @Configuration注解,实现配置文件的功能
@EnableAutoConfiguration 打开自动配置的功能,也可以关闭某个自动配置的选
@ComponentScan Spring组件扫描

Git

Maven

RabbitMQ

RabbitMQ如何确保消息发送和接收

RabbitMQ提供了两种机制来确保消息发送和接收的可靠性,分别是发送方确认机制和消费方确认机制。

发送方确认机制:

  • 信道需要设置为confirm模式,即调用channel.confirmSelect()方法;
  • 所有在信道上发布的消息都会分配一个唯一ID;
  • 一旦消息被投递到queue(可持久化的消息需要写入磁盘),信道会发送一个确认给生产者(包含消息唯一ID);
  • 如果RabbitMQ发生内部错误从而导致消息丢失,会发送一个nack给生产者(包含消息唯一ID);
  • 生产者可以通过监听信道上的ack和nack事件来处理成功或失败的消息。

消费方确认机制:

  • 消费者在订阅队列时,需要设置autoAck为false,即关闭自动确认;
  • 消费者在收到消息并处理完后,需要手动调用channel.basicAck()方法来发送确认给RabbitMQ;
  • 如果消费者无法处理消息或发生异常,可以调用channel.basicNack()或channel.basicReject()方法来拒绝或重新入队消息;
  • RabbitMQ会根据消费者的确认或拒绝来更新队列中的消息状态,如果超时未收到确认,会将消息重新分发给其他消费者。

你可以根据你的业务需求和场景选择合适的确认机制来保证消息发送和接收的可靠性。

ElasticSearch

Linux

Docker

Nginx

SpringCloud

Vue

设计模式

设计模式分类

设计模式是一种解决软件开发中常见问题的经验总结,可以提高代码的可读性、可扩展性、可复用性等特性。

设计模式分为三种类型,共23种:

  • 创建型模式:关注于对象的创建过程,如单例模式、抽象工厂模式、建造者模式、工厂模式、原型模式。
  • 结构型模式:关注于对象的组合和依赖关系,如适配器模式、桥接模式、装饰模式、组合模式、外观模式、享元模式、代理模式。
  • 行为型模式:关注于对象的行为和交互,如观察者模式、命令模式、策略模式、责任链模式、迭代器模式、中介者模式、备忘录模式、解释器模式、状态模式、访问者模式 。

设计模式的六大原则

设计模式的六大原则是指一些编程规范,用来指导设计模式的应用,使得代码更加优雅、高效和易于维护。

设计模式六大原则有:

  • 单一职责原则:一个类或者一个方法只负责一项职责。
  • 开闭原则:对扩展开放,对修改关闭。即在不修改原有代码的基础上增加新的功能。
  • 里氏替换原则:子类可以替换父类,并且保持父类的功能和特性。
  • 迪米特法则:一个对象应该尽可能少地与其他对象发生相互作用。
  • 接口隔离原则:使用多个专门的接口,而不使用单一的总接口,避免出现臃肿的接口。
  • 依赖倒置原则:高层模块不应该依赖低层模块,两者都应该依赖抽象。抽象不应该依赖细节,细节应该依赖抽象。

23种设计模式概述

  1. 简单工厂模式(Simple Factory Pattern):一个工厂对象负责根据传入的参数创建不同的对象实例。
  2. 工厂方法模式(Factory Method Pattern):每个具体的类都有自己的工厂方法,负责创建对象实例。
  3. 抽象工厂模式(Abstract Factory Pattern):一组相关或相互依赖的对象,由一个抽象工厂对象负责创建。
  4. 单例模式(Singleton Pattern):确保类只有一个实例,并提供全局访问点。
  5. 建造者模式(Builder Pattern):将一个复杂对象的构建过程分解为多个简单对象的构建过程。
  6. 原型模式(Prototype Pattern):一种创建型设计模式,它通过克隆已有对象来创建新的对象,而不是通过实例化对象来创建。
  7. 适配器模式(Adapter Pattern):将一个类的接口转换成客户希望的另一个接口。
  8. 装饰器模式(Decorator Pattern):动态地为对象增加新的功能。
  9. 代理模式(Proxy Pattern):为其他对象提供一种代理以控制对这个对象的访问。
  10. 外观模式(Facade Pattern):为复杂的子系统提供一个简单的接口。
  11. 桥接模式(Bridge Pattern):将抽象部分与它的实现部分分离,使它们都可以独立地变化。
  12. 组合模式(Composite Pattern):将对象组合成树形结构以表示部分-整体的层次结构。
  13. 享元模式(Flyweight Pattern):通过共享技术来实现大量细粒度对象的复用。
  14. 策略模式(Strategy Pattern):定义一系列算法,将它们封装起来,并且使用他们可以相互替换。
  15. 模板方法模式(Template Method Pattern):定义一个算法的骨架,将一些步骤延迟到子类中实现。
  16. 观察者模式(Observer Pattern):定义对象间的一种一对多的依赖关系,当一个对象状态发生改变时,所有依赖于它的对象都得到通知并自动更新。
  17. 迭代器模式(Iterator Pattern):提供一种方法顺序访问一个聚合对象中各个元素,而且又不暴露该对象的内部表示。
  18. 责任链模式(Chain of Responsibility Pattern):为某个请求创建一个对象链,处理该请求的对象沿着该练依次处理,知道一个对象处理它为止。
  19. 命令模式(Command Pattern):将一个请求封装成一个对象,使发出请求的责任和执行请求的责任分割开。
  20. 备忘录模式(Memento Pattern):它允许在不暴露对象实现细节的情况下,保存和恢复对象之前的状态。
  21. 状态模式(State Pattern):允许对象在其内部状态改变时改变它的行为。
  22. 访问者模式(Visitor Pattern):封装一些作用于某种数据结构中的各元素的操作,它可以在不改变这个数据结构的前提下定义作用于这些元素的新操作。
  23. 中介者模式(Mediator Pattern):通过封装一系列对象之间的交互,来降低对象之间的耦合度。

排序算法

选择排序

插入排序

冒泡排序

快速排序

堆排序

希尔排序

归并排序

计算机基础

tcp的三次握手和四次挥手

tcp是传输控制协议,它是一种面向连接的协议,意味着在传输数据之前,必须建立并确认一个可靠的连接。

三次握手是建立连接的过程,它涉及到三个步骤:

  1. 客户端向服务器发送一个同步报文(SYN),表示客户端想要建立连接,并告诉服务器客户端的初始序列号(ISN)。
  2. 服务器收到SYN后,回复一个同步确认报文(SYN-ACK),表示服务器也同意建立连接,并告诉客户端服务器的初始序列号(ISN)和对客户端ISN加一后的确认号(ACK)。
  3. 客户端收到SYN-ACK后,回复一个确认报文(ACK),表示客户端已经收到了服务器的ISN和ACK,并告诉服务器对服务器ISN加一后的确认号(ACK)。这样,双方就完成了三次握手,并建立了一个双向通信的连接。

四次挥手是终止连接的过程,它涉及到四个步骤:

  1. 客户端向服务器发送一个结束报文(FIN),表示客户端已经发送完所有数据,并请求关闭连接。
  2. 服务器收到FIN后,回复一个确认报文(ACK),表示已经收到了客户端的FIN,并告诉客户端对客户端序列号加一后的确认号(ACK)。此时,客户端进入等待状态,等待服务器发送自己的FIN。
  3. 服务器在发送完所有数据后,向客户端发送一个结束报文(FIN),表示也想要关闭连接,并告诉客户端自己的序列号和对客户端ACK加一后的确认号(ACK)。
  4. 客户端收到FIN后,回复一个确认报文(ACK),表示已经收到了服务器的FIN,并告诉服务器对服务器序列号加一后的确认号(ACK)。此时,双方就完成了四次挥手,并终止了连接。但是由于网络延迟或其他原因,最后一个ACK可能会丢失或延迟到达。为了防止这种情况发生,在发送最后一个ACK之后,客户端会启动一个定时器,在定时器超时之前不会关闭套接字。如果在定时器超时之前收到了重复的FIN,则重新发送最后一个ACK。如果在定时器超时之前没有收到任何消息,则认为最后一个ACK已经成功送达,并关闭套接字。

项目总结

酒店订购平台搜索系统

RBAC权限设计思想:

RBAC(Role-Based Access Control)是一种基于角色的访问控制模型,它将用户、角色和权限三者分开,通过给用户分配角色,给角色分配权限,来实现对系统资源的访问控制。

RBAC的优点是:

  • 简化了权限的管理,只需要维护用户和角色、角色和权限的关系,而不需要为每个用户单独设置权限;
  • 提高了权限的复用性,可以根据不同的业务需求和场景,灵活地组合角色和权限;
  • 支持了最小权限原则、责任分离原则和数据抽象原则,提高了系统的安全性和可维护性。

动态控制页面操作按钮的显示和隐藏:

  • 后端通过SpringSecurity的登录成功处理器,查询数据库,遍历该用户的所有的角色,获取角色所具有的权限,将当前用户分配角色所具有的权限字符通过StringBuilder拼接成一个字符串,然后转化成数组响应给前端。

  • 前端vue通过store的vuex功能将该权限字符串存储到浏览器的SessionStorage缓存中,然后在前端会写一个需要传入权限字符参数,并遍历SessionStorage中的权限字符串的js方法,如果SessionStorage的权限字符串包含该字符,就会返回ture,否则返回false。

  • 在页面的操作按钮通过v-if中调用js方法并传入相对于的权限字符,如果有权限就会显示该按钮,否则不显示。

后台数据更新,通过RabbitMQ同步到ElasticSearch:

  • 该搜索系统分为后端管理系统和前台搜索系统,然后后端管理系统主要操作Mysql数据库对酒店数据进行维护,前台搜索页面的酒店数据是存储在ElasticSearch搜索引擎中,当后端管理系统对酒店数据进行更新操作的时候,需要将酒店数据进行同步更新到ElasticSearch中。
  • 当后端管理系统新增、修改和删除酒店数据的时候,会向RabbitMQ发送一条消息,消息所携带的就是该酒店数据的id。
  • 前台搜索系统会监听MQ发布的消息,当有消息的时候,会获取消息内容的酒店数据id,并同步到ElastcSearch中。

Tips:减少业务代码的耦合,避免在一个服务中去调用另一个服务的接口去同步数据。

数据聚合和自动补全:

  • 数据聚合功能主要利用了ElasticSearch中的Bucket聚合,对搜索结果中的文档基于品牌分组、基于城市分组,就能得知包含哪些品牌、哪些城市了。然后通过Map<key, List<String>>响应给搜索页面,就可以在页面显示出聚合搜索条件了。因为是对搜索结果聚合,因此聚合是限定范围的聚合,也就是说聚合的限定条件跟搜索文档的条件一致。
  • 自动补全功能主要利用了ElasticSearch的IK中文分词器,将酒店文档数据进行分词,建立倒排索引并存储,然后用户在搜索页面输入词条的时候,会携带词条向后台发送请求,查询出对应的数据并列举在搜索框下面,用户可以点击补全的内容进行检索。

酒店竞价排名和检索周边酒店:

  • 酒店竞价排名是通过给酒店添加是否为广告的字段,然后在过滤条件中就可以根据这个标记来判断,是否要提高算分。是有标记的酒店就会通过算分函数增加它的weight权重值,大大提高广告酒店的算分,就实现了广告酒店排名靠前的功能。
  • 检索周边酒店主要是用户向后台发送自己当前坐标的经纬度,后台通过ElasticSearch的地理坐标排序检索出周边范围的酒店数据响应给前端。

项目遇到的问题以及解决方案:

  • 菜单数据转化成树形结构数据响应给前端。

    • 一个用户可以有多个角色,然后不同的角色所拥有的权限菜单数据不同,都是多对多的关系,所以我先通过用户和角色关联表查询出该用户所拥有的角色集合,再遍历角色集合通过角色和权限菜单关联表查询出所有的权限菜单集合,但不同角色的权限菜单会有相同的,所以我就通过一个set集合把权限菜单集合数据进行去重。
    • 构建菜单树主要是数据库字段有个parent_id,这个字段指向的就是自己的父亲菜单,通过双层for循环把菜单集合数据进行树化。
  • 动态控制页面操作按钮的显示和隐藏。

    • 后端通过SpringSecurity的登录成功处理器,查询数据库,遍历该用户的所有的角色,获取角色所具有的权限,将当前用户分配角色所具有的权限字符通过StringBuilder拼接成一个字符串,然后转化成数组响应给前端。

    • 前端vue通过store的vuex功能将该权限字符串存储到浏览器的SessionStorage缓存中,然后在前端会写一个需要传入权限字符参数,并遍历SessionStorage中的权限字符串的js方法,如果SessionStorage的权限字符串包含该字符,就会返回ture,否则返回false。

    • 在页面的操作按钮通过v-if中调用js方法并传入相对于的权限字符,如果有权限就会显示该按钮,否则不显示。

小区宅急送

项目遇到的问题以及解决方案:

  • 在用户注册的时候我会对用户密码进行md5加密存储在数据库中,然后当用户登录的时候需要校验用户的密码是否正确,因为md5加密的数据是不可逆的,所以在这块遇到了一点难题。

    • 先通过登录的用户名去数据库查询数据,如果有数据,就对登录请求的用户密码也进行md5加密,然后通过equal进行比较,如果然会true就登录证明密码是正确的。
  • 小区宅急送项目在设计购物车表的时候遇到了一些逻辑上的难题,就是用户增减购物车商品的时候如何进行数据的处理。

    • 用户将商品加入购物车时,我首先会去查询当前用户的当前商品是否已经在购物车,这样就需要在购物车表中关联用户id和商品id,如果商品已经在购物车了,只需要对其数量进行加1,如果不在购物车就新增一条购物车数据,并且数量设置为1。在删减购物车商品的时候,也需要先获取购物车商品的数量,如果大于1就直接在数量上减1,否之直接删除购物车记录,防止出现购物车商品数量为负数的情况,清空购物车就可以直接通过用户id进行删除。
  • 因为项目是前后端分离开发的,所以vue项目和后台接口项目的端口是不一样的,然后因为浏览器的同源策略,会出现跨域问题,请求会被浏览器拦截。

    • 在vue项目中添加代理,当发送请求的时候会将请求代理成和后端一样的地址和端口号,就不会产生跨域访问了。
    • 在后端项目中重写WebMvcConfig的跨域配置(addCorsMappings),添加允许所有请求来源和请求方式进行跨域访问。
  • 在项目开发的时候,虽然在前端对表单进行了校验处理(如果校验不通过就不会向后端发送请求),但无法防止用户通过postman、jmeter等接口请求工具去对后端的接口发送请求,如果后端不进行数据校验的话就会破坏数据库的完整性。

    • 之前是在业务层对请求数据进行校验,手写校验逻辑繁琐并且冗余,影响代码的可读性,所以我就使用JSR303对后端数据进行校验,只需要在实体类上添加校验注解(@NotNull、@Email、@Pattern)并设置校验失败的响应信息,然后在controller层的方法参数上加上@validated注解就可以了,当校验失败就会在controller层抛出异常,定义一个全局异常捕获器进行处理,响应出去。
  • 在设计上也参考了网上一些优秀的设计方案,前后端分离项目中后端接口主要响应的是状态码、json数据和信息,我会封装一个通用结果返回类,这个类包括code、msg、data三个属性,每次给前端响应的都是这个封装好的通用结果返回类。

面试技巧