- java面经guid
基础
基础概念与常识
java特点
1、java语言是面向对象的
2、Java语言是健壮的。Java 的强类型机制、异常处理、垃圾的自动收集等是 Java 程序健壮性的重要保证
3、Java语言是跨平台性的[即:一个编译好的class文件可以在多个系统下运行,这种特性称为跨平台 能够实现跨平台性的原因是Java底层有一个机制叫Java虚拟机(JVM),在java中数据类型具有固定大小
4、Java语言是解释型的
解释性语言:javascript,PHP,java
编译性语言:c/c++
区别是:解释性语言,编译后的代码,不能直接被机器执行,需要解释器来执行,编译性语言,编译后的代码可以直接被机器执行
正因为Java是解释型的,因此对于关建的应用程序速度太慢了。
早期的Java是解释型的。现在除了像手机这样的 “微型” 平台外,jvm采用即时编译,因此采用Java编写的 ‘热点’ 代码,其运行速度与c++相差无几。
5、简单易学。语法简单,上手容易
6、java语言支持多线程
7、java语言是高效的
java为什么是跨平台的
Java 能支持跨平台,主要依赖于 JVM 。编写的Java源码,编译后会生成一种 .class 文件,称为字节码文件。Java虚拟机可以将字节码文件翻译成特定平台下的机器码然后运行。实现了”一次编译,到处运行“
JVM、JDK、JRE三者关系?
JDK全称(Java开发工具包)
JDK=JRE+jave开发工具[java,javac,javadoc,javap等]
JDK是提供给Java开发人员使用的,其中包含了java的开发工具,也包括了JRE。所以安装了JDK就不用再单独安装JRE了
JRE简介
JRE(java运行环境)
JRE=JVM+Java核心类库(类)
包括Java虚拟机(JVM)和Java程序所需的核心类库等,
如果想要运行一个开发好的Java程序,计算机中只需要安装JRE即可
为什么Java解释和编译都有?
Java的编译过程是将源代码转换成字节码,而解释过程是JVM将这些字节码转换成机器码来执行。这种混合模式使得Java既能够跨平台运行,又能在运行时优化性能。
编译型语言和解释型语言的区别
编译型语言在运行前就转换成了机器码,执行速度快,但跨平台性差;而解释型语言在运行时才转换成机器码,执行速度慢,但跨平台性好。
javase、javaee、javame
javase:java标准版,可以用来构建一些简单的桌面应用程序或者简单的服务器应用程序
javaee:java企业版,用于构建企业级项目
javame:java卫微型版,用于构建嵌入式消费的电子设备的应用程序
Java 和 C++ 的区别?
Java 和 C++ 都是面向对象的语言,都支持封装、继承和多态,但是,它们还是有挺多不相同的地方:
Java 不提供指针来直接访问内存,程序内存更加安全
Java 的类是单继承的,C++ 支持多重继承;虽然 Java 的类不可以多继承,但是接口可以多继承。
Java 有自动内存管理垃圾回收机制(GC),不需要程序员手动释放无用内存。
C ++同时支持方法重载和操作符重载,但是 Java 只支持方法重载(操作符重载增加了复杂性,这与 Java 最初的设计思想不符)。
基本数据类型
八种基本的数据类型
Java支持数据类型分为两类: 基本数据类型和引用数据类型。
基本数据类型共有8种,可以分为三类:
数值型:整数类型(byte、short、int、long)和浮点类型(float、double)
字符型:char
布尔型:boolean
8种基本数据类型的默认值、位数、取值范围,如下表所示:
Float和Double的最小值和最大值都是以科学记数法的形式输出的,结尾的“E+数字”表示E之前的数字要乘以10的多少倍。比如3.14E3就是3.14×1000=3140,3.14E-3就是3.14/1000=0.00314。
注意一下几点:
java八种基本数据类型的字节数:1字节(byte、boolean)、 2字节(short、char)、4字节(int、float)、8字节(long、double)
浮点数的默认类型为double(如果需要声明一个常量为float型,则必须要在末尾加上f或F)
整数的默认类型为int(声明Long型在末尾加上l或者L)
八种基本数据类型的包装类:除了char的是Character、int类型的是Integer,其他都是首字母大写
char类型是无符号的,不能为负,所以是0开始的
#long和int可以互转吗 ?
可以的,Java中的long
和int
可以相互转换。由于long
类型的范围比int
类型大,因此将int
转换为long
是安全的,而将long
转换为int
可能会导致数据丢失或溢出。
将int
转换为long
可以通过直接赋值或强制类型转换来实现。例如:
int intValue = 10;
long longValue = intValue; // 自动转换,安全的
将long
转换为int
需要使用强制类型转换,但需要注意潜在的数据丢失或溢出问题。
数据类型转换方式你知道哪些?
自动类型转换(隐式转换):将精度小的类型转换为精度大的类型
强制类型转换(显式转换):将精度大的类型转换为精度小的类型
字符串转换:Java提供了将字符串表示的数据转换为其他类型数据的方法。例如,
将字符串转换为整型int
,可以使用Integer.parseInt()
方法;
将字符串转换为浮点型double
,可以使用Double.parseDouble()
方法等。
数值之间的转换:Java提供了一些数值类型之间的转换方法,如将整型转换为字符型、将字符型转换为整型等。
这些转换方式可以通过类型的包装类来实现,例如Character
类、Integer
类等提供了相应的转换方法。
类型互转会出现什么问题吗?
数据丢失:当将一个精度大的数据类型转换为一个精度小的数据类型时,可能会发生数据丢失。
数据溢出:当将一个精度小的数据类型转换为一个精度大的数据类型时,可能会发生数据溢出。
精度损失:在进行浮点数类型的转换时,可能会发生精度损失。由于浮点数的表示方式不同,将一个单精度浮点数(float
)转换为双精度 浮点数(double
)时,精度可能会损失。
类型不匹配导致的错误:在进行类型转换时,需要确保源类型和目标类型是兼容的。如果两者不兼容,会导致编译错误或运行时错误。
为什么用bigDecimal 不用double ?
double会出现精度丢失的问题,double执行的是二进制浮点运算(只能够表示能够用1/(2^n)的和的任意组合),有些情况下不能准确的表示一个小数,
System.out.println(0.05 + 0.01);
System.out.println(1.0 - 0.42);
System.out.println(4.015 * 100);
System.out.println(123.3 / 100);
输出:
0.060000000000000005
0.5800000000000001
401.49999999999994
1.2329999999999999
Decimal 是精确计算 , 所以一般牵扯到金钱的计算 , 都使用 Decimal。
import java.math.BigDecimal;
public class BigDecimalExample {
public static void main(String[] args) {
BigDecimal num1 = new BigDecimal("0.1");
BigDecimal num2 = new BigDecimal("0.2");
BigDecimal sum = num1.add(num2);
BigDecimal product = num1.multiply(num2);
System.out.println("Sum: " + sum);
System.out.println("Product: " + product);
}
}
//输出
Sum: 0.3
Product: 0.02
注意:在创建BigDecimal
对象时,应该使用字符串作为参数,而不是直接使用浮点数值,以避免浮点数精度丢失。
基本类型和包装类型的区别?
用途:基本类型通常用于定义一些常量和局部变量,包装类型通常用于方法参数、对象属性。并且,包装类型可用于泛型,而基本类型不可以。
存储方式:基本数据类型的局部变量存放在 Java 虚拟机栈中的局部变量表中,基本数据类型的成员变量(未被
static
修饰 )存放在 Java 虚拟机的堆中。包装类型属于对象类型,我们知道几乎所有对象实例都存在于堆中。占用空间:相比于包装类型(对象类型), 基本数据类型占用的空间往往非常小。
默认值:成员变量包装类型不赋值就是
null
,而基本类型有默认值且不是null
。比较方式:对于基本数据类型来说,
==
比较的是值。对于包装数据类型来说,==
比较的是对象的内存地址。所有整型包装类对象之间值的比较,全部使用equals()
方法。
⚠️ 注意:基本数据类型存放在栈中是一个常见的误区! 基本数据类型的存储位置取决于它们的作用域和声明方式。如果它们是局部变量,那么它们会存放在栈中;如果它们是成员变量,那么它们会存放在堆/方法区/元空间中。
面向对象
#怎么理解面向对象?简单说说封装继承多态
面向对象是一种编程范式,它将现实世界中的事物抽象为对象,对象具有属性(称为字段或属性)和行为(称为方法)。面向对象编程的设计思想是以对象为中心,通过对象之间的交互来完成程序的功能,具有灵活性和可扩展性,通过封装和继承可以更好地应对需求变化。
Java面向对象的三大特性包括:封装、继承、多态:
封装:封装是指将对象的属性(数据)和行为(方法)结合在一起,对外隐藏对象的内部细节,仅通过对象提供的接口与外界交互。封装的目的是增强安全性和简化编程,使得对象更加独立。
继承:继承是一种可以使得子类自动共享父类数据结构和方法的机制。提高代码的复用性,通过继承可以建立类与类之间的层次关系,使得结构更加清晰。
多态:多态是指允许不同类的对象对同一消息作出不同的响应。即同一个接口,使用不同的实例而执行不同操作。多态性可以分为编译时多态(重载)和运行时多态(重写)。它使得程序具有良好的灵活性和扩展性。
#多态体现在哪几个方面?
多态在面向对象编程中可以体现在以下几个方面:
方法重载:
方法重载是指同一类中可以有多个同名方法,它们具有不同的参数列表(参数类型、数量或顺序不同)。虽然方法名相同,但根据传入的参数不同,编译器会在编译时确定调用哪个方法。
示例:对于一个
add
方法,可以定义为add(int a, int b)
和add(double a, double b)
。
方法重写:
方法重写是指子类能够提供对父类中同名方法的具体实现。在运行时,JVM会根据对象的实际类型确定调用哪个版本的方法。这是实现多态的主要方式。
示例:在一个动物类中,定义一个
sound
方法,子类Dog
可以重写该方法以实现bark
,而Cat
可以实现meow
。
接口与实现:
多态也体现在接口的使用上,多个类可以实现同一个接口,并且用接口类型的引用来调用这些类的方法。这使得程序在面对不同具体实现时保持一贯的调用方式。
示例:多个类(如
Dog
,Cat
)都实现了一个Animal
接口,当用Animal
类型的引用来调用makeSound
方法时,会触发对应的实现。
向上转型和向下转型:
在Java中,可以使用父类类型的引用指向子类对象,这是向上转型。通过这种方式,可以在运行时期采用不同的子类实现。
向下转型是将父类引用转回其子类类型,但在执行前需要确认引用实际指向的对象类型以避免
ClassCastException
。
#面向对象的设计原则你知道有哪些吗
面向对象编程中的六大原则:
单一职责原则(SRP):一个类应该只负责一项职责。例子:考虑一个员工类,它应该只负责管理员工信息,而不应负责其他无关工作。
开放封闭原则(OCP):软件实体应该对扩展开放,对修改封闭。例子:通过制定接口来实现这一原则,比如定义一个图形类,然后让不同类型的图形继承这个类,而不需要修改图形类本身。
里氏替换原则(LSP):子类对象应该能够替换掉所有父类对象。例子:一个正方形是一个矩形,但如果修改一个矩形的高度和宽度时,正方形的行为应该如何改变就是一个违反里氏替换原则的例子。
接口隔离原则(ISP):客户端不应该依赖那些它不需要的接口,即接口应该小而专。例子:通过接口抽象层来实现底层和高层模块之间的解耦,比如使用依赖注入。
依赖倒置原则(DIP):高层模块不应该依赖低层模块,二者都应该依赖于抽象;抽象不应该依赖于细节,细节应该依赖于抽象。例子:如果一个公司类包含部门类,应该考虑使用合成/聚合关系,而不是将公司类继承自部门类。
最少知识原则 (Law of Demeter):一个对象应当对其他对象有最少的了解,只与其直接的朋友交互。
重载与重写有什么区别?
重载(Overloading)指的是在同一个类中,可以有多个同名方法,它们具有不同的参数列表(参数类型、参数个数或参数顺序不同),编译器根据调用时的参数类型来决定调用哪个方法。
重写(Overriding)指的是子类可以重新定义父类中的方法,方法名、参数列表和返回类型必须与父类中的方法一致,通过@override注解来明确表示这是对父类方法的重写。
重载是指在同一个类中定义多个同名方法,而重写是指子类重新定义父类中的方法。
创建一个对象用什么运算符?对象实体与对象引用有何不同?
new 运算符,new 创建对象实例(对象实例在堆内存中),对象引用指向对象实例(对象引用存放在栈内存中)。
一个对象引用可以指向 0 个或 1 个对象(一根绳子可以不系气球,也可以系一个气球);
一个对象可以有 n 个引用指向它(可以用 n 条绳子系住一个气球)。
对象的相等和引用相等的区别
对象的相等一般比较的是内存中存放的内容是否相等。
引用相等一般比较的是他们指向的内存地址是否相等。
构造方法有哪些特点?是否可被 override?
构造方法具有以下特点:
名称与类名相同:构造方法的名称必须与类名完全一致。
没有返回值:构造方法没有返回类型,且不能使用
void
声明。自动执行:在生成类的对象时,构造方法会自动执行,无需显式调用。
构造方法不能被重写(override),但可以被重载(overload)。
#抽象类和普通类区别?
实例化:普通类可以直接实例化对象,而抽象类不能被实例化,只能被继承。
方法实现:普通类中的方法可以有具体的实现,而抽象类中的方法可以有实现也可以没有实现。
继承:一个类可以继承一个普通类,而且可以实现多个接口;而一个类只能继承一个抽象类,并且必须实现其所有的抽象方法 (除非他也是抽象类),但可以同时实现多个接口。
实现限制:普通类可以被其他类继承和使用,而抽象类一般用于作为基类,被其他类继承和扩展使用。
Java抽象类和接口的区别是什么?
两者的特点:
抽象类用于描述类的共同特性和行为,可以有成员变量、构造方法和具体方法。适用于有明显继承关系的场景。
接口用于定义行为规范,可以多实现,只能有常量和抽象方法(Java 8 以后可以有默认方法和静态方法)。适用于定义类的能力或功能。
两者都不能被实例化,都包含有抽象方法
两者的区别:
实现方式:实现接口的关键字为implements,继承抽象类的关键字为extends。一个类可以实现多个接口,但一个类只能继承一个抽象类。所以,使用接口可以间接地实现多重继承。
方法方式:接口只有定义,不能有方法的实现,java 1.8中可以定义default方法体和
static
(静态)方法,自 Java 9 起,接口可以包含private
方法。,而抽象类可以有定义与实现,方法可在抽象类中实现。访问修饰符:接口成员变量默认为public static final,必须赋初值,不能被修改;其所有的成员方法都是public、abstract的。抽象类中成员变量默认default,可在子类中被重新定义,也可被重新赋值;抽象方法被abstract修饰,不能被private、static、synchronized和native等修饰,必须以分号结尾,不带花括号。
变量:抽象类可以包含实例变量和静态变量,而接口只能包含常量(即静态常量)。
#深拷贝和浅拷贝的区别?
浅拷贝:如果属性是基本类型,拷贝的就是基本类型的值;如果属性是内存地址(引用类型),拷贝的就是内存地址,也就是说新旧对象还是共享同一块内存。
深拷贝会另外创造一个一模一样的对象,新对象跟原对象不共享内存。
浅拷贝
浅拷贝的示例代码如下,我们这里实现了 Cloneable
接口,并重写了 clone()
方法。
clone()
方法的实现很简单,直接调用的是父类 Object
的 clone()
方法。
public class Address implements Cloneable{
private String name;
// 省略构造函数、Getter&Setter方法
@Override
public Address clone() {
try {
return (Address) super.clone();
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
}
public class Person implements Cloneable {
private Address address;
// 省略构造函数、Getter&Setter方法
@Override
public Person clone() {
try {
Person person = (Person) super.clone();
return person;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
}
测试:
Person person1 = new Person(new Address("武汉"));
Person person1Copy = person1.clone();
// true
System.out.println(person1.getAddress() == person1Copy.getAddress());
从输出结构就可以看出, person1
的克隆对象和 person1
使用的仍然是同一个 Address
对象。
深拷贝
这里我们简单对 Person
类的 clone()
方法进行修改,连带着要把 Person
对象内部的 Address
对象一起复制。
@Override
public Person clone() {
try {
Person person = (Person) super.clone();
person.setAddress(person.getAddress().clone());
return person;
} catch (CloneNotSupportedException e) {
throw new AssertionError();
}
}
测试:
Person person1 = new Person(new Address("武汉"));
Person person1Copy = person1.clone();
// false
System.out.println(person1.getAddress() == person1Copy.getAddress());
从输出结构就可以看出,显然 person1
的克隆对象和 person1
包含的 Address
对象已经是不同的了。
Object类
==和equals的区别
对于基本数据类型来说,
==
比较的是值。对于引用数据类型来说,
==
比较的是对象的内存地址。
equals()
方法存在两种使用情况:
类没有重写
equals()
方法:通过equals()
比较该类的两个对象时,等价于通过“==”比较这两个对象,使用的默认是Object
类equals()
方法。类重写了
equals()
方法:一般我们都重写equals()
方法来比较两个对象中的属性是否相等;若它们的属性相等,则返回 true(即,认为这两个对象相等)。
为什么重写 equals() 时必须重写 hashCode() 方法?
因为两个相等的对象的 hashCode
值必须是相等。也就是说如果 equals
方法判断两个对象是相等的,那这两个对象的 hashCode
值也要相等。
如果重写 equals()
时没有重写 hashCode()
方法的话就可能会导致 equals
方法判断是相等的两个对象,hashCode
值却不相等。
String、StringBuffer、StringBuilder 的区别?
String类被声明为final class是不可变字符串。所以拼接字符串时候会产生很多无用的中间对象,如果频繁的进行这样的操作对性能有所影响。
StringBuffer提供了 append 和 add 方法,可以将字符串添加到已有序列的末尾或指定位置,它的本质是一个线程安全的可修改的字符序列。
StringBuilder 是 JDK1.5 发布的,它和 StringBuffer 本质上没什么区别,就是去掉了保证线程安全的那部分,减少了开销。
使用场景:
操作少量的数据使用 String。
单线程操作大量数据使用 StringBuilder。
多线程操作大量数据使用 StringBuffer。
字符串常量池的作用了解吗?
字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建。
// 在字符串常量池中创建字符串对象 ”ab“
// 将字符串对象 ”ab“ 的引用赋值给 aa
String aa = "ab";
// 直接返回字符串常量池中字符串对象 ”ab“,赋值给引用 bb
String bb = "ab";
System.out.println(aa==bb); // true
String s1 = new String(“abc”);这句话创建了几个字符串对象?
先说答案:会创建 1 或 2 个字符串对象。
字符串常量池中不存在 “abc”:会创建 2 个 字符串对象。一个在字符串常量池中,由
ldc
指令触发创建。一个在堆中,由new String()
创建,并使用常量池中的 “abc” 进行初始化。字符串常量池中已存在 “abc”:会创建 1 个 字符串对象。该对象在堆中,由
new String()
创建,并使用常量池中的 “abc” 进行初始化。
String#intern 方法有什么作用?
intern()
方法的主要作用是确保字符串引用在常量池中的唯一性。当调用
intern()
时,如果常量池中已经存在相同内容的字符串,则返回常量池中已有对象的引用;否则,将该字符串添加到常量池并返回其引用。
// s1 指向字符串常量池中的 "Java" 对象
String s1 = "Java";
// s2 也指向字符串常量池中的 "Java" 对象,和 s1 是同一个对象
String s2 = s1.intern();
// 在堆中创建一个新的 "Java" 对象,s3 指向它
String s3 = new String("Java");
// s4 指向字符串常量池中的 "Java" 对象,和 s1 是同一个对象
String s4 = s3.intern();
// s1 和 s2 指向的是同一个常量池中的对象
System.out.println(s1 == s2); // true
// s3 指向堆中的对象,s4 指向常量池中的对象,所以不同
System.out.println(s3 == s4); // false
// s1 和 s4 都指向常量池中的同一个对象
System.out.println(s1 == s4); // true
String 类型的变量和常量做“+”运算时发生了什么?
没有加final关键字修饰时
String str1 = "str";
String str2 = "ing";
String str3 = "str" + "ing";
String str4 = str1 + str2;
String str5 = "string";
System.out.println(str3 == str4);//false
System.out.println(str3 == str5);//true
System.out.println(str4 == str5);//false
对于 String str3 = "str" + "ing";
编译器会给你优化成 String str3 = "string";
。
对象引用和“+”的字符串拼接方式,实际上是通过 StringBuilder
调用 append()
方法实现的,拼接完成之后调用 toString()
得到一个 String
对象 。
String str4 = new StringBuilder().append(str1).append(str2).toString();
不过,字符串使用 final
关键字声明之后,可以让编译器当做常量来处理。
示例代码:
final String str1 = "str";
final String str2 = "ing";
// 下面两个表达式其实是等价的
String c = "str" + "ing";// 常量池中的对象
String d = str1 + str2; // 常量池中的对象
System.out.println(c == d);// true
被 final
关键字修饰之后的 String
会被编译器当做常量来处理,编译器在程序编译期就可以确定它的值,其效果就相当于访问常量。
如果 ,编译器在运行时才能知道其确切值的话,就无法对其优化。
示例代码(str2
在运行时才能确定其值):
final String str1 = "str";
final String str2 = getStr();
String c = "str" + "ing";// 常量池中的对象
String d = str1 + str2; // 在堆上创建的新的对象
System.out.println(c == d);// false
public static String getStr() {
return "ing";
}
异常
介绍一下异常
Java 异常类层次结构图概览:
Java的异常体系主要基于两大类:Throwable类及其子类。Throwable有两个重要的子类:Error和Exception,它们分别代表了不同类型的异常情况。
Error(错误):表示运行时环境的错误。错误是程序无法处理的严重问题,如系统崩溃、Java 虚拟机运行错误(
Virtual MachineError
)、虚拟机内存不够错误(OutOfMemoryError
)、类定义错误(NoClassDefFoundError
)等。通常,程序不应该尝试捕获这类错误。例如,OutOfMemoryError、StackOverflowError等。Exception(异常):表示程序本身可以处理的异常条件。异常分为两大类:
非运行时异常:这类异常在编译时期就必须被捕获或者声明抛出。它们通常是外部错误,如文件不存在(FileNotFoundException)、类未找到(ClassNotFoundException)等。非运行时异常强制程序员处理这些可能出现的问题,增强了程序的健壮性。
运行时异常:这类异常包括运行时异常(RuntimeException)和错误(Error)。运行时异常由程序错误导致,如空指针访问(NullPointerException)、数组越界(ArrayIndexOutOfBoundsException)等。运行时异常是不需要在编译时强制捕获或声明的。
try-catch-finally 如何使用?
try
块:用于捕获异常。其后可接零个或多个catch
块,如果没有catch
块,则必须跟一个finally
块。catch
块:用于处理 try 捕获到的异常。finally
块:无论是否捕获或处理异常,finally
块里的语句都会被执行。当在try
块或catch
块中遇到return
语句时,finally
语句块将在方法返回之前被执行。
不要在 finally 语句块中使用 return! 当 try 语句和 finally 语句中都有 return 语句时,try 语句块中的 return 语句会被finally中的return语句覆盖。这是因为 try 语句中的 return 返回值会先被暂存在一个本地变量中,当执行到 finally 语句中的 return 之后,这个本地变量的值就变为了 finally 语句中的 return 返回值。(try{return “a”} fianlly{return “b”} return值为b)
泛型
#什么是泛型?
泛型是 Java 编程语言中的一个重要特性,它允许类、接口和方法在定义时使用一个或多个类型参数,这些类型参数在使用时可以被指定为具体的类型。
使用泛型参数,可以增强代码的可读性以及稳定性。
编译器可以对泛型参数进行检测,并且通过泛型参数可以指定传入的对象类型。比如 ArrayList<Person> persons = new ArrayList<Person>()
这行代码就指明了该 ArrayList
对象只能传入 Person
对象,如果传入其他类型的对象就会报错。
ArrayList<E> extends AbstractList<E>
并且,原生 List
返回类型是 Object
,需要手动转换类型才能使用,使用泛型后编译器自动转换。
反射
#什么是反射?
Java 反射机制是在运行状态中,对于任意一个类,都能够知道这个类中的所有属性和方法,对于任意一个对象,都能够调用它的任意一个方法和属性;这种动态获取的信息以及动态调用对象的方法的功能称为 Java 语言的反射机制。
反射具有以下特性:
运行时类信息访问:反射机制允许程序在运行时获取类的完整结构信息,包括类名、包名、父类、实现的接口、构造函数、方法和字段等。
动态对象创建:可以使用反射API动态地创建对象实例,即使在编译时不知道具体的类名。这是通过Class类的newInstance()方法或Constructor对象的newInstance()方法实现的。
动态方法调用:可以在运行时动态地调用对象的方法,包括私有方法。这通过Method类的invoke()方法实现,允许你传入对象实例和参数值来执行方法。
访问和修改字段值:反射还允许程序在运行时访问和修改对象的字段值,即使是私有的。这是通过Field类的get()和set()方法完成的。
获取Class对象的四种方法
如果我们动态获取到这些信息,我们需要依靠 Class 对象。Class 类对象将一个类的方法、变量等信息告诉运行的程序。Java 提供了四种方式获取 Class 对象:
1. 知道具体类的情况下可以使用:
Class alunbarClass = TargetObject.class;
但是我们一般是不知道具体类的,基本都是通过遍历包下面的类来获取 Class 对象,通过此方式获取 Class 对象不会进行初始化
2. 通过 Class.forName()
传入类的全路径获取:
Class alunbarClass1 = Class.forName("cn.javaguide.TargetObject");
3. 通过对象实例instance.getClass()
获取:
TargetObject o = new TargetObject();
Class alunbarClass2 = o.getClass();
4. 通过类加载器xxxClassLoader.loadClass()
传入类路径获取:
ClassLoader.getSystemClassLoader().loadClass("cn.javaguide.TargetObject");
通过类加载器获取 Class 对象不会进行初始化,意味着不进行包括初始化等一系列步骤,静态代码块和静态对象不会得到执行
反射的应用场景?
像 Spring/Spring Boot、MyBatis 等等框架中都大量使用了反射机制。
利用反射获取实体类的Class实例,创建实体类的实例对象,调用方法
public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException, IOException, ClassNotFoundException, InstantiationException {
// 使用反射机制,根据这个字符串获得Class对象
Class<?> c = Class.forName(getName("className"));
System.out.println(c.getSimpleName());
// 获取方法
Method method = c.getDeclaredMethod(getName("methodName"));
// 绕过安全检查
method.setAccessible(true);
// 创建实例对象
TestInvoke testInvoke = (TestInvoke)c.newInstance();
// 调用方法
method.invoke(testInvoke);
}
运行结果:
注解
什么是注解?
注解主要用于修饰类、方法或者变量,提供某些信息供程序在编译或者运行时使用。
注解本质是一个继承了Annotation
的特殊接口:
@Target注解 可以用来表明是作用在类、方法还是字段上
@Retention注解:什么时候被保留
SPI
什么是SPI
专门提供给 服务提供者
或者扩展框架功能的开发者
去使用的一个接口
SPI 将服务接口
和具体的服务实现
分离开来,将服务调用方
和服务实现者
解耦,能够提升程序的扩展性、可维护性。
很多框架都使用了 Java 的 SPI 机制,比如:Spring 框架、数据库加载驱动(JDBC)、日志接口、以及 Dubbo 的扩展实现等等。
SPI 和 API 有什么区别?
他们广义上都属于接口。
API:当实现方提供了接口和实现,我们可以通过调用实现方的接口从而拥有实现方给我们提供的能力,这就是 API
SPI:由接口调用方确定接口规则,然后由不同的厂商根据这个规则对这个接口进行实现,从而提供服务。
举个通俗易懂的例子:公司 A 是一家科技公司,新设计了一款芯片,然后现在需要量产了,而市面上有好几家芯片制造业公司,这个时候,只要 A 公司指定好了这芯片生产的标准(定义好了接口标准),那么这些合作的芯片公司(服务提供者)就按照标准交付自家特色的芯片(提供不同方案的实现,但是给出来的结果是一样的)。
序列化和反序列化
将 Java 对象保存在文件中,或者在网络传输 Java 对象,这些场景都需要用到序列化。
什么是序列化和反序列化
序列化:将数据结构或对象转换成可以存储或传输的形式,通常是二进制字节流,也可以是 JSON, XML 等文本格式
反序列化:将在序列化过程中所生成的数据转换为原始数据结构或者对象的过程
序列化的目的和应用常景(网络传输,文件系统,数据库、内存)
序列化的主要目的是通过网络传输对象或者说是将对象存储到文件系统、数据库、内存中。
IO
IO流简介
IO 即 Input/Output
,输入和输出。数据输入到计算机内存的过程即输入,反之输出到外部存储(比如数据库,文件,远程主机)的过程即输出。数据传输过程类似于水流,因此称为 IO 流。IO 流在 Java 中分为输入流和输出流,而根据数据的处理方式又分为字节流和字符流。
Java IO 流的 40 多个类都是从如下 4 个抽象类基类中派生出来的。
InputStream
/Reader
: 所有的输入流的基类,前者是字节输入流,后者是字符输入流。OutputStream
/Writer
: 所有输出流的基类,前者是字节输出流,后者是字符输出流。
字节流
InputStream字节输入流
InputStream
用于从源头(通常是文件)读取数据(字节信息)到内存中,java.io.InputStream
抽象类是所有字节输入流的父类。
InputStream
常用方法:
read()
:返回输入流中下一个字节的数据。返回的值介于 0 到 255 之间。如果未读取任何字节,则代码返回-1
,表示文件结束。read(byte b[ ])
: 从输入流中读取一些字节存储到数组b
中。如果数组b
的长度为零,则不读取。如果没有可用字节读取,返回-1
。如果有可用字节读取,则最多读取的字节数最多等于b.length
, 返回读取的字节数。这个方法等价于read(b, 0, b.length)
。read(byte b[], int off, int len)
:在read(byte b[ ])
方法的基础上增加了off
参数(偏移量)和len
参数(要读取的最大字节数)。skip(long n)
:忽略输入流中的 n 个字节 ,返回实际忽略的字节数。available()
:返回输入流中可以读取的字节数。close()
:关闭输入流释放相关的系统资源。
从 Java 9 开始,InputStream
新增加了多个实用的方法:
readAllBytes()
:读取输入流中的所有字节,返回字节数组。readNBytes(byte[] b, int off, int len)
:阻塞直到读取len
个字节。transferTo(OutputStream out)
:将所有字节从一个输入流传递到一个输出流。
FileInputStream
是一个比较常用的字节输入流对象,可直接指定文件路径,可以直接读取单字节数据,也可以读取至字节数组中。
不过,一般我们是不会直接单独使用 FileInputStream
,通常会配合 BufferedInputStream
(字节缓冲输入流,后文会讲到)来使用。
// 新建一个 BufferedInputStream 对象
BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("input.txt"));
// 读取文件的内容并复制到 String 对象中
String result = new String(bufferedInputStream.readAllBytes());
System.out.println(result);
DataInputStream
用于读取指定类型数据,不能单独使用,必须结合其它流,比如 FileInputStream
。
FileInputStream fileInputStream = new FileInputStream("input.txt");
//必须将fileInputStream作为构造参数才能使用
DataInputStream dataInputStream = new DataInputStream(fileInputStream);
//可以读取任意具体的类型数据
dataInputStream.readBoolean();
dataInputStream.readInt();
dataInputStream.readUTF();
ObjectInputStream
用于从输入流中读取 Java 对象(反序列化),ObjectOutputStream
用于将对象写入到输出流(序列化)。
另外,用于序列化和反序列化的类必须实现 Serializable
接口,对象中如果有属性不想被序列化,使用 transient
修饰
ObjectInputStream input = new ObjectInputStream(new FileInputStream("object.data"));
MyClass object = (MyClass) input.readObject();
input.close();
OutputStream
OutputStream
用于将数据(字节信息)写入到目的地(通常是文件),java.io.OutputStream
抽象类是所有字节输出流的父类。
OutputStream
常用方法:
write(int b)
:将特定字节写入输出流。write(byte b[ ])
: 将数组b
写入到输出流,等价于write(b, 0, b.length)
。write(byte[] b, int off, int len)
: 在write(byte b[ ])
方法的基础上增加了off
参数(偏移量)和len
参数(要读取的最大字节数)。flush()
:刷新此输出流并强制写出所有缓冲的输出字节。close()
:关闭输出流释放相关的系统资源。
FileOutputStream
是最常用的字节输出流对象,可直接指定文件路径,可以直接输出单字节数据,也可以输出指定的字节数组。
字符流
字符输入流
Reader
用于从源头(通常是文件)读取数据(字符信息)到内存中,java.io.Reader
抽象类是所有字符输入流的父类。
Reader
用于读取文本, InputStream
用于读取原始字节。
Reader
常用方法:
read()
: 从输入流读取一个字符。read(char[] cbuf)
: 从输入流中读取一些字符,并将它们存储到字符数组cbuf
中,等价于read(cbuf, 0, cbuf.length)
。read(char[] cbuf, int off, int len)
:在read(char[] cbuf)
方法的基础上增加了off
参数(偏移量)和len
参数(要读取的最大字符数)。skip(long n)
:忽略输入流中的 n 个字符 ,返回实际忽略的字符数。close()
: 关闭输入流并释放相关的系统资源。
字符输出流
Writer
用于将数据(字符信息)写入到目的地(通常是文件),java.io.Writer
抽象类是所有字符输出流的父类。
Writer
常用方法:
write(int c)
: 写入单个字符。write(char[] cbuf)
:写入字符数组cbuf
,等价于write(cbuf, 0, cbuf.length)
。write(char[] cbuf, int off, int len)
:在write(char[] cbuf)
方法的基础上增加了off
参数(偏移量)和len
参数(要读取的最大字符数)。write(String str)
:写入字符串,等价于write(str, 0, str.length())
。write(String str, int off, int len)
:在write(String str)
方法的基础上增加了off
参数(偏移量)和len
参数(要读取的最大字符数)。append(CharSequence csq)
:将指定的字符序列附加到指定的Writer
对象并返回该Writer
对象。append(char c)
:将指定的字符附加到指定的Writer
对象并返回该Writer
对象。flush()
:刷新此输出流并强制写出所有缓冲的输出字符。close()
:关闭输出流释放相关的系统资源。
字节缓存流
IO 操作是很消耗性能的,缓冲流将数据加载至缓冲区,一次性读取/写入多个字节,从而避免频繁的 IO 操作,提高流的传输效率。
字节缓冲流这里采用了装饰器模式来增强 InputStream
和OutputStream
子类对象的功能。
举个例子,我们可以通过 BufferedInputStream
(字节缓冲输入流)来增强 FileInputStream
的功能。
// 新建一个 BufferedInputStream 对象
BufferedInputStream bufferedInputStream = new BufferedInputStream(new FileInputStream("input.txt"));
字节流和字节缓冲流的性能差别主要体现在我们使用两者的时候都是调用 write(int b)
和 read()
这两个一次只读取一个字节的方法的时候。由于字节缓冲流内部有缓冲区(字节数组),因此,字节缓冲流会先将读取到的字节存放在缓存区,大幅减少 IO 次数,提高读取效率。
字节缓冲输入流
BufferedInputStream
从源头(通常是文件)读取数据(字节信息)到内存的过程中不会一个字节一个字节的读取,而是会先将读取到的字节存放在缓存区,并从内部缓冲区中单独读取字节。这样大幅减少了 IO 次数,提高了读取效率。
BufferedInputStream
内部维护了一个缓冲区,这个缓冲区实际就是一个字节数组
字节缓冲输出流
BufferedOutputStream
将数据(字节信息)写入到目的地(通常是文件)的过程中不会一个字节一个字节的写入,而是会先将要写入的字节存放在缓存区,并从内部缓冲区中单独写入字节。这样大幅减少了 IO 次数,提高了读取效率
字符缓存流
BufferedReader
(字符缓冲输入流)和 BufferedWriter
(字符缓冲输出流)类似于 BufferedInputStream
(字节缓冲输入流)和BufferedOutputStream
(字节缓冲输入流),内部都维护了一个字节数组作为缓冲区。不过,前者主要是用来操作字符信息
IO设计模式
装饰器模式
装饰器(Decorator)模式 可以在不改变原有对象的情况下拓展其功能。
装饰器模式通过组合替代继承来扩展原始类的功能,在一些继承关系比较复杂的场景(IO 这一场景各种类的继承关系就比较复杂)更加实用。
举个例子,我们可以通过 BufferedInputStream
(字节缓冲输入流)来增强 FileInputStream
的功能。
对于字符流来说,BufferedReader
可以用来增加 Reader
(字符输入流)子类的功能,BufferedWriter
可以用来增加 Writer
(字符输出流)子类的功能。
比如说文件流,字节流,缓冲流都是在InputStream基础上装饰得到的。
适配器模式
适配器(Adapter Pattern)模式 主要用于接口互不兼容的类的协调工作,你可以将其联想到我们日常经常使用的电源适配器。
适配器模式中存在被适配的对象或者类称为 适配者(Adaptee) ,作用于适配者的对象或者类称为适配器(Adapter) 。适配器分为对象适配器和类适配器。类适配器使用继承关系来实现,对象适配器使用组合关系来实现。
举个例子:在使用InputStream的子类(FileInputStream)时是字节流不能操纵字符流,我们可以借助InputStreamReader将其转为一个reader的子类,因此就可以操作字符流,实现字节流到字符流的一个转换。
工厂模式
工厂模式用于创建对象,NIO 中大量用到了工厂模式,比如 Files
类的 newInputStream
方法用于创建 InputStream
对象(静态工厂)、 Paths
类的 get
方法创建 Path
对象(静态工厂)、ZipFileSystem
类(sun.nio
包下的类,属于 java.nio
相关的一些内部实现)的 getPath
的方法创建 Path
对象(简单工厂)。
InputStream is = Files.newInputStream(Paths.get(generatorLogoPath))
观察者模式
IO模型
IO即输入输出。
计算机角度的IO
根据冯.诺伊曼结构,计算机分为五部分:控制器、运算器、存储器、输入/输出设备
输入设备(比如键盘)和输出设备(比如显示器)都属于外部设备。网卡、硬盘这种既可以属于输入设备,也可以属于输出设备。
输入设备向计算机输入数据,输出设备接收计算机输出的数据。
从计算机结构的视角来看的话, I/O 描述了计算机系统与外部设备之间通信的过程。
从应用程序的视角来看的话,我们的应用程序对操作系统的内核发起 IO 调用(系统调用),操作系统负责的内核执行具体的 IO 操作。也就是说,我们的应用程序实际上只是发起了 IO 操作的调用而已,具体 IO 的执行是由操作系统的内核来完成的。
BIO:同步阻塞IO
同步阻塞 IO 模型中,应用程序发起 read 调用后,如果内核的数据没有准备好,应用程序进程会一直阻塞,直到内核把数据拷贝到用户空间。
缺点:在客户端连接数量不高的情况下,是没问题的。但是,当面对十万甚至百万级连接的时候,传统的 BIO 模型是无能为力的。
NIO同步非阻塞IO
应用程序进程发起read调用后,如果内核数据没有准备好,可以先返回错误的提示给用户进程,让他不需要再等待,而是通过轮询的方式再来请求,知道内核把数据拷贝到用户空间。
缺点:轮询操作十分损耗CPU资源
NIO I/O 多路复用模型
应用程序进程通过调用select函数,来询问内核数据是否准备就绪,等内核把数据准备好了,用户线程再发起 read 调用。
优点:只需要发动一次轮询,大大优化了性能。
缺点:监听的IO最大连接数有限,在Linux中一般为1024。
AIO:异步IO
应用进程发起read调用之后是立即返回的,但是返回的不是处理结果,而是表示提交成功。等内核数据准备好了,再将数据拷贝到用户空间,发送信号通知用户进程IO操作执行完毕。
BIO,NIO,AIO例子总结
代理模式详解
代理模式介绍:
代理模式是一种比较好理解的设计模式。简单来说就是 我们使用代理对象来代替对真实对象(real object)的访问,这样就可以在不修改原目标对象的前提下,提供额外的功能操作,扩展目标对象的功能。
代理模式的主要作用是扩展目标对象的功能,比如说在目标对象的某个方法执行前后你可以增加一些自定义的操作。
eg:明星与经纪人:明星负责长跳,代理人负责唱跳前的准备工作、以及找对应明星来进行唱跳
静态代理
静态代理中,我们对目标对象的每个方法的增强都是手动完成的(*后面会具体演示代码*),非常不灵活(*比如接口一旦新增加方法,目标对象和代理对象都要进行修改*)且麻烦(*需要对每个目标类都单独写一个代理类*)。 实际应用场景非常非常少,日常开发几乎看不到使用静态代理的场景。
上面我们是从实现和应用角度来说的静态代理,从 JVM 层面来说, 静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。
静态代理实现步骤:
定义一个接口及其实现类;
创建一个代理类同样实现这个接口
将目标对象注入进代理类,然后在代理类的对应方法调用目标类中的对应方法。这样的话,我们就可以通过代理类屏蔽对目标对象的访问,并且可以在目标方法执行前后做一些自己想做的事情。
动态代理
相比于静态代理来说,动态代理更加灵活。我们不需要针对每个目标类都单独创建一个代理类,并且也不需要我们必须实现接口,我们可以直接代理实现类( CGLIB 动态代理机制)。
从 JVM 角度来说,动态代理是在运行时动态生成类字节码,并加载到 JVM 中的。
说到动态代理,Spring AOP、RPC 框架应该是两个不得不提的,它们的实现都依赖了动态代理。
动态代理在我们日常开发中使用的相对较少,但是在框架中的几乎是必用的一门技术。学会了动态代理之后,对于我们理解和学习各种框架的原理也非常有帮助。
就 Java 来说,动态代理的实现方式有很多种,比如 JDK 动态代理、CGLIB 动态代理
3.1. JDK 动态代理机制
3.1.1. 介绍
在 Java 动态代理机制中 InvocationHandler
接口和 Proxy
类是核心。
Proxy
类中使用频率最高的方法是:newProxyInstance()
,这个方法主要用来生成一个代理对象。
public static Object newProxyInstance(ClassLoader loader,
Class<?>[] interfaces,
InvocationHandler h)
throws IllegalArgumentException
{
......
}
这个方法一共有 3 个参数:
loader :类加载器,用于加载代理对象。
interfaces : 被代理类实现的一些接口;
h : 实现了
InvocationHandler
接口的对象;
要实现动态代理的话,还必须需要实现InvocationHandler
来自定义处理逻辑。 当我们的动态代理对象调用一个方法时,这个方法的调用就会被转发到实现InvocationHandler
接口类的 invoke
方法来调用。
public interface InvocationHandler {
/**
* 当你使用代理对象调用方法的时候实际会调用到这个方法
*/
public Object invoke(Object proxy, Method method, Object[] args)
throws Throwable;
}
invoke()
方法有下面三个参数:
proxy :动态生成的代理类
method : 与代理类对象调用的方法相对应
args : 当前 method 方法的参数
也就是说:你通过Proxy
类的 newProxyInstance()
创建的代理对象在调用方法的时候,实际会调用到实现InvocationHandler
接口的类的 invoke()
方法。 你可以在 invoke()
方法中自定义处理逻辑,比如在方法执行前后做什么事情。
3.1.2. JDK 动态代理类使用步骤
定义一个接口及其实现类;
自定义
InvocationHandler
并重写invoke
方法,在invoke
方法中我们会调用原生方法(被代理类的方法)并自定义一些处理逻辑;通过
Proxy.newProxyInstance(ClassLoader loader,Class<?>[] interfaces,InvocationHandler h)
方法创建代理对象;
3.1.3. 代码示例
这样说可能会有点空洞和难以理解,我上个例子,大家感受一下吧!
1.定义发送短信的接口
public interface SmsService {
String send(String message);
}
2.实现发送短信的接口
public class SmsServiceImpl implements SmsService {
public String send(String message) {
System.out.println("send message:" + message);
return message;
}
}
3.定义一个 JDK 动态代理类
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
/**
* @author shuang.kou
* @createTime 2020年05月11日 11:23:00
*/
public class DebugInvocationHandler implements InvocationHandler {
/**
* 代理类中的真实对象
*/
private final Object target;
public DebugInvocationHandler(Object target) {
this.target = target;
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws InvocationTargetException, IllegalAccessException {
//调用方法之前,我们可以添加自己的操作
System.out.println("before method " + method.getName());
Object result = method.invoke(target, args);
//调用方法之后,我们同样可以添加自己的操作
System.out.println("after method " + method.getName());
return result;
}
}
invoke()
方法: 当我们的动态代理对象调用原生方法的时候,最终实际上调用到的是 invoke()
方法,然后 invoke()
方法代替我们去调用了被代理对象的原生方法。
4.获取代理对象的工厂类
public class JdkProxyFactory {
public static Object getProxy(Object target) {
return Proxy.newProxyInstance(
target.getClass().getClassLoader(), // 目标类的类加载器
target.getClass().getInterfaces(), // 代理需要实现的接口,可指定多个
new DebugInvocationHandler(target) // 代理对象对应的自定义 InvocationHandler
);
}
}
getProxy()
:主要通过Proxy.newProxyInstance()
方法获取某个类的代理对象
5.实际使用
SmsService smsService = (SmsService) JdkProxyFactory.getProxy(new SmsServiceImpl());
smsService.send("java");
运行上述代码之后,控制台打印出:
before method send
send message:java
after method send
3.2. CGLIB 动态代理机制
3.2.1. 介绍
JDK 动态代理有一个最致命的问题是其只能代理实现了接口的类。
为了解决这个问题,我们可以用 CGLIB 动态代理机制来避免。
CGLIB(Code Generation Library)是一个基于ASM的字节码生成库,它允许我们在运行时对字节码进行修改和动态生成。CGLIB 通过继承方式实现代理。很多知名的开源框架都使用到了CGLIB, 例如 Spring 中的 AOP 模块中:如果目标对象实现了接口,则默认采用 JDK 动态代理,否则采用 CGLIB 动态代理。
在 CGLIB 动态代理机制中 MethodInterceptor
接口和 Enhancer
类是核心。
你需要自定义 MethodInterceptor
并重写 intercept
方法,intercept
用于拦截增强被代理类的方法。
public interface MethodInterceptor
extends Callback{
// 拦截被代理类中的方法
public Object intercept(Object obj, java.lang.reflect.Method method, Object[] args,MethodProxy proxy) throws Throwable;
}
obj : 被代理的对象(需要增强的对象)
method : 被拦截的方法(需要增强的方法)
args : 方法入参
proxy : 用于调用原始方法
你可以通过 Enhancer
类来动态获取被代理类,当代理类调用方法的时候,实际调用的是 MethodInterceptor
中的 intercept
方法。
3.2.2. CGLIB 动态代理类使用步骤
定义一个类;
自定义
MethodInterceptor
并重写intercept
方法,intercept
用于拦截增强被代理类的方法,和 JDK 动态代理中的invoke
方法类似;通过
Enhancer
类的create()
创建代理类;
3.2.3. 代码示例
不同于 JDK 动态代理不需要额外的依赖。CGLIB(Code Generation Library) 实际是属于一个开源项目,如果你要使用它的话,需要手动添加相关依赖。
<dependency>
<groupId>cglib</groupId>
<artifactId>cglib</artifactId>
<version>3.3.0</version>
</dependency>
1.实现一个使用阿里云发送短信的类
package github.javaguide.dynamicProxy.cglibDynamicProxy;
public class AliSmsService {
public String send(String message) {
System.out.println("send message:" + message);
return message;
}
}
2.自定义 MethodInterceptor
(方法拦截器)
import net.sf.cglib.proxy.MethodInterceptor;
import net.sf.cglib.proxy.MethodProxy;
import java.lang.reflect.Method;
/**
* 自定义MethodInterceptor
*/
public class DebugMethodInterceptor implements MethodInterceptor {
/**
* @param o 被代理的对象(需要增强的对象)
* @param method 被拦截的方法(需要增强的方法)
* @param args 方法入参
* @param methodProxy 用于调用原始方法
*/
@Override
public Object intercept(Object o, Method method, Object[] args, MethodProxy methodProxy) throws Throwable {
//调用方法之前,我们可以添加自己的操作
System.out.println("before method " + method.getName());
Object object = methodProxy.invokeSuper(o, args);
//调用方法之后,我们同样可以添加自己的操作
System.out.println("after method " + method.getName());
return object;
}
}
3.获取代理类
import net.sf.cglib.proxy.Enhancer;
public class CglibProxyFactory {
public static Object getProxy(Class<?> clazz) {
// 创建动态代理增强类
Enhancer enhancer = new Enhancer();
// 设置类加载器
enhancer.setClassLoader(clazz.getClassLoader());
// 设置被代理类
enhancer.setSuperclass(clazz);
// 设置方法拦截器
enhancer.setCallback(new DebugMethodInterceptor());
// 创建代理类
return enhancer.create();
}
}
4.实际使用
AliSmsService aliSmsService = (AliSmsService) CglibProxyFactory.getProxy(AliSmsService.class);
aliSmsService.send("java");
运行上述代码之后,控制台打印出:java
before method send
send message:java
after method send
3.3. JDK 动态代理和 CGLIB 动态代理对比
JDK 动态代理只能代理实现了接口的类或者直接代理接口,而 CGLIB 可以代理未实现任何接口的类。 另外, CGLIB 动态代理是通过生成一个被代理类的子类来拦截被代理类的方法调用,因此不能代理声明为 final 类型的类和方法。
就二者的效率来说,大部分情况都是 JDK 动态代理更优秀,随着 JDK 版本的升级,这个优势更加明显。
4. 静态代理和动态代理的对比
灵活性:动态代理更加灵活,不需要必须实现接口,可以直接代理实现类,并且可以不需要针对每个目标类都创建一个代理类。另外,静态代理中,接口一旦新增加方法,目标对象和代理对象都要进行修改,这是非常麻烦的!
JVM 层面:静态代理在编译时就将接口、实现类、代理类这些都变成了一个个实际的 class 文件。而动态代理是在运行时动态生成类字节码,并加载到 JVM 中的。
5. 总结
这篇文章中主要介绍了代理模式的两种实现:静态代理以及动态代理。涵盖了静态代理和动态代理实战、静态代理和动态代理的区别、JDK 动态代理和 Cglib 动态代理区别等内容。
集合
List
ArrayList与数组Array的区别
数组是一种用连续的内存空间存储相同数据类型数据的线性数据结构
数组的寻址公式:a[i] = baseAddress(数组首地址) + i*dataTypeSize(数组中元素类型的大小)
为什么索引要从0开始,而不是从1开始?
ArrayList内部是基于动态数组实现的,Array是静态数组
1、ArrayList可以根据内部存储的元素个数进行动态的扩容,Array被创建之后大小就不能被改变
2、ArrayList初始时不需要指定长度(不指定时,容量为0。也可以指定初始的长度)。Array初始时需要指定长度
3、ArrayList进行扩容时,每次的容量变为原来的1.5倍,而且每次扩容的时候都需要拷贝数组
4、ArrayList只能存储对象对于基本数据类型要存放其包装类,而Array中可以存储对象,也可以存储基本数据类型
5、ArrayList支持插入、删除、遍历等常见操作,Array只能通过下标来访问元素,不能动态添加、删除元素
注意:ArrayList在添加数据的时候,要先判断数组已使用的长度+1后是否可以存下下一个数据,如果存不下,则调用grow方法进行扩容(原来的1.5倍)然后将原数组的值拷贝到新数组中,再将新的元素添加到新数组的尾部
ArrayList和LinkedList的区别是什么?
1、底层数据结构:ArrayList底层采用的是数组,而LinkedList底层采用的是双向链表
2、是否支持快速随机访问(操作数据的效率):ArrayList可以按照下标来快速查找( get(index) ),LinkedList不支持下标快速查找
3、查找位置索引:ArrayList,LinkedList都需要遍历,时间复杂度为O(n)
4、插入和删除操作:ArrayList在尾部进行插入/删除时,时间复杂度为O(1),在其他位置进行插入/删除时,时间复杂度为O(n),因为插 入/删除元素之后,其他元素需要向前/向后移动一格位置。LinkedList在头尾节点增删时间复杂度是O(1),其他位置需要遍历链表,时 间复杂度为O(n)。
5、内存占用:ArrayList底层是数组,内存连续,节省内存。LinkedList底层是双向链表需要存储数据和两个指针,更加占用内存。
6、线程安全:都不是线程安全的
Set
Comparable和Comparator
Comparable
是java.lang
包下的一个接口,它有一个 compareTo(Object obj)
方法用来排序
Comparator
是 java.util
包下的一个接口,它有一个compare(Object obj1, Object obj2)
方法用来排序
Comparator定制排序方法
// 定制排序的用法
Collections.sort(arrayList, new Comparator<Integer>() {
@Override
public int compare(Integer o1, Integer o2) {
return o2.compareTo(o1);
}
});
重写 compareTo 方法实现按年龄来排序
// person对象没有实现Comparable接口,所以必须实现,这样才不会出错,才可以使treemap中的数据按顺序排列
// 前面一个例子的String类已经默认实现了Comparable接口,详细可以查看String类的API文档,另外其他
// 像Integer类等都已经实现了Comparable接口,所以不需要另外实现了
public class Person implements Comparable<Person> {
private String name;
private int age;
/**
* T重写compareTo方法实现按年龄来排序
*/
@Override
public int compareTo(Person o) {
if (this.age > o.getAge()) {
return 1;
}
if (this.age < o.getAge()) {
return -1;
}
return 0;
}
}
HashSet、TreeSet、LinkedHashSet的区别
三者都是元素唯一,线程不安全的
HashSet底层是哈希表,Linked HashSet底层是哈希表+链表,TreeSet底层是红黑树
Map
二叉搜索树:对于任意一个节点,左子树的每个节点要小于他,右子树的每个节点要大于他。正常是O(logn),最坏:退化为链表O(n)
红黑树:自平衡的二叉搜索树(BST)
左根右,根叶黑,不红红,黑路同
散列表:HashMap中最重要的一个数据结构就是散列表,在散列表中又是用了红黑树和链表
hash(key)相同,则会进行equals比较,如果相同则覆盖,不同则以链表的形式挂在该槽位后边
HashMap的实现原理
JDK1.8 之前 HashMap
底层是 数组和链表 结合在一起使用也就是 链表散,1.8是数组+链表,红黑树当往hashMap中添加元素(put)时,hashMap的底层会利用key的hashCode重新hash(key)计算出当前元素存放在数组中的下标。如果该位置有元素了,那么就调用equals方法来进行比较,比较相同则覆盖掉原始值,不相同(hash冲突),则将当前的key-value放入到链表或者红黑树中(链表的长度 > 8并且数组的长度 >= 64。红黑树节点数量<= 6时会变回链表)
HashMap的put方法的具体流程
hashMap中有四个常见的属性:默认初始值、默认加载因子、存放值的数组,数组大小
hashMap默认的初始量为16,默认的加载因子为0.75。扩容的阈值 = 16 * 0.75 = 12
数组里有hash值用于计算元素在数组中的索引位置,key,value,next指针
在调用put的方法添加元素时,会先判断数组是否为空,如果为空,则调用resize()方法,初始化一个长度为16的数组,然后根据key通过hash计算出数组的索引值,在判断当前索引位置有没有元素。
如果是空的,就直接插入,然后将当前已占用位置的那部分数组的长度+1与threshold进行比较(数组总长度0.75),如果不大于,这 段添加元素的逻辑结束了,要是大于,就进入到数组的扩容操作。
如果不为空,会调用eauqls方法进行比较(key之间)相同则覆盖,不同则先判断槽位链接的是不是红黑树,如果是红黑树,就在红黑 树中添加,不是则在链表中添加(这部分添加时也要进行hash与equals比较)。若添加在链表中,添加完成之后要判断链表长度是否大 于8,符合条件,链表转为红黑树(里边还会判断数组长度是否>=64进行数组扩容),不符合进入数组扩容操作。
HashMap的扩容机制
先判断数组的大小是否 > 0,为0,则初始化数组的长度为16,加载因子为0.75,创建数组,逻辑结束。
如果数组长度 > 0(容量达到12),就将长度扩容为原来的两倍,新创建一个数组,将原来数组的添加到新的数组中。
数组拷贝过程:先判断槽位的next是为null,之间将当前数据添加到新的数组中
不为null,为红黑树,就添加过去。
不为null,为链表,遍历链表,对每一个元素进行判断
HashMap的多线程死循环问题
1.7版本,在数组进行扩容的时候,因为链表是头插法,在进行数据迁移的过程中,可能导致死循环
这是由于当一个桶位中有多个元素需要进行扩容时,多个线程同时对链表进行操作,头插法可能会导致链表中的节点指向错误的位置,从而形成一个环形链表,进而使得查询元素的操作陷入死循环无法结束。
JDK1.8 版本的 HashMap 采用了尾插法而不是头插法来避免链表倒置,使得插入的节点永远都是放在链表的末尾,避免了链表中的环形结构。但是还是不建议在多线程下使用 HashMap
,因为多线程下使用 HashMap
还是会存在数据覆盖的问题。并发环境下,推荐使用 ConcurrentHashMap
。
HashMap与HahsTable 的区别
1、底层数据结构:底层都是hash表,但是JDK1.8 以后的 HashMap
在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为 8)时,将链表转化为红黑树(将链表转换成红黑树前会判断,如果当前数组的长度小于 64,那么会选择先进行数组扩容,而不是转换为红黑树)
2、初始容量大小和每次扩充容量大小的不同:
1、初始时没有指定初始值 Hashtable
默认的初始大小为 11,之后每次扩充,容量变为原来的 2n+1。HashMap
默认的初始化大小为 16。之后每次扩充,容量变为原来的 2 倍
2、创建时如果给定了容量初始值,那么 Hashtable
会直接使用你给定的大小,而 HashMap
会将其扩充为 2 的幂次方大小
3、对 Null key 和 Null value 的支持:HashMap
可以存储 null 的 key 和 value,但 null 作为键只能有一个,null 作为值可以有多个;Hashtable 不允许有 null 键和 null 值,否则会抛出 NullPointerException
。
4、效率: 因为线程安全的问题,HashMap
要比 Hashtable
效率高一点。另外,Hashtable
基本被淘汰,不要在代码中使用它;
5、线程是否安全:HashMap
是非线程安全的,Hashtable
是线程安全的,因为 Hashtable
内部的方法基本都经过synchronized
修饰。(如果你要保证线程安全的话就使用 ConcurrentHashMap
吧!)
6、哈希函数的实现:HashMap
对哈希值进行了高位和低位的混合扰动处理以减少冲突,而 Hashtable
直接使用键的 hashCode()
值。
HashMap 和 HashSet 区别
HashSet
底层就是基于 HashMap
实现的。
HashMap 为什么线程不安全?
JDK1.7 及之前版本,在多线程环境下,HashMap
扩容时会造成死循环和数据丢失的问题。
JDK 1.8 后,在 HashMap
中,多个键值对可能会被分配到同一个桶 / 槽位(bucket),并以链表或红黑树的形式存储。多个线程对 HashMap
的 put
操作会导致线程不安全,具体来说会有数据覆盖的风险。
举个例子:
两个线程 1,2 同时进行 put 操作,并且发生了哈希冲突(hash 函数计算出的插入下标是相同的)。
不同的线程可能在不同的时间片获得 CPU 执行的机会,当前线程 1 执行完哈希冲突判断后,由于时间片耗尽挂起。线程 2 先完成了插入操作。
随后,线程 1 获得时间片,由于之前已经进行过 hash 碰撞的判断,所有此时会直接进行插入,这就导致线程 2 插入的数据被线程 1 覆盖了。
ConcurrentHashMap
ConcurrentHashMap是一个线程安全的Map集合
ConcurrentHashMap的底层数据结构:
1、JDK1.7底层采用的时分段的数组 + 链表实现的(Segment (扮演锁的角色)+ HashEntry数组 (保存键值对数据)实现的)
2、JDK1.8之后,采用的是数组 + 链表/红黑树实现(当链表长度超过8时,链表转换为红黑树)
加锁方式
JDK1.7采用的是Segment分段锁,底层使用的是ReentrantLock。而Segment个数不可变,默认大小是16,最多支持16个线程并发写
JDK1.8采用的是CAS添加新的节点,采用synchronized锁定链表或者红黑树的首节点,也就是说当前插槽没有数据时,采用了CAS 保 证了多线程添加数据的线程安全问题,添加成功后,采用的时synchronized锁(相比于Segment分段锁粒度更细,性能更好)
ConcurrentHashMap 线程安全的具体实现方式/底层具体实现
ConcurrentHashMap 和 Hashtable 的区别
1、底层数据结构:JDK1.7 的 ConcurrentHashMap
底层采用 分段的数组+链表 实现,JDK1.8 采用的数据结构跟 HashMap1.8
的结构一样,数组+链表/红黑二叉树。Hashtable的底层是采用 数组+链表 的形式
2、实现线程安全的方式:
在 JDK1.7 的时候,ConcurrentHashMap
对整个桶数组进行了分割分段(Segment
,分段锁),每一把锁只锁容器其中一部分数据(下 面有示意图),多线程访问容器里不同数据段的数据,就不会存在锁竞争,提高并发访问率。
到了 JDK1.8 的时候,ConcurrentHashMap
已经摒弃了 Segment
的概念,而是直接用 Node
数组+链表+红黑树的数据结构来实现, 并发控制使用 synchronized
和 CAS 来操作。
Hashtable
(同一把锁) :使用synchronized
来保证线程安全,效率非常低下。当一个线程访问同步方法时,其他线程也访问同步方法,可能会进入阻塞或轮询状态,如使用 put 添加元素,另一个线程不能使用 put 添加元素,也不能使用 get,竞争会越来越激烈效率越低。
JDK 1.7 和 JDK 1.8 的 ConcurrentHashMap 实现有什么不同?
线程安全实现方式:JDK 1.7 采用
Segment
分段锁来保证安全,Segment
是继承自ReentrantLock
。JDK1.8 放弃了Segment
分段锁的设计,采用Node + CAS + synchronized
保证线程安全,锁粒度更细,synchronized
只锁定当前链表或红黑二叉树的首节点。Hash 碰撞解决方法 : JDK 1.7 采用拉链法,JDK1.8 采用拉链法结合红黑树(链表长度超过一定阈值时,将链表转换为红黑树)。
并发度:JDK 1.7 最大并发度是 Segment 的个数,默认是 16。JDK 1.8 最大并发度是 Node 数组的大小,并发度更大。
ConcurrentHashMap 为什么 key 和 value 不能为 null?
ConcurrentHashMap
的 key 和 value 不能为 null 主要是为了避免二义性。null 是一个特殊的值,表示没有对象或没有引用。如果你用 null 作为键,那么你就无法区分这个键是否存在于 ConcurrentHashMap
中,还是根本没有这个键。同样,如果你用 null 作为值,那么你就无法区分这个值是否是真正存储在 ConcurrentHashMap
中的,还是因为找不到对应的键而返回的。
拿 get 方法取值来说,返回的结果为 null 存在两种情况:
值没有在集合中 ;
值本身就是 null。
这也就是二义性的由来。
多线程环境下,存在一个线程操作该 ConcurrentHashMap
时,其他的线程将该 ConcurrentHashMap
修改的情况,所以无法通过 containsKey(key)
来判断否存在这个键值对,也就没办法解决二义性问题了。
ConcurrentHashMap 能保证复合操作的原子性吗?
复合操作是指由多个基本操作(如put
、get
、remove
、containsKey
等)组成的操作,例如先判断某个键是否存在containsKey(key)
,然后根据结果进行插入或更新put(key, value)
。这种操作在执行过程中可能会被其他线程打断,导致结果不符合预期。
例如,有两个线程 A 和 B 同时对 ConcurrentHashMap 进行复合操作,如下:
// 线程 A
if (!map.containsKey(key)) {
map.put(key, value);
}
// 线程 B
if (!map.containsKey(key)) {
map.put(key, anotherValue);
}
如果线程 A 和 B 的执行顺序是这样:
线程 A 判断 map 中不存在 key
线程 B 判断 map 中不存在 key
线程 B 将 (key, anotherValue) 插入 map
线程 A 将 (key, value) 插入 map
那么最终的结果是 (key, value),而不是预期的 (key, anotherValue)。这就是复合操作的非原子性导致的问题。
那如何保证 ConcurrentHashMap
复合操作的原子性呢?
ConcurrentHashMap
提供了一些原子性的复合操作,如 putIfAbsent
、compute
、computeIfAbsent
、computeIfPresent
、merge
等。这些方法都可以接受一个函数作为参数,根据给定的 key 和 value 来计算一个新的 value,并且将其更新到 map 中。
并发编程
什么是线程和进程?
何为进程?
进程是程序的一次执行过程,是系统运行程序的基本单位,进程是动态的。
系统运行一个程序就是一个进程从创建、运行到消亡的过程。
何为线程?
线程是操作系统能够进行运算调度的最小单位,负责执行程序中的任务。
一个进程在其执行的过程中可以产生多个线程。
一个 Java 程序的运行是 main 线程和多个其他线程同时运行。
Java 线程和操作系统线程的关系:现在的 Java 线程的本质其实就是操作系统的线程。
在 Windows 和 Linux 等主流操作系统中,Java 线程采用的是一对一的线程模型,也就是一个 Java 线程对应一个系统内核线程。
请简要描述线程与进程的关系,区别及优缺点?
1、一个进程中包含多个线程,各个线程执行不同的任务
2、不同的进程使用不同的内存空间,一个进程下的所有线程共享当前进程的内存空间(堆和方法区(jdk1.8之后的元空间))
但是每个线程有自己的程序计数器、虚拟机栈和本地方法栈
3、线程更加轻量,线程的上下文切换成本一般比进程的上下文切换成本低,因此线程也叫轻量级进程(上下文切换指一个线程切换到另一个线程)
· Java 运行时数据区域(JDK1.8 之后)
如何创建线程?
1、通过继承Thread类来创建线程对象
2、实现Runnable接口来创建线程对象
3、通过实现Callable接口和FutureTask来创建线程对象
为什么用FutureTask?
因为Thread构造器只能接受实现了Runnable接口的实例对象,而FutureTask实现了Runnable接口
Runnable接口和Callable接口区别?
callable接口的call方法有返回值,而Runnable接口的run方法没有返回值
启动线程时可以使用run方法吗?run()和start()的区别 ?
start方法是用来启动线程的,通过该线程来调用run方法来执行run方法中定义的逻辑代码。start方法只能执行一次。
可以直接调用 Thread 类的 run 方法吗?
可以直接调用run方法,但是直接调用的run方法 会把run方法当作主线程的一个方法,不会以多线程的方式来执行
说说线程的生命周期和状态? @@@@
线程状态的变换
创建线程是新建状态
调用start方法转变为可执行状态
执行结束是终止状态
在可执行状态的过程中
如果没有得到锁,进入锁阻塞状态,得到锁在切换为可执行状态
如果调用了wait方法进入无限等待状态,知道其他线程调用notify/notifyAll方法唤醒之后,可以切换为可执行状态
如果调用了sleep方法,进入计时等待状态,到时间后切换为可执行状态。
如果调用了有参的wait方法,进入计时等待状态,如果时间结束/被其他线程notify唤醒,并且得到锁,就进入可执行状态。如果时间结 束/被其他线程notify唤醒,没有得到锁,进入锁阻塞状态。
sleep方法和wait方法的区别 @@@
sleep()
方法执行完成后,线程会自动苏醒,或者也可以使用 wait(long timeout)
超时后线程会自动苏醒。wait()
方法被调用后,线程不会自动苏醒,需要别的线程调用同一个对象上的 notify()
或者 notifyAll()
方法。
sleep()通常被用于线程的暂停执行。wait()
通常被用于线程间交互/通信,
sleep方法没有释放锁,wait方法释放了锁
sleep()
是 Thread
类的静态方法,wait()
则是 Object
类的方法
多线程
并行,并发的区别
多核cup下:
并发是多个线程轮流使用一个或者多个cpu。
并行是n核cpu同时执行n个线程
新建三个线程,如何保证按顺序执行
join方法:等待线程运行结束
如何退出线程
1、正常退出,即run方法执行完毕后线程终止
2、stop强制终止,不推荐
3、使用interrupt方法中断线程
打断阻塞的线程(sleep、wait、join),线程会抛出中断异常
打断正常的线程,可以根据打断状态(true、false)来标记/判断是否退出线程(一个循环的判断条件 初始时interrupted初始为false)
使用多线程可能带来什么问题?
并发编程的目的就是为了能提高程序的运行效率,但是并发编程并不总是能提高程序运行速度的,而且并发编程可能会遇到很多问题,比如:内存泄漏、死锁、线程不安全等等。
如何理解线程安全和不安全?
线程安全指的是在多线程环境下,对于同一份数据,多个线程同时访问,都能保证这份数据的正确性和一致性。
线程不安全则表示在多线程环境下,对于同一份数据,多个线程同时访问时可能会导致数据混乱、错误或者丢失
什么是死锁 ?
多个进程因竞争资源而导致同时被无限期的阻塞。
如何预防和避免线程死锁?
如何预防死锁? 破坏死锁的产生的必要条件即可:
破坏请求与保持条件:一次性申请所有的资源。
破坏不剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源。
破坏循环等待条件:按某一顺序申请资源,释放资源则反序释放。
死锁产生的条件
互斥条件:即在一段时间内某 资源仅为一个进程所占有
请求与保持条件:一个线程在请求资源阻塞时,对已获得的资源保持不放。
不剥夺条件:线程已获得的资源在未使用完之前不能被其他线程强行剥夺,只有自己使用完毕后主动释放资源。
循环等待条件:若干线程之间形成一种头尾相接的循环等待资源关系。
如何避免死锁?
避免死锁就是在资源分配时,借助于算法(比如银行家算法)对资源分配进行计算评估,使其进入安全状态。
安全状态 指的是系统能够按照某种线程推进顺序(P1、P2、P3……Pn)来为每个线程分配所需资源,直到满足每个线程对资源的最大需求,使每个线程都可顺利完成。称
<P1、P2、P3.....Pn>
序列为安全序列。
JMM模型(java内存模型)
java内存模型,定义了共享内存中多线程程序读写操作的行为规范。通过这些行为规范来规范对内存的读写,从而保证了指令的正确性
JMM把内存分为了两块,一块是线程私有的本地内存,一块是所有线程共享的主内存(里边存放的是共享变量)。线程与线程之间是相互隔离的,他们之间的交互是通过主内存来实现的。(eg:修改共享变量 a 两个线程将共享数据同步到本地内存,t1进行更改,并同步到主内存中,t2再将数据同步到本地内存)
CAS
原子性:一个或者一组操作中要么全部失败,要么全部成功
CAS体现的是一种乐观锁的思想,在无锁的情况下保证 线程操作 共享数据的原子性。
CAS 通过 Unsafe
类中的 native
方法实现(c++语言编写),这些方法调用操作系统底层的硬件指令来完成原子操作
CAS在操作共享数据的时候使用的是自旋锁,效率更高。
CAS使用在AQS框架,AtomicXXX类
线程会先获取当前内存中的值,称为旧值。然后对线程中的旧值进行更新操作,更新完成后得到的值称为新值。然后将旧值与内存中的值进行比较,如果相同,就将内存中的值修改为新值,否则不进行修改。如果CAS操作失败,那就通过自旋的方式进行等待并再次尝试,直到成功
自旋:底层里边是一个while(true)循环,进行的操作就是上述的操作,当CAS操作失败时,它不会退出循环,而是再次执行上述的操作,直到CAS操作成功,才会退出循环。(通常会设置自旋次数,以降低对效率的影响)
悲观锁与乐观锁
乐观锁:总是假设最好的情况,认为线程执行时不会出现任何的问题,线程可以一直执行,无需加锁也无需等待,只是在提交的时候再验证对应的资源是否被其他线程修改(自回旋,效率更高)
悲观锁:总是假设最坏的情况,认为共享资源每次被访问时,总是会出现问题,所以每次在对资源操作时,都会加锁,只给一个和线程使用,其他线程阻塞,用完之后,再把资源转给其他线程。
高并发的场景下,乐观锁相比悲观锁来说,不存在锁竞争造成线程阻塞,也不会有死锁问题,在性能上往往会更胜一筹。但是,如果冲突频繁发生(写占比非常多的情况),会频繁失败并重试,这样同样会非常影响性能,导致 CPU 飙升。
volatile关键字
volatile是一个关键字,可以用来修饰共享变量(成员变量/静态成员变量)。
**volatile
关键字能保证变量的可见性(能够防止编译器优化发生,让一个线程对共享变量的修改对另一个线程可见),但不能保证对变量的操作是原子性的。(synchronized
关键字两者都能保证)
还可以禁止进行指令重排序(阻止JVM的指令重排序)。当将一个变量声明为volatile时,对这个变量进行读写操作的时候,会加入特定的 内存屏障 阻止其他读写操作越过屏障,来实现禁止指令重排序。
synchronized关键字底层原理@@@@@
synchronized采用互斥的方式,让同一时刻只有一个线程能够持有对象锁。底层是由monitor实现的。
当一个线程进入到synchronized修饰的代码块/方法后,会让对象锁与Monitor进行关联(介绍Monitor),检查Monitor中的Owner是否为null,如果为null,则让当前线程持有该对象锁,如果不为null,则要到EntryList中进行阻塞等待。如果线程调用了wait方法,则会进入到WaitSet中等待
synchronized进阶,底层优化,锁升级
锁升级是针对于synchronized锁在不同竞争条件下的一种优化,根据锁在多线程中竞争的程度和状态,synchronized锁可在无锁、偏向锁、轻量级锁和重量级锁之间进行流转,以降低获取锁的成本,提高获取锁的性能。(分别对应锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁)一旦线程之间对锁有了竞争,就会升级为重量级锁
Monitor重量级锁:对象怎么关联上的Monitor?
在使用synchronized给对象上锁(重量级锁)之后,该对象的Mark Word中就设置了执向Monitor对象的指针。
会持有同一把锁(可重入锁)
AQS 抽象队列同步器
AQS 是抽象队列同步器,主要用来构建锁和同步器。它就是一个抽象类, 让其他类继承实现它的方法,比如ReentrantLock、Semaphore
底层实现原理:AQS内部有一个volatile(保证多个线程的可见性)修饰的state,在独占模式下,相当于是一个资源,默认情况下是0,如果一个线程修改state为1,代表这个线程持有了这个资源,其他线程会进入到AQS内部的一个双向队列中进行等待。在共享模式下state的值就代表了当前持有的读锁数量
公平性:上一个线程使用完锁释放之后,线程1获取锁
非公平性:线程5是新来的,线程1是队列中第一个元素,他们两个争夺锁
支持公平锁和非公平锁两种
什么是公平锁与非公平锁
公平锁:锁释放后,先申请的线程先得到锁,性能差一些,因为公平锁为了保证时间上的绝对顺序,上下文切换更加频繁
非公平锁:锁释放后,后申请的线程可能先得到锁,性能更好一些,但是可能导致某些线程永远无法获得锁
ReentrantLock实现原理
ReentrantLock是一个可重入锁(可重入:同一个线程可以多次调用lock这个加锁的方法即一个线程可以多次获得自己的内部锁),相比于synchronized(也可重入)它增加了中断、超时、轮询、公平锁和非公平锁等高级功能
ReentrantLock里面有一个内部类Sync,Sync继承了AQS,添加锁和释放锁,大部分操作都是在Sync中实现的,Sync有公平锁和非公平锁两个子类,默认使用非公平锁,也可以通过构造器显示指定使用公平锁。
synchronized和lock(ReentrantLock)的区别
联系:这两个都是可重入锁,都属于悲观锁:一个线程可以多次获得自己的内部锁
语法层面:
synchronized是关键字,源码在jvm中,用c++实现。
lock是一个接口,(实现类ReentrantLock)由jdk提供,java语言实现的
功能层面:
lock提供了可中断、可超时、公平锁和非公平锁 、多条件变量等高级功能
性能层面
没有竞争时,synchronized可以使用偏向锁/轻量锁,性能较好,竞争激烈时,lock的性能更好一些
java并发编程的三大特征:原子性,可见性、有序性
java程序中怎么保证多线程的安全 ? 导致并发程序出现问题的根本原因是什么?
原子性:一个或一组操作要么全部执行成功,要么全部不执行/执行失败 (synchronized / lock锁)
可见性:让一个线程对共享变量的修改对其他线程可见 (加volite关键字解决)
有序性:有序性是指程序在执行的时候,程序的代码执行顺序和语句的顺序是一致的。(重排序 加volite关键字解决)
什么是线程线程池
管理一些列线程的资源池
为什么要使用线程池?
1、通过重复使用已创建线程可以降低因线程创建和销毁导致的资源消耗
2、当任务到达时,可以不需要等待线程创建就可以立即执行
3、限制 线程创建的数量,减少系统资源消耗
如何创建线程池
方式一:通过ThreadPoolExecutor
构造函数来创建(推荐)。
**方式二:通过 Executor
框架的工具类 Executors
来创建 。**阿里巴巴 Java 开发手册》中强制线程池不允许使用 Executors
去创
线程池的种类建
通过Executors可以创建多种类型的线程
FixedThreadPool
:固定线程数量的线程池。
SingleThreadExecutor
: 只有一个线程的线程池。
CachedThreadPool
: 可根据实际情况调整线程数量的线程池。
ScheduledThreadPool
:给定的延迟后运行任务或者定期执行任务的线程池
为什么不推荐Executors创建线程池
线程池的核心参数/
救济线程—》临时线程
线程工厂:用来创建线程,一般默认即可
workQueue—》阻塞队列
线程池的执行原理
在提交任务时,先判断核心线程是否全部在执行,如果有空闲的,则调用并执行该任务。如果没有,则判断阻塞队列是否已满,如果没有满,就进入到阻塞队列进行等待。如果已经满了,就判断当前线程数是否小于最大线程数,如果小于,就创建临时线程,并调用执行。如果大于最大线程数,则执行拒绝策略。
线程池常见的拒绝策略有哪些?
线程池中常见的阻塞队列有哪些?
ArrayBlockingQueue:基于数组有界阻塞队列,FIFO(先进先出)
LinkedBlockingQueue:基于(单)链表的有界阻塞队列,FIFO(先进先出)
两把锁:头尾各有一把琐,两边可以同时进行操作,互不影响,效率高
如何确定核心线程数
N:当前CPU核数
IO密集型任务(文件读写、网络请求等):核心线程数大小为2N+1
CUP密集型任务(计算型代码):核心线程数:N+1
线程池的使用场景(CountDownLatch’、Future)
CountDownLatch(闭锁/倒计时锁),用来进行线程同步协助。即一个或者多个线程,等待其他多个线程完成某件事情之后才能执行
使用CountDownLatch时,会给一个初始的值,count。然后调用await方法判断count方法是否等于0,如果不等于0,就将线程挂起等待。如果等于0,await才会继续执行。
调用多个接口来汇总数据,如果所有接口(部分接口)没有依赖关系,就可以使用线程池 + future来提升性能
左边时串行执行,右边是并行执行
三个任务在线程池中并行执行,完成后再统一汇总得到结果
对于Future的理解
简单理解就是:我有一个任务,提交给了 Future
来处理。任务执行期间我自己可以去做任何想做的事情。并且,在这期间我还可以取消任务以及获取任务的执行状态。一段时间之后,我就可以 Future
那里直接取出任务执行结果
在 Java 中,Future
类只是一个泛型接口,位于 java.util.concurrent
包下,其中定义了 5 个方法,主要包括下面这 4 个功能:
取消任务;
判断任务是否被取消;
判断任务是否已经执行完成;
获取任务执行结果。
// V 代表了Future执行的任务返回值的类型
public interface Future<V> {
// 取消任务执行
// 成功取消返回 true,否则返回 false
boolean cancel(boolean mayInterruptIfRunning);
// 判断任务是否被取消
boolean isCancelled();
// 判断任务是否已经执行完成
boolean isDone();
// 获取任务执行结果
V get() throws InterruptedException, ExecutionException;
// 指定时间内没有返回计算结果就抛出 TimeOutException 异常
V get(long timeout, TimeUnit unit)
throws InterruptedException, ExecutionException, TimeoutExceptio
}
CompletableFuture 类有什么用?
Future
在实际使用过程中存在一些局限性比如不支持异步任务的编排组合、获取计算结果的 get()
方法为阻塞调用。
Java 8 才被引入CompletableFuture
类可以解决Future
的这些缺陷。CompletableFuture
除了提供了更为好用和强大的 Future
特性之外,还提供了函数式编程、异步任务编排组合(可以将多个异步任务串联起来,组成一个完整的链式调用)等能力。
多线程使用场景三:异步调用
为了避免下一级方法影响上一级方法(性能考虑),可以使用异步线程调用下一个方法(不需要下一个方法的返回值)
简单来说就是我们在使用搜索功能时,会有历史记录产生,要保证历史记录对搜索功能没有影响:(异步保存的方式——–异步线程)
在线程执行搜索时,再在线程中获取一个新的线程执行保存历史记录的任务
如何控制某个方法允许并发访问线程的数量
Semaphore 信号量,是JUC下的一个工具类,底层是AQS,可以通过其限制执行的线程数量
最多只能有三个线程并发执行。只有在这个空间里的线程执行结束释放了信号量,其他线程才能执行
ThreadLocal的理解
ThreadLocal是多线程解决线程安全的一个操作类,它会为每一个线程都分配一个独立的线程副本,从而解决了线程访问变量时 并发访问冲突的问体。ThreadLocal同时实现了线程内的资源共享。(可以理解为线程局部变量。同一份变量在每一个线程中都保存一份副本,线程对该副本的操作对其他线程完全是不可见的,是封闭的。)
如果你创建了一个ThreadLocal
变量,那么访问这个变量的每个线程都会有这个变量的本地副本。他们们可以使用 get()
和 set()
方法来获取默认值或将其值更改为当前线程所存的副本的值,从而避免了线程安全问题。
例子:两个人去宝屋收集宝物,这两个共用一个袋子的话肯定会产生争执,但是给他们两个人每个人分配一个袋子的话就不会出现这样的问题。如果把这两个人比作线程的话,那么 ThreadLocal 就是用来避免这两个线程竞争的
ThreadLocal实现原理,源码解析
Thread本质来说是一个线程内部存储类,从而让多个线程只操作自己内部的值,从而实现线程数据隔离
public class Thread implements Runnable {
//......
//与此线程有关的ThreadLocal值。由ThreadLocal类维护
ThreadLocal.ThreadLocalMap threadLocals = null;
//与此线程有关的InheritableThreadLocal值。由InheritableThreadLocal类维护
ThreadLocal.ThreadLocalMap inheritableThreadLocals = null;
//......
}
Thread类中有两个ThreadMap类型的变量,默认情况下为null。只有当前线程调用 ThreadLocal
类的 set
或get
方法时才创建它们,实际上调用这两个方法的时候,我们调用的是ThreadLocalMap
类对应的 get()
、set()
方法。
每个Thread(线程)
中都具备一个ThreadLocalMap
,而ThreadLocalMap
可以存储以ThreadLocal
为 key ,Object 对象为 value 的键值对。
ThreadLocalMap是ThreadLocal的静态内部类
set方法
最终的变量是放在了当前线程的 ThreadLocalMap
中,并不是存在 ThreadLocal
上,ThreadLocal
可以理解为只是ThreadLocalMap
的封装,传递了变量值。ThrealLocal
类中可以通过Thread.currentThread()
获取到当前线程对象后,直接通过getMap(Thread t)
可以访问到该线程的ThreadLocalMap
对象。
ThreadLocal的内存泄露问题
强引用在任何情况下都不会被回收。弱引用一旦发生垃圾回收就会被干掉(回收)
Entry(键值对对象)
总结
知识点补充:在多线程环境下,ThreadLocal可以避免线程安全问题,但是在使用线程池等多线程环境时,ThreadLocal可能会出现一些问题。例如,当使用线程池时,线程池中的线程可能会被多个任务共享,如果使用ThreadLocal存储数据,可能会导致数据被错误地共享。
eg:在传统的 Servlet 环境中,每个请求都会创建一个新的线程,请求结束后线程就结束了,所以 ThreadLocal 变量会在请求结束时自动清理。但是,在线程池中,线程会被复用,情况就不同了。
考虑以下情况:
任务A:处理用户1的请求,UserSession 被设置为用户1的会话信息。
线程复用:处理完用户1的请求后,线程没有结束,而是被线程池复用。
任务B:同一个线程开始处理用户2的请求,但是 UserSession 仍然是用户1的会话信息,没有被清理。
这就导致了用户2的请求错误地使用了用户1的会话信息,这是一个严重的线程安全问题。
为了解决这个问题,我们需要确保在使用 ThreadLocal 变量时,每次任务结束时都清理这些变量。这可以通过在任务结束时调用 ThreadLocal.remove() 方法来实现:
这样,即使线程被复用,也不会因为 ThreadLocal 变量而导致数据错误地共享。
或者使用 TransmittableThreadLocal是阿里巴巴开源的一个线程本地变量,它是ThreadLocal的一个增强版,可以在线程池等多线程环境下使用,解决了ThreadLocal在多线程环境下的一些问题。
JVM
java内存区域介绍(重点)
运行时数据区域
Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分成若干个不同的数据区域
jdk1.7运行时数据区域
jdk1.8运行时数据区域
线程私有的:
程序计数器
虚拟机栈
本地方法栈
线程共享的:
堆
方法区
直接内存 (非运行时数据区的一部分)
Java 虚拟机规范对于运行时数据区域的规定是相当宽松的。以堆为例:堆可以是连续空间,也可以不连续。堆的大小可以固定,也可以在运行时按需扩展 。虚拟机实现者可以使用任何垃圾回收算法管理堆,甚至完全不进行垃圾收集也是可以的
程序计数器
程序计数器:线程私有的,内部保存的是字节码的行号。用于记录正在执行的字节码指令的地址。
字节码解释器工作时通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等功能都需要依赖这个计数器来完成。
为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存.
程序计数器主要有两个作用:
字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
⚠️ 注意:程序计数器是唯一一个不会出现 OutOfMemoryError
的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡。
java虚拟机栈
每个线程运行时所需要的内存,称为虚拟机栈,先进后出。
与程序计数器一样,Java 虚拟机栈(后文简称栈)也是线程私有的,它的生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡。
除了一些 Native 方法调用是通过本地方法栈实现的,
其他所有的 Java 方法调用都是通过栈来实现的(也需要和其他运行时数据区域比如程序计数器配合)
每个栈由多个栈帧(frame)组成,对应着每次方法调用时所占用的内存。
方法调用的数据需要通过栈进行传递,每一次方法调用都会有一个对应的栈帧被压入栈中,每一个方法调用结束后,都会有一个栈帧被弹出。
栈由一个个栈帧组成,而每个栈帧中都拥有:局部变量表、操作数栈、动态链接、方法返回地址。和数据结构上的栈类似,两者都是先进后出的数据结构,只支持出栈和入栈两种操作。
局部变量表 主要存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用
操作数栈 主要作为方法调用的中转站使用,用于存放方法执行过程中产生的中间计算结果。另外,计算过程中产生的临时变量也会放在操作数栈中。
动态链接 主要服务一个方法需要调用其他方法的场景。(比如说栈帧1中的方法调用了栈帧2中的方法,就通过动态链接来获取其在内存地址中的直接引用)
Class 文件的常量池里保存有大量的符号引用比如方法引用的符号引用。当一个方法要调用其他方法,需要将常量池中指向方法的符号引用转化为其在内存地址中的直接引用。动态链接的作用就是为了将符号引用转换为调用方法的直接引用,这个过程也被称为 动态连接 。
Java 方法有两种返回方式,一种是 return 语句正常返回,一种是抛出异常。不管哪种返回方式,都会导致栈帧被弹出。也就是说, 栈帧随着方法调用而创建,随着方法结束而销毁。无论方法正常完成还是异常完成都算作方法结束。
运行中栈可能出现的两种错误
StackOverFlowError
: 若栈的内存大小不允许动态扩展,栈帧过多或者栈帧过大导致内存溢出,就抛出StackOverFlowError
错误。OutOfMemoryError
: 若栈的内存大小可以动态扩展, 如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError
异常
本地方法栈
和虚拟机栈所发挥的作用非常相似,区别是:虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。 在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。
堆
Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。
从 JDK 1.7 开始已经默认开启逃逸分析,如果某些方法中的对象引用没有被返回或者未被外面使用(也就是未逃逸出去),那么对象可以直接在栈上分配内存。
在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:
新生代内存(Young Generation)
老生代(Old Generation)
永久代(Permanent Generation)
JDK 8 版本之后 PermGen(永久代) 已被 Metaspace(元空间) 取代,元空间使用的是本地内存。
Eden 区:两个 Survivor 区 S0 和 S1 都属于新生代,
中间一层属于老年代,
最下面一层属于永久代。
#java堆为什么要分代
Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代;再细致一点有:Eden、Survivor、Old 等空间。进一步划分的目的是更好地回收内存,或者更快地分配内存。
#对象的年龄增长机制及所在堆中区域
1、大部分情况,对象都会首先在 Eden 区域分配。
2、在一次新生代垃圾回收后,如果对象还存活,则会进入 S0 或者 S1(幸存者区),并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄 变为 1)。
3、当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。(Tenured)
对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。不过,设置的值应该在 0-15,否则会爆出以下错误:
MaxTenuringThreshold of 20 is invalid; must be between 0 and 15
Java堆容易出现的错误:OutOfMemoryError 主要有以下两种形式
java.lang.OutOfMemoryError: GC Overhead Limit Exceeded
:当 JVM 花太多时间执行垃圾回收并且只能回收很少的堆空间时,就会发生此错误。java.lang.OutOfMemoryError: Java heap space
:假如在创建新的对象时, 堆内存中的空间不足以存放新创建的对象, 就会引发此错误。(和配置的最大堆内存有关,且受制于物理内存大小。最大堆内存可通过-Xmx
参数配置,若没有特别配置,将会使用默认值
方法区
方法区属于是 JVM 运行时数据区域的一块逻辑区域,是各个线程共享的内存区域。
在不同的虚拟机实现上,方法区的实现是不同的。
当虚拟机要使用一个类时,它需要读取并解析 Class 文件获取相关信息,再将信息存入到方法区。方法区会存储已被虚拟机加载的 类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据(主要就是类信息、运行时常量池)
方法区和永久代以及元空间是什么关系呢?
方法区和永久代以及元空间的关系很像 Java 中接口和类的关系。
也就是说永久代以及元空间是 HotSpot 虚拟机对虚拟机规范中方法区的两种实现方式。
为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) 呢?
整个永久代有一个 JVM 本身设置的固定大小上限,无法进行调整(也就是受到 JVM 内存的限制),而元空间使用的是本地内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。
当元空间溢出时会得到如下错误:java.lang.OutOfMemoryError: MetaSpace
#方法区常用参数有哪些?
可以使用 -XX:MaxMetaspaceSize=N 设置最大元空间大小,默认值为 unlimited,这意味着它只受系统内存的限制。
-XX:MetaspaceSize=N 调整定义元空间的初始大小如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。
运行时常量池
常量池表:存放编译期生成的各种字面量和符号引用。虚拟机指令会根据这张常量表找到要执行的类名、方法名、参数类型、字面量等
Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有用于存放编译期生成的各种字面量(Literal)和符号引用(Symbolic Reference)的 常量池表(Constant Pool Table) 。
字面量包括整数、浮点数和字符串字面量。
常见的符号引用包括类符号引用、字段符号引用、方法符号引用、接口方法符号。
常量池表会在类加载后存放到方法区的运行时常量池中。
当常量池无法再申请到内存时会抛出 OutOfMemoryError 错误
字符串常量池
JDK1.7 之前,字符串常量池存放在永久代。JDK1.7 字符串常量池和静态变量从永久代移动了 Java 堆中
字符串常量池 是 JVM 为了提升性能和减少内存消耗针对字符串(String 类)专门开辟的一块区域,主要目的是为了避免字符串的重复创建
// 在字符串常量池中创建字符串对象 ”ab“
// 将字符串对象 ”ab“ 的引用赋值给给 aa
String aa = "ab";
// 直接返回字符串常量池中字符串对象 ”ab“,赋值给引用 bb
String bb = "ab";
System.out.println(aa==bb); // true
JVM 常量池中存储的是对象还是引用呢?
字面量赋值
我们把上面的那个实例代码拿过来
String s1 = "古时的风筝";
这是我们平时声明字符串变量的最常用的方式,这种方式叫做字面量声明,也就用把字符串用双引号引起来,然后赋值给一个变量。
这种情况下会直接将字符串放到字符串常量池中,然后返回给变量。
那这是我再声明一个内容相同的字符串,会发现字符串常量池中已经存在了,那直接指向常量池中的地址即可。
例如上图所示,声明了 s1 和 s2,到最后都是指向同一个常量池的地址,所以 s1== s2 的结果是 true。
new String() 方式
与之对应的是用 new String() 的方式,但是基本上不建议这么用,除非有特殊的逻辑需要。
String a = "古时的";
String s2 = new String(a + "风筝");
使用这种方式声明字符串变量的时候,会有两种情况发生。
第一种情况,字符串常量池之前已经存在相同字符串
比如在使用 new 之前,已经用字面量声明的方式声明了一个变量,此时字符串常量池中已经存在了相同内容的字符串常量。
首先会在堆中创建一个 s2 变量的对象引用;
然后将这个对象引用指向字符串常量池中的已经存在的常量;
第二种情况,字符串常量池中不存在相同内容的常量
之前没有任何地方用到了这个字符串,第一次声明这个字符串就用的是 new String() 的方式,这种情况下会直接在堆中创建一个字符串对象然后返回给变量。
我看到好多地方说,如果字符串常量池中不存在的话,就先把字符串先放进去,然后再引用字符串常量池的这个常量对象,这种说法是有问题的,只是 new String() 的话,如果池中没有也不会放一份进去。
基于 new String() 的这种特性,我们可以得出一个结论:
String s1 = "古时的风筝";
String a = "古时的";
String s2 = new String(a + "风筝");
String s3 = new String(a + "风筝");
System.out.println(s1==s2); // false
System.out.println(s2==s3); // false
以上代码,肯定输出的都是 false,因为 new String() 不管你常量池中有没有,我都会在堆中新建一个对象,新建出来的对象,当然不会和其他对象相等。
intern() 池化
那什么时候会放到字符串常量池呢,就是在使用 intern() 方法之后。
intern() 的定义:如果当前字符串内容存在于字符串常量池,存在的条件是使用 equas() 方法为ture,也就是内容是一样的,那直接返回此字符串在常量池的引用;如果之前不在字符串常量池中,那么在常量池创建一个引用并且指向堆中已存在的字符串,然后返回常量池中的地址。
第一种情况,准备池化的字符串与字符串常量池中的字符串有相同(equas()判断)
String s1 = "古时的风筝";
String a = "古时的";
String s2 = new String(a + "风筝");
s2 = s2.intern();
这时,这个字符串常量已经在常量池存在了,这时,再 new 了一个新的对象 s2,并在堆中创建了一个相同字符串内容的对象。
这时,s1 == s2 会返回 fasle。然后我们调用 s2 = s2.intern(),将池化操作返回的结果赋值给 s2,就会发生如下的变化。
此时,再次判断 s1 == s2 ,就会返回 true,因为它们都指向了字符串常量池的同一个字符串。
第二种情况,字符串常量池中不存在相同内容的字符串
使用 new String() 在堆中创建了一个字符串对象
使用了 intern() 之后发生了什么呢,在常量池新增了一个对象,但是 并没有 将字符串复制一份到常量池,而是直接指向了之前已经存在于堆中的字符串对象。因为在 JDK 1.7 之后,字符串常量池不一定就是存字符串对象的,还有可能存储的是一个指向堆中地址的引用,现在说的就是这种情况,注意了,下图是只调用了 s2.intern()
,并没有返回给一个变量。其中字符串常量池(0x88)指向堆中字符串对象(0x99)就是intern() 的过程。
只有当我们把 s2.intern() 的结果返回给 s2 时,s2 才真正的指向字符串常量池。
通过以上的介绍,我们来看下面的一段代码返回的结果是什么
public class Test {
public static void main(String[] args) {
String s1 = "古时的风筝";
String s2 = "古时的风筝";
String a = "古时的";
String s3 = new String(a + "风筝");
String s4 = new String(a + "风筝");
System.out.println(s1 == s2); // 【1】 true
System.out.println(s2 == s3); // 【2】 false
System.out.println(s3 == s4); // 【3】 false
s3.intern();
System.out.println(s2 == s3); // 【4】 false
s3 = s3.intern();
System.out.println(s2 == s3); // 【5】 true
s4 = s4.intern();
System.out.println(s3 == s4); // 【6】 true
}
}
【1】:s1 == s2 返回 ture,因为都是字面量声明,全都指向字符串常量池中同一字符串。
【2】: s2 == s3 返回 false,因为 new String() 是在堆中新建对象,所以和常量池的常量不相同。
【3】: s3 == s4 返回 false,都是在堆中新建对象,所以是两个对象,肯定不相同。
【4】: s2 == s3 返回 false,前面虽然调用了 intern() ,但是没有返回,不起作用。
【5】: s2 == s3 返回 ture,前面调用了 intern() ,并且返回给了 s3 ,此时 s2、s3 都直接指向常量池的同一个字符串。
【6】: s3 == s4 返回 true,和 s3 相同,都指向了常量池同一个字符串。
直接内存
直接内存并不属于JVM的内存结构,不由JVM进行管理。是虚拟机的系统内存(属于操作系统),常见于NIO的操作,用于数据缓冲区,他分配回收成本比较高,但是读写性能好
少了拷贝操作,效率更高
JDK1.4 中新加入的 NIO(Non-Blocking I/O,也被称为 New I/O),引入了一种基于通道(Channel)与缓存区(Buffer)的 I/O 方式,它可以直接使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样就能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆之间来回复制数据。
HotSpot虚拟机
对象的创建
Step1:类加载检查
虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已被加载过、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
Step2:分配内存
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。
内存分配的两种方式 (补充内容,需要掌握):
指针碰撞:
适用场合:堆内存规整(即没有内存碎片)的情况下。
原理:用过的内存全部整合到一边,没有用过的内存放在另一边,中间有一个分界指针,只需要向着没用过的内存方向将该指针移动对象内存大小位置即可。
使用该分配方式的 GC 收集器:Serial, ParNew
空闲列表:
适用场合:堆内存不规整的情况下。
原理:虚拟机会维护一个列表,该列表中会记录哪些内存块是可用的,在分配的时候,找一块儿足够大的内存块儿来划分给对象实例,最后更新列表记录。
使用该分配方式的 GC 收集器:CMS
选择以上两种方式中的哪一种,取决于 Java 堆内存是否规整。而 Java 堆内存是否规整,取决于 GC 收集器的算法是"标记-清除",还是"标记-整理"(也称作"标记-压缩"),值得注意的是,复制算法内存也是规整的。
内存分配并发问题(补充内容,需要掌握)
在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:
CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
TLAB(线程本地分配缓冲区): 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配
Step3:初始化零值
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
Step4:设置对象头
初始化零值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
Step5:执行 init 方法
在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init>
方法还没有执行,所有的字段都还为零。所以一般来说,执行 new 指令之后会接着执行 <init>
方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
对象的内存布局
在 Hotspot 虚拟机中,对象在内存中的布局可以分为 3 块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
对象头包括两部分信息:
标记字段(Mark Word):用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等等。
类型指针(Klass Word):对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。
对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全
对象的访问定位
建立对象就是为了使用对象,我们的 Java 程序通过栈上的 reference 数据来操作堆上的具体对象。对象的访问方式由虚拟机实现而定,目前主流的访问方式有:使用句柄、直接指针。
句柄
如果使用句柄的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息。
直接指针
如果使用直接指针访问,reference 中存储的直接就是对象的地址。
JVM垃圾回收(重点)
前言
Java 的自动内存管理主要是针对对象内存的回收和对象内存的分配。同时,Java 自动内存管理最核心的功能是 堆 内存中对象的分配与回收。
Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)。
从垃圾回收的角度来说,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆被划分为了几个不同的区域,这样我们就可以根据各个区域的特点选择合适的垃圾收集算法。
对象什么时候可以被垃圾回收
如果一个对象没有任何的引用指向它了,那么这个对象现在就是垃圾,如果定位了垃圾,就有可能被垃圾回收器回收。
内存分配和回收原则
对象优先在新生代中Eden区分配
大多数情况下,对象在新生代中 Eden 区分配。
背景:allocation1已经填满了Eden区,allocation2进行分配时Eden区没有多余空间可以分配
当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC。GC 期间虚拟机又发现 allocation1
无法存入 Survivor 空间,所以只好通过 分配担保机制 把新生代的对象提前转移到老年代中去,老年代上的空间足够存放 allocation1
,所以不会出现 Full GC(如果不能存放allocation1
,就会Full GC)。执行 Minor GC 后,后面分配的对象如果能够存在 Eden 区的话,还是会在 Eden 区分配内存。
详细图解见分代收集算法
大对象直接进入老年代
大对象就是需要大量连续内存空间的对象(比如:字符串、数组)
长期存活的对象将进入老年代
虚拟机给每个对象一个对象年龄(Age)计数器
既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点.
大部分情况,对象都会首先在 Eden 区域分配。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间(s0 或者 s1)中,并将对象年龄设为 1(Eden 区->Survivor 区后对象的初始年龄变为 1)。
对象在 Survivor 中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。
主要进行GC的区域
针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:
部分收集 (Partial GC):
新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。
整堆收集 (Full GC):收集整个 Java 堆和方法区
空间分配担保机制
空间分配担保是为了确保在 Minor GC 之前老年代本身还有容纳新生代所有对象的剩余空间。
jdk6之后:只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行 Full GC
死亡对象/垃圾的判断方法
堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)
引用计数法
给对象中添加一个引用计数器,一个对象被引用了一次,计数器就加1。每当引用失效,计数器就减1。当这个对象的引用次数为0,代表这个对象可回收
缺点:容易发生循环引用,导致内存泄漏
可达性分析算法
现在虚拟机都是采用可达性分析算法来确定那些内容是垃圾
算法思想:扫描堆中的对象,看它是否能够沿着GC Root对象为起点的引用链找到该对象,找不到,表示可回收。
eg;X、Y可回收
哪些对象可以作为 GC Roots 呢?
虚拟机栈(栈帧中的局部变量表)中引用的对象
方法区中类静态属性引用的对象
方法区中常量引用的对象
本地方法栈(Native 方法)中引用的对象
所有被同步锁持有的对象
JNI(Java Native Interface)引用的对象
主要是前三种,下图分别对应1(demo)、2(b.a)、3(a)
补充内容
//对象可以被回收,就代表一定会被回收吗?
即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。
被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。
Object 类中的 finalize 方法一直被认为是一个糟糕的设计,成为了 Java 语言的负担,影响了 Java 语言的安全和 GC 的性能。JDK9 版本及后续版本中各个类中的 finalize 方法会被逐渐弃用移除。
引用类型总结
无论是通过引用计数法判断对象引用数量,还是通过可达性分析法判断对象的引用链是否可达,判定对象的存活都与“引用”有关。
强引用、软引用、弱引用、虚引用四种(引用强度逐渐减弱)
1.强引用(StrongReference)
以前我们使用的大部分引用实际上都是强引用,这是使用最普遍的引用。如果一个对象具有强引用,那就类似于必不可少的生活用品,垃圾回收器绝不会回收它。当内存空间不足,Java 虚拟机宁愿抛出 OutOfMemoryError 错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。
2.软引用(SoftReference)
如果一个对象只具有软引用,那就类似于可有可无的生活用品。如果内存空间足够,垃圾回收器就不会回收它,如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。
软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收,JAVA 虚拟机就会把这个软引用加入到与之关联的引用队列中。
3.弱引用(WeakReference)
如果一个对象只具有弱引用,那就类似于可有可无的生活用品。弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程, 因此不一定会很快发现那些只具有弱引用的对象。
弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java 虚拟机就会把这个弱引用加入到与之关联的引用队列中。
4.虚引用(PhantomReference)
"虚引用"顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。
虚引用主要用来跟踪对象被垃圾回收的活动。
注意:在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory)等问题的产生。
如何判断一个常量是废弃常量
运行时常量池主要回收的是废弃的常量。
1. **JDK1.7 之前运行时常量池逻辑包含字符串常量池存放在方法区, 此时 hotspot 虚拟机对方法区的实现为永久代**
2. **JDK1.7 字符串常量池被从方法区拿到了堆中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是 hotspot 中的永久代** 。
3. **JDK1.8 hotspot 移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace)**
假如在字符串常量池中存在字符串 “abc”,如果当前没有任何 String 对象引用该字符串常量的话,就说明常量 “abc” 就是废弃常量,如果这时发生内存回收的话而且有必要的话,“abc” 就会被系统清理出常量池了
如何判断一个类是无用的类
方法区主要回收的是无用的类
满足一下三个条件的类可以被虚拟机回收,但是不一定被回收
该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
加载该类的
ClassLoader
已经被回收。该类对应的
java.lang.Class
对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
垃圾收集算法
标记-清除算法
将垃圾回收分为两个阶段分别是标记和清除。首先根据可达性分析算法标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。
问题:
效率问题:标记和清除两个过程效率都不高。
空间问题:标记清除后会产生大量不连续的内存碎片。
标记整理算法
标记-整理(Mark-and-Compact)算法是根据老年代的特点提出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。
问题:
由于多了整理这一步,因此效率也不高,适合老年代这种垃圾回收频率不是很高的场景
复制算法
为了解决标记-清除算法的效率和内存碎片问题,复制(Copying)收集算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。
问题:
可用内存变小:可用内存缩小为原来的一半。
不适合老年代:如果存活对象数量比较大,复制性能会变得很差
分代收集算法
当前虚拟机的垃圾收集都采用分代收集算法,这种算法只是根据对象存活周期的不同将内存分为几块。一般将 Java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。
比如在新生代中,每次收集都会有大量对象死去,所以可以选择“复制”算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。
延伸面试问题: HotSpot 为什么要分为新生代和老年代?
根据上面的对分代收集算法的介绍回答
eg:现在只有A对象存活
A熬过了15次 / 幸村区内存不足 其会进入老年区
垃圾收集器
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
直到现在为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。
JDK 默认垃圾收集器(使用 java -XX:+PrintCommandLineFlags -version
命令查看):
JDK 8: Parallel Scavenge(新生代)+ Parallel Old(老年代)
JDK 9 ~ JDK22: G1
串行垃圾收集器
并行垃圾收集器
并发垃圾收集器
初始标记会标记跟GC Roots直接关联的对象,并发标记会找到当前引用链的所有对象。
重新标记:起到一个再次确认的作用。当在进行并发标记的时候,原本被认定为垃圾的对象可能会出现新的引用,或者原本存活的对象在并发标记阶段成为了垃圾对象
G1垃圾收集器
G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器. 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征
swt机制:在进行垃圾回收和线程安全点操作时,会暂停所有应用程序线程的执行
三个阶段
新生代回收(stw)
将整个堆划分为了大小相同的区域,初始时,所有的区域都属于空闲状态。当创建对象时,就会挑出一些空闲的区域作为伊甸园来存储这些对象。当伊甸园区放满时(需要垃圾回收),会挑出一个空闲区作为幸存区,用复制算法复制存活对象到幸存区,其他区域的空间就释放掉。(使用复制算法时会暂停用户线程 –> stw)
伊甸园区和上一次的幸存者区的内存就可以释放
并发标记(重新标记stw)
在老年代中找到存活的对象,并给他们加上标记(这个过程是并发执行的,不会暂停用户的线程)
混合收集
类文件结构详讲
回顾字节码
在 Java 中,JVM 可以理解的代码就叫做字节码
(即扩展名为 .class
的文件),它不面向任何特定的处理器,只面向虚拟机。Java 语言通过字节码的方式,在一定程度上解决了传统解释型语言执行效率低的问题,同时又保留了解释型语言可移植的特点。所以 Java 程序运行时比较高效,而且,由于字节码并不针对一种特定的机器,因此,Java 程序无须重新编译便可在多种不同操作系统的计算机上运行
可以说.class
文件是不同的语言在 Java 虚拟机之间的重要桥梁,同时也是支持 Java 跨平台很重要的一个原因
Class文件结构总结
根据 Java 虚拟机规范,Class 文件通过 ClassFile
定义,有点类似 C 语言的结构体。
ClassFile
的结构如下:
ClassFile {
u4 magic; //Class 文件的标志
u2 minor_version;//Class 的小版本号
u2 major_version;//Class 的大版本号
u2 constant_pool_count;//常量池的数量
cp_info constant_pool[constant_pool_count-1];//常量池
u2 access_flags;//Class 的访问标记
u2 this_class;//当前类
u2 super_class;//父类
u2 interfaces_count;//接口数量
u2 interfaces[interfaces_count];//一个类可以实现多个接口
u2 fields_count;//字段数量
field_info fields[fields_count];//一个类可以有多个字段
u2 methods_count;//方法数量
method_info methods[methods_count];//一个类可以有个多个方法
u2 attributes_count;//此类的属性表中的属性数
attribute_info attributes[attributes_count];//属性表集合
}
class文件组成
通过 IDEA 插件 jclasslib
查看的,你可以更直观看到 Class 文件结构
使用 jclasslib
不光可以直观地查看某个类对应的字节码文件,还可以查看类的基本信息、常量池、接口、属性、方法等信息。
魔数(Magic Number)
u4 magic; //Class 文件的标志
每个 Class 文件的头 4 个字节称为魔数(Magic Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接收的 Class 文件。Java 规范规定魔数为固定值:0xCAFEBABE。如果读取的文件不是以这个魔数开头,Java 虚拟机将拒绝加载它。
Class 文件版本号(Minor&Major Version)
u2 minor_version;//Class 的小版本号
u2 major_version;//Class 的大版本号
紧接着魔数的四个字节存储的是 Class 文件的版本号:第 5 和第 6 个字节是次版本号,第 7 和第 8 个字节是主版本号。
每当 Java 发布大版本(比如 Java 8,Java9)的时候,主版本号都会加 1。你可以使用 javap -v
命令来快速查看 Class 文件的版本号信息。
高版本的 Java 虚拟机可以执行低版本编译器生成的 Class 文件,但是低版本的 Java 虚拟机不能执行高版本编译器生成的 Class 文件。所以,我们在实际开发的时候要确保开发的的 JDK 版本和生产环境的 JDK 版本保持一致
常量池(Constant Pool)
u2 constant_pool_count;//常量池的数量
cp_info constant_pool[constant_pool_count-1];//常量池
紧接着主次版本号之后的是常量池,常量池的数量是 constant_pool_count-1
(常量池计数器是从 1 开始计数的,将第 0 项常量空出来是有特殊考虑的,索引值为 0 代表“不引用任何一个常量池项”)。
常量池主要存放两大常量:字面量和符号引用。字面量比较接近于 Java 语言层面的的常量概念,如文本字符串、声明为 final 的常量值等。而符号引用则属于编译原理方面的概念。包括下面三类常量:
类和接口的全限定名
字段的名称和描述符
方法的名称和描述符
常量池中每一项常量都是一个表,这 14 种表有一个共同的特点:开始的第一位是一个 u1 类型的标志位 -tag 来标识常量的类型,代表当前这个常量属于哪种常量类型.
访问标志(Access Flags)
u2 access_flags;//Class 的访问标记
在常量池结束之后,紧接着的两个字节代表访问标志,这个标志用于识别一些类或者接口层次的访问信息,包括:这个 Class 是类还是接口,是否为 public
或者 abstract
类型,如果是类的话是否声明为 final
等等。
当前类(This Class)、父类(Super Class)、接口(Interfaces)索引集合
u2 this_class;//当前类
u2 super_class;//父类
u2 interfaces_count;//接口数量
u2 interfaces[interfaces_count];//一个类可以实现多个接口
Java 类的继承关系由类索引、父类索引和接口索引集合三项确定。类索引、父类索引和接口索引集合按照顺序排在访问标志之后,
类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名,由于 Java 语言的单继承,所以父类索引只有一个,除了 java.lang.Object
之外,所有的 Java 类都有父类,因此除了 java.lang.Object
外,所有 Java 类的父类索引都不为 0。
接口索引集合用来描述这个类实现了那些接口,这些被实现的接口将按 implements
(如果这个类本身是接口的话则是extends
) 后的接口顺序从左到右排列在接口索引集合中
字段表集合(Fields)
u2 fields_count;//字段数量
field_info fields[fields_count];//一个类会可以有个字段
字段表(field info)用于描述接口或类中声明的变量。字段包括类级变量以及实例变量,但不包括在方法内部声明的局部变量。
access_flags: 字段的作用域(
public
,private
,protected
修饰符),是实例变量还是类变量(static
修饰符),可否被序列化(transient 修饰符),可变性(final),可见性(volatile 修饰符,是否强制从主内存读写)。name_index: 对常量池的引用,表示的字段的名称;
descriptor_index: 对常量池的引用,表示字段和方法的描述符;
attributes_count: 一个字段还会拥有一些额外的属性,attributes_count 存放属性的个数;
attributes[attributes_count]: 存放具体属性具体内容。
方法表集合(Methods)
u2 methods_count;//方法数量
method_info methods[methods_count];//一个类可以有个多个方法
methods_count 表示方法的数量,而 method_info 表示方法表。
Class 文件存储格式中对方法的描述与对字段的描述几乎采用了完全一致的方式。方法表的结构如同字段表一样,依次包括了访问标志、名称索引、描述符索引、属性表集合几项。
注意:因为volatile
修饰符和transient
修饰符不可以修饰方法,所以方法表的访问标志中没有这两个对应的标志,但是增加了synchronized
、native
、abstract
等关键字修饰方法,所以也就多了这些关键字对应的标志
属性表集合(Attributes)
u2 attributes_count;//此类的属性表中的属性数
attribute_info attributes[attributes_count];//属性表集合
在 Class 文件,字段表,方法表中都可以携带自己的属性表集合,以用于描述某些场景专有的信息。与 Class 文件中其它的数据项目要求的顺序、长度和内容不同,属性表集合的限制稍微宽松一些,不再要求各个属性表具有严格的顺序,并且只要不与已有的属性名重复,任何人实现的编译器都可以向属性表中写 入自己定义的属性信息,Java 虚拟机运行时会忽略掉它不认识的属性
类加载过程
类的生命周期
类从被加载到虚拟机内存中开始到卸载出内存为止,它的整个生命周期可以简单概括为 7 个阶段:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)。其中,验证、准备和解析这三个阶段可以统称为连接(Linking)
类加载过程
Class 文件需要加载到虚拟机中之后才能运行和使用,那么虚拟机是如何加载这些 Class 文件呢?
系统加载 Class 类型的文件主要三步:加载->连接->初始化。连接过程又可分为三步:验证->准备->解析。
加载
类加载过程的第一步,主要完成下面 3 件事情:
通过全类名获取定义此类的二进制字节流。
将类的二进制数据流转换为方法区的运行时数据结构。
在内存中创建一个代表该类的
Class
对象,作为方法区这个类的各种数据的访问入口。
加载这一步主要是通过我们后面要讲到的 类加载器 完成的。
类加载器有很多种,当我们想要加载一个类的时候,具体是哪个类加载器加载由 双亲委派模型 决定(不过,我们也能打破由双亲委派模型)。
每个 Java 类都有一个引用指向加载它的 ClassLoader。
不过,数组类不是通过 ClassLoader 创建的,而是 JVM 在需要的时候自动创建的,数组类通过getClassLoader()方法获取 ClassLoader 的时候和该数组的元素类型的 ClassLoader 是一致的。
加载阶段与连接阶段的部分动作(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未结束,连接阶段可能就已经开始了。
验证
验证是连接阶段的第一步,这一阶段的目的是确保 Class 文件的字节流中包含的信息符合《Java 虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
验证阶段主要由四个检验阶段组成:
文件格式验证(Class 文件格式检查)
元数据验证(字节码语义检查)
字节码验证(程序语义检查)
符号引用验证(类的正确性检查)
前三个都是格式检查:文件格式是否错误、语法是否错误、字节码是否合规
第四个:Class文件在其常量池会通过字符串记录自己将要使用的其他类或者方法,检查他们是否存在。
//符号引用验证发生在类加载过程中的解析阶段,具体点说是 JVM 将符号引用转化为直接引用的时候(解析阶段会介绍符号引用和直接引用)。
//符号引用验证的主要目的是确保解析阶段能正常执行,如果无法通过符号引用验证,JVM 会抛出异常,比如:
java.lang.IllegalAccessError:当类试图访问或修改它没有权限访问的字段,或调用它没有权限访问的方法时,抛出该异常。
java.lang.NoSuchFieldError:当类试图访问或修改一个指定的对象字段,而该对象不再包含该字段时,抛出该异常。
java.lang.NoSuchMethodError:当类试图访问一个指定的方法,而该方法不存在时,抛出该异常。
准备
准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。
注意事项:
这时候进行内存分配的仅包括类变量(静态变量),而不包括实例变量。实例变量会在对象实例化时随着对象一块分配在 Java 堆中。
2.这里所设置的初始值"通常情况"下是数据类型默认的零值(如 0、0L、null、false 等),比如我们定义了
public static int value=111
,那么 value 变量在准备阶段的初始值就是 0 而不是 111(初始化阶段才会赋值)。特殊情况:比如给 value 变量加上了 final 关键字public static final int value=111
,那么准备阶段 value 的值就被赋值为 111。如果是引用类型变量由final修饰,赋值会在初始化阶段完成。
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符 7 类符号引用进行。
比如:方法中调用了其他方法,方法名可以理解为符号引用,而直接引用就是使用指针直接指向方法。
举个例子:在程序执行方法时,系统需要明确知道这个方法所在的位置。Java 虚拟机为每个类都准备了一张方法表来存放类中所有的方法。当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法了。通过解析操作符号引用就可以直接转变为目标方法在类中方法表的位置,从而使得方法可以被调用。
综上,解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,也就是得到类或者字段、方法在内存中的指针或者偏移量
初始化
初始化阶段是执行初始化方法 <clinit> ()
方法的过程,是类加载的最后一步,这一步 JVM 才开始真正执行类中定义的 Java 程序代码(字节码)。
说明:
<clinit> ()
方法是编译之后自动生成的。
对于<clinit> ()
方法的调用,虚拟机会自己确保其在多线程环境中的安全性。因为 <clinit> ()
方法是带锁线程安全,所以在多线程环境下进行类初始化的话可能会引起多个线程阻塞,并且这种阻塞很难被发现。
对于初始化阶段,虚拟机严格规范了有且只有 6 种情况下,必须对类进行初始化(只有主动去使用类才会初始化类):
当遇到
new
、getstatic
、putstatic
或invokestatic
这 4 条字节码指令时,比如new
一个类,读取一个静态字段(未被 final 修饰)、或调用一个类的静态方法时。当 jvm 执行
new
指令时会初始化类。即当程序创建一个类的实例对象。当 jvm 执行
getstatic
指令时会初始化类。即程序访问类的静态变量(不是静态常量,常量会被加载到运行时常量池)。当 jvm 执行
putstatic
指令时会初始化类。即程序给类的静态变量赋值。当 jvm 执行
invokestatic
指令时会初始化类。即程序调用类的静态方法。
使用
java.lang.reflect
包的方法对类进行反射调用时如Class.forName("...")
,newInstance()
等等。如果类没初始化,需要触发其初始化。初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。
当虚拟机启动时,用户需要定义一个要执行的主类 (包含
main
方法的那个类),虚拟机会先初始化这个类。MethodHandle
和VarHandle
可以看作是轻量级的反射调用机制,而要想使用这 2 个调用,
就必须先使用findStaticVarHandle
来初始化要调用的类。当一个接口中定义了 JDK8 新加入的默认方法(被 default 关键字修饰的接口方法)时,如果有这个接口的实现类发生了初始化,那该接口要在其之前被初始化。
类卸载过程
卸载类即该类的 Class 对象被 GC。
卸载类需要满足 3 个要求:
该类的所有的实例对象都已被 GC,也就是说堆不存在该类的实例对象。
该类没有在其他任何地方被引用
该类的类加载器的实例已被 GC
所以,在 JVM 生命周期内,由 jvm 自带的类加载器加载的类是不会被卸载的。但是由我们自定义的类加载器加载的类是可能被卸载的。
只要想通一点就好了,JDK 自带的 `BootstrapClassLoader`, `ExtClassLoader`, `AppClassLoader` 负责加载 JDK 提供的类,所以它们(类加载器的实例)肯定不会被回收。而我们自定义的类加载器的实例是可以被回收的,所以使用我们自定义加载器加载的类是可以被卸载掉的。
总结
类加载器详解(重点)
类加载器
类加载器介绍
类加载器赋予了 Java 类可以被动态加载到 JVM 中并执行的能力。
类加载器是一个负责加载类的对象。ClassLoader 是一个抽象类。给定类的二进制名称,类加载器应尝试定位或生成构成类定义的数据。典型的策略是将名称转换为文件名,然后从文件系统中读取该名称的“类文件”。
每个 Java 类都有一个引用指向加载它的 ClassLoader。不过,数组类不是通过 ClassLoader 创建的,而是 JVM 在需要的时候自动创建的,数组类通过getClassLoader()方法获取 ClassLoader 的时候和该数组的元素类型的 ClassLoader 是一致的
类加载器是一个负责加载类的对象,用于实现类加载过程中的加载这一步。
每个 Java 类都有一个引用指向加载它的
ClassLoader
。数组类不是通过
ClassLoader
创建的(数组类没有对应的二进制字节流),是由 JVM 直接生成的
类加载器的主要作用就是加载 Java 类的字节码( .class
文件)到 JVM 中(在内存中生成一个代表该类的 Class
对象)
类加载器的加载规则
JVM 启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载。也就是说,大部分类在具体用到的时候才会去加载,这样对内存更加友好。
对于已经加载的类会被放在 ClassLoader
中。在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。也就是说,对于一个类加载器来说,相同二进制名称的类只会被加载一次。
类加载器总结
JVM 中内置了三个重要的 ClassLoader
:
BootstrapClassLoader
(启动类加载器):最顶层的加载类,由 C++实现,通常表示为 null,并且没有父级,主要用来加载 JDK 内部的核心类库(%JAVA_HOME%/lib
目录下的rt.jar
、resources.jar
、charsets.jar
等 jar 包和类)以及被-Xbootclasspath
参数指定的路径下的所有类。ExtensionClassLoader
(扩展类加载器):主要负责加载%JRE_HOME%/lib/ext
目录下的 jar 包和类以及被java.ext.dirs
系统变量所指定的路径下的所有类。AppClassLoader
(应用程序类加载器):面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类CustomClassLoader(自定义类加载器):就比如说,我们可以对 Java 类的字节码(
.class
文件)进行加密,加载时再利用自定义的类加载器对其解密。
除了 BootstrapClassLoader 是 JVM 自身的一部分之外,其他所有的类加载器都是在 JVM 外部实现的,并且全都继承自 ClassLoader抽象类。这样做的好处是用户可以自定义类加载器,以便让应用程序自己决定如何去获取所需的类
每个 ClassLoader 可以通过getParent()获取其父 ClassLoader,如果获取到 ClassLoader 为null的话,那么该类是通过 BootstrapClassLoader 加载的。
public abstract class ClassLoader {
...
// 父加载器
private final ClassLoader parent;
@CallerSensitive
public final ClassLoader getParent() {
//...
}
...
}
为什么 获取到 ClassLoader 为null就是 BootstrapClassLoader 加载的呢?
这是因为BootstrapClassLoader 由 C++ 实现,由于这个 C++ 实现的类加载器在 Java 中是没有与之对应的类的,所以拿到的结果是 null。
自定义类加载器
如果我们要自定义自己的类加载器,很明显需要继承 ClassLoader
抽象类。
除了 `BootstrapClassLoader` 其他类加载器均由 Java 实现且全部继承自`java.lang.ClassLoader`。
ClassLoader
类有两个关键的方法:
protected Class loadClass(String name, boolean resolve)
:加载指定二进制名称的类,实现了双亲委派机制 。name
为类的二进制名称,resolve
如果为 true,在加载时调用resolveClass(Class<?> c)
方法解析该类。protected Class findClass(String name)
:根据类的二进制名称来查找类,默认实现是空方法。
如果我们不想打破双亲委派模型,就重写 ClassLoader 类中的 findClass() 方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass() 方法
双亲委派模型
双亲委派模型介绍
类加载器有很多种,当我们想要加载一个类的时候,具体是哪个类加载器加载呢?这就需要提到双亲委派模型了。
ClassLoader
类使用委托模型来搜索类和资源。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器。
ClassLoader
实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器。
通俗的讲:加载某一个类,先委托上一级的加载器进行加载,如果上一级加载器也有上级,则会继续向上委托,如果该类委托上级没有被加载,子类加载器尝试加载该类
类加载器中的图就体现了这样的模型
注意 ⚠️:双亲委派模型并不是一种强制性的约束,只是 JDK 官方推荐的一种方式。如果我们因为某些特殊需求想要打破双亲委派模型,也是可以的
双亲委派模型流程
eg:以下图为例
自己编写的student类在AppClassLoader`(应用程序类加载器)中加载,他会委托上级加载器加载器(ExClassLoader)扩展类加载器。扩展类加载器也有上级,他会继续向上委托,找到了启动类加载器。但是Student类没有在lib,ext目录下。这两个加载器并不能加载这个类,这个时候才会交给应用程序类加载器进行加载。
对于String类,在启动类加载器中加载(lib中有这个类),然后返回给应用程序类加载器,让他直接使用即可
注意:如果子类加载器也无法加载这个类,那么它会抛出一个 ClassNotFoundException 异常。
🌈 拓展一下:
JVM 判定两个 Java 类是否相同的具体规则:JVM 不仅要看类的全名是否相同,还要看加载此类的类加载器是否一样。只有两者都相同的情况,才认为两个类是相同的。即使两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相同
使用双亲委派模型的好处
双亲委派模型保证了 Java 程序的稳定运行,可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了 Java 的核心 API 不被篡改。
如果没有使用双亲委派模型,而是每个类加载器加载自己的话就会出现一些问题,比如我们编写一个称为 java.lang.Object 类的话,那么程序运行的时候,系统就会出现两个不同的 Object 类。双亲委派模型可以保证加载的是 JRE 里的那个 Object 类,而不是你写的 Object 类。这是因为 AppClassLoader 在加载你的 Object 类时,会委托给 ExtClassLoader 去加载,而 ExtClassLoader 又会委托给 BootstrapClassLoader,BootstrapClassLoader 发现自己已经加载过了 Object 类,会直接返回,不会去加载你写的 Object 类
打破双亲委派模型的方法
自定义加载器的话,需要继承 ClassLoader
。如果我们不想打破双亲委派模型,就重写 ClassLoader
类中的 findClass()
方法即可,无法被父类加载器加载的类最终会通过这个方法被加载。但是,如果想打破双亲委派模型则需要重写 loadClass()
方法。
例如,子类加载器可以在委派给父类加载器之前,先自己尝试加载这个类,或者在父类加载器返回之后,再尝试从其他地方加载这个类。具体的规则由我们自己实现,根据项目需求定制化。
我们比较熟悉的 Tomcat 服务器为了能够优先加载 Web 应用目录下的类,然后再加载其他目录下的类,就自定义了类加载器 WebAppClassLoader 来打破双亲委托机制。这也是 Tomcat 下 Web 应用之间的类实现隔离的具体原理
最重要的JVM参数总结
JVM调优的参数可以在哪里设置
这时在linux环境下
xms:初始大小 xmx:最大大小
JVM调优的参数有那些
JDK监控和故障处理工具总结
JVM调优工具
JVM线上问题排查和性能调优案例
Java内存泄漏排查思路
第一种是项目运行中才能使用
如何使用第二种方案
概要中查看 main
CPU飙高的排查方案与思路
例子:
打印出当前进程的所有线程 2266:进程id 2276线程占用cpu高
打印出当前进程(2266)所有线程
得到16进制的线程id
数据库MySQL
数据库:由数据库管理系统(DBMS)管理的数据的集合
数据库管理系统(DBMS):一种操纵和管理数据库的大型软件
数据库系统(DBS):通常由软件、数据库、数据库管理员(DBA)组成
数据库管理员:负责全面管理和控制数据库系统
元组:关系型数据库(二维表)中的行
码:关系型数据库中的列
主码:主键
外码:外键
ER图:实体联系图
实体:业务对象/逻辑对象
属性:某个实体所拥有的属性
联系:实体与实体之间的关系
NoSql
非关系型数据库,主要用来存储主键、文档以及图形类的数据
Mysql
SQL语言
DDL 的主要功能是定义数据库对象。
DDL 的核心指令是 CREATE
、ALTER
、DROP
。
数据操纵语言(DML)
数据操纵语言(Data Manipulation Language, DML)是用于数据库操作,对数据库其中的对象和数据运行访问工作的编程语句。
DML 的主要功能是 访问数据,因此其语法都是以读写数据库为主。
DML 的核心指令是 INSERT
、UPDATE
、DELETE
、SELECT
。这四个指令合称 CRUD(Create, Read, Update, Delete),即增删改查
having
vs where
:
where
:过滤指定的行,后面不能加聚合函数(分组函数)。where
在group by
前。having
:过滤分组,一般都是和group by
连用,不能单独使用。having
在group by
之后。
Like
LIKE
操作符在WHERE
子句中使用,作用是确定字符串是否匹配模式。只有字段是文本值时才使用
LIKE
。LIKE
支持两个通配符匹配选项:%
和_
。不要滥用通配符,通配符位于开头处匹配会非常慢。
%
表示任何字符出现任意次数。_
表示任何字符出现一次。
ON
和 WHERE
的区别:
ON
是连接表的条件,它决定临时表的生成。WHERE
是在临时表生成以后,再对临时表中的数据进行过滤,生成最终的结果集,这个时候已经没有 JOIN-ON 了。
所以总结来说就是:SQL 先根据 ON 生成一张临时表,然后再根据 WHERE 对临时表进行筛选。tml
START TRANSACTION
- 指令用于标记事务的起始点。
SAVEPOINT
- 指令用于创建保留点。
ROLLBACK TO
- 指令用于回滚到指定的保留点;如果没有设置保留点,则回退到 START TRANSACTION
语句处。
COMMIT
- 提交事务。
什么是关系型数据库
关系型数据库就是依据一张二维表来存储数据的
什么是sql
sql是一种用来操纵关系型数据库的语言
Mysql字段类型
整数类型的 unsigned属性有什么用?
MySQL 中的整数类型可以使用可选的 unsigned 属性来表示不允许负值的无符号整数。
可以将正整数的上限提高一倍
TINYINT UNSIGNED 类型的取值范围是 0 ~ 255,而普通的 TINYINT 类型的值范围是 -128 ~ 127。INT UNSIGNED 类型的取值范围是 0 ~ 4,294,967,295,而普通的 INT 类型的值范围是 -2,147,483,648 ~ 2,147,483,647。
CHAR 和 VARCHAR 的区别是什么?
char是定长字符串,varchar是变长字符串。
char多用来存储定长字符串,varchar多用来存储变长字符串
VARCHAR(100)和 VARCHAR(10)的区别是什么?
varchar(100)表示最多可以存储100个字符
varchar(10)表示最多可以存储10个字符
如果两者存储相同的字符串所占用的磁盘空间是一样的。
不过varchar(100)会消耗更多的内存,这是因为varchar在内存中操作时,通常会分配固定大小的内存块。所以对于varchar(100)来说
系统可能会为VARCHAR(100)
分配足够的内存来容纳100个字符,而不管实际存储的字符串有多长。
DECIMAL 和 FLOAT/DOUBLE 的区别是什么?
decimal 和 float 的区别是:DECIMAL 是定点数,FLOAT/DOUBLE 是浮点数。DECIMAL 可以存储精确的小数值,FLOAT/DOUBLE 只能存储近似的小数值。
DECIMAL 用于存储具有精度要求的小数,例如与货币相关的数据,可以避免浮点数带来的精度损失。
在 Java 中,MySQL 的 DECIMAL 类型对应的是 Java 类 java.math.BigDecimal
。
DATETIME 和 TIMESTAMP 的区别是什么?
DATETIME 类型没有时区信息,TIMESTAMP 和时区有关。
TIMESTAMP 只需要使用 4 个字节的存储空间,但是 DATETIME 需要耗费 8 个字节的存储空间。但是,这样同样造成了一个问题,Timestamp 表示的时间范围更小。
DATETIME:1000-01-01 00:00:00 ~ 9999-12-31 23:59:59
Timestamp:1970-01-01 00:00:01 ~ 2037-12-31 23:59:59
NULL 和 ‘’ 的区别是什么?
NULL
跟 ''
(空字符串)是两个完全不一样的值,区别如下:
NULL
代表一个不确定的值,就算是两个NULL
,它俩也不一定相等。例如,SELECT NULL=NULL
的结果为 false,但是在我们使用DISTINCT
,GROUP BY
,ORDER BY
时,NULL
又被认为是相等的。''
的长度是 0,是不占用空间的,而NULL
是需要占用空间的。NULL
会影响聚合函数的结果。例如,SUM
、AVG
、MIN
、MAX
等聚合函数会忽略NULL
值。COUNT
的处理方式取决于参数的类型。如果参数是*
(COUNT(*)
),则会统计所有的记录数,包括NULL
值;如果参数是某个字段名(COUNT(列名)
),则会忽略NULL
值,只统计非空值的个数。查询
NULL
值时,必须使用IS NULL
或IS NOT NULLl
来判断,而不能使用 =、!=、 <、> 之类的比较运算符。而''
是可以使用这些比较运算符的。ml
Boolean 类型如何表示?
MySQL 中没有专门的布尔类型,而是用 tinyint(1) 类型来表示布尔值。tinyint(1) 类型可以存储 0 或 1,分别对应 false 或 true。
连接器:进行身份和权限的认证
分析器:看sql语句是干嘛的,然后判断语法是否正确
优化器:按照mysql认为的最优方案去执行
执行器:执行语句,然后从存储引擎中返回数据(执行之前会判断有没有权限)
存储引擎:主要负责数据的存储和读写。Mysql是插件式架构。支持 InnoDB、MyISAM、Memory 等多种存储引擎。
MySQL 支持哪些存储引擎?默认使用哪个?
show engings
命令来查看 MySQL 支持的所有存储引擎。
Mysql默认使用的引擎是InnoDB。所有引擎中,只有InnoDB支持事务。
MySQL 5.5.5 之前,MyISAM 是 MySQL 的默认存储引擎。5.5.5 版本之后,InnoDB 是 MySQL 的默认存储引擎。
select version() 查看当前mysql的版本
show variables like ‘%storage_engine%’ 查看mysql当前默认的存储引擎
MySQL 存储引擎架构了解吗?
mysql存储引擎架构是插件式的。
存储引擎是基于表的,而不是数据库。
MyISAM 和 InnoDB 有什么区别?
MyISAM 不支持事务和行级锁,而且最大的缺陷就是崩溃后无法安全恢复。
1、MyISAM只支持表级锁,InnoDB支持表级锁和行级锁,默认是行级锁
2、MyISAM不支持事务。InnoDB支持事务,实现了sql的四个隔离级别(事务的ACID) 原子性、一致性、隔离性、持久性。具有提交和 回滚事务的能力
3、MyISAM不支持外键,InnoDb支持外键
4、MyISAM不支持数据库异常崩溃后的安全恢复,InnoDB支持
5、MyISAM不支持MVCC,而InnoDB支持
6、索引实现不一样。
虽然 MyISAM 引擎和 InnoDB 引擎都是使用 B+Tree 作为索引结构,但是两者的实现方式不太一样。
InnoDB 引擎中,其数据文件本身就是索引文件。相比 MyISAM,索引文件和数据文件是分离的,其表数据文件本身就是按 B+Tree 组织的一个索引结构,树的叶节点 data 域保存了完整的数据记录。
7、性能有差别。
InnoDB 的性能比 MyISAM 更强大,不管是在读写混合模式下还是只读模式下,随着 CPU 核数的增加,InnoDB 的读写能力呈线性增长。MyISAM 因为读写不能并发,它的处理能力跟核数没关系。
MySQL 日志
MySQL 日志常见的面试题有:
MySQL 中常见的日志有哪些?
MySQL 日志 主要包括错误日志、查询日志、慢查询日志、事务日志、二进制日志几大类。其中,比较重要的还要属二进制日志 binlog(归档日志)和事务日志 redo log(重做日志)和 undo log(回滚日志)。
慢查询日志有什么用?
binlog 主要记录了什么?
redo log 如何保证事务的持久性?
页修改之后为什么不直接刷盘呢?
binlog 和 redolog 有什么区别?
undo log 如何保证事务的原子性?
undo log 和 redo log的区别
这两个都是MySQL的日志文件
当操作一个表的数据之后,会按照一定的频率,将数据同步到磁盘中(会减少磁盘的IO,加快处理速度),有可能处理完后的页在内存中,还没有同步到磁盘中(脏页),服务器宕机了,同步数据失败,内存中数据消失,违背了MySQL的持久性。
redo log(重做日志)让 InnoDB 存储引擎拥有了崩溃恢复能力。
binglog
主要用于主从同步
当主库的数据有了变化,会把变化的数据操作写入到一个binglong日志文件中,而从库有一个IOthread线程,专门负责将binglog日志文件中的数据读取并写入到从库的Relay log日志文件中。再由从库的SQLthread线程读取Relay log文件。将里边的操作再次执行,执行之后就完成数据的同步。
MySQL事务
事务:事务是逻辑上的一组操作,要么都执行,要么都不执行。(eg:转账操作)
事务分为:分布式事务,数据库事务(往往接触到的事务)
数据库事务的作用:可以保证多个对数据库的操作(sql语句)构成一个逻辑上的整体。(要么全部执行成功,要么全部不执行)
关系型数据库的ACID特性
1、原子性:事务是不可分割的最小的操作单元,要么全部执行成功,要么全部不执行
2、一致性:执行事务前后,数据保持一致。(eg:转账业务,不管转没转成功,总钱数不变)
3、隔离性:并发访问数据库时,每个用户的事务都是独立的,不会被其他用户干扰
4、持久性:一个事务提交之后,他对数据库中的数据的改变是永久的
**注意:**只有保证了事务的原子性、隔离性、持久性、才能保证事务的一致性。(A、I、D是手段,C是目的)
并发事务带来了哪些问题?
解决方法:设置隔离级别
多个事务并发运行,通常会导致多个用户对同一数据进行操做,可能导致以下问题:
1、脏读
简:一个事务读到了另一个事务还没有提交的数据
详:一个事务读取数据,并对其进行修改,这对其他事务来说是可见的,即便当前事务还没有提交。
这时另一个事务读取了这个还未提交的数据,但是第一个事务突然回滚,导致数据并没有提交到数据库,那么第二个事务读取到的数据就是脏数据。
一个事务访问数据的时候,另一个事务也对该数据进行访问,第一个事务进行修改的时候,第二个事务也进行了修改,就会导致第一个事务修改的数据丢失。
3、不可重复读
简:一个事务先后读取同一条数据,但是两次读取到的数据不同
详:一个事务中可能会多次读取同一个数据,当第一个事务进行第一次读取后,另一个事务对该数据进行了修改,第一个事务第二次读取到的数据就与第一次读取到的数据不一样,就发生了不可重复读
4、幻读
一个事务读取了一些数据之后,另一个事务插入了一些数据,导致第一个事务再次读取数据的时候会发现一些
原本不存在的记录,就好像发生了幻觉一样,成为幻读
不可重复读和幻读有什么区别?
不可重复读的重点是内容修改或者记录减少比如多次读取一条记录发现其中某些记录的值被修改;
幻读的重点在于记录新增比如多次执行同一条查询语句(DQL)时,发现查到的记录增加了。
幻读其实可以看作是不可重复读的一种特殊情况,单独把幻读区分出来的原因主要是解决幻读和不可重复读的方案不一样。
并发事务的控制方式有哪些?/事务的隔离级别如何保证
Mysql中并发事务的控制方式有两种:锁,MVCC(多版本并发控制)。
锁可以看作是悲观控制的模式。
MVCC可以看作是乐观控制的模式。
Mysql主要通过读写锁来控制并发事务。
共享锁:读锁,事务在读取数据的时候,获取共享锁,可以允许多个事务同时读取
排他锁:写锁,事务在修改数据的时候,获得排他锁,可以防止多个事务同时进行修改
读写锁可以做到读读并行,但是无法做到写读、写写并行。另外,根据根据锁粒度的不同,又被分为 表级锁(table-level locking) 和 行级锁(row-level locking)。InnoDB支持行级锁,针对一行或者多行数据加锁,可以做到并发执行,性能更高。
MVCC是多版本的并发访问控制方法,即一份数据会存储多个版本,通过事务的可见性来保证事务能够看到自己应该看到的版本,通常会有一个全局的版本分配器来为每一行数据设置版本号,版本号是唯一的。
MVCC 在 MySQL 中实现所依赖的手段主要是: 隐藏字段、read view、undo log。
undo log : undo log 用于记录某行数据的多个版本的数据。
read view 和 隐藏字段 : 用来判断当前版本数据的可见性。
解释一下MVCC
MVCC的作用:就是在多个事务并发的情况下,确定到底该访问哪个版本
事务id:在表中新增一条记录,那这个记录自动添加一条事务id:DB_TRX_ID,默认为1。当修改了这条记录之后,当前事务id会自动加1
回滚指针:eg:当前事务id为2,那么它指向的就是事务id为1的版本记录(对应上例就是插入新增操作对应的记录)
通俗理解:ReadView就是快照读所读取的数据。并且记录当前操作同一记录的所有事务中还没有commit的事务id
eg:针对当前案例
当前活跃事务集合:第一次查询事务的时候,事务3、4、5都是活跃事务(没有提交)
当前最小的活跃事务id:事务3
预分配事务id:当前最大事务id+1(6)
ReadView创建者的事务id:事务5
第一条规则对应事务5
第二条规则和第四条规则 是等价的,对应事务2
第三条规则对应事务6
RC隔离级别下:读已提交(只能读取已经提交的最新数据) 具体原理:
第一次查询只能访问事务2
第二次查询只能访问事务3
RR可重复读:只能读取最早(旧)的历史版本
MySQL的主从同步原理
项目上线时,MySQL通常会搭建主从的架构,一个应用会链接mysql的中间件(它负责处理数据库请求、管理连接),中间件会链接数据库的主库和从库。主库负责写数据,从库负责读数据。当主库写数据的时候,就要把数据同步到从库中。
问:如何进行同步?/同步的原理
当主库的数据有了变化,会把变化的数据写入到一个binglong日志文件中,而从库有一个IOthread线程,专门负责将binglog日志文件中的数据读取并写入到从库的Relay log日志文件中。再由从库的SQLthread线程读取Relay log文件。将里边的操作再次执行,执行之后就完成数据的同步。
分库分表
什么时候使用?
为什么使用:主丛分库解决了访问压力大的问题,没有解决海量数据存储的问题
分库分表的使用时机:
1、前提:当项目业务数据逐渐增多时(单表数据量达到1000w或者20G以后)
2、优化已经解决不了的性能问题(主从读写分离、查询索引…)
3、IO瓶颈(磁盘IO/网络IO)、CPU瓶颈(聚合查询、连接数太多)
分库:一个库分为多个库
分表:一个表分为多个表
不同业务的表放进不同的库中
基本字段放一个表中,详情描述放一个表中
针对同一业务数据过多的问题
问题
SQL 标准定义了哪些事务隔离级别?
SQL标准定义了四个事务隔离级别
读未提交:最低的隔离级别,允许读取未提交的数据。可能导致脏读、幻读、不可重复读
读已提交:允许读取并发事务已经提交的数据,可以防止脏读,但是幻读和不可重复读,依然有可能发生
可重复读(默认):多次读取同一数据的结果是一样的,除非这个数据是由本事务自己修改的,可以阻止脏读和不可重复读,幻读依然可能 发生
可串行化:最高隔离级别,完全服从ACID的隔离级别,所有的事务一次逐个执行,所以可以防止脏读,可重复读,幻读。(效率低,相 当于放弃了并发事务)
事务隔离级别越高,性能越差。
MySQL 的隔离级别是基于锁实现的吗?
MySQL 的隔离级别基于锁和 MVCC 机制共同实现的。
可串行化隔离级别是通过锁来实现的,读已提交 和 可重复读 隔离级别是基于 MVCC 实现的。不过, 可串行化 之外的其他隔离级别可能也需要用到锁机制,就比如 REPEATABLE-READ 在当前读情况下需要使用加锁读来保证不会出现幻读
MySQL 的默认隔离级别是什么?
MySQL InnoDB 存储引擎的默认支持的隔离级别是 REPEATABLE-READ(可重读)
MySQL锁
锁的概念:所是一种常见的并发事务控制方式
MyISAM支持的表级锁:锁的是一整个表,并发写入的时候性能很差。
InnoDB还支持行级锁:锁住相关的行,并发写入的性能更好
表级锁和行级锁了解吗?有什么区别?
表级锁: MySQL 中锁定粒度最大的一种锁(全局锁除外),是针对非索引字段加的锁,对当前操作的整张表加锁,实现简单,资源消耗也比较少,加锁快,不会出现死锁。不过,触发锁冲突的概率最高,高并发下效率极低。表级锁和存储引擎无关,MyISAM 和 InnoDB 引擎都支持表级锁。
行级锁: MySQL 中锁定粒度最小的一种锁,是 针对索引字段加的锁 ,只针对当前操作的行记录进行加锁。 行级锁能大大减少数据库操作的冲突。其加锁粒度最小,并发度高,但加锁的开销也最大,加锁慢,会出现死锁。行级锁和存储引擎有关,是在存储引擎层面实现的
行级锁的使用有什么注意事项?
InnoDB 的行锁是针对索引字段加的锁,表级锁是针对非索引字段加的锁。当我们执行 UPDATE
、DELETE
语句时,如果 WHERE
条件中字段没有命中唯一索引或者索引失效的话,就会导致扫描全表对表中的所有行记录进行加锁。这个在我们日常工作开发中经常会遇到,一定要多多注意!!!
不过,很多时候即使用了索引也有可能会走全表扫描,这是因为 MySQL 优化器的原因。
InnoDB 有哪几类行锁?
InnoDB 行锁是通过对索引数据页上的记录加锁实现的,MySQL InnoDB 支持三种行锁定方式:
记录锁(Record Lock):属于单个行记录上的锁。
间隙锁(Gap Lock):锁定一个范围,不包括记录本身。
临键锁(Next-Key Lock):Record Lock+Gap Lock,锁定一个范围,包含记录本身,主要目的是为了解决幻读问题(MySQL 事务部分提到过)。记录锁只能锁住已经存在的记录,为了避免插入新记录,需要依赖间隙锁。
在 InnoDB 默认的隔离级别 REPEATABLE-READ 下,行锁默认使用的是 Next-Key Lock。但是,如果操作的索引是唯一索引或主键,InnoDB 会对 Next-Key Lock 进行优化,将其降级为 Record Lock,即仅锁住索引本身,而不是范围。
共享锁和排他锁呢?
不论是表级锁还是行级锁,都存在共享锁(Share Lock,S 锁)和排他锁(Exclusive Lock,X 锁)这两类:
共享锁(S 锁):又称读锁,事务在读取记录的时候获取共享锁,允许多个事务同时获取(锁兼容)。
排他锁(X 锁):又称写锁/独占锁,事务在修改记录的时候获取排他锁,不允许多个事务同时获取。如果一个记录已经被加了排他锁,那其他事务不能再对这条事务加任何类型的锁(锁不兼容)。
排他锁与任何的锁都不兼容,共享锁仅和共享锁兼容。
由于 MVCC 的存在,对于一般的 SELECT
语句,InnoDB 不会加任何锁。
意向锁有什么作用?
意向锁的作用:可以快速判断是否能对表加表锁(内部有行锁时,是不能加表锁的)
意向锁是表级锁,共有两种:
意向共享锁(Intention Shared Lock,IS 锁):事务有意向对表中的某些记录加共享锁(S 锁),加共享锁前必须先取得该表的 IS 锁。
意向排他锁(Intention Exclusive Lock,IX 锁):事务有意向对表中的某些记录加排他锁(X 锁),加排他锁之前必须先取得该表的 IX 锁。
意向锁是由数据引擎自己维护的,用户无法手动操作意向锁,在为数据行加共享/排他锁之前,InnoDB 会先获取该数据行所在在数据表的对应意向锁。
意向锁之间是互相兼容的。l
意向锁和共享锁和排它锁互斥(这里指的是表级别的共享锁和排他锁,意向锁不会与行级的共享锁和排他锁互斥)。
当前读和快照读有什么区别?
快照读(一致性非锁定读)就是单纯的 SELECT
语句(不包括加入S、X锁的select语句)
快照即记录的历史版本,每行记录可能存在多个历史版本(多版本技术)。
快照读的情况下,如果读取的记录正在执行 UPDATE/DELETE 操作,读取操作不会因此去等待记录上 X 锁的释放,而是会去读取对应行的一个快照。
只有在事务隔离级别 RC(读取已提交) 和 RR(可重读)下,InnoDB 才会使用一致性非锁定读:
在 RC 级别下,对于快照数据,一致性非锁定读总是读取被锁定行的最新一份快照数据。
在 RR 级别下,对于快照数据,一致性非锁定读总是读取本事务开始时的行数据版本。
快照读比较适合对于数据一致性要求不是特别高且追求极致性能的业务场景。
当前读 (一致性锁定读)就是给行记录加 X 锁或 S 锁。
MySQL性能优化
能用 MySQL 直接存储文件(比如图片)吗?
可以是可以,直接存储文件对应的二进制数据即可。不过,还是建议不要在数据库中存储文件,会严重影响数据库性能,消耗过多存储空间。
可以选择使用各大云服务厂商提供的文件存储服务,成熟稳定,价格也比较低
数据库只存储文件地址信息,文件由文件存储服务负责存储。
MySQL 如何存储 IP 地址?
可以将 IP 地址转换成整形数据存储,性能更好,占用空间也更小。
MySQL 提供了两个方法来处理 ip 地址
INET_ATON()
:把 ip 转为无符号整型 (4-8 位)INET_NTOA()
:把整型的 ip 转为地址
插入数据前,先用 INET_ATON()
把 ip 地址转为整型,显示数据时,使用 INET_NTOA()
把整型的 ip 地址转为地址显示即可
如何定位慢查询、及优化
回表是指根据索引查询到的主键值再去访问主键索引,从而获取完整的数据记录。
尽可能不要使用index和 all(all相当于不使用索引,index是全盘索引。如果使用,代表需要优化)
Mysql索引
阶数:指针数
聚簇索引和二级索引
聚簇索引会在没有主键的情况下使用rowId(隐藏主键)
eg:这个表中的name就可以作为二级索引
聚簇索引叶子节点存放的是一行的值,二级索引存放的是主键
回表是指根据索引查询到的主键值再去访问主键索引,从而获取完整的数据记录。
覆盖索引
第一条sql语句:id为主键索引,即聚簇索引,根据id查询会返回一整行的数据(包含了所有的列与要查询的 * 相符)
SELECT * FROM table_name WHERE id = '特定ID';
第二条sql语句:name为索引,即二级索引,根据name查询会查到name和聚簇索引(id)(与要查询的 id,name 相符)
SELECT id, name FROM table_name WHERE name = '特定name';
第三条sql语句:name为索引,即二级索引,根据name查询会查到name和聚簇索引(id)(与要查询的 id,name,gender不相符)
SELECT id, name, gender FROM table_name WHERE name = '特定name';
前两条是覆盖索引,最后一条不是
这个排序是Mysql内部的filesort排序
(在MySQL中的ORDER BY有两种排序实现方式:
\1. 利用有序索引获取有序数据 (效率较高)
\2. 文件排序)
这里是利用了主键索引进行高效的排序(主键索引在创建表时已经创建好了),然后再利用子查询(自身的id)与自身进行一个自关联查询(归根结底就是使用了聚簇索引(覆盖索引))
索引创建的索引原则
1、2、5、6比较重要
第三点:
城市区分度不高,使用索引的效率不高,尽量不使用
第四点:
字段太长,不适合作为索引,(也可以使用前缀索引—–针对字符串的特点,建立前缀索引)
第五点:
这个表中有三个索引:name,status,address
第六点:如何控制索引的数量?一般控制在多少
一般单表的索引数量控制在5-10个,过多的索引会增加磁盘的占用空间,同时也会增加增删改的性能(核心表不超过7个索引,普通表不超过5个,小型表不超过3个。)
什么情况下,索引会失效
按照索引的排序顺序进行查询,不会失效
五种失效的情况:
1、跳过最左前缀的索引(第跳过一个索引,使用后边的索引进行查询,索引会失效)
2、跳过中间的某一个索引,只有最左索引生效
3、范围查询时,右边的列不能使用索引。
跟据前边两个字段name,status查询是走索引的,但是最后一个条件address没有用到索引。
4、在索引列上进行运算操作,索引将会失效
5、对索引进行类型转换,字符串不加单引号,可能会造成索引失效
6、模糊查询可能会导致索引失效(头部模糊匹配,索引会失效)
对sql优化的经验
SQL语句优化 第5点:将嵌套循环看作两个表的联合查询,外边放小的循环,表示数据库与服务器端只进行三次的链接,然后在数据库内部进行1000次的操作。(性能更好)
Join优化详解
1. 减少驱动表的数据量
优化器选择较小的表作为驱动表(即外表),可以减少JOIN操作中需要处理的数据量。这是因为驱动表的每一行都需要与被驱动表(即内表)进行匹配,所以减少驱动表的数据量可以显著提高性能。
2. 使用子查询代替LEFT JOIN
在某些情况下,使用子查询可以减少驱动表的数据量,从而减少访问匹配表的次数。这种方法可以避免使用GROUP BY,减少CPU对分组数据的处理,提高查询效率。
Redis
结合项目进行redis的提问
Redis基础
什么是 Redis?
Redis 是一个基于 C 语言开发的开源 NoSQL 数据库(BSD 许可)。与传统数据库不同的是,Redis 的数据是保存在内存中的(内存数据库,支持持久化),因此读写速度非常快,被广泛应用于分布式缓存方向。并且,Redis 存储的是 KV 键值对数据。
Redis 为什么这么快?
Redis 内部做了非常多的性能优化,比较重要的有下面 3 点:
Redis 基于内存,内存的访问速度比磁盘快很多;
Redis 基于 Reactor 模式设计开发了一套高效的事件处理模型,主要是单线程事件循环和 IO 多路复用(Redis 线程模式后面会详细介绍到);
Redis 内置了多种优化过后的数据类型/结构实现,性能非常高。
Redis 通信协议实现简单且解析高效。
那既然都这么快了,为什么不直接用 Redis 当主数据库呢?主要是因为内存成本太高且 Redis 提供的数据持久化仍然有数据丢失的风险
为什么要用 Redis?
1、访问速度更快
传统数据库数据保存在磁盘,而 Redis 基于内存,内存的访问速度比磁盘快很多。引入 Redis 之后,我们可以把一些高频访问的数据放到 Redis 中,这样下次就可以直接从内存中读取,速度可以提升几十倍甚至上百倍。
2、高并发
一般像 MySQL 这类的数据库的 QPS 大概都在 4k 左右(4 核 8g) ,但是使用 Redis 缓存之后很容易达到 5w+,甚至能达到 10w+(就单机 Redis 的情况,Redis 集群的话会更高)。
QPS(Query Per Second):服务器每秒可以执行的查询次数;
由此可见,直接操作缓存能够承受的数据库请求数量是远远大于直接访问数据库的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。进而,我们也就提高了系统整体的并发。
3、功能全面
Redis 除了可以用作缓存之外,还可以用于分布式锁、限流、消息队列、延时队列等场景,功能强大
常见的缓存读写策略有哪些?
1、旁路缓存模式
写:
先更新 db
然后直接删除 cache 。
读 :
从 cache 中读取数据,读取到就直接返回
cache 中读取不到的话,就从 db 中读取数据返回
再把数据放到 cache 中
在写数据的过程中,可以先删除 cache ,后更新 db 么?
不行的!因为这样可能会造成 数据库(db)和缓存(Cache)数据不一致的问题
请求 1 先把 cache 中的 A 数据删除 -> 请求 2 从 db 中读取数据->请求 1 再把 db 中的 A 数据更新。就会产生数据不一致性的问题
“在写数据的过程中,先更新 db,后删除 cache 就没有问题了么?
可能会出现数据不一致性的问题,不过概率非常小
请求 1 从 db 读数据 A-> 请求 2 更新 db 中的数据 A(此时缓存中无数据 A ,故不用执行删除缓存操作 ) -> 请求 1 将数据 A 写入 cache
缺点:
缺陷 1:首次请求数据一定不在 cache 的问题
解决办法:可以将热点数据提前放入 cache 中。
缺陷 2:写操作比较频繁的话导致 cache 中的数据会被频繁被删除,这样会影响缓存命中率 。
解决办法:
数据库和缓存数据强一致场景:更新 db 的时候同样更新 cache,不过我们需要加一个锁/分布式锁来保证更新 cache 的时候不存在线程安全问题。
可以短暂地允许数据库和缓存数据不一致的场景:更新 db 的时候同样更新 cache,但是给缓存加一个比较短的过期时间,这样的话就可以保证即使数据不一致的话影响也比较小
2、读写穿透
读写穿透中服务端把 cache 视为主要数据存储,从中读取数据并将数据写入其中。cache 服务负责将此数据读取和写入 db,从而减轻了应用程序的职责。
写(Write Through):
先查 cache,cache 中不存在,直接更新 db。
cache 中存在,则先更新 cache,然后 cache 服务自己更新 db(同步更新 cache 和 db)。
读(Read Through):
从 cache 中读取数据,读取到就直接返回 。
读取不到的话,先从 db 加载,写入到 cache 后返回响应。
缺陷:首次请求数据一定不在 cache 的问题
解决办法:可以将热点数据提前放入 cache 中。
3、异步缓存写入
异步缓存写入 和 读写穿透 很相似,两者都是由 cache 服务来负责 cache 和 db 的读写。
但是,两个又有很大的不同:读写穿透 是同步更新 cache 和 db,而 异步缓存写入 则是只更新缓存,不直接更新 db,而是改为异步批量的方式来更新 db。
很明显,这种方式对数据一致性带来了更大的挑战,比如 cache 数据可能还没异步更新 db 的话,cache 服务可能就就挂掉了。
这种策略在我们平时开发过程中也非常非常少见,但是不代表它的应用场景少,比如消息队列中消息的异步写入磁盘、MySQL 的 Innodb Buffer Pool 机制都用到了这种策略。
异步缓存写入 下 db 的写性能非常高,非常适合一些数据经常变化又对数据一致性要求没那么高的场景,比如浏览量、点赞量。
Redis应用
Redis 除了做缓存,还能做什么?
分布式锁:通过 Redis 来做分布式锁是一种比较常见的方式。通常情况下,我们都是基于 Redisson 来实现分布式锁。
限流:一般是通过 Redis + Lua 脚本的方式来实现限流。如果不想自己写 Lua 脚本的话,也可以直接利用 Redisson 中的
RRateLimiter
来实现分布式限流,其底层实现就是基于 Lua 代码+令牌桶算法。消息队列:Redis 自带的 List 数据结构可以作为一个简单的队列使用。Redis 5.0 中增加的 Stream 类型的数据结构更加适合用来做消息队列。它比较类似于 Kafka,有主题和消费组的概念,支持消息持久化以及 ACK 机制。
延时队列:Redisson 内置了延时队列(基于 Sorted Set 实现的)。
分布式 Session :利用 String 或者 Hash 数据类型保存 Session 数据,所有的服务器都可以访问。
复杂业务场景:通过 Redis 以及 Redis 扩展(比如 Redisson)提供的数据结构,我们可以很方便地完成很多复杂的业务场景比如通过 Bitmap 统计活跃用户、通过 Sorted Set 维护排行榜
如何基于 Redis 实现分布式锁?
解决跨JVM的多线程并发访问互斥机制问题
在集群情况下,比如不同的jvm下,不能使用syn…锁(本地锁),而要使用外部锁(分布式锁)
推荐直接使用Redisson来实现
第一步
在 Redis 中, SETNX
命令是可以帮助我们实现互斥。SETNX
即 SET if Not eXists (对应 Java 中的 setIfAbsent
方法),如果 key 不存在的话,才会设置 key 的值。如果 key 已经存在, SETNX
啥也不做。
> SETNX lockKey uniqueValue
(integer) 1
> SETNX lockKey uniqueValue
(integer) 0
释放锁的话,直接通过 DEL
命令删除对应的 key 即可。
> DEL lockKey
(integer) 1
为了防止误删到其他的锁,这里我们建议使用 Lua 脚本通过 key 对应的 value(唯一值)来判断。
选用 Lua 脚本是为了保证解锁操作的原子性。因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,从而保证了锁释放操作的原子性。
// 释放锁时,先比较锁对应的 value 值是否相等,避免锁的误释放
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
第二步
问题:如果释放锁的逻辑突然挂掉,锁无法释放,那么共享资源无法再被其他线程/进程访问
解决办法:给这个 key(也就是锁) 设置一个过期时间
127.0.0.1:6379> SET lockKey uniqueValue EX 3 NX
OK
lockKey:加锁的锁名;
uniqueValue:能够唯一标识锁的随机字符串;
NX:只有当 lockKey 对应的 key 值不存在的时候才能 SET 成功;
EX:过期时间设置(秒为单位)EX 3 标示这个锁有一个 3 秒的自动过期时间。与 EX 对应的是 PX(毫秒为单位),这两个都是过期时间设置。
一定要保证设置指定 key 的值和过期时间是一个原子操作!!! 不然的话,依然可能会出现锁无法被释放的问题。
第三步
进而引出新的问题:如果操作共享资源的时间大于过期时间,就会出现锁提前过期的问题,进而导致分布式锁直接失效。如果锁的超时时间设置过长,又会影响到性能
解决办法:如果操作共享资源的操作还未完成,锁过期时间能够自己续期就好了! ——– 》 Redisson
Redisson 是一个开源的 Java 语言 Redis 客户端,提供了很多开箱即用的功能,不仅仅包括多种分布式锁的实现。并且,Redisson 还支持 Redis 单机、Redis Sentinel、Redis Cluster 等多种部署架构。
实现自动续期的原理:Redisson 中的分布式锁自带自动续期机制,使用起来非常简单,原理也比较简单,其提供了一个专门用来监控和续期锁的 Watch Dog( 看门狗),如果操作共享资源的线程还未执行完成的话,Watch Dog 会不断地延长锁的过期时间,进而保证锁不会因为超时而被释放。
默认情况下,每过 10 秒,看门狗就会执行续期操作,看门狗的检查锁的超时时间是30秒钟,这个时间间隔可以通过修改Config.lockWatchdogTimeout
来另行指定
在源码层面,自动续期的实现涉及到几个关键的函数调用。首先,当锁被成功获取后,会调用scheduleExpirationRenewal方法来启动自动续期的定时任务。这个方法会创建一个定时任务,定期检查锁是否仍然需要续期。如果锁仍然被持有,那么会通过调用renewExpiration方法来延长锁的租期。
在renewExpiration方法中,会使用一个TimerTask来实现定时任务,这个任务会每隔一段时间(默认是锁的租期的三分之一)检查锁的状态,并调用renewExpirationAsync方法中的Lua脚本来延长锁的过期时间(保证续期操作的原子性)。这个Lua脚本会检查锁对应的键是否存在,并且是否仍然被当前线程持有,如果是的话,就会通过pexpire命令来延长锁的过期时间。
此外,如果在使用tryLock方法时没有指定leaseTime,或者将其设置为-1,那么Redisson会认为需要自动续期,否则会使用指定的leaseTime作为锁的过期时间,并且不会进行自动续期
具体案例
我这里以 Redisson 的分布式可重入锁 `RLock` 为例来说明如何使用 Redisson 实现分布式锁:
// 1.获取指定的分布式锁对象
RLock lock = redisson.getLock("lock");
// 2.拿锁且不设置锁超时时间,具备 Watch Dog 自动续期机制
lock.lock();
// 3.执行业务
...
// 4.释放锁
lock.unlock();
只有未指定锁超时时间,才会使用到 Watch Dog 自动续期机制。
// 手动给锁设置过期时间,不具备 Watch Dog 自动续期机制
lock.lock(10, TimeUnit.SECONDS);
ZooKeeper如何实现分布式锁
ZooKeeper 分布式锁是基于 临时顺序节点 和 Watcher(事件监听器) 实现的。
获取锁:
首先我们要有一个持久节点
/locks
,客户端获取锁就是在locks
下创建临时顺序节点。假设客户端 1 创建了
/locks/lock1
节点,创建成功之后,会判断lock1
是否是/locks
下最小的子节点。如果
lock1
是最小的子节点,则获取锁成功。否则,获取锁失败。如果获取锁失败,则说明有其他的客户端已经成功获取锁。客户端 1 并不会不停地循环去尝试加锁,而是在前一个节点比如
/locks/lock0
上注册一个事件监听器。这个监听器的作用是当前一个节点释放锁之后通知客户端 1(避免无效自旋),这样客户端 1 就加锁成功了。
释放锁:
成功获取锁的客户端在执行完业务流程之后,会将对应的子节点删除。
成功获取锁的客户端在出现故障之后,对应的子节点由于是临时顺序节点,也会被自动删除,避免了锁无法被释放。
我们前面说的事件监听器其实监听的就是这个子节点删除事件,子节点删除就意味着锁被释放。
Redis 和 ZooKeeper的比较与选择
Redis分布式锁的优缺点:
优点:
性能较高:Redis作为一个基于内存的数据库,其操作速度快,因此在分布式锁的实现上性能相对较高。
缺点:
可靠性问题:Redis分布式锁不能100%保证可用性,在集群模式下,如果主节点宕机,可能会导致锁信息丢失
ZooKeeper分布式锁的优缺点:
优点:
稳定性和健壮性:ZooKeeper的分布式锁基于临时节点实现,当客户端断开连接时,与其相关的锁会自动释放,保证了锁的可靠性
缺点:
性能相对较低:由于ZooKeeper在创建锁和释放锁的过程中需要动态创建、销毁瞬时节点,涉及到频繁的网络通信,因此在性能上存在短板
如何实现可重入锁
什么是可重入锁:
可重入锁指的是在一个线程中可以多次获取同一把锁,比如一个线程在执行一个带锁的方法,该方法中又调用了另一个需要相同锁的方法,则该线程可以直接执行调用的方法即可重入 ,而无需重新获得锁。像 Java 中的 synchronized
和 ReentrantLock
都属于可重入锁。
可重入锁实现的核心思路:
可重入分布式锁的实现核心思路是线程在获取锁的时候判断是否为自己的锁,如果是的话,就不用再重新获取了。为此,我们可以为每个锁关联一个可重入计数器和一个占有它的线程。当可重入计数器大于 0 时,则锁被占有,需要判断占有该锁的线程和请求获取锁的线程是否为同一个。
实际项目如何实现:
Redisson ,其内置了多种类型的锁比如可重入锁(Reentrant Lock)、自旋锁(Spin Lock)、公平锁(Fair Lock)、多重锁(MultiLock)、 红锁(RedLock)、 读写锁(ReadWriteLock)。
Redis实现消息队列的缺点
1、通过List机制实现消息队列
缺点:
无法避免消息丢失
只支持单消费者
2、基于PubSub的消息队列
缺点:
不支持数据持久化
无法避免消息丢失
消息堆积有上限,超出时数据丢失
3、基于Stream的消息队列
缺点:
Redis 发生故障恢复后不能保证消息至少被消费一次
如何基于 Redis 实现延时任务?
类似的问题:
订单在 10 分钟后未支付就失效,如何用 Redis 实现?
红包 24 小时未被查收自动退还,如何用 Redis 实现?
基于 Redis 实现延时任务的功能无非就下面两种方案:
Redis 过期事件监听
Redisson 内置的延时队列
Redis 过期事件监听的存在时效性较差、丢消息、多服务实例下消息重复消费等问题,不被推荐使用。
Redisson 内置的延时队列具备下面这些优势:
减少了丢消息的可能:DelayedQueue 中的消息会被持久化,即使 Redis 宕机了,根据持久化机制,也只可能丢失一点消息,影响不大。当然了,你也可以使用扫描数据库的方法作为补偿机制。
消息不存在重复消费问题:每个客户端都是从同一个目标队列中获取任务的,不存在重复消费的问题。
Redis数据类型
Redis 常用的数据类型有哪些?
Redis 中比较常见的数据类型有下面这些:
5 种基础数据类型:String(字符串)、List(列表)、Set(集合)、Hash(散列)、Zset(有序集合)。
3 种特殊数据类型:HyperLogLog(基数统计)、Bitmap (位图)、Geospatial (地理位置)。
除了上面提到的之外,还有一些其他的比如 Bloom filter(布隆过滤器)、Bitfield(位域)
String 的应用场景有哪些?
String 是 Redis 中最简单同时也是最常用的一个数据类型。它是一种二进制安全的数据类型,可以用来存储任何类型的数据比如字符串、整数、浮点数、图片(图片的 base64 编码或者解码或者图片的路径)、序列化后的对象。
String 的常见应用场景如下:
常规数据(比如 Session、Token、序列化后的对象、图片的路径)的缓存;
计数比如用户单位时间的请求数(简单限流可以用到)、页面单位时间的访问数;
分布式锁(利用
SETNX key value
命令可以实现一个最简易的分布式锁);
List
可以用来做消息队列 (最新文章、最新动态。)
Hash 是一个 String 类型的 field-value(键值对) 的映射表,特别适合用于存储对象,后续操作的时候,你可以直接修改这个对象中的某些字段的值。(用户信息、商品信息、文章信息、购物车信息。)
Set 类型是一种无序集合,集合中的元素没有先后顺序但都唯一,有点类似于 Java 中的 HashSet
。(动态点赞)基于 Set 轻易实现交集、并集、差集的操作(共同关注、共同粉丝、共同喜好)
Sorted Set 类似于 Set,但和 Set 相比,Sorted Set 增加了一个权重参数 score
,使得集合中的元素能够按 score
进行有序排列,还可以通过 score
的范围来获取元素的列表。有点像是 Java 中 HashMap
和 TreeSet
的结合体。(各种排行榜)
需要保存状态信息(0/1 即可表示)的场景
举例:用户签到情况、活跃用户情况、用户行为统计(比如是否点赞过某个视频)。
HyperLogLog 是一种有名的基数计数概率算法,Redis 只是实现了这个算法并提供了一些开箱即用的 API。
Redis 提供的 HyperLogLog 占用空间非常非常小,只需要 12k 的空间就能存储接近2^64
个不同元素!并且,Redis 对 HyperLogLog 的存储结构做了优化,采用两种方式计数:
稀疏矩阵:计数较少的时候,占用空间很小。
稠密矩阵:计数达到某个阈值的时候,占用 12k 的空间。
数量量巨大(百万、千万级别以上)的计数场景
举例:热门网站每日/每周/每月访问 ip 数统计、热门帖子 uv (访问的用户数)统计、
Geospatial index(地理空间索引,简称 GEO) 主要用于存储地理位置信息,基于 Sorted Set 实现。
通过 GEO 我们可以轻松实现两个位置距离的计算、获取指定位置附近的元素等功能
需要管理使用地理空间数据的场景
举例:附近的人。
String 还是 Hash 存储对象数据更好呢?
简单对比一下二者:
对象存储方式:String 存储的是序列化后的对象数据,存放的是整个对象,操作简单直接。Hash 是对对象的每个字段单独存储,可以获取部分字段的信息,也可以修改或者添加部分字段,节省网络流量。如果对象中某些字段需要经常变动或者经常需要单独查询对象中的个别字段信息,Hash 就非常适合。
内存消耗:Hash 通常比 String 更节省内存,特别是在字段较多且字段长度较短时。Redis 对小型 Hash 进行优化(如使用 ziplist 存储),进一步降低内存占用。
复杂对象存储:String 在处理多层嵌套或复杂结构的对象时更方便,因为无需处理每个字段的独立存储和操作。
性能:String 的操作通常具有 O(1) 的时间复杂度,因为它存储的是整个对象,操作简单直接,整体读写的性能较好。Hash 由于需要处理多个字段的增删改查操作,在字段较多且经常变动的情况下,可能会带来额外的性能开销。
总结:
在绝大多数情况下,String 更适合存储对象数据,尤其是当对象结构简单且整体读写是主要操作时。
如果你需要频繁操作对象的部分字段或节省内存,Hash 可能是更好的选择
购物车信息用 String 还是 Hash 存储更好呢?
由于购物车中的商品频繁修改和变动,购物车信息建议使用 Hash 存储:
用户 id 为 key
商品 id 为 field,商品数量为 value
那用户购物车信息的维护具体应该怎么操作呢?
用户添加商品就是往 Hash 里面增加新的 field 与 value;
查询购物车信息就是遍历对应的 Hash;
更改商品数量直接修改对应的 value 值(直接 set 或者做运算皆可);
删除商品就是删除 Hash 中对应的 field;
清空购物车直接删除对应的 key 即可。
这里只是以业务比较简单的购物车场景举例,实际电商场景下,field 只保存一个商品 id 是没办法满足需求的
使用 Redis 实现一个排行榜怎么做?
Redis 中有一个叫做 Sorted Set
(有序集合)的数据类型经常被用在各种排行榜的场景,比如直播间送礼物的排行榜、朋友圈的微信步数排行榜、王者荣耀中的段位排行榜、话题热度排行榜等等。
相关的一些 Redis 命令: ZRANGE
(从小到大排序)、 ZREVRANGE
(从大到小排序)、ZREVRANK
(指定元素排名)。
Redis 的有序集合底层为什么要用跳表,而不用平衡树、红黑树或者 B+树?
这道面试题很多大厂比较喜欢问,难度还是有点大的。
平衡树 vs 跳表:平衡树的插入、删除和查询的时间复杂度和跳表一样都是 O(log n)。对于范围查询来说,平衡树也可以通过中序遍历的方式达到和跳表一样的效果。但是它的每一次插入或者删除操作都需要保证整颗树左右节点的绝对平衡,只要不平衡就要通过旋转操作来保持平衡,这个过程是比较耗时的。跳表诞生的初衷就是为了克服平衡树的一些缺点。跳表使用概率平衡而不是严格强制的平衡,因此,跳表中的插入和删除算法比平衡树的等效算法简单得多,速度也快得多。
红黑树 vs 跳表:相比较于红黑树来说,跳表的实现也更简单一些,不需要通过旋转和染色(红黑变换)来保证黑平衡。并且,按照区间来查找数据这个操作,红黑树的效率没有跳表高。
B+树 vs 跳表:B+树更适合作为数据库和文件系统中常用的索引结构之一,它的核心思想是通过可能少的 IO 定位到尽可能多的索引来获得查询数据。对于 Redis 这种内存数据库来说,它对这些并不感冒,因为 Redis 作为内存数据库它不可能存储大量的数据,所以对于索引不需要通过 B+树这种方式进行维护,只需按照概率进行随机维护即可,节约内存。而且使用跳表实现 zset 时相较前者来说更简单一些,在进行插入时只需通过索引将数据插入到链表中合适的位置再随机维护一定高度的索引即可,也不需要像 B+树那样插入时发现失衡时还需要对节点分裂与合并。
使用跳表的优点:
1、更省内存:跳表可以根据需要调整节点的层数,通过改变节点拥有给定层数的概率参数,可以使得跳表比B树更节省内存。
2、高效的范围查询:跳表支持快速的范围查询操作,(如ZRANGE或ZREVRANGE)。跳表通过链表的方式进行遍历,具有很好的缓存局部性(至少和其他类型的平衡树一样好。)
3、简单性:跳表的实现相对于其他平衡树结构如红黑树来说更为简单,这使得它更容易实现和维护
Set 的应用场景是什么?
Redis 中 Set
是一种无序集合,集合中的元素没有先后顺序但都唯一,有点类似于 Java 中的 HashSet
。
Set
的常见应用场景如下:
存放的数据不能重复的场景:网站 UV 统计(数据量巨大的场景还是
HyperLogLog
更适合一些)、文章点赞、动态点赞等等。需要获取多个数据源交集、并集和差集的场景:共同好友(交集)、共同粉丝(交集)、共同关注(交集)、好友推荐(差集)、音乐推荐(差集)、订阅号推荐(差集+交集) 等等。
需要随机获取数据源中的元素的场景:抽奖系统、随机点名等等。
使用 Set 实现抽奖系统怎么做?
如果想要使用 Set
实现一个简单的抽奖系统的话,直接使用下面这几个命令就可以了:
SADD key member1 member2 ...
:向指定集合添加一个或多个元素。SPOP key count
:随机移除并获取指定集合中一个或多个元素,适合不允许重复中奖的场景。SRANDMEMBER key count
: 随机获取指定集合中指定数量的元素,适合允许重复中奖的场景。
使用 Bitmap 统计活跃用户怎么做?
Bitmap 存储的是连续的二进制数字(0 和 1),通过 Bitmap, 只需要一个 bit 位来表示某个元素对应的值或者状态,key 就是对应元素本身 。我们知道 8 个 bit 可以组成一个 byte,所以 Bitmap 本身会极大的节省储存空间。
你可以将 Bitmap 看作是一个存储二进制数字(0 和 1)的数组,数组中每个元素的下标叫做 offset(偏移量)。
如果想要使用 Bitmap 统计活跃用户的话,可以使用日期(精确到天)作为 key,然后用户 ID 为 offset,如果当日活跃过就设置为 1。
初始化数据:
> SETBIT 20210308 1 1
(integer) 0
> SETBIT 20210308 2 1
(integer) 0
> SETBIT 20210309 1 1
(integer) 0
统计 20210308~20210309 总活跃用户数:
> BITOP and desk1 20210308 20210309
(integer) 1
> BITCOUNT desk1
(integer) 1
统计 20210308~20210309 在线活跃用户数:
> BITOP or desk2 20210308 20210309
(integer) 1
> BITCOUNT desk2
(integer) 2
使用 HyperLogLog 统计页面 UV 怎么做?
使用 HyperLogLog 统计页面 UV 主要需要用到下面这两个命令:
PFADD key element1 element2 ...
:添加一个或多个元素到 HyperLogLog 中。PFCOUNT key1 key2
:获取一个或者多个 HyperLogLog 的唯一计数。
1、将访问指定页面的每个用户 ID 添加到 HyperLogLog
中。
PFADD PAGE_1:UV USER1 USER2 ...... USERn
2、统计指定页面的 UV。
PFCOUNT PAGE_1:UV
Redis持久化机制(重要)
引入持久化:
使用缓存的时候,我们经常需要对内存中的数据进行持久化也就是将内存中的数据写入到硬盘中。大部分原因是为了之后重用数据(比如重启机器、机器故障之后恢复数据),或者是为了做数据同步(比如 Redis 集群的主从节点通过 RDB 文件同步数据)。
Redis 支持持久化,而且支持 3 种持久化方式:
快照(snapshotting,RDB)
只追加文件(append-only file, AOF)
RDB 和 AOF 的混合持久化(Redis 4.0 新增)
RDB持久化
RDB简介
RDB全称:Redis数据备份文件,也叫Redis数据快照。简单来说就是把内存中的所有数据记录到磁盘中。当Redis实例故障重启后,从磁盘读取快照文件,恢复数据
快照持久化是 Redis 默认采用的持久化方式,在 redis.conf
配置文件中默认有此下配置:
save 900 1 #在900秒(15分钟)之后,如果至少有1个key发生变化,Redis就会自动触发bgsave命令创建快照。
save 300 10 #在300秒(5分钟)之后,如果至少有10个key发生变化,Redis就会自动触发bgsave命令创建快照。
save 60 10000 #在60秒(1分钟)之后,如果至少有10000个key发生变化,Redis就会自动触发bgsave命令创建快照。
RDB 创建快照时会阻塞主线程吗?
Redis 提供了两个命令来生成 RDB 快照文件:
save
: 同步保存操作,会阻塞 Redis 主进程;bgsave
: fork 出一个子进程,子进程执行,不会阻塞 Redis 主进程,默认选项。
RDB的执行原理
Redis的主进程操作虚拟内存,虚拟内存基于页表的映射,关联到物理内存中真正处理数据的位置,可以实现对物理内存读与写的操作。当执行bgsave的时候,会fork(拷贝页表数据)主进程得到一个子进程,子进程共享主进程的内存数据,可以将读取到的数据写入到磁盘中的RDB文件
问题:主进程在修改数据,子进程在读取数据,可能发生脏读。
解决方案:copy-on-write技术
AOF持久化
AOF简介
与快照持久化相比,AOF 持久化的实时性更好。默认情况下 Redis 没有开启 AOF(append only file)方式的持久化(Redis 6.0 之后已经默认是开启了),可以通过 appendonly
参数开启:
#默认是no
appendonly yes
#AOF文件的名称
appendfilename "appendonly.aof"
AOF全称:追加文件。开启 AOF 持久化后每执行一条会更改 Redis 中的数据的命令,Redis 就会将该命令写入到 AOF 缓冲区 server.aof_buf
中,然后再写入到 AOF 文件中(此时还在系统内核缓存区未同步到磁盘),最后再根据持久化方式( fsync
策略)的配置来决定何时将系统内核缓存区的数据同步到硬盘中的。
只有同步到磁盘中才算持久化保存了,否则依然存在数据丢失的风险,比如说:系统内核缓存区的数据还未同步,磁盘机器就宕机了,那这部分数据就算丢失了。
AOF 文件的保存位置和 RDB 文件的位置相同,都是通过 dir 参数设置的,默认的文件名是 appendonly.aof。
AOF的流程
AOF 持久化功能的实现可以简单分为 5 步:
命令追加(append):所有的写命令会追加到 AOF 缓冲区中。
文件写入(write):将 AOF 缓冲区的数据写入到 AOF 文件中。这一步需要调用
write
函数(系统调用),write
将数据写入到了系统内核缓冲区之后直接返回了(延迟写)。注意!!!此时并没有同步到磁盘。文件同步(fsync):AOF 缓冲区根据对应的持久化方式(
fsync
策略)向硬盘做同步操作。这一步需要调用fsync
函数(系统调用),fsync
针对单个文件操作,对其进行强制硬盘同步,fsync
将阻塞直到写入磁盘完成后返回,保证了数据持久化。文件重写(rewrite):随着 AOF 文件越来越大,需要定期对 AOF 文件进行重写,达到压缩的目的。
重启加载(load):当 Redis 重启时,可以加载 AOF 文件进行数据恢复。
Linux 系统直接提供了一些函数用于对文件和设备进行访问和控制,这些函数被称为 系统调用(syscall)。
这里对上面提到的一些 Linux 系统调用再做一遍解释:
write
:写入系统内核缓冲区之后直接返回(仅仅是写到缓冲区),不会立即同步到硬盘。虽然提高了效率,但也带来了数据丢失的风险。同步硬盘操作通常依赖于系统调度机制,Linux 内核通常为 30s 同步一次,具体值取决于写出的数据量和 I/O 缓冲区的状态。fsync
:fsync
用于强制刷新系统内核缓冲区(同步到到磁盘),确保写磁盘操作结束才会返回。
AOF持久化三种方式
在 Redis 的配置文件中存在三种不同的 AOF 持久化方式( fsync
策略),它们分别是:
appendfsync always
:主线程调用write
执行写操作后,后台线程(aof_fsync
线程)立即会调用fsync
函数同步 AOF 文件(刷盘),fsync
完成后线程返回,这样会严重降低 Redis 的性能(write
+fsync
)。appendfsync everysec
:主线程调用write
执行写操作后立即返回,由后台线程(aof_fsync
线程)每秒钟调用fsync
函数(系统调用)同步一次 AOF 文件(write
+fsync
,fsync
间隔为 1 秒)appendfsync no
:主线程调用write
执行写操作后立即返回,让操作系统决定何时进行同步,Linux 下一般为 30 秒一次(write
但不fsync
,fsync
的时机由操作系统决定)。
可以看出:这 3 种持久化方式的主要区别在于 fsync
同步 AOF 文件的时机(刷盘)。
AOF重写
当 AOF 变得太大时,Redis 能够在后台自动重写 AOF 产生一个新的 AOF 文件,这个新的 AOF 文件和原有的 AOF 文件所保存的数据库状态一样,但体积更小。
由于 AOF 重写会进行大量的写入操作,为了避免对 Redis 正常处理命令请求造成影响,Redis 将 AOF 重写程序放到子进程里执行。
AOF 文件重写期间,Redis 还会维护一个 AOF 重写缓冲区,该缓冲区会在子进程创建新 AOF 文件期间,记录服务器执行的所有写命令。当子进程完成创建新 AOF 文件的工作之后,服务器会将重写缓冲区中的所有内容追加到新 AOF 文件的末尾,使得新的 AOF 文件保存的数据库状态与现有的数据库状态一致。最后,服务器用新的 AOF 文件替换旧的 AOF 文件,以此来完成 AOF 文件重写操作。
开启 AOF 重写功能,可以调用 BGREWRITEAOF 命令手动执行,也可以设置下面两个配置项,让程序自动决定触发时机:
#AOF文件体积最小多大以上才触发重写
auto-aof-rewrite-min-size:如果 AOF 文件大小小于该值,则不会触发 AOF 重写。默认值为 64 MB;
#AOF文件相比上次文件,增长超过多少百分比则触发重写
auto-aof-rewrite-percentage:将此值设置为 0 将禁用自动 AOF 重写。默认值为 100。
如何选择RDB和AOF
综上:
Redis 保存的数据丢失一些也没什么影响的话,可以选择使用 RDB。
不建议单独使用 AOF,因为时不时地创建一个 RDB 快照可以进行数据库备份、更快的重启以及解决 AOF 引擎错误。
如果保存的数据要求安全性比较高的话,建议同时开启 RDB 和 AOF 持久化或者开启 RDB 和 AOF 混合持久化
Redis线程模型(重要)
对于读写命令来说,Redis 一直是单线程模型。不过,在 Redis 4.0 版本之后引入了多线程来执行一些大键值对的异步删除操作, Redis 6.0 版本之后引入了多线程来处理网络请求(提高网络 IO 读写性能)
Redis单线程
Redis是单线程的,为什么还那么快
I/O多路复用模型
提高I/O效率可以从两点入手
1、减少无效等待(用户空间需要从内核空间获取数据,加入内核空间此时没有数据,用户空间就会一直等待)
2、减少拷贝次数(用户空间和内核空间是通过拷贝实现数据传输的)
阻塞IO两个阶段都阻塞
非阻塞IO只在第一个阶段步阻塞,第二个阶段依然阻塞。第一个阶段虽然是非阻塞,但是性能没有得到提高。而且忙等机制会导致CPU空转,CPU使用率暴增。
eg:前两种当用户要点餐时,会通过开关点亮灯泡,服务员接收到这个信息后,就遍历所有人,来确认该用户
epoll模式下:用户点餐时按下按钮,就会直接在计算机上显示出该号桌就绪,服务员就会为该用户上菜
加入多线程的部分都是网络IO部分(换言之IO才是影响性能的关键因素)
总结
Redis6.0之前为何不使用多线程
虽然说 Redis 是单线程模型,但实际上,Redis 在 4.0 之后的版本中就已经加入了对多线程的支持。
不过,Redis 4.0 增加的多线程主要是针对一些大键值对的删除操作的命令,使用这些命令就会使用主线程之外的其他线程来“异步处理”,从而减少对主线程的影响。
为此,Redis 4.0 之后新增了几个异步命令:
unlink
:可以看作是DEL
命令的异步版本。flushall async
:用于清空所有数据库的所有键,不限于当前select
的数据库。flushdb async
:用于清空当前select
数据库中的所有键。
总的来说,直到 Redis 6.0 之前,Redis 的主要操作仍然是单线程处理的。
那 Redis6.0 之前为什么不使用多线程? 我觉得主要原因有 3 点:
单线程编程容易并且更容易维护;
Redis 的性能瓶颈不在 CPU ,主要在内存和网络;
多线程就会存在死锁、线程上下文切换等问题,甚至会影响性能。
Redis6.0之后为何引入多线程
Redis6.0 引入多线程主要是为了提高网络 IO 读写性能,因为这个算是 Redis 中的一个性能瓶颈(Redis 的瓶颈主要受限于内存和网络)。
虽然,Redis6.0 引入了多线程,但是 Redis 的多线程只是在网络数据的读写这类耗时操作上使用了,执行命令仍然是单线程顺序执行。因此,你也不需要担心线程安全问题。
Redis6.0 的多线程默认是禁用的,只使用主线程。如需开启需要设置 IO 线程数 > 1,需要修改 redis 配置文件 redis.conf
:
io-threads 4 #设置1的话只会开启主线程,官网建议4核的机器建议设置为2或3个线程,8核的建议设置为6个线程
另外:
io-threads 的个数一旦设置,不能通过 config 动态设置。
当设置 ssl 后,io-threads 将不工作。
开启多线程后,默认只会使用多线程进行 IO 写入 writes,即发送数据给客户端,如果需要开启多线程 IO 读取 reads,同样需要修改 redis 配置文件 redis.conf
:
io-threads-do-reads yes
但是官网描述开启多线程读并不能有太大提升,因此一般情况下并不建议开启
Redis后台线程
我们虽然经常说 Redis 是单线程模型(主要逻辑是单线程完成的),但实际还有一些后台线程用于执行一些比较耗时的操作:
通过
bio_close_file
后台线程来释放 AOF / RDB 等过程中产生的临时文件资源。通过
bio_aof_fsync
后台线程调用fsync
函数将系统内核缓冲区还未同步到到磁盘的数据强制刷到磁盘( AOF 文件)。通过
bio_lazy_free
后台线程释放大对象(已删除)占用的内存空间.
Redis内存管理
Redis 给缓存数据设置过期时间有什么用?
一般情况下,我们设置保存的缓存数据的时候都会设置一个过期时间。为什么呢?
内存是有限且珍贵的,如果不对缓存数据设置过期时间,那内存占用就会一直增长,最终可能会导致 OOM(内存溢出) 问题。通过设置合理的过期时间,Redis 会自动删除暂时不需要的数据,为新的缓存数据腾出空间。
Redis 自带了给缓存数据设置过期时间的功能,比如:
127.0.0.1:6379> expire key 60 # 数据在 60s 后过期
(integer) 1
127.0.0.1:6379> setex key 60 value # 数据在 60s 后过期 (setex:[set] + [ex]pire)
OK
127.0.0.1:6379> ttl key # 查看数据还有多久过期
(integer) 56
注意 ⚠️:Redis 中除了字符串类型有自己独有设置过期时间的命令 setex
外,其他方法都需要依靠 expire
命令来设置过期时间 。另外, persist
命令可以移除一个键的过期时间。
过期时间除了有助于缓解内存的消耗,还有什么其他用么?
很多时候,我们的业务场景就是需要某个数据只在某一时间段内存在,比如我们的短信验证码可能只在 1 分钟内有效,用户登录的 Token 可能只在 1 天内有效。
如果使用传统的数据库来处理的话,一般都是自己判断过期,这样更麻烦并且性能要差很多
Redis 过期 key 删除策略
惰性删除:只会在取出/查询 key 的时候才对数据进行过期检查。这种方式对 CPU 最友好,但是可能会造成太多过期 key 没有被删除。(对内存不友好)
定期删除:每隔一段时间,就周期性的抽取一些key进行检查,删除里边过期的key。相比于惰性删除,定期删除对内存更友好,对 CPU 不太友好。
Redis是两种过期策略配合使用
Redis的定期删除如何减轻对CPU的压力?
Redis 底层会通过限制删除操作执行的时长和频率来减少删除操作对 CPU 时间的影响。
另外,定期删除还会受到执行时间和过期 key 的比例的影响:
执行时间已经超过了阈值,那么就中断这一次定期删除循环,以避免使用过多的 CPU 时间。
如果这一批过期的 key 比例超过一个比例,就会重复执行此删除流程,以更积极地清理过期 key。相应地,如果过期的 key 比例低于这个比例,就会中断这一次定期删除循环,避免做过多的工作而获得很少的内存回收。
#Redis 7.2 版本的执行时间阈值是 **25ms**,过期 key 比例设定值是 **10%**。
define ACTIVE_EXPIRE_CYCLE_FAST_DURATION 1000 /* Microseconds. */
define ACTIVE_EXPIRE_CYCLE_SLOW_TIME_PERC 25 /* Max % of CPU to use. */
define ACTIVE_EXPIRE_CYCLE_ACCEPTABLE_STALE 10 /* % of stale keys after which
we do extra efforts. */
定期删除每次随机抽查数量是多少?
#expire.c中定义了每次随机抽查的数量,Redis 7.2 版本为 20 ,也就是说每次会随机选择 20 个设置了过期时间的 key 判断是否过期。
define ACTIVE_EXPIRE_CYCLE_KEYS_PER_LOOP 20 /* Keys for each DB loop. */
如何控制定期删除的执行频率?
在 Redis 中,定期删除的频率是由 hz 参数控制的。hz 默认为 10,代表每秒执行 10 次,也就是每秒钟进行 10 次尝试来查找并删除过期的 key。 Redis 官方建议,hz 的值不建议超过 100,对于大部分用户使用默认的 10 就足够了。(增加 hz 的值,但这会加 CPU 的使用率)
类似的参数还有一个 dynamic-hz,这个参数开启之后 Redis 就会在 hz 的基础上动态计算一个值。Redis 提供并默认启用了使用自适应 hz 值的能力,
这两个参数都在 Redis 配置文件 redis.conf
中:
# 默认为 10
hz 10
# 默认开启
dynamic-hz yes
为什么定期删除不是把所有过期 key 都删除呢?
这样会对性能造成太大的影响。如果我们 key 数量非常庞大的话,挨个遍历检查是非常耗时的,会严重影响性能。Redis 设计这种策略的目的是为了平衡内存和性能。
大量 key 集中过期怎么办?
当 Redis 中存在大量 key 在同一时间点集中过期时,可能会导致以下问题:
请求延迟增加: Redis 在处理过期 key 时需要消耗 CPU 资源,如果过期 key 数量庞大,会导致 Redis 实例的 CPU 占用率升高,进而影响其他请求的处理速度,造成延迟增加。
内存占用过高: 过期的 key 虽然已经失效,但在 Redis 真正删除它们之前,仍然会占用内存空间。如果过期 key 没有及时清理,可能会导致内存占用过高,甚至引发内存溢出。
为了避免这些问题,可以采取以下方案:
尽量避免 key 集中过期: 在设置键的过期时间时尽量随机一点。
开启 lazy free 机制: 修改
redis.conf
配置文件,将lazyfree-lazy-expire
参数设置为yes
,即可开启 lazy free 机制。开启 lazy free 机制后,Redis 会在后台异步删除过期的 key,不会阻塞主线程的运行,从而降低对 Redis 性能的影响
Redis 内存淘汰策略
相关问题:MySQL 里有 2000w 数据,Redis 中只存 20w 的数据,如何保证 Redis 中的数据都是热点数据?
Redis 的内存淘汰策略只有在运行内存达到了配置的最大内存阈值时才会触发,这个阈值是通过redis.conf
的maxmemory
参数来定义的。64 位操作系统下,maxmemory
默认为 0 ,表示不限制内存大小。32 位操作系统下,默认的最大内存值是 3GB。
你可以使用命令 config get maxmemory
来查看 maxmemory
的值。
> config get maxmemory
maxmemory
0
最后两种是4.0 版本后增加
Redis事务
Redis 事务实际开发中使用的非常少
Redis 事务提供了一种将多个命令请求打包的功能。然后,再按顺序执行打包的所有命令,并且不会被中途打断。
除了不满足原子性和持久性之外,事务中的每条命令都会与 Redis 服务器进行网络交互,这是比较浪费资源的行为。明明一次批量执行多个命令就可以了。
Redis 从 2.6 版本开始支持执行 Lua 脚本,它的功能和事务非常类似。我们可以利用 Lua 脚本来批量执行多条 Redis 命令,这些 Redis 命令会被提交到 Redis 服务器一次性执行完成,大幅减小了网络开销。
一段 Lua 脚本可以视作一条命令执行,一段 Lua 脚本执行过程中不会有其他脚本或 Redis 命令同时执行,保证了操作不会被其他指令插入或打扰。
不过,如果 Lua 脚本运行时出错并中途结束,出错之后的命令是不会被执行的。并且,出错之前执行的命令是无法被撤销的,无法实现类似关系型数据库执行失败可以回滚的那种原子性效果。因此, 严格来说的话,通过 Lua 脚本来批量执行 Redis 命令实际也是不完全满足原子性的。
如果想要让 Lua 脚本中的命令全部执行,必须保证语句语法和命令都是对的。
Redis性能优化(重要)
使用批量操作减少网络传输
一个 Redis 命令的执行可以简化为以下 4 步:
发送命令
命令排队
命令执行
返回结果
其中,第 1 步和第 4 步耗费时间之和称为 Round Trip Time (RTT,往返时间) ,也就是数据在网络上传输的时间。
使用批量操作可以减少网络传输次数,进而有效减小网络开销,大幅减少 RTT。
另外,除了能减少 RTT 之外,发送一次命令的 socket I/O 成本也比较高(涉及上下文切换,存在read()
和write()
系统调用),批量操作还可以减少 socket I/O 成本
原生批量操作命令
Redis 中有一些原生支持批量操作的命令,比如:
MGET
(获取一个或多个指定 key 的值)、MSET
(设置一个或多个指定 key 的值)、HMGET
(获取指定哈希表中一个或者多个指定字段的值)、HMSET
(同时将一个或多个 field-value 对设置到指定哈希表中)、SADD
(向指定集合添加一个或多个元素)
Lua 脚本
Lua 脚本同样支持批量操作多条命令。一段 Lua 脚本可以视作一条命令执行,可以看作是 原子操作 。也就是说,一段 Lua 脚本执行过程中不会有其他脚本或 Redis 命令同时执行,保证了操作不会被其他指令插入或打扰,这是 pipeline 所不具备的。
并且,Lua 脚本中支持一些简单的逻辑处理比如使用命令读取值并在 Lua 脚本中进行处理,这同样是 pipeline 所不具备的。
不过, Lua 脚本依然存在下面这些缺陷:
如果 Lua 脚本运行时出错并中途结束,之后的操作不会进行,但是之前已经发生的写操作不会撤销,所以即使使用了 Lua 脚本,也不能实现类似数据库回滚的原子性。
Redis Cluster 下 Lua 脚本的原子操作也无法保证了,原因同样是无法保证所有的 key 都在同一个 hash slot(哈希槽)上
大量 key 集中过期问题
我在前面提到过:对于过期 key,Redis 采用的是 定期删除+惰性/懒汉式删除 策略。
定期删除执行过程中,如果突然遇到大量过期 key 的话,客户端请求必须等待定期清理过期 key 任务线程执行完成,因为这个这个定期任务线程是在 Redis 主线程中执行的。这就导致客户端请求没办法被及时处理,响应速度会比较慢。
如何解决呢? 下面是两种常见的方法:
给 key 设置随机过期时间。
开启 lazy-free(惰性删除/延迟释放) 。lazy-free 特性是 Redis 4.0 开始引入的,指的是让 Redis 采用异步方式延迟释放 key 使用的内存,将该操作交给单独的子线程处理,避免阻塞主线程。
个人建议不管是否开启 lazy-free,我们都尽量给 key 设置随机过期时间
Redis bigkey(大 Key)
什么是 bigkey?
简单来说,如果一个 key 对应的 value 所占用的内存比较大,那这个 key 就可以看作是 bigkey。具体多大才算大呢?有一个不是特别精确的参考标准:
String 类型的 value 超过 1MB
复合类型(List、Hash、Set、Sorted Set 等)的 value 包含的元素超过 5000 个(不过,对于复合类型的 value 来说,不一定包含的元素越多,占用的内存就越多)。
bigkey 是怎么产生的?有什么危害?
bigkey 通常是由于下面这些原因产生的:
程序设计不当,比如直接使用 String 类型存储较大的文件对应的二进制数据。
对于业务的数据规模考虑不周到,比如使用集合类型的时候没有考虑到数据量的快速增长。
未及时清理垃圾数据,比如哈希中冗余了大量的无用键值对。
bigkey 除了会消耗更多的内存空间和带宽,还会对性能造成比较大的影响。
大 key 还会造成阻塞问题。具体来说,主要体现在下面三个方面:
客户端超时阻塞:由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。
网络阻塞:每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。
工作线程阻塞:如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。
大 key 造成的阻塞问题还会进一步影响到主从同步和集群扩容。
如何处理 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 使用的内存,将该操作交给单独的子线程处理,避免阻塞主线程
Redis hotkey(热 Key)
什么是 hotkey?
如果一个 key 的访问次数比较多且明显多于其他 key 的话,那这个 key 就可以看作是 hotkey(热 Key)。例如在 Redis 实例的每秒处理请求达到 5000 次,而其中某个 key 的每秒访问量就高达 2000 次,那这个 key 就可以看作是 hotkey。
hotkey 出现的原因主要是某个热点数据访问量暴增,如重大的热搜事件、参与秒杀的商品。
hotkey 有什么危害?
处理 hotkey 会占用大量的 CPU 和带宽,可能会影响 Redis 实例对其他请求的正常处理。此外,如果突然访问 hotkey 的请求超出了 Redis 的处理能力,Redis 就会直接宕机。这种情况下,大量请求将落到后面的数据库上,可能会导致数据库崩溃。
因此,hotkey 很可能成为系统性能的瓶颈点,需要单独对其进行优化,以确保系统的高可用性和稳定性
如何解决 hotkey?
hotkey 的常见处理以及优化办法如下(这些方法可以配合起来使用):
读写分离:主节点处理写请求,从节点处理读请求。
使用 Redis Cluster:将热点数据分散存储在多个 Redis 节点上。
二级缓存:hotkey 采用二级缓存的方式进行处理,将 hotkey 存放一份到 JVM 本地内存中(可以用 Caffeine)。
慢查询命令
为什么会有慢查询命令?
我们知道一个 Redis 命令的执行可以简化为以下 4 步:
发送命令
命令排队
命令执行
返回结果
Redis 慢查询统计的是命令执行这一步骤的耗时,慢查询命令也就是那些命令执行时间较长的命令。
Redis 为什么会有慢查询命令呢?
Redis 中的大部分命令都是 O(1)时间复杂度,但也有少部分 O(n) 时间复杂度的命令,例如:
- `KEYS *`:会返回所有符合规则的 key。
- `HGETALL`:会返回一个 Hash 中所有的键值对。
- `LRANGE`:会返回 List 中指定范围内的元素。
- `SMEMBERS`:返回 Set 中的所有元素。
- `SINTER`/`SUNION`/`SDIFF`:计算多个 Set 的交集/并集/差集
由于这些命令时间复杂度是 O(n),有时候也会全表扫描,随着 n 的增大,执行耗时也会越长。不过, 这些命令并不是一定不能使用,但是需要明确 N 的值。另外,有遍历的需求可以使用 HSCAN
、SSCAN
、ZSCAN
代替。
除了这些 O(n)时间复杂度的命令可能会导致慢查询之外, 还有一些时间复杂度可能在 O(N) 以上的命令,例如:
- `ZRANGE`/`ZREVRANGE`:返回指定 Sorted Set 中指定排名范围内的所有元素。时间复杂度为 O(log(n)+m),n 为所有元素的数量, m 为返回的元素数量,当 m 和 n 相当大时,O(n) 的时间复杂度更小。
- `ZREMRANGEBYRANK`/`ZREMRANGEBYSCORE`:移除 Sorted Set 中指定排名范围/指定 score 范围内的所有元素。时间复杂度为 O(log(n)+m),n 为所有元素的数量, m 被删除元素的数量,当 m 和 n 相当大时,O(n) 的时间复杂度更小
Redis 内存碎片
1、什么是内存碎片
可以理解为:不可用的空闲内存
内存碎片不会影响性能,但是会增加内存消耗
2、为什么会有内存碎片
1、Redis 存储数据的时候向操作系统申请的内存空间可能会大于数据实际需要的存储空间。
Redis 使用 zmalloc 方法(Redis 自己实现的内存分配方法)进行内存分配的时候,除了要分配 size 大小的内存之外,还会多分配 PREFIX_SIZE 大小的内存。
2、频繁修改 Redis 中的数据也会产生内存碎片。
3、怎么处理内存碎片
Redis4.0-RC3 版本以后自带了内存整理,可以避免内存碎片率过大的问题。
直接通过 config set
命令将 activedefrag
配置项设置为 yes
即可。
config set activedefrag yes
具体什么时候清理需要通过下面两个参数控制:
# 内存碎片占用空间达到 500mb 的时候开始清理
config set active-defrag-ignore-bytes 500mb
# 内存碎片率大于 1.5 的时候开始清理
config set active-defrag-threshold-lower 50
通过 Redis 自动内存碎片清理机制可能会对 Redis 的性能产生影响,我们可以通过下面两个参数来减少对 Redis 性能的影响:
# 内存碎片清理所占用 CPU 时间的比例不低于 20%
config set active-defrag-cycle-min 20
# 内存碎片清理所占用 CPU 时间的比例不高于 50%
config set active-defrag-cycle-max 50
另外,重启节点可以做到内存碎片重新整理。如果你采用的是高可用架构的 Redis 集群的话,你可以将碎片率过高的主节点转换为从节点,以便进行安全重启
Redis生产问题(重要)
1、缓存穿透
什么是缓存穿透?
缓存穿透说简单点就是大量请求的 key 是不合理的,根本不存在于缓存中,也不存在于数据库中 。这就导致这些请求直接到了数据库上,根本没有经过缓存这一层,对数据库造成了巨大的压力,可能直接就被这么多请求弄宕机了。
举个例子:某个黑客故意制造一些非法的 key 发起大量请求,导致大量请求落到数据库,结果数据库上也没有查到对应的数据。也就是说这些请求最终都落到了数据库上,对数据库造成了巨大的压力。
缓存穿透解决办法?
最基本的就是首先做好参数校验,一些不合法的参数请求直接抛出异常信息返回给客户端。
比如查询的数据库 id 不能小于 0、传入的邮箱格式不对的时候直接返回错误消息给客户端等等。
1)缓存无效 key
如果缓存和数据库都查不到某个 key 的数据,那就写一个到 Redis 中去并设置过期时间,具体命令如下:SET key value EX 10086
。
这种方式可以解决请求的 key 变化不频繁的情况。
如果黑客恶意攻击,每次构建不同的请求 key,会导致 Redis 中缓存大量无效的 key 。很明显,这种方案并不能从根本上解决此问题。如果非要用这种方式来解决穿透问题的话,尽量将无效的 key 的过期时间设置短一点比如 1 分钟。
注意:一般情况下我们是这样设计 key 的:表名:列名:主键名:主键值
。
2)布隆过滤器
布隆过滤器介绍:
我们可以把它看作由二进制向量(或者说位数组)和一系列随机映射函数(哈希函数)两部分组成的数据结构。通过它可以非常方便地判断一个给定数据是否存在于海量数据中。
优点:相比于我们平时常用的 List、Map、Set 等数据结构,它占用空间更少并且效率更高。
缺点:其返回的结果是概率性的,而不是非常准确的。理论情况下添加到集合中的元素越多,误报的可能性就越大。并且,存放在布隆过滤器的数据不容易删除。
当一个元素加入布隆过滤器中的时候,会进行如下操作:
使用布隆过滤器中的哈希函数对元素值进行计算,得到哈希值(有几个哈希函数得到几个哈希值)。
根据得到的哈希值,在位数组中把对应下标的值置为 1。
当我们需要判断一个元素是否存在于布隆过滤器的时候,会进行如下操作:
对给定元素再次进行相同的哈希计算;
得到值之后判断位数组中的每个元素是否都为 1,如果值都为 1,那么说明这个值在布隆过滤器中,如果存在一个值不为 1,说明该元素不在布隆过滤器中。
加入布隆过滤器之后的缓存处理流程
对于热点数据,进行缓存预热,可以先将其批量添加到缓存中,在添加缓存的同时将数据添加到布隆过滤器
2、缓存击穿
什么是缓存击穿?
缓存击穿中,请求的 key 对应的是 热点数据 ,并且一般都设置了过期时间 。当key过期的时候,恰好这个时间点有大量的并发请求过来直接打到了数据库上,对数据库造成了巨大的压力,可能直接就被这么多请求弄宕机了。
举个例子:秒杀进行过程中,缓存中的某个秒杀商品的数据突然过期,这就导致瞬时大量对该商品的请求直接落到数据库上,对数据库造成了巨大的压力
缓存击穿解决办法?
永不过期(不推荐):设置热点数据永不过期或者过期时间比较长。
提前预热(推荐):针对热点数据提前预热,将其存入缓存中并设置合理的过期时间比如秒杀场景下的数据在秒杀结束之前不过期。
加互斥锁(看情况:保证数据高一致性,性能差):在缓存失效后,通过设置互斥锁确保只有一个请求去查询数据库并更新缓存。
逻辑过期 (看情况:保证高可用性与性能):不设置过期时间,在存储数据时新增一个过期时间的字段
逻辑过期:在线程1查询缓存时,如果发现逻辑时间过期,就会获取一个互斥锁(用于缓存重建),然后新开一个线程2来重建缓存数据,并由线程2来释放锁,线程1在开启线程2之后并不需要等待线程2重建成功,可以直接返回过期数据。
线程3:发现逻辑时间过期,也要获取锁来构建缓存,但是线程1已经获取了,所以线程2就返回过期数据
线程4:在重建数据成功后,返回的是最新的数据
所以:逻辑过期可用性与性能高,数据一致性不高
缓存穿透和缓存击穿有什么区别?
缓存穿透中,请求的 key 既不存在于缓存中,也不存在于数据库中。
缓存击穿中,请求的 key 对应的是 热点数据 ,该数据 存在于数据库中,但不存在于缓存中(通常是因为缓存中的那份数据已经过期)
3、缓存雪崩
什么是缓存雪崩?
缓存雪崩指的是在同一时间内大量的缓存key同时失效或者Redis服务宕机,导致大量的并发请求打到数据库上,对数据库造成了巨大的压力,甚至直接宕机。
举个例子:数据库中的大量数据在同一时间过期,这个时候突然有大量的请求需要访问这些过期的数据。这就导致大量的请求直接落到数据库上,对数据库造成了巨大的压力
缓存雪崩解决办法?
针对 Redis 服务不可用(宕机)的情况:
Redis 集群:采用 Redis 集群,避免单机出现问题整个缓存服务都没办法使用。Redis Cluster 和 Redis Sentinel 是两种最常用的 Redis 集群实现方案。(哨兵模式、集群模式)
多级缓存:设置多级缓存,例如本地缓存+Redis 缓存的二级缓存组合,当 Redis 缓存出现问题时,还可以从本地缓存中获取到部分数据。
针对大量缓存同时失效的情况:
设置随机失效时间(可选):为缓存设置随机的失效时间,例如在固定过期时间的基础上加上一个随机值,这样可以避免大量缓存同时到期,从而减少缓存雪崩的风险。(ttl添加随机值)
提前预热(推荐):针对热点数据提前预热,将其存入缓存中并设置合理的过期时间比如秒杀场景下的数据在秒杀结束之前不过期。
持久缓存策略(看情况):虽然一般不推荐设置缓存永不过期,但对于某些关键性和变化不频繁的数据,可以考虑这种策略
缓存预热如何实现?
常见的缓存预热方式有两种:
使用定时任务,比如 xxl-job,来定时触发缓存预热的逻辑,将数据库中的热点数据查询出来并存入缓存中。
使用消息队列,比如 Kafka,来异步地进行缓存预热,将数据库中的热点数据的主键或者 ID 发送到消息队列中,然后由缓存服务消费消息队列中的数据,根据主键或者 ID 查询数据库并更新缓存。
缓存雪崩和缓存击穿有什么区别?
缓存雪崩和缓存击穿比较像,但缓存雪崩导致的原因是缓存中的大量或者所有数据失效,缓存击穿导致的原因主要是某个热点数据不存在与缓存中(通常是因为缓存中的那份数据已经过期)
如何保证缓存和数据库数据的一致性?/双写一致
强一致性的解决方案:
延迟双删不能保证强一致性
在写数据或者读数据时,添加一个互斥锁。可以保证数据的绝对一致性,但是性能低
使用读写锁,可以保证数据的强一致性,但是性能较低(在写数据时会阻塞其他线程来读数据)
允许短暂不一致(延迟一致)解决方案:
异步通知(基于MQ、Canal):保证数据的最终一致性
延迟双删
删除缓存,修改数据库之后延迟一小会儿,再删除缓存
有可能一个线程删除了缓存,修改数据库之前另一个线程又读取了数据库修改前的东西然后放入缓存,所以第一个线程修改后要再删除一次
关于延迟双删的第一个问题:先删缓存还是先修改数据库?
针对删除缓存,修改数据据这一步有两种方案,这两种方案都可能产生的问题:数据不一致问题
1、先删缓存,再修改数据库(问题)
原来缓存和数据库中的内容都为10
线程1先删除掉了缓存中的数据,这个时间点有一个线程2他来查询缓存,未命中,查询数据库,并将数据(10)写入到缓存。而线程1在线程2操作完成后这一时间点修改了数据库中的数据(20),最终就导致了缓存和数据库的数据不一致问题
2、先修改数据库,再删缓存(问题)
原来缓存中的数据已经过期 原来数据库中的内容为10
线程1查询缓存(key已过期),未命中,查询数据库,拿到了原来数据库中的数据(10)。这个时间点线程切换,有一个线程2他来更新数据库中的数据(20),(删除缓存)。然后回到线程1,线程1将拿到的数据(10)写入到缓存,最终就导致了缓存和数据库的数据不一致问题
关于延迟双删的第二个问题:为什么要删除两次缓存?
降低数据不一致性出现的可能。
关于延迟双删的第三个问题:为什么要延时双删?
因为一般情况下数据库是主从模式,他是读写分离的,更新主库中的数据后,需要延时一会儿,将主库中的数据同步到从库。
而延时时间不好把控,也可能有脏数据的风险(发生数据不一致性)
分布式锁(互斥锁)
在写数据或者读数据时,添加一个互斥锁。可以保证数据的绝对一致性,但是性能低
读写锁(共享锁、排他锁)
使用读写锁,可以保证数据的强一致性,但是性能较低(在写数据时会阻塞其他线程来读数据)
读锁:
写锁:
基于MQ的异步通知
可以允许数据短暂不一致,保证数据的最终一致性
在修改数据库中的数据后,会发一条消息给MQ,缓存服务监听MQ,接收到其中的消息,来更新缓存(主要通过保证MQ的可靠性来实现)
基于Canal的异步通知
可以允许数据短暂不一致,保证数据的最终一致性,对于业务代码几乎0侵入
在修改数据库中的数据后,会将其发生的变化记录到一个binglog的二进制日志文件中,cancal通过监听binglog文件,将数据变化的情况发送给缓存服务,缓存服务接受到其中的消息,来更新缓存。
哪些情况可能会导致 Redis 阻塞?
O(n) 命令
Redis 中的大部分命令都是 O(1)时间复杂度,但也有少部分 O(n) 时间复杂度的命令,例如:
KEYS *
:会返回所有符合规则的 key。HGETALL
:会返回一个 Hash 中所有的键值对。LRANGE
:会返回 List 中指定范围内的元素。SMEMBERS
:返回 Set 中的所有元素。SINTER
/SUNION
/SDIFF
:计算多个 Set 的交集/并集/差集。
由于这些命令时间复杂度是 O(n),有时候也会全表扫描,随着 n 的增大,执行耗时也会越长,从而导致客户端阻塞。不过, 这些命令并不是一定不能使用,但是需要明确 N 的值。另外,有遍历的需求可以使用 HSCAN
、SSCAN
、ZSCAN
代替。
SAVE 创建 RDB 快照
Redis 提供了两个命令来生成 RDB 快照文件:
save
: 同步保存操作,会阻塞 Redis 主线程;bgsave
: fork 出一个子进程,子进程执行,不会阻塞 Redis 主线程,默认选项。
默认情况下,Redis 默认配置会使用 bgsave
命令。如果手动使用 save
命令生成 RDB 快照文件的话,就会阻塞主线程
大 Key
如果一个 key 对应的 value 所占用的内存比较大,那这个 key 就可以看作是 bigkey。具体多大才算大呢?有一个不是特别精确的参考标准:
string 类型的 value 超过 1MB
复合类型(列表、哈希、集合、有序集合等)的 value 包含的元素超过 5000 个(对于复合类型的 value 来说,不一定包含的元素越多,占用的内存就越多)。
大 key 造成的阻塞问题如下:
客户端超时阻塞:由于 Redis 执行命令是单线程处理,然后在操作大 key 时会比较耗时,那么就会阻塞 Redis,从客户端这一视角看,就是很久很久都没有响应。
引发网络阻塞:每次获取大 key 产生的网络流量较大,如果一个 key 的大小是 1 MB,每秒访问量为 1000,那么每秒会产生 1000MB 的流量,这对于普通千兆网卡的服务器来说是灾难性的。
阻塞工作线程:如果使用 del 删除大 key 时,会阻塞工作线程,这样就没办法处理后续的命令。
查找大 key
当我们在使用 Redis 自带的 --bigkeys
参数查找大 key 时,最好选择在从节点上执行该命令,因为主节点上执行时,会阻塞主节点。
我们还可以使用 SCAN 命令来查找大 key;
通过分析 RDB 文件来找出 big key,这种方案的前提是 Redis 采用的是 RDB 持久化。网上有现成的工具:
redis-rdb-tools:Python 语言写的用来分析 Redis 的 RDB 快照文件用的工具
rdb_bigkeys:Go 语言写的用来分析 Redis 的 RDB 快照文件用的工具,性能更好。
删除大 key
删除操作的本质是要释放键值对占用的内存空间。
释放内存只是第一步,为了更加高效地管理内存空间,在应用程序释放内存时,操作系统需要把释放掉的内存块插入一个空闲内存块的链表,以便后续进行管理和再分配。这个过程本身需要一定时间,而且会阻塞当前释放内存的应用程序。
所以,如果一下子释放了大量内存,空闲内存块链表操作时间就会增加,相应地就会造成 Redis 主线程的阻塞,如果主线程发生了阻塞,其他所有请求可能都会超时,超时越来越多,会造成 Redis 连接耗尽,产生各种异常。
删除大 key 时建议采用分批次删除和异步删除的方式进行。
清空数据库
清空数据库和上面 bigkey 删除也是同样道理,flushdb
、flushall
也涉及到删除和释放所有的键值对,也是 Redis 的阻塞点
Redis集群
主从复制
主从复制介绍
解决高并发
eg:一台redis节点的并发能力是10W,那么两台就是20W,提高了并发能力
注意:要进行数据的主从同步
主从数据同步的流程
主从全量同步
从节点执行replicaof命令,与主节点建立连接,然后向主节点请求数据同步,主节点接收到请求后,先判断是否是第一次同步,如果是第一次,就返回master的数据版本信息给从节点,从节点将信息保存到本地,之后主节点执行bgsave,生成一个RDB文件,并发送给从节点,从节点接收到这个文件后就清空本地数据,加载发送过来的RDB文件。(如果主节点在生成RDB文件的时候,又接受了客户端的其他请求,会把新的请求记录到rep_baklog日志文件中,再把这个日志文件中的命令发送给从节点,从节点执行接收到的命令)这样就实现了主从全量同步。
#主节点如何判断是不是第一次同步?
主节点和每一个从节点都有一个replid,当从节点请求数据同步时,会发送其replid给主节点,主节点会通过replid的对比来判断是否是第一次同步。如果两者replid不相同,就证明时第一次同步,主节点会发送其数据信息,和replid给该从节点,从节点就会将数据信息和replid记录到本地,这时两者的数据和replid就一致了,才会执行接下来的操作。
如果两者replid相同,主节点就不会生成RDB文件,而是通过rep_baklog日志文件来进行同步数据。
#如何判断从节点要从主节点中的rep_baklog文件中接收多少新命令?
通过偏移量之差(master偏移量-slave偏移量)
主从增量同步
总结:
哨兵模式
为了保证redis集群的高可用性,redis提供了哨兵模式,可以实现主从集群的自动故障恢复
eg:主节点宕机之后,redis就丧失了写数据的能力
哨兵的作用
哨兵工作原理和哨兵选举主从规则
哨兵模式可能出现的问题(脑裂)及解决方案
由于网络原因,哨兵、slave和master处于不同的网络分区,只能监测从节点。哨兵就会在从节点中选举出一个主节点。这时就出现了两个主节点,但是客户端连接的是老的master,向老的master中写入数据时,从节点不能同步数据。如果网络此时恢复,哨兵会将老的master降为slave,其也会从master中同步数据,而自己的数据就会被清空,即原先从客户端写入的数据就丢失了。
解决办法
master至少有一个slave时才能从客户端接收数据‘
设置主从数据同步的延迟不能超过5s
总结
针对第二个问题:针对不同的服务,可以搭建多个redis的集群
分片集群
分片集权特点
分片式集群实现了哨兵的所有功能,而且客户端请求时可以访问任意一个master,最终会被转发到正确的master节点(因为这些节点之间做了自动的路由,会把客户端的请求路由到正确的节点)
分片集群中数据读写的流程
将hash槽平均分配到每一个master节点中,每一个key通过CRC16校验后对 16384取模来决定放哪个槽
可以通过设置有效部分来讲相同业务的key存放到相同的hash槽中
总结
Spring&SpringBoot框架
Spring
什么是Spring框架
Spring 是一款开源的轻量级 Java 开发框架,旨在提高开发人员的开发效率以及系统的可维护性。
我们一般说 Spring 框架指的都是 Spring Framework,它是很多模块的集合,使用这些模块可以很方便地协助我们进行开发,比如说 Spring 支持 IOC(Inversion of Control:控制反转) 和 AOP(Aspect-Oriented Programming:面向切面编程)、可以很方便地对数据库进行访问、可以很方便地集成第三方组件(电子邮件,任务,调度,缓存等等)、对单元测试支持比较好、支持 RESTful Java 应用程序的开发
Spring,SpringMVC,SpringBoot之间有什么关系
Spring 框架指的都是 Spring Framework,它是很多模块的集合。其中最重要的是 Spring-Core(主要提供 IoC 依赖注入功能的支持) 模块, Spring 中的其他模块(比如 Spring MVC)的功能实现基本都需要依赖于该模块。
Spring MVC 是 Spring 中的一个很重要的模块,主要赋予 Spring 快速构建 MVC 架构的 Web 程序的能力。MVC 是模型(Model)、视图(View)、控制器(Controller)的简写,其核心思想是通过将业务逻辑、数据、显示分离来组织代码。
Spring 旨在简化 JavaEE 企业应用程序开发。Spring Boot 旨在简化 Spring 开发(减少配置文件,开箱即用!)。
Spring Boot 只是简化了配置,如果你需要构建 MVC 架构的 Web 程序,你还是需要使用 Spring MVC 作为 MVC 框架,只是说 Spring Boot 帮你简化了 Spring MVC 的很多配置,真正做到开箱即用
Spring框架使用到了那些配置模式
工厂设计模式 : Spring 使用工厂模式通过
BeanFactory
、ApplicationContext
创建 bean 对象。
两者对比:
BeanFactory
:延迟注入(使用到某个 bean 的时候才会注入),相比于ApplicationContext
来说会占用更少的内存,程序启动速度更快。ApplicationContext
:容器启动的时候,不管你用没用到,一次性创建所有 bean 。BeanFactory
仅提供了最基本的依赖注入支持,ApplicationContext
扩展了BeanFactory
,除了有BeanFactory
的功能还有额外更多功能,所以一般开发人员使用ApplicationContext
会更多。
ApplicationContext
的三个实现类:
ClassPathXmlApplication
:把上下文文件当成类路径资源。FileSystemXmlApplication
:从文件系统中的 XML 文件载入上下文定义信息。XmlWebApplicationContext
:从 Web 系统中的 XML 文件载入上下文定义信息。
代理设计模式 : Spring AOP 功能的实现。
AOP就是将那些与业务无关,却为业务模块所共同调用的逻辑(例如事务处理、日志管理、权限控制等)封装起来,便于减少系统的重复代码,降低模块间的耦合度,同时提高了系统的可扩展性和可维护性。
Spring AOP 就是基于动态代理的,如果要代理的对象,实现了某个接口,那么 Spring AOP 会使用 JDK Proxy 去创建代理对象,而对于没有实现接口的对象,就无法使用 JDK Proxy 去进行代理了,这时候 Spring AOP 会使用 Cglib 生成一个被代理对象的子类来作为代理,
单例设计模式 : Spring 中的 Bean 默认都是单例的。
在我们的系统中,有一些对象其实我们只需要一个,比如说:线程池、缓存、对话框、注册表、日志对象、充当打印机、显卡等设备驱动程序的对象
使用单例模式的好处 :
对于频繁使用的对象,可以省略创建对象所花费的时间,这对于那些重量级对象而言,是非常可观的一笔系统开销;
由于 new 操作的次数减少,因而对系统内存的使用频率也会降低,这将减轻 GC 压力,缩短 GC 停顿时间。
模板方法模式 : Spring 中
jdbcTemplate
、hibernateTemplate
等以 Template 结尾的对数据库操作的类,它们就使用到了模板模式。
模板方法模式是一种行为设计模式,它定义一个操作中的算法的骨架,而将一些步骤延迟到子类中。 模板方法使得子类可以不改变一个算法的结构即可重定义该算法的某些特定步骤的实现方式
public abstract class Template {
//这是我们的模板方法
public final void TemplateMethod(){
PrimitiveOperation1();
PrimitiveOperation2();
PrimitiveOperation3();
}
protected void PrimitiveOperation1(){
//当前类实现
}
//被子类实现的方法
protected abstract void PrimitiveOperation2();
protected abstract void PrimitiveOperation3();
}
public class TemplateImpl extends Template {
@Override
public void PrimitiveOperation2() {
//当前类实现
}
@Override
public void PrimitiveOperation3() {
//当前类实现
}
}
包装器设计模式 : 我们的项目需要连接多个数据库,而且不同的客户在每次访问中根据需要会去访问不同的数据库。这种模式让我们可以根据客户的需求能够动态切换不同的数据源。
观察者模式: Spring 事件驱动模型就是观察者模式很经典的一个应用。
适配器模式 : Spring AOP 的增强或通知(Advice)使用到了适配器模式、spring MVC 中也是用到了适配器模式适配
Controller
。
在 Spring MVC 中,DispatcherServlet
根据请求信息调用 HandlerMapping
,解析请求对应的 Handler
。解析到对应的 Handler
(也就是我们平常说的 Controller
控制器)后,开始由HandlerAdapter
适配器处理。
SpringIOC
对SpringIOC的理解
IoC(Inversion of Control:控制反转) 是一种设计思想,而不是一个具体的技术实现。IoC 的思想就是将原本在程序中手动创建对象的控制权,交由 Spring 框架来管理。不过, IoC 并非 Spring 特有,在其他语言中也有应用。
为什么叫控制反转?
控制:指的是对象创建(实例化、管理)的权力
反转:控制权交给外部环境(Spring 框架、IoC 容器)
什么是SpringBean
Bean 代指的就是那些被 IoC 容器所管理的对象。
将一个类声明为Bean的注解有哪些
@Component
:通用的注解,可标注任意类为Spring
组件。如果一个 Bean 不知道属于哪个层,可以使用@Component
注解标注。@Repository
: 对应持久层即 Dao 层,主要用于数据库相关操作。@Service
: 对应服务层,主要涉及一些复杂的逻辑,需要用到 Dao 层。@Controller
: 对应 Spring MVC 控制层,主要用于接受用户请求并调用Service
层返回数据给前端页
@Component和@Bean的区别
@Component
注解作用于类,而@Bean
注解作用于方法。@Component
通常是通过类路径扫描来自动侦测以及自动装配到 Spring 容器中(我们可以使用@ComponentScan
注解定义要扫描的路径从中找出标识了需要装配的类自动装配到 Spring 的 bean 容器中)。@Bean
注解通常是我们在标有该注解的方法中定义产生这个 bean,@Bean
告诉了 Spring 这是某个类的实例,当我需要用它的时候还给我。@Bean
注解比@Component
注解的自定义性更强,而且很多地方我们只能通过@Bean
注解来注册 bean。比如当我们引用第三方库中的类需要装配到Spring
容器时,则只能通过@Bean
来实现。
注入Bean的注解有哪些
@Autowired和@Resource的区别
@Autowired
是 Spring 提供的注解,@Resource
是 JDK 提供的注解。Autowired
默认的注入方式为byType
(根据类型进行匹配),@Resource
默认注入方式为byName
(根据名称进行匹配)。当一个接口存在多个实现类的情况下,
@Autowired
和@Resource
都需要通过名称才能正确匹配到对应的 Bean。Autowired
可以通过@Qualifier
注解来显式指定名称,@Resource
可以通过name
属性来显式指定名称。@Autowired
支持在构造函数、方法、字段和参数上使用。@Resource
主要用于字段和方法上的注入,不支持在构造函数或参数上使用。
// 报错,byName 和 byType 都无法匹配到 bean
@Autowired
private SmsService smsService;
// 正确注入 SmsServiceImpl1 对象对应的 bean
@Autowired
private SmsService smsServiceImpl1;
// 正确注入 SmsServiceImpl1 对象对应的 bean
// smsServiceImpl1 就是我们上面所说的名称
@Autowired
@Qualifier(value = "smsServiceImpl1")
private SmsService smsService;
// 报错,byName 和 byType 都无法匹配到 bean
@Resource
private SmsService smsService;
// 正确注入 SmsServiceImpl1 对象对应的 bean
@Resource
private SmsService smsServiceImpl1;
// 正确注入 SmsServiceImpl1 对象对应的 bean(比较推荐这种方式)
@Resource(name = "smsServiceImpl1")
private SmsService smsService;
注入Bean的方式有哪些
依赖注入 (Dependency Injection, DI) 的常见方式:
构造函数注入:通过类的构造函数来注入依赖项。
Setter 注入:通过类的 Setter 方法来注入依赖项。
Field(字段) 注入:直接在类的字段上使用注解(如
@Autowired
或@Resource
)来注入依赖项。
//构造函数注入
@Service
public class UserService {
private final UserRepository userRepository;
public UserService(UserRepository userRepository) {
this.userRepository = userRepository;
}
}
//Setter注入
@Service
public class UserService {
private UserRepository userRepository;
// 在 Spring 4.3 及以后的版本,特定情况下 @Autowired 可以省略不写
@Autowired
public void setUserRepository(UserRepository userRepository) {
this.userRepository = userRepository;
}
}
//字段注入
@Service
public class UserService {
@Autowired
private UserRepository userRepository;
}
构造函数注入还是Settrt注入
Spring 官方推荐构造函数注入,这种注入方式的优势如下:
依赖完整性:确保所有必需依赖在对象创建时就被注入,避免了空指针异常的风险。
不可变性:有助于创建不可变对象,提高了线程安全性。
初始化保证:组件在使用前已完全初始化,减少了潜在的错误。
测试便利性:在单元测试中,可以直接通过构造函数传入模拟的依赖项,而不必依赖 Spring 容器进行注入。
构造函数注入适合处理必需的依赖项,而 Setter 注入 则更适合可选的依赖项,这些依赖项可以有默认值或在对象生命周期中动态设置。虽然 @Autowired
可以用于 Setter 方法来处理必需的依赖项,但构造函数注入仍然是更好的选择。
在某些情况下(例如第三方类不提供 Setter 方法),构造函数注入可能是唯一的选择。
Bean的作用域有哪些
Spring 中 Bean 的作用域通常有下面几种:(通过@Scope注解来设置)
singleton : IoC 容器中只有唯一的 bean 实例。Spring 中的 bean 默认都是单例的,是对单例设计模式的应用。
prototype : 每次获取都会创建一个新的 bean 实例。也就是说,连续
getBean()
两次,得到的是不同的 Bean 实例。request (仅 Web 应用可用): 每一次 HTTP 请求都会产生一个新的 bean(请求 bean),该 bean 仅在当前 HTTP request 内有效。
session (仅 Web 应用可用) : 每一次来自新 session 的 HTTP 请求都会产生一个新的 bean(会话 bean),该 bean 仅在当前 HTTP session 内有效。
application/global-session (仅 Web 应用可用):每个 Web 应用在启动时创建一个 Bean(应用 Bean),该 bean 仅在当前应用启动时间内有效。
websocket (仅 Web 应用可用):每一次 WebSocket 会话产生一个新的 bean。
Bean是线程安全的吗
Spring 框架中的 Bean 是否线程安全,取决于其作用域和状态。
1、prototype 作用域下,每次获取都会创建一个新的 bean 实例,不存在资源竞争问题,所以不存在线程安全问题
2、singleton 作用域下,IoC 容器中只有唯一的 bean 实例,可能会存在资源竞争问题(取决于 Bean 是否有状态)。如果这个 bean 是有状态的话,那就存在线程安全问题(有状态 Bean 是指包含可变的成员变量的对象)。
//有状态Bean(线程不安全)
// 定义了一个购物车类,其中包含一个保存用户的购物车里商品的 List
@Component
public class ShoppingCart {
private List<String> items = new ArrayList<>();
public void addItem(String item) {
items.add(item);
}
public List<String> getItems() {
return items;
}
}
//无状态Bean(Dao、Service)即:没有定义可变的成员变量(线程安全)
// 定义了一个用户服务,它仅包含业务逻辑而不保存任何状态。
@Component
public class UserService {
public User findUserById(Long id) {
//...
}
//...
}
对于有状态单例 Bean 的线程安全问题,常见的三种解决办法是:
避免可变成员变量: 尽量设计 Bean 为无状态。
使用
ThreadLocal
: 将可变成员变量保存在ThreadLocal
中,确保线程独立。使用同步机制: 利用
synchronized
或ReentrantLock
来进行同步控制,确保线程安全。单例设置为多例:将Bean的作用域由singleton变更为prototype
Bean的生命周期
1、在创建 Bean 之前,Spring 会通过扫描获取 BeanDefinition。通过BeanDefinition获取bean的定义信息
2、通过构造函数来实例化bean
3、通过依赖注入,为Bean设置相关的属性和依赖
4、Bean的初始化
1、处理Bean实现的Aware接口,重写其中的一些方法。比如BeanNameAware
接口调用 setBeanName()
方法,传入 Bean 的名字,BeanFactoryAware
接口,调用 setBeanFactory()
方法,传入 BeanFactory
对象的实例。
2、执行Bean实现的后置处理器接口(BeanPostProcessor)中的在Bean初始化之前的方法(postProcessBeforeInitialization())
3、执行Bean的初始化方法。如果 Bean 实现了InitializingBean
接口,执行afterPropertiesSet()
方法。如果 Bean 在配置文件中 的定义包含 init-method
属性,执行指定的方法
4、执行Bean实现的后置处理器接口(BeanPostProcessor)中的在Bean初始化之后的方法(postProcessAfterInitialization())
5、销毁Bean。如果 Bean 实现了 DisposableBean
接口,执行 destroy()
方法。如果 Bean 在配置文件中的定义包含 destroy-method
属性,执行指定的 Bean 销毁方法。
Bean的循环引用/Spring的循环依赖
创建A对象时需要使用B对象,创建B对象时需要使用A对象。或者创建A对象时需要使用A对象自己。
Spring框架通过三级缓存解决循环依赖(解决的是初始化时产生的循环依赖)
Spring框架中提供了一个类DefaultSingletonBeanRegistry(单实例对象注册器),类中定义了三个集合即三级缓存
一级缓存是一个单例池,如果一个Bean已经完成了一个完整的生命周期,就会把对象放到一级缓存中。
二级缓存,存放的是生命周期还没有走完的对象,就是半成品的对象。
三级缓存:来存储Bean的对象工厂(创建对象用的)
在对象A创建成功放入到一级缓存中后,二级缓存中的半成品A就被清除掉。
一级缓存和二级缓存可以解决普通对象的循环依赖问题,但是不能解决代理对象(增强的对象)的循环依赖问题(三级缓存)
当 Spring 创建 A 之后,发现 A 依赖了 B ,又去创建 B,B 依赖了 A ,又去创建 A;
在 B 创建 A 的时候,那么此时 A 就发生了循环依赖,由于 A 此时还没有初始化完成,因此在 一二级缓存 中肯定没有 A;
那么此时就去三级缓存中调用 getObject() 方法去获取 A 的 前期暴露的对象 ,也就是调用上边加入的 getEarlyBeanReference() 方法,生成一个 A 的 前期暴露对象;
然后就将这个 ObjectFactory 从三级缓存中移除,并且将前期暴露对象放入到二级缓存中,那么 B 就将这个前期暴露对象注入到依赖,来支持循环依赖。
//只用两级缓存够吗?
在没有 AOP 的情况下,确实可以只使用一级和三级缓存来解决循环依赖问题。但是,当涉及到 AOP 时,二级缓存就显得非常重要了,因为它确保了即使在 Bean 的创建过程中有多次对早期引用的请求,也始终只返回同一个代理对象,从而避免了同一个 Bean 有多个代理对象的问题。
还有三级缓存解决不了的问题:需要手动解决
构造方法出现了循环依赖
在构造方法上加一个延迟加载注解,在需要使用B对象的时候,Spring会生成并返回一个B的代理对象
SpringAOP
什么是AOP
AOP就是将那些与业务无关,却为业务模块所共同调用的逻辑(例如事务处理、日志管理、权限控制等)封装起来,便于减少系统的重复代码,降低模块间的耦合度,同时提高了系统的可扩展性和可维护性。
eg:Spring管理的事务,底层就是AOP。AOP的底层用的是动态代理
AOP中常见的通知类型
Before(前置通知):目标对象的方法调用之前触发
After (后置通知):目标对象的方法调用之后触发
AfterReturning(返回通知):目标对象的方法调用完成,在返回结果值之后触发
AfterThrowing(异常通知):目标对象的方法运行中抛出 / 触发异常后触发。AfterReturning 和 AfterThrowing 两者互斥。如果方法调用成功无异常,则会有返回值;如果方法抛出了异常,则不会有返回值。
Around (环绕通知):编程式控制目标对象的方法调用。环绕通知是所有通知类型中可操作范围最大的一种,因为它可以直接拿到目标对象,以及要执行的方法,所以环绕通知可以任意的在目标对象的方法调用前后搞事,甚至不调用目标对象的方法
多个切面的执行顺序如何控制
1、通常使用@Order
注解直接定义切面顺序
// 值越小优先级越高
@Order(3)
@Component
@Aspect
public class LoggingAspect implements Ordered {
2、实现Ordered
接口重写 getOrder
方法。
@Component
@Aspect
public class LoggingAspect implements Ordered {
@Override
public int getOrder() {
// 返回值越小优先级越高
return 1;
}
}
Spring事务
Spring管理事务的方式有几种
编程式事务:在代码中硬编码(在分布式系统中推荐使用) : 通过
TransactionTemplate
或者TransactionManager
手动管理事务,事务范围过大会出现事务未提交导致超时,因此事务要比锁的粒度更小。声明式事务:本质是通过 AOP 实现,对方法前后进行拦截,在执行方法之前开启事务,在执行完目标方法之后根据执行情况提交或者回滚事务。(单体应用或者简单业务系统推荐使用) : (基于
@Transactional
的全注解方式使用最多
事务失效的场景
1、异常捕获处理
需要手动向上抛
2、抛出检查异常
@Transactional(rollbackFor = Exception.class)注解了解吗?
@Transactional
注解默认回滚策略是只有在遇到RuntimeException
(运行时异常) 或者 Error
时才会回滚事务,而不会回滚 Checked Exception
(受检查异常)。这是因为 Spring 认为RuntimeException
和 Error 是不可预期的错误,而受检异常是可预期的错误,可以通过业务逻辑来处理。
如果想要修改默认的回滚策略,可以使用 @Transactional
注解的 rollbackFor
和 noRollbackFor
属性来指定哪些异常需要回滚,哪些异常不需要回滚
//让所有的异常都回滚事务
@Transactional(rollbackFor = Exception.class)
public void someMethod() {
// some business logic
}
//让某些特定的异常不回滚事务
@Transactional(noRollbackFor = CustomException.class)
public void someMethod() {
// some business logic
}
3、非public方法导致的事务失效
SpringMVC
SpringMVC的核心组件
DispatcherServlet
:核心的中央处理器,负责接收请求、分发,并给予客户端响应。HandlerMapping
:处理器映射器,根据 URL 去匹配查找能处理的Handler
,并会将请求涉及到的拦截器和Handler
一起封装。HandlerAdapter
:处理器适配器,根据HandlerMapping
找到的Handler
,适配执行对应的Handler
;Handler
:请求处理器,处理实际请求的处理器。ViewResolver
:视图解析器,根据Handler
返回的逻辑视图 / 视图,解析并渲染真正的视图,并传递给DispatcherServlet
响应客户端
SpringMVC的执行流程
流程说明(重要):
客户端(浏览器)发送请求,
DispatcherServlet
拦截请求。DispatcherServlet
根据请求信息调用HandlerMapping
。HandlerMapping
根据 URL 去匹配查找能处理的Handler
(也就是我们平常说的Controller
控制器) ,并会将请求涉及到的拦截器和Handler
一起封装。DispatcherServlet
调用HandlerAdapter
适配器执行Handler
。Handler
完成对用户请求的处理后,会返回一个ModelAndView
对象给DispatcherServlet
,ModelAndView
顾名思义,包含了数据模型以及相应的视图的信息。Model
是返回的数据对象,View
是个逻辑上的View
。ViewResolver
会根据逻辑View
查找实际的View
。DispaterServlet
把返回的Model
传给View
(视图渲染)。把
View
返回给请求者(浏览器)
SpringBoot的自动配置原里
文件里边的自动配置类不是所有的都会加载进来
判断是否有对应的字节码文件:如果在pom文件中引入了相应的起步依赖,那他的class文件就存在
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Inherited
@AutoConfigurationPackage //作用:将main包下的所有组件注册到容器中
@Import({AutoConfigurationImportSelector.class}) //加载自动装配类 xxxAutoconfiguration
public @interface EnableAutoConfiguration {
String ENABLED_OVERRIDE_PROPERTY = "spring.boot.enableautoconfiguration";
Class<?>[] exclude() default {};
String[] excludeName() default {};
}
Spring框架常见的注解
Spring中常见的注解
SpringBoot中常见的注解
SpringMVC中常见的注解
Mybatis
Mybatis的执行流程
核心配置文件的主要作用:1、加载环境的配置 2、加载一些映射文件(映射文件、扫描包)
yml了就说导入了mybatis-spring-boot的起步依赖,然后在yml配置文件中写数据库(jdbc)的相关信息,在数据持久层的类上(Dao)添加Mapper注解用于扫描替代了原本mybat
Mybatis的缓存
perpetualCache:本地缓存
一级缓存的作用域是同一个SqlSession,在同一个SqlSession中两次执行相同的sql语句,第一次执行完毕会将数据库查询的数据写到缓存(内存),第二次会从缓存中获取数据而不进行数据库查询,大大提高了查询效率。当一个SqlSession结束后该SqlSession中的一级缓存也就不存在了。MyBtais默认启动以及缓存。
二级缓存是多个SqlSession共享的,其作用域是mapper的同一个namespace,不同的sqlSession两次执行相同namespace下的sql语句且向sql中传递的参数也相同时,第一次执行完毕会将数据库中查询到的数据写到缓存(内存),第二次会直接从缓存中获取,从而提高了查询效率。MyBatis默认不开启二级缓存,需要在MyBtais全局配置文件中进行setting配置开启二级缓存。
通过application.yml配置二级缓存开启
# mybatis相关配置
mybatis:
configuration:
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
#开启MyBatis的二级缓存
cache-enabled: true
在 xxxMapper.xml 文件中添加
<cache eviction="FIFO" flushInterval="60000" readOnly="false" size="1024"/>
消息中间件
MQ基础概念
这里的消息队列主要指的是分布式消息队列。
什么是消息队列
把消息队列看作是一个存放消息的容器,当我们需要使用消息的时候,直接从容器中取出消息供自己使用即可。由于队列 Queue 是一种先进先出的数据结构,所以消费消息时也是按照顺序来消费的。
参与消息传递的双方称为 生产者 和 消费者 ,生产者负责发送消息,消费者负责处理消息。
中间件就是一类为应用软件服务的软件,应用软件是为用户服务的,用户不会接触或者使用到中间件。
消息队列的作用
异步处理
削峰/限流
降低系统耦合性
实现分布式事务、顺序保证和数据流处理
异步处理
将用户请求中包含的耗时操作,通过消息队列实现异步处理,将对应的消息发送到消息队列之后就立即返回结果,减少响应时间,提高用户体验。随后,系统再对消息进行消费
因为用户请求数据写入消息队列之后就立即返回给用户了,但是请求数据在后续的业务校验、写数据库等操作中可能失败。因此,使用消息队列进行异步处理之后,需要适当修改业务流程进行配合,比如用户在提交订单之后,订单数据写入消息队列,不能立即返回用户订单提交成功,需要在消息队列的订单消费者进程真正处理完该订单之后,甚至出库后,再通过电子邮件或短信通知用户订单成功,以免交易纠纷。这就类似我们平时手机订火车票和电影票
削峰/限流
先将短时间高并发产生的事务消息存储在消息队列中,然后后端服务再慢慢根据自己的能力去消费这些消息,这样就避免直接把后端服务打垮掉
降低系统耦合性
使用消息队列还可以降低系统耦合性。如果模块之间不存在直接调用,那么新增模块或者修改模块就对其他模块影响较小,这样系统的可扩展性无疑更好一些。
生产者(客户端)发送消息到消息队列中去,消费者(服务端)处理消息,需要消费的系统直接去消息队列取消息进行消费即可而不需要和其他系统有耦合,这显然也提高了系统的扩展性。
消息队列使用发布-订阅模式工作,消息发送者(生产者)发布消息,一个或多个消息接受者(消费者)订阅消息。 从上图可以看到消息发送者(生产者)和消息接受者(消费者)之间没有直接耦合,消息发送者将消息发送至分布式消息队列即结束对消息的处理,消息接受者从分布式消息队列获取该消息后进行后续处理,并不需要知道该消息从何而来。对新增业务,只要对该类消息感兴趣,即可订阅该消息,对原有系统和业务没有任何影响,从而实现网站业务的可扩展性设计。
例如,我们商城系统分为用户、订单、财务、仓储、消息通知、物流、风控等多个服务。用户在完成下单后,需要调用财务(扣款)、仓储(库存管理)、物流(发货)、消息通知(通知用户发货)、风控(风险评估)等服务。使用消息队列后,下单操作和后续的扣款、发货、通知等操作就解耦了,下单完成发送一个消息到消息队列,需要用到的地方去订阅这个消息进行消息即可。
另外,为了避免消息队列服务器宕机造成消息丢失,会将成功发送到消息队列的消息存储在消息生产者服务器上,等消息真正被消费者服务器处理后才删除消息。在消息队列服务器宕机后,生产者服务器会选择分布式消息队列服务器集群中的其他服务器发布消息。
实现分布式事务
分布式事务的解决方案之一就是 MQ 事务。
RocketMQ、 Kafka、Pulsar、QMQ 都提供了事务相关的功能。事务允许事件流应用将消费,处理,生产消息整个过程定义为一个原子操作。
顺序保证
在很多应用场景中,处理数据的顺序至关重要。消息队列保证数据按照特定的顺序被处理,适用于那些对数据顺序有严格要求的场景。大部分消息队列,例如 RocketMQ、RabbitMQ、Pulsar、Kafka,都支持顺序消息
延时/定时处理
消息发送后不会立即被消费,而是指定一个时间,到时间后再消费。大部分消息队列,例如 RocketMQ、RabbitMQ、Pulsar、Kafka,都支持定时/延时消息。
即时通讯
MQTT(消息队列遥测传输协议)是一种轻量级的通讯协议,采用发布/订阅模式,非常适合于物联网(IoT)等需要在低带宽、高延迟或不可靠网络环境下工作的应用。它支持即时消息传递,即使在网络条件较差的情况下也能保持通信的稳定性。
RabbitMQ 内置了 MQTT 插件用于实现 MQTT 功能(默认不启用,需要手动开启)。
数据流处理
针对分布式系统产生的海量数据流,如业务日志、监控数据、用户行为等,消息队列可以实时或批量收集这些数据,并将其导入到大数据处理引擎中,实现高效的数据流管理和处理
使用消息对列带来的问题
系统可用性降低: 系统可用性在某种程度上降低,因为在加入 MQ 之前,不用考虑消息丢失或者说 MQ 挂掉等等的情况,但是,引入 MQ 之后就需要去考虑了!
系统复杂性提高: 加入 MQ 之后,你需要保证消息没有被重复消费、处理消息丢失的情况、保证消息传递的顺序性等等问题!
一致性问题: 我上面讲了消息队列可以实现异步,消息队列带来的异步确实可以提高系统响应速度。但是,万一消息的真正消费者并没有正确消费消息怎么办?这样就会导致数据不一致的情况了
消息队列的种类,如何选择
Kafka 、RocketMQ、RabbitMQ、Pulsar、ActiveMQ(已经淘汰)
总结:
ActiveMQ 的社区算是比较成熟,但是较目前来说,ActiveMQ 的性能比较差,而且版本迭代很慢,不推荐使用,已经被淘汰了。
RabbitMQ 在吞吐量方面虽然稍逊于 Kafka、RocketMQ 和 Pulsar,但是由于它基于 Erlang 开发,所以并发能力很强,性能极其好,延时很低,达到微秒级。但是也因为 RabbitMQ 基于 Erlang 开发,所以国内很少有公司有实力做 Erlang 源码级别的研究和定制。如果业务场景对并发量要求不是太高(十万级、百万级),那这几种消息队列中,RabbitMQ 或许是你的首选。
RocketMQ 和 Pulsar 支持强一致性,对消息一致性要求比较高的场景可以使用。
RocketMQ 阿里出品,Java 系开源项目,源代码我们可以直接阅读,然后可以定制自己公司的 MQ,并且 RocketMQ 有阿里巴巴的实际业务场景的实战考验。
Kafka 的特点其实很明显,就是仅仅提供较少的核心功能,但是提供超高的吞吐量,ms 级的延迟,极高的可用性以及可靠性,而且分布式可以任意扩展。同时 Kafka 最好是支撑较少的 topic 数量即可,保证其超高吞吐量。Kafka 唯一的一点劣势是有可能消息重复消费,那么对数据准确性会造成极其轻微的影响,在大数据领域中以及日志采集中,这点轻微影响可以忽略这个特性天然适合大数据实时计算以及日志收集。如果是大数据领域的实时计算、日志采集等场景,用 Kafka 是业内标准的,绝对没问题,社区活跃度很高,绝对不会黄,何况几乎是全世界这个领域的事实性规范
RabbitMQ
Kafka
Kafka是什么?主要应用场景是什么
Kafka 是一个分布式流式处理平台
流式处理平台具有三个关键功能:
消息队列:发布和订阅消息流,这个功能类似于消息队列,这也是 Kafka 也被归类为消息队列的原因。
容错的持久方式存储记录消息流:Kafka 会把消息持久化到磁盘,有效避免了消息丢失的风险。
流式处理平台: 在消息发布的时候进行处理,Kafka 提供了一个完整的流式处理类
Kafka 主要有两大应用场景:
消息队列:建立实时流数据管道,以可靠地在系统或应用程序之间获取数据。
数据处理: 构建实时的流数据处理程序来转换或处理数据流
Kafka得优势在哪里
极致的性能:基于 Scala 和 Java 语言开发,设计中大量使用了批量处理和异步的思想,最高可以每秒处理千万级别的消息。
生态系统兼容性无可匹敌:Kafka 与周边生态系统的兼容性是最好的没有之一,尤其在大数据和流计算领域。
Kafaka得消息模型
发布-订阅模型
发布订阅模型(Pub-Sub) 使用主题(Topic) 作为消息通信载体,类似于广播模式;发布者发布一条消息,该消息通过主题传递给所有的订阅者,在一条消息广播之后才订阅的用户则是收不到该条消息的。
举个例子
想象一下,你在一个会议室里,这个会议室有一个大喇叭,这个喇叭就是“主题(Topic)”。现在,会议室里的每个人都是订阅者,他们都能听到喇叭里播放的消息。
1. **发布者发布消息**:假设你是公司的CEO,你通过这个喇叭(主题)宣布:“明天上午9点,全体员工在大会议室开年会。” 这个通知就是“发布者发布的消息”。
2. **消息传递给所有订阅者**:会议室里的所有人都能听到这个通知,这就是“消息通过主题传递给所有订阅者”。
3. **后来的订阅者收不到消息**:但是,如果有人在你宣布这个消息之后才进入会议室,那么他就错过了这个通知,因为他是在“一条消息广播之后才订阅的”。他不会知道明天上午9点要开年会。
/*这个例子说明了发布订阅模型中的一个特点:消息是实时传递的,如果订阅者在消息发布之后才订阅,那么他就无法接收到之前发布的消息。这与邮件列表不同,邮件列表中的新订阅者可以查看之前发送的所有邮件,而发布订阅模型中的消息是即时的,错过了就无法再获取。
RocketMQ 的消息模型和 Kafka 基本是完全一样的。唯一的区别是 Kafka 中没有队列这个概念,与之对应的是 Partition(分区)
Kafaka几个重要概念
Producer(生产者) : 产生消息的一方。
Consumer(消费者) : 消费消息的一方。
Broker(代理) : 可以看作是一个独立的 Kafka 实例。多个 Kafka Broker 组成一个 Kafka Cluster。
同时,你一定也注意到每个 Broker 中又包含了 Topic 以及 Partition 这两个重要的概念:
Topic(主题) : Producer 将消息发送到特定的主题,Consumer 通过订阅特定的 Topic(主题) 来消费消息。
Partition(分区) : Partition 属于 Topic 的一部分。一个 Topic 可以有多个 Partition ,并且同一 Topic 下的 Partition 可以分布在不同的 Broker 上,这也就表明一个 Topic 可以横跨多个 Broker 。这正如我上面所画的图一样。
划重点:Kafka 中的 Partition(分区) 实际上可以对应成为消息队列中的队列。topic是逻辑上的划分,分区是物理上的
Kafaka的多副本机制
还有一点我觉得比较重要的是 Kafka 为分区(Partition)引入了多副本(Replica)机制。分区(Partition)中的多个副本之间会有一个叫做 leader 的家伙,其他副本称为 follower。我们发送的消息会被发送到 leader 副本,然后 follower 副本才能从 leader 副本中拉取消息进行同步。
生产者和消费者只与 leader 副本交互。你可以理解为其他副本只是 leader 副本的拷贝,它们的存在只是为了保证消息存储的安全性。当 leader 副本发生故障时会从 follower 中选举出一个 leader,但是 follower 中如果有和 leader 同步程度达不到要求的参加不了 leader 的竞选。
Kafka 的多分区(Partition)以及多副本(Replica)机制有什么好处呢?
Kafka 通过给特定 Topic 指定多个 Partition, 而各个 Partition 可以分布在不同的 Broker 上, 这样便能提供比较好的并发能力(负载均衡)。
Partition 可以指定对应的 Replica 数, 这也极大地提高了消息存储的安全性, 提高了容灾能力,不过也相应的增加了所需要的存储空间
Kafka保证消息不丢失
三种消息丢失情况
生产者丢失消息的情况及解决方案
生产者(Producer) 调用send
方法发送消息之后,消息可能因为网络问题并没有发送过去。
为了确定消息是否发送成功,我们要判断消息发送的结果。如果消息发送失败的话,我们检查失败的原因之后重新发送即可!
1 、Kafka 生产者(Producer) 使用 send
方法发送消息实际上是异步的操作,我们可以通过 get()
方法获取调用结果,但是这样也让它变为了同步操作!一般不推荐这么做!
2、改为为其添加回调函数形式
另外,这里推荐为 Producer 的retries
(重试次数)设置一个比较合理的值,一般是 3 ,但是为了保证消息不丢失的话一般会设置比较大一点。设置完成之后,当出现网络问题之后能够自动重试消息发送,避免消息丢失。另外,建议还要设置重试间隔,因为间隔太小的话重试的效果就不明显了,网络波动一次你 3 次一下子就重试完了
消息在Brocker中丢失
在Kafka中提供了一种发送确认机制acks(在默认情况下),即生产者发送消息到brocker之后这些消息都会存储在分区中的leader中,然后由leader把数据同步到follower中,就会给生产者发送一个成功响应,生产者才会确认消息发送成功。
补充:acks=all的情况:leader把数据同步到follower中,所有的follower都需要成功保存,并且给leader以恶搞成功响应。(性能最低)
消费者接收消息丢失
同一个消费者组负责同一个topic,里边不同的消费者负责不同的分区(消费者->分区 1对多或者1对1 分区->消费者 1对1)
每个分区是按照偏移量来存储数据的,每个分区都是由有顺序的不可变的消息队列,并且可以持续的添加。分区中的消息都分配了一个序列号,称之为偏移量。偏移量是从0开始,是一个自增的数值,在每一个分区中都是唯一的。添加数据也是在分区中添加的。消费者消费消息的时候,也会按照偏移量来消费。而每个消费者到分区中消费消息的时候,对于已经消费完成的消息都会去做一个标记,即消费者偏移量。默认情况下,每个消费者都会每隔5s提交一次偏移量。但是如果发生重平衡的情况,可能会重复消费或者丢失数据。
重平衡 eg:现在消费者组内有两个消费者,每个消费者负责的分区不一样,如果消费者2宕机了,那她负责的分区三就要交给消费者1来消费,这个重新分配的过程就叫重平衡。
而重平衡过程可能导致重复消费或者消息丢失,消费者实际消费偏移量和提交的消息偏移量不相同。
通俗讲:
重复消费:消费者2实际消费偏移量 > 提交偏移量,那么消费者1就会从消费者2提交的偏移量处开始消费,会导致重复消费。
消息丢失就是消费者2实际消费偏移量 < 提交偏移量,导致其中间的消息没有被消费造成丢失
解决的办法
禁止自动提交,设置手动提交。Kafka提供两种手动提交的方式,通常采用同步+异步组合提交的方式
同步缺点:同步会阻塞,性能不高。
异步缺点:如果消费失败,可能导致偏移量不准确
Kafka如何保证消息的顺序性
只提供一个分区或者相同的业务只在同一个分区下进行存储和消费,因为同一个分区的偏移量是有顺序的
Kafka高可用机制
这两种模式可以保证高可用性
一个leader分区存在多个分区副本,且副本分别存储在不同的brocker中
同步保存的数据更完整,更贴近leader中的数据(但是性能更低,所以不会全都是lsr,一般一个isr)
总结
Kafka数据清理机制
Kafka文件存储机制
每个topic下存在多个分区,分区是以文件夹形式存在的,每个分区下的文件是分段存储的,每个分段都是以索引和日志文件的形式存储
所有的文件是以偏移量来命名的。
分段的好处?
1、可以减少单个文件内容的大小,查找数据方便
2、方便kafka进行日志清理
数据清理机制
消息保留时间默认是168个小时(7天)
自动删除最久的文件,默认是关闭的,需要手动开启
Kafka中实现高性能的设计
没有实现零拷贝时:
零拷贝
设计模式
工厂方法模式
最大的优点:解耦
问题:要更换对象,需要对这段代码进行修改,违背了开闭原则(对扩展开放,对修改关闭)
简单工厂模式
通过在咖啡厅与咖啡类直接加一个工厂,修改时,对工厂修改即可(没有实现开闭原则)
咖啡厅类:
工厂类:
工厂方法模式
完全遵循开闭原则(通过不同的工厂创建不同的产品对象)
但是只能创建同类的产品(其他的比如:奶茶、等不可以)
咖啡工厂接口
美式咖啡工厂
咖啡店
抽象工厂模式
产品族:品牌 产品等级:分类
总结:典型应用:Spring框架底层用来解耦
策略模式
工厂+策略
实现了ApplicationContextAware这个接口:将当前类交给了SpringIOC管理。需要重写setApplicationContext()
setApplicationContext 方法提供了一个初始化Bean的机会,可以在这个方法中执行一些初始化操作,比如根据配置文件动态加载Bean。
通过applicationContex这个实体对象的getBean方法可以获得相应的初始化的Bean对象
//注意:
当您在 setApplicationContext 方法中通过 ApplicationContext 获取一个 Bean 时,该 Bean 应该已经完成了Bean的生命周期中的所有初始化步骤。这意味着您获取的是一个完整的、已经准备好被使用的 Bean 对象。
然而,如果您在 setApplicationContext 方法中直接获取 Bean,这实际上是在 Bean 的初始化过程中提前引用了它。这可能会导致一些问题,因为此时 Bean 可能还没有完成所有的初始化步骤。
//ApplicationContext
在Spring框架中,ApplicationContext 是一个接口,它提供了许多用于访问和管理Bean的方法
ApplicationContext 维护了一个Bean工厂,这个工厂负责Bean的创建、配置、初始化等生命周期管理。当Spring容器启动时,它会根据配置创建Bean实例,并将它们存储在一个内部的Bean定义注册表中。
责任链模式
订单处理操作的顺序由客户端设计,通过Handler的setNext方法,来一一调用具体的操作
客户端:
常见的技术场景
单点登录怎么实现(SSO)
用户只需要登录一次,就可以访问所有信任的应用系统
权限认证是如何实现的
上传数据的安全性如何控制(网络传输)
负责项目的时候遇到过那些棘手的问题
项目中日志如何采集ELK
查看日志的命令
生产问题怎么排查
java -jar 参数 project.....jar //启动当前的sprin项目 参数的目的:让当前项目支持远程debug
Host: 项目部署的服务器的ip地址 参数与第一步中的参数一样
评论区