1. 什么是JVM
与JVM的初次见面,是在我们Java SE的开始,认识Java跨平台原理的时候.时隔多日,我们先来回顾一下.
Java的广告语是,"编写一次,到处运行",而它凭借的就是JVM(Java Virtual Machine).而对于不同的平台,Windows,Linux,Mac OS等,有具体不同的JVM版本.这些JVM屏蔽了平台的不同,提供了统一的运行环境,让Java代码无需考虑平台的差异,运行在相同的环境中.
下图即Oracle官网下载JDK 8时所需要进行选择的页面
image而至于JRE和JDK,就不再赘述了,包含关系应该很清楚的,而今天我们的重点就在于对JVM的进一步认识以及对它进行优化调整.
2. 为什么要优化JVM
正如前面我们所回顾的,我们的Java代码都是运行在JVM中的,而部署的硬件及应用场景有所不同时,仍然采用默认的配置不见得能起到最好的效果,甚至可能会导致运行效率更差,又或者面临高并发情况下,想让程序平稳顺畅的运行,所以我们需要针对实际的需要来进行优化.
3. 分析工具
我们只知道有JVM的存在,但它的运行对于我们来说感觉像是摸不着看不见的,所以我们需要借助工具来监控它的一个实时状态,就像Windows的性能监视器一样,JDK也有自己的可视化工具.
我们以管理员身份运行DOS
image输入jvisualvm,将Java VisualVM启动
image在这里我们可以看到
image本地列表中有多个条目,而一眼也可以看到我们SpringBoot项目的main方法,直接双击
经过短时间的加载后,得到这样一个界面
image这个是概述页面,可以得到很多信息,但对于我们分析JVM的运行还是没有什么帮助,所以我们切换到监视页
image监视页展示的就是实时的JVM信息,应该还是很直观的
image现在安装插件,插件的安装属于VisualVM的一个重要功能,凭借插件我们可以将这个工具的功能变得更强大。
打开工具->插件;选择"可用插件"页;我们在这里安装一个Visual GC,方便我们看到内存回收以及各个分代的情况;打上勾之后点击安装,就是常规的next以及同意协议等,网络不是很稳定,有时候可能需要多尝试几次。
安装完成后我们将当前监控页关掉,再次打开,就可以看到Profiler后面多了一个Visual GC页。
image在这里我们可以看到JIT活动时间,类加载活动时间,GC活动时间以及各个分代的情况。
需要注意的是,当前课件使用的JDK版本为1.8,仍然自带了VisualVM,从1.9开始的版本是没有自带的,需要额外下载,下载的github地址:
另外,如果开发工具使用的是Intellij IDEA的话,可以下载一个插件,VisualVM Launcher,通过插件启动可以直接到上述页面,不用在左边的条目中寻找自己的项目.
当然也有其他的工具,但这个在可预见的未来都会是主力发展的多合一故障处理工具.所以我们后面将会使用这个工具来分析我们的JVM运行情况,进而优化.而需要优化我们还需要对JVM的组成有进一步的了解.接下来我们来看一下JVM的组成
4. JVM组成
image从图上可以看到,大致分为以下组件:
- 类加载器子系统
- 运行时数据区
- 执行引擎
- 本地方法库
而本地库接口也就是用于调用本地方法的接口,在此我们不细说,主要关注的是上述的4个组件
4.1类加载器子系统
顾名思义,这是用于类加载的一个子系统.
4.1.1类加载的过程
类加载的过程包括了加载,验证,准备,解析和初始化这5个步骤
1. 加载:找到字节码文件,读取到内存中.类的加载方式分为隐式加载和显示加载两种。隐式加载指的是程序在使用new关键词创建对象时,会隐式的调用类的加载器把对应的类加载到jvm中。显示加载指的是通过直接调用class.forName()方法来把所需的类加载到jvm中。
2. 验证:验证此字节码文件是不是真的是一个字节码文件,毕竟后缀名可以随便改,而内在的身份标识是不会变的.在确认是一个字节码文件后,还会检查一系列的是否可运行验证,元数据验证,字节码验证,符号引用验证等.Java虚拟机规范对此要求很严格,在Java 7的规范中,已经有130页的描述验证过程的内容.
3. 准备:为类中static修饰的变量分配内存空间并设置其初始值为0或null.可能会有人感觉奇怪,在类中定义一个static修饰的int,并赋值了123,为什么这里还是赋值0.因为这个int的123是在初始化阶段的时候才赋值的,这里只是先把内存分配好.但如果你的static修饰还加上了final,那么就会在准备阶段就会赋值.
4. 解析:解析阶段会将java代码中的符号引用替换为直接引用.比如引用的是一个类,我们在代码中只有全限定名来标识它,在这个阶段会找到这个类加载到内存中的地址.
5. 初始化:如刚才准备阶段所说的,这个阶段就是对变量的赋值的阶段.
4.1.2类与类加载器
每一个类,都需要和它的类加载器一起确定其在JVM中的唯一性.换句话来说,不同类加载器加载的同一个字节码文件,得到的类都不相等.我们可以通过默认加载器去加载一个类,然后new一个对象,再通过自己定义的一个类加载器,去加载同一个字节码文件,拿前面得到的对象去instanceof,会得到的结果是false.
4.1.3双亲委派机制
image类加载器一般有4种,其中前3种是必然存在的
- 启动类加载器:加载<JAVA_HOME>\lib下的
- 扩展类加载器:加载<JAVA_HOME>\lib\ext下的
- 应用程序类加载器:加载Classpath下的
- 自定义类加载器
而双亲委派机制是如何运作的呢?
我们以应用程序类加载器举例,它在需要加载一个类的时候,不会直接去尝试加载,而是委托上级的扩展类加载器去加载,而扩展类加载器也是委托启动类加载器去加载.
启动类加载器在自己的搜索范围内没有找到这么一个类,表示自己无法加载,就再让扩展类加载器去加载,同样的,扩展类加载器在自己的搜索范围内找一遍,如果还是没有找到,就委托应用程序类加载器去加载.如果最终还是没找到,那就会直接抛出异常了.
而为什么要这么麻烦的从下到上,再从上到下呢?
这是为了安全着想,保证按照优先级加载.如果用户自己编写一个名为java.lang.Object的类,放到自己的Classpath中,没有这种优先级保证,应用程序类加载器就把这个当做Object加载到了内存中,从而会引发一片混乱.而凭借这种双亲委派机制,先一路向上委托,启动类加载器去找的时候,就把正确的Object加载到了内存中,后面再加载自行编写的Object的时候,是不会加载运行的.
4.2运行时数据区
运行时数据区分为虚拟机栈,本地方法栈,堆区,方法区和程序计数器.
4.2.1程序计数器
程序计数器是线程私有的,虽然名字叫计数器,但主要用途还是用来确定指令的执行顺序,比如循环,分支,跳转,异常捕获等.而JVM对于多线程的实现是通过轮流切换线程实现的,所以为了保证每个线程都能按正确顺序执行,将程序计数器作为线程私有.程序计数器是唯一一个JVM没有规定任何OOM的区块.
4.2.2Java虚拟机栈
Java虚拟机栈也是线程私有的,每个方法执行都会创建一个栈帧,局部变量就存放在栈帧中,还有一些其他的动态链接之类的.通常有两个错误会跟这个有关系,一个是StackOverFlowError,一个是OOM(OutOfMemoryError).前者是因为线程请求栈深度超出虚拟机所允许的范围,后者是动态扩展栈的大小的时候,申请不到足够的内存空间.而前者提到的栈深度,也就是刚才说到的每个方法会创建一个栈帧,栈帧从开始执行方法时压入Java虚拟机栈,执行完的时候弹出栈.当压入的栈帧太多了,就会报出这个StackOverflowError.
4.2.3本地方法栈
本地方法栈中存放的是JVM实际需要调用到的native方法,实际上还是和Java虚拟机栈很相似的.
4.2.4方法区
方法区是所有线程共享的一块内存分区,它的名字其实感觉不太恰当,它主要保存的就是我们前面说的,类加载器加载到JVM中的类信息等.(而方法区在JVM规范中只是规定了它的存在和作用,并没有限制它的实现,所以HotSpot就在Java7以及之前版本的设计中搞了个永生代来实现方法区,其他的厂商都没有这个永生代.而这个设计经过多个版本的验证,并不是一个好的设计,所以在Java 8的时候就移除掉了永生代,使用一个本地的内存块来替代,命名为MetaSpace)
4.2.5堆
堆和方法区一样(确切来说JVM规范中方法区就是堆的一个逻辑分区),就是一个所有线程共享的,存放对象的区域,也是GC的主要区域.其中的分区分为新生代,老年代.新生代中又可以细分为一个Eden,两个Survivor区(From,To).Eden中存放的是通过new 或者newInstance方法创建出来的对象,绝大多数都是很短命的.正常情况下经历一次gc之后,存活的对象会转入到其中一个Survivor区,然后再经历默认15次的gc,就转入到老年代.这是常规状态下,在Survivor区已经满了的情况下,JVM会依据担保机制将一些对象直接放入老年代。
4.3执行引擎
执行引擎包含即时编译器(JIT)和垃圾回收器(GC),对即时编译器我们简单介绍一下,主要重点在于垃圾回收器.
4.3.1即时编译器(JIT,Just-In-Time Compiler)
看到这个东西的存在可能有些人会感到疑问,不是通过javac命令就把我们的java代码编译成字节码文件了吗,这个即时编译器又是干嘛的?
我们需要明确一个概念就是,计算机实际上只认识0和1,这种由0和1组成的命令集称之为"机器码",而且会根据平台不同而有所不同,可读性和可移植性极差.我们的字节码文件包含的并不是机器码,不能由计算机直接运行,而需要JVM"解释"执行.JVM将字节码文件中所写的命令解释成一个个计算机操作命令,再通知计算机进行运算.
JIT并不是Java虚拟机规范定义中规定必须存在的.但它又是JVM性能重要影响因素之一.
在上面的内容里,提到了HotSpot这么一个名字,它是我们一直使用的这款虚拟机的名称.HotSpot中文意思是"热点",而HotSpot VM的特点之一也就是可以探测并优化热点代码,JIT就是它进行优化的方式.
HotSpot通过计数以及其他方式,监测到某些方法或者某些代码块执行的频率很高,就会将其编译成为平台相关的机器码,甚至于在保证结果的情况下通过优化执行顺序等方式进行优化,这种机器码的执行效率比解释执行要高出很多.而编译完成后,会通过"栈上替换"等方式进行动态的替换,比如循环执行,循环一次JIT的计数器就+1,到了阈值的时候就开始编译重复执行的代码,同时为了不影响系统的运行,原来的解释执行仍然继续,直到在第N次循环时,编译完成,会在N+1次执行前替换成编译后的机器码执行.
计数器分为两种,一种方法调用计数器,一种回边计数器。
方法计数器就是用于统计方法的直接调用,而回边计数器用于循环代码的技术。检测的是频率,所以他们的计数值不会一直累加,而是在一定时间段内叠加,而超过时间段还没有达到阈值,就减半。这个减半称为"热度衰减",而这个时间段被称为"半衰周期"
但编译成为机器码需要时间,会导致JVM启动时间变长,内存消耗也会增加.所以需要根据实际情况权衡,在启动时附加命令选择执行模式.
- 纯解释执行模式:-Xint
- 纯编译执行模式:-Xcomp
- 混合模式:默认
JIT包含两种编译器,Client Compiler,Server Compiler.
Client Compiler,就是俗称的C1编译器.Server Compiler也就是俗称的C2编译器.JVM会根据版本及宿主机的硬件性能来自动选择,也可以通过附加命令"-client"或者"-server"手动选择.
C1编译器编译速度快,但编译后的质量可靠,但性能优化程度不高.
C2编译器编译速度慢,但编译后的性能优化程度很高,有时候会根据性能的监控情况采取"激进"优化.当然,这种激进优化如果失败了,仍然会"逆优化"回退到解释执行来保证代码的正常运行.
4.4垃圾回收器(Garbage Collection)
4.4.1什么是垃圾
说到垃圾回收器,首先需要说一下什么叫垃圾.
所有的对象都存放在堆中,而有些对象用过之后就不会再被使用了,这种就叫做垃圾.概念很容易理解,但对于JVM来说,怎么确定一个对象是否是垃圾或者说怎么找到所有的垃圾对象就需要算法的支持.
4.4.2怎么确定一个对象是垃圾
不得不提的一种是引用计数法,实现起来最简单,一个对象被引用一次,计数器就+1,失去引用就计数器-1,等到计数器减为0了,这个对象就没有其他对象在使用了,也就可以对它进行回收了.这种算法效率很高,但这种会有一个问题在于,两个对象相互引用,但两个对象都没有被其他对象继续引用了,计数器仍然不会减为0.
image通过引用计数来看,node1被node2引用着,node2也被node1引用着,两个互相引用,却没有其他地方在引用,应该被清除掉,但引用计数器的值并没有减为0,无法回收。所以几乎已经被现代语言抛弃掉了,取而代之的是可达性分析标记存活对象而后使用其他算法.
可达性分析是从一个GC Root节点开始找引用的节点,找到后继续找其引用的节点,直到查找完毕,其余没有被找到过的节点就是垃圾节点,一般作为GC Root的对象有Java栈中的本地变量对象,方法区的静态变量引用的对象,方法区的常量引用的对象,本地方法栈中引用的对象等.
image如上图所示,遍历所有的GC Root(黑色的对象),然后向下寻找所有的引用关系,能够找到的就标记为存活(蓝色的对象)。而无法找到的,也就无法打上标记(黄色的对象),这些没有存活标记的就是可以回收的对象。
4.4.3基本垃圾回收算法
大多数人对于GC的直观感受是,飘忽不定,它执行的时间是不确定的,就算手动调用System.gc()也不见得会执行.但其实不尽然,GC作为一个守护线程,它的优先级是随着内存使用情况不断变化的,会在可用内存低到一定程度后自动调用.
基本GC算法主要是标记-清除算法,复制算法,标记-整理算法.
image标记-清除算法其实在JVM中没怎么露脸,但它是现代GC算法的基础。通过可达性分析,将存活的对象打上标记,然后对全部对象进行扫描,将没有标记的对象清除掉.这种算法会有一个问题,清除废弃对象后,释放的内存并不是连续的,而是一个个内存碎片,这对于后续JVM分配内存并不是很好,如果需要一块较大的连续内存就没有办法将这些碎片利用起来.并且它需要遍历所有的对象,清除没有标记的,这种性能消耗很大。
image复制算法,一般应用于新生代,这也是为什么新生代要设计成一个Eden,两个Survivor区的原因。所有对象都在Eden创建出来,每次gc就会把Eden和其中一个正在使用的Survivor区中存活的对象复制到另外一个没有使用的Survivor区。然后清除掉原来内存区的所有对象,也就是废弃的对象。每次gc都这样操作,始终留一个Survivor区不使用。这种算法的好处在于不会残留内存碎片,方便内存管理,但是需要预留一块内存,并且性能消耗是根据存活对象多少而来的,不适用于存活对象较多的情况。
image标记-整理算法,是标记-清除算法的升级版,一般用于老年代。它将标记存活的对象统一移到内存的某一端,然后将边界外的空间清空。这样既不会占着一块内存作为备用,也不会存在内存碎片无法有效利用。但是由于要遍历存活的对象,还有重新存活对象的引用地址,所以效率要低于复制算法。
4.4.4分代回收算法
正如我们前面了解到的,新生代和老年代各自的情况不同,直接把某种算法套用在两个区上,可能效果并不理想。而现在商业虚拟机的GC都是采用的分代回收算法,不同的堆分区采用不同的算法进行回收。
4.5Minor GC和Full GC
在说这两种回收的区别之前,我们先来说一个概念,"Stop-The-World"。
如字面意思,每次垃圾回收的时候,都会将整个JVM暂停,回收完成后再继续。如果一边增加废弃对象,一边进行垃圾回收,完成工作似乎就变得遥遥无期了。
而一般来说,我们把新生代的回收称为Minor GC,Minor意思是次要的,新生代的回收一般回收很快,采用复制算法,造成的暂停时间很短。而Full GC一般是老年代的回收,病伴随至少一次的Minor GC,新生代和老年代都回收,而老年代采用标记-整理算法,这种GC每次都比较慢,造成的暂停时间比较长,通常是Minor GC时间的10倍以上。
所以很明显,我们需要尽量通过Minor GC来回收内存,而尽量少的触发Full GC。毕竟系统运行一会儿就要因为GC卡住一段时间,再加上其他的同步阻塞,整个系统给人的感觉就是又卡又慢。
5JVM的优化
JVM的优化我们可以从JIT优化,内存分区设置优化以及GC选择优化三个方面入手。
5.1JIT优化
正如前面所说的,在系统启动的时候,首先Java代码是解释执行的,当方法调用次数到达一定的阈值的时候(client:1500,server:10000),会采用JIT优化编译。而直接将JVM的启动设置为-Xcomp并不会有想象中那么好。没有足够的profile(侧写,可以大致理解为分析结果),优化出来的代码质量很差,甚至于执行效率还要低于解释器执行,并且机器码的大小很容易就超出字节码大小的10倍以上。
那么我们能做的,就是通过附加启动命令适当的调整这个阈值或者调整热度衰减行为,在恰当的时候触发对代码进行即时编译。
- 方法计数器阈值:-XX:CompileThreshold
- 回边计数器阈值:-XX:OnStackReplacePercentage(这并不是直接调整阈值,回边计数器的调整在此仅作简单介绍,此计数器会根据是Client模式还是Server模式有不同的计算公式)
- 关闭热度衰减:-XX:UseCounterDecay
- 设置半衰周期:-XX:CounterHalfLifeTime
而JIT也是一片广阔的知识海洋,有兴趣可以根据以下的优化技术名称搜索了解详情,在此就不赘述了。
image image5.2JVM内存分区优化
我们依据Java Performance这本书的建议的设置原则进行设置,
Java整个堆大小设置,Xmx 和 Xms设置为老年代存活对象的3-4倍,即FullGC之后的老年代内存占用的3-4倍,Xmx和Xms的大小设置为一样,避免GC后对内存的重新分配。而Full GC之后的老年代内存大小,我们可以通过前面在Visual VM中添加的插件Visual GC查看。先手动进行一次GC,然后查看老年代的内存占用。
- 新生代Xmn的设置为老年代存活对象的1-1.5倍。
- 老年代的内存大小设置为老年代存活对象的2-3倍。
5.3垃圾回收器的认识
垃圾回收器有很多,他们各自有各自的特点,没有什么回收器是最好的,所以才会有这么多存在,而我们就需要根据实际情况来选择组合,进行JVM的调优。
主要有以下7个垃圾回收器:
- Serial
- ParNew
- Parallel Scavenge
- Serial Old
- Parallel Old
- CMS
- G1
可以从这张图大概看到,哪些垃圾回收器是用于回收哪个代的,以及连线表示可以搭配组合使用。
5.3.1Serial
Serial是最基本,也是发展最悠久的垃圾回收器。它采用单线程收集,在单CPU环境下效率很高,没有线程切换,专注于垃圾回收。它作为Client模式JVM的默认垃圾回收器。
我们通过-XX:+UseSerialGC来选择使用它。
5.3.2ParNew
这个也就是Serial的多线程版本,代码重复度都很高。它是作为Server模式JVM的默认垃圾回收器。但需要注意的是,多线程是它的特点,并不见得是优点。在单核环境下是绝对不如Serial的效率,在双核环境下都不能保证100%比Serial的效率高。它默认的线程数和CPU核数相同,在CPU核数非常多的环境下,比如32个,我们没有必要同时用32个线程来进行垃圾回收,线程的切换也是有相当大的性能开销的。
我们可以通过-XX:+UseParNewGC来选择使用它,通过-XX:ParallelGCThreads来指定线程数。
5.3.3Parallel Scavenge
这个垃圾回收器的特点感觉跟ParNew都一样,但它的关注点不同。它的目标是达到一个可控制的吞吐量。
吞吐量是什么意思呢?假如我们虚拟机总共运行了100分钟,其中垃圾收集花掉了1分钟,吞吐量则是99%。
吞吐量越高,那么响应速度越快,在与用户的交互中就会感觉更顺畅,这在注重交互的环境中更为重要。
我们通过-XX:+UseParallelGC来选择使用它,然后使用-XX:MaxGCPauseMillis参数设置最大GC暂停时间(毫秒数),然后GC会尽量在这个时间内完成。但并不是越小越好,越小那么每次回收的内存也就越少,那么回收的次数也会增长起来,总体的吞吐量也会降低。同样我们也可以使用-XX:GCTimeRatio设置非GC占用时间的比重。比如设置为19,那非GC占用时间的比重就是19/(1+19).
除去上述两个配置参数外,我们还可以使用-XX:+UseAdaptiveSizePolicy命令,这个命令添加后,就不需要手动去指定新生代大小,以及Eden区和Survivor区的比例,晋升老年代的年龄阈值了,JVM会根据当前系统的运行情况智能调节这些大小比例等。
5.3.4Serial Old
看名字应该能够猜得出来,这就是Serial收集器的老年代版本,它同样也是Client模式下默认的垃圾回收器,但它在Server模式下有一个另外的用途,作为CMS收集器的后备预案。这个不用手动开启,一般在指定Serial收集器的时候就自动搭配了Serial Old收集器。
5.3.5Parallel Old
这个是Parallel Scavenge收集器的老年代版本,专门用于与Parallel Scavenge搭配使用。不用手动开启,在我们开启Parallel Scavenge收集器的时候自动使用。
5.3.6CMS
Concurrent-Mark-Sweep收集器,它是并发收集,低停顿的。它的目标就是尽量减少停顿时间,我们通过-XX:+UseConcMarkSweepGC开启CMS收集器,打开后就会使用ParNew+CMS+Serial Old组合,而Serial Old是作为CMS回收失败时的后备GC。
5.3.7G1
G1是现目前最前沿的技术,它跟上面所说的那些收集器完全不同。我们现在只需要知道它的目标也是低停顿,与CMS目标一致,但CMS更为成熟,G1却潜力更大,可能在Java9作为默认的垃圾收集器。
我们可以通过-XX:+UseG1GC来指定使用G1收集器。
后记:
在最新的Java 11中,已经提供了ZGC这种新型的垃圾回收器了,相比G1不再像其他垃圾回收器一样将新生代,老年代分为固定内存区域,而是分成了很多个Region,每个Region可以是新生代或者老年代。它更加灵活,在大堆的情况下能够显著改善内存回收的停顿时间。
而ZGC更是逆天,无论堆内存大小是多大,最高的JVM停顿不超过10ms,而SPECjbb2015测试中,128G的堆内存,最大停顿才1.68ms,是最大。这样一来基本就可以告别上文所说的一切调优了,但无论如何,静下心来学习始终是一件有着重要意义的事情(并不是可惜我啃了这么久的书后面发现可能再也用不上了),对于ZGC有兴趣的同学可以自己去看看,Java 11是一个值得期待的版本。
网友评论