JVM
快来分享你的内容吧~
- 01-27 02:10·Java后端多标-浮动垃圾 在并发垃圾回收过程中,由于应用程序线程与GC线程同时运行,导致某些本应该被回收的垃圾仍旧被标记到引用(对象标记期间变成垃圾或新分配的对象未被标记),从而未能回收的那部分垃圾,称之为浮动垃圾。浮动垃圾通常不会影响垃圾回收的正确性,只是需要等到下一轮才会被清除。针对并发标记/并发清理期间产生的新对象,通常都是全部标记为黑色 漏标-读写屏障 漏标是指在并发标记阶段,由于应用线程同时修改对查看全文加油鸭:这份笔记太扎实了!概念清晰、对比到位、细节丰富,看得出你对JVM垃圾回收机制下了很深的功夫,点赞!331分享
01-15 13:15·Java后端- 2025-11-18·Java后端
- 2025-11-13·Java后端
Day19 JVM调优分析
### JVM调优分析 - jmap 查看内存信息,实例个数以及占用内存大小 ```bash # 查看java进程信息 jps # 查看当前存活的实例,执行过程中可能会触发一次full gc jmap -histo:live 进程ID > outputDir # 将内存信息输出到文件 # 输出文件内容有四列 # num instances bytes class name # 序号 实例数 占用空间大小 类名称[C=>char[];[S=>short[];[I=>int[];[B=>byte[] # 查看堆信息 JDK9+不再支持 jmap -heap 进程ID # 导出dump文件 jmap -dump:format=b,file=eureka.hprof # JDK9+ jhsdb jmap --binaryheap --dumpfile=heap.hprof --pid 546543 ``` dump文件导入到jvisualvm进行分析  - jstack - jstack <pid> 获取线程快照,可以寻找死锁,也可以利用jvisualvm自动检测死锁 jstack <pid> > fileDir/fileName   - jstack寻找cpu最高的线程堆栈信息 ```bash # 查看cpu进程使用情况 top # 查看使用率最高的进程 top -p <pid> # 展开进程里详细的线程占用信息 H # 找到cpu占用率最高的线程,将线程id转化为小写的16进制 # 执行以下命令 pid是十进制进程号,N是显示行数,jpid是十六进制的线程号 jstack <pid> | grep -A N <jpid> ```   - jinfo - 查看正在运行的java程序扩展参数  - 查看系统参数  - jstat jstat [-命令选项] [vmid] [间隔时间(毫秒)] [查询次数] - 查看GC信息 jstat -gc <pid> S0C:第一个幸存区的大小,单位KB; S1C:第二个幸存区的大小 S0U:第一个幸存区的使用大小; S1U:第二个幸存区的使用大小 EC:伊甸园区的大小; EU:伊甸园区的使用大小 OC:老年代大小; OU:老年代使用大小 MC:方法区大小(元空间); MU:方法区使用大小 CCSC:压缩类空间大小; CCSU:压缩类空间使用大小 YGC:年轻代垃圾回收次数; YGCT:年轻代垃圾回收消耗时间,单位s FGC:老年代垃圾回收次数; FGCT:老年代垃圾回收消耗时间,单位s GCT:垃圾回收消耗总时间,单位s  - 堆内存统计 jstat -gccapacity <pid> NGCMN:新生代最小容量 NGCMX:新生代最大容量 NGC:当前新生代容量  - 新生代垃圾回收统计 jstat -gcnew <pid>  - 诊断工具arthas - 下载 `wget https://alibaba.github.io/arthas/arthas-boot.jar` - 启动并分析在运行的进程  dashboard查看进程运行情况  thread查看线程情况(thread <线程id> 查看线程堆栈 thread -b 查看是否有死锁)  jad +全类名 实现反编译  ognl 获取变量(更离谱的,可以改变线上变量值)  - GC日志分析 java -jar -Xloggc:./gc-%t.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps -XX:+PrintGCCause -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=10 -XX:GCLogFileSize=100M microservice-eureka-server.jar 拿到gc文件后可以上传至gceasy(https://gceasy.io) 进行分析,会输出一个可视化界面查看堆内存及分代使用情况,以及会给出优化建议。
Day18 JVM垃圾收集器二
- 多标-浮动垃圾 在并发垃圾回收过程中,由于应用程序线程与GC线程同时运行,导致某些本应该被回收的垃圾仍旧被标记到引用(对象标记期间变成垃圾或新分配的对象未被标记),从而未能回收的那部分垃圾,称之为**浮动垃圾**。浮动垃圾通常不会影响垃圾回收的正确性,只是需要等到下一轮才会被清除。针对并发标记/并发清理期间产生的新对象,通常都是全部标记为黑色 - 漏标-读写屏障 漏标是指在并发标记阶段,由于应用线程同时修改对象引用关系,导致本应存活的对象被错误的标记为垃圾,从而在回收时被错误的释放,这会直接导致程序崩溃,是一种严重的程序错误。两种解决方案 - **写屏障** 当应用线程写入一个引用字段时,JVM自动加入一些处理操作来确保GC不会漏掉新建立的引用或丢失旧引用 **增量更新(Incremental Update)** 当黑色对象插入新的指向白色对象的引用关系时,会将新插入的引用记录下来,等并发扫描结束之后,再以这个加入了新引用的黑色对象为根,进行重新扫描。简单理解就是,黑色对象一旦有对白色对象的新引用,就记为灰色。 **原始快照(Snapshot At The Beginning SATB)** 当灰色对象要删除指向白色对象的引用关系时,会将删除的引用记录下来,等并发扫描结束之后,再以这个被删除了的对象为根,进行重新扫描,确保白色对象能被正确标记(即使有可能是浮动垃圾,也让它先活过这一轮)。缺点是浮动垃圾增多,以及会有额外开销和内存占用 **为什么G1选择用SATB,而CMS用增量更新?** 效率和成本的综合考虑,SATB相对于增量更新效率会更高,因为不需要在重新标记阶段再次进行深度扫描,G1的很多对象都位于不同的Regin,CMS就只负责老年代一个区域,如果G1也进行深度扫描的话代价会高很多 - **读屏障** 当应用线程读取一个引用时插入屏障,在访问对象前修正指针、触发对象转移,以确保对象已标记 - 记忆集和卡表 新生代做GC Roots可达性扫描过程中可能会碰到跨代引用的对象,如果又到老年代再去扫描的话效率太低了,为此引入了记忆集(Remember Set) - 卡表是记忆集的一种实现,使用一个字节数组CARD_TABLE[],数组每个元素对应着其标识的一块特定大小的内存区域,成为卡页,hotspot卡页大小是2^9=512字节。一个卡页中可能包含多个对象,只要有一个对象的字段存在跨代指针,其对应的卡表元素标识就会变成1,表示该元素变脏,否则为0,同样也是使用写屏障维护卡表状态。GC时只筛选本收集区的卡表变脏的元素加入GC Roots里 - G1收集器(-XX:UseG1GC JDK9+默认) Garbage-First收集器是一款**面向服务器**的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器,以极高概率满足GC停顿时间要求(-XX:MaxGCPauseMillis设置,默认200ms)的同时,还具备高吞吐量性能特征。 G1将Java堆划分为多个大小相等的独立区域,目标是不超过2048个Region,一般**Region的大小等于堆大小除以2048**,可以使用-XX:G1HeapRegionSize手动指定region大小,一般使用默认方式。 G1保留了年轻代和老年代的概念,但并非是物理隔离,而是以分区的形式存在。默认年轻代再堆内存的占比是5%,可通过-XX:G1NewSizePercent设置比例,在实际运行中,并不会完全严格按照这个比例,JVM在垃圾回收过程中的优化有可能会不停的给年轻代增加更多的Region,最多占比不会超过60%(-XX:G1MaxNewSizePercent),年轻代Eden/S0/S1占比依旧为8:1:1。除了年轻代和老年代之外,G1还新增了一个**Humongous区**来用于专门保存大对象,而不是让大对象直接进入老年代region中,只要对象的大小超过了region的50%,就会被放入**Humongous区(FullGC时也会回收),**如果对象太大,会横跨多个region区来存放。 回收主要还是**采用复制算法**,将一个region的存活对象复制到另一个region,这样就不用像CMS一样回收完存在碎片还需要整理一次。 - 特性 - 并行与并发 充分利用多核CPU硬件优势,缩短stw停顿时间 - 分代回收 保留分代概念,做区域分代收集 YoungGC:Eden区满立即触发,会STW,预测回收所需时间,如果时间远小于MaxGCPauseMillis,就新增年轻代region,继续给新对象存放,不会马上做YoungGC,直到下一次Eden区满回收时间接近设定值才做GC MixedGC:老年代的堆占有率达到参数(**-XX:InitiatingHeapOccupancyPercent**)设定的值则触发,回收所有年轻代和部分老年代。需要把各个region存活的对象拷贝到别的region区,如果拷贝过程中发现没有足够的空间承载对象就会触发FullGC FullGC:停止系统,然后采用单线程进行标记、清理和压缩整理,过程非常耗时,应尽量避免 - **可预测停顿** 对历史GC数据建模,预测每次回收的耗时 1)优先选择垃圾比例高+回收成本低的region 2)动态决定回收多少个region - G1收集器的运行过程(主要是指MixedGC) - **初始标记** 暂停所有其他线程STW,并记录下GC Roots直接能引用的对象。每个region有都有自己的remember sets记录跨区引用,由写屏障维护 - **并发标记 同CMS,基于SATB** - **最终标记 同CMS** - **筛选回收** 对各个Region的回收价值和成本进行排序,根据用户期望的GC停顿时间来定制回收计划,尽可能保证总停顿≤MaxGCPauseMillis(不与用户线程并发执行) G1收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的region,保证有限时间内垃圾回收的高效率。  - G1参数配置 -XX:+UseG1GC:使用G1收集器 -XX:ParallelGCThreads:指定GC工作的线程数量 -XX:G1HeapRegionSize:指定分区大小(1MB~32MB,且必须是2的N次幂),默认将整堆划分为2048个分区 -XX:MaxGCPauseMillis:目标暂停时间(默认200ms) -XX:G1NewSizePercent:新生代内存初始空间(默认整堆5%,值配置整数,默认就是百分比) -XX:G1MaxNewSizePercent:新生代内存最大空间 -XX:TargetSurvivorRatio:Survivor区的填充容量(默认50%),Survivor区域里的一批对象(年龄1+年龄2+年龄n的多个年龄对象)总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代 -XX:MaxTenuringThreshold:最大年龄阈值(默认15) -XX:InitiatingHeapOccupancyPercent:老年代占用空间达到整堆内存阈值(默认45%),则执行新生代和老年代的混合收集(**MixedGC**),比如我们之前说的堆默认有2048个region,如果有接近1000个region都是老年代的region,则可能就要触发MixedGC了 -XX:G1MixedGCLiveThresholdPercent(默认85%) region中的存活对象低于这个值时才会回收该region,如果超过这个值,存活对象过多,回收的的意义不大。 -XX:G1MixedGCCountTarget:在一次回收过程中指定做几次筛选回收(默认8次),在最后一个筛选回收阶段可以回收一会,然后暂停回收,恢复系统运行,一会再开始回收,这样可以让系统不至于单次停顿时间过长。 -XX:G1HeapWastePercent(默认5%): gc过程中空出来的region是否充足阈值,在混合回收的时候,对Region回收都是基于复制算法进行的,都是把要回收的Region里的存活对象放入其他Region,然后这个Region中的垃圾对象全部清理掉,这样的话在回收过程就会不断空出来新的Region,一旦空闲出来的Region数量达到了堆内存的5%,此时就会立即停止混合回收,意味着本次混合回收就结束了。 - 调优建议 - 不要盲目调整 `MaxGCPauseMillis`如设为 10ms→ G1 会频繁 GC,吞吐下降,如设为1000ms→G1单次处理时间长,存活的对象可能比较多,有可能触发FullGC - 监控FullGC 一旦发生需要即使优化 - 尽量避免大对象 - 使用场景 - 大堆内存 8G以上(例如Kafka服务器) - 停顿时间要求高≤500ms - 50%以上的堆被存活对象占用 - ZGC收集器(-XX:UseZGC 15+转正) jdk11引入的一款低延迟、可伸缩、基于分区的垃圾收集器,目标是在任意堆大小下(TB级),实现始终低于10ms的GC停顿时间,并对吞吐量的影响极小,适用于超大内存、对延迟敏感度极低的系统 - 内存布局 Region-Based - 小型region(Small) 固定容量为2MB,用于放置小于256KB的对象。 - 中型region(Medium) 固定容量为32MB,用于放置256KB≤对象大小≤4MB的对象 - 大型region(Large) 不固定容量,动态变化,用于放置4MB以上的大对象,每个大region中只会存放一个大对象。所以它的实际容量最小有可能仅有4MB   - **在JDK11-JDK20中,ZGC是不支持分代的,自JDK21起试验性引入分代模式(需通过-XX:+ZGenerational手动启用),默认还是非分代模式,但在JDK24之后,该参数被废弃,默认是强制分代模式。** - NUMA-aware(Non Uniform Memory Access Architecture) 早期的内存是CPU通过总线访问,随着核心数增多,都走一条总线的话争抢会越来越严重,也就相应的造成了性能瓶颈,于是就把CPU和内存集成到一个单元访问,形成了NUMA,每次CPU访问内存时,优先访问自己分配到的这块,如果本地内存空间不够了,就会从远程调配,以防出现内存使用倾斜。   - ZGC的回收过程 - 并发标记(Concurrent Mark) 遍历对象图做可达性分析,它的初始标记(**Mark Start**)和最终标记(**Mark End**)也会出现短暂的停顿,ZGC的标记是在指针上用64位地址的高四位记录,标记阶段会更新染色指针 - 并发预备重分配(Concurrent Prepare for Relocate) 需要根据特定的查询条件统计得出本次收集过程要清理的region,并将这些region组成重分配集(Relocation Set)。用更大的扫描成本换G1记忆集的维护成本。 - 并发重分配(Concurrent Relocate) 将重分配集中的存活对象复制到新的region上,并为分配集中的每个region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。 ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障(**读屏障**)所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(Self-Healing)能力。只有第一次触发时会有较大消耗 - 并发重映射(Concurrent Remap) 重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,但是ZGC中对象引用存在“自愈”功能,所以ZGC可以很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,节省了一次遍历对象图的开销,一旦所有指针修正后,原来记录新旧对象关系的转发表就会被释放。 - 染色指针(Colored Pointer) 以往的垃圾回收器的GC信息都保存在对象头中,而ZGC的GC信息保存在64位地址的指针中,使得ZGC在扫描引用时,不需要访问对象本身,只看指针就能知道对象的状态(也使得ZGC高度依赖64位机器,32位系统无法使用) - 18位:预留给以后使用【46-63】; - 1位:Finalizable标识【45】,此位与并发引用处理有关,它表示这个对象只能通过finalizer才能访问; - 1位:Remapped标识【44】,设置此位的值后,对象未指向relocation set中(relocation set表示需要GC的Region集合); - 1位:Marked1标识【43】; - 1位:Marked0标识【42】,和上面的Marked1都是标记对象用于辅助GC; - 42位:对象的地址【0-41】(所以它可以支持2^42=4T内存): 使用双缓冲标记,在每一轮GC周期开始时交替使用,提高并发性能,第一轮GC使用Marked0,第二轮使用Marked1 - 读屏障 之前的GC都是采用Write Barrier,这次ZGC采用了完全不同的方案读屏障,这个是ZGC一个非常重要的特性。 在标记和移动对象的阶段,每次「从堆里对象的引用类型中读取一个指针」的时候,都需要加上一个Load Barriers。  - 缺点 - 浮动垃圾多,每次停顿时间都在10ms以下,并且不做分代,意味着每次GC都有极大概率清理不完朝生夕死的垃圾,增大堆内存能有更多的喘息时间,但也是治标不治本 - CPU消耗较高,因为读屏障的存在,每次读取引用都会触发检查 - ZGC触发时机 - 定时触发 默认禁用,可通过-XX:ZCollectionInterval配置 - 预热触发 最多三次,当堆内存达到10%、20%、30%时触发,主要统计GC时间,为其他GC机制使用 - 分配速率 基于正态分布统计,计算内存99.9%可能的最大分配速率,以此速率下内存将要耗尽的时间点,在耗尽之前触发 - 主动触发 (默认,通过-XX:ZProactive配置)距上次GC堆内存增长10%,或超过5分钟时,对比距上次GC的间隔时间跟(49 * 一次GC的最大持续时间),超过则触发 - 如何选择垃圾收集器? 1. 优先调整堆的大小让服务器自己来选择 2. 如果内存小于100M,使用串行收集器 3. 如果是单核,并且没有停顿时间的要求,串行或JVM自己选择 4. 如果允许停顿时间超过1秒,选择并行或者JVM自己选 5. 如果响应时间最重要,并且不能超过1秒,使用并发收集器 6. **4G以下可以用parallel,4-8G可以用ParNew+CMS,8G以上可以用G1,几百G以上用ZGC(JDK9+默认使用G1)**  - 安全点与安全域 **安全点**就是指代码中一些特定的位置,当线程运行到这些位置时它的状态是确定的,这样JVM就可以安全的进行一些操作,比如GC等,所以GC不是想什么时候做就立即触发的,是需要等待所有线程运行到安全点后才能触发。 这些特定的安全点位置主要有以下几种: 1. 方法返回之前 2. 调用某个方法之后 3. 抛出异常的位置 4. 循环的末尾 大体实现思想是当垃圾收集需要中断线程的时候, 不直接对线程操作, 仅仅简单地设置一个标志位, 各个线程执行过程时会不停地主动去轮询这个标志, 一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。 轮询标志的地方和安全点是重合的。 **安全区域又是什么?** Safe Point 是对正在执行的线程设定的。如果一个线程处于 Sleep 或中断状态,它就不能响应 JVM 的中断请求,再运行到 Safe Point 上。因此 JVM 引入了 Safe Region。Safe Region 是指在一段代码片段中,**引用关系不会发生变化**。在这个区域内的任意地方开始 GC 都是安全的。
Day17 JVM垃圾收集
### 垃圾收集器与三色标记算法 - 垃圾收集算法 - 标记-清除 分为两个阶段,标记存活对象,然后统一回收未被标记的对象。但如果需要标记的对象太多的话效率不高,而且会产生大量不连续的碎片 - 标记-复制 为解决效率问题,引生了复制收集算法,将内存一分为二,平时只用一半,回收的时候把活着的对象移到另一半整齐排好,然后把原来的那一半直接清空。效率提高了,也没有空间碎片,但是每次都有一半的空间不使用,太浪费内存 - 标记-整理 根据老年代特点出的一种算法,老年代对象活得久,用复制算法得复制一大堆太慢,标记清除又有碎片。标记整理会先做标记,然后把活着的对象往一端推,最后把存活边界之外的内存清理掉 - 分代回收理论 基于对象生命周期假设的高效内存管理策略,核心思想是:大多数对象朝生暮死,只有少数对象会长期存活 - **垃圾收集器** - Serial收集器(-XX:+UseSerialGC -XX:UseSerialOldGC) 串行收集器是最基本、历史最悠久的收集器。单线程收集,收集过程中会停止其他所有的工作线程(Stop The World),直到它完成垃圾收集。 新生代采用复制算法,老年代使用标记-整理算法 - Parallel Scavenge收集器(-XX:+UseParallelGC -XX:UseParallelOldGC) 并行收集器,可以理解为Serial的多线程版本,除了是多线程收集外,其余行为与Serial无差异。Parallel Scavenge关注的是吞吐量,在于高效利用CPU,CMS等垃圾收集器关注的则是用户线程的停顿时间,提高用户体验。 新生代采用复制算法,老年代使用标记-整理算法 - ParNew收集器(-XX:UseParNewGC) 跟Parallel类似,区别是ParNew可以和CMS搭配使用,是大多数Server模式下虚拟机的首选 新生代采用复制算法 - CMS收集器(-XX:UseConcMarkSweepGC) ConCurrent Mark Sweep收集器是一种以获取最短回收停顿时间为目标的收集器,适合在注重用户体验的应用上使用,采用标记-清除算法。 **优点**:并发收集,低停顿;**缺点**:对CPU资源敏感,无法清理浮动垃圾(用户线程和清理线程并行,也可能产生垃圾),标记-清除算法会产生大量空间碎片(通过参数-XX:UseCMSCompactAtFullCollection可以让jvm在执行完标记清除后再做整理),执行过程中存在不确定性,特别是在并发标记和并发清理阶段,一边回收一边运行,可能没回收完就再次触发了FullGC,也就是”**concurrent mode failure”**,**此时会进入stop the world,用serial old垃圾收集器来回收** - 运行过程分为五步: 1. **初始标记** 暂停所有的其他线程(STW),并记录下gc roots**直接能引用**的对象,速度很快。 2. **并发标记** 并发标记阶段是从gc roots的**直接关联对象**开始遍历整个对象引用链,整个过程耗时较长,但是不会停止用户线程,可以与垃圾收集线程一起并发运行,此阶段因为用户程序还在继续运行,所以可能会有已标记过的对象状态发生改变 3. **重新标记** 为了修正并发标记期间因用户程序运行而导致标记状态变化的那一部分对象的标记记录(主要是**处理漏标问题**),这阶段处理停顿时间比初始标记阶段长,比并发标记阶段时间短。主要**使用三色标记里的增量更新算法**做重标 4. **并发清理** 开启用户线程,同时GC线程开始对未标记的区域做清扫,如果有新增对象会被标记为黑色不做任何处理(宁愿不清,不愿误清) 5. **并发重置** 重置本次GC过程中的标记数据  - **CMS的相关核心参数** 1. -XX:+UseConcMarkSweepGC:启用cms 2. -XX:ConcGCThreads:并发的GC线程数 3. -XX:+UseCMSCompactAtFullCollection:FullGC之后做压缩整理(减少碎片) 4. -XX:CMSFullGCsBeforeCompaction:多少次FullGC之后压缩一次,默认是0,代表每次FullGC后都会压缩一次 5. -XX:CMSInitiatingOccupancyFraction: 当老年代使用达到该比例时会触发FullGC(默认是92,这是百分比) 6. -XX:+UseCMSInitiatingOccupancyOnly:只使用设定的回收阈值(-XX:CMSInitiatingOccupancyFraction设定的值),如果不指定,JVM仅在第一次使用设定值,后续则会自动调整 7. -XX:+CMSScavengeBeforeRemark:用于在CMS垃圾收集器的重新标记阶段(Remark)之前,强制触发一次年轻代的垃圾回收。其目的是减少需要扫描的对象数量,从而缩短重新标记阶段的停顿时间。 8. -XX:+CMSParallellnitialMarkEnabled:表示在初始标记的时候多线程执行,缩短STW 9. -XX:+CMSParallelRemarkEnabled:在重新标记的时候多线程执行,缩短STW; - **三色标记算法** > 在并发标记过程中,因为存在标记期间GC线程和应用线程并行的情况,期间对象的引用可能发生变化,多标和漏标就可能发生,因此引入了三色标记算法来解决此问题 > 三色标记算法是把GC Roots可达性分析遍历对象过程中遇到的所有对象按照“是否访问过”的条件标记成三种颜色: - **黑色** 表示对象已经被垃圾收集器访问过,且这个对象的**所有引用都已经扫描**过。 - **灰色** 表示对象已经被垃圾收集器访问过,但这个对象上**至少存在一个引用还未被扫描** - **白色** 表示对象尚**未被垃圾收集器访问**过。可达性分析初始阶段,所有对象都是白色的,分析结束后如果还是白色对象,代表此对象不可达
Day16 JVM对象内存分配
### JVM对象创建与内存分配 - 对象创建流程  1. 类加载检查。虚拟机遇到对象创建指令时,先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用代表的类是否已被加载、解析和初始化过,如果没有,则先执行对应类的加载过程。 2. 分配内存。类加载完成后,可以完全确定对象所需内存的大小,此时需要把一块大小确定的内存从堆中划分出来 > **如何划分内存?** 1. 指针碰撞(Bump the Pointer,默认方式):如果java堆中的内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间使用指针作为分界,分配内存时就只需要将指针往空闲空间的那边移动一段与新对象 大小相等的距离即可 2. 空闲列表(Free List):如果java堆中的内存不是规整的,而是已使用的内存和空闲内存相互交错,那虚拟机就会维护一个列表,记录哪些内存块可用,分配时从列表里找到一块足够大的空间划分给对象实例,并更新列表上的记录 **如何解决对象并发创建?** 1. CAS(compare and swap) 虚拟机采用CAS配上失败重试的方式保证更新操作的原子性,来对分配内存空间的动作进行同步处理 2. 本地线程分配缓冲(Thread Local Allocation Buffer TLAB)把内存分配的动作按线程划分再不同的空间中进行,即每个线程在Java堆中预先分配一小块内存。通过-XX:+/-UserTLAB参数来设定是否开启,默认是开启的,-XX:TLABSize指定TLAB的大小 > 3. 初始化零值。内存分配完成后,虚拟机会将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这一工作过程可以提前至TLAB分配时进行。这步操作保证了对象的实例字段在java代码中可以不赋初始值就能直接使用,程序能访问到这些字段数据类型对应的零值。 > 对象在内存中的存储包含三个部分: 对象头、实例数据、对齐填充(保证对象是8个字节的整数倍) > 4. 设置对象头。初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头Object Header之中。   5. 执行<init>方法,按照程序设定执行构造方法和赋值 - 对象大小和指针压缩 - 什么是指针压缩Compressed Ordinary Object Pointers? 一种用于**减少对象引用内存占用**的优化技术,-XX:+UseCompressedOops(**默认开启**),禁止指针压缩:-XX:-UseCompressedOops - 为什么需要指针压缩? > 减少堆内存占用、提高CPU缓存命中率、降低GC压力 > 在64位JVM中,对象引用默认使用8字节表示,相比32位的4字节引用,会显著增加对内存的使用量,使用较大指针在主内存和缓存之间移动数据,会占用较大带宽,同时GC也会承受较大压力,为了减少64位的堆内存消耗,引入了指针压缩功能。 真实地址>>3得到32位地址存储,拿到32位地址<<3得到真实地址。 32位地址最大支持4G(2^32)内存,通过优化后使用32位的地址就可以实际做到寻址空间为2^35=4G*8=32G 所以当堆内存小于等于4G时,不需要启用指针压缩,jvm会直接去除高32位地址,当堆内存大于32G时,压缩指针会失效,强制使用64位来对java对象寻址。 - 对象内存分配  - 对象栈上分配 > **什么是对象逃逸分析?** 通过分析对象(-XX:+DoEscapeAnalysis)动态作用域,当一个对象在方法中被定义后,JIT会分析判断它会不会逃离当前方法或当前线程。 方法逃逸:对象被return出去,或者作为参数传递 线程逃逸:对象被赋值给静态变量、实例变量,或者被其他线程访问 标量替换(-XX:+EliminateAllocations):如果不会逃逸,并且对象可以分解时,就不会创建对象,而是把对象的成员变量当成独立的局部变量来存 同步消除(-XX:+EliminateLocks): 对象只在单线程内使用,不会被其他线程看到,那加synchronized就是多余的,直接去掉 > 经过逃逸分析后,确定该对象不会被外部访问,就会把该对象放在栈上分配内存,让对象随着栈帧出栈而销毁,减轻垃圾回收负担 - 对象在Eden区分配 大多数情况下,对象都在新生代中的Eden区分配,当Eden区没有足够的空间进行分配时,虚拟机将发起一次Minor GC(Young GC),可通过添加运行JVM参数: -XX:+PrintGCDetails打印堆占用情况 **Minor GC(Young GC)**:指发生新生代的垃圾回收操作,Minor GC非常频繁,回收速度也比较快 **Major GC(Full GC)**:会回收老年代、新生代、方法区的垃圾,回收速度慢 - 大对象直接进入老年代 大对象就是指需要大量连续内存空间的对象,JVM参数 -XX:PretenureSizeThreshold (unit:byte)可以设置大对象的大小,如果对象所需的内存空间超过这个值,为了避免大对象分配内存的时候的复制操作,就会跳过新生代内存,直接被分配到老年代,个参数只在 Serial 和ParNew两个收集器下有效。 - 长期存活的对象进入老年代 虚拟机会给每个对象分配一个对象年龄计数器,如果对象在Eden区出生,在经过第一次Young GC仍然存活,并且能够被Survivor容纳的话,将会被转移到Survivor空间中,并将对象年龄设置为1,对象在Survivor区每熬过一次Young GC,年龄就增加1岁,当他的年龄增长到一定程度时,就会被放到老年代中,晋生到老年代的阈值可以通过**-XX:MaxTenuringThreshold** 设置 > 如果Eden到Survivor区存不下怎么办? 如果Survivor容纳不下Eden区存活的对象,会将对象直接放入老年代 > - 对象动态年龄判断(Young GC后触发) 当前存放对象的Survivor区里,一批对象的总大小大于这块区域内存大小的50%(-XX:TargetSurvivorRatio可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代 - 老年代空间分配担保机制 老年代每次Yonug GC之前JVM都会计算老年代剩余可用空间,如果这个可用空间小于新生代里所有的对象大小之和(包括垃圾对象),就会看一个-XX:-HandlePromotionFailure的参数(默认有值),如果有值,会判断老年代的可用内存大小,是否大于之前每一次Young GC后进入老年代的对象的平均大小,若是小于,就会触发full gc,如果回收玩还是不够存储新的对象就会造成OOM。  - 对象内存回收 > 对垃圾进行回收之前,先要判断哪些对象已经死亡 > - 引用计数法 给每个对象维护一个引用计数器,有引用就加一,引用失效就减一,计数器为0则回收。实现简单效率高,但是无法解决对象之间相互引用的问题 - 可达性分析 从GC Roots出发,顺着引用链往下遍历,能遍历到的对象都是活的,其余的都是要回收的。能解决循环引用问题,但需要花更多的时间遍历做标记  - 引用类型 > 按引用强度分为四种,强度越弱,GC越容易回收 > - 强引用 普通的变量引用,例如User user = new User(); - 软引用 通过SoftReference包裹的引用,没存够用的时候不会回收,内存吃紧快要OOM的时候才回收,适合做缓存 - 弱引用 通过WeakReference包裹的引用,GC会直接回收 - 虚引用 通过PhantomReference包裹的引用,包裹后拿不到对象实例,永远返回null,几乎不用  - 如何判断一个类是无用的类 - 该类的实例对象都已被回收,也就是堆中不存在该类的任何实例 - 加载该类的ClassLoader已被回收 - 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问到改类的方法
Day15 JVM结构和线程模型
> 今天偷个懒了属于是 ### JVM模型  - 程序计数器 记录当前线程正在执行的字节码指令地址,线程私有,生命周期与线程一致,是唯一不会发生OOM的区域,执行native方法时,程序计数器的值为undefined - 虚拟机栈 存储方法调用的栈帧,每个方法调用都会创建一个栈帧,线程私有,随线程创建而创建,通过-Xss设置栈大小,有栈深度限制,若递归太深,会抛出StackOverflowError - 本地方法栈 为JVM调用native提供服务。 - 堆 存放几乎所有对象实例和数组。线程共享,是JVM中最大的一块内存区域,由内存回收器GC管理,内部划分为新生代(young generation: Eden + Survivor)、老年代(Old)和永久代(PermGen JDK8+已不再属于堆,使用的是本地内存,且改名为元空间Metaspace),可以提供-Xmx(最大堆)和-Xms(初始堆)调节大小,OOM:Java heap space属于此区域  - 方法区 储存类的元数据,包括类结构信息、运行时常量池、静态变量、即时编译器JIT优化后的代码缓存。线程共享,不需要连续内存,可固定大小或扩展,通过-XX:MataspaceSize(触发fullGC的最小阈值,默认21M)和-XX:MaxMetaspaceSixe(元空间最大值,默认-1不限制)配置 - 直接内存 JVM向操作系统申请的一块堆外内存,不受JVM堆大小限制,由操作系统直接管理,通过`java.nio.ByteBuffer.allocateDirect()`分配
Day14 JVM类加载机制
### 类加载机制 > 类加载就是把.class文件的二进制数据读进内存,经过校验、转换,最终变成JVM能用的class对象。二进制流也可以是字节码工具动态生成的,也可以是网络传输的,不一定非得来自class文件,只要格式对就行 > 1. 类缓存: 每个类加载器对它加载过的类都有缓存 2. 双亲委派:向上委托查找,向下委托加载 3. 沙箱保护机制:不允许应用程序加载JDK内部系统类 - 类加载器ClassLoader,由底层开始逐层向上寻找 - 启动类加载器(BootStrap) 负责加载<JAVA_HOME>/lib下的核心类库 - 扩展类加载器(Extension) 负责加载<JAVA_HOME>/lib/ext下的扩展库 - 应用程序类加载器(Application) 复杂加载业务代码和第三方jar包  - JDK8中的类加载器都继承自一个统一的抽象类ClassLoader,以下是类加载的核心方法,此方法中包含核心的双亲委派机制,而且方法声明为protected,意味着我们可以打破**双亲委派**机制,自行实现加载 > **什么是双亲委派模型?** 当一个类加载器收到加载请求时,不会自己先去加载,而是把请求向上委派给父加载器,一直委派到顶层BootStrap ClassLoader,如果父类加载器加载不了,才轮到自己加载。 这样做的好处? > 1. 保护核心类库的安全,核心类永远由BootStrap加载,就算写一个同名类放到classpath下,也不会被加载到 > 2. 避免类重复加载,同一个类在JVM中只会被加载一次,保证类的唯一性 > >  ```java //类加载器的核心方法 protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { synchronized (getClassLoadingLock(name)) { // 每个类加载起对他加载过的类都有一个缓存,先去缓存中查看有没有加载过 Class<?> c = findLoadedClass(name); if (c == null) { //没有加载过,就走双亲委派,找父类加载器进行加载。 long t0 = System.nanoTime(); try { if (parent != null) { c = parent.loadClass(name, false); } else { c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { } if (c == null) { long t1 = System.nanoTime(); // 父类加载起没有加载过,就自行解析class文件加载。 c = findClass(name); sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0); sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); sun.misc.PerfCounter.getFindClasses().increment(); } } //这一段就是加载过程中的链接Linking部分,分为验证、准备,解析三个部分。 // 运行时加载类,默认是无法进行链接步骤的。 if (resolve) { resolveClass(c); } return c; } } ``` - 类加载过程 > 整个类加载的过程分为三个大阶段**加载、连接、初始化**,连接又能拆分成**验证、准备、解析**三个步骤 > 1. 加载 把二进制流读进内存,在方法区生成类的运行时数据结构,同时在堆里创建一个Class对象作为访问入口。是应用唯一可以介入干涉的步骤。 2. 验证 校验二进制流是否符合JDK Class文件规范,为了安全考虑,防止恶意代码 3. 准备 为静态变量分配内存并设置默认值(比如static int a = 1;此时的a=0,并不是1),此时变量为半初始化状态。 4. 解析 将常量池里的符号引用转换成为直接引用。符号引用:一个字符串标识,java/lang/Object;直接引用是真正的内存地址 5. 初始化 执行构造器clinit方法,这时候才真正执行复制操作  - 沙箱保护机制 前文讲到双亲委派机制最大的作用就是保护核心类库不会被应用覆盖,而java为了确保内部核心类的安全性,在双亲委派的基础上,还增加了一层保护机制,简单粗暴的禁止加载以java.开头的类加载 ```java // ClassLoader.java private ProtectionDomain preDefineClass(String name, ProtectionDomain pd) { if (!checkName(name)) throw new NoClassDefFoundError("IllegalName: " + name); // 不允许加载核心类 if ((name != null) && name.startsWith("java.")) { throw new SecurityException ("Prohibited package name: " + name.substring(0, name.lastIndexOf('.'))); } if (pd == null) { pd = defaultDomain; } if (name != null) checkCerts(name, pd.getCodeSource()); return pd; } ``` - 实战演练 - 通过类加载器加载外部jar包 > 什么时候适合外部加载? 流程统一,但实现经常变化的场景,比如工作流引擎 > ```java public class OADemo2 { public static void main(String[] args) throws Exception { Double salary = 15000.00; Double money = 0.00; // 也可以加载远程文件 URL jarPath = new URL("file:/Users/roykingw/DevCode/ClassLoadDemo/out/artifacts/SalaryCaler_jar/SalaryCaler.jar"); URLClassLoader urlClassLoader = new URLClassLoader(new URL[] {jarPath}); //模拟不停机状态 while (true) { try { money = calSalary(salary,urlClassLoader); System.out.println("实际到手Money:" + money); }catch(Exception e) { e.printStackTrace(); System.out.println("加载出现异常 :"+e.getMessage()); } Thread.sleep(5000); } } private static Double calSalary(Double salary,ClassLoader classloader) throws Exception { Class<?> clazz = classloader.loadClass("com.roy.oa.SalaryCaler"); if(null != clazz) { Object object = clazz.newInstance(); return (Double)clazz.getMethod("cal", Double.class).invoke(object, salary); } return -1.00; } } ``` - 自定义加载器 ```java // SalaryJARLoader.java public class SalaryJARLoader extends SecureClassLoader { private String jarFile; public SalaryJARLoader(String jarFile) { this.jarFile = jarFile; } @Override protected Class<?> findClass(String fullClassName) throws ClassNotFoundException { String classFilepath = fullClassName.replace('.', '/').concat(".class"); System.out.println("重新加载类:"+classFilepath); int code; try { // 访问jar包的url URL jarURL = new URL("jar:file:" + jarFile + "!/" + classFilepath); // InputStream is = jarURL.openStream(); URLConnection urlConnection = jarURL.openConnection(); // 不使用缓存 不然有些操作系统下会出现jar包无法更新的情况 urlConnection.setUseCaches(false); InputStream is = urlConnection.getInputStream(); // 此时可以对.class文件里面的数据做一些加密操作 ByteArrayOutputStream bos = new ByteArrayOutputStream(); while ((code = is.read()) != -1) { bos.write(code); } byte[] data = bos.toByteArray(); is.close(); bos.close(); return defineClass(fullClassName, data, 0, data.length); } catch (Exception e) { e.printStackTrace(); System.out.println("加载出现异常 :"+e.getMessage()); throw new ClassNotFoundException(e.getMessage()); // return null; } } } // OADemo3.java public class OADemo3 { public static void main(String[] args) throws Exception { Double salary = 15000.00; Double money = 0.00; SalaryClassLoader salaryClassLoader = new SalaryClassLoader("/Users/roykingw/DevCode/ClassLoadDemo/out/production/SalaryCaler/"); //模拟不停机状态 while (true) { try { money = calSalary(salary,salaryClassLoader); System.out.println("实际到手Money:" + money); }catch(Exception e) { System.out.println("加载出现异常 :"+e.getMessage()); System.exit(-1); } Thread.sleep(5000); } } private static Double calSalary(Double salary,ClassLoader classloader) throws Exception { Class<?> clazz = classloader.loadClass("com.roy.oa.SalaryCaler"); if(null != clazz) { Object object = clazz.newInstance(); return (Double)clazz.getMethod("cal", Double.class).invoke(object, salary); } return -1.00; } } ``` - 自定义类加载器实现热加载 > 类加载什么时候会触发? 1. 遇到new、getstatic、putstatic、invokestatic这四条字节码指令 2. 使用java.lang.reflect 反射包对类进行反射调用 3. 初始化子类时发现父类还未初始化,此时除法父类加载 4. JVM启动时指定的主类执行(包含main方法的那个类) > ```java public class OADemo5 { public static void main(String[] args) throws Exception { Double salary = 15000.00; Double money = 0.00; //模拟不停机状态 while (true) { try { money = calSalary(salary); System.out.println("实际到手Money:" + money); }catch(Exception e) { System.out.println("加载出现异常 :"+e.getMessage()); } Thread.sleep(5000); } } private static Double calSalary(Double salary) throws Exception { // 每次调用都重新new一个实例出来,虽然能实现热更新,但是问题也很明显 // 创建的对象过多会造成GC线程压力 SalaryJARLoader salaryClassLoader = new SalaryJARLoader("/Users/roykingw/lib/SalaryCaler.jar"); System.out.println(salaryClassLoader.getParent()); Class<?> clazz = salaryClassLoader.loadClass("com.roy.oa.SalaryCaler"); if(null != clazz) { Object object = clazz.newInstance(); return (Double)clazz.getMethod("cal", Double.class).invoke(object, salary); } return -1.00; } } ``` - 打破双亲委派 > 现实中打破双亲委派的场景 1. SPI机制 使用Thread.currentThread().getContextClassLoader()获取线程上下文类加载器进行加载 2. Tomcat实现应用隔离和热部署 > ```java public class SalaryJARLoader6 extends SecureClassLoader { private String jarFile; public SalaryJARLoader6(String jarFile) { this.jarFile = jarFile; } @Override public Class<?> loadClass(String name,boolean resolve) throws ClassNotFoundException { // 把双亲委派机制反过来,先到子类加载器中加载,加载不到再去父类加载器中加载。 Class<?> c = null; synchronized (getClassLoadingLock(name)) { c = findLoadedClass(name); if(c == null){ c = findClass(name); if(c == null){ c = super.loadClass(name,resolve); } } } return c; } @Override protected Class<?> findClass(String fullClassName) throws ClassNotFoundException { String classFilepath = fullClassName.replace('.', '/').concat(".class"); System.out.println("重新加载类:"+classFilepath); int code; try { // 访问jar包的url URL jarURL = new URL("jar:file:" + jarFile + "!/" + classFilepath); URLConnection urlConnection = jarURL.openConnection(); urlConnection.setUseCaches(false); InputStream is = urlConnection.getInputStream(); // InputStream is = jarURL.openStream(); ByteArrayOutputStream bos = new ByteArrayOutputStream(); while ((code = is.read()) != -1) { bos.write(code); } byte[] data = bos.toByteArray(); is.close(); bos.close(); return defineClass(fullClassName, data, 0, data.length); } catch (Exception e) { // e.printStackTrace(); //当前类加载器出现异常,就会通过双亲委派,交由父加载器去加载 // System.out.println("加载出现异常 :"+e.getMessage()); // throw new ClassNotFoundException(e.getMessage()); return null; } } } ``` - 如何不在类加载里面使用反射而达到相同效果? 如果直接做强制类型转换,会报错。这类加载器的隔离性问题,因为判定两个类是否是同一个,需要符合两个条件,一是全限定类名一致,二是加载他的ClassLoader一致,这里是由两个不同的Loader加载的,尽管字节码内容一模一样,JVM依旧会判定是两个不同的类型,因此不能强转。 这里如果使用Class.forName来加载是可以的,会拿到调用者的ClassLoader加载,就可以进行强转 > Class.forName和ClassLoader区别 1. Class.forName会初始化静态变量而ClassLoader仅会加载并赋默认值(Class<?> clazz = Class.forName("MyClass", false, this.getClass().getClassLoader());false参数可以实现不初始化) 2. Class.forName使用的Loader是调用者的ClassLoader,一般是当前线程上下文或者过应用类加载器,而ClassLoader取决你调用的哪个实现类 3. Class.forName适合需要立即初始化使用的场景,如JDBC、配置注册等;ClassLoader适合只想加载但不初始化的场景,如性能优化,延迟加载等 > ```java Class<?> clazz = salaryJARLoader.loadClass("com.roy.oa.SalaryCaler"); Object obj = clazz.newInstance(); SalaryCaler caler = (SalaryCaler)obj; // 得到报错 // Exception in thread "main" java.lang.ClassCastException: com.roy.oa.SalaryCaler cannot be cast to com.roy.oa.SalaryCaler ``` 使用SPI机制,load方法通过传入Thread.currentThread().getContextClassLoader()线程上下文加载器来加载 ```java // ServiceLoader.java public static <S> ServiceLoader<S> load(Class<S> service) { ClassLoader cl = Thread.currentThread().getContextClassLoader(); return ServiceLoader.load(service, cl); } ```
05 Java后端面试八股合集 - 涵盖计网、JVM、Spring及RabbitMQ
# 00 引言 ## 写在最前面的话 本文是本人秋招时自用的八股准备文档,相较于八股网站繁多的内容,做了内容的压缩与高频考点的提炼,亲测覆盖面试 90% 以上的相关八股问题,分享出来以供大家参考。 另外本人已写 **Java后端完整版学习路线**(仔细讲解每个技术栈怎么学,学到什么程度可以投实习/面试)+ **各大厂真实面试问题和参考答案** + **Redis核心考点/面试真题深度梳理** + **MySQL高频考点深度梳理**(覆盖MySQL实习/校招面试95%以上的问题) 4篇高质量长文,如有需要欢迎大佬们自取(如果可以的话顺手点个赞👍),后续还会更新更多八股梳理、有趣的原创项目或实用的工具分享等内容,欢迎关注。 ### **其他优质内容导航:** **面试八股相关:** [01 非科班转码拿下大厂Offer,花费一天整理的Java后端完整版学习路线](https://www.codefather.cn/post/2005544965425446914) [02 盘点2025遇到的各大厂面试真题总结](https://www.codefather.cn/post/2005573237194469377) [03 一文吃透 Redis 核心考点,面试真题深度梳理](https://www.codefather.cn/post/2006203053207732225) [04 MySQL面试八股看这一篇就够了——深度梳理MySQL面试问题](https://www.codefather.cn/post/2008053544355131393) **工具分享**: [超实用的AI工具合集分享,赶快收藏起来吧 ](https://www.codefather.cn/post/2008402573266022402) **原创项目**: [01 基于用户画像和多模态需求驱动的多智能体推荐系统 - 技术文档](https://www.codefather.cn/post/2010000937266987009) (项目暂未开源,后续会开源,小伙伴可以关注一下) [02 ContentGuard Pro - 一套针对文本内容安全检测和风控的解决方案](https://www.codefather.cn/post/2010604558601961474) [Github链接](https://github.com/Mrchen-1600/Content-Guard-Pro) 03 TouchFish | 摸鱼神器(离线版)(最近即将推出,小伙伴们可以关注一下) 大致功能描述:是一款基于 Python 开发的高性能桌面隐私保护应用。它利用计算机视觉(CV)和离线语音识别(ASR)技术,实时监控用户周围环境。当检测到陌生人出现在摄像头内、用户离开品目前或触发特定语音关键词时,系统将毫秒级响应,执行静音、隐藏窗口并自动全屏打开用户提前设置的伪装工作软件/文件,为用户的“摸鱼”时光提供全方位保护。 ### 本文内容概述 **Notebook LM总结:** > 这份参考资料是一份详尽的计算机技术面试八股文合集,核心涵盖了计算机网络、JVM、Spring框架以及RabbitMQ四大技术模块。在网络层面,它详细拆解了从输入URL到页面展示的完整流程,并深入剖析了TCP可靠传输机制与HTTP/HTTPS的区别。针对Java虚拟机,文中深入探讨了内存区域划分、垃圾回收算法及对象创建过程,并提供了实用的JVM调优建议。框架部分重点解释了Spring IOC与AOP的核心原理,以及声明式事务在并发场景下的应用。最后,针对消息中间件,资料归纳了RabbitMQ如何处理消息丢失、重复消费及分布式事务一致性等实战难题。 # 计算机网络部分 01 从输入URL到页面展示到底发生了什么? ---------------------- 第一步是**用户输入URL**,并按下回车。此时,浏览器开始**解析URL**,将其分解为**协议**(例如HTTP或HTTPS)、**域名**、**路径**(例如/home)等部分。 第二步是进行**DNS解析**。浏览器通过DNS解析域名,**查找对应的IP地址**。若该域名的IP地址**已被缓存**,浏览器会**直接使用缓存的IP地址**;若**未缓存**,则浏览器会向DNS服务器**发送请求,获取IP地址**。 第三步是通过三次握手**建立TCP连接**。获取到IP地址后,浏览器通过**TCP/IP协议**与目标服务器**建立连接**。在HTTPS请求中,浏览器还会通过**SSL协议进行加密**,确保通信的安全性。 第四步是**客户端发送HTTP请求**。TCP连接建立后,浏览器会发送HTTP请求到服务器。请求包括**请求行、请求头和请求体**。**请求行包含请求方法(如GET、POST)、请求的路径和HTTP版本**。请求头包括目标域名、浏览器身份标识、可以处理的内容类型等信息。**请求体则用于发送数据(如POST表单提交数据)**。 第五步是**服务器处理请求**。服务器接收到请求后,首先会解析请求头并**根据请求的URL路径**选择合适的处理方式。 第六步是**服务器发送HTTP响应**。服务器处理完请求后,构造HTTP响应并返回给浏览器。**响应包含响应行、响应头和响应体**。**响应行指示处理结果(如200 OK、404 NotFound)**,响应头包含响应体的数据类型、响应体的长度等信息,**响应体则是实际的页面内容(HTML、CSS、JavaScript、图片等)**。 第七步是**浏览器解析和渲染页面**。浏览器接收到响应后,开始解析响应体中的HTML内容。 第八步是通过四次挥手**关闭TCP连接**。 02 TCP的粘包和拆包? -------------   03 常见的HTTP状态码? --------------   04 TCP和UDP的区别? --------------  05 GET和POST的区别 -------------- GET和POST的5点主要区别: 第一个是数据传输方式的区别:GET请求**通过URL传递数据**,数据被附加在URL后面,以键值对的形式传输。而POST请求将数据**放在请求体中传递**,数据不会显示在URL中。 第二个是数据大小限制的区别:GET**请求的数据大小有限制**,通常为2至8KB。而POST请求的数据没有固定限制,**可以传输大量数据**,理论上只有Web服务器的配置限制。 第三个是安全性的区别:GET请求的数据通过URL传递,**数据泄露**给第三方,安全性较差。而POST请求的数据**保存在请求体中**,不会显示在URL中,**相对更安全**一些。 第四个是使用场景的区别:GET请求**通常用于获取资源或数据,适用于查询操作**。而POST请求通常用于**提交数据或进行数据更改**操作,适用于表单提交、用户注册、登录、文件上传等场景。 第五个是**幂等性**和缓存的区别:GET请求是幂等的,即同样的请求多次发送,服务器的响应不会发生变化,且可以被**缓存**。而POST请求是非幂等的,每次请求都会产生变化,因此不能缓存。 06 TCP超时重传机制为了解决什么问题? ---------------------  07 HTTP1.0、2.0和3.0的区别 ---------------------  08 服务器如何解析HTTP请求的数据 -------------------  09 三次握手和四次挥手? -------------   10 为什么要等2MSL?为什么是等待2MSL? ------------------------ (1)确保客户端最后的ACK能够被成功接收。因为如果这个ACK丟失了,服务器没有收到确认包,会重新发送FIN报文,而MSL是TCP报文在网络中可以存活的最大时间,服务器重发FIN,客户端收到之后重发ACK,这一来一回就需要2MSL的时间。 (2)防止旧的报文干扰新的连接。TCP连接关闭之后,可能会有一些延迟的或者已经失效的报文还在网络中传输,如果我们立即用相同的IP地址和端口建立新的连接,可能会受到这些旧的报文的干扰。 11 TCP实现可靠传输的原理 --------------- 首先是**连接管理机制**,TCP通过**三次握手建立连接**,确保双方通信正常,并用**四次挥手终止连接**,防止数据残留或资源浪费。 第二个是数据分块与序号标识机制,发送端将**数据分割为合适大小的报文段**,每个段分配**唯一序号**,**标识数据的顺序**;接收端**通过序号重组乱序到达的段**,确保数据完整性。 第三个是**确认应答与超时重传机制**,接收端对每个接收到的段返回确认应答,发送端如果超时未收到ACK就会触发超时重传,解决数据丢失问题。 第四个是**流量控制**机制,接收端**通过窗口大小告知发送端可接收的数据量**,**避免缓冲区溢出**。滑动窗口机制允许连续发送多个段,提升传输效率。 第五个是**拥塞控制**机制,能够根据网络负载情况**动态调整发送速率**,防止网络瘫痪。 12 HTTP怎么实现流量控制(滑动窗口算法) -----------------------  ## 13 TCP每次连接时序列号都一样吗?为什么不一样,有什么作用?    14 HTTPS协议和HTTP协议的区别? --------------------- (1)数据传输安全性: http:明文传输,容易被窃听、篡改 https:通过SSL/TSL协议对数据进行加密传输,提供数据机密性和完整性保障。 (2)端口号: http:默认端口号80 https:默认端口号443 (3)性能: http:无加密过程,连接建立速度稍快。 https:基于http上又加了SSL或TSL协议来实现的加密传输,加解密过程增加了计算开销,握手时间较长。  15 DDOS攻击 --------- DDOS攻击(Distributed Denial of Service,**分布式拒绝服务攻击**)是一种通过大量恶意流量淹没目标服务器、网络或服务,使其无法正常响应合法用户请求的网络攻击方式。 **基本原理** **拒绝服务(DoS)**:攻击者通过耗尽目标的带宽、计算资源(如CPU、内存)或应用处理能力,导致服务瘫痪。 **分布式(Distributed)**:攻击流量来自全球大量被控制的设备(如僵尸网络中的电脑、IoT设备等),而非单一来源,难以追踪和防御。 **常见攻击类型** **流量洪泛** 例如:UDP洪水,通过垃圾流量塞满目标带宽。 **协议攻击** 例如:SYN洪水(耗尽TCP连接资源)、DNS放大攻击(利用DNS协议缺陷放大流量)。 **应用层攻击** 例如:HTTP洪水(模拟大量合法请求耗尽服务器资源),更隐蔽且难以识别。 **防御措施** **流量清洗**:通过云安全服务(如Cloudflare、阿里云高防IP)过滤恶意流量。 **黑名单/IP限速**:识别并拦截异常IP。 **冗余架构**:分布式服务器分散流量压力。 # JVM部分 01 GC 怎么知道哪些是垃圾?(垃圾搜索的算法) ------------------------- 垃圾回收需要知道哪些对象是垃圾(垃圾的搜集算法),主要是两种方式: (1)**引用计数法:**每个对象都有一个**引用计数器**,每当有一个引用指向他,计数器就加1,所以GC只要去看对象的计数器是不是0就行,是0的就可以直接回收,但是这个方法最大的问题就是,如果**两个对象相互引用**,那就永远不能被回收,所以就需要方法2 (2)**可达性分析算法** 从**GC Roots**出发,看看哪些对象是可达的,可达的即存在引用,不可达的就可以直接回收(GC Roots可以是栈中引用的对象,类静态属性引用的对象,常量引用的对象以及本地方法引用的对象) 02 GC Roots包含哪些对象 -----------------   标记出垃圾之后就是进行回收了,下面是回收的算法: 03 GC垃圾回收算法 ----------- **1.标记清除算法** 当垃圾回收器进行内存扫描后会标记出所有的垃圾,然后**直接清除这些带标记**的垃圾对象即可,但是因为垃圾在内存上是分散的,这样清理就会**产生大量的内存碎片**,使得内存利用率越来越低; **2.复制算法** **准备两块一模一样的内存空间**,当第一块剩余空间不足时,可以将**所有需要保留的对象拷贝**至另一块空内存,然后将**前一块内存全部清空**,这样即做到了垃圾回收,又做到了碎片整理,但是这样**内存空间就会有一半被浪费**。 **3.标记整理法** 标记整理算法就是在清理垃圾的基础上,多了一步碎片整理的工作,因为整理是比较耗时的,所以显然这种垃圾回收机制不适合高频率的执行。 知道了常见的垃圾回收算法,再介绍下常见的垃圾回收器: 04 JVM常见的垃圾回收器 -------------- 最早期的就是**Serial和SerialOld**,但是这种垃圾回收器是**单线程的**,不支持并发,**开始垃圾回收后所有用户线程都必须暂停**。 后面是**parallel**的垃圾回收器,垃圾回收可以**多线程执行**,但是当开始垃圾回收的时候依然会**触发所有用户线程暂停**。 再之后就是**CMS**,CMS虽然在**初始标记的时候也会触发用户线程暂停**,但是因为CMS**初始标记只会标记第一层的根对象**,所以时间很短,**真正的标记阶段,打标记是和用户线程并行的**;为了避免并行过程中可能的错标,还有第三个**重新标记的阶段**,**这个阶段也会让用户线程暂停,然后通过多线程的方式去检查并且修正错误标记。**但是这个算法也有问题,就是**清理垃圾的时候用户线程也在运行,因此新产生的垃圾没办法及时清理**,需要等下一次回收。 再就是**G1回收器**,G1回收器会**把整个堆内存划分成若干相等大小的区域**,然后**对这些区域进行价值排序**,垃圾越多,回收需要的时间也越多,但是G1回收器可以**根据我们设定的用户线程暂停时间来调整策略**,会尽量**满足我们设定的时间去回收价值高的区域**。另外**G1还把大对象单独存放在了一个区域**,避免了整理时候**频繁移动大对象**。 补充一个新生代和老年代的知识: 05 新生代和老年代? ----------- 区分新生代和老年代主要是为了提高垃圾回收效率,大多数的对象存活时间短,很快就会变成垃圾不再使用,这些短生命周期的对象就会分配在新生代;少部分对象长期存活,不会很快被回收,就晋升到老年代。 针对不同的分区的垃圾回收算法也不一样,**新生代通常采用复制算法**,**老年代**通常采用**标记整理算法或标记清除算法**。 06 什么是FullGC?什么情况下触发?怎么解决? -------------------------- Full GC(完全垃圾回收)是指对整个JVM**堆(包括新生代、老年代)**以及**方法区(元空间)**进行的全面垃圾回收。Full GC会暂停所有应用线程(Stop-The-World),通常耗时更长。 **触发的场景**: 1. 老年代空间不足,且通过old GC仍然不足 2. 元空间或者永久代内存不足 3. 使用了System.gc()命令 4. 新生代对象要晋升到老年代,但是老年代空间不够 **频繁full GC的问题**: 长时间的Stop-The-World暂停会导致应用响应变慢,大量的CPU时间用于GC而非业务处理,也会导致业务吞吐量下降,可能会导致OOM或者服务超时 **解决**: 增加堆大小、调整新生代与老年代的比例、选择合适的垃圾回收器(比如G1替代CMS)、代码优化(及时释放不再使用的对象,减少大对象的频繁创建) 07 JVM的内存区域? ------------ 首先是**线程共享**的部分,一共有两个: 一个是**堆(Heap)**,所有**对象实例和数组**都在这里分配内存,垃圾回收器(**GC**)也主要在堆中工作。堆中还包含了**字符串常量池**(String Constant Pool)。 另一个是**元空间(Method Area)**:用于**存储类信息、常量、静态变量、方法字节码**等。其中运行时常量池(Runtime Constant Pool)是元空间的一部分,用于存储编译期生成的各种字面量和符号引用。 ### 字符串常量池的作用? **为什么需要字符串常量池?** String s1 = "Hello"; String s2 = "Hello"; String s3 = new String("Hello"); 如果没有常量池:s1会创建一个新的String对象。s2会再创建一个内容完全相同的新String对象。s3通过new关键字,毫无疑问也会创建一个新对象。这样,内存中就会有三个内容完全相同的"Hello"对象,这是极大的浪费。 字符串常量池就是为了解决这个问题而生的。**工作原理(核心机制):**当在代码中直接使用字符串字面量(用双引号包裹)时,例如String s = "Hello";,JVM会首先去字符串常量池中查找是否已经存在一个内容为"Hello"的字符串对象。 **如果存在**:JVM不会创建新的对象,而是直接返回池中已有对象的引用。这样,所有相同的字面量都指向同一个内存地址。 **如果不存在**:JVM会在字符串常量池中创建一个新的String对象,内容为"Hello",然后返回这个新对象的引用。 new String()**的创建**:当使用new关键字(如String s = new String("Hello");)时,JVM的行为会有所不同:new关键字会**强制**在Java堆的**非常量池区域**创建一个全新的、独立的String对象。这个新对象的内容会初始化为"Hello",但它和常量池中的那个"Hello"是两个不同的对象。  然后是**线程私有**的部分,一共有三个, 第一个是**虚拟机栈**(VM Stack),**每个线程启动时都会创建一个虚拟机栈**,它存储方法调用过程中产生的栈帧,包括**局部变量、方法返回地址**等,**每个方法调用都会创建一个新的栈帧**,方法执行结束后栈帧出栈。 第二个是**本地方法栈**(Native Method Stack),专门用于存储**本地方法**(Native Method)的调用信息,与虚拟机栈类似,但用于JNI(Java Native Interface)调用。 第三个是**程序计数器**(Program Counter Register),**记录当前线程正在执行的字节码指令地址**。它是JVM运行时最小的内存区域,每个线程都有一个独立的程序计数器。 在JDK1.8时 JVM的内存结构主要有两点不同: 一个是方法区(Method Area)在JDK 1.8被替换为元空间(Metaspace)实现,且元空间使用本地内存(原因:元空间可以动态调整大小,能够避免Out of Memory的错误,并且提高了GC回收效率) 另一个是运行时常量池(Runtime Constant Pool)在JDK1.7属于方法区的一部分,而在JDK 1.8变成元空间的一部分。 08 对象创建的过程了解吗? -------------- 第一步是进行**类加载检查**,当程序执行到new指令时,JVM会**先检查对应的类是否已经被加载**、解析和初始化过。如果类尚未加载,JVM会按照类加载机制(加载、验证、准备、解析、初始化)完成类的加载过程。这一步**确保了类的元信息(如字段、方法等)已经准备好**,为后续的对象创建奠定基础。 第二步是进行**内存的分配**,JVM会为新对象分配内存空间。对象所需的内存大小在类加载完成后就可以确定,因此**分配内存的过程就是从堆中划分一块连续的空间**,主要有两种方式: 一种是通过指针碰撞,**如果堆中的内存是规整的**(已使用和空闲区域之间有明确分界),JVM可以**通过移动指针来分配内存**。另一种是通过空闲列表,如果**堆中的内存是碎片化的**,JVM会维护一个**空闲列表**,记录可用的内存块,并从中分配合适的区域。 第三步是将**零值初始化**,JVM会对分配的内存空间进行初始化,将其所有字段设置为零值(如int为0,boolean为false,引用类型为null)。这一步确保了对象的实例字段在未显式赋值前有一个**默认值**,从而避免未初始化的变量被访问。 第四步是**设置对象头**,其中包含Mark Word、Klass Pointer和数组长度。Mark Word用于**存储对象的哈希码、GC分代年龄**等信息。Klass Pointer指向对象所属类的元数据(即Person.class的地址)。 第五步是**执行构造方法**,用**<init>方法完成对象的初始化**。构造方法会根据代码逻辑对对象的字段进行赋值,并调用父类的构造方法完成继承链的初始化。这一步完成后,对象才真正可用。 09 JVM相关的配置与调优专题 ---------------- ### **一、堆配置:** **1.1.配置项** `-Xms`:初始堆大小 `-Xmx`:最大堆大小 `-XX:NewSize=n`:设置年轻代大小 `-XX:NewRatio=n`:设年轻代和年老代的比值。如:为3表示年轻代和年老代比值为1:3,年轻代占整个年轻代年老代和的1/4,默认为2 `-XX:SurvivorRatio=n`:年轻代中Eden区与两个survivor区的比值,注意Survivor区有两个,默认8。如:3表示Eden:3 Survivor:2,一个Survivor区占整个年轻代的1/5 `-XX:MaxPermSize=n`:设置持久代大小 **1.2.说明** 1、一般初始堆和最大堆设置一样,因为:现在内存不是什么稀缺的资源,但是如果不一样,从初始堆到最大堆的过程会有一定的性能开销,所以一般设置为初始堆和最大堆一样。64位系统理论上可以设置为无限大,但是一般设置为4G,因为如果再大,JVM进行垃圾回收出现的暂停时间会比较长,这样全GC过长,影响JVM对外提供服务,所以不能太大。一般设置为4G。 2、`-XX:NewRaio`和`-XX:SurvivorRatio`这两个参数,第一是设置年轻代的大小,二个是设置年轻代的比值、理论上设置一个即可以满足需求(因为有默认值) **1.3. 概念解释** 年轻代:包括Eden区和Survivor区,用于管理新创建的对象。 老年代:用于存放从年轻代中存活下来的对象。 * Eden区:新对象首先被分配到这里。 * Survivor区:用于存放从Eden区中存活下来的对象,通过两个Survivor区的交替使用减少内存碎片。 **年轻代(Young Generation)** 年轻代是对象最初被分配的地方。大多数对象在年轻代中创建,并且大多数对象在年轻代中死亡(即不再被引用)。年轻代的主要特点是: Eden区:新创建的对象首先被分配到Eden区。Eden区是年轻代的主要部分,通常占据年轻代的大部分空间。 Survivor区:Survivor区分为两个部分,通常称为From Survivor区和To Survivor区。在每次Minor GC(年轻代垃圾回收)后,存活的对象会被移动到Survivor区中的一个,而另一个Survivor区则被清空。这种设计有助于减少内存碎片。(复制算法) **老年代(old Generation)** 老年代用于存放从年轻代中存活下来的对象。当对象在年轻代中经过多次Minor GC后仍然存活,它们会被晋升到老年代。老年代的特点是: Major GC(Full GC):老年代的垃圾回收称为Major GC或Full GC。Full GC通常比Minor GC更耗时,因为它需要扫描整个堆内存。 **Eden区与Survivor区** Eden区:新对象首先被分配到Eden区。当Eden区满时,会触发一次Minor GC,将存活的对象移动到Survivor区中的一个(通常是From Survivor区)。 Survivor区:Survivor区用于存放从Eden区中存活下来的对象。在每次Minor GC后,存活的对象会被移动到另一个Survivor区(To Survivor区),而原来的Survivor区(From Survivor区)则被清空。这种设计有助于减少内存碎片,提高内存利用率。 ### **二、调优总结** **年轻代大小选择:** * **响应时间优先的应用:** 尽可能设置大,直到接近系统的最低响应时间限制(根据实际情况选择)。在此种情况下,年轻代收集发生的频率也是最小的。同时减少到达年老代的对象。 * **吞吐量优先的应用:** 尽可能的设置大,可能到达Gbit的程度,因为对响应时间没有要求,垃圾收集可以并行进行,一般适合8核CPU以上应用。 **年老代大小选择:** * **响应时间优先的应用:** 年老代使用并发收集器,所以其大小需要小心设置,一般要考虑并发会话率和会话持续时间等一些参数。如果堆设置小了,可能会造成内存碎片、高回收频率以及应用暂停而使用传统的标记清除方式;如果堆大了,则需要较长的收集时间。最优化的方案,一般需要参考以下数据获得: * 1、并发垃圾收集信息 * 2、持久代并发收集次数 * 3、传统GC信息 * 4、花在年轻代和年老代回收上的时间比例,减少年轻代和年老代花费的时间,一般会提高应用的效率。 * **吞吐量优先的应用:** 一般吞吐量优先的应用都有一个很大的年轻代和一个较小的年老代。原因是,这样可以尽可能回收掉大部分短期对象,减少中期对象,而年老代尽存放长期存活的对象 **较小堆引起的碎片问题:** 因为年老代的并发收集器使用标记、清除算法,所以不会对堆进行压缩。当收集器回收时,他会把相邻的空间进行合并,这样可以分配给较大的对象。但是当堆空间较小时,运行一段时间以后,就会出现“碎片”,如果并发收集器找不到足够的空间,那么并发收集器将会停止,然后使用传统的标记、清除方式进行回收。如果出现“碎片”,可能需要进行如下配置: `-XX:+UseCMSCompactAtFullCollection`:使用并发收集器时,开启对年老代的压缩 `-XX:CMSFullGCsBeforeCompaction=0`:上面配置开启的情况下,这里设置多少次FullGc后,对年老代进行压缩。 ### **三、内存泄露检查** 根据垃圾回收前后情况对比,同时根据对象引用情况(常见的集合对象引用)分析,基本都可以找到泄漏点。 **持久代占满处理:** 1、`-XX:MaxPermSize=16m`设置持久代大小 2、换JDK、比如:JRocket **系统内存被占满:** 一般是因为没有足够的资源产生线程造成的,系统创建线程时,除了要在Java堆中分配内存外,操作系统本身也需要分配资源来创建线程。因此,当线程数量大的一定程度以后,堆中或许还有空间,但是操作系统分配不出资源来了,出现异常; 分配给Java虚拟机的内存越多,系统剩余的资源就越少,因此,当系统内存固定时,分配给Java虚拟机的内存越多,那么,系统总共能够产生的线程也就越少,两者成反比。同时,可以通过修改-Xss来减少分配给单个线程的空间,也可以增加系统总共生产的线程数。 ### **四、GC使用与性能优化管理** (1)不要显式调用System.gc()。此函数建议JVM进行主动GC,虽然只是建议而非一定,但很多情况下它会触发主GC,从而增加主动GC的频率、也即增加了间歇性停顿的次数。大大的影响系统性能。 (2)尽量减少临时对象的使用。临时对象在跳出函数调用后,会成为垃圾,少用临时变量就相当于减少了垃圾的产生,从而减少了主GC的机会。 (3)对象不用时最好显式置为Null。一般而言,为Null的对象都会被作为垃圾处理,所以将不用的对象显式地设为Null,有利于GC收集器判定垃圾,从而提高了GC的效率。 (4)尽量使用StringBuffer,而不用String来累加字符串。由于String是固定长的字符串对象,累加String对象时,并非在一个String对象中扩增,而是重新创建新的String对象,如`Str5=Str1+Str2+Str3+Str4`,这条语句执行过程中会产生多个垃圾对象,因为对次作“+”操作时都必须创建新的String对象,但这些过渡对象对系统来说是没有实际意义的,只会增加更多的垃圾。避免这种情况可以改用StringBuffer来累加字符串,因StringBuffer是可变长的、它在原有基础上进行扩增,不会产生中间对象。 (5)能用基本类型如int,long,就不用Integer,Long对象。基本类型变量占用的内存资源比相应对象占用的少得多,如果没有必要,最好使用基本变量。 (6)尽量少用静态对象变量。静态变量属于全局变量,不会被GC回收,它们会一直占用内存 (7)注意分散对象创建或删除的时间,集中在短时间内大量创建新对象,特别是大对象,会导致突然需要大量内存、JVM在面临这种情况时,只能进行主GC,以回收内存或整合内存碎片,从而增加主GC的频率。集中删除对象,道理也是一样的。 ### **五、JVM调优参数参考** 1.针对JVM堆的设置,一般可以通过`-Xms -Xmx`限定其最小、最大值,为了防止垃圾收集器在最小、最大之间收缩堆而产生额外的时间,通常把最大、最小设置为相同的值; 2.年轻代和年老代将根据默认的比例(1:2)分配堆内存,可以通过调整二者之间的比率NewRadio来调整二者之间的大小,也可以针对回收代 比如年轻代,通过: `-XX:newSize-XX:MaxNewSize`来设置其绝对大小。同样,为了防止年轻代的堆收缩,我们通常会把`-XX:newSize-XX:MaxNewSize`设置为同样大小。 3.年轻代和年老代设置多大才算合理 * 更大的年轻代必然导致更小的年老代,大的年轻代会延长普通GC的周期,但会增加每次GC的时间;小的年老代会导致更频繁的FullGC * 更小的年轻代必然导致更大年老代,小的年轻代会导致普通GC很频繁,但每次的GC时间会更短:大的年老代会减少FullGC的频率 如何选择应该依赖应用程序对象生命周期的分布情况:如果应用存在大量的临时对象,应该选择更大的年轻代;如果存在相对较多的持久对象,年老代应该适当增大。但很多应用都没有这样明显的特性。 在抉择时应该根据以下两点: (1)本着FullGC尽量少的原则,让年老代尽量缓存常用对象,JVM的默认比例1:2也是这个道理。 (2)通过观察应用一段时间,看其他在峰值时年老代会占多少内存,在不影响FullGC的前提下,根据实际情况加大年轻代,比如可以把比例控制在1:1。但应该给年老代至少预留1/3的增长空间。 4.在配置较好的机器上(比如多核、大内存),可以为年老代选择并行收集算法-`XX+UseParallelOldGC`。 5.线程堆栈的设置:每个线程默认会开启1M的堆栈,用于存放栈帧、调用参数、局部变量等,对大多数应用而言这个默认值太了,一般256K就足用。 理论上,在内存不变的情况下,减少每个线程的堆栈,可以产生更多的线程,但这实际上还受限于操作系统。 ### **六、触发Full GC的几种情况** **1.老年代空间不足** (1)年轻代晋升:当年轻代中的对象经过多次Minor GC后仍然存活,会被晋升到老年代。如果老年代空间不足,无法容纳这些晋升的对象,就会触发Full GC。 (2)大对象直接分配:如果应用程序直接分配大对象(超过年轻代Eden区的大小),这些对象会直接进入老年代。如果老年代空间不足,也会触发Full GC。 **2.方法区(元空间或永久代)空间不足** (1)类加载:如果应用程序动态加载大量类,导致方法区(Metaspace或永久代)空间不足,会触发Full GC。 (2)元数据回收:Metaspace或永久代中的元数据(如类信息、方法信息等)不再被引用时,需要通过Full GC进行回收。 **3.System.gc()调用** 显式调用:应用程序中显式调用System.gc()或Runtime.getRuntime().gc()会建议JVM执行Full GC。不过JVM不一定会立即执行,具体行为取决于JVM的实现和配置。 **4.垃圾回收器策略** (1)CMS GC的并发模式失败:在使用CMS(Concurrent Mark-Sweep)垃圾回收器时,如果在并发标记过程中老年代空间不足,会触发Full GC。 (2)G1 GC的疏散失败:在使用G1(Garbage-First)垃圾回收器时,如果在疏散(Evacuation)阶段无法找到足够的空闲区域来存放存活对象,会触发Full GC。 **5.堆内存分配失败** 内存泄漏:如果应用程序存在内存泄漏,导致堆内存中大量对象无法被回收,最终堆内存耗尽,会触发Full GC # Java集合部分 01 HashMap的原理 ------------- HashMap在jdk1.7和1.8实现上是有些不一样的,先介绍1.7。在1.7中,底层是通过**数组+链表**来实现的,当我们插入元素的时候,会计算key的hash值,也就是**对数组长度取模**,得到插入位置的下标,如果该位置为空,就插入元素,**如果不为空,就会以链表的形式存储**,会去遍历链表,如果找到了相同的key,就替换value,表示修改操作;如果没有相同的key,就把新的entry**插入到链表的头部**。在1.7中,扩容机制是,比如默认数组容量是16,加载因子0.75,所以当我们插入第13个元素的时候就会触发扩容,**扩容会重新对所有元素进行hash计算,去把元素放到新的数组中去**。 但是1.7中存在一些问题:首先就是**哈希冲突比较严重的时候链表会变得很长**,链表的查询效率是O(n),这就会影响性能。另外,**头插法虽然比较快,但是在多线程环境可能就会形成环形链表,陷入死循环**(比如现在链表是A->B->C,线程A和线程B同时去操作链表,如果线程A先去操作时候发现需要扩容,通过头插法扩容A先放入新数组,然后是B和C,顺序就变成了C->B->A,这个时候线程B再去操作,线程B还以为是原链表,即A指向B,但是现在实际已经变成了B指向A,就形成了死循环);再就是扩容,1.7是对所有元素重新计算,这个也比较复杂。 针对这3个问题,首先一点,1**.8改成了尾插法**,**扩容不会反转链表**,所以避免了死循环的产生;第二点,**1.8中采用数组+链表+红黑树的结构**。当我们插入数据,如果对应位置已经有元素,会先存储成链表,但是如果**链表的长度已经等于8**了,就需要看**数组长度是否大于64**,**如果没有就优先扩容数组**;**如果数组元素已经大于64,就把链表转换成红黑树存储**,这个转换的契机就是链表长度大于8,然后**如果元素少于6个,就从二叉树再退化回链表**。1.8里的扩容契机和1.7一样,除了前面提到的链表长度那里以外,也是判断数组存储的元素是否超过临界值。但是1.8的扩容不是全部重新计算hash,而是**通过元素的hash和老数组的长度进行&运算来计算出元素是处于高位还是低位**,**如果结果是0就把元素留在原来位置不移动,否则就移动到原来索引位置加上老数组容量的位置去**,显著提升了扩容的速度。  注意:声明初始容量的时候需要考虑集合的实现类型,如果是hashmap或者hashset(实际就是hashmap,只不过value为空),那初始容量实际要声明成100W/扩容因子(默认0.75),如果是非hashmap实现,比如arraylist,直接声明成100W就可以了,因为arraylist是存满再扩容,没有扩容因子。 02 ArrayList和LinkedList的区别? ---------------------------  03 常见的集合有哪些? ------------  04 1亿量级的ArrayList数据去重? ---------------------- (1)直接hashset,但是注意初始化容量,避免频繁扩容(可以初始化成ArrayList.size() / 0.75 + 1) (2)排序+遍历去重,排序完之后,一遍扫过去,只要和前一个不一样就保留; (3)bitmap,先遍历一遍arraylist,用一个二进制位去把arraylist里元素值对应位置的bit值改为1,遍历完再遍历位图,收集所有标记为1的位置下标(如果是0-1亿之间的数字,bitmap只需要12MB的内存,如果是int范围512MB也够了) # SSM框架部分 01 Spring IOC ------------- IoC即控制反转。 例如:现有类A依赖于类B 传统的开发方式:往往是在类A中手动通过new关键字来new一个B的对象出来;使用IoC思想的开发方式:不通过new关键字来创建对象,而是通过IoC容器(Spring框架)来帮助我们实例化对象。我们需要哪个对象,直接从IoC容器里面去取即可。 从以上两种开发方式的对比来看:我们“丧失了一个权力” (创建、管理对象的权力),从而也得到了一个好处(不用再考虑对象的创建、管理等一系列的事情) 为什么叫控制反转? 控制:指的是对象创建(实例化、管理)的权力 反转:控制权交给外部环境(IoC容器) ### IoC解决了什么问题? IoC的思想就是对象之间不互相依赖,由第三方容器来管理相关资源。这样有什么好处呢? 1、降低了对象之间的耦合度或者说依赖程度; 2、资源变的容易管理;比如用Spring容器提供的话很容易就可以实现一个单例。 例如:现有一个针对User的操作,利用Service和Dao两层结构进行开发,在没有使用IoC思想的情况下,Service层想要使用Dao层的具体实现的话,需要通过new关键字在UserService的实现类中手动new出UserDao的具体实现类。如果后续接到新的需求,针对UserDao接口需要开发另一个新的实现类,我们就需要手动修改UserService实现类中new的对象。如果有许许多多的地方都引用了UserDao的具体实现的话,那修改起来就会非常的繁琐。 使用IoC的思想,我们将对象的控制权(创建、管理)交由IoC容器去管理,我们在使用的时候直接向IoC容器 “要”就可以了(通过注解去标识,按照类型/名称去进行注入)。 ### IoC和DI有区别么? IoC是一种设计思想或者说是某种模式。这个设计思想就是**将原本在程序中手动创建对象的控制权交给第三方比如IoC容器。**对于我们常用的Spring框架来说,IoC容器实际上就是个Map(key,value),Map中存放的是各种对象。 IoC最常见的实现方式叫做依赖注入简称DI。 02 Spring自动装配原理 ---------------  通过注解或者一些简单的配置就能在Spring Boot的帮助下快速实现某块功能。 在传统Spring中,我们需要在XML或Java配置中显式地定义很多Bean(如数据源等)。而在Spring Boot中,只引入了特定的依赖,Spring Boot就会自动配置好这些组件。举个例子:要使用JDBC,只需在pom.xml中引入spring-boot-starter-jdbc依赖,并在yml文件中配置一些连接信息即可。 **自动装配的实现:** 核心机制:条件化装配 这是自动装配的基石。Spring Boot不会盲目地配置所有东西,它只在某些**条件满足**的情况下才进行配置。这是通过一系列@Conditional注解来实现的。 条件注解: 1. 如果没有引入web启动依赖,所有与web相关的自动配置类都不会被加载; 2. 用户自定义的加了@Bean注解的都会被优先加载; 3. 在yml等配置文件中我们可以自定义配置属性,比如内置的tomcat启动端口是8080,如果我们配置成其他端口,就会按照我们的配置进行自动装配。 自动装配的过程可以概括为以下几个步骤: 1. **启动注解:**@SpringBootApplication 每个Spring Boot主类上都标有@SpringBootApplication,它是一个复合注解,其中@EnableAutoConfiguration注解就启用自动配置的。 1. **启用自动配置:**@EnableAutoConfiguration 这个注解的作用是启用Spring Boot的自动配置机制。它背后是通过一个import选择器的类实现的(AutoConfigurationImportSelector.class)。 1. **加载自动配置列表:** 会通过import选择器类读Classpath下所有JAR包中的META-INF/spring.factories文件。 1. **关键文件:** META-INF/spring.factories 这个文件是一个标准的Java配置文件,内容是**键=值**对的形式。这个列表定义了**所有可能被自动配置的类**。 1. **过滤与条件判断:** Import选择器不会直接加载所有可能的配置类。而是通过**条件注解**对列表中的配置类进行筛选。只有满足条件的配置类,才会被真正解析,将其中的Bean定义加载到Spring容器中。 03 Spring AOP ------------- AOP(Aspect Oriented Programming)即面向切面编程,AOP是OOP(面向对象编程)的一种延续,二者互补,并不对立。 AOP的目的是将一些分散在多个类或对象中的公共行为(如日志记录、事务管理、权限控制、接口限流等)从核心业务逻辑中分离出来,通过动态代理技术,实现代码的复用和解耦,提高代码的可维护性和可扩展性。 AOP之所以叫面向切面编程,是因为它的核心思想就是将横切关注点从核心业务逻辑中分离出来,形成一个个的切面(Aspect)。 ### AOP常见的通知(增强)类型  ### AOP解决了什么问题? OOP不能很好地处理一些分散在多个类或对象中的公共行为(如日志记录、事务管理、权限控制、接口限流、接口幂等等),这些行为通常被称为横切关注点。如果我们在每个类或对象中都重复实现这些行为,那么会导致代码的冗余、复杂和难以维护。 AOP可以将横切关注点(如日志记录、事务管理、权限控制、接口限流、接口幂等等)从核心业务逻辑中分离出来,实现关注点的分离。 比如日志记录,没有AOP之前,我们需要对需要加日志记录的地方挨个去写代码,全是重复的逻辑;但是有AOP技术之后,我们就可以把日志记录的逻辑封装成一个切面,然后通过切点和通知来指定具体在哪些方法中加入日志。在指定方法中只需要加一行注解就可以实现日志记录。 ### AOP的应用场景 **日志记录:** 自定义日志记录注解,利用AOP,给业务方法上加一行注解即可实现日志记录。 **性能统计:** 利用AOP在目标方法的执行前后统计方法的执行时间,方便优化和分析。 **事务管理:** @Transactional注解可以让Spring为我们进行事务管理比如回滚异常操作,免去了重复的事务管理逻辑。@Transactional注解就是基于AOP实现的。 **权限控制:** 利用AOP在目标方法执行前判断用户是否具备所需要的权限,如果具备,就执行目标方法,否则就不执行。 **接口限流:** 利用AOP在目标方法执行前通过具体的限流算法对请求进行限流处理。 ### AOP动态代理 Spring AOP是基于动态代理的,如果要代理的对象,实现了某个接口,那么Spring AOP会使用**JDK代理**,去创建代理对象,而对于没有实现接口的对象,就无法使用JDK去进行代理了,这时候Spring AOP会使用CGLIB生成一个被代理对象的子类来作为代理。 04 Spring事务 ----------- 事务是逻辑上的一组操作,要么都执行,要么都不执行。 我们系统的每个业务方法可能包括了多个原子性的数据库操作,比如转账就是经典的事务场景。事务能否生效数据库引擎是否支持事务是关键。比如常用的MySQL数据库默认使用支持事务的innodb引擎。但是,如果把数据库引擎变为myisam,那么程序也就不再支持事务了! ### Spring对事务的支持 (1)编程式事务管理 通过TransactionTemplate或者TransactionManager手动管理事务,实际应用中很少使用,但是对于你理解Spring事务管理原理有帮助。 (2)声明式事务管理 推荐使用(代码侵入性最小),实际是通过AOP实现(基于@Transactional的全注解方式使用最多)。  ### 声明式事务在多线程场景下的问题    ### 事务的属性 事务属性包含了5个方面:隔离级别、传播行为、回滚规则、是否只读、事务超时 **只读模式** @Transactional(readOnly = false) 只读模式可以提升查询事务的效率,推荐事务中只有查询代码时,使用只读模式。默认是false,一般情况下,都是在类上添加@Transactional注解,针对类下的查询方法可以通过再次添加@Transactional注解,设置为只读模式,从而提高查询的效率(因为查询并不会改变数据库的数据,所以本身就不需要事务)。 **超时时间** @Transactional(timeout = 3) 默认值是-1,代表永远不超时,设置timeout = 时间(秒数),超过时间,就会回滚事务和报异常(TransactionTimedOutException),如果类上设置了,方法也设置了事务注解,方法上的注解会覆盖掉类上的注解! **指定事务异常回滚规则** @Transactional(rollbackFor=Exception.class, noRollbackFor=FileNotFoundException.class) 默认只针对运行时异常回滚(error也会回滚),编译时异常不回滚。可以指定: rollbackFor属性:指定哪些异常类才会回滚,默认是RuntimeException and Error 异常方可回滚; noRollbackFor属性:指定哪些异常不会回滚,默认没有指定,如果指定,应该在rollbackFor的范围内! 为了让发生所有异常都进行事务的回滚,我们可以指定Exception异常来控制所有异常都回滚!即:rollbackFor = Exception.class **事务隔离级别** _@Transactional(isolation = Isolation.READ_COMMITTED)_ Spring支持的隔离级别枚举: Isolation.DEFAULT:使用数据库默认隔离级别。 Isolation.READ_UNCOMMITTED:读未提交。 Isolation.READ_COMMITTED:读已提交。 Isolation.REPEATABLE_READ:可重复读。 Isolation.SERIALIZABLE:串行化。 数据库事务的隔离级别是指在多个事务并发执行时,数据库系统为了保证数据一致性所遵循的规定。常见的隔离级别包括: * 读未提交(Read Uncommitted):事务可以读取未被提交的数据,容易产生脏读、不可重复读和幻读等问题。实现简单但不太安全,一般不用。 * 读已提交(Read Committed):事务只能读取已经提交的数据,可以避免脏读问题,但可能引发不可重复读和幻读。(大多数数据库的默认隔离级别) * 可重复读(Repeatable Read):确保在同一事务中多次读取同一数据时,结果一致,不管其他事务对数据做了什么修改。可以避免脏读和不可重复读,但仍有幻读的问题。(MySQL的默认隔离级别) * 串行化(Serializable):最高的隔离级别,完全禁止了并发,只允许一个事务执行完毕之后才能执行另一个事务。可以避免以上所有问题,但效率较低,不适用于高并发场景。 **事务传播行为** _@Transactional(propagation = Propagation.REQUIRED)_ 事务传播行为定义了**多个事务方法相互调用时,事务应该如何传播**。Spring提供了7种事务传播行为,通过@Transactional注解的propagation属性进行配置。 | 传播行为类型 | 说明 | | --- | --- | | REQUIRED(默认) | 如果当前存在事务,则加入该事务;如果当前没有事务,则创建一个新事务,保证最后仅有一个事务。 REQUIRES_NEW | 无论当前是否存在事务,都创建一个新事务,并挂起当前事务(挂起事务的意思是在当前事务执行过程中,暂时将其暂停,并开启一个新的事务;挂起事务后,当前事务的状态会被保存,直到新事务执行完毕后再恢复),最后会有多个独立的事务,所以如果后面的事务报异常了,前面事务已经修改的数据并不会跟着一起回滚,适用于独立事务的场景,比如日志记录。 SUPPORTS | 如果当前存在事务,则加入该事务;如果当前没有事务,则以非事务方式执行,适用于查询方法,不需要强制事务。 NOT_SUPPORTED | 以非事务方式执行操作,如果当前存在事务,则挂起该事务,适用于不需要事务支持的操作。 MANDATORY | 如果当前存在事务,则加入该事务;如果当前没有事务,则抛出异常,强制要求必须开启事务。 NEVER | 以非事务方式执行操作,如果当前存在事务,则抛出异常,强制要求不能开始事务。 NESTED | 如果当前存在事务,则在嵌套事务内执行;如果当前没有事务,则创建一个新事务,适用于需要部分回滚的场景。 | ### 事务失效的场景 1. 数据库引擎不支持事务,比如mysql数据库使用的是MyISAM引擎; 2. 事务注解所在的方法是非public的,因为SpringAOP默认使用CGLIB代理,无法给非public的方法创建代理,导致事务切面无法切入;  1. 异常类型不正确或被捕获,默认只回滚运行时异常和error,受检异常(IO异常,SQL异常)被视为业务异常,默认会提交事务;另外事务代理会通过捕获目标方法抛出的异常来决定是回滚还是提交,如果异常在方法内部被catch捕获且没有被重新抛出,代理会认为方法执行成功,从而提交事务; 2. 一个没有事务注解的方法调用了同一个类中有注解的方法,这种情况下使用的是this对象,是真实的对象,而不是代理对象; 3. 错误配置了事务传播属性,比如配置成以非事务方式运行,事务也会不生效; 4. 试图在final或static方法上使用事务,CGLIB代理是通过生成目标类的子类来实现的,它无法重写final和static方法。 05 Spring设计模式 ------------- ### 工厂模式 Spring使用工厂模式可以通过BeanFactory或ApplicationContext创建bean对象。 **两者对比:** BeanFactory:延迟注入(使用到某个bean的时候才会注入),相比于ApplicationContext来说会占用更少的内存,程序启动速度更快。 ApplicationContext:容器启动的时候,不管你用没用到,一次性创建所有bean。BeanFactory仅提供了最基本的依赖注入支持,ApplicationContext扩展了BeanFactory, 除了有BeanFactory的功能还有额外更多功能(比如:支持基于观察者模式的事件驱动编程,另外Java EE的标准注解,如@Resource也自动支持),所以一般开发人员使用ApplicationContext会更多。 ### 单例设计模式 在我们的系统中,有一些对象其实我们只需要一个,比如说:线程池、缓存、日志对象等。事实上,这一类对象只能有一个实例,如果制造出多个实例就可能会导致一些问题的产生,比如:资源使用过量、或者结果不一致性。 **使用单例模式的好处**: 对于频繁使用的对象,可以省略创建对象所花费的时间,这对于那些大对象而言,是非常可观的一笔系统开销; 由于new操作的次数减少,因而对系统内存的使用频率也会降低,这将减轻GC压力,缩短GC停顿时间。 **Spring中bean的默认作用域就是singleton(单例)的。** 除了singleton作用域,Spring中bean还有下面几种作用域: * **prototype**:每次获取都会创建一个新的bean实例。也就是说,连续getBean()两次,得到的是不同的Bean实例。 * **request**(仅Web应用可用):每一次HTTP请求都会产生一个新的bean(请求bean),该bean仅在当前HTTP request内有效。 * **session**(仅Web应用可用):每一次来自新session的HTTP请求都会产生一个新的bean(会话bean),该bean仅在当前HTTP session内有效。 * **global-session**(仅Web应用可用):每个Web应用在启动时创建一个Bean(应用Bean),该bean仅在当前应用启动时间内有效。 ### 代理模式 **一个经典的例子就是AOP,** 能够将那些与业务无关,却为业务模块所共同调用的逻辑(例如事务处理、日志管理、权限控制等)封装起来,便于减少系统的重复代码,降低模块间的耦合度。 Spring AOP就是基于动态代理的,如果要代理的对象,实现了某个接口,那么Spring AOP会使用**JDK**去创建代理对象,而对于没有实现接口的对象,Spring AOP会使用**Cglib**生成一个被代理对象的子类来作为代理。 ### 观察者模式 观察者模式表示的是一种对象与对象之间具有依赖关系,当一个对象发生改变的时候,依赖这个对象的所有对象也会做出反应。Spring事件驱动模型就是观察者模式很经典的一个应用。比如我们每次添加商品的时候都需要重新更新商品索引,这个时候就可以利用观察者模式来解决这个问题。 ### 适配器模式 适配器模式(Adapter Pattern)可以将一个接口转换成我们希望的另一个接口,适配器模式使接口不兼容的那些类可以一起工作。 # RabbitMQ部分 01 RabbitMQ的底层架构 ----------------  02 消息什么情况下会进入死信队列? ------------------  03 RabbitMQ怎样实现延迟队列? --------------------  04 RabbitMQ中无法路由的消息会去哪里? ------------------------  05 如何避免重复处理消息? --------------  06 RabbitMQ的推和拉模式? ------------------ 推模式: 推模式也称为订阅模式。消息是主动推送给消费者的,消费者会预先注册一个回调函数(消费者处理器),消息到达队列后会立刻推送给消费者,消费者设置预取数量来控制流量。这样的话消息的实时性高,到达后立即推送,减少了不必要的请求开销;缺点的话可能因处理能力不足导致消息堆积,需要合理设置预取数量以避免过载。适用于实时性要求高,消息量稳定的场景。 拉模式: 拉模式需要消费者主动从队列中获取消息。消费者主动请求,可以精确控制获取消息的时机和数量,适用于消息处理耗时较长或需要批量处理的场景。消费者可以按照自身能力获取消息,避免消息积压在消费者端,适合处理耗时任务。但是实时性较差,需要轮询去获取消息,大量的获取请求浪费可能增加了网络请求开销。适用于处理耗时,需要精确控制的场景。 07 消费者消费失败的常见因素? ---------------- (1)消息处理逻辑错误:比如消息内容是订单支付成功通知,但是消费者处理时因逻辑错误(比如金额计算错误)导致异常;也可能是外部依赖的异常,比如消息需要调用第三方的API,但是接口返回超时或者错误响应 (2)消费者也可能因为服务突然宕机,导致正在处理的消息未确认;或者消费者处理消息时执行耗时操作(比如生成大型图表),长时间未返回ACK触发RabbitMQ的超时机制。 (3)网络或中间件问题:消费者与RabbitMQ之间的网络抖动,导致心跳超时或通道(Channel)关闭;或者集群中某个节点宕机,消费者未正确切换到其他节点,导致消息无法投递。 08 消费者怎么保证消息消费的可靠性? -------------------  09 RabbitMQ消息丟失的3种情况和对应解决? --------------------------  (1)生产者弄丢数据 生产者将数据发送到RabbitMQ的时候,可能数据在半路弄丢了(比如网络问题之类的)。RabbitMQ有两种解决方式:1、生产者发送数据之前开启RabbitMQ的**事务功能**,如果消息没有成功被MQ接收到,那么生产者就会收到异常报错,此时就可以回滚事务,然后**重试发送消息**,但是问题是事务机制是同步的,提交一个事务之后就会阻塞等待事务处理完,所以吞吐量会下降,比较消耗性能; 2、开启MQ的**confirm模式**,这样每次写的消息都会分配一个唯一的id,然后如果写入了RabbitMQ中,MQ就会回传一个ack消息,告诉生产者这个消息ok了,如果MQ没能处理这个消息就会回调生产者的nack接口,告诉生产者消息接收失败了,然后可以进行重发。这个过程是异步的,发送完这个消息之后可以紧接着发生下一个消息。 (2)RabbitMQ弄丢了数据 这个我们需要开启RabbitMQ的持久化,保证消息写入之后会**持久化**到磁盘,这样即使RabbitMQ自己挂了,恢复之后也可以自动读取之前存储的数据。(需要在创建队列的时候给queue设置为持久化,并且还需要给消息也设置为持久化的) (3)消费者弄丢了数据 比如消息刚收到消费者就宕机了,这种情况我们需要**关闭MQ的默认ack机制**(因为默认是收到了就会触发ack确认),我们应该在消费者这边**业务处理完成之后再手动去ack确认**,这样即使消费者宕机没能处理消息,消息也不会被ack,所以消息可以重新入队处理。 10 RabbitMQ消息怎么传输? ------------------  11 RabbitMQ怎么保证消息的顺序性? ---------------------- **方案一:单队列单消费者(FIFO最简单模式)** 这是最直接但也限制最大的方法。 **原理**: * **生产者**:将需要保证顺序的所有消息发送到**同一个队列**中。 * **消费者**:该队列**只能有一个消费者**,并且设置channel.basicQos(1),即每次只取一条消息,处理完一条再取下一条。 **优点**: * 实现简单,充分利用了RabbitMQ队列本身的FIFO(先进先出)特性。 **缺点**: * **无法水平扩展**:单个消费者是性能瓶颈,吞吐量低。 * **单点风险**:如果该消费者宕机,虽然消息不会丢,但处理会完全停止。 * **适用场景**:消息量非常小,对吞吐量要求不高的场景。 **方案二:根据消息ID或业务键进行分组(路由到同队列)** 这是最常用且合理的方案,核心思想是:**将需要保证顺序的消息通过路由键确保它们进入同一个队列,并被同一个消费者顺序处理**。 **原理**: * **消息分组**:对消息进行分组。例如,订单ID为order_123的所有消息(创建、付款、发货)必须顺序处理。 * **生产端**:使用**一致性哈希交换器**或**自定义路由键**,将同一组的消息总是路由到同一个队列。 **例如**:使用x-modulus-hash这类交换器,以订单ID order_123作为路由键,计算出的哈希值总会将其路由到队列queue_2。 * **消费端**:为**每个队列启动一个消费者**(可以是多个队列,即多个消费者实例,每个实例处理不同组的消息)。同样,每个消费者需要设置prefetchCount=1。 **工作流程**: 订单A(ID=1)的所有消息 → 路由键order_1→始终发往**队列1**→ 由**消费者1**处理。 * 订单B(ID=2)的所有消息 → 路由键order_2→ 始终发往**队列2**→ 由**消费者2**处理。 **优点**: * **高性能且可扩展**:不同的组(如不同的订单)可以被不同的消费者并行处理,解决了方案一的瓶颈问题。 * **逻辑清晰**:符合大部分业务场景(如订单、会话跟踪)。 **缺点**: * 需要提前规划好消息的分组逻辑。 * 如果某个组的消息特别多(“热点订单”),对应的队列和消费者可能会有压力,但通常这种情况较少。 12 RabbitMQ消息堆积怎么处理 -------------------   13 优惠劵场景,RabbitMQ保证一致性问题 ------------------------ 本地消息表的核心思想是**将分布式事务拆分为两个本地事务**,通过数据库的事务特性和重试机制保证最终一致性: 第一个本地事务:在业务数据库中同时完成订单状态更新和消息记录 第二个本地事务:通过定时任务将记录的消息可靠地发送到MQ **具体实现流程:** **第一阶段:业务处理与消息记录** **开启数据库事务**:当优惠券被抢后,系统开始处理优惠券状态更新 **更新订单状态**:在同一个数据库事务中,将优惠券状态从"待发放"改为"已发放" **写入本地消息表**:在同一事务中,向专门设计的消息表插入一条记录,包含: 消息ID(唯一标识)、消息内容(JSON格式的优惠券数据)、消息状态(初始为"待发送")、创建时间、重试次数(初始为0) **提交事务**:只有当优惠券更新和消息记录都成功后才提交事务 **第二阶段:消息发送与状态更新** **定时任务扫描**:系统有一个独立的定时任务,定期(如每5秒)扫描本地消息表中状态为"待发送"的记录 **发送MQ消息**:对于每条待发送记录:尝试将消息内容发送到消息队列。如果发送成功:更新该消息记录状态为"已发送";如果发送失败:增加重试次数、记录错误日志、保持状态为"待发送" **重试机制**:对于发送失败的消息,定时任务会在下次扫描时重新尝试发送,直到:发送成功或达到最大重试次数(如5次),此时可将状态改为"发送失败"并报警。
JVM-盛大厨房的剖析(下)
### 第七章:类加载机制——厨房的菜谱管理体系 接下来要讲的是**类加载机制与字节码执行**,这是理解Java"一次编写,到处运行"的关键,也是面试中展示你技术深度的绝佳话题。 想象你的厨房要引入新菜谱(`.class`文件),但直接让厨师看原始菜谱太危险了(可能有错误或恶意指令)。所以你需要一套严格的**菜谱管理制度**。 #### 7.1 类加载的五个阶段——菜谱审核流程 阶段一:加载——获取菜谱原件 * **任务**:根据菜谱名(全限定类名)找到菜谱文件 * **来源**:可以是文件系统、网络、JAR包等 * **结果**:在方法区创建类的Class对象 **类比**:派人去仓库找到"宫保鸡丁"的原始菜谱,在档案室登记备案。 阶段二:验证——菜谱安全检查 这是**安全防线**,确保菜谱不会破坏厨房: 1. **文件格式验证**:是不是标准的.class文件格式? 2. **元数据验证**:菜谱逻辑是否合理?(比如继承final类?) 3. **字节码验证**:操作步骤是否安全?(比如会不会让厨师切到手?) 4. **符号引用验证**:引用的其他菜谱是否存在? **面试重点**:验证阶段保证了JVM不会因为恶意或错误的字节码而崩溃。 阶段三:准备——准备基础食材 * **任务**:为静态变量分配内存并设置**默认值** * **注意**:这里设置的是零值,不是初始值! ```java public static int chefLevel = 5; // 准备阶段:chefLevel = 0 public static final String TYPE = "CHINESE"; // 准备阶段:TYPE = "CHINESE"(常量特殊处理) ``` **类比**:为"厨师等级"这个指标预留记录位置,但先填0,等正式开业再写实际等级。 阶段四:解析——替换专业术语 * **任务**:把菜谱中的"专业术语"转换成具体操作指引 ```java // 解析前(符号引用): "请使用 com.kitchen.Knife.SHARP_KNIFE" // 解析后(直接引用): "请使用存放在0x1234内存地址的那把刀" ``` 阶段五:初始化——正式启用菜谱 * **任务**:执行`<clinit>()`方法,给静态变量赋真实值,执行静态代码块 * **时机**:**第一次主动使用**类时触发 ```java public class Menu { public static int chefLevel = 5; // 这里才赋值为5! static { System.out.println("菜单初始化完成"); // 静态代码块在这里执行 } } ``` #### 7.2 类加载器——多级菜谱管理员 厨房有严格的层级管理制度: 1. 启动类加载器 - 国家餐饮标准制定局 * 加载`JAVA_HOME/lib`下的核心类库(rt.jar) * C++实现,是JVM的一部分 * **类比**:国家制定的基础烹饪标准,所有厨房必须遵守 2. 扩展类加载器 - 行业协会 * 加载`JAVA_HOME/lib/ext`下的扩展类 * **类比**:餐饮行业协会提供的进阶标准 3. 应用类加载器 - 本店总厨 * 加载classpath下的应用类 * **类比**:本餐厅的专属菜谱 4. 自定义类加载器 - 特邀顾问 * 用户自定义的类加载器 * **类比**:从米其林餐厅请来的特邀顾问带来的特殊菜谱 #### 7.3 双亲委派模型——菜谱请示制度 这是面试**超高频考点**! **工作流程**: 1. 收到新菜谱请求,先请示上级:"爸,这个菜谱你能处理吗?" 2. 上级继续请示上级,直到顶级"国家标准局" 3. 如果上级说"我能处理",就用上级的版本 4. 如果所有上级都说"不会",才自己动手 **代码实现**: ```java protected Class<?> loadClass(String name, boolean resolve) { synchronized (getClassLoadingLock(name)) { // 1. 检查是否已加载 Class<?> c = findLoadedClass(name); if (c == null) { try { // 2. 委托父加载器 if (parent != null) { c = parent.loadClass(name, false); } else { c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { // 父加载器找不到,不立即失败 } // 3. 父加载器找不到,自己尝试加载 if (c == null) { c = findClass(name); } } return c; } } ``` **为什么要双亲委派?** * ✅ **安全**:防止核心API被篡改(比如自定义java.lang.String) * ✅ **避免重复**:保证类只被加载一次 * ✅ **清晰职责**:各级加载器各司其职 **打破双亲委派的场景**: * SPI机制(JDBC等):核心接口由启动类加载,实现类由应用类加载 * 热部署:需要重新加载修改过的类 * OSGi:模块化加载 * * * ### 第八章:字节码执行引擎——厨房的智能指挥系统 现在菜谱已经加载完成,真正的烹饪要开始了! #### 8.1 运行时栈帧结构——厨师的临时工作台 每次厨师开始做一道新菜(调用方法),就会在自己的操作台上铺一张**栈帧**: ```plain |--------------------| | 局部变量表 | ← 手边的食材 |--------------------| | 操作数栈 | ← 正在处理的半成品 |--------------------| | 动态链接 | ← 其他菜谱的引用 |--------------------| | 方法返回地址 | ← 做完这道菜要回到哪里 |--------------------| ``` #### 8.2 方法调用——分配任务的艺术 静态分派——根据食材类型分配厨师 ```java // 重载是静态分派,编译期确定 public void cook(Beef beef) { ... } public void cook(Chicken chicken) { ... } // 编译期根据参数类型决定调用哪个方法 cook(new Beef()); // 调用cook(Beef) ``` **类比**:根据食材类型(牛肉/鸡肉)分配对应的专长厨师。 动态分派——多态的魔法 ```java Chef chef = new ChineseChef(); // 实际类型是ChineseChef chef.cook(); // 调用ChineseChef的cook方法 ``` **实现原理**:在类的方法区维护一个**虚方法表**,记录每个方法的实际入口地址。 **类比**:虽然任务单上写的是"厨师",但实际会根据今天值班的是中餐厨师还是西餐厨师来分配具体人选。 #### 8.3 解释执行 vs 编译执行——两种指挥模式 **解释执行——逐行念菜谱** * **方式**:一行一行读取字节码,翻译成机器码执行 * **优点**:启动快 * **缺点**:执行慢 **类比**:总厨逐行念菜谱,厨师跟着做一步等一步。 **JIT编译执行——熟能生巧** * **方式**:把热点代码(频繁执行的方法)编译成本地机器码 * **优点**:执行速度快(直接说"老规矩"就行) * **缺点**:编译需要时间 **分层编译策略**: * **第0层**:解释执行,不 profiling * **第1层**:C1编译,简单优化 * **第2层**:C1编译,有限的profiling * **第3层**:C1编译,完整的profiling * **第4层**:C2编译,使用C1的profiling数据深度优化 #### 8.4 面试实战:方法调用的底层原理 **面试官**:说说虚方法表的作用? **你**: "虚方法表是实现动态分派的关键。每个类在方法区维护一个虚方法表,记录了该类所有虚方法的实际入口地址。当发生多态调用时,JVM通过对象的实际类型找到对应的虚方法表,然后根据方法签名找到具体的方法实现。这样就实现了'运行时确定调用哪个方法'的多态特性。" **面试官**:什么是方法内联? **你**: "方法内联是JIT编译器的重要优化手段。比如我们有个小方法`calculateTotal()`,JIT发现这个方法很小且频繁调用,就会把它的代码直接'内联'到调用处,避免方法调用的开销。这就好比把常用的小菜谱步骤直接印在主菜谱旁边,不用每次都翻到另一页去看。" * * * ### 第九章:实战演练——解决类冲突问题 #### 场景:NoSuchMethodError的诡异问题 ```java // 运行时报错:NoSuchMethodError: com.utils.StringUtil.isEmpty() ``` **排查思路**: 1. **检查类加载**:用`-verbose:class`参数查看哪个版本的StringUtil被加载 2. **依赖冲突**:用`mvn dependency:tree`检查是否有多个版本的依赖 3. **类加载器问题**:不同模块用不同类加载器加载了同一个类 **解决方案**: ```xml <!-- 在pom.xml中排除冲突依赖 --> <dependency> <groupId>com.example</groupId> <artifactId>my-app</artifactId> <exclusions> <exclusion> <groupId>com.utils</groupId> <artifactId>string-util</artifactId> </exclusion> </exclusions> </dependency> ``` 面试金句总结 "类加载机制保证了Java程序的安全性和稳定性,而字节码执行引擎的优化则体现了JVM的智能化。理解这些底层原理,能让我们在遇到诡异问题时快速定位,写出更高效的代码。" * * * 掌握了类加载和字节码执行,你就真正理解了Java程序从"写代码"到"跑起来"的完整生命周期。这些知识在面试中绝对是**加分项**,能让你从众多候选人中脱颖而出! 接下来我们可以聊聊**JVM面试真题解析**,把前面所有知识串起来,帮你构建完整的面试应对策略。 ### 第十章:JVM面试实战——总厨的终极答辩 想象你现在坐在技术面试官面前,他们正要深度考察你的JVM知识。我会扮演面试官,提出真实的高频问题,并给出完美的回答策略。 #### 10.1 基础概念篇——检验基本功 ##### 问题1:说一下JVM的内存区域划分? **完美回答**: "JVM内存区域分为线程私有和线程共享两大部分: **线程私有**: * **程序计数器**:记录当前线程执行的字节码行号,就像厨师的当前工作步骤 * **虚拟机栈**:存储方法调用的栈帧,包含局部变量、操作数栈等,就像每个厨师的操作台 * **本地方法栈**:为Native方法服务 **线程共享**: * **堆**:存放对象实例,是GC的主战场,就像核心食材区 * **方法区**:存储类信息、常量、静态变量等,就像菜谱档案库 * **直接内存**:NIO使用的堆外内存 在JDK8中,方法区的实现由永久代被元空间取代,使用本地内存。" **面试官追问**: 1. 为什么要把内存分成线程私有和共享? "主要是为了平衡性能和安全。线程私有的数据不需要同步,访问速度快;共享区域需要同步机制,但方便线程间通信。" 2. 为什么JDK8要用元空间替代永久代? * **永久代问题**:在堆中固定大小,容易OOM;Full GC时回收效率低 * **元空间优势**:使用本地内存,默认只受系统内存限制;元数据在类加载器死亡时回收,更精准 3. 元空间就不会OOM了吗? 元空间仍然可能OOM,比如动态生成大量类(CGLib代理),或者内存泄漏导致类加载器无法回收 4. 元空间使用本地内存有什么缺点? 缺点是可能挤占系统其他进程内存,需要合理设置MaxMetaspaceSize ##### 问题2:对象在堆中是如何分配的? **完美回答**: "新对象优先在Eden区分配,具体流程: 1. **Eden(伊甸园)分配**:大多数新对象在这里诞生 2. **Minor GC(新生代清理)**:Eden满时触发,存活对象复制到Survivor区(幸存区) 3. **年龄计数**:对象每经历一次Minor GC,年龄加1 4. **晋升老年代**:当年龄达到阈值(默认15),或Survivor空间不足时进入老年代 5. **大对象直接进入老年代**:避免在Eden和Survivor间大量复制 这就像新食材先放临时区,经过多次检查合格的进入长期存储区。" * * * #### 10.2 GC篇——重中之重的考察点 ##### 问题3:有哪些垃圾回收算法?各有什么优缺点? **完美回答**: "主要有三种基础算法: **1. 标记-清除** * **过程**:先标记存活对象,然后清除未标记对象 * **优点**:简单直接 * **缺点**:产生内存碎片,效率较低 * **类比**:在杂乱仓库中挑出有用物品,剩下的直接扔掉 **2. 复制算法** * **过程**:将内存分为两块,每次只用一块,存活对象复制到另一块 * **优点**:没有碎片,实现简单 * **缺点**:内存利用率只有50% * **类比**:两个备用食材区轮流使用 **3. 标记-整理** * **过程**:标记存活对象,然后向一端移动,清理边界外内存 * **优点**:没有碎片,内存利用率高 * **缺点**:移动对象成本高 * **类比**:整理冰箱,把有用食材集中摆放 现代JVM采用分代收集,对不同区域使用不同算法。" ##### 问题4:G1垃圾回收器的工作原理? **完美回答**: "G1将堆划分为多个相同大小的Region,不再物理分代,而是逻辑分代。它的核心思想是**可预测的停顿时间模型**。 **工作流程**: 1. **初始标记**:Stop-The-World,标记GC Roots直接关联的对象 2. **并发标记**:与用户线程并发,标记所有存活对象 3. **最终标记**:Stop-The-World,处理并发标记期间的变化 4. **筛选回收**:优先回收价值最大的Region(垃圾最多的区域) **优势**: * 可以设置最大停顿时间:`-XX:MaxGCPauseMillis=200` * 整体采用标记-整理,局部采用复制算法,避免内存碎片 这就像智能保洁团队,每次都优先清理最脏的区域,保证不影响正常营业。" * * * #### 10.3 调优实战篇——展示你的经验 ##### 问题5:如何排查线上CPU 100%的问题? **完美回答**: "我会按照以下四步排查: ```bash # 1. 找到问题进程 top -c # 2. 找到问题线程 top -H -p <pid> # 3. 线程ID转16进制 printf "%x\n" <thread_id> # 4. 分析线程堆栈 jstack <pid> | grep -A 10 <hex_id> ``` **常见原因**: * 死循环:比如`while(true)`没有退出条件 * 频繁GC:GC线程占用大量CPU * 锁竞争:线程在BLOCKED状态等待锁 有一次我们线上就遇到`HashMap`并发操作导致的CPU飙升,通过jstack发现大量线程在`putVal`方法阻塞。" ##### 问题6:如何分析内存泄漏? **完美回答**: "内存泄漏排查四步法: 1. **监控确认**:用`jstat -gcutil`观察老年代使用率是否持续上升 2. **生成快照**:用`jmap -dump:format=b,file=heap.hprof <pid>` 3. **工具分析**:用MAT分析,重点关注: * Dominator Tree找到占用内存最大的对象 * Leak Suspects Report看泄漏嫌疑 * Path to GC Roots查看引用链 4. **代码修复**:根据分析结果修复代码,比如及时清理缓存 我们曾经因为静态Map缓存没有过期机制导致内存泄漏,后来改用Guava Cache并设置合理过期时间。" * * * #### 10.4 高级原理篇——拉开差距的关键 ##### 问题7:什么是双亲委派模型?为什么要用双亲委派? **完美回答**: "双亲委派是类加载器的工作机制: **工作流程**: 1. 收到加载请求,先委托父加载器处理 2. 父加载器再委托它的父加载器,直到启动类加载器 3. 父加载器无法完成时,子加载器才尝试加载 **三大好处**: 1. **安全**:防止核心API被篡改,比如自定义java.lang.String 2. **避免重复**:保证类只被加载一次 3. **清晰职责**:各级加载器分工明确 **打破双亲委派的场景**: * SPI机制:如JDBC驱动加载 * 热部署:需要重新加载修改的类 * OSGi:模块化加载需求" ##### 问题8:说说JVM的即时编译器(JIT)? **完美回答**: "JIT编译器是JVM性能优化的关键,它将热点代码编译成本地机器码。 **分层编译**: * **解释执行**:快速启动,执行慢 * **C1编译**:简单优化,编译快 * **C2编译**:深度优化,编译慢但执行快 **热点代码检测**: * 方法调用计数器 * 回边计数器(循环) **主要优化技术**: * 方法内联:消除方法调用开销 * 逃逸分析:栈上分配、锁消除 * 公共子表达式消除 这就像厨师做菜,第一次按菜谱一步步做,做多了就形成肌肉记忆,直接快速完成。" * * * #### 10.5 场景设计题——检验综合能力 ##### 问题9:设计一个高并发的电商系统,如何设计JVM参数? **完美回答**: "针对电商系统的高并发特点,我会这样设计: **设计思路**: * 电商系统对象生命周期符合"朝生夕死",所以新生代设大 * 高并发要求低延迟,G1的停顿预测很合适 * 预留故障排查手段,方便线上问题定位" ```bash # 基础内存设置 -Xms4g -Xmx4g # 堆内存固定,避免动态调整 -Xmn2g # 新生代较大,适应大量短期对象 -XX:MetaspaceSize=256m # 元空间,防止动态类加载OOM # GC优化 -XX:+UseG1GC # G1适合大堆内存,停顿可控 -XX:MaxGCPauseMillis=200 # 目标停顿200ms,保证响应 -XX:ParallelGCThreads=4 # GC线程数 # 故障排查 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dumps -Xloggc:/app/logs/gc.log # 其他优化 -XX:+DisableExplicitGC # 禁止System.gc() -XX:+UseCompressedOops # 压缩指针,节省内存 ``` * * * #### 10.6 面试收尾技巧 ##### 当面试官问:"你还有什么问题吗?" **不要问**: * "我这次面试能过吗?" * "薪水多少?" **要问这些展示深度的问题**: "我想了解咱们团队在JVM调优方面的具体实践: * 目前线上环境的JVM参数配置策略是怎样的? * 有没有遇到过比较有挑战性的性能问题,最后是如何解决的? * 团队在GC日志监控和分析方面有哪些自动化工具?" * * * #### 终极心法总结 记住JVM面试的**三个层次**: 1. **基础层**:准确说出概念、参数、流程 2. **理解层**:能用类比解释原理,知道"为什么" 3. **实战层**:结合项目经验,展示排查和优化能力 **你的优势**: * 用厨房类比让复杂概念变得生动易懂 * 有完整的故障排查思路和工具使用经验 * 理解调优不仅仅是改参数,而是系统工程 现在,你已经准备好了!带着这份自信去面试,让面试官看到你不仅会写代码,更懂得代码如何运行。 **祝你面试顺利,拿到心仪的Offer!** 🚀
JVM-盛大厨房的剖析(中)
### 第四章:厨房的保洁革命——垃圾回收机制 想象一下你的中央厨房已经运营了一段时间,核心食材区(**堆内存**)开始出现问题了... #### 4.1 问题的产生:厨房的垃圾危机 在堆区这个"核心食材区"里: * 厨师们不断地取用新食材(`new Object()`) * 有些食材用完了就废弃了(对象不再被引用) * 切掉的菜叶、用过的包装袋、炒糊的菜(垃圾对象)堆积如山 很快,厨房面临两个严重问题: 1. **空间不足**:新食材没地方放了 → `OutOfMemoryError` 2. **空间碎片化**:可用空间被分割成无数个小块,大份食材找不到连续空间存放 **现实对应**:这就是为什么需要垃圾回收——回收不再使用的对象,释放内存空间。 #### 4.2 谁是垃圾?——垃圾识别算法 保洁团队首先要能识别什么是垃圾。JVM用了两种主要方法: **方法一:引用计数法(原始但有问题)** * **原理**:每个食材贴个标签,记录有多少人在使用它 * **当引用数为0时**:说明没人用了,是垃圾 * **致命缺陷**:**循环引用**问题 ```java // 就像两个食材互相引用,但实际都没用了 宫保鸡丁.sauce = 特调酱汁; 特调酱汁.usedBy = 宫保鸡丁; // 它们互相指着说"还有人用我",但实际上客人都走了 ``` **方法二:可达性分析(JVM实际采用)** * **原理**:从一些"根"开始,能找到的对象都是存活的,找不到的就是垃圾 * **GC Roots包括**: * 正在操作的厨师(**虚拟机栈中的局部变量**) * 菜谱档案库里的固定配方(**方法区中的静态变量**) * 厨房的固定设备清单(**方法区中的常量**) **类比理解**:大扫除时,从正在用的灶台、菜谱上的必备食材开始,所有能直接或间接联系到的食材都保留,其他统统扔掉。 #### 4.3 代际假说——厨房的智慧分区 观察发现:**大部分食材都是"朝生夕死"的** * 80%的食材(对象)在第一次使用后就被丢弃 * 只有少数食材(如老汤、卤水)会长期保存 基于这个发现,厨房进行了**分区优化**: ```plain 核心食材区(堆) ├── 新生代 (Young Generation) - 占1/3 │ ├── 伊甸园 (Eden) - 新食材都放在这里 │ ├── 幸存者区S0 (Survivor 0) - 经历过一次清扫的食材 │ └── 幸存者区S1 (Survivor 1) - 同上,两个轮流使用 └── 老年代 (Old Generation) - 占2/3 └── 长期保存的珍贵食材 ``` #### 4.4 保洁流程——Minor GC 与 Full GC ##### Minor GC(新生代清理) **触发条件**:伊甸园满了 **清理过程**: 1. **标记**:识别出伊甸园和S0区中的所有存活对象 2. **复制**:把存活对象复制到S1区(如果对象年龄足够大,直接晋升到老年代) 3. **清空**:一次性清空伊甸园和S0区 **特点**: * 频率高,但速度快 * 采用"复制算法",没有内存碎片 **类比**:每天下班前对操作台进行快速整理,把还要用的食材移到备用区,其他全部清理。 ##### Full GC(全局大扫除) **触发条件**:老年代满了、方法区满了、或者调用`System.gc()` **清理过程**: * 清理整个堆 + 方法区 * 采用"标记-整理"或"标记-清除"算法 * **Stop-The-World**:整个厨房停业整顿,所有工作暂停! **特点**: * 频率低,但停顿时间长,影响巨大 * 要尽量避免Full GC的发生 **类比**:每月一次的大扫除,整个厨房停业,彻底清理每个角落。 #### 4.5 不同的保洁团队——垃圾回收器 JVM提供了多种保洁团队,各有特色: 1. Serial收集器 - 单人保洁队 * 单线程工作,清理时厨房完全停业 * 适合小厨房(客户端应用) 2. Parallel收集器 - 多人保洁队 * 多线程并行清理,速度更快 * 注重**吞吐量**(尽可能多处理订单) 3. CMS收集器 - 快速响应团队 * 目标是**最小化停顿时间** * 大部分工作与厨房运营并发进行 * 但会产生内存碎片 4. G1收集器 - 智能分区团队(JDK9+默认) * 把堆分成多个小区域,优先清理最脏的区域 * 在停顿时间和吞吐量间取得平衡 * **预测停顿时间**:可以设置"最多停业XX毫秒" 5. ZGC/Shenandoah - 超低停顿团队 * 停顿时间控制在10ms以内 * 适合超大厨房(上百G堆内存) #### 4.6 作为总厨,如何优化? 理解了保洁机制,你现在可以: **减少垃圾产生** ```java // 坏做法:在循环中创建大量临时对象 for (int i = 0; i < 10000; i++) { String temp = new String("item" + i); // 产生大量垃圾! process(temp); } // 好做法:重用对象或使用基础类型 StringBuilder sb = new StringBuilder(); for (int i = 0; i < 10000; i++) { sb.setLength(0); sb.append("item").append(i); process(sb.toString()); } ``` **避免内存泄漏** ```java // 常见内存泄漏:静态集合引用 public class MenuManager { private static Map<Long, Order> allOrders = new HashMap<>(); public void addOrder(Order order) { allOrders.put(order.getId(), order); // 订单永不释放! } // 应该提供移除方法 public void removeOrder(Long orderId) { allOrders.remove(orderId); } } ``` **合理设置JVM参数** ```bash # 设置堆大小 -Xms4g -Xmx4g # 初始堆4G,最大堆4G(避免动态扩容) # 设置新生代比例 -XX:NewRatio=2 # 新生代:老年代 = 1:2 # 选择垃圾回收器 -XX:+UseG1GC # 使用G1回收器 # 设置目标停顿时间 -XX:MaxGCPauseMillis=200 # G1目标停顿200ms ``` * * * **实战思考题** 假设你的餐厅应用出现频繁Full GC,可能是什么原因? 1. ❌ 对象过早进入老年代? 2. ❌ 内存泄漏导致老年代撑爆? 3. ❌ 新生代设置太小? 4. ❌ 大对象直接分配在老年代? **答案**:都有可能!需要你用`jstat`、`jmap`等工具具体分析。 * * * ### 第五章:JMM内存模型——厨房的协作规则与幽灵问题 想象一下,你的中央厨房现在规模扩大了,有**多个厨师团队同时工作**。这时候出现了诡异的现象: * 厨师A明明往锅里放了盐,但厨师B尝的时候却说"没味道" * 厨师C和厨师D同时抢最后一份珍贵松露,结果两人都以为抢到了 * 有时候做菜的顺序莫名其妙被打乱,导致菜品失败 这就是**多线程并发问题**。要理解这些问题,我们需要学习JVM的**内存模型(JMM)**。 #### 5.1 主内存 vs 工作内存——中央仓库与私人操作台 在JMM视角下,厨房内存被重新划分: * **主内存**:中央大仓库,所有食材的"官方记录"都在这里 * **工作内存**:每个厨师的**私人操作台**,有自己临时存放食材的地方 **关键规则**:厨师不能直接操作中央仓库的食材!必须先拿到自己操作台上处理,处理完再同步回中央仓库。 这就引出了第一个幽灵问题: #### 5.2 可见性问题——"我加了盐,你为什么尝不到?" **场景还原**: ```java // 中央仓库记录:saltAdded = false public class Kitchen { private static boolean saltAdded = false; public static void main(String[] args) { // 厨师A线程 new Thread(() -> { try { Thread.sleep(1000); // 准备食材... saltAdded = true; // 厨师A在自己的操作台上标记"已加盐" System.out.println("盐已加入!"); } catch (InterruptedException e) { e.printStackTrace(); } }).start(); // 厨师B线程 new Thread(() -> { while (!saltAdded) { // 死循环:厨师B一直查看自己操作台上的记录 // 但看不到厨师A的修改! } System.out.println("检测到盐已加入,继续下一步..."); }).start(); } } ``` **问题根源**:厨师A在自己操作台上改了"盐状态",但没及时同步到中央仓库。厨师B看的还是自己操作台上的旧数据。 **面试回答模板**: "可见性问题是指一个线程对共享变量的修改,另一个线程不能立即看到。这是因为每个线程有自己的工作内存,修改后需要同步到主内存,其他线程才能从主内存读取到最新值。" #### 5.3 原子性问题——"最后一份松露之争" **场景**:珍贵松露只剩最后一份,两个厨师同时去取: ```java public class TruffleProblem { private int truffleCount = 1; // 最后一份松露 public void takeTruffle() { if (truffleCount > 0) { // 线程A执行到这里,暂停! // 线程B也执行到这里,也看到truffleCount > 0 try { Thread.sleep(10); // 模拟处理时间 } catch (InterruptedException e) { e.printStackTrace(); } truffleCount--; // 两人都减了1,结果变成-1! System.out.println(Thread.currentThread().getName() + "取走了松露,剩余:" + truffleCount); } } } ``` **问题根源**:`truffleCount--`看似一步,实际包含:读取→修改→写入,不是原子操作。 **面试回答模板**: "原子性问题是指一个操作在执行过程中被其他线程中断,导致数据不一致。比如i++操作实际上包含三个步骤,在多线程环境下需要同步机制保证原子性。" #### 5.4 有序性问题——"不按菜谱顺序做菜" 为了优化效率,JVM和处理器会**重排序指令**: ```java // 菜谱写的顺序: 1. 热锅 2. 倒油 3. 放食材 // 实际可能执行的顺序: 1. 倒油 2. 热锅 // 糟糕!冷油下锅 3. 放食材 ``` **这就是指令重排序**,单线程没问题,但多线程会出大问题。 #### 5.5 解决方案——厨房的同步机制 ##### 方案一:synchronized(厨房专用锁) 就像给厨房某个区域加锁,一次只允许一个厨师进入: ```java public class SafeKitchen { private boolean saltAdded = false; public synchronized void addSalt() { saltAdded = true; // 加锁操作,保证可见性和原子性 } public synchronized boolean checkSalt() { return saltAdded; // 同样加锁读取 } } ``` **synchronized保证**: * ✅ 原子性:锁内代码不会被中断(核心) * ✅ 可见性:**解锁前**会把修改**强制刷新**到主内存,获取锁时读取主内存(核心) * ✅ 有序性:限制重排序(不能完全防止) ##### 方案二:volatile(高亮标签) 给特定食材贴上"易变"标签: ```java public class VolatileKitchen { private volatile boolean saltAdded = false; // 告诉所有厨师:这个状态经常变,每次都要去中央仓库查看最新值! } ``` **volatile保证**: * ✅ 可见性:修改**立即**同步到主内存,读取总是从主内存获取 * ✅ 有序性:防止指令重排序 * ❌ 原子性:不保证! **面试高频题**:synchronized vs volatile | 特性 | synchronized | volatile | | --- | --- | --- | | 原子性 | ✅ 保证 | ❌ 不保证 可见性 | ✅ 保证 | ✅ 保证 有序性 | ✅ 保证 | ✅ 保证 使用场景 | 复杂同步 | 简单状态标记 | #### 5.6 实战:单例模式的双重检查锁定 这是面试**超级高频题**! ```java public class Singleton { // 必须volatile! private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance == null) { // 第一次检查 synchronized (Singleton.class) { // 加锁 if (instance == null) { // 第二次检查 instance = new Singleton(); // 创建实例 } } } return instance; } } ``` **面试官必问**:为什么要用volatile?为什么要双重检查? **标准答案**: "1. volatile防止指令重排序:`instance = new Singleton()`包含三个步骤:分配内存→初始化对象→赋值引用。如果没有volatile,可能发生重排序,其他线程拿到未初始化的对象。 2. 双重检查是为了减少同步开销:第一次检查避免每次都要加锁,第二次检查在同步块内确保只创建一个实例。" * * * **面试实战演练** **面试官**:说说你在项目中遇到的多线程问题? **你**:(结合厨房类比) "在我们的订单系统中,遇到过库存扣减的原子性问题。就像多个厨师同时抢最后一份食材,我们用synchronized保证了扣减操作的原子性。另外在状态标记上,比如订单状态变更,我们使用volatile保证所有线程都能立即看到状态变化..." **面试官**:知道happens-before原则吗? **你**: "happens-before是JMM的核心规则,比如: * 程序顺序规则:一个线程内的操作按顺序发生 * volatile规则:volatile写先于后续的读 * 锁规则:解锁先于后续的加锁 * 传递性:如果A先于B,B先于C,那么A先于C" * * * 掌握了JMM,你就能解释多线程中各种"灵异现象",这是区分普通程序员和高级程序员的关键。接下来我们要进入**JVM性能调优与故障排查**,这也是面试官最爱深挖的方向: * **常用监控工具**(jstack, jmap, jstat, jvisualvm) * **线上故障排查**(CPU 100%、内存泄漏、死锁) * **JVM参数调优实战** 我们继续用厨房的类比,看看当厨房出现各种"紧急状况"时,作为总厨该如何应对。 * * * ### 第六章:JVM性能调优与故障排查——厨房的应急预案 想象你的餐厅现在生意火爆,但厨房开始出现各种问题:灶台过热、食材堆积、厨师卡住... 作为总厨,你需要一套完整的监控和应急方案。 #### 6.1 厨房监控中心——JVM监控工具 1. **jps:查看所有正在营业的厨房** ```bash jps ``` * 作用:列出当前运行的所有JVM进程 * **类比**:查看酒店里所有正在运营的厨房列表 * 输出:进程ID + 主类名 2. **jstat:实时厨房仪表盘** 这是**最常用的监控工具**,面试高频考点! ```bash # 监控GC情况,每秒1次,共10次 jstat -gc <pid> 1000 10 # 监控类加载情况 jstat -class <pid> ``` **关键指标解读**: ```plain S0C S1C S0U S1U EC EU OC OU MC MU YGC YGCT FGC FGCT GCT 5120.0 5120.0 0.0 5120.0 33280.0 33280.0 87552.0 43768.5 21248.0 20567.7 100 2.123 5 0.876 2.999 ``` **厨房翻译**: * **S0U/S1U**:两个幸存区使用量(备用食材区) * **EU**:伊甸园使用量(新食材堆积程度) * **OU**:老年代使用量(长期食材堆积) * **YGC/YGCT**:小扫除次数和总耗时 * **FGC/FGCT**:大扫除次数和总耗时 ← **重点监控!** **面试技巧**:如果FGC频繁或FGCT时间很长,说明内存配置有问题! 3. **jstack:查看厨师工作状态** ```bash jstack <pid> > thread_dump.txt ``` * **作用**:生成线程快照,查看每个线程在做什么 * **类比**:突然厨房卡住了,你去记录每个厨师当前的操作步骤 **分析线程状态**: * **RUNNABLE**:厨师正在炒菜 * **BLOCKED**:厨师在等待某个厨具(锁) * **WAITING**:厨师在等配菜员送食材 4. **jmap:食材库存盘点** ```bash # 生成堆转储文件(内存快照) jmap -dump:format=b,file=heap.hprof <pid> # 查看堆内存概要 jmap -heap <pid> # 查看对象统计 jmap -histo <pid> ``` * **类比**:给整个食材仓库拍照,看看都是什么食材占地方 * * * #### 6.2 厨房紧急故障处理 ##### 故障一:CPU 100%——灶台过热报警 **现象**:厨房监控显示CPU持续100%,订单处理缓慢。 **排查步骤**: 1. **定位问题进程**: ```bash top -c # 找到CPU最高的Java进程 ``` 2. **定位问题线程**: ```bash top -H -p <pid> # 查看该进程内各个线程的CPU占用 ``` 3. **分析线程堆栈**: ```bash # 把线程ID转成16进制 printf "%x\n" <thread_id> # 在jstack结果中搜索这个16进制ID jstack <pid> | grep -A 10 <hex_thread_id> ``` **常见原因**: * **死循环**:厨师在不停地搅拌空锅 * **频繁GC**:保洁团队一直在打扫,没人做饭 * **锁竞争**:多个厨师抢同一把菜刀 **面试回答模板**: "首先用top定位到CPU高的Java进程,然后用top -H查看进程内线程情况,找到CPU高的线程ID转成16进制,最后用jstack分析该线程的堆栈,通常会发现死循环或频繁GC的问题。" ##### 故障二:内存泄漏——食材堆积如山 **现象**:老年代使用率持续增长,频繁Full GC,最终OOM。 **排查步骤**: 1. **实时监控**: ```bash jstat -gcutil <pid> 1000 ``` 2. **生成内存快照**: ```bash jmap -dump:live,format=b,file=leak.hprof <pid> ``` 3. **使用MAT/Eclipse Memory Analyzer分析**: * 查找**Dominator Tree**(支配树)找到占用内存最大的对象 * 查看**Leak Suspects Report**(泄漏嫌疑报告) **常见内存泄漏模式**: ```java // 静态集合引起的内存泄漏 public class MenuManager { private static Map<Long, Order> orderCache = new HashMap<>(); public void cacheOrder(Order order) { orderCache.put(order.getId(), order); // 订单永不移除! } } ``` **面试回答模板**: "先用jstat观察老年代使用率是否持续上升,然后用jmap生成堆转储文件,最后用MAT工具分析。通常会发现是静态集合、缓存未设置过期时间、或数据库连接未关闭等原因导致的对象无法回收。" ##### 故障三:死锁——厨师互相等待 **现象**:请求无响应,但CPU和内存使用率正常。 **排查步骤**: ```bash jstack <pid> | grep -i deadlock # 或者直接查看完整的线程转储,jstack会自动检测死锁 ``` **典型死锁场景**: ```java // 厨师A:先拿刀,再拿案板 synchronized(knife) { synchronized(cuttingBoard) { // 切菜... } } // 厨师B:先拿案板,再拿刀 synchronized(cuttingBoard) { synchronized(knife) { // 切菜... } } ``` **面试回答模板**: "使用jstack生成线程转储,jstack会自动检测并报告死锁。分析线程状态可以看到哪些线程在BLOCKED状态,以及它们等待的锁被哪个线程持有。" * * * #### 6.3 厨房优化参数——JVM调优实战 **基础内存设置** ```bash # 堆内存:根据系统内存合理分配 -Xms4g -Xmx4g # 初始堆=最大堆,避免动态调整 # 新生代:占堆的1/3到1/2 -Xmn2g # 新生代2G # 幸存区比例 -XX:SurvivorRatio=8 # Eden:S0:S1 = 8:1:1 ``` **GC调优参数** ```bash # 使用G1回收器(JDK9+默认) -XX:+UseG1GC # 目标停顿时间 -XX:MaxGCPauseMillis=200 # 并行GC线程数 -XX:ParallelGCThreads=4 ``` **故障排查参数** ```bash # OOM时自动生成堆转储 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dumps # 开启GC日志 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/to/gc.log ``` **根据应用类型调优** 1. **Web应用(如Spring Boot)**: ```bash # 增大元空间,防止动态类加载OOM -XX:MaxMetaspaceSize=256m # 增大栈空间,应对复杂调用链 -Xss1m ``` 2. **大数据计算应用**: ```bash # 更大新生代,减少对象晋升 -Xmn8g # 使用并行收集器,追求吞吐量 -XX:+UseParallelGC ``` * * * #### 6.4 面试实战:调优案例 **面试官**:如果线上应用频繁Full GC,你会如何排查和优化? **你的回答**: "我会分四步处理: 1. **现象确认**:用`jstat -gc pid`确认FGC频率和老年代使用率 2. **紧急止血**:如果有OOM风险,先扩容堆内存`-Xmx` 3. **根因分析**:用`jmap`生成堆转储,用MAT分析对象引用链 4. **彻底解决**: * 如果是内存泄漏:修复代码,比如及时清理缓存 * 如果是配置问题:调整新生代大小,避免对象过早进入老年代 * 优化GC策略:比如切换到G1,设置合理的MaxGCPauseMillis 同时我会设置`-XX:+HeapDumpOnOutOfMemoryError`,确保下次OOM能自动保存现场。" **面试官**:如何设置JVM参数监控GC? **你的回答**: "我通常会配置: 这样GC日志会滚动保存,方便后续分析GC频率和停顿时间。" ```bash -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps -Xloggc:/app/logs/gc.log -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=10M ``` * * * #### 调优心法总结 记住这个**JVM调优金字塔**: 1. **底层**:代码优化 - 减少不必要的对象创建,及时释放资源 2. **中层**:JVM参数 - 合理配置堆内存、选择合适GC器 3. **上层**:架构设计 - 缓存策略、异步处理、限流降级 **面试金句**:"调优不是盲目调整参数,而是先测量、再分析、最后验证的持续过程。" 掌握了这些,你在面试中就是那个"既有理论深度,又有实战经验"的候选人!接下来我们可以聊聊**类加载机制**或者**字节码层面**的优化,这两个也是能让你脱颖而出的高级话题。
JVM-盛大厨房的剖析(上)
将JVM比作一个**五星级大酒店的中央厨房**。而每一名Java程序员就是这个厨房的**行政总厨**。我们的任务是把一份份“菜谱”(`.java`文件)变成客人能吃的美味佳肴。 * * * ### **第一章:厨房开业——JVM是什么?** 你想开一家叫“Java餐厅”的连锁店,但有个问题:**全世界的厨房(Windows, Linux, Mac)设备、灶台、锅具都不一样**,你没法为每个厨房写一份不同的菜谱。 怎么办?你制定了一个**终极解决方案**: 1. **你只写一套“标准菜谱”**:这套菜谱用一种特殊的、通用的“符号”写成,这就是 `.java`**源码**。 2. **雇佣一位“翻译官”**:这位翻译官叫 `javac`,他的工作就是把你的“标准菜谱”翻译成一套精确的、所有分店都能看懂的 **“标准指令集”**,这就是 `.class`**字节码文件**。 3. **在每个分店安装“智能厨房系统”**:这个系统就是 **JVM**。它被设计成可以理解并执行那份“标准指令集”。无论这个厨房本身是燃气灶还是电磁炉,这个智能系统都能将标准指令转化成当地厨房设备能执行的具体操作。 所以,**JVM就是一个“智能厨房系统”进程**。你启动它,就像启动了一个微信一样,它就在那里待命,准备处理你的“菜谱指令”。 **类比小结**: * `.java`**文件**:你写的标准菜谱。 * `javac`**编译器**:菜谱翻译官。(非JVM的一部分,JDK自带的工具) * `.class`**文件**:翻译好的、通用的标准厨房指令。 * **JVM**:安装在每个操作系统上的“智能厨房系统”。 * * * ### **第二章:厨房的布局——JVM内存结构** 现在,你的“智能厨房系统”(JVM)开始运转了。我们来看看这个中央厨房是怎么布局的。 想象一下,厨房被划分成了几个关键区域: #### **1. 方法区 - 菜谱档案库** * **功能**:这里存放所有**菜谱的原版文件**(加载进来的Class对象)。比如“宫保鸡丁的标准做法”、“鱼香肉丝的秘方”。所有厨师都可以来这里查阅。 * **特点**:**共享区域**,所有厨师(线程)都能访问。 * **现实对应**:JVM中的**方法区**(JDK8后叫**元空间**),存放类的元信息、常量池、静态变量等。 #### **2. 堆 - 核心食材加工区 & 成品暂放区** * **功能**:这是厨房里**最大、最核心**的区域。所有**需要烹饪的食材**(你`new`出来的对象)都放在这里。比如,你要做宫保鸡丁,就得从这里取出鸡肉、花生、黄瓜等对象。 * **特点**: * **共享区域**,所有厨师共用。 * **会产生垃圾**!切掉的鸡皮、用过的蛋壳、炒糊的菜(不再被引用的对象)都堆在这里。 * 所以,这里需要**保洁团队**,这就是**垃圾回收器**。 * **现实对应**:JVM中的**堆内存**,几乎所有的对象实例和数组都在这里分配内存。 #### **3. 虚拟机栈 - 每位厨师的操作台** * **功能**:每个厨师(线程)都有**自己私有的操作台**。厨师每开始做一道新菜(调用一个方法),就会在自己的操作台上铺上一张**“厨艺便签”**(**栈帧**)。 * 这张便签上记录了:这道菜现在进行到第几步了(**程序计数器**)、手边正在处理的食材(**局部变量**)、以及一些临时工具(**操作数栈**)。 * **特点**:**线程私有**。厨师A不会去用厨师B的操作台。 * **现实对应**:JVM中的**虚拟机栈**,每个方法执行都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。 #### **4. 程序计数器 - 厨艺便签上的当前步骤(线程私有)** 功能类似CPU的寄存器,记录下一条要执行的**字节码指令地址**。 * **功能**:就在刚才说的“厨艺便签”(栈帧)上,有一个特别的位置,专门用来记录**“下一步该干嘛”**。比如,“现在鸡丁已经切好了,下一步是热油”。 * **为什么需要它?** 想象一下,厨师正在炒菜,突然接到通知要去处理一个更紧急的外卖订单(线程被切换)。当他回来时,他只需要看一眼“当前步骤”,就能立刻接着刚才的步骤继续炒菜,而不会乱套。 * **现实对应**:JVM中的**程序计数器**,记录当前线程所执行的字节码的行号指示器。 #### **5. 本地方法栈 - 特种设备操作间(线程私有)** * **功能**:厨房里有些高级设备,比如分子料理机、低温慢煮机,它们的使用说明书不是用“标准指令”写的,而是用设备厂商自己的语言(如C/C++)写的。这个操作间就是用来处理这些“本地方法”的。 * **现实对应**:JVM中的**本地方法栈**,为Native方法服务。 * * * ### **第三章:厨房的工作流程——从接单到上菜** 现在我们看一份“宫保鸡丁”的订单是如何被处理的: 1. **接单 & 准备菜谱**: * 订单来了(`java com.restaurant.GongBaoJiDing`)。 * 厨房系统(JVM)收到指令,首先去“菜谱档案库”(方法区)查找“宫保鸡丁”的菜谱(`.class`文件)。如果没找到,就派人去仓库(磁盘)取,这就是**类加载**。 * 取回来后,要**验证**菜谱是不是正规、安全的。然后为菜谱里提到的“标准盐用量5克”这样的**静态变量**在档案库划出位置,并先给个**默认值0**(**准备**阶段)。最后把菜谱里的“主厨特调酱汁”这样的**符号引用**,转换成实际仓库里的位置(**解析**阶段),符号引用存储在方法区里面的常量池。 2. **开始烹饪**: * 系统指派一位厨师(线程)来处理这道菜。厨师在自己的**操作台(虚拟机栈)** 上铺开一张新的“宫保鸡丁厨艺便签”(栈帧)。 * 厨师看了一眼便签上的**当前步骤(程序计数器)**,第一步是“准备食材”。 * 他根据菜谱,去**核心食材区(堆)** 里取来鸡肉、花生等对象。 * 他开始在自己的操作台上进行切丁、腌制等操作(在**栈帧**的局部变量表和操作数栈中进行计算)。 3. **处理与协作**: * 在整个过程中,厨师可能会需要查阅菜谱档案库(方法区),比如看看“宫保汁”的精确比例。 * 他炒菜产生的各种废料(如鸡皮、蒜皮)会直接丢在核心食材区(堆)的旁边,等待**保洁团队(GC)** 来回收。 * 最终,菜肴制作完成,从厨师的流程中输出。这张“厨艺便签”被销毁(栈帧出栈)。  中间思考: 1. **为什么会有内存溢出?** * `OutOfMemoryError: Java heap space`:你的核心食材区堆满了,保洁团队(GC)都来不及收拾。说明你可能创建了太多对象且无法回收(比如内存泄漏)。 * `StackOverflowError`:某位厨师的操作台(虚拟机栈)被无数的“厨艺便签”(栈帧)铺满了,通常是因为方法调用层次太深(比如无限递归)。 2. **为什么要懂垃圾回收?** * 你的厨房保洁团队(GC)有不同的工作模式(Serial, Parallel, CMS, G1, ZGC等)。有的喜欢在打烊后彻底大扫除(Stop-The-World),有的则喜欢边工作边打扫。了解他们,你就能在开发时**写出更“环保”的代码**,比如及时断开对不用的对象的引用(`obj = null`),帮助GC快速识别垃圾,让你的厨房(应用)即使在高峰期也能流畅运转。 怎么样,是不是感觉JVM不再是冷冰冰的机器,而是一个充满活力的智慧厨房了?接下来我们可以深入任何一个“区域”去探险,比如好好聊聊那位至关重要的“保洁团队”——垃圾回收器。
