0%

介绍一下线程池的工作原理

线程池是为了减少频繁的创建线程和销毁线程带来的性能损耗

线程池分为核心线程池,线程池的最大容量,还有等待任务的队列,提交一个任务,如果核心线程没有满,就创建一个线程,如果满了,就是会加入等待队列,如果等待队列满了,就会增加线程,如果达到最大线程数量,就会按照一些丢弃的策略进行处理。

线程池的参数有哪些?

线程池的构造函数有7个参数:

  • corePoolSize:线程池核心线程数量。默认情况下,线程池中线程的数量如果 <= corePoolSize,那么即使这些线程处于空闲状态,那也不会被销毁。
  • maximumPoolSize:线程池中最多可容纳的线程数量。当一个新任务交给线程池,如果此时线程池中有空闲的线程,就会直接执行,如果没有空闲的线程且当前线程池的线程数量小于maximumPoolSize,就会创建新的线程来执行任务,否则就会将该任务加入到阻塞队列中,如果阻塞队列满了,就会创建一个新线程,从阻塞队列头部取出一个任务来执行,并将新任务加入到阻塞队列末尾。如果当前线程池中线程的数量等于maximumPoolSize,就不会创建新线程,就会去执行拒绝策略。
  • keepAliveTime:当线程池中线程的数量大于corePoolSize,并且某个线程的空闲时间超过了keepAliveTime,那么这个线程就会被销毁。
  • unit:就是keepAliveTime时间的单位。
  • workQueue:工作队列。当没有空闲的线程执行新任务时,该任务就会被放入工作队列中,等待执行。
  • threadFactory:线程工厂。可以用来给线程取名字等等
  • handler:拒绝策略。

线程池工作队列满了有哪些拒绝策略?

当线程池的任务队列满了之后,线程池会执行指定的拒绝策略来应对,常用的四种拒绝策略包括:
CallerRunsPolicy、AbortPolicy、DiscardPolicy、DiscardOldestPolicy,此外,还可以通过实现RejectedExecutionHandler接口来自定义拒绝策略。

四种预置的拒绝策略:

  • CallerRunsPolicy,使用线程池的调用者所在的线程去执行被拒绝的任务,除非线程池被停止或者线程池的任务队列已有空缺。
  • AbortPolicy,直接抛出一个任务被线程池拒绝的异常。
  • DiscardPolicy,不做任何处理,静默拒绝提交的任务。
  • DiscardOldestPolicy,抛弃最老的任务,然后执行该任务。
  • 自定义拒绝策略,通过实现接口可以自定义任务拒绝策略。

有线程池参数设置的经验吗?

核心线程数(corePoolSize)设置的经验:

  • CPU密集型:corePoolSize = CPU核数 + 1(避免过多线程竞争CPU)
  • IO密集型:corePoolSize = CPU核数 x 2(或更高,具体看IO等待时间)

场景一:电商场景,特点瞬时高并发、任务处理时间短,线程池的配置可设置如下:

1
2
3
4
5
6
7
new ThreadPoolExecutor(
16, // corePoolSize = 16(假设8核CPU × 2)
32, // maximumPoolSize = 32(突发流量扩容)
10, TimeUnit.SECONDS, // 非核心线程空闲10秒回收
new SynchronousQueue<>(), // 不缓存任务,直接扩容线程
new AbortPolicy() // 直接拒绝,避免系统过载
);

说明:

  • 使用 SynchronousQueue 确保任务直达线程,避免队列延迟。
  • 拒绝策略快速失败,前端返回“活动火爆”提示,结合降级策略(如缓存预热)。

场景二:后台数据处理服务,特点稳定流量、任务处理时间长(秒级)、允许一定延迟,线程池的配置可设置如下:

1
2
3
4
5
6
7
new ThreadPoolExecutor(
8, // corePoolSize = 8(8核CPU)
8, // maximumPoolSize = 8(禁止扩容,避免资源耗尽)
0, TimeUnit.SECONDS, // 不回收线程
new ArrayBlockingQueue<>(1000), // 有界队列,容量1000
new CallerRunsPolicy() // 队列满后由调用线程执行
);

说明:

  • 固定线程数避免资源波动,队列缓冲任务,拒绝策略兜底。
  • 配合监控告警(如队列使用率>80%触发扩容)。

场景三:微服务HTTP请求处理,特点IO密集型、依赖下游服务响应时间,线程池的配置可设置如下:

1
2
3
4
5
6
7
new ThreadPoolExecutor(
16, // corePoolSize = 16(8核 × 2)
64, // maximumPoolSize = 64(应对慢下游)
60, TimeUnit.SECONDS, // 非核心线程空闲60秒回收
new LinkedBlockingQueue<>(200), // 有界队列容量200
new CustomRetryPolicy() // 自定义拒绝策略(重试或降级)
);

说明:

  • 根据下游RT(响应时间)调整线程数,队列防止瞬时峰值。
  • 自定义拒绝策略将任务暂存Redis,异步重试。

核心线程数设置为0可不可以?

可以,当核心线程数为0的时候,会创建一个非核心线程进行执行。
从下面的源码也可以看到,当核心线程数为 0 时,来了一个任务之后,会先将任务添加到任务队列,同时也会判断当前工作的线程数是否为 0,如果为 0,则会创建线程来执行线程池的任务。

线程池种类有哪些?

  • ScheduledThreadPool:可以设置定期的执行任务,它支持定时或周期性执行任务,比如每隔 10 秒钟执行一次任务,我通过这个实现类设置定期执行任务的策略。
  • FixedThreadPool:它的核心线程数和最大线程数是一样的,所以可以把它看作是固定线程数的线程池,它的特点是线程池中的线程数除了初始阶段需要从 0 开始增加外,之后的线程数量就是固定的,就算任务数超过线程数,线程池也不会再创建更多的线程来处理任务,而是会把超出线程处理能力的任务放到任务队列中进行等待。而且就算任务队列满了,到了本该继续增加线程数的时候,由于它的最大线程数和核心线程数是一样的,所以也无法再增加新的线程了。
  • CachedThreadPool:可以称作可缓存线程池,它的特点在于线程数是几乎可以无限增加的(实际最大可以达到 Integer.MAX_VALUE,为 2^31-1,这个数非常大,所以基本不可能达到),而当线程闲置时还可以对线程进行回收。也就是说该线程池的线程数量不是固定不变的,当然它也有一个用于存储提交任务的队列,但这个队列是 SynchronousQueue,队列的容量为0,实际不存储任何任务,它只负责对任务进行中转和传递,所以效率比较高。
  • SingleThreadExecutor:它会使用唯一的线程去执行任务,原理和 FixedThreadPool 是一样的,只不过这里线程只有一个,如果线程在执行任务的过程中发生异常,线程池也会重新创建一个线程来执行后续的任务。这种线程池由于只有一个线程,所以非常适合用于所有任务都需要按被提交的顺序依次执行的场景,而前几种线程池不一定能够保障任务的执行顺序等于被提交的顺序,因为它们是多线程并行执行的
  • SingleThreadScheduledExecutor:它实际和 ScheduledThreadPool 线程池非常相似,它只是ScheduledThreadPool 的一个特例,内部只有一个线程。

线程池一般是怎么用的?

Java 中的 Executors 类定义了一些快捷的工具方法,来帮助我们快速创建线程池。《阿里巴巴 Java 开发手册》中提到,禁止使用这些方法来创建线程池,而应该手动 new ThreadPoolExecutor 来创建线程池。这一条规则的背后,是大量血淋淋的生产事故,最典型的就是 newFixedThreadPool 和newCachedThreadPool,可能因为资源耗尽导致 OOM 问题。

所以,不建议使用 Executors 提供的两种快捷的线程池,原因如下:

  • 我们需要根据自己的场景、并发情况来评估线程池的几个核心参数,包括核心线程数、最大线程数、线程回收策略、工作队列的类型,以及拒绝策略,确保线程池的工作行为符合需求,一般都需要设置有界的工作队列和可控的线程数。
  • 任何时候,都应该为自定义线程池指定有意义的名称,以方便排查问题。当出现线程数量暴增、线程死锁、线程占用大量 CPU、线程执行出现异常等问题时,我们往往会抓取线程栈。此时,有意义的线程名称,就可以方便我们定位问题。

除了建议手动声明线程池以外,我还建议用一些监控手段来观察线程池的状态。线程池这个组件往往会表现得任劳任怨、默默无闻,除非是出现了拒绝策略,否则压力再大都不会抛出一个异常。如果我们能提前观察到线程池队列的积压,或者线程数量的快速膨胀,往往可以提早发现并解决问题。

线程池中shutdown (),shutdownNow()这两个方法有什么作用?

  • shutdown使用了以后会置状态为SHUTDOWN,正在执行的任务会继续执行下去,没有被执行的则中断。此时,则不能再往线程池中添加任何任务,否则将会抛出 RejectedExecutionException 异常
  • shutdownNow 为STOP,并试图停止所有正在执行的线程,不再处理还在池队列中等待的任务,当然,它会返回那些未执行的任务。 它试图终止线程的方法是通过调用 Thread.interrupt() 方法来实现的,但是这种方法的作用有限,如果线程中没有sleep 、wait、Condition、定时锁等应用,interrupt()方法是无法中断当前的线程的。所以,ShutdownNow()并不代表线程池就一定立即就能退出,它可能必须要等待所有正在执行的任务都执行完成

提交给线程池中的任务可以被撤回吗?

可以,当向线程池提交任务时,会得到一个 Future 对象。这个 Future 对象提供了几种方法来管理任务的执行,包括取消任务。

取消任务的主要方法是 Future 接口中的 cancel(boolean mayInterruptIfRunning) 方法。这个方法尝试取消执行的任务。参数 mayInterruptIfRunning 指示是否允许中断正在执行的任务。如果设置为true ,则表示如果任务已经开始执行,那么允许中断任务;如果设置为 false ,任务已经开始执行则不会被中断。

juc包下你常用的类?

线程池相关:

  • ThreadPoolExecutor :最核心的线程池类,用于创建和管理线程池。通过它可以灵活地配置线程
    池的参数,如核心线程数、最大线程数、任务队列等,以满足不同的并发处理需求。
  • Executors :线程池工厂类,提供了一系列静态方法来创建不同类型的线程池,如newFixedThreadPool (创建固定线程数的线程池)、newCachedThreadPool (创建可缓存线程池)、 newSingleThreadExecutor (创建单线程线程池)等,方便开发者快速创建线程池。

并发集合类:

  • ConcurrentHashMap :线程安全的哈希映射表,用于在多线程环境下高效地存储和访问键值对。它采用了分段锁等技术,允许多个线程同时访问不同的段,提高了并发性能,在高并发场景下比传统的 Hashtable 性能更好。
  • CopyOnWriteArrayList :线程安全的列表,在对列表进行修改操作时,会创建一个新的底层数
    组,将修改操作应用到新数组上,而读操作仍然可以在旧数组上进行,从而实现了读写分离,提高了并发读的性能,适用于读多写少的场景。

同步工具类:

  • CountDownLatch :允许一个或多个线程等待其他一组线程完成操作后再继续执行。它通过一个计数器来实现,计数器初始化为线程的数量,每个线程完成任务后调用 countDown 方法将计数器减一,当计数器为零时,等待的线程可以继续执行。常用于多个线程完成各自任务后,再进行汇总或下一步操作的场景。
  • CyclicBarrier :让一组线程互相等待,直到所有线程都到达某个屏障点后,再一起继续执行。与CountDownLatch 不同的是, CyclicBarrier 可以重复使用,当所有线程都通过屏障后,计数器会重置,可以再次用于下一轮的等待。适用于多个线程需要协同工作,在某个阶段完成后再一起进入下一个阶段的场景。
  • Semaphore :信号量,用于控制同时访问某个资源的线程数量。它维护了一个许可计数器,线程在访问资源前需要获取许可,如果有可用许可,则获取成功并将许可计数器减一,否则线程需要等待,直到有其他线程释放许可。常用于控制对有限资源的访问,如数据库连接池、线程池中的线程
    数量等。

原子类:

  • AtomicInteger :原子整数类,提供了对整数类型的原子操作,如自增、自减、比较并交换等。通
    过硬件级别的原子指令来保证操作的原子性和线程安全性,避免了使用锁带来的性能开销,在多线
    程环境下对整数进行计数、状态标记等操作非常方便。
  • AtomicReference :原子引用类,用于对对象引用进行原子操作。可以保证在多线程环境下,对对象的更新操作是原子性的,即要么全部成功,要么全部失败,不会出现数据不一致的情况。常用于实现无锁数据结构或需要对对象进行原子更新的场景。

怎么保证多线程安全?

  • synchronized关键字:可以使用 synchronized 关键字来同步代码块或方法,确保同一时刻只有一个线程可以访问这些代码。对象锁是通过 synchronized 关键字锁定对象的监视器(monitor)来实现的。
  • volatile关键字: volatile 关键字用于变量,确保所有线程看到的是该变量的最新值,而不是可能存储在本地寄存器中的副本。
  • Lock接口和ReentrantLock类: java.util.concurrent.locks.Lock 接口提供了比synchronized 更强大的锁定机制, ReentrantLock 是一个实现该接口的例子,提供了更灵活的锁管理和更高的性能。
  • 原子类:Java并发库提供了原子类,如 AtomicInteger 、
    AtomicLong 等,这些类提供了原子操作,可以用于更新基本类型的变量而无需额外的同步。
  • 线程局部变量: ThreadLocal 类可以为每个线程提供独立的变量副本,这样每个线程都拥有自己的变量,消除了竞争条件。
  • 并发集合:使用 java.util.concurrent 包中的线程安全集合,如 ConcurrentHashMap 、ConcurrentLinkedQueue 等,这些集合内部已经实现了线程安全的逻辑。
  • JUC工具类: 使用 java.util.concurrent 包中的一些工具类可以用于控制线程间的同步和协作。例如: Semaphore 和 CyclicBarrier 等

Java中有哪些常用的锁,在什么场景下使用?

Java中的锁是用于管理多线程并发访问共享资源的关键机制。锁可以确保在任意给定时间内只有一个线程可以访问特定的资源,从而避免数据竞争和不一致性。Java提供了多种锁机制,可以分为以下几类:

  • 内置锁(synchronized):Java中的 synchronized 关键字是内置锁机制的基础,可以用于方法或代码块。当一个线程进入 synchronized 代码块或方法时,它会获取关联对象的锁;当线程离开该代码块或方法时,锁会被释放。如果其他线程尝试获取同一个对象的锁,它们将被阻塞,直到锁被
    释放。其中,syncronized加锁时有无锁、偏向锁、轻量级锁和重量级锁几个级别。偏向锁用于当一个线程进入同步块时,如果没有任何其他线程竞争,就会使用偏向锁,以减少锁的开销。轻量级锁使用线程栈上的数据结构,避免了操作系统级别的锁。重量级锁则涉及操作系统级的互斥锁。
  • ReentrantLock: java.util.concurrent.locks.ReentrantLock 是一个显式的锁类,提供了比 synchronized 更高级的功能,如可中断的锁等待、定时锁等待、公平锁选项等。ReentrantLock 使用 lock() 和 unlock() 方法来获取和释放锁。其中,公平锁按照线程请求锁的顺序来分配锁,保证了锁分配的公平性,但可能增加锁的等待时间。非公平锁不保证锁分配的顺序,可以减少锁的竞争,提高性能,但可能造成某些线程的饥饿。
  • 读写锁(ReadWriteLock): java.util.concurrent.locks.ReadWriteLock 接口定义了一种锁,允许多个读取者同时访问共享资源,但只允许一个写入者。读写锁通常用于读取远多于写入的
    情况,以提高并发性。
  • 乐观锁和悲观锁
    • 悲观锁通常指在访问数据前就锁定资源,假设最坏的情况,即数据很可能被其他线程修改。 synchronized 和 ReentrantLock 都是悲观锁的例子。
    • 乐观锁通常不锁定资源,而是在更新数据时检查数据是否已被其他线程修改。乐观锁常使用版本号或时间戳来实现。
  • 自旋锁:自旋锁是一种锁机制,线程在等待锁时会持续循环检查锁是否可用,而不是放弃CPU并阻塞。通常可以使用CAS来实现。这在锁等待时间很短的情况下可以提高性能,但过度自旋会浪费CPU资源。

