尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

JVM核心原理与调优实战:从内存分配到垃圾回收

JVM核心原理与调优实战:从内存分配到垃圾回收 1. 先弄清楚JVM到底是什么从一次OOM排查说起很多人学了几年Java张口就能背出“Java虚拟机是Java运行环境的核心”但真到了线上服务报警、堆内存被撑爆、应用假死的时候却不知道从哪里下手。这不怪你——因为大多数资料把JVM讲成了“八股文”只告诉你结论不告诉你为什么会有这些结论。先聊一个真实的场景。某个电商系统在高峰期突然出现接口大面积超时日志文件疯狂刷报错java.lang.OutOfMemoryError: Java heap space团队第一反应是“堆内存不够了加参数-Xmx2g改成4g”。改完重启撑了半小时又挂了。后来看了监控才发现GC日志里Full GC的频率从原来的每小时几次飙升到每分钟几十次每次回收后内存只能释放一小部分紧接着又被占满。这根本不是容量问题而是代码里有人把大对象缓存进了静态Map且永不清理——内存泄漏。这个案例能拆出好几个值得深挖的问题JVM到底是怎么分配内存的一个对象从创建到被回收经历了什么为什么Full GC一多系统就卡得像死机这些问题的答案全部藏在“JVM到底是什么”这个看似基础的问题里。简单说JVM是一台“运行在操作系统之上的抽象计算机”。它屏蔽了底层操作系统和硬件的差异让Java代码编译出的字节码.class文件能在任何装有JVM的平台上运行。这就是“一次编写到处运行”的核心秘密。但JVM不只是翻译字节码的执行器它同时承担着内存管理、线程调度、垃圾回收、运行时编译优化等一系列复杂工作。不理解这些你写出来的Java程序就像开着一辆从不保养的车——平时没事上了高速就抛锚。2. 内存区域划分每个对象在JVM里住什么样的“房子”2.1 运行时数据区到底分几块每块是干什么的JVM在运行Java程序时会把自己管理的内存划分成若干个区域统称“运行时数据区”。你可以把这个结构理解成一家酒店的楼层分配程序计数器每层楼的“值班表”记录当前线程执行到哪一条字节码指令。线程私有生命周期和线程一致。它也是JVM规范里唯一没有规定OOMOutOfMemoryError的区域因为每个线程单独记录空间极小。虚拟机栈每层楼的“工作台”存放栈帧。每次方法调用JVM往栈里压入一个栈帧方法结束栈帧弹出。栈帧里装着局部变量表、操作数栈、动态链接、方法出口等信息。局部变量表存放基本数据类型和对象引用——注意对象本身不在这里在堆里。线程私有。如果线程请求的栈深度超过虚拟机允许的深度抛StackOverflowError如果可以动态扩展却扩不动了抛OutOfMemoryError。本地方法栈和虚拟机栈作用类似只不过它为Native方法服务。所谓Native方法就是用C/C等非Java语言实现的方法典型如Java NIO底层的读写操作。这部分栈在很多实现里直接合并进虚拟机栈但规范层面是独立概念。堆全酒店最大的一层几乎所有的对象实例和数组都在这里分配内存。堆被所有线程共享是垃圾回收器重点照顾的区域。这就是为什么堆的大小、GC策略、内存分配方式直接决定应用的吞吐量和响应时间。堆本身还可以细分新生代Eden区、From Survivor、To Survivor、老年代后续专门讲。方法区存类的结构信息——类名、方法字节码、字段描述、常量、静态变量等。它也是线程共享的。在JDK 8之前方法区被实现为“永久代”也在堆内JDK 8开始改为元空间Metaspace使用本地内存。这个调整影响深远后面章节细说。运行时常量池方法区的一部分存放编译期生成的各种字面量和符号引用——比如字符串常量、final修饰的常量值、类名和方法名。字符串常量池在JDK 7之后被挪到了堆里这是一个很多面试者会踩坑的细节。2.2 栈帧背后的执行逻辑虚拟机栈的每个栈帧对应一次方法调用这个“对应”关系很关键。观察一下它的结构栈帧里局部变量表以“槽”为单位long和double占两个槽其余类型占一个槽。这里有个实践经验局部变量表在编译期就已经确定大小不会在运行期变化。所以一个方法能装载多少个局部变量是固定的。操作数栈是字节码运算的临时工作区。看一段简单的代码public int add(int a, int b) { return a b; }对应的字节码大致是iload_1 // 将局部变量槽1的int值压入操作数栈 iload_2 // 将局部变量槽2的int值压入操作数栈 iadd // 弹出栈顶两个int相加结果压回栈 ireturn // 返回栈顶int每一步的数据都从局部变量表加载到操作数栈运算结果再写回局部变量表或者给方法出口。理解这个模型你就明白为什么字节码是“栈式”的而不是像x86寄存器那样直接操作寄存器。这是JVM实现跨平台、跨架构的一个关键设计。2.3 JDK 8的元空间改动不只是一个版本差异永久代换成元空间很多人在面试里被问过但真正理解意义的没几个。永久代的大小难以预估。项目依赖越多、动态生成类越多比如CGLIB代理、反射永久代越容易撑爆然后报java.lang.OutOfMemoryError: PermGen space。这是当年很多框架爆过的雷。JDK 8把类的元数据挪到本地内存默认情况下元空间大小只受操作系统可用内存限制这样不会因为“永久代太小”而轻易崩溃但代价是如果类加载失控元空间可能无限增长直接把机器内存吃光。实践中的做法是给元空间设置上限-XX:MetaspaceSize256m -XX:MaxMetaspaceSize512m。线上环境建议明确设置防止某些框架在运行时大量生成代理类导致内存失控。另外本地内存不像堆内存那样受-Xmx控制审视内存预算时一定要把这部分单独算进去。3. 类加载与对象创建一个对象从.class到进入堆的完整旅程3.1 加载、验证、准备、解析、初始化Java程序使用的类很多是“用到时才加载”——这被称为懒加载。一个类从被读入内存到完全可用经历五个阶段加载通过类的全限定名获取该类的二进制字节流将字节流中的静态存储结构转换为方法区元空间中的运行时数据结构并在堆中生成一个代表该类的Class对象作为方法区这个类的访问入口。验证检查字节流是否符合JVM规范。Class文件不一定由Java编译器生成也可能是手写字节码或者其他语言编译产物所以这一步不能省。验证包括文件格式验证、元数据验证、字节码验证、符号引用验证。很多反编译再修改的字节码就是在这里被拦下来的。准备为类的静态变量分配内存并设置零值。比如static int x 100准备阶段后x的值是0不是100。真正的赋值动作发生在初始化阶段。但有一个例外如果静态字段是常量final修饰编译期就写入字段属性里的ConstantValue准备阶段直接赋成指定值。解析将常量池内的符号引用替换为直接引用。符号引用相当于“门牌号码的查找线索”直接引用才是“实际门牌”。这阶段涉及类、接口、字段、方法等解析也是Java动态绑定机制的重要占位——因为多态某些方法调用只有在运行时才能确定真实地址。初始化终于执行类的静态代码块、静态变量赋值语句。这里触发类初始化的典型时机创建实例、调用静态方法、访问静态字段、反射、初始化子类时父类未初始化等。这五个阶段里实际编码中最容易出问题的是“初始化”阶段——比如静态代码块抛异常导致类加载失败但后续引用这个类时仍然会尝试初始化反复抛异常日志里出现ExceptionInInitializerError和NoClassDefFoundError并存的情况。3.2 双亲委派模型到底在防什么类加载器之间有严格的层级关系。启动类加载器Bootstrap ClassLoader加载JDK核心类比如java.lang.*扩展类加载器Extension ClassLoader加载JDK扩展目录里的类应用程序类加载器Application ClassLoader加载classpath下的类。双亲委派模型的核心规则当一个类加载器收到类加载请求时先不自己加载先交给父加载器尝试加载父加载器又往上抛直到顶层。只有父加载器反馈自己无法完成加载子加载器才尝试自己加载。这个机制保护了Java核心类库不被篡改。你想想如果没有这层约束你自己写一个java.lang.Object放进classpath被加载了整个Java运行的环境就被污染了。正因为双亲委派java.lang.Object永远由启动类加载器加载你写的同名类根本没有机会被执行。但有些场景需要打破双亲委派典型代表是Tomcat。一个Web容器里部署多个应用每个应用可能依赖不同版本的Spring、不同版本的类库如果全交给同一个应用类加载器版本冲突会直接爆炸。所以Tomcat实现了WebAppClassLoader优先自己加载WEB-INF/classes和WEB-INF/lib下的类加载不到才交给父加载器。这就是“打破双亲委派”的经典应用。热部署也是类似原理——用新的类加载器重新加载类新对象使用新类旧类加载器整体作废。3.3 对象创建的四步内存分配、零值初始化、设置对象头、执行构造类加载完成之后对象创建就是从new指令开始的一连串动作。JVM在堆中为新对象分配内存时有两种策略指针碰撞Bump the Pointer堆内存规整空闲空间和已用空间之间存在一个“分界指针”对象新建就把指针往空闲侧挪动一段距离。配合Serial、ParNew这类带压缩整理功能的收集器使用。空闲列表Free List堆内存碎片化严重时JVM维护一个列表记录哪些内存块可用需要多大就找一块足够大的分出来。配合CMS这种基于标记-清除算法的收集器使用。内存分配还需要考虑并发安全。JVM有两种处理方式一种是用CAS配上失败重试保障分配操作的原子性另一种是“线程本地分配缓冲”Thread Local Allocation BufferTLAB。每一个线程在Eden区划一小块私有的内存区域对象优先在TLAB里分配TLAB空间不够了才去共享区通过CAS方式分配。实践中很多小对象的创建都发生在TLAB内避免了对共享栈的锁竞争。分配完内存后JVM将这块内存的空间不包括对象头都初始化为零值这样实例字段即使不显式赋值也能有默认值。然后设置对象头这个对象是哪个类的实例、对象的哈希码、对象的GC分代年龄、锁状态等。最后才执行init方法——即构造方法里开发者写的那段初始化逻辑这时一个真正可用的对象诞生了。一个经验对象内存布局分三块对象头、实例数据、对齐填充。对象头在64位虚拟机默认开启压缩指针时可占8字节或12字节不等。如果你是做高并发写入场景特别在意内存占用对象头的大小影响是很实际的——成千上万个对象累计下来占用差距非常可观。4. 垃圾回收比想象中复杂的“自动挡”分代机制与收集器选型4.1 怎么判断一个对象该回收引用计数法的致命缺陷和可达性分析Java里判断对象是否存活不用引用计数法。为什么引用计数法极其简单——每个对象维护一个计数器被引用加1引用失效减1减到0就回收。但循环引用问题能直接让它瘫痪A引用BB引用A双方计数器都不是0却又没有外部引用这两个对象就应该回收却收不掉。JVM采用可达性分析Reachability Analysis。从一组称为“GC Roots”的根对象出发沿着引用链向下搜索凡是能链到的对象都是存活的没有链到的就是可回收的。GC Roots包括虚拟机栈栈帧中的本地变量表中引用的对象、方法区中静态属性引用的对象、常量引用的对象、本地方法栈中JNI引用的对象等。这里有一个面试常问的细节软引用SoftReference和弱引用WeakReference。软引用的对象在内存充足时不会被回收内存不够时才回收弱引用则在下一次GC时必定被回收。实践里做本地缓存时软引用比较实用但一般建议用成熟缓存框架而非自己手写。弱引用经常配合ThreadLocal使用解决ThreadLocalMap里Entry的key不释放问题——正因为Entry继承自WeakReferenceThreadLocal?ThreadLocal被业务代码丢弃后key会被GC掉value才能在下一次操作时被清理。4.2 垃圾回收算法各有取舍复制、标记-清除、标记-整理标记-清除Mark-Sweep先标记出所有需要回收的对象然后统一回收。最大的问题是碎片化——回收后内存空间不连续后续分配较大对象时因为没有足够连续空间不得不提前触发GC。标记-整理Mark-Compact标记完成后将存活对象往一端移动直接清掉边界以外的内存。没有碎片但移动对象成本高需要Stop The WorldSTW——应用线程暂停。复制算法Copying把内存分成两块每次只用一块GC时把存活对象一次性复制到另一块然后清掉原来的那一整块。实现简单、运行高效但内存利用率低。现代商用虚拟机在新生代采用的不是“对半开”而是把Eden区和一个Survivor区作为已用空间另一个Survivor区作为复制目标这样只有约10%的内存被浪费。4.3 新生代和老年代为什么要分代分代收集的理论基础是“弱分代假说”绝大多数对象的生命周期极短活过几次GC的对象越来越少。于是堆被分成新生代和老年代。新生代里Eden区存放新创建对象两个Survivor区From和To存放经历过一次Minor GC后仍然存活的对象。每次Minor GC时把Eden区和From区里存活的对象复制到To区同时对象年龄加1达到阈值默认15后被移到老年代。对象进入老年代还有一些动态判定规则比如同龄对象总和超过Survivor空间一半时大于或等于该年龄的对象直接进入老年代——这就是“动态年龄判定”不能死记硬背15这个数字。老年代的对象存活率高不适合复制算法通常使用标记-清理或标记-整理算法。4.4 常见收集器怎么选Serial、ParNew、Parallel Scavenge、CMS、G1以及新版ZGC基础阶段需要掌握的收集器Serial使用复制算法的单线程收集器GC时必须暂停其他所有工作线程。适合单CPU、客户端模式的桌面应用简单但效率不高。ParNewSerial的多线程版本常用于服务端配合老年代的CMS收集器。Parallel Scavenge和ParNew类似但更关注可控制的吞吐量——也就是运行用户代码时间占总时间的比例。适合后台计算密集型任务。CMSConcurrent Mark Sweep第一款真正意义上的并发收集器目标是低停顿。它基于标记-清除算法在初始标记、并发标记、重新标记、并发清理四个阶段中只有初始标记和重新标记需要STW。问题是会产生内存碎片且并发阶段CPU竞争激烈。G1Garbage First面向服务端的收集器把堆划分为多个大小相等的Region跟踪每个Region里垃圾堆积的价值优先回收价值最大的Region。G1可以预测停顿时间通过-XX:MaxGCPauseMillis设置期望的GC停顿毫秒数。从JDK 9开始G1是默认收集器。ZGCJDK 11引入的、面向大堆低延迟的收集器。核心特点是GC停顿时间几乎不随堆大小变化可以控制在10毫秒以内。它在标记整理的过程中大量使用读屏障处理对象搬移时对程序造成的感知极小。目前很多追求超低延时的金融、搜索场景已经切换到了ZGC。选型时要结合业务场景推荐收集器理由离线计算、批处理Parallel Scavenge Parallel Old高吞吐量优先Web服务响应延迟敏感G1 或 ZGC停顿可控延迟低JDK 8默认组合Parallel Scavenge Parallel Old无需特意改动稳定至上JDK 9 默认组合G1官方默认适应大多数场景4.5 Stop The World为什么GC会让应用卡顿任何Java线程在GC进行时需要到达一个安全点SafePoint才能暂停。SafePoint是JVM确定的一份指令位置列表一般选在循环跳转、方法返回等“不容易出现栈内存状态不一致”的位置。如果线程迟迟不进入SafePoint就叫“长时间停顿自旋”。GC的STW时间由两个维度组成一是垃圾标记、清理本身需要的时间二是所有Java线程进入SafePoint的等待时间。实际调优时经常发现GC停顿不全是GC过程本身而是应用线程Checksum计算太重、或大量线程在长循环里没有及时响应暂停信号导致的。排查这类问题要结合线程转储Thread Dump观测哪个线程卡住了GC安全点。5. 从理论到实战JVM调优的正确姿势与常用工具5.1 先判断要不要调优调优不是拍脑袋改参数很多人学完JVM理论后第一件事就是把-Xmx调大再开CMS或G1结果并没有显著效果。你真正要做的第一步永远是先弄清楚系统的瓶颈是不是在JVM。观察指标CPU使用率、堆内存占用曲线、GC频率、GC停顿时间、线程池活跃度、IO等待等。如果CPU飙高、线程争用严重可能问题在锁或线程池配置如果内存占用长期居高不下且GC频繁才需要调整堆大小和收集器。判断“需不需要调优”的心法JVM默认配置经过大量实践验证在没有明显异常指标时别为了调优而调优。一个稳定跑了几年的服务贸然改大-Xmx反而可能因为Full GC耗时太长导致超时风暴。5.2 必备工具链路jps、jstat、jmap、jstack、jconsole、visualvm以及更现代的Arthas专业排查按顺序来jps列出当前机器上所有Java进程。这是起点拿到进程IDPID之后才能做后续操作。jstat实时监控虚机统计信息。最常用jstat -gcutil pid 1000每1000毫秒打印一次堆内存和GC情况。S0/S1表示Survivor区的使用百分比E表示Eden区百分比O表示老年代百分比M表示元空间百分比FGC是Full GC次数FGCT是Full GC累计耗时。如果看到FGC数字不断上涨恭喜不用怀疑一定有对象在往老年代泄漏或者被错误晋升。jmap导出堆内存快照。拿到堆转储文件后用MAT或者VisualVM分析大对象。jstack打印线程转储。查到线程当前状态、锁信息定位死锁、线程阻塞。jconsole图形化监控CPU、内存、线程适合快速看整体形态。visualvm更强力的图形化工具可以装各种插件Visual GC插件直接看堆里每个分区的动态变化。Arthas阿里开源的Java诊断工具如果你能在生产服务器上用调试体验比传统命令式幸福很多。常用命令dashboard实时看线程、内存、GC概况。thread定位线程状态找出CPU飙高的线程。sc/sm搜索类、查看方法信息。watch观察方法入参和返回值——不需要重新部署代码就能知道某个方法被什么数据调用。trace追踪一个方法内部各子调用耗时做性能热点分析。在这些工具里我平时用得最频繁的是Arthas。尤其线上问题没有本地监控权限、又不敢随意重启服务时Arthas的watch和trace能在不破坏现场的情况下拿到关键信息。5.3 常见调优参数每项改动都要知道代价参数作用改动须知-Xmssize初始堆大小通常等于-Xmx避免运行期堆扩容带来的性能损耗-Xmxsize最大堆大小取决于物理内存留足系统和其他进程的空间-Xmnsize新生代大小过大会减少老年代空间容易Full GC频繁过小会让短命对象频繁Minor GC-XX:MaxMetaspaceSizesize元空间上限防止动态类加载泄漏把机器内存打满-XX:SurvivorRatio8Eden和Survivor比例默认8:1一般不要动-XX:MaxTenuringThreshold晋升老年代年龄阈值默认15对象存活期短可适当调小-XX:MaxGCPauseMillisG1期望停顿时间设置太小可能导致GC过于频繁吞吐量下降-XX:HeapDumpOnOutOfMemoryError堆OOM时自动导出转储文件强烈建议开启否则OOM现场丢失排查无从下手这里要重点说OOM参数。线上环境一定加两个参数-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs/heapdump.hprof这样一旦发生堆OOM自动生成堆转储文件拿到文件后用MAT分析最大对象和引用链找到是谁占着内存不放手。这比出了OOM再靠猜靠谱得多。5.4 一次线上Full GC频繁的排查复盘之前接手过一个广告投放服务表现是每天下午Full GC次数从个位数变成几百次接口延迟从30ms变成1.5秒。先用jstat -gcutil观察发现老年代使用率在每次Full GC后只下降10%左右很快又涨回来。初步判断有对象在快速晋升老年代。然后jmap -dump导堆快照用MAT分析结果看到一个自定义的UserProfileCache对象数组占了老年代将近6GB空间。查代码发现这是一个每五分钟刷新一次的全局缓存理论上应该只保留当前版本的快照但实现时用了HashMapLong, UserProfile且每次刷新往同一个Map里put从不清理旧key。用户ID的数量一直在涨老数据又不是强引用——但缓存对象本身在Map里被强引用所以GC怎么回收都没用内存只会越来越高。修复方式很简单改用ConcurrentHashMap加定时清理或者直接换Guava Cache/Caffeine设置过期策略。改完后观察了一周Full GC基本消失老年代曲线平稳。这个案例里前面的理论——可达性分析、分代晋升、GC Roots——全部用上了。你只有理解了对象是怎么被引用的才能在面对GC异常时快速定位根因。6. 内存模型与并发JMM不只是面试题6.1 JMM定义了什么可见性、原子性、有序性Java内存模型Java Memory ModelJMM规定了一个线程对共享变量的写入何时对另一个线程可见。它脱胎于缓存一致性问题的抽象CPU有多级缓存Java线程工作内存线程私有的高速缓存抽象与主内存之间天然存在不一致的可能。JMM的核心约束原子性一个操作或多个操作要么全部执行且不被中断要么全不执行。Java中synchronized、Lock以及java.util.concurrent.atomic包里的原子类都是用来保证原子性的手段。可见性一个线程修改了共享变量其他线程能立刻看到新值。volatile关键字保证可见性同时禁止指令重排序但仍不保证复合操作的原子性。volatile int count; count就是非原子的——它包含“读取-加1-写回”三步并发下会丢更新。有序性指令重排序可能带来意想不到的执行顺序问题。JMM通过synchronized、volatile以及java.util.concurrent下的显式锁来提供有序性保证。6.2 volatile与synchronized的正确打开方式volatile的典型适合场景是“一个线程写、多线程读”的状态标志比如开关量volatile boolean running true; // 线程A void stop() { running false; } // 线程B void run() { while (running) { // 执行任务 } }这里volatile保证线程B能立刻看到running的变化不死循环。如果换成普通变量线程B可能一直读到自己本地缓存里的旧值循环永远退不出去。synchronized更重但也更全面它同时保证原子性、可见性和有序性。synchronized还可以选择锁的粒度方法锁、代码块锁、对象锁、Class锁。加锁范围越小竞争的线程越少吞吐量越高。但过度缩小锁范围有时反而增加代码复杂度权衡需要经验。6.3 面试之外的实战经验并发调优先看锁竞争JMM的很多理论面试题里会问实际排查中也有对应场景。比如线上CPU飙到90%以上你用jstack看线程堆栈发现大量线程处于BLOCKED或WAITING状态等某一把锁——那问题就是锁竞争。常见优化手段用ReentrantReadWriteLock做读写分离用ConcurrentHashMap代替HashMap用LongAdder替代AtomicLong在高并发计数场景下的CAS自旋。但所有这些优化都建立在“你监控到了真实瓶颈”的基础上靠猜是没意义的。7. GC调优和监控指标的独门经验学会看数字别靠感觉7.1 吞吐量、延迟、内存占用调优不可能三角任何GC调优都是在“高吞吐量、低延迟、小内存占用”这三者之间做取舍。把-Xmx调很大可以减少GC频率、提升吞吐量但一旦Full GC发生停顿时间可能长得吓人把G1的MaxGCPauseMillis设成极小值GC频率上升系统吞吐量下降。所以调优的本质不是“找到最佳参数”而是“找到业务可接受的最优平衡”。一般服务型系统优先级延迟 吞吐量 内存占用。因为用户直接感受的是接口响应时间。后台批处理则相反吞吐量 内存占用 延迟。7.2 借助GC日志找出停顿的真凶GC日志是调优的基础。JDK 8需要显式打印GC日志建议如下参数-Xloggc:/data/logs/gc.log -XX:PrintGCDetails -XX:PrintGCDateStamps -XX:PrintGCTimeStampsJDK 9及以上GC日志参数统一到了-Xlog比如-Xlog:gc*:file/data/logs/gc.log拿到日志后看几个重点字段Young GCMinor GC频率和耗时。对象朝生夕灭Minor GC应该很频繁但每次耗时极短几十毫秒级别。如果Minor GC单次耗时几十毫秒甚至上百毫秒检查-Xmn大小是否合适、Eden区是否被超大对象瞬间塞满。Full GC这是红线指标出现一次就要警惕。Full GC频率增长往往伴随内存泄漏或对象晋升异常。promotion failed晋升失败说明老年代连续空间不足需要调大老年代或调整晋升阈值。concurrent mode failureCMS特有的并发失败说明并发清理阶段空间不够用导致退化为Serial Old单线程Full GC。碰到这个一般要增大老年代或者调整CMS触发阈值。我有一次排查线上卡顿GC日志显示每次Minor GC间隔越来越短Eden区回收后存活对象大小每次都超出To Survivor空间导致对象直接被晋升到老年代老年代迅速被堆满触发Full GC。解决办法是增大Survivor区比例把-XX:SurvivorRatio从8改成4并适当调大新生代。那之后GC曲线就平稳了。7.3 用线程转储定位“伪GC问题”还有一种情况GC日志正常但接口还是慢。这时问题可能不在JVM而在业务线程阻塞。用jstack拿线程快照查BLOCKED状态的线程等在哪把锁上、WAITING状态的线程等在哪个条件上就能看到真相。曾经有一个案例现象是“每次GC之后应用卡顿十几秒”。GC日志显示STW只有几百毫秒不符合现象。查线程快照发现GC安全点触发后大量线程在等一把DB连接池的锁锁的持有者又因为慢SQL没返回线程迟迟不释放连接连接池空转应用整体假死。那把DB连接池的锁等到了GC结束但依然释放不了所以卡顿远超GC本身。这说明调优JVM之前先把应用和依赖层面的瓶颈排查干净否则你改了GC参数问题还在那里。8. 调优之外的进阶意识何时改用ZGC何时重新审视代码8.1 ZGC适合什么样的业务ZGC从JDK 11开始孵化到JDK 16、17已经相当成熟。它的优势是GC停顿时间几乎与堆大小无关特别适合以下场景堆内存16GB以上、甚至数百GB服务。业务对延迟极其敏感接口P99不能因为GC抖动。长期运行的服务进程不希望Full GC造成突发性卡顿。如果你的服务堆内存小于4GBG1完全够用没必要追新。ZGC为低延迟付出的是CPU占用和内存占用更高的代价小堆上没有明显收益。8.2 有些“调优”其实该写代码最后说点扎心话很多调优需求的出现是因为代码写得不好。线程被频繁创建销毁于是你用线程池对象在循环里大量创建于是出现GC压力频繁字符串拼接产生了大量临时String对象。如果你的业务代码能从根上减少不必要的对象创建那比调所有GC参数都有效。你写代码时多想想这个方法每秒被调用多少次每次创建的对象生命周期多长值不值得缓存复用这些意识比记住任何一条JVM参数都值钱。这也是为什么我始终觉得JVM不只是一门“调优技术”更是一种理解程序运行方式的方法论。学会用它你排查任何线上问题都会比“来回重启”高一个段位。
返回列表