-- 2024-03-24 摄于荷兰花卉小镇
Read More ~
业务逻辑编排错误 & TTL浅拷贝导致参数丢失问题
前言
在DDD项目中,为了方便参数的传递,通常会使用ThreadLocal来保存一个对象来实现对参数的跨方法传递,避免通过形参的形式传递。在内部项目中,有一个项目使用的是 alibaba开源的 transmittable-thread-local来存储参数,新建了一个上下文对象(AbilityContext.java),使用HashMap来临时存储和获取参数。
AbilityContext 示例
public class AbilityContext {
private static final ThreadLocal<Map<String, Object>> CONTEXT = new TransmittableThreadLocal<>();
private AbilityContext() {
}
/**
* 初始化上下文
*/
public static void initContext() {
Map<String, Object> con = CONTEXT.get();
if (con == null) {
CONTEXT.set(new HashMap<>(8));
} else {
CONTEXT.get().clear();
}
}
/**
* 清除上下文
*/
public static void clearContext() {
CONTEXT.remove();
}
public static Map<String, Object> getInnerMap() {
return CONTEXT.get();
}
/**
* 获取上下文内容
*/
public static <T> T getValue(String key) {
Map<String, Object> con = CONTEXT.get();
if (con == null) {
return null;
}
return (T) con.get(key);
}
/**
* 设置上下文参数
*/
public static void putValue(String key, Object value) {
Map<String, Object> con = CONTEXT.get();
if (con == null) {
CONTEXT.set(new HashMap<>(8));
con = CONTEXT.get();
}
con.put(key, value);
}
}
项目情况介绍
通常来说,DDD项目的基本流程是由interface->application,中间封装一层来集中处理上下文的初始化和清空动作,如下图:
在正常情况下,上述流程可以正确的完成参数的写入和获取,但是,在项目运行过程中遇到了一个bug,正常写入参数后,偶现性(低频)获取值为NULL,导致程序出错,示例代码如下(隐去业务代码,重新写的伪代码):
其中demo()方法为当时复现的方法
demo2()为伪代码,是业务代码中调用了另一个application,假设其逻辑和demo()方法一致的业务代码。
@Slf4j
public class AlibabaTtlWrongUsageExampleApplication {
public static void main(String[] args) {
demo(i);
}
private static void demo(int idx) {
// 初始化
AbilityContext.initContext();
// 赋业务值
AbilityContext.putValue("main", "mainValue");
// 这里简化了代码,实际上经过了很多层业务代码调用后才出现了此方法
ThreadUtil.execute(() -> {
execute->demo2();
});
// do something
// 主线程再次获取业务值(偶现为null)
String value = AbilityContext.getValue("main");
if (Objects.isNull(value)) {
log.warn("lastGetNullValue, idx={}", idx);
}
}
}
上述代码运行设置了一个key=main,值为mainValue。在下方AbilityContext.getValue("main")偶现获取==NULL。
展开分析
当时在分析的开始有推测是业务代码中参数被重新赋值为NULL,但通过对后续业务代码逐行查看,并没有找到重新赋值的逻辑。
在深入业务代码分析的过程中,发现主流程中有一个异步方法调用(ThreadUtil.execute()),再次调用了另一个领域服务(这是不符合DDD规范的!),而领域服务的入口都会AbilityContext.initContext()的逻辑,通过这个线索 ,继续展开了深入分析。
编码者的初衷可能是想到异步线程已经脱离了当前线程,再次调用 initContext()方法是初始化了一个新的对象上下文,但是由于项目使用的是 alibaba TTL,能够实现跨线程的传递,所以在子线程中依旧能拿到父线程的HashMap。并且TTL默认是使用的浅拷贝对象。由于initContext()中,调用了HashMap.clear()方法,相当于将父线程的HashMap给清空了!。
通过比对父子线程的hashCode值确定为同一对象
// 主线程获取hashCode
final int hashCode = AbilityContext.getInnerMap().hashCode();
ThreadUtil.execute(() -> {
// 子线程对比hashCode
log.info("{}, ThreadUtil hashCode={}", idx, AbilityContext.getInnerMap().hashCode() == hashCode);
// 子线程再次初始化(错误的根源)
AbilityContext.initContext();
// do something
});
14:42:28.198 [pool-1-thread-26] INFO top.imyzt.learning.caseanalysis.ttl.AlibabaTtlWrongUsageExampleApplication -- 25, ThreadUtil hashCode=true
持续分析
有了上述的线索,基本把问题原因找到了,但是为什么是偶现的呢?
因为使用了异步线程,而线程的调度由操作系统的线程调度算法来决定,并不是一定保证顺序的,所以只要当操作系统优先调度异步线程,那么HashMap就被清空了,如果主线程优先往下走,那么就能够获取到完整的HashMap。
后记
至此,问题分析就告一段落了,整个过程中涉及到 TTL值的父子线程传递、对象浅拷贝、线程的调度,还涉及到了DDD的不规范逻辑编排,整个分析下来花费了一上午的时间,收获还是很大的。
transmittable-thread-local
TransmittableThreadLocal的传递只有浅拷贝吗?
线程的优先级
我将源代码上传了GitHub,如果你想在本地调试运行上述案例,可以下载到本地调试,有问题可以评论区沟通。
Read More ~
使用Redis实现分布式锁的坑
分布式锁的关键在于对单一资源的竞争。获得资源的实例将继续执行,其余实例要么退出(互斥锁),要么等待(阻塞锁)。
实现分布式锁的方案有很多,既可以直接使用MySQL作为分布式锁(例如xxl-job),也可以利用ZooKeeper、Redis等。
在基于Spring Cloud的业务系统中,一般都会引入Redis作为分布式缓存中间件,因此更多的人会选择使用Redis来实现分布式锁。本文将介绍使用Redis作为分布式锁时常见的问题和解决方法。
1. 没有使用原子操作指令
错误写法
Boolean tryLock = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockValue);
stringRedisTemplate.expire(lockKey, Duration.ofSeconds(expireTime));
if (!tryLock) {
return;
}
上述操作通常出现在新手阶段,在写入锁对象时,没有考虑到原子性问题。在Redis中有提供SET NX PX指令,支持在设置锁的同时指定过期时间,并且支持原子性判断key是否已存在。
NX 和 PX 是 Redis 命令中用于设置 key 的两个选项。
NX: 当指定 NX 选项时,只有在 key 不存在的情况下才会设置 key 的值。如果 key 已经存在,则不进行任何操作。
PX: PX 选项用于设置 key 的过期时间(以毫秒为单位)。例如,PX 10000 表示在 10 秒后将 key 设置为过期状态。
正确写法:
Boolean tryLock = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, expireTime, TimeUnit.MILLISECONDS);
2. 释放了别人的锁
错误写法
try {
Boolean tryLock = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, expireTime, TimeUnit.MILLISECONDS);
if (!tryLock) {
return;
}
// do something
} finally {
stringRedisTemplate.delete(lockKey);
}
在加锁的过程中,没有设定唯一值作为Value存储到Redis中,在释放时,不判断直接对锁进行释放。其二,将获取锁的代码放在了try代码块中。
在上述代码中存在两个问题:
不该执行到finlly代码块:A请求获得了锁正在执行业务代码,而B请求没有获得锁,但是因为获取锁的代码在try代码块中,导致finally一定会执行,B请求就会将A请求的锁释放,而如果A请求依旧未执行完毕,此时C请求过来时,则C请求错误的拿到了锁。
不该删除别人的锁:在删除锁时,应该判断自己是否是上锁人,由于多次执行Redis指令不具备原子性,所以一般是交由LUA脚本来实现的。
if redis.call('get', KEYS[1]) == ARGV[1]
then
return redis.call('del', KEYS[1])
else
return 0
end
正确写法
提前将LUA脚本载入到Redis服务端
script = new DefaultRedisScript<>();
script.setResultType(Long.class);
script.setScriptSource(new ResourceScriptSource(new ClassPathResource("release_lock.lua")));
获取和释放锁示例
Boolean tryLock = stringRedisTemplate.opsForValue().setIfAbsent(lockKey, lockValue, expireTime, TimeUnit.MILLISECONDS);
if (!tryLock) {
return;
}
try {
// do something
} finally {
ArrayList<String> keys = new ArrayList<>();
keys.add(context.getLockKey());
stringRedisTemplate.execute(this.script, keys, context.getLockValue());
}
3. 事务未提交锁就释放了
错误代码
/**
* 事务内获取分布式锁
*/
@Transactional(rollbackFor = Exception.class)
public void saveUserWithDistributedLock(String name) {
String lockKey = "lock_key:" + name;
RedisLock.LockContext lockContext = redisLock.tryLock(lockKey, 10000L);
if (!lockContext.getTryLock()) {
// printLog("没拿到锁");
return;
}
printLog("拿到锁了" + lockKey);
try {
this.save(name);
} finally {
redisLock.release(lockContext);
printLog("释放锁了");
}
}
MySQL常规情况下是RR的隔离级别,只有等到事务提交数据才对其他事务可见,存在**“读视图”,在上述的代码中,A请求拿到了锁执行了业务代码,执行到redisLock.release时将锁释放了,但Spring的@Transactional依赖的是AOP,其需要等到方法执行完毕才会提交事务,在这个临界点,B请求可以正常拿到锁,但是A请求的事务还未提交,B请求的读视图**中还未查询到A请求提交的数据,最终造成了数据的不一致性。
正确代码
正确的情况是在另一个方法中获取到锁之后,再调用包含事务的业务代码。此时需要注意SpringAOP在本方法内代理失效的问题,通常需要新建一个Service来处理。
业务代码执行超过锁过期时间
错误代码
// Domain-Service
public void save(String name) {
String lockKey = "lock_key:" + name;
RedisLock.LockContext lockContext = redisLock.tryLock(lockKey, 10000L);
if (!lockContext.getTryLock()) {
printLog("没拿到锁");
return;
}
printLog("拿到锁了" + lockKey);
try {
userService.save(name);
} finally {
redisLock.release(lockContext);
printLog("释放锁了");
}
}
// UserService
@Transactional(rollbackFor = Exception.class)
public void save(String name) {
List<User> users = userRepository.findUsersByName(name);
if (CollUtil.isNotEmpty(users)) {
printLog("已经写入, 不再写入" + users);
return;
}
// 业务保存模拟执行很慢
TimeUnit.SECONDS.sleep(70);
}
上述代码中,锁对象只有10s的时间,但是业务代码执行却需要70s,A请求虽然拿到了锁,此时后续10秒其他请求均无法获取锁,但是从第11秒开始的请求将可以拿到锁,而此时A请求还未执行完毕,此时开始出现错误的获取锁,最终造成数据的不一致。
正确写法
参考Redisson的WatchDog机制,另外开辟线程每隔 10s 就给还未执行完毕的 Key 自动续期 30s,保证业务代码能够安全的执行完毕再自行释放锁对象。
示例代码:
// watch dog
Executors.newScheduledThreadPool(1).scheduleAtFixedRate(() -> {
if (!LOCK_CONTEXTS.isEmpty()) {
for (LockContext lockContext : LOCK_CONTEXTS) {
// 如果执行线程还未释放锁, 续期30s(模拟Redisson)
stringRedisTemplate.expire(lockContext.getLockKey(), Duration.ofSeconds(30));
Long expire = stringRedisTemplate.getExpire(lockContext.getLockKey());
log.info("WatchDog, expire 30s, lockKey={}, ttl={}", lockContext.getLockKey(), expire);
}
}
}, 0,
// 10秒检测一次
10, TimeUnit.SECONDS);
后记
分布式锁的错误还有很多,本篇主要是自己在工作过程中遇到的一些坑,着重介绍新手阶段在编写分布式锁时遇到的比较基础的问题,后面有空再进行其他场景的逐个介绍。
本文参考:聊聊redis分布式锁的8大坑
本文代码:redis-lua-distributed-lock
Read More ~
错用HashedWheelTimer导致的OOM问题
事件中心在私有化环境下,只要server一启动过几秒就oom,查看日志是 Failed to create a thread: retVal -1073741830, errno 11。
异常堆栈:
Caused by: java.lang.OutOfMemoryError: Failed to create a thread: retVal -1073741830, errno 11
at java.lang.Thread.startImpl(Native Method)
at java.lang.Thread.start(Thread.java:993)
at io.netty.util.HashedWheelTimer.start(HashedWheelTimer.java:366)
at io.netty.util.HashedWheelTimer.newTimeout(HashedWheelTimer.java:447)
at 业务调用代码省略
在标品环境下没有问题,在其他KA客户上也没有问题
通过对日志的分析,最终发现是事件中心的延迟消息代码存在缺陷,使用了Netty的HashedWheelTimer,但是语法存在问题,理论上应该是new一个HashedWheelTimer来处理所有时间延迟,但是错用程每次new一个新的HashedWheelTimer,HashedWheelTimer内部每次都会new一个新的线程来处理做调度,一个线程占用1MB,最终内存资源被耗尽。
io.netty.util.HashedWheelTimer#HashedWheelTimer源码:
public HashedWheelTimer(
ThreadFactory threadFactory,
long tickDuration, TimeUnit unit, int ticksPerWheel, boolean leakDetection,
long maxPendingTimeouts, Executor taskExecutor) {
checkNotNull(threadFactory, "threadFactory");
checkNotNull(unit, "unit");
checkPositive(tickDuration, "tickDuration");
checkPositive(ticksPerWheel, "ticksPerWheel");
this.taskExecutor = checkNotNull(taskExecutor, "taskExecutor");
// Normalize ticksPerWheel to power of two and initialize the wheel.
wheel = createWheel(ticksPerWheel);
mask = wheel.length - 1;
// Convert tickDuration to nanos.
long duration = unit.toNanos(tickDuration);
// Prevent overflow.
if (duration >= Long.MAX_VALUE / wheel.length) {
throw new IllegalArgumentException(String.format(
"tickDuration: %d (expected: 0 < tickDuration in nanos < %d",
tickDuration, Long.MAX_VALUE / wheel.length));
}
if (duration < MILLISECOND_NANOS) {
logger.warn("Configured tickDuration {} smaller then {}, using 1ms.",
tickDuration, MILLISECOND_NANOS);
this.tickDuration = MILLISECOND_NANOS;
} else {
this.tickDuration = duration;
}
// 每次都new一个线程来处理
workerThread = threadFactory.newThread(worker);
leak = leakDetection || !workerThread.isDaemon() ? leakDetector.track(this) : null;
this.maxPendingTimeouts = maxPendingTimeouts;
if (INSTANCE_COUNTER.incrementAndGet() > INSTANCE_COUNT_LIMIT &&
WARNED_TOO_MANY_INSTANCES.compareAndSet(false, true)) {
reportTooManyInstances();
}
}
因为标品和其他KA使用的是阿里云RocketMQ,此客户使用的是自建的开源版RocketMQ,开源RocketMQ是没有自定义时长的延迟消息的,所以我们自己实现了一套时间轮来实现任意时长的延迟消息,当小于60s的延迟消息会丢入我们的时间轮来处理延迟投递,当时此客户的环境中有大量的60s内的延迟消息,导致一启动就会崩溃。
不过在RocketMQ5.0也支持任意时长了。
Read More ~
CircuitBreak导致ThreadLocal参数丢失问题
背景
使用OpenFeign时,通常会实现RequestInterceptor接口来自定义FeignConfiguration,OpenFeign暴露了feign.RequestTemplate信息,给到我们在发送请求前自定义参数信息的扩展点。
在分布式系统中,通常会将本服务的信息(UserInfo、RequestId)透传至下游服务,从而实现分布式链路追踪等功能,对于像用户信息等,在Web系统中通常使用 ThreadLocal 来存储信息,在自定义的FeignConfiguration中获取ThreadLocal再塞入到feign.RequestTemplate中,实现向下游服务的传递,示例:
public class FeignConfiguration implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
String userId = SubjectContext.get().getUserId();
if (null != attributes) {
HttpServletRequest request = attributes.getRequest();
template.header("token", request.getHeader("TOKEN"));
template.header("userId", userId);
}
}
}
简单的Context示例:
public class SubjectContext {
protected static ThreadLocal<UserInfo> subjectContext = new ThreadLocal();
public static void remove() {
subjectContext.remove();
}
public static void set(UserInfo uerInfo) {
subjectContext.set(uerInfo);
}
public static UserInfo get() {
return (UserInfo)subjectContext.get();
}
}
出现错误
上述代码在常规情况下,是能够按照预期执行的。
但是最近项目引入了CircuitBreaker作为服务熔断的断路器之后,上述代码在执行到SubjectContext.get()时,会抛出空指针,拿不到用户信息。
通过分析CircuitBreaker的源码,最终定位到代码出现在Resilience4JCircuitBreaker内部,在Resilience4JCircuitBreaker中有一个public <T> T run(Supplier<T> toRun, Function<Throwable, T> fallback)方法,方法入参的toRun就是封装过的我们定义的Feign接口,其包装过程在FeignCircuitBreakerInvocationHandler#asSupplier代码中,如下:
private Supplier<Object> asSupplier(final Method method, final Object[] args) {
final RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
return () -> {
try {
RequestContextHolder.setRequestAttributes(requestAttributes);
// 执行我们的真正方法
return dispatch.get(method).invoke(args);
}
catch (RuntimeException throwable) {
throw throwable;
}
catch (Throwable throwable) {
throw new RuntimeException(throwable);
}
};
}
Spring Cloud CircuitBreaker Resilience4j 提供了两种实现:
使用 Semaphores 的 SemaphoreBulkhead。
一个 FixedThreadPoolBulkhead,它使用一个有界队列和一个固定的线程池。
默认情况下,Spring Cloud CircuitBreaker Resilience4j 使用 FixedThreadPoolBulkhead。要修改默认行为以使用 SemaphoreBulkhead,请将属性 spring.cloud.circuitbreaker.resilience4j.enableSemaphoreDefaultBulkhead 设为 true。
正是由于上述原因,默认将我们的FeignConfiguration提交给了线程池,由于我们使用的是ThreadLocal导致线程本地变量没有向子线程传递,在执行FeignConfiguration时子线程无法拿到Context信息,最终导致程序的报错。
解决办法
通过分析源码我们发现,执行任务的线程池Resilience4JCircuitBreaker#executorService是由外部传递过来进行初始化的,调用方在Resilience4JCircuitBreakerFactory#create(java.lang.String, java.lang.String, java.util.concurrent.ExecutorService)
在Resilience4JCircuitBreakerFactory中发现,是由本实例在create方法被调用时传入的本类的成员变量,即:
private ExecutorService executorService = Executors.newCachedThreadPool();
private ConcurrentHashMap<String, ExecutorService> executorServices = new ConcurrentHashMap<>();
而我们在没有定义自定义Feign Group时,默认使用的就是executorService,在本类中有一个Resilience4JCircuitBreakerFactory#configureExecutorService方法专门保留了外部传入自定义线程池的扩展,我们可以自己实现创建一个支持传递Context到子线程的线程池,即可将参数向下传递,比如像这样:
@Configurable
@AllArgsConstructor
public class CircuitBreakerConfiguration implements ApplicationRunner {
private final Resilience4JCircuitBreakerFactory factory;
@Override
public void run(ApplicationArguments args) throws Exception {
ContextThreadPoolExecutor contextThreadPoolExecutor =
new ContextThreadPoolExecutor(2, 5, 1, TimeUnit.MINUTES, new ArrayBlockingQueue<>(1024));
// **change ThreadPoolExecutor**
factory.configureExecutorService(contextThreadPoolExecutor);
}
public static class ContextThreadPoolExecutor extends ThreadPoolExecutor {
public ContextThreadPoolExecutor(int corePoolSize, int maximumPoolSize, long keepAliveTime, TimeUnit unit, BlockingQueue<Runnable> workQueue) {
super(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue);
}
public void execute(Runnable command) {
super.execute(wrap(command));
}
private static Runnable wrap(Runnable runnable) {
**SubjectContext context = SubjectContext.getContext();**
return () -> {
// 将参数向下传递
**SubjectContext.setContext(context);**
try {
runnable.run();
} finally {
**SubjectContext.clear();**
}
};
}
}
}
后记
上述的方案只解决了没有自定义Group的情况,官方在自定义Group的情况下是没有保留扩展位的,所以给官方提了一个MR并且已成功合并到主分支,如下:
Customizable groupExecutorService #180
Read More ~
IDEA 常用插件
编码类
Generate All Getter And Setter 如其名
AceJump 快速将光标导航到编辑器中可见的任何位置,ctrl+,然后输入字符,进行高亮定位
GsonFormat 将JSONObject格式的String 解析成实体。
Lombok 针对模型对象精简代码
MybatisCodeHelperPro 通过方法名来生成sql,全自动提示,自动检测
工具类
any-rule 可以搜索正则表达式
CamelCase 变量名转驼峰
Jrebel 快速实现热部署,跳过了构建和部署的过程,可以省去大量的部署用的时间。
jrebel-mybatisplus-extension 热部署中mybatis的扩展,他的功能就是配合 Jrebel 一起使用,修改 mybatis 的 mapper.xml 文件不用重启项目
CodeGlance 显示代码地图插件,进行语法突出显示
RestfulToolKit 可以搜索接口
POJO TO JSON 将pojo类转换为json
Translation 翻译插件
Maven类
Maven Helper 依赖冲突分析利器
Maven Dependency Helper Maven中央仓库搜索
规范类
P3C 编码规范的一个插件,高亮提示违规代码
SonarLint 打开文件的时候自动进行代码审查
提效类
Key Promoter X 在IDEA里面使用鼠标的时候,如果这个鼠标操作是能够用快捷键替代,此插件会进行提示
POJO TO JSON 将pojo类转换为json
装修类
Nyan Progress Bar 漂亮的进度条
Rainbow Brackets 配对括号相同颜色,并且实现选中区域代码高亮的功能
Read More ~
IDEA 2023 非常用配置
配置文件全部使用UTF-8
Editor-File Encodings,将所有内容均设置为UTF-8。
Create UTF-8 files 选择 with NO BOM,避免在创建文件前3个字节来标志为UTF-8文件。
参数提示
默认情况下,是没有参数提示的,需要手动开启。
Editor-General-Code Completion-Parameter Info
注释默认在当前缩进
默认情况下,自动补全注释是在最前面,可以通过:
Editor-Code Style-Java(其它语言位置类似)-Code Generation-Comment Code按如下图配置进行处理。
如果配置的内容,在新项目不生效,则注意此处
除了字体样式字体大小外,其它的配置IDEA官方认为,每个项目在创建时,可能都需要一些不同的配置,所以有很多内容都是跨项目不生效的(Maven、文件格式等),需要在此处配置(点进去和Settings一模一样,但是对应的是Next Project(新项目)的配置)
Read More ~
使用highlightjs让Gridea支持代码高亮
Gridea的Lemon主题,默认不支持代码高亮,需要自己补充。目前开源的highlight.js正合适。记录下处理的过程
打开官网,挑选自己喜欢的风格
highlightjs.org/examples
找一个CDN托管网站,避免走自己的小油管,这里推荐bootcdn(这么多年了,还是如此坚挺)
www.bootcdn.cn/highlight.js
将上述找到的代码,应用在自己主题下的post.ejs的 <head> 代码块中。
1.
~/Gridea/themes/gridea-theme-lemon,找到自己的主题目录,gridea-theme-lemon是我的Lemon主题目录。
2. 编辑post.ejs文件,vim templates/post.ejs
3. 在<head>中补充如下代码
<link href="https://cdn.bootcdn.net/ajax/libs/highlight.js/11.8.0/styles/此处选择自己喜欢的主题.min.css" rel="stylesheet">
<script src="https://cdn.bootcss.com/highlight.js/9.12.0/highlight.min.js"></script>
<script>hljs.initHighlightingOnLoad();</script>
Read More ~
Java 20~21 新特性
Java 20没有发布重要的新特性,本文以Java21版本为主。
Java 21 是新的LTS版本,其中发布了众多新的特性。
字符串模板(预览版)
使用官方的STR、FMT
支持通过STR、FMT来实现字符串拼接,可以自定义模板处理器组织字符串输出形式。
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import static java.util.FormatProcessor.FMT;
/**
* @author imyzt
* @date 2023/12/19
* @description 模板字符串
*/
public class TemplateString {
public static void main(String[] args) {
String str = "world";
String result = STR."hello \{str}";
System.out.println(result);
System.out.println(STR);
String name = "yzt";
String[] blogAddress = {"imyzt.top", "blog.imyzt.top"};
String text = FMT."""
My name is \{name}
My blog address is \{blogAddress[0].toUpperCase()}, \{blogAddress[1].toLowerCase()}""";
System.out.println(text);
System.out.println(STR."\{Math.random()}");
System.out.println(STR."\{Integer.MAX_VALUE}");
System.out.println(STR."\{index++}, \{++index}");
//hello world
//java.lang.StringTemplate$$Lambda/0x00000001260457f0@33c7353a
//My name is yzt
//My blog address is IMYZT.TOP, blog.imyzt.top
//0.9361799484353136
//2147483647
//现在的时间是: 2023-12-20
//0, 2
}
}
自定义StringTemplate
import java.util.Iterator;
/**
* @author imyzt
* @date 2023/12/20
* @description 自定义StringTemplate
*/
public class CustomTemplate {
public static void main(String[] args) {
var INTER = StringTemplate.Processor.of((StringTemplate st) -> {
StringBuilder sb = new StringBuilder();
Iterator<String> fragments = st.fragments().iterator();
for (Object value : st.values()) {
sb.append(fragments.next());
sb.append(value);
}
sb.append(fragments.next());
return sb.toString();
});
String name = "yzt";
int index = 0;
String text = INTER."""
{
"name":\{name},
"index":\{++index}
}
""";
System.out.println(text);
//{
//"name":yzt,
//"index":1
//}
}
}
Record Patterns
记录模式更强大了,支持直接在表达式中创建和使用。还支持嵌套
/**
* @author imyzt
* @date 2023/12/19
* @description 记录模式
*/
public class RecordPatterns {
public static void main(String[] args) {
Object p = new Point(1, 2);
if (p instanceof Point(int x, int y)) {
System.out.println(x + y);
}
Object w = new Window(new Point(1, 2), 3);
if (w instanceof Window(Point(int x, int y), int z)) {
System.out.println(x + y + z);
}
}
}
record Point(int x, int y) {}
record Window(Point p, int z) {}
ScopedValue
ScopedValue是一个隐藏的方法参数,只有方法可以访问ScopedValue,它可以让两个方法传递参数时无需声明形参(对于Web项目中传递用户信息是非常场景的操作)
ThreadLocal的问题
通常对于上述场景,都会采用ThreadLocal来解决,但是由于ThreadLocal在设计上的瑕疵,导致一直有以下问题:
内存泄露,在使用完ThreadLocal之后,若没有调用remove方法,会出现内存泄漏。
增加开销,在具有继承关系的线程中,子线程需要为父线程中ThreadLocal里面的数据分配内存
不是不可变对象,在方法中可以随意调用set方法篡改。
随着虚拟线程的到来,内存泄漏问题不用担心了,因为虚拟线程会很快的终止,此时会自动删除ThreadLocal中的数据,这样就不用调用remove方法了。
但是虚拟线程的数量是非常多的,假如有上百万个虚拟线程都要拷贝一份ThreadLocal中的变量(问题2),内存将会被严重的浪费掉。
示例
示例代码中通过模拟一个“送礼物”的场景,来演示ScopedValue。
在 giveGift 方法中,将礼物送出,是500元
在 receiveMiddleMan 方法模拟中间人抽成的场景,抽成后送出仅 200元
在 receiveGift 方法中 ,对 ScopedValue 进行获取,得到的就是200元
但整个过程中,中间人 receiveMiddleMan 在送出前后获取到的信息都是 500元,这正是 Scope 的含义,修改只在本作用域(方法)中生效。
/**
* @author imyzt
* @date 2023/12/20
* @description ScopedValue
*/
public class ScopedValueTest {
private static final ScopedValue<String> GIFT = ScopedValue.newInstance();
public static void main(String[] args) {
ScopedValueTest test = new ScopedValueTest();
test.giveGift();
//中间人开始: 500
//收礼物: 200
//中间人结束: 500
}
private void giveGift() {
ScopedValue.where(GIFT, "500").run(this::receiveMiddleMan);
}
private void receiveMiddleMan() {
System.out.println(STR."中间人开始: \{GIFT.get()}");
ScopedValue.where(GIFT, "200").run(this::receiveGift);
System.out.println(STR."中间人结束: \{GIFT.get()}");
}
private void receiveGift() {
System.out.println(STR."收礼物: \{GIFT.get()}");
}
}
Switch 又升级了,支持when关键字了
switch 可谓是升级升级再升级,我还是在用 if/else 啊。
从12开始,一路升级。
支持 -> 表达式(Java 12)
支持返回值(Java 12)
case支持单行写多个条件(Java 12)
新增yield关键字返回switch的值(Java 12)
引入模式匹配(Java 15)
这回又新增了一个when关键字(都快改成SQL了) (Java 21)
Java switch升级之路
/**
* @author imyzt
* @date 2023/12/20
* @description Switch 又升级了~, 这次支持when关键字了
*/
public class SwitchFuture {
void main() {
var str = "yes";
var result = switch (str) {
case null -> "空对象";
case String s
when "yes".equals(s) -> {
System.out.println("确定");
yield "字符串的Yes";
}
case String s
when "no".equals(s) -> {
System.out.println("取消");
yield "字符串的No";
}
default -> "default";
};
System.out.println(result);
//确定
//字符串的Yes
}
}
简化了main方法(注意看上一个章节的main方法)
Class声明和强制的public访问修饰符是必须的。当用在外部组件定义良好的接口封装代码单元时,它们很有用。但在这个小例子中,它们毫无意义。
String[]参数主要用于将代码与外部组件(在本例中为操作系统的shell,接收命令传入的参数)连接。它在这里很神秘且无用,尤其是它从未被使用过。
static修饰符是Java类和对象模型的一部分。对于新手来说,这不仅是神秘的,而且是有害的:要添加更多可以调用和使用的方法或字段,学生必须要么将它们全部声明(传播一种既不常见也不是好习惯的用法),或者就要面对是否有static修饰的区别问题,并学习如何实例化对象。
未命名模式和变量(Go:和我有点像)
/**
* @author imyzt
* @date 2023/12/21
* @description 像golang学习, 支持不使用的变量不命名了
*/
public class UnnamedVariable {
void main() {
List<String> list = new ArrayList<>();
list.add("first");
list.add("last");
try {
System.out.println(STR."first -> \{list.getFirst()}");
System.out.println(STR."last -> \{list.getLast()}");
System.out.println("try");
} catch (Exception _) {
System.out.println("异常了, 但是我没有 Exception");
}
}
}
有序集合
Sequenced Collections
Sequenced Collections引入了三个新接口:
SequencedCollection
SequencedMap
SequencedSet
接口都附带了一些新方法,可以提高操作集合的效率。比如:java.util.SequencedCollection#reversed等等。
集合补充了获取收尾元素的方法(hutool:当我不存在?)
List<String> list = new ArrayList<>();
list.add("first");
list.add("last");
System.out.println(STR."first -> \{list.getFirst()}");
System.out.println(STR."last -> \{list.getLast()}");
加了一些工具类
StringBuilder中的repeat方法
补充java.lang.Character#isEmoji
等等
/**
* @author imyzt
* @date 2023/12/21
* @description 加了些新方法
*/
public class SomethingExample {
void main() {
System.out.println(STR."repeat => \{new StringBuffer().repeat("*", 10)}");
//repeat => **********
var happy = "你开心吗? 😄";
System.out.println(STR."isEmoji => \{happy.codePoints().anyMatch(Character::isEmoji)}");
//isEmoji => true
}
}
Read More ~
Java 19 新特性
Java 19 最核心的特性就是虚拟线程(Virtual Threads)
简介
该特性在Java19中是预览特性,虚拟线程是用户态下的线程,和go语言中的goroutines,Erlang中的processes类似,虚拟线程并非比线程快,而是提高了应用的吞吐量,相比于传统的线程是由操作系统调度来看,虚拟线程是我们自己程序调度的线程。虚拟线程的出现并没有修改Java原有的并发模型,也不会替代原有的线程,虚拟线程主要作用是提高服务器端的吞吐量(主要解决IO密集型而非CPU密集型任务)
吞吐量的瓶颈
服务器应用程序的伸缩性受利特尔法则(Little's Law)的制约,与下面三点有关:
延迟:请求处理的耗时
并发量:同一时刻处理的请求数量
吞吐量:单位时间内处理的数据数量
如果一个服务处理延迟是50ms,处理10个并发请求,则吞吐量是200请求/秒(10/0.05),如果吞吐量要达到2000请求/秒,则处理的并发请求数量是100. 如果按照1个请求一个线程来看,要想提高吞吐量,线程数量也要增加。
Java中的线程在操作系统线程(OS Thread)进行了一层封装,而操作系统重线程是重量级资源,在硬件配置确定的前提下,不能无限制创建线程。
与虚拟地址可以映射到物理内存类似,Java将大量的虚拟线程映射到少量的操作系统线程上,虚拟线程的生命周期短暂,不会有很深的栈调用,一个虚拟线程的生命周期只运行一个任务,因此可以大量创建虚拟线程,且无需池化。
虚拟线程的应用场景
在服务器端的应用程序中,虚拟线程能够明显提高应用的吞吐量:
至少几千的并发任务量
任务是IO密集型
平台线程和虚拟线程
平台线程(platform thread):指Java中的线程,比如通过new Thread()创建的线程。
虚拟线程并不会直接分配给CPU执行,而是通过调度器分配给平台线程,平台线程再被调度器管理。Java中的虚拟线程的调度器采用了工作窃取的模式进行FIFO的操作,调度器的并行书默认是jvm获取的处理器数量(Runtime.getRuntime().availableProcessors()),调度器并非分时(time sharing)的。在使用虚拟线程编写程序时,不能控制虚拟线程合适分配给平台线程,也不能控制平台线程合适分配给CPU。
以前任务和平台线程的关系:
在使用虚拟线程之后,任务-虚拟线程-调度器-平台线程的关系,1个平台线程可以被调度器分配不同的虚拟线程:
携带器
调度器将虚拟线程挂载到平台线程之后,该平台线程叫做虚拟线程的携带器,调度器并不维护虚拟线程和携带器的关联关系,因此在一个虚拟线程的生命周期可以被分配到不同的携带器,即虚拟线程运行了一小段代码后,可能会脱离携带器,此时其他的虚拟线程会被分配到这个携带器上。
携带器和虚拟线程是相互独立的,比如:
虚拟线程不能使用携带器的标识,Thread.current()方法获取的是虚拟线程本身。
两者有各自的栈空间。
两者不能访问对方的ThreadLocal变量
在程序的执行过程中,虚拟线程遇到阻塞的操作是大部分情况下会被解除挂载,阻塞结束后,虚拟线程会被调度器重新挂载到携带器上,因此虚拟线程会频繁的挂载和解除挂载,这并不会导致操作系统线程的阻塞。
有些阻塞操作并不会导致虚拟线程接触挂载,这样会同时阻塞携带器和操作系统线程,例如:操作系统基本的文件操作,Java的Object.wait()方法,下面两种情况下不会导致虚拟线程的解除挂载:
执行synchronized同步代码(会导致携带器阻塞,所以建议使用ReentrantLock替换掉Synchronized)
执行本地方法或外部函数
虚拟线程和平台线程api的区别
从内存空间上来说,虚拟线程的栈空间可以看做是一个大块的站对象,他被存储在Java堆中,相比于单独存储对象,堆中存储虚拟线程会造成一些空间的浪费,这点在后续的Java版本中应该会得到改善,当然也有一些好处,就是可以重复利用这部分栈空间,不用多次申请开辟新的内存地址,虚拟线程的栈空间最大可以达到平台线程的栈空间容量。
虚拟线程并不是GC root,其中的引用不会出现Stop World,当虚拟线程阻塞之后比如BlockingQueue.take(),平台线程既不能获取到虚拟线程,也不能获取到queue队列,这样该平台线程可能被回收掉,虚拟线程在运行或阻塞时不会被GC
通过Thread构造方法创建的线程都是平台线程
虚拟线程是守护线程,不能通过setDaemon方法改为非守护线程
虚拟线程的优先级是默认的5,不能被修改,将来版本可能允许修改
虚拟线程不支持stop(), suspend(),resume()方法
创建虚拟线程的方式
Java中创建的虚拟线程本质都是通过Thread.Builder.OfVirtual对象进行创建的,有如下三种方式:
Thread.startVirtualThread()直接创建一个虚拟线程
Thread.ofVirtual().name("virtual-thread-").unstarted(r)
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()
示例代码
虚拟线程池
import java.time.Duration;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.IntStream;
/**
* @author imyzt
* @date 2023/12/18
* @description VirtualThread 1
*/
public class VirtualThread {
public static void main(String[] args) throws InterruptedException {
// 通过线程池创建虚拟线程池
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
IntStream.range(0, 10000000).forEach(i -> {
executor.submit(() -> {
try {
Thread.sleep(Duration.ofSeconds(1));
System.out.println("执行任务: " + i);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
});
} catch (Exception e) {
e.printStackTrace();
}
}
}
创建虚拟线程
import java.util.concurrent.TimeUnit;
/**
* @author imyzt
* @date 2023/12/18
* @description VirtualThread 2
*/
public class CreateVirtualThread {
public static void main(String[] args) throws InterruptedException {
Runnable r = () -> System.out.println(Thread.currentThread().getName() + " --- 执行了");
// 创建虚拟线程, 方式1
Thread.startVirtualThread(r);
Thread virtualThread = Thread.ofVirtual().name("virtual-thread-").unstarted(r);
virtualThread.start();
System.out.println("是虚拟线程吗? " + virtualThread.isVirtual());
Thread platformThread = Thread.ofPlatform().priority(0).daemon(true).name("platform-thread-").unstarted(r);
platformThread.start();
System.out.println("是虚拟线程吗? " + platformThread.isVirtual());
// --- 执行了
//virtual-thread- --- 执行了
//是虚拟线程吗? true
//platform-thread- --- 执行了
//是虚拟线程吗? false
// 主线程休眠
TimeUnit.SECONDS.sleep(1);
}
}
其它
除了提出虚拟线程外,还提出来新的并发编程模型结构化并发,
Read More ~