JVM篇:对象的深度剖析,Javaweb资料视频

  • 循环CAS: 使用过多线程的同学都知道这种方式,就是把内存中的预期值拿出来进行更新,在更新之前再判断一下当内存中现有的值和预期值是否一致,不一致则重新获取预期值,一致的话直接进行更新。但是这种方式会出现因自旋太久带来的cpu开销问题,所以默认使用的是TLAB方式解决。

  • 本地线程分配缓冲(Thread Local Allocation Buffer): 为每个线程都预先分配一块空间去划分内存,每个线程来了都从自己的空间里去分配内存。如果预留的内存不够划分则会回退到CAS的方式;可以通过-XX:TLABSize=xx来设置预留的大小,避免回退到CAS。

补充:这里我们要注意一个细节:对象的半初始化问题。

对象的组成结构


我们java的对象不仅仅只有成员变量,这个层面的理解太浅了,实际上java的对象包含了3个部分:对象头实例数据对齐填充

JVM篇:对象的深度剖析,Javaweb资料视频

  • 对象头:对象头是Java对象中非常重要的一部分,他存储了对象的各种底层信息:比如MarkWord、KlassPointer、数组长度

  • 实例数据:实例数据就是我们对象拥有的属性。

  • 对其填充:因为是64bit操作系统,内存的宽度是64bit也就是8字节,8的整数倍寻址会更高效,对对象大小进行补位的,有中间对齐和尾部对齐。

对象头

下面我们深入对象头看看,到hotspot源码中找到markOop.hpp文件,看下注释怎么描述对象头的:

// Bit-format of an object header (most significant first, big endian layout below):

//

// 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位,我们详细来分析一下对象头里面有哪些东西

JVM篇:对象的深度剖析,Javaweb资料视频

从上面的图中可以发现在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());

}

复制代码

运行结果: JVM篇:对象的深度剖析,Javaweb资料视频 把结果复制出来,和上面的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日志:

JVM篇:对象的深度剖析,Javaweb资料视频 我们把-XX:-DoEscapeAnalysis这个参数去掉,再看一下结果:

JVM篇:对象的深度剖析,Javaweb资料视频 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, 0x00000006cc1000

《一线大厂Java面试题解析+后端开发学习笔记+最新架构讲解视频+实战项目源码讲义》

【docs.qq.com/doc/DSmxTbFJ1cmN1R2dB】 完整内容开源分享

00, 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

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

JVM篇:对象的深度剖析,Javaweb资料视频

老年代空间分配担保机制

上一篇:运行时数据区-堆


下一篇:好久不见(致win7)