深入理解JVM(九)一一 对象实例化和内存布局

1,758 阅读24分钟

对象实例化和内存布局

对象实例化

创建对象的方式

  • new对象
    • 变形1 : Xxx的静态方法
    • 变形2 : XxxBuilder/XxxFactory的静态方法(建造者模式和工厂模式都能获取对象)
  • Class的newInstance0 :反射的方式,只能调用空参的构造器,权限必须是public
  • Constructor的newInstance(Xxx) :反射的方式,可以调用空参、带参的构造器,权限没有要求
  • 使用clone() :不调用任何构造器,当前类需要实现Cloneable接口,实现clone()方法
  • 使用反序列化:从文件中、从网络中获取一个对象的二进制流
  • 第三方库Objenesis

对象实例化步骤

1. 加载类元信息

虚拟机遇到一条new指令,首先去检查这个指令的参数能否在Metaspace的常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化。(即判断类元信息是否存在)。

如果没有,那么在双亲委派模式下,使用当前类加载器以ClassLoader+包名+类名为Key进行查找对应的.class文件。如果没有找到文件,则抛出ClassNotFoundException异常,如果找到,则进行类加载,并生成对应的Class类对象

类加载阶段:加载、链接(验证->准备->解析)、初始化

2. 为对象分配内存

首先计算对象占用空间大小,接着在堆中划分一块内存给新对象。如果实例成员变量是引用变量,仅分配引用变量(地址)空间即可,即4个字节大小。对象大小具体算。

  • 如果内存规整,使用指针碰撞

如果内存是规整的,那么虚拟机将采用的是指针碰撞法(Bump The Pointer) 来为对象分配内存。意思是所有用过的内存在一边,空闲的内存在另外一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把指针向空闲那边挪动一段与对象大小相等的距离罢了。如果垃圾收集器选择的是Serial、ParNew这种基于压缩算法的,虛拟机采用这种分配方式。一般使用带有compact(整理)过程的收集器时,使用指针碰撞。

  • 如果内存不规整,使用空闲列表

如果内存不是规整的,已使用的内存和未使用的内存相互交错,那么虚拟机将采用的是空闲列表法来为对象分配内存。意思是虚拟机维护了一个列表,记录上哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。这种分配方式成为:空闲列表(Free List)。

说明:选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

3. 处理并发问题

在分配内存空间时,另外一个问题是及时保证new对象时候的线程安全性,创建对象是非常频繁的操作,虚拟机需要解决并发问题。虚拟机采用了两种方式解决并发问题:

  • CAS(Compare And Swap)失败重试、区域加锁:保证指针更新操作的原子性

  • TLAB把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲区,(TLAB ,Thread Local Allocation Buffer) 虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。(堆篇章已介绍)

4. 属性的默认初始化(零值初始化)

内存分配结束,虚拟机将分配到的内存空间都初始化为零值(不包括对象头)。这一步保证了对象的实例字段在Java代码中可以不用赋初始值就可以直接使用,程序能访问到这些字段的数据类型所对应的零值。

5. 设置对象的对象头

将对象的所属类(即类的元数据信息)、对象的HashCode和对象的GC信息、锁信息等数据存储在对象的对象头中。这个过程的具体设置方式取决于JVM实现。

6. 属性的显式初始化、构造代码块中初始化、构造器中初始化(执行init方法进行初始化)

在Java程序的视角看来,初始化才正式开始。初始化成员变量,执行构造代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。因此一般来说(由字节码中是否跟随有invokespeclal指令所决定), new指令之后会接着就是执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全创建出来。

注意:

  • 属性的显式初始化不包括静态变量,静态变量和静态代码块是加载时执行clinit()方法已经显式初始化了,并且只执行一次。

  • 构造代码块中初始化{}:是没有static修饰的。static{}修饰的代码块为静态代码块,也是在clinit()方法中执行。

  • 构造代码块,也叫实例化代码块:{}

  • 静态代码块: static{}