CountDownLatch 是做什么的讲一讲?

CountDownLatch 是 Java 并发包中的一个同步工具类,用于让一个或多个线程等待其他线程完成操作后再继续执行。
其核心是通过一个计数器实现线程间的协调,常用于多线程任务的分阶段控制或主线程等待多个子线程就绪的场景,核心原理:

  • 初始化计数器:创建 CountDownLatch 时指定一个初始计数值(如 N )。
  • 等待线程阻塞:调用 await() 的线程会被阻塞,直到计数器变为 0。
  • 任务完成通知:其他线程完成任务后调用 countDown() ,使计数器减 1。
  • 唤醒等待线程:当计数器减到 0 时,所有等待的线程会被唤醒。

synchronized和reentrantlock及其应用场景?

synchronized 工作原理

synchronized是Java提供的原子性内置锁,这种内置的并且使用者看不到的锁也被称为监视器锁,使用synchronized之后,会在编译之后在同步的代码块前后加上monitorenter和monitorexit字节码指令,他依赖操作系统底层互斥锁实现。他的作用主要就是实现原子性操作和解决共享变量的内存可见性问题。

执行monitorenter指令时会尝试获取对象锁,如果对象没有被锁定或者已经获得了锁,锁的计数器+1。

此时其他竞争锁的线程则会进入等待队列中。执行monitorexit指令时则会把计数器-1,当计数器值为0时,则锁释放,处于等待队列中的线程再继续竞争锁。

synchronized是排它锁,当一个线程获得锁之后,其他线程必须等待该线程释放锁后才能获得锁,而且由于Java中的线程和操作系统原生线程是一一对应的,线程被阻塞或者唤醒时时会从用户态切换到内核态,这种转换非常消耗性能。

从内存语义来说,加锁的过程会清除工作内存中的共享变量,再从主内存读取,而释放锁的过程则是将工作内存中的共享变量写回主内存。

如果再深入到源码来说,synchronized实际上有两个队列waitSet和entryList。

  1. 当多个线程进入同步代码块时,首先进入entryList
  2. 有一个线程获取到monitor锁后,就赋值给当前线程,并且计数器+1
  3. 如果线程调用wait方法,将释放锁,当前线程置为null,计数器-1,同时进入waitSet等待被唤醒,调用notify或者notifyAll之后又会进入entryList竞争锁
  4. 如果线程执行完毕,同样释放锁,计数器-1,当前线程置为null

reentrantlock工作原理

ReentrantLock 的底层实现主要依赖于 AbstractQueuedSynchronizer(AQS)这个抽象类。AQS 是一
个提供了基本同步机制的框架,其中包括了队列、状态值等。

ReentrantLock 在 AQS 的基础上通过内部类 Sync 来实现具体的锁操作。不同的 Sync 子类实现了公平锁和非公平锁的不同逻辑:

  • 可中断性: ReentrantLock 实现了可中断性,这意味着线程在等待锁的过程中,可以被其他线程中断而提前结束等待。在底层,ReentrantLock 使用了与LockSupport.park() 和LockSupport.unpark() 相关的机制来实现可中断性。
    设置超时时间: ReentrantLock 支持在尝试获取锁时设置超时时间,即等待一定时间后如果还未获得锁,则放弃锁的获取。这是通过内部的 tryAcquireNanos 方法来实现的。
  • 公平锁和非公平锁: 在直接创建 ReentrantLock 对象时,默认情况下是非公平锁。公平锁是按照线程等待的顺序来获取锁,而非公平锁则允许多个线程在同一时刻竞争锁,不考虑它们申请锁的顺序。公平锁可以通过在创建 ReentrantLock 时传入 true 来设置,
  • 多个条件变量: ReentrantLock 支持多个条件变量,每个条件变量可以与一个 ReentrantLock 关联。这使得线程可以更灵活地进行等待和唤醒操作,而不仅仅是基于对象监视器的 wait() 和notify()。多个条件变量的实现依赖于 Condition 接口
  • 可重入性: ReentrantLock 支持可重入性,即同一个线程可以多次获得同一把锁,而不会造成死锁。这是通过内部的 holdCount 计数来实现的。当一个线程多次获取锁时,holdCount 递增,释放锁时递减,只有当 holdCount 为零时,其他线程才有机会获取锁。

应用场景的区别

synchronized:

  • 简单同步需求: 当你需要对代码块或方法进行简单的同步控制时,synchronized 是一个很好的选择。它使用起来简单,不需要额外的资源管理,因为锁会在方法退出或代码块执行完毕后自动释放。
  • 代码块同步: 如果你想对特定代码段进行同步,而不是整个方法,可以使用 synchronized 代码块。这可以让你更精细地控制同步的范围,从而减少锁的持有时间,提高并发性能。
  • 内置锁的使用: synchronized 关键字使用对象的内置锁(也称为监视器锁),这在需要使用对象作为锁对象的情况下很有用,尤其是在对象状态与锁保护的代码紧密相关时。

ReentrantLock:

  • 高级锁功能需求: ReentrantLock 提供了 synchronized 所不具备的高级功能,如公平锁、响应中断、定时锁尝试、以及多个条件变量。当你需要这些功能时, ReentrantLock 是更好的选择。
  • 性能优化: 在高度竞争的环境中, ReentrantLock 可以提供比 synchronized 更好的性能,因为它提供了更细粒度的控制,如尝试锁定和定时锁定,可以减少线程阻塞的可能性。
  • 复杂同步结构: 当你需要更复杂的同步结构,如需要多个条件变量来协调线程之间的通信时,ReentrantLock 及其配套的 Condition 对象可以提供更灵活的解决方案。

综上, synchronized 适用于简单同步需求和不需要额外锁功能的场景,而 ReentrantLock 适用于需要更高级锁功能、性能优化或复杂同步逻辑的情况。选择哪种同步机制取决于具体的应用需求和性能考虑。

除了用synchronized,还有什么方法可以实现线程同步?

  • 使用 ReentrantLock 类: ReentrantLock 是一个可重入的互斥锁,相比 synchronized 提供了更灵活的锁定和解锁操作。它还支持公平锁和非公平锁,以及可以响应中断的锁获取操作。
  • 使用 volatile 关键字:虽然 volatile 不是一种锁机制,但它可以确保变量的可见性。当一个变量被声明为 volatile 后,线程将直接从主内存中读取该变量的值,这样就能保证线程间变量的可见性。但它不具备原子性。
  • 使用 Atomic 类:Java提供了一系列的原子类,例如 AtomicInteger 、 AtomicLong 、AtomicReference 等,用于实现对单个变量的原子操作,这些类在实现细节上利用了CAS算法,可以用来实现无锁的线程安全

synchronized锁静态方法和普通方法区别?

锁的对象不同

  • 普通方法:锁的是当前对象实例( this )。同一对象实例的 synchronized 普通方法,同一时间只能被一个线程访问;不同对象实例间互不影响,可被不同线程同时访问各自的同步普通方法。
  • 静态方法:锁的是当前类的 Class 对象。由于类的 Class 对象全局唯一,无论多少个对象实例,该静态同步方法同一时间只能被一个线程访问。

作用范围不同

  • 普通方法:仅对同一对象实例的同步方法调用互斥,不同对象实例的同步普通方法可并行执行。
  • 静态方法:对整个类的所有实例的该静态方法调用都互斥,一个线程进入静态同步方法,其他线程无法进入同一类任何实例的该方法。

多实例场景影响不同

  • 普通方法:多线程访问不同对象实例的同步普通方法时,可同时执行。
  • 静态方法:不管有多少对象实例,同一时间仅一个线程能执行该静态同步方法。

synchronized和reentrantlock区别?

synchronized 和 ReentrantLock 都是 Java 中提供的可重入锁:

  • 用法不同:synchronized 可用来修饰普通方法、静态方法和代码块,而 ReentrantLock 只能用在代码块上。
  • 获取锁和释放锁方式不同:synchronized 会自动加锁和释放锁,当进入 synchronized 修饰的代码块之后会自动加锁,当离开synchronized 的代码段之后会自动释放锁。而 ReentrantLock 需要手动加锁和释放锁
  • 锁类型不同:synchronized 属于非公平锁,而 ReentrantLock 既可以是公平锁也可以是非公平锁。
  • 响应中断不同:ReentrantLock 可以响应中断,解决死锁的问题,而 synchronized 不能响应中
    断。
  • 底层实现不同:synchronized 是 JVM 层面通过监视器实现的,而 ReentrantLock 是基于 AQS 实现的。

怎么理解可重入锁?

可重入锁是指同一个线程在获取了锁之后,可以再次重复获取该锁而不会造成死锁或其他问题。当一个线程持有锁时,如果再次尝试获取该锁,就会成功获取而不会被阻塞。

ReentrantLock实现可重入锁的机制是基于线程持有锁的计数器。
当一个线程第一次获取锁时,计数器会加1,表示该线程持有了锁。在此之后,如果同一个线程再次获取锁,计数器会再次加1。每次线程成功获取锁时,都会将计数器加1。

当线程释放锁时,计数器会相应地减1。只有当计数器减到0时,锁才会完全释放,其他线程才有机会获取锁。

这种计数器的设计使得同一个线程可以多次获取同一个锁,而不会造成死锁或其他问题。每次获取锁时,计数器加1;每次释放锁时,计数器减1。只有当计数器减到0时,锁才会完全释放。

ReentrantLock通过这种计数器的方式,实现了可重入锁的机制。它允许同一个线程多次获取同一个锁,并且能够正确地处理锁的获取和释放,避免了死锁和其他并发问题。

synchronized 支持重入吗?如何实现的?

synchronized是基于原子性的内部锁机制,是可重入的,因此在一个线程调用synchronized方法的同时在其方法体内部调用该对象另一个synchronized方法,也就是说一个线程得到一个对象锁后再次请求该对象锁,是允许的,这就是synchronized的可重入性。

synchronized底层是利用计算机系统mutex Lock实现的。每一个可重入锁都会关联一个线程ID和一个锁状态status。

当一个线程请求方法时,会去检查锁状态。

  1. 如果锁状态是0,代表该锁没有被占用,使用CAS操作获取锁,将线程ID替换成自己的线程ID。
  2. 如果锁状态不是0,代表有线程在访问该方法。此时,如果线程ID是自己的线程ID,如果是可重入锁,会将status自增1,然后获取到该锁,进而执行相应的方法;如果是非重入锁,就会进入阻塞队列等待。在释放锁时,
    1. 如果是可重入锁的,每一次退出方法,就会将status减1,直至status的值为0,最后释放该锁。
    2. 如果非可重入锁的,线程退出方法,直接就会释放该锁

syncronized锁升级的过程讲一下

具体的锁升级的过程是:无锁->偏向锁->轻量级锁->重量级锁。

  • 无锁:这是没有开启偏向锁的时候的状态,在JDK1.6之后偏向锁的默认开启的,但是有一个偏向延迟,需要在JVM启动之后的多少秒之后才能开启,这个可以通过JVM参数进行设置,同时是否开启偏向锁也可以通过JVM参数设置。
  • 偏向锁:这个是在偏向锁开启之后的锁的状态,如果还没有一个线程拿到这个锁的话,这个状态叫做匿名偏向,当一个线程拿到偏向锁的时候,下次想要竞争锁只需要拿线程ID跟MarkWord当中存储的线程ID进行比较,如果线程ID相同则直接获取锁(相当于锁偏向于这个线程),不需要进行CAS操作和将线程挂起的操作。
  • 轻量级锁:在这个状态下线程主要是通过CAS操作实现的。将对象的MarkWord存储到线程的虚拟机栈上,然后通过CAS将对象的MarkWord的内容设置为指向Displaced Mark Word的指针,如果设置成功则获取锁。在线程出临界区的时候,也需要使用CAS,如果使用CAS替换成功则同步成功,如果失败表示有其他线程在获取锁,那么就需要在释放锁之后将被挂起的线程唤醒。
  • 重量级锁:当有两个以上的线程获取锁的时候轻量级锁就会升级为重量级锁,因为CAS如果没有成功的话始终都在自旋,进行while循环操作,这是非常消耗CPU的,但是在升级为重量级锁之后,线程会被操作系统调度然后挂起,这可以节约CPU资源。

线程A进入 synchronized 开始抢锁,JVM 会判断当前是否是偏向锁的状态,如果是就会根据 Mark Word中存储的线程 ID 来判断,当前线程A是否就是持有偏向锁的线程。如果是,则忽略 check,线程A直接执行临界区内的代码。

但如果 Mark Word 里的线程不是线程 A,就会通过自旋尝试获取锁,如果获取到了,就将 Mark Word中的线程 ID 改为自己的;如果竞争失败,就会立马撤销偏向锁,膨胀为轻量级锁。

后续的竞争线程都会通过自旋来尝试获取锁,如果自旋成功那么锁的状态仍然是轻量级锁。然而如果竞争失败,锁会膨胀为重量级锁,后续等待的竞争的线程都会被阻塞。

JVM对Synchornized的优化?

synchronized 核心优化方案主要包含以下 4 个:

  • 锁膨胀:synchronized 从无锁升级到偏向锁,再到轻量级锁,最后到重量级锁的过程,它叫做锁膨胀也叫做锁升级。JDK 1.6 之前,synchronized 是重量级锁,也就是说 synchronized 在释放和获取锁时都会从用户态转换成内核态,而转换的效率是比较低的。但有了锁膨胀机制之后,synchronized 的状态就多了无锁、偏向锁以及轻量级锁了,这时候在进行并发操作时,大部分的场景都不需要用户态到内核态的转换了,这样就大幅的提升了 synchronized 的性能。
  • 锁消除:指的是在某些情况下,JVM 虚拟机如果检测不到某段代码被共享和竞争的可能性,就会将这段代码所属的同步锁消除掉,从而到底提高程序性能的目的。
  • 锁粗化:将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。
  • 自适应自旋锁:指通过自身循环,尝试获取锁的一种方式,优点在于它避免一些线程的挂起和恢复操作,因为挂起线程和恢复线程都需要从用户态转入内核态,这个过程是比较慢的,所以通过自旋的方式可以一定程度上避免线程挂起和恢复所造成的性能开销。

介绍一下AQS

AQS全称为AbstractQueuedSynchronizer,是Java中的一个抽象类。 AQS是一个用于构建锁、同步器、协作工具类的工具类(框架)。

AQS核心思想是,如果被请求的共享资源空闲,那么就将当前请求资源的线程设置为有效的工作线程,将共享资源设置为锁定状态;如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是CLH队列的变体实现的,将暂时获取不到锁的线程加入到队列中。

CLH:Craig、Landin and Hagersten队列,是单向链表,AQS中的队列是CLH变体的虚拟双向队列(FIFO),AQS是通过将每条请求共享资源的线程封装成一个节点来实现锁的分配。

AQS使用一个Volatile的int类型的成员变量来表示同步状态,通过内置的FIFO队列来完成资源获取的排队工作,通过CAS完成对State值的修改。

可以看到: Sync 是 AQS 的实现。 AQS 主要完成的任务:

  • 同步状态(比如说计数器)的原子性管理;
  • 线程的阻塞和解除阻塞;
  • 队列的管理。

AQS原理

AQS最核心的就是三大部分:

  • 状态:state
  • 控制线程抢锁和配合的FIFO队列(双向链表);
  • 期望协作工具类去实现的获取/释放等重要方法(重写)。

状态state:

这里state的具体含义,会根据具体实现类的不同而不同:比如在Semapore里,他表示剩余许可证的数量;在CountDownLatch里,它表示还需要倒数的数量;在ReentrantLock中,state用来表示“锁”的占有情况,包括可重入计数,当state的值为0的时候,标识该Lock不被任何线程所占有。

state是volatile修饰的,并被并发修改,所以修改state的方法都需要保证线程安全,比如getState、setState以及compareAndSetState操作来读取和更新这个状态。这些方法都依赖于unsafe类。

FIFO队列:

这个队列用来存放“等待的线程,AQS就是“排队管理器”,当多个线程争用同一把锁时,必须有排队机制将那些没能拿到锁的线程串在一起。当锁释放时,锁管理器就会挑选一个合适的线程来占有这个刚刚释放的锁。

AQS会维护一个等待的线程队列,把线程都放到这个队列里,这个队列是双向链表形式。

