今日面试之项目拷打:锁与事务的深度解析

前言:

今天在做云图库项目的空间模块,简单总结一下今天学到的一些新东西

项目代码背景:

java 复制代码
@Override
    public long addSpace(SpaceAddRequest spaceAddRequest, User loginUser) {

        //1.填充默认参数值
        //转换实体类和DTO
        Space space=new Space();
        BeanUtils.copyProperties(spaceAddRequest,space);
        //填充容量和大小
        Integer spaceLevel = space.getSpaceLevel();
        if(spaceLevel==null){
            space.setSpaceLevel(spaceAddRequest.getSpaceLevel());
        }
        if(space.getMaxSize()==null){
            space.setMaxSize(SpaceLevelEnum.getEnumByValue(spaceLevel).getMaxSize());
        }
        if(space.getMaxCount()==null){
            space.setMaxCount(SpaceLevelEnum.getEnumByValue(spaceLevel).getMaxCount());
        }
        //2.校验参数
        validSpace(space, true);
        //3.校验权限,非管理员只能创建普通级别的空间
        Long userId = loginUser.getId();
        space.setUserId(userId);
        if (SpaceLevelEnum.COMMON.getValue() != spaceAddRequest.getSpaceLevel() && !userService.isAdmin(loginUser)) {
            throw new BusinessException(ErrorCode.NO_AUTH_ERROR, "无权限创建指定级别的空间");
        }
        //4.控制同一用户只能创建一个私有空间
        String lock = String.valueOf(userId).intern();
        synchronized (lock){
            Long newSpaceId = transactionTemplate.execute(status -> {
                //判断是否已有空间
                boolean exists = this.lambdaQuery().eq(Space::getUserId, userId).exists();
                //如果已有则不能创建
                if (exists) {
                    throw new BusinessException(ErrorCode.OPERATION_ERROR, "每个用户仅能创建一个私有空间");
                }
                //创建
                boolean save = this.save(space);
                ThrowUtils.throwIf(!save, ErrorCode.OPERATION_ERROR, "创建私有空间失败");
                //返回新写入的数据id
                return space.getId();
            });

            return Optional.ofNullable(newSpaceId).orElse(-1L);
        }


    }

问题一:

为什么这里使用到transactionTemplate而不是直接在方法上增加@Transactional ?

答:①(业务逻辑角度)从时间线上来看,假设同一个用户在一个时间切片内连续发出两次请求,此时第一次请求获取到锁并完成了数据库的新增然后释放锁,因为用的是事务注解在方法上,所以是在方法完成后才会提交事务,但当方法还没有返回结果时,第二次请求迅速拿到锁开始业务逻辑处理,由于上一次事务没有提交,就导致了第二次请求再一次在数据库中新增了一条记录(这就违反了业务逻辑)。

java 复制代码
时间 →
┌───────────────────────────────┬───────────────────────────────┐
│           线程 A(请求 1)     │          线程 B(请求 2)      │
├───────────────────────────────┼───────────────────────────────┤
│ 开启事务 T1                   │                                │
│ 获取锁                        │                               │
│ 校验:库中无记录               │                               │
│ 插入记录(未提交,仍在事务中) │                               │
│ 释放锁                        │                               │
│                               │ 获取锁成功                    │
│                               │ 校验:仍查不到记录(T1未提交)│
│                               │ 插入记录(未提交,T2中)       │
│                               │ 释放锁                        │
│ 提交事务 T1(记录1真正落库)   │                               │
│                               │ 提交事务 T2(记录2真正落库)   │
└───────────────────────────────┴───────────────────────────────┘

最终结果:同一用户有两条记录 ❌

②(并发安全角度)

如果整个方法都加上了 @Transactional

  1. 事务会在方法一开始就开启 ,然后才进入 synchronized 块。

  2. 多个线程同时进来时,虽然 synchronized 还能保证同一个 userId 串行化,但此时数据库连接和事务资源已经被占用。

    • 如果线程很多,就可能撑爆连接池。

    • 等锁释放时,有些事务可能已经超时。

  3. 事务范围过大 → 把校验逻辑、加锁等待也算进事务里,这些其实不需要事务,却占用了事务资源。

transactionTemplate 的写法,只有真正需要保证原子性的 查询+保存 才跑在事务里,外层的锁竞争和参数校验都不会拖慢数据库事务。

问题二:

为什么String lock = String.valueOf(userId).intern();要用userId作为锁对象?

