1.Java对象内存布局
1.1在堆内存中布局
在HotSpot虚拟机里, 对象在堆内存中的存储布局可以划分为三个部分: 对象头(Header) 、 实例数据(Instance Data) 和对齐填充(Padding) 。
Eg: new Person(); 存在堆里面的, 存的时候分三个部分


1.1.1对象头(16个字节)
1.1.1.1对象标记Mark Word
默认存储对象的HashCode、分代年龄(4位==>默认情况下,经过15次垃圾回收,对象会进入老年代)和锁标志位等信息。这些信息都是与对象自身定义无关的数据,所以MarkWord被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。
它会根据对象的状态复用自己的存储空间,也就是说在运行期间MarkWord里存储的数据会随着锁标志位的变化而变化。
1.1.1.2类型指针
类的对象在对象头中保存了一个类型指针,这个类型指针指向 InstanceKlass 对象。InstanceKlass 对象中有一个 _java_mirror 字段,指向 java.lang.Class 实例(即 .class)。这样,对象可以通过类型指针找到方法区(>=1.8是元空间内)中的 InstanceKlass,进而通过 _java_mirror 字段获取 java.lang.Class 实例,从而获取类的各种信息。

在64位系统中,对象标记Mark Word占了8个字节,类型指针占了8个字节,一共是16个字节
1.1.2实例数据
- 存放类的属性(Field)数据信息,包括父类的属性信息。
- 如果是数组的实例部分还包括数组的长度,这部分内存按4字节对齐。
实际大小占5个字节, 涉及到了数组, 最终占8个字节
实际大小占12个字节, 涉及到了数组, 最终占12个字节
实际大小占14个字节, 涉及到了数组, 最终占16个字节
1.1.3对齐填充
虚拟机要求对象起始地址必须是8字节的整数倍。填充数据不是必须存在的,仅仅是为了字节对齐这部分内存按8字节补充对齐。
1.1.4小结
Q1: Java对象在堆内存中有几部分组成?
Java对象在堆内存中有3部分组成
- 对象头
-
- markword(对象标记)
- 类型指针
- 实例数据
- 对齐填充
1.2深入对象头的MarkWord
1.2.1介绍
- 对象头多大? 16个字节= 对象标记MarkWord 8字节+ 类元信息(类元指针, 指向方法区的) 8字节。(注: 在1.3.2里面看到的类元指针是4字节,原因是压缩指针导致的)
- 1个字节=8位, 对象标记MarkWord 是64位
- 对象标记MarkWord里面: 有hashcode, 分代年龄, 锁....
64位虚拟机