实现获取/释放等方法:

这里的获取和释放方法,是利用AQS的协作工具类里最重要的方法,是由协作类自己去实现的,并且含义各不相同;

  • 获取方法:获取操作会以来state变量,经常会阻塞(比如获取不到锁的时候)。在Semaphore中,获取就是acquire方法,作用是获取一个许可证; 而在CountDownLatch里面,获取就是await方法,作用是等待,直到倒数结束;
  • 释放方法:在Semaphore中,释放就是release方法,作用是释放一个许可证; 在CountDownLatch里面,获取就是countDown方法,作用是将倒数的数减一;需要每个实现类重写tryAcquire和tryRelease等方法。

CAS 和 AQS 有什么关系?

CAS 和 AQS 两者的区别:

  • CAS 是一种乐观锁机制,它包含三个操作数:内存位置(V)、预期值(A)和新值(B)。CAS 操作的逻辑是,如果内存位置 V 的值等于预期值 A,则将其更新为新值 B,否则不做任何操作。整个过程是原子性的,通常由硬件指令支持,如在现代处理器上, cmpxchg 指令可以实现 CAS 操作。
  • AQS 是一个用于构建锁和同步器的框架,许多同步器如 ReentrantLock 、 Semaphore 、CountDownLatch 等都是基于 AQS 构建的。AQS 使用一个 volatile 的整数变量 state 来表示同步状态,通过内置的 FIFO 队列来管理等待线程。它提供了一些基本的操作,如 acquire (获取资源)和 release (释放资源),这些操作会修改 state 的值,并根据 state 的值来判断线程是否可以获取或释放资源。AQS 的 acquire 操作通常会先尝试获取资源,如果失败,线程将被添加到等待队列中,并阻塞等待。 release 操作会释放资源,并唤醒等待队列中的线程。

CAS 和 AQS 两者的联系:

  • CAS 为 AQS 提供原子操作支持:AQS 内部使用 CAS 操作来更新 state 变量,以实现线程安全的状态修改。在 acquire 操作中,当线程尝试获取资源时,会使用 CAS 操作尝试将 state 从一个值更新为另一个值,如果更新失败,说明资源已被占用,线程会进入等待队列。在 release 操作中,当线程释放资源时,也会使用 CAS 操作将 state 恢复到相应的值,以保证状态更新的原子性。

如何用 AQS 实现一个可重入的公平锁?

AQS 实现一个可重入的公平锁的详细步骤:

  1. 继承 AbstractQueuedSynchronizer:创建一个内部类继承自AbstractQueuedSynchronizer ,重写 tryAcquire 、tryRelease 、 isHeldExclusively 等方法,这些方法将用于实现锁的获取、释放和判断锁是否被当前线程持有。
  2. 实现可重入逻辑:在 tryAcquire 方法中,检查当前线程是否已经持有锁,如果是,则增加锁的持有次数(通过 state 变量);如果不是,尝试使用 CAS操作来获取锁。
  3. 实现公平性:在 tryAcquire 方法中,按照队列顺序来获取锁,即先检查等待队列中是否有线程在等待,如果有,当前线程必须进入队列等待,而不是直接竞争锁。
  4. 创建锁的外部类:创建一个外部类,内部持有AbstractQueuedSynchronizer 的子类对象,并提供 lock 和 unlock 方法,这些方法将调用AbstractQueuedSynchronizer 子类中的方法。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
import java.util.concurrent.locks.AbstractQueuedSynchronizer;
import java.util.concurrent.locks.Condition;

public class FairReentrantLock {
// 内部同步器类,继承AQS
private static class Sync extends AbstractQueuedSynchronizer {
// 判断锁是否被当前线程持有
@Override
protected boolean isHeldExclusively() {
return getExclusiveOwnerThread() == Thread.currentThread();
}

// 尝试获取锁(公平锁逻辑)
@Override
protected boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();

// 锁未被持有
if (c == 0) {
// 公平性检查:队列中有前驱节点则不获取锁
if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
// 锁已被当前线程持有(可重入)
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) {
throw new Error("Maximum lock count exceeded");
}
setState(nextc);
return true;
}

// 获取锁失败
return false;
}

// 尝试释放锁
@Override
protected boolean tryRelease(int releases) {
int c = getState() - releases;

// 释放非持有锁的线程,抛出异常
if (Thread.currentThread() != getExclusiveOwnerThread()) {
throw new IllegalMonitorStateException();
}

boolean free = false;
// 锁完全释放
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);

return free;
}

// 创建条件变量
Condition newCondition() {
return new ConditionObject();
}
}

// 同步器实例
private final Sync sync = new Sync();

// 加锁方法
public void lock() {
sync.acquire(1);
}

// 解锁方法
public void unlock() {
sync.release(1);
}

// 判断当前线程是否持有锁
public boolean isLocked() {
return sync.isHeldExclusively();
}

// 获取条件变量
public Condition newCondition() {
return sync.newCondition();
}
}

代码解释:

  • 内部类 Sync:
    • isHeldExclusively :使用 getExclusiveOwnerThread 方法检查当前锁是否被当前线程持有。
    • tryAcquire :首先获取当前锁的状态 c 。
      • 如果 c 为 0,表示锁未被持有,此时进行公平性检查,通过 hasQueuedPredecessors 检查是否有前驱节点在等待队列中。如果没有,使用 compareAndSetState 尝试将状态设置为acquires (通常为 1),并设置当前线程为锁的持有线程。
      • 如果 c 不为 0,说明锁已被持有,检查是否为当前线程持有。如果是,增加锁的持有次数(可重入),但要防止溢出。
    • tryRelease :先将状态减 releases (通常为 1)。检查当前线程是否为锁的持有线程,如果不是,抛出异常。如果状态减为 0,说明锁被完全释放,将持有线程设为 null 。
    • newCondition :创建一个 ConditionObject 用于更复杂的同步操作,如等待 / 通知机制。
  • 外部类 FairReentrantLock:
    • lock 方法:调用 sync.acquire(1) 尝试获取锁。
    • unlock 方法:调用 sync.release(1) 释放锁。
    • isLocked 方法:调用 sync.isHeldExclusively 判断锁是否被当前线程持有。
    • newCondition 方法:调用 sync.newCondition 提供条件变量

Threadlocal作用,原理,具体里面存的key value是啥,会有什么问题,如何解决?

ThreadLocal 是Java中用于解决线程安全问题的一种机制,它允许创建线程局部变量,即每个线程都有自己独立的变量副本,从而避免了线程间的资源共享和同步问题。

从内存结构图,我们可以看到:

Thread类中,有个ThreadLocal.ThreadLocalMap 的成员变量。
ThreadLocalMap内部维护了Entry数组,每个Entry代表一个完整的对象,key是ThreadLocal本
身,value是ThreadLocal的泛型对象值。

ThreadLocal的作用

  • 线程隔离: ThreadLocal 为每个线程提供了独立的变量副本,这意味着线程之间不会相互影响,可以安全地在多线程环境中使用这些变量而不必担心数据竞争或同步问题。
  • 降低耦合度:在同一个线程内的多个函数或组件之间,使用 ThreadLocal 可以减少参数的传递,降低代码之间的耦合度,使代码更加清晰和模块化。
  • 性能优势:由于 ThreadLocal 避免了线程间的同步开销,所以在大量线程并发执行时,相比传统的锁机制,它可以提供更好的性能。

ThreadLocal的原理

ThreadLocal 的实现依赖于 Thread 类中的一个ThreadLocalMap 字段,这是一个存储 ThreadLocal 变量本身和对应值的映射。每个线程都有自己的 ThreadLocalMap 实例,用于存储该线程所持有的所有ThreadLocal 变量的值。

当你创建一个 ThreadLocal 变量时,它实际上就是一ThreadLocal 对象的实例。每个 ThreadLocal对象都可以存储任意类型的值,这个值对每个线程来说是独立的。
当调用 ThreadLocal 的 get() 方法时, ThreadLocal 会检查当前线程的 ThreadLocalMap 中是否有与之关联的值。

  • 如果有,返回该值;
  • 如果没有,会调用 initialValue() 方法(如果重写了的话)来初始化该值,然后将其放入ThreadLocalMap 中并返回。

当调用 set() 方法时, ThreadLocal 会将给定的值与当前线程关联起来,即在当前线程的ThreadLocalMap 中存储一个键值对,键是 ThreadLocal 对象自身,值是传入的值。

当调用 remove() 方法时,会从当前线程的 ThreadLocalMap 中移除与该 ThreadLocal 对象关联的条目。

可能存在的问题

当一个线程结束时,其 ThreadLocalMap 也会随之销毁,但是 ThreadLocal 对象本身不会立即被垃圾回收,直到没有其他引用指向它为止。

因此,在使用 ThreadLocal 时需要注意,如果不显式调用 removE() 方法,或者线程结束时未正确清理ThreadLocal 变量,可能会导致内存泄漏,因为 ThreadLocalMap 会持续持有 ThreadLocal 变量的引用,即使这些变量不再被其他地方引用。

因此,实际应用中需要在使用完 ThreadLocal 变量后调用 removE() 方法释放资源。

悲观锁和乐观锁的区别?

  • 乐观锁: 就像它的名字一样,对于并发间操作产生的线程安全问题持乐观状态,乐观锁认为竞争不总 是会发生,因此它不需要持有锁,将比较-替换这两个动作作为一个原子操作尝试去修改内存中的变量,如果失败则表示发生冲突,那么就应该有相应的重试逻辑。
  • 悲观锁: 还是像它的名字一样,对于并发间操作产生的线程安全问题持悲观状态,悲观锁认为竞争总是会发生,因此每次对某资源进行操作时,都会持有一个独占的锁,就像 synchronized,不管三七二十一,直接上了锁就操作资源了。

Java中想实现一个乐观锁,都有哪些方式?

  1. CAS(Compare and Swap)操作: CAS 是乐观锁的基础。Java 提供了java.util.concurrent.atomic 包,包含各种原子变量类(如 AtomicInteger、AtomicLong),这些类使用 CAS 操作实现了线程安全的原子操作,可以用来实现乐观锁。
  2. 版本号控制:增加一个版本号字段记录数据更新时候的版本,每次更新时递增版本号。在更新数据时,同时比较版本号,若当前版本号和更新前获取的版本号一致,则更新成功,否则失败。
  3. 时间戳:使用时间戳记录数据的更新时间,在更新数据时,在比较时间戳。如果当前时间戳大于数据的时间戳,则说明数据已经被其他线程更新,更新失败。

CAS 有什么缺点?

CAS的缺点主要有3点:

  • ABA问题:ABA的问题指的是在CAS更新的过程中,当读取到的值是A,然后准备赋值的时候仍然是A,但是实际上有可能A的值被改成了B,然后又被改回了A,这个CAS更新的漏洞就叫做ABA。只是ABA的问题大部分场景下都不影响并发的最终效果。Java中AtomicStampedReference来解决这个问题,他加入了预期标志和更新后标志两个字段,更新时不光检查值,还要检查当前的标志是否等于预期标志,全部相等的话才会更新。
  • 循环时间长开销大:自旋CAS的方式如果长时间不成功,会给CPU带来很大的开销。
  • 只能保证一个共享变量的原子操作:只对一个共享变量操作可以保证原子性,但是多个则不行,多个可以通过AtomicReference来处理或者使用锁synchronized实现。

为什么不能所有的锁都用CAS?

CAS操作是基于循环重试的机制,如果CAS操作一直未能成功,线程会一直自旋重试,占用CPU资源。在高并发情况下,大量线程自旋会导致CPU资源浪费。

CAS 有什么问题,Java是怎么解决的?

会有 ABA 的问题,变量值在操作过程中先被其他线程从 A 修改为 B,又被改回 A,CAS 无法感知中途变化,导致操作误判为“未变更”。比如:

  • 线程1读取变量为 A ,准备改为 C 。
  • 此时线程2将变量 A → B → A 。
  • 线程1的CAS执行时发现仍是 A ,但状态已丢失中间变化。

Java 提供的工具类会在 CAS 操作中增加版本号(Stamp)或标记,每次修改都更新版本号,使得即使值相同也能识别变更历史。比如,可以用 AtomicStampedReference 来解决 ABA 问题,通过比对值和版本号识别ABA问题。

voliatle关键字有什么作用?

volatite作用有 2 个:

  • 保证变量对所有线程的可见性。当一个变量被声明为volatile时,它会保证对这个变量的写操作会立即刷新到主存中,而对这个变量的读操作会直接从主存中读取,从而确保了多线程环境下对该变量访问的可见性。这意味着一个线程修改了volatile变量的值,其他线程能够立刻看到这个修改,不会受到各自线程工作内存的影响。

  • 禁止指令重排序优化。volatile关键字在Java中主要通过内存屏障来禁止特定类型的指令重排序。

    1. 写-写(Write-Write)屏障:在对volatile变量执行写操作之前,会插入一个写屏障。这确保了在该变量写操作之前的所有普通写操作都已完成,防止了这些写操作被移到volatile写操作之后。
    2. 读-写(Read-Write)屏障:在对volatile变量执行读操作之后,会插入一个读屏障。它确保了对volatile变量的读操作之后的所有普通读操作都不会被提前到volatile读之前执行,保证了读取到的数据是最新的。
    3. 写-读(Write-Read)屏障:这是最重要的一个屏障,它发生在volatile写之后和volatile读之前。这个屏障确保了volatile写操作之前的所有内存操作(包括写操作)都不会被重排序到volatile读之后,同时也确保了volatile读操作之后的所有内存操作(包括读操作)都不会被重排序到volatile写之前

指令重排序的原理是什么?

在执行程序时,为了提高性能,处理器和编译器常常会对指令进行重排序,但是重排序要满足下面 2 个条件才能进行:

  • 在单线程环境下不能改变程序运行的结果
  • 存在数据依赖关系的不允许重排序。

所以重排序不会对单线程有影响,只会破坏多线程的执行语义。

我们看这个例子,A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。因此在最终执行的指令序列中,C不能被重排序到A和B的前面,如果C排到A和B的前面,那么程序的结果将会被改变。但A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序

volatile可以保证线程安全吗?

volatile关键字可以保证可见性,但不能保证原子性,因此不能完全保证线程安全。volatile关键字用于修饰变量,当一个线程修改了volatile修饰的变量的值,其他线程能够立即看到最新的值,从而避免了线程之间的数据不一致。

但是,volatile并不能解决多线程并发下的复合操作问题,比如i++这种操作不是原子操作,如果多个线程同时对i进行自增操作,volatile不能保证线程安全。对于复合操作,需要使用synchronized关键字或者Lock来保证原子性和线程安全。

volatile和sychronized比较?

Synchronized解决了多线程访问共享资源时可能出现的竞态条件和数据不一致的问题,保证了线程安全性。Volatile解决了变量在多线程环境下的可见性和有序性问题,确保了变量的修改对其他线程是可见
的。

  • Synchronized: Synchronized是一种排他性的同步机制,保证了多个线程访问共享资源时的互斥性,即同一时刻只允许一个线程访问共享资源。通过对代码块或方法添加Synchronized关键字来实现同步。
  • Volatile: Volatile是一种轻量级的同步机制,用来保证变量的可见性和禁止指令重排序。当一个变量被声明为Volatile时,线程在读取该变量时会直接从内存中读取,而不会使用缓存,同时对该变量的写操作会立即刷回主内存,而不是缓存在本地内存中。

什么是公平锁和非公平锁?

  • 公平锁: 指多个线程按照申请锁的顺序来获取锁,线程直接进入队列中排队,队列中的第一个线程才能获得锁。公平锁的优点在于各个线程公平平等,每个线程等待一段时间后,都有执行的机会,而它的缺点就在于整体执行速度更慢,吞吐量更小。
  • 非公平锁: 多个线程加锁时直接尝试获取锁,能抢到锁到直接占有锁,抢不到才会到等待队列的队尾等待。非公平锁的优势就在于整体执行速度更快,吞吐量更大,但同时也可能产生线程饥饿问题,也就是说如果一直有线程插队,那么在等待队列中的线程可能长时间得不到运行

