Java面试汇总

Java虚拟机相关知识

1.Java虚拟机内存模型

1.内存区域及其特点

Java内存区域 是否线程私有 是否出现OOM 存储数据
程序计数器 字节码执行的行号指示器
Java虚拟机栈 存储局部变量表、操作数栈、动态链接、方法返回地址
本地方法栈 存储本地方法的局部变量表、参数、方法返回地址
方法区 存储静态变量、常量、类信息
对象、数组

2.⽅法区和永久代

方法区存储了类信息、常量、静态变量等数据,在Java7及其之前,在永久代实现了方法区,也就是这部分数据保存在永久代,位于堆中,固定大小易触发 OOM,因此在Java8及其之后,使用了本地内存实现了方法区,也就是所谓的元空间,这样既可以保持GC独立,而且内存也可以动态扩展,且无上限。总的来说,方法区是虚拟机规范,永久代和元空间则是具体的实现。

  • Java所有的对象分配都在堆上吗?

JVM 通过逃逸分析判断对象是否​​仅被当前线程使用​​(未逃逸出方法或线程),若满足条件则优化为栈分配。

public void foo() {
    Object localObj = new Object(); // 未逃逸对象
    System.out.println(localObj);
}

3.阐述下Java对象的创建过程?
4.对象的访问定位的两种⽅式知道吗?各有什么优缺点?
5.如何判断对象是否死亡(两种⽅法)。 讲⼀下可达性分析算法的流程。
6.JDK 中有⼏种引⽤类型?分别的特点是什么?
7.堆空间的基本结构了解吗?什么情况下对象会进⼊⽼年代?
8.垃圾收集有哪些算法,各⾃的特点?
9.有哪些常⻅的 GC?谈谈你对 Minor GC、还有 Full GC 的理解。Minor GC 与 Full GC 分别在什么时候发⽣? Minor GC 会发⽣ stop the world 现象吗?
10.讲⼀下 CMS 垃圾收集器的四个步骤。CMS有什么缺点?
11.并发标记要解决什么问题?并发标记带来了什么问题?如何解决并发扫描时对象消失问题?
12.G1 垃圾收集器的步骤。有什么缺点?
13.ZGC了解吗?
14.JVM 中的安全点和安全区各代表什么?写屏障你了解吗?
15.虚拟机基础故障处理⼯具有哪些?
16.什么是字节码?类⽂件结构的组成了解吗?
17.类的⽣命周期?类加载的过程了解么?加载这⼀步主要做了什么事情?初始化阶段中哪⼏种情况必须对类初始化?
19.JDK 中有哪些默认的类加载器?
20.堆内存相关的 JVM 参数有哪些?你在项⽬中实际配置过了吗?
21.如何对栈进⾏参数调优?
22.你在项⽬中遇到过 GC 问题吗?怎么分析和解决的?
23.GC 性能指标了解吗?调优原则呢?
24.如何降低 Full GC 的频率?

类的加载机制

1.双亲委派模型

所谓双亲委派模型,则是Java类加载机制的核心原则,它规定了类加载的层级委托机制。当一个类被加载的时候,则被该类加载器的父类加载,如果父类无法加载,则再有自己加载。

使用双亲委派模型加载类,可以保证类的唯一性,而且可以保证核心类的安全性,防止用户自定的类覆盖核心类,还可以保证类的隔离性,不同类加载器加载的类互不可见。

2.自定义类加载器

继承ClassLoader,并且重写loadClass方法(默认的 loadClass()实现了双亲委派逻辑,如果直接重写它,可以跳过委托)

public class InfoLoader extends ClassLoader{

    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        // 1. 自定义加载逻辑(不委托给父加载器)
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            c = findClass(name); // 直接自己加载
        }
        return c;
    }
}

Java线程内存模型

1.基本线程内存模型

对于现代计算机而言,处理器处理数据的速度是远高于内存处理数据的速度(大约是100-1000倍),这样就形成了一堵内存墙。计算机为了解决这个问题,引入了高速缓存;高速缓存和处理器的处理速度差不多,这样就会弥补处理器和内存处理速度之间的差异;而高速缓存和共享内存之间通过缓存一致性协议完成数据的同步。

计算机内存模型

而对于Java内存模型而言,其实和上面展示的处理器、高速缓存和主内存的模型大差不差。

Java内存模型

那么工作内存和主内存之间的交互协议到底是什么呢?其实就是如何把一个变量从主内存拷贝到工作内存,或者如何把一个变量从工作内存同步到主内存的一个流程。整个流程有8种操作,并且都是原子性的操作。

假如某个线程需要操纵主内存的变量,首先就要锁定,操作完成之后需要解锁,作用于主内存的变量。然后我们需要将主内存的数据传输到工作内存,这一步就是读取,也是作用于主内存的变量。读取完成之后工作内存需要载入该变量,作用于工作内存。然后就是使用该变量,为该变量赋值,作用于工作内存。最后就是将修改之后的变量传送到主内存,这一步就是存储,然后放到主内存,也就是写入。也是作用于主内存的变量。

  1. 锁定-作用于主内存的变量
  2. 解锁-作用于主内存的变量
  3. 读取-作用于主内存的变量
  4. 载入-作用于工作内存的变量
  5. 使用-作用于工作内存的变量
  6. 赋值-作用于工作内存的变量
  7. 存储-作用于工作内存的变量
  8. 写入-作用于主内存的变量

2.happen-before原则

happen-before原则规定了多线程环境下操作的执行顺序,确保内存可见性和有序性。

  1. 程序顺序原则:同一线程内的操作按照顺序执行
  2. 锁原则则:解锁操作先于后于的加锁操作
  3. volatile变量原则:volatile变量的写操作优先于后续的读操作
  4. 线程启动原则:Thread.start()前的操作优于线程的任何操作
  5. 线程终止原则:线程的所有操作优先于其他线程检测到该线程的终止操作
  6. 中断原则:线程A中断线程B之前,线程B可以感受到中断
  7. 传递原则:A->B,B->C,那么A-C
  8. 对象终结原则:对象的构造函数执行结束先于 finalize()方法

volatile关键字

在并发编程中,有三个基本概念来保证安全性。

原子性、可见性、有序性

原子性

即一个操作或者多个操作要么全部执行且执行的过程不会被任何因素中断,要么都不执行,在Java中,Atomic包中的一切操作就属于原子操作。

可见性

当多线程访问同一个变量时,其中一个线程修改了变量的值,其他线程立马可以看到最新修改的值。而volatile就可以保证可见性,他的原理是:当某个线程修改了变量的值之后,此时表示本地工作内存的变量无效,立马将最新的值刷新的主内存;其他线程读取共享变量的时候,直接会从主内存读取最新的值。此外synchronized和Lock锁也可以保证可见性。

有序性

即程序执行的顺序按照代码的顺序执行。但是在Java内存模型中,为了执行效率,通常编译器会对操作指令进行重排序,在单线程执行的情况下,结果自然没什么问题,但是在多线程环境下,对执行结果自然有影响。而使用volatile关键字之后,他会禁止指令重排。

volatile的特点

  • 保证内存的可见性
  • 禁止指令重排

volatile的缺点

  • 无法保证原子性(volatile 不能保证复合操作的原子性,如 i++(读-改-写操作))
  • 只能作用于单个变量,无法作用多个变量或者代码块

volatile的应用场景

  1. 单变量状态标志(如 boolean running)。
  2. DCL 单例模式(防止指令重排序)。

如下代码则是状态标志的Demo

public class VolatileInfo {

    private volatile boolean flag = true;

    public void executeTask() {
        while (flag) {
            System.out.println("task is running");
            // 模拟某个条件触发停止任务
            if (System.currentTimeMillis() % 1000 == 0) {  // 示例条件:每秒检查一次
                flag = false;  // 修改 volatile 变量,其他线程立即可见
                System.out.println("Task stopped!");
            }
        }
    }

    public static void main(String[] args) {
        VolatileInfo task = new VolatileInfo();
        new Thread(task::executeTask).start();  // 启动任务线程

        // 模拟另一个线程修改 flag
        new Thread(() -> {
            try {
                Thread.sleep(3000);  // 3秒后停止任务
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            task.flag = false;  // 修改 volatile 变量,任务线程能立即感知
            System.out.println("External stop signal sent!");
        }).start();
    }
}

DCL(双重检查锁-单例模型)

public class DCL {

    private DCL() {
    }

    private static volatile DCL instance;


    public static DCL getInstance() {
        if (instance == null) {
            synchronized (DCL.class) {
                if (instance == null) {
                    instance = new DCL(); //这里存在指令重排序,使用了volatile则禁止了指令重排序
                }
            }
        }
        return instance;
    }
}

我们在new Object的时候,步骤是:
1、堆上分配内存
2、初始化对象
3、将引用指向该内存的地址

但是指令重排之后,会是:
1、堆上分配内存
2、将引用指向该内存的地址
3、初始化对象

因此多线程环境下,如果变量没有被volatile修饰,那么假设线程1假设以及完成了 instance = new DCL() 这一步,但是存在指令重排,实质上只完成了分配内存和引用指向了该对象,还没有初始化,但是线程2在 if (instance == null) 判断的时候,发现存在指向该对象的引用,就直接返回,此时线程2拿到的对象并没有初始化,所以这个方法是不安全的,加了volatile就会避免这个问题

synchronized关键字

1.synchronized底层原理

  1. 字节码层面:monitorenter/monitorexit实现
  2. JVM层面:对象头标记+Monitor对象实现
  3. 锁升级​​:无锁 → 偏向锁 → 轻量级锁 → 重量级锁
  • 总结:synchronized的底层实现对于无锁、偏向锁、轻量级锁和重量级锁是不一样的。无锁、偏向锁、轻量级锁的状态信息存储在对象头,对象头记录了锁的偏向标志,锁状态标志,以及线程ID,因此对于无锁、偏向锁、轻量级锁基于对象头就可以实现。当第一次进入同步代码块是,JVM通过CAS自旋方式将对象头中的​​偏向线程ID设置为当前线程ID,如果下次还是相同的线程,直接检查对象头中的线程ID​​,无需获取新的锁。如果是其他线程进入,那么首先JVM会在当前线程的栈中记录一个锁记录,然后通过CAS将对象头替换为指向所记录的指针,这就是轻量级锁,JDK 15 后​​偏向锁默认禁用​​。自旋次数由 JVM 自适应调整,之后升级为重量级锁,重量级锁基于Monitor对象实现(字节码层面:monitorenter/monitorexit实现),没有获取到锁的线程进入阻塞队列(EntryList),当其他线程释放锁之后,从阻塞队列(EntryList)唤醒一个线程获取锁,记录到Owner

2.面试问题

