java面试jvm问题
jvm是java重要的一环,也是各种大小面试中必考的存在,本文整理了一些大牛的及《深入如理解java虚拟机》一书中较经典的问题,希望对和像我一样奋斗的小程序员们一下帮助。
1.简要概括jvm结果及内存区域。
jvm在执行java程序时内存结构如下
(1)程序计数器
程序计数器是一块较小的内存空间,它可以看着当前线程所执行字节码的行号指示器,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器完成。由于jvm的多线程是通过线程轮流切换并分配处理器执行时间来实现的,因此每条线程都有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”。
如果线程正在执行的是一个java方法,这个计数器记录的是正在执行的jvm字节码指令的地址,如是native方法,计数器值为空。此内存区域是唯一一个没有规定任何OutOfMemoryError情况的区域。
(2)虚拟机栈
虚拟机栈也是线程私有的,它的生命周期与线程相同。它描述的是java方法执行的内存模型:每个方法在执行的同时都会创建一个栈整用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用到运行完成的过程,就对应一个栈帧在虚拟机中入栈到出栈的过程。
局部变量表存放了编译期可知的各种基本数据类型、对象引用类型,他不等同与对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置和returnAddress类型。
如果线程请求的栈深度大于jvm所允许的深度,将抛出StackOverflowRrror异常。如果jvm扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。
(3)本地方法栈
本地方法栈为jvm使用到的Native方法服务。本地栈也会抛出StackOverflowRrror异常和OutOfMemoryError异常。
(4)java堆
对应大多数应用来说,Java堆是jvm所管理的内存中最大的一块。堆是被所有线程共享的一块内存区域,在jvm启动时创建。此内存区域唯一的目的就是存放对象实例。几乎所有的对象实例都在这里分配内存。
Java堆是垃圾收集器管理的主要区域。由于现在收集器基本采用分带收集算法,所有Java堆还可以细分为:新生代和老年代,再细致一点的有Eden空间、From Survivor空间、To Survivor空间等。根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间、只要逻辑是连续的即可。在实现时,既可以实现成固定大小。也可以实现是可扩展的,不过当前主流的jvm都是按照可扩展来实现的(通过-Xxm和Xms)控制。如果在堆中没有内存完成实例分配,并且堆也无法扩展时,将会抛出OutOfMemoryError异常。
2.垃圾回收机制
(1)判断对象是否存活
在堆里面放着java世界几乎所有的内存实例。垃圾收集器在对堆进行回收前,第一件事就是要确定哪些对象还在存活着,哪些以及死去。
通过《深入了解java虚拟机》一书中我们可以知道,java是通过可达性分析算法来判断对象是否存活的,这个算法的基本思想是通过一系列成为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径叫做引用链,当一个对象到GC Roots没有任何引用链相连时,证明此对象是不可用的。
如图所示,o5,o6,o7虽然互相有关联,但是他们到GC Roots是不可达的,所以他们将会被判定为是可回收对象。
在java中,可作为GC Roots的对象包括以下几种
a.虚拟机栈中的引用对象
b.方法区中类静态属性引用的对象
c.方法区中常量引用的对象
d.本地方法栈中JNI(Native方法)引用的对象
(2)生存还是死亡
可达性算法之后,就是应该决定对象到底是生存还是死亡。
首先要说明,即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,要真正的宣告一个对象死亡,至少要经历两次标记过程:如果不可达,标记一次并进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为"没有必要执行"。
如果这个对象有必要执行finalize(),那么这个对象将会放置在一个叫做F-Queue的队列之中,并在稍后由一个用jvm自动建立的、低优先级的Finalizer线程去执行它,这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺等待它运行结束。finalize()方法是 对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象在finalize()中成功拯救自己,那么在第二次标记时他将被移除“即将回收的”的集合,如果对象这时候还没有逃脱,那基本上他就真的被回收了。
(3)垃圾收集算法与垃圾收集器
常见的垃圾收集算法有标记-清除算法,复制算法,标记-整理算法,分带收集算法。
a.标记-清除算法
如它的名字一样,算法分为“标记”和“清除两个过程”:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象,他是最基础的收集算法。它主要有两个不足:一个是效率问题、一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后再程序运行中需要分配较大对象时,无法找到足够的内存而不得不提前触发另一次垃圾收集动作。
b.复制算法
为了解决上面提到的效率问题,复制算法实现了,它将内存分为相等的两块,每次只使用其中de一块,当这一块内存用完了,就将还存活的对象复制到另一块上去,然后把已使用过的内存空间一次清理掉,这样使得每次都是对半区进行内存回收,内存分配时不用考虑内存碎片的复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效,只是这种算法的代价是将内存缩小为原来的一半,未免太高了一点。
c.标记-整理算法
标记-整理算法的标记过程与标记-清除算法一样,但后续步骤不是直接对可回收的对象进行清理,而是让所有存活的对象都向另一端移动,然后直接清理掉边界以外的内存。
d.分带收集算法
这种算法并没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般分为新生代和老年代,这样就根据各代的特点使用最合适的算法。
(4)内存分配与回收策略
在JAVA中堆被分为两块区域:新生代(young)、老年代(old)。
堆大小=新生代+老年代;(新生代占堆空间的1/3、老年代占堆空间2/3)
新生代又被分为了eden、from survivor、to survivor(8:1:1);
新生代这样划分是为了更好的管理堆内存中的对象,方便GC算法---复制算法来进行垃圾回收。
JVM每次只会使用eden和其中一块survivor来为对象服务,所以无论什么时候,都会有一块survivor空间,因此新生代实际可用空间只有90%。
对象会优先在eden分配,当eden区没有足够的空间进行分配时,jvm将发起一次Minor GC(新生代GC),如果对象在eden出生并且在第一次Minor GC之后仍然存活,并且能被Survivor区容纳的话,将被移动到Survivor空间中,并且将age设置为1,当他age增长到一定程度时(默认15),就会被晋升到老年代中。没有达到阈值的对象会被复制到“To”区域。经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。
3.类加载机制
(1)类加载过程
其中类加载的过程包括了加载、验证、准备、解析、初始化五个阶段。在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也成为动态绑定或晚期绑定)。另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段。
a.加载
加载是类加载过程中的一个阶段,这个阶段会在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的入口。注意这里不一定非得要从一个Class文件获取,这里既可以从ZIP包中读取(比如从jar包和war包中读取),也可以在运行时计算生成(动态代理),也可以由其它文件生成(比如将JSP文件转换成对应的Class类)。
b.验证
这一阶段的主要目的是为了确保Class文件的字节流中包含的信息是否符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
c.准备
准备阶段是正式为类变量分配内存并设置类变量的初始值阶段,即在方法区中分配这些变量所使用的内存空间。注意这里所说的初始值概念,比如一个类变量定义为:
1 |
|
实际上变量v在准备阶段过后的初始值为0而不是8080,将v赋值为8080的putstatic指令是程序被编译后,存放于类构造器<client>方法之中,这里我们后面会解释。
但是注意如果声明为:
1 |
|
在编译阶段会为v生成ConstantValue属性,在准备阶段虚拟机会根据ConstantValue属性将v赋值为8080。
d.解析
解析阶段是指虚拟机将常量池中的符号引用替换为直接引用的过程。符号引用就是class文件中的:
- CONSTANT_Class_info
- CONSTANT_Field_info
- CONSTANT_Method_info
等类型的常量。
下面我们解释一下符号引用和直接引用的概念:
- 符号引用与虚拟机实现的布局无关,引用的目标并不一定要已经加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。
- 直接引用可以是指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。如果有了直接引用,那引用的目标必定已经在内存中存在。
e.初始化
初始化阶段是类加载最后一个阶段,前面的类加载阶段之后,除了在加载阶段可以自定义类加载器以外,其它操作都由JVM主导。到了初始阶段,才开始真正执行类中定义的Java程序代码。
初始化阶段是执行类构造器<client>方法的过程。<client>方法是由编译器自动收集类中的类变量的赋值操作和静态语句块中的语句合并而成的。虚拟机会保证<client>方法执行之前,父类的<client>方法已经执行完毕。p.s: 如果一个类中没有对静态变量赋值也没有静态语句块,那么编译器可以不为这个类生成<client>()方法。
注意以下几种情况不会执行类初始化:
- 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。
- 定义对象数组,不会触发该类的初始化。
- 常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常量所在的类。
- 通过类名获取Class对象,不会触发类的初始化。
- 通过Class.forName加载指定类时,如果指定参数initialize为false时,也不会触发类初始化,其实这个参数是告诉虚拟机,是否要对类进行初始化。
- 通过ClassLoader默认的loadClass方法,也不会触发初始化动作。
(2)类加载器
虚拟机设计团队把加载动作放到JVM外部实现,以便让应用程序决定如何获取所需的类,JVM提供了3种类加载器:
- 启动类加载器(Bootstrap ClassLoader):负责加载 JAVA_HOME\lib 目录中的,或通过-Xbootclasspath参数指定路径中的,且被虚拟机认可(按文件名识别,如rt.jar)的类。
- 扩展类加载器(Extension ClassLoader):负责加载 JAVA_HOME\lib\ext 目录中的,或通过java.ext.dirs系统变量指定路径中的类库。
- 应用程序类加载器(Application ClassLoader):负责加载用户路径(classpath)上的类库。
JVM通过双亲委派模型进行类的加载,当然我们也可以通过继承java.lang.ClassLoader实现自定义的类加载器。
当一个类加载器收到类加载任务,会先交给其父类加载器去完成,因此最终加载任务都会传递到顶层的启动类加载器,只有当父类加载器无法完成加载任务时,才会尝试执行加载任务。
下面详细说一下:
当一个类加载和初始化的时候,类仅在有需要加载的时候被加载。假设你有一个应用需要的类叫作Abc.class,首先加载这个类的请求由Application类加载器委托给它的父类加载器Extension类加载器,然后再委托给Bootstrap类加载器。Bootstrap类加载器会先看看rt.jar中有没有这个类,因为并没有这个类,所以这个请求由回到Extension类加载器,它会查看jre/lib/ext目录下有没有这个类,如果这个类被Extension类加载器找到了,那么它将被加载,而Application类加载器不会加载这个类;而如果这个类没有被Extension类加载器找到,那么再由Application类加载器从classpath中寻找。记住classpath定义的是类文件的加载目录,而PATH是定义的是可执行程序如javac,java等的执行路径。
采用双亲委派的一个好处是比如加载位于rt.jar包中的类java.lang.Object,不管是哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这样就保证了使用不同的类加载器最终得到的都是同样一个Object对象。
我们看一下jdk中加载器的源码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
|
- 首先通过Class c = findLoadedClass(name);判断一个类是否已经被加载过。
- 如果没有被加载过执行if (c == null)中的程序,遵循双亲委派的模型,首先会通过递归从父加载器开始找,直到父类加载器是Bootstrap ClassLoader为止。
- 最后根据resolve的值,判断这个class是否需要解析。