> 文档中心 > JVM | 第1部分:自动内存管理与性能调优《深入理解 Java 虚拟机》

JVM | 第1部分:自动内存管理与性能调优《深入理解 Java 虚拟机》

JVM | 第1部分:自动内存管理与性能调优

  • 前言
  • 1. 自动内存管理
    • 1.1 JVM运行时数据区
    • 1.2 Java 内存结构
    • 1.3 HotSpot 虚拟机创建对象
    • 1.4 HotSpot 虚拟机的对象内存布局
    • 1.5 访问对象
  • 2. 垃圾回收与内存分配
    • 2.1 判断对象是否存活
    • 2.2 分代与内存分配、回收策略
    • 2.3 垃圾回收算法(GC 的算法)
    • 2.4 HotSpot 的算法实现
    • 2.5 垃圾收集器
  • 3. JVM 参数配置
    • 3.1 JVM 内存参数简述
    • 3.2 JVM 的 GC 收集器设置
  • 4. JVM 性能调优案例分析
    • 4.1 大内存硬件上的应用程序部署策略
    • 4.2 集群间同步导致的内存溢出
    • 4.3 堆外内存导致的溢出错误
    • 4.4 外部命令导致系统缓慢
    • 4.5 服务器虚拟机进程崩溃
    • 4.6 不恰当数据结构导致内存占用过大
    • 4.7 由 Windows 虚拟内存导致的长时间停顿
    • 4.8 由安全点导致长时间停顿
    • 4.9 调优总结
  • 最后

前言

参考资料
《深入理解 Java 虚拟机 - JVM 高级特性与最佳实践》

第1部分主题为自动内存管理,以此延伸出 Java 内存区域与内存溢出、垃圾收集器与内存分配策略、参数配置与性能调优等相关内容;

第2部分主题为虚拟机执行子系统,以此延伸出 class 类文件结构、虚拟机类加载机制、虚拟机字节码执行引擎等相关内容;

第3部分主题为程序编译与代码优化,以此延伸出程序前后端编译优化、前端易用性优化、后端性能优化等相关内容;

第4部分主题为高效并发,以此延伸出 Java 内存模型、线程与协程、线程安全与锁优化等相关内容;

本系列学习笔记可看做《深入理解 Java 虚拟机 - JVM 高级特性与最佳实践》书籍的缩减版与总结版,想要了解细节请见纸质版书籍;


1. 自动内存管理

1.1 JVM运行时数据区

JVM | 第1部分:自动内存管理与性能调优《深入理解 Java 虚拟机》

  • 线程共享数据区
    • 方法区(Non-Heap 非堆):存储已被 Java 虚拟机加载的类信息常量静态变量即时编译器编译后的代码等数据。当方法区无法满足内存分配需求时,抛出 OutOfMemoryError 异常;
      • 运行时常量池:存放编译期生成的各种字面量和符号引用;
    • Java 堆(Java Heap):内存中最大的一块。存放对象实例和数组。是垃圾收集器管理的主要区域。可能划分出多个线程私有的分配缓冲区。目的是为了更好的回收内存,或者更快的分配内存。可以处于物理上不连续的内存空间中。没有内存可以完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError 异常;
  • 线程独立数据区
    • 程序计数器(Program Counter Register 线程计数器):线程正在执行 Java 方法时,保存虚拟机字节码指令的地址,否则 Undefined。Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的。每条线程都有一个独立的程序计数器,各个线程之间计数器互不影响,独立存储。是虚拟机中唯一没有规定 OutOfMemoryError 情况的区域;
    • 本地方法栈(Native Method Stack):为虚拟机使用到的 Native 方法服务。源码是 C 和 C++;
    • Java 虚拟机栈(Java Virtual Machine Stacks):生命周期和线程一致。存储 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表操作数栈动态链接方法出口等信息;
      • 局部变量表:存放方法参数和方法内定义的局部变量。存放编译期可知的各种基本类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,能找到对象在 Java 堆中的数据存放的起始地址索引,与对象所属数据类型在方法区中存储的类型信息)、returnAddress类型(指向了一条字节码指令的地址)。线程安全。通过索引定位方式使用局部变量表,容量以变量槽(slot)为最小单位;
        • slot 可以复用,但可能会导致 GC 问题:大对象复用时会作为 GC Roots的一部分,当它的其中一个局部变量超过作用域时,理应回收大对象。但由于 slot 复用保持着大对象的引用,导致 GC 无法回收;
      • 操作数栈:操作数栈是用来操作的。栈中的数据元素必须与字节码指令的序列严格匹配。在概念模型中,两个栈帧是相互独立的;在实际实现中,上下两个栈帧可能会出现一部分重叠,以实现数据共享;
      • 动态链接:链接到别的方法中去,用来存储链接的地方。动态体现在:在每一次运行期间转化为直接引用,而不是第一次类加载阶段(静态解析);
      • 方法出口:有两种出口:
        • 正常 return:方法调用者的程序计数器的值可以作为返回地址;
        • 不正常抛出异常:需要通过异常处理表来确定出口;
      • 附加信息:虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与调试相关的信息,由虚拟机自行实现;