非公平锁吞吐量为什么比公平锁大?

  • 公平锁执行流程:获取锁时,先将线程自己添加到等待队列的队尾并休眠,当某线程用完锁之后,会去唤醒等待队列中队首的线程尝试去获取锁,锁的使用顺序也就是队列中的先后顺序,在整个过程中,线程会从运行状态切换到休眠状态,再从休眠状态恢复成运行状态,但线程每次休眠和恢复都需要从用户态转换成内核态,而这个状态的转换是比较慢的,所以公平锁的执行速度会比较慢。
  • 非公平锁执行流程:当线程获取锁时,会先通过 CAS 尝试获取锁,如果获取成功就直接拥有锁,如果获取锁失败才会进入等待队列,等待下次尝试获取锁。这样做的好处是,获取锁不用遵循先到先得的规则,从而避免了线程休眠和恢复的操作,这样就加速了程序的执行效率。

Synchronized是公平锁吗?

Synchronized不属于公平锁,ReentrantLock是公平锁。

ReentrantLock是怎么实现公平锁的?

公平锁与非公平锁的 lock() 方法唯一的区别就在于公平锁在获取锁时多了一个限制条件:hasQueuedPredecessors() 为 false,这个方法就是判断在等待队列中是否已经有线程在排队了。

这也就是公平锁和非公平锁的核心区别,如果是公平锁,那么一旦已经有线程在排队了,当前线程就不再尝试获取锁;对于非公平锁而言,无论是否已经有线程在排队,都会尝试获取一下锁,获取不到的话,再去排队。这里有一个特例需要我们注意,针对 tryLock() 方法,它不遵守设定的公平原则。

例如,当有线程执行 tryLock() 方法的时候,一旦有线程释放了锁,那么这个正在 tryLock 的线程就能获取到锁,即使设置的是公平锁模式,即使在它之前已经有其他正在等待队列中等待的线程,简单地说就是 tryLock 可以插队。

看它的源码就会发现:

1
2
3
public boolean tryLock() {
return sync.nonfairTryAcquire(1);
}

这里调用的就是 nonfairTryAcquire(),表明了是不公平的,和锁本身是否是公平锁无关。综上所述,公平锁就是会按照多个线程申请锁的顺序来获取锁,从而实现公平的特性。

非公平锁加锁时不考虑排队等待情况,直接尝试获取锁,所以存在后申请却先获得锁的情况,但由此也提高了整体的效率。

什么情况会产生死锁问题?如何解决?

死锁只有同时满足以下四个条件才会发生:

  • 互斥条件:互斥条件是指多个线程不能同时使用同一个资源。
  • 持有并等待条件:持有并等待条件是指,当线程 A 已经持有了资源 1,又想申请资源 2,而资源 2已经被线程 C 持有了,所以线程 A 就会处于等待状态,但是线程 A 在等待资源 2 的同时并不会释放自己已经持有的资源 1。
  • 不可剥夺条件:不可剥夺条件是指,当线程已经持有了资源 ,在自己使用完之前不能被其他线程获取,线程 B 如果也想使用此资源,则只能在线程 A 使用完并释放后才能获取。
  • 环路等待条件:环路等待条件指的是,在死锁发生的时候,两个线程获取资源的顺序构成了环形链

例如,线程 A 持有资源 R1 并试图获取资源 R2,而线程 B 持有资源 R2 并试图获取资源 R1,此时两个线程相互等待对方释放资源,从而导致死锁。

避免死锁问题就只需要破环其中一个条件就可以,最常见的并且可行的就是使用资源有序分配法,来破环环路等待条件。

那什么是资源有序分配法呢?

线程 A 和 线程 B 获取资源的顺序要一样,当线程 A 是先尝试获取资源 A,然后尝试获取资源 B 的时候,线程 B 同样也是先尝试获取资源 A,然后尝试获取资源 B。也就是说,线程 A 和 线程 B 总是以相同的顺序申请自己想要的资源。

java里面的线程和操作系统的线程一样吗?

Java 底层会调用 pthread_create 来创建线程,所以本质上 java 程序创建的线程,就是和操作系统线程是一样的,是 1 对 1 的线程模型。

使用多线程要注意哪些问题?

要保证多线程的程序是安全,不要出现数据竞争造成的数据混乱的问题。
Java的线程安全在三个方面体现:

  • 原子性:提供互斥访问,同一时刻只能有一个线程对数据进行操作,在Java中使用了atomic包和synchronized关键字来确保原子性;
  • 可见性:一个线程对主内存的修改可以及时地被其他线程看到,在Java中使用了synchronized和volatile这两个关键字确保可见性;
  • 有序性:一个线程观察其他线程中的指令执行顺序,由于指令重排序,该观察结果一般杂乱无序,在Java中使用了happens-before原则来确保有序性。

保证数据的一致性有哪些方案呢?

  • 事务管理:使用数据库事务来确保一组数据库操作要么全部成功提交,要么全部失败回滚。通过ACID属性,数据库事务可以保证数据的一致性。
  • 锁机制:使用锁来实现对共享资源的互斥访问。在 Java 中,可以使用 synchronized 关键字、ReentrantLock 或其他锁机制来控制并发访问,从而避免并发操作导致数据不一致。
  • 版本控制:通过乐观锁的方式,在更新数据时记录数据的版本信息,从而避免同时对同一数据进行修改,进而保证数据的一致性

线程的创建方式有哪些?

1.继承Thread类

这是最直接的一种方式,用户自定义类继承java.lang.Thread类,重写其run()方法,run()方法中定义了线程执行的具体任务。创建该类的实例后,通过调用start()方法启动线程。

  • 优点: 编写简单,如果需要访问当前线程,无需使用Thread.currentThread ()方法,直接使用this,即可获得当前线程
  • 缺点:因为线程类已经继承了Thread类,所以不能再继承其他的父类

2.实现Runnable接口

如果一个类已经继承了其他类,就不能再继承Thread类,此时可以实现java.lang.Runnable接口。实现Runnable接口需要重写run()方法,然后将此Runnable对象作为参数传递给Thread类的构造器,创建Thread对象后调用其start()方法启动线程。

  • 优点:线程类只是实现了Runable接口,还可以继承其他的类。在这种方式下,可以多个线程共享同一个目标对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。
  • 缺点:编程稍微复杂,如果需要访问当前线程,必须使用Thread.currentThread()方法。

3.实现Callable接口与FutureTask

java.util.concurrent.Callable接口类似于Runnable,但Callable的call()方法可以有返回值并且可以抛出异常。要执行Callable任务,需将它包装进一个FutureTask,因为Thread类的构造器只接受Runnable参数,而FutureTask实现了Runnable接口。

  • 优点:线程只是实现Runnable或实现Callable接口,还可以继承其他类。这种方式下,多个线程可以共享一个target对象,非常适合多线程处理同一份资源的情形。
  • 缺点:编程稍微复杂,如果需要访问当前线程,必须调用Thread.currentThread()方法。

4.使用线程池(Executor框架)

避免了频繁创建和销毁线程的开销。可以通过Executors类的静态方法创建不同类型的线程池。

  • 优点:线程池可以重用预先创建的线程,避免了线程创建和销毁的开销,显著提高了程序的性能。对于需要快速响应的并发请求,线程池可以迅速提供线程来处理任务,减少等待时间。并且,线程池能够有效控制运行的线程数量,防止因创建过多线程导致的系统资源耗尽(如内存溢出)。通过合理配置线程池大小,可以最大化CPU利用率和系统吞吐量。
  • 缺点:程池增加了程序的复杂度,特别是当涉及线程池参数调整和故障排查时。错误的配置可能导致死锁、资源耗尽等问题,这些问题的诊断和修复可能较为复杂。

怎么启动线程 ?

启动线程的通过Thread类的start()。

如何停止一个线程的运行?

  • 异常法停止:线程调用interrupt()方法后,在线程的run方法中判断当前对象的interrupted()状态,如果是中断状态则抛出异常,达到中断线程的效果。
  • 在沉睡中停止:先将线程sleep,然后调用interrupt标记中断状态,interrupt会将阻塞状态的线程中断。会抛出中断异常,达到停止线程的效果
  • stop()暴力停止:线程调用stop()方法会被暴力停止,方法已弃用,该方法会有不好的后果:强制让线程停止有可能使一些请理性的工作得不到完成。
  • 使用return停止线程:调用interrupt标记为中断状态后,在run方法中判断当前线程状态,如果为中断状态则return,能达到停止线程的效果。

调用 interrupt 是如何让线程抛出异常的?

每个线程都一个与之关联的布尔属性来表示其中断状态,中断状态的初始值为false,当一个线程被其它线程调用 Thread.interrupt() 方法中断时,会根据实际情况做出响应。

  • 如果该线程正在执行低级别的可中断方法(如 Thread.sleep() 、 Thread.join() 或Object.wait() ),则会解除阻塞并抛出 InterruptedException 异常。
  • 否则 Thread.interrupt() 仅设置线程的中断状态,在该被中断的线程中稍后可通过轮询中断状态来决定是否要停止当前正在执行的任务。

Java线程的状态有哪些?

  • NEW:尚未启动的线程状态,即线程创建,还未调用start方法
  • RUNNABLE:就绪状态(调用start,等待调度)+正在运行
  • BLOCKED:等待监视器锁时,陷入阻塞状态
  • WAITING:等待状态的线程正在等待另一线程执行特定的操作(如notify)
  • TIMED_WAITING:具有指定等待时间的等待状态
  • TERMINATED:线程完成执行,终止状态

sleep 和 wait的区别是什么?

  • 所属分类的不同:sleep 是 Thread 类的静态方法,可以在任何地方直接通过 Thread.sleep() 调用,无需依赖对象实例。wait 是 Object 类的实例方法,这意味着必须通过对象实例来调用。

  • 锁释放的情况: Thread.sleep() 在调用时,线程会暂停执行指定的时间,但不会释放持有的对象锁。也就是说,在 sleep 期间,其他线程无法获得该线程持有的锁。 Object.wait() :调用该方
    法时,线程会释放持有的对象锁,进入等待状态,直到其他线程调用相同对象的 notify() 或notifyAll() 方法唤醒它

  • 使用条件:sleep 可在任意位置调用,无需事先获取锁。 wait 必须在同步块或同步方法内调用(即线程需持有该对象的锁),否则抛出 IllegalMonitorStateException 。

  • 唤醒机制:sleep 休眠时间结束后,线程 自动恢复 到就绪状态,等待CPU调度。wait 需要其他线程调用相同对象的 notify() 或 notifyAll() 方法才能被唤醒。 notify() 会随机唤醒一个在该对象上等待的线程,而 notifyAll() 会唤醒所有在该对象上等待的线程。

sleep会释放cpu吗?

是的,调用 Thread.sleep() 时,线程会释放 CPU,但不会释放持有的锁。

当线程调用 sleep() 后,会主动让出 CPU 时间片,进入 TIMED_WAITING 状态。此时操作系统会触发调度,将 CPU 分配给其他处于就绪状态的线程。这样其他线程(无论是需要同一锁的线程还是不相关线程)便有机会执行。

sleep() 不会释放线程已持有的任何锁(如 synchronized 同步代码块或方法中获取的锁)。因此,如果有其他线程试图获取同一把锁,它们仍会被阻塞,直到原线程退出同步代码块。

blocked和waiting有啥区别

  • 触发条件:线程进入BLOCKED状态通常是因为试图获取一个对象的锁,但该锁已经被另一个线程持有。这通常发生在尝试进入synchronized块或方法时,如果锁已被占用,则线程将被阻塞直到锁可用。线程进入WAITING状态是因为它正在等待另一个线程执行某些操作。在这种状态下,线程将不会消耗CPU资源,并且不会参与锁的竞争。
  • 唤醒机制:当一个线程被阻塞等待锁时,一旦锁被释放,线程将有机会重新尝试获取锁。如果锁此时未被其他线程获取,那么线程可以从BLOCKED状态变为RUNNABLE状态。线程在WAITING状态中需要被显式唤醒。

所以,BLOCKED和WAITING两个状态最大的区别有两个:

  • BLOCKED是锁竞争失败后被被动触发的状态,WAITING是人为的主动触发的状态
  • BLCKED的唤醒时自动触发的,而WAITING状态是必须要通过特定的方法来主动唤醒

wait 状态下的线程如何进行恢复到 running 状态?

线程从 等待(WAIT) 状态恢复到 运行(RUNNING) 状态的核心机制是 通过外部事件触发或资源可用性变化,比如等待的线程被其他线程对象唤醒, notify() 和 notifyAll() 。

notify 选择哪个线程?

notify在源码的注释中说到notify选择唤醒的线程是任意的,但是依赖于具体实现的jvm。JVM有很多实现,比较流行的就是hotspot,hotspot对notofy()的实现并不是我们以为的随机唤醒,而是“先进先出”的顺序唤醒。

notify 和 notifyAll 的区别?

同样是唤醒等待的线程,同样最多只有一个线程能获得锁,同样不能控制哪个线程获得锁。

  • notify:唤醒一个线程,其他线程依然处于wait的等待唤醒状态,如果被唤醒的线程结束时没调用notify,其他线程就永远没人去唤醒,只能等待超时,或者被中断
  • notifyAll:所有线程退出wait的状态,开始竞争锁,但只有一个线程能抢到,这个线程执行完后,其他线程又会有一个幸运儿脱颖而出得到锁

不同的线程之间如何通信?

共享变量是最基本的线程间通信方式。多个线程可以访问和修改同一个共享变量,从而实现信息的传递。为了保证线程安全,通常需要使用 synchronized 关键字或 volatile 关键字。

如何停止一个线程?

在 Java 中,停止线程的正确方式是 通过协作式的逻辑控制线程终止,而非强制暴力终止(如已废弃的Thread.stop() )。

  • 通过共享标志位主动终止:定义一个 可见的 状态变量,由主线程控制其值,工作线程循环检测该变量以决定是否退出
  • 线程中断机制:通过 Thread.interrupt() 触发线程中断状态,结合中断检测逻辑实现安全停止。
  • Future 取消任务:使用线程池提交任务,并通过 Future.cancel() 停止线程,依赖中断机制
  • 处理不可中断的阻塞操作:某些 I/O 或同步操作(如 Socket.accept() 、Lock.lock() )无法通过中断直接响应。此时需结合资源关闭操作。比如,关闭 Socket 释放阻塞。

避免使用以下已废弃方法:

  • Thread.stop() :暴力终止,可能导致状态不一致。
  • Thread.suspend() / resume() :易导致死锁。

Redis集群的模式了解吗 优缺点了解吗

当 Redis 缓存数据量大到一台服务器无法缓存时,就需要使用 Redis 切片集群方案,它将数据分布在不同的服务器上,以此来降低系统对单主节点的依赖,从而提高 Redis 服务的读写性能。

Redis Cluster 方案采用哈希槽(Hash Slot),来处理数据和节点之间的映射关系。在 Redis Cluster 方案中,一个切片集群共有 16384 个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的key,被映射到一个哈希槽中,具体执行过程分为两大步:

  • 根据键值对的 key,按照 CRC16 算法计算一个 16 bit 的值。
  • 再用 16bit 值对 16384 取模,得到 0~16383 范围内的模数,每个模数代表一个相应编号的哈希槽。

接下来的问题就是,这些哈希槽怎么被映射到具体的 Redis 节点上的呢?有两种方案:

  • 平均分配: 在使用 cluster create 命令创建 Redis 集群时,Redis 会自动把所有哈希槽平均分布到集群节点上。比如集群中有 9 个节点,则每个节点上槽的个数为 16384/9 个。
  • 手动分配: 可以使用 cluster meet 命令手动建立节点间的连接,组成集群,再使用 clusteraddslots 命令,指定每个节点上的哈希槽个数

优点:

  • 高可用性:Redis集群最主要的优点是提供了高可用性,节点之间采用主从复制机制,可以保证数据的持久性和容错能力,哪怕其中一个节点挂掉,整个集群还可以继续工作。
  • 高性能:Redis集群采用分片技术,将数据分散到多个节点,从而提高读写性能。当业务访问量大到单机Redis无法满足时,可以通过添加节点来增加集群的吞吐量。
  • 扩展性好:Redis集群的扩展性非常好,可以根据实际需求动态增加或减少节点,从而实现可扩展性。集群模式中的某些节点还可以作为代理节点,自动转发请求,增加数据模式的灵活度和可定制性。