  1. 两个线程同时访问 synchronized 静态方法、两个线程分别访问 synchronized 静态方法和非静态方法分别是如何执行的?

线程A和B同时访问synchronized静态方法,那么此时静态方法共享同一把锁,锁的是当前的Class对象,因此A执行的时候B会出现阻塞。分别访问 synchronized 静态方法和非静态方法,此时因为静态方法的锁是当前的Class对象,非静态方法的锁是对象实例,因此锁对象不同,不会出现互斥现象,二者同时执行。

  1. 自旋、粗化、消除

多线程

1.线程和进程

特性 进程 (Process) 线程 (Thread)
定义 操作系统分配资源的基本单位 CPU调度的基本单位 (进程内的执行单元)
内存空间 独立内存空间(隔离) 共享所属进程的内存(堆、方法区)
创建开销 大(需分配独立资源) 小(共享进程资源)

2.线程创建的三种方式

1.继承Thread类

继承Thread类,然后重写run方法即可

public class ThreadInfo01 extends Thread{

    @Override
    public void run() {
        System.out.println("当先线程name:"+Thread.currentThread().getName());
    }

    public static void main(String[] args) {
        //创建了2个线程
        ThreadInfo01 t1 = new ThreadInfo01();
        ThreadInfo01 t2 = new ThreadInfo01();
        t1.start();
        t2.start();
    }
}

2.实现Runnable接口

public class ThreadInfo02 implements Runnable {

    public static void main(String[] args) {
        //创建了2个任务
        ThreadInfo02 task1=new ThreadInfo02();
        ThreadInfo02 task2=new ThreadInfo02();
        Thread t1=new Thread(task1);
        Thread t2=new Thread(task2);
        t1.start();
        t2.start();
    }

    @Override
    public void run() {
        System.out.println("当先线程name:"+Thread.currentThread().getName());
    }
}

3.实现Callable接口

public class ThreadInfo03 implements Callable<Integer> {

    @Override
    public Integer call() throws Exception {
        int sum = 0;
        for (int i = 1; i <= 100; i++) {
            sum += i;
        }
        return sum;
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        Callable task = new ThreadInfo03();
        FutureTask<Integer> result = new FutureTask(task);
        new Thread(result).start();
        Integer x = result.get();
        System.out.println(x);
    }
}

相比于继承Thread和实现Runnable方法,该方式则可以有返回值返回。

3.线程的生命周期

线程有5个状态,分别是:

  1. NEW 新建状态
  2. RUNNABLE 可运行状态
  3. BLOCKED 阻塞状态
  4. WAITING 等待状态
  5. TIMED_WAITING 超时等待
  6. TERMINATED 终止状态

下面的图片分别是线程的生命周期和线程状态转换示意图。

img

img

  • RUNNABLE:RUNNABLE状态是可运行状态,此状态其实包含了两个过程,可运行和运行状态,所谓可运行状态是:线程调用了start()方法之后,准备运行,但是是否立马运行取决于CPU的调度,假设此时CPU资源不够用,那么线程依然不会开始运行。

  • BLOCKED:阻塞状态,这个状态表示线程没有获取到监视器锁。

public class BlockedStateDemo {
    private static final Object lock = new Object();

    public static void main(String[] args) {
        Thread t1 = new Thread(() -> {
            synchronized (lock) {
                System.out.println("t1 已经获取锁");
                try {
                    Thread.sleep(3000); // 模拟持有锁
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        Thread t2 = new Thread(() -> {
            System.out.println("t2 尝试获取锁......");
            synchronized (lock) {  // 此处会进入 BLOCKED 状态
                System.out.println("t2 已经获取锁");
            }
        });

        t1.start();
        t2.start();
    }
}

运行程序,结果如下:

t1 已经获取锁
t2 尝试获取锁......
t2 已经获取锁

T1线程持有锁期间,T2线程是没有获取到锁,因此它处于BLOCKED状态,等他获取到锁之后,才处于RUNNABLE状态。

public class ThreadStateDemo {
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            try {
                Thread.sleep(1000);
                synchronized (ThreadStateDemo.class) {  // 获取 Class对象的锁
                    ThreadStateDemo.class.wait();       // 释放锁,进入 WAITING 状态
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        });



        System.out.println("1. NEW: " + thread.getState());  // NEW
        thread.start();
        System.out.println("2. RUNNABLE: " + thread.getState());  // RUNNABLE
        Thread.sleep(500);
        System.out.println("3. TIMED_WAITING: " + thread.getState());  // TIMED_WAITING
        Thread.sleep(1000);
        System.out.println("4. WAITING: " + thread.getState());  // WAITING

        synchronized (ThreadStateDemo.class) {
            ThreadStateDemo.class.notify();  // 唤醒线程
        }

        thread.join();
        System.out.println("5. TERMINATED: " + thread.getState());  // TERMINATED
    }
}

上面的代码是线程状态的几种转换,唯独缺少的BLOCKED,我在上面也有对应的代码(多线程竞态获取锁)。如果上面的代码缺少thread.join();那么最后一句打印的状态是什么呢?

  synchronized (ThreadStateDemo.class) {
            ThreadStateDemo.class.notify();  // 唤醒线程
        }
  • ​BLOCKED出现的原因​​:子线程被 notify()唤醒后,必须重新获取锁,但主线程仍然持有锁,导致子线程进入 BLOCKED状态。

  • ​如何避免​​:使用 thread.join()让主线程等待子线程真正结束,确保最后打印的是 TERMINATED。

  • 关键点​​:wait()会释放锁,notify()唤醒后必须重新获取锁。如果锁被占用,被唤醒的线程会进入 BLOCKED状态。

  • WAITING:等待状态,调用Object.wait、Thread.join、LockSupport.park方法才会出现。

上面的代码其实已经调用了wait(),值得注意的是:wait()会释放锁,需要重新获取锁才会转化为可运行状态。

thread.join方法介绍

join方法:阻塞主线程,等待其他线程运行。
在主线程调用子线程的join方法,就会阻塞主线程,等待主线程运行完毕

  • 面试题:在 Java 中,如果主线程需要等待大量子线程执行完毕后再继续执行,可以采用多种方式,每种方式在​​性能、资源消耗、代码复杂度​​等方面有所不同。以下是几种常见方案,并分析它们的效率:

1. Thread.join()(基本方式,但效率较低)​

public class MainThread {
    public static void main(String[] args) throws InterruptedException {
        long start = System.currentTimeMillis();
        System.out.println("main线程开始运行......");
        List<Thread> threadList=new ArrayList<>();
        for (int i=0;i<1000_0;i++){
            Thread t = new SonThread();
            t.start();
            threadList.add(t);
        }
        // 主线程等待所有子线程完成
        for (Thread t : threadList) {
            t.join();  // 阻塞主线程,直到当前线程完成
        }
        System.out.println("main线程运行结束......,耗时"+(System.currentTimeMillis()-start)+"ms");
    }
}

class SonThread extends Thread {
    @Override
    public void run() {
        try {
            System.out.println("子线程开始运行......");
            Thread.sleep(10);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

使用Thread.join()方法,效率低下,因为join()是阻塞调用,主线程必须​​串行等待​​每个子线程结束。如果子线程数量很大(如 10,000+),主线程会长时间阻塞,影响整体执行时间。

for (Thread t : threads) {
    t.join();  // 主线程必须依次等待每个线程
}

上面的代码,主线程会依次检查子线程的执行状态,如果有 10,000 个线程,主线程需要​​被唤醒 10,000 次​​,效率极低。

2. 使用CountDownLatch(效率较高)

CountDownLatch 是一种同步器,它允许一个线程在开始处理之前等待一个或多个线程。
img

public class MainThread {
   public static void main(String[] args) throws InterruptedException {
       long start = System.currentTimeMillis();
       System.out.println("main线程开始运行......");
       CountDownLatch latch = new CountDownLatch(1000_0);

       for (int i = 0; i < 1000_0; i++) {
           new Thread(() -> {
               try {
                   System.out.println("子线程" + Thread.currentThread().getName() + "开始运行......");
                   Thread.sleep(10);
               } catch (InterruptedException e) {
                   e.printStackTrace();
               } finally {
                   latch.countDown();  // ✅ 子线程完成后才调用
               }
           }).start();
       }

       latch.await();  // 主线程等待所有子线程完成
       System.out.println("main线程运行结束......,耗时" + (System.currentTimeMillis() - start) + "ms");
   }
}

3. ExecutorService+ shutdown()+ awaitTermination()(线程池管理)​最高效

public class MainThread {
    public static void main(String[] args) throws InterruptedException {
        long start = System.currentTimeMillis();
        System.out.println("main线程开始运行......");
        int threadCount = 1000_0;
        ExecutorService executor = Executors.newFixedThreadPool(1000);  // 控制并发数

        for (int i = 0; i < threadCount; i++) {
            executor.submit(() -> {
                System.out.println("子线程" + Thread.currentThread().getName() + "开始运行......");
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            });
        }

        executor.shutdown();  // 停止接收新任务
        executor.awaitTermination(1, TimeUnit.HOURS);  // 等待所有任务完成
        System.out.println("main线程运行结束......,耗时" + (System.currentTimeMillis() - start) + "ms");
    }
}

executor.awaitTermination(1, TimeUnit.HOURS); // 等待所有任务完成,此期间会阻塞主线程,让当前线程(通常是主线程)等待线程池中的所有任务执行完毕,最多等待 1 小时​​。如果所有任务在 1 小时内完成,则立即返回;如果超时仍有任务未完成,则返回 false。使用线程池的好处还可避免频繁创建/销毁线程的开销

4. CompletableFuture.allOf()(Java 8+,异步编程风格)​

public class MainThread {
    public static void main(String[] args) throws InterruptedException {
        long start = System.currentTimeMillis();
        System.out.println("main线程开始运行......");
        // 创建固定大小的线程池(避免默认池的线程数不足)
        ExecutorService executor = Executors.newFixedThreadPool(2000);
        List<CompletableFuture<Void>> futures = new ArrayList<>();
        for (int i = 0; i < 1000_0; i++) {
            CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
                System.out.println("子线程" + Thread.currentThread().getName() + "运行");
                try {
                    Thread.sleep(10);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt(); // 恢复中断状态
                }
            }, executor);  // 指定自定义线程池
            futures.add(future);
        }

        CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join();
        executor.shutdown(); // 关闭线程池
        System.out.println("main线程运行结束......,耗时" + (System.currentTimeMillis() - start) + "ms");
    }
}

✅ ​​非阻塞+函数式风格​​,适合异步编程。
✅ 底层使用 ForkJoinPool,自动优化任务调度。
❌ 适用于​​CPU密集型任务​​,IO密集型任务可能需要调整线程池。

5. Phaser

public class MainThread {
    public static void main(String[] args) throws InterruptedException {
        long start = System.currentTimeMillis();
        System.out.println("main线程开始运行......");
        Phaser phaser = new Phaser(1000_0);  // 注册1000个线程

        for (int i = 0; i < 1000_0; i++) {
            new Thread(() -> {
                try {
                    System.out.println("子线程" + Thread.currentThread().getName() + "运行");
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt(); // 恢复中断状态
                    }
                } finally {
                    phaser.arriveAndDeregister();  // 任务完成,注销
                }
            }).start();
        }

        phaser.awaitAdvance(phaser.getPhase());  // 主线程等待所有线程完成
        System.out.println("main线程运行结束......,耗时" + (System.currentTimeMillis() - start) + "ms");
    }
}

✅ ​​动态调整线程数​​(比 CountDownLatch更灵活)。
✅ 支持​​分阶段同步​​(适用于多阶段任务)。

thread.park方法介绍

park()方法同样可以让运行的线程处于WAITING状态

public class ParkDemo {
    public static void main(String[] args) {
        Thread thread = new Thread(() -> {
            System.out.println("子线程开始执行,即将进入 WAITING 状态");
            LockSupport.park(); // 挂起当前线程
            System.out.println("子线程被唤醒,继续执行");
        });

        thread.start();

        try {
            Thread.sleep(2000); // 主线程休眠 2 秒
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("主线程唤醒子线程");
        LockSupport.unpark(thread); // 唤醒子线程
    }
}

4.线程的其他几个方法

1.yield方法

用于 ​​提示线程调度器当前线程愿意让出 CPU 执行权​​,但具体是否让出由 JVM 决定.下面是几个方法对比。

| 方法 | 是否释放锁 | 线程状态变化 |是否强制让出CPU|
| ---- | ---- | ---- |
| yield | 否 | RUNNABLE |否|
| sleep | 否 | TIMED_WAITING |是|
| wait | 是 | WAITING 或 TIMED_WAITING |是|
| join | 否 | WAITING 或 TIMED_WAITING |是|

 public static void main(String[] args) {
       new Thread(new Runnable() {
           @Override
           public void run() {
               for (int i = 0; i < 5; i++){
                   System.out.println("线程1正在运行");
               }
           }
       }).start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 5; i++){
                    System.out.println("线程2正在运行");
                }
            }
        }).start();
    }

上面的代码运行,基本上是第一个先运行结束(也存在例外,取决于CPU调度),第二次开始运行。如果在第一个线程调用了yield方法,就会出现交替运行(可能存在,取决于CPU调度)。