帧栈

1.2 Java 内存结构

Java 内存结构

  • 直接内存(Direct Memory):非虚拟机运行时数据区的部分。不是 Java 虚拟机规范中定义的内存区域;
    • 应用:JDK1.4 中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,可以使用 Native 函数库直接分配堆外内存,然后使用一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。避免了在 Java 堆和 Native(本地)堆中来回复制数据;
    • 内存区域总和大于物理内存限制从而导致动态扩展时出现 OutOfMemoryError 异常;
  • 直接内存与堆内存的区别
    • 直接内存:ByteBuffer.allocateDirect();
    • 非直接内存:ByteBuffer.allocate();
    • 直接内存申请空间耗费高性能,堆内存申请空间耗费比较低;
    • 直接内存的 IO 读写的性能优于堆内存,在多次读写操作的情况相差非常明显;
  • JVM 字节码执行引擎:核心组件。负责执行虚拟机的字节码;
  • 垃圾收集系统:垃圾收集系统是 Java 的核心。垃圾指没有引用指向的内存对象;

1.3 HotSpot 虚拟机创建对象

  • 1. 判断是否加载:遇到 new 指令时,首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,执行相应的类加载;
  • 2. 分配内存:类加载检查通过之后,为新对象分配内存(在堆里,内存大小在类加载完成后便可确认)。在堆的空闲内存中划分一块区域(有两种:‘指针碰撞’——serial、ParNew 算法;‘空闲列表’——CMS 算法);
    • 这里可能会有并发线程安全问题,多个个线程同时分配同一块内存,两种解决方法:对分配内存的动作进行同步处理(采用 CAS 配上失败重试保证原子操作)。或者采用:根据线程不同划分不同的内存缓冲区执行内存分配操作;
  • 3. 初始化值:内存空间分配完成后会初始化为 0(不包括对象头),然后填充对象头(哪个类的实例、何找到类的元数据信息、哈希码、GC 分代年龄等);
  • 4. 执行 init 方法:赋实际值,程序员可控;

1.4 HotSpot 虚拟机的对象内存布局

  • 对象头(Header):包含两部分:
    • 用于存储对象自身的运行时数据:哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等;
    • 类型指针:对象指向它的类的元数据指针,确定是哪个类的实例;
    • 数组在对象头中还必须有一块用于记录数组长度的数据(普通对象可以通过元数据确定大小);
  • 实例数据(Instance Data):程序代码中所定义的各种类型的字段内容(包含父类继承下来的和子类中定义的)
  • 对齐填充(Padding):不是必然需要,主要是占位,保证对象大小是某个字节的整数倍;

1.5 访问对象

  • 通过栈上的 reference 数据(在 Java 堆中)来操作堆上的具体对象:
    • reference 存储的是句柄地址:好处是在对象移动(GC)时只改变实例数据指针地址;
    • reference 中直接存储对象地址:好处是速度快(只需一次指针寻址);

通过句柄访问对象
通过直接指针访问对象

2. 垃圾回收与内存分配

垃圾回收机制的缺点:是否执行,什么时候执行却是不可知的;

2.1 判断对象是否存活

  • 引用计数法
    • 如果一个对象没有被任何引用指向,则可视之为垃圾;
    • 主流的Java虚拟机里面都没有选用引用计数算法来管理内存;
    • 缺点:不能解决循环引用问题;
  • 可达性分析法:(主流)
    • 从 GC Roots 开始向下搜索,搜索所走过的路径为引用链。当一个对象到 GC Roots 没用任何引用链时,则证明此对象是不可用的,表示可以回收。实际上一个对象的真正死亡至少要经历两次标记过程;
    • GC Roots 的对象:
      • 虚拟机栈(栈帧中的本地变量表)中引用的对象;
      • 方法区中类静态属于引用的对象
      • 方法区中常量引用的对象
      • 本地方法栈中 JNI(即一般说的 Native方法)引用的对象;
    • 目前主流的虚拟机都是采用的算法;
  • 对象的四种引用:(JDK 1.2 之后,引用概念进行了扩充)
    • 强引用:类似 new 关键字创建的引用,只要强引用在就不回收;
    • 软引用:SoftReference 类实现,发生内存溢出异常之前,会把这些对象列进回收范围;
    • 弱引用:WeakReference 类实现,在垃圾收集器工作时,无论内存是否足够都会回收;
    • 虚引用:PhantomReference 类实现,无法访问实例,唯一目的是在这个对象被收集器回收时收到一个系统通知;