缺点:

  • 部署和维护较复杂:Redis集群的部署和维护需要考虑到分片规则、节点的布置、主从配置以及故障处理等多个方面,需要较强的技术支持,增加了节点异常处理的复杂性和成本。
  • 集群同步问题:当某些节点失败或者网络出故障,集群中数据同步的问题也会出现。数据同步的复杂度和工作量随着节点的增加而增加,同步时间也较长,导致一定的读写延迟。
  • 数据分片限制:Redis集群的数据分片也限制了一些功能的实现,如在一个key上修改多次,可能会因为该key所在的节点位置变化而失败。此外,由于将数据分散存储到各个节点,某些操作不能跨节点实现,不同节点之间的一些操作需要额外注意。

Redis主从同步中的增量和完全同步怎么实现?

完全同步

完全同步发生在以下几种情况:

  • 初次同步:当一个从服务器(slave)首次连接到主服务器(master)时,会进行一次完全同步。
  • 从服务器数据丢失:如果从服务器数据由于某种原因(如断电)丢失,它会请求进行完全同步。
  • 主服务器数据发生变化:如果从服务器长时间未与主服务器同步,导致数据差异太大,也可能触发完全同步。

主从服务器间的第一次同步的过程可分为三个阶段:

  • 第一阶段是建立链接、协商同步;
  • 第二阶段是主服务器同步数据给从服务器;
  • 第三阶段是主服务器发送新写操作命令给从服务器。

实现过程:

  1. 从服务器发送SYNC命令:从服务器向主服务器发送 SYNC 命令,请求开始同步。
  2. 主服务器生成RDB快照:接收到 SYNC 命令后,主服务器会保存当前数据集的状态到一个临时文
    件,这个过程称为RDB(Redis Database)快照。
  3. 传输RDB文件:主服务器将生成的RDB文件发送给从服务器。
  4. 从服务器接收并应用RDB文件:从服务器接收RDB文件后,会清空当前的数据集,并载入RDB文件中的数据。
  5. 主服务器记录写命令:在RDB文件生成和传输期间,主服务器会记录所有接收到的写命令到replication backlog buffer 。
  6. 传输写命令:一旦RDB文件传输完成,主服务器会将 replication backlog buffer 中的命令发送给从服务器,从服务器会执行这些命令,以保证数据的一致性。

增量同步

增量同步允许从服务器从断点处继续同步,而不是每次都进行完全同步。它基于 PSYNC 命令,使用了运行ID(run ID)和复制偏移量(offset)的概念。

主要有三个步骤:

  • 从服务器在恢复网络后,会发送 psync 命令给主服务器,此时的 psync 命令里的 offset 参数不是-1;
  • 主服务器收到该命令后,然后用 CONTINUE 响应命令告诉从服务器接下来采用增量复制的方式同步数据;
  • 然后主服务将主从服务器断线期间,所执行的写命令发送给从服务器,然后从服务器执行这些命令。

主服务器怎么知道要将哪些增量数据发送给从服务器呢?

  • repl_backlog_buffer,是一个「环形」缓冲区,用于主从服务器断连后,从中找到差异的数据;
  • replication offset,标记上面那个缓冲区的同步进度,主从服务器都有各自的偏移量,主服务器使用 master_repl_offset 来记录自己「写」到的位置,从服务器使用 slave_repl_offset 来记录自己「读」到的位置。

那 repl_backlog_buffer 缓冲区是什么时候写入的呢?

在主服务器进行命令传播时,不仅会将写命令发送给从服务器,还会将写命令写入到repl_backlog_buffer 缓冲区里,因此 这个缓冲区里会保存着最近传播的写命令。

网络断开后,当从服务器重新连上主服务器时,从服务器会通过 psync 命令将自己的复制偏移量slave_repl_offset 发送给主服务器,主服务器根据自己的 master_repl_offset 和 slave_repl_offset 之间
的差距,然后来决定对从服务器执行哪种同步操作:

  • 如果判断出从服务器要读取的数据还在 repl_backlog_buffer 缓冲区里,那么主服务器将采用增量同步的方式;
  • 相反,如果判断出从服务器要读取的数据已经不存在 repl_backlog_buffer 缓冲区里,那么主服务器将采用全量同步的方式。

当主服务器在 repl_backlog_buffer 中找到主从服务器差异(增量)的数据后,就会将增量的数据写入到replication buffer 缓冲区,这个缓冲区我们前面也提到过,它是缓存将要传播给从服务器的命令。

repl_backlog_buffer 缓行缓冲区的默认大小是 1M,并且由于它是一个环形缓冲区,所以当缓冲区写满后,主服务器继续写入的话,就会覆盖之前的数据。因此,当主服务器的写入速度远超于从服务器的读取速度,缓冲区的数据一下就会被覆盖。

那么在网络恢复时,如果从服务器想读的数据已经被覆盖了,主服务器就会采用全量同步,这个方式比增量同步的性能损耗要大很多。

因此,为了避免在网络恢复时,主服务器频繁地使用全量同步的方式,我们应该调整下repl_backlog_buffer 缓冲区大小,尽可能的大一些,减少出现从服务器要读取的数据被覆盖的概率,从而使得主服务器采用增量同步的方式。

redis主从和集群可以保证数据一致性吗 ?

redis 主从和集群在CAP理论都属于AP模型,即在面临网络分区时选择保证可用性和分区容忍性,而牺牲了强一致性。这意味着在网络分区的情况下,Redis主从复制和集群可以继续提供服务并保持可用,但可能会出现部分节点之间的数据不一致。

哨兵机制原理是什么?

在 Redis 的主从架构中,由于主从模式是读写分离的,如果主节点挂了,那么将没有主节点来服务客户端的写操作请求,也没有主节点给从节点进行数据同步了。

这时如果要恢复服务的话,需要人工介入,选择一个「从节点」切换为「主节点」,然后让其他从节点指向新的主节点,同时还需要通知上游那些连接 Redis 主节点的客户端,将其配置中的主节点 IP 地址更新为「新主节点」的 IP 地址。

这样也不太“智能”了,要是有一个节点能监控「主节点」的状态,当发现主节点挂了,它自动将一个「从节点」切换为「主节点」的话,那么可以节省我们很多事情啊!

Redis 在 2.8 版本以后提供的哨兵(Sentinel)机制,它的作用是实现主从节点故障转移。它会监测主节点是否存活,如果发现主节点挂了,它就会选举一个从节点切换为主节点,并且把新主节点的相关信息通知给从节点和客户端。

哨兵其实是一个运行在特殊模式下的 Redis 进程,所以它也是一个节点。从“哨兵”这个名字也可以看得出来,它相当于是“观察者节点”,观察的对象是主从节点。

当然,它不仅仅是观察那么简单,在它观察到有异常的状况下,会做出一些“动作”,来修复异常状态。

哨兵节点主要负责三件事情:监控选主通知

哨兵机制的选主节点的算法介绍一下

当redis集群的主节点故障时,Sentinel集群将从剩余的从节点中选举一个新的主节点,有以下步骤:

  1. 故障节点主观下线
  2. 故障节点客观下线
  3. Sentinel集群选举Leader
  4. Sentinel Leader决定新主节点

故障节点主观下线

Sentinel集群的每一个Sentinel节点会定时对redis集群的所有节点发心跳包检测节点是否正常。如果一个节点在down-after-milliseconds时间内没有回复Sentinel节点的心跳包,则该redis节点被该Sentinel节点主观下线。

故障节点客观下线

当节点被一个Sentinel节点记为主观下线时,并不意味着该节点肯定故障了,还需要Sentinel集群的其他Sentinel节点共同判断为主观下线才行。

该Sentinel节点会询问其他Sentinel节点,如果Sentinel集群中超过quorum数量的Sentinel节点认为该redis节点主观下线,则该redis客观下线。

如果客观下线的redis节点是从节点或者是Sentinel节点,则操作到此为止,没有后续的操作了;如果客观下线的redis节点为主节点,则开始故障转移,从从节点中选举一个节点升级为主节点

Sentinel集群选举Leader

如果需要从redis集群选举一个节点为主节点,首先需要从Sentinel集群中选举一个Sentinel节点作为Leader。

每一个Sentinel节点都可以成为Leader,当一个Sentinel节点确认redis集群的主节点主观下线后,会请求其他Sentinel节点要求将自己选举为Leader。被请求的Sentinel节点如果没有同意过其他Sentinel节点的选举请求,则同意该请求(选举票数+1),否则不同意。

如果一个Sentinel节点获得的选举票数达到Leader最低票数(quorum和Sentinel节点数/2+1的最大值),则该Sentinel节点选举为Leader;否则重新进行选举

Sentinel Leader决定新主节点

当Sentinel集群选举出Sentinel Leader后,由Sentinel Leader从redis从节点中选择一个redis节点作为主节点:

  1. 过滤故障的节点
  2. 选择优先级slave-priority最大的从节点作为主节点,如不存在则继续
  3. 选择复制偏移量(数据写入量的字节,记录写了多少数据。主服务器会把偏移量同步给从服务器,当主从的偏移量一致,则数据是完全同步)最大的从节点作为主节点,如不存在则继续
  4. 选择runid(redis每次启动的时候生成随机的runid作为redis的标识)最小的从节点作为主节点

过期删除策略和内存淘汰策略有什么区别?

区别:

  • 内存淘汰策略是在内存满了的时候,redis 会触发内存淘汰策略,来淘汰一些不必要的内存资源,以腾出空间,来保存新的内容
  • 过期键删除策略是将已过期的键值对进行删除,Redis 采用的删除策略是惰性删除+定期删除

介绍一下Redis 内存淘汰策略

在 32 位操作系统中,maxmemory 的默认值是 3G,因为 32 位的机器最大只支持 4GB 的内存,而系统本身就需要一定的内存资源来支持运行,所以 32 位操作系统限制最大 3 GB 的可用内存是非常合理的,这样可以避免因为内存不足而导致 Redis 实例崩溃。

Redis 内存淘汰策略共有八种,这八种策略大体分为「不进行数据淘汰」和「进行数据淘汰」两类策略。

不进行数据淘汰的策略

noeviction(Redis3.0之后,默认的内存淘汰策略) :它表示当运行内存超过最大设置内存时,不淘汰任何数据,这时如果有新的数据写入,会报错通知禁止写入,不淘汰任何数据,但是如果没用数据写入的话,只是单纯的查询或者删除操作的话,还是可以正常工作。

进行数据淘汰的策略

针对「进行数据淘汰」这一类策略,又可以细分为「在设置了过期时间的数据中进行淘汰」和「在所有数据范围内进行淘汰」这两类策略。

在设置了过期时间的数据中进行淘汰:

  • volatile-random:随机淘汰设置了过期时间的任意键值;
  • volatile-ttl:优先淘汰更早过期的键值。
  • volatile-lru(Redis3.0 之前,默认的内存淘汰策略):淘汰所有设置了过期时间的键值中,最久未使用的键值;
  • volatile-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰所有设置了过期时间的键值中,最少使用的键值;

在所有数据范围内进行淘汰:

  • allkeys-random:随机淘汰任意键值;
  • allkeys-lru:淘汰整个键值中最久未使用的键值;
  • allkeys-lfu(Redis 4.0 后新增的内存淘汰策略):淘汰整个键值中最少使用的键值

介绍一下Redis过期删除策略

Redis 选择「惰性删除+定期删除」这两种策略配和使用,以求在合理使用 CPU 时间和避免内存浪费之间取得平衡。

Redis 在访问或者修改 key 之前,都会调用 expireIfNeeded 函数对其进行检查,检查 key 是否过期:

  • 如果过期,则删除该 key,至于选择异步删除,还是选择同步删除,根据 lazyfree_lazy_expire 参数配置决定(Redis 4.0版本开始提供参数),然后返回 null 客户端;
  • 如果没有过期,不做任何处理,然后返回正常的键值对给客户端;

Redis 的定期删除是每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期key。

1、这个间隔检查的时间是多长呢?

在 Redis 中,默认每秒进行 10 次过期检查一次数据库,此配置可通过 Redis 的配置文件 redis.conf 进行配置,配置键为 hz 它的默认值是 hz 10。特别强调下,每次检查数据库并不是遍历过期字典中的所有
key,而是从数据库中随机抽取一定数量的 key 进行过期检查。

2、随机抽查的数量是多少呢?

数据库每轮抽查时,会随机选择 20 个 key 判断是否过期。:

  1. 从过期字典中随机抽取 20 个 key;
  2. 检查这 20 个 key 是否过期,并删除已过期的 key;
  3. 如果本轮检查的已过期 key 的数量,超过 5 个(20/4),也就是「已过期 key 的数量」占比「随机抽取 key 的数量」大于 25%,则继续重复步骤 1;如果已过期的 key 比例小于 25%,则停止继续删除过期 key,然后等待下一轮再检查。

可以看到,定期删除是一个循环的流程。那 Redis 为了保证定期删除不会出现循环过度,导致线程卡死现象,为此增加了定期删除循环流程的时间上限,默认不会超过 25ms。

Redis的缓存失效会不会立即删除?

不会,Redis 的过期删除策略是选择「惰性删除+定期删除」这两种策略配和使用。

惰性删除策略的做法是,不主动删除过期键,每次从数据库访问 key 时,都检测 key 是否过期,如果过期则删除该 key。

定期删除策略的做法是,每隔一段时间「随机」从数据库中取出一定数量的 key 进行检查,并删除其中的过期key。

那为什么我不过期立即删除?

在过期 key 比较多的情况下,删除过期 key 可能会占用相当一部分 CPU 时间,在内存不紧张但 CPU 时间紧张的情况下,将 CPU 时间用于删除和当前任务无关的过期键上,无疑会对服务器的响应时间和吞吐量造成影响。所以,定时删除策略对 CPU 不友好。

Redis有哪2种持久化方式?分别的优缺点是什么?

Redis 的读写操作都是在内存中,所以 Redis 性能才会高,但是当 Redis 重启后,内存中的数据就会丢失,那为了保证内存中的数据不会丢失,Redis 实现了数据持久化的机制,这个机制会把数据存储到磁盘,这样在 Redis 重启就能够从磁盘中恢复原有的数据。Redis 共有三种数据持久化的方式:

  • AOF 日志:每执行一条写操作命令,就把该命令以追加的方式写入到一个文件里;
  • RDB 快照:将某一时刻的内存数据,以二进制的方式写入磁盘

AOF 日志是如何实现的?

Redis 在执行完一条写操作命令后,就会把该命令以追加的方式写入到一个文件里,然后 Redis 重启时,会读取该文件记录的命令,然后逐一执行命令的方式来进行数据恢复。

Redis 提供了 3 种写回硬盘的策略, 在 Redis.conf 配置文件中的 appendfsync 配置项可以有以下 3 种参数可填:

  • Always,这个单词的意思是「总是」,所以它的意思是每次写操作命令执行完后,同步将 AOF 日
    志数据写回硬盘;
  • Everysec,这个单词的意思是「每秒」,所以它的意思是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,然后每隔一秒将缓冲区里的内容写回到硬盘(宕机丢失数据);
  • No,意味着不由 Redis 控制写回硬盘的时机,转交给操作系统控制写回的时机,也就是每次写操作命令执行完后,先将命令写入到 AOF 文件的内核缓冲区,再由操作系统决定何时将缓冲区内容写回硬盘(宕机丢失更多)。

RDB 快照是如何实现的呢?

因为 AOF 日志记录的是操作命令,不是实际的数据,所以用 AOF 方法做故障恢复时,需要全量把日志都执行一遍,一旦 AOF 日志非常多,势必会造成 Redis 的恢复操作缓慢。为了解决这个问题,Redis 增加了 RDB 快照。

RDB 快照就是记录某一个瞬间的内存数据,记录的是实际数据,而 AOF 文件记录的是命令操作的日志,而不是实际的数据。因此在 Redis 恢复数据时, RDB 恢复数据的效率会比 AOF 高些,因为直接将 RDB 文件读入内存就可以,不需要像 AOF 那样还需要额外执行操作命令的步骤才能恢复数据。

Redis 提供了两个命令来生成 RDB 文件,分别是 save 和 bgsave,他们的区别就在于是否在「主线程」里执行:

  • 执行了 save 命令,就会在主线程生成 RDB 文件,由于和执行操作命令在同一个线程,所以如果写入 RDB 文件的时间太长,会阻塞主线程;
  • 执行了 bgsave 命令,会创建一个子进程来生成 RDB 文件,这样可以避免主线程的阻塞;

