news 2026/10/20 16:14:36

JVM核心三连:队列与栈、四种引用、符号引用的深度解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JVM核心三连:队列与栈、四种引用、符号引用的深度解析

“队列和栈的区别,强、软、弱、虚引用,JVM的直接引用和符号引用(间接引用)”——这个标题经常以“JVM灵魂拷问三连”的形式出现在面试中。我第一次完整把这三块答出来的时候,是面试完复盘才发现自己讲漏了弱引用和ReferenceQueue配合使用、符号引用何时解析成直接引用这些细节。实际上,这三个知识点分别回答了三类问题:数据是怎么组织存放的、对象在内存中什么时候能死、类加载后方法调用怎么定位到真实目标。把它们放在一起看,比单独背概念要有价值得多。这篇文章就按这条主线把三块拆开讲,每个点都会给到代码级实例和实际踩坑经验,适合准备面试的、写业务代码想排查内存问题的人,也适合想真正看懂字节码的读者。

1. 为什么这三个问题经常被绑在一起问

1.1 三个机制分别管住“存取、寿命、定位”

先看第一层,队列和栈解决的是数据“怎么存取”的问题。栈是后进先出,先进来的数据反而最后才出去;队列是先进先出,谁先来谁先走。这个差异决定了它们在业务代码里截然不同的应用场景:栈天然适配函数调用、表达式求值、撤销操作;队列则支撑着消息队列、任务调度、广度优先遍历。很多Java开发只知道“一个LIFO一个FIFO”,但真正被问倒的地方往往是实现选择和底层结构边界。

第二层,强、软、弱、虚引用解决的是“对象何时可回收”。这四个层级其实就是JVM给开发者提供的四档内存回收开关:强引用默认且霸道,只要还有强引用链,内存溢出也不回收;软引用在内存紧张时被回收,适合做缓存;弱引用在下一次GC时就会被清掉,连“内存紧张”都不等;虚引用最特殊,它根本拿不到对象,只是一个“回收通知器”。这层知识直接关系到缓存设计、连接池设计、以及ThreadLocal这类组件内存泄漏的根因。

第三层,符号引用和直接引用解决的是“类加载之后怎么找到真实目标”。Class文件里存的是常量池中的符号引用,也就是类的全限定名、方法名、字段描述符这类字符串信息;等到类加载到JVM里,运行时常量池中这些符号引用才会被解析成直接引用——真实内存地址、方法表偏移量或者句柄。这个问题不感兴趣的开发完全可以不知道,但遇到线上“NoSuchMethodError”、排查字节码性能问题、或者深入理解多态分派时,这块知识就是必须的。

1.2 面试官真正想考察的东西

把这三块塞进同一个问题的场景,本质上是想看两件事:第一,基础是否成体系。面试者如果只能背出“队列FIFO栈LIFO”“四种引用名字”这类零散概念,说明平时学习只停留在记忆层;但要是能顺着“数据结构决定组织方式、引用类型决定回收策略、引用解析决定运行时定位”这条逻辑线把答案接起来,就能证明理解是成网的。第二,是否能把概念映射到真实问题。比如问队列和栈,可以立刻想到JVM虚拟机栈栈帧的压栈出栈、方法递归的StackOverflowError;问引用类型,可以想到MySQL驱动的缓存实现、DirectByteBuffer堆外内存回收;问符号引用直接引用,可以想到字节码中的invokevirtual指令如何完成动态分派。这套迁移能力,比“记住结论”值钱得多。

2. 队列和栈:不只是FIFO与LIFO的六个维度对比

2.1 操作边界的区别:栈只在栈顶,队列两端分工

