JVM运行时数据区域
JVM内存结构演进
从JDK1.6开始,JVM内存结构变化是比较大的
JDK1.6版本有方法区,且常量池在方法区中
JDK1.7版本从堆里面还划分出了方法区,但字符串已经被挪到堆内存了
JDK1.8版本取消了方法区,同时用元数据区代替了永久代,改放直接内存,即最大的区别就是让元空间脱离虚拟机内存,直接使用本地内存。元空间的内存上限被大大提高,只受限于操作系统的内存大小。
到JDK17版本就没有很多大的变化,而是重点在更新ZGC回收器上
程序计数器
是程序的字节码行号指示器,控制分支、循环、跳转、异常处理等。执行JAVA方法时计数器记录正在执行的虚拟机内存指令地址,执行native方法时是空的
多线程并发在JVM中实际是通过切换正在执行的线程进行的,依赖程序计数器
程序计数器不会出现任何的内存溢出场景
虚拟机栈
描述java方法执行的内存模型,一个方法创建一个栈帧,用于存储局部变量表、操作数栈、动态连接、方法出口等信息
递归调用的方法会创建下一个栈帧,因此栈有一个深度。这个深度不一定可扩展
对于这个区域,可能会出现如下溢出场景:
如果线程运行时请求栈深度超过虚拟机能分配的最大深度会出现StackOverflowError
方法要求栈容量可扩展,但又超过了编译时分配的大小,会出现OutOfMemoryError
局部变量表
又叫本地变量表,存放编译期可知的各种java虚拟机基本数据类型(布尔、byte、char、short、int、float、long、double)、对象引用(不等于对象本身,可能是指向对象起始地址的指针)、returnAddress类型。
例如Person person = new Person();
中,左边是在方法中创建的变量,存在局部变量表,右边是实际类的实例,存储在JAVA堆。person在局部变量表中的内容就是那个实际实例的起始地址。
局部变量表由slot(槽)组成,long和double类型是64位,占两个slot,其他数据类型均占1个slot
进入一个方法时,编译期就可以预知方法需要多少slot,因此局部变量表大小是事先分配好的,方法运行期间不会改变。这里的大小指的是局部变量表中slot的数量
本地方法栈
本地方法栈与虚拟机栈的结构、功能都差不多,二者的区别就是虚拟机栈是用来执行java方法,本地方法栈则是执行native方法
java堆
堆是占用内存最大的部分。作用是存放对象实例,也是JVM内存溢出的重点分析区域。如果java堆内存不够存放对象且堆再也无法扩展,会出现OutOfMemoryError。
java堆是JVM垃圾回收器的重点关照区域,JVM垃圾回收器都是基于分代理论实现的,包括JDK1.7的新生代、老年代、永久代,JDK1.8的eden区、survivor区(from区、to区)等概念,可以见后续垃圾回收部分补链接
JVM堆可以物理上不在连续空间内,但逻辑是连续的。
java堆可以是固定也可以是可扩展的(通过-Xmx
和-Xms
参数配置)
java1.8的堆分成了老年代和年轻代。年轻代分成了Eden区、Surviver区,Suerviver区分成了from区和to区。一般年轻代中E:S:T的结果是8:1:1。GC流程:
内存首先使用Eden区
一次YoungGC,Eden区存活对象进入From区
第二次YoungGC,Eden区存活对象进入To区,From区根据经历GC次数判断进入To区还是老年代
最后交换To区和From区,这样又获得了一个完全空置的To区
方法区和运行时常量池(JDK1.8之前)
方法区又叫非堆内存,存储被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。方法区是基于永久代概念开发的,有上限(通过-XX:MaxPermSize设置,不指定也有上限),容易发生溢出
运行时常量池是方法区的一部分,存放编译期生成的类版本、字段、方法、接口等描述信息,还有常量池表,存放编译期生成的各种字面量、符合引用。常量池是动态的,运行时可以加载新的常量进常量池,如String.intern()
方法。因此申请不到内存可能出现OutOfMemoryError
直接内存
直接内存不是JVM运行时数据区的一部分,不受堆内存影响,但跟堆内存相加不能大于物理内存,因此也有可能出现OutOfMemoryError。使用-MaxDirectMemorySize
参数可以限制直接内存,减少溢出
NIO类使用native函数直接分配堆外内存,然后通过堆内的一个DirectByteBuffer对象作为这块内存的引用进行操作。很多客户端连接可能基于netty实现,例如redisTemplate。可以参考如下链接:
元数据区(JDK1.8以后)
主要用来保存被虚拟机加载的类信息、常量、静态变量以及即时编译器编译后的代码等数据。不容易变动。属于堆外内存。
可以使用参数-XX:MaxMetaspaceSize
限制元数据区
HopSpot虚拟机
JVM对象创建原理
收到new指令时首先确认对应的类有没有加载、解析、初始化过,没有,先执行类的加载过程
类加载完成后,为对象分配内存空间(在类加载时就可以完全确定),从堆内存中划分出这个空间。
堆内存如果是绝对整齐的,即空闲内存和非空闲内存顺序排列,只需移动指针即可,即指针碰撞
如果不是绝对整齐,需要维护一个空闲内存表
多线程时,如果两个线程并发申请内存,可能冲突,这时可以使用本地线程分配缓存(TLAB),即预先按照线程把内存划分成不同空间,哪个线程创建对象就在对应空间进行。缓冲区分配完了再去堆内存申请时,会进行加锁操作。是否使用TLAB可以用参数
-XX:+/-UseTLAB
配置
分配内存后,JVM将分配到的内存空间初始化为0值。如果使用了TLAB,会在TLAB分配前执行这个流程。
JVM对对象做必要设置,如元数据信息、哈希码、年代信息等
执行程序中的初始化方法,字节流中new指令后面会接着执行
<init>()
方法,完成程序初始化。创建字节码实例参考:http://www.chymfatfish.cn/archives/JVMknowledge#%E5%88%9B%E5%BB%BA%E5%AF%B9%E8%B1%A1
jvm对象内存布局
jvm中对象有三部分组成:对象头、实例数据、对齐填充
对象头:对象头中有两类数据:
存储对象自身运行时数据:例如哈希码、GC分代年龄、锁状态等,长度在32位和64位虚拟机中分别为32bit和64bit;
指向对象的类型元数据的指针:以此来确定是哪个类的实例。如果对象是java数组,还会存有记录数组长度的数据。
实例数据:存储父类中继承的和子类定义的数据。这部分的存储数据收到参数
-XX:FieldsAllocationStyle
参数影响。默认策略是将相同宽度的对象放到一起存。而参数-XX:CompactFields
参数为true时(默认为true)允许长度短的对象在长的对象之间插空。对齐填充:无意义,填充使用
jvm对象访问定位
两种方式:使用句柄访问、直接使用指针访问。
使用句柄访问:jvm从堆内存中划分出一块作为句柄池,java栈本地变量reference中存储的是对象的句柄地址,句柄中包含对象的实例数据与类型数据各自的地址信息。好处是如果对象地址改变,只需要改变句柄池中的地址,reference中的数据始终都能够恒定不变。
直接使用指针访问:java栈本地变量表中存储的是对象地址。好处时速度块,节省一次指针定位开销。但需要jvm堆能够布局如何访问到类型数据的相关信息。
jvm内存溢出场景分析
java堆溢出
模拟这个场景,只需要给小一点java堆,然后不断向列表中添加数据即可
/**
* -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
*/
public class OOMTest {
static class OOMObject {
}
public static void main(String[] args) {
List<OOMObject> list = new ArrayList<>();
while (true) {
list.add(new OOMObject());
}
}
}
可以看到如下异常:Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
当出现堆内存溢出,有两种思路去分析:
内存泄露:即垃圾回收机制没有正确回收这些对象。这一点要从代码角度分析,看看哪里持有了对象不释放,或高频次生成大量无用对象。
内存溢出:即这些对象确实应该存活,需要看下堆内存参数是否有扩展空间。这一点就得从JVM配置角度触发,调整不同区域占比,调整GC频次,达到内存占用、GC时长、STW时长的平衡。
可以使用idea自带的profile工具分析内存,链接如下:
java栈溢出
有两种异常:
如果线程请求的栈深度大于虚拟机运行最大深度,抛出stackOverflowError
如果栈内存运行动态扩展,当扩展栈容量无法申请足够内存,抛出OutOfMemoryError
/**
* -Xss128K
*/
public class OOMTest {
private int stackLength = 1;
public void stackLeak() {
stackLength++;
stackLeak();
}
public static void main(String[] args) {
OOMTest oomTest = new OOMTest();
try {
oomTest.stackLeak();
} catch (Throwable e) {
System.out.println("stackLength:" + oomTest.stackLength);
throw e;
}
}
}
通过不断递归自己触发虚拟机栈溢出
可以看到如下异常:Exception in thread "main" java.lang.StackOverflowError
有stackOverflowError异常时一般会有明确的错误堆栈可供分析
一般栈深度到达1000~2000是完全没有问题的,但是对于超高并发系统,建立过多线程导致内存溢出,在不能减少线程数量或更换64位虚拟机的情况下,可以考虑通过减少最大堆(-Xmx
)和减少栈容量(-Xss
)两个参数优化。
这样做的原理是:减少堆内存可以使更多内存分配到虚拟机栈上,而-Xss
参数决定虚拟机栈分配给单个线程的内存大小,减少-Xss
大小可以使得允许创建线程数更多。
有时anon区也和堆外内存相关,参考如下案例:
方法区和运行时常量池内存溢出
jdk8目前实际上已经抛弃了永久代这一个概念,而改用元空间来实现方法区。而字符串常量池被移动到java堆中。
/**
* -Xmx6M
*/
public class OOMTest {
public static void main(String[] args) {
// 使用set保持常量池引用,避免Full GC回收常量池
Set<String> set = new HashSet<>();
// 在short范围内足以让6MB的堆内存产生OOM
short i = 0;
while (true) {
set.add(String.valueOf(i++).intern());
}
}
}
可以看到如下异常:Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded
此场景下JVM每次进行垃圾回收,却只能回收到很少的垃圾。默认情况下,如果Java进程花费98%以上的时间执行GC,并且每次只有不到2%的堆被恢复,则JVM抛出此错误,即耗尽了所有内存,却无法清理他们。
除此之外,使用元空间的jdk8基本不会有方法区溢出的情况,但jvm也提供了一系列参数来进行防御,例如-XX:MaxMetaspaceSize
本机直接内存溢出
NIO底层调用Unsafe#getUnsafe
方法申请直接内存。
/**
* -Xmx20M -XX:MaxDirectMemorySize=10M
*/
public class OOMTest {
private static final int _1MB = 1024 * 1024;
public static void main(String[] args) throws IllegalAccessException {
Field unsafeField = Unsafe.class.getDeclaredFields()[0];
unsafeField.setAccessible(true);
Unsafe unsafe = (Unsafe) unsafeField.get(null);
while (true) {
unsafe.allocateMemory(_1MB);
}
}
}
可以看到如下异常:Exception in thread "main" java.lang.OutOfMemoryError at sun.misc.Unsafe.allocateMemory
这种场景下产生的Dump文件很小,程序中又间接使用了DirectMemroy(最典型就算NIO),那就可以重点检查下直接内存方面了。
评论区