JVM初学 GC

前段时间领导给设定了一个方向做一下学习分享,于是便有了这个jvm初学版本,之前只顾着写代码,没怎么深入了解这块东西,所以我的学习笔记也是比较简单易懂的,当然可能还有很多漏洞,希望大家给予批评指正,以后再深入了解了也会继续后面的更新。我整理的文档是以word的形式,所以就直接贴出来了(直接从word中复制,格式有的会有问题,字太多没全部调整)。

目录

JVM

JVM是什么?

JVM的安装

JVM的内存模型

类装载器

运行时数据区

GC

年轻代:Minor GC

老年代:Major GC

全局:Full GC

JVM 调优

JVM监控

Java启动参数详情

JVM调优

总结


JVM

JVM是什么?

上述是百度百科对JVM的定义,之所以说JVM是Java虚拟机,是因为他拥有类似计算机的内存架构,还具有相应的指令系统。但本质上讲,JVM就是一个程序,运行于各个操作系统之上,他的作用就是为了运行Java程序,准确来说是字节码文件(.class)。

 

所以咱们常说Java是跨平台的语言,是因为在不同的操作系统上安装了对应的JVM,而同一份代码可以不用修改运行于各个系统的JVM上,但JVM本身是不跨平台的。

拓展:JVM并不是Java独有的,只要是能编译成.class文件的语言都可在JVM上运行,如Kotlin、Groovy、JRuby、Jython、Scala等。在idea新建文件时也可以看出来:

 

JVM的安装

我们知道Java程序运行于JVM上,所以在做Java开发之前,要把JVM安装在我们的操作系统上,但是通常我们在做Java环境准备的时候安装的不是JVM而是JDK,那可想而知,JDK是包含JVM的。

下图是官网上JDK的一个体系结构图:

简单来说就是JDK(Java Development Kit)包含JRE(Java Runtime Environment),JRE又包含JVM的关系。

拓展:JVM有很多种类,比如HotSpot VM、Apache Harmony、Micorsoft JVM、Taobao JVM等等,我们开发最常用的就是HotSpot VM,可以查看本机的JDK版本得知:

 

JVM的内存模型

经过上述对JVM的了解,我们知道JVM是用来运行字节码文件的,也就是我们的编写完.java文件后,通过javac命令编译成.class文件,剩下的事情就交给JVM了,那JVM是如何运行的呢,这就是JVM中最重要的部分——JVM的内存模型。

下图是从网上找的一个比较全面的内存模型图:

类装载器

一个类从被加载到卸载出内存,一共包含下面七个阶段:

加载、验证、准备、解析、初始化、使用、卸载

也叫类加载器(ClassLoader),要想JVM运行.class文件,首先就要将.class文件加载到JVM中,JVM提供了三层ClassLoader:

  1. Bootstrap ClassLoader: 引导类加载器(也叫根加载器)

可以看到它在我们的核心jar包(rt.jar)中的java.lang包里,它是所有加载器的根类。

2.Extension ClassLoader: 扩展类加载器

3.Application ClassLoader: 系统类加载器

4.第四种是开发人员可以继承ClassLoader,重写其findClass方法来自定义加载器

加载机制:

jvm对 class 文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的 class 文件加载到内存中生成 class 对象。而且加载某个类的class文件时,如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委托给父类的加载器去执行,一直委托到根类。如果父类加载器可以完成类加载任务,它将会将之前未加载过的类成功加载并返回,如果之前加载过,则直接返回。倘若父类加载器无法完成此加载任务,再依次将加载任务派发给子类,由子类去尝试加载。

这种加载机制叫“双亲委派机制”。下图为双亲委派机制的加载流程图:

 

这种机制有什么好处呢?

  • 避免了重复加载:

这个很好理解,每次类加载器都会询问父类“你加载了么,你能加载么?”

  • 保护程序安全,防止核心API被随意篡改

示例:我手动写了一个java.lang.String,运行却报错了:

虽然我点击进入String类,是进入了我们手写的String,但实际上在加载的时候,由于加载器的不断向上委托,最终是由引导类加载器加载的rt.jar中的String类,核心类库的String类是没有main方法的,所以报错。

