25年最新Java高级开发面试题

131 阅读28分钟

前言

同学们好,这里是佩恩的博客,最近抽空面试了一下Java高级开发的岗位,总体来说面试题比前几年要深入很多,奉劝各位能苟则苟,但一定不要忘记学习技术,以免突然的大礼包让你手足无措,下面就直接进入正题吧。

面试题汇总

1.线程池参数及作用

2.任务产生是50/s,持续不断产生,处理时间为30s,如何设定线程池参数

3.JVM参数在Docker部署和主机部署有什么区别

4.Dubbo服务如何实现双注册中心注册

5.MySQL性能优化

6.Nacos在线更新配置实现流程

7.JVM如何调优

8.如果一直产生FullGC,如何定位并处理问题

9.Spring如何解决循环依赖

10.Redis数据类型有哪些?Hash类型如何实现的?

11.Spring事务传播机制详解

12.雪花算法产生的ID会重复吗?

1.线程池属性

在IDEA搜索ThreadPoolExecutor,Alt+7 或者 Ctrl+F12,查看方法及属性,可以看到属性相当的多

以下是线程池核心参数

ThreadPoolExecutor(
    int corePoolSize,
    int maximumPoolSize,
    long keepAliveTime,
    TimeUnit unit,
    BlockingQueue<Runnable> workQueue,
    ThreadFactory threadFactory,
    RejectedExecutionHandler handler
)

corePoolSize:核心线程数

  • 核心线程在空闲状态下,也不会被销毁
  • 当线程池线程数小于corePoolSize时会创建

maximumPoolSize:最大线程数

  • 当线程池中的线程数达到 maximumPoolSize 时,线程池不再创建新线程,任务会被放入任务队列等待执行。
  • 如果任务队列已满且线程数达到 maximumPoolSize,新任务会被拒绝,并由 RejectedExecutionHandler 处理。

keepAliveTime:存活时间

  • 非核心线程的空闲存活时间,比如设置60秒,非核心线程在空闲60秒后会被销毁,避免占用过多资源

unit:时间单位

  • 这是一个枚举类,包括纳秒、微秒、毫秒、秒、分、时、天
  • 建议TimeUnit.SECONDS

workQueue:工作队列或称为任务队列

  • 用于存储等待执行的任务

常见的任务队列实现

  • ArrayBlockingQueue:基于数组的有界阻塞队列,固定容量,先进先出
  • LinkedBlockingQueue:基于链表的可选容量阻塞队列,容量默认为Integer.MAX_VALUE
  • SynchronousQueue:不存储任务的阻塞队列,每个任务必须立即被线程处理
  • PriorityBlockingQueue:支持优先级排序的无界阻塞队列

选择合适的任务队列对线程池的性能和行为有重要影响

threadFactory:线程工厂

  • 用于创建线程
  • 可以自定义

handler:拒绝策略处理器

常见的拒绝策略

  • AbortPolicy:默认策略,直接抛出 RejectedExecutionException
  • CallerRunsPolicy:由调用线程执行任务,如主线程
  • DiscardPolicy:直接丢弃任务,不报异常
  • DiscardOldestPolicy:丢弃队列中最老的任务,然后尝试提交新任务

2.任务产生是50/s,持续不断产生,处理时间为30s,如何设定线程池参数

当任务产生速率为 50/s(每秒产生 50 个任务),且每个任务的处理时间为 30 秒时,线程池的配置需要确保任务能够被及时处理,同时避免资源过度使用。以下是详细的分析和建议:

1. 关键参数分析

(1)任务产生速率
  • 每秒产生 50 个任务。
  • 每小时产生 50×3600=180,000 个任务。
(2)任务处理时间
  • 每个任务的处理时间为 30 秒。
  • 每个线程每小时可以处理 3600/30=120 个任务。
(3)系统处理能力
  • 每小时需要处理的任务总数为 180,000 个。
  • 每小时每个线程可以处理 120 个任务。
  • 因此,需要的线程数为180000/120=1,500 个线程

2. 线程池参数配置

(1)corePoolSize
  • 建议值:500
  • 说明:核心线程数是线程池中始终存在的线程数量。设置一个合理的初始值,确保有足够的线程立即处理任务。500 个核心线程可以处理大部分任务,同时避免过多线程占用资源。