AOF和RDB优缺点

AOF:

  • 优点:首先,AOF提供了更好的数据安全性,因为它默认每接收到一个写命令就会追加到文件末尾。即使Redis服务器宕机,也只会丢失最后一次写入前的数据。其次,AOF支持多种同步策略(如everysec、always等),可以根据需要调整数据安全性和性能之间的平衡。同时,AOF文件在Redis启动时可以通过重写机制优化,减少文件体积,加快恢复速度。并且,即使文件发生损坏,AOF还提供了redis-check-aof工具来修复损坏的文件。
  • 缺点:因为记录了每一个写操作,所以AOF文件通常比RDB文件更大,消耗更多的磁盘空间。并且,频繁的磁盘IO操作(尤其是同步策略设置为always时)可能会对Redis的写入性能造成一定影响。而且,当问个文件体积过大时,AOF会进行重写操作,AOF如果没有开启AOF重写或者重写频率较低,恢复过程可能较慢,因为它需要重放所有的操作命令。

RDB:

  • 优点: RDB通过快照的形式保存某一时刻的数据状态,文件体积小,备份和恢复的速度非常快。并且,RDB是在主线程之外通过fork子进程来进行的,不会阻塞服务器处理命令请求,对Redis服务的性能影响较小。最后,由于是定期快照,RDB文件通常比AOF文件小得多。
  • 缺点: RDB方式在两次快照之间,如果Redis服务器发生故障,这段时间的数据将会丢失。并且,如果在RDB创建快照到恢复期间有写操作,恢复后的数据可能与故障前的数据不完全一致

如何实现redis 原子性?

redis 执行一条命令的时候是具备原子性的,因为 redis 执行命令的时候是单线程来处理的,不存在多线程安全的问题。

如果要保证 2 条命令的原子性的话,可以考虑用 lua 脚本,将多个操作写到一个 Lua 脚本中,Redis 会把整个 Lua 脚本作为一个整体执行,在执行的过程中不会被其他命令打断,从而保证了 Lua 脚本中操作的原子性。

比如说,在用 redis 实现分布式锁的场景下,解锁期间涉及 2 个操作,分别是先判断锁是不是自己的,是自己的才能删除锁,为了保证这 2 个操作的原子性,会通过 lua 脚本来保证原子性

除了lua有没有什么也能保证redis的原子性?

redis 事务也可以保证多个操作的原子性。

如果 redis 事务正常执行,没有发生任何错误,那么使用 MULTI 和 EXEC 配合使用,就可以保证多个操作都完成。

但是,如果事务执行发生错误了,就没办法保证原子性了。比如说 2 个操作,第一个操作执行成果了,

但是第二个操作执行的时候,命令出错了,那事务并不会回滚,因为Redis 中并没有提供回滚机制。

因此,Redis 对事务原子性属性的保证情况:

  • Redis 事务正常执行,可以保证原子性
  • Redis 事务执行中某一个操作执行失败,不保证原子性

为什么使用redis?

主要是因为 Redis 具备「高性能」和「高并发」两种特性。

  • Redis 具备高性能:假如用户第一次访问 MySQL 中的某些数据。这个过程会比较慢,因为是从硬盘上读取的。将该用户访问的数据缓存在 Redis 中,这样下一次再访问这些数据的时候就可以直接从缓存中获取了,操作 Redis 缓存就是直接操作内存,所以速度相当快
  • 单台设备的 Redis 的 QPS是 MySQL 的 10 倍,Redis单机的 QPS 能轻松破 10w,而 MySQL 单机的 QPS 很难破 1w。所以,直接访问 Redis 能够承受的请求是远远大于直接访问 MySQL 的,所以我们可以考虑把数据库中的部分数据转移到缓存中去,这样用户的一部分请求会直接到缓存这里而不用经过数据库。

为什么redis比mysql要快?

  • 内存存储:Redis 是基于内存存储的 NoSQL 数据库,而 MySQL 是基于磁盘存储的关系型数据库。由于内存存储速度快,Redis 能够更快地读取和写入数据,而无需像 MySQL 那样频繁进行磁盘 I/O操作。
  • 简单数据结构:Redis 是基于键值对存储数据的,支持简单的数据结构。相比之下,MySQL 需要定义结构、索引等复杂的关系型数据结构,因此在某些场景下 Redis 的数据操作更为简单高效,比如 Redis 用哈希表查询, 只需要O(1) 时间复杂度,而MySQL引擎的底层实现是B+Tree,时间复杂度是O(logn)
  • 线程模型:Redis 采用单线程模型可以避免了多线程之间的竞争,省去了多线程切换带来的时间和性能上的开销,而且也不会导致死锁问题。

本地缓存和分布式缓存的区别?

本地缓存是指将数据存储在本地应用程序或服务器上,通常用于加速数据访问和提高响应速度。本地缓存通常使用内存作为存储介质,利用内存的高速读写特性来提高数据访问速度。

本地缓存的优势:

  • 访问速度快:由于本地缓存存储在本地内存中,因此访问速度非常快,能够满足频繁访问和即时响应的需求。
  • 减轻网络压力:本地缓存能够降低对远程服务器的访问次数,从而减轻网络压力,提高系统的可用性和稳定性。
  • 低延迟:由于本地缓存位于本地设备上,因此能够提供低延迟的访问速度,适用于对实时性要求较高的应用场景。

本地缓存的不足:

  • 可扩展性有限:本地缓存的可扩展性受到硬件资源的限制,无法支持大规模的数据存储和访问。

分布式缓存(Redis)是指将数据存储在多个分布式节点上,通过协同工作来提供高性能的数据访问服务。分布式缓存通常使用集群方式进行部署,利用多台服务器来分担数据存储和访问的压力。

分布式缓存的优势:

  • 可扩展性强:分布式缓存的节点可以动态扩展,能够支持大规模的数据存储和访问需求。
  • 数据一致性高:通过分布式一致性协议,分布式缓存能够保证数据在多个节点之间的一致性,减少数据不一致的问题。
  • 易于维护:分布式缓存通常采用自动化管理方式,能够降低维护成本和管理的复杂性。

分布式缓存的不足:

  • 访问速度相对较慢:相对于本地缓存,分布式缓存的访问速度相对较慢,因为数据需要从多个节点进行访问和协同。
  • 网络开销大:由于分布式缓存需要通过网络进行数据传输和协同操作,因此相对于本地缓存来说,网络开销较大。

在选择使用本地缓存还是分布式缓存时,我们需要根据具体的应用场景和需求进行权衡。以下是一些考虑因素:

  • 数据大小:如果数据量较小,且对实时性要求较高,本地缓存更适合;如果数据量较大,且需要支持大规模的并发访问,分布式缓存更具优势。
  • 网络状况:如果网络状况良好且稳定,分布式缓存能够更好地发挥其优势;如果网络状况较差或不稳定,本地缓存的访问速度和稳定性可能更有优势。

高并发场景,Redis单节点+MySQL单节点能有多大的并发量?

  • 如果缓存命中的话,4 核心 8g 内存的配置,redis 可以支撑 10w 的 qps
  • 如果缓存没有命中的话,4 核心 8g 内存的配置,mysql 只能支持 5000 左右的 qps

redis应用场景是什么?

Redis 是一种基于内存的数据库,对数据的读写操作都是在内存中完成,因此读写速度非常快,常用于缓存,消息队列、分布式锁等场景。

  • 缓存: Redis最常见的用途就是作为缓存系统。通过将热门数据存储在内存中,可以极大地提高访问速度,减轻数据库负载,这对于需要快速响应时间的应用程序非常重要。
  • 排行榜: Redis的有序集合结构非常适合用于实现排行榜和排名系统,可以方便地进行数据排序和排名。
  • 分布式锁: Redis的特性可以用来实现分布式锁,确保多个进程或服务之间的数据操作的原子性和一致性。
  • 计数器:由于Redis的原子操作和高性能,它非常适合用于实现计数器和统计数据的存储,如网站访问量统计、点赞数统计等。
  • 消息队列: Redis的发布订阅功能使其成为一个轻量级的消息队列,它可以用来实现发布和订阅模式,以便实时处理消息

Redis除了缓存,还有哪些应用?

Redis实现消息队列:

  • 使用Pub/Sub模式:Redis的Pub/Sub是一种基于发布/订阅的消息模式,任何客户端都可以订阅一个或多个频道,发布者可以向特定频道发送消息,所有订阅该频道的客户端都会收到此消息。该方式实现起来比较简单,发布者和订阅者完全解耦,支持模式匹配订阅。但是这种方式不支持消息持久化,消息发布后若无订阅者在线则会被丢弃;不保证消息的顺序和可靠性传输。
  • 使用List结构:使用List的方式通常是使用 LPUSH 命令将消息推入一个列表,消费者使用 BLPOP 或BRPOP 阻塞地从列表中取出消息(先进先出FIFO)。这种方式可以实现简单的任务队列。这种方式可以结合Redis的过期时间特性实现消息的TTL;通过Redis事务可以保证操作的原子性。但是需要客户端自己实现消息确认、重试等机制,相比专门的消息队列系统功能较弱。

Redis实现分布式锁:

  • set nx方式:Redis提供了几种方式来实现分布式锁,最常用的是基于 SET 命令的争抢锁机制。客户端可以使用 SET resource_name lock_value NX PX milliseconds 命令设置锁,其中 NX 表示只有当键不存在时才设置, PX 指定锁的有效时间(毫秒)。如果设置成功,则认为客户端获得锁。客户端完成操作后,解锁的还需要先判断锁是不是自己,再进行删除,这里涉及到 2 个操作,为了保证这两个操作的原子性,可以用 lua 脚本来实现。
  • RedLock算法:为了提高分布式锁的可靠性,Redis作者Antirez提出了RedLock算法,它基于多个独立的Redis实例来实现一个更安全的分布式锁。它的基本原理是客户端尝试在多数(大于半数)Redis实例上同时加锁,只有当在大多数实例上加锁成功时才认为获取锁成功。锁的超时时间应该远小于单个实例的超时时间,以避免死锁。该方式可以通过跨多个节点减少单点故障的影响,提高了锁的可用性和安全性。

Redis支持并发操作吗?

  • 单个 Redis 命令的原子性:Redis 的单个命令是原子性的,这意味着一个命令要么完全执行成功,要么完全不执行,确保操作的一致性。这对于并发操作非常重要。
  • 多个操作的事务:Redis 支持事务,可以将一系列的操作放在一个事务中执行,使用 MULTI、EXEC、DISCARD 和 WATCH 等命令来管理事务。这样可以确保一系列操作的原子性。

Redis分布式锁的实现原理?什么场景下用到分布式锁?

分布式锁是用于分布式环境下并发控制的一种机制,用于控制某个资源在同一时刻只能被一个应用所使用。

Redis 本身可以被多个客户端共享访问,正好就是一个共享存储系统,可以用来保存分布式锁,而且Redis 的读写性能高,可以应对高并发的锁操作场景。Redis 的 SET 命令有个 NX 参数可以实现「key不存在才插入」,
所以可以用它来实现分布式锁:

  • 如果 key 不存在,则显示插入成功,可以用来表示加锁成功;
  • 如果 key 存在,则会显示插入失败,可以用来表示加锁失败。

基于 Redis 节点实现分布式锁时,对于加锁操作,我们需要满足三个条件。

  • 加锁包括了读取锁变量、检查锁变量值和设置锁变量值三个操作,但需要以原子操作的方式完成,所以,我们使用 SET 命令带上 NX 选项来实现加锁;
  • 锁变量需要设置过期时间,以免客户端拿到锁后发生异常,导致锁一直无法释放,所以,我们在SET 命令执行时加上 EX/PX 选项,设置其过期时间;
  • 锁变量的值需要能区分来自不同客户端的加锁操作,以免在释放锁时,出现误释放操作,所以,我们使用 SET 命令设置锁变量值时,每个客户端设置的值是一个唯一值,用于标识客户端;

而解锁的过程就是将 lock_key 键删除(del lock_key),但不能乱删,要保证执行操作的客户端就是加锁的客户端。所以,解锁的时候,我们要先判断锁的 unique_value 是否为加锁客户端,是的话,才将lock_key 键删除。

可以看到,解锁是有两个操作,这时就需要 Lua 脚本来保证解锁的原子性,因为 Redis 在执行 Lua 脚本时,可以以原子性的方式执行,保证了锁释放操作的原子性。

这样一来,就通过使用 SET 命令和 Lua 脚本在 Redis 单节点上完成了分布式锁的加锁和解锁。

Redis的大Key问题是什么?

Redis大key问题指的是某个key对应的value值所占的内存空间比较大,导致Redis的性能下降、内存不足、数据不均衡以及主从同步延迟等问题。
到底多大的数据量才算是大key?

没有固定的判别标准,通常认为字符串类型的key对应的value值占用空间大于1M,或者集合类型的k元素数量超过1万个,就算是大key。

Redis大key问题的定义及评判准则并非一成不变,而应根据Redis的实际运用以及业务需求来综合评估。

例如,在高并发且低延迟的场景中,仅10kb可能就已构成大key;然而在低并发、高容量的环境下,大key的界限可能在100kb。因此,在设计与运用Redis时,要依据业务需求与性能指标来确立合理的大key阈值。

大Key问题的缺点?

  • 内存占用过高。大Key占用过多的内存空间,可能导致可用内存不足,从而触发内存淘汰策略。在极端情况下,可能导致内存耗尽,Redis实例崩溃,影响系统的稳定性。
  • 性能下降。大Key会占用大量内存空间,导致内存碎片增加,进而影响Redis的性能。对于大Key的操作,如读取、写入、删除等,都会消耗更多的CPU时间和内存资源,进一步降低系统性能。
  • 阻塞其他操作。某些对大Key的操作可能会导致Redis实例阻塞。例如,使用DEL命令删除一个大Key时,可能会导致Redis实例在一段时间内无法响应其他客户端请求,从而影响系统的响应时间和吞吐量。
  • 网络拥塞。每次获取大key产生的网络流量较大,可能造成机器或局域网的带宽被打满,同时波及其他服务。例如:一个大key占用空间是1MB,每秒访问1000次,就有1000MB的流量。
  • 主从同步延迟。当Redis实例配置了主从同步时,大Key可能导致主从同步延迟。由于大Key占用较多内存,同步过程中需要传输大量数据,这会导致主从之间的网络传输延迟增加,进而影响数据一致性。
  • 数据倾斜。在Redis集群模式中,某个数据分片的内存使用率远超其他数据分片,无法使数据分片的内存资源达到均衡。另外也可能造成Redis内存达到maxmemory参数定义的上限导致重要的key被逐出,甚至引发内存溢出。

Redis大key如何解决?

对大Key进行拆分。例如将含有数万成员的一个HASH Key拆分为多个HASH Key,并确保每个Key的成员数量在合理范围。在Redis集群架构中,拆分大Key能对数据分片间的内存平衡起到显著作用。

对大Key进行清理。将不适用Redis能力的数据存至其它存储,并在Redis中删除此类数据。注意,要使用异步删除。

监控Redis的内存水位。可以通过监控系统设置合理的Redis内存报警阈值进行提醒,例如Redis内存使用率超过70%、Redis的内存在1小时内增长率超过20%等。

对过期数据进行定期清。堆积大量过期数据会造成大Key的产生,例如在HASH数据类型中以增量的形式不断写入大量数据而忽略了数据的时效性。可以通过定时任务的方式对失效数据进行清理。

什么是热key?

通常以其接收到的Key被请求频率来判定,例如:

  • QPS集中在特定的Key:Redis实例的总QPS(每秒查询率)为10,000,而其中一个Key的每秒访问量达到了7,000。
  • 带宽使用率集中在特定的Key:对一个拥有上千个成员且总大小为1 MB的HASH Key每秒发送大量的HGETALL操作请求。
  • CPU使用时间占比集中在特定的Key:对一个拥有数万个成员的Key(ZSET类型)每秒发送大量的ZRANGE操作请求。

