JVM学习笔记概念篇

猪小花1号2018-09-17 12:51

作者:牛小宝


1. Java内存管理

1.1 Java运行时的内存区域

Java的内存区域包括线程私有区域和线程共享区域两种,其中线程私有区域有:程序计数器,虚拟机栈和本地方法栈,这些内存区域是每个线程独立拥有,并与线程具有同样的生命周期。线程共享区包括:堆区和方法区,线程共享区会在JVM初始运行时就分配好。

1程序计数器

可以看成当前线程所执行字节码的行号指示器。java的多线程是通过JVM切换时间片运行的,也就是说在每个时刻只能有一个线程在运行,因此每个线程在某个时刻可能在运行也可能被挂起,那么为了线程切换后能恢复到正确的位置,就需要有独立的计数器,各线程之间的计数器互不影响;由于程序计数器只是记录当前指令地址,所以不存在内存溢出的情况,因此,程序计数器也是所有JVM内存区域中唯一一个没有定义OutOfMemoryError的区域。

2虚拟机栈:

虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧用户存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用至执行完成的过程,就对应着一个栈帧在虚拟机中入栈到出栈的过程。

Java虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展,如果扩展时无法申请的足够大 内存,就会抛出OOM异常;

3本地方法栈:

与虚拟机栈所发挥的作用类似,他们之间的区别不过是虚拟机栈为虚拟机执行java方法服务,而本地方法栈则为虚拟机使用到的Native方法服务。在很多虚拟机中(如SunJDK默认的HotSpot虚拟机),会将本地方法栈与虚拟机栈放在一起使用。

4Java堆:

又称GC堆,是垃圾收集器管理的主要区域,在虚拟机启动的时创建。此内存区域的唯一目的就是用来存放对象实例,几乎所有的对象实例都是在这里分配内存(并非绝对,现在也出现栈上分配、标量替换等优化技术)。Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,在实现时既可以是固定大小,也可以是可扩展的,当前主流的虚拟机都是按照可扩展来实现的,通过-Xms-Xmx控制。如果在堆中内有内存完成实例分配,并且堆也无法再拓展时,会抛出OOM异常;

5方法区:

用于存储已被虚拟机加载的类信息,常量,静态变量,即时编译后的代码等数据,方法区并不等价于永久代,只是使用永久代来实现方法区,方法区的限制很宽松既可以像堆一样具有不连续的内存,还可以选择不实现垃圾回收,定义有OOM异常;

Java7之前,HotSpot虚拟机中将GC分代收集扩展到了方法区,使用永久代来实现了方法区。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载。但是在之后的HotSpot虚拟机实现中,逐渐开始将方法区从永久代移除。Java7中已经将运行时常量池从永久代移除,在Java 堆(Heap)中开辟了一块区域存放运行时常量池。而在Java8中,已经彻底没有了永久代,将方法区直接放在一个与堆不相连的本地内存区域,这个区域被叫做元空间。 

6运行时常量池:

此内存区域是方法区的一部分,用于存放编译期生成的各种字面量和符合引用(符号引用就是编码是用字符串表示某个变量、接口的位置,直接引用就是根据符号引用翻译出来的地址,将在类链接阶段完成翻译),这部分内容将在类加载后进入方法区的运行时常量池中存放。当运行时常量池无法再申请到内存时会爬抛出OOM异常;

7直接内存:

 直接内存并不是JVM管理的内存,可以理解成直接内存就是JVM以外的机器内存,比如,你有4G的内存,JVM占用了1G,则其余的3G就是直接内存,JDK中有一种基于通道(Channel)和缓冲区(Buffer)的内存分配方式,将由C语言实现的native函数库分配在直接内存中,用存储在JVM堆中的DirectByteBuffer来引用。由于直接内存收到本机器内存的限制,所以也可能出现OutOfMemoryError的异常。 


1.2 对象的创建:

1new一个对象之后,先检查这个指令的参数是否能在常量池中定位到符号引用,  以及是否进行了类的加载;

