简介:这是一份专为Java后端开发者及求职者打造的面试核心知识精要整理,聚焦JVM底层原理、多线程机制、集合框架与设计模式等高频考点,直击大厂技术面试中的‘八股文’难点。资源以结构化PDF形式呈现,内容覆盖JVM内存模型(程序计数器、虚拟机栈、本地方法区、堆、方法区)、运行时内存划分(新生代Eden/Survivor、老年代、永久代/元空间)、Minor GC执行流程等深度细节,并预留Java语法、集合、多线程、设计模式等后续模块扩展空间。压缩包仅含1个PDF文件,大小10.71MB,排版清晰、目录层级分明(含20+二级标题与50+三级要点),便于快速定位与碎片化复习。目前已有648人学习下载,适合作为面试前系统梳理、查漏补缺或校招/社招冲刺阶段的高效复盘资料。
1. JVM 垃圾回收不是背概念,是考你“能画出来、能讲清、能调参”:八股文里最易翻车的3个致命点
面试官翻到 JVM 这一页,90% 不会问“什么是堆”,而是直接甩一句:“来,画一下 G1 的 Region 划分,标出 E、S、O 区,再讲讲 Humongous Object 怎么触发 Mixed GC。”——这句话一出,当场有三分之一的人笔停在纸上,嘴张在半空。这不是考记忆力,是考你有没有真正在线上调过 GC 日志、有没有在压测时盯着 jstat 输出发过呆、有没有因为 CMS 失败导致服务雪崩后连夜改参数。Java 八股文里,JVM 占比超 35%,其中 GC 相关题直接决定你能不能进二面:它不考定义,考路径(对象从 new 到被回收的完整生命周期)、考对比(CMS vs G1 在什么场景下谁先跪)、考实操(-XX:MaxGCPauseMillis=200 是调给谁听的?)。这份《Java核心知识整理(常考八股文)》PDF 共 283 页,JVM 部分占 33 页,但真正高频、高区分度、高失分率的,就集中在 2.3.1 新生代结构、2.4.5 分代算法、2.7.7 G1 收集器这三块。适合两类人:一类是刚刷完 LeetCode 却在 JVM 环节被连环追问到怀疑人生的后端新人;另一类是写了 3 年 Spring Boot、但没亲手配过 -XX:+UseG1GC 的业务开发——别慌,这份资料不是让你背完 283 页,而是帮你把这 33 页里“面试官必问、你必答错、答案必须带参数”的硬核点,全拆成可复现、可默写、可现场画图的答题模块。
2. JVM 内存结构与 GC 算法:从线程私有区到堆分区,每一块都藏着面试官的钩子
2.1 线程私有区:程序计数器、虚拟机栈、本地方法区,为什么它们“不参与 GC”?
这三个区域之所以被划为“线程私有”,根本原因在于其生命周期与线程强绑定:线程启动时分配,线程结束时自动释放。程序计数器只存下一条字节码指令地址,虚拟机栈只存方法调用帧(局部变量、操作数栈、动态链接、方法出口),本地方法区只存 native 方法的 C/C++ 栈帧。它们不存 Java 对象实例,自然不归 GC 管。但面试官爱在这里埋钩子——比如问:“虚拟机栈会不会 OOM?”答案是会,但不是因为对象太多,而是因为线程数过多(-Xss 设置过小 + 无限递归)导致栈空间耗尽,抛StackOverflowError或OutOfMemoryError: unable to create new native thread。注意:这不是堆内存溢出,jmap -heap 看不到,得用 jstack 查线程数。再比如,“本地方法区在 JDK8 后去哪了?”答案是:被元空间(Metaspace)取代,而元空间在本地内存(Native Memory),不受 -XX:MaxMetaspaceSize 限制时可能吃光系统内存——这正是线上服务突然被 OOMKilled 的常见黑匣子。
提示:当面试官问“哪些区域不参与 GC”,别只答“程序计数器、虚拟机栈、本地方法区”,必须补一句:“因为它们不存储 Java 对象实例,其内存由线程生命周期管理,GC 只负责堆和方法区(元空间)中的对象和类元数据。”
2.2 堆内存分区:新生代三区(Eden/S0/S1)不是静态地图,而是动态流转的“对象中转站”
新生代不是三个固定大小的盒子,而是一个按 Minor GC 触发条件动态重分配的流水线。关键参数是-XX:SurvivorRatio=8(默认值),它表示 Eden : S0 : S1 = 8 : 1 : 1。假设新生代总大小为 1GB,则 Eden 占 800MB,S0 和 S1 各占 100MB。对象优先在 Eden 分配,Eden 满时触发 Minor GC:
- 复制:将 Eden 和 From Survivor(即 S0)中存活的对象复制到 To Survivor(即 S1),每复制一次,对象年龄 +1;
- 清空:清空 Eden 和 S0;
- 互换:S0 与 S1 角色互换,下次 GC 时 S1 变 From,S0 变 To。
这个过程看似简单,但面试官常考两个反直觉点:
- 对象年龄阈值不是固定 15:
-XX:MaxTenuringThreshold默认是 15,但实际晋升老年代的阈值由动态计算决定。当某次 Minor GC 后,To Survivor 区放不下所有存活对象时,JVM 会把年龄 ≥ 当前阈值的对象直接送入老年代,这个阈值叫“动态年龄判定”。 - 大对象直接进老年代:
-XX:PretenureSizeThreshold参数(仅对 Serial/ParNew 有效)可设置大对象阈值,超过此值的对象(如 byte[4MB])直接分配到老年代,避免在 Eden 和 Survivor 之间来回复制。G1 中则用 Humongous Region 处理。
// 演示大对象触发直接分配(需配合 JVM 参数) public class LargeObjectTest { public static void main(String[] args) { // 创建一个 6MB 的 byte 数组(假设 -Xms2g -Xmx2g -XX:NewRatio=2) byte[] bigArray = new byte[6 * 1024 * 1024]; // 6MB // 若未设 -XX:PretenureSizeThreshold 或值 < 6MB,则该对象直接进老年代 System.gc(); // 强制触发 GC,观察 GC 日志中是否出现 "Promotion Failed" } }这段代码本身不报错,但配合jstat -gc <pid>或-Xlog:gc*日志,你能清晰看到:Eden 使用率几乎不变,而老年代(Old)使用量突增——这就是大对象绕过新生代的铁证。很多候选人说“大对象进老年代”,却答不出“怎么验证”,更答不出“G1 里没有 PretenureSizeThreshold,靠的是 Humongous Region 分配逻辑”。
2.3 方法区演进:从永久代到元空间,不是名词替换,而是内存管理权的移交
JDK7 之前,方法区 = 永久代(PermGen),是堆的一部分,受-XX:MaxPermSize限制;JDK8 开始,方法区 = 元空间(Metaspace),使用本地内存(Native Memory),默认无上限。这个变化带来三个实操级考点:
- OOM 场景迁移:JDK7 时代,
java.lang.OutOfMemoryError: PermGen space常见于频繁加载/卸载类(如热部署、OSGi);JDK8 后变成java.lang.OutOfMemoryError: Metaspace,但更隐蔽——它可能耗尽整个系统的物理内存或 swap,导致进程被系统 OOM Killer 杀死。 - 参数失效:
-XX:MaxPermSize在 JDK8+ 完全无效,必须用-XX:MaxMetaspaceSize(建议设为 256m~512m,防失控);-XX:PermSize同理。 - 类卸载条件更严苛:永久代时代,类卸载需满足“该类所有实例已被回收 + 加载该类的 ClassLoader 已被回收 + 该类的 Class 对象无引用”。元空间时代,ClassLoader 的回收更难,因为应用服务器(如 Tomcat)常持有着 ClassLoader 的强引用,导致元空间持续增长直至 OOM。
注意:面试官若问“为什么 JDK8 要废永久代”,别只答“避免 OOM”,要补一句:“永久代是堆内存的一部分,GC 时需扫描整个堆,影响 Full GC 效率;元空间独立于堆,GC 不扫描它,且可动态扩容,更符合现代应用类加载频繁的特性。”
3. 四种引用类型与 GC 触发时机:软引用不是“软弱”,虚引用不是“虚构”
3.1 强、软、弱、虚引用的本质区别:不是强度,是 GC 时的“豁免权等级”
很多人背口诀“强引用不回收、软引用内存不足时回收、弱引用每次 GC 都回收、虚引用必须配合 ReferenceQueue”,但面试官要听的是触发条件与典型场景:
- 强引用(StrongReference):
Object obj = new Object()。只要obj还在作用域内,GC 绝对不回收。这是唯一一种“不参与 GC 决策”的引用。 - 软引用(SoftReference):
SoftReference<Object> sf = new SoftReference<>(new Object())。JVM 在发生 OOM 之前,会尝试回收所有软引用指向的对象。典型场景:实现内存敏感的缓存(如SoftReference<byte[]>缓存图片),当堆内存紧张时自动释放,避免 OOM。 - 弱引用(WeakReference):
WeakReference<Object> wf = new WeakReference<>(new Object())。只要发生 GC(包括 Minor GC),无论内存是否充足,都会回收。典型场景:ThreadLocal的 key 使用弱引用,防止 ThreadLocalMap 持有 ThreadLocal 实例导致内存泄漏(value 泄漏需手动 remove)。 - 虚引用(PhantomReference):
PhantomReference<Object> pf = new PhantomReference<>(new Object(), queue)。它不能通过 get() 获取对象,唯一作用是:当对象被 GC 时,JVM 会将该虚引用加入关联的ReferenceQueue,通知程序“对象已不可达,可以执行清理逻辑”。典型场景:堆外内存(DirectByteBuffer)的清理——Cleaner类就是基于虚引用实现的。
// 演示弱引用在 ThreadLocal 中的防泄漏设计 public class WeakReferenceDemo { static class MyThreadLocal<T> { private final ThreadLocal<WeakReference<T>> tl = ThreadLocal.withInitial(() -> new WeakReference<>(null)); public void set(T value) { tl.set(new WeakReference<>(value)); // key 是弱引用 } public T get() { WeakReference<T> ref = tl.get(); return ref == null ? null : ref.get(); // get() 返回实际对象 } } public static void main(String[] args) throws InterruptedException { MyThreadLocal<String> t1 = new MyThreadLocal<>(); t1.set("Hello"); System.gc(); // 弱引用对象会被回收 Thread.sleep(100); System.out.println(t1.get()); // 输出 null,证明弱引用已失效 } }这段代码的关键在于:tl.set(new WeakReference<>(value))中,WeakReference作为 value 存入ThreadLocalMap,其 key(即MyThreadLocal实例)是强引用,但WeakReference自身持有的value是弱引用。当t1离开作用域,t1实例可被回收,WeakReference的get()返回 null,从而避免ThreadLocalMap持有业务对象导致内存泄漏。这是面试官最爱挖的“原理+代码+场景”三位一体题。
3.2 易错题:四种引用在 GC 中的真实行为,90% 的人答错这 3 点
错在哪 → 为什么错 → 怎么答对
错在哪:认为“软引用在 Minor GC 时也会被回收”。
为什么错:软引用的回收时机由 JVM 内存压力决定,Minor GC 只清理新生代,不评估整个堆内存是否充足;只有当 JVM 判断“即使触发 Full GC 也无法满足内存需求”时,才会回收软引用。
怎么答对:软引用回收发生在 JVM 认为“即将 OOM”时,通常伴随 Full GC,但不是 Full GC 的必然结果;可通过-XX:+PrintGCDetails观察日志中SoftReference的cleared数量。错在哪:认为“弱引用对象在 GC 后立即消失,无法获取”。
为什么错:弱引用对象在 GC 后只是被标记为可回收,实际回收动作可能延迟(尤其在并发收集器中),且WeakReference.get()在对象被回收前仍返回非 null。
怎么答对:弱引用对象在下一次 GC 周期开始前仍可能存活,get()返回非 null 是正常现象;判断是否回收应以ReferenceQueue为准,或观察 GC 日志中WeakReference的cleared计数。错在哪:认为“虚引用可以像软/弱引用一样通过 get() 获取对象”。
为什么错:PhantomReference.get()永远返回 null,这是 JVM 强制设计——虚引用存在的唯一意义是接收 GC 通知,而非访问对象。
怎么答对:虚引用必须与ReferenceQueue配合使用;当queue.poll()返回该虚引用时,说明对象已被 GC,此时可安全执行清理(如Unsafe.freeMemory()),但绝不能试图get()它。
4. GC 收集器实战对比:CMS 已淘汰,G1 是底线,ZGC 是加分项
4.1 CMS 收集器:曾经的低延迟王者,为何被 JDK14 正式移除?
CMS(Concurrent Mark Sweep)的设计哲学是“以 CPU 换时间”:在老年代并发执行标记-清除,避免长时间 STW。但它有三大硬伤:
- 浮动垃圾(Floating Garbage):并发标记阶段用户线程继续运行,新产生的垃圾无法在本次 GC 清理,只能等下次;
- 并发失败(Concurrent Mode Failure):并发标记未完成,老年代就满了,被迫退化为 Serial Old 的单线程标记-整理,STW 时间长达数秒;
- 碎片化严重:标记-清除算法不整理内存,长期运行后老年代碎片化,大对象无法分配,触发 Full GC。
JDK9 开始默认启用 G1,JDK14 彻底移除 CMS。面试官若还问 CMS,是在考你是否了解技术演进脉络。正确答法是:“CMS 是 JDK8 及以前的低延迟方案,但因并发失败和碎片问题,在 JDK9 后被 G1 取代;现在生产环境应默认选 G1,ZGC/Shenandoah 用于超低延迟场景。”
4.2 G1 收集器:不是“全堆并发”,而是“分区+预测+混合回收”的精密调度
G1(Garbage-First)的核心突破在于将堆划分为多个大小相等的 Region(默认 2048 个),每个 Region 可扮演 Eden、Survivor 或 Old 角色。它不坚持分代,但保留分代概念——通过 Remembered Set(RSet)记录跨 Region 引用,实现部分 Region 的精准回收。G1 的 GC 分为三类:
- Young GC:回收所有 Eden 和部分 Survivor Region,STW,但时间可控(目标
-XX:MaxGCPauseMillis); - Mixed GC:在 Young GC 基础上,加入若干老年代 Region(由
-XX:G1MixedGCCountTarget控制),逐步清理老年代; - Full GC:仅当 Mixed GC 无法跟上分配速度时触发,单线程,STW 极长,必须避免。
关键参数解读:
-XX:MaxGCPauseMillis=200:G1 的暂停时间目标,不是保证值,而是 JVM 尽力达成的软目标。G1 会动态调整 Young GC 的 Region 数量、Mixed GC 的老年代 Region 数量来逼近该值;-XX:G1HeapRegionSize=1M:Region 大小,影响 Humongous Object 判定(≥ 50% Region Size 即为大对象);-XX:G1MixedGCCountTarget=8:Mixed GC 最多清理 8 轮,之后强制 Young GC,防老年代 Region 清理过慢。
提示:当面试官问“G1 如何解决 CMS 的碎片问题”,答:“G1 在 Mixed GC 阶段采用‘标记-整理’算法,将存活对象复制到新的 Region,原 Region 整块回收,彻底消除碎片。”
4.3 ZGC/Shenandoah:毫秒级 GC 的代价是什么?
ZGC(JDK11 引入)和 Shenandoah(JDK12 引入)的目标是:无论堆多大(TB 级),GC STW 时间 < 10ms。它们的共同技术是读屏障(Load Barrier)+ 颜色指针(Colored Pointers):对象引用中嵌入标记位(ZGC)或通过转发指针(Shenandoah),在对象访问时异步完成重定位。但代价明显:
- CPU 开销增加 10%~15%:每次对象访问都要走读屏障;
- 内存占用略高:ZGC 的颜色指针需额外位宽(64 位系统用 4 位存颜色);
- 功能限制:ZGC 不支持类卸载(Class Unloading),Shenandoah 在 JDK12 时还不支持 JNI Critical。
所以,面试官若问“何时用 ZGC”,标准答案是:“堆内存 > 4GB 且业务对延迟极度敏感(如高频交易、实时风控),且能接受 CPU 开销上升;普通 Web 应用,G1 完全够用,ZGC 是锦上添花,不是雪中送炭。”
5. Java 集合框架高频陷阱:HashMap 的红黑树阈值、ConcurrentHashMap 的锁粒度、ArrayList 的扩容血泪史
5.1 HashMap:从 JDK7 头插法到 JDK8 尾插法,一次扩容引发的线程安全灾难
JDK7 的HashMap扩容采用头插法:新链表的节点插入到链表头部。这在多线程环境下会导致环形链表——当两个线程同时触发 resize,一个线程执行到一半被挂起,另一个线程完成 resize,前者恢复后继续执行,可能将节点插入到错误位置,形成环。后果是get()时死循环(CPU 100%)。JDK8 改为尾插法,虽仍不线程安全,但至少不会死循环。真正的线程安全方案是ConcurrentHashMap或Collections.synchronizedMap()。
// JDK7 头插法伪代码(危险!) void transfer(Entry[] newTable, Entry[] src) { for (int j = 0; j < src.length; j++) { Entry<K,V> e = src[j]; if (e != null) { src[j] = null; do { Entry<K,V> next = e.next; // 保存下一个节点 int i = indexFor(e.hash, newTable.length); e.next = newTable[i]; // 头插:e 指向原链表头 newTable[i] = e; // e 成为新链表头 e = next; } while (e != null); } } }这段伪代码的致命点在e.next = newTable[i]:如果newTable[i]已被另一线程修改,e.next就可能指向自己,形成环。JDK8 的尾插法通过last.next = e避免了这个问题,但put()本身仍非原子操作,多线程写必须加锁。
5.2 ConcurrentHashMap:JDK7 的 Segment 锁 vs JDK8 的 CAS + synchronized
JDK7 的ConcurrentHashMap采用Segment 分段锁:将整个 HashTable 分为 16 个 Segment(默认),每个 Segment 是一个独立的 HashTable,加ReentrantLock。优点是锁粒度比Hashtable(全表锁)细;缺点是 Segment 数量固定,扩容需锁全部 Segment,且内存占用高(每个 Segment 有独立的 HashEntry 数组)。
JDK8 彻底重构:取消 Segment,改用CAS + synchronized。底层仍是数组 + 链表 + 红黑树,但锁粒度降到单个桶(bin)级别:
put()时,先 CAS 尝试插入头节点;失败则synchronized住该 bin 的首节点,再遍历链表/红黑树;size()不再依赖全局计数,而是用CounterCell数组分段计数,最后汇总,避免高并发下size()成为瓶颈。
关键参数:-Djdk.util.concurrent.ForkJoinPool.common.parallelism=4可调computeIfAbsent的并行度,但默认值已足够。
5.3 ArrayList:扩容不是 1.5 倍,而是(oldCapacity >> 1) + oldCapacity
ArrayList的扩容公式是newCapacity = oldCapacity + (oldCapacity >> 1),即 1.5 倍。但很多人忽略两点:
- 首次扩容:空
ArrayList第一次add()时,elementData为DEFAULTCAPACITY_EMPTY_ELEMENTDATA,扩容至DEFAULT_CAPACITY = 10,不是 1.5 倍; - 大容量扩容:当
oldCapacity接近Integer.MAX_VALUE时,(oldCapacity >> 1) + oldCapacity可能溢出,此时hugeCapacity()方法会返回Integer.MAX_VALUE或MAX_ARRAY_SIZE(Integer.MAX_VALUE - 8)。
// ArrayList.ensureCapacityInternal() 关键逻辑 private void ensureCapacityInternal(int minCapacity) { if (elementData == DEFAULTCAPACITY_EMPTY_ELEMENTDATA) { minCapacity = Math.max(DEFAULT_CAPACITY, minCapacity); // 首次扩容到 10 } ensureExplicitCapacity(minCapacity); } private void ensureExplicitCapacity(int minCapacity) { modCount++; if (minCapacity - elementData.length > 0) grow(minCapacity); // 真正扩容 } private void grow(int minCapacity) { int oldCapacity = elementData.length; int newCapacity = oldCapacity + (oldCapacity >> 1); // 1.5 倍 if (newCapacity - minCapacity < 0) newCapacity = minCapacity; if (newCapacity - MAX_ARRAY_SIZE > 0) newCapacity = hugeCapacity(minCapacity); // 溢出处理 elementData = Arrays.copyOf(elementData, newCapacity); }这段源码揭示了:扩容不是简单的乘法,而是位运算优化(>>1比/2快),且有严格的溢出兜底。面试官若问“ArrayList 扩容机制”,必须答出“首次扩容到 10、后续 1.5 倍、溢出时取 MAX_ARRAY_SIZE”。
5.4 易错题:集合框架里最常被误解的 3 个行为
错在哪 → 为什么错 → 怎么答对
错在哪:认为
HashSet的底层是HashMap,所以HashSet.add()是线程安全的。
为什么错:HashSet确实是HashMap的包装(map.put(e, PRESENT)),但HashMap本身不线程安全,HashSet也就不安全;ConcurrentHashMap的线程安全不传递给HashSet。
怎么答对:HashSet线程安全需用Collections.synchronizedSet(new HashSet<>())或new CopyOnWriteArraySet<>()(适用于读多写少)。错在哪:认为
TreeSet的排序只支持Comparable,不支持Comparator。
为什么错:TreeSet有两个构造函数:TreeSet()(要求元素实现Comparable)和TreeSet(Comparator<? super E> comparator)(传入比较器)。后者更灵活,可对无Comparable的类排序。
怎么答对:TreeSet排序逻辑由Comparator决定,若未传入,则调用元素的compareTo();面试中可举例new TreeSet<>(Comparator.comparing(String::length))。错在哪:认为
LinkedList的get(int index)时间复杂度是 O(1)。
为什么错:LinkedList是双向链表,get()需从头或尾遍历到指定索引,平均时间复杂度 O(n/2) = O(n);只有addFirst()、addLast()、removeFirst()、removeLast()是 O(1)。
怎么答对:LinkedList适合频繁首尾增删,不适合随机访问;若需 O(1) 随机访问,应选ArrayList。
6. 答题心法:从“背答案”到“建模型”,我的三遍过题法
面试不是知识复述,是思维建模。我带过的 A同学,第一次模拟面试被 JVM 问题打懵,回来问我:“老师,G1 的 Mixed GC 是啥?”我反问:“你家小区有 100 个快递柜,每天有 20 个柜子装满快递(对应 Eden 满),快递员(GC 线程)要清空它们。但有些柜子(老年代 Region)里有积压半年的旧快递(长期存活对象),他不可能一次清空全部 100 个柜子(Full GC 太慢),怎么办?”A同学脱口而出:“分批清!今天清 20 个新柜子 + 5 个旧柜子,明天再清 20 个新 + 5 个旧……”——那一刻,他懂了 Mixed GC 的本质:增量式清理,用时间换响应。
从此,我教所有学员用“三遍过题法”:
第一遍:画图建模(5 分钟)
不翻资料,拿白纸画:
- JVM 内存结构:标出线程私有区(PC、栈、本地方法区)和线程共享区(堆、元空间),用不同颜色区分;
- G1 Region:画 8×8 网格,标出 E、S、O、H(Humongous)区域,箭头标出 Young GC(E→S)、Mixed GC(E+S+O)流向;
- HashMap 扩容:画两个数组,标出头插法如何形成环(JDK7)vs 尾插法如何避免(JDK8)。
画错没关系,画的过程就是强制建立空间关系。
第二遍:参数锚定(3 分钟)
针对每个模型,写下 2 个关键参数:
- G1:
-XX:MaxGCPauseMillis=200(目标)、-XX:G1HeapRegionSize=1M(Region 大小); - HashMap:
-Djdk.util.HashMap.capacity=16(初始容量)、-Djdk.util.HashMap.loadFactor=0.75(负载因子); - ArrayList:
-Djdk.util.ArrayList.initialCapacity=10(首次扩容值)。
参数是面试官验证你是否真懂的“指纹”,背概念不如记参数。
第三遍:场景反推(2 分钟)
闭眼想一个线上故障:
- “服务 RT 突增 500ms,监控显示 Full GC 频繁” → 答:“先
jstat -gc <pid>看老年代使用率是否持续 >90%,若是,检查是否有大对象直接进老年代(-XX:PretenureSizeThreshold是否过小),或 CMS 并发失败(JDK8 前)”; - “CPU 100%,
jstack显示大量线程 BLOCKED 在HashMap.get()” → 答:“JDK7 的 HashMap 头插法导致环形链表,应升级 JDK8 或改用ConcurrentHashMap”。
场景是答案的“氧气”,没场景的答案是尸体。
从那以后我每次准备面试,都强制走一遍这三遍:画图 → 写参数 → 想故障。不是为了押中题,而是让知识长成肌肉记忆——当面试官问“G1 如何避免碎片”,我不用回忆定义,手指已经在空中画出 Region 移动的轨迹,嘴里自然说出“标记-整理算法,存活对象复制到新 Region,原 Region 整块回收”。希望帮到你。
本文还有配套的精品资源,点击获取