2.2 分代与内存分配、回收策略

  • 相关代码:
    • 手动回收垃圾:System.gc()
    • 执行 GC 操作调用:Object.finalize()
  • 分代
    • 方法区永久代。不容易回收。主要回收废弃的常量(没有该常量的引用)和无用的类(所有实例已回收、该类的 ClassLoader 已回收、无法通过反射访问);
    • Java 堆:新生代 + 老年代。默认新生代与老年代的比例的值为 1:2;
      • 老年代(2/3):对象存活率高、没有额外空间对它进行分配担保。“标记-清理”算法或者“标记-整理”算法。大对象、长期存活对象分配在老年代;
      • 新生代(1/3):Eden + From Survivor + To Survivor。默认的 Edem : From Survivor : To Survivor = 8 : 1 : 1。JVM 每次只会使用 Eden 和其中的一块 Survivor 区域来为对象服务,剩余的存放回收后存活的对象(与复制算法有关);
        • Eden(4/15):数据会首先分配到 Eden 区。Eden没有足够空间的时候就会触发 JVM 发起一次 Minor GC,存活则进入 Survivor
        • From Survivor(1/30) 和 To Survivor(1/30):对象每熬过一次 Minor GC 还存活则年龄加1,当年龄达到(默认为15)时晋升到老年代
  • 几种分代 GC:
    • Minor GC:新生代 GC。执行频繁,回收速度快;
      • 触发条件:Eden 区满。
    • Major GC:老年代 GC。通常会连着 Minor GC 一起执行。速度慢;
      • 触发条件:晋升老年代对象大小 > 老年代剩余空间。Minor GC 后存活对象大小 > 老年代剩余空间。永久代空间不足。执行 System.gc()。CMS GC异常。堆内存分配很大对象。
    • Full GC:清理整个堆空间,包括新生代和老年代。Full GC 相对于Minor GC来说,停止用户线程的 STW(stop the world)时间过长,应尽量避免;
      • 触发条件:System.gc() 方法调用。晋升老年代对象大小 > 老年代剩余空间。Metaspace区内存达到阈值(JDK8 引入,使用本地内存)。堆中产生大对象超过阈值。老年代连续空间不足。
  • 动态对象年龄判定:在 Survivor 空间中相同年龄 x 的对象总大小 > Survivor 空间的一半时,年龄大于等于 x 的对象将直接进入老年代;(HotSpot 虚拟机)
  • 空间分配担保
    • JDK 6 Update 24 之前:老年代可用连续空间大小 < 新生代对象总大小 时,查看相关参数判断是否允许担保失败。允许则判断是否:年代可用连续空间大小 > 历次晋升老年代对象平均大小,成立则进行 Major GC(有风险);不成立说明老年代可用连续空间很少,进行 Full GC。或者不允许担保失败也会进行 Full GC;
    • JDK 6 Update 24 之后:老年代可用连续空间大小 > 新生代对象总大小老年代可用连续空间大小 > 次晋升老年代对象平均大小 时,进行 Major GC。反之进行 Full GC;

2.3 垃圾回收算法(GC 的算法)

  • 引用计数算法(Reference counting)
    • 每个对象在创建的时候,就给这个对象绑定一个计数器。每当有一个引用指向该对象时,计数器加一。每当有一个指向它的引用被删除时,计数器减一。计数器为0就代表该对象死亡,这时就应该对这个对象进行垃圾回收操作;
    • 主流的 Java 虚拟机里面都没有选用引用计数算法来回收垃圾;
  • 标记–清除算法(Mark-Sweep)
    • 分为两个阶段,一个是标记阶段,这个阶段内,为每个对象更新标记位,检查对象是否死亡。第二个阶段是清除阶段,该阶段对死亡的对象进行清除,执行 GC 操作;
    • 优点:必要时才回收。解决循环引用的问题;
    • 缺点:回收时,应用需要挂起。效率不高。会造成内存碎片;
    • 应用:老年代(生命周期比较长);
  • 标记–整理算法
    • 在第二个清除阶段,该算法并没有直接对死亡的对象进行清理,而是将所有存活的对象整理一下,放到另一处空间,然后把剩下的所有对象全部清除;
    • 优点:解决内存碎片问题;
    • 缺点:由于移动了可用对象,需要去更新引用;
    • 应用:老年代(生命周期比较长);
  • 复制算法
    • 把空间分成两块,每次只对其中一块进行 GC。当这块内存使用完时,就将还存活的对象复制到另一块上面,循环下去。实际分为一块 Eden 和两块 Survivor;
    • 优点:存活对象不多时性能高。解决内存碎片和引用更新问题;
    • 缺点:内存浪费。存活对象数量大时性能差;
    • 应用:新生代(当回收时,将 Eden 和 Survivor 中还存活的对象一次性复制到另一块 Survivor 上,最后清理 Eden 和 Survivor 空间);
  • 分代算法:(次要)
    • 针对不同代使用不同的 GC 算法;

