大家好,我是苏三。
最近看到一些校招同学反馈 OPPO 开奖了
根据大家提供的信息,我这边也整理了 OPPO 校招开发岗位的薪资情况,目前发现主要以下几个档次:
20k X 15 + 1.2k X12 = 31w 22k X 15 + 1.2k X 12 = 34w 24k X 15 + 1.2k X 12 = 37w 25k X 15 + 1.2k X 12 = 38w 26k X 15 + 1.2k X12 = 40w
整体看 OPPO 校招薪资还是很不错的,跟互联网公司基本差不多,年包基本在 30w+,其中 1.2kX12 属于房补,今年的薪资情况基本跟去年差不多。
也有不少同学好奇 OPPO 面试会问什么?
这次,就来分享一位同学的 OPPO 校招后端开发的面经,考察了 Java 并发+MySQL+操作系统+计算机网络这几个方面的知识,有些地方考察的还是比较底层和细节。
大家觉得难度如何呢?
Java
线程池是什么,有什么作用?
线程池是为了减少频繁的创建线程和销毁线程带来的性能损耗,线程池的工作原理如下图:
线程池分为核心线程池,线程池的最大容量,还有等待任务的队列,提交一个任务,如果核心线程没有满,就创建一个线程,如果满了,就是会加入等待队列,如果等待队列满了,就会增加线程,如果达到最大线程数量,如果都达到最大线程数量,就会按照一些丢弃的策略进行处理。
线程之间的状态如何变化?
源自《Java并发编程艺术》 java.lang.Thread.State枚举类中定义了六种线程的状态,可以调用线程Thread中的getState()方法获取当前线程的状态。
线程状态 | 解释 |
---|---|
NEW | 尚未启动的线程状态,即线程创建,还未调用start方法 |
RUNNABLE | 就绪状态(调用start,等待调度)+正在运行 |
BLOCKED | 等待监视器锁时,陷入阻塞状态 |
WAITING | 等待状态的线程正在等待另一线程执行特定的操作(如notify) |
TIMED_WAITING | 具有指定等待时间的等待状态 |
TERMINATED | 线程完成执行,终止状态 |
Java 中的锁机制,分别讲讲?
Java中的锁是用于管理多线程并发访问共享资源的关键机制。锁可以确保在任意给定时间内只有一个线程可以访问特定的资源,从而避免数据竞争和不一致性。Java提供了多种锁机制,可以分为以下几类:
内置锁(synchronized):Java中的 synchronized
关键字是内置锁机制的基础,可以用于方法或代码块。当一个线程进入synchronized
代码块或方法时,它会获取关联对象的锁;当线程离开该代码块或方法时,锁会被释放。如果其他线程尝试获取同一个对象的锁,它们将被阻塞,直到锁被释放。其中,syncronized加锁时有无锁、偏向锁、轻量级锁和重量级锁几个级别。偏向锁用于当一个线程进入同步块时,如果没有任何其他线程竞争,就会使用偏向锁,以减少锁的开销。轻量级锁使用线程栈上的数据结构,避免了操作系统级别的锁。重量级锁则涉及操作系统级的互斥锁。ReentrantLock: java.util.concurrent.locks.ReentrantLock
是一个显式的锁类,提供了比synchronized
更高级的功能,如可中断的锁等待、定时锁等待、公平锁选项等。ReentrantLock
使用lock()
和unlock()
方法来获取和释放锁。其中,公平锁按照线程请求锁的顺序来分配锁,保证了锁分配的公平性,但可能增加锁的等待时间。非公平锁不保证锁分配的顺序,可以减少锁的竞争,提高性能,但可能造成某些线程的饥饿。读写锁(ReadWriteLock): java.util.concurrent.locks.ReadWriteLock
接口定义了一种锁,允许多个读取者同时访问共享资源,但只允许一个写入者。读写锁通常用于读取远多于写入的情况,以提高并发性。乐观锁和悲观锁:悲观锁(Pessimistic Locking)通常指在访问数据前就锁定资源,假设最坏的情况,即数据很可能被其他线程修改。 synchronized
和ReentrantLock
都是悲观锁的例子。乐观锁(Optimistic Locking)通常不锁定资源,而是在更新数据时检查数据是否已被其他线程修改。乐观锁常使用版本号或时间戳来实现。自旋锁:自旋锁是一种锁机制,线程在等待锁时会持续循环检查锁是否可用,而不是放弃CPU并阻塞。通常可以使用CAS来实现。这在锁等待时间很短的情况下可以提高性能,但过度自旋会浪费CPU资源。
synchronized 和 lock 的实现原理是什么
synchronized 的实现原理:
字节码层面:通过 monitorenter 和 monitorexit 指令来实现加锁和解锁。 底层机制:基于 JVM 中的 Monitor 来实现,本质上依赖操作系统的互斥锁来保证共享数据不会被并发访问。 锁升级:从偏向锁开始,随着锁竞争的不断升级,逐步演化至轻量级锁,最终变为重量级锁。
Lock 的实现原理:
以 ReentrantLock 为例,通过 AQS(AbstractQueuedSynchronizer)来实现。AQS 使用一个 int 成员变量表示同步状态,通过内置的双向链表来完成资源获取线程的排队工作。 Lock 可以通过 tryAcquire 和 tryRelease 等方法来实现对同步状态的获取和释放。 当 AQS 需要阻塞或唤醒一个线程时,会使用 LockSupport 工具类来完成相应的工作。
AQS的原理是什么?
AQS(QueuedSynchronizer)是一个用于构建锁和同步容器的框架。它使用一个 int 成员变量表示同步状态,通过内置的 FIFO 队列来完成获取资源线程的排队工作。AQS 使用 CAS 对该同步状态进行原子操作实现对其值的修改。
AQS 内部维护了一个 CLH 队列来管理锁。当线程获取同步状态失败(锁)时,AQS 会将当前线程以及等待状态等信息构造成一个节点并将其加入同步队列,同时会阻塞当前线程。当同步状态释放时,则会把节点中的线程唤醒,使其再次尝试获取同步状态。
AQS 定义了两种资源共享方式:独占锁和共享锁。独占锁在同一时刻只能有一个线程能获取到锁,如 ReentrantLock 采用独占模式。共享锁允许多个线程同时获取锁,多个线程可同时执行,如 Semaphore、CountDownLatch 等。
在 AQS 中,节点的状态不再仅仅是简单的布尔值,而是有更复杂的定义。并且,AQS 中的队列是双向链表,通过 Head、Tail 头尾两个节点来组成队列结构,通过 volatile 修饰保证可见性。Head 节点为已获取锁的节点,是一个虚拟节点,节点本身不持有具体的线程对象。获取不到同步状态,会将节点进行自旋获取锁,自旋一定次数失败后会将线程阻塞,相对于 CLH 队列性能较好。
总结下来,用大白话说就是,AQS
是基于 CLH 队列,使用volatile
修饰共享变量state
,线程通过CAS
方式去改变state
状态值,如果成功则获取锁成功,失败则进入等待队列,等待被唤醒的线程同步器框架。
Java申请一块内存或者新建一个对象,他的地址是在创建对象的时候就有的吗?
对象的内存地址是在创建对象的过程中由 JVM 自动分配的。程序员对对象的访问是通过引用实现的,而不是通过直接操作内存地址。以下是相关细节:
1、内存分配过程
对象创建:当你使用
new
关键字创建一个对象时,例如MyClass obj = new MyClass();
,JVM 会进行以下操作:内存分配:JVM 从堆内存中分配足够的空间来存储这个对象的实例,包括对象的字段和元数据(如对象的头部信息)。
执行构造函数:在分配内存后,JVM 会调用对象的构造函数来初始化对象。
2、内存地址的获取
对象地址:当对象被创建并且内存分配完成后,JVM 会返回一个对该对象的引用,虽然在 Java 中并没有直接展示内存地址。但这个引用实际上包含了指向对象在内存中的实际地址。 引用的表现:对于程序员来说,操作对象时使用的是引用,而不是直接操作内存地址。这种设计提高了安全性和可移植性,避免了许多常见的内存管理错误。
3、对象地址的时机
在创建时:对象的内存地址是在调用 new
关键字(或类似的内存分配方法)时确定的,在对象创建完成之前,该地址并不可用。动态分配:内存的地址是动态分配的,意味着每次创建对象时,JVM 可能会给不同的对象分配不同的内存地址。这个地址在程序运行时是动态变化的。
4.、使用 System.identityHashCode()
如果你希望通过对象引用查看其“地址”的哈希值(实际上是对象的哈希代码),可以使用 System.identityHashCode(obj)
。这个方法会返回对象的哈希码,可以用作对象在 JVM 中的唯一标识(并不等于内存地址,但可以作为识别对象的依据)。
例子
public class MyClass {
public int value;
public MyClass(int value) {
this.value = value;
}
public static void main(String[] args) {
MyClass obj = new MyClass(10);
System.out.println("对象的哈希代码: " + System.identityHashCode(obj));
}
}
操作系统
进程之间的地址空间是否可以共享?
进程之间的地址空间通常是不共享的。这是因为每个进程都有自己的虚拟地址空间,操作系统为每个进程分配独立的内存区域,以保护进程之间的内存不受干扰。这样设计可以有效地提高系统的安全性和稳定性,防止一个进程意外或恶意地影响其他进程的运行。
尽管进程之间的地址空间通常不共享,但有多种机制可以让它们进行通信:
共享内存:操作系统提供共享内存机制,允许多个进程访问同一块物理内存区域。这样,进程可以像使用常规内存一样访问共享内存。这种方法非常高效,因为它避免了数据的复制。然而,使用共享内存时,进程之间还是需要一些同步机制(如信号量、互斥锁)来避免数据竞争问题。 管道与消息队列:进程可以通过管道(管道是单向的)和消息队列等机制进行数据交换。这些机制提供了一种可靠的方式来传递信息,而不需要直接共享内存。 套接字:套接字允许不同主机上的进程进行通信,也可以在同一台机器上的不同进程之间进行 IPC。 文件:进程还可以通过读写文件的方式交换数据。虽然这种方法速度较慢,但在一定情况下可以保证数据的持久性。
线程之间是否可以共享内存空间?
可以的,线程可以共享全局变量、静态变量以及Heap(堆)中的动态分配的内存。
尽管线程之间可以共享内存空间,但这也引入了数据一致性的问题:
竞争条件:多个线程同时访问共享数据并试图修改同一数据时,会导致数据竞争和不一致性。例如,一个线程在读取数据时,另一个线程可能正在修改该数据。 同步机制:为了避免竞争条件,通常需要使用同步机制,如互斥锁(mutex)、读写锁(rwlock)、信号量(semaphore)、条件变量等。这些机制可以确保在某一时刻只有一个线程能够访问特定的共享资源,从而维护数据的一致性。
在设计多线程程序时,确保对共享数据的访问是线程安全的非常重要。程序员需要采取适当的方法来保护共享资源,以避免潜在的问题。常用的方法包括:
使用互斥锁:将对共享数据的访问代码块用互斥锁包围。 使用原子操作:某些操作可以使用寻址机制直接在硬件层面实现原子化,降低线程间的干扰。 线程局部存储(Thread Local Storage):在某些情形下,可以让每个线程有自己的数据副本,以减少共享带来的复杂性。
操作系统如何调度线程?
操作系统可以采用不同的调度策略,主要有以下几种:
先来先服务:按照线程进入就绪队列的顺序进行调度,较为简单,但可能导致“饥饿”现象(即长时间无法得到CPU时间的线程)。 短作业优先:优先调度执行时间短的线程,这有助于提高系统的吞吐量,但缺点是可能导致长作业被饿死。 轮转调度:为各个线程分配固定的时间片(time slice),如在多任务环境中,每个线程按照顺序获得CPU执行权,时间片耗尽后切换到下一个线程。 优先级调度:根据线程的优先级进行调度,高优先级的线程先执行。这种调度可结合时间片,会造成低优先级线程可能较长时间得不到执行。 多级反馈队列:使用多个就绪队列,线程可以根据运行情况在不同队列间动态变化,以提高系统的灵活性和响应时间。
当操作系统从一个线程切换到另一个线程时,会发生上下文切换(Context Switch),其过程一般包括以下步骤:
保存当前线程的上下文:在内存中保存当前线程的 CPU 寄存器状态、程序计数器、堆栈指针以及其他此线程需要的信息。 选择下一个线程:根据调度策略选择下一个要执行的线程。 加载下一个线程的上下文:从内存中恢复所选线程的 CPU 寄存器状态、程序计数器、堆栈指针等信息,使得该线程能够继续执行。 转移控制:通过调度程序恢复新的线程运行,操作系统进入该线程的执行状态。
页面的换入换出机制是怎么的?
在操作系统中,由于虚拟地址空间往往大于实际物理内存大小,所以会存在虚拟地址到物理地址的映射重叠。
当进程当前访问的内容不在物理内存中时,就需要从磁盘导入,这称为内存换入; 而当分配给进程的内存页有限,需要将不活跃的内存换出到磁盘,以腾出空间给当前需要的页面,这称为内存换出。
内存换入时,进程根据逻辑地址访问内存,逻辑地址转换为虚拟地址,再根据虚拟地址得出页号查页表得出物理页框号。若页表显示该页号未映射到具体物理页框号或页内容不在内存中,会产生缺页中断,中断处理程序负责将内容从磁盘中导入并更新页表。
内存换出的关键在于选择哪一页,常见的页面置换算法有 FIFO(先进先出)、OPTIMAL(最优算法)、LRU(最近最少使用)等。
中断是什么?
CPU停下当前的工作任务,去处理其他事情,处理完后回来继续执行刚才的任务,这一过程便是中断。
中断分为外部中断和内部中断:
外部中断分为可屏蔽中断和不可屏蔽中断:
可屏蔽中断:通过INTR线向CPU请求的中断,主要来自外部设备如硬盘,打印机,网卡等。此类中断并不会影响系统运行,可随时处理,甚至不处理,所以名为可屏蔽。
不可屏蔽中断:通过NMI线向CPU请求的中断,如电源掉电,硬件线路故障等。这里不可屏蔽的意思不是不可以屏蔽,不建议屏蔽,而是问题太大,屏蔽不了,不能屏蔽的意思。注:INTR和NMI都是CPU的引脚
内部中断分为陷阱、故障、终止:
陷阱:是一种有意的,预先安排的异常事件,一般是在编写程序时故意设下的陷阱指令,而后执行到陷阱指令后,CPU将会调用特定程序进行相应的处理,处理结束后返回到陷阱指令的下一条指令。如系统调用,程序调试功能等。如printf函数,最底层的实现中会有一条int 0x80指令,这就是一条陷阱指令,使用0x80号中断进行系统调用。
故障:故障是在引起故障的指令被执行,但还没有执行结束时,CPU检测到的一类的意外事件。出错时交由故障处理程序处理,如果能处理修正这个错误,就将控制返回到引起故障的指令即CPU重新执这条指令。如果不能处理就报错**。常见的故障为缺页,当CPU引用的虚拟地址对应的物理页不存在时就会发生故障。缺页异常是能够修正的,有着专门的缺页处理程序,它会将缺失的物理页从磁盘中重新调进主存。而后再次执行引起故障的指令时便能够顺利执行了。
终止:执行指令的过程中发生了致命错误,不可修复,程序无法继续运行,只能终止,通常会是一些硬件的错误。终止处理程序不会将控制返回给原程序,而是直接终止原程序
物理内存不够,为什么程序可以很大?
应用程序通过 malloc 函数申请内存的时候,实际上申请的是虚拟内存,此时并不会分配物理内存。
当应用程序读写了这块虚拟内存,CPU 就会去访问这个虚拟内存, 这时会发现这个虚拟内存没有映射到物理内存, CPU 就会产生缺页中断,进程会从用户态切换到内核态,并将缺页中断交给内核的 Page Fault Handler (缺页中断函数)处理。
缺页中断处理函数会看是否有空闲的物理内存:
如果有,就直接分配物理内存,并建立虚拟内存与物理内存之间的映射关系。 如果没有空闲的物理内存,那么内核就会开始进行回收内存的工作,如果回收内存工作结束后,空闲的物理内存仍然无法满足此次物理内存的申请,那么内核就会放最后的大招了触发 OOM (Out of Memory)机制。
32 位操作系统和 64 位操作系统的虚拟地址空间大小是不同的,在 Linux 操作系统中,虚拟地址空间的内部又被分为内核空间和用户空间两部分,如下所示:
通过这里可以看出:
32 位系统的内核空间占用 1G,位于最高处,剩下的 3G 是用户空间;因为进程最大只能申请 3 GB 大小的虚拟内存,所以直接申请 8G 内存,会申请失败。 64 位系统的内核空间和用户空间都是 128T,分别占据整个内存空间的最高和最低处,剩下的中间部分是未定义的。因为进程最大只能申请 128 TB 大小的虚拟内存,即使物理内存只有 4GB,申请 8G 内存也是没问题,因为申请的内存是虚拟内存,等这块虚拟内存被访问了,因为物理空间不够,就会发生 OOM。
缺页中断是什么,操作系统会干什么 ?
当 CPU 访问的页面不在物理内存时,便会产生一个缺页中断,请求操作系统将所缺页调入到物理内存。那它与一般中断的主要区别在于:
缺页中断在指令执行「期间」产生和处理中断信号,而一般中断在一条指令执行「完成」后检查和处理中断信号。 缺页中断返回到该指令的开始重新执行「该指令」,而一般中断返回回到该指令的「下一个指令」执行。
我们来看一下缺页中断的处理流程,如下图:
在 CPU 里访问一条 Load M 指令,然后 CPU 会去找 M 所对应的页表项。 如果该页表项的状态位是「有效的」,那 CPU 就可以直接去访问物理内存了,如果状态位是「无效的」,则 CPU 则会发送缺页中断请求。 操作系统收到了缺页中断,则会执行缺页中断处理函数,先会查找该页面在磁盘中的页面的位置。 找到磁盘中对应的页面后,需要把该页面换入到物理内存中,但是在换入前,需要在物理内存中找空闲页,如果找到空闲页,就把页面换入到物理内存中。 页面从磁盘换入到物理内存完成后,则把页表项中的状态位修改为「有效的」。 最后,CPU 重新执行导致缺页异常的指令。
上面所说的过程,第 4 步是能在物理内存找到空闲页的情况,那如果找不到呢?
找不到空闲页的话,就说明此时内存已满了,这时候,就需要「页面置换算法」选择一个物理页,如果该物理页有被修改过(脏页),则把它换出到磁盘,然后把该被置换出去的页表项的状态改成「无效的」,最后把正在访问的页面装入到这个物理页中。
这里提一下,页表项通常有如下图的字段:
那其中:
状态位:用于表示该页是否有效,也就是说是否在物理内存中,供程序访问时参考。 访问字段:用于记录该页在一段时间被访问的次数,供页面置换算法选择出页面时参考。 修改位:表示该页在调入内存后是否有被修改过,由于内存中的每一页都在磁盘上保留一份副本,因此,如果没有修改,在置换该页时就不需要将该页写回到磁盘上,以减少系统的开销;如果已经被修改,则将该页重写到磁盘上,以保证磁盘中所保留的始终是最新的副本。 硬盘地址:用于指出该页在硬盘上的地址,通常是物理块号,供调入该页时使用。
这里我整理了虚拟内存的管理整个流程,你可以从下面这张图看到:
所以,页面置换算法的功能是,当出现缺页异常,需调入新页面而内存已满时,选择被置换的物理页面,也就是说选择一个物理页面换出到磁盘,然后把需要访问的页面换入到物理页。
MySQL
MySQL 的索引结构是什么,为什么用B+树
MySQL InnoDB 引擎是用了B+树作为了索引的数据结构,原因有:
B+ 树的非叶子节点不存放实际的记录数据,仅存放索引,因此数据量相同的情况下,相比存储即存索引又存记录的 B 树,B+树的非叶子节点可以存放更多的索引,因此 B+ 树可以比 B 树更「矮胖」,查询底层节点的磁盘 I/O次数会更少。 B+ 树有大量的冗余节点(所有非叶子节点都是冗余索引),这些冗余索引让 B+ 树在插入、删除的效率都更高,比如删除根节点的时候,不会像 B 树那样会发生复杂的树的变化; B+ 树叶子节点之间用链表连接了起来,有利于范围查询,而 B 树要实现范围查询,因此只能通过树的遍历来完成范围查询,这会涉及多个节点的磁盘 I/O 操作,范围查询效率不如 B+ 树。
网络
TCP 丢包了会怎么样?
重传机制:当TCP发送数据包时,会启动一个定时器来监控该数据包的确认(ACK)是否会收到。如果在设定的时间内没有收到确认,TCP会认为数据包已经丢失,并会重新发送该数据包。 流量控制:TCP通过滑动窗口机制来控制数据流的速率。当网络丢包造成拥塞时,TCP会减小窗口大小,从而减缓数据发送速率,避免进一步的丢包。 拥塞控制:TCP使用各种算法(如慢启动、拥塞避免、快重传和快恢复)来检测和处理网络拥塞情况。通过这些算法,TCP能够调整其发送速率以适应网络的当前状态,减少丢包的发生。
拥塞避免的方法有哪些?
TCP 拥塞控制是为了防止网络过载而采取的一系列措施,这些措施能够动态调整数据传输速率以适应网络状态。TCP 的拥塞控制主要包括以下几种算法和策略:
慢启动:当 TCP 连接建立或发生丢包重传时,TCP 从一个相对较小的拥塞窗口(cwnd)开始。这一窗口随着每次成功收到确认(ACK)而指数增长(每收到一个 ACK,窗口大小增加 1),目的是快速探测可用带宽。 拥塞避免:当拥塞窗口达到一个阈值(ssthresh, slow start threshold)后,TCP 进入拥塞避免阶段。在这个阶段,拥塞窗口以线性增长方式增加(每经过一个轮次,增加 1,即每 RTT 增加 1),这样可以减少发生拥塞的风险。 快速重传(Fast Retransmit):发送方在未收到某个数据包的 ACK 时,收到三个重复的 ACK(即接收方多次确认相同的数据包)时,发送方会立即重传缺失的数据包,而不是等待重传定时器超时。这有助于减少由于丢包导致的延迟。 快速恢复(Fast Recovery):在快速重传机制之后,TCP 会进入快速恢复阶段。此时,ssthresh 被设置为当前的 cwnd 值的一半,接着 cwnd 被设置为 ssthresh 加上三个未确认的 MSS(最大段大小)。这样,TCP 在丢包后不会完全降低其传输速率,而是球立一个新的目标以便快速返回正常状态。
网络中序号为 101 数据发送失败了,102,103 发送成功了,102, 103 还会重新发送吗?
序号为 102 和 103 的数据包不会被重传,因为它们已经成功发送并且接收方已经接收了它们。但是,由于序号为 101 的数据包未成功接收,接收方会继续期望接收 101,当收到了 102 和 103 数据的时候,会重复响应 101 数据的ACK,让发送方重传 101。
虽然 102 和 103 能被接收方成功接收,但是应用层此时还是无法读到这个数据,因为这时候 101 是丢失的,代表这时候的数据并不是连续的,应用层只能按序的读取数据,如果数据是乱序的,就无法读取。
四次挥手的 timewait 的作用是什么
TIME_WAIT 状态的存在是为了确保网络连接的可靠关闭。只有主动发起关闭连接的一方(即主动关闭方)才会有 TIME_WAIT 状态。
TIME_WAIT 状态的需求主要有两个原因:
防止具有相同「四元组」的「旧」数据包被收到:在网络通信中,每个 TCP 连接都由源 IP 地址、源端口号、目标 IP 地址和目标端口号这四个元素唯一标识,称为「四元组」。当一方主动关闭连接后,进入 TIME_WAIT 状态,它仍然可以接收到一段时间内来自对方的延迟数据包。这是因为网络中可能存在被延迟传输的数据包,如果没有 TIME_WAIT 状态的存在,这些延迟数据包可能会被错误地传递给新的连接,导致数据混乱。通过保持 TIME_WAIT 状态,可以防止旧的数据包干扰新的连接。 保证「被动关闭连接」的一方能被正确关闭:当连接的被动关闭方接收到主动关闭方的 FIN 报文(表示关闭连接),它需要发送一个确认 ACK 报文给主动关闭方,以完成连接的关闭。然而,网络是不可靠的,ACK 报文可能会在传输过程中丢失。如果主动关闭方在收到 ACK 报文之前就关闭连接,被动关闭方将无法正常完成连接的关闭。TIME_WAIT 状态的存在确保了被动关闭方能够接收到最后的 ACK 报文,从而帮助其正常关闭连接。
场景题
如何设计一个可重入的分布式锁,用什么结构设计?
可重入锁允许同一线程在持有锁的情况下多次获得锁而不会产生死锁。当线程请求锁时,如果它已经拥有了该锁,则可以直接获得锁,锁的计数器会增加。在释放锁时,计数器会减少,只有当计数器为零时,锁才会真正释放。
在分布式系统中,通常会使用诸如 Redis、Zookeeper 或 etcd 等组件来实现锁的管理。下面以 Redis 为例,简单描述如何设计一个可重入的分布式锁。
设计要素:
锁的标识符(Lock ID):用于标识锁的唯一性。 持有者标识(Owner ID):线程或进程的唯一标识符,通常是线程的 ID 或进程的 ID。 计数器:记录当前持有锁的次数。 过期时间:为了避免死锁,锁需要设定一个合理的超时时间。
我们可以在 Redis 中使用一个哈希表或简单的键值对来存储锁的状态。使用一个 key(如 lock:<resource>
)来表示锁,包含以下字段:
owner
: 当前持有锁的线程/进程标识符count
: 当前计数器,表示获得锁的次数expires_at
: 锁的到期时间,用于防止死锁
示例数据结构
{
"key": "lock:resource_1",
"value": {
"owner": "thread_id_or_process_id",
"count": 3,
"expires_at": "2023-10-01T12:00:00Z"
}
}
以下是围绕这个锁结构的基本操作:
获取锁 (Lock Acquisition):尝试设置 lock: 的值,如果这个 key 不存在(即没有锁),那么可以创建该 key,并设置 owner 为当前线程/进程的唯一ID,count 设为 1,expires_at 设为当前时间加上锁的过期时间。如果该 key 已存在且 owner 是当前线程/进程的 ID,则增加 count 并更新 expires_at。 释放锁 (Lock Release):检查当前的 owner 是否是当前线程/进程的 ID。如果是,减少 count。如果 count 减少到 0,则删除该 key。如果在持有锁的情况下,锁已过期,系统会根据 expires_at 检查锁是否可释放,避免死锁。 锁续期 (Lock Renewal):如果当前线程在执行过程中需要继续持有锁,可以在逻辑处理中重新设置 expires_at. 超时与故障恢复:可以设置锁的自动超时时间,例如,设定一个最大持锁时间,超时后锁自动释放。这可以在一定程度上防止死锁的情况。
整体流程示例
获取锁:线程A调用获取锁的 API。如果成功,返回锁的状态。如果线程B也尝试获取同一把锁,则会返回锁已被占用。
释放锁:线程A在完成任务时调用释放锁的 API,递减
count
字段。如果count
达到 0,删除锁并释放资源。续约锁:线程A在处理过程中可以根据需要续约锁,更新
expires_at
。
最后欢迎加入苏三的星球,你将获得:商城系统实战、秒杀系统实战、代码生成工具
、系统设计、性能优化、技术选型、高频面试题、底层原理、Spring源码解读、工
作经验分享、痛点问题等多个优质专栏。
还有1V1答疑、修改简历、职业规划、送书活动、技术交流。
目前星球已经更新了4400+篇优质内容,还在持续爆肝中..星球已经被官方推荐了3次,收到了小伙伴们的一致好评。戳我加入学习,已有1400+小伙伴加入学习。
我的技术专栏《程序员最常见的100个问题》,目前已经更新了80篇干货文章,里面收录了很多踩坑经历,对你的职业生涯或许有些帮助,最近收到的好评挺多的。
这个专栏总结了我10年工作中,遇到过的100个非常有代表性的技术问题,非常有参考和学习价值。
Java、Spring、分布式、高并发、数据库、海量数据、线上问题什么都有。
每篇文章从发现问题、分析问题、解决问题和问题总结等多个维度,深入浅出,分享了很多技术细节,定位和排查问题思路,解决问题技巧,以及实际工作经验。
你能从中学到很多有用知识,帮你少走很多弯路。
扫描下方二维码即可订阅:
原价199,现价只需23,即将涨价。