JVM调优——1、基础结构和机制
一、JVM类加载机制
1.1了解类加载器
public class TestDemo {
public static void main(String[] args) {
User user = new User();
user.setName("张三");
System.out.println(user.getName());
}
}
这里有个TestDemo类,当我们执行main方法后,jvm做了一系列操作:
其中loadClass的类加载过程有如下几步:
加载 >> 验证 >> 准备 >> 解析 >> 初始化 >> 使用 >> 卸载
- 加载:找到文件在硬盘的路径地址,使用到类的时候才会去加载,加载会生成内存地址。
- 验证:校验.class文件的格式正确性
- 解析:将符号或对象的引用替换为地址来引用
- 初始化:对类的静态变量初始化为指定的值,执行静态代码块
上面的类加载过程主要是通过类加载器来实现的
java中的类加载器有:
- 引导类加载器(bootstrapLoader C++实现的):加载安装JRE的lib目录下的核心类库
- 扩展类加载器(ExtClassLoader):加载安装JRE的lib目录下的ext扩展目录中的类库
- 应用程序类加载器(AppClassLoader):加载ClassPath路径下的类包,也就是加载程序员自己写的那些类
- 自定义加载器: 开发者自定义的加载器 自己指定要加载的目录
1.2类加载器的双亲委派机制
通过看源码我们可以看到:
应用程序类加载器(AppClassLoader) 的父类是 => 扩展类加载器(ExtClassLoader)
扩展类加载器(ExtClassLoader) 的父类是 => 引导类加载器 (bootstrapLoader C++实现的)
知道他们的父子关系,我们就可以分析加载过程了:
如上图,这就是双亲委派机制:
加载某个类时会先委托父加载器寻找目标类,找不到再委托上层父加载器加载,如果所有父加载器在自己的加载类路径下都找不到目标类,则在自己的类加载路径中查找并载入目标类。
1.3 为什么要设计双亲委派机制?
- 沙箱安全机制:
如果你自己写一个java.lang.String.class 类(跟jdk自带的类一样,包名什么都完全一样)是不会被加载的,这样便可以防止核心API库被随意篡改。 - 避免类的重复加载:
当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次,保证被加载类的唯一性
1.4打破双亲委派机制
通过查看源码,会发现:类加载器 继承了ClassLoader 类,该类中有一个loadClass(String, boolean)的方法,它实现了双亲委派机制。
如果我们要打破它的规则就需要重写该loadClass方法。
/**
* 重写类加载方法,实现自己的加载逻辑,不委派给双亲加载
* @param name
* @param resolve
* @return
* @throws ClassNotFoundException
*/
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
}
二、JVM整体结构及内存模型
2.1JDK体系结构图
从图中我们可以看到,JVM是 hotSpot VM,然而HotSpot Vm 的实现并不是由java代码来完成的,所以我们如果想完全搞懂jvm的源码并不容易(大部分是C++完成)。
2.2 JVM结构图
堆区域 中存放我们java程序中的对象,我们知道在java项目中会有很多很多对象,所以堆占用内存空间是最大,也是我们在优化JVM的时候,主要优化的地方。所以我们需要详细了解它的结构模型。
由于项目不断的运行,会产生越来越多的对象存储在堆区域。那么我们系统有限的内存空间会不断的增加,如果不清除那些不再用的对象(也就是垃圾对象),我们的机器内存会存满从而导致内存溢出(OOM)。
所以jvm有垃圾回收机制(GC)去自动清除内存中的垃圾,腾出更多的空间。
堆的内存模型:主要分为 年轻带和老年代两个区域, 年轻带由划分了: Eden(伊甸区)和 survivor(幸存区)。 新进来的对象会存在年轻代中的Eden,Eden的垃圾回收我们叫 minor gc或者 young gc。 年代中一直存活的对象会存在 老年代, 等老年代触发垃圾回收 我们叫 full gc (full gc不只是回收老年的垃圾,也会回收 年轻代的垃圾), 重点是这些 垃圾回收的过程 都会 STW (stop the world) 也就是停止所有我们项目中用户触发的线程,gc过程要专心去回收垃圾。 这就意味着,我们在使用APP或者网站的时候,用户会有时候感觉到卡顿的原因之一。 所以 减少gc的次数往往是 我们做jvm调优主要的目的。
2.3 JVM内存参数设置
我们在调优的时候,需要设置一些内存参数
通常启动的时候在java命令后携带这个参数 ,方式如下:
java ‐Xms2048M ‐Xmx2048M ‐Xmn1024M ‐Xss512K ‐XX:MetaspaceSize=256M ‐XX:MaxMetaspaceSize=256M ‐jar microservice‐eureka‐server.jar
具体的参数什么作用,我们用到的时候去查文档就可以。
2.4 对象进入老年代的机制
- 大对象直接进入老年代
大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。JVM参数 -XX:PretenureSizeThreshold 可以设置大对象的大小,如果对象超过设置大小会直接进入老年代,不会进入年轻代,这个参数只在 Serial 和ParNew两个收集器下有效。
比如设置JVM参数:-XX:PretenureSizeThreshold=1000000 (单位是字节) -XX:+UseSerialGC ,再执行下上面的第一个程序会发现大对象直接进了老年代
为什么要这样呢?
为了避免为大对象分配内存时的复制操作而降低效率。 - 长期存活的对象将进入老年代
既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。
如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为1。对象在 Survivor 中每熬过一次 MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。 - 对象动态年龄判断
当前放对象的Survivor区域里(其中一块区域,放对象的那块s区),一批对象的总大小大于这块Survivor区域内存大小的50%(-XX:TargetSurvivorRatio可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了,例如Survivor区域里现在有一批对象,年龄1+年龄2+年龄n的多个年龄对象总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。对象动态年龄判断机制一般是在minor gc之后触发的。 - 老年代空间分配担保机制
年轻代每次minor gc之前JVM都会计算下老年代剩余可用空间
如果这个可用空间小于年轻代里现有的所有对象大小之和(包括垃圾对象)
就会看一个“-XX:-HandlePromotionFailure”(jdk1.8默认就设置了)的参数是否设置了
如果有这个参数,就会看看老年代的可用内存大小,是否大于之前每一次minor gc后进入老年代的对象的平均大小。
如果上一步结果是小于或者之前说的参数没有设置,那么就会触发一次Full gc,对老年代和年轻代一起回收一次垃圾,如果回收完还是没有足够空间存放新的对象就会发生"OOM"
当然,如果minor gc之后剩余存活的需要挪动到老年代的对象大小还是大于老年代可用空间,那么也会触发full gc,full gc完之后如果还是没有空间放minor gc之后的存活对象,则也会发生“OOM”