复盘面经(1):滴滴一面 @垃圾回收对象

作者:垃圾回收对象

链接:https://www.nowcoder.com/feed/main/detail/034d6bc1c7bb40e791cfa09b57a8bf06?sourceSSR=users

来源:牛客网

我的面试过程中的表现不太好,面试时经常会紧张,因为自己的知识储备不够,而且表达能力上也有一定问题,经常是想到什么就说什么。

我要改变。首先第一步是完善自己的知识储备,第二步就是回答的要有逻辑,首先回答问题,其次讲为什么要这样做,为什么要将清楚这样做的原因,第一条、第二条。最后可以主动延伸扩展,把自己会的知识都说出来。直到说不出来为止。

1、介绍线程池参数和运行原理?

1.核心线程数:当任务队列没有满的时候的最多可以同时执行的线程数

2.最大线程数:当阻塞队列满的时候,最多可以同时执行的线程数

3.任务队列:当核心线程数用完的时候,需要将新的任务放到任务队列中等待。

4.存活时间:当核心线程数用完,并且任务队列已满,就会创建救急线程,救急线程的个数是最大线程数减去核心线程数,这些救急线程使用完后不会立即销毁,而是会等待一段时间后销毁。

5.就急线程的时间单位

6.线程工厂:用来创建线程

7.拒绝策略:当任务队列满了并且当前线程数也达到了最大线程数,那么新任务就会执行拒绝策略。

2、如果任务很多被拒绝之后想记录异常,如何实现?

我以为是try catch块捕获异常

package org.example.htuoj.project.config;

import java.util.concurrent.*;

public class ThreadPoolAbortPolicyDemo {
    public static void main(String[] args) {
        // 核心线程数
        int corePoolSize = 2;
        // 最大线程数
        int maximumPoolSize = 2;
        // 线程空闲时间
        long keepAliveTime = 1L;
        // 时间单位
        TimeUnit unit = TimeUnit.SECONDS;
        // 任务队列,使用容量为 2 的有界队列
        BlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(2);

        // 创建线程池,使用 AbortPolicy 拒绝策略
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                corePoolSize,
                maximumPoolSize,
                keepAliveTime,
                unit,
                workQueue,
                new ThreadPoolExecutor.AbortPolicy()
        );

        try {
            // 提交 5 个任务,超过了线程池最大处理能力(2 个线程 + 2 个队列容量)
            for (int i = 0; i < 5; i++) {
                final int taskId = i;
                executor.submit(() -> {
                    System.out.println("Task " + taskId + " is being executed by " + Thread.currentThread().getName());
                    try {
                        // 模拟任务执行时间
                        Thread.sleep(2000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("Task " + taskId + " is completed.");
                });
            }
        } catch (RejectedExecutionException e) {
            System.out.println("Task is rejected: " + e.getMessage());
        } finally {
            // 关闭线程池
            executor.shutdown();
        }
    }
}

在 Java 中,当线程池的任务队列已满且线程池中的线程都在忙碌时,新提交的任务会被拒绝。为了在任务被拒绝后记录异常,可以通过自定义拒绝策略来实现。下面为你详细介绍实现方法,并给出示例代码。

线程池拒绝策略概述

Java 中的 ThreadPoolExecutor 提供了四种内置的拒绝策略,分别是 AbortPolicy(默认策略,直接抛出 RejectedExecutionException 异常)、CallerRunsPolicy(由提交任务的线程来执行该任务)、DiscardPolicy(直接丢弃该任务,不做任何处理)和 DiscardOldestPolicy(丢弃任务队列中最旧的任务,然后尝试重新提交新任务)。如果需要记录异常,这些内置策略无法满足需求,需要自定义拒绝策略。

自定义拒绝策略实现记录异常

自定义拒绝策略需要实现 RejectedExecutionHandler 接口,并重写 rejectedExecution 方法。在该方法中,可以记录任务被拒绝的异常信息。

示例代码

import java.util.concurrent.*;
import java.util.logging.Level;
import java.util.logging.Logger;

// 自定义拒绝策略类
class CustomRejectedExecutionHandler implements RejectedExecutionHandler {
    private static final Logger logger = Logger.getLogger(CustomRejectedExecutionHandler.class.getName());

    @Override
    public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
        // 记录任务被拒绝的异常信息
        logger.log(Level.SEVERE, "Task " + r.toString() + " rejected from " + executor.toString());
        // 可以根据需要添加其他处理逻辑,如发送告警信息等
    }
}

// 测试类
public class ThreadPoolRejectionExample {
    public static void main(String[] args) {
        // 创建一个固定大小的线程池,核心线程数和最大线程数都为 2,任务队列大小为 1
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                2,
                2,
                0L, TimeUnit.MILLISECONDS,
                new ArrayBlockingQueue<>(1),
                new CustomRejectedExecutionHandler() // 使用自定义拒绝策略
        );