这就保护了程序安全,避免核心API被篡改,这也是jvm“沙箱安全机制的”的一个重要表现。

 

运行时数据区

类加载器将class文件加载完后就进入了我们的运行时数据区,也就是JVM的内存区域,JVM的内存区域分为五块,下面来详细介绍。

方法区

简单来说,我们可以把方法区看做一个存储类的模板的内存区域(有点片面,便于理解),主要存储类信息(类名称,方法信息,),常量、静态变量、运行时常量池:

 

我们在创建一个类对象时,正是用了上述方法区中定义的模板,既然是模板,那方法区肯定是公用的,是各个线程共享的内存区域。方法区在JVM启动的时候被创建,方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误(java.lang.OutOfMemoryError : Metaspace),关闭JVM就会释放这个区域的内存。

实际上方法区是JVM 的规范,所有虚拟机必须遵守的,在上面我们也列举了多种JVM虚拟机,其中我们用的HotSpot 虚拟机基于JVM规范对方法区落地实现主要是:

  • 永久代(PermGen space):JDK8以前的
  • 元空间(Metaspace):JDK8及以后的。

元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。默认情况下,元空间的大小仅受本地内存限制,但可以通过以下参数来指定元空间的大小:

-XX:MetaspaceSize=N -XX:MaxMetaspaceSize=N

 

示例:我将元空间大小设置的很小,在运行程序时就会出现内存溢出:

 

如果不指定元空间的大小,对于一个64位的服务端JVM来说,其默认的          -XX:MetaspaceSize= 21MB,-XX:MaxMetaspaceSize=-1 即没有限制。元空间最大的大小是系统内存的大小,元空间一直扩大,虚拟机可能会消耗完所有的可用系统内存。

-XX:MetaspaceSize= 21MB,表示初始的高水位线,一旦元空间的大小触及这个高水位线,就会触发Full GC并会卸载没有用的类,然后高水位线的值将会被重置。

拓展:JDK6以后,静态变量和运行时常量池就存在了堆中,当我们不断创建静态变量时,内存溢出的位置报的是堆中:

 

程序计数器(PC寄存器)

程序计数器是一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

由于Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,所以程序计数器是线程私有的

拓展:如果线程正在执行的是一个Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Natvie 方法,这个计数器值则为空(Undefined)。

本地方法栈

顾名思义本地方法栈用于管理本地方法的调用,具体做法在Native Method Stack中登记native方法,在执行引擎(Execution Engine)执行时加载本地方法库,每个线程都可能独立的调用本地方法,所以本地方法栈也是线程私有的

而本地方法就是Native Method,通常是用C、C++等非JAVA语言实现的,用native修饰,例如Thread类中的start0,只有方法的声明,而实现是在本地库中用C语言编写的。

 

本地方法栈的开辟也主要是为了满足Java与外面的环境交互,但是目前本地方法使用的越来越少了,除非是与硬件有关的应用,比如通过Java程序驱动打印机等。因为现在的异构领域间的通信很发达,比如可以使用Socket通信,也可以使用Web Service、HTTP等等。

虚拟机栈

       虚拟机栈就是我们学习数据结构时所熟知的栈,我们经常与队列做对比:

栈:先进后出,后进先出

队列:先进先出

栈初识

我们可以看做是一个一个的方法,首先程序运行的入口是main方法,所以先把main方法压入栈内,如果main方法里面又调用了a方法,a又调用了b方法,那按照先后顺序把a方法和b方法压入栈内,这就是入栈:

 

 

方法执行完以后,方法就会被弹出栈,b方法在最里层,所以b方法是最先弹出的,这就是出栈:

 

直到main方法执行完成,这个线程就结束了,此时栈内存也就释放了,由上图我们也可以得出栈是线程私有的,他的生命周期是和线程同步的。

所以栈是不存在垃圾回收的,因为他执行完一个方法就弹出了,但是栈却存在栈溢出,最常见的就是我们的递归程序:

上图栈溢出,是因为在a方法和b方法互相调用的过程中,不断的压栈,最终达到栈的内存限度导致了栈溢出。也就是说为什么我们写的递归方法一定要设置出口,否则会导致栈溢出。

 

