Birditch的博客 Birditch的博客

Java 里那些看着像「魔法」的东西:反射、注解和虚拟线程

反射让程序在运行时看见自己,注解让你把意图写在代码旁边,虚拟线程让「一个任务一个线程」重新变成一个能用的方案。

第一次用 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 的作者不可能提前把你的类名写进源码,只能在运行时读结构、按规则处理。

所以这些地方都少不了它:

  1. 依赖注入——找到构造器或字段,把对象塞进去

  2. ORM(对象关系映射)——把数据库的一行数据变成一个 Java 对象

  3. JSON 转换——Jackson 读你的字段,在对象和 JSON 之间来回转

  4. 插件系统——按配置或扫描结果加载第三方类

  5. 测试框架——找出带测试注解的方法然后跑起来

  6. 通用工具——IDE、调试器、对象查看器,都要读类结构

Java 官方教程里列的典型用途也是这几类:扩展机制、开发工具、调试和测试。所以反射不是「绕过 private」的小聪明,它真正擅长的是写那种事先不知道会遇到什么业务类的通用程序。

3. 那 Spring Boot 是不是就是靠反射

准确点说:反射是 Spring 的重要基础能力之一,但远不是全部。

你看到的那些自动配置,真正干活的是 IoC 容器(负责帮你创建和管理对象的那个东西)、注解模型、类路径扫描、Bean 生命周期、代理机制这一整套凑在一起的结果。

拿一个 @Service 举例,大概是这么走的:

  1. 扫描指定包下的类

  2. 认出 @Component@Service@Controller 这些注解

  3. 把符合条件的类登记成一份「Bean 定义」,相当于建档

  4. 根据构造器、字段、参数信息创建对象、注入依赖

  5. 如果涉及事务、缓存、异步,再包一层代理

这里面有反射,也有类加载、字节码元数据读取、动态代理、字节码生成。有个细节值得一提: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. 和传统线程的区别

对比项

平台线程(传统)

虚拟线程

谁来调度

操作系统

JVM,再挂到载体线程上跑

创建成本

高,数量受限

很低,可以开非常多

遇到阻塞 I/O

占住对应的系统线程

通常挂起并释放载体线程

典型用法

固定大小的线程池

一个任务一个线程

适合场景

CPU 密集、数量可控的长期任务

大量等网络 / 数据库 / 文件 I/O 的任务

能加快单次任务吗

不一定

不能。它提升的是吞吐量,不是单次快慢

建一个虚拟线程很简单:

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 有用得多。


参考资料