0
点赞
收藏
分享

微信扫一扫

对象在JVM中的创建与内存分配

创建对象的方法

使用 new 关键字创建对象

对象在JVM中的创建与内存分配_标量

使用反射 Class 类的 newInstance 方法

对象在JVM中的创建与内存分配_JVM底层原理_02

使用 Constructor 类的 newInstance 方法

对象在JVM中的创建与内存分配_老年代_03

  • 使用 Clone 方法创建对象
  • 使用(反)序列化机制创建对象

对象创建流程

类加载检查

  • 关于对象的创建过程一般是从​​new​​ 指令
  • JVM 首先对符号引用进行解析,如果找不到对应的符号引用,那么这个类还没有被加载
  • JVM 便会进行类加载过程

分配内存

  • 符号引用解析完毕之后,JVM 会为对象在堆中分配内存
  • ​HotSpot​​ 虚拟机实现的 Java 对象包括三个部分
  • 对象头、实例字段和对齐填充字段
  • 实例字段包括自身定义的和从父类继承下来的
  • 对象所需内存的大小在类加载完成后便可完全确定

零值初始化

  • JVM 会将该内存(除了对象头区域)进行零值初始化

设置对象头

  • 虚拟机要对对象进行必要的设置
  • 像对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息都在对象头当中存放

执行初始化和构造器

  • ​class​​ 是从子类到父类依次查找,有关静态初始化的动作从父类到子类依次执行

创建一个对象的过程

Person p = new Person("zs");

过程

  1. 由于是要创建​​Person​​​ 类对象,Java 虚拟机(JVM)先去找​​Person.class​​ 文件,如果没有的话,将其加载到内存,如果有就直接创建对象,给对象分配地址空间
  2. 将类型信息(包括静态变量,方法等)加载进方法区
  3. 执行该类中​​static​​ 代码块
  4. 到这时才进行堆内存空间的开辟,并为对象分配首地址
  5. 在堆内存中建立对象的成员属性,并对其进行初始化(零值初始化)
  6. 进行构造代码块的初始化
  7. 对象的构造函数进行初始化
  8. 将堆内存中的地址(引用)赋给栈内存中的​​p​​ 变量

对象在JVM中的创建与内存分配_标量_04

如上是一个对象创建的过程,如下图是通过一个局部变量表,找到堆当中对应的方法信息,就是说你的数据全部存放在堆当中,它是如何进行调用到某个方法的流程图

对象在JVM中的创建与内存分配_老年代_05

分配内存

如何划分内存

指针碰撞(Bump the Pointer)

  • 假设 Java 堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器
  • 所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为​​“指针碰撞”​​(Bump the Pointer)

空闲列表(Free List)

  • 如果 Java 堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错
  • 虚拟机就必须维护一个列表,记录上哪些内存块是可用的
  • 在分配的时候从列表中找到一块足够大的空间划分给对象实例
  • 并更新列表上的记录,这种分配方式称为​​“空闲列表”​​(Free List)

选择问题

  • 分配方式由 Java 堆是否规整决定
  • Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定
  • HotSpot 采取​​G1​​​ 垃圾回收器,其具有压缩整理功能,系统采用的分配算法是​​指针碰撞​

并发情况下如何划分内存

并发问题

在并发情况下分配内存并不是线程安全的,可能出现正在给对象 ​​A​​​ 分配内存,指针还没来得及修改,对象 ​​B​​​ 又同时使用了原来的指针来分配内存的情况,解决这个问题有两种方案,一种是对分配内存空间的动作进行同步处理,虚拟机采用 ​​CAS​​ 配上失败重试的方式保证更新操作的原子性,把内存分配的动作按照线程划分在不同的空间之中进行

  • 每个线程在 Java 堆中预先分配一小块内存
  • 称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)
  • 哪个线程要分配内存,就在哪个线程的​​TLAB​​ 上分配
  • 只有​​TLAB​​​ 用完并分配新的​​TLAB​​ 时,才需要同步锁定
  • 虚拟机是否使用​​TLAB​​​,可以通过​​-XX:+/-UseTLAB​​ 参数来设定