栈帧

上述为了便于理解,我们把方法看做一个整体模拟入栈出栈,实际上栈的组成元素有个专业名词叫——栈帧,当我们调用一个方法时,JVM为当前栈分配一个栈帧,然后将该栈帧压入栈。

一个线程中方法的调用链可能会很长,很多方法都同时处于执行状态。对于JVM执行引擎来说,在在活动线程中,只有位于JVM虚拟机栈栈顶的元素才是有效的,即称为当前栈帧,与这个栈帧相关连的方法称为当前方法,定义这个方法的类叫做当前类。

调用新的方法时,新的栈帧也会随之创建。并且随着程序控制权转移到新方法,新的栈帧成为了当前栈帧。方法返回之际,原栈帧会返回方法的执行结果给之前的栈帧(返回给方法调用者),随后虚拟机将会弹出此栈帧。

栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息:

 

局部变量表:存储方法的参数或者定义在方法体中的局部变量,一个局部变量可以保存一个类型为boolean、byte、char、short、int、float、reference和returnAddress类型的数据。reference类型表示对一个对象实例的引用。returnAddress类型是为jsr、jsr_w和ret指令服务的,目前已经很少使用了。

局部变量表的容量以变量槽(Variable Slot)为最小单位,一个槽可以存放一个32位以内的数据类型,如果64位数据类型的变量(如long或double型),则会连续使用两个连续的Slot来存储。

操作数栈:也常称为操作栈,当一个方法刚刚开始执行时,其操作数栈是空的,随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者,也就是出栈/入栈操作。一个完整的方法执行期间往往包含多个这样出栈/入栈的过程。

动态连接:一个方法要调用其他方法,需要将这些方法的符号引用转化为其在内存地址中的直接引用,而符号引用存在于运行时常量池。每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的是为了支持方法调用过程中的动态连接。

方法返回:当一个方法开始执行时,可能有两种方式退出该方法:

正常完成出口

异常完成出口

总结:简单来说栈主要存储的就是我们常说的八大基本类型,对象的引用,方法的引用。

堆概述

在运行时数据区的图上,我们把方法区和堆放在了一边,标为了红色,是因为方法区和堆是线程共享的,是每个进程唯一的,一个java程序对应一个进程,一个进程对应一个jvm实例,一个jvm实例拥有一个单例的运行时数据区,堆是java内存管理的核心区域,我们常说的垃圾回收和内存调优也基本都是针对这个区域来讲的。

《Java虚拟机规范》中对java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。从实际使用的角度看,几乎所有的对象的实例都在这里分配内存,简单说就是堆中存放的引用对象,而栈中存放的引用地址,在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。

堆内存

 

JVM内存划分为堆内存和非堆内存,其中上图的永久代就是在讲方法区中提到的JDK1.8以前的实现,在JDK1.8版本废弃了永久代,替代的是元空间(MetaSpace),元空间并不在JVM中,而是使用本地内存,这里就不过多赘述了。

根据上图可知堆内存分为年轻代和老年代,年轻代又包括新生区和幸存区:

 

之所以采用这种分区方式,是因为每个分区中GC的算法是不一样的,将对象根据存活概率进行分类,对存活时间长的对象,放到固定区,从而减少扫描垃圾时间及GC频率。针对分类进行不同的垃圾回收算法,对算法扬长避短。那下面我们就详细讲下不同的垃圾回收算法。

GC

       在C/C++程序中,程序员在内存中主动开辟一段相应的空间来存值。当程序不再需要使用该内存空间时,需要由程序员手动地释放其占用的内存。但是这样显然非常繁琐,如果有所遗漏,就可能造成资源浪费甚至内存泄露。

有了GC(Garbage Collection),程序员就不需要再手动的去控制内存的释放。当JVM发觉内存资源紧张的时候,就会自动地去清理无用对象(没有被引用到的对象)所占用的内存空间,其主要作用空间就是堆。如果需要,可以在程序中显式地使用System.gc()或者Runtime.getRuntime().gc()来强制进行一次立即的内存清理。