实例对象的属性赋值的操作执行顺序(init()):

  1. 属性的默认值初始化
  2. 显式初始化或构造代码块中初始化 (看代码位置顺序)
  3. 构造器中初始化

静态变量和静态代码块:

  1. 属性的默认值初始化(链接-准备阶段)

  2. 静态变量和静态代码块显式初始化(初始化阶段-clinit())

对象分布例子

public class Customer{
    int id = 1001;
    String name;
    Account acct;

    {
        name = "匿名客户";
    }
    public Customer(){
        acct = new Account();
    }

}
class Account{

}
public class CustomerTest {
    public static void main(String[] args) {
        Customer cust = new Customer();
    }
}

内存布局图例子.png

对象定位访问

  1. 句柄访问

句柄访问.png

优点:

reference中存储稳定句柄地址,对象被移动(垃圾收集时移动对象很普遍)时只会改变句柄中实例数据指针即可, reference本身不需要被修改。

  1. 直接指针(Hotspot采用)

直接指针.png

优点:

reference直接指向对象的地址,不需要再经过句柄池,减少一次引用查询。Hotspot采用这种方式的。

对象内存布局

image.png

对象头(Header)

标记字(Mark Word)

对象头中的Mark Word(标记字)主要用来表示对象的线程锁状态,GC年龄、对象hashCode

在32位系统,为32位,即4个字节。

在64位系统,为64位,即8个字节。

image.png

  • unused:没有使用的位

  • identity_hashcode:31位的对象标识hashCode,采用延迟加载技术。调用方法System.identityHashCode()计算,并会将结果写到该对象头中。当对象加锁后(偏向、轻量级、重量级),MarkWord的字节没有足够的空间保存hashCode,因此该值会移动到管程Monitor中。

  • age:4位的Java对象年龄。在GC中,如果对象在Survivor区复制一次,年龄增加1。当对象达到设定的阈值时,将会晋升到老年代。默认情况下,并行GC的年龄阈值为15,并发GC的年龄阈值为6。由于age只有4位,所以最大值为15,这就是-XX:MaxTenuringThreshold选项最大值为15的原因。

  • biased_lock:对象是否启用偏向锁标记,只占1个二进制位。为1时表示对象启用偏向锁,为0时表示对象没有偏向锁。lock和biased_lock共同表示对象处于什么锁状态。

  • lock:2位的锁状态标记位,由于希望用尽可能少的二进制位表示尽可能多的信息,所以设置了lock标记。该标记的值不同,整个Mark Word表示的含义不同。biased_lock和lock一起,表达的锁状态含义如下:

image.png

  • thread:持有偏向锁的线程ID。

  • epoch:偏向锁的时间戳。

  • ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针。

  • ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针。

image.png

锁升级
  1. 初期锁对象刚创建时,还没有任何线程来竞争,对象的Mark Word是下图的第一种情形,这偏向锁标识位是0,锁状态01,说明该对象处于无锁状态(无线程竞争它)。
  2. 当有一个线程来竞争锁时,先用偏向锁,表示锁对象偏爱这个线程,这个线程要执行这个锁关联的任何代码,不需要再做任何检查和切换,这种竞争不激烈的情况下,效率非常高。这时Mark Word会记录自己偏爱的线程的ID,把该线程当做自己的熟人。如下图第二种情形。
  3. 当有两个线程开始竞争这个锁对象,情况发生变化了,不再是偏向(独占)锁了,锁会升级为轻量级锁,两个线程公平竞争,哪个线程先占有锁对象并执行代码,锁对象的Mark Word就执行哪个线程的栈帧中的锁记录。如下图第三种情形。
  4. 如果竞争的这个锁对象的线程更多,导致了更多的切换和等待,JVM会把该锁对象的锁升级为重量级锁,这个就叫做同步锁,这个锁对象Mark Word再次发生变化,会指向一个监视器对象,这个监视器对象用集合的形式,来登记和管理排队的线程

image.png

类型指针(klass pointer)

Klass Word是一个指向方法区中Class信息的指针

在32位系统,为32位,即4个字节。

