JDK21 虚拟线程的实现原理和应用

本文核心内容参考自得物技术,内容有删减和补充

背景

本文将详细介绍虚拟线程的使用场景,实现原理,及虚拟线程在应用层面的的优缺点

串行模式

在 Web 中我们最常见的请求模型就是使用一请求一线程的模型,每个请求都由单独的线程处理。此模型易于理解和实现,对编码的可读性,Debug 都非常友好,但是,它有一些缺点。当线程执行阻塞操作(如连接到数据库或进行网络调用)时,线程会被阻塞,直到操作完成,这意味着线程在此期间将无法处理任何其他请求。当遇到大促或突发流量等场景导致服务承受的请求数增大时,为了保证每个请求在尽可能短的时间内返回,减少等待时间,我们经常会采用以下方案:

  1. 扩大服务最大线程数: 简单有效,但由于存在下列问题,导致平台线程有最大数量限制,不能大量扩充。
  • 每个平台线程默认都会开辟一块大小约 1m 私有的栈空间,大量平台线程会占据大量内存。
  • 系统资源有限导致系统线程总量有限,进而导致与系统线程一一对应的平台线程有限。
  • 平台线程的调度依赖于系统的线程调度程序,当平台线程创建过多,会消耗大量资源用于处理线程上下文切换。
  • 垂直扩展,升级机器配置,水平扩展,增加服务节点,也就是俗称的升配扩容大法,效果好,也是最常见的方案,缺点是会增加成本,同时有些场景下扩容并不能 100% 解决问题。
  • 采用异步/响应式编程方案,例如 RPC NIO 异步调用,WebFlux,Rx-Java 等非阻塞的基于 Ractor 模型的框架,使用事件驱动使得少量线程即可实现高吞吐的请求处理,拥有较好的性能与优秀的资源利用,缺点是学习成本较高兼容性问题较大,编码风格与目前的一请求一线程的模型差异较大,理解难度大,同时对于代码的调试比较困难。

那么有没有一种方法可以易于编写,方便迁移,符合日常编码习惯,同时性能很不错,CPU 资源利用率较高的方案呢?

JDK21 中的虚拟线程可能给出了答案 , JDK 提供了与 Thread 完全一致的抽象 Virtual Thread 来应对这种经常阻塞的情况,阻塞仍然是会阻塞,但是换了阻塞的对象,由昂贵的平台线程阻塞改为了成本很低的虚拟线程的阻塞,当代码调用到阻塞 API 例如 IO,同步,Sleep 等操作时,JVM 会自动把 Virtual Thread 从平台线程上卸载 ,平台线程就会去处理下一个虚拟线程,通过这种方式,提升了平台线程的利用率,让平台线程不再阻塞在等待上,从底层实现了少量平台线程就可以处理大量请求,提高了服务吞吐和 CPU 的利用率。

概念介绍

线程术语定义

操作系统线程(OS Thread):由操作系统管理,是操作系统调度的基本单位。

平台线程(Platform Thread):Java.Lang.Thread 类的每个实例,都是一个平台线程,是 Java 对操作系统线程的包装,与操作系统是 1:1 映射。

虚拟线程(Virtual Thread):一种轻量级,由 JVM 管理的线程。对应的实例 java.lang.VirtualThread 这个类。

载体线程(Carrier Thread):指真正负责执行虚拟线程中任务的平台线程。一个虚拟线程装载到一个平台线程之后,那么这个平台线程就被称为虚拟线程的载体线程。

虚拟线程定义

JDK 中 java.lang.Thread 的每个实例都是一个平台线程。平台线程在底层操作系统线程上运行 Java 代码,并在代码的整个生命周期内独占操作系统线程,平台线程实例本质是由系统内核的线程调度程序进行调度,并且平台线程的数量受限于操作系统线程的数量

而虚拟线程(Virtual Thread)它不与特定的操作系统线程相绑定 。它在平台线程上运行 Java 代码,由 JDK 负责调度。但在代码的整个生命周期内不独占平台线程。**这意味着许多虚拟线程可以在同一个平台线程上运行他们的 Java 代码,共享同一个平台线程。**同时虚拟线程的成本很低,虚拟线程的数量可以比平台线程的数量大得多。

虚拟线程创建

方法一:直接创建虚拟线程