GC采用分代收集算法:

年轻代:Minor GC

Minor GC指新生代GC,即发生在新生代(包括Eden区和Survivor区)的垃圾回收操作,当新生代无法为新生对象分配内存空间的时候,会触发Minor GC。因为新生代中大多数对象的生命周期都很短,所以发生Minor GC的频率很高,虽然它会触发stop-the-world,但是它的回收速度很快。

新生成的对象首先放到年轻代Eden区,当Eden空间满了,触发Minor GC进行垃圾回收,Minor GC是轻GC,采用的是复制算法,这也是年轻代要分为Eden和两个Survivor区的原因。

Minor GC原理

Minor GC会把Eden中的所有活的对象都移到Survivor区域中,如果Survivor区中放不下,那么剩下的活的对象就被移到Old Gen中,也即一旦收集后,Eden是就变成空的了。

Minor GC算法分析

因为年轻代中的对象基本都是朝生夕死的(80%以上),所以在年轻代的垃圾回收算法使用的是复制算法(Copying),复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。复制算法不会产生内存碎片。

 

HotSpot JVM把年轻代分为了三部分:1个Eden区和2个Survivor区(分别叫from和to),默认比例为8:1:1,默认参数-XX:SurvivorRatio=8。(可通过jinfo -flag SurvivorRatio +进程pid查看参数)

 

一般情况下,新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区的to区,此时form区和to区将会互换角色,也就是新的to就是上次GC前的from,新的from就是上次GC前的to。当下次Eden空间满时,触发新一轮的GC,此时Eden和form区存活的对象会被复制的to区,然后form和to再次交换。不管怎样,都会保证名为to的Survivor区域是空的。

对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时(默认是15岁,通过-XX:MaxTenuringThreshold来设定参数),就会被移动到年老代中。Minor GC会一直重复这样的过程,直到to区被填满,to区被填满之后,会将所有对象移动到年老代中。

复制算法的优势显而易见就是效率高,没有内存碎片,但是缺点也显而易见,它浪费了一半的内存用来存放那些不断多次GC下存活的对象,所以对象的存活率要非常低才能适用,如果对象经历了多次GC还仍存活那就应该适用老年代的GC了。

注意事项

★ 在Eden区满了的时候,才会触发Minor GC,而幸存者区满了后,不会触发Minor GC操作。

★ 如果Survivor区满了后,将会触发一些特殊的规则,也就是可能直接晋升老年代。

老年代:Major GC

       Major GC用于回收老年代,出现Major GC通常会出现至少一次Minor GC。

年轻代和老年代的内存比例是1:2,默认参数-XX:NewRatio=2,表示年轻代占1,老年代占2。

随着Minor GC的持续进行,老年代中对象也会持续增长,导致老年代的空间也会不够用,最终会执行Major GC。

Major GC算法分析

Major GC采用的是标记-清除或者是标记-清除与标记-整理的混合实现。

标记-清除

 

标记:从引用根节点开始标记所有被引用的对象。标记的过程其实就是遍历所有的GC Roots,然后将所有GC Roots可达的对象 标记为存活的对象。

清除:遍历整个堆,把未标记的对象清除。

用通俗的话解释一下标记-清除算法,随着Minor GC的持续进行,老年代中对象也会持续增长,导致老年代的空间也会不够用,Major GC 线程就会被触发并将程序暂停,随后将依旧存活的对象标记一遍,最终再将堆中所有没被标记的对象全部清除掉,接下来便让程序恢复运行。

缺点:此算法暂停程序且耗时较长会造成卡顿,会产生内存碎片

标记-整理

 

也叫标记-压缩,在整理压缩阶段,不再对标记的对象做回收,而是通过所有存活对象都向一端移动,然后直接清除边界以外的内存。就是在标记-清除的基础上多了一步整理的操作,当然效率也会降低,但是可以弥补标记-清除算法当中,内存区域分散的缺点,处理了内存碎片,也消除了复制算法当中,内存减半的高额代价。

 

标记-清除-整理

标记-清除和标记压缩的结合使用,可以多次标记清除后进行一次压缩,提高效率。

