JVM基础和问题分析入门笔记

1.1 JDK、JRE、JVM的关系

JDK是java开发工具集合,JRE是java运行环境,JVM是Java虚拟机

JDK > JRE > JVM

JDK = JRE + 开发工具

JRE = JVM + 类库

三者在开发运行Java程序时的交互关系:

通过JDK开发的程序,编译以后,可以打包发给装有JRE的机器上去运行。而运行的程序,则是通过Java命令启动的一个JVM实例,代码逻辑的执行都运行在这个JVM实例上。

Java程序的开发运行过程:

利用JDK开发Java程序,编译成字节码或者打包程序。然后在JRE里启动一个JVM实例,加载、验证、执行Java字节码和依赖库,运行Java程序。JVM将程序和依赖库的Java字节码解析并变成本地代码执行,产生结果。

常用性能指标

  1. 延迟:平均响应时间
  2. 吞吐量:每秒处理事务数TPS,每秒处理请求数QPS
  3. 系统容量:设计容量,硬件配置,成本约束

这三个维度互相关联,相互制约。

我们可采用的手段和方式包括:

  • 使用 JDWP 或开发工具做本地/远程调试
  • 系统和 JVM 的状态监控,收集分析指标
  • 性能分析: CPU 使用情况/内存分配分析
  • 内存分析: Dump 分析/GC 日志分析
  • 调整 JVM 启动参数,GC 策略等等

性能调优总结

性能调优的第一步是制定指标,收集数据,第二步是找瓶颈,然后分析解决瓶颈问题。通过这些手段,找当前的性能极限值。压测调优到不能再优化了的 TPS 和 QPS,就是极限值。知道了极限值,我们就可以按业务发展测算流量和系统压力,以此做容量规划,准备机器资源和预期的扩容计划。最后在系统的日常运行过程中,持续观察,逐步重做和调整以上步骤,长期改善改进系统性能。

脱离场景谈性能都是耍流氓”,实际的性能分析调优过程中,我们需要根据具体的业务场景,综合考虑成本和性能,使用最合适的办法去处理。系统的性能优化到 3000TPS 如果已经可以在成本可以承受的范围内满足业务发展的需求,那么再花几个人月优化到 3100TPS 就没有什么意义,同样地如果花一倍成本去优化到 5000TPS 也没有意义。

过早的优化是万恶之源”,我们需要考虑在恰当的时机去优化系统。在业务发展的早期,量不大,性能没那么重要。我们做一个新系统,先考虑整体设计是不是 OK,功能实现是不是 OK,然后基本的功能都做得差不多的时候(当然整体的框架是不是满足性能基准,可能需要在做项目的准备阶段就通过 POC(概念证明)阶段验证。),最后再考虑性能的优化工作。因为如果一开始就考虑优化,就可能要想太多导致过度设计了。而且主体框架和功能完成之前,可能会有比较大的改动,一旦提前做了优化,可能这些改动导致原来的优化都失效了,又要重新优化,多做了很多无用功。

关于跨平台

  • 编译执行:C,C++,Golang,Rust,C#,Java,Scala,Clojure,Kotlin,Swift 等等
  • 解释执行:NodeJS,Python,Perl,Ruby和JavaScript 的部分实现等等

一般来说解释型语言都是跨平台的,同一份脚本代码,可以由不同平台上的解释器解释执行。

但是对于编译型语言,存在两种级别的跨平台: 源码跨平台和二进制跨平台。

1、典型的源码跨平台(C++):

71212109.png

2、典型的二进制跨平台(Java 字节码):

71237637.png

C++可以一次编写,到处编译,但是在不同环境的依赖不一致或者不完全,需要到处调试,到处找依赖,该配置。

