JVM核心知识点小结
[toc]
# 写在文章开头
本文针对JVM一些比较核心的知识点进行剖析和总结,以帮助读者对JVM有一个相对全面的感知。
我是 SharkChili ,Java 开发者,Java Guide 开源项目维护者。欢迎关注我的公众号:写代码的SharkChili,也欢迎您了解我的开源项目 mini-redis:https://github.com/shark-ctrl/mini-redis (opens new window)。
为方便与读者交流,现已创建读者群。关注上方公众号获取我的联系方式,添加时备注加群即可加入。
# JVM基础知识点
# JDK1.6、1.7、1.8 内存区域的变化
JDK6使用永久代作为方法区:

JDK7时考虑到永久代垃圾回收效率问题,将字符串常量池、静态变量放到堆区,而类常量池、运行时常量池仍然存放在方法区中。

JDK8则是用元数据区实现作为方法区的实现。去掉了永久代这么个东西,而元数据区存放的仍然是运行时常量池和类常量池。

# 详解JVM方法区
方法区主要是用于存储类信息、静态变量以及常量信息的。是各个线程共享的一个区域。我们都知道JVM中有个区域叫堆区,所以有时候人们也会称方法区为Non-Heap(非堆)。
在JDK8之前方法区存放在一个叫永久代的空间里。
在JDK8之后由于HotSpot 和JRockit 的合并,所以方法区就被作为元数据区了。
# 方法区和永久代的关系
其实方法区并不是一个实际的区域,他不过是JVM虚拟机规范提出的一个概念而已。在HotSpot 实现方法区的方式就在JVM内存中划分一个区域作为永久代来存放这些数据。
在JDK8之前我们可以用下面的参数来调整永久代的大小
-XX:PermSize=N //方法区 (永久代) 初始大小
-XX:MaxPermSize=N //方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen
2
# 为什么JDK8之后要把永久代 (PermGen)换成元数据区(MetaSpace)
将数据放在永久代固然没问题,但是随着时间的推移,方法区使用的空间可能会逐渐变大,若我们分配大小不当很可能造成线上OOM问题,所以设计者们就在方法区移动到本地内存中,通过本地内存来存放数据。并且元数据区默认分配值为unlimited(我们也可以通过-XX:MetaspaceSize来动态调整),理论上是没有明确大小,是可以动态分配空间的,这样一来由于元数据区就不会受到JVM内存分配的约束了,所以理论上发生OOM的概率会小于永久代。
# 详解运行时常量池
首先我们需要了解一下类常量池,它要用于存放两大类常量:字面量(Literal)和符号引用量(Symbolic References)。
我们都知道Class文件包含字段描述符、方法描述符、接口等描述信息,还有编译器生成的字面量和符号引用,都会被存放到JVM方法区的运行时常量池中。
- 在JDK7之前,运行时常量池包括字符串常量池都存放在永久代。
- JDK7将字符串常量池移动到了堆区。而其他数据依然保留在方法区中,即可永久代区。
- 在JDK8则将永久代改为元空间,这就意味着运行时常量池就被存放到元数据区了。
# JVM 常量池中存储的是对象还是引用
运行时常量池 runtime constant pool(而不是interned string pool / StringTable之类的其他东西)的话,其中的引用类型常量(例如CONSTANT_String、CONSTANT_Class、CONSTANT_MethodHandle、CONSTANT_MethodType之类)都存的是引用,而对象还是存在Java heap上的。
# 详解元空间的直接内存
在JDK1.4中 NIO(New Input/Output) 类提供的一个名为MappedByteBuffer的内存映射文件的方式,直接调用Native操作本机内存,通过这种方式避免操作数据从JVM堆到Native堆的开销,从而提高程序执行效率。这就意味着这种这个操作会受到本机内存大小以及处理器寻址空间的限制。
# Java对象的创建过程
整体过程大抵是一下几个步骤:
- 类加载检查: 在JVM收到new命令后,就会先去常量池查看是否有这个类的符号引用,若有则再查看这个类是否被加载、解析、初始化过。若没有则进行类加载。
- 分配内存空间: 在堆区划出一个空间将为对象分配空间。
- 设置零值: 完成对象空间的分配之后,就需要将对象中的字段都赋为初始值
(除了对象头)。 - 设置对象头: 完成上述步骤,我们就需要为对象头设置
哈希码、对象的GC分代信息、元数据信息、以及是否使用偏向锁等都放到对象头中。 - 执行init方法: new方法最终一步,调用
<init>完成对象的创建。
# java对象的内存布局
宏观来说有这么几个模块,大抵可以分为:
- 对象头
- 实例数据
- 对齐填充
先来说说对象头,它由两个部分组成,第一个部分则是记录自身信息的,包含哈希码、gc分代年龄、锁状态标志、线程持有的锁、偏向锁id、偏向时间戳等,它也叫markword。需要补充的是,如果这个是属于数组类型的话 第二个部分则是类型指针,指向对象的类元数据类型,这个类型指针的存在使得我们可以知晓它是哪个类。
实例数据用来存储对象中各自类型的字段内容,即使是从父类继承来的,它也会记录。
对齐填充不是必须的,仅仅作为占位符使用的,更多关于java对象的内部结构,可以参考笔者的这篇文章:
Java对象大小的精确计算方法:https://mp.weixin.qq.com/s/EiNn5RnRU2lQYEI-fiON-w (opens new window)
# java对象内存分配的两种算法
一种是指针碰撞、还有一种是空闲列表。
指针碰撞使用是堆区空间规整的情况下,例如你使用复制算法、或者标记-整理算法时,堆区空间就是规整的。而空闲列表则适用于空间不完整的情况,例如标记-清除算法。
空闲列表:虚拟机必须维护一个列表,记录哪些内存是可用的,在分配的时候从列表找到一块大的空间分配给对象实例,并更新列表上的记录,这种分配方式就是空闲列表。
# Java如何多线程并发分配对象问题
在分配空间时JVM首先会预先为Eden区为每个线程分配一个TLAB空间。每次线程都只能操作自己的TLAB区以及读取其他线程的TLAB区(但是不能操作),若TLAB空间满了或者不够分配当前对象时,则基于CAS+失败重试在堆区其他空间尝试分配空间。
# 对象访问定位的两种方式
对象访问定位的方式目前有以下两种有:
使用句柄方式访问直接指针
句柄访问则将对象实例数据地址、类型数据地址交由句柄保存,然后Java堆中开辟一块空间作为句柄池,对应的引用需要访问对象时,首先会通过引用定位到句柄池中的句柄,然后基于句柄定位到堆区的对象实例的指针。 当然这种做法有以下几个优势:
- 内存管理灵活:对象实例数据和类型数据(元信息)分开存储,方便单独优化(指针压缩、类元信息共享等)。
- 稳定性高:在垃圾收集时如果对象地址发生改变,只会改变句柄池中的对象指针,其余所有引用本身都无需修改。
当然也有如下几个劣势:
- 额外的内存占用:因为需要专门维护句柄池,需要占用额外的堆空间。
- 效率较慢:访问对象时需要通过句柄间接到达对象,效率相对直接访问低效一些。

