前言:
今天在做云图库项目的空间模块,简单总结一下今天学到的一些新东西
项目代码背景:
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
:
-
事务会在方法一开始就开启 ,然后才进入
synchronized
块。 -
多个线程同时进来时,虽然
synchronized
还能保证同一个userId
串行化,但此时数据库连接和事务资源已经被占用。-
如果线程很多,就可能撑爆连接池。
-
等锁释放时,有些事务可能已经超时。
-
-
事务范围过大 → 把校验逻辑、加锁等待也算进事务里,这些其实不需要事务,却占用了事务资源。
而 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. 为什么相同内容的字符串只有一份引用
-
当你创建一个字符串时:
javaString s1 = "hello"; String s2 = "hello";
-
JVM 会先去 常量池 查找是否有
"hello"
。 -
如果存在,就直接返回常量池中的引用。
-
如果不存在,才会新建一个对象放入池中。
-
-
因此:
javaSystem.out.println(s1 == s2); // true
两个变量 引用的是同一个对象,而不是两个不同的对象。
3. intern()
的作用
-
当你有一个 运行时生成的字符串:
javaString s3 = new String("hello");
-
这是在堆上创建了一个新对象,引用和常量池不同:
javaSystem.out.println(s1 == s3); // false
-
-
使用
intern()
:javaString 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"
的唯一引用。
- JVM 在类加载或首次使用时,会在常量池中创建
-
new String("hello")
会在 堆上 新建一个 String 对象(即 s3 指向的对象),内容是"hello"
。 -
此时堆对象和常量池对象是两个不同的对象:
java
常量池: "hello" ← JVM唯一
堆对象: s3 -> "hello"(新对象)
此时若是执行s3.intern()
-
intern()
会去 常量池查找是否存在相同内容的字符串:-
如果存在 → 返回 常量池中的引用
-
如果不存在 → 把 s3 的引用放入常量池,并返回该引用
-
-
在你这个例子中,常量池里已经有
"hello"
(第一次使用"hello"
字面量时就放进去的),所以:javaString s4 = s3.intern(); s4 == s1 // true,指向常量池对象 s3 == s4 // false,堆对象和池对象不同