2.4 HotSpot 的算法实现

2.5 垃圾收集器

垃圾回收算法是内存回收的理论,垃圾回收器是内存回收的实践;

垃圾回收器

  • 上图说明:如果两个收集器之间存在连线说明他们之间可以搭配使用;
  • 垃圾收集器
    • 是垃圾回收算法的具体实现,不同版本的 JVM 所提供的垃圾收集器可能会有很在差别;
  • JDK8 的垃圾收集器:
    • Serial:Client 模式下默认。一个单线程收集器,只会使用一个 CPU 或者线程去完成垃圾收集工作,而且在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束。单线程收集高效;
      • 工作区域:新生带;
      • 回收算法:复制算法;
      • 工作线程:单线程;
      • 线程并行:不支持;
    • ParNew:可看做 Serial 的多线程版本,Server 模式下首选, 可搭配 CMS 的新生代收集器;
      • 工作区域:新生带;
      • 回收算法:复制算法;
      • 工作线程:多线程;
      • 线程并行:不支持;
    • Parallel Scavenge:目标是达到可控制的吞吐量(即:减少垃圾收集时间)。吞吐量 Throughput = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间);
      • 工作区域:新生带;
      • 回收算法:复制算法;
      • 工作线程:多线程;
      • 线程并行:不支持;
    • Serial Old:Serial 老年代版本,Client 模式下的虚拟机使用;
      • 工作区域:老年带;
      • 回收算法:标记-整理算法;
      • 工作线程:单线程;
      • 线程并行:不支持;
    • Parallnel old:Parallel Scavenge 老年代版本,吞吐量优先;
      • 工作区域:老年带;
      • 回收算法:标记-整理算法;
      • 工作线程:多线程;
      • 线程并行:不支持;
    • CMS:一种以获取最短回收停顿时间为目标的收集器,适用于互联网站或者 B/S 系统的服务端上。并发收集、低停顿。与用户线程可以同时工作;
      • 工作区域:老年带;
      • 回收算法:标记-清除算法(内存碎片);
      • 工作线程:多线程;
      • 线程并行:支持;
      • 缺点:对 CPU 资源敏感。无法收集浮动垃圾(Concurrent Mode Failure)。内存碎片;
      • 运作步骤:初始标记(标记 GC Roots 能直接关联到的对象)、并发标记(进行 GC Roots Tracing)、重新标记;
    • G1:最前沿成果之一。面向服务端应用的垃圾收集器。可看做 CM的终极改进版。JDK1.9 默认垃圾收集器。能充分利用多CPU、多核环境下的硬件优势。可以并行来缩短(Stop The World)停顿时间。能独立管理整个 GC 堆。采用不同方式处理不同时期的对象。
      • 工作区域:新生带 + 老年带;
      • 回收算法:标记-整理 + 复制算法;
      • 工作线程:多线程;
      • 线程并行:支持;
      • 运作步骤:初始标记(标记 GC Roots 能直接关联到的对象)、并发标记(进行 GC Roots Tracing)、重新标记;

3. JVM 参数配置