而直接指针则是引用直接持有对象的物理地址,访问时可直接通过引用地址获取对象,相应的对象地址发生改变时,所有的引用地址信息也都需要发生改变,即直接指针访问的优势为:
- 访问速度快:直接通过引用访问地址,避免间接查询的开销,提升程序运行速度。
- 避免非必要空间占用:避免独立维护一份间接引用句柄池的开销。
同样的缺点也很明显:
- 对象移动成本高:在对象地址发生改变时,所有引用都需要同步更新信息,维护繁琐。
- 耦合性高:对象布局(比如对象头必须包含元数据指针),限制了内存优化的灵活性。

我们以hotspot虚拟机为例,是采用直接指针的方式,主要原因:
- 减小一次指针定位开销。
- hotspot对于对象访问工作进行大量的优化。
- 现代计算机对于直接地址访问做了很多良好的支持。
# java堆内存分配的基本策略
大体遵循如下步骤:
- 对象优先会被分配在
eden区,如果是大对象执行步骤2。 - 如果是大对象直接分配到老年区
(避免空间分配担保机制的负担) - 当对象存活时间达到
-XX:MaxTenuringThreshold的值时也会到老年区
# 什么是内存分配担保机制
确保minor gc后,晋升的对象可以成功分配到老年代的一种机制,以jdk7之后的jvm版本为例,空间分配担保只要保证如下两种情况中的一种,就会认为担保成功,说明老年代空间足够,无需进行full gc释放空间,反之直接进行minor gc:
- 历次晋升的平均对象小于老年代剩余最大连续空间总和。
- 新生代存活对象总和小于老年代剩余最大连续空间总和。
# 内存溢出和内存泄露的区别
内存泄露指的无用的对象未能实时的清除,导致堆区内存被一些无用的垃圾占用。而导致内存泄漏的大概会有以下几个原因:
- 静态集合类,静态集合声明周期和JVM一致的,所以它不可能释放掉,代码如下所示:
public class OOM {
static List list = new ArrayList();
public void oomTests(){
Object obj = new Object();
list.add(obj);
}
}
2
3
4
5
6
7
8
9
10
11
- 单例模式,单例但模式和静态集合类原因差不多,如果这个单例模式是大对象且未能及时销毁很可能导致内存泄漏问题。
- IO等连接资源未能及时释放
ThreadLocal变量:ThreadLocal的弱引用导致内存泄漏也是个老生常谈的话题了,使用完 ThreadLocal 一定要记得使用 remove 方法来进行清除。- hash值改变:对象 Hash 值改变,使用 HashMap、HashSet 等容器中时候,由于对象修改之后的 Hah 值和存储进容器时的 Hash 值不同,所以无法找到存入的对象,自然也无法单独删除了,这也会造成内存泄漏。说句题外话,这也是为什么 String 类型被设置成了不可变类型。
- 变量作用域过大
public class Simple {
Object object;
public void method1(){
object = new Object();
//...其他代码
//由于作用域原因,method1执行完成之后,object 对象所分配的内存不会马上释放
object = null;
}
}
2
3
4
5
6
7
8
9
10
11
而内存溢出则是当前堆区空间无法容纳新对象导致OOM问题。代码如下所示:
/**
* VM参数: -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
*/
public class HeapOOM {
static class OOMObject {
}
public static void main(String[] args) {
List<OOMObject> list = new ArrayList<OOMObject>();
while (true) {
list.add(new OOMObject());
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
总的来说内存泄漏会导致内存溢出。
# Minor GC/Young GC、Major GC/Old GC、Mixed GC、Full GC 的基本概念
Minor GC/Young GC指的是年轻代的垃圾收集。Major GC/Old GC指的是老年代的GC,目前只有CMS收集器会有单独收集老年代的行为。Mixed GC:混合收集,指的是新生代和老年代的垃圾收集,目前只有G1收集器会有这种行为。Full GC:收集整个Java堆和方法去的垃圾。
# Minor GC什么时候触发?
当新生代空间不足的时候就会触发Minor GC
# 什么时候会触发 Full GC
minor GC前检查老年代发现空间不足:在要进行Young GC的时候,发现老年代可用的连续内存空间 < 新生代历次Young GC后升入老年代的对象总和的平均大小,说明本次Young GC后可能升入老年代的对象大小,可能超过了老年代当前可用内存空间,那就会触发Full GC。Minor gc后老年代空间不足:执行Young GC之后有一批对象需要放入老年代,此时老年代就是没有足够的内存空间存放这些对象了,此时必须立即触发一次Full GC- 调用
system.gc() - 空间分配担保失败:新生代的 To 区放不下从 Eden 和 From 拷贝过来对象,或者新生代对象 GC 年龄到达阈值需要晋升这两种情况,老年代如果放不下的话都会触发 Full GC。
- 老年代空间不足:老年代内存使用率过高,达到一定比例,也会触发 Full GC。
- 方法区内存空间不足:如果方法区由永久代实现,永久代空间不足 Full GC。
# 对象什么时候会进入老年代
- 长时间存活的对象:在对象的对象头信息中存储着对象的迭代年龄,迭代年龄会在每次 YoungGC 之后对象的移区操作中增加,每一次移区年龄加一.当这个年龄达到 15(默认)之后,这个对象将会被移入老年代。
这个可以通过下面这个参数进行设置
- XX:MaxTenuringThreshold
2
- 大对象直接进入老年代:有一些占用大量连续内存空间的对象在被加载就会直接进入老年代.这样的大对象一般是一些数组,长字符串之类的对。大对象的阈值可以通过这个参数进行设置
-XX:PretenureSizeThreshold
2
- 动态对象年龄判断:为了能更好地适应不同程序的内存状况,
HotSpot虚拟机并不是永远要求对象的年龄必须达到- XX:MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半时,年龄大于或等于该年龄的对象就可以直接进入老年代。 - 空间分配担保:假如在
Young GC之后,新生代仍然有大量对象存活,就需要老年代进行分配担保,把Survivor无法容纳的对象直接送入老年代。
# 什么是强引用、软引用、弱引用和虚引用
强引用、软引用、弱引用和虚引用 用于管理对象生命周期,主要用于帮助垃圾回收器决定对象回收的时机,从而保证高效管理堆内存:
强引用:我们默认情况下的引用就是强引用,即使JVM发生OOM使程序异常终止,这一类被强引用所持有的对象也不会被回收。
软引用:即SoftReference软引用的使用方式如下:
SoftReference<Object> softReference=new SoftReference<>(new Object());
软引用特点是,当堆空间不足需要进行垃圾回收时,垃圾回收器会优先处理掉仅被软引用持有的对象,已释放内存:
Soft reference objects, which are cleared at the discretion of the garbage collector in response to memory demand. Soft references are most often used to implement memory-sensitive caches.
弱引用:即WeakReference,对应的使用语法如上所述,仅被这类引用所持有的,不论内存空间是否充足,对象一旦被扫描到直接回收:
Weak reference objects, which do not prevent their referents from being made finalizable, finalized, and then reclaimed. Weak references are most often used to implement canonicalizing mappings.
虚引用:即PhantomReference对于对象生命周期没有任何作用,单纯标记跟踪对象的回收状态,它常常与ReferenceQueue互相配合,如果回收器在某个时间认为这个虚引用对象可达就会将其入队,并且在此期间虚引用是不可访问对外都会返回null,直到被回收:
Phantom reference objects, which are enqueued after the collector determines that their referents may otherwise be reclaimed. Phantom references are most often used for scheduling pre-mortem cleanup actions in a more flexible way than is possible with the Java finalization mechanism. If the garbage collector determines at a certain point in time that the referent of a phantom reference is phantom reachable, then at that time or at some later time it will enqueue the reference. In order to ensure that a reclaimable object remains so, the referent of a phantom reference may not be retrieved: The get method of a phantom reference always returns null. Unlike soft and weak references, phantom references are not automatically cleared by the garbage collector as they are enqueued. An object that is reachable via phantom references will remain so until all such references are cleared or themselves become unreachable.
对于get会null这一点,我们可以直接通过查看源码印证:
public class PhantomReference<T> extends Reference<T> {
//......
public T get() {
return null;
}
//......
}
2
3
4
5
6
7
8
# 如何判断一个常量是废弃常量?
我们以字符串为例,如果字符串常量没有任何引用指向的话,那么在垃圾回收阶段这个常量就会被回收。
# 如何判断一个类是无用的类?
这里说到的是类吧?判断类是否无用大概是从以下3点判断:
1. 这个类的所有实例都被垃圾回收器回收了,也就是Java堆中没有任何该类的实例。
2. ClassLoader 被回收了。
3. java.lang.Class类没有被被引用,无法通过任何地方完成反射操作了。
如果符合上述三点,就说明这个类可以被回收了,注意仅仅是可以,不代表真的就要被回收了。
Java的堆分为新生代和老年代,新生代的对象大部分都是用后即焚的,所以采用标记复制法,为了提高标记复制法的空间利用率(这一点下一个知识点会说明),又将新生代分为eden区和survivor区,默认情况下Eden和两个survivor对应的比例为8:1:1,这一点我们可以找到任意一个对应的JVM程序键入如下指令查看:
jinfo -flag SurvivorRatio pid
默认情况下输出结果为-XX:SurvivorRatio=8,即Eden和survivor比例为8:2
老年代(old)基本上存活比较久的对象这些对象大部分都是由新生代经过无数轮GC存活下来的对象。
# Class 的作用是什么
class文件即字节码文件,是面向虚拟机的一种文件,它解决传统解释器语言效率低的问题。也正是由于它是面向虚拟机的文件,所以Java代码只需编译一次即可在任何有虚拟机的平台使用。
# 详解一个类的加载过程
类的生命周期分为以下几步:
- 加载:这个加载是从虚拟机规范角度出发,它是整个类加载过程中的一个阶段,更多强调的是文件的二进制流,至于怎么来的,交由实现者实现,加载本质上就是通过类的全限定名,将类的二进制流数据转为方法区运行时数据结构,并在堆内存中生成唯一的Class对象作为方法区中针对这个类的各种数据的访问入口。
- 验证:基于上一步的结果,查看这个Class文件是否存在危害JVM自身安全的行为,这意味着它必须要符合《Java虚拟机规范》的全部约束要求。
- 准备:准备阶段是针对类中的静态变量
(被static修饰的变量)在方法区进行0值初始化分配,需要强调的是方法区是一个逻辑的概念,不同版本的JVM都有着不同的实现,例如7以前用永久代实现方法区,JDK8以后类变量会随着Class对象一起存放的java堆中。 - 解析:将虚拟机常量池内的符号引用转为替换为直接引用,该过程包含类加载解析、字段和方法解析如果通过符号引用解析不到对应直接引用,那么这个创建过程就会抛出
NoSuchXXX相关的异常。 - 初始化:创建对象时,底层调用
javac自动生成的<cinit>完成对象创建,改方法会收集所有静态代码块和赋值语句并按顺序执行从而完成类的初始化。
# JVM类加载器有哪些?
知道,大概有以下三个:
- BootstrapClassLoader(启动类加载器):负责
%JAVA_HOME%/lib目录下的 jar 包和类或者或被-Xbootclasspath参数指定的路径中的所有类。 - ExtensionClassLoader(扩展类加载器) :主要负责加载目录 %JRE_HOME%/lib/ext 目录下的 jar 包和类,或被 java.ext.dirs 系统变量所指定的路径下的 jar 包。
- AppClassLoader(应用程序类加载器) : 负责加载当前应用程序classpath下的所有jar包。
# 什么是双亲委派?如何破坏双亲委派
双亲委派模型要求除了顶层的类加载器以外,其余的类加载器都应该有自己的类加载器,并且在类加载器加载请求直接委派给父类加载器完成,由此保证最终都会传送到顶层的启动类加载器,只有根加载器无法完成加载请求,子加载器才会尝试自己去加载。
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
//不断向底层传递类加载请求
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
}
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name);
// this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
要破坏直接重写loadClass即可
# 双亲委派模型有什么好处是什么
- 避免重复加载相同的类。
- 避免用户编写的类破坏核心API。
# 静态变量在堆区还是在元空间 它是否会被GC回收
静态成员变量迁移到了堆中,由static所修饰的引用持有时就不会被回收。

# Java发生了OOM一定会导致JVM 退出吗
不会,从操作系统角度来说,JVM本质上也是操作系统上的一个进程,当一个进程尝试访问一个未分配给它的内存或者超出允许范围的内存时,操作系统会向程序发送SIGSEGV(无效的内存引用),对应的如果函数没有针对这个信号的处理函数,则会直接退出,并产生segment fault的错误提示。
而JVM所抛出OOM不过是自身能够容忍和隔离的单个线程出现的问题,当一个线程因此常出现问题而崩溃时,JVM会尝试将问题限定在该线程内,也就只会导致某个线程挂掉,这也就是为什么我们线上出现OOM问题时,依然可以通过某个代理程序attach到某个程序上进行故障定位。
# JVM进阶知识点
# 什么是JIT优化技术
JVM内置的解释器会将编译后的字节码翻译为机器码,然后再执行,考虑到代码边解释边执行的效率非常低,于是hotspot就引入JIT技术(Just-In-Time),它会针对那些执行比较频繁的热点代码(hot spot code)进行优化并缓存,由此提升程序执行的效率:

# Java中的对象是否一定在堆内存上分配
不一定,如果对象经过逃逸分析的判定发现对象没有发生逃逸,hotspot虚拟机的JIT优化机制则直接在栈上完成对象分配,对此我们不妨做一个实验,以下面这代码为例,我们通过JVM参数-XX:-DoEscapeAnalysis关闭逃逸分析:
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < Integer.MAX_VALUE; i++) {
new TestObj();
}
long end = System.currentTimeMillis();
System.out.println(end - start);
ThreadUtil.sleep(Integer.MAX_VALUE);
}
static class TestObj {
}
2
3
4
5
6
7
8
9
10
11
12
13
14
从抽样器重可以知晓,关闭逃逸分析之后所有的对象都会创建在堆上,且分配在堆就会因为堆内存已满导致GC,所以关闭逃逸分析之后这段代码的执行耗时为7754ms:

与之的对应的是eden区的GC次数也达到了34次:

与之相反一旦我们开启逃逸分析-XX:+DoEscapeAnalysis,可以看到因为JIT优化的缘故,本次堆区创建的对象就少了很多
同时新生代gc也直接变为0:

# JVM为什么要把堆和栈区分出来呢
首先我们需要了解堆内存是存储对象实例的,栈是每一个线程独享的内存区域,用于方法调用和局部变量信息存储,之所以将两者区分出来的原因如下:
- 存储内容不同,分开管理,堆内存用垃圾回收器选用合适算法进行垃圾回收,而栈内存靠编译器和虚拟机执行完成。
- 独立划分区域,让堆内存溢出、泄漏问题不影响到栈,栈溢出不会影响到堆。
- 栈是线程独享,可通过栈封闭技术做到线程独享,将共享数据放在堆上。
- 通过划分区域,让栈来分配局部变量保证高效,让堆出去复杂的对象信息,针对各种实例内存分配进行按需的内存分配管理。
# 什么样的代码会导致StackOverflow即栈溢出
即递归的代码超越了JVM给每个线程配置的栈空间大小就会导致StackOverflow,例如下面这段经典的无限递归的代码,它就是典型的虚拟机栈(JVM Stack)溢出:
public static void main(String[] args) {
infiniteRecursion(); // 最终抛出 StackOverflowError
}
// 典型的递归导致虚拟机栈溢出
public static void infiniteRecursion() {
infiniteRecursion();
}
2
3
4
5
6
7
8
# 哪些不会导致OOM
我们还是以jdk8版本的JVM运行时内存区域来说明:
- 虚拟机堆:堆内存在对象无法分配时就会导致OOM。
- 虚拟机栈:如下所说,无限递归的情况下可能导致
StackOverflow,也是内存溢出的一种情况。 - 本地方法栈:与虚拟机栈类似,只不过调用的是native方法。
- 方法区:
jdk8版本方法区是通过元空间实现,有一定概率出现的元空间(例如疯狂创建动态代理对象)不足也是OOM的一种表现。 - 程序计数器:每个线程独有的区域,记录当前线程执行的字节码行号指示器,不会导致
OOM。
# 运行时常量池和字符串常量池的关系是什么
这一点上文已经详细说明了,这里简单补充一下,字符串池可以理解为运行时常量池的一部分,按照虚拟机规范应该存在于方法区,但是在物理层面的实现,早期两者都位于永久代,在jdk7之后将字符串池放到堆内存中,而运行时常量池在jdk7则是在永久代,jdk8之后就直接放在元空间了。
# FullGC多久一次算正常
按照二八(即80%的流量是在20%的时间段产生)原则来说,非高峰工作时间一次full gc都是正常的,甚至笔者认为在如今分布式架构的情况下,尽可能去避免full gc才是正常的。
# 字符串常量池是如何实现的
按照JVM虚拟机规范:
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(ConstantPoolTable),用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
字符串常量池就是一个放在方法区的字面量,而JVM虚拟机规范并没有对方法区的实现进行界定,所以不同的版本的方法区都有着不同的实现,这也就意味着字符串常量池在不同的JDK版本会存放在不同的区域:
- 对于
jdk6而言方法区是放在永久代实现的,所以字符串常量池就是放在永久代。 - 到了
jdk7之后,因为永久代垃圾回收效率不高,只有在full gc的时候才会执行回收,所以对应的字符串常量池直接放到堆空间保证更高效的回收。
# JVM是如何创建一个对象的创建
- 检查该指令能否在常量池定位到类的符号引用,并检查符号引用代表的类是否已经被加载、解析、准备、初始化了,如果没有必须先进行这个过程。
- 然后是内存分配,它会通过指针碰撞或者空闲列表的方式进行内存分配,注意分配是用上述方案,但是并发问题则还需要TLAB或者CAS重试进行配合。
- 然完成分配后就是针对实例变量的0值初始化。
- 设置对象头中各种元信息、哈希码、GC分配年龄、锁信息。
- 调用构造方法按照逻辑完成变量赋值。
- 返回对象引用,后续的逻辑就可以基于这个引用操作堆内存中的对象。
# YoungGC和FullGC的触发条件是什么
YoungGC即新生代gc,触发条件比较简单,即新生代eden区空间不足时触发。
Fullgc则有如下几个条件触发:
- java程序创建了一个大对象且超过了阈值(可通过
jinfo -flag PretenureSizeThreshold <PID>指令查看这个值的大小),则会直接保存到老年代,如果发现老年代空间不足,则会触发依次Full GC。 YoungGC触发时会进行空间担保分配策略,在担保的过程中,一旦发现老年代连续空间大小小于新生代所有兑现过多总空间,且HandlePromotionFailure设置为false(这个配置在jdk7已失效),就会触发依次full gc。- 早期jdk版本在永久代空间不足时,也会触发full gc。
- 如果我们手动执行
System.gc();也会触发full gc,至于什么时候执行,未知。
# 什么是Stop The World
简称STW,是在执行垃圾回收算法时,java程序会暂停所有线程,是一种全局的暂停现象,除了native代码以外,所有的java代码都停止运行。
需要注意的是不管哪种垃圾回收算法都需要STW,这个步骤不能跳过,只能说适当减小其时长,之所以这么做的原因也是处于回收的可靠性,为了保证垃圾回收期间不会不断产生新的对象和垃圾对象而导致垃圾回收时出现漏标或者多标现象,通过STW保证垃圾回收时的准确性。
# 新生代如果只有一个Eden+一个Survivor可以吗
可以,但是垃圾回收效率不高,新生代的垃圾回收算法是标记复制法,按照原有配比设计,每次GC后都会将Eden区清空并将存活的对象移动到Survivor区,由此保证新生代空间利用率达到90%:

如果我们将配比改为1:1的话,每次GC后都只能将存活对象移动到另一半的新生代堆空间,使得空间利用率只有50%:

# 新生代和老年代的垃圾回收器理念和实现上的区别?
新生代一般来说GC次数要比老年代高很多,通常来说新生代我们考虑高效且空间利用率高,保证尽可能少的内存碎片,所以新生代采用标记复制法进行垃圾回收,将区域分为1个eden区和两个survivor区,当eden区满了之后就会触发minor gc,将存活的对象移动的另外一个survivor区中,由此保证空间利用率为90%且没有内存碎片。
对于老年代来说,大部分对象都是存活比较久的对象,为了减少空间浪费和内存碎片问题,通常使用标记整理发,当然也有些回收器(例如CMS)为了减小STW的时延,通常会用标记清除法。
# 详解JVM跨代引用问题
我们都知道堆内存是分为新生代和老年代的,这就可能存在新生代持有老年代引用或者老年代持有新生代对象引用的问题,以下图为例,新生代进行gc时,因为感知不到老年代持有新生代引用的情况,而无故将新生代应该存活的对象给回收了:

对此,解决方案有两个:
- 进行
minor gc时,gc root扫描到老年代后继续进行扫描标记引用的新生代。 - 进行
minor gc时,把所有老年代对象作为gc root进行可达性扫描。
上述无论那种都可能存在全堆扫描的情况,于是设计者们按照老年代回收一般都晚于新生代的特点,针对老年代做一个remember set存储跨代引用关系,双方进行GC只要根据remember set查看是否存在跨代引用,从而减小扫描的开销。
而这里我们也补充一下remember set的实现,对于hotspot虚拟机而言,本质上就是使用一个512 btye的card table他会记录堆内存中的page信息,每一个byte记录一个page页地址,这个page可能包含一个或者多个对象,只要某个对象存在跨代指针,就将其设置为dirty,后续进行minor gc时,就可以用card table中的dirty page作为gc root参考的依据:

关于跨代引用问题更多可以参考:浅谈JVM垃圾收集——记忆集与卡表 :https://blog.csdn.net/a1076067274/article/details/115275114 (opens new window)
# 详解JVM完整的GC流程(重点)
一般来说,GC流程都是在对象的过程中产生,我们以JDK8为例,对应的完整的GC流程为:
- 除去
JIT优化后的栈上分配,对象一般都会在堆内存进行分配,首先JVM会查看当前对象是否是大对象,至于如何判定为大对象,则是由-XX:PretenureSizeThreshold决定,默认情况下这个值是0,即不会提前进入老年代。 - 我们顺着思路往下,对象一般会先在
Eden区进行内存分配,如果Eden区内存不够,则准备触发一次minor gc,对应的垃圾回收算法是标记复制法。 - 在正式
minor gc之前,JVM会检查当前老年代的连续内存空间是否大于新生代所有对象使用的空间,如果大于则说明本次minor gc是安全的,故直接进行一次minor gc,这一步会将新生代中的垃圾对象都进行回收,并将存活的对象移动到另外一个survivor区中。如果老年代连续空间小于新生代对象大小则进入步骤4。 - 这一步会检查
JVM是否配置空间分配担保,如果有则判断老年代的连续空间是否大于新生代历次晋升到老年代的内存大小,如果大于则直接进行minor gc即可,若小于或者未开启空间分配担保则说明本次minor gc有风险,则需要进行一次full gc。 - 经过GC后将看看能不能将对象放在新生代,如果放不下则放到老年代。
- 经过
full gc之后发现老年代空间依然无法存放当前对象则直接触发OOM异常。