1.2.2小结
Q1: MarkWord存哪些内容?
hashcode, 分代年龄,锁状态等
Q2:为什么垃圾回收是15次之后进入老年代, 不是17次,18次?
因为每个对象的对象头的markword里面有4位是分代年龄, 4位最大能存15
1.3new Object()占多少空间
1.3.1环境准备
JOL(Java对象布局)是用于分析JVM中对象布局方案的微型工具箱
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.9</version>
</dependency>
1.3.2例子
package com.lcuyp.jvm.e_obj;
import org.openjdk.jol.info.ClassLayout;
/**
* @Description:
* @author: yp
*/
public class ObjectDemo {
public static void main(String[] args) {
Object o = new Object();
//MyObject o = new MyObject();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
class MyObject {
private int i = 10;// 4个字节
private long j = 10l;// 8个字节
private short k = 2; //2个字节
}

答案: 12字节或者16字节都正确。
- MyObject
public class Demo16JolDemo {
public static void main(String[] args) {
MyObject o = new MyObject();
System.out.println(ClassLayout.parseInstance(o).toPrintable());
}
}
class MyObject {
private int i = 4; //4字节
private boolean flag = true; //1字节
}

1.3.3小结
Q1: 为什么我们看到类型指针只占4字节?
默认情况下, 为了节省空间,虚拟机开启了指针压缩。
2.对象创建流程

2.1类加载检查
当遇到new指令之后,首先会去到静态常量池中看看能否找到这个指令所对应的符号引用,然后会检查符号引用所对应的类是否被 加载-链接-初始化,如果有的话就进行第二步,如果没有就要先进行类的加载。
2.2分配内存
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类 加载完成后便可完全确定,为对象分配空间的任务等同于把 一块确定大小的内存从Java堆中划分出来。
2.2.1划分内存的方法
- 指针碰撞(Bump the Pointer)(默认用指针碰撞)
如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。 - 空闲列表(Free List)
如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录
2.2.2并发问题
在并发情况下划分不一定是线程安全的,有可能出现正在给A对象分配内存,指针还没有来得及修改,对象B又同时使用了原来的指针分配内存的情况,所以解决方法:
- CAS+失败重试:通过CAS乐观锁去尝试更新此次操作,如果CAS失败就去重试,直至成功为止。
- TLAB:预先在堆内存的Eden区为每一个线程分配一块名为TLAB的预存地址空间,当创建对象的时候就可以使用这块内存,当TLAB空间被占满时,再去采用CAS+失败重试的方法区分配内存
2.3初始化
之前的类加载过程我们了解到:在准备过程中会将final修饰的静态变量直接赋初值,对static修饰的静态变量赋零值。但是对于普通的成员变量我们不清楚是何时初始化的,那么这个阶段就是给成员变量进行初始化。
虚拟机需要将分配到的内存空间中的数据类型都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
class A{
privite int i;
privite String j;
private static int k = 123;
private final static int l = 123;
}
2.4设置对象头
初始化"零"值完成之后,虚拟机要对对象进行必要的设置,例如这个对象是那个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。 这些信息存放在对象头(对象标记+类型指针)中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。
2.5执行init方法
执行方法,即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(注意,这与上面的赋零值不同,这是由程序员赋的值),和执行构造方法 。
2.6小结
Q1:对象的创建流程是怎么样的?
类加载检查 --分配内存--初始化--设置对象头--执行init()方法
Q2: 对象在创建的过程中, 是怎么样分配内存的?
JVM有两种方式:
- 内存空间规整: 指针碰撞(默认)
- 内存空间不规整: 空闲列表
3.指针压缩
3.1为什么要进行指针压缩
- 减少64位平台下内存的消耗
- 减轻GC的压力
在jvm中,32位地址最大支持4G内存(2的32次方),可以通过对对象指针的压缩编码、解码方式进行优化,使得jvm只用32位地址就可以支持更大的内存配置(右移3位, 小于等于32G,2的35次方)。
堆内存小于4G时,不需要启用指针压缩,jvm会直接去除高32位地址,即使用低虚拟地址空间。
堆内存大于32G时,压缩指针会失效,会强制使用64位(即8字节)来对java对象寻址,所以堆内存不要大于32G为好。
3.2如何实现压缩指针
- 地址右移:在64位系统中,对象地址通常是对齐的,例如8字节对齐。这意味着对象地址的低3位总是0。因此,JVM 可以将对象地址右移3位,将一个64位地址压缩成一个32位地址。
- 地址恢复:在需要使用对象地址时,JVM 再将32位地址左移3位,恢复成原始的64位地址。
为什么低3位总是0, 因为8字节对齐的地址是8的倍数,所以它们的二进制表示的最后3位一定是0。具体来说:
- 8的二进制表示是
1000。 - 16的二进制表示是
10000。 - 24的二进制表示是
11000。 - 32的二进制表示是
100000。
3.3指针压缩设置
- jdk1.6 update14开始,在64bit操作系统中,JVM支持指针压缩
- jvm配置参数:UseCompressedOops,compressed压缩、oop(ordinary object pointer)对象指针
- 参数
- 启用指针压缩【默认开启】
-XX:+UseCompressedOops
- 禁止指针压缩:
-XX:-UseCompressedOops
3.4案例
- 代码
package com.lcuyp.jvm.e_obj;
import org.openjdk.jol.info.ClassLayout;
/**
* @Description: 计算对象大小
* @author: yp
*/
public class JOLSample {
public static void main(String[] args) {
ClassLayout layout = ClassLayout.parseInstance(new Object());
System.out.println(layout.toPrintable());
System.out.println();
ClassLayout layout1 = ClassLayout.parseInstance(new A());
System.out.println(layout1.toPrintable());
}
public static class A {
// ‐XX:+UseCompressedOops 默认开启的压缩所有指针22 // ‐XX:+UseCompressedClassPointers 默认开启的压缩对象头里的类型指针Klass Pointer23 // Oops : Ordinary Object Pointers24 public static class A {
//8B mark word26 //4B Klass Pointer 如果关闭压缩‐XX:‐UseCompressedClassPointers或-XX:-UseCompressedOops,则占用8B27 int id; //4B
int id; //4B
String name; //4B 如果关闭压缩‐XX:‐UseCompressedOops,则占用8B
byte b; //1B
Object o; //4B 如果关闭压缩‐XX:‐UseCompressedOops,则占用8B
}
}
- 关闭指针压缩结果

3.4小结
Q1: JVM中的指针压缩你有了解吗?
在64位平台下,为了减少内存的消耗和减轻GC的压力, JVM默认开启了指针压缩。使得只使用32位地址就可以支持更大的内存配置,最大只能是32G。如果大于32G, 指针压缩会失效。
4.逃逸分析
我们通过JVM内存分配可以知道JAVA中的对象都是在堆上进行分配,当对象没有被引用的时候,需要依靠GC进行回收内存,如果对象数量较多的时候,会给GC带来较大压力,也间接影响了应用的性能。为了减少临时对象在堆内分配的数量,JVM通过逃逸分析确定该对象不会被外部访问。
4.1逃逸分析
如果不会逃逸可以将该对象在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。
public User test1() {
User user = new User();
user.setId(1);
user.setName("zs");
return user;
}
public void test2() {
User user = new User();
user.setId(1);
user.setName("zs");
}
很显然test1方法中的user对象被返回了,这个对象的作用域范围不确定,test2方法中的user对象我们可以确定当方法结束这个对象就可以认为是无效对象了,对于这样的对象我们其实可以将其分配在栈内存里,让其在方法结束时跟随栈内存一起被回收掉。
JVM对于这种情况可以通过开启逃逸分析参数(-XX:+DoEscapeAnalysis)来优化对象内存分配位置,使其通过标量替换优先分配在栈上(栈上分配),JDK7之后默认开启逃逸分析,如果要关闭使用参数(-XX:-DoEscapeAnalysis)
4.2标量替换
通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配。开启标量替换参数(-XX:+EliminateAllocations),JDK7之后默认开启。
4.3标量与聚合量
标量即不可被进一步分解的量,而JAVA的基本数据类型就是标量(如:int,long等基本数据类型以及reference类型等),标量的对立就是可以被进一步分解的量,而这种量称之为聚合量。而在JAVA中对象就是可以被进一步分解的聚合量。
4.4案例
package com.lcuyp.jvm.e_obj;
import com.lcuyp.jvm.pojo.User;
/**
* 栈上分配,标量替换
* 代码调用了1亿次alloc(),如果是分配到堆上,大概需要1GB以上堆空间,如果堆空间小于该值,必然会触发GC。
*
* 使用如下参数不会发生GC
* -Xmx15m -Xms15m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations
* 使用如下参数都会发生大量GC
* -Xmx15m -Xms15m -XX:-DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations
* -Xmx15m -Xms15m -XX:-DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations
*/
public class AllotOnStack {
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
alloc();
}
long end = System.currentTimeMillis();
System.out.println(end-start);
}
private static void alloc() {
User user = new User();
user.setId(1L);
user.setName("zs");
}
}
4.5小结
Q1:你对逃逸分析有了解吗?
为了减少临时对象在堆内分配的数量,JVM通过逃逸分析确定该对象不会被外部访问。如果不会被外部访问,这个对象就直接在栈分配内存空间了,不在堆创建了。
5.对象分配原则
5.1对象分配过程

