JVM
对象一定是在堆中创建的吗?

逃逸
在方法中创建对象之后,如果这个对象除了在方法体中还在其它地方被引用了,此时如果方法执行完毕,由于该对象有被引用,所以 GC 有可能是无法立即回收的,此时便成为 内存逃逸现象。
逃逸状态:
一个对象有三种逃逸状态:
全局逃逸(GlobalEscape):即一个对象的作用范围逃出了当前方法或者当前线程,
一般有以下几种场景:
① 对象是一个静态变量
② 对象是一个已经发生逃逸的对象
③ 对象作为当前方法的返回值参数逃逸(ArgEscape):即一个对象被作为方法参数传递或者被参数引用,但在调用过程中不会发生全局逃逸,这个状态是通过被调方法的字节码确定的。
没有逃逸:即方法中的对象没有发生逃逸。
public class EscapeAnalysisTest { |
标量替换
标量可以理解成一种不可分解的变量,如java内部的基本数据类型、引用类型等。 与之对应的聚合量是可以被拆解的,如对象。
当通过逃逸分析一个对象只会作用于方法内部,虚拟机可以通过使用标量替换来进行优化。
public String noEscape(){ |
比如上述noEscape()方法中person对象只会在方法内部,通过标量替换技术得到如下伪码:
public String noEscape(){ |
可以看到运行上面伪码之后,将不再从堆中分配内存创建Person对象,而是将成员变量分解作为栈帧中的局部变量。这样随着方法调用结束,栈帧也会销毁。有效的减少了堆中创建对象及gc次数
逃逸分析的优势
因为同步锁是非常消耗性能的,所以当编译器确定一个对象没有发生逃逸时,它便会移除该对象的同步锁。
首先要明白标量和聚合量,基础类型和对象的引用可以理解为标量,它们不能被进一步分解。而能被进一步分解的量就是聚合量,比如:对象。
对象是聚合量,它又可以被进一步分解成标量,将其成员变量分解为分散的变量,这就叫做标量替换。
这样,如果一个对象没有发生逃逸,那压根就不用创建它,只会在栈或者寄存器上创建它用到的成员标量,节省了内存空间,也提升了应用程序性能。
栈内存分配很好理解,在上文中提过,就是将原本分配在堆内存上的对象转而分配在栈内存上,这样就可以减少堆内存的占用,从而减少 GC 的频次。
什么是程序计数器?
程序计数器是每个线程私有的(线程安全的),每个线程一份,内部保存的字节码的行号,用于记录正在执行的字节码指令的地址。
详细介绍Java堆
堆是线程共享的区域(线程不安全的):主要用来保存对象实例,数组等,内存不够则抛出OOM(内存溢出)异常。
java8及之后堆的组成是年轻代 + 老年代。
- 年轻代被划为三部分,Eden区和两个大小严格相同的Survivor区(幸存者区)
- 老年代主要保存生命周期长的对象,一般是一些老的对象
根据JVM的策略,在经过几次垃圾收集后,任然存活于幸存者区的对象将被移动到老年代区
JDK1.7和JDK1.8的区别
- 1.7中有一个永久代,存储的是类信息、静态变量、常量、编译后的代码
- 1.8移除了永久代,把数据存储到了本地内存的元空间中,防止内存溢出
以下为JVM内存结构图:

虚拟机栈
什么是虚拟机栈?
每个线程运行时所需要的内存(线程安全的),称为虚拟机栈,先进后出
每个栈由多个栈帧(frame)组成,对应每次方法调用时所占用的内存
每个线程只能有一个活动栈,对应着当前正在执行的那个方法
例如,此时线程调用方法一,那么此时活动栈就是栈一,接着在方法一中调用了方法二,此时栈二压栈,活动栈就变成了栈二

垃圾回收是否涉及栈内存?
垃圾回收的对象主要指堆内存,而栈内存在栈帧弹出以后就会释放。
栈内存分配的越打越好吗?
并不是。默认的栈内存为1024k(1m),栈内存过大会导致线程数变少。例如,机器总内存为512m,目前能活动的线程数则为512个,如果把栈内存改为2048k,那么能活动的栈帧就会减半。
方法内的局部变量是否线程安全?
- 如果方法内局部变量没有逃离方法的作用范围,他是线程安全的
- 如果局部变量引用了对象,并逃离方法的作用范围,则需要考虑线程安全

什么情况可能会导致栈内存溢出?
- 栈帧过多:例如递归调用

- 栈帧过大
堆栈的区别是什么?
栈内存一般会用来存储局部变量和方法调用,但堆内存是用来存储Java对象和数组的。堆会GC垃圾回收,而栈不会。
栈内存是线程私有的,而堆内存是线程共有的
两者异常错误不同:
栈空间不足:StackOverFlowError
堆空间不足:OutOfMemoryError
介绍一下方法区(元空间)
- 方法区是各个线程共享的内存区域
- 主要存储类的信息、运行时常量池
- 虚拟机启动的时候创建,关闭虚拟机时释放
- 如果方法区中的内存无法满足分配需求,则会抛出OutOfMemoryError: Metaspace,有时也会提醒你方法区空间太小。


介绍一下运行时常量池
常量池
可以看作是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息。

运行时常量池
常量池是*.class文件中的,当该类被加载,它的常量信息就会被放入运行时常量池,并把里面的符号地址变为真实地址。

你听过直接内存吗?
Java文件拷贝

常规IO的数据拷贝流程

由于Java无法直接读取系统缓存区的数据,所以要先将数据拷贝一份到java缓冲区中,正是因为这种不必要的拷贝操作导致常规IO执行效率低。
NIO数据拷贝流程

Java可以直接读取直接内存,执行效率大大提高。
总结:
- 直接内存并不属于JVM中的内存结构,不由JVM进行管理,是虚拟机的系统内存。(也就是操作系统内存)
- 常见于NIO操作时,用于数据缓冲区,分配回收成本较高,但读写性能高,不受JVM内存回收管理
什么是类加载器、双亲委派机制?
类加载器

JVM只会运行二进制文件,类加载器的作用就是将字节码文件加载到JVM中,从而让Java程序能够启动起来。
类加载器种类
启动类加载器(加载对应目录下的类)、扩展类加载器(加载对应目录下的类)、应用类加载器(加载用户自己编写的Java类)、自定义加载器。

双亲委派模型
加载某一个类,先委托上一级的加载器进行加载,如果上级加载器也有上级,则会继续向上委托,如果该类委托上级没有被加载,子加载器尝试加载该类。

例如现在有一个用户编写的Student类,先在AppClassLoader加载,发现有上一级就先委托给上级类加载器加载,然后ExtClassLoader发现有上一级,就继续向上委派给BootStrapClassLoder,此时已经到顶开始正式加载,此时BootStrapClassLoder无法加载,ExtClassLoader开始尝试加载,发现也无法加载,最终回到AppClassLoader加载。
JVM为什么采用双亲委派机制?
- 通过双亲委派机制可以避免同一个类被反复加载,当父类已经加载后则无需重复加载,保证唯一性。
- 为了安全,保证类库API不会被修改
例如:

此时执行main函数会报错,在java.lang.String中找不到main方法。
由于双亲委派机制,java.lang.String在启动类加载器得到加载,但在核心jre库中有其相同名字的类文件,但该类中并没有main方法。这样就能防止恶意篡改核心API库。
类装载的执行过程(重要)

类装载过程分为以上7个阶段,其中验证、准备和解析这三个部分统称为链接(linking)。
加载
- 通过类的全名,获取类的二进制数据流
- 解析类的二进制数据流为方法区内的数据结构(Java类模型) (其实就是将类的信息存入到方法区)
- 创建
java.lang.Class类的实例,表示该类型。作为方法区这个类的各种数据的访问入口

验证
验证类是否符合JVM规范,安全性检查。
文件格式验证
元数据验证
字节码验证
以上这三点都是格式检查,例如:文件格式是否错误、语法是否错误、字节码是否错误。
符号引用验证——Class文件在其常量池会通过字符串记录自己将要使用的其他类或者方法,检查他们是否存在

上图“=”后面的就是所说的字符串。
准备
为类变量(静态变量)分配内存并设置类变量初始值。
- static变量,分配空间在准备阶段完成(设置默认值),赋值在初始阶段完成
- static变量是final的基本类型以及字符串常量,值已确定,赋值在准备阶段完成
- static变量是final的引用类型,那么赋值也会在初始化阶段完成
解析
把类中的符号引用转换为直接引用。
例如:方法中调用了其他方法,方法名可以理解为符号引用,而直接引用就是使用指针直接指向方法。


“#1”、“#2”等就可以看作是符号引用,而通过符号引用找到的java/io/PrintStream、println就是直接引用。
初始化
对类的静态变量、静态代码块执行初始化操作。
- 如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类
- 如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行
注意:子类访问父类静态变量,只触发父类初始化
使用
JVM开始从入口方法开始执行用户的程序代码
- 调用静态类成员信息(比如:静态字段、静态方法)
- 使用new关键字为其创建对象实例
卸载
当用户程序代码执行完毕后,JVM便开始销毁创建的Class对象。
总结
- 加载:查找和导入class文件
- 验证:保证加载类的准确性
- 准备:为类变量分配内存并设置类变量初始值
- 解析:把类中的符号引用转换为直接引用
- 初始化:对类的静态变量、静态代码块执行初始化操作
- 使用:JVM开始从入口方法开始执行用户的程序代码
- 卸载:当用户程序代码执行完毕后,JVM便开始销毁创建的Class对象
对象什么时候可以被垃圾回收?
如果一个或多个对象没有任何的引用指向它了,那么这个对象现在就是垃圾,如果定位了垃圾,则有可能会被垃圾回收。
定位垃圾的方式有两种:
引用计数法(对象间循环引用会导致内存泄漏)

可达性分析算法

扫描堆中的对象,看是否能够沿着GC Root对象为起点的引用链找到该对象,找不到就表示可以回收。(上图中的X,Y两个节点是可以回收的)
哪些对象可以作为GC Root?
虚拟机栈中引用的对象

方法区中类静态属性引用的对象

方法区中常量引用的对象

本地方法中JNI(即Native方法)引用的对象
JVM垃圾回收算法有哪些?
标记清除算法:垃圾回收分为两个阶段,分别是标记和清除,效率高,有磁盘碎片,内存不连续。

标记整理算法:和标记清除算法一样,将存活对象都向内存另一端移动,然后清理边界以外的垃圾,无碎片,对象需要移动,效率低。(一般老年代使用)

复制算法:将原有的内存空间一分为二,每次只用其中的一块,正在使用的对象复制到另一个内存空间中,然后将该内存空间清空,交换两个内存的角色,完成垃圾回收。无碎片,内存使用率低。(一般年轻代使用)

说一下JVM中的分代回收
一、堆的区域划分
堆被分为了两份:新生代和老年代【1:2】
对于新生代,内部又被分为了三个区域,Eden,幸存者区survivor(分成from和to)【8:1:1】

二、对象回收分代回收策略
- 新创建的对象都会先分配到Eden区
- 当Eden区内存不足,标记Eden与from的存活对象
- 将存活对象采用复制算法复制到to中,复制完毕后,Eden和from内存都得到释放
- 经过一段时间后Eden的内存又出现不足,标记Eden区和to区存活对象,将其复制到from区
- 当幸存区对象熬过几次回收(最多15次),晋升到老年代(幸存者区内存不足或者大对象会提前晋升)
MinorGC、MixedGC、FullGC的区别是什么?
- MinorGC(youngGC)发生在新生代的垃圾回收、暂停时间(STW)短
- MixedGC:新生代 + 老年代部分区域的垃圾回收,G1收集器持有
- FullGC:新生代 + 老年代完整垃圾回收,暂停时间(STW)长,应尽力避免
STW(Stop-The-World):暂停所有应用程序线程,等待垃圾回收完成
说一下JVM有哪些垃圾回收器?
串行垃圾收集器:Serial GC、Serial Old GC

并行垃圾收集器:Parallel Old GC、ParNew GC(JDK8默认使用此垃圾回收器)

CMS(并发)垃圾收集器:CMS GC,作用在老年代

G1垃圾收集器,作用在新生代和老年代(JDK9之后默认使用G1)
详细聊一下G1垃圾回收器
- 应用于新生代和老年代,在JDK9之后默认使用
- 划分为多个区域,每个区域都可以充当Eden、Survivor、old、humongous,其中humongous专为大对象准备
- 采用复制算法
- 响应时间与吞吐量兼顾
- 分成三个阶段:新生代回收(stw)、并发标记(重新标记要stw)、混合收集
- 如果并发失败(即回收速度赶不上创建新对象的速度)会触发Full GC
Java内存泄漏的排查思路
内存泄漏通常是指堆内存,通常是指一些大对象不被回收的情况
- 通过jmap或设置jvm参数获取堆内存快照dump
- 通过工具VisualVM去分析dump分析,VisualVM可以加载离弦的dump文件
- 通过查看堆信息的情况,可以大概定位内存溢出是哪行代码出了问题
- 找到对应的代码,通过阅读上下文的情况,进行修复即可