2)虚拟机为新生对象分配内存,假如内存是规整的,即用过的和空闲的内存各置一边,由一个指针作为分界点指示器,如果对象大小足够在这块内存中存放,则存放后指针后移对象大小的距离,这种方式为指针碰撞,采用TLAB解决频繁创建对象产生的脏写问题(Thread Loca Allocation Buffer:把内存分配的动作按照线程划分在不同的空间之中,即每个线程在java堆分配一小块内存,线程分配内存时就在相应的TLAB上分配,使用完了TLAB之后重新分配时需要加锁)。如果内存非规整则采用空闲列表方式:就是当内存不是规整的,这时就需要维护一个列表,记录着哪些内存可用的,分配的时候从列表中找到足够大的内存块;

3)内存分配完后,虚拟机需要将分配到的内存空间都初始化为零值,保证对象的实例字段在java代码中可以不赋初始值就可以直接使用;

4)之后虚拟机堆对象进行必要的设置,如对象是哪个类的实例,如何才能找到类的元数据信息等,这些都存放在对象头之中; 


1.3 对象是否存活:

CC++类似,当我们new一个对象用完之后如果没有free或者delete掉,那内存的占用就会越来越多,最终造成内存泄露,而判断一个对象是不是已经成为垃圾最简单的标准就是看它还有没有再用,在我们的代码中都是通过引用的形式来使用对象实例;

现在主流的语言中时采用可达性分析算法来判断对象是否存活,这个算法的基本思路是通过一系列成为“GC Roots”的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径成为引用连,当一个对象到GC Roots没有任何引用链相连时,则证明此对象时不可用的。

GC Roots的对象包括如下四种: 

1)虚拟机栈(栈桢中的本地变量表)中的引用的对象

2)方法区中的类静态属性引用的对象

3)方法区中的常量引用的对象

4)本地方法栈中的native方法引用对象


2Java的回收机制

2.1 GC堆的分代划分

Java内存分配和回收的机制概括的说,就是:分代分配,分代回收。对象将根据存活的时间被分为:新生代(Young Generation)、老年代(Old Generation)、永久代(Permanent Generation

新生代:

新生代是由一块内存较大的Eden空间和两块较小的Survivor空间组成,垃圾回收采用的是标记-复制算法,每次使用Eden和其中一块SurvivorHotSpot默认的Edensurvivor的比例为8:1,用-XX:SurvivorRatio参数来配置Eden区域Survivor区的容量比值,如果一次回收中Survivor+Eden中存活下来的内存超过了10%,则需要将一部分对象分配到老年代。

老年代:

老年代存储的对象比年轻代多很多,而且不乏大对象,对老年代进行内存清理时,老年代用的算法是标记-清除-整理算法,即:标记出仍然存活的对象(存在引用的),将所有存活的对象向一端移动,以保证内存的连续;

永久代:

永久代的回收有两种:常量池中的常量,无用的类信息,常量的回收很简单,没有引用了就可以被回收。对于无用的类进行回收,必须保证3点:1)类的所有实例都已经被回收;2)加载类的ClassLoader已经被回收;3)类对象的Class对象没有被引用(即没有通过反射引用该类的地方);

永久代的回收并不是必须的,可以通过参数来设置是否对类进行回收。HotSpot提供-Xnoclassgc进行控制 使用-verbose-XX:+TraceClassLoading-XX:+TraceClassUnLoading可以查看类加载和卸载信息 -verbose-XX:+TraceClassLoading可以在ProductHotSpot中使用,-XX:+TraceClassUnLoading需要fastdebug版HotSpot支持。 


2.2 垃圾收集算法

1标记-清除算法:

算法分为标记和清除两个阶段:首先标记处需要回收的对象,在标记完成后统一回收所以被标记的对象。缺点:1)效率低,2)会产生大量的内存空间碎片;

2标记-复制算法:

将内存划分为容量大小相等的两块,每次使用其中一块,当这一块内存用完后,将存活的对象复制到另外一块上面,然后将已使用过的内存清理掉。复制的时候采用移动指针,顺序分配内存即可,可避免内存碎片等复杂情况,但是会浪费一半内存。

3)标记-整理算法:

标记-整理的算法和标记-清除一样,但后续步骤不是直接对可回收的对象进行清理,而是直接将所有存活的对象移动到内存的一端,然后直接清理掉端边界以外的内存。


2.3 分代收集机制

对象的内存分配基本就是在堆上进行分配,对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将线程优先在TLAB上分配,少数情况下也可能会直接分配的老年代中配,在新生代采用的是标记-复制算法,只不过相比而言又多了一个Eden区,由于Eden区和两个survivor区是811的关系,所以采用复制算法只是浪费了10%的内存空间,但可以带来更高的回收效率。如图:

1)首先大部分对象会在新生代的Eden区进行内存分配,当Eden的内存不够时,会触发一次Minor GC,回收废弃的对象,并把存活的对象复制到其中一个survivor1区,清空Eden区;

2)当Eden区再次满了之后,会和survivor1区一起进行Minior GC,并把存活的对象复制到survivor2区中;

3)如果存活的对象在两个survivor区域交换多次,到达指定年龄(初始年龄为0,移动到survivor区一次年龄加1)后,将会被晋升到老年代,HotSpot中的默认年龄是15岁(通过参数-XX:MaxTenuringThreshold指定),可以通过参数调整;

4)当老年代内存不足时则会触发Full GC/Major GC,一般会伴随minior GC


2.4 进入老年代的两种情况

1长期存活的对象将会进入老年代

 前面说到过,虚拟机会给每个对象定义一个对象年龄(Age)计数器,第一次Minor GC后如果对象扔存活并移动到survivor区则对象年龄为1,之后在survivor区的每一次Minor GC后年龄都会加1,当年龄达到15岁时(默认),会晋升到老年代,这个年龄阈值可以通过参数-XX:MaxTenuringThreshold设置;

2大对象直接进入老年代

 大对象即需要大量连续内存空间的java对象,如很长的字符串以及数组,虚拟机提供了-XX:PretenureSizeThreshold参数,大于这个值的对象直接放入老年代,这个可以避免新生代Eden区和两个survivor区之间的频繁复制现象,但在新生代垃圾收集器Parallel Scavenge中不可用;


2.5 频繁的GC会有什么影响

  • Minor GC

由于Minor GC发生在新生代区,大部分java对象的生命周期都很短,所以Minor GC非常频繁,回收的速度也比较快,因此对系统性能影响较小;

  • Full GC

由于Full GC发生在老年代,并且Full GC的同时至少伴随一次Minor GCFull GC的速度一般会比Minor GC10倍以上,会极大的影响性能;

无论是Minor GC还是Full GC,只要触发GC,就会产生“Stop The World”的现象,即所有的工作线程都会挂起,等待垃圾回收的完成,因此我们要尽量避免频繁GC,尤其是Full GC


2.6 触发FULL GC的条件

1老年代空间不足

只有当新生代的对象转入,或者大对象的创建才会导致老年代因内存空间不足而引发的Full GC,如果GC后空间扔不足,则会抛出OOM的异常,所以要尽量避免大对象的创建,也要尽量做到对象在Minor GC的阶段就被回收掉;

2GC担保失败

空间分配担保失败,在发生MinorGC前,检查老年代是否有连续空间,如果有则执行MinorGC,如果没有,根据参数-XX:-HandlePromotionFailure的设置,如果打开,那么继续检查当前老年代最大可用连续空间大于平均历次晋升到老年代大小,如果大于,则进行MinorGC,否则进行FullGC,如果HandlePromotionFailure 不设置直接进行FullGC

3CMS无法收集浮动垃圾

CMS是基于标记-清除的算法实现,这样就会产生大量的空间碎片,这样就会出现虽然老年代仍有很大的空间剩余,但是由于没有连续的空间存放大对象而提前触发Full GC,可以通过-XX:+UseCMSCompactAtFullCollection开关参数控制,默认是开启的,开启后停顿的时间会变长;

4调用System.gc

5永久代空间不足(JDK 1.7之前)


2.7 垃圾收集器