ini 复制代码
Thread vt = Thread.startVirtualThread(() -> {
    System.out.println("hello wolrd virtual thread");
});

方法二:创建虚拟线程但不自动运行,手动调用start()开始运行

scss 复制代码
Thread.ofVirtual().unstarted(() -> {
    System.out.println("hello wolrd virtual thread");
});
vt.start();

方法三:通过虚拟线程的 ThreadFactory 创建虚拟线程

ini 复制代码
ThreadFactory tf = Thread.ofVirtual().factory();
Thread vt = tf.newThread(() -> {
    System.out.println("Start virtual thread...");
    Thread.sleep(1000);
    System.out.println("End virtual thread. ");
});
vt.start();

方法四:Executors.newVirtualThreadPer -TaskExecutor()

ini 复制代码
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
executor.submit(() -> {
    System.out.println("Start virtual thread...");
    Thread.sleep(1000);
    System.out.println("End virtual thread.");
    return true;
});

实现原理

虚拟线程的本质是可挂起的计算任务

本段参考自:得物技术 www.vlts.cn/post/virtua...

虚拟线程是一种轻量级(用户模式)线程,这种线程是由Java虚拟机调度,实现代码都在JDK 层面,而不是操作系统。总体来看,虚拟线程实现如下:

shell 复制代码
virtual thread = continuation + scheduler(ForkJoinPool)

虚拟线程会把任务(一般是java.lang.Runnable)包装到一个Continuation实例中:

  • 当任务需要阻塞挂起的时候,会调用 Continuationyield 操作进行阻塞,虚拟线程会从平台线程卸载。
  • 当任务解除阻塞继续执行的时候,调用 Continuation.run 会从阻塞点继续执行。

Scheduler也就是执行器,会把任务提交到一个载体线程池中执行:

  • 执行器是java.util.concurrent.Executor的子类,可以在 VirtualThread 创建的时候指定
  • 虚拟线程框架提供了一个默认的 FIFO 的 ForkJoinPool 用于执行虚拟线程任务。对应实现 VirtualThread#DEFAULT_SCHEDULER

操作系统调度系统线程,而Java平台线程与系统线程一一映射,所以平台线程被操作系统调度,但是虚拟线程是由JVM调度。JVM 把虚拟线程分配给平台线程的操作称为 mount(挂载),取消分配平台线程的操作称为 unmount(卸载):

  • mount 操作:虚拟线程挂载到平台线程,虚拟线程中包装的 Continuation 堆栈帧数据会被拷贝到平台线程的线程栈,这是一个从堆复制到栈的过程。

  • unmount 操作:虚拟线程从平台线程卸载,此时虚拟线程的任务还没有执行完成,所以虚拟线程中包装的 Continuation 栈数据帧会会留在堆内存中。

这个mount -> run -> unmount过程实现在 VirtualThread#runContinuation ,用伪代码表示如下:

java 复制代码
mount();
try {
    Continuation.run();
} finally {
    unmount();
}

虚拟线程和线程池的区别

看上去虚拟线程和线程池有类似之处,都是利用M个内核线程,完成N个任务,而避免平台线程频繁的创建和销毁。但他们是有本质区别的:

  • 线程池中的正在执行的任务只有到任务执行完成后,才会释放平台线程,如果某个任务在执行过程中发生IO阻塞也不会被挂起执行其他任务。
  • 虚拟线程中运行的代码调用阻塞I/O操作时,Java运行时会挂起虚拟线程,然后切换到另一个可执行的虚拟线程,直到它可以恢复为止。

Continuation

Continuation 的核心能力是对 Runnable 任务的包装,先看成员变量和构造函数:

java 复制代码
public class Continuation {
    // 判断是否需要保留当前线程的本地缓存,由系统参数jdk.preserveExtentLocalCache决定
    private static final boolean PRESERVE_SCOPED_VALUE_CACHE;

    // 真正要被执行的任务实例
    private final Runnable target;

    // 标识Continuation的范围,
    private final ContinuationScope scope;

    // Continuation的父节点,如果为空的时候则为本地线程栈
    private Continuation parent;

    // Continuation的子节点,非空时候说明在子Continuation中进行了yield操作
    private Continuation child;

    // 猜测为Continuation栈结构,由JVM管理,无法得知其真实作用
    private StackChunk tail;