如何解决热key问题?

  • 在Redis集群架构中对热Key进行复制。在Redis集群架构中,由于热Key的迁移粒度问题,无法将请求分散至其他数据分片,导致单个数据分片的压力无法下降。此时,可以将对应热Key进行复制并迁移至其他数据分片,例如将热Key foo复制出3个内容完全一样的Key并名为foo2、foo3、foo4,将这三个Key迁移到其他数据分片来解决单个数据分片的热Key压力。
  • 使用读写分离架构。如果热Key的产生来自于读请求,您可以将实例改造成读写分离架构来降低每个数据分片的读请求压力,甚至可以不断地增加从节点。但是读写分离架构在增加业务代码复杂度的同时,也会增加Redis集群架构复杂度。不仅要为多个从节点提供转发层(如Proxy,LVS等)来
    实现负载均衡,还要考虑从节点数量显著增加后带来故障率增加的问题。Redis集群架构变更会为监控、运维、故障处理带来了更大的挑战。

如何保证 redis 和 mysql 数据缓存一致性问题?

对于读数据,我会选择旁路缓存策略,如果 cache 不命中,会从 db 加载数据到 cache。对于写数据,我会选择更新 db 后,再删除缓存。

缓存是通过牺牲强一致性来提高性能的。这是由CAP理论决定的。缓存系统适用的场景就是非强一致性的场景,它属于CAP中的AP。所以,如果需要数据库和缓存数据保持强一致,就不适合使用缓存。

所以使用缓存提升性能,就是会有数据更新的延迟。这需要我们在设计时结合业务仔细思考是否适合用缓存。然后缓存一定要设置过期时间,这个时间太短、或者太长都不好:

  • 太短的话请求可能会比较多的落到数据库上,这也意味着失去了缓存的优势。
  • 太长的话缓存中的脏数据会使系统长时间处于一个延迟的状态,而且系统中长时间没有人访问的数据一直存在内存中不过期,浪费内存。

但是,通过一些方案优化处理,是可以最终一致性的。
针对删除缓存异常的情况,可以使用 2 个方案避免:

  • 删除缓存重试策略(消息队列)
  • 订阅 binlog,再删除缓存(Canal+消息队列)

消息队列方案

我们可以引入消息队列,将第二个操作(删除缓存)要操作的数据加入到消息队列,由消费者来操作数据。

  • 如果应用删除缓存失败,可以从消息队列中重新读取数据,然后再次删除缓存,这个就是重试机制。当然,如果重试超过的一定次数,还是没有成功,我们就需要向业务层发送报错信息了。
  • 如果删除缓存成功,就要把数据从消息队列中移除,避免重复操作,否则就继续重试。

重试删除缓存机制还可以,就是会造成好多业务代码入侵。

订阅 MySQL binlog,再操作缓存

「先更新数据库,再删缓存」的策略的第一步是更新数据库,那么更新数据库成功,就会产生一条变更日志,记录在 binlog 里。

于是我们就可以通过订阅 binlog 日志,拿到具体要操作的数据,然后再执行缓存删除,阿里巴巴开源的Canal 中间件就是基于这个实现的。

Canal 模拟 MySQL 主从复制的交互协议,把自己伪装成一个 MySQL 的从节点,向 MySQL 主节点发送dump 请求,MySQL 收到请求后,就会开始推送 Binlog 给 Canal,Canal 解析 Binlog 字节流之后,转换为便于读取的结构化数据,供下游程序订阅使用。

将binlog日志采集发送到MQ队列里面,然后编写一个简单的缓存删除消息者订阅binlog日志,根据更新log删除缓存,并且通过ACK机制确认处理这条更新log

缓存雪崩、击穿、穿透是什么?怎么解决?

  • 缓存雪崩:当大量缓存数据在同一时间过期(失效)或者 Redis 故障宕机时,如果此时有大量的用户请求,都无法在 Redis 中处理,于是全部请求都直接访问数据库,从而导致数据库的压力骤增,严重的会造成数据库宕机,从而形成一系列连锁反应,造成整个系统崩溃,这就是缓存雪崩的问题。
  • 缓存击穿:如果缓存中的某个热点数据过期了,此时大量的请求访问了该热点数据,就无法从缓存中读取,直接访问数据库,数据库很容易就被高并发的请求冲垮,这就是缓存击穿的问题。
  • 缓存穿透:当用户访问的数据,既不在缓存中,也不在数据库中,导致请求在访问缓存时,发现缓存缺失,再去访问数据库时,发现数据库中也没有要访问的数据,没办法构建缓存数据,来服务后续的请求。那么当有大量这样的请求到来时,数据库的压力骤增,这就是缓存穿透的问题

  • 缓存雪崩解决方案:
    • 均匀设置过期时间:如果要给缓存数据设置过期时间,应该避免将大量的数据设置成同一个过期时间。我们可以在对缓存数据设置过期时间时,给这些数据的过期时间加上一个随机数,这样就保证数据不会在同一时间过期。
    • 互斥锁:当业务线程在处理用户请求时,如果发现访问的数据不在 Redis 里,就加个互斥锁,保证同一时间内只有一个请求来构建缓存(从数据库读取数据,再将数据更新到 Redis 里),当缓存构建完成后,再释放锁。未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。实现互斥锁的时候,最好设置超时时间,不然第一个请求拿到了锁,然后这个请求发生了某种意外而一直阻塞,一直不释放锁,这时其他请求也一直拿不到锁,整个系统就会出现无响应的现象。
    • 后台更新缓存:业务线程不再负责更新缓存,缓存也不设置有效期,而是让缓存“永久有效”,并将更新缓存的工作交由后台线程定时更新。
  • 缓存击穿解决方案:
    • 互斥锁方案,保证同一时间只有一个业务线程更新缓存,未能获取互斥锁的请求,要么等待锁释放后重新读取缓存,要么就返回空值或者默认值。不给热点数据设置过期时间,由后台异步更新缓存,或者在热点数据准备要过期前,提前通知后台线程更新缓存以及重新设置过期时间;
  • 缓存穿透解决方案:
    • 非法请求的限制:当有大量恶意请求访问不存在的数据的时候,也会发生缓存穿透,因此在 API 入口处我们要判断求请求参数是否合理,请求参数是否含有非法值、请求字段是否存在,如果判断出是恶意请求就直接返回错误,避免进一步访问缓存和数据库。
    • 缓存空值或者默认值:当我们线上业务发现缓存穿透的现象时,可以针对查询的数据,在缓存中设置一个空值或者默认值,这样后续请求就可以从缓存中读取到空值或者默认值,返回给应用,而不会继续查询数据库。
    • 布隆过滤器:我们可以在写入数据库数据时,使用布隆过滤器做个标记,然后在用户请求到来时,业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在。即使发生了缓存穿透,大量请求只会查询 Redis 和布隆过滤器,而不会查询数据库,保证了数据库能正常运行,Redis 自身也是支持布隆过滤器的。

布隆过滤器原理介绍一下

布隆过滤器由「初始值都为 0 的位图数组」和「 N 个哈希函数」两部分组成。当我们在写入数据库数据时,在布隆过滤器里做个标记,这样下次查询数据是否在数据库时,只需要查询布隆过滤器,如果查询到数据没有被标记,说明不在数据库中。

布隆过滤器会通过 3 个操作完成标记:

  • 第一步,使用 N 个哈希函数分别对数据做哈希计算,得到 N 个哈希值;
  • 第二步,将第一步得到的 N 个哈希值对位图数组的长度取模,得到每个哈希值在位图数组的对应位置。
  • 第三步,将每个哈希值在位图数组的对应位置的值设置为 1;

举个例子,假设有一个位图数组长度为 8,哈希函数 3 个的布隆过滤器。
在数据库写入数据 x 后,把数据 x 标记在布隆过滤器时,数据 x 会被 3 个哈希函数分别计算出 3 个哈希值,然后在对这 3 个哈希值对 8 取模,假设取模的结果为 1、4、6,然后把位图数组的第 1、4、6 位置的值设置为 1。

当应用要查询数据 x 是否数据库时,通过布隆过滤器只要查到位图数组的第 1、4、6 位置的值是否全为 1,只要有一个为 0,就认为数据 x 不在数据库中。

布隆过滤器由于是基于哈希函数实现查找的,高效查找的同时存在哈希冲突的可能性,比如数据 x 和数据 y 可能都落在第 1、4、6 位置,而事实上,可能数据库中并不存在数据 y,存在误判的情况。

所以,查询布隆过滤器说数据存在,并不一定证明数据库中存在这个数据,但是查询到数据不存在,数据库中一定就不存在这个数据。

如何设计秒杀场景处理高并发以及超卖现象?

在数据库层面解决

  1. 在查询商品库存时加排他锁,执行如下语句:在事务中线程A通过select * from goods for where goods_id=#{id} for update语句给goods_id为#{id}的数据行上了锁。那么其他线程此时可以使用select语句读取数据,但是如果也使用select for update语句加锁,或者使用update,delete都会阻塞,直到线程A将事务提交(或者回滚),其他线程中的某个线程排在线程A后的线程才能获取到锁。
  2. 更新数据库减库存的时候,进行库存限制条件:update goods set stock = stock - 1 where goods_id = ? and stock >0这种通过数据库加锁来解决的方案,性能不是很好,在高并发的情况下,还可能存在因为获取不到数据库连接或者因为超时等待而报错。

利用分布式锁

同一个锁key,同一时间只能有一个客户端拿到锁,其他客户端会陷入无限的等待来尝试获取那个锁,只有获取到锁的客户端才能执行下面的业务逻辑。这种方案的缺点是同一个商品在多用户同时下单的情况下,会基于分布式锁串行化处理,导致没法同时处理同一个商品的大量下单的请求。

利用分布式锁+分段缓存

把数据分成很多个段,每个段是一个单独的锁,所以多个线程过来并发修改数据的时候,可以并发的修改不同段的数据

假设场景:假如你现在商品有100个库存,在redis存放5个库存key,形如:
用户下单时对用户id进行%5计算,看落在哪个redis的key上,就去取哪个,这样每次就能够处理5个进程请求这种方案可以解决同一个商品在多用户同时下单的情况,但有个坑需要解决:当某段锁的库存不足,一定要实现自动释放锁然后换下一个分段库存再次尝试加锁处理,此种方案复杂比较高。

利用redis的incr、decr的原子性 + 异步队列

实现思路

  1. 在系统初始化时,将商品的库存数量加载到redis缓存中
  2. 接收到秒杀请求时,在redis中进行预减库存(利用redis decr的原子性),当redis中的库存不足时,直接返回秒杀失败,否则继续进行第3步;
  3. 将请求放入异步队列中,返回正在排队中;
  4. 服务端异步队列将请求出队(哪些请求可以出队,可以根据业务来判定,比如:判断对应用户是否已经秒杀过对应商品,防止重复秒杀),出队成功的请求可以生成秒杀订单,减少数据库库存(在扣减库存的sql如下,返回秒杀订单详情)update goods set stock = stock - 1 where goods_id = ? and stock >0
  5. 用户在客户端申请秒杀请求后,进行轮询,查看是否秒杀成功,秒杀成功则进入秒杀订单详情,否则秒杀失败

这种方案的缺点:由于是通过异步队列写入数据库中,可能存在数据不一致,其次引用多个组件复杂度比较高

Redis为什么快?

之所以 Redis 采用单线程(网络 I/O 和执行命令)那么快,有如下几个原因:

  • Redis 的大部分操作都在内存中完成,并且采用了高效的数据结构,因此 Redis 瓶颈可能是机器的内存或者网络带宽,而并非 CPU,既然 CPU 不是瓶颈,那么自然就采用单线程的解决方案了;
  • Redis 采用单线程模型可以避免了多线程之间的竞争,省去了多线程切换带来的时间和性能上的开销,而且也不会导致死锁问题。
  • Redis 采用了 I/O 多路复用机制处理大量的客户端 Socket 请求,IO 多路复用机制是指一个线程处理多个 IO 流,就是我们经常听到的 select/epoll 机制。简单来说,在 Redis 只运行单线程的情况下,该机制允许内核中,同时存在多个监听 Socket 和已连接 Socket。内核会一直监听这些 Socket上的连接请求或数据请求。一旦有请求到达,就会交给 Redis 线程处理,这就实现了一个 Redis 线程处理多个 IO 流的效果。

Redis哪些地方使用了多线程?

Redis 单线程指的是「接收客户端请求->解析请求 ->进行数据读写等操作->发送数据给客户端」这个过程是由一个线程(主线程)来完成的,这也是我们常说 Redis 是单线程的原因。

但是,Redis 程序并不是单线程的,Redis 在启动的时候,是会启动后台线程(BIO)的:

  • Redis 在 2.6 版本,会启动 2 个后台线程,分别处理关闭文件、AOF 刷盘这两个任务;
  • Redis 在 4.0 版本之后,新增了一个新的后台线程,用来异步释放 Redis 内存,也就是 lazyfree 线程。例如执行 unlink key / flushdb async / flushall async 等命令,会把这些删除操作交给后台线程来执行,好处是不会导致 Redis 主线程卡顿。因此,当我们要删除一个大 key 的时候,不要使用del 命令删除,因为 del 是在主线程处理的,这样会导致 Redis 主线程卡顿,因此我们应该使用unlink 命令来异步删除大key。

之所以 Redis 为「关闭文件、AOF 刷盘、释放内存」这些任务创建单独的线程来处理,是因为这些任务的操作都是很耗时的,如果把这些任务都放在主线程来处理,那么 Redis 主线程就很容易发生阻塞,这样就无法处理后续的请求了。

后台线程相当于一个消费者,生产者把耗时任务丢到任务队列中,消费者(BIO)不停轮询这个队列,拿出任务就去执行对应的方法即可。

虽然 Redis 的主要工作(网络 I/O 和执行命令)一直是单线程模型,但是在 Redis 6.0 版本之后,也采用了多个 I/O 线程来处理网络请求,这是因为随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络 I/O 的处理上。

所以为了提高网络 I/O 的并行度,Redis 6.0 对于网络 I/O 采用多线程来处理。但是对于命令的执行,Redis 仍然使用单线程来处理,所以大家不要误解Redis 有多线程同时执行命令。

Redis 6.0 版本支持的 I/O 多线程特性,默认情况下 I/O 多线程只针对发送响应数据(write client socket),并不会以多线程的方式处理读请求(read client socket)。要想开启多线程处理客户端读请求,就需要把 Redis.conf 配置文件中的 io-threads-do-reads 配置项设为 yes。

关于线程数的设置,官方的建议是如果为 4 核的 CPU,建议线程数设置为 2 或 3,如果为 8 核 CPU 建议线程数设置为 6,线程数一定要小于机器核数,线程数并不是越大越好。

因此, Redis 6.0 版本之后,Redis 在启动的时候,默认情况下会额外创建 6 个线程(这里的线程数不包括主线程):

  • Redis-server : Redis的主线程,主要负责执行命令;
  • bio_close_file、bio_aof_fsync、bio_lazy_free:三个后台线程,分别异步处理关闭文件任务、AOF刷盘任务、释放内存任务;
  • io_thd_1、io_thd_2、io_thd_3:三个 I/O 线程,io-threads 默认是 4 ,所以会启动 3(4-1)个I/O 多线程,用来分担 Redis 网络 I/O 的压力。

Redis怎么实现的io多路复用?

为什么 Redis 中要使用 I/O 多路复用这种技术呢?

因为 Redis 是跑在「单线程」中的,所有的操作都是按照顺序线性执行的,但是由于读写操作等待用户输入 或 输出都是阻塞的,所以 I/O 操作在一般情况下往往不能直接返回,这会导致某一文件的 I/O 阻塞导,致整个进程无法对其它客户提供服务。而 I/O 多路复用就是为了解决这个问题而出现的。为了让单线程(进程)的服务端应用同时处理多个客户端的事件,Redis 采用了 IO 多路复用机制。

这里“多路”指的是多个网络连接客户端,“复用”指的是复用同一个线程(单进程)。I/O 多路复用其实是使用一个线程来检查多个 Socket 的就绪状态,在单个线程中通过记录跟踪每一个 socket(I/O流)的状态来管理处理多个 I/O 流。

  • 一个 socket 客户端与服务端连接时,会生成对应一个套接字描述符(套接字描述符是文件描述符的一种),每一个 socket 网络连接其实都对应一个文件描述符。
  • 多个客户端与服务端连接时,Redis 使用 I/O 多路复用程序 将客户端 socket 对应的 FD 注册到监听列表(一个队列)中。当客服端执行 read、write 等操作命令时,I/O 多路复用程序会将命令封装成一个事件,并绑定到对应的 FD 上。
  • 文件事件处理器使用 I/O 多路复用模块同时监控多个文件描述符(fd)的读写情况,当 accept、read、write 和 close 文件事件产生时,文件事件处理器就会回调 FD 绑定的事件处理器进行处理相关命令操作。