前面讲了几种垃圾回收的算法,垃圾收集器就是这些算法的具体实现,由于java虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同的厂商、不同的版本的虚拟机所提供的垃圾收集器都会有很大的区别,并且一般都会提供参数供用户自己根据应用特点和要求组合,如下图,连线代表两个收集器之间可以组合使用:

可以通过配置JVM参数进行垃圾收集器的选择(白皮书转):

JVM参数

新生代垃圾收集器

老年代垃圾收集器

应用场合

-XX:+UseSerialGC

Serial New

Serial Old

内存小,CPU核较少

-XX:+UseParNewGC

ParNew

Serial Old

多核,对停止时间无过高要求

-XX:+UseParallelGC

Parallel Scavenge

Serial Old

 

-XX:+UseParallelOldGC

Parallel Scavenge

Parallel Old

多核,适用于吞吐量优先的应用

-XX:+UseConcMarkSweepGC

ParNew

CMS

大内存、多核、停止时间短,适用于响应时间优先的应用

查看JVM参数及垃圾收集器的方法,可见下图垃圾收集器参数为-XX:+UseParallelGC,使用的新生代垃圾收集器为Parallel Scavenge,老年代垃圾收集器为Serial Old

垃圾收集相关常用参数:

参 数 描 述
UseSerialGC 虚拟机运行在Client 模式下的默认值,打开此开关后,使用Serial +Serial Old 的收集器组合进行内存回收
UseParNewGC 打开此开关后,使用ParNew +Serial Old 的收集器组合进行内存回收
UseConcMarkSweepGC 打开此开关后,使用ParNew + CMS + Serial Old 的收集器组合进行内存回收。Serial Old 收集器将作为CMS 收集器出现Concurrent Mode Failure失败后的后备收集器使用
UseParallelGC 虚拟机运行在Server 模式下的默认值,打开此开关后,使用ParallelScavenge + Serial OldPS MarkSweep)的收集器组合进行内存回收
UseParallelOldGC
打开此开关后,使用Parallel Scavenge + Parallel Old 的收集器组合进行内存回收
SurvivorRatio

新生代中Eden 区域与Survivor 区域的容量比值, 默认为8, 代表 Eden Survivor=8∶1

PretenureSizeThreshold

直接晋升到老年代的对象大小,设置这个参数后,大于这个参数的对象将直接在老年代分配

MaxTenuringThreshold

晋升到老年代的对象年龄。每个对象在坚持过一次Minor GC 之后,年龄就加1,当超过这个参数值时就进入老年代

UseAdaptiveSizePolicy

动态调整Java 堆中各个区域的大小以及进入老年代的年代

HandlePromotionFailure

是否允许分配担保失败,即老年代的剩余空间不足以应付新生代的整个Eden Survivor 区的所有对象都存活的极端情况

ParallelGCThreads

设置并行GC 时进行内存回收的线程数

GCTimeRatio

GC 时间占总时间的比率,默认值为99,即允许1% GC 时间。仅在使用Parallel Scavenge 收集器时生效

MaxGCPauseMillis

设置GC 的最大停顿时间。仅在使用Parallel Scavenge 收集器时生效

CMSInitiatingOccupancyFraction

设置CMS 收集器在老年代空间被使用多少后触发垃圾收集。默认值为68%,仅在使用CMS 收集器时生效

UseCMSCompactAtFullCollection

设置CMS 收集器在完成垃圾收集后是否要进行一次内存碎片整理。仅在使用CMS 收集器时生效

CMSFullGCsBeforeCompaction

设置CMS 收集器在进行若干次垃圾收集后再启动一次内存碎片整理。仅在使用CMS 收集器时生效


3. JVM性能监控

3.1 GC日志的查看

1可以通过指定JVM参数打印出GC日志

-XX:+PrintGC 输出GC日志

-XX:+PrintGCDetails 输出GC的详细日志

-XX:+PrintGCTimeStamps 输出GC的时间戳(以基准时间的形式)

-XX:+PrintGCDateStamps 输出GC的时间戳(以日期的形式)

-XX:+PrintHeapAtGC 在进行GC的前后打印出堆的信息

-Xloggc:../logs/gc.log 日志文件的输出路径

推荐的配置为:

-verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:{指定路径}/gc.log