在64位系统,为64位,即8个字节。(开启指针压缩是4字节)

当64位机器设置最大堆内存为32G以下时,将会默认开启指针压缩,将8字节的指针压缩为4字节

关闭指针压缩:-XX:-UseCompressedOops

数组长度(数组对象才有)

这是可选的,只有当本对象是一个数组对象时才会有这个部分,占用4个字节。 无论是否开启指针压缩都是4字节

image.png

实例数据(Instance Data)

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

基本类型安装基本类型大小计算

引用变量在32位系统,为32位,即4个字节。

引用变量在64位系统,为64位,即8个字节。(开启指针压缩是4字节)

实例数据部分只会存放对象的实例数据,并不会存放静态数据。此外,子对象的实例数据部分会继承父类所有实例数据,包括私有类型,这里可以理解为子类拥有父类所有类型的成员变量,但在子类中无法直接访问这些私有实例变量。

对齐填充(Padding)

  1. HotSpot虚拟机规定对象的起始地址必须是8的整数倍,也就是要求对象的大小必须是8的整数倍。因此如果一个对象的对象头+实例数据占用的总内存没有达到8的倍数时,会进行对齐填充,将总大小填充到最近的8的倍数上。

  2. 字段与字段之前也需要对齐,字段对齐的最小单位是4个字节。

可以这样理解,虚拟机每次会为字段发放一个最近的4倍数的一个盒子。比如,有个类的字段有一个boolean和一个int,这时候先为boolean发放第一个大小为4字节的盒子,将boolean放入其中,占用1个字节,浪费3个字节,因为int占用4个字节,根本放不下,需要虚拟机再分配一个大小为4的盒子。

虚拟机不会按照字段声明的顺序去给字段分配盒子,而是会进行重排序,使得物尽其用。比如一个类有以下变量:char、int、boolean、byte。如果按照声明顺序去分配盒子的话,则需要为char分配一个盒子,浪费2个字节。再为int分配一个盒子,这个盒子正好满了,没有浪费。接着为boolean分配一个盒子,浪费3个字节。最后为byte分配一个盒子,又浪费3个字节。

在进行重排序后,此时可以按照int(4)、char(2)+boolean(1)+byte(1)的顺序,虚拟机可以只分配2个盒子,大大减少内存浪费。但是引用类型的字段必定在最后才分配。

计算对象大小

各类型数据大小

原生类型占用内存大小(字节)
reference开启指针压缩4、关闭指针压缩8
boolean1
byte1
short2
char2
float4
int4
long8
double8

关闭指针压缩:-XX:-UseCompressedOops

对象大小 = 对象头(标记字+类型指针+数组长度(可选)) + 实例数据 + 对齐填充

静态属性不算在对象大小内

为了减少空间浪费,一般情况下,field分配的优先依次顺序是:

double > long > int > float > char > short > byte > boolean > object reference。

这里有个基本的原则是:尽可能先分配占用空间大的类型。

JOL(Java Object Layout)

  1. JOT可以查询对象大小,引入如下依赖:
<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.16</version>
</dependency>
  1. 其他方式查询对象大小:
  • Instrument
  • SA
  • Unfase

下面例子位于64位机器上,默认开启指针压缩。

没有属性对象大小

public class Test {
    public static void main(String[] args) {
        Object o = new Object();
        System.out.println(ClassLayout.parseInstance(o).toPrintable());
    }
}

image.png

对象大小 =标记字8+类型指针4+数组长度0 + 实例数据0 + 对齐填充4=16字节

有属性对象大小

public class App {
    public char charP;
    public int intP;
    public boolean booleanP;
    public byte byteP;
}

先估算对象大小 = 标记字8+类型指针4+数组0 + 实例数据(intP 4+charP 2+booleanP 1+byteP 1) + 对齐填充4=24字节

public class Test {
    public static void main(String[] args) {
        App app = new App();
        System.out.println(ClassLayout.parseInstance(app).toPrintable());
    }
}

输出结果 image.png

关闭指针压缩结果:-XX:-UseCompressedOops image.png

