JVM中类创建和内存分配机制
JVM内存模型
运行时数据区域
程序计数器
- 每个线程都有一个自己独有的程序计数器, 为了线程切换后能恢复到正确的执行位置
- 如果正在执行的是Java方法, 则改计数器记录的是正在执行的虚拟机字节码指令的地址
- 如果正在执行的事Native方法, 这个计数器的值应为空
- 该区域是唯一一个没有OOM的区域
虚拟机栈
-
线程私有的, 即每个线程有自己独立的虚拟机栈
-
每个方法被执行的之后都会创建一个虚拟机栈帧, 其内容:
- 局部变量表
- 存放了基础数据类型
- 对象引用
- returnAddress类型
- 存储空间是以Slot来表示的, 其中需要注意long和double是需要用两个slot来存储的
- 操作数栈
- 动态链接
- 方法出口等信息
一个方法的调用实际上就是栈帧入站到出栈的过程
- 局部变量表
-
会发生StackOverflowError(超过虚拟机允许的)和OOM
本地方法栈
- 是为Native方法服务的
- 也会StackOverflowError和OOM
堆
- 所有线程共享, 存放对象实例
- GC的主要区域
- 可以通过-Xmx和Xms来设置堆的大小
方法区
- 线程共享
- 用来存储已被虚拟机加载的类型信息, 常量, 静态变量, 即时编译后的代码缓存的数据
- 以前称之为永久代
- JDK8 完全废弃了永久代的概念
- 会出现OOM
运行时常量池
-
方法区的一部分
-
用于存放编译期生成的各种字面量和符号引用的常量池表(Constant Pool Table)
-
并非阈值的Class文件中的常量池才能进入该区域, 运行期间也可以将新的常量放入池中, 如
String
类的intern()
方法intern()
; 如果常量池中存在当前字符串, 就会直接返回当前字符串. 如果常量池中没有此字符串, 会将此字符串放入常量池中后, 再返回 -
会出现OOM
直接内存
- 直接内存的分配不受堆分配参数的影响 ?? (不记得在哪本书上看到过说直接内存默认使用的是堆内存的大小? 有待探究)
- 也会出现OOM的情况
限制
直接内存(Direct Memory)指的是使用 sun.misc.Unsafe
或 java.nio.ByteBuffer.allocateDirect()
分配的内存,这部分内存不在 Java 堆中管理,而是由操作系统直接管理。因此,直接内存的大小受到几个因素的限制:
- 操作系统限制:操作系统本身对可用内存的限制。32 位系统通常只能支持最多 4GB 的地址空间(实际可用内存可能更少),而 64 位系统则可以支持更大的地址空间。
- 物理内存和虚拟内存限制:可用的物理内存和交换空间(虚拟内存)的限制。如果系统内存不足以满足分配请求,可能会导致
OutOfMemoryError
。 - JVM 选项:JVM 有一个专门的选项来限制直接内存的最大使用量,即
-XX:MaxDirectMemorySize
。如果没有指定此选项,默认值是与最大堆大小(-Xmx
)相同。在某些 JVM 实现中,默认值可能会有所不同。 - 操作系统配置和权限:操作系统配置(如 ulimit 在 Unix 系统上)和用户权限也可能影响直接内存的分配。
常见问题
- OutOfMemoryError: Direct buffer memory:当直接内存用尽时,会抛出这个错误。可以通过增加
-XX:MaxDirectMemorySize
或优化内存使用来解决。 - 性能考虑:尽管直接内存访问速度快,但分配和释放成本较高,使用不当可能导致性能问题。
创建对象时的内存分配
类加载检查
当虚拟机遇到一条new指令时(对应到语言层面上讲是new关键词、对象克隆、对象序列化等),首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号的引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行类加载过程。
分配内存
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定。
这个步骤会有两个问题:
- 如何划分内存
- 在并发的情况下,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况
划分内存的方法
-
指针碰撞(Bump the Pointer)
默认使用的方法,如果Java堆中的内存是绝对规整的,所有用过的内存都放在一边,空闲的放在另一边
-
空闲列表(Free List)
如果Java堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。
并发问题解决方法
-
CAS
对分配内存空间的动作进行同步处理——实际上虚拟机是采用CAS配上失败重试的方式保证更新操作的原子性
-
本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)
把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。虚拟机是否使用TLAB,可以通过
-XX:+/-UseTLAB
参数来设定
初始化零值
分配内存之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值
设置对象头
初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。
在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、 实例数据(Instance Data) 和 对齐填充(Padding)。
对应openjdk源码中的markOop.hpp
对象头:
-
Mark Word
第一部分用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时 间戳等
在64为机器上面是8个字节
-
Klass Pointer
另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。
在64为机器上面是8个字节, 开启压缩指整后, 占用4个字节
32位对象头:
64位对象头:
注意: 在上面的图中可以看到分代年龄占用了4bit, 所以年龄最大为15
|
|
实例数据
对象真正存储的有效信息,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来,存储顺序会受到虚拟机分配策略参数-XX:FieldsAllocationStyle
的影响。默认分配顺序:longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs)
|
|
查看内存布局
|
|
指针压缩:
- jdk1.6 update14开始,在64为操作系统中,JVM支持指针压缩
- JVM的配置参数UseCompressedOops,compressed:压缩、oop(ordinary object pointer):对象指针
- 启用指针压缩:
-XX:+UseCompressedOops
(默认开启),禁止指针压缩:-XX:-UseCompressedOops
为什么可以指针压缩
指针压缩(Compressed Oops)的原理基于对象在内存中的对齐特性。以下是指针压缩的详细解释和工作原理:
原理概述
在 64 位 JVM 中,内存地址通常用 64 位指针表示,每个指针占用 8 个字节。如果对象地址按一定的对齐方式排列,例如按 8 字节对齐,那么可以利用这一特性来压缩指针。通过这种方法,可以使用较小的指针(例如 32 位)来表示对象地址,从而减少指针的内存占用。
指针压缩的工作原理
要理解为什么可以通过忽略对象地址的最低 3 位将 64 位地址压缩为 32 位地址,我们需要了解一些计算机内存对齐和地址空间的基本概念。
JVM中指整压缩的代码如下:
|
|
对齐和地址空间
对象地址对齐
在JVM中,对象地址通常是按8字节对齐的。这意味着对象的内存地址总是8的倍数。由于8的二进制表示是1000
,这意味着对象地址的最低3位总是0。例如:
|
|
地址空间
64位系统可以使用的地址空间非常大,理论上可以达到 $2^{64}$ 字节(16 EB)。但是,在实际应用中,JVM通常不会分配超过32GB的堆空间(即 $2^{35}$ 字节)。
由于对象地址是8字节对齐的,我们可以忽略对象地址的最低3位,而不丢失任何信息。这意味着我们可以将一个64位地址的最低3位去掉,只用剩下的高61位来表示地址。
同时,由于JVM限制堆空间最多为32GB(指针压缩的限制, 指针要锁通过base+offset能表示的最大空间就是32GB),我们只需要能够表示 $2^{35}$ 字节的地址。因此,我们可以用一个32位整数来表示这个压缩后的地址。具体过程如下:
-
去掉最低3位:
将对象地址右移3位,以去掉最低的3位对齐位。例如:
1 2 3
0x00000000_00000018 (64位) // 原始地址 >> 3 0x00000000_00000003 (64位) // 去掉最低3位后的地址
-
压缩到32位:
由于我们只需要表示 $2^{35}$ 字节的地址,右移3位后,最大地址是 $2^{35-3} = 2^{32}$。这意味着压缩后的地址可以用32位整数表示。
解压缩过程
当需要使用这个压缩后的地址时,我们将其左移3位(即乘以8)并加上基地址,恢复原始地址:
|
|
示例
假设堆基地址为 0x00000000_10000000
,对象地址为 0x00000000_10000018
:
-
原始地址(64位):
1
0x00000000_10000018
-
去掉最低3位:
1
0x00000000_10000018 >> 3 = 0x00000000_02000003
-
压缩到32位:
1
compressed_offset = 0x02000003
-
解压缩:
1 2 3 4
addr = base + (compressed_offset << 3) = 0x00000000_10000000 + (0x02000003 << 3) = 0x00000000_10000000 + 0x10000018 = 0x00000000_10000018
通过这种方式,JVM可以将64位的对象指针压缩到32位,从而节省内存空间,同时也减少了内存带宽的占用。
使用基地址
为了在堆中找到实际的内存地址,JVM 维护了一个基地址 base
。压缩指针表示相对于这个基地址的偏移量。
以下是JVM中解压缩指针的代码
|
|
详细解释
-
基地址 (
base
) 和位移量 (shift
)base
是指针压缩的基地址,通常是堆的起始地址。shift
是位移量,通常是 3,因为对象地址按 8 字节对齐。
-
解压缩过程
- 将基地址转换为无符号整数类型
uintptr_t
。 - 将压缩指针
v
左移shift
位,以恢复原来的地址。 - 将上述两者相加,得到实际内存地址。
- 将基地址转换为无符号整数类型
-
地址对齐检查
- 使用
check_obj_alignment
检查解压后的地址是否对齐。如果地址不对齐,程序将终止并输出错误信息。
- 使用
为什么指针压缩有效
-
减少内存占用:使用 32 位指针代替 64 位指针,可以节省大量内存。例如,如果系统中有大量对象引用,指针压缩可以显著减少总内存占用。
-
提高缓存效率:减少指针大小意味着在相同的缓存空间内可以存储更多的指针,从而提高缓存命中率,减少内存访问延迟,提高性能。
-
增加可用堆内存:通过减少指针占用的内存空间,可以在相同的堆内存限制下容纳更多的对象,从而提高内存利用率。
启用指针压缩
在大多数情况下,JVM 在堆内存小于等于 32GB 时默认启用指针压缩。如果需要手动启用或禁用,可以使用以下 JVM 参数:
-
启用指针压缩:
1
java -XX:+UseCompressedOops -Xmx16g -jar myapp.jar
-
禁用指针压缩:
1
java -XX:-UseCompressedOops -Xmx16g -jar myapp.jar
栈上分配
我们通过JVM内存分配可以知道JAVA中的对象都是在堆上进行分配,当对象没有被引用的时候,需要依靠GC进行回收内存,如果对象数量较多的时候,会给GC带来较大压力,也间接影响了应用的性能。为了减少临时对象在堆内分配的数量,JVM通过逃逸分析确定该对象不会被外部访问。如果不会逃逸可以将该对象在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。
对象逃逸分析:就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中。
|
|
VM对于这种情况可以通过开启逃逸分析参数-XX:+DoEscapeAnalysis
来优化对象内存分配位置,使其通过标量替换优先分配在栈上(栈上分配),
JDK7之后默认开启逃逸分析,如果要关闭使用参数-XX:-DoEscapeAnalysis
标量替换
通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配。开启标量替换参数-XX:+EliminateAllocations,JDK7之后默认开启
标量与聚合量:标量即不可被进一步分解的量,而Java的基本数据类型就是标量(如:int
,long
等基本数据类型以及reference
类型等),标量的对立就是可以被进一步分解的量,而这种量称之为聚合量。而在Java中对象就是可以被进一步分解的聚合量。
|
|
栈上分配依赖于逃逸分析和标量替换
堆上分配
Eden区分配
大多数情况下,对象在新生代中 Eden 区分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
Minor GC和Full GC :
- Minor GC/Young GC:指发生新生代的的垃圾收集动作,Minor GC非常频繁,回收速度一般也比较快。
- Major GC/Full GC:一般会回收老年代 ,年轻代,方法区的垃圾,Major GC的速度一般会比Minor GC的慢10倍以上。
Eden与Survivor区默认:8:1:1
大量的对象被分配在eden区,eden区满了后会触发minor gc,可能会有99%以上的对象成为垃圾被回收掉,剩余存活的对象会被挪到为空的那块survivor区,下一次eden区满了后又会触发minor gc,把eden区和survivor区垃圾对象回收,把剩余存活的对象一次性挪动到另外一块为空的survivor区,因为新生代的对象都是朝生夕死的,存活时间很短,所以JVM默认的8:1:1的比例是很合适的,让eden区尽量的大,survivor区够用即可,
JVM默认有这个参数-XX:+UseAdaptiveSizePolicy
(默认开启),会导致这个8:1:1比例自动变化,如果不想这个比例有变化可以设置参数-XX:-UseAdaptiveSizePolicy
|
|
我们可以看出eden区内存几乎已经被分配完全(即使程序什么也不做,新生代也会使用至少几M内存)。
假如我们再为allocation2分配内存会出现什么情况呢?
因为给allocation2分配内存的时候eden区内存几乎已经被分配完了,我们刚刚讲了当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC,GC期间虚拟机又发现allocation1无法存入Survior空间,所以只好把新生代的对象提前转移到老年代中去,老年代上的空间足够存放allocation1,所以不会出现Full GC。执行Minor GC后,后面分配的对象如果能够存在eden区的话,还是会在eden区分配内存。
大对象直接进入老年代
大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。JVM参数 -XX:PretenureSizeThreshold
可以设置大对象的大小,如果对象超过设置大小会直接进入老年代,不会进入年轻代,这个参数只在 Serial 和ParNew两个收集器下有效。
比如设置JVM参数:-XX:PretenureSizeThreshold=1000000
(单位是字节) -XX:+UseSerialGC
,再执行下上面的第一个程序会发现大对象直接进了老年代。
这样可以避免为大对象分配内存时的复制操作而降低效率。
长期存活的对象将进入老年代
如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为1。
对象在 Survivor 中每熬过一次 MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同),就会被晋升到老年代中。
对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold
来设置。
对象动态年龄判断进入老年代
当前放对象的Survivor区域里(其中一块区域,放对象的那块s区),一批对象的总大小大于这块Survivor区域内存大小的50%(-XX:TargetSurvivorRatio
可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了,例如Survivor区域里现在有一批对象,年龄1+年龄2+年龄n的多个年龄对象总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。
对象动态年龄判断机制一般是在Minor GC之后触发的。
老年代空间分配担保机制
年轻代每次Minor GC之前JVM都会计算下老年代剩余可用空间
如果这个可用空间小于年轻代里现有的所有对象大小之和。(包括垃圾对象)
就会看一个-XX:-HandlePromotionFailure
(jdk1.8默认就设置了)的参数是否设置了,如果有这个参数,就会看看老年代的可用内存大小,是否大于之前每一次Minor GC后进入老年代的对象的平均大小。如果上一步结果是小于或者之前说的参数没有设置,那么就会触发一次Full GC,对老年代和年轻代一起回收一次垃圾,如果回收完还是没有足够空间存放新的对象就会发生OOM
当然,如果Minor GC之后剩余存活的需要挪动到老年代的对象大小还是大于老年代可用空间,那么也会触发Full GC,Full GC完之后如果还是没有空间放Minor GC之后的存活对象,则也会发生OOM