篇文章将重点分析jvm,涉及到的内容包括jvm内存模型,类加载器,GC回收算法,GC回收器,整体偏向于理论。
本篇文章不适合初学者,适合具有3年以上开发经验的技术人员,欢迎大家一起交流分享,文章若有不足之处,欢迎读者朋友们指出,先感谢。
一 明确jdk,jre和jvm之间关系
下图为官网关于jdk,jre和jvm的架构图,从该架构图,很容易看出三者之间关系:
(1)jdk包含jre,而jre又包含jvm
(2)jdk主要用于开发环境,jre主要用于发布环境,当然,发布环境用jdk也没问题,仅仅是性能可能会有点影响,jdk与jre关系有点类似程序debug版本和release版本之间关系
(3)从文件大小来说,jdk比jre大。从图中可以看出,jdk比jre多了一层工具包,如常用的JAVAc,java命令等
二 类加载器
关于jvm类加载器,可概括为如下图:
1.为什么要有类加载器?
(1)将字节码文件加载到运行时数据区。.java源码通过Javac命令编译后形成的字节码文件(.class),通过类加载器加载进入jvm中的。
(2)确定字节码文件在运行时数据区的唯一性。相同的字节码文件,通过不同的类加载器,就形成不同的文件,因此字节码文件在运行时数据区的唯一性是由字节码文件和加载它的类加载器共同决定的
2.类加载器的种类
从种类上来划分,类加载器主要划分为四大类
(1)启动类加载器 (根类加载器Bootstrap ClassLoader):该类加载器位于类加载器的最顶层,主要加载jre核心相关jar包,如 /jre/lib/rt.jar
(2)扩展类加载器(Extension ClassLoader):该类加载器位于类加载器层次的第二层,主要加载 jre扩展相关jar包,如/jre/lib/ext/*.jar
(3)应用程序类加载器(Application ClassLoader) App:该类加载器位于类加载器的第三层,主要加载类路径(classpaht)下的相关jar包
(4)用户自定义类加载器(User ClassLoader):该类加载器为用户自定义类加载器,主要加载用户指定的路径下的相关jar包
3.类加载器的机制(双亲委派)
对于字节码的加载,类加载机制为双亲委派,什么叫双亲委派呢?
类加载器获取字节码文件后,不是直接加载,而是将该字节码文件传递给其直接父级类加载器,其直接父加载器又继续传递给其直接父加载器的直接父加载器,依次类推到根父加载器,若根父加载器
能加载,则加载,否则交给其直接孩子加载器加载,直接孩子加载器能加载就加载,若不能,依次类推其直接孩子类加载器,若都不能加载,最后才由用户自定义类加载器加载。
4.jdk 1.8 如何实现类加载器?
如下为jdk 1.8 类加载器的实现,采用递归方式
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) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
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.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
5.破坏双亲委派模型
在某些情况下,由于受加载范围限制,父类加载器无法加载到需要的文件,因此父类加载器需要委托其子类加载器去加载相应的字节码文件。
如在jdk中定义的数据库驱动接口Driver,但该接口的实现却由不同的数据库厂商来实现,这就产生这样一个问题:由启动类(Bootstrap ClassLoader)
执行的DriverManager要加载实现了Driver接口的相关实现类,从而实现统一管理,但Bootstrap ClassLoader只能加载jre/lib下的相应文件,不能加载
由各个厂商实现的Dirver接口相关实现类(Dirver实现类是由Application ClassLoader加载),这时就需要Bootstrap ClassLoader委托其子类加载器加载Driver
来实现,从而破坏了双亲委派模型。
三 类的生命周期
java中的类,在jvm中的生命周期,大概分为五个阶段:
1.加载阶段:获取字节码二进制流,并将静态存储结构转化成方法区的运行时数据结构,且在方法区生成相应的类对象(java.lang.Class对象),作为该类的数据访问入口。
2.连接阶段:该阶段包括三个小阶段,即验证,准备和解析三阶段
(1)验证:确保字节码文件符合虚拟机规范要求,如元数据验证,文件格式验证,字节码验证和符号验证等
(2)准备:为内的静态表里分配内存,并且设置jvm默认值,对于非静态变量,此阶段,不需分配内存。
(3)解析:将常量池内的符号引用转化为直接引用
3.初始化阶段:类对象使用前的一些必要初始化工作
如下引用自一位博友的观点,个人认为解释得很好。
在 Java 代码中,如果要初始化一个静态字段,我们可以在声明时直接赋值,也可以在静态代码块中对其赋值。
除了 final static 修饰的常量,直接赋值操作以及所有静态代码块中的代码,则会被 Java 编译器置于同一方法中,并把它命名为 < clinit > 。初始化的目的是是为标记为
常量值的字段赋值,以及执行< clinit > 方法的过程。Java 虚拟机会通过加锁来确保类的 < clinit > 方法仅被执行一次。
哪些条件会发生类初始化呢?
(1)当虚拟机启动时,初始化用户指定的主类(main函数);
(2)当遇到用于新建目标类实例的 new 指令时,初始化 new 指令的目标类;
(3)当遇到调用静态方法的指令时,初始化该静态方法所在的类;
(4)子类的初始化会触发父类的初始化;
(5)如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化;
(6)使用反射 API 对某个类进行反射调用时,初始化这个类;
(7)当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类。
4.使用阶段:jvm中使用对象
5.卸载阶段:将对象从jvm中卸载(unload),哪些条件会使jvm发生类卸载呢?
(1)加载该类的类加载器被回收
(2)该类的所有实例已经被回收
(3)该类对应的java.lang.Class对象没有任何地方被引用
四 jvm内存模型
1.JVM内存模型是怎样的?
如下为JVM内存模型架构图,由于在之前的文章中论述过,这里就不再一 一论述,主要讲解堆区。
在jdk 1.8前,堆区主要分为新生代、老年代和永久代。jdk 1.8后,去掉了永久代,增加了MetaSpace区。这里,主要分享jdk 1.8。
根据jdk1.8,堆区逻辑抽象为三个部分:
(1)新生代:包括Eden区,S0区(也叫from区),S21(也叫TO区)
(2)老年代
(3)Metaspace区
2.新生代和老年代的内存大小是怎样的?
根据官方建议,新生代占三分之一(Eden:S0:S1=8:1:1),老年代占三分之二,因此内存分配图如下:
3.GC回收是怎样进行的?
对象先在Eden区运行,当Eden内存用占用满时,Eden会进行两个操作:回收不用的对象和将未回收对象放入s0区,此时s0区和s1区互换名称,即s0->s1,s1->s0,Eden区经过一次对象回收后,释放了空间,当Eden下次再满时,执行相同步骤,依次循环执行,当Eden区回收后,剩下的对象超过s0容量,则将触发一次Minor GC,此时将未回收的对象放入老年区,依次循环执行,当Eden区触发Minor GC时,剩余的对象容量大于old区剩余容量时,则old区将触发一次Major GC,此时便会触发一次Full GC。需要注意的是,一般发生Major GC,基本都都会伴随一次Full GC回收,Full GC非常损耗性能,在JVM调优时,要注意。
下图我在生产环境截的一张GC图,监控工具VisualVM
4.垃圾回收算法有哪些?
(1)标记-清除算法
该算法分为2个阶段,即标记阶段和清除阶段,首先标记所有要回收的对象,然后回收被标记的对象。该算法效率低,且容易产生内存碎片。
a.效率低:需要遍历两次内存,第一次标记,第二次回收被标记对象
b.由于是非连续内存片段,容易产生碎片,当对象过大时,容易发生Full GC
下图为标记-清除算法 回收前和回收后对比示意图
(2)标记-复制算法
该算法解决了“标记-清除”算法效率低和大部分内存碎片问题,它将内存分为大小相等的两块,每次只使用其中一块,当其中一块需要回收时,只需将该块区域还存活的对象复制到另一块,然后再把该块内存一次性清理掉,循环往复。
下图为标记-复制算法回收前和回收收简要示意图
然而,由于年轻代大部分对象驻留时间都非常短,98%的对象都很快被回收,存活的对象非常少,不需要按照内存1:1来划分,而是按照8:1:1来划分,
将2%存活的对象放在s0(from区)即可。
如下为按照Eden:s0:s1 =8:1:1 划分示意图
(3)标记-整理算法
该算法分为两阶段,即标记和整理,首先标记所有存活对象,将这些对象向一端移动,然后直接清理掉端边界以外的内存。由于老年代的对象存活时间比较长,因此适合用该算法。
标记过程仍与“标记-清除”过程一致,但后续步骤不是直接对可回收对象进行清理,而是让所有存活对象向一端移动,然后直接清理掉端边界以外的内存。
如下为"标记-整理算法"回收期和回收后示意图
(4)分代收集算法
该算法为目前jvm算法,采用分代思想,模型如下:
5.常见GC回收器有哪些?
(1)SerialGC
SerialGC又叫串行回收器,也是最基础的GC回收器,主要适用于单核cpu,新生代采用复制算法, 老年代采用标记-压缩算法,在运行的过程中需要暂停应用程序,
因此会造成STW问题,在JVM标注参数为:-XX:+UseSerialGC 。
(2)ParallelGC
ParallelGC基于SerialGC,主要解决SerialGC串行问题,改为并行问题,解决多线程问题,但同样会产生STW问题,jvm关键参数:
a.-XX:+UseParNewGC,表示新生代并行(复制算法) 老年代串行(标记-压缩)
b.XX:+UseParallelOldGC,老年代也是并行
(3)CMS GC
CMSGC属于老年代回收器,采用“标记-清除算法”,不会发生STW问题,在jvm中参数设置:
-XX:+UseConcMarkSweepGC,表示老年代使用CMS收集器
(4)Garbage First
Garbage First面向jvm垃圾收集器 ,它满足短时间停顿的同时达到一个高的吞吐量,适用于多核cpu和大内存的服务端,也是jdk9的默认垃圾回收器。
五 总结
深入分析了JVM内存模型,其中重点分析了jdk,jre和jvm关系,jvm类加载器,jvm堆内存划分,GC回收器和GC回收算法等,整体偏向于理论,由于篇幅有限,本篇文章未分析这些技术在JVM实际调优中是如何运用的,将在接下来的文章中与大家分享。