全局:Full GC

Full GC是针对整个新生代、老生代、元空间的全局范围的GC,Major GC 在很多参考资料中是等价于 Full GC的。Full GC会对整个堆进行整理,因此比较慢。

触发Full GC的原因有很多:

  • 当年轻代晋升到⽼年代的对象⼤⼩,并⽐⽬前⽼年代剩余的空间⼤⼩还要⼤时,会触发Full GC;
  • 当⽼年代的空间使⽤率超过某阈值时,会触发Full GC;
  • 当元空间不⾜时(JDK1.7永久代不足),也会触发Full GC;
  • 当调⽤System.gc()也会安排⼀次Full GC。

JVM 调优

JVM监控

JDK自带的几款在线监控工具

这是通过JVM命令行来进行jvm的监控:

JPS

使用jps可以查看正在运行的Java进程ID,jps查询出的Java进程ID和操作系统的进程ID一致。

参数解释:

内容

说明

-q

仅显示LVMID(local virtual machine id),即本地虚拟机唯一id,不显示主类的名称

-l

输出主类的全类名,如果执行的是jar包,则输出jar包的完整名称

-m

输出虚拟机进程启动时传递给main()的参数

-v

列出虚拟机进程启动时的JVM参数

举例:

//输出主类的全类名,以及pid和Java进程ID对比

JVM参数:

这里我们可以看到java启动时的jvm参数,下面会具体详解。

Jstat

主要用于查看各个功能和区域的统计信息(如:类加载、编译相关信息统计,各个内存区域GC概况和统计),命令格式:jstat [选项] [进程ID] [间隔时间 ] [查询次数]。

jstat 命令选项:

举例:

//查看8145进程应用的堆内存使用、垃圾回收统计信息,每隔1000毫秒输出一次,总共输出5次

指令: jstat  -gc  8145  1000  5

S0C 和 S0U     //S0区的总内存大小和已使用的内存大小。

S1C 和S1U     //S1区的总内存大小和已使用的内存大小。

EC 和 EU       //Eden区的总内存大小 和已使用的内存大小。

OC和OU       //Old区的总内存大小 和已使用的内存大小。

MC和MU      //方法区的总内存大小 和已使用的内存大小。

CCSC和CCSU  //压缩类空间大小 和已使用的内存大小。

YGC和 YGCT   //Young GC 的总次数 和消耗总时间。

FGC和 FGCT   //Full Gc的总次数和消耗总时间。

GCT           //所有GC的消耗时间。

Jinfo

用于查看和调整JVM启动和运行参数。

举例:

//输出8145进程jvm的全部参数和系统属性

指令 :jinfo 8145

//查看老年代内存大小

指令:jinfo -flag OldSize 8145

//开启堆内存溢出日志打印(默认是关闭的)。

指令:jinfo -flag +PrintGCDetails 8145

还有修改某个参数值,这里就不一一列举了。

Jmap

用于监控堆内存使用情况和对象占用情况, 生成堆内存快照文件,查看堆内存区域配置信息。

Jmap命令选项:

举例:

//查看堆内存的配置和使用情况

指令 :jmap -heap 8145

//统计实例最多的类 前十位有哪些

指令 :jmap -histo 8145 | sort -n -r -k 2 | head -10 

//统计合计容量前十的类有哪些

指令 :jmap -histo 8145 | sort -n -r -k 3 | head -10

//dump 堆快照

指令 :jmap -dump:live,format=b,file=/myheapdump.hprof 8145

       live    加上live代表只dump存活的对象

 fomat  格式

 filie    导出的文件名

 8145   java进程ID

这里生成的 dump文件可以用我们后面讲的可视化工具VisualVM来打开文件对里面的内容进行分析。

Jstack

用于查看JVM线程信息 和生成线程快照。

参数解释:

选项

说明

-F

当 jstack [-l] pid 没有响应时,强制打印一个堆栈转储

-l

除堆栈外,显示关于锁的附加信息

-m

如果调用到本地方法的话,可显示C/C++的堆栈

举例:

//打印堆栈线程信息 ,输出到文件