答:

  • 每个用户对应一个唯一的 userId

  • 通过 String.valueOf(userId)userId 转成字符串,再用 .intern() 保证:

    • JVM 内部 相同内容的字符串只会有一份引用(后面有详细解释)

    • 所以 synchronized(lock) 针对的是同一个用户唯一的锁对象。

java 复制代码
String lock1 = String.valueOf(1001).intern(); 
String lock2 = String.valueOf(1001).intern(); 
System.out.println(lock1 == lock2); // true
  • 两个线程拿到的是 同一个锁对象 → 线程安全。

  • 不同用户的 userId → 不同锁对象 → 不互相阻塞,提高并发。

问题三:

为什么JVM 内部 相同内容的字符串只会有一份引用?

答:

这是一个 Java 字符串常量池(String Pool) 的机制问题。我们慢慢拆开解释。


1. 字符串常量池的概念

  • 在 Java 中,字符串是不可变对象String 是 immutable 的)。

  • 为了节省内存和提高效率,JVM 在 方法区/元空间 中维护一个 字符串常量池(String Pool)。

  • 任何 编译时的字面量字符串 或者通过 intern() 方法的字符串,都会放入常量池中。


2. 为什么相同内容的字符串只有一份引用

  • 当你创建一个字符串时:

    java 复制代码
    String s1 = "hello"; 
    String s2 = "hello";
    • JVM 会先去 常量池 查找是否有 "hello"

    • 如果存在,就直接返回常量池中的引用。

    • 如果不存在,才会新建一个对象放入池中。

  • 因此:

    java 复制代码
    System.out.println(s1 == s2); // true

    两个变量 引用的是同一个对象,而不是两个不同的对象。


3. intern() 的作用

  • 当你有一个 运行时生成的字符串

    java 复制代码
    String s3 = new String("hello");
    • 这是在堆上创建了一个新对象,引用和常量池不同:

      java 复制代码
      System.out.println(s1 == s3); // false
  • 使用 intern()

    java 复制代码
    String s4 = s3.intern();
    • JVM 会检查常量池中是否有 "hello"

      • 如果有 → 返回常量池的引用。

      • 如果没有 → 将 s3 的内容放入池中,并返回引用。

  • 这样就保证了 相同内容的字符串在常量池中只有一份引用

写法 对象位置 引用关系
String s1 = "hello"; 常量池 s1 指向池中对象
String s2 = "hello"; 常量池 s2 指向同一个池中对象
String s3 = new String("hello"); 堆(Heap) s3 指向新对象,与池中不同
s3.intern() 常量池 返回池中对象的引用

为了给大家彻底理清这个问题,我最后补充一个问题:

问题四:

项目中第一次执行String s3 = new String("hello");会发生什么?

答:

1. 执行 String s3 = new String("hello");

  • "hello"字面量 ,会先被 放入常量池(如果还没放的话)。

    • JVM 在类加载或首次使用时,会在常量池中创建 "hello" 的唯一引用。
  • new String("hello") 会在 堆上 新建一个 String 对象(即 s3 指向的对象),内容是 "hello"

  • 此时堆对象和常量池对象是两个不同的对象

java 复制代码
常量池: "hello"   ← JVM唯一
堆对象: s3 -> "hello"(新对象)

此时若是执行s3.intern()

  • intern() 会去 常量池查找是否存在相同内容的字符串

    • 如果存在 → 返回 常量池中的引用

    • 如果不存在 → 把 s3 的引用放入常量池,并返回该引用

  • 在你这个例子中,常量池里已经有 "hello"(第一次使用 "hello" 字面量时就放进去的),所以:

    java 复制代码
    String s4 = s3.intern();
    s4 == s1 // true,指向常量池对象
    s3 == s4 // false,堆对象和池对象不同
相关推荐
sunbin2 小时前
软件授权管理系统-整体业务流程图
后端
cpsvps_net2 小时前
基础镜像清理策略在VPS环境存储优化中的维护规范
运维·服务器
ajassi20002 小时前
开源 java android app 开发(十五)自定义绘图控件--仪表盘
android·java·开源
间彧2 小时前
Java中,wait()和sleep()区别
后端
FrankYoou2 小时前
Spring Boot 自动配置之 TaskExecutor
java·spring boot
爱读源码的大都督2 小时前
Spring AI Alibaba JManus底层实现剖析
java·人工智能·后端
间彧2 小时前
synchronized的wait/notify机制详解与实战应用
后端
Voyager_42 小时前
双网卡服务器校园网访问故障排查与解决
服务器·网络·智能路由器
间彧3 小时前
ReentrantLock与ReadWriteLock在性能和使用场景上有什么区别?
java