文章目录
为什么学习JVM
- JVM是Java的运行环境,优点是一次编译,到处运行。这是因为JVM是运行在操作系统上的,无论在什么操作系统都可以执行,所以常说Java是跨平台性的。
- 学习JVM能更深入的理解Java这门语言,理解Java语言底层代码的执行过程,为后期写出优质代码做好准备。比如很多时候一个问题需要深入字节码层次去分析才能得到准确的结论,字节码就是JVM的一部分。并且项目上线去排查一些程序log日志中无法呈现的问题,如:内存溢出等。
- 相较于C/C++Java不需要手动的去进行垃圾回收,但是正因为Java将内存控制交给JVM,一旦出现内存泄漏和溢出方面的问题,如果不了解JVM,是很难进行排查的。
JVM的执行流程
程序在执行前先要把Java代码转换成字节码(.class)文件,JVM需要将字节码文件通过一定方式的类加载器(ClassLoader)把文件加载到内存的运行时数据区(Runtime Data Area),而字节码文件是JVM的一套指令集规范,并不能直接由底层操作系统区执行,因此需要特定的命令解析器**执行引擎(Execution Engine)将字节码翻译成底层系统指令再交给CPU去执行,这个过程中需要调用其他语言的接口本地库接口(Native Interface)**来实现整个程序的功能。
JVM的组成部分
类加载、运行时数据区(内存区域)、本地方法接口、执行引擎。
类加载
-
加载:读取字节码文件,转换并存储,为每个类创建一个class类对象并存储在方法区中。
-
链接:
- 验证:检查被加载的类内部结构是否正确,对字节码文件格式进行验证,判断文件是否污染并对基本语法格式验证。
- 准备:为静态的变量分配内存,并设置默认初始值,不包含使用final修饰的static常量。
- 解析:将符号引用(方法名)转化为直接引用(使用指针指向地址),将字节码中的表现形式转为内存中的表现形式。
-
初始化:类的初始化,为类中定义的静态变量进行赋值。
-
类加载器分类:引导(启动)类加载器(C+)、扩展类加载器、应用程序类加载器(默认)、自定义类加载器。
-
双亲委派机制及其打破:如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,如果父加载器还存在其父加载器,则继续向上委托,最终将到达顶层的启动类加载器,如果父类加载器可以完成类的加载任务,就成功返回,若无法完成加载任务,子加载器才会尝试自己去加载。如果都加载失败,则抛出异常ClassNotFoundException。 目的:为了确保加载系统类。优点:安全,可以避免用户自己编写的类替换Java的核心类库,并避免类重复加载。 打破: 通过集成ClassLoader类,重写loaclClass/findClass方法,实现自定义类加载。Tomcat就是自定义的类加载。
运行时数据区
- 程序计数器:线程私有的,内部保存的是字节码的行号,用于记录正在执行的字节码指令的地址。
- 字节码的行号:Java代码运行时,编译后的字节码文件是一行一行执行的,PC计数器就是记录当前线程执行的行号的,目的是其他线程抢占该线程后,下次接着之前执行的位置执行。
- Java虚拟机栈:线程私有的,随着线程创建而创建,随着线程销毁而死亡。每个线程在运行时所需要的内存就是虚拟机栈。每个栈都是由一个个栈帧组成,对应的每次方法调用占用的内存。每个栈帧:局部变量表,操作数栈,动态链接,方法返回地址。每个线程中只能有一个活动栈帧,对应着当前正在执行的那个方法。
- 本地方法栈:本地方法栈和Java虚拟机栈发挥的作用相似,区别在于Java虚拟机栈为虚拟机执行Java方法服务,而本地方法栈则为虚拟机使用到的Native方法服务,也就是执行native()方法,这些方法是C、C++写的。
- 堆:线程共享区域,主要保存对象的实例、数组等。
- 方法区:共享的内存区域,主要用来存储类信息、即时编译器编译后的信息以及运行时常量池。JVM启动时创建,关闭JVM释放。
- 常量池:是一张表,主要存储的是要执行的类名、方法名、参数类型、字面量等信息,JVM根据指令会在这张表中进行查找。
- 运行时常量池:常量池是.class文件中的,当类被加载时,它的常量池信息会放入运行时常量池,并将里面的符号地址变为真实地址(#1 #2 之类的)。
本地方法接口
简单地讲,一个 Native Method 是一个Java调用非Java代码的接囗。一个 Native Method 是这样一个Java方法:该方法的实现由非Java语言实现,比如C。特点:用native关键字修饰的方法称为一个本地方法,没有方法体。
为什么使用:因为Java在有些层次的任务使用Java实现起来不容易,Java语言需要与外部环境进行交互,直接访问操作系统接口即可,JVM本身开发也是在底层使用了C语言。
执行引擎
- 解释器:解释器有两种 ,一种是古老的字节码解释器:在执行时通过纯软件代码翻译字节码的执行,效率非常低下。另一种现在普遍使用的模板解释器:将每一条字节码和一个模板函数相关联,模板函数中直接产生这条字节码执行时的机器码,提高了解释器的性能。
- JIT即时编译器:可以将整个函数体编译成机器码,有效的避免函数体被解释执行,在重复执行时直接执行编译后的机器码即可,大大提升了执行效率。通俗的说就是如果遇到经常执行的字节码指令,只要执行过一次,将一些频繁执行的热点代码进行编译,并缓存到方法区中,后续再来执行就不需要翻译,可以直接取出对应的机器指令,性能更快,提高了执行效率。
垃圾回收
什么样的对象是垃圾呢
Java中的垃圾对象是指没有被任何引用变量所引用的对象。这些对象无法被访问,也无法被使用,因此它们占用内存空间而不被程序所使用,成为垃圾对象
内存溢出和内存泄漏
内存溢出指的是程序在申请内存时,由于没有足够的内存可用,而导致程序崩溃或者出现其他异常情况的现象。这通常是因为程序错误地使用了内存,例如未及时释放不需要的内存或者使用了太多内存资源,导致系统无法提供足够的内存来满足应用程序的需求。
内存泄漏指的是程序中存在一些对象或变量没有被垃圾回收器及时回收,导致这些对象一直占用着内存空间并最终耗尽可用内存的现象。通常是因为程序中存在不合理的设计或编码问题,例如忘记释放动态分配的内存、使用循环引用等等。还有就是打开了使用对象的东西,但是没有关闭,导致垃圾处理时认为对象处于运行状态,不会被回收处理,IO流close和jdbc链接close没有关闭。
两者区别在于,内存泄漏是程序代码中存在的开发问题,内存溢出则是由于系统资源有限造成的结果。需要解决内存泄漏问题,通常需要审查代码并进行调试,而需要解决内存溢出问题,则需要考虑优化应用程序,增加可用内存资源,并可能需要进行代码重新设计,以便更有效地使用和释放内存。
定位垃圾的方法
-
引用计数法:当一个对象被引用了一次,就在当前对象头上递增一次引用次数,如果这个对象的引用次数为0,代表这个对象可回收。比如创建一个demo对象,在JVM内存中会在栈中存储一个变量然后指向在堆中开辟对的一块空间来存储这个对象,引用计数法会给堆中的对象添加一个引用的参数ref=1,当demo=null,此时栈中的变量不会指向内存中的对象,ref变为0。引用计数法原理简单,效率也很高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,主要原因是引用计数就很难解决对象之间相互循环引用的问题。当相互引用时ref次数增加了两次,此时如果demo=null,ref就会变为1,不会被识别为垃圾,这就是循环引用,会引发内存泄漏。
-
根搜索算法:目前的虚拟机都是通过可达性分析算法来确定哪些内容是垃圾,核心思想是沿着GC Root对象,遍历寻找关联着的对象就不是垃圾对象,扫描过程中,不能GC Root访问到该对象的就是可以被回收的。
-
GC Root可以是:虚拟机栈(栈帧中的本地变量表)中引用的对象。方法区中类静态属性引用的对象。方法区中常量引用的对象。本地方法栈中JNI(Native方法)引用的对象。
对象的finalization机制
对象的 finalization 机制是一种内存管理模式,它允许程序在对象被垃圾回收之前执行特定的清理和释放操作。在Java中,finalize() 方法是用于实现对象的 finalization 机制的。当一个对象变为垃圾之前,JVM会在内部自动调用其 finalize() 方法(如果该对象的 finalize() 方法未被重写,则不会执行任何操作),并在 finalize() 方法执行结束之后回收该对象。开发人员可以在 finalize() 方法中编写释放资源、关闭打开的文件、清除临时数据等操作,以便程序尽快回收不再使用的内存空间。
垃圾回收算法
- 标记清除: 使用GC Root标记处存活的对象,清除没有标记的对象。优点:标记和清除速度快。缺点:内存碎片化严重,内存不连贯。
- 标记复制: 将内存区域分为两块,当使用GC Root标记出存活的对象,将这些对象复制到另外一块之前清空的区域中。优点:当垃圾对象多的时候效率高,清理后内存没有碎片。缺点:需要两块内存空间,同一时刻只能使用一块空间,内存使用率较低。
- 标记整理:使用GC Root标记出存活的对象,清除没有标记的对象,将标记存活的对象向一端移动,避免了内存碎片化,但是由于移动,相较于标记清除性能是有一定影响。
分代回收
-
MinorGC(young GC):发生在新生代的垃圾回收,SWT时间短。
-
MixedGC:新生代+老年代垃圾部分区域垃圾回收,G1收集器特有。
-
FullGC:新生代+老年代完整垃圾回收,STW时间长,应尽量避免。
-
SWT(Stop The World):暂停所有应用程序线程,等待垃圾回收的完成。
垃圾回收器
- 串行垃圾回收器: Serial和SerialOld,单线程垃圾回收,堆内存较小。Serial作用于新生代,采用标记复制算法。SerialOld作用于老年代,采用标记整理算法。工作原理:垃圾回收时只有一个线程在工作,并且需要SWT。
- 并行垃圾回收器:Paraller New和Paraller Old,并行垃圾回收器。Paraller New作用于新生代,采用标记复制算法。Paraller Old作用于老年代,采用标记整理算法。这个垃圾回收器是JKD8中默认使用的,工作原理是垃圾回收时多个线程工作,Java应用中所有线程SWT。
- CMS(并发)垃圾回收器:主要是针对老年代的垃圾回收器,并发执行的,使用标记清除的垃圾回收器,是一款以获取最短停顿时间为目标的收集器,停顿时间短用户体验是比较良好的,最大的特点是在进行垃圾回收时,应用仍能正常运行。主要过程:
- 初始标记(SWT):标记直接与GC Root关联的对象 。
- 并发标记:标记与GC Root间接关联的对象。
- 重新标记:防止之前标记时有的垃圾被关联,漏标。
- 并发清理
- G1垃圾回收器:和其他垃圾回收器不同的是G1垃圾回收器是将堆区域分为多个区域,每个区域都可以充当eden、survivor、old、humongous(为大对象准备),采用的是标记复制算法进行垃圾回收。特点是响应时间与吞吐量兼顾,垃圾回收主要分为三个阶段新生代回收、并发标记、混合收集。如果回收的速度赶不上创建新对象的速度就会触发Full GC。
- 新生代垃圾回收:新生代的内存区域一般在G1堆中分配5%-6%,如果达到这个区间就会触发垃圾回收,使用标记复制算法将存活的对象复制到幸存者区中(挑出一个空闲区域),需要暂停用户线程。有新对象创建会将一块区域创建为eden区进行存储,之后进行垃圾回收时会将eden和幸存者区中存活的对象复制到另一个区域(幸存者区),超过15次的对象会复制到创建的老年区中。
- 并发标记:当老年代占总堆内存超过45%就会触发并发标记,并发标记就是将老年代中所有的存活对象标记出来,这个过程是并发的,无需暂停用户线程。
- 混合收集:在并发标记之后,会有一个重新标记阶段,用来解决标记阶段的漏标问题,此时需要swt。在回收老年代时,并不是一次将所有的老年代区域进行垃圾回收,而是有一个人为设置的预期的暂停时间,根据这个暂停时间优先回收价值高的区域(标记期间存活对象少,这个也是G1名称的由来),将这些回收价值高的老年代以及伊甸园,幸存者区一同进行一次垃圾回收,这就是混合收集,然后将伊甸园区和幸存者区中存活的对象放入新创建的幸存者区中,将老年代中存活的对象放入新创建的老年区中。
JVM调优参数
- 堆空间大小:-Xms -Xmx : 设置堆的初始大小和最大大小,为了防止垃圾收集器在初始大小和最大大小之间收缩堆,而产生额外的时间,通常将最大和初始大小设置为相同的值,不指定的话默认单位是字节。
- 堆空间设置多少合适:一般最大大小默认为物理内存的1/4,初始大小是物理内存的1/64,堆太小的话,可能会频繁导致GC,会产生stw,暂停用户线程,对空间大肯定好,但是也有风险,假如发生Full GC扫描整堆空间,暂停用户进程时间较长。
- 虚拟机栈的设置:-Xss : 默认值为1M,栈中一般存放栈帧,调用参数、局部变量表等,每个线程都会创建虚拟机栈,如果设置太大会导致线程数量减少,如果太小会导致栈内存溢出,一般建议设置256K或512K。
- 年轻代和老年代大小比例:-XXSurvivorRatio=8 表示survivor:eden=2:8,这是默认的比例,我们也可以设置增大eden区的大小,用来减少YGC发生的次数,但是虽然减少了,但是eden区满时占用空间大,导致释放缓慢,此时STW时间较长,因此还是需要根据程序情况去调优。
- 年轻代晋升老年代阈值:-XX:MaxTenuringThreshold=threshold 默认15,取值范围0-15
- 设置垃圾回收器:-XX:+useParallerGC,-XX:+useParalloldGC,-XX:+useG1GC
可以通过增大吞吐量来提高系统性能,可以通过这个设置并行垃圾回收器。
JVM调优工具
命令工具:jps查看进程状态、jstack查看进程内线程的堆栈信息、jmap查看堆转信息、jhat堆转储快照分析工具、jstatJVM统计检测工具。可视化工具:jconsole用于对JVM的内存线程,类的监控、
VisualVM能够监控线程内存情况。
-
命令工具: jmap:通过jmap =heap pid 显示Java堆的信息
jmap -dump:format=b,file=heap hprof pid,fomat=b表示以hprof=进制格式转Java堆的内存
file= 用于指定快照dump文件的文件名
使用以上命令生成一个进程或系统在某一时间的快照,比如在进程崩溃时甚至是任何时候,我们都可以通过工具将系统或进程的内存备份出来供调试分析使用。dump文件中包含了程序进行的模块信息,线程信息,堆栈调用信息,异常信息等数据,方便系统技术人员进行错误排查。jstat: jstat -gcutil pid 总结垃圾回收统计
jstat -gc pid 垃圾回收统计 -
jconsole: 通过java/bin/jconsole.ext可以直接打开线程信息。
VisualVM:目前只有1.8中有,高版本没有,通过java/bin/jvisualvm.exe打开。
Java内存泄漏排查思路
-
获取堆内存快照dump。
- 使用jmap命令获取运行中程序的dump文件,有的情况是内存一处之后程序中断了但是jmap只能打印运行中的程序,所以可以通过使用Vm参数获取dump文件。
- 使用VisualVM可以加载离线的dump文件。
-
VisualVM去分析dump文件。
-
通过查看堆信息的情况去定位内存溢出的问题。
-
找到对应代码,通过阅读上下文情况,进行修复即可。
CPU飙高排查方案与思路
- 使用top命令查看哪一个命令占用CPU较高,可以拿到相应的pid。
- 使用ps H =eo pid,tid,%cpu | grep 进程pid 可以找到进程中所有线程的信息。
- 使用jstack 进程id 打印当前进程的所有线程信息,将刚才进程的线程id转换为16进制的线程id(打印的线程信息的id是16进制的),然后根据相应的线程id,定位到问题代码的代码行。