先从生活例子入手。栈就像一摞盘子,你永远只能从最上面拿新的、放新的;先放下去的盘子只能等上面的全部取走才能露出来。队列就好比食堂排队打饭,新来的人只能排在队尾,窗口师傅只服务队头,谁先到谁先端走饭。

  • 操作位置:栈只允许在栈顶执行push和pop,读也只能读栈顶(peek);普通队列是队尾入队、队头出队(offer/poll),队头队尾职责分明。双端队列Deque则放宽到两端都可操作,但这是后话。
  • 访问顺序:一个LIFO,一个FIFO。这个差异会直接决定算法形态,比如深度优先搜索用栈,广度优先搜索用队列。
  • 时间复杂度:栈和队列基于数组实现时,插入删除都是O(1);基于链表实现时同样是O(1)。这一点两者打成平手,面试时不用在复杂度上做文章。
  • 遍历能力:两者都能遍历,但栈的遍历顺序是反序的,队列遍历是正序的。实际操作中不建议直接遍历栈,会破坏现场,应该用临时栈倒腾或者改成数组结构。
  • 内存模型对应:JVM的虚拟机栈是一个标准的栈结构,方法调用就是压栈帧、方法返回就是弹栈帧;而消息队列是业务层面的队列体现,底层存储却可能是链表、数组甚至数据库,应用场景决定实现。
  • 典型场景:栈——递归、括号匹配、中缀表达式转后缀、浏览器前进后退、代码编辑器撤销;队列——任务调度、消息缓冲、BFS遍历、线程池任务队列。

2.2 Java语法层面的正确姿势:Stack类该退休了

这里要讲一个Java开发者常见的“历史包袱”。java.util.Stack类确实存在,但它是从Vector继承来的,而Vector所有方法都加了synchronized,这在单线程环境下是纯浪费性能。更难受的是Stack在设计和实现上也不够“纯粹”,它既能push/pop,又继承了add(int index, E element)这类按索引插入的方法,这已经破坏了栈“只能在栈顶操作”的纪律。

所以我强烈建议用Deque接口来代替:

// 栈语义:使用ArrayDeque Deque<Integer> stack = new ArrayDeque<>(); stack.push(1); stack.push(2); System.out.println(stack.pop()); // 输出 2 System.out.println(stack.peek()); // 输出 1 // 队列语义:使用LinkedList或ArrayDeque Queue<String> queue = new LinkedList<>(); queue.offer("a"); queue.offer("b"); System.out.println(queue.poll()); // 输出 a System.out.println(queue.peek()); // 输出 b

选择ArrayDeque还是LinkedList也讲究。ArrayDeque底层是循环数组,缓存局部性好,迭代速度更快,但它不允许存入null元素;LinkedList基于双向链表,允许null,在频繁头尾插入、尤其是队列长度极不稳定的场景里空间利用更灵活。普通业务场景我基本都用ArrayDeque。有一点容易被忽略:ArrayDeque默认初始容量是16,扩容时要整体拷贝数组,如果你的队列长期接近满负荷,最好在构造函数里提前指定容量:

Deque<Integer> bigStack = new ArrayDeque<>(1024);

一个容量预估能避开拷贝扩容带来的卡顿,这在写核心链路时很重要。

2.3 用倒置思维打通:双栈模拟队列与双队列模拟栈

面试中这个问题的进阶变体:“用两个栈实现队列”和“用两个队列实现栈”。这类题背代码没有意义,关键是理解它们如何通过对冲顺序来改变存取规则。

双栈实现队列的思路是:一个栈作为入队缓冲区,另一个栈作为出队缓冲区。入队时直接压入in栈;出队时,如果out栈空了,就把in栈所有元素弹出再压入out栈,这样元素顺序被两次反转,最终出队顺序就变成了正确的FIFO。

class MyQueue { private final Deque<Integer> in = new ArrayDeque<>(); private final Deque<Integer> out = new ArrayDeque<>(); public void push(int x) { in.push(x); } public int pop() { if (out.isEmpty()) { while (!in.isEmpty()) { out.push(in.pop()); } } return out.pop(); } }

摊还时间复杂度是O(1),因为每个元素最多被移动两次。用两个队列模拟栈也同理:始终维护一个主队列,入栈时先进入临时队列,再把主队列元素搬到临时队列,这样新元素总是排在队头,出队顺序也就是LIFO。这个题纯粹考“顺序反转”的直觉,真的理解了栈和队列的本质,代码可以不背。

3. 强、软、弱、虚引用:对象回收的“亲疏远近”

3.1 强引用:默认且顽固,宁可OOM也不回收

绝大多数Java代码中的引用都是强引用,比如Object obj = new Object()。只要引用链上还有强引用存在,垃圾回收器就算把堆挤爆都不会回收这个对象。JVM创始人当年把这个设计叫做“除非内存耗尽,否则不回收强引用对象”,但实际表现就是:你new出来的对象,只要还有变量指向它,它就死不了。