    // 标记Continuation是否已经完成
    private boolean done;

    // 标记是否进行了mount操作
    private volatile boolean mounted = false;

    // yield操作时候设置的信息
    private Object yieldInfo;

    // 标记一个未挂载的Continuation是否通过强制抢占式卸载
    private boolean preempted;

    // 保留当前线程的本地缓存的副本
    private Object[] extentLocalCache;

    // 构造函数,要求传入范围和任务包装实例
    public Continuation(ContinuationScope scope, Runnable target) {
        this.scope = scope;
        this.target = target;
    }
}

Continuation 组件十分重要,它是用户真实任务的包装器,同时提供了虚拟线程任务暂停/继续的能力,以及虚拟线程与平台线程数据转移功能(栈帧、局部变量)。当任务需要阻塞挂起的时候,调用 Continuation 的 yield 操作进行阻塞。当任务需要解除阻塞继续执行的时候,则调用 Continuation 的 run 恢复执行。

graph TD A[阻塞操作] --> B{虚拟线程运行} B -->|I/O阻塞| C[保存状态到堆] C --> D[释放载体线程] D --> E[调度其他虚拟线程] E -->|I/O完成| F[恢复状态] F --> B

虚拟线程中任务执行时候调用 Continuation#run() 先执行了部分任务代码,然后尝试获取锁 ,该操作是阻塞操作会导致 Continuation 的 yield 操作让出控制权,如果 yield 操作成功,会从载体线程 unmount,载体线程栈数据会移动到 Continuation 栈的数据帧中,保存在堆内存中,虚拟线程任务完成,此时虚拟线程和 Continuation 还没有终结和释放,载体线程被释放到执行器中等待新的任务;如果 Continuation 的 yield 操作失败,则会对载体线程进行 Park 调用,阻塞在载体线程上,此时虚拟线程和载体线程同时会被阻塞,本地方法,synchronized 修饰的同步方法都会导致 yield 失败。

当锁持有者释放锁之后,会唤醒虚拟线程获取锁 ,获取锁成功后,虚拟线程会重新进行 mount,让虚拟线程任务再次执行,此时有可能是分配到另一个载体线程中执行,Continuation 栈会的数据帧会被恢复到载体线程栈中,然后再次调用Continuation#run() 恢复任务执行。

  • 虚拟线程任务执行完成,标记 Continuation 终结,标记虚拟线程为终结状态,清空上下文变量,解除载体线程的挂载载体线程返还到调度器(线程池)中作为平台线程等待处理下一个任务。

VirtualThread

VirtualThread的核心成员变量和方法如下:

java 复制代码
final class VirtualThread extends BaseVirtualThread {

    // 调度器,或者说执行器,默认就是用此调度器运行虚拟线程
    private static final ForkJoinPool DEFAULT_SCHEDULER = createDefaultScheduler();
    // 调度线程池实例,用于唤醒带超时阻塞的虚拟线程实例,主要用于sleep的唤醒
    private static final ScheduledExecutorService UNPARKER = createDelayedTaskScheduler();

    // pin模式,也就是pined thread的跟踪模式,决定打印堆栈的详细程度,
    // 来自于系统参数jdk.tracePinnedThreads,full表示详细,short表示简略
    private static final int TRACE_PINNING_MODE = tracePinningMode();

    // 调度器实例
    private final Executor scheduler;
    // Continuation实例
    private final Continuation cont;
    // Continuation实例的Runnable包装实例
    private final Runnable runContinuation;

    // 虚拟线程状态,这个值由JVM访问和修改
    private volatile int state;
    // 运载线程实例
    private volatile Thread carrierThread;
    // 终结倒数栅栏实例,主要用于join操作
    private volatile CountDownLatch termination;

    // 唯一构造函数
    VirtualThread(Executor scheduler, String name, int characteristics, Runnable task) {
        // 默认标记bound为false,当bound为true的时候标记为绑定到系统线程
        super(name, characteristics, /*bound*/ false);
        Objects.requireNonNull(task);
        // 如果传入的调度器实例非空则直接使用
        // 否则,如果父线程是虚拟线程,则使用父虚拟线程的调度器实例
        // 如果传入的调度器实例为空,父线程为平台线程,那么使用默认的调度器
        // choose scheduler if not specified
        if (scheduler == null) {
            Thread parent = Thread.currentThread();
            if (parent instanceof VirtualThread vparent) {
                scheduler = vparent.scheduler;
            } else {
                scheduler = DEFAULT_SCHEDULER;
            }
        }
        // 赋值调度器
        this.scheduler = scheduler;
        // 封装和初始化Continuation
        this.cont = new VThreadContinuation(this, task);
        // 初始化Continuation的Runnable包装器,最终提交到调度器中执行
        this.runContinuation = this::runContinuation;
    }