# JVM内存区域的分配
内存区域有堆区、虚拟机栈、本地方法栈、程序计数器、方法区,这里笔者以JDK8为例给出一张架构图:

其中方法区和堆区为线程共享的。其余都是线程隔离的。
而各个组成部分的作用为:
程序计数器(Program Counter Register)也被称为 PC 寄存器,是一块较小的内存空间。它可以看作是当前线程所执行的字节码的行号指示器。它指向当前线程要执行的下一条指令的地址。Java 虚拟机栈(Java Virtual Machine Stack):也是线程私有的,它的生命周期与线程相同。Java 虚拟机栈描述的是 Java 方法执行的线程内存模型,方法执行时,JVM 会同步创建一个栈帧,用来存储局部变量表、操作数栈、动态连接等。本地方法栈(Native Method Stacks):与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。- 对于 Java 应用程序来说,
Java 堆(Java Heap)是虚拟机所管理的内存中最大的一块。Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java 里“几乎”所有的对象实例都在这里分配内存。 Java 堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作“GC 堆”(Garbage Collected Heap)。从回收内存的角度看,由于现代垃圾收集器大部分都是基于分代收集理论设计的,所以 Java 堆中经常会出现新生代、老年代、Eden空间、From Survivor空间、To Survivor空间等名词,需要注意的是这种划分只是根据垃圾回收机制来进行的划分,不是 Java 虚拟机规范本身制定的。 方法区:是比较特别的一块区域,和堆类似,它也是各个线程共享的内存区域,用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。它特别在 Java 虚拟机规范对它的约束非常宽松,所以方法区的具体实现历经了许多变迁,例如 jdk1.7 之前使用永久代作为方法区的实现。
# 什么是safe point
安全点即代码执行过程中的一个特殊区域,当线程执行到这个区域的时候,可以被认定为处于安全状态,可进行STW进行垃圾收集等工作,引用《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版) 》中的说法,JIT(即时编译)会在以下几个序列产生安全点:
安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而长时间执行,“长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等指令序列复用会符合这些要求。
如果JVM有需要,可以在这个位置将线程暂停挂起,进行GC、偏向锁撤销、代码热替换、获取dump信息等。
# 详解CMS的垃圾回收过程
- 初始标记:找到
gc root可达的对象进行标记,该对象会触发STW,但只标记直接可达对象,因此这个阶段通常执行时间很快。 - 并发标记:该阶段与用户线程一起工作,基于上一步可达对象遍历整个对象图,标记所有可达对象,该阶段不会stw。
- 重新标记:基于上一步结果进行stw确认扫描,标记最终结果。
- 并发清理:GC线程清除不可达垃圾对象,释放内存空间。不需要STW。
# 为什么CMS垃圾回收器要求初始标记和重新标记需要进行一次STW,而并发标记阶段不用
CMS垃圾回收器存在的目的就是减小应用程序停顿的时间,初始标记需要获取GC root,因为大多数gc root不是对象,为了保证准确性,这个阶段可以STW,并发标记和用户线程一起工作,因为这个阶段涉及全堆扫描且后续有重新标记进行兜底,所以这个阶段不会进行STW,而重新标记确认上一步结果,也就是偿还债务,需要stw以保证最终垃圾回收的准确性。
# G1垃圾回收器的基本原理
可参考笔者这篇深入解析JVM中的G1垃圾回收器:https://mp.weixin.qq.com/s/COkvJfkoMWQ0-l-FpcYlCw (opens new window)
# 如何选择合适的垃圾回收器
垃圾回收器的选择,我们大体需要从硬件资源、系统使用场景和JDK版本的角度进行多重判断选择:
- 我们首先是要考虑硬件资源,如果是单核或者内存比较小的服务器,则直接选择
serial gc即串行回收器,如果是多核进入步骤2。 - 如果需要考虑吞吐量还是
STW时长,例如我们的程序主要是处理批处理任务,那么它就是一个更在意实时吞吐量的系统,则优先考虑parallel gc即并行垃圾回收器,如果追求尽可能少的延迟则进入下一步。 - 如果是当前服务器的内存小于4g我们可以直接选择cms垃圾回收器,反之进入下一步。
- 如果jdk版本小于11则直接选择g1,如果等于11选择zgc,反之就是选择
shenandoah。
对应的我们给出一张流程图:

# 小结
我是 SharkChili ,Java 开发者,Java Guide 开源项目维护者。欢迎关注我的公众号:写代码的SharkChili,也欢迎您了解我的开源项目 mini-redis:https://github.com/shark-ctrl/mini-redis (opens new window)。
为方便与读者交流,现已创建读者群。关注上方公众号获取我的联系方式,添加时备注加群即可加入。
# 参考
JVM内存分配担保机制:https://blog.csdn.net/kavito/article/details/82292035 (opens new window)
运行时常量池(JVM06):https://zhuanlan.zhihu.com/p/353663613 (opens new window)
剖析面试最常见问题之JVM(下):https://xiaozhuanlan.com/topic/3621504987#section1class (opens new window)
深入理解Java虚拟机(第3版):https://book.douban.com/subject/34907497/ (opens new window)
jdk8之后,静态成员变量存储在哪?有说存在元数据区,有说迁移到堆中?希望大佬能给个详细的解答,多谢? - 红尘修行的回答 - 知乎 https://www.zhihu.com/question/324306038/answer/688264413 (opens new window)
jdk8之后,静态成员变量存储在哪?有说存在元数据区,有说迁移到堆中?希望大佬能给个详细的解答,多谢? - 红尘修行的回答 - 知乎 https://www.zhihu.com/question/324306038/answer/688264413 (opens new window)
java static GC 回收问题:https://blog.csdn.net/kangojian/article/details/5186530 (opens new window)
《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》