Java 对象结构

整体结构

对象头

Mark Word

  • Mark Word(标记字)主要用来表示对象的线程锁状态,另外还可以用来配合 GC、存放该对象的 hashCode
  • Mark Word 在 32 位 JVM 中的长度是​​32bit​​​,在 64 位 JVM 中长度是​​64bit​

Klass Word

  • ​Klass Word​​ 是一个指向方法区中 Class 信息的指针
  • 虚拟机通过这个指针来确定这个对象是哪个类的实例,意味着该对象可随时知道自己是哪个 Class 的实例
  • 该指针在 32 位 JVM 中的长度是​​32bit​​​,在 64 位 JVM 中长度是​​64bit​

数组长度

  • 是可选的,只有当本对象是一个数组对象时才会有这个部分
  • 该数据在 32 位和 64 位 JVM 中长度都是​​32bit​

如下特意的画了一下对象的创建与 ​​Klass Word​​ 的整体过程

对象在JVM中的创建与内存分配_JVM底层原理_06

如下代码就可以验证如上流程图的整体结果是否正确,第一个首先是在堆当中有一个实例对象,在对象头中有一个 KClass 指向着元数据区中的 intanceKClass 所以可以拿到 Class 实例,第二个就可以直接通过元数据区当中的 intanceKClass 拿到 Class 实例

/**
* @author BNTang
*/
public class Test {
public static void main(String[] args) {
Client client = new Client();
// 1
Class<? extends Client> aClass = client.getClass();

// 2
Class<? extends Class> bClass = Client.class.getClass();
}
}

对象体

对象体是用于保存对象属性和值的主体部分,占用内存空间取决于对象的属性数量和类型

对齐字节

  • 因为 JVM 要求 Java 的对象占的内存大小应该是​​8bit​​ 的倍数
  • 所以后面有几个字节用于把对象的大小补齐至​​8bit​​ 的倍数,没有特别的功能

对象头结构

对象头概述

  • 对象头被分为两个部分,第一个部分是​​Mark Word​​,代表标记信息,例如 hashCode,锁的标志位,GC 分代年龄等信息
  • 第二个部分是​​KClass Word​​,代表类型信息,该部分是一个指针,指向方法区(元数据空间)中的实际类型