    // 虚拟线程Continuation的专有子类,默认为ContinuationScope("VirtualThreads"),
    // 从而实现Continuation.enter()执行时候实际上执行的是VirtualThread.run()方法
    // 也就是 Runnable.run()[runContinuation by carrier thread from executor] 
    //          --> Continuation.run() 
    //              --> Continuation.enter() 
    //                  --> VirtualThread.run()
    //                      -->  Runnable.run()[user task]
    private static class VThreadContinuation extends Continuation {
        VThreadContinuation(VirtualThread vthread, Runnable task) {
            super(VTHREAD_SCOPE, () -> vthread.run(task));
        }

        // pin之前回调的方法,基于TRACE_PINNING_MODE的返回值决定pinned线程栈的打印详略
        @Override
        protected void onPinned(Continuation.Pinned reason) {
            if (TRACE_PINNING_MODE > 0) {
                boolean printAll = (TRACE_PINNING_MODE == 1);
                PinnedThreadPrinter.printStackTrace(System.out, printAll);
            }
        }
    }

    // 在当前线程上运行或继续Continuation的执行,必须由平台线程运行此方法,
    // 最终会封装为Runnable包装器提交到执行器中运行
    private void runContinuation() {
        // the carrier must be a platform thread
        if (Thread.currentThread().isVirtual()) {
            throw new WrongThreadException();
        }

        // set state to RUNNING
        boolean firstRun;
        int initialState = state();
        // 当前为STARTED状态并且CAS更新为RUNNING状态则标记首次运行为true
        if (initialState == STARTED && compareAndSetState(STARTED, RUNNING)) {
            // first run
            firstRun = true;
        } else if (initialState == RUNNABLE && compareAndSetState(RUNNABLE, RUNNING)) {
            // 当前为RUNNABLE状态并且CAS更新为RUNNING状态则标记首次运行为false,并且设置park许可为false
            // consume parking permit
            setParkPermit(false);
            firstRun = false;
        } else {
            // not runnable
            return;
        }

        // notify JVMTI before mount
        if (notifyJvmtiEvents) notifyJvmtiMountBegin(firstRun);

        try {
            // 执行Continuation.run()
            cont.run();
        } finally {
            // Continuation执行完成,回调钩子方法afterTerminate
            if (cont.isDone()) {
                afterTerminate(/*executed*/ true);
            } else {
                // Continuation没有执行完成,说明调用了Continuation.yield或者pin到运载线程中进行了park操作
                afterYield();
            }
        }
    }
}

DEFAULT_SCHEDULER :对于虚拟线程的默认调度器的创建,它是一个ForkJoinPool实例,构造参数的选取如下:

  • parallelism参数由系统变量jdk.virtualThreadScheduler.parallelism决定,默认值为Runtime.getRuntime().availableProcessors(),即 CPU 核心数。如果配置了系统参数jdk.virtualThreadScheduler.maxPoolSize则取min(parallelism,maxPoolSize)
  • maxPoolSize参数由系统变量jdk.virtualThreadScheduler.maxPoolSize决定,默认值为max(parallelism, 256)
  • minRunnable参数由系统变量jdk.virtualThreadScheduler.minRunnable决定,默认值为max(parallelism / 2, 1)
  • asyncMode参数固定值true,也就是选用FIFO模式
  • keepAliveTime参数为固定值30
  • saturate参数在JDK17引入,是一个Predicate函数,在此固定返回true,用于忽略minRunnable值允许线程池饱和
  • 线程工厂用于创建CarrierThread实例,CarrierThreadForkJoinWorkerThread的子类

UNPARKER :对于调度线程池的创建,它是一个ScheduledThreadPoolExecutor实例,构造参数的选取如下:

  • corePoolSize参数由系统变量jdk.unparker.maxPoolSize决定,并且确保最小值为1
  • 线程工厂用于创建InnocuousThread实例,线程名称为VirtualThread-unparker

