JVM常见面试问题
JVM常见问题
JVM内存结构
介绍JVM内存结构,哪些是线程私有的?哪些会发生OOM(OutOfMemory内存溢出)?
根据JVM规范,JVM内存共分为堆、方法区、虚拟机栈、本地方法栈、程序计数器五个部分。
- 堆(Heap):线程共享。所有的对象实例以及数组都要在堆上分配。垃圾回收器主要管理的对象。
- 方法区(Method Area):线程共享。存储类信息、常量、静态变量、即时编译器编译后的代码。
- 虚拟机栈(JVM Stack):线程私有。存储局部变量表、操作数栈、动态链接、方法出口,对象指针。
- 本地方法栈(Native Method Stack):线程私有。为虚拟机使用到的 Native 方法服务。如Java使用c或者c++编写的接口服务时,代码在此区运行。
- 程序计数器(Program Counter Register):线程私有。有些文章也翻译成PC寄存器(PC Register),同一个东西。它可以看作是当前线程所执行的字节码的行号指示器。存储正在执行的虚拟机字节码指令的地址,指向下一条要执行的指令。
除了程序计数器不会发生OOM之外,其他区域都有可能发生OOM。
堆内存(Heap):堆内存是Java程序中用于存储对象实例的区域,如果堆内存不足以存储新的对象实例,就会触发OutOfMemoryError。
方法区(Method Area):方法区用于存储类的结构信息、静态变量、常量池等数据。如果方法区无法满足新的类加载、常量存储等需求,就会导致OutOfMemoryError。
栈内存(Stack):栈内存用于存储线程私有的方法调用栈、局部变量等信息。如果线程调用的方法层次太深,栈内存不足,就会抛出StackOverflowError。虽然不是严格意义上的OOM,但也是一种内存相关的错误。
本地方法栈(Native Method Stack):本地方法栈类似于Java栈,但是用于执行Native方法(即用C、C++等语言编写的方法)。如果Native方法调用层次太深,本地方法栈不足,就会抛出StackOverflowError。
直接内存(Direct Memory):直接内存是一种使用NIO库进行I/O操作时可能使用的一种内存,它并不受Java虚拟机内存管理系统的管理,因此也有可能发生OOM。
对象创建机制
分为五个步骤:类加载检查、分配内存、初始化内存空间、设置对象头、执行构造函数
类加载检查:当虚拟机(JVM)遇到一条
new
的指令时,首先将去检查这个指令的参数是否能够在常量池中定位到这个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,那么就要执行相应的类加载过程。总结为 if (new指令的参数不在常量池 || 此符号引用的类没有被加载、解析和初始化过)———>{执行相应类加载}
分配内存:在类加载完成后,对象所需的内存大小已完全确定,虚拟机可以对new出来的对象分配内存。主要有两种方式:
指针碰撞 空闲列表 堆内存规整时 堆内存不规整时 用过的内存放在一边,没用的放一边,中间有个分界值指针 JVM维护一个列表记录划分的内存 GC收集器:Serial、ParNew GC收集器:CMS 堆内存规整与否取决于GC收集器的算法是标记-清除、还是标记-整理
创建对象时涉及到的问题:
线程安全:先TLAB再 CAS + 失败重试
CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而且假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。
TLAB: 为每一个线程预先在 Eden 区分配一块儿内存,JVM 在给线程中的对象分配内存时,首先在 TLAB 分配,当对象大于 TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配
初始化内存空间:内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。
设置对象头:虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
执行构造函数:经过1-4步骤,从JVM的角度看一个新的对象已经产生,但从Java程序上看,对象创建才刚开始。执行
new
指令之后会接着执行构造方法,把对象按照照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。
对象的结构
⼀个对象分为以下几个部分: 对象头,实例数据,对齐填充
对象头:对象头包括两类数据:对象自身的运行时数据和类型指针。
对象自身的运行时数据
对象⾃身的运⾏时数据叫做 Mark Word,包括哈希码, GC 分代年龄,锁状态标志,线程持有的锁,偏向线程 ID,偏 向时间戳等。其中,Mark Word 是⼀个动态的数据结构,为的就是在极小的空间内存储尽量多的数据。
类型指针
类型指针,对象指向元数据的指针,Java 通过这个来确定对象是哪个类的实例。
如果对象是数组类型,那么在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通 Java 对象的元数据信息确定 Java 对象的大小,但是如果数据的长度不确定,将无法通过元数据中的信息判断出数组的大小。
实例数据:实例数据是对象真正存储的有效信息,也就是我们在程序代码中所定义的各种类型的字段内容,包括从父类继承的。 这部分的存储顺序会受到 HotSpot 虚拟机默认的分配策略参数和字段在 Java 源码中定义顺序的影响。
对齐填充:对⻬填充其实不是必然的,作⽤就是在占位符,保证对象的起始地址是 8 字节的整数倍。 也就是说,任何对象的大小都必须是 8 字节的整数倍,这个是 HotSpot 虚拟机的⾃动内存管理系统的要求。
对象访问定位
对象的访问的两种方式?区别和优缺点
Java 通过栈上的 reference 数据来操作堆上的具体对象, 对象访问的两种方式为句柄和直接指针。
- 句柄方式:Java 堆中将可能会划分除⼀块内存来作为句柄池,然后 reference 储存句柄池的地址,句柄中包含了指向对象实例数据的指针,和对象类型数据的指针。
- 直接指针:reference 中存放的是对象地址。
优缺点:
句柄方式比较稳定,对象被移动时,只会改变句柄中的实例数据指针,不需要修改 reference 本身。直接指针的话,少了⼀次指针定位的开销,所以好处就是访问速度更快。
如何判断对象死亡
两种方法:引用计数器法和可达性分析算法
引用计数器法:给对象添加一个引用计数器,每当有一个地方引用它,计数器就加1,当引用失效时,计数器就减1,任何时刻当计数器为0的时候,这个对象就不能再被使用了。优点:实现简单效率高。缺点:主流 JVM 不使用这个算法,因为无法解决对象循环依赖(A依赖B,B依赖A)的问题。
可达性分析法:从一系列称作”GC ROOT”的对象作为起点,往下搜索,节点所走过的路径称作引用链。当一个对象到GC ROOT的路径没有引用链的时候,就判断为死亡。
JVM垃圾回收机制、垃圾回收算法
故事版本:JVM垃圾回收算法都有哪些?简述它们的执行过程及优缺点 - 知乎 (zhihu.com)
JVM的垃圾回收机制——垃圾回收算法 JVM的垃圾回收机制——垃圾回收算法 - 知乎 (zhihu.com)
垃圾回收算法:
标记-清除算法:标记无用对象,然后进行清除回收。缺点:效率不高,无法清除垃圾碎片。
该算法分为两个阶段,标记和清除。标记阶段标记所有需要回收的对象,清除阶段回收被标记的对象所占用的空间。该算法最大的问题就是内存碎片严重化,后续可能发生对象不能找到利用空间的问题。
复制算法:按照容量划分二个大小相等的内存区域,当一块用完的时候将活着的对象复制到另一块上,然后再把已使用的内存空间一次清理掉。缺点:内存使用率不高,只有原来的一半。
按内存容量将内存划分为等大小的两块。每次只使用其中一块,当这一块内存满后将尚存活的对象复制到另一块上去,把已使用的内存清掉。
标记整理算法:标记无用对象,让所有存活的对象都向一端移动,然后直接清除掉端边界以外的内存。
标记后不是清理对象,而是将存活对象移向内存的一端。然后清除端边界外的对象。
分代收集算法(jdk8):根据对象存活周期的不同将内存划分为几块,一般是新生代和老年代,新生代基本采用复制算法,老年代采用标记整理算法。
当前商业虚拟机都采用分代收集的垃圾收集算法。分代收集算法,顾名思义是根据对象的存活周期将内存划分为几块。一般包括年轻代、老年代和永久代。
垃圾收集器
Java 堆内存被划分为新生代和年老代两部分,新生代主要使用复制和标记-清除垃圾回收算法;
年老代主要使用标记-整理垃圾回收算法,因此 java 虚拟中针对新生代和年老代分别提供了多种不同的垃圾收集器,JDK1.6 中 Sun HotSpot 虚拟机的垃圾收集器如下:
垃圾收集器
其中用于回收新生代的收集器包括Serial、PraNew、Parallel Scavenge,
回收老年代的收集器包括Serial Old、Parallel Old、CMS,
有用于回收整个Java堆的G1收集器。
新生代垃圾回收器一般采用的是复制算法,复制算法的优点是效率高,缺点是内存利用率低;老年代回收器一般采用的是标记-整理的算法进行垃圾回收。
- Serial收集器(复制算法): 新生代单线程收集器,标记和清理都是单线程,优点是简单高效;
- ParNew收集器 (复制算法): 新生代收并行集器,实际上是Serial收集器的多线程版本,在多核CPU环境下有着比Serial更好的表现;
- Parallel Scavenge收集器 (复制算法): 新生代并行收集器,追求高吞吐量,高效利用 CPU。吞吐量 = 用户线程时间/(用户线程时间+GC线程时间),高吞吐量可以高效率的利用CPU时间,尽快完成程序的运算任务,适合后台应用等对交互相应要求不高的场景;
- Serial Old收集器 (标记-整理算法): 老年代单线程收集器,Serial收集器的老年代版本;
- Parallel Old收集器 (标记-整理算法): 老年代并行收集器,吞吐量优先,Parallel Scavenge收集器的老年代版本;
- CMS(Concurrent Mark Sweep)收集器(标记-清除算法): 老年代并行收集器,以获取最短回收停顿时间为目标的收集器,具有高并发、低停顿的特点,追求最短GC回收停顿时间。
- **G1(Garbage First)收集器 (标记-整理算法)**: Java堆并行收集器,G1收集器是JDK1.7提供的一个新收集器,G1收集器基于“标记-整理”算法实现,也就是说不会产生内存碎片。此外,G1收集器不同于之前的收集器的一个重要特点是:G1回收的范围是整个Java堆(包括新生代,老年代),而前六种收集器回收的范围仅限于新生代或老年代。
CMS 是英文 Concurrent Mark-Sweep 的简称,是以牺牲吞吐量为代价来获得最短回收停顿时间的垃圾回收器。对于要求服务器响应速度的应用上,这种垃圾回收器非常适合。在启动 JVM 的参数加上 “-XX:+UseConcMarkSweepGC” 来指定使用 CMS 垃圾回收器。
CMS 使用的是标记-清除的算法实现的,所以在 gc 的时候回产生大量的内存碎片,当剩余内存不能满足程序运行要求时,系统将会出现 Concurrent Mode Failure,临时 CMS 会采用 Serial Old 回收器进行垃圾清除,此时的性能将会被降低。
G1和CMS
G1:
全称Garbage-First,之前的收集器因为是直接将堆内存物理划分为新生代老年代,所以需要来两种收集器配合使用才能实现全代的回收。
以往对于Java堆区域的划分为:新生代和老年代,新生代又划分为 Eden区和 Survivor区,Survivor区又分为 from区和 to区。但是现在,G1将整个堆内存划分为多个等大的Region,每个Region都可以成为 Eden空间、Survivor空间、老年代空间。
Region还有一类特殊的 Humongous 区域,专门用来存储大对象。G1认为只要大小超过了一个Region容量一半的对象即可判定为大对象。如果是那些超过了整个Region容量的超大对象,将会放在连续 N 个 Humongous Region区域。
对于G1来说整体采用的是标记-整理算法,然后从Region的角度来看,采用的是复制算法。
G1 垃圾收集过程主要分为4个阶段:
初始标记
标记GC Roots直接关联的对象
触发STW
并发标记
和⽤户程序并发执行,标记出所有回收对象
最终标记
处理并发标记后,新产生的对象
触发STW
筛选回收
先对Region的回收价值进行排序,然后根据期望暂停时间,选择性回收Region
回收时采用标记复制,多条收集器线程并发执行
不追求⼀次全部清理完
触发STW
CMS:
CMS(Concurrent Mark Sweep)垃圾回收器是第一个关注 GC 停顿时间的垃圾收集器。
在 CMS 垃圾回收器出现之前,无论是 Serious 垃圾回收器,还是 ParNew 垃圾回收器,亦或是 Parallel Scavenge 垃圾回收器,他们在进行垃圾回收的时候都需要 Stop the World,即无法实现垃圾回收线程与用户线程并发执行。而 CMS 垃圾回收器通过三色标记算法,实现了垃圾回收线程与用户线程并发执行,从而极大地降低了系统响应时间,提高了强交互应用程序的体验。
对于 CMS 垃圾回收器来说,其实通过标记 - 清除算法实现的,它的运行过程分为 4 个步骤,包括:
初始标记,指的是寻找所有被 GCRoots 引用的对象,该阶段需要「Stop the World」
并发标记,指的是对「初始标记阶段」标记的对象进行整个引用链的扫描,该阶段不需要「Stop the World」
重新标记,指的是对「并发标记」阶段出现的问题进行校正,该阶段需要「Stop the World」
并发清除,指的是将标记为垃圾的对象进行清除,该阶段不需要「Stop the World」
主要优点:并发收集、低停顿
但是它有下⾯三个明显的缺点:
- 对 CPU 资源消耗较大
- 无法处理浮动垃圾
- 它使⽤的“标记-清除”算法会导致收集结束时会产生大量空间碎片
垃圾回收中的三色标记法
首先,我们重新定义黑、白、灰三种颜⾊的含义
白色代表需要 GC 的对象;
黑色代表确定不需要 GC 的对象;
灰色代表可能不需要 GC 的对象,但是还未完成标记的任务,也可以认为是增任务。
在三⾊标记-清除算法中
准备⼯作:⼀开始所有对象都染成⽩⾊初始化完成后,会启动标记程序。在标记的过程中,是可以暂停标记程序执⾏ Mutation,算法需要维护 3 个集合,⽩⾊集合、⿊⾊集合、灰⾊集合。3 个集合是互斥的,对象只能在⼀个集合 中。执⾏之初,所有对象都放⼊白色集合。
第⼀次执⾏: 算法将 Root 集合能直接引⽤的对象加⼊灰⾊集合;
往后执⾏:不断从灰⾊集合中取出元素进⾏标记;
标记的过程主要分为 3 个步骤:
- 如果对象在⽩⾊集合中,那么先将对象放⼊灰⾊集合;
- 然后遍历节点的所有的引⽤对象,并递归所有引⽤对象;
- 当⼀个对象的所有引⽤对象都在灰⾊集合中,就把这个节点放⼊为⿊⾊集合。
当标记算法执⾏完成的时候,所有不需要 GC 的元素都会涂⿊;标记算法完成后,⽩⾊集合内就是需要回收的对象。
JVM类加载机制
类加载机制:类的数据从Class⽂件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java类型。
类的生命周期
⼀个类型从被加载到虚拟机内存中开始,到卸载出内存为⽌,它的整个⽣命周期将会经历加载 (Loading)、验证 (Verification)、准备(Preparation)、解析(Resolution)、初始化 (Initialization)、使⽤(Using)和卸载 (Unloading)七个阶段,其中验证、准备、解析三个部分统称 为连接(Linking)。
类加载分为三个步骤:加载、链接、初始化
加载
- 类加载指的是将class文件读入内存,并为之创建一个java.lang.Class对象,即程序中使用任何类时,系统都会为之建立一个java.lang.Class对象,系统中所有的类都是java.lang.Class的实例。
- 类的加载由类加载器完成,JVM提供的类加载器叫做系统类加载器,此外还可以通过继承ClassLoader基类来自定义类加载器。
- 通常可以用如下几种方式加载类的二进制数据:
- 从本地文件系统加载class文件。
- 从JAR包中加载class文件,如JAR包的数据库启驱动类。
- 通过网络加载class文件。
- 把一个Java源文件动态编译并执行加载。
连接:连接阶段负责把类的二进制数据合并到JRE中,其又可分为如下三个阶段:
- 验证:确保加载的类信息符合JVM规范,无安全方面的问题。
- 准备:为类的静态Field分配内存,并设置初始值。
- 解析:将类的二进制数据中的符号引用替换成直接引用。
初始化
- 该阶段主要是对静态Field进行初始化,在Java类中对静态Field指定初始值有两种方式:
- 声明时即指定初始值,如static int a = 5;
- 使用静态代码块为静态Field指定初始值,如:static{ b = 5; }
JVM初始化一个类包含如下几个步骤:
- 假如这个类还没有被加载和连接,则程序先加载并连接该类。
- 假如该类的直接父类还没有被初始化,则先初始化其直接父类。
- 假如类中有初始化语句,则系统依次执行这些初始化语句。
所以JVM总是最先初始化java.lang.Object类。
类初始化的时机(对类进行主动引用时):
创建类的实例时(new、反射、反序列化)。
调用某个类的静态方法时。
使用某个类或接口的静态Field或对该Field赋值时。
使用反射来强制创建某个类或接口对应的java.lang.Class对象,如Class.forName(“Person”)
初始化某个类的子类时,此时该子类的所有父类都会被初始化。
直接使用java.exe运行某个主类时。
- 该阶段主要是对静态Field进行初始化,在Java类中对静态Field指定初始值有两种方式:
类加载器及加载机制
类加载器
绝大多数Java程序都会使用以下三种系统提供的Java类:
- 启动(Bootstrap)类加载器: 引导类装入器是用本地代码实现的类装入器,它负责将 jdk中jre/lib下面的核心类库或-Xbootclasspath选项指定的jar包加载到内存中。由于引导类加载器涉及到虚拟机本地实现细节,开发者无法直接获取到启动类加载器的引用,所以不允许直接通过引用进行操作。
bootstrap classloader是由C++写成的,所以在Java中无法获得它的引用(会返回Null) - 扩展(ExtClassLoader)类加载器: 扩展类加载器是由Sun的(sun.misc.Launcher$ExtClassLoader)实现的。它负责将jdk中jre/lib/ext或者由系统变量-Djava.ext.dir指定位置中的类库加载到内存中。开发者可以直接使用标准扩展类加载器。
- 系统类加载器(System ClassLoader): 系统类加载器是由 Sun的 AppClassLoader(sun.misc.Launcher$AppClassLoader)实现的。它负责将系统类路径java -classpath或-Djava.class.path变量所指的目录下的类库加载到内存中。开发者可以直接使用系统类加载器。
双亲委派模型
1)双亲委派模式原理
在Java的日常应用程序开发中,类的加载几乎是由上述3种类加载器相互配合执行的,在必要时,我们还可以自定义类加载器,需要注意的是,Java虚拟机对class文件采用的是按需加载的方式,也就是说当需要使用该类时才会将它的class文件加载到内存生成class对象,而且加载某个类的class文件时,Java虚拟机采用的是双亲委派模式即把请求交由父类处理,它是一种任务委派模式。
双亲委派模式要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器,请注意双亲委派模式中的父子关系并非通常所说的类继承关系,而是采用组合关系来复用父类加载器的相关代码,类加载器间的关系如下:
2)双亲委派模型的工作流程
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。
3)双亲委派模型优点(为什么用)
采用双亲委派模式的是好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,通过这种层级关可以避免类的重复加载,当父亲已经加载了该类时,就没有必要子ClassLoader再加载一次。其次是考虑到安全因素,java核心api中定义类型不会被随意替换,假设通过网络传递一个名为java.lang.Integer的类,通过双亲委托模式传递到启动类加载器,而启动类加载器在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载网络传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,这样便可以防止核心API库被随意篡改。可能你会想,如果我们在classpath路径下自定义一个名为java.lang.SingleInterge类(该类是胡编的)呢?该类并不存在java.lang中,经过双亲委托模式,传递到启动类加载器中,由于父类加载器路径下并没有该类,所以不会加载,将反向委托给子类加载器加载,最终会通过系统类加载器加载该类。但是这样做是不允许,因为java.lang是核心API包,需要访问权限,强制加载将会报出如下异常
1 | java.lang.SecurityException: Prohibited package name: java.lang |
怎么判断JVM里是否出现死锁
- 通过
jps
命令,查看java进程的PID - 通过jstck PID查看线程日志
- 如果存在死锁,日志中会给出
Found one Java-level deadlock:
,定位到这个信息就可以快速定位问题。
1 | Found one Java-level deadlock: |
1 | public class ThreadDemo { |
堆和栈的区别
堆用来存放实例对象和数组,栈用来存放局部变量和方法调用;
堆是里面的对象是线程共享的,栈内存是线程私有的;
堆是先进先出、栈是先进后出;
堆是运行时确定内存大小,而栈在编译时确定内存大小;并且堆内存远大于栈内存,但是栈内存在分配速度上更快;
堆内存由JVM管理,栈内存会被自动释放。