3.1 JVM 内存参数简述

  • 常用:
    • -Xms:初始堆大小,JVM 启动的时候,给定堆空间大小;
    • -Xmx:最大堆大小,JVM 运行过程中,如果初始堆空间不足的时候,最大可以扩展到多少;
    • -Xmn:设置堆中年轻代大小。整个堆大小=年轻代大小+年老代大小+持久代大小;
    • -XX:NewSize=n 设置年轻代初始化大小大小;
    • -XX:MaxNewSize=n 设置年轻代最大值;
    • -XX:NewRatio=n 设置年轻代和年老代的比值。如: -XX:NewRatio=3,表示年轻代与年老代比值为 1:3,年轻代占整个年轻代+年老代和的 1/4 ;
    • -XX:SurvivorRatio=n 年轻代中 Eden 区与两个 Survivor 区的比值。注意 Survivor 区有两个。8表示两个Survivor :eden=2:8 ,即一个Survivor占年轻代的1/10,默认就为8;
    • -Xss:设置每个线程的堆栈大小。JDK5后每个线程 Java 栈大小为 1M,以前每个线程堆栈大小为 256K;
    • -XX:ThreadStackSize=n 线程堆栈大小;
    • -XX:PermSize=n 设置持久代初始值;
    • -XX:MaxPermSize=n 设置持久代大小;
    • -XX:MaxTenuringThreshold=n 设置年轻带垃圾对象最大年龄。如果设置为 0 的话,则年轻代对象不经过 Survivor 区,直接进入年老代;
  • 不常用:
    • -XX:LargePageSizeInBytes=n 设置堆内存的内存页大小;
    • -XX:+UseFastAccessorMethods 优化原始类型的 getter 方法性能;
    • -XX:+DisableExplicitGC 禁止在运行期显式地调用 System.gc(),默认启用;
    • -XX:+AggressiveOpts 是否启用JVM开发团队最新的调优成果。例如编译优化,偏向锁,并行年老代收集等,jdk6 之后默认启动;
    • -XX:+UseBiasedLocking 是否启用偏向锁,JDK6 默认启用;
    • -Xnoclassgc 是否禁用垃圾回收;
    • -XX:+UseThreadPriorities 使用本地线程的优先级,默认启用;

3.2 JVM 的 GC 收集器设置

  • -XX:+UseSerialGC:设置串行收集器,年轻带收集器;
  • -XX:+UseParNewGC:设置年轻代为并行收集。可与 CMS 收集同时使用。JDK5.0 以上,JVM 会根据系统配置自行设置,所以无需再设置此值;
  • -XX:+UseParallelGC:设置并行收集器,目标是目标是达到可控制的吞吐量;
  • -XX:+UseParallelOldGC:设置并行年老代收集器,JDK6.0 支持对年老代并行收集;
  • -XX:+UseConcMarkSweepGC:设置年老代并发收集器;
  • -XX:+UseG1GC:设置 G1 收集器,JDK1.9 默认垃圾收集器;

4. JVM 性能调优案例分析

调优目的:GC 的时间足够的小、GC 的次数足够的少、发生 Full GC 的周期足够的长;
问题原因:Full GC 的停止用户线程的 STW 时间过长,应尽量避免;
Full GC 触发条件:主要是两个:老年代内存过小、老年代连续内存过小;
控制 Full GC 频率的关键:保障老年代空间的稳定,大多数对象的生存时间不应当太长,尤其是不能有成批量的、长生存时间的大对象产生;