VirtualThread 源码中还可以发现,run()方法覆盖了Thread#run()替换为空实现,因为VirtualThread最终是触发Continuation#run(),这一点已经在start()方法进行提交和调度。

虚拟线程的阻塞

分析虚拟线程的阻塞(不带超时,也就是timeout = 0)、限时阻塞(timeout > 0)、join的实现。总的来说就是:

  • 阻塞:通过Continuation.yield()调用实现阻塞,主要是提供给Thread.sleep()调用
  • 限时阻塞:Continuation.yield()调用之前计算唤醒时间并且向调度线程池(UNPARKER)提交一个「延时执行」的unpark任务通过"懒提交"方式重新运行Continuation.run()调用链解除阻塞,主要是提供给Thread.sleep(long nanos)调用
  • join(Nanos):通过CountDownLatch.await()调用实现阻塞,在虚拟线程终结钩子方法afterTerminate()中调用CountDownLatch.countDown()解除阻塞,join(Nanos)()方法主要是提供给Thread.join()调用
  • 特殊情况:如果Continuation.yield()调用失败,则会通过Unsafe提供的park API阻塞在运载线程上,在unpark任务中通过Unsafe提供的unpark API解除阻塞

分析完虚拟线程实现的核心代码,这里总结一下虚拟线程的状态切换,由于支持的状态比较多,这里通过一张状态图进行展示:

值得注意的是:虚拟线程实现上来看都是"守护线程",也就是说虚拟线程不需要设置daemon参数。平台线程或者虚拟线程的建造器或者工厂实现都是包访问权限的内部类,其父类使用了permits关键字指定继承范围,目前是只能通过链式设置值的方式初始化,无法修改其中的成员或者方法

其他关键点

  • 栈内存暂存:在涉及到虚拟线程切换的时候,如何保存当前虚拟线程的现场(保存到StackChunk)
    • JVM 层面调用 Continuation#doYield 方法,这个方法是 C++实现,主要保存栈帧和程序计数器

IO 类针对 JDK21 做的适配

默认调度器(ForkJoinPool)如何实现任务窃取的,关键方法在?(何时、如何从任务队列中拉取任务)

优势

内存占用少

单个平台线程的资源占用:

  • 根据 JVM 规范,预留 1 MB 线程栈空间。这个空间大小可以通过 jvm 参数配置
  • 平台线程实例,会占据 2000+ byte 数据。

单个虚拟线程的资源占用:

  • Continuation 栈会占用数百 byte 到数百 KB 内存空间,是作为堆栈块对象存储在 Java 堆中。具体的大小取决于对应虚拟线程在栈帧中的实际活跃内存大小
  • 虚拟线程实例会占据 200 - 240 byte 数据。

从对比结果来看,理论上单个平台线程占用的内存空间大于 1MB,而单个虚拟线程实例占用的内存空间可以是 byte 级别,实际两者的内存占用差距取决于虚拟内存占用的栈帧实际大小,通常来说虚拟内存占用远小于 1MB。这也是虚拟线程可以大批量创建的原因。

非阻塞

在需要非阻塞I/O操作时,它们能够在等待I/O时挂起,并允许JVM将CPU时间分配给其他任务,从而提高了吞吐量。

线程调度开销

由于虚拟线程在 JVM 层面进行调用,不涉及操作系统级别的上下文切换,因此线程调度开销会少很多。

已知问题

synchronized 块/方法

原因:synchronized 关键字是 Java 内置的同步机制,其锁操作直接绑定到平台线程(OS 线程)。 若虚拟线程在 synchronized 块内发生阻塞(如 I/O、锁等待),JVM无法挂起该虚拟线程,因为它需要保持平台线程对监视器锁(Monitor)的持有。

  • synchronized在jvm的实现是依赖于对象的监视器,当方法进入synchronized函数后,jvm将会记录持有对象监视器对应线程。
  • 由于记录的是平台线程,所以如果在虚拟线程A进入对象obj的synchronized函数后,如果没有Pin住Carrier Thread,此时另一个虚拟线程B也被调度到了同样的Carrier Thread上执行对象obj的其他synchronized函数,此时jvm会认为虚拟线程B已经获取了对象的监视器,从而不阻塞直接进入函数内部,导致并发问题。
  • 最极端的情况,如果所有的Carrier Thread都被Pin住,而synchronized外仍然有其他线程与之抢占资源,则会发生死锁。