指令 :jstack -l 8145 >jstack.prof

JVM离线分析工具

       JVM自带的离线分析工具VisualVM 不需要额外安装,我们安装JDK的时候就自带了VisualVM,在安装JDK的 bin目录下可以找到jvisualvm.exe:

 

打开界面是这样的:

 

VisualVM分析dump文件

因为我们通常都是没办法直接在生产环境进行调优分析的,所以一般都会把相关的内存、线程的dump文件拿到自己的电脑进行分析,VisualVM 支持导入dump文件的方式。

首先我们已经利用Jmap将dump文件导出,接下来将文件导入到VisualVM中:

点击 文件->装入 选择我们导出的dump文件。

 

 

在概要信息里面我们可以查看JVM环境配置、JDK版本,应用基本信息:

在类信息里面主要关注的是对应类型的对象 在内存中的实例对象实例树 、总占用空间大小分别是多少,如果是因为产生大对象、或者突然产生大批量的对象则可以通过这里定位到问题:

 

VisualVM本地监控

       我们启动一个本地项目,打开VisualVM 就可以从左侧栏目里看到本机的应用,点击对应的应用就可以看到对应的内存、线程、GC信息。

 

Java启动参数详情

java启动参数可以分类三类,在上述jps -v命令时我们也可以看到java的这几类启动参数,在前面提到元空间GC时也分别提到过部分参数。

标准参数(-)

所有的JVM实现都必须实现这些参数的功能,而且向后兼容,该参数在程序中任何位置都可以访问到,优先级最高。我们可以通过 -help 命令来检索出所有标准参数。

非标准参数(-X)

非标准参数又称为扩展参数,默认JVM实现这些参数的功能,但是并不保证所有JVM实现都满足,且不保证向后兼容。这类参数一般是用到最多的,例如:

-Xms :初始堆大小

-Xmx :最大堆大小。主要用于JVM调优和debug。

 

非Stable参数(-XX)

此类参数各个JVM实现会有所不同,将来可能会随时取消,需要慎重使用(但是,这些参数往往是非常有用的)。通过命令java -XX:+PrintFlagsFinal 查看。因为参数太多,只截取一小部分。

 

 

一些常用的调优参数:

参数及其默认值

描述

-XX:MaxNewSize=size

新生成对象能占用内存的最大值

-XX:MaxPermSize=64m

老生代对象能占用内存的最大值

-XX:NewRatio=2

新生代内存容量与老生代内存容量的比例

-XX:NewSize=2.125m

新生代对象生成时占用内存的默认值

 

JVM调优

性能定义

上面两部分我们知道了如何监控jvm,也知道了jvm的一些常用参数,而jvm调优其实就是针对我们监控的内容对部分参数进行调整设置,以让jvm达到性能最优,jvm性能优不优从我们的直观感受来看,就是程序的运行速度快,延时低,无卡顿。

专业角度主要分为以下三个调优指标:

吞吐量:运行代码的时间占程序总运行时间(总运行时间=程序的运行时间+内存回收的时间);

暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间;

内存占用:java堆区所占的内存大小;

这三者共同构成一个“不可能三角”。时间和空间不能同时满足,所以现在的调优标准是在最大吞吐量优先的情况下,降低停顿时间

调优分析

程序的总运行时间=程序的运行时间+内存回收的时间,我们知道GC进行时会暂停程序,所以我们的主要的目的是减小GC的频率和Full GC(回收更慢)的次数。简单来说就是:

1.GC的时间足够的小

2.GC的次数足够的少

3.发生Full GC的周期足够的长

但是实际上以上前两点是有点冲突的,要想GC时间小必须要一个更小的堆,要保证GC次数足够少,必须保证一个更大的堆,我们只能取其平衡。

常见调优参数