(2)maximumPoolSize
  • 建议值:1500
  • 说明:最大线程数是线程池中允许的最大线程数量。设置为 1500 个线程,以应对突发的任务高峰。
(3)keepAliveTime
  • 建议值:60 秒
  • 说明:非核心线程的空闲存活时间。设置为 60 秒,确保非核心线程在空闲 60 秒后被销毁,避免过多线程占用资源。
(4)unit
  • 建议值:TimeUnit.SECONDS
(5)workQueue
  • 建议值:LinkedBlockingQueue 或 ArrayBlockingQueue
  • 容量:建议设置为 10,000 或更高。
  • 说明:任务队列用于存储等待执行的任务。由于任务产生速率较高,任务队列需要有足够的容量来缓冲任务。
(6)threadFactory
  • 建议值:自定义线程工厂。
  • 说明:自定义线程工厂可以为线程设置合适的名称和优先级,便于调试和监控。
(7)handler
  • 建议值:CallerRunsPolicy
  • 说明:当任务队列已满且线程数达到 maximumPoolSize 时,使用 CallerRunsPolicy 由调用线程执行任务,避免任务被拒绝。

3. 示例代码

以下是一个配置线程池的示例代码:

import java.util.concurrent.*;

public class ThreadPoolExample {
    public static void main(String[] args) {
        // 创建线程池
        ThreadPoolExecutor executor = new ThreadPoolExecutor(
            500, // corePoolSize
            1500, // maximumPoolSize
            60, // keepAliveTime
            TimeUnit.SECONDS, // unit
            new LinkedBlockingQueue<>(10000), // workQueue
            new ThreadFactory() {
                @Override
                public Thread newThread(Runnable r) {
                    return new Thread(r, "CustomThread-" + Thread.activeCount());
                }
            }, // threadFactory
            new ThreadPoolExecutor.CallerRunsPolicy() // handler
        );

        // 提交任务
        for (int i = 0; i < 50; i++) {
            int taskNumber = i;
            executor.execute(() -> {
                System.out.println("Executing task " + taskNumber + " by " + Thread.currentThread().getName());
                try {
                    Thread.sleep(30000); // 模拟任务处理时间 30 秒
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            });
        }

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

4. 注意事项

(1)资源限制
  • 内存:确保服务器有足够的物理内存来支持大量的线程。每个线程默认占用 1MB 的堆栈空间,1500 个线程可能需要 1.5GB 的内存。
  • CPU:确保服务器有足够的 CPU 核心来支持高并发处理。如果 CPU 核心数不足,线程可能会频繁上下文切换,导致性能下降。
(2)任务队列容量
  • 队列大小:任务队列的容量需要足够大,以避免任务被拒绝。如果队列已满,任务会被拒绝执行,可以使用 CallerRunsPolicy 由调用线程执行任务。
(3)拒绝策略
  • 选择合适的拒绝策略:使用 CallerRunsPolicy 可以避免任务被拒绝,但可能会导致调用线程阻塞。如果任务产生速率过高,可以考虑使用其他拒绝策略(如 DiscardPolicy)。
(4)优化任务处理
  • 并行处理:如果可能,优化任务的处理逻辑,减少每个任务的处理时间。例如,通过并行处理或优化算法来提高效率。
  • 批量处理:将多个任务批量处理,减少任务提交的频率。
(5)监控和调整
  • 监控工具:使用监控工具(如 JMX、VisualVM)实时监控线程池的状态,动态调整参数。
  • 日志记录:记录任务处理的详细日志,便于分析和排查问题。

5. 总结

通过合理配置线程池参数,可以有效应对高任务产生速率和长任务处理时间的场景,确保系统能够高效运行。以下是一些关键点:

  • 核心线程数:设置合理的初始值,确保有足够的线程立即处理任务。
  • 最大线程数:设置为计算出的所需线程数,以应对突发的任务高峰。
  • 任务队列:设置足够大的任务队列,避免任务被拒绝。
  • 拒绝策略:选择合适的拒绝策略,确保系统的稳定性。
  • 资源监控:实时监控系统的资源使用情况,动态调整参数。

通过这些措施,可以确保线程池在高负载下稳定高效地运行

3.JVM参数在Docker部署和主机部署有什么区别

(1) 命令有所不同

如堆大小限制

  • 主机部署使用-Xms(初始堆大小)、-Xmx(最大堆大小) 来限制JVM访问主机的物理内存大小
  • 容器使用 --memory 限制容器内存使用

(2) 内存管理机制

  • 主机部署JVM 默认直接读取宿主机物理内存,可能导致容器内存超限触发 OOM
  • 容器部署使 JVM 识别容器内存限制,可以避免内存溢出。

(3) CPU资源限制

-XX:ParallelGCThreads(并行垃圾回收线程数)、-XX:ConcGCThreads(并发垃圾回收线程数)

  • 主机部署限制CPU资源使用,如:-XX:ParallelGCThreads=8 -XX:ConcGCThreads=4
  • 容器部署通过 --cpus 参数限制容器的 CPU 使用,如:docker run --cpus=4 my-java-app

(4) 参数传递

  • 主机部署通过启动脚本或命令行直接传递 JVM 参数
  • 容器部署通过环境变化量或Dockerfile、docker-compose.yml等文件

(5) 日志

  • 主机部署的日志路径一般由 logback.xml 文件或启动文件指定
  • 容器部署的日志存放于Docker文件夹内

4.Dubbo服务如何实现双注册中心注册

原文地址:blog.csdn.net/qq_43012298…

下面我们以 Zookeeper 和 Nacos 为案例进行Springboot项目做双注册中心注册

主要有以下几个步骤:引入依赖、编写配置文件、定义接口、启动服务

1.引入依赖,pom文件引入 Zookeeper 和 Nacos 以及Springboot等依赖

2.配置注册中心:在 application.yml 文件中配置多个注册中心

3.定义服务接口和实现:通过 @DubboService 注解指定注册中心

服务接口:

服务实现:

4.启动服务提供者和消费者:编写启动类,启动Springboot项目

5.MySQL性能优化

与MySQL性能相关的方面主要有:建表、sql、表分割、硬件

建表相关:表字段类型设置、引擎、索引、冗余列、派生列

sql:是否使用索引、执行语句调优

表分割:单表数据量过大,达到性能瓶颈,需要分表,读写分离,数据库集群等

硬件:增大mysql占用内存、增加更快的硬盘

详情请看我的掘金博客:juejin.cn/post/749396…

6.Nacos在线更新配置实现流程

主要实现步骤:引入依赖、填写配置、编写配置类

1.引入依赖:Nacos 和 Springboot

2.编写配置文件:在 application.yml 中填写

3.使用@ConfigurationProperties 或 @Value 注解来绑定配置值,并通过 @RefreshScope 来支持动态刷新

import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.stereotype.Component;

@RefreshScope
@Component
public class AppConfig {
    @Value("${app.name}")
    private String appName;

    @Value("${app.version}")
    private String appVersion;

    // Getters and Setters
}

4.可以自行编写测试类来测试是否能动态获取参数值,并通过 spring.cloud.nacos.config.refresh-time 来调整轮询频率

7.JVM如何调优

7.1 JVM 调优能达到什么样的效果?

提升性能、减少内存泄漏风险、合理设置堆内存和元数据区大小以避免 OOM,降低GC频率,改善用户体验

7.2 JVM 哪些区域可以调优?

堆内存、垃圾回收、新生代、老年代、线程等

(1) 设置堆内存大小

  • -Xms:初始堆大小
  • -Xmx:最大堆大小

建议:将 -Xms 和 -Xmx 设置成相同的值,避免堆大小动态扩展带来的性能开销,根据应用的内存需求和服务器内存大小,合理分配堆内存,对于内存密集型应用,可以设置堆内存大小为服务器内存的50%~70%

(2) 垃圾回收

调优领域:内存、锁竞争、cpu占用、io

最快的GC是不发生GC,当然这不可能发生,可以尽量减少,如果频繁发生 Full GC,考虑以下几个问题:

  • 数据是不是太多?
    • resultSet = statement.executeQuery(“select * from 大表 limit n”)
  • 数据表示是否太臃肿
    • 对象图
    • 对象大小 16 Integer 24 int 4
  • 是否存在内存泄漏
    • static Map map ...
    • 软引用
    • 弱引用
    • 第三方缓存实现

(3) 新生代调优

新生代的特点

  • 所有的 new 操作的内存分配非常廉价
    • TLAB thread-local allocation buffer
  • 死亡对象的回收代价是零
  • 大部分对象用过即死
  • Minor GC 的时间远远低于 Full GC

新生代理想大小:

  • 新生代能容纳所有【并发量*(请求-响应)】的数据
  • 幸存区大到能保留【当前活跃对象+需要晋升对象】
  • 晋升阈值配置得当,让长时间存活的对象尽快晋升
-XX:MaxTenuringThreshold=threshold
-XX:+PrintTenuringDistrubution

(4) 老年代调优

以CMS为例

  • CMS 的老年代内存越大越好
  • 先尝试不做调优,如果没有 Full GC 那么已经...,否则先尝试调优新生代
  • 观察发生 Full GC 时老年代内存占用,将老年代内存预设调大 1/4~1/3
    • -XX:CMSInitiatingOccupancyFraction=percent

8.生产环境一直产生FullGC,如何定位并处理问题

(1) 开启 GC 日志,在 JVM 启动参数中添加以下选项

-XX:+PrintGCDetails
-XX:+PrintGCTimeStamps
-XX:+PrintGCDateStamps
-Xloggc:/path/to/gc.log

这些参数会将 GC 的详细信息、时间戳记录到指定的日志文件中

(2) 分析 GC 日志

使用 GCViewer 或 GCEasy 分析 GC 日志,重点关注以下内容

  • Full GC 的频率:频繁的 Full GC 表明可能存在内存泄漏或内存不足
  • Full GC 的持续时间:长时间的 Full GC 表明可能需要优化垃圾回收器或调整堆内存大小
  • 堆内存使用情况:观察堆内存的使用趋势,是否存在持续增长的情况

(3) 检查堆内存配置

  • 堆内存大小
    • 检查 -Xms 和 -Xmx 参数,确保堆内存大小合理,如果堆内存过小,可能导致频繁的 Full GC
    • 如果堆内存过大,可能会导致 GC 时间过长
  • 新生代和老年代比例:
    • 检查 -Xmn、 -XX:NewRatio 和 -XX:SurvivorRatio 参数,确保新生代和老年代的大小合理
    • 如果新生代过小,可能导致对象频繁晋升到老年代,增加 Full GC 的频率

(4) 检查内存泄漏

  • 检查代码中的内存泄漏点(重点)
    • 资源未关闭的情况(如文件流、线程、数据库连接等)
    • 是否有静态集合(如List、Map)不断增长,导致内存泄漏
    • 第三方依赖漏洞(如Json无限转化,使用@JsonIgnore解决)
  • 使用内存分析工具
    • VisualVM 或 MAT(Memory Analyzer Tool)拍摄堆转储(Heap Dump),分析内存使用情况

(5) 监控应用性能

  • 实时监控工具:
    • JConsole 或 VisualVM 实时监控
    • Prometheus 和 Grafna 搭建监控系统,并设置告警规则

9.Spring如何解决循环依赖

(1) 什么是循环依赖?

循环依赖其实就是循环引用,也就是两个或者两个以上的bean互相持有对方,最终形成闭环。比如A依赖于B,B依赖于C,C又依赖于A。如下图:

注意,这里不是函数的循环调用,是对象的相互依赖关系。循环调用其实就是一个死循环,除非有终结条件。

Spring中循环依赖场景有:

(1)构造器的循环依赖

(2)field属性的循环依赖

其中,构造器的循环依赖问题无法解决,只能拋出BeanCurrentlyInCreationException异常,在解决属性循环依赖时,spring采用的是提前暴露对象的方法。

(2) Spring怎么解决循环依赖

Spring的循环依赖的理论依据基于Java的引用传递,当获得对象的引用时,对象的属性是可以延后设置的。(但是构造器必须是在获取引用之前)

Spring的单例对象的初始化主要分为三步:

(1)createBeanInstance:实例化,其实也就是调用对象的构造方法实例化对象

(2)populateBean:填充属性,这一步主要是多bean的依赖属性进行填充

(3)initializeBean:调用spring xml中的init 方法。

从上面单例bean的初始化可以知道:循环依赖主要发生在第一、二步,也就是构造器循环依赖和field循环依赖。那么我们要解决循环引用也应该从初始化过程着手,对于单例来说,在Spring容器整个生命周期内,有且只有一个对象,所以很容易想到这个对象应该存在Cache中,Spring为了解决单例的循环依赖问题,使用了三级缓存。

这三级缓存分别指:

  • singletonFactories : 单例对象工厂的cache
  • earlySingletonObjects :提前暴光的单例对象的Cache
  • singletonObjects:单例对象的cache

在创建bean的时候,首先想到的是从cache中获取这个单例的bean,这个缓存就是singletonObjects。如果获取不到,并且对象正在创建中,就再从二级缓存earlySingletonObjects中获取。如果还是获取不到且允许singletonFactories通过getObject()获取,就从三级缓存singletonFactory.getObject()(三级缓存)获取,如果获取到了则:从singletonFactories中移除,并放入earlySingletonObjects中。其实也就是从三级缓存移动到了二级缓存。

从上面三级缓存的分析,我们可以知道,Spring解决循环依赖的诀窍就在于singletonFactories这个三级cache。这个cache的类型是ObjectFactory。这里就是解决循环依赖的关键,发生在createBeanInstance之后,也就是说单例对象此时已经被创建出来(调用了构造器)。这个对象已经被生产出来了,虽然还不完美(还没有进行初始化的第二步和第三步),但是已经能被人认出来了(根据对象引用能定位到堆中的对象),所以Spring此时将这个对象提前曝光出来让大家认识,让大家使用。

这样做有什么好处呢?让我们来分析一下“A的某个field或者setter依赖了B的实例对象,同时B的某个field或者setter依赖了A的实例对象”这种循环依赖的情况。A首先完成了初始化的第一步,并且将自己提前曝光到singletonFactories中,此时进行初始化的第二步,发现自己依赖对象B,此时就尝试去get(B),发现B还没有被create,所以走create流程,B在初始化第一步的时候发现自己依赖了对象A,于是尝试get(A),尝试一级缓存singletonObjects(肯定没有,因为A还没初始化完全),尝试二级缓存earlySingletonObjects(也没有),尝试三级缓存singletonFactories,由于A通过ObjectFactory将自己提前曝光了,所以B能够通过ObjectFactory.getObject拿到A对象(虽然A还没有初始化完全,但是总比没有好呀),B拿到A对象后顺利完成了初始化阶段1、2、3,完全初始化之后将自己放入到一级缓存singletonObjects中。此时返回A中,A此时能拿到B的对象顺利完成自己的初始化阶段2、3,最终A也完成了初始化,进去了一级缓存singletonObjects中,而且更加幸运的是,由于B拿到了A的对象引用,所以B现在hold住的A对象完成了初始化。

知道了这个原理时候,肯定就知道为啥Spring不能解决“A的构造方法中依赖了B的实例对象,同时B的构造方法中依赖了A的实例对象”这类问题了!因为加入singletonFactories三级缓存的前提是执行了构造器,所以构造器的循环依赖没法解决。

总结

不要使用基于构造函数的依赖注入,可以通过以下方式解决:

  • 在字段上使用@Autowired注解,让Spring决定在合适的时机注入
  • 用基于setter方法的依赖注入

10.Redis数据类型有哪些?Hash类型如何实现的?

Redis的原有数据类型有 String、List、Set、ZSet、Hash,新版本新增了Bitmap、HyperLogLog、Geo、Stream类型

1.String

Redis中的String类型可以存储字符串、整数和浮点数,底层实现一般为SDS(简单动态字符串) ,基于此结构可以实现对字符串的整个部分和一部分进行操作,也可以实现浮点数或整数的自增和自减操作。此外Redis中的String类型是Redis中最基本的数据类型,其他类型都是基于字符串构建的

常见操作有SET key value、GET key、 INCR/DECR

String类型可一般用于缓存计数器分布式锁

2.List

Redis中的List类型可以理解为一种有序的字符串集集合,List的底层实现可以为:

1.双向链表(数据量大时使用)

2.压缩列表(数据量小时使用,节约内存空间。在新版本中被废弃,使用ListPack替代)

基于上述两种数据结构,可以实现List的按索引访问,或对队头和队尾弹出或删除等操作

List类型可以被用于消息队列,但是基于List的消息队列有两个问题:

1.生产者需要自行实现全局唯一ID

2.不能以消费者形式消费数据

3.Set

Redis中的Set类型可以理解为是一种无序的字符串集集合,且Set类型的key是唯一的,内部不可重复,Set类型的底层实现可以为:

1.哈希表(数据量大时使用,可以实现较快的删改查)

2.整数列表IntSet(数据量小时且存储为整数使用,节省内存)

Set类型可以用作数据去重(存储用户唯一ID)和关系聚合计算(共同求交集,推荐求并集等)。

4.Hash

Redis的Hash类型是一种键值对集合,用于存储结构化数据,Hash类型的底层实现可以为:

1.哈希表(字段较多时使用,支持快速的插入和查找)

2.压缩列表(字段较少时,值较小时使用,节约内存。在新版本被废弃,使用ListPack替代)

Hash类型一般用于存储结构化数据(对象),存储用户信息:如用户名、密码、邮箱等,很适合存储JavaBean

5.ZSet

Redis中的ZSet是一种存储有序集合的数据类型,特殊在于每个集合元素关联一个分数,按分数从小到大排序。ZSet类型的底层实现可以为:

1.跳表(用于存储有序集合,支持范围的快速查找和排序操作)

2.哈希表(将集合与分数映射,便于快速查找)

ZSet类型一般用于排行榜,或者延迟队列(用分数来存储时间戳,按照分数取任务)

6.Bitmap

Redis后续更新的位图Bitmap类型本质上是字符串类型的扩展,但可以对字符串进行位操作。Bitmap类型底层实现同为SDS,但以位(bit)为单位进行操作,即为:Bitmap将一个字符串视为一串连续的二进制位(bit) ,可以对这些二进制位进行操作,从而实现高效的状态存储和查询。每一位(bit)都可以用0或1表示不同的状态

应用场景有状态管理、记录用户在线、离线状态、用户签到

7.HyperLogLog

Redis中的HyperLogLog是一种近似基数统计的数据结构,适合大规模数据的去重统计。

底层主要通过概率算法实现(存在误差,在0.81%以内),且使用固定内存(12kb)存储消息

应用场景主要为网站UV计数或大数据去重计数

11.Spring事务传播机制详解

(1)事务的基本原理

Spring事务的本质其实就是数据库对事务的支持,没有数据库的事务支持,spring是无法提供事务功能的。对于纯JDBC操作数据库,想要用到事务,可以按照以下步骤进行:

  1. 获取连接 Connection con = DriverManager.getConnection()
  2. 开启事务con.setAutoCommit(true/false);
  3. 执行CRUD
  4. 提交事务/回滚事务 con.commit() / con.rollback();
  5. 关闭连接 conn.close();

使用Spring的事务管理功能后,我们可以不再写步骤 2 和 4 的代码,而是由Spirng 自动完成。 那么Spring是如何在我们书写的 CRUD 之前和之后开启事务和关闭事务的呢?解决这个问题,也就可以从整体上理解Spring的事务管理实现原理了。下面简单地介绍下,注解方式为例子

  1. 配置文件开启注解驱动,在相关的类和方法上通过注解@Transactional标识。
  2. spring 在启动的时候会去解析生成相关的bean,这时候会查看拥有相关注解的类和方法,并且为这些类和方法生成代理,并根据@Transaction的相关参数进行相关配置注入,这样就在代理中为我们把相关的事务处理掉了(开启正常提交事务,异常回滚事务)。
  3. 真正的数据库层的事务提交和回滚是通过binlog或者redo log实现的。

(2)Spring事务的传播属性

所谓spring事务的传播属性,就是定义在存在多个事务同时存在的时候,spring应该如何处理这些事务的行为。这些属性在TransactionDefinition中定义,具体常量的解释见下表:

常量名称常量解释
PROPAGATION_REQUIRED支持当前事务,如果当前没有事务,就新建一个事务。这是最常见的选择,也是 Spring 默认的事务的传播。
PROPAGATION_REQUIRES_NEW新建事务,如果当前存在事务,把当前事务挂起。新建的事务将和被挂起的事务没有任何关系,是两个独立的事务,外层事务失败回滚之后,不能回滚内层事务执行的结果,内层事务失败抛出异常,外层事务捕获,也可以不处理回滚操作
PROPAGATION_SUPPORTS支持当前事务,如果当前没有事务,就以非事务方式执行。
PROPAGATION_MANDATORY支持当前事务,如果当前没有事务,就抛出异常。
PROPAGATION_NOT_SUPPORTED以非事务方式执行操作,如果当前存在事务,就把当前事务挂起。
PROPAGATION_NEVER以非事务方式执行,如果当前存在事务,则抛出异常。
PROPAGATION_NESTED如果一个活动的事务存在,则运行在一个嵌套的事务中。如果没有活动事务,则按REQUIRED属性执行。它使用了一个单独的事务,这个事务拥有多个可以回滚的保存点。内部事务的回滚不会对外部事务造成影响。它只对DataSourceTransactionManager事务管理器起效。

(3)数据库事务隔离级别

隔离级别隔离级别的值导致的问题
Read-Uncommitted0导致脏读
Read-Committed1避免脏读,允许不可重复读和幻读
Repeatable-Read2避免脏读,不可重复读,允许幻读
Serializable3串行化读,事务只能一个一个执行,避免了脏读、不可重复读、幻读。执行效率慢,使用时慎重

脏读:一事务对数据进行了增删改,但未提交,另一事务可以读取到未提交的数据。如果第一个事务这时候回滚了,那么第二个事务就读到了脏数据。

不可重复读:一个事务中发生了两次读操作,第一次读操作和第二次操作之间,另外一个事务对数据进行了修改,这时候两次读取的数据是不一致的。

幻读:第一个事务对一定范围的数据进行批量修改,第二个事务在这个范围增加一条数据,这时候第一个事务就会丢失对新增数据的修改。

总结:

隔离级别越高,越能保证数据的完整性和一致性,但是对并发性能的影响也越大。

大多数的数据库默认隔离级别为 Read Commited,比如 SqlServer、Oracle

少数数据库默认隔离级别为:Repeatable Read 比如: MySQL InnoDB

(4)Spring中的隔离级别

常量解释
ISOLATION_DEFAULT这是个 PlatfromTransactionManager 默认的隔离级别,使用数据库默认的事务隔离级别。另外四个与 JDBC 的隔离级别相对应。
ISOLATION_READ_UNCOMMITTED这是事务最低的隔离级别,它充许另外一个事务可以看到这个事务未提交的数据。这种隔离级别会产生脏读,不可重复读和幻像读。
ISOLATION_READ_COMMITTED保证一个事务修改的数据提交后才能被另外一个事务读取。另外一个事务不能读取该事务未提交的数据。
ISOLATION_REPEATABLE_READ这种事务隔离级别可以防止脏读,不可重复读。但是可能出现幻像读。
ISOLATION_SERIALIZABLE这是花费最高代价但是最可靠的事务隔离级别。事务被处理为顺序执行。

(5)事务的嵌套

通过上面的理论知识的铺垫,我们大致知道了数据库事务和spring事务的一些属性和特点,接下来我们通过分析一些嵌套事务的场景,来深入理解spring事务传播的机制。

假设外层事务 Service A 的 Method A() 调用 内层Service B 的 Method B()

PROPAGATION_REQUIRED(spring 默认)

如果ServiceB.methodB() 的事务级别定义为 PROPAGATION_REQUIRED,那么执行 ServiceA.methodA() 的时候spring已经起了事务,这时调用 ServiceB.methodB(),ServiceB.methodB() 看到自己已经运行在 ServiceA.methodA() 的事务内部,就不再起新的事务。

假如 ServiceB.methodB() 运行的时候发现自己没有在事务中,他就会为自己分配一个事务。

这样,在 ServiceA.methodA() 或者在 ServiceB.methodB() 内的任何地方出现异常,事务都会被回滚。

PROPAGATION_REQUIRES_NEW

比如我们设计 ServiceA.methodA() 的事务级别为 PROPAGATION_REQUIRED,ServiceB.methodB() 的事务级别为 PROPAGATION_REQUIRES_NEW。

那么当执行到 ServiceB.methodB() 的时候,ServiceA.methodA() 所在的事务就会挂起,ServiceB.methodB() 会起一个新的事务,等待 ServiceB.methodB() 的事务完成以后,它才继续执行。

他与 PROPAGATION_REQUIRED 的事务区别在于事务的回滚程度了。因为 ServiceB.methodB() 是新起一个事务,那么就是存在两个不同的事务。如果 ServiceB.methodB() 已经提交,那么 ServiceA.methodA() 失败回滚,ServiceB.methodB() 是不会回滚的。如果 ServiceB.methodB() 失败回滚,如果他抛出的异常被 ServiceA.methodA() 捕获,ServiceA.methodA() 事务仍然可能提交(主要看B抛出的异常是不是A会回滚的异常)。

PROPAGATION_SUPPORTS

假设ServiceB.methodB() 的事务级别为 PROPAGATION_SUPPORTS,那么当执行到ServiceB.methodB()时,如果发现ServiceA.methodA()已经开启了一个事务,则加入当前的事务,如果发现ServiceA.methodA()没有开启事务,则自己也不开启事务。这种时候,内部方法的事务性完全依赖于最外层的事务。

PROPAGATION_NESTED

现在的情况就变得比较复杂了, ServiceB.methodB() 的事务属性被配置为 PROPAGATION_NESTED, 此时两者之间又将如何协作呢? ServiceB.methodB() 如果 rollback, 那么内部事务(即 ServiceB.methodB()) 将回滚到它执行前的 SavePoint 而外部事务(即 ServiceA.methodA()) 可以有以下两种处理方式:

a、捕获异常,执行异常分支逻辑

void methodA() { 
      try { 
          ServiceB.methodB(); 
      } catch (SomeException) { 
          // 执行其他业务, 如 ServiceC.methodC(); 
      } 
  }

这种方式也是嵌套事务最有价值的地方, 它起到了分支执行的效果, 如果 ServiceB.methodB() 失败, 那么执行 ServiceC.methodC(), 而 ServiceB.methodB 已经回滚到它执行之前的 SavePoint, 所以不会产生脏数据(相当于此方法从未执行过), 这种特性可以用在某些特殊的业务中, 而 PROPAGATION_REQUIRED 和 PROPAGATION_REQUIRES_NEW 都没有办法做到这一点。

b、 外部事务回滚/提交 代码不做任何修改, 那么如果内部事务(ServiceB.methodB()) rollback, 那么首先 ServiceB.methodB() 回滚到它执行之前的 SavePoint(在任何情况下都会如此), 外部事务(即 ServiceA.methodA()) 将根据具体的配置决定自己是 commit 还是 rollback

另外三种事务传播属性基本用不到,在此不做分析。

(6)总结

对于项目中需要使用到事务的地方,我建议开发者还是使用spring的TransactionCallback接口来实现事务,不要盲目使用spring事务注解,如果一定要使用注解,那么一定要对spring事务的传播机制和隔离级别有个详细的了解,否则很可能发生意想不到的效果。

12.雪花算法产生的ID会重复吗?

雪花算法的ID结构

主要由以下几部分组成:

  • 时间戳(41 位) :记录自 2022-01-01 00:00:00 UTC 以来的毫秒数。
  • 机器 ID(10 位) :用于区分不同的机器实例。
  • 序列号(12 位) :在同一毫秒内生成的 ID 的序列号

可能导致ID重复的情况

1.时间回拨

  • 问题:回拨的幅度超过1毫秒,可能产生重复
  • 解决方案:
    • 时间补偿机制:检测到时间回拨,暂停ID生成,直到系统时间恢复正常
    • 使用本地时间戳:使用本地时间戳(如System.nanoTime())代替系统时间

2.机器ID冲突

  • 问题:多个机器使用了同个机器ID,可能会导致雪花算法ID重复
  • 解决方案:确保机器ID唯一

3.序列号溢出

  • 问题:在同一毫秒内,如果生成的ID数量超过了4096个,序列号部分会溢出,导致ID重复
  • 解决方案:
    • 限制生成速率:用限流器来限制生成速率,如 Guava 的 RateLimiter
    • 增加序列号位数:如业务需要更高的生成速率,可以适当增加序列号的位数,但会减少时间戳或机器ID的位数,雪花ID总位数不变