JVM 篇:对象的深度剖析,mybatis 入门程序
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) e5 01 00 20 (11100101 00000001 00000000 00100000) (536871397)
复制代码
前 8 个字节是 markword,它的值是:00000001 00000000 00000000 00000000 00000000 00000000 00000000 00000000
。其中 01 是锁标志位,前面的 0 表示是否是偏向锁,我们这个对象是没有加锁的,所以这个地方是 0。后 4 个字节是类型指针,理论上在 64bit 操作系统中它应该是 8 个字节才对,但是因为 jvm 默认开启的指针压缩,所以它的大小和 32bit 大小一样。可以通过:-XX:-UseCompressedOops
来关闭,关闭之后我们看一下它的值:
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 00 1c 39 7e (00000000 00011100 00111001 01111110) (2117671936)
12 4 (object header) b7 01 00 00 (10110111 00000001 00000000 00000000) (439)
Instance size: 16 bytes
复制代码
关闭指针压缩之后,类型指针的大小就变成了 16byte。
指针压缩
指针压缩是 jdk1.6 之后针对 64 位机器采取的一种内存优化措施,当堆内存小于 4G 时,不需要启用指针压缩,jvm 会直接去除高 32 位地址,即使用低虚拟地址空间,当堆内存大于 32G 时,压缩指针会失效,会强制使用 64 位(即 8 字节)来对 java 对象寻址,这就会出现 1 的问题,所以堆内存建议不要大于 32G。
压缩范围:
对象的全局静态变量(即类属性)
对象头信息:64 位平台下,原生对象头大小为 16 字节,压缩后为 12 字节
对象的引用类型:64 位平台下,引用类型本身大小为 8 字节,压缩后为 4 字节
对象数组类型:64 位平台下,数组类型本身大小为 24 字节,压缩后 16 字节
为什么要进行指针压缩:
将对象的指针进行压缩,对象存储在堆中占用的内存就会很少,GC 发生的频次就低,相同时间下可以存储更多的对象。
在 jvm 中,32 位地址最大支持 4G 内存(2 的 32 次方),可以通过对对象指针的存入堆时压缩编码、取出到 cpu 寄存器后解码方式进行优化(对象指针在堆中是 32 位,在寄存器中是 35 位,2 的 35 次方=32G),使得 jvm 只用 32 位地址就可以支持更大的内存配置(小于等于 32G)。
申请内存的过程
潜意识里,我们都认为只要 new 对象,都会放在堆内存里。如果我换种方式问你:new 出来的对象一定是在堆里面吗?不一定吧?
对象栈上分配
如果所有对象都在堆中进行分配,当对象没有被引用的时候,GC 对于对象的回收会产生大量的 STW,性能下降,hotspot 这么强大的研发团队怎么会意识不到这个问题呢,所以在 jdk1.7 版本及之后的版本中对对象的分配做了优化,尽可能的让对象分配在栈内存中,这样就会减少 GC 的回收压力;但是对象要分配在栈中要同时满足逃逸分析和标量替换。默认是开启的,可以通过以下参数关闭,关闭逃逸分析:-XX:-DoEscapeAnalysis
;关闭标量替换:-XX:-EliminateAllocations
。
逃逸分析: 分析对象动态作用域,当一个对象在方法中被定义后,如果会被外部方法引用,比如
Person p = createPerson();
这个 p 对象是 createPerson 方法内部创建的,被外部引用的,这种情况属于对象逃逸出方法外;否则对象就没有逃逸;针对没有逃逸的对象就会进行优化。标量替换: 通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM 不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配。
通过下面的例子演示一下对象是怎么在栈上分配的,先关闭标量替换,看一下优化之前的 GC 情况:
// -Xmx15m -Xms15m -XX:-DoEscapeAnalysis -XX:+PrintGC
public static void main(String[] args) {
for (int i = 0; i < 100000000; i++) {
allocate();
}
}
public static void allocate() {
Person person = new Person();
person.setId(1);
person.setName("zhangsan");
}
复制代码
控制台会打印很多次 GC 日志:
我们把-XX:-DoEscapeAnalysis
这个参数去掉,再看一下结果:
GC 只执行了一次,这很正常,在 JVM 启动的时候内部也会创建一些对象,很明显和上面的结果不同,说明我们的对象没有逃逸,直接在栈上分配了。
对象 Eden 区分配
eden 区是对象分配在堆内存的情况下大多数优先分配的空间。如果没有剩余空间则会进行一次 MinorGC,将剩余对象复制到另外一块 survivor 区,默认情况下 eden 区和 survivor 区的空间比例是 8:1:1,这是通过-XX:+UseAdaptiveSizePolicy
这个参数设置的,默认是开启的。我们可以通过下面的例子看一下对象的分配情况:
// -XX:+PrintGCDetails
public static void main(String[] args) {
byte[] allocation1, allocation2;
allocation1 = new byte[1024 * 60000];
}
复制代码
输出如下结果:
Heap
PSYoungGen total 76288K, used 65536K [0x000000076b200000, 0x0000000770700000, 0x00000007c0000000)
eden space 65536K, 100% used [0x000000076b200000,0x000000076f200000,0x000000076f200000)
from space 10752K, 0% used [0x000000076fc80000,0x000000076fc80000,0x0000000770700000)
to space 10752K, 0% used [0x000000076f200000,0x000000076f200000,0x000000076fc80000)
ParOldGen total 175104K, used 0K [0x00000006c1600000, 0x00000006cc100000, 0x000000076b200000)
object space 175104K, 0% used [0x00000006c1600000,0x00000006c1600000,0x00000006cc100000)
Metaspace used 3301K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 359K, capacity 388K, committed 512K, reserved 1048576K
复制代码
仔细分析一下:eden 区被使用空间已经 100%,from 和 to 就是两个 survivor 区,也可以叫做 s0 和 s1,他俩的使用率都是 0,再看老年代的使用也是 0;改一下上面的代码,看看会出现什么现象:
// -XX:+PrintGCDetails
public static void main(String[] args) {
byte[] allocation1, allocation2;
allocation1 = new byte[1024 * 60000];
allocation2 = new byte[1024 * 30000];
}
复制代码
输出结果:
[GC (Allocation Failure) [PSYoungGen: 65245K->776K(76288K)] 65245K->60784K(251392K), 0.0247767 secs] [Times: user=0.00 sys=0.02, real=0.03 secs]
Heap
PSYoungGen total 76288K, used 31431K [0x000000076b200000, 0x0000000774700000, 0x00000007c0000000)
eden space 65536K, 46% used [0x000000076b200000,0x000000076cfefef8,0x000000076f200000)
from space 10752K, 7% used [0x000000076f200000,0x000000076f2c2020,0x000000076fc80000)
to space 10752K, 0% used [0x0000000773c80000,0x0000000773c80000,0x0000000774700000)
ParOldGen total 175104K, used 60008K [0x00000006c1600000, 0x00000006cc100000, 0x000000076b200000)
object space 175104K, 34% used [0x00000006c1600000,0x00000006c509a010,0x00000006cc100000)
Metaspace used 3302K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 359K, capacity 388K, committed 512K, reserved 1048576K
复制代码
eden 区 46%,from 区 7%,to 区 0%,老年代 34%,为什么会这样子呢?
看上面的信息发现 eden 区是 65M 左右,from 和 to 各 10M 左右;当执行allocation1 = new byte[1024 * 60000];
的时候对象优先在 eden 区分配 60M 空间,此时 eden 区域已经满了(eden 区可能也会存在一些 jdk 内部的一些对象,所以 eden 区会放满),紧接着又执行allocation2 = new byte[1024 * 30000];
这个 allocation2 对象大小是 30M,也要往 eden 区放,因为 eden 已经满了,所以执行了一次MinorGC
,准备将 eden 区原有的对象放到了 survivor 区,但是此时 survivor 区是放不下 60M 的对象的,所以被移动到了老年代,因为老年代的空间比较大所以存放对象之后,used 就变成了 34%。再将 allocation2 的大概 30M 对象放入 eden 区。from 区的 7%是 jdk 内部的一些其他对象。
大对象直接进老年代
JVM 对于大对象的定义是申请一块连续内存且内存大小大于-XX:PretenureSizeThreshold
参数的值,如果大于这个大小的对象需要回收的话,会进行大量的内存复制,导致年轻的 STW 也会很长,所以针对这种情况,hotspot 的实现中直接将这样的对象放入老年代,给年轻代更大的空间。注意:这种机制只支持Serial
和ParNew
回收器。
下面一段代码演示一下对象直接分配到老年代的效果:
public static void main(String[] args) {
byte[] bytes = new byte[1024 * 1000 * 1024 * 600000];
}
复制代码
输出结果:
Heap
PSYoungGen total 76288K, used 6556K [0x000000076b200000, 0x0000000770700000, 0x00000007c0000000)
eden space 65536K, 10% used [0x000000076b200000,0x000000076b867130,0x000000076f200000)
from space 10752K, 0% used [0x000000076fc80000,0x000000076fc80000,0x0000000770700000)
to space 10752K, 0% used [0x000000076f200000,0x000000076f200000,0x000000076fc80000)
ParOldGen total 1748480K, used 1572864K [0x00000006c1600000, 0x000000072c180000, 0x000000076b200000)
object space 1748480K, 89% used [0x00000006c1600000,0x0000000721600010,0x000000072c180000)
Metaspace used 3302K, capacity 4496K, committed 4864K, reserved 1056768K
class space used 359K, capacity 388K, committed 512K, reserved 1048576K
复制代码
可以看到老年代直接占用 89%,占用的空间大概是我们执行的这段代码。如果老年代也放不下的话会先执行一次 FullGC,对老年的垃圾做一次回收,如果还没有回收出来可用的空间的话就会出现我们经常说的Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
。
看完以上的知识点我们可以梳理出来一个对象分配的流程图,如下所示:
老年代空间分配担保机制
上面已经说过了,当对象往 eden 区分配内存的时候,如果 eden 区已经满了,会执行一次 MinorGC,其实在执行 MinorGC 之前还有一步很重要的判断:年轻代里所有的对象大小之和是否小于老年代的可用空间大小,为什么要做这个判断呢,在极端情况下,很有可能年轻代里面所有的对象都不是垃圾,会导致所有对象都进入老年代,如果老年代放不下年轻代的全部对象,会接着判断老老年代的可用空间是否大于以前年轻代对象移入老年代的平均大小,这一步是根据-XX:-HandlePromotionFailure
参数来的,默认开启,如果放不下则会触发一次 Full GC,对年轻代,老年代,方法区都进行一次垃圾回收,回收之后如果还放不下那就 OOM。
为什么要有间分配担保机制:
还是 jvm 内部的优化机制,尽量减少 Full GC 的频率, 尽量让对象放入老年代的时候不触发 GC,通过各种判断各种策略如果对象还是无法放入老年代的话,那没办法了,只能 GC 了。
老年代空间分配担保机制的过程:
内存回收
判定垃圾的方式
我们都知道的一个概念就是如果一个对象变成垃圾的时候就会被进
行回收,那么对于垃圾是怎么定义的,什么样的对象才算是垃圾呢,通过引用计数法
和根可达算法
进行判定。
引用计数法:一旦有对象被其他对象引用,那么就给这个对象加的引用值+1,当引用被释放的时候就给引用值-1;当引用值等于 0 的时候,说明该对象就是垃圾了。但是这样会存在一个问题:
循环引用
:比如 A 引用了 B,B 又引用了 A,但是他俩没有别的对象去引用,他俩都是垃圾,这种问题可以通过 Recycler 算法解决,但是性能不高,没必要。根可达算法(Hotspot 默认):以线程栈的本地变量、静态变量、本地方法栈的变量作为 GC Root, 从这个起点开始向下搜索引用的对象并进行标记,没有标记的对象就是垃圾了。
对象的引用类型
虽然说 GC 进行垃圾回收的时候是判断对象有没有被标记,一般情况下是这样的,但是特殊情况下也会出现对象的引用还在但是依然会被回收的情况,这里就涉及到了对象的 4 种引用:强引用
、软引用
、弱引用
、虚引用
。
强引用: 我们通常 new 的对象都属于强引用,这种情况下只有对象没有被 GC ROOT 引用的时候才会被回收。比如:
Object o = new Object();
这个 object 就是强引用。软引用: 被
SoftReference
包裹的对象就属于软引用,他的特征是当执行完 GC 之后如果没有可用内存的话,软引用的对象就会被回收,不管你有没有 GC ROOT。比如:SoftReference<Object> object = new SoftReference<Object>(new Object());
,这种对象一般可以用来做 jvm 级别的缓存。弱引用: 被
WeakReference
包裹,但是 GC 每次都会对他进行回收,所以这种对象在我们的业务中基本也找不到使用场景,但是在ThreadLocal
类中,key 就是弱引用,所以在 GC 的时候每次都会把这个 key 回收掉,造成 value 的内存泄漏:
虚引用: 虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系,几乎不用。
对象的自救
即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历再次标记过程。对象最终要被回收要经过两个过程:
当一个对象第一次被标记位垃圾的时候,会判断对象有没有重写 finaliza()方法,如果没有重写则直接会被回收,如果重写则进入第 2 步;
如果重写了 finalize()方法,会在回收前再执行一次 finalize()方法,如果对象要在 finalize()中成功拯救自己,只要重新与引用链上的任何的一个对象建立关联即可,譬如把自己赋值给某个类变量或对象的成员变量,那在第二次标记时它将移除出“即将回收”的集合。如果对象这时候还没逃脱,那基本上它就真的被回收了。但是一个对象的 finalize()方法只会被执行一次,也就是说通过调用 finalize 方法自我救命的机会就一次。
评论