4.1 大内存硬件上的应用程序部署策略

  • 场景简述:原来有 16GB 物理内存(堆内存有 4GB),升级硬件配置后控制堆内存为 12GB。结构出现不定期长时间失去响应的问题;
  • 场景特点:用户交互性强、对停顿时间敏感、内存较大、Java堆较大;
  • 问题原因:内存出现很多由文档序列化产生的大对象,大对象大多在分配时就直接进入了老年代,Minor GC 清理不掉。最终导致导致老年代内存过小,经常发生 Full GC;
  • 解决思路:通过减少单个进程的内存,减低老年代内存,使文档序列化对象不易进入老年代,在 Minor GC 时就被清理;
  • 实际方案:目前单体应用在较大内存的硬件上主要的部署方式有两种:
    • 方案一:通过一个单独的 Java 虚拟机实例来管理大量的 Java 堆内存。具体来说:
      • 1. 使用 Shenandoah、ZGC 这些明确以控制延迟为目标的垃圾收集器;
      • 2. 在把 Full GC 频率控制得足够低的情况下(老年代的相对稳定),使用 Parallel Scavenge/Old 收集器,并且给 Java 虚拟机分配较大的堆内存;
    • 方案二:使用多个 Java 虚拟机,建立逻辑集群来利用硬件资源。具体来说:
      • 1. 在一台物理机器上启动多个应用服务器进程,为每个服务器进程分配不同端口,然后在前端搭建一个负载均衡器,以反向代理的方式来分配访问请求;
      • 2. 使用无 Session 复制的亲合式集群,即:均衡器按一定的规则算法(譬如根据 Session ID 分配)将一个固定的用户请求永远分配到一个固定的集群节点进行处理;(一致 hash 算法的思想);
  • 调优过程
    • 1. 发现问题:监控服务器运行状况 -> 发现网站失去响应是由垃圾收集停顿所导致的;
    • 2. 分析解决
  • 经验之谈
    • 1. 计划使用单个 Java 虚拟机实例来管理大内存,可能遇到的问题:
      • 回收大块堆内存而导致的长时间停顿(G1 收集器缓解问题,ZGC 和 Shenandoah 收集器彻底解决);
      • 大内存必须有 64 位 Java 虚拟机的支持,但由于压缩指针、处理器缓存行容量(Cache Line)等因素,64 位虚拟机的性能测试结果普遍略低于相同版本的 32 位虚拟机;
      • 必须保证应用程序足够稳定,因为这种大型单体应用要是发生了堆内存溢出,几乎无法产生堆转储快照(要产生十几GB乃至更大的快照文件)。出了问题可能必须应用 JMC 这种能够在生产环境中进行的运维工具;
      • 相同的程序在 64 位虚拟机中消耗的内存一般比 32 位虚拟机要大,这是由于指针膨胀,以及数据类型对齐补白等因素导致的,可以开启(默认即开启)压缩指针功能来缓解;
    • 2. 使用逻辑集群的方式来部署程序,可能遇到的问题:
      • 节点竞争全局的资源,最典型的就是磁盘竞争;
      • 很难最高效率地利用某些资源池,譬如连接池,一般都是在各个节点建立自己独立的连接池,这样有可能导致一些节点的连接池已经满了,而另外一些节点仍有较多空余。尽管可以使用集中式的 JNDI 来解决,但这个方案有一定复杂性并且可能带来额外的性能代价;
      • 如果使用 32 位 Java 虚拟机作为集群节点的话,各个节点仍然不可避免地受到 32 位的内存限制,在 32 位 Windows 平台中每个进程只能使用 2GB 的内存,考虑到堆以外的内存开销,堆最多一般只能开到 1.5GB。在某些 Linux 或 UNIX 系统(如 Solaris)中,可以提升到 3GB 乃至接近 4GB 的内存,但 32 位中仍然受最高 4GB(2 的 32 次幂)内存的限制;
      • 大量使用本地缓存(如大量使用 HashMap 作为 K/V 缓存)的应用,在逻辑集群中会造成较大的内存浪费,因为每个逻辑节点上都有一份缓存,这时候可以考虑把本地缓存改为集中式缓存(如 4.6);

4.2 集群间同步导致的内存溢出

  • 场景简述:采用亲合式集群的 MIS 系统,为了实现部分数据在各个节点中共享,使用 JBossCache 构建了一个全局缓存。结果不定期出现多次的内存溢出问题;
  • 场景特点:亲合式集群、JBossCache 全局缓存;
  • 问题原因:JBossCache 基于 JGroups 进行集群间的数据通信,JGroups 在收发数据包时会在内存构建 NAKACK 栈保证顺序与重发。当网络不好时重发数据在内存中不断堆积;
  • 解决思路:改进 JBossCache 的缺陷,改进 MIS 系统;
  • 实际方案:可以允许读操作频繁,不允许写操作频繁,避免大的网络同步开销;
  • 调优过程
    • 1. 发现问题:添加 -XX:+HeapDumpOnOutOfMemoryError 参数 -> 运行一段时间发现存在大量 t.NAKACK 对象;
    • 2. 分析解决

4.3 堆外内存导致的溢出错误

  • 场景简述:使用 CometD 1.1.1 作为服务端推送框架,服务器为 4GB 内存,运行 32 位
    Windows 操作系统,堆内存设置为 1.6GB。结果不定时抛出内存溢出异常;
  • 场景特点:32 位系统、小内存、大量的 NIO 操作
  • 问题原因:32 位 Windows 平台中每个进程只能使用 2GB 的内存,其中 1.6GB 分配给了堆内存,0.4 GB 分配给了直接内存。CometD 1.1.1 框架,有大量的 NIO 操作,NIO 会使用 Native 函数库直接分配堆外内存,最终导致直接内存溢出;
  • 解决思路:注意占用较多内存的区域:调整直接内存、线程堆栈、Socket 缓冲区大小,注意 JNI 代码,选择合适的虚拟机与垃圾收集器;
    • 1. 直接内存:通过 -XX:MaxDirectMemorySize 调整直接内存大小;
    • 2. 线程堆栈:通过 -Xss 调整线程堆大小;
    • 3. Socket缓存:每个 Socket 连接都 Receive 和 Send 两个缓存区,控制 Socket 连接数;
    • 4. JNI代码:JNI调用本地库会使用 Native 函数库直接分配堆外内存;
    • 5. 虚拟机和垃圾收集器:虚拟机、垃圾收集器的工作也是要消耗一定数量的内存的;
  • 调优过程
    • 1. 发现问题:首先查看日志 -> 在内存溢出后的系统日志中找到异常堆栈(OutOfMemoryError);
    • 2. 分析解决