    public static void main(String[] args) {
       new Thread(() -> {
           for (int i = 0; i < 5; i++){
               System.out.println("线程1正在运行");
               Thread.yield();
           }
       }).start();

        new Thread(() -> {
            for (int i = 0; i < 5; i++){
                System.out.println("线程2正在运行");
            }
        }).start();
    }

2.priority()方法

    Thread highPriorityThread = new Thread(() -> {
        System.out.println("高优先级线程执行");
    });
    highPriorityThread.setPriority(Thread.MAX_PRIORITY); // 优先级10

    Thread lowPriorityThread = new Thread(() -> {
        System.out.println("低优先级线程执行");
    });
    lowPriorityThread.setPriority(Thread.MIN_PRIORITY); // 优先级1

    highPriorityThread.start();
    lowPriorityThread.start();

设置线程优先级,但也是提示,并不是强制,默认为5,取值范围[1,10],数值越大,优先级越高

3.daemon()方法

当一个线程被设置为守护线程之后,就会出现:所有的用户线程执行完任务之后,JVM退出,此时守护线程被迫中止;而用户线程则需要JVM等待所有其他线程都完成任务才会退出。

    public static void main(String[] args) {
        Thread daemonThread = new Thread(() -> {
            while (true) {
                System.out.println("守护线程运行中...");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        });
        daemonThread.setDaemon(true);  // 设置为守护线程
        daemonThread.start();

        // 主线程(用户线程)结束,JVM 退出
        System.out.println("主线程结束");
    }

4.线程中断

在Java中,线程的中断是一种协作式的终止机制,而不是强制性的。它通过设置线程的中断标志位,让线程在合适的时机自行决定是否要终止。

1.使用interruput方法

下面代码启动了一个线程,内部循环一直检测线程是否中断,没有中断则执行逻辑;调用了t.interrupt(),设置中断。阻塞方法(如 sleep()、wait()、join())​​ 被中断时会抛出 InterruptedException,并清除中断标志,通常需要重新设置

    public static void main(String[] args) throws InterruptedException {
        Thread t = new Thread(() -> {
            while (!Thread.currentThread().isInterrupted()) {
                System.out.println("线程开始运行......");
                try {
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    System.out.println("线程此时中断......");
                    Thread.currentThread().interrupt();
                }
            }
        });

        t.start();
        t.sleep(3000);
        t.interrupt();
    }

5.死锁

当一个线程A持有线程B所需要的锁,而线程B持有线程A所需要的锁,可能会发生死锁

    public static void main(String[] args) {
       Thread threadA=new Thread(() -> {
            synchronized (lock1){
                try {
                    System.out.println("Thread-A 持有 lock1");
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock2){
                    System.out.println("Thread-A 获取 lock2");
                }
            }

       });

        Thread threadB=new Thread(() -> {
            synchronized (lock2){
                try {
                    System.out.println("Thread-B 持有 lock2");
                    Thread.sleep(1000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock1){
                    System.out.println("Thread-B 获取 lock1");
                }
            }

        });
        threadA.start();
        threadB.start();
    }
  • 代码执行流程​​
    ​​线程A启动​​:
    获取 lock1的锁
    打印 "Thread-A 持有 lock1"
    休眠 1 秒(此时仍持有 lock1)
    ​​线程B启动​​:
    获取 lock2的锁
    打印 "Thread-B 持有 lock2"
    休眠 1 秒(此时仍持有 lock2)
    ​​死锁发生​​:
    线程A 醒来后尝试获取 lock2(但 lock2被线程B持有)
    线程B 醒来后尝试获取 lock1(但 lock1被线程A持有)
    两个线程互相等待,​​程序永久阻塞​。

  • 死锁检测
    运行程序后通过 jps获取进程ID,然后通过执行 jstack ,输出中会显示deadLock内容。

  • 避免死锁
    固定锁获取顺序(如总是先 lock1 后 lock2)
    使用 tryLock设置超时

6.并行和并发

并行:物理上同时执行多个任务,依赖多核CPU或者分布式处理
并发:看似同时执行,实际上是通过时间轮片/任务切换实现
比如:并行就像多车道同时行进的汽车,并发则像单车道的汽车,通过红绿灯控制,让汽车交替通过

7.乐观锁和悲观锁

  • 乐观锁:假设在操作的时候,竞争较少,因此不加锁,只有提交的时候才检测冲突,适用于无锁或者轻量级锁、读多写少,高吞吐需求,如CAS
  • 悲观锁:假设在操作的时候,竞争较大,因此提前枷锁,适用于写多读少的情况、,强一致性需求。

Java线程池

img

1.ThreadPoolExecutor构造器

public ThreadPoolExecutor(
    int corePoolSize,              // 核心线程数
    int maximumPoolSize,           // 最大线程数
    long keepAliveTime,            // 空闲线程存活时间
    TimeUnit unit,                 // 时间单位
    BlockingQueue<Runnable> workQueue,  // 工作队列
    ThreadFactory threadFactory,   // 线程工厂
    RejectedExecutionHandler handler     // 拒绝策略
);

参数介绍

  • corePoolSize 核心线程数
  • maximumPoolSize 最大线程数
  • keepAliveTime 当线程数量大于核心线程数时,空闲线程存活时间
  • unit 时间单位
  • workQueue 工作队列(阻塞队列,存放任务)
  • threadFactory 线程工厂(用于指定为线程池创建新线程的方式)
  • handler 拒绝策略

2.四种队列介绍

1.LinkedBlockingQueue

基于链表的队列,一般使用Executors.newFixedThreadPool创建线程池的时候,默认是无界队列,当然使用new ThreadPoolExecutor创建线程池的时候,可以设置容量,变成有界队列。

2.ArrayBlockingQueue

基于数组的有界队列

3.SynchronousQueue

容量为0的阻塞队列,他的作用是传递任务

public static void main(String[] args) {
        SynchronousQueue<String> queue=new SynchronousQueue<>();
        new Thread(() -> {
            try {
                queue.put("生产者提供的数据");
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }).start();

        new Thread(() -> {
            try {
                String msg = queue.take();
                System.out.println("消费者拿到的数据为:" + msg);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }).start();
    }
  • 为什么 newCachedThreadPool用 SynchronousQueue?​​
    因为 newCachedThreadPool的设计目标是 ​​立即执行任务​​,如果没有空闲线程则创建新线程。SynchronousQueue确保任务不会被缓冲,而是直接交给线程执行。

4.PriorityBlockingQueue

这个是有优先级的阻塞队列,所谓存在优先级,其实就是需要实现compareTo方法的排序,根据排序字段,选择优先执行。无界队列,也容易出现OOM。

3.四种拒绝策略

1.AbortPolicy(默认策略)​

行为​​:直接抛出 RejectedExecutionException异常。
​​适用场景​​:需要严格保证任务不被丢弃(如支付订单处理)。

    public static void main(String[] args) {
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                2,
                4,
                60L, TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(2),
                new ThreadPoolExecutor.AbortPolicy());
        try {
            for (int i = 0; i < 10; i++) {
                executor.execute(() -> {
                    try {
                        System.out.println("任务执行: " + Thread.currentThread().getName());
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                });
            }
        } catch (RejectedExecutionException e) {
            System.err.println("任务被拒绝: " + e.getMessage());
        }
        executor.shutdown();
    }

运行过程是4个线程+2个任务,总计也就是6个任务,现在有10个任务,此时进入的任务会被丢弃,直接报错:RejectedExecutionException。

2.CallerRunsPolicy

让提交者执行多余的任务。
下面的代码,如果出现多余的任务,此时线程池已经无法执行了,那么就需要任务提交者的线程自己运行该任务。相当于临时降级了(原本线程池是高效高速处理任务,此时由于线程池无法处理,只能交由任务提交者线程自己处理,相当于将任务从线程池的并发处理"降级"为提交者的单线程处理)

    public static void main(String[] args) {
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
                2,
                4,
                60L, TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(2),
                new ThreadPoolExecutor.CallerRunsPolicy());
        try {
            for (int i = 0; i < 10; i++) {
                executor.execute(() -> {
                    try {
                        System.out.println("任务执行: " + Thread.currentThread().getName());
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        throw new RuntimeException(e);
                    }
                });
            }
        } catch (RejectedExecutionException e) {
            System.err.println("任务被拒绝: " + e.getMessage());
        }
        executor.shutdown();
    }

3.DiscardPolicy(丢弃新任务)

上述的代码拒绝策略换成DiscardPolicy,那么会直接丢弃4个任务,只执行了6个任务,而且没有任何异常出现。

4.DiscardOldPolicy(丢弃旧任务)

DiscardOldPolicy,会丢弃队列中的旧任务。

String类

1.字符串常量池

字符串常量池在JDK1.6及其之前的版本,则位于永久代(和堆隔离),在JDK1.7的时候,字符串对象迁移到堆,字面量需要通过 intern()方法在堆中管理,在JDK1.8则全部到堆,和元空间无关。

        String s1="hello";
        String s2="hello";
        String s3=new String("hello");
        System.out.println(s1==s2);
        System.out.println(s1==s3);

上面的代码s1==s2,s1!=s3。s1和s2字面量都是hello,因此会存储到字符串常量池,因此s1和s2指向的是同一个字符串对象。s3则会创建新的对象。

2.new String("xxx")会创建几个对象

  • 答案:1到2个
String ref=new String("hello")

以上面的代码举例,如果字符串常量池没有这个hello,那么会首先在常量池创建一个hello,然后在堆中创建一个,而ref指向的是堆中的对象。

img

如果字符串常量池有这个字符串,那么仅在​​堆内存​​中创建一个新的 String对象。可以使用intern()方法返回常量池的引用。

        String s1="hello";
        String s2="hello";
        String s3=new String("hello");
        String s4 = s3.intern();
        System.out.println(s1==s2);//true
        System.out.println(s1==s3);//false
        System.out.println(s1==s4);//true

3.String、StringBuffer和StringBuilder

String不可变且线程安全
StringBuffer、StringBuilder可变对象,其中StringBuffer线程安全,StringBuilder线程不安全

集合框架

1.继承关系图

img

1.ArrayList和Vector及Stack

ArrayList基于数组实现的容器,线程不安全;当多线程操作ArrayList时,可能会出现一个线程正在读取时,另外一个线程正在修改;或者多个线程同时触发扩容机制;或者出现迭代的时候,另外一个线程正在修改集合(并发修改异常)。

ArrayList的扩容机制,默认是原来1.5倍。

Vector线程安全的List,然后性能低,已过时。Stack继承了Vector,因此也是线程安全,是栈结构的实现

2.HashMap

1.解决Hash冲突的方式

  1. 开放地址法:如果出现hash冲突,那么选择下一个槽位(线性探测法)、或者以平方的步长选择下一个槽位(平方探测)
  2. 链地址法:如果出现hash冲突,那么在该槽位构建一个链表,冲突的数据挂在链表上(HashMap默认的方式,JDK8及其以后还需要红黑树)
  3. 再Hash法:使用多个hash方法,第一个冲突了,使用第二个hash方法

2.HashMap的结构阐述

JDK8及其以后,HashMap结构使用了数组+链表+红黑树;如果链表的长度大于8,则将链表转化为红黑树。如果再扩容期间,红黑树节点的个数小于6,则转化为链表,也就是树化和链化。

3.为什么使用数组,可以使用ArrayList吗,或者LinkedList吗

当然可以使用ArrayList和LinkedList,但是出于性能考虑,选择了数组,而不是选择集合。LinkedList访问,需要遍历链表,因此时间复杂度为O(n),而数组和ArrayList,随机访问都是O(1);ArrayList存储了其他的属性,这些对于HashMap没有用,而且扩容机制也不同,HashMap数组扩容为2倍,ArrayList为1.5倍,性能消耗更大。

4.为什么HashMap的长度设计为2的幂次方

  1. 因为2的幂次方-1的值,二进制表示全为1,计算元素位置的时候,结果和取模相同,但是位运算效率更高
  2. hash值分布更为均匀(index = hash & (length - 1))

5.扰动函数解决了什么问题

如果直接使用一个变量的hash值,然后和 length-1 做 & 运算,那么冲突依然比较激烈;因此需要设计出优秀的 hash 计算方法。index=hash % length 或者 index=hash & (length - 1),这二者都可以计算元素的位置,但是当hash值在低位相同的时候,冲突一样激烈。例如下面的例子:

哈希表长度 length = 16​​(2^4,符合 2^n设计),​​length - 1 = 15​​(二进制 0000 1111)。​​4 个不同的 hash值​​:hash1 = 25(二进制 0001 1001)、hash2 = 41(二进制 0010 1001)、hash3 = 57(二进制 0011 1001)、hash4 = 73(二进制 0100 1001)

img

发现无论是哪一种计算,都出现了相同的冲突,根本原因是这4个值的hash低位完全相同,都是1001,因此扰动函数就是去干扰,让高位影响低位,让低位变得不同,这样更均匀分布。

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

6.讲解下hashmap的扩容机制

  1. 如果 元素的长度>= 数组的长度 * 加载因子 ,那么开始扩容,长度为原来的2倍
  2. 如果 链表的长度>=8且数组的长度<64,开始扩容

7.rehash高效实现

Java7中,还是使用的 hash % length 取模实现的,计算元素的索引,而且要重新计算元素的hash值,在Java8及其之后,使用的原理如:假设原来的容量 oldCapacity =16,那么扩容后newCapacity=32,此时不需要重新计算元素的hash值,而是需判断每个键的哈希值与旧容量的位运算结果。

  • 如果 hash & oldCapacity == 0,元素在新数组中的位置不变。
  • 如果 hash & oldCapacity != 0,元素在新数组中的位置为 原位置 + oldCapacity。 这种方法避免了完整的哈希重新计算,大幅提升了扩容效率。

hashmap的rehash过程,正是利用了hashmap的容量为2n次幂,从原来的2n次幂扩容到2^n+1,此时新容量的二进制比旧容量的二进制多了一位,如下:

  • 旧容量 oldCap = 8(二进制 1000)
  • 新容量 newCap = 16(二进制 10000)
  • oldCap - 1 = 7(二进制 0111)
  • newCap - 1 = 15(二进制 1111)
索引计算本质

扩容前索引:hash & 0111(使用低3位)
扩容后索引:hash & 1111(使用低4位)
关键观察:

扩容后比扩容前​​多使用1个高位bit​​
这个bit正好是oldCap的最高位(1000的第4位),所以决定元素的位置的关键在于这个oldCap。

img
因此,扩容前hash的最高位如果是0,那么无论是扩容前,还是扩容后,都是0xxx,和扩容前的计算结果一样。所以还是原来的位置;如果扩容前hash的最高位如果是1,那么扩容之后的结果是1xxx=0xxx+1000,也就是原来的位置+旧容量的长度

8.头插和尾插的区别

头插会颠倒链表的顺序,多线程环境下出现链表互相引用的情况;尾插则不会。(https://www.cnblogs.com/blogzero/p/18232672)

Spring框架

1. Bean的生命周期

  • 实例化
  1. 通过反射调用构造函数创建 Bean 实例
  • 属性赋值
  1. 依赖注入
  2. 执行三个aware接口的方法(BeanNameAware、BeanFactoryAware、ApplicationContextAware)
  • 初始化阶段
  1. 执行BeanPostProcessor的前置处理方法
  2. 执行标志了@PostContruct的方法
  3. 执行InitializingBean的afterPropertiesSet​​方法
  4. 执行自定义方法
  5. 执行BeanPostProcessor的后置处理方法
  • 使用阶段 (In Use)
  1. Bean 完全初始化,可以被应用程序使用
  • 销毁阶段
  1. 执行标志了@PreDestory方法
  2. 执行​​DisposableBean的destroy​​方法
  3. 执行自定义销毁方法

img

2. Spring Bean 三级缓存

Spring Bean 三级缓存解决了什么问题呢?解决了Bean循环依赖的问题,而且只针对setter方法的注入才有效,对于构造器的注入无效。

一级缓存(singletonObjects):存放了完全初始化的单例bean
二级缓存(earlySingletonObjects):存放了提前暴露的bean(已初始化,但没有设置属性)
三级缓存(singletonFactories):存放了bean的工厂对象,用于生产提前暴露的bean

  • 三级缓存如何解决循环依赖呢?
    假设有两个对象A和B,互相引用,我们看一下整个过程
  1. 当我们getBean获取A对象的时候,先去singletonObjects获取,此时没有该对象,然后去earlySingletonObjects获取,此时也没有,然后再去singletonFactories获取对象工厂创建早期引用(为什么从singletonFactories就可以获取对象工厂呢?那是因为在实例化之后,属性设置之前就会创建对象工厂)。
  2. 此时将获取到的对象的早期引用添加到二级缓存,然后删除三级缓存的对象,此时A对象完成了初始化,但没有设置属性,并且在二级缓存(earlySingletonObjects)
  3. 此时A对象开始设置属性B
  4. 当我们getBean获取B对象的时候,先去singletonObjects获取,此时没有该对象,然后去earlySingletonObjects获取,此时也没有,然后再去singletonFactories获取对象工厂创建早期引用。
  5. 将获取到B对象的早期引用加入到二级缓存,然后删除三级缓存的对象,此时B对象完成了初始化,但没有设置属性,并且在二级缓存(earlySingletonObjects)
  6. 此时B对象开始设置属性A
  7. 去一级缓存没找到A,然后去二级缓存,发现此时A对象存在,然后B对象完成属性的设置,B完全初始化,放入一级缓存,删除二级缓存中的bean。
  8. 此时A到一级缓存找到B,然后完成完全初始化,将A放入一级缓存,然后删除二级缓存的bean
  • 可以使用二级缓存解决循环依赖吗
    如果没有AOP,两级缓存可以,但是如果使用了AOP,那么必须使用三级缓存。假设只存在两级缓存的情况:
  1. 当我们getBean获取A对象的时候,先去singletonObjects获取,此时没有该对象,然后去earlySingletonObjects获取,此时也没有,然后实例化A(不是代理)。,存入earlySingletonObjects
  2. 此时A对象完成了初始化,但没有设置属性,并且在二级缓存(earlySingletonObjects)
  3. 此时A对象开始设置属性B
  4. 当我们getBean获取B对象的时候,先去singletonObjects获取,此时没有该对象,然后去earlySingletonObjects获取,此时也没有,然后实例化B(不是代理)。,存入earlySingletonObjects
  5. 此时B对象完成了初始化,但没有设置属性,并且在二级缓存(earlySingletonObjects)
  6. 此时B对象开始设置属性A
  7. 去一级缓存没找到A,然后去二级缓存,发现此时A对象存在,然后B对象完成属性的设置(B对象持有的是A的原始对象),B完全初始化,放入一级缓存,删除二级缓存中的bean。
  8. 此时A到一级缓存找到B,然后完成完全初始化
  9. A开始AOP代理,并且把代理对象放入一级缓存

结论:B拿到的是A的原始对象,而不是代理对象,导致 ​​AOP 失效​​;A 最终是代理对象​​,但 B 依赖的是原始对象,​​单例被破坏​​(内存中有两个不同的 A 实例)。

3.说一下Spring中IOC的构建流程(初始化过程)

  1. 资源加载 :xml加载、文件系统加载、注解加载
  2. BeanDefinition 载入:将加载的资源解析为BeanDefinition对象
  3. 注册BeanDefinition:将BeanDefinition对象存储到BeanDefinitionMap中,完成bean的注册
  4. 开启实例化所有的bean

4.BeanFactory和FactoryBean的区别

1.BeanFactory是IOC容器的顶级接口,提供了Spring容器的基本规范。而FactoryBean本身是一个Bean,而且可以用来生产Bean对象,相当于扩展了Bean的生产方式。

5.BeanFactory和ApplicationContext区别

BeanFactory是Spring 最基础的 IOC 容器功能,提供基本的依赖注入,而ApplicationContext继承了BeanFactory,并且增强了更多企业级功能(比如国际化、事件发布通知等)

6.Bean的作用域

  1. singleton:单例,如果bean是有状态的,那么线程不安全
  2. prototype: 原型,每次请求都创建新的实例,默认线程安全,但是如果存在静态变量或者共享变量,依然线程不安全
  3. request: 每个 HTTP 请求一个实例,请求结束自动销毁。线程安全,但是不要跨请求共享实例
  4. session: 每个会话 请求一个实例,会话结束自动销毁。线程安全(对共享数据需要同步处理)
  5. application(应用作用域),每个web应用共享一个实例,类似singleton

7.谈论下AOP

AOP,即面向切面编程。解决了传统OOP中横切关注点的问题。比如日志记录、事务管理、权限控制等横切关注点分散在整个业务逻辑中,AOP则可以通过定义切面的方式,来统一处理这些关注点,这样可以做到和业务逻辑解耦。AOP还可以做到运行时动态增强,在不修改源代码的情况下为现有逻辑增加功能。

在Spring中使用了JDK动态代理和CGLIB动态代理方式来实现,如果类存在接口,那么使用JDK代理,普通类使用的是CGLIB代理。
JDK动态代理的实现,则是通过实现InvocationHandler接口的invoke方法,完成增强;底层则通过反射生成一个类,这个类实现了原始类的接口和一切方法,并且继承了Proxy类。因此基于JDK的动态代理实现,无法再使用继承的方式去对类实现代理。而CGLIB则通过修改字节码生成一个子类,然后重写父类的方法。

8.Spring事务传播行为

Spring事务传播行为规定了在Spring中,多个方法之间互相调用的时候,事务时如何传播的。

1.​​REQUIRED​​ (默认)

如果当前没有事务,就新建一个事务;如果已经存在一个事务,就加入这个事务

   @Transactional(propagation = Propagation.REQUIRED)
    public void methodA(){
        methodB();
    }

    public void methodB(){
        
    }

如上调用,A调用B,此时A有事务,并且是​​REQUIRED,B没有事务,但是发现A是REQUIRED,所有B也会加入到这个事务。这里需要注意的是方法A和B如果在同一个类中,事务时不生效的,同类方法调用会绕过代理,直接调用目标方法。因此如果非要调用,那就需要将自己注入给自己。

2.SUPPORTS

支持当前事务,如果当前没有事务,就以非事务的方式进行

3.MANDATORY

使用当前事务,如果当前没有事务,就抛出异常

4.​​REQUIRED_NEW

如果当前没有事务,就新建一个事务;如果已经存在一个事务,挂起该事务

5.NOT_SUPPORTD

以非事务的方式进行,如果当前存在事务,挂起该事务

6.NEVER

以非事务的方式进行,如果存在事务,抛出异常

7.NESTED

如果当前存在事务,则在嵌套事务内进行,不存在事务,则与​​REQUIRED类似

9.Spring事务失效的场景

  1. 方法非public修饰
  2. 方法使用了final或者static修饰
  3. 方法子调用问题,无法使用代理对象
  4. 事务传播行为使用不当
  5. 在@Async修饰的方法是使用事务
  6. 多数据源未指定事务管理器
  7. 捕获了异常或者没有抛出RunTimeException
  8. 数据库引擎不支持(MyISAM)
  9. 嵌套事务传播出现冲突
  10. 非Spring管理的bean

10.SpringMVC的执行流程

1.​​请求接收与路由​​

  • 前端所有请求统一由 DispatcherServlet(前端控制器)接收,调用 getHandler()方法获取 HandlerExecutionChain(包含目标处理器和拦截器链)

2.​​处理器适配​​

  • 此时根据HandlerExecutionChain获取HandlerAdapter,通过 HandlerAdapter适配器定位具体控制器方法、适配器处理不同类型的控制器(注解式/传统式)

3.​业务处理​​

  • 执行目标控制器方法、返回模型视图对象

4.​视图解析​​

  • DispatcherServlet调用 ViewResolver解析视图,将逻辑视图名转换为具体 View实现
  1. ​视图渲染​​
  • 调用 View.render()方法返回HTML、生成最终响应内容返回客户端

11.@Component和@Bean的区别

二者都可以创建对象,并且被Spring IOC容器管理,但是也有区别:

  1. @Component作用在类上的注解,他是通过自动扫描,然后创建对象并且被IOC容器管理,@Bean则是作用在方法上的注解,并且显式的创建对象。
  2. @Component创建对象的过程则完全是由IOC容器控制的,@Bean则更加灵活,可以根据需要控制Bean的创建逻辑。
  3. 依赖注入方式有区别,@Component是通过@Autoired注解或者其他构造函数注入其他的bean,而@Bean则可以通过方法参数注入其他bean

SpringBoot框架

SpringBoot的优势

和Spring相比,SpringBoot有如下的优势:

  1. 简化配置;Spring需要配置大量的XML或者Java配置,而SpringBoot则时自动配置,并且使用starter统一管理包依赖;遵循约定大于配置的原则,配置工作大为减少
  2. 提升开发效率;比如我们需要mybatis,Spring需要手动添加Spring的包依赖和mybatis的包依赖,还需要写事务管理器等;而SpringBoot只需要几个starter,其他的都是自动配置
  3. 内置web容器,通过main方法即可启动,适合微服务和云原生环境
  4. SpringBoot提供了健康检查,日志系统,以及安全配置,这些都要在Spring中手动配置
    综上所述,Spring Boot通过'约定优于配置'的理念和创新的自动装配机制,在保留Spring全部能力的同时,大幅降低了使用门槛和开发成本,这正是它在已有Spring MVC基础上仍然不可或缺的价值所在

SpringBoot的启动流程

1.应用初始话

  • 入口:执行SpringApplication.run方法启动程序
  • 环境检测:根据类型判断web类型(servlet、react、none)
  • 加载扩展点:
  1. 从MATA-INF/spring.factories下面加载ApplicationContextInitializer(应用上下文初始化扩展)
  2. 加载ApplicationListener(用于读取配置文件)
  • 推断main类,确定入口类

2.环境准备

  • 加载配置文件(application.properties/yaml)
  • Profile 激活​​:通过 spring.profiles.active指定激活的环境(如 dev、prod)。

3.创建应用上下文

  • 根据环境类型创建上下文​​(webmvc\webflux\nono)
  • 准备上下文
  1. 注册主类(使用@SpringBootApplication标注的类)作为配置类
  2. 调用ApplicationContextInitializer进行自定义初始化(如修改 Bean 定义)

4.Bean的加载和自动装配

  • 组件扫描:扫描主类所在的包以及子包下面的配置类(@Component @Service)
  • 自动装配:加载MATA-INF/spring.factories里面的自动配置的类,条件加载
  • Bean实例化与依赖注入​

5.刷新上下文

  • ​​初始化所有单例 Bean​​:确保所有依赖解析完成。
  • ​​注册事件监听器​​:如 ApplicationListener监听事件(如 ContextRefreshedEvent)。
  • ​​发布事件​​:通知各组件应用已就绪(如 ApplicationReadyEvent)。

6.启动后逻辑

执行 CommandLineRunner和 ApplicationRunner​​(两者区别:CommandLineRunner接收原始命令行参数,ApplicationRunner封装为 ApplicationArguments)

7.启动内嵌容器

spring系列面试题

  1. spring缺点
  2. Spring Boot 的主要优点
  3. 什么是 Spring Boot Starters?
  4. 如何在 Spring Boot 应⽤程序中使⽤ Jetty ⽽不是 Tomcat?
  5. 介绍⼀下@SpringBootApplication 注解
  6. Spring Boot 的⾃动配置是如何实现的?
  7. 什么是 YAML?YAML 配置的优势在哪⾥ ?
  8. Spring Boot 常⽤的读取配置⽂件的⽅法有哪些?
  9. Spring Boot 加载配置⽂件的优先级了解么?
  10. 常⽤的 Bean 映射⼯具有哪些?
  11. 如何使⽤ Spring Boot 实现全局异常处理?
  12. Spring Boot 中如何实现定时任务 ?

IO

1.字节流-字符流-缓冲流

一般从文件系统或者网络系统读取数据,转换为相应的流,然后字符流负责解码,缓冲流负责优化。形象的比喻:字节流相当于水管,字符流相当于水龙头,缓冲流相当于水池。

    // 标准文本文件读取模板
    // 1. 创建字节流 (水源)
    FileInputStream fis = new FileInputStream("input.txt");

    // 2. 创建转换流,指定编码,连接字节流 (翻译器,连接水源)
    //   重要:务必指定正确的编码,通常为 UTF-8
    InputStreamReader isr = new InputStreamReader(fis, StandardCharsets.UTF_8);

    // 3. 创建缓冲流,包装转换流 (蓄水池,连接翻译器)
    BufferedReader br = new BufferedReader(isr);

    // 现在,我们可以高效、方便地从缓冲流中读取内容了
    String line;
    while ((line = br.readLine()) != null) {
        System.out.println(line);
    }

    // 关闭最外层的流即可,它会自动关闭内部包装的流
    br.close();

2.BIO(同步阻塞)

传统的Java IO就是BIO,阻塞式IO。其基本流程是:客户端请求和服务端建立连接,服务端接受客户端的连接,这里会阻塞,然后服务端读物socket的数据和向socket写数据,都是阻塞的。下面的代码时服务端的基本代码:

    private static void run() {
        try (ServerSocket serverSocket = new ServerSocket(PORT)) {
            System.out.println("服务器启动,监听端口: " + PORT);
            while (isRunning) {
                // 接受客户端连接(阻塞操作)
                try (Socket socket = serverSocket.accept()) {

                    try (BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
                         PrintWriter writer = new PrintWriter(
                                 new OutputStreamWriter(socket.getOutputStream()), true)) {
                        String msg;
                        while ((msg = reader.readLine()) != null) {
                            System.out.println("收到消息: " + msg);
                            writer.println("服务器已收到: " + msg);
                        }
                    }

                }
            }
        } 
        System.out.println("服务器已停止");
    }

上面的代码客户端和服务端的连接是串行的,比如client1连接服务端,然后处理数据返回,整个流程结束,此时其余的客户端才可以连接服务端。高并发的情况下,这个处理效率很低,此时我们为每一个新连接创建一个新的线程去处理。

    private static void run() {
        try (ServerSocket serverSocket = new ServerSocket(PORT)) {

            while (isRunning) {
                // 接受客户端连接(阻塞操作)
                try (Socket socket = serverSocket.accept()) {
                    new Thread(()->{
                        try (BufferedReader reader = new BufferedReader(new InputStreamReader(socket.getInputStream()));
                             PrintWriter writer = new PrintWriter(
                                     new OutputStreamWriter(socket.getOutputStream()), true)) {

                            String msg;
                            while ((msg = reader.readLine()) != null) {
                                System.out.println("收到消息: " + msg);
                                // 向客户端发送响应
                                writer.println("服务器已收到: " + msg);
                            }
                        }
                    }).start();
                } 
            }
        } 

    }

伪异步

上面的代码虽然读写处理任务使用了多线程,但是每个连接需要一个新的线程,如果高并发,那么创建的线程非常多,消耗的资源会很恐怖。当然上面的代码也可以使用线程池优化,但问题依然存在,线程池虽然不会大量创建线程,但任务队列的数据还是一条一条处理,而且队列存在丢弃任务等情况。这也就是BIO中的伪异步

    private static void run() {
        try (ServerSocket serverSocket = new ServerSocket(PORT)) {

            while (isRunning) {
                // 接受客户端连接(阻塞操作)
                try (Socket socket = serverSocket.accept()) {
                      // 将每个客户端连接的处理任务提交给线程池
                        pool.execute(new ClientHandler(clientSocket));
                } 
            }
        } 

    }

NIO(同步非阻塞)

1.Buffer缓冲区

Buffer是用来承载数据的,本质上也是一个数组,在读取数据的时候,先将数据放入Buffer,然后从Buffer读取,写入同理。
那么Buffer的读取和Stream +read[byte]读取有什么区别呢?

  1. Buffer是双向的,既可以读,也可以写;而Stream读取和写入需要不同的输入输出流。
  2. Buffer是结构化的数据结构,可以读取任意位置的数据,可以重置,可以清除,可以反转;而基于数组的读取则没有这些功能,它只能一个一个字节的读取。
  3. Buffer支持直接内存,而数组本质上是Stream包装类的一部分。
  4. Buffer是一块内存区域,Stream+数组是包装类的一部分
  5. 从磁盘到客户端数据传输,Buffer可以复制一次,Stream+数组是2次,先到操作系统,然后到JVM。

2.Channel通道

Channel是一个通道,用来传输数据的,他就像铁路,既可以来,也可以回。Stream就像水管,只能从一端到另外一端。

3.Selector多路复用器

Selector是多路复用器,用单线程来轮询所有的通道事件(连接、读取、写入),然后Channel注册到Selector上,Channel来处理具体的读写事件,由于Channel是非阻塞的,因此可以处理大量的请求。

  • 核心:Selector 负责 “监听事件就绪”,Channel 是 “非阻塞的 IO 通道”,Buffer 是 “数据载体”,三者协同实现单线程高效处理多连接。

  • 总结:NIO的基本流程是通过多路复用器Selector来轮询监听通道事件,分别监听连接,读写等事件,而通道Channel用来处理具体的事件,Channel是非阻塞的(线程请求完毕之后立马返回执行其他逻辑),因此可以处理大量请求,而Selector此时轮询事件,处理就绪的IO,返回给客户端。

img

4.同步、异步、阻塞、非阻塞

  1. 同步指的是线程必须主动处理IO的结果
  2. 异步指的是操作系统主动完成所有的IO过程,完成后通知线程回调
  3. 阻塞是线程等待时暂时执行其他操作
  4. 非阻塞是线程等待时可以执行其他操作

AIO(异步非阻塞)

应用程序发起 IO 操作后无需阻塞等待,可立即返回执行其他任务;当 IO 操作由操作系统在后台完成后,操作系统会主动通过回调函数通知应用程序,由应用程序处理结果

    private static void run() {
        try {
            //创建一个连接通道
            AsynchronousServerSocketChannel socketChannel=AsynchronousServerSocketChannel.open();
            socketChannel.bind(new InetSocketAddress("localhost",9200));
            socketChannel.accept(null, new CompletionHandler<>() {
                @Override
                public void completed(AsynchronousSocketChannel result, Object attachment) {
                    //完成后的方法回调
                }

                @Override
                public void failed(Throwable exc, Object attachment) {
                    //失败后的方法回调
                }
            });
        } catch (IOException e) {
            throw new RuntimeException(e);
        }

        System.out.println("服务器已停止");
    }

select、poll、epoll区别

1.select

  1. 服务端创建socket连接,绑定端口和ip以及创建listen之后,得到fd文件描述符,并加入自己设置的fd_set集合里面,然后调用select进入阻塞状态,并且将该集合从用户空间复制到内核缓冲区,当有客户端连接时,内核会将该连接放入listen_fd队列,并标记为就绪状态,然后select返回,服务端此时轮询集合,发现就绪,调用accept完成连接,得到新的client_id,同样服务端将client_id加入集合,如读写就绪,内核再次返回给服务端完成读取。

  2. 缺点:fd_size有数量上线,无法支持大量连接;而且select需要在内核和用户空间不断轮询集合,检查就绪状态,如果数量大的时候,遍历消耗时间;每次调用select,需要将用户空间数据复制到内核空间,成本较高。

  3. fd集合使用bitmap实现。

2.poll

poll实际上工作原理和select完全一样,区别在于底层使用的链表存储文件描述符,因此大小没有限制,但是随着fd数量的增大,性能也急剧下降,同样采取轮询机制。

3.epoll

  • 效率最高的IO多路复用模型。
  1. 服务端创建监听socket,然后创建epoll实例,并且调用epoll_ctl函数监听socket
  2. 此过程内核会将lesten_fd加入到红黑树中,并且为其注册一个回调函数
  3. 客户端发起请求,然后内核将新的连接放入accept队列,当队列非空的时候,内核认为listen_fd的可读事件就绪了
  4. 之前注册的回调函数被内核调用,将对应的fd加入就绪链表
  5. 之前阻塞在epoll_wait的线程被唤醒,然后返回数据,关键在于这些数据都是发生变化了的fd
  6. 接下来的事情又服务器处理,那个事件就绪处理那个事件

MySQL数据库

1.事务的四个特性(ACID)

原子性:一次事务操作,要么成功,要么失败回滚,没有其他状态
一致性:一次事务操作,只能从一个一致性到另外一个一致性状态,符合业务规则
隔立性:并发事务之间互相隔离,避免数据紊乱
持久性:事务一旦提交,对数据的修改是永久的,即使系统出现奔溃也不丢失

2.事务的隔离级别

读未提交:产生脏读、不可重复读、幻读
读已提交:产生不可重复读、幻读
可重复读:产生幻读(MySQL通过MVCC和间隙锁可以避免大多数)
串行化:最高的隔离级别

  • 脏读:事务A读取了事务B未提交的事务,产生了脏数据。
  • 不可重复读:事务A进行了多次读取,但是由于其他事务进行了修改,导致读取结果不一致。
  • 幻读:事务A进行了多次读取,但是由于其他事务新增或者删除,导致返回的结果不一致。

3.MVCC

MVCC全程是多版本并发控制,其主要目的在于并发环境下,多个事务之间在不互相阻塞的情况下访问数据库。传统的锁机制可能在读操作期间阻塞写,反之亦然。而MVCC通过为每一个事务提供数据快照,提高并发场景下的吞吐率。

  • 基本原理是通过InnoDB的行隐藏字段、Undo日志版本链、ReadView一起实现。

1.行隐藏字段

InnoDB会为每一行添加3个隐藏字段,分别是:DB_TRX_ID、DB_ROLL_PTR、DB_ROW_ID

  • DB_TRX_ID:每个事务在启动时,会被分配一个唯一的、递增的事务ID
  • DB_ROLL_PTR:指向该数据行在Undo日志中的上一个版本
  • DB_ROW_ID:隐藏的行ID(当无主键时自动生成)

MVCC主要使用的是:DB_TRX_ID、DB_ROLL_PTR

img

2.Undo日志版本链

Undo log可以进行数据回滚(因为存在历史版本,参考上图),可以多版本并发控制(因为存在历史版本,因此可以读取到和Read View相符合的历史数据,从而不影响其他事务),奔溃恢复,redo log可以恢复持久化的数据,undo log可以恢复未完成或者已回滚更改的数据,数据清理(Purge)。

3.Read View

1.Read View构成

m_ids:活跃事务ID列表
min_trx_id:最小事务id,它代表了 Read View 生成时,所有活跃事务中最早的那个事务 ID
max_trx_id:下一个事务id,实际上它是目前活跃事务最大的id+1
creator_trx_id:创建事务的id

2.Read View的生成时机

读已提交:每次select生成新的read view
可重复读:只在事务首次执行读取操作时生成一次,并在整个事务生命周期内保持不变

3.Read View 的可见性判断规则

假设当前行的DB_TRX_ID为row_trx_id,其判断规则如下:

  1. row_trx_id < min_trx_id,该数据行是在 Read View 生成之前就已经提交的事务创建或修改的,可见
  2. row_trx_id >= max_trx_id,该数据行是由在 Read View 生成之后才启动的事务创建或修改的,不可见
  3. min_trx_id <= row_trx_id < max_trx_id
    • row_trx_id == creator_trx_id 可见(自己总能看到自己的修改)
    • row_trx_id 在 m_ids 列表中,这意味着该数据行是由在 Read View 生成时仍活跃但不是当前事务的事务修改的,不可见
    • row_trx_id 不在 m_ids 列表中,该数据行是由在 Read View 生成时已经提交的事务修改的,可见

3.索引

1.B树和B+树的区别

B Tree的特点是:所有节点都存储键值(主键)和数据,并且存储指向子节点的指针,叶子节点之间没有指针连接,节点存储数据,空间利用率不高,树的高度较高,随机查询可能在任何层级找到数据,而范围查询需要多次遍历B Tree。

B+Tree的特点是:非叶子节点只存储键值和指向子节点的指针,叶子节点存储数据,叶子节点之间通过指针形成有序的链表,节点不存储数据,空间利用率高,树的高度较低,所有的查询都在叶子节点中完成,范围查询效率极高,通过叶子节点链表顺序访问。

因此,从二者的结构分析,B Tree适合内存受限环境(数据分布在各层节点,占用较大空间);B+ Tree则适合范围查询,并且磁盘IO敏感的系统。

imgimg

2.3层高度的B+Tree可以存储多少数据

  • 计算非叶子节点存储的键值:
    假设主键使用bigint类型为8字节,指针6字节,那么总结8+6=14字节,而每页默认16KB,为16 * 1024=16384字节,页末尾的额外指针6字节,所以每页的实际容量为16384-6=16378.所以计算每页存储的键值为:16378/14=1169,经过Innodb优化,我们默认为1200,意味着每个节点有1200个子节点。所以层高为3的树,叶子节点的数量为1200*1200=1,440,000

  • 计算每页存储的数量
    叶子节点假设单行的数据为1KB,所以计算存储的行数16378/1kb=15行

因此总的行数=1,440,000*15=21,600,000 大约2000w行

img

3.Innodb是如何支持范围查询走索引的

B+Tree叶子节点是有序的,并且使用双向链表连接,而且B+Tree是多路平衡,也就是从叶子节点到根节点路径长度是相同的,因此基于这些特性,使得范围查询得以实现(定位起点+顺序扫描)

4.范围查询索引失效的原理分析

什么情况下范围查询索引会失效呢?

  1. 违反最左匹配原则:
select * from table where row1=a and row2=b and row3=c

针对联合索引[a,b,c],此时如果查询条件包含a,b,c且按照顺序,那么是走索引,如果是b,c按照顺序,此时不走索引,如果是a,c且按照顺序,a查询的部分走索引,c不走索引,只作为过滤条件,这个叫做索引下推。
针对b,c按照顺序,且返回只b,c.此时由于需要的数据b,c在整个索引[a,b,c]的范围内,因此会找到联合索引,然后扫描整个索引,也就是覆盖索引。

select b,c from table where row2=b and row3=c
  1. 在使用索引的列上使用了函数
  2. 不恰当使用查询语句,比如like模糊查询的%,导致无法确定查询的范围
  3. 优化器的成本决策(选择性太低,回表成本高)。
    比如
select * from table where status>0 

(假设status只有0和1,且1占绝大多数),status为二级索引,此时发现使用索引,但是需要回表,代价太高,不如全表扫描。

总结:1和2是破坏了索引的有序性,3是不恰当使用SQL,4则是优化器决策成本

5.为什么违反了最左前缀原则,索引会失效呢

索引就像通讯录一样,如果姓相同,那么按照名字排序,名字相同按照年龄排序。如果你直接查询名字或者年龄,是查询不到的。

Redis

1. redis线程模型

  1. redis 6.0之前的模型,使用单线程完成所有的操作,包括建立连接,解析命令,执行命令。
  2. redis 6.0之后的模型,主线程来完成连接,执行命令,而IO线程处理网络读写,解析命令,解析好的命令放入队列,主线程去执行
    img

redis之所以使用多线程的原因在于,使用多线程处理网络读写,其次则是适应现代多核CPU,刚好的利用CPU的性能。

2.redis的数据结构

1.string

1.hash

3.list

4.set

  1. 取交集 sinter 集合1 集合2

5.zset

  • Redis 使用跳表 + 哈希表的组合来实现有序集合,跳表用于支持范围操作(如 ZRANGE),哈希表用于支持按成员快速查找分值(ZSCORE)
  1. 跳表
    img

缓存相关

1.缓存穿透

所谓缓存穿透是查询数据库不存在的元素,比如id=-1,此时缓存中就没有该数据,所有请求都访问数据库,数据库面临奔溃的问题。
解决方案:

  1. 接口对数据做校验,比如id<0直接返回,但是如果id没有规律,比如不是自增或者单纯的是uuid,此时接口校验不好做。
  2. 缓存空对象,比如查询id=-1的数据,数据库不存在该数据,但我们此时放入缓存,key=-1,value="",并设置一个短的过期时间。这样也可以,但是这样做缓存就有大量的无用的存储。
  3. 使用布隆过滤器,查询缓存之前,先判断id=-1的key在不在过滤器当中,不在直接返回,在了之后再查询缓存。缺点是存在误判

布隆过滤器

布隆过滤器使用了二进制数组和多个hash函数,当存储key的时候,多个hash函数计算出多个hash值,然后对数据长度取模,计算下标,然后将该位置的数据标记为1,原理大致如此。使用了hash函数,那么就会存在冲突,比如key1计算在index=9的位置,key2同样在此位置,但是key2覆盖了key1,此时查询key1的数据,过滤器返回false,表示没有,去查询数据库。

2.缓存击穿

患村击穿则是某个热点key或者某几个key在某个时刻,key过期之后,所有的请求都打在数据库上。
解决方案:

  1. 让key永不过期,但是为了保证数据库和缓存的一致性,使用一个线程去更新缓存的数据。缺点是数据一致性可能存在问题,后台线程同步数据可能会消耗大量资源。
  2. 如果是非分布式任务,直接加锁即可。加锁成功,缓存没有则去数据库获取数据,然后放入缓存,其他线程进来直接从缓存获取
  3. 逻辑过期 + 异步刷新 。也是不设置过期时间,但是在value设置key的过期时间点,请求进来查询到数据,和当前时间比较,如果当前日期大于缓存日期,则获取分布式锁,然后将数据库的数据更新到缓存,其他线程则从缓存获取。未过期直接返回
  4. 直接使用分布式锁

4.缓存雪崩

解决方案:错开过期时间,高可用的redis集群(防止缓存系统失效),服务降级,热点数据提前预热(防止系统刚启动缓存无热点数据)

5. AOF和RDB的区别

  1. rdb是存储redis某个时刻的数据快照,以二进制的方式存储,如果需要恢复数据或者迁移数据,它足够方便,体积小,并且在内存中执行。但是存在数据丢失问题,会丢失上一个快照到服务停止期间的数据,如果这个时间段很大,会丢失较多的数据。
  2. aof则记录了redis执行的所有命令,因此恢复数据的时候是重新执行这些命令。缺点是数据文件较大,它是基于redis协议的文本,AOF会随着命令的增多,文件也会变得越来越大,因此每隔一段时间,需要重写aof,重写是生成之前的数据所需要的最小命令合集,重写期间会消耗大量资源,刷盘策略可以做到每1s。
  3. redis 7.0之后可以使用混合持久化,rdb和aof一起持久,原理是生成rdb的快照,然后之后的增量数据写入aof,之后每个一段时间,还是在新的快照的基础上,aof写新的增量数据

6.redis删除键的策略

如果某个key过期了,redis会不会立即删除呢?答案是:不会。
redis使用惰性删除和定期删除的策略。所谓惰性删除指的是当你访问某个过期的key时,redis查询已过期,然后redis会立即删除,并返回空。这样做对CPU较为友好,因为只有在访问的时候才会消耗CPU资源去删除数据。

定期删除是redis会在定期清理哪里设置了过期时间的key,在这些key中随机选择一批数据来删除。当然如果这批key中过期的占据大于25%,会再随机挑选一批,让这个比例降低去删除。缺点因为是随机的,所以无法完全删除过期的key

redis淘汰机制

  1. noeviction 不删除,使用惰性删除和定期删除 redis默认
  2. volatile-ttl 删除最快要过期的键(设置过期时间的键)
  3. volatile-lru 删除最近不经常使用的键(设置过期时间的键)
  4. vloatile-lfu 删除最少使用的键(设置过期时间的键)
  5. volatile-random 随机删除(设置过期时间的键)
  6. allkeys-lru 删除最近不经常使用的键
  7. allkeys-lfu 删除最少使用的键
  8. allkeys-random 随机删除

7.redis高可用方案演化

1.单机模式

单机模式存在单点故障

2.主从模式

所谓主从模式,就是一个主节点,多个从节点;主节点用来写入数据,从节点来读取数据,做到读写分离。他们之间如何通信的呢?当从节点启动的时候,向主节点发送sync命令,主机点开启bgsave,生成rdb文件,然后将此快照数据发送给从节点,在此期间的命令写入aof缓存区,当从节点完成数据同步之后,再将该增量数据发送给从节点。
主从模式的缺点是,无法故障自动转移,当主节点挂了之后,那么需要运维人员手动设置主节点,并且客户端从新连接主节点,整个期间服务不可用。

3.哨兵模式

img
哨兵模式解决了主从模式的自动故障转移缺陷,当主节点出现故障的时候,哨兵之间会发起选举,决定哪一个从节点为新的主节点。
哨兵不存储数据,哨兵需要组成一个集群,通过订阅主节点的频道,哨兵之间互相确认对方的存在,并且哨兵之间存在TCP通信,完成主节点的选举等。主节点的下线分为主管下线和客观下线,当一个哨兵和主节点通信出了问题之后,该哨兵决定主观下线,然后哨兵之间互相通信,决定是否客观下线。

4.集群

使用多个redis服务组成一个集群,使用16384个插槽存储redis数据,数据以分片的方式存储在这些插槽上。扩容会重新分片,键也会迁移,期间可能阻塞,也可能不会。

8.一致性hash算法

使用hash函数计算计算key的hash值,然后对服务器总数量取模,计算key落在那一台服务器,这种算法一般情况下没什么问题,但是遇到集群数量发生变化的时候,就会出现缓存雪崩。比如扩容的时候,节点数量变大,此时缓存根据新的取模计算,发现数据在新的节点,但是扩容还没完成,因此获取不到数据。

为了解决这个问题,可以使用一致性hash算法,其基本原理是构建一个hash环,从0到2^32次幂。服务器节点使用hash计算出对应的位置,key同样也是如此做。从key的位置顺时针开始,遇到的第一个服务器节点就是存储该key的服务器。此时如果出现扩容,那么改变的数据只有新节点和其逆时针的上一个节点之间的数据,因此变动的范围有限。

但是还存在一些问题,服务器节点数量少,那么hash环的数据会分布不均匀,出现倾斜。另外一个问题是对应性能强悍的服务器,性能得不到更好的利用。因此出现了虚拟节点,对服务器的节点使用多个hash函数计算,得到多个位置,强悍的服务器可以多计算几个节点。这样即可解决这些问题

redis cluster没有使用hash一致性算法,而是直接使用插槽的方式。16384个插槽。每个节点负责一部分槽,和hash一致性算法相比,实现简单,易于管理。

9.面试问题

  1. Redis Cluster 中的各个节点是如何实现数据⼀致性的?
  2. Redis Cluster 虚拟槽分区有什么优点?
  3. Redis 缓存的数据量太⼤怎么办?
  4. Sentinel(哨兵) 有什么作⽤?
  5. 如何保证 Redis 服务⾼可⽤?
  6. 如何保证缓存和数据库数据的⼀致性?
  7. 什么是 Redis 内存碎⽚?为什么会有 Redis 内存碎⽚?
  8. 如何避免⼤量 key 集中过期?
  9. 什么是 bigkey?有什么危害?如何发现 bigkey?
  10. 如何使⽤ Redis 事务?
  11. Redis 事务⽀持原⼦性吗?
  12. Redis 事务还有什么缺陷?如何解决 Redis 事务的缺陷?
  13. Redis 是如何判断数据是否过期的呢?
  14. 使⽤ Redis 实现⼀个排⾏榜怎么做?
  15. 使⽤ Redis 统计⽹站 UV 怎么做?
  16. 分布式缓存常⻅的技术选型⽅案有哪些?
  17. Redis可以做消息队列吗?

Kafka

RabbitmQ

1.消息模式

1.简单模式(点对点模式)

顾名思义,一个生产者,一个队列,一个消费者,倒不是说只能一个队列可以消费队列的消息,而是一条消息只能被一个消费者消费。路由模式和队列绑定。如果存在多个消费者,消息是采用轮询机制为消费者分配队列的消息。

2.工作队列模式

一个生产者,一个队列,多个消费者。看起来和简单模式差不多,实际上却是也差不多,因为工作队列每个消费者每次也只能有一个消息被消费。二者的根本区别是:简单模式设计的目标是简单的消息和快速投放消息,一般也无需持久化,采用轮询机制为消费者分配消息;工作队列则主要目标是为需要可靠的任务而设计,公平分发是为主要目的,而且需要考虑消费者负载能力和消息的持久化。

假设有消息【1-2-3-4-5-6】,有两个消费者A、B,假设A消费能力好,B消费缓存
简单模式: A消费 1-3-5; B消费 2-4-6 (可能出现消息积压)
工作队列模式: A消费 1-3-5-6; B消费 2-4

工作队列模式必须设置 channel.basicQos(1):每个消费者一次只预取一条消息。RabbitMQ 提供 ​预取计数(Prefetch Count)​​ 机制来优化公平性,这样让处理的块的先处理,默认则是轮询机制。

3.发布订阅机制(广播)fanout

送消息到绑定到该交换机的所有的队列。没有routing key

4.路由模式 direct

routing key 和binding key完全匹配

5.Topic模式 topic

routing key 和binding key模糊匹配

6.Headers模式

通过消息头的键值匹配

3.四种交换机模式

Fanout(广播)、Direct(路由)、Topic(主题)、Headers(头键值匹配)

4.死信队列

死信队列其实也是一种普通的队列,只不过定义的原因是这些消息无法被消费者消费。

  1. 消息被拒绝且未重新入队​​
  2. 消息过期
  3. ​​队列已满​​:队列达到了其设定的最大长度,无法再容纳新的消息,最早入队的消息会被丢弃成为死信。

RocketMQ

分布式锁

分布式事务

1.CAP理论

CAP指的是一致性、可用性、网络分区容错。其中的一致性指的是在整个网络结点中,用户访问每一个节点得到的数据是相同的;可用性指的是用户请求每一个结点的可以得到响应;网络分区容错指的是当系统出现网络故障的时候,节点之间会分割成每个独立的组,组之间无法通信,此时也可以正常运行。

现实环境中,网路故障几乎无法避免,因此在三者选择的时候,P几乎是一定要考虑的。三者无法同时具备,如果具备了一致性和分区容错,那么由于一致性会进行节点之间数据同步,因此需要牺牲可用性。
如果同时具备可用性和网络分区容错,那么数据的一致性就得不到保障。而CA是最理想化的场景。

强一致性方案

1.2PC

  • 第一阶段
    事务发起者向事务参与者发起一次prepare命令,事务参与者执行本地事务,但不提交,锁定记录,准备好后,向事务协调者发起ack通知。
  • 第二阶段
    第一阶段事务协调者接收到通知均为成功之后,发送commit指令,事务参与者各自提交本地的事务,释放资源锁,然后各自通知事务协调者,都成功之后,事务完成,否则,事务失败,协调者向参与者发送回滚命令,事务完成回滚。

举个简单的例子,所谓2PC就像结婚现场,主持人问新郎新娘,你愿意娶/嫁对方为妻吗,两人都回答yes,进入第二阶段,因为2人都回答yes,主持人回答:宣布你们成为夫妻。

2.3PC

两阶段提交的时候,当事务参与者在prepraed期间,此时会一直处于阻塞阶段,直到收到事务协调者发出的commit或者callback通知;如果事务协调者宕机,那么所有参与者都会卡住。

三阶段提交就是要解决上述问题。

  • 第一阶段
    事务发起者向事务参与者发起cancommit,事务参与者接收到指令之后,检查自身是否可以执行事务,如果可以执行,则向事务协调者发起ack通知。

  • 第二阶段
    事务发起者接收到ack通知之后,向事务参与者发起precommit指令,这个阶段和2PC的第一阶段相似,参与者还是锁定资源,执行事务,但不提交。并且向协调者发起ack通知。

  • 第三阶段
    事务发起者向参与者发起docommit通知,提交或者回滚事务。

至此,完成整个的事务。假设此时第三阶段宕机,那么事务参与者也不会一直等下去,因为有超时机制,超时之后,假设之前都是precommit,那么会自动commit。这样也随着带来了一些问题,假设最后不是commit,而是acort,那么事务的一致性存在问题。因此三阶段牺牲了C,两阶段强调了C。

最终一致性方案

最终一致性是高并发环境下的主流选择,核心思想是通过异步和补偿实现最终的一致性。

1.Seata.AT

AT方案的原理是一阶段提交+自动回滚补偿。

  • 第一阶段
    事务发起者TM,向事务参与者RM发起事务操作,此时操作数据的时候,seata的代理数据源会拦截这个SQL,然后解析SQL,根据解析的条件,查询原始数据,形成日志回滚的前半部分,然后执行正常的SQL,最后再查询新的数据,形成日志回滚的后半部分。最后,将before image和after image以及SQL相关的业务信息组成一个undo log日志,插入到undo_log表。最后提交本地事务,释放资源。并且向seata server注册分支事务,并报告状态。

  • 第二阶段
    TM根据所有的RM执行的情况,通知seata server裁决。
    TC异步的向所有的RM发起删除undo log日志的命令,rm删除本地的undo log,此时流程结束。如果需要回滚,Seata server发起回滚通知,RM根据xid和branciId去本地的undo log表找对应的数据进行回滚,回滚完成,删除undo log。

2.Seata.TCC

TCC是三个阶段,try(资源预留)、confirm(确认阶段)、cancel(取消)。

  1. 首先是TM(事务协调者)向TC(seata server)发起try请求,然后再对应的RM中完成资源预留,此阶段失败了,seata 会通过AOP拦截异常,触发资源回滚,解除预留资源限制。
  2. 如果成功,进入confirm阶段,此阶段再业务协调者中没有体现,而是当业务协调者调用try都成功之后,触发全局事务,TC会直接下达commit过程,对应的分支事务完成提交。同理,失败之后,全局回滚。

现在来看看这个过程都会有什么问题:

  1. (幂等性)如果confirm阶段,TC向RM发送confirm请求,因为网络或者卡顿等原因,迟迟得不到响应,此时超时,发起重试,也就是说可能存在多次confirm,因此需要commit方法幂等。可以将每次执行的xid记录下来,然后去判断,是否存在当前的xid,如果存在,直接返回。
  2. (空回滚)假设业务发起者准备调用prepare方法,但是此时服务中断,TC检测到TM的异常,然后发起回滚,但是此时由于没有执行try阶段的预扣减工作,因此cancel阶段就会回滚失败,此时TC一直重试。此时我们的做法是从TC获取扣减的资源,如果为空,直接返回成功即可,允许回滚。
  3. (防悬挂)所谓防悬挂,主要原因是网络延迟引起的。假设事务发起者发起try阶段扣减资源,此时网络延迟,还没有到参与者,超时之后,TC发起回滚,之后try阶段的preparecommit到达,就会扣减资源,但是此时已经回滚成功了,又去扣减资源,此时也就没有人调用cancel了,资源一直被占用。解决方式还是记录回滚事务的xid,如果回滚,那么try阶段过来,先判断有没有回滚,回滚则try阶段的方法不执行。

参考代码实践:

    List<String> cancelIds=new ArrayList<>();
    List<String> commitIds=new ArrayList<>();

    // Try 阶段:先检查是否已被取消
    public boolean prepareDeduct(BusinessActionContext context, String code, int count) {
        String xid = context.getXid();

        // 🔴 关键:如果已被 Cancel,拒绝执行 Try
        if (cancelIds.contains(xid)) {
            log.warn("事务已被取消,拒绝执行 Try,XID: {}", xid);
            return false; // 或抛异常
        }

        // 正常冻结
        storageService.free(count);
        return true;
    }

    // Commit 阶段:幂等
    public boolean commit(BusinessActionContext context) {
        String xid = context.getXid(); // 全局事务ID

        // 查询是否已经提交过
        if (commitIds.contains(xid)) {
            log.info("事务已提交,忽略重复调用,XID: {}", xid);
            return true; // 直接返回,不重复扣
        }

        // 执行扣减
        storageService.reduceStock(10);

        // 记录已提交
        tccLogService.markAsCommitted(xid);

        return true;
    }

    // Cancel 阶段:先标记事务已取消
    public boolean rollback(BusinessActionContext context) {

        // 不抛异常,直接释放(如果没有冻结,也不报错)
        Integer count = (Integer) context.getActionContext("count");
        if (count == null) {
            //空回滚,直接返回true
        }
        
        String xid = context.getXid();
        cancelIds.add(xid); // 标记为已取消
        storageService.unfree();
        return true;
    }

事务消息

posted @ 2025-03-04 10:41  大佛拈花-GoSaint  阅读(18)  评论(1)    收藏  举报