目录

    Phoenix监控平台技术解析(十一):JVM 信息采集——内存、线程、GC、类加载数据的收集

    上一篇我们聊了心跳——那根维系客户端和服务端之间“生死契约”的细线。心跳回答的是“你还活着吗?”这个最基本的问题。但活着只是底线,运维真正关心的是“你活得怎么样?”。本篇将深入 Phoenix 的 JVM 信息采集机制,看它如何在不侵入一行业务代码的前提下,给每一个 Java 进程做一次全面的“体检”。


    一、JVM 体检:你需要检查什么?

    如果你是一名医生,给一个人做体检,至少要看这几项:心率、血压、血常规、体温。对应到 JVM 的世界,Phoenix 采集的“体检项目”也有五大维度:

    体检项目 JVM 对应维度 关键指标
    血液系统 内存(Memory) 堆内存、非堆内存、各内存池的 init/used/committed/max
    心率 线程(Thread) 活跃线程数、峰值线程数、守护线程数、累计创建线程总数
    代谢能力 GC(Garbage Collector) 各收集器的 GC 次数、GC 累计耗时
    细胞活性 类加载(Class Loading) 已加载类数、累计加载总数、已卸载类数
    基本信息 运行时(Runtime) VM 名称/版本/供应商、启动参数、运行时长、启动时间

    这五项加在一起,就是一个 Java 进程的完整“健康档案”。堆内存涨到 90% 了?线程数突破 500 了?Full GC 频繁触发?这些问题不用你登上服务器手动 jstatjmap,Phoenix 会定时帮你采集、传输、落库——你只需要打开 UI 页面看图表就行。


    二、JvmTaskScheduler:体检的“闹钟”

    所有定时采集任务都需要一个调度器来“定闹钟”。JVM 信息采集的调度器是 JvmTaskScheduler,它和心跳调度器的结构一脉相承,但多了一个关键差异——它可以被关掉

    public class JvmTaskScheduler {
    
        private JvmTaskScheduler() {
        }
    
        public static void run() {
            boolean jvmInfoEnable = ConfigLoader.getMonitoringProperties()
                .getJvmInfo().getEnable();
            if (jvmInfoEnable) {
                long rate = ConfigLoader.getMonitoringProperties()
                    .getJvmInfo().getRate();
                ThreadPoolAcquirer.getInstanceScheduledThreadPoolExecutor()
                    .scheduleWithFixedDelay(new JvmThread(), 45, rate, TimeUnit.SECONDS);
            }
        }
    }
    

    几个值得关注的设计点:

    可选采集。心跳是“无开关”的——实例存活的唯一证明不允许关闭。但 JVM 采集不一样,它是配置驱动的:monitoring.jvm-info.enable 默认为 false,需要显式开启。这是务实的选择——不是所有场景都需要 JVM 级别的监控,有些轻量应用只需要心跳就够了。

    延迟 45 秒。心跳延迟 35 秒,JVM 延迟 45 秒。这个 10 秒的差值不是随便写的——它确保心跳先于 JVM 包抵达服务端。为什么?因为服务端处理 JVM 包时,第一件事就是检查 MONITOR_INSTANCE 表中有没有这个实例的记录。如果 JVM 包先到、心跳包后到,服务端会因为找不到实例而直接拒绝这个 JVM 包。先让心跳“打前站”注册实例,再让 JVM 包跟上,时序上才是安全的。

    频率下界 30 秒。配置加载阶段会校验:采集频率不能低于 30 秒,否则直接抛异常。这既是对服务端的保护(防止高频采集压垮数据库),也是对客户端的保护(JVM 指标采集虽然轻量,但毕竟涉及遍历所有线程、所有内存池)。


    三、JvmThread:一次体检的完整过程

    JvmThread 是每次“闹钟响起”时实际执行的任务。它的骨架和 HeartbeatThread 几乎一致,但核心动作从“构造心跳包”变成了“采集 JVM 全量信息”:

    @Override
    public void run() {
        if (!DataExchanger.isReady()) {
            return;
        }
        TimeInterval timer = DateUtil.timer();
        try {
            // 1. 采集JVM信息
            Jvm jvm = JvmUtils.getJvmInfo();
            // 2. 构建JVM信息包
            JvmPackage jvmPackage = this.clientPackageConstructor
                .structureJvmPackage(jvm);
            // 3. 封装WebSocket包并发送
            WebSocketPackage requestPackage = new WebSocketPackage();
            requestPackage.setClassName(JvmPackage.class.getName());
            requestPackage.setPayload(jvmPackage);
            DataExchanger.sendMessage(requestPackage);
        } catch (NetException e) {
            log.error("获取网络信息异常!", e);
        } catch (Exception e) {
            log.error("其它异常!", e);
        } finally {
            String betweenDay = timer.intervalPretty();
            int criticalValue = 5;
            if (timer.intervalSecond() > criticalValue) {
                log.warn("构建+发送Java虚拟机信息包耗时:{}", betweenDay);
            } else {
                if (log.isDebugEnabled()) {
                    log.debug("构建+发送Java虚拟机信息包耗时:{}", betweenDay);
                }
            }
        }
    }
    

    整体流程是三步走:采集 → 封装 → 发送。其中“采集”这一步是重头戏——JvmUtils.getJvmInfo() 里到底做了什么?


    四、JvmUtils:JDK 送你的“免费体检仪”

    这是整篇文章最核心的部分。Phoenix 的 JVM 信息采集,没有用任何第三方字节码增强工具,没有 Agent 注入,没有 JVMTI——它用的是 JDK 自带的 java.lang.management 包,也就是所谓的 MXBean(Management Extension Bean)。

    MXBean 是 JDK 从 1.5 开始就提供的 JVM 自省接口。你可以把它理解为 JVM 内置的“传感器阵列”——每个 MXBean 负责暴露一个维度的运行时数据。ManagementFactory 则是获取这些传感器的统一入口。

    JvmUtils 在类加载时就通过静态字段拿到了所有需要的 MXBean:

    private static final RuntimeMXBean RUNTIMEMX_BEAN 
        = ManagementFactory.getRuntimeMXBean();
    private static final ThreadMXBean THREADMX_BEAN 
        = ManagementFactory.getThreadMXBean();
    private static final ClassLoadingMXBean CLASS_LOADINGMX_BEAN 
        = ManagementFactory.getClassLoadingMXBean();
    private static final MemoryMXBean MEMORYMX_BEAN 
        = ManagementFactory.getMemoryMXBean();
    private static final List<MemoryPoolMXBean> MEMORY_POOLMX_BEANS 
        = ManagementFactory.getMemoryPoolMXBeans();
    private static final List<GarbageCollectorMXBean> GARBAGE_COLLECTORMX_BEANS 
        = ManagementFactory.getGarbageCollectorMXBeans();
    

    六个 MXBean,覆盖了 JVM 五大维度的所有信息。注意这些都是 static final 的——MXBean 本身是单例的,获取一次就够了,后续每次采集直接调用方法读取最新值即可。

    下面逐一拆解五个采集方法。

    4.1 内存采集:堆、非堆与内存池

    public static MemoryDomain getMemoryInfo() {
        MemoryUsage heapMemoryUsage = MEMORYMX_BEAN.getHeapMemoryUsage();
        MemoryUsage nonHeapMemoryUsage = MEMORYMX_BEAN.getNonHeapMemoryUsage();
        Map<String, MemoryDomain.MemoryUsageDomain> memoryPoolDomainMap 
            = new HashMap<>(6);
        // 堆内存
        memoryPoolDomainMap.put("Heap", wrapMemoryUsageDomain(heapMemoryUsage));
        // 非堆内存
        memoryPoolDomainMap.put("Non_Heap", 
            wrapMemoryUsageDomain(nonHeapMemoryUsage));
        // 各内存池
        MEMORY_POOLMX_BEANS.forEach(pool -> {
            MemoryUsage memoryUsage = pool.getUsage();
            memoryPoolDomainMap.put(
                pool.getName().replace(" ", "_"),
                wrapMemoryUsageDomain(memoryUsage));
        });
        return MemoryDomain.builder()
            .memoryUsageDomainMap(memoryPoolDomainMap).build();
    }
    

    Phoenix 不只是采集“堆”和“非堆”这两个粗粒度的数据——它还遍历了所有 MemoryPoolMXBean,把每个内存池(如 Eden_SpaceOld_GenMetaspaceCode_Cache 等)的使用量都记录下来。

    每个内存区域记录四个指标:

    指标 含义
    init JVM 启动时向操作系统申请的初始内存量
    used 当前实际使用的内存量
    committed JVM 已从操作系统获取到的内存量(已承诺分配)
    max 最大可用内存量(可能为 -1,表示未定义)

    一个值得注意的细节:max 字段被声明为 String 类型而非 long。这是因为某些内存池(比如 Metaspace)的最大值可能是 -1(表示“没有上限”),Phoenix 选择在采集时就把这种情况转换为“未定义”字符串,避免下游代码到处写 if (max == -1) 的判断。

    4.2 线程采集:不只是数数

    public static ThreadDomain getThreadInfo() {
        long[] threadIds = THREADMX_BEAN.getAllThreadIds();
        List<String> threadInfos = Lists.newArrayList();
        for (long threadId : threadIds) {
            ThreadInfo threadInfo = THREADMX_BEAN.getThreadInfo(threadId);
            if (threadInfo != null) {
                threadInfos.add(StringUtils.trim(threadInfo.toString()));
            }
        }
        threadInfos.sort(Comparator.naturalOrder());
        return ThreadDomain.builder()
                .threadCount(THREADMX_BEAN.getThreadCount())
                .peakThreadCount(THREADMX_BEAN.getPeakThreadCount())
                .daemonThreadCount(THREADMX_BEAN.getDaemonThreadCount())
                .totalStartedThreadCount(THREADMX_BEAN.getTotalStartedThreadCount())
                .threadInfos(threadInfos)
                .build();
    }
    

    Phoenix 的线程采集做了两层工作:

    第一层是“统计数据”:当前活跃线程数、历史峰值、守护线程数、累计创建总数。这四个数字就像一个线程系统的“仪表盘”——活跃数告诉你现在的负载,峰值告诉你历史上的最高水位,累计总数告诉你线程的“流转速度”(如果这个数字飞速增长,说明在频繁创建销毁线程,可能存在线程池配置问题)。

    第二层是“线程快照”:遍历所有线程 ID,获取每个线程的 ThreadInfo,转成字符串后按字母排序存储。这些信息包含线程名、状态(RUNNABLE/WAITING/BLOCKED 等)、调用栈片段——相当于做了一次轻量版的 jstack。在 UI 页面上,运维人员可以直接查看某个实例当前所有线程的状态,无需 SSH 到服务器上手动执行命令。

    4.3 GC 采集:谁在回收?回收了多久?

    public static GarbageCollectorDomain getGarbageCollectorInfo() {
        List<GarbageCollectorDomain.GarbageCollectorInfoDomain> infos 
            = Lists.newLinkedList();
        GARBAGE_COLLECTORMX_BEANS.forEach(collector -> {
            GarbageCollectorDomain.GarbageCollectorInfoDomain info = 
                GarbageCollectorDomain.GarbageCollectorInfoDomain.builder()
                    .name(collector.getName())
                    .collectionCount(collector.getCollectionCount() == -1L 
                        ? "未定义" 
                        : String.valueOf(collector.getCollectionCount()))
                    .collectionTime(collector.getCollectionTime() == -1L 
                        ? "未定义" 
                        : DateUtil.formatBetween(
                            collector.getCollectionTime(), 
                            BetweenFormatter.Level.MILLISECOND))
                    .build();
            infos.add(info);
        });
        return GarbageCollectorDomain.builder()
            .garbageCollectorInfoDomains(infos).build();
    }
    

    不同的 JVM 配置会有不同的 GC 收集器组合。比如 JDK 8 默认可能是 PS Scavenge + PS MarkSweep,JDK 11 可能是 G1 Young Generation + G1 Old Generation。Phoenix 不做任何假设——它遍历 GarbageCollectorMXBean 列表,有几个收集器就采几个,每个收集器记录两个核心指标:

    • collectionCount:GC 累计执行次数
    • collectionTime:GC 累计耗时(格式化为可读字符串,如“2秒345毫秒”)

    这两个数字的变化趋势非常有价值:如果 GC 次数在短时间内飙升、GC 耗时占比越来越大,就说明 JVM 的内存压力正在逼近临界点——可能需要调整堆大小、优化对象分配策略,或者排查是否存在内存泄漏。

    4.4 类加载采集:三个数字的故事

    public static ClassLoadingDomain getClassLoadingInfo() {
        return ClassLoadingDomain.builder()
                .totalLoadedClassCount(CLASS_LOADINGMX_BEAN
                    .getTotalLoadedClassCount())
                .loadedClassCount(CLASS_LOADINGMX_BEAN.getLoadedClassCount())
                .unloadedClassCount(CLASS_LOADINGMX_BEAN.getUnloadedClassCount())
                .isVerbose(CLASS_LOADINGMX_BEAN.isVerbose())
                .build();
    }
    

    类加载信息看似简单,就三个数字加一个开关,但它能发现一类非常隐蔽的问题——类加载泄漏。正常情况下,loadedClassCount(当前已加载类数)应该趋于稳定。如果这个数字持续攀升而 unloadedClassCount(已卸载类数)纹丝不动,就说明类加载器可能存在泄漏(常见于使用了大量动态代理、热部署场景、或 OSGI 容器)。在没有监控的日子里,这种问题往往要等到 Metaspace OOM 那一刻才会暴露。

    4.5 运行时信息:JVM 的“身份证”

    运行时信息通过 RuntimeMXBean 采集,内容涵盖 VM 名称、版本、供应商、JVM 启动参数、运行时长、启动时间等。这些信息大多是“静态”的(启动后不会变),但 uptime(运行时长)是动态的——它能让你一眼看出这个 JVM 进程已经连续运行了多久。

    这五个采集方法最终在 getJvmInfo() 中被统一编排:

    public static Jvm getJvmInfo() {
        return Jvm.builder()
                .classLoadingDomain(getClassLoadingInfo())
                .memoryDomain(getMemoryInfo())
                .garbageCollectorDomain(getGarbageCollectorInfo())
                .runtimeDomain(getRuntimeInfo())
                .threadDomain(getThreadInfo())
                .build();
    }
    

    一次调用,五维数据尽收囊中。


    五、JvmPackage:体检报告的“信封”

    采集到的 Jvm 对象需要封装成 JvmPackage 才能发送:

    public class JvmPackage extends BaseRequestPackage {
        private Jvm jvm;       // JVM全量信息
        private long rate;     // 传输频率
    }
    

    structureJvmPackage() 方法负责组装:

    public JvmPackage structureJvmPackage(Jvm jvm) throws NetException {
        JvmPackage jvmPackage = new JvmPackage();
        this.structureBaseRequestPackage(jvmPackage, null);
        jvmPackage.setJvm(jvm);
        jvmPackage.setRate(ConfigLoader.getMonitoringProperties()
            .getJvmInfo().getRate());
        return jvmPackage;
    }
    

    和心跳包一样,JVM 包也携带了采集频率 rate——服务端可以据此了解这个实例的 JVM 数据上报频率。加上父类 BaseRequestPackage 的字段(实例 ID、IP、端点类型、请求时间等),一个完整的 JVM 信息包就像一份装在信封里的体检报告:信封上写着“谁的报告、什么时候做的”,里面装着五维体检数据。


    六、服务端:六路并行的数据入库

    JVM 信息包抵达服务端后,由 JvmServiceImpl.dealJvmPackage() 处理。这个方法是整个 JVM 数据处理链路中最值得细看的一段:

    public Result dealJvmPackage(JvmPackage jvmPackage) {
        // 先检查实例是否存在
        int count = this.instanceService.count(
            new LambdaQueryWrapper<MonitorInstance>()
                .eq(MonitorInstance::getInstanceId, jvmPackage.getInstanceId()));
        if (count == 0) {
            return Result.builder().isSuccess(false)
                .msg(ResultMsgConstants.FAILURE).build();
        }
        // 六路并行入库
        CompletableFuture<Void> allFutures = CompletableFuture.allOf(
            CompletableFuture.runAsync(() -> 
                this.jvmRuntimeService.operateMonitorJvmRuntime(jvmPackage), 
                this.instanceMonitorThreadPoolExecutor),
            CompletableFuture.runAsync(() -> 
                this.jvmClassLoadingService.operateMonitorJvmClassLoading(jvmPackage), 
                this.instanceMonitorThreadPoolExecutor),
            CompletableFuture.runAsync(() -> 
                this.jvmMemoryService.operateMonitorJvmMemory(jvmPackage), 
                this.instanceMonitorThreadPoolExecutor),
            CompletableFuture.runAsync(() -> 
                this.jvmMemoryHistoryService.operateMonitorJvmMemoryHistory(jvmPackage), 
                this.instanceMonitorThreadPoolExecutor),
            CompletableFuture.runAsync(() -> 
                this.jvmThreadService.operateMonitorJvmThread(jvmPackage), 
                this.instanceMonitorThreadPoolExecutor),
            CompletableFuture.runAsync(() -> 
                this.jvmGarbageCollectorService
                    .operateMonitorJvmGarbageCollector(jvmPackage), 
                this.instanceMonitorThreadPoolExecutor)
        );
        try {
            allFutures.get(30, TimeUnit.SECONDS);
        } catch (TimeoutException e) {
            allFutures.cancel(true);
            // ...
        }
        return Result.builder().isSuccess(true)
            .msg(ResultMsgConstants.SUCCESS).build();
    }
    

    这段代码有三个精彩的设计决策:

    1. 实例存在性校验。入库前先查 MONITOR_INSTANCE 表,确认这个实例确实存在(由心跳包注册)。如果不存在,直接返回失败——不会为一个“不认识”的实例写入任何 JVM 数据。这就是前面说的“心跳打前站”策略的配合点。

    2. CompletableFuture 六路并行。一个 JVM 包要写入六张表(运行时、类加载、内存实时、内存历史、线程、GC),这六个写入操作之间完全没有数据依赖——它们各自从 jvmPackage 中提取自己需要的维度数据,互不干扰。因此用 CompletableFuture.allOf() 并行执行,大幅缩短了处理延迟。

    3. 故意不加事务。代码注释中明确写道:“此处不加事务,因为操作的表太多,数据太多,不加事务能提高并发性能,而且此处对数据的一致性要求并不是很高。”这是一个非常务实的权衡——监控数据的特性是“量大、容忍丢失、不需要强一致”。如果加了事务,六张表的写入必须全部成功才提交,任何一个失败都会回滚,这在高并发场景下会成为瓶颈。不加事务意味着某一维度写入失败不影响其他维度,下一轮采集会自动覆盖。

    4. 30 秒超时保护allFutures.get(30, TimeUnit.SECONDS) 设置了超时上限。如果某个写入操作因为数据库锁竞争、慢查询等原因卡住超过 30 秒,主线程不会无限等待——而是取消所有子任务并返回失败。这避免了线程池中的任务堆积。


    七、入库细节:以内存为例的“新增或更新”模式

    六个维度的入库逻辑都遵循同一个模式,我们以内存(JvmMemoryServiceImpl)为例来看:

    @Retryable
    public void operateMonitorJvmMemory(JvmPackage jvmPackage) {
        String instanceId = jvmPackage.getInstanceId();
        Date currentTime = new Date();
        MemoryDomain memoryDomain = jvmPackage.getJvm().getMemoryDomain();
        if (memoryDomain != null) {
            Map<String, MemoryDomain.MemoryUsageDomain> map 
                = memoryDomain.getMemoryUsageDomainMap();
            List<MonitorJvmMemory> saveList = Lists.newArrayList();
            for (Map.Entry<String, MemoryDomain.MemoryUsageDomain> entry 
                    : map.entrySet()) {
                String memoryType = entry.getKey();
                MemoryDomain.MemoryUsageDomain usage = entry.getValue();
                // 查库:有没有这个实例+内存类型的记录?
                int count = this.count(
                    new LambdaQueryWrapper<MonitorJvmMemory>()
                        .eq(MonitorJvmMemory::getInstanceId, instanceId)
                        .eq(MonitorJvmMemory::getMemoryType, memoryType));
                MonitorJvmMemory entity = new MonitorJvmMemory();
                entity.setInstanceId(instanceId);
                entity.setMemoryType(memoryType);
                entity.setInit(usage.getInit());
                entity.setUsed(usage.getUsed());
                entity.setCommitted(usage.getCommitted());
                entity.setMax(usage.getMax());
                if (count == 0) {
                    entity.setInsertTime(currentTime);
                    saveList.add(entity);
                } else {
                    entity.setUpdateTime(currentTime);
                    this.update(entity, ...);
                }
            }
            if (CollectionUtils.isNotEmpty(saveList)) {
                this.saveBatch(saveList);
            }
        }
    }
    

    这是 Phoenix 中反复出现的“Upsert 模式”:先查询是否已存在,不存在则 INSERT,已存在则 UPDATE。对于内存这种有多个子类型(Heap、Non_Heap、Eden_Space、Old_Gen……)的数据,它按 instanceId + memoryType 作为联合唯一键来判断。

    @Retryable 注解则为每个写入操作提供了自动重试能力——如果因为瞬时的数据库连接抖动导致写入失败,Spring Retry 会自动重试,而不是直接丢弃这一轮采集数据。

    内存的“双表”设计

    内存数据是所有 JVM 维度中唯一采用了“实时表 + 历史表”双表设计的。MONITOR_JVM_MEMORY 存储最新一次采集的数据(每个内存类型只保留一条),而 MONITOR_JVM_MEMORY_HISTORY 则以时间序列的方式追加每一次采集结果。

    为什么只有内存有历史表?因为内存是最需要看趋势的指标。堆内存的 used 值如果呈锯齿形波动(GC 后下降、然后缓慢上升),说明一切正常;如果呈阶梯形持续攀升(GC 后也降不下来),就是内存泄漏的经典特征。没有历史数据,这种趋势分析就无从谈起。


    八、完整数据流

    把客户端到服务端的全链路串起来:

    客户端                                         服务端
      │                                             │
      │  T=45s JvmThread 首次触发                    │
      │  ├── isReady()? ✅                          │
      │  ├── JvmUtils.getJvmInfo()                  │
      │  │   ├── MemoryMXBean → 堆/非堆/内存池        │
      │  │   ├── ThreadMXBean → 线程统计+快照          │
      │  │   ├── GarbageCollectorMXBean → GC次数/耗时  │
      │  │   ├── ClassLoadingMXBean → 类加载统计       │
      │  │   └── RuntimeMXBean → VM信息/运行时长       │
      │  ├── structureJvmPackage() 封装              │
      │  └── DataExchanger.sendMessage()            │
      │        │                                    │
      │        │ ──加密──压缩──WebSocket──▶           │
      │                                             │
      │                          JvmServiceImpl.dealJvmPackage()
      │                          ├── 实例存在性校验
      │                          └── CompletableFuture.allOf()
      │                               ├── 运行时 → MONITOR_JVM_RUNTIME
      │                               ├── 类加载 → MONITOR_JVM_CLASS_LOADING
      │                               ├── 内存   → MONITOR_JVM_MEMORY
      │                               ├── 内存历史 → MONITOR_JVM_MEMORY_HISTORY
      │                               ├── 线程   → MONITOR_JVM_THREAD
      │                               └── GC    → MONITOR_JVM_GARBAGE_COLLECTOR
    

    九、小结

    JVM 信息采集是 Phoenix 客户端中“最重”的一个定时任务——它一次性采集五大维度的数据,写入服务端六张表。但整个设计依然保持了 Phoenix 一贯的简洁与务实:

    1. 零侵入:完全基于 JDK 自带的 MXBean API,不需要 -javaagent,不需要字节码增强,JvmUtils 一个工具类就能拿到所有数据。
    2. 可选开启:默认关闭,按需配置,不给不需要的应用增加额外负担。
    3. 时序安全:延迟 45 秒启动,确保心跳先行注册实例,JVM 包不会被服务端拒绝。
    4. 并行入库:六个维度的数据通过 CompletableFuture.allOf() 并行写入,最大化吞吐量。
    5. 性能优先于一致性:监控数据天然容忍短暂丢失,不加事务换取并发性能,@Retryable 兜底瞬时故障。
    6. 内存双表:只有最需要趋势分析的内存维度才额外维护历史表,避免数据库膨胀。

    下一篇,我们将从 JVM 层面上移到更高的抽象层——线程池信息采集。Phoenix 是如何通过 MonitoredExecutor 注册表机制,把你业务中使用的线程池“纳管”起来并定时上报的?敬请期待。


    项目地址
    https://gitee.com/pifeng/phoenix
    https://gitee.com/monitoring-platform/phoenix
    https://github.com/709343767/phoenix

    欢迎关注微信公众号获取更多技术干货
    微信公众号·披锋斩棘

    end
  1. 作者: 锋哥 (联系作者)
  2. 发表时间: 2026-04-06 09:07
  3. 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)
  4. 转载声明:如果是转载博主转载的文章,请附上原文链接
  5. 公众号转载:请在文末添加作者公众号二维码(公众号二维码见右边,欢迎关注)
  6. 评论

    站长头像 知录

    你一句春不晚,我就到了真江南!

    文章0
    浏览0

    文章分类

    标签云