4.4 外部命令导致系统缓慢

  • 场景简述:在一台四路处理器的 Solaris 10 操作系统上,处理每次用户请求时都会执行一个外部 Shell 脚本获取系统信息。最后发现请求响应时间比较慢,并且系统中占用绝大多数处理器资源的程序并不是该应用本身;
  • 场景特点:Shell 脚本、创建进程耗费大量资源、“fork”系统;
  • 问题原因:执行 Shell 脚本是通过 Java 的 Runtime.getRuntime().exec() 方法来调用的,它首先复制一个和当前虚拟机拥有一样环境变量的进程,再用这个新的进程去执行外部命令,最后再退出这个进程;
  • 解决思路:尽量减少创建进程的开销;
  • 实际方案:去掉这个 Shell 脚本执行的语句,改为使用 Java 的 API 去获取信息;
  • 调优过程
    • 1. 发现问题:通过 Solaris 10 的 dtrace 脚本 -> 查看当前情况下哪些系统调用花费了最多的处理器资源;
    • 2. 定位问题:发现最消耗处理器资源的竟然是“fork”系统调用(用来创建进程);
    • 3. 分析问题:Shell脚本是通过 Java 的 Runtime.getRuntime().exec() 方法创建大量进程;
    • 4. 分析解决

4.5 服务器虚拟机进程崩溃

  • 场景简述:MIS 系统在与一个 OA 门户做了集成后,服务器运行期间频繁出现集群节点的虚拟机进程自动关闭的现象;
  • 场景特点:远程断开连接异常、OA 门户集成、异步调用;
  • 问题原因:MIS 系统工作流待办事项变化时,使用异步调用 Web 服务,通知 OA 门户。两边服务速度不对等,时间越长越多 Web 服务没有调用,等待线程和 Socket 连接越多;
  • 解决思路:问题根源是异步调用导致线程过多,处理时间超过了设置的超时等待时间;可以从服务通信和超时等待两方面优化;
  • 实际方案:将异步调用改为生产者/消费者模式的消息队列;
  • 调优过程
    • 1. 发现问题:首先查看日志 -> 发现报大量相同的 Socket 重连异常(java.net.SocketException: Connection reset);
    • 2. 分析解决

4.6 不恰当数据结构导致内存占用过大

  • 场景简述:一个后台 RPC 服务器,需要每 10 min 加载一个约 800MB 的 HashMapEntry 类型的数据结构,在这段时间内执行 Minor GC 停顿较长时间;
  • 场景特点:Map数据结构、长停顿 Minor GC;
  • 问题原因:有两方面。一来 800MB 的数据很快把 Eden 填满引发垃圾收集,垃圾收集时这 800MB 数据重复复制到 Survivor 导致 Minor GC 时间长。二来 HashMap 类型 key 和 value 共占 2*8=16 字节,封装成 Map.Entry 后多了 16 字节对象头、8 字节 next 字段和 4 字节 int 类型的 hash 字段,为了对其追加 4 字节空白对象头,还有 8 字节对这个 Map.Entry 的引用。最后实际耗费的内存为 (Long(24byte)×2)+Entry(32byte)+HashMap Ref(8byte) = 88byte,空间效率为:16 字节 / 88 字节 = 18% 太低;
  • 解决思路:有两方面的思路。一来可以将大对象尽早划入老年代,二来可以优化数据结构;
  • 实际方案:将新生代空间减少或使用亲合式集群将大内存划进老年代(类似 4.1)。除此之外还可以将 Survivor 空间去掉,让新生代中存活的对象在第一次 Minor GC 后立即进入老年代,等到 Major GC 的时候再去清理它们。最根本的方法是优化数据结构;
    • 方案一:去掉 Survivor 空间。具体来说:
      • 1. 加入
        参数 -XX:SurvivorRatio=65536-XX:MaxTenuringThreshold=0
      • 2. 或者 -XX:+Always-Tenure
    • 方案二:优化数据结构,需要具体的业务背景;
  • 调优过程
    • 1. 发现问题:首先查看日志 -> 发现在每 10min 里,Minor GC 会造成 500ms 停顿;
    • 2. 分析解决