解决方案:

  • Jdk 21-23:使用JUC中的锁(比如ReentrantLock)来替换掉synchronized。包括外部依赖,比如依赖服务的SDK、使用的平台框架、中间件的SDK等,都需要使用替换掉synchronized的版本。
  • 升级到 JDK24+:jdk24对**底层synchronized,Object.wait()以及notify()函数进行了大量的重写,经过jdk开发团队的努力,synchronized已经不会导致Carrier Thread被Pin了。JEP 491: Synchronize Virtual Threads without Pinning

案例:blog.csdn.net/a417930422/...

ThreadLocal 问题

目前虚拟线程仍然是支持 ThreadLocal 的,但是由于虚拟线程的数量非常多,会导致 Threadlocal 中存的线程变量非常多,需要频繁 GC 去清理,对性能会有影响,

解决方案:官方建议尽量少使用 ThreadLocal,同时不要在虚拟线程的 ThreadLocal 中放大对象,目前官方是想通过 ScopedLocal 去替换掉 ThreadLocal,但是在 21 版本还没有正式发布,这个可能是大规模使用虚拟线程的一大难题

调用 Native 方法或 JNI 代码

  • Native 方法(通过 JNI 调用的 C/C++ 代码)可能直接操作平台线程的状态,或执行无法被 JVM 控制的阻塞操作。 JVM 无法感知 Native 方法中的阻塞行为,因此必须固定 Carrier Thread。
  • 和上面的情况类似,如果JNI函数非常的耗时,直接阻塞导致Carrier Thread被用尽,也会发生死锁的问题。

解决方案:调用原生方法时,应该尽量避免耗时较长的操作。或者直接单开一个平台线程,不要挤占虚拟线程的资源。目前没有完美的解决方案。

不适合CPU密集型任务

原因:虚拟线程的核心优势是当某个任务需要阻塞或休眠时,载体线程可以切换到其他任务去执行,避免了线程闲置。如果是 CPU 密集型任务,线程始终在执行同一个任务,就无法发挥虚拟线程的优势。

无需池化虚拟线程

虚拟线程占用的资源很少,因此可以大量地创建而无须考虑池化,它不需要跟平台线程池一样,平台线程的创建成本比较昂贵,所以通常选择去池化,去做共享,但是池化操作本身会引入额外开销,对于虚拟线程池化反而是得不偿失,使用虚拟线程我们抛弃池化的思维,用时创建,用完就扔。

中间件的支持

大多数情况下,需要将对应的中间件升级到支持虚拟线程的版本以上,才能享受虚拟线程带来的好处,这可能会导致和其他依赖版本的不兼容,在升级时需要关注和测试。下面是一些明确支持虚拟线程的依赖版本

  • spring-boot:2.7.X
  • mysql-connector-j:9.0.0
  • lombok:1.8.30

注意内存使用

虽然单个虚拟线程很轻量(约 2KB),但创建大量虚拟线程 或者是虚拟线程的栈空间中实际占用内存较大的情况仍然需要注意,因为可能会占用大量的堆内存空间

相关推荐
自由鬼30 分钟前
如何处理Y2K38问题
java·运维·服务器·程序人生·安全·操作系统
_oP_i4 小时前
RabbitMQ 队列配置设置 RabbitMQ 消息监听器的并发消费者数量java
java·rabbitmq·java-rabbitmq
Monkey-旭4 小时前
Android Bitmap 完全指南:从基础到高级优化
android·java·人工智能·计算机视觉·kotlin·位图·bitmap
我爱996!4 小时前
SpringMVC——响应
java·服务器·前端
小宋10214 小时前
多线程向设备发送数据
java·spring·多线程
大佐不会说日语~6 小时前
Redis高频问题全解析
java·数据库·redis
寒水馨6 小时前
Java 17 新特性解析与代码示例
java·开发语言·jdk17·新特性·java17
启山智软6 小时前
选用Java开发商城的优势
java·开发语言
鹦鹉0076 小时前
SpringMVC的基本使用
java·spring·html·jsp
R cddddd6 小时前
Maven模块化开发与设计笔记
java·maven