JVM浅入浅出
说是浅入浅出,其实还是需要在入和出的过程中,进行一个深入的了解。在了解JVM之前,我其实是从比较常见的JVM面试题进入的,毕竟带着问题看事情的过程中,逐渐把问题解答开的还是很爽的。由此我先列出几个比较常见的JVM面试题。因此文章会比较长。
1. 什么是JVM(Java Virtual Machine java虚拟机)、为什么Java是夸平台的开发语言?
2. 描述一下JVM内存模型?
3. 什么是类、类加载、类加载器、JVM加载class文件的原理机制?
4. 什么情况下会发生堆内存溢出(OutOfMemoryError)、栈内存溢出(StackOverflowError)、无法找到指定类异常(ClassNotFoundException)?
5. JVM内存为什么要分成新生代,老年代?新生代中为什么要分为Eden和Survivor?
6. JVM中一次完整的GC流程是怎样的,对象如何晋升到老年代?
7. 主要的JVM参数有哪些?
8. JVM调优
9. 强引用、软引用、弱引用、虚引用以及他们之间和gc的关系?
大致整理以上几个问题,那接下来就对逐个问题进行学习。过程中以我当前能理解的深度进行学习。因为如果太深入,我怕自己也很难理解或者产生误导。
1. 什么是JVM(Java Virtual Machine java虚拟机)、为什么Java是夸平台的开发语言?
JVM是JRE的一部分,而我们安装JDK其实是包含了JRE的。当然也可以独立安装JRE。
JVM其实就是JAVA虚拟机的缩写,字面意思理解就是一个虚拟出来的计算机,这个计算机通过Java解释器将字节码文件解释成机器码来运行。我们在运行一个java程序后,windows下我们可以看到一个java.exe的进程,其实java.exe可以理解为一个包装程序,它内部装载了运行平台下对应的动态库(windows下 jvm.dll,linux下和solaris下其实类似 libjvm.so)。这个动态库链接库就是JVM实际操作处理所在。
由此可知,其实Java之所以可以做到跨平台,完全依赖于JVM的这个机制,我们的可执行Java程序,是通过JVM解释成对应的平台的计算机指令执行的,当然深入了解的话,我们的 .java文件从编译到.class文件,在到如何解释称字节码,在变为机器码,在转换为对应的计算机指令,就需要再度深入学习了。
2. 描述一下JVM内存模型?
左侧的堆 、方法区 是线程共享 的,全局共享一个堆和方法区。(由此可以延伸出后续线程安全性问题)
堆:一块内存区域,存放所有程序运行时创建的对象,凡是new的对象都放在这里,也是GC主要管理的区域。当超出设置的-Xmx设置的量时,可爱的OutOfMemoryError:heapSpace异常就出现了。
方法区:JVM的类加载器加载 .class文件,解析的类信息存放在方法区。(名称、修饰符等)、类中的静态变量、类中定义为final类型的常量、类中的Field信息、类中的方法信息。
右侧JVM栈、本地方法栈、程序计数器是线程私有的与线程共存亡。
JVM栈:一块内存区域,存放基本数据类型的变量数据和对象的引用,其中对象本身不存在栈中而是放在堆中(new)或者常量池中。 线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常。(此处异常基本都是无休止地柜或者循环调用方法、构造方法等)导致)
本地方法栈: 为虚拟机使用的Native方法服务(Native方法可自行百度,简单讲就是Java调用一个非Java代码的方式,例如调用.dll / .os)
程序计数器:一个记录着当前线程锁执行的字节码的行号的指示器。(分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成)
再来了解一下每个区域存储的内容
3. 什么是类、类加载、类加载器、JVM加载class文件的原理机制?
类,作为JAVA的基本概念,不做过多描述。但这是我们了解类加载、类加载器(双亲委派模型)、JVM如何加载class文件的基础。
类:是一种抽象的数据类型,具有相同“事物”的属性和行为进行抽象。
首先强调一个大家都知道的问题,类加载的是 .class文件而不是 .java文件。而 .class文件是通过 .java文件编译得来的。
类加载
实际上就是指将 .class文件中的二进制数据读取到内存中,也就是放在运行时数据区的方法区(Method Area)内,然后在堆区(Heap)内创建一个java.lang.Class对象,并提供了访问方法区内数据结构的接口。加载过程如图。
JVM加载class文件的原理机制
1. 装载(loading)
找到二进制字节码并加载至JVM中。JVM是通过类名、包名、ClassLoader完成类的加载。因此标识一个被加载的类:类名+包名+ClassLoader实例ID。
2. 链接(Linking)
负责对二进制字节码进行校验,静态变量初始化,初始化类中的接口
1)验证:文件格式、元数据、字节码、符号引用等;
2)准备:为静态变量分配内存,初始化默认值;(并非准确赋值)
3)解析:将符号引用转换为直接引用;(主要针对类、接口、字段、类方法、接口方法、方法类型等)
3. 初始化(initializing)
负责对执行类中的静态初始化代码、构造函数、静态属性的初始化。
1)创建对象(new的方式);
2)访问类或接口中的静态变量,或者对类中的静态属性赋值;
3)反射(Class.forName() );
4)调用类的静态方法;
5)初始化子类时,如父类为初始化,则将其初始化;
6)Java虚拟机启动时被表明为启动类的类;
类加载器
在类加载过程中,负责加载类(.class)的程序,加载的二进制数据的来源大致有以下几种。
1)本地文件系统中的.class文件;
2)jar包中的.class文件;
3)通过网络加载.class文件;
4)动态编译.java原文件,并进行加载;
JVM提供的类加载器大致分为三类
1)启动类加载器 Bootstrap ClassLoader
负责加载 JDK/jre/lib 下的jar,或被-Xbootclasspath参数指定的路径中的 ,并且加载rt.jar(所有java.*的类均被Bootstrap ClassLoader加载),无法呗Java程序直接引用。2)拓展类加载器 Extension ClassLoader
该加载器由sun.misc.Launcher$ExtClassLoader实现
负责加载JDK/jre/lib/ext 下的jar,或者由java.ext.dirs系统变量指定的路径中的所有类库(所有的javax.*的类有)
开发者可以直接使用拓展类加载器。3)应用程序类加载器 Application ClassLoader
该类加载器由sun.misc.Launcher$AppClassLoader来实现
负责加载用户类路径(classpath)所指定的类
开发这可以直接使用该类加载器
双亲委派模式
类加载器在加载一个类的时候,首先不是自己取加载这个类,先委派任务给自己上一层加载器,上一层加载器检查自己是否加载了这个类,如已加载,就使用当前加载的类。如未加载,则委派任务给顶层加载器。顶层加载器进行判断。如已加载,则使用已加载的类,如未加载,则反向向下加载。向下加载逻辑就是不同加载器的职责,BootstrapClassLoader加载核心类,ExtensionClassLoader加载拓展类,ApplicationClassLoader加载classpath指定的类。
双亲委派机制
1. ApplicationClassLoader加载一个class时,首先会委托ExtendionClassLoader来完成加载;
2. ExtensionClassLoader接受委托时,也会首先委托BootstrapClassLoader来完成加载;
3. BootstrapClassLoader接受委托后,如加载失败(未在核心类库(rt.jar)中找到此类),则会使用ExtensionClassLoader进行加载;
4. ExtensionClassLoader如也加载失败(未在javax.*下找到此类),则会使用ApplcationClassLoader进行加载;
5. 如ApplicationClassLoader也加载失败,则抛出ClassNotFoundException异常。
双亲委派模式的意义,很好的避免了类的重复加载,有效的保证了Java程序的安全性,委派只能从下至上。
4. 什么情况下会发生堆内存溢出(OutOfMemoryError)、栈内存溢出(StackOverflowError)、无法找到指定类异常(ClassNotFoundException)?
这几个异常是在了解JVM过程中需要知道其发生原因的,有助于我们在后续学习中,深入的解析问题。通过刚刚了解JVM内存模型,我们可以清楚的看到,发生堆、栈内存溢出的情况。这里再次总结一下。
堆内存溢出(OutOfMemoryError):当我们在频繁的new对象的时候,堆内存超出JVM设定的-Xmx的大小时,就会出现堆内存溢出。
public class JvmTest {
public static void main(String[] args) {
List<String> testList = new ArrayList<String>();
try{
while(true){ //无限循环进行List.add()操作,导致内存溢出
testList.add("test");
}
}catch(Throwable e){
System.out.println(aList.size());
e.printStackTrace();
}
}
}
栈内存溢出(StackOverflowError):当我们使用不合理递归或者无限制频繁引用的时候,就会出现栈内存溢出。
public class JvmTest {
private int i = 0;
public void a(){
System.out.println(i++);
a(); //方法递归调用,导致栈帧超过栈的深度
}
public static void main(String[] args) {
JvmTest j = new JvmTest();
j.a();
}
}
在讲述双亲委派机制时,我们可以看到,ClassNotFoundException出现的原因。
ClassNotFoundException:当加载类时,无法找到该类,则会出现此异常。常见情况,就是加载jdbc驱动时没有对应驱动的jar包。
5. JVM内存为什么要分成新生代,老年代?新生代中为什么要分为Eden和Survivor?
Minor GC:发生在新生代的垃圾回收动作;
Major GC:发生在老年代的垃圾回收动作;
Full GC :清理整个内存堆 – 包括年轻代和年老代;
为什么要分为新生代和老年代?
JVM在运行程序过程中,会大量的创建对象,一部分是短周期对象,一部分是长周期对象。对于短周期对象,JVM需要频繁的进行垃圾回收以保证无用对象尽可能早的被释放掉。对于长周期对象,则不需要频繁的进行垃圾回收机制的扫描检测。为了解决这种问题,JVM的内存管理采用分代管理策略。
新生代:主要存放新创建的对象,这类对象相对内存占用比较小,会比较频繁进行 Minor GC。
老年代:主要存放JVM认为的生命周期比较长的对象,同时这类对象占用内存也比较大,不需要进行频繁的 Major GC。
新生代中为什么要分为Eden和Survivor?
新生代中,准确来说,其实是分为1个Eden(伊甸园)和2个Survivor(幸存者),比例分配为8:1:1。
每次GC操作,都是扫描的Eden和1个Survivor。两个Survivor交换使用,此操作为了避免内存碎片产生。
当对象在堆中创建时回进入新生代的Eden Space,当进行Minor GC时将扫描Eden Space 和 A Survivor Space,还存活的对象复制到B Survivor Space中,如B Survivor Space满时则复制到Old Gen(老年代)。
同时,在扫描Survivor Space时,将多次Minor GC后仍然存活的对象认定为一个持久化对象,移动至 Old Gen(老年代)中。完成扫描后,清空Eden Space 和 A Survivor Space,然后A Survivor与 B Survivor进行角色交换,在下次Minor GC时扫描的是Eden和B Survivor。
我们能看到,在对象创建后,进行一次Minor GC时,作为被扫描的Eden和A Survivor被清空,并且交换了A Survivor和B Survivor的角色,使得下一次Minor GC时,被扫描者变为Eden和B Survivor,A Survivor 作为一个空的区域,等待Minor GC后为接收对象。A与B依此反复交换角色。这样的操作,保证来自Eden和被扫描的Survivor的两部分存活对象都占用连续的内存空间,也就避免了内存碎片化的问题。
6. JVM中一次完整的GC流程是怎样的,对象如何晋升到老年代?
对象创建时会优先分配至新生代,当Eden Space空间不足时,会出发Minor GC。
1. 大对象(需要大量连续储存空间),直接进入老年代。(毕竟Survivor Space 大小有限,所以直接将其置入老年代)
2. 经历过一次Minor GC的对象,会将其放入 Survivor Space标记年龄为1,每经历一次Minor GC年龄+1,当对象经历过15次 Minor GC仍然存活,JVM认为这是一个持久化对象,就将其晋升至老年代。
3. 老年代触发Major GC,至少伴随一次Minor GC,并且Major GC相对于Minor GC要慢很多。
4. 当老年代内存满了,无法储存更多对象时,将触发Full GC,清理整个内存堆,包括新生代和老年代。
7. 主要的JVM参数有哪些?
以下列出几个比较常用参数
-Xmx3550m:最大堆大小为3550m -Xms3550m:设置初始堆大小为3550m
-Xmn2g:设置年轻代大小为2g
-Xss128k:每个线程的堆栈大小为128k
-XX:MaxPermSize: 设置持久代大小为16m
-XX:NewRatio=4: 设置年轻代(包括Eden和两个Survivor区)与年老代的比值(除去持久代)
-XX:SurvivorRatio=4:设置年轻代中Eden区与Survivor区的大小比值。设置为4,则两个Survivor区与一个Eden区的比值为2:4,一个Survivor区占整个年轻代的1/6
-XX:MaxTenuringThreshold=15:设置垃圾最大年龄。如果设置为0的话,则年轻代对象不经过Survivor区,直接进入年老代。
8. JVM调优
JVM调优的主要目的减少GC的频率,和Full GC的次数,以提高程序运行速度。实际应用中,我们的Java程序有两种启动方式,jar包启动、war包启动。主要调整的就是JVM的年轻代和老年的内存分配大小,垃圾回收器的类型等。
注:在未使用工具进行前面监控、收集信息的情况下,我们仅需要进行堆内存大小、新生代大小、GC类型进行简单设置。JVM调优其实应该是一个发现问题并进行调整的过程,盲目调整并不能称作调优,我一般叫它瞎调。
JAR包启动,Linux下一般使用 start.sh 脚本,脚本内容如下
java -server -Xms4G -Xmx4G -Xmn2G -XX:SurvivorRatio=1 -XX:+UseConcMarkSweepGC -jar app.jar
Tomcat下WAR包启动,Linux下需要调整的是 bin/catalina.sh 文件中找到这两行
# OS specific support. $var _must_ be set to either true or false.
JAVA_OPTS="-Xms4G -Xmx4G - XX:SurvivorRatio=1 -XX:+UseConcMarkSweepGC"
-Xms4G :JVM启动时整个堆内存的初始化大小 4G
-Xmx4G :JVM启动时整个堆内存最大4G
-Xmx2G :年轻代的空间大小,剩下的是年老代的空间
-XX:SurvivorRatio=1 : 年轻代中Eden 与 2个 Survivor 的占比(1:1:1),默认为 8(8:1:1)。例:3:1:1
-XX:+UseConcMarkSweepGC:GC类型,此处为CMS,JDK1.7以后推荐使用+UseG1GC。
几种JVM垃圾收集器特点,优劣势、及使用场景,可另行查阅资料。
9. 强引用、软引用、弱引用、虚引用以及他们之间和gc的关系?
简单整理一下这四种引用,理论上其实很简单,引用的强弱关系到垃圾回收机制对其的效果的强弱,可以拆分成我们常用的 new(强引用)和 Reference 的三个子类(显示引用)。
强引用:new 就是强引用,GC 宁愿抛出OutOfMemoryError,也不会回收这类对象。例:Object obj = new Object();
三种显示引用类型:SoftReference(软引用) >WeakReference(弱引用) >PhantomReference(虚引用)
越弱标识垃圾回收器对其回收时的限制越少,越容易被回收。 这三种引用类型,可以理解为用以上三种不同类型的引用对象来对一个对象的引用进行封装,来控制他的引用强度,并提供了get方法,不提供set方法。
SoftReference(软引用) :内存足够则不回收,内存不足则回收;
WeakReference(弱引用) :不论内存是否足够,都进行回收;
PhantomReference(虚引用): 任何时候都可被回收,虚引用主要用来跟踪对象被垃圾回收的活动;