目录
1.0.什么是JVM
2.0 JVM的组成
2.1类加载器子系统
2.2运行时数据区
2.3.程序计数器
2.4.Java虚拟机栈
2.5.本地方法栈
2.6.方法区
2.7.堆内存
2.8元空间
2.9.执行引擎
3.0 JVM内存溢出
3.1.1堆内存溢出
3.2.2 虚拟机栈/本地方法栈溢出
3.3. 方法区溢出-元空间
3.4.本机直接内存溢出
4.0 JVM垃圾回收
4.1 判断对象是否已死
4.2 常用垃圾回收算法
4.2.常见垃圾收集器
5.0 JVM的优化
5.1JIT编译器优化
5.2JVM内存分区优化
5.3.jvisualvm分析内存快照
5.4.内存溢出问题定位
5.5常用JVM参数参考
5.6.设置jvm参数的几种方式
与JVM的初次见面,是在我们Java开始的时候就已经相见了.时隔多日,我们先来回顾一下.
Java的广告语是,”编写一次,到处运行”,而它凭借的就是JVM(Java Virtual Machine).而对于不同的平台,Windows,Linux,Mac OS等,有具体不同的JVM版本.这些JVM屏蔽了平台的不同,提供了统一的运行环境,让Java代码无需考虑平台的差异,运行在不同的环境中.
而至于JRE和JDK,就不再赘述了,包含关系应该很清楚的,而今天我们的重点就在于对JVM的进一步认识以及对它进行优化调整.
1.1为什么要优化JVM
正如前面我们所回顾的,我们的Java代码都是运行在JVM中的,而部署的硬件及应用场景有所不同时,仍然采用默认的配置不见得能起到最好的效果,甚至可能会导致运行效率更差,又或者面临高并发情况下,想让程序平稳顺畅的运行,所以我们需要针对实际的需要来进行优化.
所谓优化就是配置一些参数,让jvm运行时使用这些参数,让jvm运行的程序更优。
1.2分析工具(jvisualvm)
我们只知道有JVM的存在,但它的运行对于我们来说感觉像是摸不着看不见的,所以我们需要借助工具来监控它的一个实时状态,就像Windows的性能监视器一样,JDK也有自己的可视化工具.Java提供了2个监视工具:
我们以管理员身份运行DOS ,输入jvisualvm,将Java VisualVM启动,输入jvisualvm,将Java VisualVM启动
在这里我们可以看到
本地列表中有多个条目,而一眼也可以看到我们SpringBoot项目的main方法,直接双击,经过短时间的加载后,得到这样一个界面
这个是概述页面,可以得到很多信息,但对于我们分析JVM的运行还是没有什么帮助,所以我们切换到监视页
应该还是很直观的 , 现在安装插件,插件的安装属于VisualVM的一个重要功能,凭借插件我们可以将这个工具的功能变得更强大。
打开工具 -> 插件 -> 选择“可用插件”页 : 我们在这里安装一个Visual GC,方便我们看到内存回收以及各个分代的情况 . 打上勾之后点击安装,就是常规的next以及同意协议等 ,网络不是很稳定,有时候可能需要多尝试几次。可以在设置中修改插件中心地址:
根据如下步骤修改地址:找到插件中心
VisualVM: Plugins Centers
找到对应的JDK版本:
Update Center documentation
复制插件地址:
安装插件:
然后再 可用插件中 找到 Visual GC
安装完成后我们将当前监控页关掉,再次打开,就可以看到Profiler后面多了一个Visual GC页。
在这里我们可以看到JIT活动时间,类加载活动时间,GC活动时间以及各个分代的情况。
需要注意的是,当前课件使用的JDK版本为1.8,仍然自带了VisualVM,从1.9开始的版本是没有自带的,需要额外下载,下载的github地址:
VisualVM: Download
另外,如果开发工具使用的是Intellij IDEA的话,可以下载一个插件,VisualVM Launcher,通过插件启动可以直接到上述页面,不用在左边的条目中寻找自己的项目.
当然也有其他的工具,但这个在可预见的未来都会是主力发展的多合一故障处理工具.所以我们后面将会使用这个工具来分析我们的JVM运行情况,进而优化.而需要优化我们还需要对JVM的组成有进一步的了解.接下来我们来看一下JVM的组成
从图上可以看到,大致分为以下组件:
而本地库接口也就是用于调用本地方法的接口,在此我们不细说,主要关注的是上述的4个组件
顾名思义,这是用于类加载的一个子系统.
2.1.1类加载的过程
类加载的过程包括了加载,验证,准备,解析和初始化这5个步骤
如上过程都是在JVM执行的过程中自己完成的,我们无需干涉。
2.2.2类与类加载器
每一个类,都需要和它的类加载器一起确定其在JVM中的唯一性.换句话来说,不同类加载器加载的同一个字节码文件,得到的类都不相等.我们可以通过默认加载器去加载一个类,然后new一个对象,再通过自己定义的一个类加载器,去加载同一个字节码文件,拿前面得到的对象去instanceof,会得到的结果是false.
2.2.3双亲委派机制
JVM中内置的类加载器:
类加载时使用了双亲委派模式:
载规则,优先使用爷爷加载,如果没有加载到再使用它爹加载,如果他爹也没有加载到,才到自己加载,如果自己也没有加载到才报ClassNotFountException。在这过程中只要上一级加载到了,下一级就不会加载了,这麽做的目的:
类加载器一般有4种,其中前3种是必然存在的
而双亲委派机制是如何运作的呢?
我们以应用程序类加载器举例,它在需要加载一个类的时候,不会直接去尝试加载,而是委托上级的扩展类加载器去加载,而扩展类加载器也是委托启动类加载器去加载.
启动类加载器在自己的搜索范围内没有找到这么一个类,表示自己无法加载,就再让扩展类加载器去加载,同样的,扩展类加载器在自己的搜索范围内找一遍,如果还是没有找到,就委托应用程序类加载器去加载.如果最终还是没找到,那就会直接抛出异常了.
而为什么要这么麻烦的从下到上,再从上到下呢?
这是为了安全着想,保证按照优先级加载.如果用户自己编写一个名为java.lang.Object的类,放到自己的Classpath中,没有这种优先级保证,应用程序类加载器就把这个当做Object加载到了内存中,从而会引发一片混乱.而凭借这种双亲委派机制,先一路向上委托,启动类加载器去找的时候,就把正确的Object加载到了内存中,后面再加载自行编写的Object的时候,是不会加载运行的.
结论:JDK自带的类是没法覆盖的,而引入的三方的JAR是可以自己定义相同的类来覆盖的。
案例测试:分别自定义类来覆盖JDK自带的String 类 , 和导入一个三方jar lang3包中的StringUtils 类,看是否能覆盖。
覆盖String
覆盖StringUtils :
JDK1.8以后,方法区被元空间替代,没有方法区了,元空间直接使用本地内存
程序计数器是线程私有的,虽然名字叫计数器,但主要用途还是用来确定指令的执行顺序,比如循环,分支,跳转,异常捕获等.而JVM对于多线程的实现是通过轮流切换线程实现的,所以为了保证每个线程都能按正确顺序执行,将程序计数器作为线程私有.程序计数器是唯一一个JVM没有规定任何OOM的区块.(out of memory)
程序计数器是一块非常小的内存空间,可以看做是当前线程执行字节码的行号指示器,每个线程都有一个独立的程序计数器,因此程序计数器是线程私有的一块空间,此外,程序计数器是Java虚拟机规定的唯一不会发生内存溢出的区域。
Java虚拟机栈也是线程私有的,每个方法执行都会创建一个栈帧,局部变量就存放在栈帧中,还有一些其他的动态链接之类的.通常有两个错误会跟这个有关系,一个是StackOverFlowError,一个是OOM(OutOfMemoryError).前者是因为线程请求栈深度超出虚拟机所允许的范围,后者是动态扩展栈的大小的时候,申请不到足够的内存空间.而前者提到的栈深度,也就是刚才说到的每个方法会创建一个栈帧,栈帧从开始执行方法时压入Java虚拟机栈,执行完的时候弹出栈.当压入的栈帧太多了,就会报出这个StackOverflowError.
虚拟机会为每个线程分配一个虚拟机栈,每个虚拟机栈中都有若干个栈帧,每个栈帧中存储了局部变量表、操作数栈、动态链接、返回地址等。一个栈帧就对应Java代码中的一个方法,当线程执行到一个方法时,就代表这个方法对应的栈帧已经进入虚拟机栈并且处于栈顶的位置,每一个Java方法从被调用到执行结束,就对应了一个栈帧从入栈到出栈的过程。
需要将这些方法的符号引用转化为其在内存地址中的直接引用,而符号引用存在于方法区中的运行时常量池。
Java虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用 的目的是为了支持方法调用过程中的动态连接(Dynamic Linking)。
这些符号引用一部分会在类加载阶段或者第一次使用时就直接转化为直接引用,这类转化称为静态解 析。另一部分将在每次运行期间转化为直接引用,这类转化称为动态连接。
方法返回指令 : 执行引擎遇到一个方法返回的字节码指令,这时候有可能会有返回值传递给上层的 方法调用者,这种退出方式称为正常完成出口。
异常退出 : 在方法执行过程中遇到了异常,并且没有处理这个异常,就会导致方法退出。
无论采用任何退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方 法返回时可能需要在栈帧中保存一些信。用来帮助恢复它的上层方法的执行状态。
方法退出的过程实际上就等于把当前栈帧出栈,因此退出可能执行的操作有:
1.恢复上层方法的局部变量表和操作数栈
2.把返回值(如果存在返回值)压入调用者栈帧的操作数栈中
3.调整PC计数器的值以指向方法调用指令后面的一条指令
本地方法栈与虚拟机栈的区别是,虚拟机栈执行的是Java方法,本地方法栈执行的是本地方法(Native Method),其他基本上一致,在HotSpot中直接把本地方法栈和虚拟机栈合二为一,这里暂时不做过多叙述。
https://xiaomogui.iteye.com/blog/857821
方法区主要用于存储虚拟机加载的类信息、常量、静态变量,以及编译器编译后的代码等数据。在jdk1.7及其之前,方法区是堆的一个“逻辑部分”(一片连续的堆空间),但为了与堆做区分,方法区还有个名字叫“非堆”,也有人用“永久代”(HotSpot对方法区的实现方法)来表示方法区。
从jdk1.7已经开始准备“去永久代”的规划,jdk1.7的HotSpot中,已经把原本放在方法区中的静态变量、字符串常量池等移到堆内存中,(常量池除字符串常量池还有class常量池等),这里只是把字符串常量池移到堆内存中;在jdk1.8中,方法区已经不存在,原方法区中存储的类信息、编译后的代码数据等已经移动到了元空间(MetaSpace)中,元空间并没有处于堆内存上,而是直接占用的本地内存(NativeMemory)。
去永久代的原因有:
(1)字符串存在永久代中,容易出现性能问题和内存溢出。
(2)类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
(3)永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
gc garbage collection
堆和方法区一样(确切来说JVM规范中方法区就是堆的一个逻辑分区),就是一个所有线程共享的,存放对象的区域,也是GC的主要区域.其中的分区分为新生代,老年代.新生代中又可以细分为一个Eden,两个Survivor区(From,To).Eden中存放的是通过new 或者newInstance方法创建出来的对象,绝大多数都是很短命的.正常情况下经历一次gc之后,存活的对象会转入到其中一个Survivor区,然后再经历默认15次的gc,就转入到老年代.这是常规状态下,在Survivor区已经满了的情况下,JVM会依据担保机制将一些对象直接放入老年代。
堆内存主要用于存放对象和数组,它是JVM管理的内存中最大的一块区域,堆内存和方法区都被所有线程共享,在虚拟机启动时创建。在垃圾收集的层面上来看,由于现在收集器基本上都采用分代收集算法,因此堆还可以分为新生代(YoungGeneration)和老年代(OldGeneration),新生代还可以分为Eden、From Survivor、To Survivor
上面说到,jdk1.8中,已经不存在永久代(方法区),替代它的一块空间叫做“元空间”,和永久代类似,都是JVM规范对方法区的实现,但是元空间并不在虚拟机中,而是使用本地内存,元空间的大小仅受本地内存限制,但可以通过-XX:MetaspaceSize和-XX:MaxMetaspaceSize来指定元空间的大小
Jdk1.8去除了方法区,取而代之是元空间,直接使用本地内存
执行引擎包含即时编译器(JIT)和垃圾回收器(GC),对即时编译器我们简单介绍一下,主要重点在于垃圾回收器.
2.9.1即时编译器(JIT,Just-In-Time Compiler)
看到这个东西的存在可能有些人会感到疑问,不是通过javac命令就把我们的java代码编译成字节码文件了吗,这个即时编译器又是干嘛的?
我们需要明确一个概念就是,计算机实际上只认识0和1,这种由0和1组成的命令集称之为”机器码”,而且会根据平台不同而有所不同,可读性和可移植性极差.我们的字节码文件包含的并不是机器码,不能由计算机直接运行,而需要JVM”解释”执行.JVM将字节码文件中所写的命令解释成一个个计算机操作命令,再通知计算机进行运算.
总结:Javac把源文件编译成字节码文件,即使编译JIT把字节码文件中的命令编译成机器码即计算机操作命令去执行。
堆内存中主要存放对象、数组等,只要不断地创建这些对象,并且保证GC Roots到对象之间有可达路径来避免垃圾收集回收机制清除这些对象,当这些对象所占空间超过最大堆容量时,就会产生OutOfMemoryError的异常。堆内存异常示例如下:
/**
* 设置最大堆最小堆:-Xms20m -Xmx20m
* 运行时,不断在堆中创建OOMObject类的实例对象,且while执行结束之前,GC Roots(代码中的oomObjectList)到对象(每一个OOMObject对象)之间有可达路径,垃圾收集器就无法回收它们,最终导致内存溢出。
*/
public class HeapOOM {
static class OOMObject {
}
public static void main(String[] args) {
List oomObjectList = new ArrayList<>();
while (true) {
oomObjectList.add(new OOMObject());
}
}
}
运行后会报异常,在堆栈信息中可以看到 java.lang.OutOfMemoryError: Java heap space 的信息,说明在堆内存空间产生内存溢出的异常。
新产生的对象最初分配在新生代,新生代满后会进行一次Minor GC,如果Minor GC后空间不足会把该对象和新生代满足条件的对象放入老年代,老年代空间不足时会进行Full GC,之后如果空间还不足以存放新对象则抛出OutOfMemoryError异常。常见原因:内存中加载的数据过多如一次从数据库中取出过多数据;集合对对象引用过多且使用完后没有清空;代码中存在死循环或循环产生过多重复对象;堆内存分配不合理;网络连接问题、数据库问题等。
不会自己改ide的对空间,通过虚拟机参数修改该运行空间.
修改所有:
单个修改:
StackOverflowError:当线程请求的栈的深度大于虚拟机所允许的最大深度,则抛出StackOverflowError,简单理解就是虚拟机栈中的栈帧数量过多(一个线程嵌套调用的方法数量过多)时,就会抛出StackOverflowError异常。最常见的场景就是方法无限递归调用,如下:
/**
* 设置每个线程的栈大小:-Xss256k
* 运行时,不断调用doSomething()方法,main线程不断创建栈帧并入栈,导致栈的深度越来越大,最终导致栈溢出。
*/
public class StackSOF {
private int stackLength=1;
public void doSomething(){
stackLength++;
doSomething();
}
public static void main(String[] args) {
StackSOF stackSOF=new StackSOF();
try {
stackSOF.doSomething();
}catch (Throwable e){//注意捕获的是Throwable
System.out.println("栈深度:"+stackSOF.stackLength);
throw e;
}
}
}
上述代码执行后抛出:Exception in thread “Thread-0” java.lang.StackOverflowError的异常。
OutOfMemoryError:如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError。我们可以这样理解,虚拟机中可以供栈占用的空间≈可用物理内存 - 最大堆内存 - 最大方法区内存,比如一台机器内存为4G,系统和其他应用占用2G,虚拟机可用的物理内存为2G,最大堆内存为1G,最大方法区内存为512M,那可供栈占有的内存大约就是512M,假如我们设置每个线程栈的大小为1M,那虚拟机中最多可以创建512个线程,超过512个线程再创建就没有空间可以给栈了,就报OutOfMemoryError异常了。
栈上能够产生OutOfMemoryError的示例如下:
/**
* 设置每个线程的栈大小:-Xss2m
* 运行时,不断创建新的线程(且每个线程持续执行),每个线程对一个一个栈,最终没有多余的空间来为新的线程分配,导致OutOfMemoryError
*/
public class StackOOM {
private static int threadNum = 0;
public void doSomething() {
try {
Thread.sleep(100000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
final StackOOM stackOOM = new StackOOM();
try {
while (true) {
threadNum++;
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
stackOOM.doSomething();
}
});
thread.start();
}
} catch (Throwable e) {
System.out.println("目前活动线程数量:" + threadNum);
throw e;
}
}
}
上述代码运行后会报异常,在堆栈信息中可以看到 java.lang.OutOfMemoryError: unable to create new native thread的信息,无法创建新的线程,说明是在扩展栈的时候产生的内存溢出异常。
总结:在线程较少的时候,某个线程请求深度过大,会报StackOverflow异常,解决这种问题可以适当加大栈的深度(增加栈空间大小),也就是把-Xss的值设置大一些,但一般情况下是代码问题的可能性较大;在虚拟机产生线程时,无法为该线程申请栈空间了,会报OutOfMemoryError异常,解决这种问题可以适当减小栈的深度,也就是把-Xss的值设置小一些,每个线程占用的空间小了,总空间一定就能容纳更多的线程,但是操作系统对一个进程的线程数有限制,经验值在3000~5000左右。在jdk1.5之前-Xss默认是256k,jdk1.5之后默认是1M,这个选项对系统硬性还是蛮大的,设置时要根据实际情况,谨慎操作。
方法区主要用于存储虚拟机加载的类信息、常量、静态变量,以及编译器编译后的代码等数据,所以方法区溢出的原因就是没有足够的内存来存放这些数据。
由于在jdk1.6之前字符串常量池是存在于方法区中的,所以基于jdk1.6之前的虚拟机,可以通过不断产生不一致的字符串(同时要保证和GC Roots之间保证有可达路径)来模拟方法区的OutOfMemoryError异常;但方法区还存储加载的类信息,所以基于jdk1.7的虚拟机,可以通过动态不断创建大量的类来模拟方法区溢出。
本机直接内存(DirectMemory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但Java中用到NIO相关操作时(比如ByteBuffer的allocteDirect方法申请的是本机直接内存),也可能会出现内存溢出的异常。
垃圾回收,就是通过垃圾收集器把内存中没用的对象清理掉。
垃圾回收涉及到的内容有:
1、判断对象是否已死; 是否为垃圾
2、选择垃圾收集算法;
3、选择垃圾收集的时间;
4、选择适当的垃圾收集器清理垃圾(已死的对象)。
判断对象是否已死就是找出哪些对象是已经死掉的,以后不会再用到的,就像地上有废纸、饮料瓶和百元大钞,扫地前要先判断出地上废纸和饮料瓶是垃圾,百元大钞不是垃圾。判断对象是否已死有引用计数算法和可达性分析算法。
(1)引用计数算法
给每一个对象添加一个引用计数器,每当有一个地方引用它时,计数器值加1;每当有一个地方不再引用它时,计数器值减1,这样只要计数器的值不为0,就说明还有地方引用它,它就不是无用的对象。如下图,对象2有1个引用,它的引用计数器值为1,对象1有两个地方引用,它的引用计数器值为2 。
这种方法看起来非常简单,但目前许多主流的虚拟机都没有选用这种算法来管理内存,原因就是当某些对象之间互相引用时,无法判断出这些对象是否已死,如下图,对象1和对象2都没有被堆外的变量引用,而是被对方互相引用,这时他们虽然没有用处了,但是引用计数器的值仍然是1,无法判断他们是死对象,垃圾回收器也就无法回收。
(2)可达性分析算法
了解可达性分析算法之前先了解一个概念——GC Roots,垃圾收集的起点,可以作为GC Roots的有虚拟机栈中本地变量表中引用的对象、方法区中静态属性引用的对象、方法区中常量引用的对象、本地方法栈中JNI(Native方法)引用的对象。
当一个对象到GC Roots没有任何引用链相连(GC Roots到这个对象不可达)时,就说明此对象是不可用的,是死对象。如下图:object1、object2、object3、object4和GC Roots之间有可达路径,这些对象不会被回收,但object5、object6、object7到GC Roots之间没有可达路径,这些对象就被判了死刑。
上面被判了死刑的对象(object5、object6、object7)并不是必死无疑,还有挽救的余地。进行可达性分析后对象和GC Roots之间没有引用链相连时,对象将会被进行一次标记,接着会判断如果对象没有覆盖Object的finalize()方法或者finalize()方法已经被虚拟机调用过,那么它们就会被行刑(清除);如果对象覆盖了finalize()方法且还没有被调用,则会执行finalize()方法中的内容,所以在finalize()方法中如果重新与GC Roots引用链上的对象关联就可以拯救自己,但是一般不建议这么做.
(3)方法区回收
上面说的都是对堆内存中对象的判断,方法区中主要回收的是废弃的常量和无用的类。
判断常量是否废弃可以判断是否有地方引用这个常量,如果没有引用则为废弃的常量。
判断类是否废弃需要同时满足如下条件:
合理方案:堆空间使用可达性分析算法,堆里面常量使用引用计数法,元空间里面的类有自己算法.
常用的垃圾回收算法有三种:标记-清除算法、复制算法、标记-整理算法。
(1)标记-清除算法:
分为标记和清除两个阶段,首先标记出所有需要回收的对象,标记完成后统一回收所有被标记的对象,如下图。
缺点:标记和清除两个过程效率都不高;标记清除之后会产生大量不连续的内存碎片。
(2)复制算法
把内存分为大小相等的两块,每次存储只用其中一块,当这一块用完了,就把存活的对象全部复制到另一块上,同时把使用过的这块内存空间全部清理掉,往复循环,如下图。
缺点:实际可使用的内存空间缩小为原来的一半,比较适合
(3)标记-整理算法
先对可用的对象进行标记,然后所有被标记的对象向一段移动,最后清除可用对象边界以外的内存,如下图
(4)分代收集算法
把堆内存分为新生代和老年代,新生代又分为Eden区、From Survivor和To Survivor。一般新生代中的对象基本上都是朝生夕灭的,每次只有少量对象存活,因此新生代采用复制算法,只需要复制那些少量存活的对象就可以完成垃圾收集;老年代中的对象存活率较高,就采用标记-清除和标记-整理算法来进行回收。
在这些区域的垃圾回收大概有如下几种情况:
新生代使用时minor gc, 老年代使用的full gc 同时会触发一minor gc
(5)Minor GC和Full GC
在说这两种回收的区别之前,我们先来说一个概念,“Stop-The-World”。
如字面意思,每次垃圾回收的时候,都会将整个JVM暂停,回收完成后再继续。如果一边增加废弃对象,一边进行垃圾回收,完成工作似乎就变得遥遥无期了。
而一般来说,我们把新生代的回收称为Minor GC,Minor意思是次要的,新生代的回收一般回收很快,采用复制算法,造成的暂停时间很短。而Full GC一般是老年代的回收,并伴随至少一次的Minor GC,新生代和老年代都回收,而老年代采用标记-整理算法,这种GC每次都比较慢,造成的暂停时间比较长,通常是Minor GC时间的10倍以上。
所以很明显,我们需要尽量通过Minor GC来回收内存,而尽量少的触发Full GC。毕竟系统运行一会儿就要因为GC卡住一段时间,再加上其他的同步阻塞,整个系统给人的感觉就是又卡又慢。
解释:Eden空间不够,执行MinoGC, Eden和S0中存活的对象进入S1,对象年龄计数+1 ,
如果S1空间不够,直接进 入老年代 ,否则对象默认经过 15次MinorCG后才会进入老年代
解释:Minor GC之前,如果预测老年代内存不够,就进行Full GC老年代,否则就Minor GC新生代
现在常见的垃圾收集器有如下几种
每种垃圾收集器之间有连线,表示他们可以搭配使用。
Serial 新生代收集器:
Serial是一款用于新生代的单线程收集器,采用复制算法进行垃圾收集。Serial进行垃圾收集时,不仅只用一条线程执行垃圾收集工作,它在收集的同时,所有的用户线程必须暂停(Stop The World)。就比如妈妈在家打扫卫生的时候,肯定不会边打扫边让儿子往地上乱扔纸屑,否则一边制造垃圾,一遍清理垃圾,这活啥时候也干不完。
如下是Serial收集器和Serial Old收集器结合进行垃圾收集的示意图,当用户线程都执行到安全点时,所有线程暂停执行,Serial收集器以单线程,采用复制算法进行垃圾收集工作,收集完之后,用户线程继续开始执行。
适用场景:Client模式(桌面应用);单核服务器。可以用-XX:+UserSerialGC来选择Serial作为新生代收集器。
2.ParNew 新生代收集器:
ParNew就是一个Serial的多线程版本,其它与Serial并无区别。ParNew在单核CPU环境并不会比Serial收集器达到更好的效果,它默认开启的收集线程数和CPU数量一致,可以通过-XX:ParallelGCThreads来设置垃圾收集的线程数。
如下是ParNew收集器和Serial Old收集器结合进行垃圾收集的示意图,当用户线程都执行到安全点时,所有线程暂停执行,ParNew收集器以多线程,采用复制算法进行垃圾收集工作,收集完之后,用户线程继续开始执行。
3.Parallel Scavenge 新生代收集器:
Parallel Scavenge也是一款用于新生代的多线程收集器,与ParNew的不同之处是,ParNew的目标是尽可能缩短垃圾收集时用户线程的停顿时间,Parallel Scavenge的目标是达到一个可控制的吞吐量。吞吐量就是CPU执行用户线程的的时间与CPU执行总时间的比值【吞吐量=运行用户代代码时间/(运行用户代码时间+垃圾收集时间)】,比如虚拟机一共运行了100分钟,其中垃圾收集花费了1分钟,那吞吐量就是99% 。比如下面两个场景,垃圾收集器每100秒收集一次,每次停顿10秒,和垃圾收集器每50秒收集一次,每次停顿时间7秒,虽然后者每次停顿时间变短了,但是总体吞吐量变低了,CPU总体利用率变低了。
可以通过-XX:MaxGCPauseMillis来设置收集器尽可能在多长时间内完成内存回收,可以通过-XX:GCTimeRatio来精确控制吞吐量。
如下是Parallel收集器和Parallel Old收集器结合进行垃圾收集的示意图,在新生代,当用户线程都执行到安全点时,所有线程暂停执行,Parallel收集器以多线程,采用复制算法进行垃圾收集工作,收集完之后,用户线程继续开始执行;在老年代,当用户线程都执行到安全点时,所有线程暂停执行,Parallel Old收集器以多线程,采用标记整理算法进行垃圾收集工作。
适用场景:注重吞吐量,高效利用CPU,需要高效运算且不需要太多交互。可以使用-XX:+UseParallelGC来选择Parallel Scavenge作为新生代收集器,jdk7、jdk8默认使用Parallel Scavenge作为新生代收集器。
普及几个概念: pv uv tqs qps
4.Serial Old老年代收集器:
Serial Old收集器是Serial的老年代版本,同样是一个单线程收集器,采用标记-整理算法。
如下图是Serial收集器和Serial Old收集器结合进行垃圾收集的示意图:
适用场景:Client模式(桌面应用);单核服务器;与Parallel Scavenge收集器搭配;作为CMS收集器的后备预案。
5.CMS(Concurrent Mark Sweep) 老年代收集器:
CMS收集器是一种以最短回收停顿时间为目标的收集器,以“最短用户线程停顿时间”著称。整个垃圾收集过程分为4个步骤
① 初始标记:标记一下GC Roots能直接关联到的对象,速度较快
② 并发标记:进行GC Roots Tracing,标记出全部的垃圾对象,耗时较长
③ 重新标记:修正并发标记阶段引用户程序继续运行而导致变化的对象的标记记录,耗时较短
④ 并发清除:用标记-清除算法清除垃圾对象,耗时较长
整个过程耗时最长的并发标记和并发清除都是和用户线程一起工作,所以从总体上来说,CMS收集器垃圾收集可以看做是和用户线程并发执行的。
CMS收集器也存在一些缺点:
6.Parallel Old 老年代收集器:
Parallel Old收集器是Parallel Scavenge的老年代版本,是一个多线程收集器,采用标记-整理算法。可以与Parallel Scavenge收集器搭配,可以充分利用多核CPU的计算能力。
适用场景:与Parallel Scavenge收集器搭配使用;注重吞吐量。jdk7、jdk8默认使用该收集器作为老年代收集器,使用 -XX:+UseParallelOldGC来指定使用Paralle Old收集器。
7.G1 收集器:
G1 收集器是jdk1.7才正式引用的商用收集器,现在已经成为jdk1.9默认的收集器。前面几款收集器收集的范围都是新生代或者老年代,G1进行垃圾收集的范围是整个堆内存,它采用“化整为零”的思路,把整个堆内存划分为多个大小相等的独立区域(Region),在G1收集器中还保留着新生代和老年代的概念,它们分别都是一部分Region,如下图:
每一个方块就是一个区域,每个区域可能是Eden、Survivor、老年代,每种区域的数量也不一定。JVM启动时会自动设置每个区域的大小(1M~32M,必须是2的次幂),最多可以设置2048个区域(即支持的最大堆内存为32M*2048=64G),假如设置-Xmx8g -Xms8g,则每个区域大小为8g/2048=4M。
为了在GC Roots Tracing的时候避免扫描全堆,在每个Region中,都有一个Remembered Set来实时记录该区域内的引用类型数据与其他区域数据的引用关系(在前面的几款分代收集中,新生代、老年代中也有一个Remembered Set来实时记录与其他区域的引用关系),在标记时直接参考这些引用关系就可以知道这些对象是否应该被清除,而不用扫描全堆的数据。
G1收集器可以“建立可预测的停顿时间模型”,它维护了一个列表用于记录每个Region回收的价值大小(回收后获得的空间大小以及回收所需时间的经验值),这样可以保证G1收集器在有限的时间内可以获得最大的回收效率。
如下图所示,G1收集器收集器收集过程有初始标记、并发标记、最终标记、筛选回收,和CMS收集器前几步的收集过程很相似:
① 初始标记:标记出GC Roots直接关联的对象,这个阶段速度较快,需要停止用户线程,单线程执行
② 并发标记:从GC Root开始对堆中的对象进行可达新分析,找出存活对象,这个阶段耗时较长,但可以和用户线程并发执行
③ 最终标记:修正在并发标记阶段引用户程序执行而产生变动的标记记录
④ 筛选回收:筛选回收阶段会对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来指定回收计划(用最少的时间来回收包含垃圾最多的区域,这就是Garbage First的由来——第一时间清理垃圾最多的区块),这里为了提高回收效率,并没有采用和用户线程并发执行的方式,而是停顿用户线程。
适用场景:要求尽可能可控GC停顿时间;内存占用较大的应用。可以用-XX:+UseG1GC使用G1收集器,jdk9默认使用G1收集器。
Jdk1.7.18新生代使用Parallel Scavenge,老年代使用Parallel Old
Pv uv tqs qps
JVM调优目标:使用较小的内存占用来获得较高的吞吐量或者较低的延迟。
程序在上线前的测试或运行中有时会出现一些大大小小的JVM问题,比如cpu load过高、请求延迟、tps降低等,甚至出现内存泄漏(每次垃圾收集使用的时间越来越长,垃圾收集频率越来越高,每次垃圾收集清理掉的垃圾数据越来越少)、内存溢出导致系统崩溃,因此需要对JVM进行调优,使得程序在正常运行的前提下,获得更高的用户体验和运行效率。
这里有几个比较重要的指标:
当然,和CAP原则一样,同时满足一个程序内存占用小、延迟
低、高吞吐量是不可能的,程序的目标不同,调优时所考虑的方向也不同,在调优之前,必须要结合实际场景,有明确的的优化目标,找到性能瓶颈,对瓶颈有针对性的优化,最后进行测试,通过各种监控工具确认调优后的结果是否符合目标。
(1)调优可以依赖、参考的数据有系统运行日志、堆栈错误信息、gc日志、线程快照、堆转储快照等。
① 系统运行日志:系统运行日志就是在程序代码中打印出的日志,描述了代码级别的系统运行轨迹(执行的方法、入参、返回值等),一般系统出现问题,系统运行日志是首先要查看的日志。
② 堆栈错误信息:当系统出现异常后,可以根据堆栈信息初步定位问题所在,比如根据“java.lang.OutOfMemoryError: Java heap space”可以判断是堆内存溢出;根据“java.lang.StackOverflowError”可以判断是栈溢出;根据“java.lang.OutOfMemoryError: PermGen space”可以判断是方法区溢出等。
③ GC日志:程序启动时用 -XX:+PrintGCDetails 和 -Xloggc:/data/jvm/gc.log 可以在程序运行时把gc的详细过程记录下来,或者直接配置“-verbose:gc”参数把gc日志打印到控制台,通过记录的gc日志可以分析每块内存区域gc的频率、时间等,从而发现问题,进行有针对性的优化。
④ 线程快照:顾名思义,根据线程快照可以看到线程在某一时刻的状态,当系统中可能存在请求超时、死循环、死锁等情况是,可以根据线程快照来进一步确定问题。通过执行虚拟机自带的“jstack pid”命令,可以dump出当前进程中线程的快照信息,更详细的使用和分析网上有很多例,这篇文章写到这里已经很长了就不过多叙述了,贴一篇博客供参考:http://www.cnblogs.com/kongzhongqijing/articles/3630264.html
⑤ 堆转储快照:程序启动时可以使用 “-XX:+HeapDumpOnOutOfMemory” 和 “-XX:HeapDumpPath=/data/jvm/dumpfile.hprof”,当程序发生内存溢出时,把当时的内存快照以文件形式进行转储(也可以直接用jmap命令转储程序运行时任意时刻的内存快照),事后对当时的内存使用情况进行分析。
JVM的优化我们可以从JIT优化,内存分区设置优化以及GC选择优化三个方面入手。
正如前面所说的,在系统启动的时候,首先Java代码是解释执行的,当方法调用次数到达一定的阈值的时候(client:1500,server:10000),会采用JIT优化编译。而直接将JVM的启动设置为-Xcomp并不会有想象中那么好。没有足够的profile(侧写,可以大致理解为分析结果),优化出来的代码质量很差,甚至于执行效率还要低于解释器执行,并且机器码的大小很容易就超出字节码大小的10倍以上。
那么我们能做的,就是通过附加启动命令适当的调整这个阈值或者调整热度衰减行为,在恰当的时候触发对代码进行即时编译。
方法计数器阈值:-XX:CompileThreshold
回边计数器阈值:-XX:OnStackReplacePercentage(这并不是直接调整阈值,回边计数器的调整在此仅作简单介绍,此计数器会根据是Client模式还是Server模式有不同的计算公式)
关闭热度衰减:-XX:UseCounterDecay
设置半衰周期:-XX:CounterHalfLifeTime
而JIT也是一片广阔的知识海洋,有兴趣可以根据以下的优化技术名称搜索了解详情,在此就不赘述了。
1.使用jps,jmap分析内存快照
jstat -gc pid 500 10 :pid是线程ID,每500毫秒打印一次Java堆状况(各个区的容量、使用容量、gc时间等信息),打印10次
jstat还可以以其他角度监视各区内存大小、监视类装载信息等,具体可以google jstat的详细用法。
执行jmap -dump 可以转储堆内存快照到指定文件,比如执行
jmap -dump:format=b,file=/data/jvm/dumpfile_jmap.hprof PID ,可以把当前堆内存的快照转储到dumpfile_jmap.hprof文件中,然后可以对内存快照进行分析。
⑤ 分析堆转储快照
前面说到配置了 “-XX:+HeapDumpOnOutOfMemory” 参数可以在程序发生内存溢出时dump出当前的内存快照,也可以用jmap命令随时dump出当时内存状态的快照信息,dump的内存快照一般是以.hprof为后缀的二进制格式文件。
可以直接用 jhat(JVM Heap Analysis Tool) 命令来分析内存快照,它的本质实际上内嵌了一个微型的服务器,可以通过浏览器来分析对应的内存快照,比如执行 jhat -port 9810 -J-Xmx4G /data/jvm/dumpfile_jmap.hprof 表示以9810端口启动 jhat 内嵌的服务器:
在控制台可以看到服务器启动了,访问 http://127.0.0.1:9810/ 可以看到对快照中的每个类进行分析的结果。
jvisualvm也可以分析内存快照,在jvisualvm菜单的“文件”-“装入”,选择堆内存快照,快照中的信息就以图形界面展示出来了,
或者直接使用监视 - 堆Dump :如下,主要可以查看每个类占用的空间、实例的数量和实例的详情等:
制造内存溢出:
new Thread(new Runnable() {
List list = new ArrayList<>();
@Override
public void run() {
while(true){
list.add(new Object());
System.out.println(Thread.currentThread().getName()+"线程执行.....");
}
}
}).start();
使用Java Visual VM 分析日志
文件 -> 载入 -> 文件类型选择堆 -> 打开日志
概要中可以看到内存溢出异常,Thread-61 ,点击进去
这里可以看到是哪一行,哪个对象造成的内存溢出
参数 说明 实例
-Xms 初始堆大小,默认物理内存的1/64 -Xms512M
-Xmx 最大堆大小,默认物理内存的1/4 -Xms2G
-Xmn 新生代内存大小,官方推荐为整个堆的3/8 -Xmn512M
-Xss 线程堆栈大小,jdk1.5及之后默认1M,之前默认256k -Xss512k
-XX:NewRatio=n 设置新生代和年老代的比值。如:为3,表示年轻代与年老代比值为1:3,年轻代占整个年轻代年老代和的1/4 -XX:NewRatio=3
-XX:SurvivorRatio=n 年轻代中Eden区与两个Survivor区的比值。注意Survivor区有两个。如:8,表示Eden:Survivor=8:1:1,一个Survivor区占整个年轻代的1/8 -XX:SurvivorRatio=8
-XX:PermSize=n 永久代初始值,默认为物理内存的1/64 -XX:PermSize=128M
-XX:MaxPermSize=n 永久代最大值,默认为物理内存的1/4 -XX:MaxPermSize=256M
-verbose:class 在控制台打印类加载信息
-verbose:gc 在控制台打印垃圾回收日志
-XX:+PrintGC 打印GC日志,内容简单
-XX:+PrintGCDetails 打印GC日志,内容详细
-XX:+PrintGCDateStamps 在GC日志中添加时间戳
-Xloggc:filename 指定gc日志路径 -Xloggc:/data/jvm/gc.log
-XX:+UseSerialGC 年轻代设置串行收集器Serial
-XX:+UseParallelGC 年轻代设置并行收集器Parallel Scavenge
-XX:ParallelGCThreads=n设置Parallel Scavenge收集时使用的CPU数。并行收集线程数。 -XX:ParallelGCThreads=4
-XX:MaxGCPauseMillis=n 设置Parallel Scavenge回收的最大时间(毫秒) -XX:MaxGCPauseMillis=100
-XX:GCTimeRatio=n设置Parallel Scavenge垃圾回收时间占程序运行时间的百分比。公式为1/(1+n) -XX:GCTimeRatio=19
-XX:+UseParallelOldGC 设置老年代为并行收集器ParallelOld收集器
-XX:+UseConcMarkSweepGC 设置老年代并发收集器CMS
-XX:+CMSIncrementalMode 设置CMS收集器为增量模式,适用于单CPU情况。
jdk1.7 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代)
jdk1.8 默认垃圾收集器Parallel Scavenge(新生代)+Parallel Old(老年代)
jdk1.9 默认垃圾收集器G1
-XX:+PrintCommandLineFlags jvm参数可查看默认设置收集器类型
-XX:+PrintGCDetails亦可通过打印的GC日志的新生代、老年代名称判断
JVM调优经验
我们依据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倍。
JVM配置方面,一般情况可以先用默认配置(基本的一些初始参数可以保证一般的应用跑的比较稳定了),在测试中根据系统运行状况(会话并发情况、会话时间等),结合gc日志、内存监控、使用的垃圾收集器等进行合理的调整,当老年代内存过小时可能引起频繁Full GC,当内存过大时Full GC时间会特别长。
那么JVM的配置比如新生代、老年代应该配置多大最合适呢?答案是不一定,调优就是找答案的过程,物理内存一定的情况下,新生代设置越大,老年代就越小,Full GC频率就越高,但Full GC时间越短;相反新生代设置越小,老年代就越大,Full GC频率就越低,但每次Full GC消耗的时间越大。建议如下:
-Xms和-Xmx的值设置成相等,堆大小默认为-Xms指定的大小,默认空闲堆内存小于40%时,JVM会扩大堆到-Xmx指定的大小;空闲堆内存大于70%时,JVM会减小堆到-Xms指定的大小。如果在Full GC后满足不了内存需求会动态调整,这个阶段比较耗费资源。
新生代尽量设置大一些,让对象在新生代多存活一段时间,每次Minor GC 都要尽可能多的收集垃圾对象,防止或延迟对象进入老年代的机会,以减少应用程序发生Full GC的频率。
老年代如果使用CMS收集器,新生代可以不用太大,因为CMS的并行收集速度也很快,收集过程比较耗时的并发标记和并发清除阶段都可以与用户线程并发执行。
方法区大小的设置,1.6之前的需要考虑系统运行时动态增加的常量、静态变量等,1.7只要差不多能装下启动时和后期动态加载的类信息就行。
代码实现方面,性能出现问题比如程序等待、内存泄漏除了JVM配置可能存在问题,代码实现上也有很大关系:
避免创建过大的对象及数组:过大的对象或数组在新生代没有足够空间容纳时会直接进入老年代,如果是短命的大对象,会提前出发Full GC。
避免同时加载大量数据,如一次从数据库中取出大量数据,或者一次从Excel中读取大量记录,可以分批读取,用完尽快清空引用。
当集合中有对象的引用,这些对象使用完之后要尽快把集合中的引用清空,这些无用对象尽快回收避免进入老年代。
可以在合适的场景(如实现缓存)采用软引用、弱引用,比如用软引用来为ObjectA分配实例:SoftReference objectA=new SoftReference(); 在发生内存溢出前,会将objectA列入回收范围进行二次回收,如果这次回收还没有足够内存,才会抛出内存溢出的异常。
避免产生死循环,产生死循环后,循环体内可能重复产生大量实例,导致内存空间被迅速占满。
尽量避免长时间等待外部资源(数据库、网络、设备资源等)的情况,缩小对象的生命周期,避免进入老年代,如果不能及时返回结果可以适当采用异步处理的方式等。
eclipse.ini; idea,idea64.exe.vmoptions, 单个修改通过vm参数
2、解压版本的Tomcat, 通过startup.bat启动tomcat加载配置的,在tomcat 的bin 下catalina.bat 文件内添加; Linux服务器Tomcat设置JVM,修改TOMCAT_HOME/bin/catalina.sh; JAVA_OPTS
3 springboot项目
idea中配置
启动时:
java -jar -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=128m -Xms1024m -Xmx1024m -Xmn256m(年轻代) -Xss256k(线程栈) -XX:SurvivorRatio=8 -XX:+UseConcMarkSweepGC newframe-1.0.0.jar
over:当你读到这里的时候,你现在已经是一个优秀的未来架构师了。
关注松君呐,一起学习Java。