Java通过虚拟机技术解决了这个问题。源码只需要编译一次,然后把编译后的 class 文件或 jar 包,部署到不同平台,就可以直接通过安装在这些系统中的 JVM 上面执行。 同时可以把依赖库(jar 文件)一起复制到目标机器,慢慢地又有了可以在各个平台都直接使用的 Maven 中央库(类似于 linux 里的 yum 或 apt-get 源,macos 里的 homebrew,现代的各种编程语言一般都有了这种包依赖管理机制:python 的 pip,dotnet 的 nuget,NodeJS 的 npm,golang 的 dep,rust 的 cargo 等等)。这样就实现了让同一个应用程序在不同的平台上直接运行的能力。

JAVA字节码

为什么要学

Java 中的字节码,英文名为 bytecode, 是 Java 代码编译后的中间代码格式。JVM 需要读取并解析字节码才能执行相应的任务。

了解字节码对于编写高性能代码至关重要。通过修改字节码来调整程序的行为是司空见惯的事情。想了解分析器(Profiler),Mock 框架,AOP 等工具和技术这一类工具,则必须完全了解 Java 字节码。

简介

有一件有趣的事情,就如名称所示, Java bytecode 由单字节(byte)的指令组成,理论上最多支持 256 个操作码(opcode)。实际上 Java 只使用了 200 左右的操作码, 还有一些操作码则保留给调试操作。

操作码, 下面称为 指令, 主要由类型前缀操作名称两部分组成。

例如,'i' 前缀代表 ‘integer’,所以,'iadd' 很容易理解, 表示对整数执行加法运算。

根据指令的性质,主要分为四个大类:

  1. 栈操作指令,包括与局部变量交互的指令
  2. 程序流程控制指令
  3. 对象操作指令,包括方法调用指令
  4. 算术运算以及类型转换指令

此外还有一些执行专门任务的指令,比如同步(synchronization)指令,以及抛出异常相关的指令等等。下文会对这些指令进行详细的讲解。

获取字节码清单

可以用 **javap** 工具来获取 class 文件中的指令清单。 **javap**是标准 JDK 内置的一款工具, 专门用于反编译 class 文件。

GC

Serial GC 日志解读

我们关注的主要是两个数据:GC 暂停时间,以及 GC 之后的内存使用量/使用率。

FullGC,我们主要关注 GC 之后内存使用量是否下降,其次关注暂停时间。简单估算,GC 后老年代使用量为 220MB 左右,耗时 50ms。如果内存扩大 10 倍,GC 后老年代内存使用量也扩大 10 倍,那耗时可能就是 500ms 甚至更高,就会系统有很明显的影响了。这也是我们说串行 GC 性能弱的一个原因,服务端一般是不会采用串行 GC 的。

Tenured:用于清理老年代空间的垃圾收集器名称。Tenured 表明使用的是单线程的 STW 垃圾收集器,使用的算法为“标记—清除—整理(mark-sweep-compact)”。

[Times: user=0.05 sys=0.00,real=0.05 secs]:GC 事件的持续时间,分为 user、sys、real 三个部分。因为串行垃圾收集器只使用单个线程,因此“real=user+system”。50 毫秒的暂停时间,比起前面年轻代的 GC 来说增加了一倍左右。这个时间跟什么有关系呢?答案是:GC 时间,与 GC 后存活对象的总数量关系最大。

Parallel GC 日志解读

并行垃圾收集器对年轻代使用“标记—复制(mark-copy)”算法,对老年代使用“标记—清除—整理(mark-sweep-compact)”算法。

年轻代和老年代的垃圾回收时都会触发 STW 事件,暂停所有的应用线程,再来执行垃圾收集。在执行“标记”和“复制/整理”阶段时都使用多个线程,因此得名“Parallel”。

通过多个 GC 线程并行执行的方式,能使 JVM 在多 CPU 平台上的 GC 时间大幅减少。

通过命令行参数 -XX:ParallelGCThreads=NNN 可以指定 GC 线程的数量,其默认值为 CPU 内核数量。