强引用泛滥的后果大家应该不陌生。之前帮我朋友的项目排查过内存问题,一个图片处理工具把所有中间产物放进HashMap缓存,用的就是强引用。每次处理图片,Map里就积累一组大对象,等到处理几百张图,老年代被塞满,GC频繁Full GC,最后直接OutOfMemoryError。换成软引用之后,内存在临界点时这些缓存项就开始被回收,程序稳定多了。强引用本身没有类名概念,普通变量就是强引用,不要去找什么StrongReference类。

3.2 软引用:内存临界时的“后备缓存”

SoftReference的设计初衷就是“内存够就给,不够就杀”。对象只有软引用指向它时,在内存充足的情况下它继续活着,但如果JVM判断内存即将耗尽,软引用对象会在抛出OutOfMemoryError之前被回收。听起来非常适合做缓存:热点数据放软引用里,内存紧张时可以自动淘汰,不再需要手动清理。

我用它做过一个简单的基于内存的图片缓存:

public class ImageCache { private final Map<String, SoftReference<BufferedImage>> cache = new HashMap<>(); public BufferedImage get(String key) { SoftReference<BufferedImage> ref = cache.get(key); return ref == null ? null : ref.get(); } public void put(String key, BufferedImage image) { cache.put(key, new SoftReference<>(image)); } }

但这个方案有一个坑:软引用回收时机并不完全可控。回收器会综合考虑内存压力、存活对象的生命周期、以及SoftReference的LRU访问频率策略。HotSpot里有一个参数-XX:SoftRefLRUPolicyMSPerMB,默认值是1000,含义是“每MB堆空间,软引用对象在最近被访问后可以存活1毫秒”。假如堆是2GB,它就可能存活约2秒。想让它更激进,可以把参数调成0,表示强制优先回收软引用。不过实战中我更建议用成熟的缓存框架(比如以LRU策略实现为主),而不是完全依赖软引用,因为“何时淘汰”对业务来说太难预测。

3.3 弱引用:一次GC即清除,ThreadLocal的key就是它

弱引用比软引用弱一个等级:只要垃圾回收器扫描到它,无论内存是否充裕,都会把它指向的对象回收掉。弱引用的典型用途是“规范映射”(canonicalizing mappings),最出名的就是WeakHashMap。既然weak key被回收,那么对应的entry会被自动删除,这用来做需要和外部对象生命周期绑定的辅助缓存很合适。

Java里一个最容易被问到的弱引用实例是ThreadLocal。不看源码的人很难想象,ThreadLocalMap里的Entry其实是这样定义的:

static class Entry extends WeakReference<ThreadLocal<?>> { Object value; Entry(ThreadLocal<?> k, Object v) { super(k); value = v; } }

key被设计成弱引用,这意味着ThreadLocal对象本身没有其他强引用时,key会随GC被清理掉。但是——这个但是很重要——value仍然是强引用。于是线程池里线程持续存活的情况下,ThreadLocalMap的某个Entry会留下“key为null但value还在”的脏数据,这就是ThreadLocal内存泄漏的核心机制之一。所以规范做法是在finally块里调用remove(),而不是单纯依赖弱引用兜底:

