JVM篇之对象
对象内存结构
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-j391Hmsa-1667058128020)(D:\personal\日常心得\jvm\系列\img\对象结构.png)]
Mark Word
这部分主要和锁有关,8字节也就是有64位,其中有两位表示锁状态(无锁、偏向锁、轻量级锁、重量级锁),这里不做过多解释,可以想见关于另一篇关于Synchronized的底层实现。(https://blog.youkuaiyun.com/qq_41578037/article/details/123616389?spm=1001.2014.3001.5501),这部分中还可以用来配合GC分代年龄、对象的hashcode值等。
class Pointer(对象指针)
前置知识—对象在内存中怎么进行存储的(大端存储)
我们知道这个指针是指向对象的内存地址,那内存地址其实就是一段位置,既然是位置,就需要区分前后,在JAVA中采用的是大端存储,也就是高地址高字节,小端存储则是高地址低字节。
前置知识—对象访问的两种形式
reference类型在Java虚拟机规范里只规定了一个指向对象的引用,并没有定义这个引用应该通过哪种方式去定位,以及访问到Java堆中的对象的具体位置,因此不同虚拟机实现的对象访问方式会有所不同,主流的访问方式有两种:使用句柄和直接指针。
句柄池访问
直接指针访问
两种访问区别
句柄池:
使用句柄访问对象,会在堆中开辟一块内存作为句柄池,句柄中储存了对象实例数据(属性值结构体) 的内存地址,访问类型数据的内存地址(类信息,方法类型信息),对象实例数据一般也在heap中开 辟,类型数据一般储存在方法区中。
优点 :reference存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为) 时只会改变句柄中的实例数据指针,而reference本身不需要改变。
缺点 :增加了一次指针定位的时间开销。
直接访问:
直接指针访问方式指reference中直接储存对象在heap中的内存地址,但对应的类型数据访问地址需要 在实例中存储。
优点 :节省了一次指针定位的开销。
缺点 :在对象被移动时(如进行GC后的内存重新排列),reference本身需要被修改
指针压缩
其指向的位置就是class对象的实际内存地址,JVM通过这个指针(直接指针)确定对象究竟是哪个类的示例。就统计而言64位的JVM占用8个字节,为了节约内存可以选择开启指针压缩-默认开启(-XX:+UseCompressedOops),开启此功能后,下列指针将压缩至32位:
- 每个Class的属性指针(即静态变量)
- 每个对象的属性指针(即对象变量)
- 普通对象数组的每个元素指针
当然,也不是所有的指针都会压缩,一些特殊类型的指针JVM不会优化,比如指向PermGen的Class对象指针(JDK8中指向元空间的Class对象指针)、本地变量、堆栈元素、入参、返回值和NULL指针等。
同时如果说JVM内存超过32G,指针压缩功能也将失效。
为什么超过32G指针压缩就会失效?
我们可以理解压缩指针后,其实就是相当于我们用32位的系统在64位系统中使用,去寻址,查找对象。但是32位系统的CPU寻址最大支持4G,而且JVM中引用的保存是稀松保存,每个8个字节保存一个引用。(例如,原来保存每个引用0、1、2…,现在只保存0、8、16…) ,因此指针压缩后,并不是所有的引用都保存在堆中,而是以8个字节为间隔进行保存。在实现上,堆中的引用其实还是按照0x0、0x1、0x2…进行存储。只不过当引用被存入64位的寄存器时,JVM将其左移3位(相当于末尾添加3个0),例如0x0、0x1、0x2…分别被转换为0x0、0x8、0x10。而当从寄存器读出时,JVM又可以右移3位,丢弃末尾的0。(oop在堆中是32位,在寄存器中是35位,2的35次方=32G。也就是说,使用32位,来达到35位oop所能引用的堆内存空间)
对齐填充
对齐填充保证了java中的对象大小一定是8的N次方。
对齐填充的意义是 提高CPU访问数据的效率 ,主要针对会存在该实例对象数据跨内存地址区域存储的情况。
对象大小怎么计算
前面已经介绍过JVM中对象的内存结构以及每部分的大小,所以整个对象的大小其实就是类中每个部分大小加起来,然后对齐填充。
在java中怎么获取对象的大小?
引入jar包
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
编写测试类
public class Demo {
public static void main(String[] args) {
System.out.println(ClassLayout.parseInstance(new DeSize()).toPrintable());
}
}
@Data
class DeSize{
private Integer age;
private Long num;
private String name;
}
开启指针压缩
注意:在类中虽然是Integer,但是是属于类变量。也是占用8个字节。
DeSize 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) 43 c1 00 20 (01000011 11000001 00000000 00100000)(536920387)
12 1 boolean DeSize.falg false
13 3 (alignment/padding gap)
16 4 java.lang.Integer DeSize.age null
20 4 java.lang.String DeSize.name null
24 4 java.lang.Long DeSize.num null
28 4 (loss due to the next object alignment)
Instance size: 32 bytes
Space losses: 3 bytes internal + 4 bytes external = 7 bytes total
可以看到age,name,num都是占用4个字节。
关闭指针压缩
DeSize 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) a0 43 8a 17 (10100000 01000011 10001010 00010111) (394937248)
12 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
16 1 boolean DeSize.falg false
17 7 (alignment/padding gap)
24 8 java.lang.Integer DeSize.age null
32 8 java.lang.String DeSize.name null
40 8 java.lang.Long DeSize.num null
Instance size: 48 bytes
Space losses: 7 bytes internal + 0 bytes external = 7 bytes total
可以看到:
关闭指针压缩有,object header多了一个4字节,并且age,name,num都是占用8个字节。
对象逃逸
前面已经介绍了对象结构,对象大小怎么计算,下面我们说一个JVM优化面试中比较重要的点,对象逃逸。
什么是对象逃逸
**对象逃逸的本质是对象指针的逃逸。**这里指的是指向对象的指针,并不是对象内部结构中的指针。
在计算机语言编译器优化原理中,逃逸分析是指分析指针动态范围的方法,它同编译器优化原理的指针分析和外形分析相关联。当变量(或者对象)在方法中分配后,其指针有可能被返回或者被全局引用,这样就会被其他方法或者线程所引用,这种现象称作指针(或者引用)的逃逸(Escape)。通俗点讲,如果一个对象的指针被多个方法或者线程引用时,那么我们就称这个对象的指针(或对象)的逃逸(Escape)。
什么是逃逸分析
逃逸分析,是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。 逃逸分析(Escape Analysis)算是目前Java虚拟机中比较前沿的优化技术了。
注意:逃逸分析不是直接的优化手段,而是代码分析手段。
简单来说:如果一个对象只存在一个方法中,并且该方法返回值是void,该对下下那个就不存在逃逸现象,但是如果这个对象最后由方法返回了,或者这个对象作为参数传入,这时候就出现了对象逃逸。其实就是对象的作用范围。
基于逃逸分析的优化
当判断出对象不发生逃逸时,编译器可以使用逃逸分析的结果作一些代码优化
- 栈上分配:将堆分配转化为栈分配。如果某个对象在子程序中被分配,并且指向该对象的指针永远不会逃逸,该对象就可以在分配在栈上,而不是在堆上。在的垃圾收集的语言中,这种优化可以降低垃圾收集器运行的频率。
- 同步消除:如果发现某个对象只能从一个线程可访问,那么在这个对象上的操作可以不需要同步。
- 分离对象或标量替换。如果某个对象的访问方式不要求该对象是一个连续的内存结构,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
标量替换
**标量:**不可被进一步分解的量,而JAVA的基本数据类型就是标量(比如int,long等基本数据类型) 。
聚合量: 标量的对立就是可以被进一步分解的量,称之为聚合量。 在JAVA中对象就是可以被进一步分解的聚合量。
**标量替换:**通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配。
栈上分配案例:
虚拟机参数:
-XX:+PrintGC -Xms5M -Xmn5M -XX:+DoEscapeAnalysis
-XX:+DoEscapeAnalysis表示开启逃逸分析,JDK8是默认开启的
-XX:+PrintGC 表示打印GC信息
-Xms5M -Xmn5M 设置JVM内存大小是5M
public static void main(String[] args){
for(int i = 0; i < 5000000; i++){
createObject();
}
}
public static void createObject(){
new Object();
}
[GC (Allocation Failure) 4081K->1664K(9216K), 0.0095903 secs]
[GC (Allocation Failure) 5760K->2317K(9216K), 0.0010894 secs]
把虚拟机参数改成 -XX:+PrintGC -Xms5M -Xmn5M -XX:-DoEscapeAnalysis。关闭逃逸分析得到结果的部分截图是,说明了进行了GC,并且次数还不少。
[GC (Allocation Failure) 4081K->1670K(8704K), 0.0009906 secs]
[GC (Allocation Failure) 5766K->2259K(8704K), 0.0010563 secs]
[GC (Allocation Failure) 6355K->2315K(8704K), 0.0009416 secs]
[GC (Allocation Failure) 6411K->2339K(8704K), 0.0008876 secs]
[GC (Allocation Failure) 6435K->2355K(8704K), 0.0006387 secs]
[GC (Allocation Failure) 6451K->2379K(6656K), 0.0005455 secs]
[GC (Allocation Failure) 4427K->2587K(7680K), 0.0008818 secs]
[GC (Allocation Failure) 4635K->2571K(7680K), 0.0003212 secs]
[GC (Allocation Failure) 4619K->2539K(7680K), 0.0005824 secs]
[GC (Allocation Failure) 4587K->2571K(7680K), 0.0005237 secs]
[GC (Allocation Failure) 4619K->2515K(7680K), 0.0003071 secs]
[GC (Allocation Failure) 4563K->2515K(7680K), 0.0003498 secs]
这说明了JVM在逃逸分析之后,将对象分配在了方法createObject()方法栈上。方法栈上的对象在方法执行完之后,栈桢弹出,对象就会自动回收。这样的话就不需要等内存满时再触发内存回收。这样的好处是程序内存回收效率高,并且GC频率也会减少,程序的性能就提高了。
同步锁消除
如果发现某个对象只能从一个线程可访问,那么在这个对象上的操作可以不需要同步 。
虚拟机配置参数:-XX:+PrintGC -Xms500M -Xmn500M -XX:+DoEscapeAnalysis。配置500M是保证不触发GC。
public class Demo {
public static void main(String[] args){
long start = System.currentTimeMillis();
for(int i = 0; i < 5000000; i++){
createObject();
}
System.out.println("cost = " + (System.currentTimeMillis() - start) + "ms");
}
public static void createObject(){
synchronized (new Object()){
}
}
}
cost = 4ms
把逃逸分析关掉:-XX:+PrintGC -Xms500M -Xmn500M -XX:-DoEscapeAnalysis
cost = 109ms
说明了逃逸分析把锁消除了,并在性能上得到了很大的提升。这里说明一下Java的逃逸分析是方法级别的,因为JIT ( just in time )即时编译器的即时编译是方法级别。
什么条件下会触发逃逸分析?
对象会先尝试栈上分配,如果不能成功分配,那么就去TLAB,如果还不行,就判定当前的垃圾收集器悲观策略,可不可以直接进入老年代,最后才会进入Eden。
Java的逃逸分析只发在JIT的即时编译中,因为在启动前已经通过各种条件判断出来是否满足逃逸,通过上面的流程图也可以得知对象分配不一定在堆上,所以可知满足逃逸的条件如下,只要满足以下任何一种都会判断为逃逸。
一、对象被赋值给堆中对象的字段和类的静态变量。
二、对象被传进了不确定的代码中去运行。
对象逃逸的范围有:全局逃逸、参数逃逸、没有逃逸。
TLAB(Thread Local Allocation Buffer)
即线程本地分配缓存区,这是一个线程专用的内存分配区域。
由于对象一般会分配在堆上,而堆是全局共享的。因此在同一时间,可能会有多个线程在堆上申请空间。因此,每次对象分配都必须要进行同步(虚拟机采用CAS配上失败重试的方式保证更新操作的原子性),而在竞争激烈的场合分配的效率又会进一步下降。JVM使用TLAB来避免多线程冲突,在给对象分配内存时,每个线程使用自己的TLAB,这样可以避免线程同步,提高了对象分配的效率。
每个线程会从Eden分配一大块空间,例如说100KB,作为自己的TLAB。这个start是TLAB的起始地址,end是TLAB的末尾,然后top是当前的分配指针。显然start <= top < end。
当一个Java线程在自己的TLAB中分配到尽头之后,再要分配就会出发一次“TLAB refill”,也就是说之前自己的TLAB就“不管了”(所有权交回给共享的Eden),然后重新从Eden里分配一块空间作为新的TLAB。所谓“不管了”并不是说就让旧TLAB里的对象直接死掉,而是把那块空间的控制权归还给普通的Eden,里面的对象该怎样还是怎样。通常情况下,在TLAB中分配多次才会填满TLAB、触发TLAB refill,这样使用TLAB分配就比直接从共享部分的Eden分配要均摊(amortized)了同步开销,于是提高了性能。其实很多关注多线程性能的malloc库实现也会使用类似的做法,例如TCMalloc。
到触发GC的时候,无论是minor GC还是full GC,要收集Eden的时候里面的空间无论是属于某个线程的TLAB还是不属于任何TLAB都一视同仁,把Eden当作一个整体来收集里面的对象——把活的对象拷贝到survivor space(或者直接晋升到Old Gen)。在GC结束之后,每个Java线程又会重新从Eden分配自己的TLAB。周而复始。
TLAB分配的对象可以共享吗?
答:只要是Heap上的对象,所有线程都是可以共享的,就看你有没有本事访问到了。在GC的时候只从root sets来扫描对象,而不管你到底在哪个TLAB中。