并行垃圾收集器适用于多核服务器,其主要目标是增加系统吞吐量(也就是降低 GC 总体消耗的时间)。为了达成这个目标,会使用尽可能多的 CPU 资源:

  • 在 GC 事件执行期间,所有 CPU 内核都在并行地清理垃圾,所以暂停时间相对来说更短;
  • 在两次 GC 事件中间的间隔期,不会启动 GC 线程,所以这段时间内不会消耗任何系统资源。

另一方面,因为并行 GC 的所有阶段都不能中断,所以并行 GC 很可能会出现长时间的卡顿。

长时间卡顿的意思,就是并行 GC 启动后,一次性完成所有的 GC 操作,所以单次暂停的时间较长。

假如系统延迟是非常重要的性能指标,那么就应该选择其他垃圾收集器。

Minor GC 日志分析

前面的 GC 事件是发生在年轻代 Minor GC:

2019-12-18T00:37:47.463-0800: 0.690:
  [GC (Allocation Failure)
    [PSYoungGen: 104179K->14341K(116736K)]
    383933K->341556K(466432K),0.0229343 secs]
  [Times: user=0.04 sys=0.08,real=0.02 secs]

解读如下:

  1. 2019-12-18T00:37:47.463-0800: 0.690:GC 事件开始的时间。
  2. GC:用来区分 Minor GC 还是 Full GC 的标志。这里是一次“小型 GC(Minor GC)”。
  3. PSYoungGen:垃圾收集器的名称。这个名字表示的是在年轻代中使用并行的“标记—复制(mark-copy)”,全线暂停(STW)垃圾收集器。104179K->14341K(116736K) 表示 GC 前后的年轻代使用量,以及年轻代的总大小,简单计算 GC 后的年轻代使用率 14341K/116736K=12%。
  4. 383933K->341556K(466432K) 则是 GC 前后整个堆内存的使用量,以及此时可用堆的总大小,GC 后堆内存使用率为 341556K/466432K=73%,这个比例不低,事实上前面已经发生过 FullGC 了,只是这里没有列出来。
  5. [Times: user=0.04 sys=0.08,real=0.02 secs]:GC 事件的持续时间,通过三个部分来衡量。user 表示 GC 线程所消耗的总 CPU 时间,sys 表示操作系统调用和系统等待事件所消耗的时间; real 则表示应用程序实际暂停的时间。因为并不是所有的操作过程都能全部并行,所以在 Parallel GC 中,real 约等于 user+system/GC 线程数。笔者的机器是 8 个物理线程,所以默认是 8 个 GC 线程。分析这个时间,可以发现,如果使用串行 GC,可能得暂停 120 毫秒,但并行 GC 只暂停了 20 毫秒,实际上性能是大幅度提升了。

通过这部分日志可以简单算出:在 GC 之前,堆内存总使用量为 383933K,其中年轻代为 104179K,那么可以算出老年代使用量为 279754K。

在此次 GC 完成后,年轻代使用量减少了 104179K-14341K=89838K,总的堆内存使用量减少了 383933K-341556K=42377K。

那么我们可以计算出有“89838K-42377K=47461K”的对象从年轻代提升到老年代。老年代的使用量为:341556K-14341K=327215K。

老年代的大小为 466432K-116736K=349696K,使用率为 327215K/349696K=93%,基本上快满了。

总结:

年轻代 GC,我们可以关注暂停时间,以及 GC 后的内存使用率是否正常,但不用特别关注 GC 前的使用量,而且只要业务在运行,年轻代的对象分配就少不了,回收量也就不会少。

此次 GC 的内存变化示意图为:

Full GC 日志分析

前面介绍了并行 GC 清理年轻代的 GC 日志,下面来看看清理整个堆内存的 GC 日志:

2019-12-18T00:37:47.486-0800: 0.713:
  [Full GC (Ergonomics)
    [PSYoungGen: 14341K->0K(116736K)]
    [ParOldGen: 327214K->242340K(349696K)]
    341556K->242340K(466432K),
    [Metaspace: 3322K->3322K(1056768K)],
  0.0656553 secs]
  [Times: user=0.30 sys=0.02,real=0.07 secs]