设置堆内存大小
-Xms:启动JVM时的堆内存空间。
-Xmx:堆内存最大限制。
-Xmn:设置年轻代大小,Sun官方推荐配置为整个堆的3/8。
-XX:PermSize=128M:设置持久代大小。
-XX:MaxPermSize=128M:设置持久代最大值。
设置新生代大小
-XX:NewRatio:新生代和老年代的占比。
-XX:NewSize:新生代空间。
-XX:SurvivorRatio:伊甸园空间和幸存者空间的占比。
-XX:MaxTenuringThreshold:对象进入老年代的年龄阈值。
设定垃圾回收器
-XX:+UseSerialGC:开启串行收集器。
-XX:+UseParallelGC:开启年轻代并行收集器,JDK5.0以上,JVM会根据系统配置自行设置。
-XX:+UseParallelOldGC:开启老年代并行收集器。
-XX:+UseConcMarkSweepGC:开启老年代并发收集器(简称CMS)。 
-XX:CMSInitiatingOccupancyFraction=70:老年代内存使用比例到多少激活CMS收集器。
-XX:+UseCMSCompactAtFullCollection:使用并发收集器时,开启对年老代的压缩。
其他
-Xss:设置每个线程的堆栈大小。
-XX:MaxTenuringThreshold=15:设置垃圾最大年龄。
-XX:ParallelGCThreads=xx:设置并行垃圾回收的线程数。
-XX:MaxGCPauseMillis=xx:指定垃圾回收时的最长暂停时间,单位毫秒。
-XX:GCTimeRatio=xx:设定吞吐量为垃圾回收时间与非垃圾回收时间的比值。 
-XX:+UseAdaptiveSizePolicy:设置此选项后,并行收集器会自动选择年轻代区大小和相应的Survivor区比例,以达到目标系统规定的最低相应时间或者收集频率等,此值建议使用并行收集器时,一直打开。
-XX:+DisableExplicitGC:禁止 java 程序中的 full gc, 如System.gc() 的调用. 最好加上, 防止程序在代码里误用了对性能造成冲击。
-XX:+PrintGCDetails:打印垃圾收集的情况。
-XX:+PrintGCTimeStamps:打印垃圾收集的情况。
-XX:+PrintGCApplicationConcurrentTime:打印每次垃圾回收前,程序未中断的执行时间。可与上面混合使用。
-XX:+PrintGCApplicationStoppedTime:打印垃圾收集时 , 系统的停顿时间。
-XX:+PrintGC:打印GC情况。
-XX:PrintHeapAtGC:打印GC前后的详细堆栈信息。

常见调优策略

在学习GC时我们知道,Minor GC效率高于Major GC高于Full GC,所以主要从以下几个方面考虑调优:

设定堆内存的大小

这是最基本的调优手段,-Xms初始堆大小-Xmx最大堆大小,一般情况下这两个值设为相同大小。

举例:

手写一个死循环 设置一个较小的堆内存 并打印GC信息

设置jvm参数:-Xms5M -Xmx5M -XX:+PrintGCDetails

设置位置:

 

 

咱们虚机上项目的启动参数一般在shell脚本中

 

接下来,运行上述程序:

明显可以看到GC的回收过程,因为我们用-XX:+PrintGCDetails打印了GC信息,不过这个报错不是我们常见的Heap溢出,而是GC overhead limit exceeded原因是因为我们设置的堆内存太小,Sun 官⽅对此的定义:超过98%的时间⽤来做GC并且回收了不到2%的堆内存时会抛出此异常。

 

 

 我们稍微调大Xms和Xmx

 

再运行上述程序,就是我们常见的堆溢出了

 

一般我们在条件允许的情况下,适当调大堆内存会有效处理程序性能问题。

拓展:

JVM初始分配的内存,由-Xms指定,默认是物理内存的1/64;JVM最大分配的内存由-Xmx指定,默认是物理内存的1/4。默认空余堆内存小于40%时,JVM就会增大堆,直到-Xmx的最大限制;空余堆内存大于70%时,JVM会减少堆,直到-Xms的最小限制。因此服务器一般设置-Xms与-Xmx相等,以避免在每次GC 后调整堆的大小发生内存抖动现象。

当然上述设置会获得一个稳定的堆,但同时也增加了每次GC的时间。让堆大小在一个区间中震荡,在系统不需要使用大内存时,压缩堆空间,使 GC 应对一个较小的堆,可以加快单次 GC 的速度。基于这样的考虑,JVM 还提供了两个参数用于压缩和扩展堆空间:

