Java全栈-上
Java 基础
语法基础
面向对象特性
- 封装:将数据和业务逻辑细节封装到类中,对外提供一些方法。1. 简化操作 2. 保护数据 3. 可复用性
- 继承:类是单继承,接口支持多继承。父类引用指向子类对象称为 向上转型
- 多态:编译时多态和运行时多态。编译时多态就是方法的重载:方法名一样,但参数不一致。运行时多态就是在运行期间才确定具体对象类型,三个条件:继承、重写、向上转型
访问修饰符
用来控制类、变量、方法和构造函数的可见性和访问权限
- public:都可以访问
- protect:同包和子类中可以访问
- 无修饰符:同一个包内的类可以访问
- private:仅在类内部可以访问
如何理解private所修饰的方法是隐式的final
private 修饰的方法,子类无法继承,更不能重写,所以可以看成是 final 的
final 类如何拓展?
final 类无法被继承,但可以使用外观模式进行拓展
final方法可以被重载吗?
父类的final方法是不能够被子类重写的,那么final方法可以被重载吗? 答案是可以的,也就是编译时多态
Java 内存模型 JMM
Java内存模型(Java Memory Model, JMM)将内存分为主内存和工作内存。主内存是所有线程共享的(对应 Java堆内存和方法区/元空间),而每个线程有自己的工作内存(虚拟机栈、本地方法栈、程序计数器)
volatile
保证数据在多线程中的可见性
当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主内存,当有其他线程需要读取时,它会去主内存中读取新值
而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
volatile是如何实现可见性的?
内存屏障
volatile能保证原子性吗?
不能完全保证,只能保证单次的读/写操作具有原子性
a = a + b 与 a += b 的区别
+= 隐式的将加操作的结果类型强制转换为持有结果的类型。
如果两个整型相加,如 byte、short 或者 int,首先会将它们提升到 int 类型,然后在执行加法操作。
3*0.1 == 0.3 将会返回什么? true 还是 false?
false,因为有些浮点数不能完全精确的表示出来。
十进制转二进制
整数部分:除2 取余,商不为0,继续除 2 取余,倒序排列
小数部分:乘 2 取整,结果的小数部分继续乘 2 取整,直到达到所需的精度或发现循环,顺序排列
单精度浮点数存储
32 位,由三部分组成:符号位(S)、指数位(E)和尾数位(M或F)。
- 符号位(S):1位,用来表示数的正负。0代表正数,1代表负数。对于3.14,它是正数,所以符号位是0。
- 指数位(E):8位,用于存储指数部分,但实际存储的是偏置后的值。在单精度浮点数中,指数值会加上一个固定的偏置量(127)。这样做的目的是为了能够表示负指数(通过存储一个小于127的值)。
- 尾数位(M):23位,存储有效数字的小数部分。在实际存储中,会默认在最前面有一个隐含的1(即所谓的隐藏位),因此实际上可以表示24位精度。
能在 Switch 中使用 String 吗?
从 Java 7 开始,我们可以在 switch case 中使用字符串,但这仅仅是一个语法糖。内部实现在 switch 中使用字符串的 hash code
对equals()和hashCode()的理解?
- 为什么在重写 equals 方法的时候需要重写 hashCode 方法?
因为有强制的规范指定需要同时重写 hashcode 与 equals 是方法,许多容器类,如 HashMap、HashSet 都依赖于 hashcode 与 equals 的规定。
- 有没有可能两个不相等的对象有相同的 hashcode?
有可能,两个不相等的对象可能会有相同的 hashcode 值,这就是为什么在 hashmap 中会有冲突。相等 hashcode 值的规定只是说如果两个对象相等,必须有相同的hashcode 值,但是没有关于不相等对象的任何规定。
- 两个相同的对象会有不同的 hash code 吗?
不能,根据 hash code 的规定,这是不可能的。
try-with-resources
Java 7引入的一种声明式语法 try () {}
,用于自动管理资源,确保在代码块执行完毕后即使遇到异常也能正确关闭资源。
1 | // 源文件路径 |
final、finalize 和 finally 的不同之处
- final 是一个修饰符,可以修饰类-不可被继承、成员变量-只读、方法-不可重写。
- Java 中一个对象生命周期相关的方法(
Object
类的一个保护方法)。在 Java编程实践中,避免使用finalize()
- finally 是一个关键字,与 try 和 catch 一起用于异常的处理。finally 块一定会被执行,无论在 try 块中是否有发生异常。推荐使用
try-with-resources
语法结构
编译期final常量和非编译期final常量
- 编译期final常量:声明时初始化,初始化的表达式是一个基本类型或String类型
- 非编译期final常量:初始化的表达式不是编译期可知的(例如运行时计算的结果),那么这个字段就不是编译期常量。虽然它仍然是不可变的(一旦初始化完成就不能再改变)
String、StringBuffer与StringBuilder的区别?
可变性。String对象是不可变的,而StringBuffer和StringBuilder是可变字符序列。每次对String的操作相当于生成一个新的String对象,而对StringBuffer和StringBuilder的操作是对对象本身的操作,而不会生成新的对象,所以对于频繁改变内容的字符串避免使用String,因为频繁的生成对象将会对系统性能产生影响。
线程安全性。String由于有final修饰,是immutable的,安全性是简单而纯粹的。StringBuilder和StringBuffer的区别在于StringBuilder不保证同步,也就是说如果需要线程安全需要使用StringBuffer,不需要同步的StringBuilder效率更高。
接口与抽象类的区别?
- 一个子类只能继承一个抽象类, 但能实现多个接口
- 抽象类可以有构造方法, 接口没有构造方法
- 抽象类可以有普通成员变量, 接口没有普通成员变量
- 抽象类和接口都可有静态成员变量, 抽象类中静态成员变量访问类型任意,接口只能public static final(默认)
- 抽象类可以没有抽象方法, 抽象类可以有普通方法;接口在JDK8之前都是抽象方法,在JDK8可以有default方法,在JDK9中允许有私有普通方法
- 抽象类可以有静态方法;接口在JDK8之前不能有静态方法,在JDK8中可以有静态方法,且只能被接口类直接调用(不能被实现类的对象调用)
- 抽象类中的方法可以是public、protected; 接口方法在JDK8之前只有public abstract,在JDK8可以有default方法,在JDK9中允许有private方法
this() 和 super()在构造方法中的区别
- 调用super()必须写在子类构造方法的第一行, 否则编译不通过
- super从子类调用父类构造, this在同一类中调用其他构造均需要放在第一行
- 尽管可以用this调用一个构造器, 却不能调用2个
- this和super不能出现在同一个构造器中, 否则编译不通过
- this()、super()都指的对象,不可以在static环境中使用
- 本质this指向本对象的指针。super是一个关键字
Java移位运算符?
java中有三种移位运算符
<<
:左移运算符,x << 1
,相当于x乘以2(不溢出的情况下),低位补0>>
:带符号右移,x >> 1
,相当于x除以2,正数高位补0,负数高位补1>>>
:无符号右移,忽略符号位,空位都以0补齐
泛型
- 在类、接口和方法中使用泛型,实现类型的参数化
- 重用性(代码更简洁)、类型安全性(编译期间检查、转换,避免了强制类型转换)
- 泛型上下限
- 帮助开发者更精确地控制类型范围,提高代码的灵活性和安全性
- 泛型上限:使用
extends
关键字来指定其上限,意味着该类型参数必须是指定类型或者其子类型 - 泛型下限:使用
super
关键字来指定其下限,意味着该类型参数必须是指定类型或者其超类型
- 通配符(未知类型,不关注类型 T,修饰方法形参中类型)
- > 无边界的通配符。匹配任何类型
- extends T> :实参类为 T及其子类
- super T> :实参类为 T及其超类
注解
注解(Annotation)是一种元数据,它可以为程序元素(如类、方法、变量等)提供附加信息。注解被编译器、开发工具或运行时环境生成其他代码、进行验证或执行某些处理。
内置注解有
@Override
,@Deprecated
,@SuppressWarnings
等,同时开发者也可以自定义注解。自定义注解通过使用@interface
关键字实现,并可以指定元注解(如@Retention
,@Target
,@Documented
,@Inherited
)来控制注解的生命周期、适用范围、是否包含在文档中以及是否可被子类继承等特性。作用:
- 生成文档,通过代码里标识的元数据生成javadoc文档
- 编译检查,通过代码里标识的元数据让编译器在编译期间进行检查验证
- 编译时动态处理,编译时通过代码里标识的元数据动态处理,例如动态生成代码
- 运行时动态处理,运行时通过代码里标识的元数据动态处理,例如使用反射注入实例
异常
Java异常类层次结构?
- Throwable 是 Java 语言中所有错误与异常的超类。
- Error 类及其子类:程序中无法处理的错误,表示运行应用程序中出现了严重的错误。
- Exception 程序本身可以捕获并且可以处理的异常。Exception 这种异常又分为两类:运行时异常和编译时异常
- 运行时异常:运行期间产生的异常,例如空指针异常、下标越界访问异常等
- 编译时异常:在编译期间产生的异常,不处理,编译通不过,例如 IOException、SQLException、用户自定义的Exception异常
可查的异常和不可查的异常区别?
- 可查异常(编译器要求必须处置的异常)。除了RuntimeException及其子类以外,其他的Exception类及其子类都属于可查异常
- 不可查异常(编译器不要求强制处置的异常):包括运行时异常(RuntimeException与其子类)和错误(Error)
throw和throws的区别?
- throw: 主动抛出异常实例
- throws: 若方法中存在检查异常,如果不对其捕获,那必须在方法头中显式声明该异常,以便于告知方法调用者此方法有异常,需要进行处理。 关键字throws,后面是异常类,若声明多种异常,则使用逗号分割
异常的底层? - 异常表
Exception Table,以下称为异常表
反射
反射是什么
- 从镜子中看到自己,这就是反射
- class 文件被类加载器加载到 JVM 内存的方法区
- 通过反射,程序在运行时能够动态地访问和操作类和对象信息
反射的作用
- 动态加载类
- 获取类信息,包括解析类、方法、字段上的注解信息
- 实例化对象
- 调用方法,包括私有方法
- 访问和修改成员变量的值
SPI
- SPI(Service Provider Interface)机制,是Java提供的一种用于发现和加载实现类的方式,它允许第三方为一组接口提供自己的实现
- SPI主要用于框架扩展,使得框架的设计者在不需要修改代码的情况下就能够引入新的功能模块
- 原理
- 定义接口:首先,需要定义一个或多个接口,这些接口声明了服务的规范
- 提供实现类和配置文件:实现该接口的提供者需要在自己jar包的
META-INF/services/
目录下创建一个以接口全限定名命名的文件。该文件的内容就是这个接口的一个或多个实现类的全限定名,每行一个,如果有多个实现,则可以通过换行分隔 - ServiceLoader 服务查找:当应用程序需要使用这个服务时,可以通过Java的
ServiceLoader
类来加载这个接口的所有实现。ServiceLoader
会查找所有已知的jar包中的META-INF/services/
目录下的配置文件,然后依次加载这些文件中指定的实现类,并实例化它们
- 示例
- JDBC接口定义:首先在java中定义了接口
java.sql.Driver
,并没有具体的实现,具体的实现都是由不同厂商来提供的。 - mysql实现:在mysql的jar包
mysql-connector-java-6.0.6.jar
中,可以找到META-INF/services
目录,该目录下会有一个名字为java.sql.Driver
的文件,文件内容是com.mysql.cj.jdbc.Driver
,这里面的内容就是针对Java中定义的接口的实现。
- JDBC接口定义:首先在java中定义了接口
Java 集合
Collection 和 Map 两种,Collection 存储着对象的集合,而 Map 存储着键值对(两个对象)的映射表。
Collection
Set
TreeSet
使用树数据结构(通常是红黑树)来存储元素,当需要一个自动排序且唯一的元素的集合时,可以选择该数据结构- 有序:默认情况下,元素按照其自然顺序排序(如果实现了
Comparable
接口),或者可以根据自定义的Comparator
进行排序 - 无重复:不允许有重复的元素
- 性能:查找、删除和插入操作的时间复杂度为 O(log n),其中 n 是集合中的元素数量
- 迭代器:迭代器按升序遍历元素
- 有序:默认情况下,元素按照其自然顺序排序(如果实现了
HashSet
底层基于HashMap
实现(数据存储在HashMap的key中,value是PRESENT,一个私有静态final对象),用于存储无序且唯一的元素集合,支持快速查找。非线程安全- 用于快速判断某个元素是否属于一个集合,元素去重(无顺序要求)
LinkedHashSet
具有 HashSet 的查找效率,且内部使用双向链表维护元素的插入顺序。非线程安全ConcurrentSkipListSet
可以看作是TreeSet
并发版本
List
ArrayList
基于动态数组实现,支持随机访问,非线程安全- 默认容量为 10
- 每次扩容 50%,新数组是原来的 1.5 倍。1.5 就是扩容因子
- 扩容过程:创建新数组,原数组数据拷贝到新数组中,引用指向新数组,原数组被回收(没有引用的话),插入新元素
- 通过
ensureCapacity
方法手动扩容
Vector
和 ArrayList 类似,但它是线程安全的LinkedList
基于双向链表实现,只能顺序访问,但是可以快速地在链表中间插入和删除元素。不仅如此,LinkedList 还可以用作栈、队列和双向队列。
Queue
LinkedList
是基于双向链表实现的,这意味着每个元素(节点)都包含对其前一个元素和后一个元素的引用- 支持双向遍历
- 插入和删除效率高,但查询效率低
- 因为实现了
Deque
接口,LinkedList
可以像栈一样进行push
和pop
操作,像队列一样进行offer
和poll
操作,以及作为双端队列进行两端的插入和删除操作。 LinkedList
对每个元素都需要额外的空间存储前后节点的引用,因此在存储大量简单类型数据时可能会占用更多的内存
- PriorityQueue 基于堆结构实现,可以用它来实现优先队列
- 排序: 元素在队列中的位置由它们的优先级决定。默认情况下,优先队列中的元素按照其自然顺序排序(如果元素可比较),或者在创建队列时提供一个自定义比较器。
- 无界:
PriorityQueue
是无界的,意味着它可以增长以容纳任意数量的元素(受限于系统内存)。 - 非线程安全:
PriorityQueue
类本身不是线程安全的。如果多个线程要同时访问一个优先队列,通常需要外部同步。 - 操作: 主要的操作包括
add(E)
添加元素、poll()
获取并移除队列头部的元素、peek()
查看但不移除队列头部的元素、remove(Object)
移除指定元素等。 - 内部实现:
PriorityQueue
通常使用二叉堆(通常是小顶堆)来维护元素的有序状态,这样可以保证对add
、remove
、peek
等操作的高性能。
Map
HashMap
1.8 基于数组+链表+红黑树- 允许null键和null值
- 不显式指定,初始容量是16(数组的大小)。自定义初始容量,必须是2的幂次方
- HashMap内部使用位运算来计算索引,这比取模运算更快
- 默认的负载因子是0.75,这意味着当HashMap填充到其容量的75%时,会自动进行扩容。扩容为原来的 2 倍
- Key 用来确定位置,最终 key 和 value 都会存储到这个位置上
- 数组中每个桶的位置存放的有 key、value、链表指针。如果还未存放数据,则放入数据。如果出现哈希冲突,将新键值放入链表后面(链表为空则先创建)。这个链表是单向链表,节点中只存储下一个节点的引用
- 链表转红黑树: 当桶中的链表长度超过某个阈值(默认是8)时,该链表会被转换成红黑树。这大大提高了在大量哈希冲突情况下的查找效率,从 O(n) 提升到了 O(log n)
- 红黑树转链表: 当桶中红黑树的节点数量降到6以下时,红黑树会退化回链表,因为对于较小的集合,链表的遍历可能更高效。
HashTable
和 HashMap 类似,但它是线程安全的,现在不再使用ConcurrentHashMap
支持线程安全,效率比 HashTable 更高LinkedHashMap
使用双向链表来维护元素的顺序,顺序为插入顺序或者最近最少使用(LRU)顺序TreeMap
基于红黑树实现- 排序:
TreeMap
中的元素是按照 key 的自然顺序(Comparable)或者是通过自定义的比较器(Comparator)进行排序的。这意味着当你遍历TreeMap
时,会按照 key 的排序顺序来访问元素。 - 有序映射: 由于其内部实现为红黑树,
TreeMap
提供了对映射的快速访问,特别是对于范围查询(如子 map、从某个键查找更高或更低的键等)非常高效。 - 操作: 常用操作包括
put(K key, V value)
插入键值对、get(Object key)
根据键获取值、remove(Object key)
删除键值对、以及多种基于 key 的范围查询方法。 - 键唯一性: 和所有 Map 实现一样,
TreeMap
中的键必须是唯一的;每个键最多只能对应一个值。 - 线程不安全: 和
PriorityQueue
类似,TreeMap
也是非线程安全的。在多线程环境下并发访问和修改时,需要外部同步或使用ConcurrentSkipListMap
这样的并发集合。
- 排序:
ConcurrentSkipListMap
是TreeMap
的并发版本
Java 并发
并发和并行
- 并发是任务在宏观上看起来像是同时发生,但实际上可能是交替执行的。
- 并行则是指任务在物理上同时执行,这通常需要多处理器或者多核处理器的支持。
多线程中数据安全问题
同步关键字 synchronized
- 由 Java虚拟机(JVM)实现
- 它可以用于方法或代码块
- 非公平锁
- 它是基于监视器锁(monitor lock)的,对于非静态方法,锁是当前实例对象;对于静态方法,锁是对应的Class对象;对于同步代码块,锁是synchonized括号里配置的对象
- 同步的方法或代码块中抛出了未被捕获的异常,锁会自动释放
- 作用域不宜过大,影响程序执行的速度
可重入互斥锁 ReentrantLock
比 synchronized 更灵活,是Java标准库的一部分
获取锁:lock(); 尝试锁- tryLock(); 可定时锁- tryLock(long time, TimeUnit unit); 可中断锁lockInterruptibly();
释放锁:unlock(); 需要手动在finally块中调用unlock()方法来释放锁,以避免死锁
Atomic 原子变量
如 AtomicInteger、AtomicLong
volatile关键字
volatile 确保了变量的可见性和部分有序性,但不保证原子性。适用于状态标记等简单场景,不适用于复合操作
不可变对象:不可变对象一旦创建就无法改变,因此它们是线程安全的。如
String
、以及通过final关键字定义且其内部状态不可变的类
CopyOnWrite容器
对于读多写少的并发场景,可以使用CopyOnWriteArrayList
或CopyOnWriteArraySet
,这些容器在修改数据时会创建原容器的副本,避免了并发修改异常
并发集合 concurrent
java.util.concurrent包提供了多种线程安全的集合类,如
ConcurrentHashMap、ConcurrentLinkedQueue等,它们通过内部机制实现了高效且线程安全的数据访问
ThreadLocal
本地线程变量,保证每个线程中都有一份数据的副本,是独立的。
分布式锁
基于 Redis 实现一个分布式锁
ThreadLocal是如何实现线程隔离的?
ThreadLocalMap
方法中局部变量是否存在多线程安全问题?
不存在。存在于 JVM 栈中,线程之间是隔离的
有哪几种创建线程方式?
有三种使用线程的方法:
- 实现 Runnable 接口
- 实现 Callable 接口
- 继承 Thread 类
实现 Runnable 和 Callable 接口的类只能当做一个可以在线程中运行的任务,不是真正意义上的线程,因此最后还需要通过 Thread 来调用。可以说任务是通过线程驱动从而执行的。
线程的中断方式有哪些?
- 一个线程执行完毕之后会自动结束,如果在运行过程中发生异常也会提前结束
- 通过调用一个线程的
interrupt()
来中断该线程,如果该线程处于阻塞、限期等待或者无限期等待状态,那么就会抛出 InterruptedException,从而提前结束该线程。但是不能中断 I/O 阻塞和 synchronized 锁阻塞 - 调用
interrupt()
方法会设置线程的中断标记,此时调用interrupted()
方法会返回 true。因此可以在循环体中使用 interrupted() 方法来判断线程是否处于中断状态,从而提前结束线程
多线程顺序控制
thread.join()
thread.join()
方法可以让一个线程等待另一个线程执行完毕后再继续执行。
CountDownLatch
- 作用:实现多线程同步控制。例如多个子线程执行各任务,主线程等待所有子线程执行完毕后,再往后执行。
- 构造方法:
CountDownLatch(int count)
:初始化一个CountDownLatch
实例,计数器的初始值为给定的count
值。
- 主要方法:
await()
:调用该方法的线程会被阻塞,直到计数器的值减至0。如果当前计数为0,则该方法立即返回。countDown()
:调用该方法会使计数器减1。当计数器达到0时,所有在await()
方法处等待的线程都将被释放。
CyclicBarrier
它允许一组线程相互等待,直到到达某个屏障点,所有线程才会继续执行
Semaphore
信号量,主要用于控制同时访问特定资源的线程数量,但也可以间接用来实现顺序控制
AsycnTool
解决多线程的并行、串行、阻塞、依赖和回调等问题,还能应对高并发场景下热数据相关的需求
Java IO
网络上传输的数据是以字符还是字节传输的?
在网络上传输的数据本质上是以字节(byte)形式进行的。无论是文本、图片、音频还是视频文件
字节流和字符流
字节流(Byte Streams):
- 字节流是最基本的数据传输方式,所有类型的文件在网络传输和磁盘存储时,最终都是以字节形式存在的
- 因为字节是计算机存储数据的基本单位,所以字节流可以用来处理任何形式的文件,包括二进制文件(如图片、音频、视频)和文本文件
InputStream
和OutputStream
及其子类(如FileInputStream
、FileOutputStream
)
字符流(Character Streams):
- 字符流是对字节流的封装,提供了对文本数据更友好的操作接口。它内部使用字节流来实现,但对外隐藏了字节处理的细节,让用户可以以字符为单位读写数据。
- 字符流在读写时会自动进行字符编码和解码,这对于处理文本文件非常有用,可以避免开发者直接处理字节编码的复杂性。
Reader
和Writer
及其子类(如FileReader
、FileWriter
),它们在内部使用了字节流,并通过指定的字符集进行编码和解码。
为什么需要两者:
- 处理文本文件时字符流更为方便
- 处理图片、音频等二进制数据时则更适合使用字节流
Java IO、NIO、AIO
- 传统 Java IO 是面向流的,是阻塞的
NIO
- NIO(New I/O) 是面向缓冲区的,是非阻塞的
- NIO 主要是通过Selector和非阻塞的Channel来实现多路复用的同步非阻塞I/O,提高了单线程处理多个连接的能力
- NIO 引入了选择器Selector、通道Channel、缓冲区Buffer以及非阻塞IO模型,提高了并发处理能力
- 选择器Selector:一个线程处理一个Selector,一个Selector支持管理多个通道,Channel需要注册到Selector上,可以监控多个通道的事件(如连接打开、数据可读等)【可以认为选择器需要轮训通道状态】
- 通道:
- 通道是非阻塞的,也就是从通道读取数据或者写入数据时,即使通道无数据可读或者无法写入,通道也会直接返回结果,而不是让 Selector 阻塞等待,直到数据可读或可写
- 通道支持异步读写,是双向的
- 数据从通道读取到缓冲区,在缓冲区将数据写入到通道
- 缓冲区:NIO的所有数据都是通过缓冲区处理的,缓冲区实质上是一个可读写的内存块
- 非阻塞 IO:意味着在数据未准备好时,操作不会阻塞当前线程,而是立即返回
AIO
- AIO(Asynchronous I/O),在Java中也被称为Java New I/O (NIO.2),是随着JDK 7引入的,它是真正意义上的异步非阻塞I/O。在AIO模型中,一旦发起了一个异步的读写操作,应用程序就可以继续执行其他任务,无需进行任何轮询操作去检查I/O操作的状态。当I/O操作完成时,会通过之前注册的回调函数直接通知应用程序【事件驱动】,这种方式更加高效且编程模型更为简洁
- 异步I/O是通过事件或回调机制实现的
I/O 多路复用
IO多路复用就是一个线程同时管理多个IO连接
它允许一个单一的线程(或者有限数量的线程)监控大量的文件描述符(例如,网络套接字),并能有效确定哪些描述符已经准备好进行读取、写入或其他I/O操作,而无需为每个连接单独分配一个线程
常见的I/O多路复用技术
- select:是最原始的多路复用函数,它限制了最大监视的文件描述符数量,并且每次调用都需要重新传入所有描述符集合,效率较低。
- poll:是对select的一个改进,使用链表结构存储文件描述符,解决了select的最大文件描述符数量限制问题,但依然存在轮询所有描述符的效率问题。
- epoll(Linux特有):提供了更高效的事件通知机制,仅关注发生变化的文件描述符,避免了无谓的遍历操作。epoll分为水平触发(LT)和边缘触发(ET)两种模式
- kqueue(BSD系统和macOS):类似于epoll,提供了事件通知机制,支持注册和接收多种类型的事件。
- IOCP(Windows):输入输出完成端口(Input/Output Completion Port),是Windows平台上用于实现高并发I/O操作的机制
I/O多路复用工作原理
注册:应用程序首先将它感兴趣的文件描述符(如套接字)和关注的事件(读、写、异常)注册到I/O多路复用器(如
select
、poll
、epoll
)。这意味着程序告诉内核它关心哪些描述符上的哪些类型的操作内核监控:操作系统内核接管这些文件描述符,并开始监控它们的状态。内核实际上是通过硬件中断来感知这些变化的。操作系统内核与硬件紧密协作,硬件(如网卡)在接收到数据包或准备好发送数据时,会向CPU发送一个中断信号。CPU响应中断后,会执行中断处理程序,这个程序会检查是什么类型的中断并采取相应的行动。对于网络I/O而言,中断处理程序会通知内核有新的数据到来或者发送缓冲区已腾出空间。流程为:硬件状态变化——>给 CPU 发中断信号——>CPU 响应中断——>执行中断处理程序——>通知内核状态变化
事件检测:
- 对于
select
和poll
,内核会遍历所有注册的描述符来检查状态。这种方法效率较低,特别是在描述符数量很多时 select
和poll
之所以需要遍历所有注册的描述符,是因为它们没有在内核中为每个描述符维护一个持续更新的就绪状态,而是依赖于每次调用时的主动检查epoll
采用了不同的策略。在Linux中,epoll使用了两个主要的数据结构:一个红黑树用于存储已注册的文件描述符,另一个链表或哈希表用于记录就绪的文件描述符。当有事件发生时(如数据到达或缓冲区有空间,内核会监控到状态变化),内核直接更新就绪列表。通过就绪列表,内核可以一次性收集一段时间窗口内的所有就绪事件,然后一次性通知应用程序,这样可以减少上下文切换的次数- 总结:
select
和poll
在每次调用时都需要主动检查描述符状态,而epoll
则是在事件发生时由内核被动地更新就绪列表,调用epoll_wait
时直接获取结果,从而提升了性能
- 对于
通知应用:当有描述符上的事件发生(例如,数据可读或可写),I/O多路复用器会通过之前调用的
select
、poll
或epoll_wait
等函数返回,告知应用程序哪些描述符已就绪。应用程序可以根据这些信息决定下一步的操作,比如读取数据或发送数据处理事件:应用程序收到事件通知后,可以直接对相应的文件描述符进行操作,而不必再次检查其状态,因为内核已经确保了这些描述符是可操作的
通过这种方式,I/O多路复用器能够高效地通知应用程序哪些连接上有事件发生,从而使得单个线程能够管理大量的并发连接,提高了系统的并发处理能力
为什么更新就绪列表而不是直接通知应用程序?
- 批量处理:如果每当一个事件发生就立即通知应用程序,那么在高并发场景下可能会导致频繁的上下文切换,反而降低效率。通过维护就绪列表,内核可以一次性收集一段时间窗口内的所有就绪事件,然后一次性通知应用程序,这样可以减少上下文切换的次数。
- 统一接口:I/O多路复用的API(如
epoll_wait
)提供了一个统一的接口给应用程序查询就绪的文件描述符,应用程序只需要调用一次就可以得知所有感兴趣的事件,而不需要为每个事件分别处理,这样简化了编程模型 - 效率与控制:直接更新就绪列表可以让内核决定何时是通知应用程序的最佳时机,这可以基于各种优化策略,比如合并事件通知、避免惊群效应等。此外,内核还可以根据就绪事件的数量和类型,决定是否唤醒等待的进程,以及如何高效地调度这些进程
零拷贝
零拷贝(Zero-Copy)是一种计算机程序设计领域的优化技术,旨在减少数据在操作系统内核空间和用户空间之间,以及内核内部的复制次数,从而提高数据传输速度和降低CPU使用率。这一技术特别适用于大量数据的网络传输或磁盘I/O操作。
mmap【内存映射】 + write
应用进程调用了 mmap() 后,DMA 会把磁盘的数据拷贝到内核的缓冲区里。接着,应用进程跟操作系统内核「共享」这个缓冲区;
应用进程再调用 write(),操作系统直接将内核缓冲区的数据拷贝到 socket 缓冲区中,这一切都发生在内核态,由 CPU 来搬运数据;
最后,把内核的 socket 缓冲区里的数据,拷贝到网卡的缓冲区里,这个过程是由 DMA 搬运的
Sendfile系统调用
在 Linux 内核版本 2.1 中,提供了系统调用函数 sendfile()
从 Linux 内核 2.4 版本开始起,对于网卡支持 SG-DMA 的情况:全程没有通过 CPU 来搬运数据,所有的数据都是通过 DMA 来进行传输的
通过 DMA 将磁盘上的数据拷贝到内核缓冲区
网卡的 SG-DMA 控制器将内核缓存中的数据拷贝到网卡的缓冲区
JVM和调优
类加载生命周期
Java 类加载器(Class Loader)负责将类的字节码文件从不同的位置加载到 JVM 中,并对类进行链接和初始化,以便它们能够在 Java 应用程序中使用。类加载的生命周期通常包含以下五个阶段:
加载(Loading)
- 查找并加载类的二进制数据(class文件),存储在方法区(Java 8 及以前版本)或元数据区(Java 9 及以后版本)
验证(Verification)
确保被加载的类的正确性
JVM 会对类的字节码进行验证,确保其符合 Java 语言规范,不会危害 JVM 的安全。验证过程包括文件格式验证、元数据验证、字节码验证和符号引用验证
准备(Preparation)
为类的静态变量分配内存,初始化默认值
解析(Resolution)
- 把类中的符号引用转换为直接引用
- 符号引用是在编译期间生成的,直接引用则是运行期间可以定位到类、字段、方法等的直接地址或者句柄
初始化(Initialization)
为类的静态变量赋予正确的初始值,JVM负责对类进行初始化,主要对类变量进行初始化
使用: 类访问方法区内的数据结构的接口, 对象是Heap区的数据
卸载: 结束生命周期
类加载器
Java虚拟机有三个内置的类加载器
- Bootstrap ClassLoader(启动类加载器):负责加载核心库 lib,如
rt.jar
等,这个加载器是用C++编写的,没有父加载器 - Extension ClassLoader(扩展类加载器):负责加载扩展库(
$JAVA_HOME/jre/lib/ext
)下的jar包或类库,它的父加载器是Bootstrap ClassLoader - Application ClassLoader(应用程序类加载器):负责加载用户类路径(ClassPath)上的指定类库,是我们平时开发中默认使用的类加载器,其父加载器是Extension ClassLoader
- 支持自定义类加载器,
extends ClassLoader
类加载器的缓存机制
缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM,程序的修改才会生效
双亲委派机制
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类
- 当AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。
- 当ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成。
- 如果BootStrapClassLoader加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class),会使用ExtClassLoader来尝试加载;
- 若ExtClassLoader也加载失败,则会使用AppClassLoader来加载,如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException。
Class.forName()和ClassLoader.loadClass()区别?
Class.forName()
: 将类的.class文件加载到jvm中之外,还会对类进行解释,执行类中的static块;ClassLoader.loadClass()
: 只干一件事情,就是将.class文件加载到jvm中,不会执行static中的内容,只有在newInstance才会去执行static块。Class.forName(name, initialize, loader)
带参函数也可控制是否加载static块。并且只有调用了newInstance()方法采用调用构造函数,创建类的对象
JVM内存结构
程序计数器(Program Counter Register)
每个线程都有一个程序计数器,用于存储当前线程所执行的字节码行号或 native 方法的地址。线程私有。
虚拟机栈(Java Virtual Machine Stack)
每个线程在创建时都会分配一个虚拟机栈,它是线程私有的
当线程执行一个方法时,JVM 会为该方法创建一个栈帧,并将其压入当前线程的虚拟机栈顶
栈帧中存储了方法执行所需的局部变量表、操作数栈、动态链接、方法返回地址和一些额外的附加信息
- 局部变量表:用于存放方法参数和方法内部定义的局部变量。
- 操作数栈:用于存储计算过程中的中间结果,操作数栈的每一个元素可以是任意Java数据类型。
- 动态链接:每个栈帧都包含一个执行运行时常量池中该方法所属类的引用,用于支持方法调用过程中的动态连接。
- 方法返回地址:当方法执行完毕后,需要通过返回地址来确定下一步程序应该继续执行的位置。
Java方法就是在虚拟机栈的栈帧(Stack Frame)中执行的,执行过程中调用其它方法,还会创建新的栈帧入栈,以此类推,当前栈帧执行完毕,就会出栈。
本地方法栈(Native Method Stack)
native method
(本地方法)是指用非Java语言(如C、C++或其他底层语言)编写的方法- 每个线程有自己的本地方法栈,是线程私有的
- 当一个native方法被调用时,也会有相应的栈帧(尽管其结构可能与Java方法的栈帧有所不同,因为它们遵循的是native代码的调用约定),用于维护方法调用的状态,包括局部变量、方法参数以及返回地址等。一旦native方法执行完毕,其栈帧同样会被弹出,以恢复调用前的栈状态
堆(Heap)
JVM中最大的一块内存区域,用来存储几乎所有的对象实例和数组。堆是线程共享的
方法区(Method Area)/ 元空间(Metaspace)
- 用于存储类的元数据、常量、静态变量等。是线程共享。
- JDK1.8开始方法区(HotSpot的永久代)被彻底删除了,取而代之的是元空间,元空间直接使用的是本地内存,不再使用堆内存
JVM堆内存细分
在Java 8及之后的版本中,堆内存主要分为两大部分:
新生代(Young Generation): 包括 Eden 空间、两个 Survivor 空间(通常称为 S0 和 S1)。
- Eden Space: 新创建的对象首先分配在这里。当Eden区空间不足时,会触发一次Minor GC(也称为YGC)
- Survivor Spaces: 包含两个相同大小的区域,通常称为S0和S1。在每次 Minor GC 后,Eden区存活的对象以及从上次GC的Survivor区(假设为S0)存活下来的对象会被复制到另一个Survivor区(S1)。这个过程用来筛选出频繁被使用的对象,最终晋升到老年代
- 一个Survivor区是空的(作为目标to-space),另一个(from-space)包含活的对象。
- 这个过程重复进行,通常一个对象需要经历几次YGC并在Survivor区之间移动(默认15次回收标记),才会被移到老年代
老年代(Old Generation / Tenured Generation)
用于存储经过多次 YGC 后仍然存活的对象
大对象直接进入老年代(大对象是指需要大量连续内存空间的对象),避免在 Eden 区和两个Survivor 区之间发生大量的内存拷贝
老年代的空间一般比新生代大,GC频率相对较低,但是每次GC的时间更长,因为需要进行更复杂的标记-清除或标记-压缩算法
老年代垃圾收集称为 主GC(Major GC)
在 Java8 之前,永久代存在于方法区中。Java8 之后,取消了永久代,方法区也由元空间替代。元空间使用的本地内存,不再使用堆内存。
GC垃圾回收
判断一个对象是否可回收(存活)?
引用计数算法
- 给对象添加一个引用计数器,当对象增加一个引用时计数器加 1,引用失效时计数器减 1。引用计数为 0 的对象可被回收。
- 两个对象出现循环引用的情况下,此时引用计数器永远不为 0,导致无法对它们进行回收。
- 正因为循环引用的存在,因此 Java 虚拟机不使用引用计数算法。
可达性分析算法
通过 GC Roots 作为起始点进行搜索,能够到达的对象都是存活的,不可达的对象可被回收
Java 虚拟机使用该算法来判断对象是否可被回收,在 Java 中 GC Roots 一般包含以下内容:
虚拟机栈中引用的对象
本地方法栈中引用的对象
方法区中类静态变量引用的对象
方法区中的常量引用的对象
对象有哪些引用类型?强软弱虚引用
Java 具有四种强度不同的引用类型
强引用
被强引用关联的对象不会被回收。
使用 new 一个新对象的方式来创建强引用。
软引用
被软引用关联的对象只有在内存不够的情况下才会被回收(内存不足才回收)
使用 SoftReference 类来创建软引用
1 | Object obj = new Object(); |
弱引用
被弱引用关联的对象一定会被回收(下次 GC 回收)
使用 WeakReference 类来实现弱引用
1 | Object obj = new Object(); |
虚引用
又称为幽灵引用或者幻影引用。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用取得一个对象
为一个对象设置虚引用关联的唯一目的就是能在这个对象被回收时收到一个系统通知
使用 PhantomReference 来实现虚引用
1 | import java.lang.ref.PhantomReference; |
垃圾回收算法?
标记 - 清除
标记存活对象,然后将未被标记的对象清理掉
缺点:
- 标记和清除过程效率都不高
- 会产生大量不连续的内存碎片,导致无法给大对象分配内存
标记 - 整理
让存活的对象移向一端,将端边界以外的内存清理掉
复制
将内存划分为大小相等的两块,每次只使用其中一块。当这一块内存用完了就将还存活的对象复制到另一块上面,然后再把使用过的内存空间进行一次清理。
缺点:只使用了内存的一半
现在的商业虚拟机都采用这种收集算法来回收新生代,但是并不是将新生代划分为大小相等的两块,而是分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 空间和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象一次性复制到另一块 Survivor 空间上,最后清理 Eden 和使用过的那一块 Survivor。
HotSpot 虚拟机的 Eden 和 Survivor 的大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 空间就不够用了,此时需要依赖于老年代进行分配担保,也就是借用老年代的空间存储放不下的对象
分代收集算法
是一种垃圾回收策略
新生代-复制算法
每次垃圾收集都能发现大批对象已死, 只有少量存活. 因此选用复制算法, 只需要付出少量 存活对象的复制成本就可以完成收集
老年代-标记整理算法
对象存活率高、没有额外空间对它进行分配, 就必须采用“标记—清理”或“标 记—整理”算法来进行回收
分区收集算法
是一种垃圾回收策略,将整个堆空间划分为连续的小区间, 每个小区间独立使用, 独立回收
垃圾回收器可以灵活选择回收哪些分区,而不是每次都回收整个堆。可以显著降低垃圾回收引起的程序暂停时间
- G1: 一种服务器端的垃圾收集器,应用在多处理器和大容量内存环境中,在实现高吞吐量的同时,尽可能地满足垃圾收集暂停时间的要求。
- ZGC: JDK11 中推出的一款低延迟垃圾回收器,适用于大内存低延迟服务的内存管理和回收,SPECjbb 2015 基准测试,在 128G 的大堆下,最大停顿时间才 1.68 ms,停顿时间远胜于 G1 和 CMS。
什么是Minor GC、Major GC、Full GC?
在Java的垃圾回收机制中,根据回收对象所在的内存区域不同,垃圾回收操作主要分为三大类:Minor GC、Major GC(也常被称为Old GC)和Full GC。
Minor GC: Minor GC主要针对的是Java堆内存中的新生代(Young Generation)。新生代通常被细分为三个子区:Eden区、两个Survivor区(From和To)。大部分新创建的对象首先分配在Eden区。当Eden区满时,会触发一次Minor GC,将Eden区以及Survivor区中仍然存活的对象复制到另一个Survivor区中,同时清理掉未被引用的对象。这个过程相对快速,因为新生代中的对象大多寿命较短,很多对象在第一次或第二次GC后就会被回收。
Major GC(或Old GC): Major GC主要针对的是老年代(Old Generation)的垃圾回收。当老年代空间不足或者某些特定条件满足时(比如晋升到老年代的对象过多),会触发Major GC。Major GC通常比Minor GC慢,因为它涉及到的对象更多,且可能需要进行更复杂的标记-压缩或标记-清除算法。虽然Major GC主要关注老年代,但它也可能影响到整个堆,特别是如果老年代的回收导致年轻代对象晋升无处安放时。
Full GC: 对整个Java堆内存(包括新生代和老年代),方法区进行全面的垃圾回收。也是最消耗资源的一种GC类型,因为它需要停止所有应用线程(Stop-The-World),因此会导致较长的应用暂停时间。
为了保持应用的响应性和整体性能,通常需要尽量减少Major GC和Full GC的发生频率,通过合理的堆内存分配、选择合适的垃圾收集器以及调优GC参数等方式来优化
Full GC通常在以下情况下发生
调用
System.gc()
或Runtime.getRuntime().gc()
,只是建议 JVM 进行Full GC,JVM 可能会忽略或者执行部分 GC,一般不会执行 Full GC老年代空间不足
大对象直接进入老年代、长期存活的对象进入老年代等。
为了避免以上原因引起的 Full GC,应当尽量不要创建过大的对象以及数组。除此之外,可以通过 -Xmn 虚拟机参数调大新生代的大小,让对象尽量在新生代被回收掉,不进入老年代。还可以通过 -XX:MaxTenuringThreshold 调大对象进入老年代的年龄,让对象在新生代多存活一段时间
元空间内存不足不会触发Full GC,会直接抛出
OutOfMemoryError: Metaspace
在某些GC算法中,如G1进行并发周期失败时,也可能触发Full GC作为最后的回收手段
其他极端情况:比如某些严重的数据结构损坏,或者其他内部错误也可能导致JVM执行Full GC
Java8+ 元空间区的数据是否会进行垃圾回收?
会。
在Java 8中,方法区被元空间取代,元空间使用的是本地内存(Native Memory),而不是虚拟机堆内存
元空间中的垃圾回收主要针对那些不再需要的类加载器和它们加载的类
Java8+元空间触发GC条件?
- 类加载器不可达:当一个类加载器实例不再可达,即没有任何引用指向它时,该类加载器以及它所加载的所有类都成为垃圾收集的候选对象。这意味着相关的类和类加载器可以被回收
- 元空间内存不足:如果元空间的增长超出了配置的最大限制(通过
-XX:MaxMetaspaceSize
设置),那么JVM会尝试启动垃圾回收来回收无用的类和类加载器,以释放空间。如果回收后仍然无法满足新的内存分配需求,可能会抛出OutOfMemoryError: Metaspace
错误
Hotspot中有哪些垃圾回收器?
单线程与多线程: 单线程指的是垃圾收集器只使用一个线程进行收集,而多线程使用多个线程
串行:垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序
并形:垃圾收集器和用户程序同时执行。除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行。
Serial 收集器
单线程、串行收集器
对于单个 CPU 环境来说,由于没有线程交互的开销,因此拥有最高的单线程收集效率
Client 模式下的默认新生代收集器,因为在用户的桌面应用场景下,分配给虚拟机管理的内存一般来说不会很大。Serial 收集器收集几十兆甚至一两百兆的新生代停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿是可以接受的。
ParNew 收集器
Serial 收集器的多线程版本
Server 模式下的虚拟机首选新生代收集器,除了性能原因外,主要是因为除了 Serial 收集器,只有它能与 CMS 收集器配合工作
默认开启的线程数量与 CPU 数量相同,可以使用 -XX:ParallelGCThreads 参数来设置线程数
Parallel Scavenge 收集器
- 与 ParNew 一样是多线程收集器
- 吞吐量优先 收集器,高效率地利用 CPU 时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务
Serial Old 收集器
Serial 收集器的老年代版本,也是给 Client 模式下的虚拟机使用。如果用在 Server 模式下,它有两大用途:
- 在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用。
- 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。
Parallel Old 收集器
Parallel Scavenge 收集器的老年代版本
在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器
CMS 收集器
CMS(Concurrent Mark Sweep,并发标记清除)垃圾收集器是Java虚拟机中一种以获取最短回收停顿时间为目标的垃圾收集器,适用于对响应时间有严格要求的服务,如大型互联网应用。CMS收集器主要关注如何缩短用户线程的停顿时间,通过与用户线程同时运行的方式来减少垃圾收集对应用的影响。
以下是CMS垃圾收集器的主要工作流程:
- 初始标记(Initial Mark):用于标记从GC Roots直接可达的对象。这个过程很快,因为只扫描根对象。
- 并发标记(Concurrent Mark):在这个阶段,垃圾收集器与用户线程并发执行。收集器会遍历整个堆,标记所有可到达的对象。这个阶段耗时较长,但因为与用户线程并发执行,所以不会导致应用停顿。
- 预清理(Precleaning,可选步骤):在并发标记完成后,如果发现堆内存使用率较高,可能会执行预清理步骤,以确保有足够的空间容纳接下来的重新标记阶段中可能移动的对象。这一步也是短暂的Stop-The-World操作。
- 重新标记(Remark):这是另一个Stop-The-World事件,用于修正并发标记期间因用户程序继续运行而变动的对象关系。相较于初始标记,这一步耗时更长,但通常仍比老一代的完全垃圾收集要快。
- 并发清除(Concurrent Sweep):此阶段清除已被标记为垃圾的对象,同时用户线程继续运行。这也是一个并发操作,旨在减少应用的暂停时间。
CMS收集器的优缺点:
- 优点:
- 减少了应用的停顿时间,提供了较好的用户体验。
- 在大多数情况下,能与应用程序线程并发执行,提高了系统的吞吐量。
- 缺点:
- CPU资源消耗较大,因为并发阶段需要与用户线程争抢CPU资源。
- 可能会出现内存碎片问题,因为清除过程并不做压缩,长期运行可能导致大对象无法分配空间。
- 在极端情况下,CMS收集器可能会出现“Concurrent Mode Failure”,这时会退化到使用串行收集器进行一次完整的垃圾收集。
自Java 9起,G1(Garbage First)垃圾收集器被推荐作为CMS的替代品,因为G1在很多方面进行了改进,特别是在内存碎片处理和可预测的停顿时间控制上。到了Java 14,CMS垃圾收集器已经被废弃,并在Java 17中完全移除。因此,对于新项目或者升级旧项目时,建议使用G1或ZGC、Shenandoah等更现代的垃圾收集器。
G1垃圾收集器
G1(Garbage First)垃圾收集器是Java平台中的一种先进的垃圾收集器,它从Java 7 Update 4开始可用,并逐渐成为默认的垃圾收集器(Java 9开始推荐,Java 14中CMS被废弃后更为重要)。G1的设计目标是在大内存的多处理器机器上实现高吞吐量的同时,还能满足低延迟的需求,特别是对垃圾收集暂停时间的可预测性和控制。
G1的主要特点和工作原理包括:
- 分区的堆内存:G1将堆内存划分为多个大小相等的区域(Region),每个区域可以是Eden、Survivor、老年代或Humongous(巨型)区域之一。这种划分方式允许G1更灵活地管理内存,独立地对各个区域进行垃圾回收。
- 并行与并发:G1同样采用并行和并发的方式进行垃圾回收,以减少应用的停顿时间。它在年轻代和老年代的垃圾收集过程中都能实现并行处理。
- 基于优先级的回收:G1收集器会优先回收垃圾最多的区域,这也就是其名字“Garbage First”的由来。这种策略能够更有效地利用有限的收集时间回收尽可能多的垃圾。
- 可预测的停顿时间:G1提供了暂停预测模型,允许用户设置期望的最大垃圾收集停顿时间目标(-XX:MaxGCPauseMillis)。G1会尽力调整其回收策略,以满足这个目标。
- 混合收集周期:G1的垃圾收集过程通常包含年轻代和部分老年代的混合收集。这意味着每次垃圾收集不仅清理年轻代,还会根据需要回收一部分老年代的区域,从而逐步减少老年代的内存占用,避免了传统分代收集器中的全堆收集。
- 内存压缩:与CMS不同,G1在垃圾回收过程中能够进行一定程度的内存整理,减少了内存碎片问题。
- 自动内存管理:G1具备自适应性,能够自动调整堆的大小、年轻代和老年代的比例等,减少了手动调优的复杂度。
尽管G1具有许多优点,但它也有自己的适用场景和限制,比如在极小或极大的堆上可能不是最优选择,且相比其他收集器,它的监控和调优更为复杂。随着Java的发展,ZGC(Zero Garbage Collection)和Shenandoah垃圾收集器也作为低延迟的解决方案被引入,它们在某些场景下可能提供更好的性能表现。
ZGC垃圾收集器
ZGC(Zero Garbage Collector)是Java中一个先进的低延迟垃圾收集器,首次出现在 JDK 11
中,并作为实验性功能提供,到了JDK 15
之后成为生产就绪状态。ZGC的设计目标是在拥有大量内存的应用程序中实现暂停时间几乎为零的垃圾收集,同时保持高吞吐量。以下是ZGC的一些关键特性和工作原理:
关键特性:
- 低暂停时间:ZGC旨在实现任意大小堆上的暂停时间不超过10毫秒的目标,即使是在数十到数百GB的堆上。这对于要求极高响应性的应用程序至关重要。
- 并发标记-复制算法:ZGC使用一种特殊的并发标记-复制算法,几乎所有的工作都在应用程序线程之外并发完成,这大大减少了垃圾回收导致的暂停时间。
- 染色指针:ZGC引入了一种称为“染色指针”的技术,它在指针中编码了额外的信息,如对象是否处于垃圾回收阶段的可达性状态。这一设计使得ZGC无需扫描整个堆来更新指针,减少了停顿时间。
- 可伸缩性:ZGC设计为高度并行化,能够有效利用多核处理器,从而在大型系统上提供良好的性能。
- 无停顿的类卸载:除了对象分配外,ZGC还能够并发处理类卸载,进一步减少暂停时间。
- 内存可扩展至数TB:ZGC支持非常大的堆内存,理论上可以扩展到16EB(虽然实际限制通常由操作系统和硬件决定),适合大数据和内存密集型应用。
使用条件与限制:
- JDK版本:ZGC在JDK 15及更高版本中为生产环境准备就绪,但在早期版本中为实验性功能。
- 操作系统:ZGC目前主要支持Linux和macOS(Apple Silicon自JDK 17起)。
- 资源消耗:虽然ZGC提供了优秀的低延迟特性,但与G1等收集器相比,它可能会消耗更多的内存(因为需要维护读屏障和额外的数据结构),以及在某些情况下可能会有更高的CPU开销。
总的来说,ZGC非常适合那些对延迟敏感、需要处理大规模数据集的应用场景。不过,在考虑使用ZGC之前,应该根据具体的应用需求和环境进行评估和测试,以确定它是否是最合适的垃圾收集器选项。
JVM内存相关启动参数
基本内存配置
- -Xms:设定初始堆大小。控制JVM启动时的内存分配量,单位可以是K、M或G(千字节、兆字节、吉字节)。例如,
-Xms256m
设置初始堆大小为256兆字节。 - -Xmx:设定最大堆大小。指定了JVM能够使用的最大内存,超过这个值将会触发OOM(Out Of Memory)错误。单位同样可以是K、M或G。例如,
-Xmx1024m
设置最大堆大小为1GB。
新生代与老年代配置
- -Xmn:直接设定新生代的大小。如前所述,可以用来精确控制新生代的内存分配,例如,
-Xmn512m
设新生代为512兆字节。 - -XX:NewRatio:设置老年代与年轻代的比例。如,
-XX:NewRatio=3
表示老年代是新生代的3倍。 - -XX:SurvivorRatio:设置Eden区与Survivor区的比例。默认情况下,一个Survivor区大小是Eden区的1/8或1/10,通过这个参数可以调整。如,
-XX:SurvivorRatio=6
表示一个Survivor区是Eden区的1/6大小。
其他高级内存配置
- -XX:MaxPermSize(JDK 8之前):设定永久代(PermGen)的最大大小。在JDK 8之后,永久代被元空间(Metaspace)取代,应使用
-XX:MaxMetaspaceSize
。 - -XX:MetaspaceSize:设置元空间的初始大小。
- -XX:MaxMetaspaceSize(可选):限制元空间的最大大小。如果不设置,默认情况下,元空间可以无限增长,仅受系统内存限制。
- -XX:InitialHeapSize 和 -XX:MaxHeapSize:这两个参数分别等价于
-Xms
和-Xmx
,提供了一种更长的命名方式。
垃圾收集器相关参数
- -XX:+UseSerialGC、**-XX:+UseParallelGC、-XX:+UseConcMarkSweepGC、-XX:+UseG1GC**:选择不同的垃圾收集器。每种收集器有其独特的内存管理策略和适用场景。
- -XX:ParallelGCThreads:对于并行垃圾收集器(如Parallel GC),设置垃圾回收时使用的线程数。
监控与诊断
- -XX:+PrintGCDetails:打印垃圾收集的详细信息到标准输出,有助于分析GC行为。
- -XX:+HeapDumpOnOutOfMemoryError:当发生OOM错误时,自动创建堆转储文件,便于事后分析。
问题排查
方法论
复现问题、查看日志、远程调试、分析异常堆栈、性能监控、内存分析、并发问题检查、版本比对、缩小范围、单元测试、社区和文档
Linux定位问题的工具
网络、防火墙、磁盘、内存、环境变量、日志
- 文本操作
- 文本查找 - grep
- 文本分析 - awk
- 文本处理 - sed
- 文件操作
- 文件监听 - tail
- 文件查找 - find
- 网络和进程
- 网络接口 - ifconfig
- 防火墙 - iptables -L
- 路由表 - route -n
- netstat
- 其它常用
- 进程 ps -ef | grep java
- 分区大小 df -h
- 内存 free -m
- 硬盘大小 fdisk -l |grep Disk
- top
- 环境变量 env
JDK自带的工具?
jps、jstack、jmap、jinfo、jstat
- jps 查看当前java进程的小工具, 可以看做是JavaVirtual Machine Process Status Tool的缩写
1 | jps –l # 输出进程ID和全路径的应用主类名,示例 16694 com.neo.ActuatorApplication |
- jstack 线程堆栈分析工具,使用该命令可以查看或导出 Java 应用程序中线程堆栈信息。
1 | # 基本 |
- jinfo 查看正在运行的 java 应用程序的扩展参数,包括 Java 系统属性和 JVM参数;也可以动态的修改正在运行的 JVM 一些参数。当系统崩溃时,jinfo可以从core文件里面知道崩溃的Java应用程序的配置信息
1 | jinfo 2815 # 输出当前 jvm 进程的全部参数和系统属性 |
- jmap 用于生成堆内存的快照(heap dump)或打印Java进程的内存详情。这个工具对于分析内存泄漏、监控内存使用情况以及故障诊断非常有用
1 | # 查看堆的情况 |
- jstat 可以显示包括类加载、内存使用、垃圾收集等多种与 JVM 性能相关的信息。它的工作原理是直接读取 JVM 的内部统计结构,因此可以提供实时的监控数据
1 | jstat -gcutil 2815 1000 |
Java 诊断工具Arthas?
阿里巴巴开源的 Java 诊断工具,用于在线调试 Java 应用,实时监控与诊断问题,无需重启 JVM,大大提升线上问题排查效率
特性
- 分析线程、内存:
dashboard
、thread
sc
、sm
查询类或方法加载:某个类或方法是否被加载jad
查看类或方法源码watch
监控方法入参、返回值,执行耗时- 方法调用栈分析:
stack
- 代码热更新:
jad
、mc
、redefine
使用步骤
- 下载:
curl -O https://arthas.aliyun.com/arthas-boot.jar
- 启动:在目标 Java 应用的服务器上,通过命令行启动 Arthas,通常命令为
java -jar arthas-boot.jar
- 连接:Arthas 会列出当前系统中所有运行的 Java 进程,你可以选择一个进程连接
- 使用命令:连接后,就可以开始使用 Arthas 提供的各种命令进行诊断和分析了
使用示例
查看 dashboard
实时监控进程、JVM内存、运行环境信息
- thread - 查看最繁忙的线程,以及是否有阻塞情况发生?
场景:我想看下查看最繁忙的线程,以及是否有阻塞情况发生? 常规查看线程,一般我们可以通过 top 等系统命令进行查看,但是那毕竟要很多个步骤,很麻烦。
1 | thread -n 3 # 查看最繁忙的三个线程栈信息 |
- sc、sm - 确认某个类、方法是否已被系统加载?
场景:我新写了一个类或者一个方法,我想知道新写的代码是否被部署了?
1 | # 即可以找到需要的类全路径,如果存在的话 |
- jad - 查看一个class类的源码信息
场景:我新修改的内容在方法内部,而上一个步骤只能看到方法,这时候可以反编译看下源码
1 | # 直接反编译出java 源代码,包含一此额外信息的 |
- watch - 跟踪某个方法的入参、返回值和执行耗时
场景:我想看下我新加的方法在线运行的参数和返回值?
1 | # watch [类名] [方法名] [表达式] [-b 访问前执行] [-a 访问后执行] [--skipJDKMethod true|false] [其他选项] |
- stack - 查看方法调用栈的信息
场景:我想看下某个方法的调用栈的信息?
1 | stack pdai.tech.servlet.TestMyServlet testMethod |
运行此命令之后需要即时触发方法才会有响应的信息打印在控制台上
- 找到最耗时的方法调用?
场景:testMethod这个方法入口响应很慢,如何找到最耗时的子调用?
1 | # 执行的时候每个子调用的运行时长,可以找到最耗时的子调用。 |
运行此命令之后需要即时触发方法才会有响应的信息打印在控制台上,然后一层一层看子调用。
- mc、redefine - 临时更改代码运行
场景:我找到了问题所在,能否线上直接修改测试,而不需要在本地改了代码后,重新打包部署,然后重启观察效果?
1 | # 先反编译出class源码 |
如上,是直接更改线上代码的方式,但是一般好像是编译不成功的。所以,最好是本地ide编译成 class文件后,再上传替换为好!
总之,已经完全不用重启和发布了!这个功能真的很方便,比起重启带来的代价,真的是不可比的。比如,重启时可能导致负载重分配,选主等等问题,就不是你能控制的了。
- 测试某个方法的性能问题
1 | monitor -c 5 demo.MathGame primeFactors |
Idea的远程调试 -Xdebug
要让远程服务器运行的代码支持远程调试,则启动的时候必须加上特定的 JVM 参数,这些参数是:
1 | -Xdebug -Xrunjdwp:transport=dt_socket,suspend=n,server=y,address=9000 |
IDEA 启动 Remote_debug
后,在代码中设置断点,等待远程访问触发断点即可
Java 新版本
Java 8 特性
接口支持 default 实现方法
- 接口可以有实现方法,只需在前面加个 default 关键字即可
- 一个接口可以有1 个或多个default 方法
- 一个类实现了包含有 default 方法的接口,则继承了该方法,也可以重写该方法
- 一个类实现了多个接口,每个接口有相同的default 方法(不同实现),那么这个类必须重写这个方法,否则编译报错
1 | public interface A { |
接口支持静态方法
- 允许接口中包含静态方法,支持一个或多个
- 通过接口直接调用静态方法(静态方法属于接口)
函数式接口
- 函数式接口中只包含一个抽象方法
- 函数式接口中可以有 default 实现方法和静态方法
- 可以使用
@FunctionalInterface
注解可以标记在接口上,以明确指定这是一个函数式接口,编译时会做检查 - 可以利用Lambda表达式书写,这是一种更简洁、更功能化的编程方式
Lambda表达式
- Lambda 表达式提升了代码的简洁性、可读性
- lambda表达式,基于函数式接口,表达的是接口函数,箭头左侧是函数参数,箭头右侧是函数体
- 函数的参数类型和返回值类型都可以省略,程序会根据接口定义的上下文自动确定数据类型
- 简化匿名内部类: 在Java 8之前,为了实现接口或者抽象类的一个方法,通常需要创建一个匿名内部类。Lambda表达式提供了一种更简洁的方式来实现这样的功能,减少了大量的模板代码
Optional
- 提升代码自解释性:明确表达可能存在空值的情况,有助于避免空指针异常(NullPointerException)
ifPresent()
如果 optional 对象中的值不为空的话,执行一些操作isPresent()
判断 optional 对象中的值是否存在(非空)- 链式调用:
Optional
提供了丰富的API,如map()
,flatMap()
,orElse()
,orElseGet()
,orElseThrow()
等,使得可以很容易地进行一系列操作而无需显式检查中间结果是否为null,这有助于写出更简洁、易读的代码 - 促进函数式编程:
Optional
与Java 8的流(Streams)和Lambda表达式紧密配合,促进了更加函数式风格的编程,使得代码更加声明式,易于理解和维护
类型注解
- 使用场景:类型注解可以被应用在类声明、接口声明、方法和构造器的返回类型、方法和构造器的参数类型、泛型类型参数、数组类型、局部变量声明、类型强制转换以及新实例的创建等位置
- 编译时验证:类型注解特别适合于编译时强类型检查,比如可以通过注解来标记某些类型必须满足特定的约束条件,然后利用注解处理器在编译阶段检查这些约束是否被遵守
- 不改变语义:类型注解本身并不改变程序的运行时行为,它们主要提供给编译器和其他工具使用
- @Target 扩展:为了让注解能够应用在类型上下文中,
java.lang.annotation.ElementType
枚举中添加了新的元素,如TYPE_USE
和TYPE_PARAMETER
。TYPE_USE
表示注解可以应用于任何类型使用的地方,而TYPE_PARAMETER
则专用于泛型类型参数声明 - 示例
@NonNull
、@Readonly
1 | import java.lang.annotation.ElementType; |
数据结构和算法
数据结构基础
数据操作基本上就是增、删、改、查、排序
数组 (Array)
- 数组是一种线性数据结构,用于存储固定数量的同类型元素。每个元素都有一个索引,从0开始计数。
- 支持根据索引下标快速查询数据,但插入、删除操作较慢(可能需要移动大量元素)。在一个增删比较频繁的数据结构中,数组不会被优先考虑
链表 (Linked List)
- 链表也是一种线性结构,但与数组不同,它的元素在内存中不是连续存放的。每个元素(节点)包含数据和指向下一个节点的指针。
- 插入和删除操作快(只需修改指针),但查询效率不高(需要遍历链表),适用于频繁插入/删除的场景
- 单向链表:例如每个节点中存储下一个节点的引用,而没有上一个节点的引用。查找时只能单向查找
- 双向链表:每个节点中既包含下一个节点的引用,也包含上一个节点的引用,在查找时支持双向查找
栈 (Stack)
- 栈是一种后进先出(LIFO, Last In First Out)的数据结构,只允许在一端(栈顶)进行添加和移除操作。
- 理解要点:常用于函数调用等场景,体现了“先进后出”的逻辑。
队列 (Queue)
- 队列遵循先进先出(FIFO, First In First Out)原则,一端添加(入队),另一端移除(出队)。
- 理解要点:广泛应用于需要处理一系列任务的场景,如打印队列、CPU调度等。
哈希表 (Hash Table)
- 使用哈希函数根据键(Key)确定存储位置
- 存在哈希冲突时,需要通过链地址法或开放寻址法解决
- 是数组和链表的折中,同时它的设计依赖散列函数的设计,数组不能无限长、链表也不适合查找,所以也不适合大规模的查找
树 (Tree)
树是一种分层数据结构,由节点组成,每个节点可以有零个或多个子节点
二叉树
- 介绍
- 节点结构:每个节点包含一个数据元素以及指向左子节点和右子节点的引用(指针或链接)。对于没有子节点的情况,对应的引用可以是空(null)
- 根节点:二叉树的顶部节点称为根节点,它没有父节点
- 叶节点(终端节点):没有子节点的节点称为叶节点
- 边:连接节点的线称为边。在二叉树中,从一个节点到其子节点的边有明确的方向性,从父节点指向子节点
- 二叉搜索树(Binary Search Tree, BST):左子树所有节点的值小于父节点,右子树所有节点的值大于父节点。『左小右大』
- 平衡二叉树:每个节点的左右子树高度差不能超过1,如AVL树和红黑树都是平衡二叉树的实例
- AVL树:严格平衡二叉搜索树
- 适用于插入与删除较少,查找多的情况
- AVL树通过每次插入或删除节点后重新计算节点的高度,并检查是否满足平衡条件(任何节点的两个子树的高度差不超过1),如果不满足则通过旋转操作来恢复平衡
- 严格平衡:AVL树要求高度平衡,任何节点的两个子树高度之差最多为1。
- 旋转操作:包括单旋转(左旋、右旋)和双旋转(左右旋、右左旋)来恢复平衡。
- 较高的平衡度:这使得AVL树的查找效率非常高,最坏情况下的时间复杂度为O(log n)。
- 红黑树:非完全平衡二叉树,在每个节点增加一个存储位表示节点的颜色,可以是红或黑(非红即黑)。适用于搜索,插入,删除操作较多的情况
- AVL树:严格平衡二叉搜索树
- 介绍
B树
- 自平衡多叉树
- 包含根节点、内部节点和叶子节点
- 每个节点都可以包含数据(data)和一个或多个键值(keys),以及指向子节点的指针。这意味着在B树中,数据分散在树的所有节点上
- 由于数据可能在任何层级的节点中,范围查询可能需要在树的多个层级进行,效率相对较低
- 每个节点存储数据可能导致节点能存储的键值较少,增加了树的高度,从而可能增加磁盘I/O操作次数
- 适用于读写相对大的数据块的存储系统,例如磁盘。它的应用是文件系统及部分非关系型数据库索引
B+树
- 自平衡多叉树
- 包含根节点、内部节点和叶子节点
- 只有叶子节点才存储实际的数据(或者指向数据的指针),而内部节点只存储键值作为索引(导航作用),以及指向子节点的指针。这使得B+树的内部节点可以容纳更多的键值,降低了树的高度,提高了磁盘读写的效率
- B+ 树是 B 树 的一个升级,它比 B 树更适合实际应用中操作系统的文件索引和数据库索引。目前现代关系型数据库最广泛的支持索引结构就是 B+ 树
- B+ 树是一种多叉排序树,即每个节点通常有多个子节点
- 叶子节点通过指针相连,形成了一个有序链表,便于范围查询
图 (Graph)
- 图是由节点(顶点)和边(连接节点的线)组成,用来表示对象之间的关系
- 理解要点:分为有向图和无向图,常用于网络路由、社交网络分析等问题
算法思想
分治算法
分治算法的基本思想是将一个规模为N的问题分解为K个规模较小的子问题,这些子问题相互独立且与原问题性质相同。求出子问题的解,就可得到原问题的解
动态规划算法
- 动态规划算法与分治法类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解
- 动态规划算法求解的问题经过分解后得到的子问题往往不是独立的,而是下一个子阶段的求解是建立在上一个子阶段的解的基础上的
贪心算法
保证每次操作都是局部最优的,并且最后得到的结果是全局最优的
二分法
比如重要的二分法,比如二分查找;二分查找也称折半查找(Binary Search),它是一种效率较高的查找方法。但是,折半查找要求线性表必须采用顺序存储结构,而且表中元素按关键字有序排列
搜索算法
主要包含BFS,DFS
Backtracking(回溯)
属于 DFS, 回溯算法实际上一个类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就“回溯”返回,尝试别的路径。回溯法是一种选优搜索法,按选优条件向前搜索,以达到目标。但当探索到某一步时,发现原先选择并不优或达不到目标,就退回一步重新选择,这种走不通就退回再走的技术为回溯法
排序算法
冒泡排序 (Bubble Sort)
- 总结:遍历、相邻比较、交换
- 简单直观,通过重复遍历要排序的数列,比较相邻元素并在必要时交换它们的位置,直到没有再需要交换的元素为止。
快速排序 (Quick Sort)
- 总结:选基准元素,一边小,一边大,各边继续排
- 采用分治法策略,选取一个“基准”元素,通过一趟排序将待排序的记录分割成独立的两部分,其中一部分的所有数据都比另外一部分的所有数据要小,然后再按此方法对这两部分数据分别进行快速排序。
插入排序(Insertion Sort)
- 总结:依次取出未排序元素,插入已排序中合适位置
- 将数组分为已排序和未排序两部分,依次取出未排序部分的元素,在已排序部分找到合适的位置将其插入,直到全部元素排序完成。
选择排序(Selection sort)
总结:选择未排序中最小(or最大)元素,放到已排序末尾
它的基本思想是: 首先在未排序的数列中找到最小(or最大)元素,然后将其存放到数列的起始位置;接着,再从剩余未排序的元素中继续寻找最小(or最大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
归并排序(Merge Sort)
- 总结:拆分,排序,合并
- 也是分治法,将数组分成两半,分别排序,然后将两个有序数组合并成一个有序数组
堆排序(Heap Sort)
堆排序是指利用堆这种数据结构所设计的一种排序算法。堆是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。
加密算法
摘要算法
也称为哈希函数、散列函数或消息摘要,是一种将任意长度的数据映射为固定长度输出的算法。其主要特点和用途包括:
- 不可逆性:摘要算法的输出(哈希值或消息摘要)通常是无法从输出反推回原始数据的。这意味着,给定一个哈希值,很难找到或构造出原始输入数据,这为数据的安全存储提供了基础。
- 确定性:对于同一输入数据,无论何时何地执行摘要算法,都会得到相同的结果。这保证了哈希值的一致性和可验证性。
- 敏感性:即使是原始数据的微小变化,也会导致哈希值产生看似随机且显著不同的变化。这一特性使得摘要算法非常适合用于检测数据完整性。
- 抗碰撞性:理想的摘要算法应使找到两个不同输入但有相同输出的情况非常困难(这称为弱碰撞性),并且几乎不可能找到这样的输入对(强碰撞性)。虽然理论上没有绝对安全的哈希函数,但好的摘要算法应该尽可能难以被破解。
常见的摘要算法有:
- MD5:一种广泛使用的128位哈希函数,虽然曾经很流行,但由于安全性问题现在不推荐用于安全认证。
- SHA-1:比MD5更安全的160位摘要算法,但近年来也被证明存在安全弱点,不建议用于新的安全应用。
- SHA-2(包括SHA-256, SHA-512等):作为SHA-1的加强版,目前被广泛认为是安全的,用于多种安全协议和标准中。
- SHA-3:最新的安全哈希算法系列,设计用于替代SHA-2,具有更好的安全性和灵活性。
摘要算法在密码学、数据校验、数字签名、文件完整性验证、数据库索引等多个领域有着广泛应用。
对称加密算法
- 对称加密使用相同的密钥进行数据的加密和解密。这种类型的加密算法简单高效,适合于大量数据的加密,但密钥的管理和分发是一个挑战,因为所有参与通信的双方都必须安全地知道这个密钥。
- 加密、解密速度快,适合大量数据的快速传输
常见对称加密算法有:
- AES(Advanced Encryption Standard):高级加密标准,是最常用的对称加密算法之一,支持128、192、256位密钥长度,广泛应用于各种安全场景。
- DES(Data Encryption Standard):数据加密标准,较老的算法,密钥长度为56位,因安全性较低已逐渐被AES取代。
- 3DES(Triple DES):是DES的增强版,通过使用三个不同的密钥对数据进行三次加密,有效密钥长度达到112或168位,提高了安全性。
非对称加密算法
- 非对称加密算法使用一对密钥:公钥和私钥。公钥用于加密数据,而私钥用于解密数据。由于公钥可以公开,而私钥需要保密,这种方式解决了密钥分发的问题,常用于身份验证、数字签名和密钥交换场景。
- 不适合大数据量的加密解密,速度慢
常见非对称加密算法有:
- RSA:基于大数因子分解难题的算法,是最著名的非对称加密算法之一,广泛应用于SSL/TLS协议、电子邮件加密等。
- ECC(Elliptic Curve Cryptography):椭圆曲线密码学,相比RSA,ECC能够在更短的密钥长度下提供相同的安全强度,更适合资源受限的环境。
- DH(Diffie-Hellman) 和 ECDH(Elliptic Curve Diffie-Hellman):这两种算法用于安全密钥交换,使得两方可以在不安全的通道上协商出一个共享的密钥。
混合加密系统
- 在实际应用中,通常会结合使用对称加密和非对称加密,形成混合加密系统
- 例如HTTPS ,利用非对称加密(RSA)来安全地交换对称加密(如AES密钥)的密钥,然后使用对称加密算法来加密数据。这样既利用了非对称加密的安全性,又发挥了对称加密的高效性。
国密算法
- SM1 为对称加密。其加密强度与AES相当。该算法不公开,调用该算法时,需要通过加密芯片的接口进行调用。
- SM2 非对称加密,基于ECC。该算法已公开。由于该算法基于ECC,故其签名速度与秘钥生成速度都快于RSA。ECC 256位(SM2采用的就是ECC 256位的一种)安全强度比RSA 2048位高,但运算速度快于RSA。
- SM3 消息摘要。可以用MD5作为对比理解。该算法已公开。校验结果为256位。
- SM4 无线局域网标准的分组数据算法。对称加密,密钥长度和分组长度均为128位。
- SM7 是一种分组密码算法,分组长度为128比特,密钥长度为128比特。SM7适用于非接触式IC卡,应用包括身份识别类应用(门禁卡、工作证、参赛证),票务类应用(大型赛事门票、展会门票),支付与通卡类应用(积分消费卡、校园一卡通、企业一卡通等)。
- SM9 不需要申请数字证书,适用于互联网应用的各种新兴应用的安全保障。如基于云技术的密码服务、电子邮件安全、智能终端保护、物联网安全、云存储安全等等。这些安全应用可采用手机号码或邮件地址作为公钥,实现数据加密、身份认证、通话加密、通道加密等安全应用,并具有使用方便,易于部署的特点,从而开启了普及密码算法的大门。
领域算法
布隆过滤器
- 用于测试一个元素是否在一个集合中。它的主要特点是空间效率高,但是可能会有误报(false positives),即判断一个不在集合中的元素可能被错误地标记为在集合中,但它绝不会错误地判断一个在集合中的元素不在集合中(即不存在false negatives)。
- 原理:
- 位数组:布隆过滤器的核心是一个很长的二进制位数组(一系列比特位),所有位初始都设置为0。
- 哈希函数:选择多个独立的哈希函数(理想情况下,这些哈希函数之间的碰撞概率很低)。一般情况下,k个不同的哈希函数用于处理每个待插入的元素。
- 插入操作:当一个元素要加入到布隆过滤器时,它会经过k个哈希函数的处理,每个哈希函数都会产生一个位数组的索引位置。然后,这些索引位置上的比特位都会被置为1。
- 查询操作:检查某个元素是否存在于集合中时,同样将该元素通过k个哈希函数映射到位数组上,如果所有这些位置的比特位都是1,则算法会报告该元素“可能”在集合中;如果有任何一个位置是0,则可以确定该元素肯定不在集合中。
数据库
事务
事务基本特性ACID
事务的ACID特性是数据库管理系统中保证数据一致性和可靠性的四个基本原则,它们分别是:
- 原子性(Atomicity): 原子性确保事务是不可分割的工作单位。这意味着一个事务中的所有操作要么全部成功执行,要么全部不执行。如果事务中的任何一部分操作失败,则整个事务都会被回滚,仿佛从未开始过一样,以此来保持数据库的一致性状态。
- 一致性(Consistency): 一致性保证事务执行前后,数据库从一种一致状态转换到另一种一致状态。在事务开始之前和结束之后,数据库的完整性约束不会被破坏。例如,如果有一个规则说账户余额不能为负数,那么一致性就会确保事务结束后所有账户余额都是非负的。
- 隔离性(Isolation): 隔离性要求在并发环境中,多个事务同时执行时,每个事务好像在单独、序列化执行一样,彼此之间互不影响。不同的隔离级别(如读未提交、读已提交、可重复读、串行化)提供了不同程度的事务隔离,同时也会影响并发性能。
- 持久性(Durability): 持久性意味着一旦事务被提交,它对数据库的更改就是永久性的,即使系统发生故障(如断电、崩溃等),这些更改也不会丢失。为了确保这一点,数据库系统通常会采用日志或其他机制来记录事务的更改,并在系统恢复时根据日志重做或回滚事务
事务的隔离级别
- 读未提交:最低的隔离级别,一个事务可以读到另一个事务修改但未提交的数据,可能导致“脏读”
- 读已提交:一个事务只能读取其他事务已经提交的数据(不提交看不到),避免了脏读,但可能出现“不可重复读”。
- 可重复读:在一个事务内多次读取同一数据的结果是一致的,即使其他事务在这期间对数据进行了修改或删除,但可能会遇到“幻读”。例如其他事务在这两次查询之间插入了新记录,导致第二次查询看到了第一次查询没有的新行。
- 串行化(Serializable):最高级别的隔离,通过完全序列化事务的执行,避免了脏读、不可重复读和幻读,但性能开销最大。
隔离级别 | 脏读 | 不可重复读 | 幻影读 |
---|---|---|---|
读未提交 | √ | √ | √ |
读已提交 | × | √ | √ |
可重复读 | × | × | √ |
串行化 | × | × | × |
并发一致性问题
在并发环境下,事务的隔离性很难保证,因此会出现很多并发一致性问题。
- 修改丢失:后面事务修改的值覆盖了前面事务修改后的值
- 脏读:A 事务修改数据未提交,B 事务读取该数据,就是脏数据(A 可能回滚)
- 不可重复读(关注的是修改):A 事务读取某数据,B 事务修改该数据并提交,A 事务再次读取该数据,出现数据不一致情况
- 幻读(关注的是插入):在同一事务内,两次查询同一个范围的数据,由于其他事务在这两次查询之间插入了新记录,导致第二次查询看到了第一次查询没有的新行
SQL 优化
字段类型
- 能用数字就不要用字符串
- 支持数据库中数学运算,无需进行数据类型转换
- 占用的存储空间更少
- 索引更小,占用的空间较少,查询更快
- 更适合范围查询(如
WHERE price BETWEEN 100 AND 200
) - 排序比字符串类型快
- 特别是在处理大量数据和执行复杂查询时,优势更加明显
- 尽可能使用小的类型,比如:用
bit
存布尔值,用tinyint
存枚举值 - 长度固定的字符串字段,用
char
类型。字段值为空,也会分配定义长度的存储空间。除非字段定义不为空,否则推荐使用varchar
- 长度可变的字符串字段,用
varchar
类型。字段值为空,不会分配存储空间。灵活性和空间效率更好 - 金额字段用
long
类型 - 字段尽量
NOT NULL
,给一个默认值。但像备注、描述、评论之类的可以设置为 NULL
基础
避免使用
select *
,应按需指定要返回的字段先缩小查询范围,再范围内操作
使用
union all
代替union
,后者会去重,不管数据集是否存在重复。如果需要去重,也可以在后端代码中去重小表驱动大表
- 用小表的数据集驱动大表的数据集
in
:会优先执行 in 里面的子查询语句
,然后再执行 in 外面的语句。**in
适用于左边大表,右边小表。**exists
:优先执行 exists 左边的语句(即主查询语句)。然后把它作为条件,去跟右边的语句匹配。**exists
适用于左边小表,右边大表。**
批量插入:建立一次连接,批量插入数据,注意每批数据量也要控制,不能太多
使用
limit
- 限定返回数据量,例如加上分页功能,而不是一次性返回大量数据
- 判断数据是否存在,使用
limit 1
而不是select count
分页:在查询数据时,为了避免一次性返回过多的数据影响接口性能,我们一般会对查询接口做分页处理
使用
join
联表查询,代替子查询- left join:以左边的表为基准,遍历右侧的表,查找符合条件的数据
- right join:同理
- inner join:求两个表交集的数据
- 如果能用
inner join
的地方,尽量少用left join
- 为联表的字段创建索引
join的表不宜过多: 阿里巴巴开发者手册的规定,join表的数量不应该超过
3
个
索引
使用
explain
查看 sql 执行计划:查看是否用到了索引,用到了哪个索引控制索引的数量
- 阿里巴巴的开发者手册中规定,单表的索引数量应该尽量控制在
5
个以内,并且单个索引中的字段数不超过5
个 - 删除无用的单个索引
- 创建复合索引,代替多个单个索引。但要注意:复合索引使用时要符合『最左前缀原则』
- 阿里巴巴的开发者手册中规定,单表的索引数量应该尽量控制在
在 where 、 order by、group by 涉及的列上建立索引
复合索引:联合索引的“最左前缀原则”,只有当查询条件使用了联合索引中最左侧的一个字段时,索引才会被有效利用
- 联合索引有效:a,a和b,a和b和c
- 联合索引无效:b,c,b和c,a和c
where 子句中对字段 is null 改为默认值,可利用到索引
1
2
3select id from t where num is null
改为
select id from t where num = 0避免在 where 子句中使用
!=
或<>
操作符:可以转换为全部减去等于情况的数据避免在 where 子句中使用 or,特别是一个字段有索引,一个字段没有索引。索引将失效
1
2
3
4
5
6select id from t where num=10 or Name = 'admin'
改成
select id from t where num = 10
union all
select id from t where Name = 'admin'in 连续值,改成 between and 。索引效率更高
1
2
3
4select id from t where num in(1,2,3)
改为
select id from t where num between 1 and 3左模糊查询会导致索引失效
1
select id from t where name like '%abc%'
不要在 where 中的 = 左边进行函数、算术运算或其他表达式运算,否则系统将可能无法正确使用索引
1
2
3
4select id from t where substring(name,1,3) = 'abc'
改为:
select id from t where name like 'abc%'update 语句,如果只更改1、2个字段,不要Update全部字段,否则频繁调用会引起明显的性能消耗,同时带来大量日志
对于多张大数据量(这里几百条就算大了)的表JOIN,要先分页再JOIN,否则逻辑读会很高,性能很差。
select count(*) from table;这样不带任何条件的count会引起全表扫描,并且没有任何业务意义,是一定要杜绝的
尽量避免使用游标,因为游标的效率较差,如果游标操作的数据超过1万行,那么就应该考虑改写
尽量避免大事务操作,提高系统并发能力
尽量避免向客户端返回大数据量,若数据量过大,应该考虑相应需求是否合理
扩展
create [TEMPORARY] table xxx as select 语句
:以 select 语句输出结果为输入,创建临时表
MySQL
默认事务隔离级别?
MySQL InnoDB 存储引擎的事务默认隔离级别是 可重复度 Repeatable Read
索引
在 MySQL InnoDB 存储引擎中,索引数据采用B+树数据结构,具体分为 2 种:
聚簇索引(Clustered Index)- 主键索引
如果一个表定义了主键,InnoDB会使用主键作为聚簇索引。在这种情况下,叶子节点直接存储数据记录。也就是说,数据行实际存储在叶子节点中,每个叶子节点包含了表中的实际数据行,因此在聚簇索引中,索引即数据,数据即索引。这种设计使得按主键查询非常高效
二级索引(Secondary Index)- 辅助索引
对于非主键的索引,如唯一索引或者其他普通索引,这些索引被称为二级索引。在二级索引中,叶子节点不直接存储完整的数据记录,而是存储指向相应行的聚簇索引键(通常是主键的值)。当通过二级索引来查询数据时,InnoDB首先通过二级索引找到对应的主键值,然后使用这个主键值去聚簇索引中查找实际的数据行,这个过程称为回表查询
MyISAM 和 InnoDB 的区别
- MyISAM 不支持事务、外键、行级锁(只有表级锁)
- InnoDB 支持事务处理(ACID兼容),可以进行提交(commit)、回滚(rollback)操作,适合需要数据一致性和完整性的应用程序,如银行系统、金融应用
- InnoDB 支持行级锁,这意味着在更新操作时,只会锁定需要修改的行,不会阻塞其他行的并发访问
- MyISAM 的索引文件和数据文件是分开的。InnoDB 的主键索引(聚簇索引)中包含实际数据,所以索引和数据在一块。对于二级索引来说,则是分开的
MySQL的索引有哪些
- B+树 索引
- 是MySQL InnoDB 存储引擎的索引类型
- 哈希索引
- 哈希索引能以 O(1) 时间进行查找,但是失去了有序性
- InnoDB 存储引擎有一个特殊的功能叫“自适应哈希索引”,当某个索引值被使用的非常频繁时,会在 B+Tree 索引之上再创建一个哈希索引,这样就让 B+Tree 索引具有哈希索引的一些优点,比如快速的哈希查找
- 全文索引
- MyISAM 存储引擎支持全文索引,用于查找文本中的关键词,而不是直接比较是否相等。查找条件使用 MATCH AGAINST,而不是普通的 WHERE
- 全文索引一般使用倒排索引实现,它记录着关键词到其所在文档的映射
- InnoDB 存储引擎在 MySQL 5.6.4 版本中也开始支持全文索引
- 空间数据索引
- 从 MySQL 5.7 版本开始,InnoDB 存储引擎支持空间数据索引(R-树),可以用于地理数据存储。空间数据索引会从所有维度来索引数据,可以有效地使用任意维度来进行组合查询
COUNT(*)、
COUNT(0)、和
COUNT(id)的区别
- COUNT(*):
COUNT(*)
会计算表中的所有行,包括那些具有NULL值的行。它是SQL标准中用于统计行数的推荐方式。- 在InnoDB存储引擎中,
COUNT(*)
通常是非常高效的,因为它可以直接从存储引擎的行计数器中获取行数,而不必扫描整个表。 - 不管表中是否有主键或索引,它都能准确快速地给出结果。
- COUNT(0):
COUNT(0)
或COUNT(1)
在行为上通常与COUNT(*)
相同,都返回表中的行数。- 早期的一些资料可能会指出
COUNT(0)
或COUNT(1)
相比COUNT(*)
在某些数据库系统中更高效,因为它们只计数一个固定值,而不是访问每一列。然而,在现代的MySQL尤其是InnoDB存储引擎中,这种差异通常不存在,因为优化器能够识别这些情况并进行相应的优化。 - 使用
COUNT(0)
或COUNT(1)
而非COUNT(*)
更多是基于习惯或是对历史性能差异的误解。
- COUNT(id):
- 当
id
是表的一个列名,特别是当它是主键时,COUNT(id)
的行为也类似于COUNT(*)
,统计表中的所有行。 - 但是,与
COUNT(*)
相比,如果id
列上有索引,尤其是在覆盖索引的情况下,理论上COUNT(id)
可能在某些数据库系统中表现得更快,因为数据库可以直接从索引中计数而无需访问表数据。然而,在InnoDB中,由于聚簇索引的设计,这种差异通常不明显。 - 如果
id
列允许NULL值,COUNT(id)
将不计算那些id
为NULL的行,这是它与COUNT(*)
的主要区别。
- 当
什么是MVCC?
全称Multi-Version Concurrency Control,即多版本并发控制。MVCC是一种并发控制的方法,一般在数据库管理系统中,实现对数据库的并发访问,在编程语言中实现事务内存。
InnoDB引擎实现MVCC的3个基础点
隐藏字段
DB_ROW_ID
是数据库默认为该行记录生成的隐藏主键;DB_TRX_ID
是当前操作该记录的事务ID; 而DB_ROLL_PTR
是一个回滚指针,用于配合undo日志,指向上一个旧版本
Undo Log(回滚日志)
Undo Log在MySQL的InnoDB存储引擎中主要用于记录事务过程中对数据的修改信息,以便在事务失败需要回滚时能够恢复到事务开始前的状态
Undo Log 是实现 MVCC 的关键,它保存了数据的旧版本信息。每当事务更新一行数据时,InnoDB 不会直接覆盖原数据,而是将更改记录在 Undo Log 中,并更新该行的
DB_TRX_ID
和DB_ROLL_PTR
。这样,通过DB_ROLL_PTR
可以追踪到该行数据的多个历史版本。在需要根据事务的可见性规则回溯历史版本时,Undo Log 起着至关重要的作用Undo Log主要记录以下类型的操作日志:
- INSERT操作:当一个事务执行INSERT操作时,Undo Log会记录一条逻辑日志来表示相反的操作,即一个DELETE记录,这样在事务回滚时可以根据这条记录删除插入的新记录,恢复到事务开始前的状态。
- UPDATE操作:对于UPDATE操作,Undo Log会记录一条或多条逻辑日志,表示将数据从更新后的状态还原到更新前的状态。也就是说,如果一行数据被更新,Undo Log中会记录一条相反的UPDATE操作,使用更新前的值覆盖更新后的值,确保在回滚时能恢复原值。
- DELETE操作:当执行DELETE操作时,Undo Log会记录一个INSERT记录,包含被删除数据的完整信息。这样在事务回滚时,可以通过Undo Log中的INSERT记录重新插入这些数据,实现撤销删除的效果。
ReadView
ReadView 理解成一种快照
在 MySQL 的 InnoDB 存储引擎中,ReadView 用于实现事务的隔离性,尤其是对于“可重复读”(Repeatable Read)隔离级别的支持
当一个事务在可重复读隔离级别下开始时,InnoDB 会创建一个 ReadView,这个 ReadView 记录了在该事务视图下,其他活跃事务的 ID 列表以及事务的活跃状态
ReadView 实际上提供了一个数据的快照视图,确保了在同一事务内的多次查询看到的数据是一致的,即使其他事务在这期间对数据进行了修改或插入
这意味着在可重复读隔离级别下,事务开始时能看到的数据版本,在事务期间不会因其他事务的提交而改变,从而避免了不可重复读的现象
锁的类型有哪些
共享锁(简称S锁)和排他锁(简称X锁)
读锁是共享锁,可以通过
lock in share mode
实现,这时候只读不写- 使用
LOCK IN SHARE MODE
必须在一个事务中,因为锁是在事务的上下文中维护的 - 共享锁之间是共存的,但与排他锁(如
FOR UPDATE
产生的锁)冲突,如果某行数据已经被加了排他锁,试图加共享锁的事务将需要等待 - 查询结果为多条记录时,MySQL 的 InnoDB 存储引擎会为每一条匹配的记录分别加上一个共享锁,而不是对所有结果集加一个整体的共享锁
1
2
3
4
5
6START TRANSACTION;
-- 查询商品ID为123的库存,并加上共享锁
SELECT quantity FROM inventory WHERE product_id = 123 LOCK IN SHARE MODE;
-- 此处可以根据查询结果进行一些逻辑判断,但不修改数据
-- ...
COMMIT;- 使用
写锁是排它锁,它会阻塞其它写锁和读锁
- 当事务需要更新(包括INSERT、UPDATE、DELETE操作)一行数据时,会获取该行的排他锁。一旦一个事务对某一行持有了排他锁,其他任何事务都不能再对该行加任何类型的锁,不论是共享锁还是排他锁,从而确保了该事务可以独占进行写操作
- 使用
select ... for update
,会为结果集中每一行添加排它锁
表锁和行锁
表锁:
表锁既可以是共享锁,也可以是排它锁
使用
LOCK TABLES xxx read;
申请表的共享锁,但应谨慎使用,可以考虑行级锁来满足需求。操作完成后使用UNLOCK TABLES
来释放锁1
2
3
4
5# 这里的 table_name 是你要对其申请共享锁的表名。这条命令会使得当前会话能够读取表中的数据,同时阻止其他会话对表进行写操作(例如 UPDATE, DELETE, 或 INSERT),但不会阻止其他会话也获取共享锁来读取数据
LOCK TABLES table_name READ;
# 释放锁
UNLOCK TABLES;使用
alter table xxx
或者LOCK TABLES table_name WRITE;
申请表的排它锁,谨慎使用
行锁:
- 行锁是数据库中最细粒度的锁,锁定特定的行。由于锁的范围小,可以有效减少锁之间的冲突,提高数据库的并发处理能力
- 行锁既可以是共享锁,也可以是排他锁
- 使用
SELECT ... LOCK IN SHARE MODE
,会为查询结果集中的每一行加上共享锁,阻止其他事务对这些行进行修改,但允许其他事务继续读取(获取共享锁) - 使用
select ... for update
,会为查询结果集中的每一行加上行锁,是排它锁
悲观锁和乐观锁
悲观锁假定多线程或事务之间的数据竞争总会发生,因此在数据进行处理前就将其锁定,以防止并发修改
在数据库中,悲观锁通常通过显式地使用锁机制来实现,比如 InnoDB 引擎中的
SELECT ... FOR UPDATE
或SELECT ... LOCK IN SHARE MODE
。这些操作会实际地在数据库层面为数据行加上锁
通过 for update
实现悲观锁
- 如果没有查询条件,则会加表锁,而不是行锁
- 行锁必要条件:必须有查询条件 & 查询字段有索引 & 索引有效
- 查询字段可以有多个,必须保证索引有效,也就是字段都建立单个索引或有复合索引
- 返回多条数据时,如果行锁有效,则会为每条数据生成一个行锁,而不是生成一个整体锁,且是排它锁
1 | BEGIN; |
通过版本号或时间戳实现乐观锁
乐观锁的核心思想是「先操作后验证」
乐观锁并不会在数据读取时立即进行锁定,而是在数据更新时检查数据是否被其他事务修改过,以此决定是否进行更新。这种方式降低了锁的开销,提高了系统的并发性能,尤其适合读多写少的场景
乐观锁常见的实现方法之一是通过版本控制(Versioning)或时间戳(Timestamp)字段
- 版本号:在表中添加一个额外的列,通常是整数类型,作为版本号。当数据首次被创建时,版本号初始化为1。每次数据更新时,除了更新实际的数据字段,还会将版本号加1。在执行更新操作前,会先检查当前版本号是否与读取时的版本号相匹配,如果匹配则更新成功并递增版本号;如果不匹配,说明数据已被其他事务修改,更新操作失败。
- 时间戳:与版本号类似,但使用时间戳作为比较依据。更新时检查当前记录的时间戳是否与读取时相同,不同则拒绝更新
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21# 查询
SELECT id, title, content, version FROM posts WHERE id = 123;
# 其它操作xxxxxx
# 更新
BEGIN;
-- 再次查询文章以获取最新版本号
SELECT * FROM posts WHERE id = 123 FOR UPDATE;
-- 在应用层,比较版本号
IF (客户端缓存的版本号 == 查询到的版本号) {
-- 执行更新
UPDATE posts SET title = '新标题', content = '新内容', version = version + 1 WHERE id = 123 AND version = 客户端缓存的版本号;
} ELSE {
-- 版本号不匹配,抛出异常或重试逻辑
ROLLBACK;
-- 可能的处理逻辑:提示用户数据已更新,请刷新页面重试
}
COMMIT;
MySQL主从复制
MySQL主从复制是一种数据库管理技术,用于将数据从一个MySQL服务器(称为主服务器或Master)自动复制到一个或多个其他MySQL服务器(称为从服务器或Slave),以实现数据冗余、提高可用性和读取性能。这一过程涉及以下4个关键组件和步骤:
- 二进制日志(Binary Log): 主服务器开启二进制日志记录功能,它会记录所有表和数据的增删改操作(如INSERT、UPDATE、DELETE、truncate table 以及CREATE TABLE、ALTER TABLE、drop table等,SELECT 操作默认不会记录到日志中)
- Log Dump线程: 当从服务器连接到主服务器请求数据时,主服务器会启动一个Log Dump线程,该线程负责读取二进制日志并将数据发送到从服务器的I/O线程
- I/O线程: 在每个从服务器上,有一个I/O线程负责与主服务器通信,读取主服务器的二进制日志,并将这些日志事件写入到从服务器上的中继日志(Relay Log)
- SQL线程: 从服务器上的另一个线程——SQL线程,读取中继日志并执行其中的SQL语句,以使得从服务器的数据与主服务器保持一致
复制模式
- 异步复制: 主服务器在执行完事务后立即返回给客户端,不等待从服务器完成复制。这是MySQL默认的复制模式,效率较高,但存在数据不一致的风险。例如提交事务后,马上通过从服务器查询数据,有可能查询不到最新数据,因为数据还没有完成复制
- 半同步复制: 主服务器在至少一个从服务器确认接收到日志后(接收到binlog数据,并将其写入到relay log中)才提交事务。这种方式平衡了数据安全和性能,减少了数据丢失的风险,但在网络延迟高或从服务器故障时会影响主服务器的响应速度
- 全同步复制: 主服务器在所有从服务器确认接收到日志并完成复制后才提交事务。这种模式数据安全性最高,但性能较差,特别是在从服务器数量多或网络延迟高的情况下
读写分离
主从复制常用于实现读写分离,即写操作(增删改)集中在主服务器上,而读操作(查询)分散到从服务器上,以此减轻主服务器的压力,提高系统的读取能力和可用性
配置与管理
配置MySQL主从复制涉及在主服务器上设置二进制日志,在从服务器上配置连接主服务器的信息,以及启动复制进程。管理方面,需定期检查复制状态,监控复制延迟,并对复制链路故障进行及时处理
MySQL读写分离方案
搭建一主多从的MySQL集群
- 原理:设置一个主数据库(Master)处理所有写操作,并通过MySQL的复制功能同步数据到多个从数据库(Slave)。从数据库用于处理读请求
- 优点:简单易行,能有效分担主数据库的读取压力,提高系统整体的读取性能
- 缺点:配置和维护相对复杂,数据同步存在延迟,可能导致数据不一致
方案一、dynamic datasource 使用动态数据源框架
- 支持 数据源分组 ,适用于多种场景 纯粹多库 读写分离 一主多从 混合模式。
- 支持数据库敏感配置信息 加密(可自定义) ENC()。
- 支持每个数据库独立初始化表结构schema和数据库database。
- 支持无数据源启动,支持懒加载数据源(需要的时候再创建连接)。
- 支持 自定义注解 ,需继承DS(3.2.0+)。
- 提供并简化对Druid,HikariCp,BeeCp,Dbcp2的快速集成。
- 提供对Mybatis-Plus,Quartz,ShardingJdbc,P6sy,Jndi等组件的集成方案。
- 提供 自定义数据源来源 方案(如全从数据库加载)。
- 提供项目启动后 动态增加移除数据源 方案。
- 提供Mybatis环境下的 纯读写分离 方案。
- 提供使用 spel动态参数 解析数据源方案。内置spel,session,header,支持自定义。
- 支持 多层数据源嵌套切换 。(ServiceA >>> ServiceB >>> ServiceC)。
- 提供 基于seata的分布式事务方案 。
- 提供 本地多数据源事务方案。
方案二、基于中间件的方案
- 工具:ProxySQL、MaxScale等。
- 原理:作为数据库的前端代理,根据SQL语句类型自动路由到主库或从库。
- 优点:高性能,高可用,易于管理和监控。
- 缺点:引入了额外的组件,增加了系统的复杂度
MySQL主从延迟问题
优化网络配置:减少主从服务器之间的网络延迟,例如使用高速网络连接,优化网络设备配置,或尽量将主从服务器部署在同一局域网内
使用半同步复制:相较于默认的异步复制,半同步复制能够在一定程度上保证数据的即时同步,即在事务提交前至少有一个从节点接收并确认了日志。这可以通过设置
rpl_semi_sync_master_enabled
和rpl_semi_sync_slave_enabled
参数来启用增强从库处理能力:提升从库的硬件配置(如CPU、内存、存储I/O),并优化MySQL配置参数(如增加
innodb_buffer_pool_size
)以提高SQL执行效率多线程复制:MySQL 5.6及以上版本支持多线程复制,可以并行复制不同数据库或表,显著加快从库应用日志的速度。需要配置
slave_parallel_workers
参数减少主库写入压力:优化SQL语句,避免长事务,合理设计索引,使用批量插入等,减少主库的负载,从而减少产生日志的数量和频率
分库分表:对大型数据库进行水平拆分或垂直拆分,可以减轻单一数据库的压力,间接降低复制延迟
选择性复制:只在从库上复制必要的数据,比如只复制部分数据库或表,减少复制的数据量
监控和警报:定期监控复制延迟情况,并设置延迟警报,以便及时发现并解决问题
使用专门的中间件或代理:如ProxySQL等,它们可以根据策略自动路由读写请求,还可以帮助管理复制延迟问题
定期维护:包括清理无用的日志、索引优化、定期重启服务等,保持数据库的健康状态
Redis
什么是Redis,为什么用Redis
Redis是一个高性能键值对存储系统。全称为Remote Dictionary Server。可用于数据库、缓存,消息队列用于发布或订阅的场景、分布式锁、分布式 ID等场景。支持网络,基于内存,可持久化
- 高性能:
- Redis将数据存储在内存中,这使得读写操作极其快速
- Redis能读的速度是110000次/s,写的速度是81000次/s (测试条件见下一节)
- 单线程模型:Redis的主线程负责处理客户端的网络请求、执行命令以及响应结果。这意味着所有命令的执行在一个线程中串行进行,避免了多线程环境下的加锁开销和上下文切换成本
- I/O多路复用:Redis利用I/O多路复用技术监听和管理多个客户端连接。当有客户端发起请求或者数据准备好被读取时,I/O多路复用器(如epoll)会通知Redis主线程。这意味着Redis主线程可以同时监控成千上万个连接,而不需要为每个连接分配一个单独的线程
- 事件驱动:当I/O多路复用器检测 IO 状态(如可读、可写)时,它会将这些事件放入一个事件队列中。Redis主线程会从这个队列中取出事件并逐一处理,即按顺序执行相关的读写操作。这种方式使得Redis能够在一个线程中并发处理多个客户端的I/O请求,虽然处理本身是串行的,但因为等待I/O的时间被有效利用,整体效率非常高
- 非阻塞操作:Redis在处理网络I/O时采用非阻塞模式,这意味着即使在数据未准备好读取或缓冲区没有空间写入时,也不会阻塞主线程。配合I/O多路复用,主线程总能快速地检测并响应就绪的连接,保持高效运行
- 原子性:Redis的所有操作都是原子性的,同时Redis还支持对几个操作全并后的原子性执行(MULTI和EXEC)**
- 持久化:Redis支持RDB, AOF等持久化方式
- 基于消息队列的发布订阅:Redis的发布/订阅功能使其可以作为一个轻量级的消息代理系统,用于实现消息队列或发布/订阅模式的应用
- 主从复制:Redis支持数据复制,可以配置主服务器和多个从服务器,实现数据备份和故障转移,增强数据的可靠性和系统的可用性
- 哨兵系统&故障切换:Redis Sentinel(哨兵)系统用于监控主服务器状态并在主服务器故障时自动进行故障转移
- 分布式/集群:Redis Cluster 提供了自动分割数据到多个节点的能力,实现数据分布式存储,进一步提高了可用性和可扩展性
支持哪些数据类型
- String、List、Set、Zset、Hash
- 三种特殊的数据类型 分别是 HyperLogLogs(基数统计), Bitmaps (位图) 和 geo(地理位置)
- HyperLogLog(超日志对数): 用于估算集合的唯一元素数量,即使集合元素分布在多台服务器上也能高效地进行统计
- BitMap(位图): 用于将字符串看作比特数组,可以进行位级别的操作,适合存储和计算布尔值序列
- GEO(地理空间): 用于存储地理位置信息,并提供地理位置相关的查询操作,如查找附近的位置、计算两点距离等
String(字符串):尽管名为字符串,但它非常灵活,不仅可以存储字符串数据,也可以用来存储整数、浮点数、二进制数据(如图片、序列化对象)。你可以直接设置和获取整数值,Redis 提供了自增 (INCR
)、自减 (DECR
) 等命令专门用于整数值的操作。
1 | SET myInt 100 # 存储整数值 |
Hash(哈希):在哈希中,你可以将整数值作为哈希表中的字段值存储。虽然主要用于关联数组(field-value pairs),但字段值完全可以是整数。
1 | HSET myHash key1 100 # 在哈希表 myHash 中存储整数值 |
List(列表) 和 Set(集合):这两种数据结构主要用于存储多个值,无论是字符串还是整数。你可以直接将整数值添加到列表或集合中。
1 | LPUSH myList 100 # 将整数添加到列表头部 |
Sorted Set(有序集合):有序集合不仅存储元素,还为每个元素关联一个分数(可以是整数),用于排序。这非常适合需要根据整数值排序的场景。
1 | ZADD myZSet 100 member1 # 添加成员并关联一个分数(此处为整数) |
Redis默认最大内存?
没有限制最大内存。Redis 会持续使用可用系统内存来存储数据,直到耗尽系统资源。
RDB和AOF是什么
RDB和AOF是Redis数据库提供的两种数据持久化策略,用于确保Redis中的数据不会因为各种原因(如服务器崩溃、重启等)丢失
RDB(Redis Database)- 全量备份(定期、手动)
RDB是Redis的一种快照(snapshot)持久化方式。它会定期将某个时间点的内存数据集生成一份数据快照,保存为一个二进制文件(默认命名为dump.rdb)。这个过程可以通过执行SAVE
命令手动触发,或者通过配置让Redis周期性自动执行BGSAVE
命令来异步执行快照保存。当Redis服务器重启时,可以通过加载这个RDB文件来恢复之前的数据状态。RDB的优点包括:
- 快速备份和恢复:RDB文件是经过压缩的,占用空间较小,备份和恢复速度快。
- 数据一致性相对较高:如果RDB是在Redis无写操作时生成的,那么恢复后的数据状态就是一致的。
- 适合做灾难恢复和数据备份。
AOF(Append Only File) - 增量备份(always、everysec和no)
AOF则是另一种持久化策略,它会将Redis执行的每一个写操作命令追加到一个文件(默认为appendonly.aof)中。当Redis重启时,可以通过回放这个文件中的命令来重构整个数据库的内容。AOF有多种工作模式,包括always、everysec和no,通过配置决定是每次写操作后立即同步、每秒同步一次还是完全依赖操作系统来决定同步时机。AOF的优点包括:
- 更强的数据持久性:由于记录了每一次写操作,数据丢失的风险更低,最多只会丢失一秒的数据。
- 可以通过重写机制优化AOF文件,减少其体积。
- 支持不同的fsync策略,灵活平衡数据安全和性能。
混合持久化 - 全量+增量
在较新的Redis版本中,还有一种结合了RDB和AOF优点的持久化策略,称为混合持久化。在这种模式下,Redis会在执行AOF重写之前,先进行一次RDB快照生成,然后在AOF文件中仅记录从RDB快照以来的增量修改。这样既利用了RDB快照快速恢复的优势,又保留了AOF的高数据完整性,同时避免了AOF文件过大的问题
三种 AOF 写回策略
Always
,同步写回:每个写命令执行完,立马同步地将日志写回磁盘
Everysec
,每秒写回:每个写命令执行完,只是先把日志写到AOF文件的内存缓冲区,每隔一秒把缓冲区中的内容写入磁盘
No
,操作系统控制的写回:每个写命令执行完,只是先把日志写到AOF文件的内存缓冲区,由操作系统决定何时将缓冲区内容写回磁盘
有哪些使用场景
热点数据的缓存
Redis读写性能优异。而且,Redis内部是支持事务的,在使用时候能有效保证数据的一致性
限时业务
redis中可以使用expire命令设置一个键的生存时间,到时间后redis会失效。利用这一特性可以运用在限时的 登录验证码、token、短信验证码等业务场景
计数器相关
redis由于incrby命令可以实现原子性的递增,所以可以运用于高并发的秒杀活动、分布式ID的生成、接口限流控制
分布式锁
基于 redission 实现分布式锁,在分布式锁的场景中,主要用在比如秒杀系统等
分布式全局 ID
事件发布/订阅
消息队列,异步处理
字符串类型的值最大容量是多少
字符串类型的值最大能存储512MB。
字符串类型包括:字符串、整数、浮点数、二进制
AOF是写前日志还是写后日志?
AOF日志采用写后日志,即先写内存,后写日志
为什么采用写后日志?
Redis要求高性能,采用写日志有两方面好处:
- 避免额外的检查开销:Redis 在向 AOF 里面记录日志的时候,并不会先去对这些命令进行语法检查。所以,如果先记日志再执行命令的话,日志中就有可能记录了错误的命令,Redis 在使用日志恢复数据时,就可能会出错。
- 不会阻塞当前的写操作
但这种方式存在潜在风险:
- 如果命令执行完成,写日志之前宕机了,会丢失数据。
- 主线程写磁盘压力大,导致写盘慢,阻塞后续操作。
Redis 内存淘汰算法有哪些
Redis 提供了几种内存淘汰(eviction)策略,当Redis内存使用达到预设的最大限制时,这些策略会决定哪些键值对应该被移除以释放内存空间。以下是Redis支持的主要内存淘汰算法:
noeviction
: 不进行删除操作,只是返回错误(OOM)当内存限制达到时【不淘汰】volatile-lru
: 从设置了过期时间的键中,选择最近最少使用的数据进行删除【有过期时间的最近最少使用,淘汰】allkeys-lru
: 从所有键中选择最近最少使用的数据进行删除【所有中最近最少使用的,淘汰】volatile-ttl
: 删除那些最近将要过期的键(具有更早过期时间的键)【快过期的,淘汰】volatile-random
: 在设置了过期时间的键中随机选择数据进行删除【有过期时间的,随机淘汰】allkeys-random
: 从所有键中随机选择数据进行删除【所有中,随机淘汰】
选择哪种淘汰策略取决于应用场景的具体需求,例如,如果你希望保留活跃数据而移除不常访问的数据,可以选择LRU或LFU策略;如果你的数据中有许多键具有过期时间,可能更适合使用带有volatile
前缀的策略。正确的配置可以帮助优化内存使用,确保Redis的高效运行。
Redis的内存用完了会发生什么?
- 写操作失败:Redis无法再接受新的写入操作,包括设置、更新或删除键值对。任何尝试写入数据的操作都将失败,并返回错误消息。这是因为Redis已达到其配置的最大内存限制。
- 读操作可能继续:尽管内存已满,读取操作(如GET、HGET等)通常仍可进行。这是因为Redis可以从磁盘上的持久化文件中加载数据,尽管这样做可能会影响性能,因为频繁的磁盘I/O操作比内存访问慢得多。
- 内存淘汰策略执行:Redis提供了多种内存淘汰策略(如volatile-lru、allkeys-lru、volatile-random、allkeys-random、volatile-ttl、noeviction等),当内存用尽时,根据配置的淘汰策略,Redis会尝试移除一些现有数据以腾出空间给新的数据使用。不同的淘汰策略有不同的数据移除逻辑,比如基于过期时间、最近最少使用(LRU)、随机等。
- 性能下降:内存用尽后,Redis可能需要频繁执行内存淘汰、数据持久化到磁盘等操作,这会显著增加系统负载,降低Redis的响应速度和服务性能
- 潜在的数据丢失风险:在执行某些淘汰策略时,可能会导致一些数据被删除,尤其是在使用随机淘汰策略或者数据已过期的情况下。因此,对于重要的且未持久化的数据,可能会面临丢失的风险。
为了避免Redis内存用完带来的问题,通常建议:
- 合理配置Redis的最大内存限制(
maxmemory
),根据实际应用需求和硬件资源设定 - 选择合适的内存淘汰策略,以平衡数据的缓存效率和重要性
- 定期持久化数据,确保即使Redis重启或遇到问题,数据也能够恢复
- 监控Redis的内存使用情况,及时调整策略或扩容硬件资源
Redis如何做内存优化
缩减键值对象
缩减键(key)和值(value)的长度
key长度:如在设计键时,在完整描述业务情况下,键值越短越好
value长度:值对象缩减比较复杂,常见需求是把业务对象序列化成二进制数组放入Redis。首先应该在业务上精简业务对象,去掉不必要的属性避免存储无效数据。其次在序列化工具选择上,应该选择更高效的序列化工具来降低字节数组大小。以JAVA为例,内置的序列化方式无论从速度还是压缩比都不尽如人意,这时可以选择更高效的序列化工具,如: protobuf,kryo等
配置优化
- 设定合理的
maxmemory
限制,根据实际硬件资源和应用需求配置Redis的最大可用内存。 - 选择合适的内存淘汰策略,如LRU或LFU,以高效利用内存空间。
- 开启RDB和AOF的合理持久化策略,减少内存负担,同时保证数据安全。
数据结构与编码优化
- 选择最合适的Redis数据结构(如Strings、Hashes、Lists等),不同的数据结构有不同的内存占用和性能特点。
- 利用Redis的内部编码优化,如使用ziplist(压缩列表)代替hashtable存储小的hashes和lists,以节省内存。
命令处理与数据存储优化
- 减少不必要的数据副本,例如,通过哈希(Hashes)存储相关数据而不是单独的字符串(Strings)。
- 定期审查和清理不再需要的键值对,特别是过期数据。
- 使用批量操作减少网络往返,如使用
MGET
和MSET
代替多个独立的读写操作。
内存碎片整理
- 定期执行
MEMORY PURGE
命令(Redis 4.0及以上版本),帮助回收内存碎片,提高内存使用效率。 - 考虑使用Redis的内存分配器(如jemalloc)替换默认分配器,以减少内存碎片。
开启内存压缩
- Redis 6.0开始支持的LFU(Least Frequently Used)算法结合内存压缩,可以有效减少热点数据的内存占用。
- 对于大量短生命周期数据,考虑使用Redis Module如RedisBloom进行近似计算,减少实际数据存储量。
操作系统层面优化
- 在Linux系统上,调整
vm.overcommit_memory
设置,允许Redis在低内存情况下仍能成功执行fork操作,用于持久化和复制。 - 确保操作系统和Redis使用的内存分配器配置得当,如使用大页内存(huge pages)以减少内存管理开销。
硬件升级
- 使用高性能的内存条,增加内存容量,尤其是对于大数据集和高吞吐量的应用。
- 考虑使用固态硬盘(SSD)加速持久化操作,减少对内存的依赖。
过期键的删除策略有哪些
定时删除(Timed deletion)
在为键设置过期时间的同时,Redis会创建一个定时器(timer),当键的过期时间到达时,立即由定时器自动删除该键。这种方式可以确保过期键被尽快删除,但缺点是对CPU资源消耗较大,尤其是当有很多键设置有过期时间时,可能会引发大量的定时事件处理,影响服务器性能
惰性删除(Lazy deletion)
只有当尝试访问一个键时,Redis才会检查该键是否已过期。如果过期,则在处理这个请求之前删除该键。这种策略减少了CPU的使用,因为它只在实际访问键时才检查和删除。但是,如果大量过期键未被访问,它们会一直占用内存空间
定期删除(Periodic deletion)
Redis同时采用了惰性删除和定时删除的混合策略。除了惰性删除外,Redis还维持一个后台进程,每隔一段时间(默认是每秒)执行一次,随机检查并删除一部分已过期的键。这样可以在不影响服务器主要操作的前提下,逐步释放内存中过期的键所占用的空间。定期删除的频率和每次检查的键的数量可以在配置中调整,以达到内存使用和CPU负载之间的平衡
Redis 客户端有哪些?
Redisson、Jedis、lettuce等等,官方推荐使用Redisson
Redisson
Redisson 是一个基于 Redis 的 Java 客户端,它提供了许多分布式服务,包括分布式锁。Redisson 的分布式锁实现原理主要依赖于 Redis 的几个特性,特别是 Lua 脚本、事务以及键的过期机制。
Redis6.0之前的版本真的是单线程的吗?
Redis在处理客户端请求时,包括获取(socket读)、解析、执行、内容返回(socket写)等都是由一个顺序串行的主线程执行的,这就是所谓的 单线程.单如果严格讲,从Redis4.0之后并不是单线程,除了主线程之外,它也有后台线程在处理一些较为缓慢的操作,例如 清理脏数据, 无用链接的释放, 大key的删除, 数据持久化bgsave,bgrewriteaof等,都是在主线程之外的子线程单独执行的
开启多线程后,是否会存在线程并发安全问题?
Redis的多线程部分只是用来处理网络数据的读取和协议解析,键值对的读写仍然是单线程顺序执行,因此不存在线程的并发安全问题
缓存雪崩和击穿
- 缓存雪崩指的是由于不当的缓存过期策略或缓存服务故障,导致大量缓存数据同时失效或无法访问,这使得大量请求直接涌向后端数据库,引起系统性能急剧下降,甚至服务不可用。它影响范围广泛,可能导致整个系统崩溃
- 缓存击穿则是针对单个热点数据而言,当这个被频繁访问的数据在缓存中失效时,恰好有大量请求到来,所有这些请求因为无法在缓存中找到数据而直接访问数据库,给数据库带来巨大压力。与雪崩不同,击穿的影响范围相对有限,主要是针对特定的热点数据
MongoDB
什么是MongoDB
- MongoDB是面向文档的NoSQL数据库(非关系型数据库),支持分布式存储
- 由C++语言编写的,以其灵活的数据模型和强大的查询功能而著称
- 常用于内容管理、实时分析、物联网(IoT)数据存储,尤其适合处理非结构化的数据
MongoDB的特点
数据以文档的形式存储,类似于JSON对象,这种称为BSON(Binary JSON)的二进制格式允许存储丰富的、嵌套的数据结构
与传统的关系数据库不同,MongoDB不需要预先定义数据表结构。字段可以根据需要动态扩展,这使得它非常适合快速迭代和敏捷开发。但实际开发中,一个集合最好数据结构是固定或者变化少的,否则对搜索有影响
提供了丰富的查询API,包括聚合管道、地理空间查询等,支持复杂的查询操作,接近于关系数据库的查询能力
可以创建索引以提高MongoDB中的搜索性能。MongoDB文档中的任何字段都可以建立索引
支持主从复制,用来做数据备份和故障切换
支持分片技术,用来实现数据分布式存储
MongoDB与RDBMS区别
SQL术语/概念 | MongoDB术语/概念 | 解释/说明 |
---|---|---|
database | database | 数据库 |
table | collection | 数据库表/集合 |
row | document | 数据记录行/文档 |
column | field | 数据字段/域 |
index | index | 索引 |
table joins | 表连接,MongoDB不支持 | |
primary key | primary key | 主键,MongoDB自动将_id字段设置为主键 |
ElasticSearch
ElasticSearch是什么
- Java 编写的,核心是一个基于Lucene的全文搜索引擎
- 广泛应用于日志分析、应用搜索、安全分析、商业智能、物联网数据分析
- 隐藏了 Lucene 的复杂性,取而代之的提供一套简单一致的 RESTful API,使得数据的索引、搜索、分析等操作变得简单直观
- Elasticsearch特别适合处理大规模数据集,无论是结构化数据(如数字、文本)还是非结构化数据(如全文本、地理空间数据)
- 提供近乎实时的搜索能力,这意味着数据一旦被索引就可以立即被搜索到,适用于需要快速响应的场景,如电子商务搜索、实时日志分析等
- 支持复杂的全文本搜索、复合查询、聚合操作、过滤、排序、分页等功能,以及高级特性如faceting(用于复杂的数据分类和统计)、percolator(持续查询机制)
- 通过数据复制机制确保数据的安全性,即使在部分节点失败的情况下也能保证服务的连续性和数据的完整性
- 通常与Logstash(数据收集与处理工具)、Kibana(数据可视化和分析平台)以及Beats(轻量级数据采集器)一起使用,形成完整的Elastic Stack(前称为ELK Stack),为数据的收集、处理、分析、可视化提供端到端的解决方案
和数据库的对比
ES中什么是复合查询?有哪些复合查询方式?
在查询中会有多种条件组合的查询,在ElasticSearch中叫复合查询。它提供了5种复合查询方式:
- bool query(布尔查询)
- 通过布尔逻辑将较小的查询组合成较大的查询
- boosting query(提高查询)
- 不同于bool查询,bool查询中只要一个子查询条件不匹配那么搜索的数据就不会出现。而boosting query则是降低显示的权重/优先级(即score)
- constant_score(固定分数查询)
- 查询某个条件时,固定的返回指定的score;显然当不需要计算score时,只需要filter条件即可,因为filter context忽略score。
- dis_max(最佳匹配查询)
- 分离最大化查询(Disjunction Max Query)指的是: 将任何与任一查询匹配的文档作为结果返回,但只将最佳匹配的评分作为查询的评分结果返回
- function_score(函数查询)
- 简而言之就是用自定义function的方式来计算_score
ES底层数据持久化的过程
write -> refresh -> flush -> merge
总体过程为:写入内存缓存区、记录事务日志、写入文件系统缓存、持久化到磁盘段文件、段合并
Elasticsearch(简称ES)的数据持久化过程涉及几个关键步骤,主要目的是确保数据的可靠存储和快速检索。下面是ES数据持久化的基本流程:
内存缓冲(In-memory Buffer): 当新的文档被索引到Elasticsearch时,它们首先会被写入内存中的缓冲区。这个缓冲区是用来暂存待处理的文档,以便快速写入和搜索。
事务日志(Translog): 同时,所有的写操作还会被记录到事务日志(Translog)中。Translog是一个预写日志,用于确保数据的持久性和故障恢复。如果Elasticsearch在数据还没有被刷新到磁盘时遇到故障,可以通过Translog恢复数据。
自动刷新(Auto-Refresh): 默认情况下,Elasticsearch每隔一秒(可通过
index.refresh_interval
配置)会执行一次自动刷新(Refresh)操作。刷新操作并不会立即将数据写入硬盘上的段(Segment),而是将内存缓冲区中的数据写入到文件系统缓存(FileSystem Cache)。这意味着数据已经可以被搜索到,但还未完成最终的持久化到磁盘。这个过程让ES能够在近乎实时的情况下提供搜索结果。Flush操作: Flush操作负责将文件系统缓存中的数据持久化到硬盘上的段文件中,同时清空内存缓冲区和截断Translog(或将其滚动到一个新的文件)。Flush操作发生的条件可以是定时的(默认大约每30分钟一次,可通过
index.translog.flush_threshold_size
等配置调整),或者是当Translog达到一定大小时。Flush操作比Refresh更耗时,因为它涉及到实际的磁盘I/O操作。段合并(Segment Merge): 随着时间推移,ES会创建许多小的段文件。为了保持高效的搜索性能,ES会定期执行段合并操作,将小的段合并成更大的段,同时在这个过程中删除重复的文档、优化索引结构。这个过程也是在后台自动进行的。
整个数据持久化过程设计精巧,平衡了写入速度、搜索效率和数据安全性。通过频繁的Refresh保证数据的近实时可搜索性,而Flush和Translog机制确保了数据的持久性,即使在系统崩溃时也能恢复数据。
ES遇到什么性能问题?
- 查询响应慢:查询请求延迟高,特别是在处理复杂查询或大量数据时。
- 索引速度下降:数据写入速率降低,尤其是在高并发写入场景下。
- 资源争抢:CPU、内存或磁盘I/O资源紧张,导致性能瓶颈。
- 集群稳定性:节点故障频繁,集群健康状况不佳。
- 内存溢出:JVM堆内存不足,引发GC压力大,影响响应时间和稳定性。
- 索引膨胀:由于未及时清理或优化,索引占用空间过大。
如何优化的?
硬件优化和调参
硬件升级:
CPU: 选择更多核数的更好。多个内核提供的额外并发远胜过稍微快一点点的时钟频率
内存
配置:提供足够的内存。 lucene 能够很好的利用操作系统内存来缓存索引数据,以提供快速的查询性能。一半的物理内存留给 lucene;另一半的物理内存留给 ES(JVM heap)。一般建议分配给Elasticsearch的JVM堆内存不超过机器总内存的一半,这样可以确保另一半内存可以被操作系统用来做文件系统缓存,进而优化Lucene的性能。此外,出于JVM性能考虑,通常建议堆内存不要超过32GB,因为当堆内存小于32GB时,JVM可以使用对象指针压缩技术,减少内存消耗并提高CPU效率。如果确实需要分配超过32GB的堆内存,请确保你的JVM版本支持并已启用此压缩技术的对应选项。
禁止 swap :一旦允许内存与磁盘的交换,会引起致命的性能问题。可以通过在 elasticsearch.yml 中 bootstrap.memory_lock: true,以保持 JVM 锁定内存,保证 ES 的性能
垃圾回收器: 如果使用的 JDK8+,推荐使用G1 GC
磁盘:使用SSD存储以提升I/O性能。现实中可采用冷热分离的思路,也就是将查询频率低的数据迁移到成本低的存储上
JVM调优:合理配置JVM堆大小,监控GC行为并适时调整相关参数,比如年轻代与老年代比例、GC策略等
网络优化:确保网络带宽足够,减少网络延迟。
索引设计优化
- 合理分片:根据数据量和查询需求,选择合适的分片数量。过多分片会增加管理和查询开销,过少则可能限制并行处理能力。
- 使用别名:更新索引时,通过别名避免查询中断,保证服务稳定性。
- 字段优化:仅索引需要查询的字段,减少不必要的索引,优化字段类型以匹配查询需求。
查询优化
- 优化查询语句:避免使用过于复杂的查询,合理使用filter与query上下文,减少返回字段量。
- 缓存利用:利用查询缓存,对频繁执行且结果不经常变化的查询进行缓存。
系统与监控
- 定期维护:定期执行索引优化操作,如合并小段、清理不再使用的索引。
- 监控与报警:使用Elasticsearch自带或第三方监控工具,监控集群健康状态、资源使用情况,及时发现并解决问题。
- 性能测试:定期进行性能基准测试,根据测试结果调整配置参数。
高级策略
- 索引生命周期管理:利用ILM策略自动管理索引的生命周期,包括索引的创建、 rollover、shrink、delete等,以优化存储和查询性能。
- 冷热分离:将活跃查询较少的旧数据迁移到低成本存储上,以降低成本同时不影响热数据的查询性能。