有父类的子类大小

  • 子类的实例数据部分会排除掉父类的私有实例属性privateFlag吗?不会

  • 子类的实例数据部分会覆盖掉父类的同名实例属性吗?不会覆盖,要算两份


class Father {
    public boolean publicFlag;//1
    private boolean privateFlag;//1
    public static boolean staticFlag;//实例是没有的
}
 
public class Children extends Father {
    public boolean publicFlag;//1
    private int b;//4
    protected double c;//8
    long d;//8 注意这里不是包装类Long
}

计算Children对象大小:

父 = 实例数据2 + 对齐填充2=4字节

子 = 标记字8+类型指针4+数组0 + 实例数据21 + 对齐填充3=36字节

总 = 父+子=4+36=40

输出结果

image.png

总结:

  • 子类对象中包含了父类所有的实例变量,且首先分配父类实例变量,再分配子类实例变量。

  • 静态变量没有分配

有数组属性对象大小

大小=标记字 + 类型指针 + 数组长度 + 实例数据(数组长度*数组元数据大小) +补齐填充


    private static class ObjectC {
    	ObjectD[] array = new ObjectD[2];
    }
    
    private static class ObjectD {
    	int value;
    }
 

image.png

手工计算ObjectC obj = new ObjectC()的大小:

ObjectC大小 = 8(标记字) + 4(类型指针) + 4(ObjectD[]引用) = 16

image.png

数组对象大小

System.out.println(ClassLayout.parseInstance(new int[100]).toPrintable());
System.out.println(ClassLayout.parseInstance(new Object[100]).toPrintable());
   

new int[100] = 8(标记字) + 4(类型指针) +4(数组长度)+ 100x4(数组大小x数组内容大小) = 416字节

image.png

OOP-Klass模型

HotSpot是基于c++实现,而c++是一门面向对象的语言,本身具备面向对象基本特征,所以Java中的对象表示,最简单的做法是为每个Java类生成一个c++类与之对应。

但HotSpot JVM并没有这么做,而是设计了一个OOP-Klass Model。这里的 OOP 指的是(Ordinary Object Pointer)(普通对象指针),它用来表示对象的实例信息。而 Klass 则包含元数据和方法信息,用来描述Java类。

HotSopt JVM的设计者不想让每个对象中都含有一个vtable(虚函数表),所以就把对象模型拆成klass和oop,其中oop中不含有任何虚函数,而Klass就含有虚函数表,可以进行method dispatch。

对象访问定位.png

Klass

  • jvm在加载class文件时,创建instanceKlass,表示其元数据,包括常量池、字段、方法等,存放在方法区;instanceKlass是jvm中的数据结构;
  • 包含元数据和方法信息,用来描述Java类
  • 提供一个与java类对等的c++类型描述
  • 实现Java对象的函数分发功能
  • klass是在方法区的

image.png

  • InstanceMirrorKlass:用于表示java.lang.Class,Java代码中获取到的Class对象,实际上就是这个C++类的实例,存储在堆区,学名镜像类
  • InstanceRefKlass:用于表示java/lang/ref/Reference类的子类
  • InstanceClassLoaderKlass:用于遍历某个加载器加载的类
  • Java中的数组不是静态数据类型,是动态数据类型,即是运行期生成的,Java数组的元信息用ArrayKlass的子类来表示:
  • TypeArrayKlass:用于表示基本类型的数组
  • ObjArrayKlass:用于表示引用类型的数组

OOP

  • Ordinary Object Pointer (普通对象指针),它用来表示对象的实例信息
  • OOP则是在Java程序运行过程中new对象时创建的
  • jvm使用oop来保存用户的实例数据
  • HotSpot中,用instanceOopDesc 或 arrayOopDesc 来描述对象头,其中arrayOopDesc对象用于描述数组类型。

image.png

image.png

handle