try { threadLocal.set(value); // 业务逻辑 } finally { threadLocal.remove(); }

面试时能主动讲出“弱引用只能解决key的回收,value还会形成强引用链阻塞回收”,再配合remove()的必要性,这个点是加分的。

3.4 虚引用:回收后的“通知信号”,堆外内存清理靠它

虚引用是最难理解的一档,因为它也不能通过get()方法拿到目标对象——PhantomReference的get()永远返回null。虚引用的唯一作用是:目标对象被回收后,JVM会把该虚引用本身放入指定的ReferenceQueue,相当于投递了一封“回收完成通知”。

ReferenceQueue<Object> queue = new ReferenceQueue<>(); PhantomReference<Object> phantom = new PhantomReference<>(new Object(), queue); System.out.println(phantom.get()); // 永远为 null System.gc(); Reference<?> ref = queue.poll(); if (ref != null) { // 说明原来那个new Object()已经被回收了 }

这个“回收后的通知”特性,在需要对象被销毁后再做外部清理的场景里非常关键。NIO里堆外内存清理就是典型例子:DirectByteBuffer通过Cleaner线程监控DirectBuffer的记忆体,而Cleaner本身继承自PhantomReference。当DirectByteBuffer被GC回收后,虚引用的通知触发clean(),再去释放堆外的native内存。如果不是这样,堆外内存很容易因为ByteBuffer对象不可达而遗漏释放,造成native memory泄漏。

3.5 四个级别对照表与选型建议

引用类型回收时机可获取对象典型场景典型隐患
强引用永不(除非不可达)是普通对象、特性字段、业务数据大量对象长存导致OOM
软引用内存不足时将回收是内存敏感缓存、大对象缓存回收时机不可控,响应突刺
弱引用下一次GC时立即回收是WeakHashMap、ThreadLocal keyvalue强引用造成泄漏链
虚引用目标对象回收后收到通知否堆外内存回收、对象销毁监控必须配合ReferenceQueue使用

实际选型时记住一个简单的判断顺序:对象一定要活着就用强引用;能在内存不足时牺牲就用软引用;能接受随时被GC干掉就用弱引用;只是想知道对象死了没有就用虚引用。很多业务场景并不需要自己实现引用级别,但在排查线上OOM和内存泄漏时,看一眼对象是被哪一层引用链吸附住的,能直接判断是否属于“强引用生命周期失控”。

4. 符号引用与直接引用:类加载时的“地址簿”与“GPS坐标”

4.1 字节码里存的其实是“字符串描述”

翻开一个.class文件,看到的不是机器码,而是JVM指令+常量池。常量池里存的字符串大多不是业务字符串,而是被调用的类名、方法名、字段名。Java源码中的new User()、user.getName(),编译后会变成常量池中的符号符号信息。用javap -verbose可以直观看到:

javap -verbose UserService.class

输出里类似这样:

Constant pool: #1 = Methodref #2.#3 // java/lang/Object."<init>":()V #2 = Class #4 // java/lang/Object #3 = NameAndType #5:#6 // "<init>":()V

这些Methodref、Class、NameAndType就是符号引用,本质上它们是一组字符串:全限定名、方法名、类型描述符。它们描述的是“逻辑目标”,不直接对应具体内存地址。

4.2 从符号引用到直接引用的解析过程

类加载的五个阶段是加载、验证、准备、解析、初始化,其中解析就是把运行时常量池中的符号引用替换为直接引用的阶段。直接引用可以是:具体的内存地址、方法表偏移量、或者句柄(handle)。替换完成后,运行时指令才能知道“这个方法到底要跳到哪个位置执行”。

HotSpot实际通常采用“懒解析”,也就是在字节码指令第一次真正执行时才去解析当前用到的符号引用。这样启动时不需要把所有类和方法一一解析完毕,启动速度更快;同时解析后的结果会被缓存,后续再次执行同样的调用,直接走直接引用,不再重复解析。这也解释了为什么代码第一次跑会比较慢、预热后性能才稳定——JIT编译是原因之一,懒解析也是重要因素。

4.3 为什么不能直接在字节码里写地址

字节码为什么不直接写上方法调用的内存地址?核心原因有三个:

  • 类在高特率前还不知道自己的内存布局。类文件要经过加载、连接才能确定字段和方法在内存里的位置,编译期根本无法预知运行时地址。
  • 多态使得目标不固定。invokevirtual调用UserService.get(),实际运行时会根据对象实际类型动态分派到具体子类的方法,编译期只能先记一个“符号目标”,运行时再根据接收者类型找到真正的方法入口。
  • 类文件要在不同JVM上复用。不同JVM实现、不同运行参数下对象内存布局都不同,只有逻辑描述是稳定的。字节码存符号引用,才能保证一份class在多个平台、多种类加载器下安全运行。

如果觉得抽象,可以想象一个快递系统:符号引用是写着“收货人姓名+门牌号”的单据,直接引用是快递员手机里已规划好的GPS精确坐标。单据随时可以寄给任何人和任何快递公司,但真正上门派件那一刻才需要把单据翻译成具体坐标。 加载过程在不同类加载器下可能重复解析出不同直接引用,这也符合类加载隔离:同一个全限定名在不同ClassLoader里代表着完全不同的类定义,符号引用不落地成地址,这个隔离机制就不会被绕过去。

4.4 一个例子看清整个过程

以最简单的代码为例:

public class Main { public static void main(String[] args) { Service service = new Service(); service.run(); } }

编译后,Main类常量池里会出现Class Service和Methodref Service.run:()V这类符号引用。当JVM执行new Service()指令时,如果Service类还没有加载,触发加载、连接、解析,把常量池中的Class Service解析成Service类在方法区中的直接引用,完成类准备。接着执行invokevirtual Service.run:()V时,为了支持多态,JVM根据service的实际类型去虚方法表中查找run()的偏移量,这个偏移量就是直接引用。下一次再调用同一行,直接从虚方法表节点获取,不再做符号解析。

跨场景再用一个真实痛感说明:线上偶发NoSuchMethodError,很大概率是因为某个类用了新版本编译的字节码,类反射的符号引用在运行期找不到对应方法名或描述符。这时候去看常量池里的NameAndType字符串,再对比实际jar包里的方法签名,问题一目了然。这正是“符号引用用来描述逻辑契约,直接引用才绑定真正实现”的实践意义。

5. 容易踩坑的几个点与面试回答路径

5.1 高频错题:队列栈、引用、解析的常见误区

  • 误区:认为Stack和Vector线程安全所以性能不错。实际是:synchronized方法在单线程场景下没有收益,反而因为历史设计不能做“纯栈”,现代代码一律推荐ArrayDeque。
  • 误区:以为软引用是“永远不OOM”。软引用确实会在OOM之前尽力回收,但回收还受参数影响;如果软引用对象数量巨大且回收后依然满足不了新建需求,照样OOM。
  • 误区:把WeakReference当作清理所有内存的银弹。WeakHashMap的key弱引用只保证key被回收,value没有弱化,如果value很大且被其他地方引用链持有,泄漏依然存在。
  • 误区:认为虚引用可以拿到对象做前置清理。无法拿到对象,虚引用只负责通知;淡内存释放这类“清理”必须在对象不可达之后由守护线程去做,绝不能依赖虚引用本身。
  • 误区:把符号引用和直接引用理解为“字符串对应指针”。实际上直接引用以运行时常量池与内存布局为基准,可能是指针、偏移量或句柄;解析失败时还会抛出各种LinkageError,不是简单赋值。

5.2 面试怎么答:从“知道”到“讲清楚”

如果面试官问的是“队列和栈的区别”,按顺序说三点:访问规则不同、操作边界不同、典型场景不同,然后给一个代码示例,比如Java里用ArrayDeque实现栈。这里顺带说出“Stack已过时”的结论,能证明你了解Java类库演进。

如果问“强软弱虚引用的区别”,先给出回收时机对照,再逐个展开各自特点。把虚引用“只能配合ReferenceQueue使用”、弱引用“ThreadLocalMap key是弱引用但value是强引用”、软引用“回收时机受JVM参数影响”这些细节讲出来,就已经超出多数人的回答深度了。

如果问“符号引用和直接引用”,先说清楚符号引用是常量池中的字符串描述,直接引用是解析后的内存目标;再说解析发生在类加载的解析阶段,HotSpot默认懒解析;最后结合invokevirtual的动态分派解释“为什么必须有符号引用”。如果能完整讲下来,说明你不仅背过《深入理解Java虚拟机》,而且还真的读过字节码。

5.3 日常编码中这些知识的落点

队列和栈最直接的使用场景是:方法调用栈与BFS/DFS。写递归时能预判递归深度,就会想到栈帧占用的内存和可能抛出的StackOverflowError;写异步任务链时,能区分“先进先出”和“后进先出”带来的任务执行顺序差异,就能避免消息乱序问题。

引用类型最直接的落点是缓存和资源回收。公司里很多小型系统都会自己写一个临时缓存,直接用强引用或HashMap存大对象,等到某个时刻内存爆掉才意识到要用软引用或直接换成成熟缓存组件。还有用ThreadLocal做上下文传递时,s remove()的习惯基本是“用过才懂”。

符号引用、直接引用,看似离业务很远,其实在定位异常、阅读栈信息、以及思考“为什么同样的代码在应用A没问题、在应用B就报LinkageError”时极其有用。类加载隔离、热部署、中间件多classloader问题,最后都要回到符号和直接引用的关系上才能讲清楚。

我个人经验是,这三个概念不是各自背完就结束的,它们互相印证。理解栈的压栈弹栈,再看JVM方法调用就不懵;理解弱引用的回收时机,再看ThreadLocal的源码就通透;理解符号到直接引用的解析,再遇到线上LinkageError或类加载冲突,第一反应会变成“去查运行时常量池里那行符号引用到底指向了哪个版本的类”,而不是盲目清缓存重启。这套基础功,平时像不用花钱,关键时候全是救命技能。

最后分享一个小细节:面试官追问“四种引用的回收时机,谁最不可控”时,我一般会答“软引用”。因为强引用完全不回收是稳定的,弱引用和虚引用都在GC时表现稳定,只有软引用要兼顾“内存是否紧张”和“LRU策略参数”,预测性最差。如果你能把这个区别讲出来,说明你对垃圾回收器的运行时行为是真的有体感,而不是只背了课本上那几行定义。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 16:14:22

51单片机电子密码锁完整制作:从原理到实物调试

做电子密码锁这个项目&#xff0c;前后断断续续花了两个星期&#xff0c;原理图、代码、实物调试一条龙走下来&#xff0c;发现真正难的不是51单片机本身&#xff0c;而是那些藏在细节里的东西&#xff1a;矩阵键盘的扫描时序、EEPROM的掉电保存、继电器驱动的隔离处理。这些点…

作者头像 李华
网站建设 2026/10/20 16:14:20

纯VHDL实现FPGA四轴运动控制IP:插补、脉冲生成与多轴联动

2. 项目概述 2.1 核心需求解析 看到这个项目标题&#xff0c;我第一反应是&#xff1a;敢用纯VHDL写四轴运动控制IP的人&#xff0c;要么是对硬件描述语言有执念&#xff0c;要么是被工具链折腾到没办法了。但不管哪种情况&#xff0c;这个方向本身是对的—— 用FPGA做运动控…

作者头像 李华
网站建设 2026/10/20 16:14:11

全志T153评估板EVB5301-V1规格书解读:邮票孔与LGA封装设计要点

做嵌入式硬件这些年&#xff0c;我拿到新板子的习惯是先翻规格书&#xff0c;而不是急着插电。这次要聊的这块全志T153开发板EVB5301-V1&#xff0c;整体属于“核心板底板”的经典架构&#xff0c;核心板型号SOM5301-S1&#xff0c;采用邮票孔144Pin加LGA 16Pin的混合封装。对做…

作者头像 李华
网站建设 2026/10/20 16:13:16

基于D3D的YV12视频渲染:4K高帧率下GPU管线优化与避坑指南

简介&#xff1a;这是一份面向Windows平台音视频开发者的D3D视频渲染源码工程&#xff0c;聚焦YV12等YUV格式在GPU上的着色器渲染实现&#xff0c;适合具备一定Direct3D与Shader基础、需要解决视频显示与格式兼容问题的中高级开发者参考。压缩包共72个文件&#xff0c;约1.05MB…

作者头像 李华
网站建设 2026/10/20 16:13:12

AI推理服务的GPU资源调度:动态批处理与显存预算实战

我最早接触GPU资源调度&#xff0c;是因为某平台的推理服务在一次流量高峰里整段崩掉。几张显卡摆在那&#xff0c;跑了好几个模型&#xff0c;表面看风平浪静&#xff0c;结果活动流量一上来&#xff0c;请求全部堵在服务入口&#xff0c;超时雪崩一波带走。事后看监控&#x…

作者头像 李华
网站建设 2026/10/20 16:12:09

免费推理渠道“隐形翻车”背后:容灾调度与熔断策略实战调优

1. 项目概述&#xff1a;从一次“隐蔽抖动”说起10月中旬的某个晚上&#xff0c;我正在后台翻看当天的监控报表。上个季度刚接进来的免费推理渠道在这个月表现一直不错&#xff0c;成功率稳定在99.5%以上&#xff0c;P95延迟也一直压在200毫秒以内。但10月12号晚上&#xff0c;…

作者头像 李华