反射让程序在运行时看见自己,注解让你把意图写在代码旁边,虚拟线程让「一个任务一个线程」重新变成一个能用的方案。
第一次用 Spring Boot 的时候,我盯着 @Service 看了挺久。类上就加了这么一行,对象凭空出现了;字段上写个 @Autowired,依赖也自己进来了。没有 new,没有工厂,东西就在那儿了。
写过 Minecraft 插件的人应该更有体会。Paper 能从一个 JAR 里找到你的插件入口,Velocity 能认出 @Plugin、@Inject、@Subscribe,然后替你把对象建好、依赖注好、事件注册好。而你只写了几个注解。
这类「魔法」拆开看,底下基本都是同一对搭档:反射和注解。
Java 21 又加进来一个分量不轻的东西——虚拟线程。它和前两个不是一路技术,但同样在改变 Java 代码的写法。
下面把这三个东西分别说清楚。基础概念我会顺手解释一下,没接触过框架源码也能跟着读。
一、反射:让程序在运行时看见自己
1. 它到底是什么
平时写 Java,你在敲代码的时候就已经知道自己要用什么了:
HelloService service = new HelloService();
service.sayHello("Birditch");编译器认识 HelloService,也知道它有没有 sayHello 这个方法。名字写错了,代码根本编译不过去。
但有些代码没这个待遇。类名可能写在配置文件里,可能来自用户往目录里丢的一个插件 JAR——你编译的时候,那个类压根还不存在。这时候就得靠反射。
一句话:反射就是让程序在运行期间读取一个类的结构,并且动手操作它。
这里的「运行期间」和「编译期间」是两个阶段:编译期是你构建项目那会儿,运行期是程序已经跑起来了。反射干活的舞台是后者。
通过反射,程序能知道:
这是哪个类
它有哪些字段、方法、构造器
某个方法上挂了什么注解
实现了哪些接口、继承自谁
然后:创建实例、调用方法、读写字段
Class<?> clazz = Class.forName("com.example.HelloService");
Object service = clazz.getDeclaredConstructor().newInstance();
Method method = clazz.getDeclaredMethod("sayHello", String.class);
method.invoke(service, "Birditch");全程没有 new HelloService(),只有一个字符串。从字符串找到类,再找到构造器和方法,最后调用。
打个比方:普通调用是「我知道该去找谁办事」,反射是「我到了现场,按名单临时找人」。
2. 它解决什么问题
反射的核心价值就俩字:通用。
你的业务代码只需要处理那几个确定的类。框架不一样,它要面对成千上万个这辈子没见过的类。Spring 的作者不可能提前把你的类名写进源码,只能在运行时读结构、按规则处理。
所以这些地方都少不了它:
依赖注入——找到构造器或字段,把对象塞进去
ORM(对象关系映射)——把数据库的一行数据变成一个 Java 对象
JSON 转换——Jackson 读你的字段,在对象和 JSON 之间来回转
插件系统——按配置或扫描结果加载第三方类
测试框架——找出带测试注解的方法然后跑起来
通用工具——IDE、调试器、对象查看器,都要读类结构
Java 官方教程里列的典型用途也是这几类:扩展机制、开发工具、调试和测试。所以反射不是「绕过 private」的小聪明,它真正擅长的是写那种事先不知道会遇到什么业务类的通用程序。
3. 那 Spring Boot 是不是就是靠反射
准确点说:反射是 Spring 的重要基础能力之一,但远不是全部。
你看到的那些自动配置,真正干活的是 IoC 容器(负责帮你创建和管理对象的那个东西)、注解模型、类路径扫描、Bean 生命周期、代理机制这一整套凑在一起的结果。
拿一个 @Service 举例,大概是这么走的:
扫描指定包下的类
认出
@Component、@Service、@Controller这些注解把符合条件的类登记成一份「Bean 定义」,相当于建档
根据构造器、字段、参数信息创建对象、注入依赖
如果涉及事务、缓存、异步,再包一层代理
这里面有反射,也有类加载、字节码元数据读取、动态代理、字节码生成。有个细节值得一提:Spring 扫描类路径时,某些阶段是直接读 class 文件里的元数据的,并不会把每个候选类都先加载进 JVM——不然启动会慢得多。
所以「Spring 大量用了反射」没问题,「Spring Boot 就是反射」就太糙了。
4. Minecraft、Paper、Velocity 这一套
Minecraft 服务端插件生态确实离不开动态加载,但得把类加载、反射、注解处理这三件事分开看。
Paper 的传统插件在 plugin.yml 里写死主类全名:
name: ExamplePlugin
version: 1.0.0
main: com.example.ExamplePlugin服务端读完配置,用插件专属的类加载器(ClassLoader)找到这个类,再创建实例。Velocity 走的是另一条路:认插件类上的 @Plugin,用 @Inject 做构造器注入,用 @Subscribe 找事件监听方法。
这些流程通常是类加载 + 注解元数据 + 依赖注入 + 反射混着用的。另外,不少跨版本插件会用反射去摸 Minecraft 的内部实现,也就是常说的 NMS,好让代码不那么死死绑在某个版本的类名和方法签名上。
但要说「Minecraft、Paper、Velocity 底层全是反射」,这话太满了。更准确的讲法是:
Java 的 Minecraft 插件生态大量使用动态类加载、注解和反射,尤其是在插件发现、对象创建、依赖注入、事件注册和跨版本兼容这些环节。
顺带说一句代价:去碰内部实现意味着绕过了编译期检查。游戏一升版本,字段改个名、模块边界一变,你的代码就等着在运行时炸。能用稳定 API 的时候,还是老老实实用 API。
5. 反射不是白嫖的
它很强,但不该到处用。
普通调用编译期就能查错,反射里的类名方法名写错了,得等程序跑起来才知道
代码更难读,IDE 的「查找引用」和重构基本失效
调用频繁的话有额外开销
Java 的模块系统(JPMS)会拦住对非公开成员的深度反射
硬闯私有实现会破坏封装,以后升级很痛苦
如果接口、多态或者普通的对象组合就能解决,别为了显得高级上反射。它更适合藏在框架和基础设施里,而不是散落在业务代码中间。
二、注解:给代码贴上机器看得懂的标签
1. 它本质上是一份说明
注解说白了就是一份结构化的元数据。「元数据」听着玄,其实就是「描述数据的数据」——放在这儿,就是「描述代码的信息」。
它不会因为你写在代码上就自己跑起来。@Transactional 自己不会开事务,@Autowired 自己也不会往字段里塞东西。真正干活的是编译器、注解处理器或者框架。注解只负责告诉处理它的人:
这段代码有某种含义,请按对应规则处理。
它在三个阶段发挥作用:
编译期——比如
@Override,让编译器帮你确认这方法真的重写了父类的东西构建期——注解处理器生成代码或配置文件,Lombok、MapStruct 走的就是这条路
运行期——Spring、JUnit 读运行时注解,再决定怎么创建对象、怎么调方法
这里有个容易被忽略的点:不是所有注解都建立在反射之上。
注解有三种「保留级别」,决定它能活到哪一步:SOURCE 级别的编译完就没了;CLASS 级别的会进 class 文件,但默认不会在运行时暴露出来;只有 RUNTIME 级别的,才会在程序跑起来后被反射读到。
2. 为什么它让代码变清爽
我觉得注解好用,重点不在于它字少,而在于它把代码从「描述过程」变成了「表达意图」。
不用注解的时候,你得手动维护一张注册表:
commandManager.register("hello", commandHandler::hello);
commandManager.register("stop", commandHandler::stop);用了注解,含义直接贴在方法旁边:
@Command("hello")
public void hello() {
System.out.println("Hello!");
}读代码的人不用再跳到另一个注册文件里对照,一眼就知道这方法是干嘛的。框架也能统一扫描注册。
好处挺直接的:
少写样板——不用到处手动注册、查找、装配
意图贴着代码——事务、校验、权限就写在目标方法旁边
通用逻辑分离——业务方法只管业务,日志、事务、缓存交给框架
便于立约定——框架按统一规则扫描,项目结构更一致
可组合——Spring 允许你把好几个注解打包成一个更贴合业务语义的自定义注解
3. 它怎么和反射搭配
一个最简的命令注解:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Command {
String value();
}@Retention(RUNTIME) 就是上面说的保留级别,意思是「这个注解要留到运行时」;@Target(METHOD) 限定它只能贴在方法上。
贴到方法上:
public class AdminCommands {
@Command("reload")
public void reload() {
System.out.println("配置已重新加载");
}
}然后用反射找出来执行:
AdminCommands target = new AdminCommands();
for (Method method : target.getClass().getDeclaredMethods()) {
Command command = method.getAnnotation(Command.class);
if (command != null && command.value().equals("reload")) {
method.invoke(target);
}
}这就是绝大多数注解框架最朴素的工作方式:注解负责声明规则,反射负责把它找出来并执行。
成熟框架当然不会每次都这么裸扫,还得加缓存、代理、异常处理、参数解析、生命周期管理。但内核就是这么回事。
4. 注解也是会被玩坏的
它的好,前提是表达的东西足够稳定、足够清楚。
如果一个注解背后藏了十几步业务操作,读代码的人只看方法本身根本猜不出程序在干什么。注解叠太多层,调用链也会变得极其隐蔽——出问题的时候你只能在框架和代理之间反复横跳。
我自己的判断标准是:
注解适合表达「这是什么」或者「该守什么通用规则」,不适合用来藏一整段复杂的业务流程。
事务、权限、校验、缓存、路由、事件监听,这些用注解很合适。一套带着大量条件分支的核心业务流程,还是老老实实写成普通代码更清楚。
三、虚拟线程:线程终于可以随便建了
1. 它是什么
虚拟线程是由 JVM 调度的轻量级线程,Java 21 正式转正。
它仍然是 java.lang.Thread,写法上和你熟悉的线程没有断层。最大的区别在这:传统线程(也叫平台线程)通常长期绑着一个操作系统线程,而虚拟线程不需要一直占着。
先说清楚一个词:阻塞 I/O,说白了就是程序卡在那儿等外部结果——等网络回包、等数据库返回、等文件读完。这段时间 CPU 其实闲着,只是线程走不动。
当一个虚拟线程跑到这种阻塞 I/O,JVM 可以先把它挂起,让底下那个平台线程(专业点叫载体线程,carrier thread)去跑别的虚拟线程。等 I/O 好了,再让它接着往下走。
可以这么想:载体线程是少数几个「干活的人」,虚拟线程是一大堆「任务单」。某张单子要等外部结果,干活的人不用站那儿陪着等,先去处理别的单子就行。
2. 和传统线程的区别
建一个虚拟线程很简单:
Thread.startVirtualThread(() -> {
System.out.println("运行在线程:" + Thread.currentThread());
});更常见的是每个任务一个:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
Future<String> user = executor.submit(() -> loadUser());
Future<String> orders = executor.submit(() -> loadOrders());
System.out.println(user.get());
System.out.println(orders.get());
}注意这段代码——它还是同步的、从上往下读的那种风格,但线程成本极低,可以同时等一堆 I/O。这才是虚拟线程最香的地方:你不用为了并发去改写代码风格。
3. 那以前为什么非得用线程池
因为平台线程贵。每来一个请求就建一个线程,并发一高,内存和操作系统资源很快就撑不住。
所以过去的解法是「池化」:提前建好固定数量的线程反复使用,让大量任务排队复用它们。
ExecutorService pool = Executors.newFixedThreadPool(200);
pool.submit(this::handleRequest);线程池确实解决了「线程不能无限建」的问题,但也带来一堆新问题:
线程数到底配多少?
队列开多大?
队列满了怎么拒绝?
明明一堆线程都卡在等 I/O、CPU 空得很,新任务却还得排队
虚拟线程换了个思路:既然线程能做得足够轻,那就别复用了。一个并发任务配一个虚拟线程,任务结束,线程也结束。
所以官方明确建议不要池化虚拟线程。newVirtualThreadPerTaskExecutor() 虽然返回的是 ExecutorService,看着像线程池,但它不是「固定数量的虚拟线程池」,而是每提交一个任务就起一个新的。
4. 不池化线程,不等于不限制资源
这是虚拟线程最容易被误读的地方。
线程可以很多,但数据库连接、远程接口配额、内存、CPU 都还是有限的。你的数据库连接池只有 20 个连接,那就算开 10 万个虚拟线程,数据库也不会同时跑 10 万条 SQL——多出来的照样得排队等连接。
需要限制某类操作的并发量,继续用连接池,或者上 Semaphore(信号量,可以理解成一沓有限的通行证,拿到才能进,用完还回来):
Semaphore permits = new Semaphore(20);
String query() throws Exception {
permits.acquire();
try {
return callDatabase();
} finally {
permits.release();
}
}一句话:虚拟线程替代的是「为了省线程而搞的线程池」,不是替代数据库连接池、HTTP 连接池和各种限流机制。
5. 它真的补上了 Java 并发的短板吗
我的看法是:它确实缓解了 Java 在高并发阻塞式服务上的一个老毛病,但别理解成「Java 以前并发很差」。
Java 很早就有成熟的线程、锁、并发容器、线程池和各种异步工具。问题从来不是「不能并发」,而是平台线程太重,「一个请求一个线程」这个最直观的模型在连接数极高时扩不上去。
后来大家用回调、CompletableFuture、响应式编程来减少线程空等。这些方案能扛住高并发,代价是把一段本来顺着读下来的逻辑拆成好几段,理解、调试、异常处理的成本都上去了。
虚拟线程的意义,是让你在大量 I/O 密集的场景里重新用回那种直观的同步写法,同时还能拿到很高的并发规模。它提升的是吞吐量和资源利用率——也就是同一时间能扛住多少活,而不是让一次查询从 100 毫秒变成 10 毫秒。
如果你的任务主要在算哈希、压视频、训模型、跑复杂算法,那瓶颈是 CPU 核心数。这种情况下开再多虚拟线程也变不出 CPU 来,甚至可能因为调度反而多出开销。
一句话总结:
虚拟线程擅长解决「大家都在等」的问题,不擅长解决「大家都在算」的问题。
6. 用之前还得注意几件事
别看见「轻量」就无上限接请求,外部资源该限流还是得限流
别在
ThreadLocal里给每个虚拟线程缓存昂贵的大对象——线程一多,内存直接起飞某些本地方法调用会让虚拟线程被「钉」在载体线程上(这个现象叫 pinning),扩展性打折
Java 21 里,长时间卡在
synchronized块里的阻塞操作也会造成 pinning;Java 24 改进了这块,绝大多数这类情况已经消除别凭感觉迁移,压测看数据:吞吐量、延迟、连接池等待、内存占用
用 Spring Boot 的话,Java 21 及以上开一个配置就行:
spring.threads.virtual.enabled=true但开关打开不等于系统自动变快。能不能受益,取决于你的应用是不是以大量阻塞 I/O 为主,以及数据库、下游服务、连接池扛不扛得住更高的并发。
四、放在一起看
这三个东西解决的是三类完全不同的问题:
反射 解决「运行时怎么认识和操作一个未知类型」
注解 解决「怎么用简洁、结构化的方式表达意图」
虚拟线程 解决「怎么用简单的线程模型扛住大量并发等待」
前两个是一对:反射给框架动态能力,注解给这份能力定规矩。于是 Spring 能扫 @Service,Velocity 能认 @Plugin 和 @Subscribe,JUnit 能找到你的测试方法。
虚拟线程是另一个层面的事,它把并发的复杂度还给了 JVM。以前为了省线程要设计线程池、要拆异步调用链;现在在合适的 I/O 场景里,可以写回那种能一口气读完、也能正常打断点的同步代码。
三者其实指向同一个方向:把重复的、通用的、容易写错的部分交给平台和框架,让人把注意力放回业务本身。
但「魔法」越顺手,越得知道它的边界在哪。反射别滥用,注解别拿来藏复杂流程,虚拟线程也不是万能的性能开关。搞清楚它们为什么有效、在什么情况下才有效,比背下 API 有用得多。