Mark Word

  • 普通状态:普通状态,由 25 位哈希码,4位 GC 分代年龄,1位偏向锁标识,2位锁标志(01)组成
  • 偏向状态:由 23 位线程标识,2 位时间戳,4 位 GC 分代年龄,1 位偏向锁标识,2 位锁标识(01)组成
  • 轻量级锁状态:由 30 位指针(指向锁记录) 2 位锁标识(00)组成
  • 重量级锁状态:由 30 位指针(指向重量级锁的​​monitor​​,2 位锁标识(10)组成
  • Mared for GC:待 GC 回收状态,只有最后 2 位锁标识(11)有效

对象在JVM中的创建与内存分配_标量_07

指针压缩

寻址空间

  • 32 位 CPU 架构,最大寻址内存为 4G(2的32次方)
  • 64 位 CPU 架构,寻址内存为 TB 级别(2的64次方)

KClass地址是占用多少内存

  • KClass 是堆内存地址,在没有开启指针压缩的情况下,32 位 JVM 中,是 4 字节
  • 64 位 JVM 中,8 字节

那么可以推出如下这样的结论:

  • JVM 由 32 位升级为 64 位,堆内存可以设置更大了
  • 同时对象的引用地址(OOP)消耗的内存也增大了
  • 进而可以推出对象的引用地址增大了,其它地方的可用内存就变小了

那么有方法可以解决将 JVM 的堆内存增大的同时,还保留了 32 位 JVM 的引用地址占用内存小的特点吗?那么就是指针压缩技术(JDK1.6开始默认开启了)

  • 通常 64 位 JVM 消耗的内存会比 32 位的大​​1.5​​ 倍,这是因为对象指针在 64 位架构下,长度会翻倍(更宽的寻址)
  • 对于那些将要从 32 位平台移植到 64 位的应用来说,平白无辜多了​​1/2​​ 的内存占用,这是开发者不愿意看到的
  • 从​​JDK1.6​​​ update14 开始,64 bit JVM 正式支持了​​-XX:+UseCompressedOops​​ 这个可以压缩指针,起到节约内存占用的新参数
  • 64 位虚拟机来说,低于 32G 内存,默认开启指针压缩

指针压缩的原理

  • 指针不再表示对象在内存中的精确位置,而是表示​​偏移量​
  • 这意味着 32 位的指针可以引用 40 亿个对象而不是 40 亿个字节
  • 最终,也就是说内存增长到 32GB 的物理内存,也可以用 32 位的指针表示
  • 也就是说,我们只需要知道 JVM 内存的开始位置,知道偏移量,就可以算出我想要找的实际物理位置

非空对象

开启指针压缩

可以利用一个插件来进行打印对象占用的内存信息,首先需要创建一个 maven 工程当然你不创建 maven 工程也是可以的你可以单独的下载 jar 下来添加到对应的工程当中我这里利用的是 maven 工程进行测试的,maven 依赖如下修改 pom.xml 添加如下内容

<dependencies>
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.10</version>
</dependency>
</dependencies>

测试类代码如下

/**
* @author BNTang
*/
public class ObjectTest {
int a = 10;
int b = 20;

public static void main(String[] args) {
ObjectTest object = new ObjectTest();
String str = ClassLayout.parseInstance(object).toPrintable();
System.out.println("str" + str);
}
}

打印结果如下,如下的结果是开启了指针压缩的结果

对象在JVM中的创建与内存分配_JVM底层原理_08

关闭指针压缩

添加如下 JVM 启动参数即可关闭

-XX:-UseCompressedOops

对象在JVM中的创建与内存分配_java_09

添加参数了之后在测试代码当中添加如下的一行局部变量,博主已经测试过了开启指针压缩的大小和没开启的大小区别,开启了是 ​​24bytes​​​ 没有开启是 ​​32bytes​

对象在JVM中的创建与内存分配_java_10

如下就是没有开启指针压缩的大小结果输出信息

对象在JVM中的创建与内存分配_java_11

为什么要进行指针压缩

  • 在 64 位平台的​​HotSpot​​​ 中使用 32 位指针,内存使用会多出​​1.5​​​ 倍左右,使用较大指针在主内存和缓存之间移动数据,占用较大宽带,同时​​GC​​ 也会承受较大压力
  • 为了减少 64 位平台下内存的消耗,启用指针压缩功能
  • 在 JVM 中 32 位地址支持 4G 内存(2的32次方)可以通过对对象指针的压缩编码、解码方式进行优化,使得 JVM 只用 32 位地址就可以支持更大的内存配置(小于等于32G)
  • 堆内存小于 4G 时,不需要启用指针压缩,JVM 会直接去除高 32 位地址,即使用低虚拟地址空间
  • 堆内存大于 32G 时,压缩指针会失效,会强制使用 64 位(即8字节)来对 Java 对象寻址,所以堆内存不要大于 32G 为好

对象分配内存

对象栈上分配空间

什么时候会在栈上分配

  • 我们创建一个对象的时候一般情况下是直接在堆上面进行分配内存,有时候也会直接在栈帧上分配内存
  • JVM 通过​​逃逸分析​​ 确定该对象不会被外部访问。如果不会逃逸可以将该对象在栈上分配内存
  • 这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力

验证如上的三点参考如下代码即可,在运行之前先配置一下 JVM 的启动参数参数内容如下

-Xmx10m -Xms10m -XX:+PrintGC

其实也就是配置了把 JVM 的最大内存调小的,和打印 GC 信息,如下的代码的 Test 实例在 newTest 方法之外都没有地方引用所以不会产生逃逸分析,那么就会直接在栈当中进行分配内存,会随着方法一起出栈会立即清理内存等信息

/**
* @author BNTang
*/
public class Test {
int a = 10;

public static void newTest() {
// 堆,栈,逃逸分析,栈上的内容,方法一出栈,内存会被立即回收
Test test = new Test();
test.a = 10;
}

public static void main(String[] args) {
IntStream.rangeClosed(1, 10000000).forEach(v1 -> newTest());
}
}

运行如上代码效果图如下,如下的 GC 是 JVM 内部打印的,并不是我们代码造成的 GC

对象在JVM中的创建与内存分配_标量_12

对象逃逸分析

  • 就是分析对象动态作用域,当一个对象在方法中被定义后,没有在此方法之外有其引用,就称为对象没有发生逃逸
  • 如果这个对象只在这个方法当中使用,没有外界的引用,就会直接在方法的栈上分配内存

开启与关闭

关闭逃逸分析

-XX:-DoEscapeAnalysis

开启逃逸分析

-XX:+DoEscapeAnalysis

如上的示例代码当中没有产生逃逸,外界没有引用该 Test 实例,在紧接着来看看如下的代码这段代码我将 Test 定义为了全局实例变量,这样就会产生逃逸分析因为 newTest 方法在出栈的时候发现外界有引用该实例就会在堆当中进行分配内存地址,修改 JVM 启动参数如下,就是开启了逃逸分析这也是它默认打开的,还开启了一个标量替换也是默认打开的,配置了 JVM 最大的内存为 ​​10m​​ 并且配置了产生 GC 的时候进行打印

-Xmx10m -Xms10m -XX:-DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations

测试代码如下,如下的代码会在堆当中进行分配内存这样堆的内存就慢慢的增大然后触发 GC 产生了逃逸分析,因为如果在栈当中进行分配内存方法一出栈,该对象就会给销毁外部引用的如果调用该对象实例的方法就会产生空指针异常,所以就会在堆空间中进行分配内存????

/**
* @author BNTang
*/
public class Test {
int a = 10;

static Test test;

public static void newTest() {
test = new Test();
test.a = 10;
}

public static void main(String[] args) {
IntStream.rangeClosed(1, 10000000).forEach(idxI -> newTest());
}
}

对象在JVM中的创建与内存分配_标量_13

标量替换

  • 通过​​逃逸分析​​ 确定该对象不会被外部访问
  • JVM 不会创建该对象
  • 是将该对象成员变量分解若干个被这个方法使用的成员变量所代替
  • 这样就不会因为没有一大块连续空间导致对象内存不够分配
  • 开启标量替换参数(-XX:+EliminateAllocations)JDK7 之后默认开启

标量与聚合量

  • 标量即不可被进一步分解的量,而 Java 的基本数据类型就是标量(如:int,long 等基本数据类型以及​​reference​​ 类型等)
  • 标量的对立就是可以被进一步分解的量,而这种量称之为​​聚合量​
  • 而在 Java 中对象就是可以被进一步分解的​​聚合量​

要想验证如上的几点内容,你可以在 JVM 启动的参数当中开启逃逸分析,然后关闭标量替换参数如下

-Xmx10m -Xms10m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations

对象在JVM中的创建与内存分配_java_14

通过如上的运行结果发现出现了大量的 GC 就是因为我们虽然开启了逃逸分析但是没有开启标量替换,虽然在栈当中进行分配内存了没有开启标量替换在栈当中分配不了所以就会被分配到堆当中,JVM 当中开启逃逸分析和标量替换 ​​-​​​ 就代表关闭 ​​+​​ 就代表开启的意思,我们这个时候把标量替换打开在运行效果如下

对象在JVM中的创建与内存分配_老年代_15

大对象内存分配

如果一个占用内存比较大的对象,​​Eden​​​ 区无法装入时会直接分配到 ​​老年代​​ 当中。

可以通过配置参数来确定多大的对象为大对象,单位为 ​​字节​​:

-XX:+PrintGCDetails -XX:PretenureSizeThreshold=10000 -XX:+UseSerialGC
-XX:PretenureSizeThreshold=10000

接下来通过代码来演示一下,代码如下,在运行之前配置一下 JVM 参数,就是调整 JVM 的内存大小(初始化是30兆的大小,最大内存也是30兆的大小)如果不配置它就会使用操作系统默认的内存了就不利于演示,和打印堆空间的状态,JDK 使用的 ​​1.8​​ 版本的因为现在在很多的企业当中目前使用的都是比较多的,JDK1.9 之后后面在单独的介绍。

/**
* @author BNTang
*/
public class Test {
public static byte[] bytes = null;

public static void main(String[] args) {
bytes = new byte[1024];
}
}

JVM 参数如下:

-Xmx30m -Xms30m -XX:+PrintGCDetails

运行结果如下:

对象在JVM中的创建与内存分配_标量_16

其实如上的演示代码当中的 ​​new byte[1024];​​ 不写其实也有百分之二十四,删除之后的运行结果如下,这百分之二十四其实是 JVM 在启动的时候会给你创建一些对象这就是这百分之二十四的原因:

对象在JVM中的创建与内存分配_java_17

接下来我要将如上的代码改造一下将占用的内存加大,代码如下,代码就补贴了比较简单,运行结果如下图,还是没有放到老年代区域:

对象在JVM中的创建与内存分配_java_18

我们将如上的代码再次进行改造增大占用大小,乘以10,也就是10兆的大小,在来看看结果:

对象在JVM中的创建与内存分配_标量_19

如上的演示我们还没配置多大的占用内存,成为大对象,如上我配置1兆就为大对象了,所以会被分配到老年代当中如下,JVM 参数如下:

-Xmx30m -Xms30m -XX:+PrintGCDetails -XX:PretenureSizeThreshold=10000 -XX:+UseSerialGC

对象在JVM中的创建与内存分配_JVM底层原理_20

动态年龄判定

默认规则:

  • ​-XX:MaxTenuringThreshold=XX​​​,默认是​​15​
  • 15 的含义是从​​eden​​​ →​​survivor​​​ 对象年龄​​+1​​​,survivor → eden 对象年龄 +1,直到年龄达到​​15​​​ 后开始进入​​old Generation​

动态年龄判定:

  • 虚拟机并不是永远地要求对象的年龄必须达到了​​MaxTenuringThreshold​​ 才能晋升到老年代
  • ​年龄1​​​ +​​年龄2​​​ +​​年龄n​​​ 的多个年龄对象总和超过了​​Survivor​​​ 区域的​​50%​
  • 此时就会把​​年龄n​​(含)以上的对象都放入到老年代
  • 这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代

示例如下:

如果说 ​​MaxTenuringThreshold 为 15​

  • 年龄1 的对象占用了 33%
  • 年龄2 的对象占用了 33%
  • 年龄3 的对象占用了 34%

年龄1 的占用了 33%,年龄2 的占用了 33%,累加和超过默认的 ​​TargetSurvivorRatio​​(50%)年龄2 和 年龄3 的对象都要晋升,希望那些可能是长期存活的对象,尽早进入老年代,避免多次复制操作而降低效率。

JVM 内存分配担保机制

当对象生成在 ​​EDEN​​​ 区失败时,出发一次 ​​YGC​​​,先扫描 ​​EDEN​​​ 区中的存活对象,进入 ​​S0​​​ 区,S0 放不下的进入 ​​OLD​​​ 区,再扫描 ​​S1​​​ 区,若存活次数超过阀值则进入 OLD 区,其它进入 S0 区,然后 S0 和 S1 交换一次。当发生 ​​YGC​​​ 时,JVM 会首先检查老年代最大的可用连续空间是否大于新生代所有对象的总和,如果大于,那么这次 YGC 是安全的,如果不大于的话,JVM 就需要判断 ​​HandlePromotionFailure​​ 是否允许空间分配担保。

允许分配担保:

  • JVM 继续检查老年代最大的可用连续空间是否大于历次晋升到老年代的对象的平均大小
  • 如果大于,则正常进行一次 YGC,尽管有风险(因为判断的是平均大小,有可能这次的晋升对象比平均值大很多)
  • 如果小于,或者​​HandlePromotionFailure​​​ 设置不允许空间分配担保,这时要进行一次​​FGC​

对象在JVM中的创建与内存分配_老年代_21




举报

相关推荐

0 条评论