- 新创建的对象首先分配在 eden 区
-
- 新生代空间不足时,触发 minor gc ,eden 区 和 from 区存活的对象使用 - copy 复制到 to 中,存活的对象年龄+1,然后交换 from to
- minor gc 会引发 stop the world,暂停其他线程,等垃圾回收结束后,恢复用户线程运行
- 当幸存区对象的寿命超过阈值时,会晋升到老年代,最大的寿命是 15(4bit)
- 当触发 minor gc(新生代空间仍然不足时)后, 会晋升到老年代,如果老年代空间不足, 那么就触发 full fc ,停止的时间更长
5.2对象分配原则
- 优先分配到Eden
- 大对象直接分配到老年代
- 大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。
- JVM参数 -XX:PretenureSizeThreshold 可以设置大对象的大小,如果对象超过设置大小会直接进入老年代,不会进入年轻代,这个参数只在 Serial 和ParNew两个收集器下有效。
- 比如设置JVM参数:-XX:PretenureSizeThreshold=1000000 (单位是字节) -XX:+UseSerialGC ,再执行下上面的第一个程序会发现大对象直接进了老年代
- 为了避免为大对象分配内存时的复制操作而降低效率。
- 老年代空间分配担保机制
在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象(包括垃圾对象)的总空间:
- 如果大于,则此次Minor GC是安全的
- 如果小于,则虚拟机会查看-XX:HandlePromotionFailure设置值是否允许担保失败。
-
- 如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小
-
-
- 如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;
- 如果小于或者HandlePromotionFailure=false,则改为进行一次Full GC。
-

- 动态对象年龄判断
- 当前放对象的Survivor区域里(其中一块区域,放对象的那块s区),一批对象的总大小大于这块Survivor区域内存大小的50%(-XX:TargetSurvivorRatio可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了,
- 例如Survivor区域里现在有一批对象,年龄1+年龄2+年龄n的多个年龄对象总和超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。对象动态年龄判断机制一般是在minor gc之后触发的。
- 长期存活的对象将进入老年代
- 既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor空间中,并将对象年龄设为1。
- 对象在 Survivor 中每熬过一次 MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。
1448

被折叠的 条评论
为什么被折叠?



