0104java面经
1,笔试:使用SQL语句查询总金额大于100的订单及其对应客户的姓名?
相关信息,包含 order_id
(订单编号)、customer_id
(客户编号)、total_amount
(总金额)等字段;另一张是 customers
表,存储客户信息,包含 customer_id
(客户编号)、customer_name
(客户姓名)等字段。
SELECT c.customer_name, o.order_id, o.total_amount
FROM orders o
-- 通过客户编号关联两张表
JOIN customers c ON o.customer_id = c.customer_id
WHERE o.total_amount > 100;
在上述代码中:
JOIN
关键字用于将orders
表和customers
表根据customer_id
进行关联,这样就能把订单信息和对应的客户信息结合起来。WHERE
子句用于筛选出总金额大于 100 的订单记录。- 最终查询选择出符合条件的客户姓名、订单编号以及总金额字段信息。
2,算法:如何在旋转排序数组中查找最小值?
以下是使用 Java 语言解决在旋转排序数组中查找最小值问题的代码示例,通常可以使用二分查找的思想来解决这个问题,示例代码如下:
public class FindMinimumInRotatedSortedArray {
public static int findMin(int[] nums) {
int left = 0;
int right = nums.length - 1;
while (left < right) {
int mid = left + (right - left) / 2;
// 如果中间元素大于最右边元素,说明最小值在mid的右边
if (nums[mid] > nums[right]) {
left = mid + 1;
} else {
// 否则最小值在mid或者mid的左边
right = mid;
}
}
return nums[left];
}
public static void main(String[] args) {
int[] nums = {4, 5, 6, 7, 0, 1, 2};
int min = findMin(nums);
System.out.println("数组中的最小值为: " + min);
}
}
代码解释如下:
整体思路
利用了旋转排序数组的特性以及二分查找的思想。旋转排序数组可以看成是由两个有序的子数组拼接而成,左边的子数组元素都大于等于右边子数组的元素,而最小值就是这两个子数组的分割点。
具体步骤
- 初始化左右指针
- 定义
left
指针指向数组的起始位置(索引为 0),right
指针指向数组的末尾位置(索引为nums.length - 1
)。
- 定义
- 循环进行二分查找
- 在
left < right
的条件下进行循环,每次计算中间元素的索引mid
,通过mid = left + (right - left) / 2
来计算,这样可以避免整数溢出的问题。 - 判断中间元素与最右边元素的大小关系
- 如果
nums[mid] > nums[right]
,这意味着中间元素处于左边的较大子数组中,那么最小值必然在mid
的右边,所以将left
更新为mid + 1
,继续在右半部分查找。 - 反之,如果
nums[mid] <= nums[right]
,说明中间元素处于右边的较小子数组或者就是最小值所在位置,所以将right
更新为mid
,继续在包含mid
的这部分区间查找。
- 如果
- 在
- 返回结果
- 当循环结束时(此时
left
和right
会指向同一个位置,也就是最小值所在的位置),返回nums[left]
即可得到旋转排序数组中的最小值。
- 当循环结束时(此时
这个算法的时间复杂度是 ,其中 n
是数组的长度,因为每次循环都能将查找区间缩小一半,空间复杂度是 ,因为只使用了常数个额外的变量。
3负数的二进制如何表示?
- 原码表示法 - 原码是一种简单的机器数表示法。对于一个整数(包括正整数和负整数),原码的最高位为符号位,正数的符号位为0,负数的符号位为1,其余位表示数值的绝对值。 - 例如,对于十进制数(+5),其8位二进制原码表示为(00000101);对于十进制数(-5),其8位二进制原码表示为(10000101)。
- 反码表示法 - 对于负数,反码是在原码的基础上,除符号位外,其余各位按位取反得到的。正数的反码和原码相同。 - 例如,(-5)的8位二进制原码是(10000101),那么它的反码是(11111010)。
- 补码表示法(计算机中最常用) - 对于负数,补码是在反码的基础上加1得到的。正数的补码和原码相同。 - 例如,(-5)的8位二进制原码是(10000101),反码是(11111010),那么它的补码是(11111010 + 1=11111011)。 - 计算机中一般采用补码来表示负数,主要是因为补码可以将减法运算转换为加法运算,简化了计算机的运算电路。例如,计算(5 + (-3)),在补码表示下,(5)的补码是(00000101),(-3)的补码是(11111101),它们相加((00000101)+(11111101) = 00000010)(最高位的进位舍去),结果为(2),符合实际运算结果。
4,常用的 Linux命令有哪些?
- 文件和目录操作命令
- ls
- 功能:用于列出目录的内容。它可以显示文件和子目录的名称、大小、修改时间等信息。
- 示例:
ls -l
:以长格式显示文件和目录的详细信息,包括权限、所有者、大小、修改时间等。例如,ls -l /home/user
会详细列出/home/user
目录下的文件和目录信息。ls -a
:显示所有文件和目录,包括隐藏文件(以 “.” 开头的文件)。例如,ls -a /etc
会列出/etc
目录下包括隐藏文件在内的所有文件。
- cd
- 功能:用于切换当前工作目录。
- 示例:
cd /home/user/Documents
:将当前目录切换到/home/user/Documents
目录。cd..
:切换到上一级目录。
- mkdir
- 功能:用于创建新的目录。
- 示例:
mkdir new_folder
:在当前目录下创建一个名为new_folder
的新目录。mkdir -p parent/child
:创建多级目录,如parent
目录下的child
目录,如果parent
目录不存在,也会一并创建。
- rmdir
- 功能:用于删除空目录。
- 示例:
rmdir empty_folder
:删除名为empty_folder
的空目录。
- rm
- 功能:用于删除文件或目录。使用时要特别小心,因为文件删除后很难恢复。
- 示例:
rm file.txt
:删除当前目录下名为file.txt
的文件。rm -r directory
:递归删除directory
目录及其所有内容。
- cp
- 功能:用于复制文件或目录。
- 示例:
cp file1.txt file2.txt
:将file1.txt
复制为file2.txt
,如果file2.txt
存在,则覆盖。cp -r directory1 directory2
:递归复制directory1
目录及其所有内容到directory2
目录。
- mv
- 功能:用于移动文件或目录,也可以用于重命名文件或目录。
- 示例:
mv file.txt new_folder/
:将file.txt
移动到new_folder
目录下。mv old_name.txt new_name.txt
:将old_name.txt
重命名为new_name.txt
。
- ls
- 文件查看和编辑命令
- cat
- 功能:用于查看文件的内容。它将文件内容一次性全部输出到屏幕上。
- 示例:
cat file.txt
:查看file.txt
文件的内容。cat file1.txt file2.txt > combined_file.txt
:将file1.txt
和file2.txt
的内容合并后输出到combined_file.txt
中。
- less
- 功能:用于分页查看文件内容,适合查看大文件。它可以通过键盘上的上下箭头、Page Up 和 Page Down 等键来浏览文件。
- 示例:
less big_file.txt
:分页查看big_file.txt
的内容。在查看过程中,可以按q
键退出。
- head
- 功能:用于查看文件的开头部分内容。默认情况下,它显示文件的前 10 行。
- 示例:
head file.txt
:查看file.txt
的前 10 行内容。head -n 5 file.txt
:查看file.txt
的前 5 行内容。
- tail
- 功能:用于查看文件的末尾部分内容。默认情况下,它显示文件的后 10 行。
- 示例:
tail file.txt
:查看file.txt
的后 10 行内容。tail -n 3 file.txt
:查看file.txt
的后 3 行内容。
- vi 和 vim
- 功能:功能强大的文本编辑器。它们有命令模式、插入模式和底行模式等多种模式。
- 示例:
- 打开一个文件:
vi file.txt
。在命令模式下,可以使用i
键进入插入模式,开始编辑文件内容;编辑完成后,按Esc
键回到命令模式,然后使用:wq
保存并退出,或者:q!
不保存退出。
- 打开一个文件:
- cat
- 系统管理命令
- ps
- 功能:用于查看当前系统中的进程信息。
- 示例:
ps -ef
:显示所有进程的详细信息,包括 UID(用户 ID)、PID(进程 ID)、PPID(父进程 ID)、C(CPU 使用率)、STIME(启动时间)等。ps -aux
:以 BSD 风格显示进程信息,和ps -ef
类似,但格式略有不同。
- kill
- 功能:用于终止进程。通常需要指定要终止进程的 PID。
- 示例:
kill PID
:终止指定 PID 的进程。例如,如果一个进程的 PID 是 1234,kill 1234
就可以尝试终止这个进程。kill -9 PID
:强制终止指定 PID 的进程,-9
是信号值,表示 SIGKILL 信号,这种方式比较强硬,可能会导致数据丢失等问题。
- top
- 功能:用于实时查看系统的性能信息,如 CPU 使用率、内存使用率、进程状态等。
- 示例:
- 在终端输入
top
后,会显示一个动态的系统性能监控界面。可以按q
键退出。
- 在终端输入
- df
- 功能:用于查看文件系统的磁盘空间使用情况。
- 示例:
df -h
:以人类可读的格式(如 KB、MB、GB 等)显示磁盘空间使用情况。例如,会显示每个文件系统的总大小、已用空间、可用空间等信息。
- du
- 功能:用于查看目录或文件的磁盘使用空间。
- 示例:
du -sh directory
:以总结的方式(-s
)和人类可读的格式(-h
)显示directory
目录的磁盘使用空间。
- ps
- 网络命令
- ping
- 功能:用于测试网络连接是否通畅,它通过向目标主机发送 ICMP(Internet Control Message Protocol)数据包并等待回应来检查网络可达性。
- 示例:
ping google.com
:向google.com
发送 ICMP 数据包,查看是否能够收到回应,从而判断与该主机的网络连接情况。
- ifconfig(在某些 Linux 系统中被 ip addr 命令替代)
- 功能:用于查看和配置网络接口的信息,如 IP 地址、子网掩码、MAC 地址等。
- 示例:
ifconfig eth0
:查看eth0
网络接口的详细信息。ip addr show
:类似于ifconfig
,可以显示网络接口的 IP 地址等信息。
- netstat
- 功能:用于查看网络连接状态、路由表、网络接口统计信息等。
- 示例:
netstat -an
:显示所有的网络连接(-a
)和对应的端口号(-n
),包括 TCP 和 UDP 协议的连接情况。netstat -r
:显示内核路由表信息,了解网络数据包的转发路径。
- ping
5,HashMap 底层是如何实现的?
-
数据结构基础
-
数组:HashMap 底层是基于数组实现的,这个数组在 Java 中是
Node<K,V>[]
类型,其中K
是键的类型,V
是值的类型。数组的每个元素(节点)存储了键值对。例如,transient Node<K,V>[] table;
就是 HashMap 中的数组定义。 -
链表(用于解决哈希冲突)
:当不同的键通过哈希函数计算得到相同的数组索引时,就会发生哈希冲突。为了解决这个问题,HashMap 在数组的每个位置采用了链表结构。每个节点
Node<K,V>
包含了键
key
、值
value
、下一个节点引用
next
和哈希值
hash
,其定义如下:
static class Node<K,V> implements Map.Entry<K,V> { final int hash; final K key; V value; Node<K,V> next; // 构造函数等方法省略 }
-
红黑树(优化链表过长的情况):从 Java 8 开始,当链表的长度达到一定阈值(默认为 8)时,链表会转换为红黑树。红黑树是一种自平衡二叉查找树,它可以在 的时间复杂度内完成查找、插入和删除操作,相比链表(查找操作时间复杂度为 )性能有很大提升。
-
-
哈希函数和索引计算
- 哈希函数:
hashCode()
方法是 Object 类中的一个方法,在 HashMap 中,会先调用键对象的hashCode()
方法来获取哈希值。但是hashCode()
方法返回的哈希值范围可能比较大,并且可能存在哈希冲突的情况比较严重。所以 HashMap 会对hashCode()
得到的哈希值进行进一步的处理。例如,在 Java 8 中,通过(h = key.hashCode()) ^ (h >>> 16)
这样的位运算来得到一个更合适的哈希值h
,这个过程可以让哈希值的高位和低位都参与到索引计算中,减少哈希冲突的可能性。 - 索引计算:得到哈希值后,通过
(n - 1) & hash
来计算键值对在数组中的索引,其中n
是数组的长度。这种计算方式相当于对哈希值进行取模运算(当n
为 2 的幂次方时,(n - 1) & hash
和hash % n
等价),可以将哈希值均匀地分布到数组的各个索引位置。
- 哈希函数:
-
存储过程
- 当要向 HashMap 中插入一个键值对时,首先根据键计算出哈希值和在数组中的索引。如果该索引位置为空,直接将新的键值对节点放入该位置。如果该索引位置已经有节点(即发生哈希冲突),会先判断是链表还是红黑树。如果是链表,会遍历链表,比较键是否相等。如果键相等,则更新值;如果键不相等,则将新节点插入到链表的末尾。如果是红黑树,就按照红黑树的插入规则插入新节点。
-
获取过程
- 当要获取一个键对应的 value 时,首先根据键计算哈希值和在数组中的索引。然后在该索引位置查找。如果是链表,会遍历链表,比较键是否相等,找到相等的键就返回对应的 value。如果是红黑树,就按照红黑树的查找规则找到对应的键并返回 value。
-
扩容机制
- HashMap 有一个负载因子(默认为 0.75),当 HashMap 中的元素数量超过数组长度乘以负载因子时,就会触发扩容。扩容时,会创建一个新的更大的数组(新数组长度一般是原来的 2 倍),然后将旧数组中的所有键值对重新计算哈希值和索引,并放入新数组中。这个过程比较消耗性能,所以在使用 HashMap 时,如果能预估元素的数量,提前设置合适的初始容量可以减少扩容的次数。
6,JVM的内存模型是怎样的?
- 程序计数器(Program Counter Register)
- 功能
- 程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
- 特点
- 它是线程私有的,每个线程都有一个独立的程序计数器,这样才能保证各个线程在执行自己的代码时不会相互干扰。另外,此区域是唯一一个在 Java 虚拟机规范中没有规定任何
OutOfMemoryError
情况的区域。
- 它是线程私有的,每个线程都有一个独立的程序计数器,这样才能保证各个线程在执行自己的代码时不会相互干扰。另外,此区域是唯一一个在 Java 虚拟机规范中没有规定任何
- 功能
- Java 虚拟机栈(Java Virtual Machine Stacks)
- 功能
- 虚拟机栈描述的是 Java 方法执行的内存模型。每个方法在执行时都会创建一个栈帧(Stack Frame),用于存储局部变量表、操作数栈、动态连接、方法出口等信息。方法的调用和返回对应着栈帧在虚拟机栈中的入栈和出栈操作。
- 局部变量表
- 局部变量表用于存放方法参数和方法内部定义的局部变量。这些变量的类型可以是基本数据类型(如
int
、long
等),也可以是对象引用(reference
)。对于基本数据类型,变量的值直接存储在局部变量表中;对于对象引用,存储的是对象在堆中的首地址。
- 局部变量表用于存放方法参数和方法内部定义的局部变量。这些变量的类型可以是基本数据类型(如
- 操作数栈
- 操作数栈主要用于存储方法执行过程中的操作数和中间结果。在字节码指令执行时,会从局部变量表或者其他栈帧中将操作数压入操作数栈,然后进行运算,运算结果也会存储在操作数栈中。
- 特点
- 它也是线程私有的,生命周期与线程相同。栈的大小可以是固定的,也可以是动态扩展的。如果线程请求的栈深度大于虚拟机所允许的深度,就会抛出
StackOverflowError
;如果虚拟机栈可以动态扩展,但是在扩展时无法申请到足够的内存,就会抛出OutOfMemoryError
。
- 它也是线程私有的,生命周期与线程相同。栈的大小可以是固定的,也可以是动态扩展的。如果线程请求的栈深度大于虚拟机所允许的深度,就会抛出
- 功能
- 本地方法栈(Native Method Stacks)
- 功能
- 本地方法栈与 Java 虚拟机栈类似,它们之间的主要区别是虚拟机栈为 Java 方法服务,而本地方法栈为本地方法(Native Method)服务。本地方法是指用非 Java 语言(如 C、C++)编写的方法,这些方法通过 Java 本地接口(JNI)调用,在本地方法栈中执行。
- 特点
- 本地方法栈同样是线程私有的,也会出现
StackOverflowError
和OutOfMemoryError
这两种异常情况。
- 本地方法栈同样是线程私有的,也会出现
- 功能
- 堆(Heap)
- 功能
- 堆是 Java 虚拟机所管理的内存中最大的一块,它是被所有线程共享的一块内存区域,在堆中主要存放对象实例和数组。几乎所有的对象实例和数组都是在堆中分配内存的,这使得堆成为垃圾收集器管理的主要区域。
- 分代收集
- 为了更好地进行垃圾回收,堆通常会被划分为不同的代(Generation),如新生代(Young Generation)和老年代(Old Generation)。新生代又可以细分为
Eden
空间、From Survivor
空间和To Survivor
空间。对象首先在新生代的Eden
空间中分配内存,经过多次垃圾回收后,存活时间较长的对象会被移动到老年代。
- 为了更好地进行垃圾回收,堆通常会被划分为不同的代(Generation),如新生代(Young Generation)和老年代(Old Generation)。新生代又可以细分为
- 特点
- 堆是线程共享的,它的大小可以通过参数(如
-Xmx
和-Xms
)进行设置。由于堆中存放大量的对象,并且垃圾回收器会频繁地对堆进行操作,所以堆是最容易出现OutOfMemoryError
的区域,例如,当创建的对象过多,堆内存无法满足需求时,就会抛出OutOfMemoryError: Java heap space
。
- 堆是线程共享的,它的大小可以通过参数(如
- 功能
- 方法区(Method Area)
- 功能
- 方法区是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息(包括类的版本、字段、方法、接口等)、常量、静态变量、即时编译器编译后的代码等。
- 运行时常量池(Runtime Constant Pool)
- 运行时常量池是方法区的一部分,它是 Class 文件中常量池表(Constant Pool Table)的运行时表示形式。主要存放编译期生成的各种字面量(如文本字符串、被声明为 final 的常量值等)和符号引用(如类和接口的全限定名、字段的名称和描述符、方法的名称和描述符等)。
- 特点
- 方法区同样可能出现
OutOfMemoryError
,例如,当加载的类过多,或者运行时常量池中的常量过多等情况,都可能导致方法区内存不足。从 Java 8 开始,方法区的实现方式发生了变化,使用元数据区(Metaspace)来代替永久代(Permanent Generation),元数据区使用本地内存,而不是虚拟机内存,这使得方法区的内存管理更加灵活。
- 方法区同样可能出现
- 功能
7,那些区域可能会 OOM
-
Java 堆(Heap)
-
原因
- 堆是用来存储对象实例和数组的区域,是最容易发生
OutOfMemoryError
(OOM)的地方。当创建的对象过多,并且这些对象在经过垃圾回收后仍然无法释放足够的空间来满足新对象的分配需求时,就会出现堆内存溢出。例如,在处理大量数据,如读取一个巨大的文件并将其内容解析为对象存储,或者在循环中不断创建新的对象而没有及时释放旧对象的引用等情况下,很容易导致堆内存溢出。
- 堆是用来存储对象实例和数组的区域,是最容易发生
-
示例代码
import java.util.ArrayList; import java.util.List; public class HeapOOM { public static void main(String[] args) { List<Object> list = new ArrayList<>(); while (true) { list.add(new Object()); } } }
在上述代码中,不断地向
ArrayList
中添加新的
Object
,最终会导致堆内存被耗尽,抛出
OutOfMemoryError
-
-
虚拟机栈(Java Virtual Machine Stacks)和本地方法栈(Native Method Stacks)
-
原因
- 虚拟机栈和本地方法栈都是线程私有的。如果线程请求的栈深度大于虚拟机所允许的深度,就会抛出
StackOverflowError
,严格来说这也是一种栈空间耗尽的情况。如果虚拟机栈可以动态扩展,但是在扩展时无法申请到足够的内存,就会抛出OutOfMemoryError
。例如,在一个递归方法中,如果没有正确的终止条件,递归深度过深,就可能导致栈空间耗尽。
- 虚拟机栈和本地方法栈都是线程私有的。如果线程请求的栈深度大于虚拟机所允许的深度,就会抛出
-
示例代码(栈溢出)
public class StackOverflowErrorExample { public static void recursiveMethod() { recursiveMethod(); } public static void main(String[] args) { recursiveMethod(); } }
上述代码中,
recursiveMethod
方法会无限递归,最终导致栈溢出,抛出
StackOverflowError
。不过,在一些虚拟机实现中,如果栈可以动态扩展,并且内存不足,也可能会出现
OutOfMemoryError
-
-
方法区(Method Area)
-
原因
- 方法区用于存储已被虚拟机加载的类信息、常量、静态变量等。从 Java 8 开始,方法区的实现是元数据区(Metaspace)。当加载的类过多,或者运行时常量池中的常量过多,或者存在大量的字符串常量等情况,都可能导致方法区内存不足。例如,在一些动态生成类的场景中,如使用 CGLIB 等字节码生成库大量生成代理类,可能会导致元数据区内存溢出。
-
示例代码(模拟方法区 OOM)
import net.sf.cglib.proxy.Enhancer; import net.sf.cglib.proxy.MethodInterceptor; import net.sf.cglib.proxy.MethodProxy; import java.lang.reflect.Method; public class MethodAreaOOM { public static void main(String[] args) { while (true) { Enhancer enhancer = new Enhancer(); enhancer.setSuperclass(OOMObject.class); enhancer.setCallback(new MethodInterceptor() { @Override public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable { return proxy.invokeSuper(obj, args); } }); enhancer.create(); } } static class OOMObject {} }
上述代码使用 CGLIB 不断地创建代理类,可能会导致元数据区(方法区)内存溢出,抛出
OutOfMemoryError
。这是因为每创建一个代理类,都需要在方法区存储类的相关信息。
-
8,算法题:给两个仅由数字构成的字符串,不使用Integer的parselnt方法,返回它们相加的结果,用字符串表示。
public class AddStrings {
public static String addStrings(String num1, String num2) {
StringBuilder result = new StringBuilder();
int i = num1.length() - 1;
int j = num2.length() - 1;
int carry = 0; // 进位
while (i >= 0 || j >= 0 || carry > 0) {
int n1 = i >= 0? num1.charAt(i) - '0' : 0;
int n2 = j >= 0? num2.charAt(j) - '0' : 0;
int sum = n1 + n2 + carry;
carry = sum / 10;
result.append(sum % 10);
i--;
j--;
}
return result.reverse().toString();
}
public static void main(String[] args) {
String num1 = "123";
String num2 = "456";
String sum = addStrings(num1, num2);
System.out.println("两数字字符串相加的结果为: " + sum);
}
}
代码解释如下:
整体思路
通过模拟我们平时做加法竖式运算的过程,从两个数字字符串的末尾(也就是最低位)开始逐位相加,同时考虑进位情况,将每一位相加得到的结果依次添加到结果字符串中,最后将结果字符串反转得到最终的相加结果字符串。
9,怎么实现单点登录?怎么判断用户是否是单点登录?
- 单点登录(SSO)的实现方式
- 基于共享 Cookie 的方式(简单但有局限性)
- 原理:在同一个顶级域名下(如
.example.com
),可以通过设置共享的 Cookie 来实现单点登录。例如,有app1.example.com
和app2.example.com
两个子应用,在用户登录app1.example.com
后,在该域名下设置一个包含用户身份信息的 Cookie。当用户访问app2.example.com
时,浏览器会自动带上这个 Cookie,app2.example.com
可以通过验证这个 Cookie 来确定用户已经登录过。 - 实现步骤
- 登录应用 A 时,生成包含用户标识(如用户 ID)的加密 Cookie,设置 Cookie 的
domain
为顶级域名(如.example.com
),path
为/
,这样在同一顶级域名下的其他应用都可以访问该 Cookie。 - 应用 B 收到请求时,检查是否存在这个共享 Cookie。如果存在,对 Cookie 内容进行解密和验证,确认用户身份并完成自动登录。
- 登录应用 A 时,生成包含用户标识(如用户 ID)的加密 Cookie,设置 Cookie 的
- 缺点:这种方式只能在同一个顶级域名下使用,跨域场景(如不同公司的应用或者不同顶级域名的应用)无法使用。而且 Cookie 安全性相对较低,如果 Cookie 被窃取,可能导致安全问题。
- 原理:在同一个顶级域名下(如
- 基于中央认证服务器(CAS - Central Authentication Server)的方式(常用)
- 原理:引入一个独立的中央认证服务器,用户登录时先向该服务器进行认证。认证成功后,中央认证服务器会生成一个票据(Ticket)给用户。当用户访问其他应用时,将票据传递给应用,应用再与中央认证服务器验证票据的有效性,从而确定用户是否已经登录。
- 实现步骤
- 用户访问应用 A 并发起登录请求
- 应用 A 发现用户未登录,将用户重定向到中央认证服务器(CAS)的登录页面,同时传递应用 A 的标识(如服务 ID)。
- 用户在 CAS 登录页面输入用户名和密码,CAS 对用户进行认证。
- 如果认证成功,CAS 生成一个票据(Ticket),并将用户重定向回应用 A,同时将票据作为参数传递给应用 A。
- 应用 A 收到票据后,将票据发送给 CAS 进行验证。CAS 验证票据有效后,返回用户的相关信息给应用 A,应用 A 完成用户登录,并在本地创建会话(Session)存储用户信息。
- 用户访问应用 B
- 应用 B 发现用户未登录,同样将用户重定向到 CAS,同时传递应用 B 的标识。
- CAS 发现用户已经登录(通过之前存储的登录状态或者会话信息),直接生成一个新的票据给用户,并将用户重定向回应用 B。
- 应用 B 收到票据后,向 CAS 验证票据有效性,验证通过后,根据 CAS 返回的用户信息完成用户登录。
- 用户访问应用 A 并发起登录请求
- 基于 OAuth(开放授权)和 OpenID Connect 的方式(适用于跨平台和第三方登录场景)
- 原理:OAuth 主要用于授权,允许用户在不提供用户名和密码的情况下,授权第三方应用访问其在另一个服务提供商(如 Google、Facebook 等)的资源。OpenID Connect 是建立在 OAuth 2.0 基础上的身份认证协议,用于验证用户身份。
- 实现步骤(以使用 Google 进行第三方登录为例)
- 注册应用:在 Google 开发者控制台中注册应用,获取客户端 ID 和客户端密钥等信息。
- 用户通过应用发起 Google 登录请求
- 应用将用户重定向到 Google 的授权服务器,同时传递客户端 ID、请求的权限范围(如获取用户的基本信息)等信息。
- 用户在 Google 授权页面同意授权应用访问其指定的信息。
- Google 授权服务器验证用户同意后,返回一个授权码(Authorization Code)给应用。
- 应用使用授权码获取用户身份信息和访问令牌(Access Token)
- 应用将授权码发送给 Google 的令牌端点(Token Endpoint),同时附上客户端 ID 和客户端密钥。
- Google 令牌端点验证信息后,返回访问令牌和用户身份信息(通过 ID Token)。
- 应用验证 ID Token 的签名和有效性,获取用户身份并完成登录。
- 基于共享 Cookie 的方式(简单但有局限性)
- 判断用户是否是单点登录的方法
- 基于共享 Cookie 方式下的判断
- 检查是否存在共享 Cookie。如果存在,对 Cookie 内容进行解密和验证。如果 Cookie 中的用户标识有效,并且没有过期,那么可以判断用户是通过单点登录访问该应用的。
- 基于中央认证服务器(CAS)方式下的判断
- 当应用收到用户请求并带有票据(Ticket)时,向中央认证服务器验证票据。如果票据有效,中央认证服务器返回用户已登录的信息,此时可以判断用户是单点登录状态。并且,应用可以在本地存储一个标识(如在 Session 中设置一个标志位),表示用户是通过单点登录访问的,在后续请求中可以通过检查这个标识来快速判断。
- 基于 OAuth/OpenID Connect 方式下的判断
- 检查是否存在有效的访问令牌(Access Token)和用户身份验证信息(如 ID Token)。如果这些信息有效,并且通过验证(如验证 ID Token 的签名、有效期、受众等),则可以判断用户是通过单点登录(第三方登录)访问该应用的。同时,和 CAS 方式类似,可以在本地存储一个标识用于后续快速判断。
- 基于共享 Cookie 方式下的判断
10,Redis中的某一热点数据缓存过期了,此时有大量请求访问怎么办?
-
问题分析
- 当 Redis 中的热点数据缓存过期时,大量请求访问该数据会导致这些请求直接穿透缓存到达后端数据库。如果后端数据库无法承受这么大的并发访问量,可能会出现性能下降、响应延迟甚至服务崩溃的情况。
-
解决方案
-
使用互斥锁(Mutex)
-
原理:当发现缓存过期时,使用一个互斥锁来保证只有一个请求能够去后端数据库获取数据并更新缓存,其他请求则等待该锁释放。这样可以避免大量请求同时访问后端数据库。
-
示例代码(以 Java 为例,使用 Redis 的 SETNX 命令实现互斥锁)
import redis.clients.jedis.Jedis; public class RedisCache { private static final String LOCK_KEY = "mutex_lock"; private static final int LOCK_EXPIRE_TIME = 3; // 锁过期时间,单位:秒 private Jedis jedis; public String getCacheData(String key) { String data = jedis.get(key); if (data == null) { // 尝试获取互斥锁 Long lockResult = jedis.setnx(LOCK_KEY, "locked"); if (lockResult == 1) { // 获取锁成功,设置锁过期时间 jedis.expire(LOCK_KEY, LOCK_EXPIRE_TIME); try { // 从后端数据库获取数据 data = getFromDatabase(key); // 更新缓存 jedis.set(key, data); } catch (Exception e) { // 处理异常 } finally { // 释放锁 jedis.del(LOCK_KEY); } } else { // 未获取到锁,等待一段时间后重试 try { Thread.sleep(100); return getCacheData(key); } catch (InterruptedException ex) { // 处理中断异常 } } } return data; } private String getFromDatabase(String key) { // 模拟从数据库获取数据的过程 return "database_data"; } }
-
缺点:引入了锁机制,会增加系统的复杂性。如果获取锁的请求出现异常没有及时释放锁,可能会导致死锁情况。而且等待锁的请求会有一定的延迟,影响系统的响应速度。
-
-
使用缓存预热(Cache Warming)技术
-
原理:在缓存数据快要过期之前,提前异步地重新加载数据到缓存中。可以通过定时任务或者消息队列来触发缓存预热操作。
-
示例(以使用定时任务为例)
- 假设使用 Spring 框架,首先创建一个定时任务类:
import org.springframework.scheduling.annotation.Scheduled; public class CacheWarmingTask { private RedisCache redisCache; @Scheduled(fixedRate = 60000) // 每分钟执行一次 public void warmCache() { // 假设热点数据的键为hot_key String hotData = getFromDatabase("hot_key"); redisCache.set("hot_key", hotData); } private String getFromDatabase(String key) { // 模拟从数据库获取数据的过程 return "database_data"; } }
-
缺点:需要准确预估缓存数据的过期时间和预热时机,否则可能会造成资源浪费(提前加载了不需要的数据)或者仍然出现缓存过期后大量请求穿透的情况。
-
-
设置缓存永不过期(特殊场景)
-
原理:对于一些极其热点且更新频率很低的数据,可以考虑设置缓存永不过期。不过这种方式需要谨慎使用,因为如果数据发生变化,需要有其他机制来更新缓存。
-
示例(以 Redis 为例)
- 在设置缓存时,不设置过期时间:
SET hot_key hot_data
-
缺点:数据更新不及时,可能会导致用户看到旧的数据。需要配合数据更新通知机制来确保缓存数据的准确性。例如,当数据更新时,可以通过消息机制通知应用去更新缓存。
-
-
11,如果用互斥锁保证了同一时间只有一个请求构建缓存,那其他的请求就被阻塞了,为了不影响用户的体验,还有什么好的解决办法?
-
- 使用双重检查锁(Double - Check Locking)结合缓存标记 - **原理**:在第一次检查缓存不存在后,先设置一个缓存构建中的标记,然后再尝试获取互斥锁。其他请求发现这个标记后,知道有其他线程正在构建缓存,就可以先返回一个临时的默认值(如旧数据或者提示信息)给用户,而不是一直等待。获取到锁的线程完成缓存构建后,其他线程再次访问时就能获取到新缓存数据。 - 示例代码(以 Java 为例) ```java import redis.clients.jedis.Jedis; public class RedisCacheImproved { private static final String LOCK_KEY = "mutex_lock"; private static final String BUILDING_FLAG_KEY = "building_flag"; private static final int LOCK_EXPIRE_TIME = 3; private Jedis jedis; public String getCacheData(String key) { String data = jedis.get(key); if (data == null) { // 先设置缓存构建中的标记 if (jedis.setnx(BUILDING_FLAG_KEY, "building") == 1) { // 设置标记成功,说明自己可以去构建缓存 jedis.expire(BUILDING_FLAG_KEY, LOCK_EXPIRE_TIME); try { // 再次检查缓存,防止其他线程已经构建完成 data = jedis.get(key); if (data == null) { Long lockResult = jedis.setnx(LOCK_KEY, "locked"); if (lockResult == 1) { jedis.expire(LOCK_KEY, LOCK_EXPIRE_TIME); try { // 从后端数据库获取数据 data = getFromDatabase(key); // 更新缓存 jedis.set(key, data); } catch (Exception e) { // 处理异常 } finally { // 释放锁 jedis.del(LOCK_KEY); } } } } catch (Exception e) { // 处理异常 } finally { // 删除缓存构建中的标记 jedis.del(BUILDING_FLAG_KEY); } } else { // 发现有其他线程正在构建缓存,返回一个临时值 data = "数据正在加载中,请稍候..."; } } return data; } private String getFromDatabase(String key) { // 模拟从数据库获取数据的过程 return "database_data"; } } ``` - 采用异步构建缓存策略 - **原理**:当发现缓存过期时,不阻塞请求线程,而是通过消息队列或者线程池等方式异步地去构建缓存。请求线程可以先返回一个旧缓存数据或者默认值给用户。异步构建缓存的任务在后台完成后,更新缓存,后续请求就能获取到新的缓存数据。 - 示例(使用线程池实现异步构建缓存) - 定义线程池和缓存工具类 ```java import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import redis.clients.jedis.Jedis; public class AsyncCacheBuilder { private static final ExecutorService executor = Executors.newSingleThreadExecutor(); private Jedis jedis; public String getCacheData(String key) { String data = jedis.get(key); if (data == null) { // 直接返回一个临时值给用户 data = "数据正在加载中,请稍候..."; // 异步构建缓存 executor.submit(() -> buildCache(key)); } return data; } private void buildCache(String key) { try { // 从后端数据库获取数据 String newData = getFromDatabase(key); // 更新缓存 jedis.set(key, newData); } catch (Exception e) { // 处理异常 } } private String getFromDatabase(String key) { // 模拟从数据库获取数据的过程 return "database_data"; } } ``` - 采用多级缓存策略 - **原理**:除了 Redis 缓存,还可以在应用服务器本地设置一层缓存(如使用 Guava Cache 等)。当 Redis 缓存过期,大量请求过来时,先从本地缓存获取数据。本地缓存可以设置较短的过期时间或者采用 LRU(最近最少使用)等淘汰策略来保证数据的相对及时性。同时,异步地去更新 Redis 缓存。 - 示例(以 Guava Cache 为例) - 引入 Guava Cache 依赖并构建本地缓存 ```xml <dependency> <groupId>com.google.guava</groupId> <artifactId>guava</artifactId> <version>30.1 - jre</version> </dependency> ``` ```java import com.google.common.cache.CacheBuilder; import com.google.common.cache.CacheLoader; import com.google.common.cache.LoadingCache; import redis.clients.jedis.Jedis; import java.util.concurrent.TimeUnit; public class MultiLevelCache { private Jedis jedis; private LoadingCache<String, String> localCache; public MultiLevelCache() { // 构建本地缓存,设置过期时间为10秒,最大容量为1000 localCache = CacheBuilder.newBuilder() .expireAfterWrite(10, TimeUnit.SECONDS) .maximumSize(1000) .build(new CacheLoader<String, String>() { @Override public String load(String key) throws Exception { return getFromRedis(key); } }); } public String getCacheData(String key) { try { String data = localCache.get(key); if (data == null) { // 异步更新Redis缓存并返回一个临时值给用户 updateRedisCacheAsync(key); data = "数据正在加载中,请稍候..."; } return data; } catch (Exception e) { // 处理异常,如从Redis获取数据失败等情况 return "获取数据出现异常"; } } private String getFromRedis(String key) { return jedis.get(key); } private void updateRedisCacheAsync(String key) { // 这里可以使用线程池等方式异步更新Redis缓存 // 从后端数据库获取数据并更新Redis缓存 // 模拟从数据库获取数据的过程 String newData = getFromDatabase(key); jedis.set(key, newData); } private String getFromDatabase(String key) { // 模拟从数据库获取数据的过程 return "database_data"; } } ```
12,使用了Redis和本地缓存 两级缓存的话,怎么保证数据一致性?
更新策略选择
-
先更新本地缓存,再更新 Redis 缓存
-
原理:当数据发生变化时,先将最新的数据更新到本地缓存中,然后再更新 Redis 缓存。这种方式在更新本地缓存时可能会有短暂的数据不一致情况,因为此时 Redis 缓存还未更新。不过,在大多数情况下,这种不一致的时间窗口比较小。
-
示例代码(以 Java 为例)
public class CacheUpdater { private LoadingCache<String, String> localCache; private Jedis jedis; public void updateData(String key, String newData) { // 先更新本地缓存 localCache.put(key, newData); // 再更新Redis缓存 jedis.set(key, newData); } }
-
问题:如果在更新本地缓存后,更新 Redis 缓存之前,有请求读取数据,会读取到本地缓存中的新数据和 Redis 中的旧数据,导致数据不一致。而且如果更新 Redis 缓存失败,会导致数据不一致的情况持续存在。
-
-
先更新 Redis 缓存,再更新本地缓存
-
原理:数据更新时,首先更新 Redis 缓存,然后更新本地缓存。这种方式可以减少数据不一致的时间窗口,因为只要 Redis 缓存更新成功,后续读取 Redis 缓存的请求就能获取到最新数据。
-
示例代码(以 Java 为例)
public class CacheUpdater { private LoadingCache<String, String> localCache; private Jedis jedis; public void updateData(String key, String newData) { // 先更新Redis缓存 jedis.set(key, newData); // 再更新本地缓存 localCache.put(key, newData); } }
-
问题:如果更新本地缓存失败,本地缓存和 Redis 缓存的数据会不一致。而且在更新 Redis 缓存后,本地缓存更新之前,读取本地缓存的请求仍然会获取到旧数据。
-
-
删除缓存策略(推荐)
-
原理:当数据更新时,不是直接更新缓存,而是先删除本地缓存和 Redis 缓存中的数据。这样,下一次请求数据时,会因为缓存不存在而从后端数据库重新获取最新数据,然后更新本地缓存和 Redis 缓存。这种方式可以有效地避免数据不一致的问题,因为每次都是从数据库获取最新数据来更新缓存。
-
示例代码(以 Java 为例)
public class CacheUpdater { private LoadingCache<String, String> localCache; private Jedis jedis; public void updateData(String key) { // 先删除本地缓存 localCache.invalidate(key); // 再删除Redis缓存 jedis.del(key); } }
-
13,业务里面线程是怎么使用的?
-
多线程在业务中的常见应用场景
-
提高性能和响应速度
-
示例:并发处理多个任务
-
假设在一个电商系统中,需要处理用户订单的库存检查、价格计算和配送方式选择等多个任务。如果使用单线程,这些任务需要依次执行,可能会导致响应时间较长。通过使用多线程,可以同时处理这些任务。
-
以 Java 为例,代码如下:
import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class OrderProcessing { public static void main(String[] args) { // 创建一个固定大小的线程池,大小为3(对应3个任务) ExecutorService executor = Executors.newFixedThreadPool(3); // 模拟订单任务 OrderTask orderTask = new OrderTask(); // 提交库存检查任务 executor.submit(() -> orderTask.checkStock()); // 提交价格计算任务 executor.submit(() -> orderTask.calculatePrice()); // 提交配送方式选择任务 executor.submit(() -> orderTask.selectDeliveryMethod()); // 关闭线程池 executor.shutdown(); } } class OrderTask { public void checkStock() { System.out.println("正在检查库存..."); // 实际业务逻辑:查询数据库或调用库存服务来检查库存 } public void calculatePrice() { System.out.println("正在计算价格..."); // 实际业务逻辑:根据商品价格规则和促销活动计算价格 } public void selectDeliveryMethod() { System.out.println("正在选择配送方式..."); // 实际业务逻辑:根据用户地址和配送公司规则选择配送方式 } }
-
-
-
异步处理任务
-
示例:文件上传后的处理
-
在一个文件上传系统中,用户上传文件后,可能需要对文件进行病毒扫描、格式转换和存储到不同位置等操作。这些操作可以异步进行,用户上传完文件后就可以继续其他操作,而不需要等待所有文件处理任务完成。
-
以 Python 为例,使用
asyncio
库来实现异步处理(假设已经有对应的文件处理函数):
import asyncio async def virus_scan(file_path): print("开始病毒扫描文件:", file_path) # 实际业务逻辑:调用病毒扫描工具进行扫描 await asyncio.sleep(3) # 模拟扫描时间 print("病毒扫描完成") async def format_conversion(file_path): print("开始格式转换文件:", file_path) # 实际业务逻辑:调用格式转换工具进行转换 await asyncio.sleep(5) # 模拟转换时间 print("格式转换完成") async def file_storage(file_path): print("开始存储文件:", file_path) # 实际业务逻辑:将文件存储到不同位置,如数据库或文件系统 await asyncio.sleep(4) # 模拟存储时间 print("文件存储完成") async def main(): file_path = "example.txt" tasks = [ asyncio.create_task(virus_scan(file_path)), asyncio.create_task(format_conversion(file_path)), asyncio.create_task(file_storage(file_path)) ] await asyncio.wait(tasks) if __name__ == "__main__": asyncio.run(main())
-
-
-
资源利用优化
-
示例:数据读取和处理的并行化
-
在一个大数据处理业务中,需要从多个数据源(如不同的数据库表或文件)读取数据,然后进行整合和分析。可以使用多线程同时读取不同数据源的数据,充分利用系统资源,减少数据读取的总时间。
-
以 Java 为例,假设从两个不同的数据库表读取数据并合并处理:
import java.util.ArrayList; import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class DataProcessing { public static void main(String[] args) { // 创建一个线程池,大小为2(对应两个数据源) ExecutorService executor = Executors.newFixedThreadPool(2); List<String> dataList1 = new ArrayList<>(); List<String> dataList2 = new ArrayList<>(); // 提交从第一个数据源读取数据的任务 executor.submit(() -> dataList1.addAll(readDataFromSource1())); // 提交从第二个数据源读取数据的任务 executor.submit(() -> dataList2.addAll(readDataFromSource2())); // 关闭线程池并等待任务完成 executor.shutdown(); try { executor.awaitTermination(Long.MAX_VALUE, java.util.concurrent.TimeUnit.NANOSECONDS); } catch (InterruptedException e) { e.printStackTrace(); } // 合并数据并处理 List<String> combinedData = new ArrayList<>(dataList1); combinedData.addAll(dataList2); processData(combinedData); } private static List<String> readDataFromSource1() { System.out.println("正在从数据源1读取数据..."); // 实际业务逻辑:查询数据库表1获取数据 List<String> data = new ArrayList<>(); data.add("data1 from source1"); data.add("data2 from source1"); return data; } private static List<String> readDataFromSource2() { System.out.println("正在从数据源2读取数据..."); // 实际业务逻辑:查询数据库表2获取数据 List<String> data = new ArrayList<>(); data.add("data1 from source2"); data.add("data2 from source2"); return data; } private static void processData(List<String> data) { System.out.println("正在处理合并后的数据..."); // 实际业务逻辑:对合并后的数据进行分析、计算等操作 } }
-
-
-
-
线程安全问题及解决方案在业务中的应用
-
共享资源访问冲突
-
示例:银行账户余额修改
-
在银行系统中,多个线程可能会同时访问和修改同一个账户的余额。如果不进行处理,可能会导致数据不一致。例如,一个线程正在读取账户余额进行取款操作,另一个线程同时也在读取余额进行存款操作,可能会出现错误的计算结果。
-
以 Java 为例,可以使用
synchronized
关键字来保证线程安全。假设
Account
类有一个
balance
属性表示余额,
deposit
和
withdraw
方法用于存款和取款操作:
class Account { private double balance; public synchronized void deposit(double amount) { balance += amount; } public synchronized void withdraw(double amount) { if (balance >= amount) { balance -= amount; } else { System.out.println("余额不足"); } } }
-
-
-
死锁问题及避免
-
示例:资源分配导致的死锁
-
假设有两个资源
ResourceA
和ResourceB
,两个线程Thread1
和Thread2
。Thread1
先获取ResourceA
,然后尝试获取ResourceB
;Thread2
先获取ResourceB
,然后尝试获取ResourceA
。如果它们在获取第二个资源时都不释放第一个资源,就会导致死锁。 -
以 Java 为例,为了避免死锁,可以按照相同的顺序获取资源。例如,定义一个资源获取的顺序,所有线程都按照这个顺序获取资源:
class ResourceAllocator { private static final Object lockA = new Object(); private static final Object lockB = new Object(); public void doTask1() { synchronized (lockA) { System.out.println("Thread1获取了ResourceA"); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } synchronized (lockB) { System.out.println("Thread1获取了ResourceB"); } } } public void doTask2() { synchronized (lockA) { System.out.println("Thread2获取了ResourceA"); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } synchronized (lockB) { System.out.println("Thread2获取了ResourceB"); } } } }
-
在上述代码中,所有线程都先获取
lockA
,再获取lockB
,避免了因为资源获取顺序不同而导致的死锁。
-
-
-
14,怎么样知道主线程创建的子线程的执行成功与否?
-
-
使用
Thread
类的join
方法结合异常处理(基础方式)-
原理
- 当在主线程中调用子线程的
join
方法时,主线程会被阻塞,直到子线程执行完成。在子线程的run
方法(实现Runnable
接口时)或者call
方法(使用Callable
接口时)中,通过try - catch
块来捕获可能出现的异常。如果没有异常抛出,就可以认为子线程执行成功;如果有异常,就可以在catch
块中进行处理,并判断子线程执行失败。
- 当在主线程中调用子线程的
-
示例代码(实现
Runnable
接口)public class ThreadJoinExample { public static void main(String[] args) { Thread subThread = new Thread(new MyRunnable()); subThread.start(); try { subThread.join(); System.out.println("子线程执行成功"); } catch (InterruptedException e) { System.out.println("主线程等待子线程时被中断,子线程可能未正常执行"); } } } class MyRunnable implements Runnable { @Override public void run() { try { // 子线程的任务逻辑,这里可以是任何操作,比如访问数据库、进行计算等 System.out.println("子线程正在执行任务"); // 模拟一个可能抛出异常的情况,比如除零异常 int result = 10 / 0; } catch (Exception e) { System.out.println("子线程执行出现异常: " + e.getMessage()); } } }
-
-
使用
Future
和Callable
接口(有返回值场景)-
原理
Callable
接口允许子线程有返回值并且可以抛出异常,Future
接口用于获取异步计算的结果。通过ExecutorService
来提交Callable
任务,返回的Future
对象有多个方法可以用于判断任务状态。isDone
方法可以检查任务是否已经完成,get
方法用于获取任务的返回值,在获取返回值时,如果任务执行过程中抛出异常,get
方法会抛出ExecutionException
,通过捕获这个异常可以判断子线程执行失败。
-
示例代码
import java.util.concurrent.*; public class FutureCallableExample { public static void main(String[] args) { ExecutorService executor = Executors.newSingleThreadExecutor(); Callable<String> callable = new MyCallable(); Future<String> future = executor.submit(callable); try { if (future.isDone()) { try { String result = future.get(); System.out.println("子线程执行成功,结果为: " + result); } catch (ExecutionException e) { System.out.println("子线程执行出现异常: " + e.getCause().getMessage()); } } } finally { executor.shutdown(); } } } class MyCallable implements Callable<String> { @Override public String call() { try { // 子线程任务逻辑,比如读取文件内容并返回 System.out.println("子线程正在执行任务"); // 模拟可能出现异常的情况,比如文件不存在 throw new FileNotFoundException("模拟文件不存在的异常"); // 如果没有异常,返回一个结果,比如文件内容 // return "文件内容"; } catch (Exception e) { System.out.println("子线程执行出现异常: " + e.getMessage()); throw new RuntimeException(e); } } }
-
-
使用
CountDownLatch
(更灵活的同步方式)-
原理
CountDownLatch
是一个同步辅助类,它允许一个或多个线程等待其他线程完成操作。在主线程中创建CountDownLatch
对象,初始值设为 1(表示等待一个子线程完成)。子线程执行完任务后调用countDown
方法,将计数器减 1。主线程通过await
方法等待计数器变为 0,然后检查子线程是否出现异常来判断子线程执行成功与否。
-
示例代
import java.util.concurrent.CountDownLatch; public class CountDownLatchExample { public static void main(String[] args) { CountDownLatch latch = new CountDownLatch(1); Thread subThread = new Thread(() -> { try { System.out.println("子线程正在执行任务"); // 模拟可能出现异常的情况,比如网络连接失败 throw new ConnectException("模拟网络连接失败"); } catch (Exception e) { System.out.println("子线程执行出现异常: " + e.getMessage()); } finally { latch.countDown(); } }); subThread.start(); try { latch.await(); System.out.println("子线程执行完毕,开始检查执行情况"); // 可以在这里根据子线程是否抛出异常等情况判断执行是否成功 } catch (InterruptedException e) { System.out.println("主线程等待子线程时被中断"); } } }
-
-
15,线程池的工作原理?有哪些拒绝策略?
- 线程池工作原理
- 核心组件和概念
- 线程池管理器(ThreadPoolManager):负责创建和管理线程池,包括初始化线程池的大小、设置线程池的参数等。例如,在 Java 中,
ExecutorService
接口的实现类(如ThreadPoolExecutor
)就充当了线程池管理器的角色。 - 工作线程(Worker Threads):线程池中的线程,用于执行任务。这些线程在创建线程池时就被初始化,数量通常是固定的(由核心线程数决定),它们会不断地从任务队列中获取任务并执行。
- 任务队列(Task Queue):用于存放等待执行的任务。当提交一个任务到线程池时,如果没有空闲的工作线程,任务就会被放入任务队列中等待。任务队列的类型有多种,如
LinkedBlockingQueue
(基于链表的阻塞队列)、ArrayBlockingQueue
(基于数组的阻塞队列)等。
- 线程池管理器(ThreadPoolManager):负责创建和管理线程池,包括初始化线程池的大小、设置线程池的参数等。例如,在 Java 中,
- 工作流程
- 任务提交:当有任务需要执行时,通过线程池的
submit
或execute
方法(在 Java 中)将任务提交到线程池。 - 线程分配任务:如果线程池中有空闲的工作线程,就会直接将任务分配给空闲线程执行。工作线程会从任务队列中获取任务(如果任务队列中有任务),并执行任务的
run
方法(对于实现Runnable
接口的任务)或call
方法(对于实现Callable
接口的任务)。 - 任务排队和等待:如果线程池中没有空闲的工作线程,新提交的任务会被放入任务队列中等待。当工作线程完成一个任务后,会从任务队列中获取下一个任务进行执行。
- 线程池的动态调整(部分线程池支持):一些高级的线程池可以根据任务的负载情况动态地调整线程池的大小。例如,当任务队列中的任务过多时,增加工作线程的数量;当任务较少时,减少工作线程的数量,以优化资源利用。
- 任务提交:当有任务需要执行时,通过线程池的
- 核心组件和概念
- 线程池的拒绝策略
AbortPolicy
(默认策略)- 原理:当任务队列已满,并且线程池中的线程数量达到最大限制时,如果再提交任务,就会直接抛出
RejectedExecutionException
异常,阻止任务的提交。这种策略比较 “强硬”,可以确保系统的稳定性,因为它不会接受超过线程池处理能力的任务。 - 示例场景:在一个对任务执行准确性要求很高的系统中,如金融交易系统,当系统负载过高无法处理新任务时,直接抛出异常可以让调用者及时知道任务无法处理,从而采取相应的措施(如重试、调整任务优先级等)。
- 原理:当任务队列已满,并且线程池中的线程数量达到最大限制时,如果再提交任务,就会直接抛出
CallerRunsPolicy
- 原理:当任务被拒绝时,这个策略会将任务回退给提交任务的线程(通常是主线程)来执行。这样可以在一定程度上减缓任务提交的速度,因为提交任务的线程需要自己执行被拒绝的任务,从而给线程池一些时间来处理任务队列中的任务。
- 示例场景:在一个 Web 应用服务器中,当线程池繁忙时,将部分任务回退给处理 HTTP 请求的主线程执行,可以避免任务丢失,同时也可以让用户感知到系统的繁忙状态(因为主线程在执行被拒绝的任务时,可能会导致响应时间延长)。
DiscardPolicy
- 原理:当任务被拒绝时,直接丢弃这个任务,不会抛出异常,也不会执行这个任务。这种策略比较 “冷酷”,适用于对任务丢失不太敏感的场景,例如日志收集系统,当系统负载过高时,丢弃一些日志记录任务可能不会对系统的核心功能产生太大影响。
DiscardOldestPolicy
- 原理:当任务被拒绝时,会丢弃任务队列中最旧的一个任务,然后将新提交的任务放入任务队列。这个策略假设最旧的任务可能已经不太重要或者可以重新提交,从而为新任务腾出空间。
- 示例场景:在一个消息处理系统中,当消息队列(类似于线程池的任务队列)已满时,丢弃最早的消息,接收新消息,因为新消息可能更具有时效性或者优先级更高。
16,做项目的话是怎样使用线程池的?
-
确定线程池的类型和参数
- 线程池类型选择
FixedThreadPool
(固定大小线程池)- 适用场景:适用于处理 CPU 密集型任务,任务数量相对固定,且对响应时间要求不是极高的场景。例如,在一个简单的文件加密工具中,需要对多个文件进行加密操作。文件数量是已知的,加密过程主要是 CPU 运算,使用固定大小的线程池可以很好地控制并发度。
- 参数设置:在 Java 中,通过
Executors.newFixedThreadPool(n)
创建,其中n
为线程池的大小,通常根据 CPU 核心数来设置,例如可以设置为Runtime.getRuntime().availableProcessors()
获取的 CPU 核心数,这样可以充分利用 CPU 资源。
CachedThreadPool
(可缓存线程池)- 适用场景:适用于处理大量短生命周期的异步任务,任务提交速度不均匀的场景。例如,在一个 Web 服务器中,处理 HTTP 请求时,请求的到达时间和处理时间都不确定,可缓存线程池可以灵活地根据任务数量创建和回收线程。
- 参数设置:使用
Executors.newCachedThreadPool()
创建,它会根据需要自动创建新线程,如果线程长时间空闲(默认 60 秒)会自动回收。这种线程池的大小没有固定限制,但要注意避免创建过多线程导致系统资源耗尽。
ScheduledThreadPool
(定时线程池)- 适用场景:用于执行定时任务或周期性任务。例如,在一个系统监控工具中,需要每隔一段时间(如每分钟)收集系统的资源使用情况(CPU 使用率、内存使用率等),就可以使用定时线程池。
- 参数设置:通过
Executors.newScheduledThreadPool(n)
创建,其中n
为线程池大小,要根据定时任务的数量和执行频率来确定。对于简单的定时任务场景,n
可以设置为较小的值,如 1 或 2。
WorkStealingPool
(工作窃取线程池)- 适用场景:适用于任务之间相互独立,且存在一些任务可能比其他任务执行时间长很多的场景。例如,在一个数据处理系统中,有多个数据处理任务,部分任务可能因为数据量巨大而耗时较长,工作窃取线程池可以让空闲线程从其他忙碌线程的任务队列中 “窃取” 任务来执行,提高整体效率。
- 参数设置:使用
Executors.newWorkStealingPool()
创建,它会根据 CPU 核心数自动设置线程池大小,一般不需要手动调整。
- 线程池参数配置细节
- 核心线程数(Core Pool Size):这是线程池在没有任务时保持的最小线程数量。设置合适的核心线程数可以避免频繁地创建和销毁线程。例如,对于一个长期运行的服务,处理相对稳定的任务流,可以根据平均任务处理速度和响应时间要求来确定核心线程数。
- 最大线程数(Maximum Pool Size):线程池允许的最大线程数量。当任务队列已满,且线程数未达到最大线程数时,会创建新线程来处理任务。这个参数要根据系统资源(如内存、CPU 等)和任务特性来设置,防止创建过多线程导致系统崩溃。
- 任务队列(Work Queue):选择合适的任务队列类型和大小。例如,
LinkedBlockingQueue
是无界队列(理论上容量为Integer.MAX_VALUE
),ArrayBlockingQueue
是有界队列,需要在创建时指定队列大小。如果使用无界队列,要注意可能导致内存耗尽的风险;使用有界队列可能会导致任务被拒绝,需要配合合适的拒绝策略。 - 线程存活时间(Keep - Alive Time):对于非核心线程,当线程空闲时间超过这个时间时,线程会被回收。这个参数可以根据任务的间隔时间和频率来设置,以优化资源利用。
- 线程池类型选择
-
提交任务到线程池
-
使用
execute
方法(适用于Runnable
任务)-
示例代码(以 Java 为例)
import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class ThreadPoolExample { public static void main(String[] args) { // 创建一个固定大小为3的线程池 ExecutorService executor = Executors.newFixedThreadPool(3); // 创建并提交任务 for (int i = 0; i < 5; i++) { Runnable task = new MyRunnable(i); executor.execute(task); } // 关闭线程池 executor.shutdown(); } } class MyRunnable implements Runnable { private int taskId; public MyRunnable(int taskId) { this.taskId = taskId; } @Override public void run() { System.out.println("正在执行任务 " + taskId); // 这里可以是实际的任务逻辑,如访问数据库、进行计算等 } }
-
解释:在上述代码中,创建了一个固定大小为 3 的线程池,然后提交了 5 个
Runnable
任务。线程池会先使用 3 个核心线程来处理任务,剩下的 2 个任务会在核心线程有空闲时被处理。execute
方法用于提交任务,它不会返回任务的执行结果。
-
-
使用
submit
方法(适用于Callable
任务,可获取结果)-
示例代码(以 Java 为例)
import java.util.concurrent.*; public class ThreadPoolCallableExample { public static void main(String[] args) { // 创建一个固定大小为2的线程池 ExecutorService executor = Executors.newFixedThreadPool(2); // 创建并提交任务 Future<Integer> future1 = executor.submit(new MyCallable(1)); Future<Integer> future2 = executor.submit(new MyCallable(2)); try { // 获取任务结果 System.out.println("任务1的结果: " + future1.get()); System.out.println("任务2的结果: " + future2.get()); } catch (InterruptedException | ExecutionException e) { System.out.println("任务执行出现异常: " + e.getMessage()); } // 关闭线程池 executor.shutdown(); } } class MyCallable implements Callable<Integer> { private int taskId; public MyCallable(int taskId) { this.taskId = taskId; } @Override public Integer call() { System.out.println("正在执行任务 " + taskId); // 这里可以是实际的任务逻辑,如计算一个复杂的数学问题 return taskId * 10; } }
-
解释:这里创建了一个固定大小为 2 的线程池,提交了 2 个
Callable
任务。submit
方法会返回一个Future
对象,通过Future
对象的get
方法可以获取任务的执行结果。在获取结果时,如果任务还未完成,主线程会被阻塞;如果任务执行过程中出现异常,get
方法会抛出ExecutionException
。
-
-
-
处理任务执行结果和异常
-
获取
Callable
任务结果(通过Future
)- 如上述
submit
方法示例中所示,使用Future
对象的get
方法来获取任务的结果。如果任务还未完成,get
方法会阻塞当前线程,直到任务完成并返回结果。同时,要注意处理get
方法可能抛出的InterruptedException
(线程被中断)和ExecutionException
(任务执行过程中出现异常)。
- 如上述
-
处理
Runnable
任务异常-
对于
Runnable
任务,由于run
方法没有返回值,不能直接获取任务执行过程中的异常。一种方式是在Runnable
任务的run
方法内部使用try - catch
块捕获异常,并进行相应的处理(如记录日志、重试等)。另一种方式是使用自定义的线程工厂(ThreadFactory
)来创建线程,在创建线程时设置未捕获异常处理器(UncaughtExceptionHandler
),这样当线程执行任务出现异常时,会调用这个处理器来处理异常。 -
示例代码(使用未捕获异常处理器)
import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; public class RunnableExceptionHandling { public static void main(String[] args) { // 创建一个自定义的线程工厂,设置未捕获异常处理器 ThreadFactory threadFactory = new ThreadFactory() { @Override public Thread newThread(Runnable r) { Thread thread = new Thread(r); thread.setUncaughtExceptionHandler((t, e) -> { System.out.println("线程 " + t.getName() + " 执行任务出现异常: " + e.getMessage()); }); return thread; } }; // 创建一个固定大小为2的线程池,使用自定义的线程工厂 ExecutorService executor = Executors.newFixedThreadPool(2, threadFactory); // 提交任务 executor.execute(new MyRunnable(1)); executor.execute(new MyRunnable(2)); // 关闭线程池 executor.shutdown(); } } class MyRunnable implements Runnable { private int taskId; public MyRunnable(int taskId) { this.taskId = taskId; } @Override public void run() { try { System.out.println("正在执行任务 " + taskId); // 模拟任务出现异常 int result = 10 / 0; } catch (Exception e) { throw new RuntimeException(e); } } }
-
-
-
关闭线程池
-
shutdown
方法-
原理:当调用
shutdown
方法时,线程池会平滑地关闭。它会等待正在执行的任务完成,然后再关闭线程池。在shutdown
后,不允许再提交新任务,但已经提交的任务会继续执行。 -
示例代码(和前面示例结合)
// 创建一个固定大小为3的线程池 ExecutorService executor = Executors.newFixedThreadPool(3); // 提交任务 //... // 关闭线程池 executor.shutdown();
-
-
shutdownNow
方法-
原理:尝试立即关闭线程池。它会尝试中断所有正在执行的任务,并清空任务队列,返回尚未开始执行的任务列表。这种方法比较 “强硬”,可能会导致正在执行的任务被中断,所以要谨慎使用。
-
示例代码
// 创建一个固定大小为3的线程池 ExecutorService executor = Executors.newFixedThreadPool(3); // 提交任务 //... // 立即关闭线程池 List<Runnable> notExecutedTasks = executor.shutdownNow(); System.out.println("尚未执行的任务数量: " + notExecutedTasks.size());
-
-
17,Spring bean的生命周期?
-
实例化(Instantiation)
-
过程描述
- Spring 容器根据配置(如 XML 配置、Java 配置或基于注解的配置)来确定需要创建的 Bean。当容器启动时,会通过反射机制利用 Bean 的默认构造函数(如果没有其他特殊配置)来创建 Bean 的实例。例如,对于一个简单的 Java 类
UserService
,如果它被标记为一个 Spring Bean,容器会调用new UserService()
来创建一个实例。
- Spring 容器根据配置(如 XML 配置、Java 配置或基于注解的配置)来确定需要创建的 Bean。当容器启动时,会通过反射机制利用 Bean 的默认构造函数(如果没有其他特殊配置)来创建 Bean 的实例。例如,对于一个简单的 Java 类
-
相关配置示例(基于 Java 配置)
- 假设我们有一个
UserService
类,通过@Configuration
和@Bean
注解来配置 Bean。
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class AppConfig { @Bean public UserService userService() { return new UserService(); } }
- 假设我们有一个
-
-
属性填充(Populating Properties)
-
过程描述
- 在 Bean 实例化之后,Spring 会根据配置将依赖的其他 Bean 或者属性值注入到当前 Bean 中。这可以通过
@Autowired
、@Value
等注解或者 XML 配置中的<property>
元素来实现。例如,如果UserService
依赖于一个UserRepository
,并且UserRepository
也是一个 Spring Bean,那么 Spring 会将UserRepository
的实例注入到UserService
中。
- 在 Bean 实例化之后,Spring 会根据配置将依赖的其他 Bean 或者属性值注入到当前 Bean 中。这可以通过
-
相关配置示例(基于注解配置)
- 假设
UserService
类中有一个UserRepository
的依赖,通过@Autowired
来注入。
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @Service public class UserService { @Autowired private UserRepository userRepository; //... }
- 假设
-
-
初始化(Initialization)
-
过程描述
- 在属性填充完成后,Bean 会进入初始化阶段。这一阶段可以执行一些自定义的初始化操作,如资源的加载、缓存的预热等。Spring 提供了多种方式来定义初始化方法,如在 XML 配置中使用
init - method
属性,或者在 Bean 类中使用@PostConstruct
注解。
- 在属性填充完成后,Bean 会进入初始化阶段。这一阶段可以执行一些自定义的初始化操作,如资源的加载、缓存的预热等。Spring 提供了多种方式来定义初始化方法,如在 XML 配置中使用
-
相关配置示例(基于注解配置)
- 假设
UserService
类中有一个初始化方法init
,通过@PostConstruct
注解来定义。
import javax.annotation.PostConstruct; import org.springframework.stereotype.Service; @Service public class UserService { //... @PostConstruct public void init() { System.out.println("UserService正在初始化..."); // 可以在这里进行资源加载、缓存预热等操作 } }
- 假设
-
-
Bean 的使用(Usage)
-
过程描述
- 初始化完成后的 Bean 就可以被应用程序使用了。其他组件可以通过依赖注入获取这个 Bean 的实例,并调用其方法来完成业务逻辑。例如,在一个 Spring MVC 的控制器中,可以注入
UserService
并调用其方法来处理用户相关的请求。
- 初始化完成后的 Bean 就可以被应用程序使用了。其他组件可以通过依赖注入获取这个 Bean 的实例,并调用其方法来完成业务逻辑。例如,在一个 Spring MVC 的控制器中,可以注入
-
相关代码示例(在控制器中使用 Bean)
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @RestController public class UserController { @Autowired private UserService userService; @GetMapping("/users") public String getUsers() { return userService.getUsers(); } }
-
-
销毁(Destruction)
-
过程描述
- 当 Spring 容器关闭或者 Bean 的作用域结束(如对于
prototype
作用域的 Bean,当它不再被引用时),Bean 会进入销毁阶段。在这个阶段,可以释放 Bean 占用的资源,如关闭数据库连接、释放文件句柄等。可以通过 XML 配置中的destroy - method
属性或者@PreDestroy
注解来定义销毁方法。
- 当 Spring 容器关闭或者 Bean 的作用域结束(如对于
-
相关配置示例(基于注解配置)
- 假设
UserService
类中有一个销毁方法destroy
,通过@PreDestroy
注解来定义。
import javax.annotation.PreDestroy; import org.springframework.stereotype.Service; @Service public class UserService { //... @PreDestroy public void destroy() { System.out.println("UserService正在销毁..."); // 可以在这里释放资源,如关闭数据库连接等 } }
- 假设
-
18,MySQL事务的隔离级别?
- 读未提交(Read Uncommitted)
- 含义
- 这是最低的隔离级别。在这个级别下,一个事务可以读取到另一个事务未提交的数据,也就是所谓的 “脏读”(Dirty Read)可能会发生。例如,事务 A 正在修改数据但尚未提交,事务 B 却能读取到事务 A 修改后的数据。如果事务 A 之后回滚了这个修改,事务 B 读取到的数据就是无效的,这就产生了脏读。
- 应用场景和问题
- 这种隔离级别在一些对数据一致性要求不高的场景下可能会被使用,比如在一些简单的日志记录系统中,偶尔读取到未最终确定的数据可能不会造成严重后果。但由于存在脏读问题,在大多数涉及数据更新和读取的关键业务场景(如金融交易、库存管理等)是不适用的。
- 含义
- 读已提交(Read Committed)
- 含义
- 一个事务只能读取到另一个事务已经提交的数据。这避免了脏读问题,但是在这个隔离级别下可能会出现 “不可重复读”(Non - Repeatable Read)的情况。例如,事务 A 先读取了一个数据值,然后事务 B 修改并提交了这个数据,当事务 A 再次读取这个数据时,发现数据值已经改变了,这就导致在同一个事务中两次读取同一数据得到不同的结果。
- 应用场景和问题
- 读已提交隔离级别在许多实际业务场景中有一定的应用,如一些对实时性要求较高的数据分析系统,这些系统更关注数据的最新状态,对数据的重复读取一致性要求相对较低。不过,不可重复读问题可能会对一些需要在事务过程中保证数据一致性的操作(如复杂的统计计算)产生影响。
- 含义
- 可重复读(Repeatable Read)
- 含义
- 在这个隔离级别下,一个事务在执行过程中多次读取同一数据,其结果是一致的,避免了不可重复读的问题。这是通过在事务开始时为事务创建一个数据快照(Snapshot)来实现的。不过,在可重复读隔离级别下可能会出现 “幻读”(Phantom Read)现象。幻读是指一个事务在执行过程中,按照某个条件查询数据,第一次查询和第二次查询的结果集不同,好像出现了 “幻影” 数据一样。这是因为在事务执行期间,另一个事务插入或删除了符合查询条件的数据。
- 应用场景和问题
- 可重复读隔离级别适用于对数据一致性要求较高的业务场景,如大多数的企业级业务系统(如订单管理系统、财务管理系统等)。在这些系统中,需要保证在事务处理过程中数据的稳定性。但幻读问题可能会对一些需要精确统计数据范围的操作产生干扰,需要通过一些额外的措施(如使用
FOR UPDATE
语句锁定数据范围)来解决。
- 可重复读隔离级别适用于对数据一致性要求较高的业务场景,如大多数的企业级业务系统(如订单管理系统、财务管理系统等)。在这些系统中,需要保证在事务处理过程中数据的稳定性。但幻读问题可能会对一些需要精确统计数据范围的操作产生干扰,需要通过一些额外的措施(如使用
- 含义
- 串行化(Serializable)
- 含义
- 这是最高的隔离级别。在这个级别下,事务的执行是串行的,一个事务必须等待前一个事务完成后才能开始。这就完全避免了脏读、不可重复读和幻读的问题,保证了数据的最高一致性。
- 应用场景和问题
- 串行化隔离级别适用于对数据一致性和准确性要求极高的场景,如银行的核心账务系统等。不过,这种隔离级别会严重影响系统的并发性能,因为事务必须依次执行,导致系统的吞吐量大幅下降,所以在实际应用中需要谨慎使用。
- 含义
在 MySQL 中,可以通过SET TRANSACTION ISOLATION LEVEL
语句来设置事务的隔离级别,例如:
-- 设置事务隔离级别为读已提交
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
不同的数据库管理系统可能对事务隔离级别的实现细节和默认级别有所不同,但基本概念是相似的。在实际应用中,需要根据业务需求和性能要求来选择合适的事务隔离级别。
19,索引什么时候会失效?
- 索引失效的情况
- 在索引列上进行函数运算
- 原理:当在索引列上使用函数(如
SUBSTRING
、UPPER
、LOWER
、ROUND
等)时,数据库在执行查询时无法直接使用索引来定位数据。因为索引是按照列的原始值构建的,函数运算后的结果与索引结构不匹配。 - 示例(以 MySQL 为例)
- 假设有一个
users
表,其中有一个name
列并且在该列上建立了索引。正常查询语句SELECT * FROM users WHERE name = 'John';
可以使用索引。但是如果查询语句是SELECT * FROM users WHERE UPPER(name) = 'JOHN';
,就无法使用索引,因为对name
列进行了UPPER
函数运算。
- 假设有一个
- 原理:当在索引列上使用函数(如
- 使用
OR
连接条件并且其中部分条件没有索引支持- 原理:在
WHERE
子句中使用OR
连接多个条件时,如果这些条件中有部分条件对应的列没有索引,或者数据类型不兼容索引,数据库可能会放弃使用索引。这是因为使用索引来满足OR
条件会变得复杂,在某些情况下数据库认为全表扫描可能更高效。 - 示例(以 MySQL 为例)
- 假设有一个
products
表,id
列有索引,price
列没有索引。查询语句SELECT * FROM products WHERE id = 1 OR price > 100;
可能不会使用索引,因为price
列没有索引,数据库可能会选择全表扫描来执行这个查询。
- 假设有一个
- 原理:在
- 索引列参与了表达式计算
- 原理:与在索引列上进行函数运算类似,当索引列参与表达式计算(如
column1 + column2 > 10
)时,数据库无法直接利用索引来查找数据。因为索引是基于列的原始值存储的,而不是基于表达式的结果。 - 示例(以 MySQL 为例)
- 假设有一个
orders
表,quantity
列有索引,查询语句SELECT * FROM orders WHERE quantity * 2 > 10;
可能不会使用索引,因为quantity
列参与了表达式计算。
- 假设有一个
- 原理:与在索引列上进行函数运算类似,当索引列参与表达式计算(如
- 在
LIKE
操作中使用通配符开头- 原理:当在
LIKE
操作符中以通配符(%
或_
)开头进行模糊查询时,数据库无法使用索引来快速定位数据。因为索引是按照顺序存储数据的,以通配符开头意味着可能匹配的数据范围非常广泛,数据库通常会选择全表扫描。 - 示例(以 MySQL 为例)
- 假设有一个
books
表,title
列有索引,查询语句SELECT * FROM books WHERE title LIKE '%keyword';
无法使用索引,而SELECT * FROM books WHERE title LIKE 'keyword%';
可以使用索引。
- 假设有一个
- 原理:当在
- 数据类型不匹配
- 原理:如果在查询条件中的数据类型与索引列的数据类型不匹配,可能会导致索引失效。例如,索引列是
INT
类型,而查询条件中使用了VARCHAR
类型的值与之比较,数据库可能无法正确使用索引。 - 示例(以 MySQL 为例)
- 假设有一个
students
表,age
列(INT
类型)有索引,查询语句SELECT * FROM students WHERE age = '20';
(将INT
类型的age
与VARCHAR
类型的'20'
比较)可能会导致索引失效。
- 假设有一个
- 原理:如果在查询条件中的数据类型与索引列的数据类型不匹配,可能会导致索引失效。例如,索引列是
- 隐式转换
- 原理:当数据库在执行查询时需要进行隐式的数据类型转换,这可能会导致索引失效。这种情况通常发生在比较操作中,数据类型不一致并且数据库自动进行了转换。
- 示例(以 MySQL 为例)
- 假设有一个
employees
表,salary
列(DECIMAL
类型)有索引,查询语句SELECT * FROM employees WHERE salary = 100;
(将DECIMAL
类型的salary
与INT
类型的100
比较)可能会触发隐式转换,导致索引失效。
- 假设有一个
- 使用
NOT IN
(部分情况)和<>(!=)
操作符(部分情况)- 原理:在某些数据库中,使用
NOT IN
和<>(!=)
操作符可能会导致索引失效。这是因为这些操作符会使数据库在索引中查找不符合条件的数据,而不是直接定位符合条件的数据,这在某些情况下效率较低,数据库可能会选择全表扫描。不过,这也取决于数据库的优化策略和数据分布情况。 - 示例(以 MySQL 为例)
- 假设有一个
categories
表,id
列有索引,查询语句SELECT * FROM categories WHERE id NOT IN (1, 2, 3);
可能会导致索引失效。对于<>(!=)
操作符,例如SELECT * FROM products WHERE price <> 100;
也可能会出现类似情况。
- 假设有一个
- 原理:在某些数据库中,使用
- 在索引列上进行函数运算
需要注意的是,不同的数据库管理系统(如 Oracle、SQL Server 等)在索引失效的具体情况和处理方式上可能会有所差异,但上述这些情况是在大多数数据库中比较常见的导致索引失效的原因。
20,Redisson分布式锁的原理?
-
基于 Redis 的 SETNX 命令基础
-
SETNX 命令原理
-
Redis 的
SETNX
(SET if Not eXists)命令是实现分布式锁的基础。这个命令用于设置一个键值对,但是只有当键不存在时才会设置成功。在分布式锁的场景中,可以将锁视为一个 Redis 中的键。当一个客户端尝试获取锁时,它使用SETNX
命令尝试设置这个键,如果返回 1,表示获取锁成功;如果返回 0,表示锁已经被其他客户端获取。 -
示例(Redis 命令行)
- 假设使用
lock_key
作为锁的键,客户端 A 尝试获取锁:
SETNX lock_key "clientA"
- 如果返回 1,说明客户端 A 成功获取锁,并且可以将自己的标识(如
clientA
)存储在这个键对应的 value 中,用于后续的锁释放验证等操作。
- 假设使用
-
-
-
Redisson 对分布式锁的实现细节
- 加锁过程
- 可重入性实现:Redisson 实现了锁的可重入性。当一个线程已经持有锁,再次尝试获取同一把锁时,它能够成功获取,并且内部会记录重入的次数。这是通过在 Redis 中存储一个与线程相关的信息(如哈希结构,包含线程 ID 和重入次数)来实现的。例如,一个 Java 线程在获取锁后,Redisson 会将线程 ID 和重入次数以某种格式存储在 Redis 中。
- 锁的自动续期机制:Redisson 提供了锁自动续期的功能。当一个客户端获取锁后,会开启一个后台线程,这个线程会定期(默认是锁过期时间的三分之一时间间隔)检查锁是否快过期。如果快过期了,就会自动向 Redis 发送指令延长锁的过期时间。这样可以避免因为业务处理时间过长导致锁提前过期,其他客户端获取到锁而产生的并发问题。
- 解锁过程
- 正确性验证:在解锁时,Redisson 会首先验证解锁的客户端是否是当前持有锁的客户端。它会根据存储在 Redis 中的锁信息(如之前存储的持有锁的线程 ID 等)来进行验证。只有持有锁的客户端才能成功解锁,这样可以避免一个客户端误解锁其他客户端持有的锁。
- 减少锁竞争的优化:当一个客户端解锁后,Redisson 不会立即删除锁对应的键,而是会先检查是否有其他客户端在等待获取这个锁。如果有等待的客户端,会按照一定的顺序(如公平锁会按照等待时间先后顺序)将锁分配给下一个客户端,从而减少锁竞争,提高系统的并发性能。
- 加锁过程
-
Redisson 分布式锁的工作流程示例(以 Java 为例)
-
加锁示例代码
import org.redisson.Redisson; import org.redisson.api.RLock; import org.redisson.config.Config; public class RedissonLockExample { public static void main(String[] args) { // 配置Redisson客户端 Config config = new Config(); config.useSingleServer().setAddress("redis://123.45.67.89:6379"); Redisson redisson = (Redisson) Redisson.create(config); // 获取锁对象 RLock lock = redisson.getLock("myLock"); try { // 尝试获取锁 lock.lock(); // 业务逻辑代码,这里可以是对共享资源的访问等操作 System.out.println("获取锁成功,执行业务逻辑"); } catch (Exception e) { System.out.println("获取锁出现异常: " + e.getMessage()); } finally { // 释放锁 lock.unlock(); } } }
-
代码解释
- 首先,通过
Config
类配置 Redisson 客户端连接到 Redis 服务器。然后,使用redisson.getLock("myLock")
获取一个名为myLock
的锁对象。 - 在
try
块中,通过lock.lock()
尝试获取锁。如果获取成功,就可以执行后续的业务逻辑。这里的lock
方法会自动处理锁的可重入性和自动续期等功能。 - 在
finally
块中,通过lock.unlock()
释放锁。在释放锁时,Redisson 会自动进行正确性验证和其他优化操作,确保锁的正确释放和减少锁竞争。
- 首先,通过
-
21,es和MySQL怎么保证数据一致性?
-
双写模式
-
原理
- 在数据写入时,同时向 MySQL 和 Elasticsearch(ES)进行写入操作。这种方式的关键在于确保两个写入操作要么都成功,要么都失败,也就是实现事务性的双写。通常需要在应用层或者借助消息中间件来实现这种事务一致性。
-
实现方式
-
应用层控制
-
在应用程序中,使用数据库事务来包裹 MySQL 和 ES 的写入操作。例如,在 Java 中使用 Spring 的
@Transactional
注解(假设使用 Spring 框架)。不过,ES 本身没有像数据库那样的事务机制,所以需要在代码中精心设计写入的顺序和异常处理机制。 -
示例代码如下(简化的 Java 示例):
import org.springframework.transaction.annotation.Transactional; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @RestController public class DataController { private final MySQlRepository mySQlRepository; private final ESRepository esRepository; public DataController(MySQlRepository mySQlRepository, ESRepository esRepository) { this.mySQlRepository = mySQlRepository; this.esRepository = esRepository; } @Transactional @PostMapping("/data") public void saveData(@RequestBody Data data) { // 先写入MySQL mySQlRepository.save(data); // 再写入ES esRepository.save(data); } }
-
-
借助消息中间件
- 可以将数据变更消息发送到消息中间件(如 RabbitMQ、Kafka 等),然后有两个消费者分别处理 MySQL 和 ES 的写入操作。消息中间件可以保证消息的可靠传递,只要消费者的处理逻辑正确,就可以实现双写。
- 例如,使用 Kafka 实现双写的基本步骤:
- 当有数据变更时,生产者将数据变更消息发送到 Kafka 的一个主题(Topic)中。消息格式可以包含操作类型(插入、更新、删除)和数据内容。
- 两个消费者分别订阅这个主题。一个消费者负责将数据写入 MySQL,另一个消费者负责将数据写入 ES。在消费者处理消息时,需要考虑消息的幂等性,避免重复写入导致数据不一致。
-
-
-
异步更新模式
-
原理
- 数据首先写入 MySQL,然后通过异步的方式更新 ES。这种方式可以减轻系统的即时负载,因为不需要同时等待两个存储系统的写入完成。但是,需要处理异步更新可能带来的数据不一致问题,如更新失败、更新延迟等情况。
-
实现方式
-
使用消息队列和异步任务
-
当数据在 MySQL 中写入或更新后,将数据变更消息发送到消息队列。然后,通过一个异步任务(如使用线程池或者分布式任务调度框架)来消费消息队列中的消息,并更新 ES。
-
示例(以 Java 和 Spring 框架为例,使用
@Async
注解实现异步任务):
import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import javax.annotation.Resource; @Component public class DataSyncService { @Resource private MySQlRepository mySQlRepository; @Resource private ESRepository esRepository; @Async public void syncDataToES(Long dataId) { Data data = mySQlRepository.findById(dataId).orElse(null); if (data!= null) { esRepository.save(data); } } }
-
在数据写入 MySQL 后,可以调用这个异步服务来更新 ES。例如,在一个数据保存的服务方法中:
import org.springframework.stereotype.Service; import javax.annotation.Resource; @Service public class DataService { @Resource private MySQlRepository mySQlRepository; @Resource private DataSyncService dataSyncService; public void saveData(Data data) { mySQlRepository.save(data); // 异步更新ES dataSyncService.syncDataToES(data.getId()); } }
-
-
使用数据库的变更日志(Binlog)
- MySQL 的 Binlog 记录了数据库的所有变更操作。可以通过解析 Binlog 来获取数据的变更信息,然后将这些信息应用到 ES 中。有一些工具(如 Canal)可以帮助实现 Binlog 的解析和数据同步。
- 以 Canal 为例,它的工作流程如下:
- Canal 伪装成 MySQL 的一个从库,订阅 MySQL 的 Binlog。
- 当 MySQL 主库有数据变更时,Binlog 会被发送到 Canal。Canal 解析 Binlog,获取数据的变更细节,如插入、更新、删除操作和对应的行数据。
- Canal 将解析后的变更数据发送到目标存储系统(如 ES),从而实现数据的同步更新。
-
-
-
数据校对和修复机制
-
原理
- 无论采用双写还是异步更新模式,都可能由于网络故障、系统崩溃等原因导致数据不一致。因此,需要定期或者在发现数据可能不一致的情况下,对 MySQL 和 ES 中的数据进行校对和修复。
-
实现方式
-
全量校对和修复
-
可以定期(如每天凌晨)对 MySQL 和 ES 中的数据进行全量比对。通过查询 MySQL 中的所有数据,然后在 ES 中进行匹配查询,找出不一致的数据,然后根据一定的规则进行修复。例如,如果 ES 中的数据比 MySQL 中的数据少,可以将缺失的数据从 MySQL 同步到 ES。
-
示例(以 Java 为例,简单的全量校对思路):
import org.springframework.stereotype.Component; import javax.annotation.Resource; import java.util.List; @Component public class DataConsistencyChecker { @Resource private MySQlRepository mySQlRepository; @Resource private ESRepository esRepository; public void fullCheckAndFix() { List<Data> mySQlDataList = mySQlRepository.findAll(); List<Data> esDataList = esRepository.findAll(); // 比较数据,找出不一致的部分并修复 // 这里只是简单示例,实际可能需要更复杂的比较和修复逻辑 for (Data mySQlData : mySQlDataList) { boolean foundInES = false; for (Data esData : esDataList) { if (mySQlData.getId().equals(esData.getId())) { foundInES = true; // 可以在这里进一步比较数据的其他字段是否一致 break; } } if (!foundInES) { // 将缺失的数据同步到ES esRepository.save(mySQlData); } } } }
-
-
增量校对和修复(基于时间戳或版本号)
-
在数据模型中添加时间戳或者版本号字段。当数据在 MySQL 中发生变更时,更新这个字段。在 ES 中也存储这个字段。通过比较这个字段,可以快速找出可能不一致的数据,然后进行增量修复。
-
例如,在数据实体类中添加
lastUpdated
字段(表示最后更新时间):
import java.time.LocalDateTime; public class Data { private Long id; private String content; private LocalDateTime lastUpdated; // 省略getter和setter方法 }
-
在数据更新方法中更新
lastUpdated
public void updateData(Data data) { data.setLastUpdated(LocalDateTime.now()); mySQlRepository.save(data); }
-
在数据校对时,比较
lastUpdated
字段,只对更新时间不一致的数据进行修复操作,这样可以减少校对的数据量和时间成本。
-
-
-