《JVM 内存调优:结合业务场景的实战指南》针对客户现场反馈xx系统卡顿、反应慢等问题。对JVM内存调优进行介绍。涵盖J
JVM内存调优实战
背景
针对客户现场反馈xx系统卡顿、反应慢等问题。对JVM内存调优进行介绍。涵盖JVM内存模型、垃圾回收相关知识以及调优方法和工具等多方面内容,旨在帮助解决系统性能问题。
核心内容
JVM内存模型
- 堆
- 新生代与老年代
- 堆是 JVM 内存中用于存储对象的主要区域,被划分为新生代和老年代。新生代默认占 1/3 堆空间,是新创建对象的存放区域,由于对象的生命周期通常较短,这里的垃圾回收较为频繁。老年代默认占 2/3 堆空间,存放经过多次垃圾回收仍然存活的对象。
- 例如,在一个 Web 应用中,用户频繁请求创建的临时对象可能存放在新生代,而一些长期存活的配置对象可能存放在老年代。
- Eden 区、Survivor0 和 Survivor1
- 新生代进一步细分为 Eden 区和两个 Survivor 区(Survivor0 和 Survivor1)。Eden 区是对象最初创建的地方,当 Eden 区内存不足时,会触发 Minor GC(新生代垃圾回收),存活的对象会被移动到 Survivor 区。Survivor 区默认占新生代空间的一定比例,其中 Survivor0 和 Survivor1 各占 1/10 新生代空间(Eden 区默认占 8/10 新生代空间)。对象在 Survivor 区之间会进行多次复制和移动,经过一定次数的 Minor GC 后,如果对象仍然存活,就会被移动到老年代。这种设计是为了更好地管理对象的生命周期和提高垃圾回收效率。
- 比如,一个 Java 程序中创建了大量的小对象,这些小对象首先会被分配到 Eden 区,当 Eden 区满时,Minor GC 会将存活的对象移动到 Survivor 区,经过多次 Minor GC 后,一些存活时间较长的对象会被移动到老年代。
- 元空间
- 在 jdk1.8 版本,方法区的实现被替换为元空间。元空间用于存储类的元数据信息,如类的结构、方法、字段等。与之前版本的永久代相比,元空间的内存管理方式有所不同,它使用本地内存,并且其大小可以根据实际情况动态调整。
- 当应用程序加载大量的类库或者动态生成类时,元空间的大小会相应增加。
- 关于《Effience JAVA》中文章04章节指出为什么要 使用私有构造器执行非实例化。就是因为我们只想使用这个类上的静态方法(static)没必要进行实例化 而静态方法属于类加载环节也就是元空间上分配的空间。如果在进行实例化就有点多此一举,常见的一般为工具类等等。比如下面我自己封装了一个抛出异常的小工具ThrowUtils。
// Non instantiable utility class public class ThrowUtils { // Suppress default constructor for incontestability private ThrowUtils() { throw new AssertionError(); }
- 结合性能优化来看待堆
-
在 JVM 内存模型中,堆是一块被所有线程共享的内存区域,主要用于存放对象。它是内存中最大的一块区域,用于动态分配内存给对象。
-
也就是说当我们创建一个对象的时候实际上在堆上分配了一个内存空间。在性能优化里为什么要保证不要频繁创建对象而是引用一个对象 (避免创建不必要的对象)进行使用的原因就在这,频繁创建对象就会导致堆内存增加。而堆属于JVM内存模型,而JVM属于运行内存也就是我们说的服务器几c几g的g,如果垃圾回收不能有效清理这些对象。那么g的提高则会进一步造成cpu负载提高、服务器卡顿……
-
在源码部分就有这样一个问题关于匹配数字正则表达式验证。假设你想写一个方法来确定一个字符串是否是一个有效的罗马数字。 以下是使用正则表达式完成此操作时最简单方法:
// Performance can be greatly improved! static boolean isRomanNumeral(String s) { return s.matches("^(?=.)M*(C[MD]|D?C{0,3})" + "(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$"); }
-
这个实现的问题在于它依赖于 String.matches 方法。 虽然 String.matches 是检查字符串是否与正则表达式匹配的最简单方法,但它不适合在性能临界的情况下重复使用。 问题是它在内部为正则表达式创建一个 Pattern 实例,并且只使用它一次,之后它就有资格进行垃圾收集。 创建 Pattern 实例是昂贵的,因为它需要将正则表达式编译成有限状态机(finite state machine)。
-
为了提高性能,作为类初始化的一部分,将正则表达式显式编译为一个
Pattern
实例(不可变),缓存它,并在isRomanNumeral
方法的每个调用中重复使用相同的实例:// Reusing expensive object for improved performance public class RomanNumerals { private static final Pattern ROMAN = Pattern.compile( "^(?=.)M*(C[MD]|D?C{0,3})" + "(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$"); static boolean isRomanNumeral(String s) { return ROMAN.matcher(s).matches(); } }
-
- 新生代与老年代
- 栈
- 线程栈与栈帧
- 每个线程都有自己独立的栈空间,栈用于存储线程执行过程中的局部变量、方法调用信息等。栈由多个栈帧组成,每个栈帧对应一个方法调用。当一个方法被调用时,会在栈顶创建一个新的栈帧,用于存储该方法的局部变量表、操作数栈、动态链接、方法返回地址等信息。当方法执行完毕,栈帧会被弹出栈。
- 例如,在一个多线程的 Java 程序中,每个线程在执行方法时都会创建自己的栈帧,当方法执行结束后,栈帧会被销毁。
- 本地方法栈
- 本地方法栈与栈类似,但它是用于支持本地方法(Native Method)的执行。本地方法是用其他语言(如 C、C++)编写的方法,通过 JNI(Java Native Interface)机制在 Java 中调用。本地方法栈为这些本地方法提供了运行时环境,存储本地方法的局部变量、参数等信息。
- 比如,当 Java 程序调用一个用 C 编写的本地方法时,本地方法栈会为这个本地方法提供运行环境。
- 线程栈与栈帧
- 方法区
- 类静态属性引用的对象和常量引用的对象
- 方法区是 JVM 内存中的一个共享区域,用于存储类的信息,包括类的字节码、类的静态变量、常量池等。类的静态属性引用的对象和常量引用的对象都存储在方法区中。这些对象在整个应用程序的生命周期内都存在,只有当类被卸载时,相关的对象才可能被回收。
- 例如,一个类的静态常量引用了一个字符串对象,这个字符串对象会存储在方法区中,并且在整个应用程序运行期间都存在。
- 在1.8之后被元空间取代
- 程序计数器
- 程序计数器是一个较小的内存区域,它用于记录当前线程执行的字节码行号。由于 Java 是多线程语言,每个线程都有自己独立的程序计数器。当线程切换时,程序计数器可以帮助 CPU 快速恢复到线程上次执行的位置,保证线程的执行顺序和正确性。
- 在一个多线程并发执行的 Java 程序中,程序计数器会不断更新,以记录每个线程当前执行的字节码行号。
- 通过计数器如何更好理解程序并发?
- 首先多线程的三大基准:分工、同步、互斥
- 并发源头的BUG:可见性、原子性、有序性 也就是说满足这上面三个要求既可以保证我们并发安全
- 也就是说为什么加锁可以保证并发安全呢?加锁如何满足可见性、原子性、有序性呢?其实关于有序性和可见性在JVM内存模型就给出了答案通过synchroized关键字 当然这里面还有HB六原则 当然这里就不详细解释。通过Happen-Beforce原则解决了有序性和可见性
- 如何解决原子性呢?首先我们知道并发多线程 是属于cpu的最小调度单位 也就是说原子性真正要保证的是cpu最小单位的调度执行 关于并发在cpu上的操作其实是分片的一个线程分配了50ms 之后会立即进行切换 换算到程序层面如果不加锁导致不是原子类的操作比如正常来说ABC是一个完整的操作 有可能进行AB后就直接分片切线程了。从而导致结果不正确。
- 而通过加锁,就算切换了线程线程也没有权限操作被锁定的临界资源,从而保证了原子性!而上述程序计数器就是一种实现方式。
- 更多详细不一一叙说、JVM 并发编程都属于JAVA核心部门 之前的学习方式可能更多的是管中窥豹,也希望鼓励大家结合JVM、并发、操作系统去整体的学习。
- 类静态属性引用的对象和常量引用的对象
垃圾相关概念
- 什么是垃圾?
-
垃圾是指在程序运行过程中,不再被程序使用的对象。这些对象占用了内存空间,但对程序的后续执行没有任何作用,因此需要被回收以释放内存。例如,一个局部变量在其所在的方法执行完毕后,如果没有其他地方引用该变量所指向的对象,那么这个对象就成为了垃圾。
-
比如,在一个方法中创建了一个 ArrayList 对象,当方法执行结束后,如果没有其他地方引用这个 ArrayList 对象,它就会成为垃圾。
-
结合性能优化来看为什么要消除过期的对象引用?
-
首先让我们来看一段代码并分析此代码有什么问题?
// Can you spot the "memory leak"? public class Stack { private Object[] elements; private int size = 0; private static final int DEFAULT_INITIAL_CAPACITY = 16; public Stack() { elements = new Object[DEFAULT_INITIAL_CAPACITY]; } public void push(Object e) { ensureCapacity(); elements[size++] = e; } public Object pop() { if (size == 0) throw new EmptyStackException(); return elements[--size]; } /** * Ensure space for at least one more element, roughly * doubling the capacity each time the array needs to grow. */ private void ensureCapacity() { if (elements.length == size) elements = Arrays.copyOf(elements, 2 * size + 1); } }
-
笼统地说,程序有一个「内存泄漏」,由于垃圾回收器的活动的增加,或内存占用的增加,静默地表现为性能下降。 在极端的情况下,这样的内存泄漏可能会导致磁盘分页(disk paging),甚至导致内存溢出(OutOfMemoryError)的失败。
-
在Stack类中,push方法将对象添加到elements数组中,并且pop方法只是简单地返回数组中的元素并调整size,但并没有将返回的元素对应的数组位置设置为null。这就导致即使对象从逻辑上已经被 “弹出” 栈,但实际上在elements数组中仍然保留着对这些对象的引用。垃圾回收器在判断对象是否可回收时,会根据对象的引用关系来确定,如果一个对象仍然被引用,它就不会被回收。
-
一般来说,当一个类自己管理内存时,程序员应该警惕内存泄漏问题。 每当一个元素被释放时,元素中包含的任何对象引用都应该被清除。
-
-
如何找到垃圾
- 引用计数法
- 原理:给内存中的对象打上标记,对象被引用一次,计数就加 1,引用被释放了,计数就减 1。当这个计数为 0 的时候,这个对象就可以被回收了。
- 缺点:循环依赖会导致无法回收对象。例如,有两个对象 A 和 B,A 引用 B,B 也引用 A,此时 A 和 B 的引用计数都不会为 0,即使它们在程序中已经不再被实际使用,也无法被回收,这会导致内存泄漏。
- 假设在一个复杂的对象关系图中,存在多个对象之间的循环引用,如果使用引用计数法,这些对象将无法被正确回收。
- 根可达算法
- 原理:当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是待回收的。GC Roots 是一组特殊的对象引用,作为判断对象是否可达的起点。
- 原理:当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是待回收的。GC Roots 是一组特殊的对象引用,作为判断对象是否可达的起点。
- 引用计数法
-
- 如何清除垃圾?
- 垃圾回收算法
- Mark-Sweep 标记清除 标记后清除 会产生碎片化
- Copying 拷贝-将存活对象copy到另一区域 存在内存资源浪费问题
- Mark-Compact标记压缩 标记后压缩 效率比Coyping略低
- 垃圾回收器
- Serial 收集器:最基本、历史悠久,用于回收新生代(JDK1.3 之前唯一选择),采用复制算法、串行回收和 “Stop - the - World” 机制。
- Serial Old 收集器:用于老年代,采用串行回收和 “Stop - the - World” 机制,内存回收算法是标记 - 压缩算法。
- Parallel Scavenge + Parallel Old:JDK1.8 默认算法,Parallel Scavenge 用于新生代,采用复制算法和多线程收集;Parallel Old 用于老年代,采用多线程和 “标记 - 整理” 算法,可控制吞吐量。 CMS 收集器:清理垃圾时业务线程和回收线程可同时进行,初始标记和重新标记需 STW。
- G1 Garbage First:核心是分区算法,部分回收,物理上不分代,逻辑上分代。Region 之间用复制算法,整体可看作标记 - 压缩算法,有年轻代 GC、老年代并发标记过程、混合回收以及兜底的 Full GC,介绍了其配置参数和使用注意事项。
- 垃圾回收算法
JVM调优
- 调优概念:根据需求进行JVM规划和预调优,优化运行环境,解决运行过程中的问题(如频繁Full GC 导致慢、卡顿、OOM等)。没有经过压力测试监控,没有调优的意义。
- 调优方法
-
选择合适的垃圾回收器:根据业务场景选择如响应时间快(SWT时间短)场景选用CMS、G1 考虑吞吐量选用PS+PO
-
简单来说前者分别是CMS(Concurrent Mark Sweep)收集器、G1(Garbage First)收集器。CMS工作原理业务线程和回收线程可以同时进行。G1则是分区算法通过配置参数控制停顿时间。
-
后者:Parallel Scavenge(新生代收集器)、Parallel Old(老年代收集器)多线程 适用于对吞吐量要求较高的应用程序,吞吐量是指应用程序在单位时间内完成的工作量,即用户时间 /(用户时间 + GC 时间)。例如,一些批处理任务或者数据处理应用,它们更关注在一段时间内能够处理多少数据,而不是单个请求的响应时间。
-
配置堆内存相关参数
- G1配置参数
- 关于使用
-
分析GC日志:通过开启相关日志标识和参数,分析 GC 日志来了解垃圾回收情况,辅助调优。
-
- 如何排查
- 使用jstat命令,通过 jstat 命令排查 GC 问题,观察 Full GC 频率、耗时,Full GC 后老年代是否变小,YGC 耗时等。
- 使用 Arthas dashboard 命令:查看相关系统信息。
- 查看堆内存对象:通过 jmap - histo pid | head - 20 查看当前堆内存中实例数和占用内存最多的前 20 个对象,还可输出 dump 文件(生产环境禁止使用)并用图形化工具 MAT 分析,了解内存使用情况和是否存在内存泄漏。
- 某现场XX服务卡顿真实案例
- 也是我前段时间辅助实施解决线上问题工单。
- 思路:如果频繁Full GC:1. 尝试扩大堆内存->2. 扩大后FG次数明显降低则正常(无法接受STW时长换G1)->3. 扩大内存后运行一段时间如果仍然频繁发生FG 则考虑是否内存泄漏-分析dump日志->4.关于内存泄漏可以参考我上文中给出的例子。
结语
作为一名程序员,随着水平的提升,愈发不满足于 CRUD 操作。我们渴望成长,渴望变得更强,于是学习高并发、微服务以及各种各样的组件。然而,随着学习的深入,我们却越发感到迷茫。我们觉得自己学习了很长时间,却好像什么都没学会,或者学过的知识随着时间的推移逐渐遗忘。这其实正是我想强调的 —— 基础的重要性。
人们常说,只要学好了操作系统,并发编程就会变得简单;只要掌握了数据结构,算法学习起来也会轻松许多。
希望大家每走一步,都能停下脚步审视自己,查漏补缺,这样才能真正实现成长。
我很喜欢我的经理常说的一句话:只有在项目中作为驱动,以实战为出发点,知识才能更加巩固。
于是,在我完成主线任务之后,经理给我分配了性能优化的任务需求。通过接口性能优化,我学习了CompletableFuture的运用,这激发了我学习并发编程的动力,进而促使我去学习操作系统。除此之外,通过处理线上服务器问题工单排查,我学到了看门狗知识,学会了编写线上定时脚本,还学习了 JVM 调优……
最后,祝大家一切顺利!看到这里,如果您觉得有所收获,不妨给作者点个赞并关注一下,支持一下作者哦 (。・ω・。)ノ♡
转载自:https://juejin.cn/post/7419254995776749583