        // 提交 4 个任务,超过线程池的处理能力
        for (int i = 0; i < 4; i++) {
            final int taskId = i;
            executor.submit(() -> {
                try {
                    System.out.println("Executing task " + taskId);
                    Thread.sleep(2000); // 模拟任务执行时间
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }

        // 关闭线程池
        executor.shutdown();
    }
}

代码解释

  1. 自定义拒绝策略类 CustomRejectedExecutionHandler:实现了 RejectedExecutionHandler 接口,并重写了 rejectedExecution 方法。在该方法中,使用 Java 的日志系统记录了任务被拒绝的异常信息,包括任务对象和线程池对象的字符串表示。
  2. 线程池创建:创建了一个固定大小的线程池,核心线程数和最大线程数都为 2,任务队列大小为 1,并使用自定义的拒绝策略 CustomRejectedExecutionHandler
  3. 任务提交:提交 4 个任务到线程池,由于线程池的处理能力有限,会有任务被拒绝,触发自定义拒绝策略,记录异常信息。
  4. 线程池关闭:使用 executor.shutdown() 方法关闭线程池,等待已提交的任务执行完毕。

通过以上方式,可以在任务被线程池拒绝后记录异常信息,方便后续的问题排查和系统监控。

3、拒绝策略有哪些?

  1. 直接抛出异常
  2. 让调用者线程执行任务
  3. 不处理,直接丢弃
  4. 放弃任务队列中的最老的任务,将新任务加入到队列中

4、拒绝策略没有想用的怎么办?有没有自己重写过拒绝策略?

自定义拒绝策略,通过实现接口可以自定义任务拒绝策略。

比如我的oj提交代码,使用了线程池。如果提交代码的任务量非常大导致使用了拒绝策略,那么我不想让任务直接抛异常或者放弃任务,因为用户还在等待执行结果,我就想让这个任务必须执行,那么我的拒绝策略就是使用任务队列的put方法,加入等待队列中,这个put方法会一直while循环死等,直到队列有空余位置,这样该任务可以得到执行。

5、Synchronized关键字可以作用在哪里?

synchronized 是 Java 中的一个关键字,翻译成中文是同步的意思,主要解决的是多个线程之间访问资源的同步性,可以保证被它修饰的方法或者代码块在任意时刻只能有一个线程执行。

方法头上:整个方法被加锁

方法体里:一个代码块被加锁

synchronized 关键字的使用方式主要有下面 3 种:

  1. 修饰实例方法
  2. 修饰静态方法
  3. 修饰代码块

1、修饰实例方法 (锁当前对象实例)

给当前对象实例加锁,进入同步代码前要获得 当前对象实例的锁

synchronized void method() {    //业务代码}

2、修饰静态方法 (锁当前类)

给当前类加锁,会作用于类的所有对象实例 ,进入同步代码前要获得 当前 class 的锁

这是因为静态成员不属于任何一个实例对象,归整个类所有,不依赖于类的特定实例,被类的所有实例共享。

synchronized static void method() {    //业务代码}

静态 synchronized 方法和非静态 synchronized 方法之间的调用互斥么?不互斥!如果一个线程 A 调用一个实例对象的非静态 synchronized 方法,而线程 B 需要调用这个实例对象所属类的静态 synchronized 方法,是允许的,不会发生互斥现象,因为访问静态 synchronized 方法占用的锁是当前类的锁,而访问非静态 synchronized 方法占用的锁是当前实例对象锁。

3、修饰代码块 (锁指定对象/类)

对括号里指定的对象/类加锁:

  • synchronized(object) 表示进入同步代码库前要获得 给定对象的锁。
  • synchronized(类.class) 表示进入同步代码前要获得 给定 Class 的锁
synchronized(this) {    //业务代码}

总结:

  • synchronized 关键字加到 static 静态方法和 synchronized(class) 代码块上都是是给 Class 类上锁;
  • synchronized 关键字加到实例方法上是给对象实例上锁;
  • 尽量不要使用 synchronized(String a) 因为 JVM 中,字符串常量池具有缓存功能。

5、说一下使用Synchronized实现死锁的思路?

首先死锁的出现要满足四个条件:

  1. 互斥条件:一个资源只能被一个线程拥有
  2. 持有并等待:拥有某个资源的同时并申请另外一个资源,但另外一个资源被占有,需要等待
  3. 不可剥夺:一个线程拥有某个资源不可以被其他线程强行剥夺
  4. 循环等待:A等待B拥有的资源,B等待A拥有的资源,构成了一个环路。

如何解决死锁问题:

  1. 死锁预防破坏互斥条件:让互斥资源变成共享资源,但这个共享不是说一个资源真的同时被多个线程使用。比如打印机的假脱机技术,多个用户可以同时向打印机提交打印任务,系统将这些任务存储在输出井中,然后按照一定的顺序依次打印,实现了打印机的共享。破坏持有并等待条件:进程执行前一次性申请全部需要的资源,知道全部资源获取了才开始执行。这样就不会在执行过程中等待其他资源。进程在执行过程中,申请资源前放弃自己拥有的所有资源。这样就不会在持有自己不需要的资源。破坏不可剥夺条件:申请的资源被占用时,直接放弃自己拥有的全部资源。这样资源就可以被其他资源所占用了。破坏循环等待条件:按照资源有序分配法,给每个资源设置编号,申请资源必须按照序号递增的顺序申请,这样就不会出现一个进程拥有A去申请B,一个进程拥有B去申请A导致的循环等待了。
  2. 死锁避免:使用银行家算法,每次分配资源时去查看是否会导致死锁,如果会就不去分配资源。

现在有两个资源A和B,线程1先使用Synchronized给资源A加锁,执行一段时间后,给资源B加锁。线程2使用Synchronized给资源B加锁,执行一段时间后给资源A加锁。这样就导致了死锁。

7、Synchronized锁String或者null会不会出现问题?

会。

首先说string字符串对象,在JVM中,有一个常量池,会缓存一些常量对象,包括字符串对象。

如果现在有两个业务,业务1创建一个字符串"A",然后去给该字符串加锁。业务2也创建一个字符串"A",也去给该字符串加锁。本来这两个字符串对象应该是不同的字符串的对象,但由于常量池的缘故,就会导致这两个字符串变成 同一个对象。

Synchronized不能锁null,直接编译出错。

在 Java 中,使用 synchronized 关键字锁定 String 类型对象或者 null 会存在不同的问题,下面分别进行分析:

锁定 String 对象

问题分析

  • 字符串常量池导致的锁竞争问题:Java 中的字符串常量池会缓存字符串字面量,相同内容的字符串字面量在常量池中只会存在一份。当使用 synchronized 锁定字符串字面量时,可能会导致不同代码块看似在锁定不同的对象,实际上锁定的是同一个对象,从而引发意外的锁竞争。
  • 示例代码
public class SynchronizedStringExample {
    public static void main(String[] args) {
        String lock1 = "hello";
        String lock2 = "hello";

        // 启动两个线程,分别尝试获取锁
        Thread thread1 = new Thread(() -> {
            synchronized (lock1) {
                System.out.println("Thread 1 acquired the lock");
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("Thread 1 released the lock");
            }
        });

        Thread thread2 = new Thread(() -> {
            synchronized (lock2) {
                System.out.println("Thread 2 acquired the lock");
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("Thread 2 released the lock");
            }
        });

        thread1.start();
        thread2.start();
    }
}
  • 代码解释:在上述代码中,lock1 和 lock2 引用的是常量池中同一个 "hello" 字符串对象。因此,当 thread1 进入 synchronized 块并获取锁后,thread2 必须等待 thread1 释放锁才能进入相应的 synchronized 块,这可能不是开发者期望的结果。
  • 解决方案:如果需要使用 String 作为锁对象,建议使用 new String() 来创建新的字符串对象,避免使用字符串字面量。

锁定可变 String 对象的问题

  • 问题描述:如果使用可变的 StringBuilder 或 StringBuffer 转换后的 String 对象作为锁,并且在锁的作用域内对其进行修改,可能会导致锁的行为不可预测。因为对象的引用可能会改变,从而影响锁的正确性。

锁定 null 对象

问题分析

  • 抛出 **NullPointerException**:当使用 synchronized 关键字锁定 null 对象时,会在运行时抛出 NullPointerException。因为 synchronized 关键字需要一个有效的对象引用作为锁,而 null 不是有效的对象引用。
  • 示例代码
public class SynchronizedNullExample {
    public static void main(String[] args) {
        String nullLock = null;
        try {
            synchronized (nullLock) {
                System.out.println("This code will never be executed.");
            }
        } catch (NullPointerException e) {
            System.out.println("Caught NullPointerException: " + e.getMessage());
        }
    }
}
  • 代码解释:在上述代码中,nullLock 为 null,当执行 synchronized (nullLock) 时,会立即抛出 NullPointerException,程序会进入 catch 块进行异常处理。

综上所述,使用 synchronized 锁定 String 对象时要注意字符串常量池的影响,避免意外的锁竞争;而锁定 null 对象会导致 NullPointerException,在使用时需要确保锁对象不为 null

8、说一下ArrayList和LinkedList的区别,以及从插入删除效率上进行比较?

1.底层数据结构上:ArrayList底层使用的是数组,LinkedList底层使用的是链表。所以他俩的区别主要是数组和链表的区别。

2.随机访问上:ArrayList可以通过下标获取某一个元素,时间复杂度是O(1),LinkedList查询某个元素只能通过指针一个个遍历,时间复杂度是O(n)。

3.插入和删除:ArrayList在某个位置上插入元素,那么这个位置后面的所有元素都需要向后移一位,时间复杂度是O(n),删除同理,删除某个位置上的元素,这个位置后面的所有元素都需要向前移动一位。时间复杂度是O(n)。LinkedList在某个位置上增加或者删除某个元素,只需要改变前后两个元素的指针,时间复杂度是O(1)。但是LinkedList不支持随机访问的,所以查找某个元素的时间复杂度是O(n),总的增删复杂度还是O(n),所以LinkedList基本上很少使用。

4.空间占用上:ArrayList每次创建时都要申请一段连续的内存空间,而且有一部分空间是得不到使用的。LinkedList 使用的内存空间是不连续的,不会有空闲的空间。但是每个节点都需要存储上下节点的指针,需要很多空间来存放指针。

5.线程安全上,两个集合都不是线程安全的。

9、如果是很大的数据量,从中间插入谁更快?

我觉得是LinkedList,因为只需要从头节点遍历到中间节点,然后直接修改指针就可以了。而ArrayList需要将所有后面的元素向后移动一位,这个耗时应该比LinkedList要长。

当面对很大的数据量,需要在集合中间进行插入操作时,LinkedList 通常会比 ArrayList 更快,下面从两种集合的底层数据结构、插入操作原理以及性能分析这几个方面来详细解释。

底层数据结构

  • ArrayList:ArrayList 是基于动态数组实现的。它在内存中分配一段连续的空间来存储元素,当元素数量超过数组容量时,会创建一个更大的新数组,并将原数组中的元素复制到新数组中。
  • LinkedList:LinkedList 是基于双向链表实现的。链表中的每个节点包含元素本身以及指向前一个节点和后一个节点的引用,节点在内存中可以不连续存储。

插入操作原理

  • ArrayList:在 ArrayList 中间插入元素时,需要将插入位置之后的所有元素向后移动一位,以腾出空间给新元素。如果插入位置是 index,那么从 index 到数组末尾的所有元素都要依次向后移动一个位置,这个过程涉及大量的元素复制操作。
  • LinkedList:在 LinkedList 中间插入元素时,只需要修改插入位置前后节点的引用。具体来说,创建一个新节点,将新节点的 prev 引用指向插入位置前的节点,next 引用指向插入位置后的节点,然后更新前后节点的 next 和 prev 引用,使其分别指向新节点。这个过程只涉及少量的引用修改操作。

性能分析

  • ArrayList:插入操作的时间复杂度为 $O(n)$,其中 是 ArrayList 的大小。因为在最坏情况下,需要移动数组中一半的元素。当数据量很大时,这种大量的元素移动会消耗大量的时间和系统资源,导致插入性能下降。
  • LinkedList:插入操作的时间复杂度为 $O(1)$,这是因为只需要修改几个节点的引用。然而,在实际情况中,要找到插入位置需要从头节点或尾节点开始遍历链表,这个遍历过程的时间复杂度为 (平均情况下),所以整体的插入操作平均时间复杂度为 $O(n)$。但相比于 ArrayList 的元素移动操作,链表的引用修改操作开销要小得多。

示例代码验证

import java.util.ArrayList;
import java.util.LinkedList;
import java.util.List;

public class InsertionPerformanceTest {
    private static final int DATA_SIZE = 100000;
    private static final int INSERT_INDEX = DATA_SIZE / 2;

    public static void main(String[] args) {
        // 测试 ArrayList 的插入性能
        List<Integer> arrayList = new ArrayList<>();
        for (int i = 0; i < DATA_SIZE; i++) {
            arrayList.add(i);
        }
        long startTime = System.currentTimeMillis();
        arrayList.add(INSERT_INDEX, 9999);
        long endTime = System.currentTimeMillis();
        System.out.println("ArrayList 插入耗时: " + (endTime - startTime) + " 毫秒");

        // 测试 LinkedList 的插入性能
        List<Integer> linkedList = new LinkedList<>();
        for (int i = 0; i < DATA_SIZE; i++) {
            linkedList.add(i);
        }
        startTime = System.currentTimeMillis();
        linkedList.add(INSERT_INDEX, 9999);
        endTime = System.currentTimeMillis();
        System.out.println("LinkedList 插入耗时: " + (endTime - startTime) + " 毫秒");
    }
}

在上述代码中,首先创建了包含大量元素的 ArrayListLinkedList,然后在中间位置插入一个新元素,并记录插入操作所花费的时间。运行该代码可以发现,通常情况下 LinkedList 的插入操作会比 ArrayList 更快,尤其是当数据量非常大时,这种性能差异会更加明显。

综上所述,在很大的数据量下从中间插入元素,LinkedList 一般会比 ArrayList 具有更好的性能表现。

10、这两个List的底层插入是如何做的知道吗?

首先ArrayList:插入之前先检查数组中是否有剩余位置可以使用,如果没有则先进行扩容,扩容之后,才进行插入操作。执行插入首先将该位置的元素以及后面所有的元素向后移动一位,移动完了之后,将新元素复制到当前位置,最后将元素个数+1。

针对LinkedList:如果是插入头节点或者尾节点,那么可以直接修改前后指针完成插入操作,如果是中间某个节点,那么需要从头节点遍历到该节点插入位置的前一个节点,通过前一个节点可以获取到它后面的节点,有了这俩节点之后,就可以执行插入操作了,修改他们的前后指针。

ArrayListLinkedList 是 Java 中常用的两种 List 实现,它们底层插入操作的实现方式因各自的数据结构不同而存在较大差异,下面分别详细介绍:

ArrayList 底层插入实现

数据结构基础

ArrayList 底层基于动态数组实现。数组是一段连续的内存空间,用于存储元素。ArrayList 内部维护一个 Object 类型的数组 elementData 来存储元素,同时有一个变量 size 记录当前数组中实际存储的元素数量。

插入操作步骤

以下以 add(int index, E element) 方法为例,该方法用于在指定位置插入元素:

  1. 检查索引合法性:首先会检查传入的索引 index 是否在合法范围内(0 <= index <= size),如果不合法,会抛出 IndexOutOfBoundsException 异常。
  2. 确保容量足够:调用 ensureCapacityInternal(size + 1) 方法来确保数组有足够的空间容纳新元素。如果当前数组容量不足,会进行扩容操作。扩容时,会创建一个新的数组,其容量通常为原数组容量的 1.5 倍(oldCapacity + (oldCapacity >> 1)),然后将原数组中的元素复制到新数组中。
  3. 移动元素:将索引 index 及之后的所有元素向后移动一位,为新元素腾出位置。这是通过 System.arraycopy() 方法实现的,该方法可以高效地复制数组元素。
  4. 插入新元素:将新元素插入到指定的 index 位置,并将 size 加 1。

示例代码(简化版 add 方法)

public class SimpleArrayList<E> {
    private Object[] elementData;
    private int size;

    public SimpleArrayList() {
        this.elementData = new Object[10];
        this.size = 0;
    }

    public void add(int index, E element) {
        if (index < 0 || index > size) {
            throw new IndexOutOfBoundsException();
        }
        ensureCapacityInternal(size + 1);
        System.arraycopy(elementData, index, elementData, index + 1, size - index);
        elementData[index] = element;
        size++;
    }

    private void ensureCapacityInternal(int minCapacity) {
        if (minCapacity > elementData.length) {
            int newCapacity = elementData.length + (elementData.length >> 1);
            Object[] newElementData = new Object[newCapacity];
            System.arraycopy(elementData, 0, newElementData, 0, size);
            elementData = newElementData;
        }
    }
}

LinkedList 底层插入实现

数据结构基础

LinkedList 底层基于双向链表实现。双向链表中的每个节点包含三个部分:元素值、指向前一个节点的引用 prev 和指向后一个节点的引用 nextLinkedList 内部维护了两个特殊的节点引用 firstlast,分别指向链表的头节点和尾节点,同时有一个变量 size 记录链表中元素的数量。

插入操作步骤

以下以 add(int index, E element) 方法为例,该方法用于在指定位置插入元素:

  1. 检查索引合法性:检查传入的索引 index 是否在合法范围内(0 <= index <= size),如果不合法,会抛出 IndexOutOfBoundsException 异常。
  2. 找到插入位置:如果 index 等于 size,说明要在链表尾部插入元素,直接调用 linkLast(element) 方法;否则,需要遍历链表找到索引为 index 的节点,然后调用 linkBefore(element, node) 方法在该节点之前插入新元素。
  3. 插入新元素:在尾部插入:创建一个新节点,将其 prev 引用指向原尾节点,原尾节点的 next 引用指向新节点,然后更新 last 引用为新节点。在中间插入:创建一个新节点,将其 prev 引用指向插入位置前的节点,next 引用指向插入位置的节点,然后更新前后节点的 prev 和 next 引用,使其分别指向新节点。

示例代码(简化版 add 方法)

public class SimpleLinkedList<E> {
    private Node<E> first;
    private Node<E> last;
    private int size;

    private static class Node<E> {
        E item;
        Node<E> prev;
        Node<E> next;

        Node(Node<E> prev, E element, Node<E> next) {
            this.item = element;
            this.prev = prev;
            this.next = next;
        }
    }

    public void add(int index, E element) {
        if (index < 0 || index > size) {
            throw new IndexOutOfBoundsException();
        }
        if (index == size) {
            linkLast(element);
        } else {
            Node<E> succ = node(index);
            linkBefore(element, succ);
        }
    }

    private void linkLast(E element) {
        Node<E> l = last;
        Node<E> newNode = new Node<>(l, element, null);
        last = newNode;
        if (l == null) {
            first = newNode;
        } else {
            l.next = newNode;
        }
        size++;
    }

    private void linkBefore(E element, Node<E> succ) {
        Node<E> pred = succ.prev;
        Node<E> newNode = new Node<>(pred, element, succ);
        succ.prev = newNode;
        if (pred == null) {
            first = newNode;
        } else {
            pred.next = newNode;
        }
        size++;
    }

    private Node<E> node(int index) {
        if (index < (size >> 1)) {
            Node<E> x = first;
            for (int i = 0; i < index; i++) {
                x = x.next;
            }
            return x;
        } else {
            Node<E> x = last;
            for (int i = size - 1; i > index; i--) {
                x = x.prev;
            }
            return x;
        }
    }
}

综上所述,ArrayList 的插入操作主要涉及数组元素的移动和扩容,而 LinkedList 的插入操作主要是节点引用的修改。由于操作方式的不同,它们在不同场景下的插入性能表现也有所差异。

11、ArrayList会有线程安全问题吗?如果我想用ArrayList还想保证线程安全怎么做?

会,在ArrayList所有涉及到修改数据的方法上加一个Synchronized关键字。这样如果有多个线程同时修改ArrayList对象,那么就可以保证只有一个线程能修改,其他线程等待,这样就保证了线程安全。

1. ArrayList 存在线程安全问题

ArrayList 不是线程安全的类,在多线程环境下对其进行并发操作时会出现线程安全问题,主要体现在以下几个方面:

  • 部分值为null:当线程1走到了扩容那里发现当前size是9,而数组容量是10,所以不用扩容,这时候cpu让出执行权,线程2也进来了,发现size是9,而数组容量是10,所以不用扩容,这时候线程1继续执行,将数组下标索引为9的位置set值了,还没有来得及执行size++,这时候线程2也来执行了,又把数组下标索引为9的位置set了一遍,这时候两个先后进行size++,导致下标索引10的地方就为null了。
  • 索引越界异常:线程1走到扩容那里发现当前size是9,数组容量是10不用扩容,cpu让出执行权,线程2也发现不用扩容,这时候数组的容量就是10,而线程1 set完之后size++,这时候线程2再进来size就是10,数组的大小只有10,而你要设置下标索引为10的就会越界(数组的下标索引从0开始);
  • size与我们add的数量不符:这个基本上每次都会发生,这个理解起来也很简单,因为size++本身就不是原子操作,可以分为三步:获取size的值,将size的值加1,将新的size值覆盖掉原来的,线程1和线程2拿到一样的size值加完了同时覆盖,就会导致一次没有加上,所以肯定不会与我们add的数量保持一致的;

2. 使用 ArrayList 并保证线程安全的方法

方法一:使用 Collections.synchronizedList

可以使用 Collections 工具类的 synchronizedList 方法将 ArrayList 转换为线程安全的列表。该方法会返回一个线程安全的列表包装器,对该包装器的所有操作都会进行同步处理。

示例代码

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class SynchronizedArrayListExample {
    public static void main(String[] args) {
        // 创建一个普通的 ArrayList
        List<String> arrayList = new ArrayList<>();
        // 将 ArrayList 转换为线程安全的列表
        List<String> synchronizedList = Collections.synchronizedList(arrayList);

        // 模拟多线程操作
        Thread thread1 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                synchronizedList.add("Element " + i);
            }
        });

        Thread thread2 = new Thread(() -> {
            for (int i = 0; i < 1000; i++) {
                synchronizedList.add("Another Element " + i);
            }
        });

        thread1.start();
        thread2.start();

        try {
            thread1.join();
            thread2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("List size: " + synchronizedList.size());
    }
}

代码解释:首先创建一个普通的 ArrayList,然后使用 Collections.synchronizedList 方法将其转换为线程安全的列表。接着启动两个线程同时向列表中添加元素,最后输出列表的大小。由于使用了线程安全的列表包装器,多个线程的并发操作会被正确处理。

方法二:使用 CopyOnWriteArrayList

CopyOnWriteArrayList 是 Java 并发包(java.util.concurrent)中的一个线程安全的列表实现。它采用写时复制的策略,即当进行写操作(如添加、删除元素)时,会先复制一份原数组,在新数组上进行修改,修改完成后再将原数组的引用指向新数组。这样在进行读操作时,不需要进行同步,从而提高了读操作的性能。

示例代码

import java.util.Iterator;
import java.util.concurrent.CopyOnWriteArrayList;

public class CopyOnWriteArrayListExample {
    public static void main(String[] args) {
        // 创建一个 CopyOnWriteArrayList
        CopyOnWriteArrayList<String> copyOnWriteArrayList = new CopyOnWriteArrayList<>();

        // 模拟多线程操作
        Thread writer = new Thread(() -> {
            for (int i = 0; i < 10; i++) {
                copyOnWriteArrayList.add("Element " + i);
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        Thread reader = new Thread(() -> {
            while (true) {
                Iterator<String> iterator = copyOnWriteArrayList.iterator();
                while (iterator.hasNext()) {
                    System.out.println(iterator.next());
                }
                try {
                    Thread.sleep(200);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        writer.start();
        reader.start();
    }
}

代码解释:创建一个 CopyOnWriteArrayList,然后启动一个写线程向列表中添加元素,同时启动一个读线程不断遍历列表。由于 CopyOnWriteArrayList 的写时复制特性,读线程在遍历过程中不会受到写线程的影响,不会抛出 ConcurrentModificationException 异常。不过需要注意的是,CopyOnWriteArrayList 写操作的性能相对较低,因为每次写操作都需要复制数组,适合读多写少的场景。

12、线程安全的List有哪些?

Vector、CopyOnWriteArrayList

13、HashMap原理了解吗?(介绍了get、put、扩容、底层结构)

在jdk1.7之前,hashmap底层使用的是数组+链表的形式,当hashMap新增一个键值对的时候,首先会使用一个哈希函数将该键值队的key进行hash得到它在数组存放的下标,然后将该元素存放到该下标位置上。这样的话在想获取该元素的时候,直接根据key经过哈希函数得到下标获取对应的value,时间复杂度是O(1),但是可能会存在不同的key经过哈希函数得到了相同的下标,也就是哈希冲突问题,它会讲相同下标的所有元素通过链表的形式连接起来,这样就解决了哈希冲突问题。但是如果该链表过长的话,那么查询某个元素的时间复杂度是O(n),为了解决这一问题,在jdk1.8对此做了改进,hashMap底层数据结构改成了数组+链表+红黑树。当哈希冲突不是特别严重时,和jdk1.7是一样的,但如果某个下标的链表过长的话,就会将该链表转换成红黑树,红黑树是一种自平衡的二叉树,它查询某个元素的时间复杂度是logn。所以就很好的解决了这一问题。

接下来主要说jdk1.8版本的hashMap的一些主要方法,比如get方法和put方法

首先说一下get方法,首先通过哈希函数得到该key的下标,然后检查该下标的节点的key值是否和要获取的key值相同,如果相同,那么直接返回该节点。如果不同,那么证明该下标存放的不只是一个节点,应该是一个链表或者红黑树。检查该头节点是否是链表节点,如果是链表节点,那么就会根据next指针遍历链表直到找到相同的key。如果是红黑树节点,我们知道红黑树节点是自平衡的二叉树,它会讲每个节点的key计算得到一个权重值,根据这个权重值在红黑树中进行排序,红黑树中的每个节点的左子树都小于当前节点,每个节点的右子树都大于当前节点,要在红黑树中查询某个键值队,首先要将它的key与根节点进行比较,小于的话就在左子树中进行查找,大于就在右子树进行查找,直到找到该元素。还有就是如果在查询结束之后都没有找到该key,那么直接返回null。

在来说一下put方法:put方法主要是两件事,扩容和链表转成红黑树。

第一步:先检查数组是否初始化,如果没有先进行初始化,初始化的容量是16,负载因子是0.75。

第二步:查找要添加的键值队的key是否在hashMap中出现过,查找某个元素是否在hashMap中出现过在刚才的get方法已经说过了,具体查找步骤是一样。如果找到这个元素,那么就将原键值队的value替换成新加入键值队的value。这个方法就结束。如果没有找到这个元素,就需要新加入一个键值队了,这里就有三种情况。

  1. 如果新加入键值队的key得到的下标为null的话,说明还没有元素添加进来,直接赋值到该下标就行了。
  2. 如果下标是一个链表的话,那么就将新的键值队加入到链表的尾部。如果加入完之后,链表的长度大于等于8了,那么就需要将该链表转化成红黑树,转换成红黑树之前需要检查当前数组的长度是否大于等于64,如果没有就先扩容一次,扩容是直接将数组大小翻倍,比如一开始是16,扩容后就是32。这里扩容后直接返回了,不会在链表转换成红黑树,只有当下次又发现链表长度大于等于8,并且数组长度大于等于64的时候才会转换成红黑树,所以需要经过两次扩容后,才会将链表转换成红黑树。转换成红黑树的过程就是将原来的链表的每个节点转成红黑树节点并且一个个加入到红黑树中,红黑树会自平衡,最后就得到了一个红黑树。
  3. 如果下标是一个红黑树节点的话,那么就会根据key计算的权重值将新节点插入红黑树中。

新节点加入完之后,就检查当前集合中的元素个数是否达到阈值,如果到达阈值,就需要扩容。扩容是直接将数组大小翻倍。

14、HashMap的负载因子为什么设置成0.75?初始容量为什么16?

负载因子设置成0.75肯定是有研究的,跟概率论有关系,根据大量的实验和数据证明0.75是一个好的比率。

初始容量设置成16也是根据大量的使用情况设置的,大部分情况下,使用hashMap也就添加几个元素,所以16足够满足大多数情况下的使用。而且既不浪费内存,也减少扩容次数。

在 Java 的 HashMap 中,负载因子默认设置为 0.75,初始容量默认设置为 16,这两个参数的设定是基于性能和空间使用的综合考量,以下是详细分析。

负载因子设置为 0.75 的原因

1. 性能与空间的平衡

HashMap 底层是通过数组和链表(或红黑树)实现的哈希表结构。当往 HashMap 中添加元素时,会根据元素的哈希值计算出对应的数组索引位置。随着元素数量的增加,哈希冲突的概率也会逐渐增大。负载因子表示 HashMap 在其容量自动增加之前可以达到多满的一种尺度,计算公式为:负载因子 = 元素数量 / 数组容量

  • 负载因子过大:如果负载因子设置得过大,比如设置为 1,意味着只有当数组的每个位置都被填满时才会进行扩容操作。这样虽然可以减少扩容的次数,节省空间,但会导致哈希冲突的概率大幅增加,链表(或红黑树)的长度会变长,从而使得查找、插入和删除等操作的时间复杂度增加,性能下降。
  • 负载因子过小:若负载因子设置得过小,例如 0.5,那么 HashMap 会在元素数量达到数组容量的一半时就进行扩容。这样可以降低哈希冲突的概率,提高操作性能,但会频繁触发扩容操作,增加空间开销,并且扩容操作本身也有一定的性能消耗。

2. 泊松分布的考量

经过大量的实验和理论分析,当负载因子为 0.75 时,哈希冲突的概率和扩容的频率达到了一个比较理想的平衡状态。从数学角度来看,在哈希函数均匀分布的前提下,元素在哈希表中的分布符合泊松分布。当负载因子为 0.75 时,链表长度达到 8 的概率非常小(约为 $1 / 2^32$),此时链表转换为红黑树的情况很少发生,保证了 HashMap 的性能。

初始容量设置为 16 的原因

1. 哈希计算的效率

HashMap 在计算元素的数组索引位置时,使用的是按位与运算(hash & (table.length - 1)),而不是取模运算。为了保证按位与运算的结果和取模运算的结果一致,数组的长度必须是 2 的幂次方。初始容量设置为 16,就是 2 的 4 次方,这样可以高效地通过按位与运算计算出元素的索引位置。

2. 减少扩容次数

初始容量设置为 16 可以在一定程度上避免在添加少量元素时就触发扩容操作。如果初始容量设置得太小,可能在添加几个元素后就需要进行扩容,而扩容操作需要重新计算元素的哈希值并重新分配位置,会带来较大的性能开销。设置为 16 可以在添加一定数量的元素后才需要进行扩容,减少了扩容的频率,提高了性能。

3. 空间和性能的折中

16 这个值既不会过大导致初始时占用过多的内存空间,也不会过小而频繁触发扩容。它是一个在空间使用和性能之间取得较好平衡的初始值,适合大多数的应用场景。

综上所述,HashMap 将负载因子设置为 0.75 是为了在性能和空间使用之间找到一个平衡点,减少哈希冲突的同时避免频繁扩容;将初始容量设置为 16 是考虑到哈希计算的效率、减少扩容次数以及空间和性能的折中。

15、Mysql事务隔离级别?

Mysql事务的隔离级别总共有四种:

  1. 读未提交
  2. 读提交
  3. 可重复读
  4. 串行化

首先说,读未提交隔离级别,在这个隔离级别下,会发生脏读、不可重复读、幻读问题,脏读问题就是说一个事物读到了另一个还未提交事物的数据,不可重复读是指在事物执行的过程中前后读取同一个数据但数据不一致的问题,也就是读到了其他已提交事物的数据。幻读是指:在事物执行过程中前后两次查询某个条件下的记录个数不一致的问题,也就是说读到了其他已事物中的新增或者删除的数据。像是出现了幻觉一样,有些数据突然出现了,又突然消失了。

读提交隔离级别下,解决了脏读问题,但是不可重复读和幻读问题还是没有解决。它是怎么解决的呢,在每次执行查询语句之前,都会生成一个快照,这个快照包含了当前已经提交的所有事物的信息,在执行查询操作时,会根据这个快照来判断哪些数据是可见的,哪些是不可见的,如果某个数据被某个事物修改了但这个事物还没有提交的情况下,是不可见的,所以就解决了脏读问题,但是没有解决不可重复读和幻读问题,因为每次查询之前都会生成一个新的快照,那么其他已提交的事物就都可以看到了。

可重复读隔离级别下:解决了脏读问题和不可重复读问题,它是在事务执行开始前生成一个快照,在整个事物执行过程中都使用这个快照,所以在这个事务的执行过程中,其他事物提交了的数据对它也是不可见的,所以就解决了脏读和不可重复读问题。同时它还可以解决大部分幻读问题,因为它不会读到其他事物提交的数据。

串行化隔离界别下:解决了脏读、不可重复读、幻读问题,它是在查询语句前,给记录加入读写锁,这样其他事物就不可能修改数据,所以当前事物执行过程的数据都是可见的。

然后具体是怎么判断哪些数据可见哪些不可见呢?

这就要提到MVCC,多版本并发控制。它主要根据快照中的四个字段以及每一行记录的两个隐藏列来实现的。

快照里面有四个字段,第一个字段是创建这个快照的事物ID。第二个字段是创建快照时整个系统中所有活跃且未提交的事物ID的集合。第三个字段是第二个字段里面的所有活跃未提交的事物ID集合中最小的事物ID,第四个字段是创建快照时全局事物的最大事物ID+1,也就是未来创建下一个事物的ID。

在说一下每个记录中的两个隐藏列,第一个列是当前记录最近一次修改的事物ID,第二个隐藏列是一个指针,指向了这个记录的所有的该条记录的所有旧版本记录。我们知道对某一行数据进行修改时,会将原数据加入到undo log日志中,这一行数据的所有旧版本记录形成了一个链表,每一个节点都指向它前一个节点的记录。所以我们可以通过这个去找到它的所有之前的记录。回滚的时候也会用到这个。

有了这些信息之后,就可以知道哪些数据可见哪些不可见了。

比如现在要查询一条记录,首先查看这个条记录隐藏列中的事物ID字段,通过这个事物ID来判断可不可见,怎么判断的呢?

  1. 如果这个记录的事物ID小于快照中的第三个字段,也就是活跃未提交的最小事物ID,如果小于的话证明这一行记录是事物开启前就已经提交了的数据,所以是可见的。
  2. 如果这个记录的事物ID大于等于快照中的第四个字段,也就是全局事物ID+1,那就证明这个记录是在当前事物开启后创建的,那就是不可见,所以就需要通过回滚指针去寻找之前的旧版本记录,去查询每个旧版本记录的事物ID对自己可不可见。一直找到可见的为止。
  3. 如果这个记录的事物ID大于等于第三个字段但小于第四个字段,说明这个记录很可能是还未提交的事物修改的。所以需要检查这个事物ID是否在第二个字段的活跃未提交集合当中,如果在证明不可见,就需要通过指针找可见的记录。如果不在,证明创建当前事物的时候已经提交了,那么就是可见的。

所以通过这些信息就能判断哪些记录可见哪些不可见,这就是MVCC,多版本并发控制。

16、说一下B+树的结构?

B + 树是一种多路平衡查找树。首先它是一个多路树,每个节点都可以有多个子节点。其次,它是一个查找树,每个节点都有一个权值,每个节点的左子树的权值都小于当前节点,每个节点的右子树都大于当前节点,所以整个树都是有序的,可以根据权值来查询元素。最后,它是自平衡的树,即从根节点到所有叶子结点的的路径长度是相同的,在插入节点和删除节点的过程中会通过一些操作来保证平衡。

B+树的所有叶子结点存放的是数据记录,非叶子结点存放的是索引,所以查询某个记录的时间复杂度都是logn,而且叶子结点是通过双向链表连接的,很方便的支持范围查询,因为只要找到了范围的左边界,就可以根据链表找到他后面的所有记录。不需要在通过根节点遍历找到叶子结点。

B+树主要用来做数据库的索引以及文件系统。都是用来快速查找元素的。

mysql的innodb存储引擎的索引默认使用的就是B+树。那为什么要使用B+树呢?

主要有两个原因:一是磁盘IO次数较少,二是支持范围查询。我们知道索引都是存储在磁盘当中的,查询数据首先需要将索引从磁盘读取到内存中来,然后在通过索引得到数据在磁盘中的位置,在将数据从磁盘读入到内存中,而磁盘IO次数越多,所花费的耗时越大。所以需要尽可能少的磁盘IO次数。其次数据库很多操作都是范围查询的,所以索引使用的数据结构也要支持高效范围查询。

假如说我们用数组来存储索引,首先保证数组中的元素是有序的,那么插入和删除操作的时间复杂度是O(n),查询的时间复杂度是logn,因为可以使用二分查找,什么是二分查找呢?,。。。。

但由于数组的插入和删除操作的时间复杂度都是O(n),效率有点低,有没有更好的呢?答案是二分查找树。

什么是二分查找树呢?首先它是一个二叉树,并且它满足一个性质,每个节点的左子树的所有节点的值都小于当前节点,右子树的所有节点的值都大于当前节点,所以在查找某个值的时候,就会将它的值跟节点的值进行比较,小于就去左子树查询,大于就去右子树查询,这样的时间复杂度也是logn,并且插入和删除一个元素的时间复杂度也都是logn,很好的解决了数组插入和删除带来的时间问题。但是二分查找树也有缺陷,就是如果插入的元素都是有顺序的,那么每个节点都会插入它的右子树,那么就会退化成一个长长的链表。如果退化成链表了,那么查询和增加删除的时间复杂度都变成了n。并且我们去磁盘中获取索引的时候,是一个节点进行一次磁盘IO,本来正常的树要进行logn次磁盘IO,现在退化成链表就要进行n次磁盘IO,这大大提高了查询时间。所以二分查找树也不能作为索引的数据结构。为了解决这一问题,现在有了自平衡二叉树。

自平衡二叉树就解决了退化成链表的问题,自平衡二叉树的每个节点的左右子树的高度不超过1,所以它整个树都是平衡的,从根节点到每个叶子结点的路径长度基本相同,不超过1。它是怎么解决退化成链表问题的呢?在插入和删除节点的过程中,它会有左旋和右旋的操作,来保持整个树平衡。这样就好了,还没有,如果节点很多的话,那么整个树的高度就会随着节点个数的增加变得更高,树越高那么查询的时候磁盘IO次数就越多,因为每查询一个节点就需要一次磁盘IO。所以有没有更好的数据结构呢?有B树。

B树是一种多路平衡树。每个节点都可以有多个子节点,这样整棵树的高度就大大降低了。磁盘IO次数也变少了,查询效率也变高了。但是B树有没有缺点呢,是有的。B树的每个节点既存放索引也存放数据,所以就导致查询的时候需要将整个节点的索引和记录都从磁盘中读取到内存中,这个花费的时间较长,而且读取了很多无用的数据,导致整个节点很臃肿。而且如果做范围查询的话,需要使用中序遍历,中间也会读取到很多无用的数据,带来更多的磁盘IO操作,使得整体效率下降。有更好的吗,答案就是B+树。

B+树只有非叶子结点存索引,叶子结点用来存数据,并且叶子结点直接用双向链表连接,可以直接通过一个起始结点找到所有后续结点,不需要中序遍历,就不需要那么多的磁盘IO操作,大大提高了性能。总体来说使用B+树做索引是一个非常好的选择。

17、a,b,c联合索引, where b = 1, a = 2、 a = 1, b = % 会不会走索引?

where b = 1, a = 2 不会走索引,abc联合索引必须满足顺序匹配,也就是说where条件中只有一个字段,那么这个字段必须是a,如果条件中有俩个字段,那么必须是a,b第二个字段必须是b,第三个字段必须是c。

这里我学习的时候没有认真看,where b = 1, a = 2是走索引的,因为优化器会自动优化,将a字段放在最左边。where a = 1, b = %,也会走索引,因为有a字段,去索引中筛选所有满足a条件的记录,b由于是模糊匹配,没法走索引,但是a走了索引,可以直接将a筛选出来的记录去一个个筛选满足b的情况。

总结:联合索引一定要满足最左匹配原则,即使没有按照顺序排列,优化器也可以将它改变顺序。如果要走索引必须要有第一个字段,并且第一个字段是正常的查询,不能是模糊查询中的右查询或者左右查询,也不能对a使用函数,也不能进行表达式计算,也不能进行隐式类型转换,也不能使用or子句。那么就一定会走索引,无论有没有b或者c,只要有a就可以走索引,没有a字段是一定走不了索引。

18、为什么要使用Redis?

在项目中,有些数据是高频数据,经常被访问,如果每次访问都去查询数据库的话,显然给数据库以及整个系统带来巨大的压力,性能也会下降,响应时间也会提高。为了解决这个问题,可以直接将这些高频数据放到内存中,访问的时候直接去内存中获取,内存的访问速度比磁盘要高很多,这样就不会对数据库造成巨大压力,也提高了性能和响应速度,而redis就是一个将数据放在内存的数据库,redis具有高响应,高性能的特点,非常适合做缓存。

主要是因为Redis具备高性能和高并发。

  1. 高性能

假如用户第一次访问 MySQL 中的某些数据。这个过程会比较慢,因为是从硬盘上读取的。将该用户访问的数据缓存在 Redis 中,这样下一次再访问这些数据的时候就可以直接从缓存中获取了,操作 Redis 缓存就是直接操作内存,所以速度相当快。

  1. 高并发

一台设备的Redis的QPS(每秒钟处理完请求的次数)是MySQL的10倍。所以直接访问Redis能够承受的请求是远大于直接访问MySQL的,所以我们可以考虑把数据库中的部分数据转移到到缓存中去而不用访问数据库。

Redis(Remote Dictionary Server)是一个开源的、高性能的键值对存储数据库,它在众多领域得到了广泛应用。以下从性能、功能、易用性和应用场景适配等多个方面详细阐述使用 Redis 的原因。

高性能

  • 基于内存存储:Redis 将数据存储在内存中,相较于传统的基于磁盘的数据库,内存的读写速度要快得多。例如,Redis 的读操作速度可以达到每秒 10 万次以上,写操作速度也能达到每秒 8 万次以上,能够快速响应客户端的请求,大大降低了数据访问的延迟。
  • 单线程架构:Redis 采用单线程的 I/O 多路复用模型,避免了多线程环境下的锁竞争和上下文切换开销,使得 Redis 可以高效地处理大量并发请求。同时,Redis 的单线程并不意味着只能利用一个 CPU 核心,它可以通过部署多个实例来充分利用多核 CPU 的性能。

丰富的数据类型

  • 支持多种数据结构:Redis 支持字符串(String)、哈希(Hash)、列表(List)、集合(Set)、有序集合(Sorted Set)等多种数据类型。不同的数据类型可以满足各种不同的业务需求,例如:字符串:可以用于缓存数据、计数器等。比如,记录网站的访问量,每次有新的访问时,通过 Redis 的原子递增操作对计数器进行加 1 操作。哈希:适合存储对象信息,例如用户信息,每个字段可以作为哈希的一个键值对,方便对对象的部分属性进行单独操作。列表:可以实现消息队列、栈等数据结构,常用于异步任务处理、消息传递等场景。集合:支持交集、并集、差集等操作,可用于实现共同好友、推荐系统等功能。有序集合:可以根据分数对元素进行排序,常用于排行榜、热门列表等场景。

原子操作

  • 保证数据一致性:Redis 提供了一系列的原子操作,例如 INCR(递增)、DECR(递减)、LPUSH(列表左插入)等。这些原子操作在执行过程中不会被其他操作打断,保证了数据的一致性和完整性。在多线程或多进程环境下,使用原子操作可以避免并发问题,无需额外的锁机制。

持久化机制

  • 数据持久化:Redis 支持两种持久化方式,即 RDB(Redis Database)和 AOF(Append Only File)。RDB:定期将内存中的数据快照保存到磁盘上,生成一个二进制文件。RDB 文件紧凑,恢复速度快,适合用于数据备份和灾难恢复。AOF:将 Redis 执行的每个写操作以日志的形式追加到文件末尾。AOF 文件记录了所有的写操作,数据安全性高,即使服务器故障,也可以通过重放 AOF 文件中的操作来恢复数据。用户可以根据实际需求选择合适的持久化方式,或者同时使用两种方式,以提高数据的安全性和可靠性。

分布式支持

  • 集群和分片:Redis 提供了集群和分片功能,可以将数据分布到多个节点上,实现数据的水平扩展。通过 Redis Cluster 可以构建分布式集群,自动将数据分片存储在不同的节点上,提高系统的吞吐量和可用性。同时,Redis Sentinel 可以实现主从复制和故障自动切换,当主节点出现故障时,自动将从节点提升为主节点,保证系统的高可用性。

缓存功能

  • 减轻数据库压力:Redis 常被用作缓存层,将经常访问的数据存储在 Redis 中。当客户端请求数据时,首先检查 Redis 中是否存在该数据,如果存在则直接返回,避免了对后端数据库的频繁访问,减轻了数据库的压力,提高了系统的整体性能。例如,在电商网站中,将热门商品的信息缓存到 Redis 中,用户访问商品详情页时可以直接从 Redis 中获取数据,无需每次都查询数据库。

易用性

  • 简单的 API 和客户端支持:Redis 提供了简单易用的 API,并且支持多种编程语言的客户端,如 Java、Python、C# 等。开发者可以方便地使用自己熟悉的编程语言与 Redis 进行交互,快速实现各种功能。同时,Redis 的配置和管理也相对简单,降低了开发和运维的成本。

综上所述,Redis 以其高性能、丰富的数据类型、原子操作、持久化机制、分布式支持、缓存功能和易用性等优点,成为了众多应用场景下的理想选择,广泛应用于缓存、会话管理、消息队列、排行榜、分布式锁等领域。

19、Redis缓存和本地缓存比好在哪里呢?

redis有丰富的数据结构,而且数据结构所需要的内存空间较少,而且访问的时间上更好。

这个我回答的太烂了。

Redis 缓存和本地缓存都有各自的应用场景,Redis 缓存相较于本地缓存具有多方面的优势,以下从数据共享、扩展性、数据持久化、可靠性、缓存容量和一致性维护等方面详细分析。

数据共享

  • Redis 缓存:Redis 是一种分布式缓存,它独立于应用程序存在,可以被多个应用实例或不同服务器上的应用程序共享访问。在分布式系统中,多个服务节点可以通过网络连接到同一个 Redis 实例,实现数据的共享。例如,在一个电商系统中,多个商品服务节点可以共享 Redis 中的商品库存信息,确保各个节点获取到的库存数据一致。
  • 本地缓存:本地缓存通常是与应用程序部署在一起的,数据仅在当前应用实例内部可见和使用。不同的应用实例之间无法直接共享本地缓存中的数据,这在分布式系统中可能会导致数据不一致的问题。比如,每个微服务实例都有自己的本地缓存,当一个实例更新了缓存数据,其他实例无法及时感知到这种变化。

扩展性

  • Redis 缓存:Redis 可以通过集群、分片等技术实现水平扩展,能够轻松应对大规模数据存储和高并发访问的需求。当业务量增加时,可以通过添加更多的 Redis 节点来扩展缓存容量和处理能力。例如,在大型互联网应用中,随着用户数量的增长,可以动态地增加 Redis 节点来满足不断增加的缓存需求。
  • 本地缓存:本地缓存的扩展性相对较差,它受限于单个应用实例的内存大小。当应用程序需要处理大量数据时,本地缓存可能会因为内存不足而出现缓存命中率下降的问题,而且无法通过简单的方式进行水平扩展。

数据持久化

  • Redis 缓存:Redis 支持多种持久化方式,如 RDB(Redis Database)和 AOF(Append Only File)。RDB 可以定期将内存中的数据快照保存到磁盘上,AOF 则将 Redis 执行的每个写操作以日志的形式追加到文件末尾。通过持久化机制,即使 Redis 服务器重启,也可以从磁盘中恢复数据,保证数据的安全性和可靠性。
  • 本地缓存:本地缓存的数据通常存储在应用程序的内存中,当应用程序重启或崩溃时,本地缓存中的数据会丢失。虽然可以通过一些技术手段将本地缓存数据持久化到磁盘,但实现起来相对复杂,而且不是所有的本地缓存框架都支持数据持久化功能。

可靠性

  • Redis 缓存:Redis 可以通过主从复制、哨兵模式和集群模式等方式提高系统的可靠性。主从复制可以将数据从主节点复制到多个从节点,当主节点出现故障时,可以手动或自动将从节点提升为主节点,继续提供服务。哨兵模式可以监控 Redis 节点的状态,实现自动故障转移。集群模式则将数据分布到多个节点上,即使部分节点出现故障,也不会影响整个系统的正常运行。
  • 本地缓存:本地缓存依赖于单个应用实例的稳定性,如果应用实例出现故障或崩溃,本地缓存中的数据将无法访问,可能会影响业务的正常运行。而且本地缓存没有像 Redis 那样完善的故障转移和容错机制。

缓存容量

  • Redis 缓存:Redis 可以利用服务器的内存资源,并且可以通过集群方式整合多台服务器的内存,因此可以提供较大的缓存容量。在需要存储大量数据的场景下,Redis 能够满足需求。例如,在存储海量的用户会话信息时,Redis 可以通过集群扩展来存储这些数据。
  • 本地缓存:本地缓存的容量受限于应用程序所在服务器的内存大小,一般情况下,本地缓存的容量相对较小。如果需要缓存大量数据,可能会导致应用程序的内存占用过高,影响系统性能。

一致性维护

  • Redis 缓存:在分布式环境中,Redis 可以通过一些机制(如分布式锁)来保证数据的一致性。多个应用实例对 Redis 缓存进行读写操作时,可以使用分布式锁来控制并发访问,避免数据不一致的问题。例如,在更新 Redis 缓存中的数据时,使用分布式锁确保同一时间只有一个应用实例可以进行更新操作。
  • 本地缓存:本地缓存的一致性维护相对困难,特别是在分布式系统中。由于不同应用实例的本地缓存相互独立,很难保证各个实例的缓存数据一致。当一个实例更新了本地缓存数据后,需要通过复杂的消息机制通知其他实例更新缓存,实现成本较高。

综上所述,Redis 缓存在数据共享、扩展性、数据持久化、可靠性、缓存容量和一致性维护等方面具有明显的优势,更适合用于分布式系统和对缓存要求较高的场景。而本地缓存则具有访问速度快、实现简单等优点,适用于对缓存数据一致性要求不高、数据量较小且访问频率高的场景。

20、如果本地缓存和Redis缓存使用相同的数据结构来缓存数据,谁更快呢?

本地缓存吧,获取redis缓存还要访问redis服务器,需要网络传输,需要消耗一点时间。

如果本地缓存和 Redis 缓存使用相同的数据结构来缓存数据,通常本地缓存的访问速度会更快,以下从二者的架构、数据传输、使用场景等方面进行详细分析。

架构层面

  • 本地缓存:本地缓存是直接部署在应用程序内部的缓存机制,数据存储在应用程序所在服务器的内存中。当应用程序需要访问缓存数据时,直接在本地内存中进行查找和读取操作,无需经过网络传输。由于内存的读写速度极快,且本地缓存与应用程序在同一进程或同一台服务器上,不存在网络延迟和序列化/反序列化开销,所以能够快速地获取数据。
  • Redis 缓存:Redis 是一个独立的服务,通常以单独的进程或服务器的形式存在。应用程序与 Redis 之间通过网络进行通信,当应用程序需要访问 Redis 缓存中的数据时,需要通过网络将请求发送到 Redis 服务器,Redis 服务器处理请求并返回数据,再通过网络将数据传输回应用程序。网络传输会引入一定的延迟,从而影响数据的访问速度。

数据传输层面

  • 本地缓存:数据在本地内存中进行读写,数据的访问是直接的内存操作,没有数据传输的过程。内存的读写速度可以达到纳秒级别,能够在极短的时间内完成数据的读取和写入操作。
  • Redis 缓存:应用程序与 Redis 之间的数据传输需要经过网络协议栈的处理,包括数据的序列化、网络数据包的封装、传输、解包和反序列化等过程。这些过程会增加数据访问的时间开销,尤其是在网络状况不佳的情况下,延迟会更加明显。即使在高速网络环境下,网络传输的延迟仍然会比本地内存操作的延迟高出几个数量级。

性能测试示例

以下是一个简单的 Java 示例,对比本地缓存(使用 ConcurrentHashMap 作为简单的本地缓存)和 Redis 缓存的访问速度:

import redis.clients.jedis.Jedis;
import java.util.concurrent.ConcurrentHashMap;

public class CacheSpeedComparison {
    private static final int TEST_TIMES = 100000;
    private static final String KEY = "testKey";
    private static final String VALUE = "testValue";

    public static void main(String[] args) {
        // 本地缓存测试
        ConcurrentHashMap<String, String> localCache = new ConcurrentHashMap<>();
        localCache.put(KEY, VALUE);
        long localStartTime = System.currentTimeMillis();
        for (int i = 0; i < TEST_TIMES; i++) {
            localCache.get(KEY);
        }
        long localEndTime = System.currentTimeMillis();
        System.out.println("本地缓存访问 " + TEST_TIMES + " 次耗时: " + (localEndTime - localStartTime) + " 毫秒");

        // Redis 缓存测试
        Jedis jedis = new Jedis("localhost", 6379);
        jedis.set(KEY, VALUE);
        long redisStartTime = System.currentTimeMillis();
        for (int i = 0; i < TEST_TIMES; i++) {
            jedis.get(KEY);
        }
        long redisEndTime = System.currentTimeMillis();
        System.out.println("Redis 缓存访问 " + TEST_TIMES + " 次耗时: " + (redisEndTime - redisStartTime) + " 毫秒");
        jedis.close();
    }
}

在上述示例中,通过多次访问本地缓存和 Redis 缓存中的相同数据,统计访问所需的时间。一般情况下,本地缓存的访问耗时会明显少于 Redis 缓存。

适用场景说明

虽然本地缓存的访问速度更快,但 Redis 缓存也有其独特的优势,适用于不同的场景:

  • 本地缓存:适用于对缓存访问速度要求极高、数据量较小且不需要在多个应用实例之间共享的场景。例如,在一个单实例的 Web 应用中,缓存一些常用的配置信息或频繁访问的静态数据。
  • Redis 缓存:适用于分布式系统中需要数据共享、缓存容量较大、需要数据持久化和高可靠性的场景。例如,在一个微服务架构的系统中,多个服务实例可以共享 Redis 缓存中的数据,保证数据的一致性。

综上所述,在使用相同的数据结构缓存数据时,本地缓存由于其无需网络传输和序列化/反序列化的优势,通常会比 Redis 缓存更快。但在实际应用中,需要根据具体的业务需求和场景来选择合适的缓存方案。

21、使用Redis会造成什么问题?

redis缓存中的数据和数据库中的数据不一致的问题。比如数据库中的数据已经修改了,但是缓存中的数据还没有修改。那么缓存中获取的都是旧数据,如果对数据准确性要求比较高的话,不应该使用缓存。

这里我没说缓存三大问题。。

使用 Redis 虽然能带来诸多好处,但也会引发一些潜在问题,以下从缓存层面、数据层面、系统层面进行详细分析:

缓存层面问题

缓存穿透

  • 问题描述:指客户端请求的数据在缓存和数据库中都不存在,这样每次请求都会穿透缓存直接访问数据库。攻击者可能会利用这一点,故意大量请求不存在的数据,对数据库造成巨大压力,甚至导致数据库崩溃。
  • 解决方案:布隆过滤器:将所有可能存在的数据通过布隆过滤器进行存储,当有请求到来时,先通过布隆过滤器判断数据是否存在,如果不存在则直接返回,避免访问数据库。空值缓存:对于数据库中不存在的数据,也将其以空值的形式缓存到 Redis 中,并设置较短的过期时间,这样后续相同的请求就可以直接从缓存中获取空值,而不会再次访问数据库。

缓存击穿

  • 问题描述:指一个热点 key 在缓存中过期的瞬间,有大量的并发请求同时访问该 key,这些请求会直接穿透缓存访问数据库,导致数据库压力瞬间增大。
  • 解决方案:设置热点 key 永不过期:对于一些热点数据,可以在缓存中设置为永不过期,然后通过后台线程定期更新这些数据。加互斥锁:当发现缓存中的热点 key 过期时,先加一个互斥锁,只有获得锁的线程才能去数据库中查询数据并更新缓存,其他线程则等待缓存更新完成后再从缓存中获取数据。

缓存雪崩

  • 问题描述:指在某一时刻,大量的缓存 key 同时过期,导致大量的并发请求直接访问数据库,造成数据库压力过大甚至崩溃。
  • 解决方案:分散过期时间:在设置缓存 key 的过期时间时,为每个 key 增加一个随机的过期时间偏移量,避免大量 key 在同一时刻过期。多级缓存:使用多级缓存架构,如同时使用本地缓存和 Redis 缓存,当 Redis 缓存出现问题时,本地缓存可以暂时提供服务,减轻数据库的压力。缓存预热:在系统启动时,将一些常用的数据提前加载到缓存中,避免在系统运行初期出现大量缓存未命中的情况。

数据层面问题

数据不一致

  • 问题描述:在使用 Redis 作为缓存时,由于缓存和数据库的数据更新操作可能不是原子的,会导致缓存和数据库中的数据不一致。例如,在更新数据库成功但更新缓存失败的情况下,就会出现数据不一致的问题。
  • 解决方案:更新策略优化:采用合适的更新策略,如先更新数据库,再删除缓存,而不是直接更新缓存。同时,可以通过重试机制确保删除缓存操作的成功。消息队列:使用消息队列来异步处理缓存更新操作,保证缓存和数据库的数据最终一致性。当数据库更新成功后,发送一条消息到消息队列,由专门的消费者从消息队列中获取消息并更新缓存。

数据丢失

  • 问题描述:Redis 数据可能会因为服务器故障、内存不足等原因丢失。虽然 Redis 支持持久化机制,但在某些情况下,如持久化过程中出现异常,仍然可能导致部分数据丢失。
  • 解决方案:合理配置持久化策略:根据业务需求选择合适的持久化方式,如 RDB 和 AOF 结合使用。RDB 可以定期生成数据快照,AOF 可以记录每一个写操作,通过两者结合可以提高数据的安全性。数据备份和恢复:定期对 Redis 数据进行备份,并制定完善的数据恢复方案,以便在数据丢失时能够及时恢复。

系统层面问题

性能瓶颈

  • 问题描述:当 Redis 面临高并发请求时,可能会出现性能瓶颈。例如,单节点的 Redis 在处理大量并发请求时,可能会因为 CPU、内存或网络带宽等资源的限制而导致响应时间变长。
  • 解决方案:集群和分片:采用 Redis Cluster 或分片技术,将数据分布到多个节点上,实现负载均衡,提高系统的处理能力。优化配置:根据服务器的硬件资源和业务需求,合理配置 Redis 的参数,如内存分配、线程池大小等,以提高 Redis 的性能。

网络问题

  • 问题描述:由于 Redis 是通过网络进行通信的,网络延迟、丢包等问题可能会影响 Redis 的性能和可用性。例如,在跨机房部署的场景下,网络延迟可能会导致 Redis 的响应时间变长。
  • 解决方案:网络优化:优化网络拓扑结构,采用高速稳定的网络设备和链路,减少网络延迟和丢包率。使用本地缓存:在应用程序中结合使用本地缓存,减少对 Redis 的网络请求,提高系统的响应速度。

综上所述,在使用 Redis 时需要充分考虑这些潜在问题,并采取相应的解决方案,以确保系统的稳定性和可靠性。

22、如何保证Mysql和Redis的一致性?

这里我记不太清了,大概就是先更新缓存在更新数据库还是先更新数据库在更新缓存。

  1. 先更新数据库在更新缓存

当有数据更新时,先将新数据写入数据库,然后在更新redis缓存中的数据。

但这样存在问题,比如说现在有两个线程A和B同时执行更新操作,线程A先修改数据库,然后线程B也去修改数据库,但此时线程B先去更新了缓存,最后线程A去更新缓存。此时数据库的数据是B更新的,但是缓存却是线程A更新的,这就导致缓存和数据不一致。

  1. 先更新缓存在更新数据库

这个和先更新数据库在更新缓存遇到的是一样的问题,比如线程A先去更新缓存,线程B去更新缓存然后更新了数据库,最后线程A去更新数据库,此时缓存是线程B修改的,数据库是线程A修改的,也是数据不一致。

  1. 先更新数据库,在删除缓存

也有并发安全问题,线程A去读数据,线程B改数据。此时线程A去读缓存,发现缓存为空,然后去数据库中读取数据。但此时线程B去更新数据库,然后又去删除缓存,最后线程A去写入缓存。此时数据库中是线程B修改的数据,而此时缓存是线程A读取到的旧数据。

  1. 先删除缓存,在更新数据库

也有并发安全问题,比如线程A去读数据,线程B改数据,线程B先删除缓存,然后线程A发现缓存为空去数据库读取数据,然后将写入缓存,最后线程B去更新数据库。此时数据库是线程B修改的,缓存是线程A写入的旧数据。

先更新数据库在删除缓存的情况下,出现的并发问题概率比较小,因为缓存的写入的速度远大于数据库的写入,所以出现线程A从数据库获取数据后,线程B去把数据库更新了线程A才写入缓存。所以基本上这个策略能解决大部分问题。

  1. 延迟双删

改进先更新数据库在删除缓存,在第一次删除之后,等待一段时间进行第二次删除缓存,这样就很大情况下解决了可能会出现的并发安全问题。

但是还是有问题,因为等待时间如何设置,过短可能还是会出现数据不一致问题,过长那么也会影响数据不一致问题。还是就是有可能删除数据失败的情况也会发生。

  1. 消息队列重试机制

在更新完数据库后,将删除缓存的任务发送给消息队列,专门有一个消费者线程来专门处理删除缓存业务,即使删除失败了还可以重试。

  1. 监听数据库binlog日志

mysql会将所有的写操作记录到binlog日志文件中,可以通过监听binlog的变化,当发现有数据更新时,就去删除缓存。

23、介绍一下缓存穿透以及解决办法?

缓存穿透是指去大量请求数据库和缓存中都不存在的数据,因为缓存不存在,所以都去查询数据库,但数据库也不存在,也没办法去创建对应缓存,所以给数据库带来巨大的压力。

解决办法:

  1. 布隆过滤器,布隆过滤器底层是一个bit数组,每一个bit存的是0或者1,如果是0就表示为空,如果是1就表示有数据。布隆过滤器可以快速的判断请求的数据是否存在。它主要有两个操作,get和put方法。put方法是将数据经过几个哈希函数,得到不同的几个数组下标,然后将这几个下标的bit设置为1。get方法也是先将数据经过几个哈希函数,得到不同的几个数组下标,检查这几个下标的bit是否都是1,如果都是1证明这个数据是存在的,只要有一个不是1证明是不存在的。但是这样做有一些误差,比如数据A得到的下标是1、2、3,数据B得到的下标是3、4、5,他俩已经存在布隆过滤器中了,但是现在需要判断数据C是否存在,数据C得到的下标是2、3、4,它发现这几个下标也都是1,判断它存在了,但其实它并不存在,它是被其他数据给影响了。所以说
  2. 缓存空对象:只要查询数据库后发现不存在这个数据,就在redis中缓存这个数据的空对象,以后在查询的时候直接返回空就行了。

这里可能还会问缓存击穿和缓存雪崩的问题。

缓存击穿:缓存击穿就是指一个访问量非常大的数据突然失效了,这样大量的请求都去访问数据库,给数据库带来巨大的压力,而且从数据库中获取数据之后还要构建缓存,这就带来巨大的无效操作。

解决访问:

  1. 使用互斥锁:只要从数据库中没获取到缓存,都去竞争互斥锁,拿到锁的线程才能去访问数据库并且创建缓存,其他没有获取锁的线程可以选择等待缓存构建成功或者直接返回空值。
  2. 不给热点数据设置过期时间,如果数据库中的数据修改了,直接去修改缓存。

缓存雪崩:同一时间有大量的缓存失效了,导致大量的请求都去请求数据库,带来巨大压力。

解决方法:

  1. 给缓存设置随机的过期时间,这样就不会同时失效了。
  2. 使用互斥锁
  3. 后台更新缓存

24、布隆过滤器如何删除一个值呢?

我没了解过删除,我觉得不应该删除一个值,容易对其他值造成影响。布隆过滤器好像也不支持删除。

计数布隆过滤器(Counting Bloom Filter)

  • 原理:计数布隆过滤器是对传统布隆过滤器的改进,将位数组中的每个位替换为一个计数器。当插入一个元素时,通过多个哈希函数得到多个位置,将这些位置的计数器值加 1;当删除一个元素时,同样通过哈希函数找到对应的位置,将这些位置的计数器值减 1。在判断元素是否存在时,只要检查这些位置的计数器值是否都大于 0。

全部评论
兄弟什么时候面的
点赞 回复 分享
发布于 今天 01:45 湖北

相关推荐

去年9月份面的了,是整个秋招过程中最难的一次面试,印象特别深刻,被拷打得汗流浃背了-------------------------------------------------1.&nbsp;项目中的难点介绍下2.&nbsp;用bitmap可能会出现什么问题?你这个算法怎么改进?3.&nbsp;压缩位图的原理4.&nbsp;介绍go的gmp模型5.&nbsp;go协程调度底层原理6.&nbsp;了解go的gc调优吗?7.&nbsp;介绍下Java的垃圾回收算法8.&nbsp;介绍下tcp三次握手和四次挥手9.&nbsp;tcp的close_wait状态是什么?10.&nbsp;介绍下tcp的流量控制11.&nbsp;tcp和udp能共用一个端口吗?12.&nbsp;一条tcp连接中,如果客户端完全宕机了,服务端怎么感知到?13.&nbsp;介绍下epoll三条命令的作用及执行流程14.&nbsp;介绍下epoll边缘触发和水平触发15.&nbsp;你的项目是怎么用epoll的?用到哪种触发模式?16.&nbsp;cap了解吗?17.&nbsp;讲一下raft算法18.&nbsp;有没有做过kafka的性能提升?19.&nbsp;了解mysql高可用吗?20.&nbsp;介绍下mysql主从同步的具体流程21.&nbsp;在mysql主从同步过程中,如果因为某些网络问题,导致某些命令在从节点被重放了多次,怎么处理这种问题?22.&nbsp;在mysql的主从同步过程中,如果主节点还未把数据传送给从节点前,主节点挂了,怎么让客户端读到最新的数据?换句话说怎么保证数据的强一致性23.&nbsp;mysql你都是用什么隔离级别的?会用到间隙锁吗?24.&nbsp;mysql&nbsp;mvcc的实现原理?25.&nbsp;介绍下redis怎么实现高可用26.&nbsp;介绍下redis&nbsp;主从同步的流程27.&nbsp;redis哨兵的作用?28.&nbsp;哨兵是怎么感知其他节点的存活的呢?29.&nbsp;假如没有这个哨兵的话,从节点怎么知道主节点挂了没?30.&nbsp;redis怎么进行新主节点的选举的?31.&nbsp;集群模式下,redis的数据是怎么分布的32.&nbsp;如果哨兵挂了会怎么样?33.&nbsp;如果有一个主节点挂了,在客户端看来是怎么样的,此时redis还可用吗?34.&nbsp;如果这个主节点及其从节点全挂了,redis还可用吗?35.&nbsp;如果redis所有主节点宕机了会怎么样?36.&nbsp;grpc的实现原理? #腾讯音乐#&nbsp;&nbsp;#腾讯#
点赞 评论 收藏
分享
评论
点赞
1
分享

创作者周榜

更多
牛客网
牛客企业服务