除了oop,kclass外在JVM中还有一个东西handle:

  • handle 是对oop的行为的封装.这里需要注意的是:

  • 大多数情况下,JVM在访问java类时是一定通过handle的_handle 来得到oop,再通过oop获得对应的klass,这样handle就能够访问oop的函数了。如果是调用JVM内部的c++类所对应的oop函数,则不需要通过handle,直接通过oop拿到指定的klass即可

image.png

目的

HotSopt JVM的设计者不想让每个对象中都含有一个vtable(虚函数表),所以就把对象模型拆成klass和oop,其中oop中不含有任何虚函数,而Klass就含有虚函数表,可以进行方法分发。

为了实现多态,jvm 采用了 vtable,itable技术,而vtable,itable 就是保存在klass模型中

C++与Java区别

vtable: 该类所有的函数(除了static, final)和 父类的函数虚拟表

Itable: 该类所有实现接口的函数列表

虚函数: 同Java方法(函数),C++中的有虚函数的概念,用virtual 关键字来表示,每个类都会有一个虚函数表,该虚函数表首先会从父类中继承得到父类的虚函数表, 如果子类中重写了父类的虚函数(不管重写后的函数是否为虚函数),要调用哪个虚函数,是根据当前实际的对象来判断的(不管指针所属类型是否为当前类,有可能是父类型),指针当前指向的是哪种类型的对象,就调用哪个类型中类定义的虚函数。每个类只有一张虚拟函数表,所有的对象共用这张表。在Java中,自动实现了虚函数这一概念:多态——父类的变量,调用子类的方法

纯虚函数: 同Java抽象方法,C++中主要特征是不能被用来声明对象,是抽象类,是用来确保程序结构与应用域的结构据具有直接映射关系的设计工具。带有纯虚函数的类称为抽象类,抽象类能被子类 继承使用,在子类中必须给出纯虚函数的实现,如果子类未给出该纯虚函数的实现,那么该子类也是抽象类,只有在子类不存在纯虚函数时,子类才可以用来声明对 象!抽象类也能用于声明指针或引用,或用于函数声明中。具有抽象类特性的类还有构造函数和析构函数,全部是保护的类。如果没有给出纯虚函数的实现,则在它 所在的类的构造函数或析构函数中不能直接或间接的调用它。纯虚函数的实现可以在类声明外进行定义。

抽象类: 同Java抽象类,C++中指同时含有纯虚函数和非纯虚函数的类

纯虚类: 同Java接口,C++中指只含有纯虚函数的类

C++Java
虚函数普通函数
纯虚函数抽象函数
抽象类抽象类
纯虚类接口

拓展: C++中普通函数不存在多态——不会根据实际的对象来判断调用函数,而是直接调用当前变量类型的方法

instanceKlass,instanceOopDesc,InstanceMirrorKlass,Class对象

HSDB查看TestLog实例结构 image.png

结论:

  1. jvm在加载class时,通过类的全限定名获取存储该类的class文件,创建instanceKlass,表示其元数据,存放在方法区;并在堆区生成该类的Class对象,即instanceMirrorKlass对象。

  2. 在new一个对象时,jvm创建instanceOopDesc,来表示这个对象,存放在堆区;它用来表示对象的实例信息,看起来像个指针实际上是藏在指针里的对象;instanceOopDesc对应java中的对象实例

  3. HotSpot并不把instanceKlass暴露给Java,而会另外创建对应的java.lang.Class对象(对应InstanceMirrorKlass),并将Class对象称为前者的“Java镜像”

  4. klass持有指向class对象引用(_java_mirror便是该instanceKlass对Class对象的引用),镜像机制被认为是良好的面向对象的反射与元编程设计的重要机制

  5. new操作返回的reference指向堆中的instanceOopDesc,instanceOopDesc里的类型指针指向方法区的instanceKlass,而instanceKlass指向了对应的类型的Class对象;就是TestLog实例(instanceOopDesc)——>TestLog的类型信息(instanceKlass)——>TestLog的Class对象。