4.7 由 Windows 虚拟内存导致的长时间停顿

  • 场景简述:GUI 程序使用内存较小。在最小化时,偶尔会出现长时间完全无日志输出,程序处于停顿状态。查看内存发现在最小化时占用内存大幅减小,但虚拟了留下来没有变化;
  • 场景特点:GUI 程序、虚拟内存、应用最小化;
  • 问题原因:GUI 程序在应用最小化时,会将工作内存交换到磁盘页面文件中(修剪),在进行垃圾回收前需要恢复工作页面文件导致停顿,进而导致从准备开始垃圾收集,到真正开始之间所消耗的时间较长;
  • 解决思路:由于 GUI 程序使用内存较小,不对其修剪。修剪的好处是内存可用于其他应用程序,缺点是在恢复工作集内存时会有延迟;
  • 实际方案:在应用程序最小化后阻止 JVM 对其进行修剪。具体来说:
    • 1. -Dsun.awt.keepWorkingSetOnMinimize=true
  • 调优过程
    • 1. 定位停顿问题:加入参数 -XX:+PrintGCApplicationStoppedTime-XX:+PrintGCDate-Stamps-Xloggc:gclog.log -> 确认了停顿确实是由垃圾收集导致;
    • 2. 定位停顿日志:添加 -XX:+PrintReferenceGC 参数,找到长时间停顿的具体日志信息 -> 发现从准备开始收集,到真正开始收集之间所消耗的时间却占了绝大部分;
    • 3. 分析解决

4.8 由安全点导致长时间停顿

  • 场景简述:一个使用 G1 收集器的离线 HBase 集群,有大量的 MapReduce 或 Spark 离线分析任务对其进行访问,集群读写压力较大。结果发现垃圾收集的停顿时间较长;
  • 场景特点:MapReduce 与 Spark 任务、垃圾收集时间短但空转等待时间长、可数循环;
  • 问题原因:HotSpot 虚拟机在认为循环次数较少时,使用 int 类型或范围更小
    的数据类型作为索引值,不进入安全点(具有让程序长时间执行的特征)。在 HBase 连接中有很多个Mapper / Reducer / Executer 线程。清理这些线程靠一个连接超时清理的循环函数, HotSpot 判断这个循环函数为可数循环,等待循环全部跑完才能进入安全点,此时其他线程也必须一起等着,宏观来看就是长时间停顿;
  • 解决思路:连接超时清理的循环函数使用 int 索引因此被判断为可数循环,修改索引将其变为不可数循环即可;
  • 实际方案:把循环索引的数据类型从int改为long即可;
  • 调优过程
    • 1. 发现问题:首先查看日志 -> 发现垃圾收集停顿时间长,但实际垃圾回收时间短;
    • 2. 查看安全点日志:加入参数 -XX:+PrintSafepointStatistics-XX:PrintSafepointStatisticsCount=1 查看安全点日志 -> 发现虚拟机在等待所有用户线程进入安全点时有线程很慢;
    • 3. 找到超时线程:添加 -XX: +SafepointTimeout-XX:SafepointTimeoutDelay=2000 两个参数,使虚拟机在等到线程进入安全点的时间超过 2000 毫秒时就认定为超时 -> 输出导致问题的线程名称;
    • 4. 分析解决

4.9 调优总结

  • 在实际工作中,我们可以直接将初始的堆大小与最大堆大小相等,这样的好处是可以减少程序运行时垃圾回收次数,从而提高效率;
  • 初始堆值和最大堆内存内存越大,吞吐量就越高,但是也要根据自己电脑(服务器)的实际内存来比较;
  • 最好使用并行收集器,因为并行收集器速度比串行吞吐量高,速度快。当然,服务器一定要是多线程的;
  • 设置堆内存新生代的比例和老年代的比例最好为 1:2 或者 1:3 。默认的就是 1:2;
  • 减少 GC 对老年代的回收(老年代 GC 慢)。设置新生代垃圾对象最大年龄,尽量不要有大量连续内存空间的 Java 对象,因为会直接到老年代,内存不够就会执行 GC;
  • 默认的 JVM 堆大小是电脑实际内存的四分之一左右;

最后

新人制作,如有错误,欢迎指出,感激不尽! 欢迎关注公众号,会分享一些更日常的东西! 如需转载,请标注出处! JVM | 第1部分:自动内存管理与性能调优《深入理解 Java 虚拟机》