前言
java面试的时候经常要问到什么是jvm,到底什么是JVM?
jvm包含哪些块?
一个.java文件的生命旅途讲一下?
jvm垃圾收集器有哪些?
各自回收算法?优缺点?
你还了解哪些最新的gc算法?
实战调优?实战定位问题?
本文从两个方面出发
第一:jvm相关的知识概念介绍。
第二:从实战例子出来,解决工作中遇到的问题。
JVM是什么
简单来说就是:解析Java字节码文件,转化为不同操作系统可运行的机器码,并提供了一套内存管理机制,来创建、使用、回收Java产生的对象。
所以说Java是跨平台的,这也是Java能“一次编写,到处运行”的原因。
运行时数据区
JVM的种类有很多,目前使用最广泛的是HotSpot虚拟机,所以本文只针对HotSpot虚拟机做介绍,下面让我们来揭开它的神秘面纱,了解JVM的内存整体布局。
从上图可以看出JVM总共分为:
程序计数器、虚拟机栈、本地方法栈、堆、方法区,下面一一来介绍。
程序计算器
指向当前线程正在执行的字节码指令地址,行号。可以理解为一个书签,比如你看到一本书的第20页准备睡觉了,可以在第20页放一个书签,下次可以接着看,同理一个线程是在一个CPU上运行,但一个CPU不仅仅只运行一个线程,当CPU从一个线程切换到另一个线程时,就需要先记录程序的运行位置,当切换回来后,就可以接着上次的位置继续执行。
虚拟机栈
存储当前线程运行方法所需要的数据、指令、返回地址。每运行一个方法,都会创建一个栈针,而一个栈针又包括:
局部变量表、操作数栈、动态链接、返回地址
我们以搭积木为例,一个盒子里装满了各种积木块(对应局部变量表),然后从局部变量表中我们一个一个拿出积木(变量)放到积木台(对应操作数栈)进行拼装,比如我们要拼装一个汽车,拼装完成后汽车即对应返回地址,动态链接很多文章给的定义比较抽象,比如:将方法区中的符号引用转为直接引用,为了形象话,你可以这样理解,在拼装汽车的时候,缺少轮子部件,但盒子里有一个纸条,上面写着:“各种轮子部件在盒子B中”,那你就可以根据提示信息,找到需要的轮子,然后拼装上去,这里的纸条可以理解为动态链接。
本地方法栈
本地方法栈与虚拟机栈类似,它存储是native修饰的方法所需要的数据、指令、返回地址,比如System类中带native的方法,这些底层都是C++来实现的。
方法区
存储类信息、常量、静态变量、JIT。
类信息:包括类的名称、类的修饰符、版本号、父类是什么、具体实现类是哪些等。
常量、静态变量:表示用final、static修饰的变量。
JIT:运行时会使用JIT即时编译器对热点代码进行优化,优化方式为将字节码编译成机器码。通常情况下,JVM使用“解释执行”的方式执行字节码,即JVM在读取到一个字节码指令时,会将其按照预先定好的规则执行栈操作,而栈操作会进一步映射为底层的机器操作;通过JIT编译后,执行的机器码会直接和底层机器打交道
注:在 JDK 1.7 时 HotSpot 虚拟机已经把原本放在永久代的字符串常量池和静态变量等移出了方法区
堆
堆是内存中最重要的一块,大部分数据都存储在堆上,说到堆,就需要说说JMM(Java内存模型),JVM堆分为新生代和老年代,新生代又分为eden(伊甸园)、survivor0(幸存者0区)、survivor1(幸存者1区)。如下图:
在1.8之前,一般把方法区称为永久代,但1.8后,废弃了永久代,改为Meta Space。
这样划分堆的内存主要用于后面的对象的回收,具体对象垃圾回收后面会讲到。
对象分配:当创建一个对象,jvm会尝试在eden分配内存,如果空间不够,会触发一次Yong GC (年轻代垃圾回收,后面会讲到),清理非存活的对象后,把存活的对象移动到Survivor0,如果Survivor0满了,会将存活的对象再移动到Survivor1中,在Surivivor每移动一次,对象年龄加1岁,当它的年龄增加到一定程度(默认为15岁)时,就会被晋升到老年代中,当老年代中的空间满了后会触发一次FullGC(老年代垃圾回收,后面会讲到),Full GC会对整个堆进行回收,会造成整个应用停止,所以要尽量减少Full GC的次数
类的加载机制
上面谈到了JVM的内存布局,但我们编写了一段代码,这段代码是怎样被加载到内存中的呢?这就涉及到了类的加载机制,类的加载机制可分为7个阶段:
- 加载阶段(Loading)
- 验证阶段(Verification)
- 准备阶段(PreParation)
- 解析阶段(Resolution)
- 初始化阶段(Initialization)
- 使用阶段(Using)
- 卸载阶段(Unloading)
其中验证、准备、解析3个阶段称为连接(Linking),一般说JVM类加载通常说的是:加载、验证、准备、解析、初始化这五个阶段
加载阶段
通过类名查找对应的类,并将类的字节码转换为方法区运行时的数据结构,在内存中生成一个能代表类的java.lang.Class对象,作为其他各种数据的访问入口。
注:加载和连接是交叉执行的,可能文件内容尚未加载完成,就已经开始部分字节码文件的验证工作了
验证阶段
验证阶段很重要,主要是为了防止一些恶意代码攻击,导致系统崩溃,它是JVM自我保护的一项重要措施,主要包括以下几个验证:
-
文件格式校验
验证字节码文件是否符合Class规范,比如是否以魔数(0xCAFEBABE)开头,其它各部分是否被追加了其它信息等
-
元数据校验
对字节码语意进行分析,是否符合Java规范,比如:是否有父类,是否继承了不允许的父类,
字节码校验:对字节码进行校验,确保没有危害虚拟机安全的信息
符号引用校验:对类自身以外如常量池中各种符号引用确保能匹配到直接引用信息。
准备阶段
为类静态变量分配内存并设置类变量的初始值,这些静态变量会被分配到方法区上,比如下面代码,会在准备阶段设置int初始值0
注:如果v变量被final修饰,则初始值会被设置为123456
public static int v= 123456
解析阶段
将虚拟机常量池中的符号引用替换为直接引用。
所谓符号引用是以一组符号来描述所引用的目标,符号引用可以是任何形式的字面量,只要使用是能无歧义地定位到目标即可,
而直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。
符号引用和直接引用有一个重要的区别:使用符号引用时被引用的目标不一定已经加载到内存中;而使用直接引用时,引用的目标必定已经存在虚拟机的内存中了
初始化阶段
初始化阶段才是真正开始执行类中定义的java程序代码,Java中对类变量进行初始化有两种方式
1、声明类变量是指定初始值
2、使用静态代码块为类变量设定初始值
初始化步骤为:
1、假如这个类还没有被加载和连接,则程序先加载并连接该类
2、假如该类的直接父类还没有被初始化,则先初始化其直接父类
3、假如类中有初始化语句,则系统依次执行这些初始化语句
public class P{
public static int a = 1;
static {
a = 2;
}
}
public class C extends P{
public static int b = a;
public static void main(String[] args) {
System.out.printf("b: "+C.b);
}
}
如上代码,会打印出:b:2,因为b引用了a,初始化C会先初始化P中a的值,jvm会按照顺序先将a赋值为1,然后static静态代码块会将a更改为2,所以b的值为2,如果将“public static int a =1”和静态代码块调换位置,那么会打印出:b:1
类加载器
说完类加载机制,但这些类是被谁加载到内存的?这就又要说到java类加载器,java提供了下面几种类加载器来加载我们的代码:
引导类加载器
负责加载jre/lib目录下的核心类库,比如rt.jar/charsets.jar等,出于安全考虑,Bootstrap启动类加载器只加载包名为java、javax、sun等开头的类
拓展类加载器
负责加载jre/lib/ext目录下的jar类包
应用类加载器
负责加载classPath路径下的class字节码文件,主要就是加载你项目中写的那些类
自定义类加载器
负责加载用户自定义路径下的class字节码文件,自定义类加载器需要继承java.lang.ClassLoader类
小结:java类加载机制最初是由父类加载、如果加载失败才会由子类加载,这被称为双亲委派原则,这样做的好处是避免类被重复加载,比如通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。
垃圾回收
当类被加载到JVM内存中后,有些对象在被使用过后以后就不会再被使用,这些对象要及时回收掉,避免对象不断堆积,导致内存溢出。
哪些内存区域需要回收?
我们知道JAVA内存模型包括:Java堆,方法区,线程栈(分为:程序计数器,虚拟机栈,本地方法栈)
线程栈(程序计数器,虚拟机栈,本地方法栈)这三个是随着线程而生,线程而灭。栈中的栈针随着方法的进入和退出有条不紊的执行者出栈和入栈的操作,每一个栈帧中分配多少内存基本上是在类结构确定下来就已知的,所以方法结束或线程结束后,内存自然就跟着回收了,所以这部分不用过多考虑内存回收的问题
Java堆和方法区,这些里面都是用来存一些静态的类,或者动态代理生成的类,创建的对象,这些都是在程序运行时动态创建的,所需要的内存我们就不确定了,如果创建的对象使用完后,一直没有被回收,就导致内存溢出了,所以这部分就是垃圾回收器所要关心的,它要识别出那些无效的对象回收掉
如何判定对象是垃圾?
垃圾回收提供了2种算法来判定一个对象是否是垃圾:引用计数法和根可达算法
引用计数法:即判断某个对象不在被其它对象引用,当某个对象被一个对象引用时,计数器+1,当引用失效时,则-1,直到减为0时,表示可以回收了,但这不能解决A,B之间相互引用的问题,A中有一个变量指向B,同时B中有一个变量指向A,所以即使A,B最后都不会再用了,由于他们的引用关系还在,仍然不能被回收。
根可达算法:JAVA中定义了一个名为“GC Roots” 的对象来作为起始点,从这个节点开始找,如果某个对象到这个“GC Roots”对象没有引用连接,那么表示这个对象就可以被回收了。
注:JAVA中使用的就是根可达算法来判断对象是否存活的
哪些对象可以作为GC Roots呢?Java中定义了这些对象:
1、虚拟机栈(栈帧中的本地变量表)中引用的对象。
2、方法区中类静态属性引用的对象
3、方法区中常量引用的对象
4、本地方法栈中(一般说的本地方法)应用的对象
如下图,object1,object2,object3,object4不能被回收,因为它们可以直接或间接直达GC Roots,object5,object6,object7虽然有互连,但是没有和GC Roots关联,可以被回收
****垃圾回收算法****
如何回收jvm的内存垃圾,Java提供了三种回收算法:
标记-清除算法如上图,先标记出无效的对象,再进行清理,它有两个缺点:
效率问题:标记和清除过程的效率都不高
空间问题:如上图,清除后会产生大量不连续的内存碎片
如上图,先将可用内存划为为大小相等的两块,每次只使用其中一块,当这一块内存用完了,将存活的对象复制到另一份上,并将原先那块一次性清理掉
缺点:以空间换时间,这样,可用内存就缩小为原来的一半了,当然JVM做了也做了优化
在使用这种算法回收新生代时,不用要求内存分为1:1等量的两块,因为新生代一般都是朝生夕死,所以只会有少部分对象存活下来,JVM将新生代分为了Eden和两块较小的Survivor,Eden和Survivor内存为8:1,当Survivor内存不够时,就需要其他内存(老年代)来担保为其承担部分内存。
标记-整理算法如上图,类似于“标记-清理”,也是先标记处存活的对象,但之后是先将存活的对象都向一端移动,然后清理掉边界之外的内存,这种算法一般适用于JAVA老年代的回收
说明:新生代每次回收都会有大批对象死去,只有少量存活,所以采用复制算法
而老年代每个对象的存活率很高,且没有额外的空间对其担保,所以可以采用“标记-清理”或“标记-整理”算法来回收
垃圾回收器
以上介绍的是垃圾回收算法,垃圾回收器是垃圾回收算法的一种实现,就比如小米扫地机器人和华为扫地机器人虽然是两种产品,但底层可能采用了同一种清扫算法,Java中新生代,老年代就是通过回收器来回收垃圾对象,以下是新生代和老年代使用回收器的分布
新生代收集器有:Serial、ParNew、Parallel Scavenge 老年代收集器有:CMS、Serial Old Parallel Old
Serial 使用单线程清理,清理的时候,会停止应用线程,待清理完成后,应用线程继续执行,优点:对于单核CPU运行高效,不会来回切换线程,缺点:因为会停止应用线程,界面会产生卡顿的现象。
Serial收集器(复制算法)
新生代单线程收集器,使用标记清理算法,且标记、清理都是单线程,
优点:运行高效,缺点:界面会产生卡顿的现象
Serial Old收集器(标记-整理)
老年代单线程收集器,Serial老年代收集版本
ParNew serial多线程版本,多核CPU比较有优势,原来是1个人干的活,现在多个人一起来干了,同样会暂停应用线程
ParNew收集器(停止-复制算法)
新生代收集器,Serial的多线程版本,并发标记、清理,在多CPU下有比Serial更好的性能
Parallel Scavenge收集器(停止-复制算法)
新生代收集器,也是使用多线程并行收集,但关注的是高吞吐量(最高效率的利用CPU时间),吞吐量=用户线程时间/(用户线程时间+GC线程时间),适用于后台对交互性不高的应用
Parallel Old收集器(停止-复制算法)
老年代收集器,并行收集,吞吐量优先
CMS (Concurrent Mark Sweep)收集器(标记-清除算法)
CMS分为四个阶段:
初始标记:只是标记一下GC root能直接关联到的对象,速度很快
并发标记:并发标记阶段就是根据GC root标记需要清理的对象
重新标记:重新标记是为了修正并发标记期间因用户程序继续运作而导致需要清理对象变动的记录,这个阶段用户线程会终止一段时间,会比初始标记阶段稍长一些,但远比并发标记的时间短
并发清理:并发清理是和用户线程一起,清理需要回收的对象
除了初始标记和重新标记需要停止用户线程,其它阶段都不需要停止,所以CMS以获取最短停顿时间为目标的收集器,应用于如(B/S网站),并发收集,占用CPU资源较高。
因为CMS使用的是标记-清除算法,必然会产生很多碎片,所以可以在执行多少次CMS,再执行一次压缩的FullGC,使用:-XX:CMSFullGCsBeforeCompaction参数(默认为0,即每次每次都进行内存整理)
目前主流的web项目一般使用ParNew+CMS+Serial Old(老年代备用方案)来回收垃圾。
G1 ZGC最新的垃圾收集器,基本不需要调优,能支持更大的堆空间回收
内存操作命令
jvm提供了一系列命令来查看内存情况,并处理内存事故问题,包括:jps,jstat,jmap,jhat,jstack,jinfo
jps
JVM Process Status Tool,显示指定系统内所有的HotSpot虚拟机进程。
命令格式:
jps [options] [hostid] //options、hostid参数可省略
options参数:
-l : 输出主类全名或jar路径
-q : 只输出LVMID
-m : 输出JVM启动时传递给main()的参数
-m : 输出JVM启动时传递给main()的参数
$ jps -l21072 sun.tools.jps.Jps
3108 org.jetbrains.idea.maven.server.RemoteMavenServer
1200 org.jetbrains.jps.cmdline.Launcher
8080 com.example.springdemo.SpringDemoApplication
jstat
jstat(JVM statistics Monitoring)是用于监视虚拟机运行时状态信息的命令,它可以显示出虚拟机进程中的类装载、内存、垃圾收集、JIT编译等运行数据。
命令格式:
jps [option] LVMID [interval] [count]
参数
option: 操作参数
-q : 本地虚拟机ID
-m : 连续输出的时间间隔
-m : 连续输出的次数
option 操作参数介绍:
Option | Displays… |
---|---|
class | class loader的行为统计。Statistics on the behavior of the class loader. |
compiler | HotSpt JIT编译器行为统计。Statistics of the behavior of the HotSpot Just-in-Time compiler. |
gc | 垃圾回收堆的行为统计。Statistics of the behavior of the garbage collected heap. |
gccapacity | 各个垃圾回收代容量(young,old,perm)和他们相应的空间统计。Statistics of the capacities of the generations and their corresponding spaces. |
gcutil | 垃圾回收统计概述。Summary of garbage collection statistics. |
gccause | 垃圾收集统计概述(同-gcutil),附加最近两次垃圾回收事件的原因。Summary of garbage collection statistics (same as -gcutil), with the cause of the last and |
gcnew | 新生代行为统计。Statistics of the behavior of the new generation. |
gcnewcapacity | 新生代与其相应的内存空间的统计。Statistics of the sizes of the new generations and its corresponding spaces. |
gcold | 年老代和永生代行为统计。Statistics of the behavior of the old and permanent generations. |
gcoldcapacity | 年老代行为统计。Statistics of the sizes of the old generation. |
gcpermcapacity | 永生代行为统计。Statistics of the sizes of the permanent generation. |
printcompilation | HotSpot编译方法统计。HotSpot compilation method statistics. |
$ jstat -gcutil 8080 2000
上面命令表示每隔2秒打印出GC的使用回收情况,若FGC很多很可能出现了内存泄露
jmap
jmap是一个多功能的命令。它可以生成 java 程序的 dump 文件, 也可以查看堆内对象示例的统计信息、查看 ClassLoader 的信息以及 finalizer 队列。
命令格式:
jmap [option] LVMID
option参数
dump: 生成堆转储快照
finalizerinfo : 显示在F-Queue队列等待Finalizer线程执行finalizer方法的对
heap : 显示Java堆详细信息
histo : 显示堆中对象的统计信息
permstat : to print permanent generation statistics
F : 当-dump没有响应时,强制生成dump快照
$ jmap -heap 8080
查看Java内存的分配情况及新生代、老年代内存的使用情况,确定是否内存分配过小?
$ jmap -histo:live 8080
按使用大小进行了排序,重点查看排在前面的对象,看是否程序写的有问题。另外可以将jvm的堆内存导出来分析,使用:
jmap -dump:format=b,file=dump.hprof pid
jhat
jhat(JVM Heap Analysis Tool)命令是与jmap搭配使用,用来分析jmap生成的dump,jhat内置了一个微型的HTTP/HTML服务器,生成dump的分析结果后,可以在浏览器中查看。在此要注意,一般不会直接在服务器上进行分析,因为jhat是一个耗时并且耗费硬件资源的过程,一般把服务器生成的dump文件复制到本地或其他机器上进行分析,对于jhat分析内存在后面实战中会讲到。
命令格式
jhat [dumpfile]
参数
-stack false|true
关闭对象分配调用栈跟踪(tracking object allocation call stack)。如果分配位置信息在堆转储中不可用. 则必须将此标志设置为 false. 默认值为 true.>-refs false|true
关闭对象引用跟踪(tracking of references to objects)。默认值为 true. 默认情况下, 返回的指针是指向其他特定对象的对象,如反向链接或输入引用(referrers or incoming references), 会统计/计算堆中的所有对象。>-port port-number
设置 jhat HTTP server 的端口号. 默认值 7000.>-exclude exclude-file
指定对象查询时需要排除的数据成员列表文件(a file that lists data members that should be excluded from the reachable objects query)。例如, 如果文件列列出了 java.lang.String.value , 那么当从某个特定对象 Object o 计算可达的对象列表时, 引用路径涉及 java.lang.String.value 的都会被排除。>-baseline exclude-file
指定一个基准堆转储(baseline heap dump)。在两个 heap dumps 中有相同 object ID 的对象会被标记为不是新的(marked as not being new). 其他对象被标记为新的(new). 在比较两个不同的堆转储时很有用.>-debug int
设置 debug 级别. 0 表示不输出调试信息。值越大则表示输出更详细的 debug 信息.>-version
启动后只显示版本信息就退出>-J< flag >
因为 jhat 命令实际上会启动一个JVM来执行, 通过 -J 可以在启动JVM时传入一些启动参数. 例如, -J-Xmx512m 则指定运行 jhat 的Java虚拟机使用的最大堆内存为 512 MB. 如果需要使用多个JVM启动参数,则传入多个 -Jxxxxxx.
jstack jstack用于生成java虚拟机当前时刻的线程快照。线程快照是当前java虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等。线程出现停顿的时候通过jstack来查看各个线程的调用堆栈,就可以知道没有响应的线程到底在后台做什么事情,或者等待什么资源。如果java程序崩溃生成core文件,jstack工具可以用来获得core文件的java stack和native stack的信息,从而可以轻松地知道java程序是如何崩溃和在程序何处发生问题。另外,jstack工具还可以附属到正在运行的java程序中,看到当时运行的java程序的java stack和native stack的信息, 如果现在运行的java程序呈现hung的状态,jstack是非常有用的。
命令格式
jstack [option] LVMID
option参数
-F : 当正常输出请求不被响应时,强制输出线程堆栈
-l : 除堆栈外,显示关于锁的附加信息
-m : 如果调用到本地方法的话,可以显示C/C++的堆栈
在后面实战中,会具体讲到如何通过jstack来定位死锁和CPU占用过高的问题。
jinfo jinfo(JVM Configuration info)这个命令作用是实时查看和调整虚拟机运行参数。之前的jps -v口令只能查看到显示指定的参数,如果想要查看未被显示指定的参数的值就要使用jinfo口令
命令格式
jinfo [option] [args] LVMID
option参数
-flag : 输出指定args参数的值
-flags : 不需要args参数,输出所有JVM参数的值
-sysprops : 输出系统属性,等同于System.getProperties()
示例
$ jinfo -flags 716
实战
怎么定位内存泄漏
上面介绍jvm命令的时候,已经说了,可以是用jstat -gcutil来查看内存的回收情况,如果频繁出现FullGC(FGC指标)表示可能出现了内存泄漏,首先先说说jvm哪几块内存区域会出现内存泄漏:
1,堆溢出(java.lang.OutOfMemoryError: java heap space)
当生成一个新对象,JVM内存申请如下流程:
1)jvm先尝试在eden分配新对象所需的内存,若内存足够,则将对象放入eden返回
2)若内存不够,jvm启动youngGC,试图将eden不活跃的对象释放掉,若释放后仍不足以分配内存,则将Eden活跃的对象放入survivor中。
3)survivor作为Eden和old的中间交换区域,若old空间足够,survivor去对象会被移动到old区,否则留在survivor区
4)当old区不够时,jvm会在old区进行fullGC,若fullGC后,survivor和old仍然无法存放从Eden复制过来的对象,则会出现“outOfMemoryError: java heap space”
解决方法:加大堆内存的大小,通过设置-Xms(java heap初始化大小,默认是物理内存1/64) -Xmx(java heap的最大值) -Xmn(新生代heap的大小,一般为Xmx3或4分之一,注:增加新生代后会减少老年代的大小)
2,方法区内存溢出(java.lang.OutMemoryError: permGen space)
方法区主要是用来存放类信息,常量、静态变量等,所以程序中类加载过多(引入第三方包),或者过多使用反射、cglib这种动态代理,就可能导致该区域内存溢出
解决方法:通过设置-XX:PermSize(内存永久区初始值)和-XX:MaxPerSize(内存永久区最大值)的大小
3,线程栈溢出(java.lang.StackOverflowError)
线程栈是线程独有的一块内存区域,所以线程栈溢出必定是线程运行是出现错误,一般是递归太深,或者方法层级调用太深引起的
解决方法:设置栈区的大小,通常栈的大小是1-2M,可通过-Xss设置线程的栈的大小,jdK5以后每个栈默认大小为1M。
注:默认内存调到128k就足够了,因为大多数项目中不会存在很多递归调用。
以上说的解决方法是项目确实需要那么多的内存,可通过调节对应参数来设置内存区域的大小,但很多情况是由于我们代码异常导致内存溢出,一般出现内存溢出,可dump出内存文件进行分析,可以在项目启动参数添加:
| -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=${目录} |
或者使用jstat -gcutil来查看内存的回收情况,如果频繁出现FullGC(FGC指标)表示可能出现了内存泄漏,然后手动dump内存文件,使用下面命令:
jmap -dump:format=b,file=dump.hprof pid
知道了项目出现了内存溢出,那么怎么知道具体是哪个类,哪个方法导致的呢?以下介绍了两种方法:使用jhat、MAT、Jprofiler分析java dump文件
通过工具定位内存溢出 .。。。