image.png

  • JDK8移除了永久代,转而使用元空间来实现方法区,创建的Class实例在java heap中
  • 静态变量引用在JDK6之前是存储在instanceKlass中的。JDK7,静态属性是存在于镜像类instanceMirrorKlass中,即是class对象中。
  • Class对象在第一次加载某类时,那么会在Java堆内存中实例化一个对应的java.lang.Class类的对象,用来访问方法区中的类数据
  • Class对象保存类相关的类型信息

Class对象介绍:深入理解JVM(六)一一运行时数据区(方法区)

Class实例在堆中还是方法区中?

JVM重新认识(一)oop-klass模型--HSDB使用验证

Kclass模型和JVM类加载过程详解

直接内存

  • 不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。

  • 直接内存是在Java堆外的、直接向系统申请的内存区间。

  • 来源于NIO,通过存在堆中的DirectByteBuffer操作Native内存

  • 通常,访问直接内存的速度会优于Java堆。即读写性能高。

    • 因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存。
    • Java的NIO库允许Java程序使用直接内存,用于数据缓冲区
  • 也可能导致OutOfMemoryError异常

  • 由于直接内存在Java堆外,因此它的大小不会直接受限于-Xnx指定的最大堆大小,但是系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存。

  • -XX:MaxDirectMemorySize=大小:设置直接内存;如果不指定,默认与堆的最大值-Xmx参数值一致

  • 缺点

    • 分配回收成本较高
    • 不受JVM内存回收管理

非直接缓冲区

image.png

读写文件,需要与磁盘交互,需要由用户态切换到内核态。这里需要两份内存存储重复数据,效率低。

直接缓冲区

image.png

使用NIO时,操作系统划出的直接缓存区可以被java代码直接访问,只有一份。NIO适合对大文件的读写操作。

优点

  1. 减少垃圾回收,因为垃圾回收会STW
  2. 加快了复制速度。当我们进行IO时,会复制一份数据到堆外内存再发送。直接使用直接内存省略了这一步。
  3. 可以实现进程数据共享,减少JVM间的对象复制,使得JVM的分割部署更容易实现。
  4. 可以扩展内存。

缺点

  1. 难以排查的OOM。
  2. 不适合存储复杂对象,一般来说简单对象比较适合。

直接内存OOM

public class BufferTest2 {
    private static final int BUFFER = 1024 * 1024 * 20;//20MB

    public static void main(String[] args) {
        ArrayList<ByteBuffer> list = new ArrayList<>();

        int count = 0;
        try {
            while(true){
                //直接分配本地内存空间
                ByteBuffer byteBuffer = ByteBuffer.allocateDirect(BUFFER);
                list.add(byteBuffer);
                count++;
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        } finally {
            System.out.println(count);
        }


    }
}

运行参数:-Xmx20m -XX:MaxDirectMemorySize=10m

image.png

Unsafe操作直接内存

public class DirectMemoryTest {
    public static void main(String[] args) throws IllegalAccessException {
        Byte size=1;
        Field unsafeField = Unsafe.class.getDeclaredFields()[0];
        unsafeField.setAccessible(true);
        Unsafe unsafe = (Unsafe)unsafeField.get(null);

        long l = unsafe.allocateMemory(size);
        unsafe.putAddress(l, 100);
        long readValue = unsafe.getAddress(l);
        System.out.println(readValue);//100

    }
}

拾遗-局部变量表槽大小

局部变量表中的槽大小为4字节

boolean,byte,shor,char,int,float 都是占用一个槽4个字节

long,double占用2个槽8字节

java字节码中的大部分指令都没有支持整数类型byte、char和short,甚至没有任何指令支持boolean类型,编译器会在编译期或者运行期将byte和short类型的数据带符号扩展为相应的int类型的数据,将boolean和char类型数据零位扩展为相应的int类型数据,与之类型,在处理boolean、byte、short和char类型的数组时,也会转换为使用对应的int类型的字节码指令来处理,因此,大多数对于boolean、byte、short和char类型数据的操作,实际上都是使用相应的int类型作为运算类型

堆中存储是根据实际大小存储的,栈上局部变量表槽大小固定是为了好操作,指令少。

深入理解JVM系列