例如:以 Redis 的 I/O 多路复用程序 epoll 函数为例。多个客户端连接服务端时,Redis 会将客户端socket 对应的 fd 注册进 epoll,然后 epoll 同时监听多个文件描述符(FD)是否有数据到来,如果有数据来了就通知事件处理器赶紧处理,这样就不会存在服务端一直等待某个客户端给数据的情形。

整个文件事件处理器是在单线程上运行的,但是通过 I/O 多路复用模块的引入,实现了同时对多个 FD 读写的监控,当其中一个 client 端达到写或读的状态,文件事件处理器就马上执行,从而就不会出现 I/O堵塞的问题,提高了网络通信的性能。

Redis 的 I/O 多路复用模式使用的是 Reactor 设置模式的方式来实现

Redis的网络模型是怎样的?

Redis 6.0 版本之前,是用的是单Reactor单线程的模式

单 Reactor 单进程的方案因为全部工作都在同一个进程内完成,所以实现起来比较简单,不需要考虑进程间通信,也不用担心多进程竞争。
但是,这种方案存在 2 个缺点:

  • 第一个缺点,因为只有一个进程,无法充分利用 多核 CPU 的性能;
  • 第二个缺点,Handler 对象在业务处理时,整个进程是无法处理其他连接的事件的,如果业务处理耗时比较长,那么就造成响应的延迟;

所以,单 Reactor 单进程的方案不适用计算机密集型的场景,只适用于业务处理非常快速的场景。

Redis 是由 C 语言实现的,在 Redis 6.0 版本之前采用的正是「单 Reactor 单进程」的方案,因为 Redis业务处理主要是在内存中完成,操作的速度是很快的,性能瓶颈不在 CPU 上,所以 Redis 对于命令的处
理是单进程的方案。

到 Redis 6.0 之后,就将网络IO的处理改成多线程的方式了,目的是为了这是因为随着网络硬件的性能提升,Redis 的性能瓶颈有时会出现在网络 I/O 的处理上。

所以为了提高网络 I/O 的并行度,Redis 6.0 对于网络 I/O 采用多线程来处理。但是对于命令的执行,Redis 仍然使用单线程来处理。

讲一下Redis底层的数据结构

Redis 提供了丰富的数据类型,常见的有五种数据类型:String(字符串),Hash(哈希),List(列表),Set(集合)、Zset(有序集合)。

随着 Redis 版本的更新,后面又支持了四种数据类型:BitMap(2.2 版新增)、HyperLogLog(2.8 版新增)、GEO、Stream(5.0 版新增)。Redis 五种数据类型的应用场景:

  • String 类型:缓存对象、常规计数、分布式锁、共享 session 信息等。
  • List 类型:消息队列(但是有两个问题:1. 生产者需要自行实现全局唯一 ID;2. 不能以消费组形式消费数据)等。
  • Hash 类型:缓存对象、购物车等。
  • Set 类型:聚合计算(并集、交集、差集)场景,比如点赞、共同关注、抽奖活动等。
  • Zset 类型:排序场景,比如排行榜、电话和姓名排序等。

Redis 后续版本又支持四种数据类型,它们的应用场景如下:

  • BitMap:二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等;
  • HyperLogLog:海量数据基数统计的场景,比如百万级网页 UV 计数等;
  • GEO:存储地理位置信息的场景,比如滴滴叫车;
  • Stream:消息队列,相比于基于 List 类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息ID,支持以消费组形式消费数据

Zset 底层是怎么实现的?

Zset 类型的底层数据结构是由压缩列表跳表实现的:

  • 如果有序集合的元素个数小于 128 个,并且每个元素的值小于 64 字节时,Redis 会使用压缩列表作为 Zset 类型的底层数据结构;
  • 如果有序集合的元素不满足上面的条件,Redis 会使用跳表作为 Zset 类型的底层数据结构;

在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。

Redis为什么使用跳表而不是用B+树?

Redis 是内存数据库,跳表在实现简单性、写入性能、内存访问模式等方面的综合优势,使其成为更合适的选择。

内存结构与访问模式的差异

B+ 树的特性

  • 磁盘友好:B+ 树的设计目标是优化磁盘I/O,通过减少树的高度来降低磁盘寻道次数(例如,一个3层的B+树可以管理数百万数据)。
  • 节点填充率高:每个节点存储多个键值(Page/Block),适合批量读写。
  • 范围查询高效:叶子节点形成有序链表,范围查询(如 ZRANGE )性能极佳。

跳表的特性

  • 内存友好:跳表基于链表,通过多级索引加速查询,内存访问模式更符合CPU缓存局部性(指针跳跃更少)。
  • 简单灵活:插入/删除时仅需调整局部指针,无需复杂的节点分裂与合并。
  • 概率平衡:通过随机层高实现近似平衡,避免了严格的平衡约束。

Redis 是内存数据库,数据完全存储在内存中,不需要优化磁盘I/O,因此 B+ 树的磁盘友好特性对Redis 意义不大。而跳表的内存访问模式更优,更适合高频的内存操作。

实现复杂度的对比

B+ 树的实现复杂度:

  • 节点分裂与合并:插入/删除时可能触发节点分裂或合并,需要复杂的再平衡逻辑。
  • 锁竞争:在并发环境下,B+ 树的锁粒度较粗(如页锁),容易成为性能瓶颈。
  • 代码复杂度:B+ 树的实现需要处理大量边界条件(如最小填充因子、兄弟节点借用等)。

跳表的实现复杂度:

  • 无再平衡操作:插入时只需随机生成层高,删除时直接移除节点并调整指针。
  • 细粒度锁或无锁:跳表可以通过分段锁或无锁结构(如 CAS)实现高效并发。
  • 代码简洁:Redis 的跳表核心代码仅需约 200 行(B+ 树实现通常需要数千行)。

对于 Redis 这种追求高性能和代码简洁性的项目,跳表的低实现复杂度更具吸引力

性能对比

查询性能

  • 单点查询:跳表和 B+ 树的时间复杂度均为 O(log N) ,但跳表的实际常数更小(内存中指针跳转比磁盘块访问快得多)。
  • 范围查询:B+ 树的叶子链表在范围查询时占优,但跳表通过双向链表也能高效支持 ZRANGE 操作。

写入性能

  • B+ 树:插入可能触发节点分裂,涉及父节点递归更新,成本较高。
  • 跳表:插入仅需修改相邻节点的指针,写入性能更优(Redis 的 ZADD 操作时间复杂度为 O(logN) )。
  • 实测数据:在内存中,跳表的插入速度比 B+ 树快 2-3 倍,查询速度相当。

内存占用

  • B+ 树:每个节点需要存储多个键值和子节点指针,存在内部碎片(节点未填满时)。
  • 跳表:每个节点只需存储键值、层高和多个前向指针,内存占用更紧凑

压缩列表是怎么实现的?

压缩列表是 Redis 为了节约内存而开发的,它是由连续内存块组成的顺序型数据结构,有点类似于数组。

压缩列表在表头有三个核心字段:

  • zlbytes,记录整个压缩列表占用对内存字节数;
  • zltail,记录压缩列表「尾部」节点距离起始地址由多少字节,也就是列表尾的偏移量;
  • zllen,记录压缩列表包含的节点数量;
  • zlend(辅助字段),标记压缩列表的结束点,固定值 0xFF(十进制255)。

在压缩列表中,如果我们要查找定位第一个元素和最后一个元素,可以通过表头三个字段(zllen)的长度直接定位,复杂度是 O(1)。而查找其他元素时,就没有这么高效了,只能逐个查找,此时的复杂度就是 O(N) 了,因此压缩列表不适合保存过多的元素。

另外,压缩列表节点(entry)的构成如下:

压缩列表节点包含三部分内容:

  • prevlen,记录了「前一个节点」的长度,目的是为了实现从后向前遍历;
  • encoding,记录了当前节点实际数据的「类型和长度」,类型主要有两种:字符串和整数。
  • data,记录了当前节点的实际数据,类型和长度都由 encoding 决定;

当我们往压缩列表中插入数据时,压缩列表就会根据数据类型是字符串还是整数,以及数据的大小,会使用不同空间大小的 prevlen 和 encoding 这两个元素里保存的信息,这种根据数据大小和类型进行不同的空间大小分配的设计思想,正是 Redis 为了节省内存而采用的。

压缩列表的缺点是会发生连锁更新的问题,因此连锁更新一旦发生,就会导致压缩列表占用的内存空间要多次重新分配,这就会直接影响到压缩列表的访问性能。

所以说,虽然压缩列表紧凑型的内存布局能节省内存开销,但是如果保存的元素数量增加了,或是元素变大了,会导致内存重新分配,最糟糕的是会有「连锁更新」的问题。

因此,压缩列表只会用于保存的节点数量不多的场景,只要节点数量足够小,即使发生连锁更新,也是能接受的。

虽说如此,Redis 针对压缩列表在设计上的不足,在后来的版本中,新增设计了两种数据结构:quicklist 和 listpack。这两种数据结构的设计目标,就是尽可能地保持压缩列表节省内存的优势,同时解决压缩列表的「连锁更新」的问题。

介绍一下 Redis 中的 listpack

quicklist 虽然通过控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来减少连锁更新带来的性能影响,但是并没有完全解决连锁更新的问题。

因为 quicklistNode 还是用了压缩列表来保存元素,压缩列表连锁更新的问题,来源于它的结构设计,所以要想彻底解决这个问题,需要设计一个新的数据结构。

于是,Redis 在 5.0 新设计一个数据结构叫 listpack,目的是替代压缩列表,它最大特点是 listpack 中每个节点不再包含前一个节点的长度了,压缩列表每个节点正因为需要保存前一个节点的长度字段,就会有连锁更新的隐患。

listpack 采用了压缩列表的很多优秀的设计,比如还是用一块连续的内存空间来紧凑地保存数据,并且为了节省内存的开销,listpack 节点会采用不同的编码方式保存不同大小的数据。

listpack 头包含两个属性,分别记录了 listpack 总字节数和元素数量,然后 listpack 末尾也有个结尾标识。

每个 listpack 节点listpack entry 主要包含三个方面内容:

  • encoding,定义该元素的编码类型,会对不同长度的整数和字符串进行编码;
  • data,实际存放的数据;
  • len,encoding+data的总长度;

可以看到,listpack 没有压缩列表中记录前一个节点长度的字段了,listpack 只记录当前节点的长度,当我们向 listpack 加入一个新元素的时候,不会影响其他节点的长度字段的变化,从而避免了压缩列表的连锁更新问题。

哈希表是怎么扩容的?

进行 rehash 的时候,需要用上 2 个哈希表了。
在正常服务请求阶段,插入的数据,都会写入到「哈希表 1」,此时的「哈希表 2 」 并没有被分配空间。

随着数据逐步增多,触发了 rehash 操作,这个过程分为三步:

  • 给「哈希表 2」 分配空间,一般会比「哈希表 1」 大 2 倍;
  • 将「哈希表 1 」的数据迁移到「哈希表 2」 中;
  • 迁移完成后,「哈希表 1 」的空间会被释放,并把「哈希表 2」 设置为「哈希表 1」,然后在「哈希表 2」 新创建一个空白的哈希表,为下次 rehash 做准备。

如果「哈希表 1 」的数据量非常大,那么在迁移至「哈希表 2 」的时候,因为会涉及大量的数据拷贝,此时可能会对 Redis 造成阻塞,无法服务其他请求。

为了避免 rehash 在数据迁移过程中,因拷贝数据的耗时,影响 Redis 性能的情况,所以 Redis 采用了渐进式 rehash,也就是将数据的迁移的工作不再是一次性迁移完成,而是分多次迁移。

渐进式 rehash 步骤如下:

  • 给「哈希表 2」 分配空间;
  • 在 rehash 进行期间,每次哈希表元素进行新增、删除、查找或者更新操作时,Redis 除了会执行对应的操作之外,还会顺序将「哈希表 1 」中索引位置上的所有 key-value 迁移到「哈希表 2」上;
  • 随着处理客户端发起的哈希表操作请求数量越多,最终在某个时间点会把「哈希表 1 」的所有 keyvalue 迁移到「哈希表 2」,从而完rehash 操作。

这样就巧妙地把一次性大量数据迁移工作的开销,分摊到了多次处理请求的过程中,避免了一次性rehash 的耗时操作。

在进行渐进式 rehash 的过程中,会有两个哈希表,所以在渐进式 rehash 进行期间,哈希表元素的删除、查找、更新等操作都会在这两个哈希表进行。比如,查找一个 key 的值的话,先会在「哈希表 1」里面进行查找,如果没找到,就会继续到哈希表 2 里面进行找到。

另外,在渐进式 rehash 进行期间,新增一个 key-value 时,会被保存到「哈希表 2 」里面,而「哈希表1」 则不再进行任何添加操作,这样保证了「哈希表 1 」的 key-value 数量只会减少,随着 rehash 操作的完成,最终「哈希表 1 」就会变成空表

哈希表扩容的时候,有读请求怎么查?

查找一个 key 的值的话,先会在「哈希表 1」 里面进行查找,如果没找到,就会继续到哈希表 2 里面进行找到

String 是使用什么存储的?为什么不用 c 语言中的字符串?

Redis 的 String 字符串是用 SDS 数据结构存储的。

Redis 5.0 的 SDS 的数据结构:

  • len,记录了字符串长度。这样获取字符串长度的时候,只需要返回这个成员变量值就行,时间复杂度只需要 O(1)。
  • alloc,分配给字符数组的空间长度。这样在修改字符串的时候,可以通过 alloc - len 计算出剩余的空间大小,可以用来判断空间是否满足修改需求,如果不满足的话,就会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS 既不需要手动修改 SDS 的空间大小,也不会出现前面所说的缓冲区溢出的问题。
  • flags,用来表示不同类型的 SDS。一共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64,后面在说明区别之处。
  • buf[],字符数组,用来保存实际数据。不仅可以保存字符串,也可以保存二进制数据。

总的来说,Redis 的 SDS 结构在原本字符数组之上,增加了三个元数据:len、alloc、flags,用来解决 C语言字符串的缺陷。

  • O(1)复杂度获取字符串长度
    • C 语言的字符串长度获取 strlen 函数,需要通过遍历的方式来统计字符串长度,时间复杂度是 O(N)。
    • 而 Redis 的 SDS 结构因为加入了 len 成员变量,那么获取字符串长度的时候,直接返回这个成员变量的
      值就行,所以复杂度只有 O(1)。
  • 二进制安全
    • 因为 SDS 不需要用 “\0” 字符来标识字符串结尾了,而是有个专门的 len 成员变量来记录长度,所以可存储包含 “\0” 的数据。但是 SDS 为了兼容部分 C 语言标准库的函数, SDS 字符串结尾还是会加上 “\0” 字符。
    • 因此, SDS 的 API 都是以处理二进制的方式来处理 SDS 存放在 buf[] 里的数据,程序不会对其中的数据做任何限制,数据写入的时候时什么样的,它被读取时就是什么样的。
    • 通过使用二进制安全的 SDS,而不是 C 字符串,使得 Redis 不仅可以保存文本数据,也可以保存任意格式的二进制数据。
  • 不会发生缓冲区溢出
    • C 语言的字符串标准库提供的字符串操作函数,大多数(比如 strcat 追加字符串函数)都是不安全的,因为这些函数把缓冲区大小是否满足操作需求的工作交由开发者来保证,程序内部并不会判断缓冲区大
      小是否足够用,当发生了缓冲区溢出就有可能造成程序异常结束。
    • 所以,Redis 的 SDS 结构里引入了 alloc 和 len 成员变量,这样 SDS API 通过 alloc - len 计算,可以算出剩余可用的空间大小,这样在对字符串做修改操作的时候,就可以由程序内部判断缓冲区大小是否足够用。而且,当判断出缓冲区大小不够用时,Redis 会自动将扩大 SDS 的空间大小,以满足修改所需的大小。