2日志内容解读:

  • YoungGC
2016-08-30T16:40:17.489+0800:5623.145: [GC [PSYoungGen: 574176K->63008K(609280K)]
1866199K->1417799K(2007552K), 0.0895790 secs] [Times: user=0.45 sys=0.03, real=0.08 secs]

'2016-08-30T16:40:17.489+0800'GC时间戳,-XX:+PrintGCDateStamps指定格式;

'[PSYoungGen: 574176K->63008K(609280K)]':新生代垃圾回收,GC前为574176KGC后为63008K,新生代总大小为609280KPSYoungGen是指使用的为Parallel Scanvenge垃圾收集器;

'1866199K->1417799K(2007552K)'JavaGC前大小为1866199KGC1417799K,总大小为2007552K

' 0.0895790 secs':本次GC所占用的时间,单位秒;

日志分析:新生代GC前后大小变化为:574176K -  63008K = 511168KBJava堆总大小变化:1866199K - 1417799K = 448400K,因此Young GC晋升为老年代的大小为:511168K - 448400K = 62768K

  • FullGC
2016-08-30T16:40:17.579+0800: 5623.235: [Full GC [PSYoungGen: 63008K->0K(609280K)] [ParOldGen: 1354791K->212208K(1398272K)] 1417799K->212208K(2007552K) [PSPermGen: 48077K->47690K(96256K)], 0.5728110 secs] [Times: user=2.43 sys=0.05, real=0.58 secs]

'[ParOldGen: 1354791K->212208K(1398272K)]'老年代垃圾回收,GC前为1354791KGC后为212208K,老年代总大小1398272K

'[PSPermGen: 48077K->47690K(96256K)]':永久代垃圾回收,GC前为48077KGC后为47690K,老年代总大小96256K

其余参数同Young GC


3.2 使用MAT分析堆dump文件

3.2.1 使用MAT简单的步骤:

1)使用Jps获取当前虚拟机进程的pid

2)使用JDK自带工具生成dump文件:./jmap -dump:format=b,file=heap.hprof pid

3)将dump文件导入MAT 进行分析:


3.2.2 MAT相关视图和概念

在使用MAT的时候会遇到一些视图和字段,下面对常用视图和字段作了总结:

1Shallow Heap

Shallow size就是对象本身占用内存的大小,不包含其引用的对象内存,实际分析中作用不大。常规对象(非数组)的ShallowSize由其成员变量的数量和类型决定,数组的shallow size有数组元素的类型(对象类型、基本类型)和数组长度决定。

2Retained Heap

Retained Size是指当前对象大小和当前对象可直接或间接引用到的对象的大小总和,也就是说,Retained Size就是当前对象被GC后,从Heap上总共能释放掉的内存,如:一个ArrayList持有100,000个对象,每一个占用16 bytes,移除这些ArrayList可以释放16 x 100,000 + XX代表ArrayListshallow大小。相对于shallow heapRetainedHeap可以更精确的反映一个对象实际占用的大小(因为如果该对象释放,retained heap都可以被释放)。这个参数实际分析比较有作用

不过,释放的时候还要排除被GC Roots直接或间接引用的对象。他们暂时不会被被当做垃圾回收,如下图:

对象BRetained Size是指对象BShallow Size加上对象C(对象B的间接引用)的Shallow Size,但包括对象D,因为D是被GC Roots直接引用的;


3Path to GC Roots

查看一个对象到RC Roots的引用链通常在排查内存泄漏的时候,我们会选择exclude all phantom/weak/soft etc.references,意思是查看排除虚引用/弱引用/软引用等的引用链,因为被虚引用/弱引用/软引用的对象可以直接被GC给回收,我们要看的就是某个对象否还存在Strong 引用链;


结语

在进行Java应用的性能测试时,JVM的知识不可或缺,以上的知识点随便一个深入学习都会有很多内容,在后续的性能测试中会对内存泄漏、JVM参数调优等相关内容进行深入的学习和实践。


网易云产品免费体验馆无套路试用,零成本体验云计算价值。  

本文来自网易实践者社区,经作者牛小宝授权发布