【面试题一】谈谈JVM内存模型
JVM内存区域的划分,之所以划分是为了JVM更好的进行内存管理。就好比一间卧室,这块放床,这块放个电脑桌,每块地方各自有各自的功能,床用来睡觉,电脑桌用来办公打游戏。而JVM划分的内存区域也是各自有各自的用处。首先当JVM启动时,会申请一块很大的空间,然后把这块空间分成几块较小的区域,如下图。
- 本地方法栈:是用来存储JVM内部的native方法之间调用的关系的。本地方法栈是JVM内部C++写的代码。
- 虚拟机栈:是给Java代码使用的栈,存储的是Java方法之间调用的关系。可以认为包含了多个元素,每个元素是一个方法,一个元素又可以称为"栈帧",一个栈帧里会包含**"方法的入口地址、方法的返回地址、方法参数和局部变量"**,并且一个线程拥有一个栈,栈是线程私有的。
- 程序计数器:描述的是一个线程当前执行到了哪条指令,每个线程有一份。
- 堆:是JVM里最大的一块空间,存储的是NEW出的对象,类的成员变量。一个进程有一份,多个线程共享一份堆。
- 元数据区:在Java8之前是称为"方法区"的,在Java8之后才成为"元数据区",存储的是类对象、静态成员变量和常量池。一个进程有一份,多个线程共享一份。
注意一下哈,此处总结为:栈存储的是局部变量,堆储存的是普通成员变量,元数据区存储的是静态成员变量。
【面试题二】谈谈JVM类的加载过程
类加载就是找到.class文件,然后把文件内容从硬盘里读取到内存中。大概流程如下:
- 加载:就是找到.class文件,打开文件,然后把文件内容从硬盘读到内存中,最后完成加载得到类对象。
- 验证:即验证.class文件的格式是否正确,.class文件是一个二进制的文件。在官方的虚拟机规范文档里有描述这个二进制文件长啥样。
- 准备:给类对象分配一个内存空间,也就是在元数据区里占个位置,此时会把静态成员变量初始为0值。
- 解析:初始化字符串常量,把符号引用转为直接引用。
- 初始化:此时才会真正的给类对象的内进行初始化,也就是进行成员的初始化,执行代码块、静态代码块和加载父类。
解释一下符号引用和直接引用是什么:字符串常量得有一块内存空间进行存放,然后使用一个引用来指向这个内存空间的起始地址,但是在类加载前,字符串常量是存放在.class文件里的,此时并未分配空间,也就是这个引用其实指向的是一个类似于"偏移量"或者是"占位符"的一个东西,这个就是"符号引用"。而当类加载后,字符串常量才会被真正的分配一个内存空间,此时这个引用才会指向这个内存空间的起始地址,这个就称为"直接引用",这个过程就是"符号引用"转为"直接引用"。
【面试题三】一个类什么时候会被加载
只有当一个类被真正使用到了才会被加载,并不是当Java程序一启动就把所有类进行加载。比如进行构造类的实例,调用类的静态方法或者静态属性都会执行类加载。当使用子类时会先加载父类,并且一个类一旦被加载后,不会被重复加载。
【面试题四】谈谈双亲委派模型
双亲委派模型描述的是如何找到.class文件。JVM提供了三个类加载器,BootStrapClassLoard负责加载标准库中的类,ExtensionClassLoard负载加载JVM扩展库中的类。(扩展库就是Java规范之外,由实现JVM的厂家提供的额外功能的库)ApplicationClassLoard负责加载第三方库的类和程序猿自定义的类。这三个类存在"父子关系",即每个类里有个parent属性,这个属性里存储的是自己的"父·类加载器"。Application的父类加载器是Extension,Extension的父·类加载器是Bootstrap。
当进行类加载时,会先Application开始,不过Application会把执行加载的任务交给自己的父类,只有当自己的父类加载完成后或者没有父类,自己才会真正的执行加载任务,于是此时任务交给了Extension,而Extension又把任务交给了BootStrap,最后BootStrap由于没有父类加载器,于是BootStrap开始执行类加载,去标准库里找到相关的类进行加载,如果没有找到剩余的类,就会把任务交给自己的子类加载,此时Extension会执行加载任务,会去扩展库中找到相关的类进行加载,最后把任务交给Application加载,去第三方库和自定义的类里找到并且加载,如果剩余的类没有找到,此时会报一个"类找不到"这样的异常。
**为什么会有这样的顺序?**之所以有这样的顺序是由于JVM的实现是按照类似于递归的方式实现的,于是就导致了从上到下又从下到上的顺序,其次也是为了保证BootStrap先加载,Application最后加载,这样可以避免程序猿自定义的一些类,引起了JVM内部bug。比如自定义写了个java.lang.String,按照上述流程就是先加载的是标准库里的String类,就不会导致JVM内部已经实现的代码出现bug。类加载器也可以自定义,并且可以存放在三个类加载器的任意位置。
【面试题五】谈谈Java的垃圾机制
GC垃圾回收就是把不用的内存给自动释放掉,这样的好处是写代码简单,不容易出错,但是会额外占用系统资源。如果当内存垃圾很多了,此时触发了GC操作,就会占用很多的系统资源,并且GC的有些操作会有一些锁操作,此时就会导致正常执行的业务代码无法执行,这种问题就称为"STW"问题(Stop The World)
GC进行垃圾回收是以"对象"单位回收的。此处可以把对象分为三类:一是正在使用的内存,二是不用了但未被回收的内存,三是未被分配内存的。像一部分在使用一部分未使用的对象是不会被回收的,比如一个对象里有些属性还在使用,而有些属性已经用完了。GC的具体工作流程是:先找到垃圾,然后进行释放。
-
找到垃圾:使用的是可达性分析策略。JVM存有一份所有对象的名单,可达性分析就是把所有对象看作为一棵树,从根节点开始遍历,能访问到的就标记为"可达",访问不到的标记为"不可达",然后把这些不可达的对象当成垃圾给释放掉,可达性遍历会进行周期性的遍历。因为Java里的对象都是通过引用来指向进行访问内存的,而一个对象里的成员又可以通过引用指向另一个对象,因此就会构成一个类似于树形的结构,可达性分析的根节点/起点称为GCroots,GCroots可以是栈上的局部变量、常量池的对象和静态成员变量
-
对象的释放/垃圾释放:进行垃圾的释放有以下几种策略
-
标记清除:标记清除就是把标记为垃圾的空间直接释放掉,简单高效,不过会导致内存碎片问题,由于Java申请空间都是连续性的,所以当申请一个较大的空间时,可能就会申请失败。
-
复制算法:解决了内存碎片的问题, 复制算法就是把一个空间分成两部分,每次只用一半的内存,然后当进行垃圾回收时,把不是垃圾的对象复制到另一半没有使用的内存里,由于对象是按顺序复制过来的,所以不会有内存碎片问题。但是复制算法的成本比较高,当垃圾较少时,有效对象多,此时触发复制算法,复制的成本就比较大。
进行了复制算法,把左侧对象1(垃圾)进行了删除,此时右侧内存是顺序存放的,所以内存空间是连续的,下次比如对象2变成了垃圾,就会把对象3复制到左侧内存空间,然后直接把对象2进行释放。
-
标记整理:标记整理解决了空间利用率低的问题,类似于顺序表删除元素或者搬运元素。比如内存里有1、2、3、4、5、6此时2、4、6为垃圾,就把3、5往前进行搬运,把2、4覆盖掉,最后把6直接释放。
-
分代回收:基于前面几种策略,设计出一个复合型的策略,即把垃圾回收分为不同的场景,每个场景使用不同的算法。Java对象的生命周期一般比较长,要么就很短,所以可以根据生命周期的长短,引入一个"年龄"的概念,单位是熬过GC的轮次,即当GC扫描一轮后,当前对象存活下来就把年龄+1,每个刚NEW出来的对象年龄为0。JVM把堆划分出一系列的区域,先把一整个空间划分为两部分,一部分为新生代,另一部分为老年代,在新生代里划分出一个较大的伊甸区,和两个同等大小的幸存区,伊甸区存放刚NEW出来的新对象,当熬过一轮GC后,使用复制算法把对象复制到第一个幸存区,到了幸存区后,当前对象需要继续接受GC周期性的扫描,当第二次熬过GC轮次后,从第一个幸存区复制到第二个幸存区,这样来回两个幸存区复制,当对象的年龄达到15时,就复制到老年代,进入老年代后,GC就会降低扫描的频率,因为当进入老年代说明当前对象生命周期很长,短时间内不会成为垃圾,当老年代里的对象称为垃圾时,会使用标记整理的策略进行垃圾释放。
-