快速理解 JVM 内存模型 & 对象组成 & 对象内存分配
JVM 内存模型
JVM 内存模型分为首先在线程纬度可以分为两部分
一部分是 线程共享: 堆、元空间
- 堆 : 大多数 new 的对象都存在于堆内,也是 GC 主要回收的空间,占据 JVM 内存的大部分
- 元空间:存储静态信息,如 常量、静态变量、类元信息(你写的类解析出来的字节码数据),该部分内存空间是直接内存空间。在 cpu 执行权限上,内存分为 用户空间 和 内核空间,对于 JVM 角度来说即 JVM 堆内存区域 和 系统直接内存区域 感兴趣可以浏览文章 零拷贝
一部分是 线程独有: 线程栈、本地方法栈、程序计数器
- 线程栈: 细分栈帧,线程在执行方法的过程中,每调用一个方法即为该方法分配一块栈帧内存空间,方法调用结束则弹出该栈帧释放栈帧内存空间,每个线程栈空间有限,当你出现循环调用,不停的往线程栈中压入栈帧时,最终就会触发 StackOverFlow 异常。可通过
-Xss
参数配置 (线程栈使用的也是非堆内存,即直接内存) - 程序计数器:记录当前线程方法执行位置,用于在线程切换后恢复现场继续执行。
- 本地方法栈:java 调用本地方法(其他语言的方法,如 C 语言方法)单独区分的栈空间,原理类似于线程栈,有些虚拟机会将其和 线程栈合成一个使用。
对象组成
在 Java 当中对象在存储时会分为五个部分
- Mark Word:属于对象的固有属性,占用 8 byte (字节 ps 1 byte = 8 bit 位)
- Klass Pointer 对象指针, 占用 4 byte (开启指针压缩后,Java 1.6 版本后默认开启)。
- 数组长度,只有数组对象有,记录数组长度 占用 4 byte。
- 实例数据: 这个是我们在定义类时的内部成员变量的占用,是我们开发时经常操作看的到的。
- 对其填充: 有时有,会将对象大小补齐为 8 byte 的倍数。
所以,对于一个对象,即使你啥都没有声明,new 出来都至少要占用 8(mark word)+4(klass pointer)+4(对其填充) = 16 byte
说说指针压缩
首先 CPU 分为 32 位寻址 和 64 位寻址,32 位的 CPU 只能处理 32 位故而只能在 2^32 = 4G 内存中寻址,所以 32 位机器和系统只能支持 4G 的内存,现在大部分机器都是 64 位即 2^64 = 18446744073709551616 可支持的理论内存是绝对够人类使用了。
那在 64 位系统下,理论上 JVM 表示一个对象所在内存的地址需要 64 位来表示即 8 byte 字节,那其实大可不必🙅🏻♀️
8G 寻址 = 2^33 ,16G 寻址 = 2^34 ,32G 寻址 = 2^35 ,而我们大部分机器基本都在 32G 或之内,所以基本 35 位就可以表示所有内存位置了,JVM 通过算法讲其压缩到 32 位,在到 CPU 寄存器处理时在逆向解压缩出来。指针压缩就是这个道理。
Tips:默认配置下,如果堆内存大于 32G 指针压缩会失效,一般我们最大设置堆内存会设置的比 32G 小一点点,这样可以避免很多麻烦,所以堆并不是越大越好。
指针压缩和类型的对其填充有关,默认对其填充 8 byte 倍数,调高该值可以使指针压缩支持到更大。参考 为什么JVM开启指针压缩后支持的最大堆内存是32G?
对象内存分配
在给对象分配内存的时候,有两种方式:
- 指针碰撞: 这个是默认的方式,用于规整的 JVM 内存空间进行内存分配 (比如垃圾回收算法使用的是标记整理),这种依次分配的效率是比较高的
- 空闲列表:当内存并不规整,而是分散使用时,就需要维护一个空闲位置的列表地址进行分配 (标记清除)
那在分配内存的时候,多线程情况下不可避免会出现抢占问题,解决方案:
- 线程本地缓冲 TLAB Thread Local Allocate Buffer: 即预先给每个线程分配一块空间,有些在自己分配的空间中进行分配避免争抢
- 比较交换 CAS Compare And Swap + 失败重试:即多个线程争抢一个位置时,先拿到的先分配,其他的失败后重试其他位置分配