参数-XX:MinHeapFreeRatio用来设置堆空间最小空闲比例,默认值是 40。当堆空间的空闲内存小于这个数值时,JVM 便会扩展堆空间。

参数-XX:MaxHeapFreeRatio用来设置堆空间最大空闲比例,默认值是 70。当堆空间的空闲内存大于这个数值时,便会压缩堆空间,得到一个较小的堆。

减少线程栈的大小

-Xss是对每个线程stack大小的调整。减少线程栈的大小,这样可以使剩余的系统内存支持更多的线程,调整这个参数也直接影响对方法的调用次数。

举例:

写一个递归方法,查看递归深度

设置-Xss参数:-Xss105k 

递归977后发生栈溢出

 

增大-Xss:-Xss305k

 

将新对象预留在年轻代

虽然在大部分情况下,JVM 会尝试在 Eden 区分配对象,但是由于空间紧张等问题,很可能不得不将部分年轻对象提前向老年代压缩。因此,在 JVM 参数调优时可以为应用程序分配一个合理的年轻代空间,以最大限度避免新对象直接进入老年代的情况发生。

一般来说,Survivor 区的空间不够,或者占用量达到 50%时,就会使对象进入老年代(不管它的年龄有多大),我们可以尝试加上-XX:TargetSurvivorRatio=90参数,这样可以提高 from 区的利用率,使 from 区使用到 90%时,再将对象送入老年代。

让大对象进入老年代

我们在大部分情况下都会选择将对象分配在年轻代。但是,对于占用内存较多的大对象而言,它的选择可能就不是这样的。因为大对象出现在年轻代很可能扰乱年轻代 GC,并破坏年轻代原有的对象结构。因为尝试在年轻代分配大对象,很可能导致空间不足,为了有足够的空间容纳大对象,JVM 不得不将年轻代中的年轻对象挪到老年代。因为大对象占用空间多,所以可能需要移动大量小的年轻对象进入老年代,这对 GC 相当不利。

可以使用参数-XX:PetenureSizeThreshold设置大对象直接进入年老代的阈值。当对象的大小超过这个值时,将直接在老年代分配。

拓展:如果一个大对象同时又是一个短命的对象,假设这种情况出现很频繁,那对于 GC 来说会是一场灾难。原本应该用于存放永久对象的老年代,被短命的对象塞满,这也意味着对堆空间进行了洗牌,扰乱了分代内存回收的基本思路。因此,在软件开发过程中,应该尽可能避免使用短命的大对象。

设置对象进入老年代的年龄

       在上述讲解Minor GC的时候我们提到对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁,当对象年龄达到阈值时,就移入老年代,成为老年对象。

       这个阈值的最大值可以通过参数-XX:MaxTenuringThreshold来设置,默认值是 15。可以通过提高阈值让对象尽可能地保存在年轻代区域。

设置垃圾回收器参数

除了对jvm内存的参数设置以外,还可以通过设置GC相关的参数来调整GC的效率,比如参数–XX:ParallelGCThreads表示垃圾回收的线程数,可以提高该数来提高垃圾回收的效率;年轻代开启并行回收器:–XX:+UseParNewGC;年老代使用 CMS 收集器降低停顿–XX:+UseConcMarkSweepGC等等,这块用到的比较少,就不一一赘述了。

总结

       有句话这么说“JVM调优应该是Java性能优化的最后一颗子弹”,我们一般的Java项目JVM调优不是常规手段,性能问题一般第一选择是优化程序,最后的选择才是进行JVM调优,而常用的调优手段还是我们上面提到的第一条,-Xms和-Xmx。

       但是我们可以利用上面提到的多种JVM监控工具,来监控我们的程序,相应的去优化我们的代码,比如我们检测到创建的实例过多时,考虑是不是循环嵌套太多了;监控到程序时常触发Full GC,考虑是不是代码中创建过多大对象了;出现栈溢出,考虑递归方法是否没设置出口等等。。。所以在更深入的了解了JVM以后,我们在编写代码的时候也会更加的注重这方面的规范。

 

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值