ICode9

精准搜索请尝试: 精确搜索
首页 > 其他分享> 文章详细

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

2022-07-23 10:34:43  阅读:175  来源: 互联网

标签:Java 入门 收集器 笔记 GC 内存 JVM 线程


FhXNaOI6ZpPx9sJ0zul1CCAZ0kLA

1.1 JDK、JRE、JVM的关系

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

JDK > JRE > JVM

JDK = JRE + 开发工具

JRE = JVM + 类库

0.18346271077222331.png

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

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

Java程序的开发运行过程:

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

0.9484384203409852.png

常用性能指标

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

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

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

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

性能调优总结

9b861ce8-8350-4943-ac1f-d6fb4fa2f127.png

性能调优的第一步是制定指标,收集数据,第二步是找瓶颈,然后分析解决瓶颈问题。通过这些手段,找当前的性能极限值。压测调优到不能再优化了的 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 的内存变化示意图为:

8353526.png

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 的内存变化示意图为:

85130696.png

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

640

内存计算

操作系统中的最大可用内存除去操作系统本身使用的部分,剩下的都可以为某一个进程服务,在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栈

标签:Java,入门,收集器,笔记,GC,内存,JVM,线程
来源: https://www.cnblogs.com/CooperNiu/p/16511057.html

本站声明: 1. iCode9 技术分享网(下文简称本站)提供的所有内容,仅供技术学习、探讨和分享;
2. 关于本站的所有留言、评论、转载及引用,纯属内容发起人的个人观点,与本站观点和立场无关;
3. 关于本站的所有言论和文字,纯属内容发起人的个人观点,与本站观点和立场无关;
4. 本站文章均是网友提供,不完全保证技术分享内容的完整性、准确性、时效性、风险性和版权归属;如您发现该文章侵犯了您的权益,可联系我们第一时间进行删除;
5. 本站为非盈利性的个人网站,所有内容不会用来进行牟利,也不会利用任何形式的广告来间接获益,纯粹是为了广大技术爱好者提供技术内容和技术思想的分享性交流网站。

专注分享技术,共同学习,共同进步。侵权联系[81616952@qq.com]

Copyright (C)ICode9.com, All Rights Reserved.

ICode9版权所有