JVM篇:对象的深度剖析(1)

《一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码》点击传送门,即可获取!
// 32 bits:

// --------

// hash:25 ------------>| age:4 biased_lock:1 lock:2 (normal object)

// JavaThread*:23 epoch:2 age:4 biased_lock:1 lock:2 (biased object)

// size:32 ------------------------------------------>| (CMS free block)

// PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)

//

// 64 bits:

// --------

// unused:25 hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object)

// JavaThread*:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object)

// PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)

// size:64 ----------------------------------------------------->| (CMS free block)

//

// unused:25 hash:31 -->| cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && normal object)

// JavaThread*:54 epoch:2 cms_free:1 age:4 biased_lock:1 lock:2 (COOPs && biased object)

// narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)

// unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)

复制代码

上面分别是32位和64位的对象头信息,64位系统的MarkWord在对象头中占64位,我们详细来分析一下对象头里面有哪些东西

image.png

从上面的图中可以发现在markword中对象处于不同的状态下,它内部的结构也是不一样的,本篇文章以无锁状态进行分析:无锁偏向锁状态下用4bit来存储对象的分代年龄,默认情况下是0000, 最大值只能是1111,也就是15,之前的章节我们说过对象在躲过15次GC依然存活的话,就会被移到老年代,好像和这里的15刚好吻合,这里我们可以留一个大胆的猜想:GC回收的年龄就是通过对象头里面的MarkWord进行标识的

我们可以通过代码看一下对象的组成结构:

org.openjdk.jol

jol-core

0.9

复制代码

public static void main(String[] args) {

ClassLayout layout = ClassLayout.parseInstance(new Object());

System.out.println(layout.toPrintable());

}

复制代码

运行结果: image.png 把结果复制出来,和上面的MarkWord图对比着看一下:

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) 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。

  • 压缩范围:
  1. 对象的全局静态变量(即类属性)

  2. 对象头信息:64位平台下,原生对象头大小为16字节,压缩后为12字节

  3. 对象的引用类型:64位平台下,引用类型本身大小为8字节,压缩后为4字节

  4. 对象数组类型:64位平台下,数组类型本身大小为24字节,压缩后16字节

  • 为什么要进行指针压缩:
  1. 将对象的指针进行压缩,对象存储在堆中占用的内存就会很少,GC发生的频次就低,相同时间下可以存储更多的对象。

  2. 在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日志:

image.png 我们把-XX:-DoEscapeAnalysis这个参数去掉,再看一下结果:

image.png 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的实现中直接将这样的对象放入老年代,给年轻代更大的空间。注意:这种机制只支持SerialParNew回收器。

下面一段代码演示一下对象直接分配到老年代的效果:

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

看完以上的知识点我们可以梳理出来一个对象分配的流程图,如下所示:

image.png

老年代空间分配担保机制

上面已经说过了,当对象往eden区分配内存的时候,如果eden区已经满了,会执行一次MinorGC,其实在执行MinorGC之前还有一步很重要的判断:年轻代里所有的对象大小之和是否小于老年代的可用空间大小,为什么要做这个判断呢,在极端情况下,很有可能年轻代里面所有的对象都不是垃圾,会导致所有对象都进入老年代,如果老年代放不下年轻代的全部对象,会接着判断老老年代的可用空间是否大于以前年轻代对象移入老年代的平均大小,这一步是根据-XX:-HandlePromotionFailure参数来的,默认开启,如果放不下则会触发一次Full GC,对年轻代,老年代,方法区都进行一次垃圾回收,回收之后如果还放不下那就OOM。

  • 为什么要有间分配担保机制:

还是jvm内部的优化机制,尽量减少Full GC的频率, 尽量让对象放入老年代的时候不触发GC,通过各种判断各种策略如果对象还是无法放入老年代的话,那没办法了,只能GC了。

老年代空间分配担保机制的过程:

image.png

内存回收


判定垃圾的方式

我们都知道的一个概念就是如果一个对象变成垃圾的时候就会被进行回收,那么对于垃圾是怎么定义的,什么样的对象才算是垃圾呢,通过引用计数法根可达算法进行判定。

  • 引用计数法:一旦有对象被其他对象引用,那么就给这个对象加的引用值+1,当引用被释放的时候就给引用值-1;当引用值等于0的时候,说明该对象就是垃圾了。但是这样会存在一个问题:循环引用:比如A引用了B,B又引用了A,但是他俩没有别的对象去引用,他俩都是垃圾,这种问题可以通过Recycler算法解决,但是性能不高,没必要。

  • 根可达算法(Hotspot默认):以线程栈的本地变量、静态变量、本地方法栈的变量作为GC Root, 从这个起点开始向下搜索引用的对象并进行标记,没有标记的对象就是垃圾了。

最后

笔者已经把面试题和答案整理成了面试专题文档

image

image

image

image

image

image

《一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码》点击传送门,即可获取!
:以线程栈的本地变量、静态变量、本地方法栈的变量作为GC Root, 从这个起点开始向下搜索引用的对象并进行标记,没有标记的对象就是垃圾了。

最后

笔者已经把面试题和答案整理成了面试专题文档

[外链图片转存中…(img-a1D6HQTR-1714699244796)]

[外链图片转存中…(img-7tmNkKEr-1714699244797)]

[外链图片转存中…(img-ybJargKD-1714699244797)]

[外链图片转存中…(img-R63rbuX4-1714699244798)]

[外链图片转存中…(img-8hlvdqFF-1714699244798)]

[外链图片转存中…(img-tN08tqZn-1714699244798)]

《一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码》点击传送门,即可获取!

  • 7
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值