解读一下:

  1. 2019-12-18T00:37:47.486-0800:GC 事件开始的时间。
  2. Full GC:完全 GC 的标志。Full GC 表明本次 GC 清理年轻代和老年代,Ergonomics 是触发 GC 的原因,表示 JVM 内部环境认为此时可以进行一次垃圾收集。
  3. [PSYoungGen: 14341K->0K(116736K)]:和上面的示例一样,清理年轻代的垃圾收集器是名为“PSYoungGen”的 STW 收集器,采用“标记—复制(mark-copy)”算法。年轻代使用量从 14341K 变为 0,一般 Full GC 中年轻代的结果都是这样。
  4. ParOldGen:用于清理老年代空间的垃圾收集器类型。在这里使用的是名为 ParOldGen 的垃圾收集器,这是一款并行 STW 垃圾收集器,算法为“标记—清除—整理(mark-sweep-compact)”。327214K->242340K(349696K)]:在 GC 前后老年代内存的使用情况以及老年代空间大小。简单计算一下,GC 之前,老年代使用率为 327214K/349696K=93%,GC 后老年代使用率 242340K/349696K=69%,确实回收了不少。那么有多少内存提升到老年代呢?其实在 Full GC 里面不好算,而在 Minor GC 之中比较好算,原因大家自己想一想。
  5. 341556K->242340K(466432K):在垃圾收集之前和之后堆内存的使用情况,以及可用堆内存的总容量。简单分析可知,GC 之前堆内存使用率为 341556K/466432K=73%,GC 之后堆内存的使用率为:242340K/466432K=52%。
  6. [Metaspace: 3322K->3322K(1056768K)]:前面我们也看到了关于 Metaspace 空间的类似信息。可以看出,在 GC 事件中 Metaspace 里面没有回收任何对象。
  7. 0.0656553secs:GC 事件持续的时间,以秒为单位。
  8. [Times: user=0.30 sys=0.02,real=0.07 secs]:GC 事件的持续时间,含义参见前面。

Full GC 和 Minor GC 的区别是很明显的,此次 GC 事件除了处理年轻代,还清理了老年代和 Metaspace。

总结:

Full GC 时我们更关注老年代的使用量有没有下降,以及下降了多少。如果 FullGC 之后内存不怎么下降,使用率还很高,那就说明系统有问题了。

此次 GC 的内存变化示意图为:

细心的同学可能会发现,此次 FullGC 事件和前一次 MinorGC 事件是紧挨着的:0.690+0.02secs~0.713。因为 Minor GC 之后老年代使用量达到了 93%,所以接着就触发了 Full GC。

内存计算

操作系统中的最大可用内存除去操作系统本身使用的部分,剩下的都可以为某一个进程服务,在JVM进程中,内存又被分为堆、本地内存和栈等三大块,Java堆是JVM自动管理的内存,应用的对象的创建和销毁、类的装载等都发生在这里,本地内存是Java应用使用的一种特殊内存,JVM并不直接管理其生命周期,每个线程也会有一个栈,是用来存储线程工作过程中产生的方法局部变量、方法参数和返回值的,每个线程对应的栈的默认大小为1M。

从内存角度来看创建线程需要内存空间,如果JVM进程正当一个应用创建线程,而操作系统没有剩余的内存分配给此JVM进程,则会抛出问题中的OOM异常:unable to create new native thread。

如下公式可以用来从内存角度计算允许创建的最大线程数:

最大线程数 = (操作系统最大可用内存 - JVM内存 - 操作系统预留内存)/ 线程栈大小

根据这个公式,我们可以通过剩余内存计算可以创建线程的数量。

使用free -m查看剩余内存

使用ulimit -a来显示当前的各种系统对用户使用资源的限制:

max user processes        (-u) 1024

机器设置的允许使用的最大用户进程数为1024。

使用jstack命令查看Java栈

你可能感兴趣的:(JVM基础和问题分析入门笔记)