穿插·对象的实例化与直接内存
😄生命不息,写作不止
🔥 继续踏上学习之路,学之分享笔记
👊 总有一天我也能像各位大佬一样
🌝分享学习心得,欢迎指正,大家一起学习成长!
在Java中,对象实例化是创建一个类的实例的过程。
实例化(instantiate)是指在面向对象的编程中,把用类创建对象的过程称为实例化。是将一个抽象的概念类,具体到该类实物的过程。实例化过程中一般由类名 对象名 = new 类名(参数1,参数2...参数n) 构成。
创建对象的方式
在Java中,创建对象的方式有许多种,简单的概括都有哪些方法。
① 使用new关键字
使用new 来创建方法是最常见的一种创建方式,这种方法会调用构造方法(默认是无参构造方法),如果有自定义构造方法,就会使用自定义构造方法来创建对象。
python
MyClass myObject = new MyClass();
还有一种常见的方式,就是使用类的静态方法,这种方式,实际上在静态方法内部也是使用了new的方式来创建,这里可以是无参构造也可以是自定义的含参构造器。
还有一种也是new方式的变形,就是通过一个专门的工厂类或者静态方法来创建对象。
python
MyClass myObject = MyClassFactory.createMyClass();
② 通过反射机制
使用 Java 的反射机制,可以在运行时获取类的信息并创建对象。但是这种方式只能是使用空参的构造器,权限还必须是public。
python
Class<?> myClass = Class.forName("com.example.MyClass");
MyClass myObject = (MyClass) myClass.newInstance();
在Java9之后就不推荐使用了,可以选择使用Constructor的newInstance(xx)。clazz.getDeclaredConstructor().newInstance()
,这个也是反射的方式,可以调用空参、带参的构造器,对于权限没有要求。
③ 使用克隆的方式
通过实现 Cloneable 接口并覆盖 clone 方法,可以创建对象的副本。这种方式不调用任何构造器。
python
MyClass originalObject = new MyClass();
MyClass clonedObject = (MyClass) originalObject.clone();
④ 反序列化
反序列化是将对象从其序列化形式转换回原始对象的过程。在Java中,对象可以通过序列化将其状态保存到文件或通过网络传输。反序列化则是从这些序列化的数据中重新构建对象。
除了以上方法,还有其他的创建方式,比如三方库等。
创建对象的步骤
我们通过字节码来看一下实例化对象的过程。
对于上图中的Object s = new Object();
,首先我们可以将其分成三个部分来看,第一个部分Object
,这个是存放在方法区中的,也就是存储着类的信息、常量池、静态变量等等;第二部分就是s
,这个是存在Java栈中,这是个引用对象,在栈中的本地变量表中存放对象的引用地址,指向Java的对象实例数据;而第三部分new Object()
,这是对象的实例数据,存在Java堆中。
接下来分析一下字节码
python
// new创建一个对象,#2是常量池中对应 java/lang/Object 类的索引
0 new #2 <java/lang/Object>
// dup是复制栈顶元素。在这里,复制了刚刚创建的新对象的引用。
3 dup
// 调用对象的构造方法。#1 是常量池中对应 <init> 构造方法的索引,V 表示无返回值。这里实际上调用了 java/lang/Object 类的构造方法,对新创建的对象进行初始化。
4 invokespecial #1 <java/lang/Object.<init> : ()V>
// 将栈顶元素(即新创建的对象的引用)存储到本地变量1中。
7 astore_1
// 返回。这里返回的是 void 类型,因为在 Java 中构造方法没有显式的返回值。
8 return
以上是从字节码的过程,接下来用执行过程来介绍,以下分为6个步骤来。
① 判断对象对应的类是否类加载
首先,要实例化一个对象,必须加载其对应的类。类加载是Java程序启动的一部分。当程序首次引用一个类时,类加载器负责加载这个类的字节码到内存中。这就是加载类 。类加载的下一个阶段是链接 。在链接过程中,将为类的静态变量分配存储空间,并且如果存在父类,还会链接到父类。链接阶段将符号引用转化为直接引用。在链接中还有验证、准备、解析 。 在类的初始化阶段,执行类构造器 方法。这是一个特殊的静态方法,由编译器生成,包含类的静态字段的初始化和静态代码块的执行。初始化是按需进行的,即只有在首次实例化类的对象或者首次访问类的静态成员时才会触发。如果一个类有父类,那么会首先初始化父类。
这个部分在之前的文章《类加载子系统与加载过程》就有描述过,这里就是简单复述一下。回归正传,当虚拟机遇到一条new指令的时候,首先就会先去检查这条指令的参数能否在Metaspace的常量池中,定位到一个类的符号引用(就如以上字节码中的0 new #2 <java/lang/Object>
这行字节码,#2就是这个Object对象的符号引用),并且会检查符号引用对应的类是否已经被加载、解析和初始化。如果没有,将会重新类加载,会在双亲委派模式下,使用类加载器去查找对应的.class文件。如果没有找到就会抛出ClassNotFoundException异常。
② 为对象分配内存
一旦类初始化完成,JVM 就会为对象分配内存。内存分配的方式可以是在堆上分配,也可能是栈上分配,具体取决于对象的生命周期和大小。
再分配内存,首先需要计算对象占用的空间大小,接着就是在堆中划分一块内存给新对象,如果实例化成员变量是引用变量,仅分配引用变量空间即可,即4/8个字节大小。
关于字节大小,如果操作系统是64位 boolean:占1个字节、byte:占1个字节 char:占2个字节、short:占2个字节 int:占4个字节、float:占4个字节 long:占8个字节、double:占8个字节 引用变量:占8个字节
对于对象内存的分配,还需要看内存是否规整。
如果内存是规整的,那么JVM将采用的是指针碰撞(Pointer Bumping)[1]来为对象分配内存。
什么是指针碰撞?
指针碰撞(Pointer Bumping)是一种内存分配和垃圾回收的实现方式,通常用于实现可移植的、线程安全的垃圾回收器。它的工作原理是通过移动指针来分配和回收内存。在使用指针碰撞时,整个可用的内存被看作一个大的连续块。实际上意思就是将所有使用过的内存放在一边,空闲的内存存在另一边,中间放着一个指针作为分界点的指示器,分配内存的话,就是把指针向空闲内存挪动一段与对象大小相同的距离。如果垃圾收集器是选择Serial、ParNew这种基于压缩算法,虚拟机采用这种分配方式,一般使用带有compact(整理)过程的收集器是,使用指针碰撞。
然而,如果内存是不规整的,也就是说用过的和没用过的内存相互交错,虚拟机就会采用空闲列表法来为对象分配内存,也就是虚拟机就需要维护一个列表,记录了哪些内存块是可用的,再分配的时候会从列表中去找到一块足够大的空间去划分对象实例,并更新列表上的内容,这种的分配方式就是空闲列表(Free List)。
选择哪种分配方式是由Java堆是否规整决定的,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能来决定。
③ 处理并发安全问题
在JVM创建对象的过程中,是会涉及一些并发安全问题,并且也有一些解决策略。
- 采用CAS失败重试、区域加锁保证原子性
- 类加载是多线程环境下的一个关键问题。JVM使用类加载锁(Class Loading Lock)来保证在同一时刻只有一个线程能够加载一个类。这个锁是全局锁,确保每个类在同一时刻只能被一个线程加载。
- 为每个线程都预分配一块TLAB
- 通过-XX:+/-UserTLAB参数设定
④ 初始化
初始化分配到的空间就是最开始的默认初始化进行默认值设置,这能保证对象实例字段在不赋值时就可以直接使用。
属性赋值的操作有几部分,首先是属性的默认初始化,接着就是显示初始化、代码块中的初始化,然后是由构造器进行初始化。
⑤ 设置对象的对象头
在这一步就会将对象的所属类(元数据信息)、对象的HashCode和对象的GC信息、锁信息等数据存储到对象的对象头中,这个过程的具体设置方式取决于JVM实现。
⑥ 执行init方法进行初始化
在Java程序的视角来看,初始化才是正式开始,初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆内对象的首地址赋值给引用变量。这一部分就是将对象属性进行显示初始化、代码块中的初始化、构造器的初始化。
那么怎样才算对象创建完成呢?实际上,对象的创建从经历了加载类元信息,为对象分配内存,处理并发问题,属性的默认初始化,设置对象头的信息,数据的显示初始化、代码中初始化以及构造器初始化的几个过程之后才算对象已经创建完成,当然,如果说对象在默认初始化完毕就算创建完成也是可以的。
*对象的布局
在Hotspot 虚拟机中,对象在内存中的存储布局,可以分为三个区域:对象头(Object Header)、实例数据(Instance Data)、对齐填充(Padding)。在对象头中包含了标记字(mark word)、类指针(klass word)和 数组长度(array length)。synchronized主要是跟对象头有关系,也就是通过mark word的字节位数来表示各种锁状态。
关于锁的知识点之前在《【多线程与高并发】- synchronized锁的认知》就已经介绍过了。
1). 对象头(Object Header)
在HotSpot虚拟机中,Java对象的头部包含了一些用于管理对象的元数据信息。其中主要是包含以下两个部分。
- Mark Word(标记字): 占用 8 字节,包含了对象的一些状态信息,比如哈希值、锁状态标志、线程持有的锁、线程ID、偏向锁时间戳、GC 分代年龄等。Mark Word 的内容在对象的生命周期中可能会发生变化。
- klass word(类型指针):Class对象的类型指针, 占用 4 字节或 8 字节,Jdk1.8默认开启指针压缩后为4字节,关闭指针压缩(-XX:-UseCompressedOops)后,长度为8字节1。其指向的位置是对象对应的Class对象(其对应的元数据对象)的内存地址1。
如果是数组的话,还需要记录数组的长度。
2). 实例数据(Instance Data)
这是对象真正存储的有效信息,包括程序代码中定义的各种类型的字段(也包括了继承父类的和自身的字段)。如果存储的数组对象,会包含一个用于记录数组长度的字段以及存储了实际数组元素的部分。JVM会根据对象的布局灵活地调整内存布局,减小对象头和实例数据的总体大小,从而降低内存占用。对于相同宽度的字段会被分配到一起,父类定义的变量会出现在子类之前,如果CompactFields参数为true(也是默认值),子类的窄变量可能插入父类的变量空隙。
实例数据主要包括对象的各种成员变量,包括基本类型和引用类型。基本类型直接存储内容,引用类型则是存储的指针,static类型的变量会放到类中,而不是放到实例数据里。
3). 对齐填充(Padding)
对齐填充并不是必须的,这是一种优化的概念,是作为一种占位符的作用。主要是为了满足硬件对齐的要求,提高访问效率和性能。通过插入一些额外的字节,使得对象的起始地址符合特定的对齐要求。这样可以确保对象的实例数据按照硬件对齐规则排列,从而提高内存访问的效率。
案例理解
这里我准备一个案例,在Customer类中设置了几个变量,并继承父类User,其中有一个引用变量Account类。通过Start#main()来实现本次的案例。
java
// 父类
public class User {
String username;
}
// 子类
public class Customer extends User {
int id = 1;
String name;
Account account;
{
name = "默认客户";
}
public Customer() {
account = new Account();
}
}
// 引用的类
public class Account {
int ids;
double money;
String tab;
}
public class Start {
public static void main(String[] args) {
Customer customer = new Customer();
/*java对象的内存布局以及使用ClassLayout查看布局*/
System.out.println("Customer:" + ClassLayout.parseInstance(customer).toPrintable());
}
}
这里我引入了ClassLayout来查看Java对象内存布局的,需要引入以下坐标。
xml
<!-- java对象的内存布局以及使用ClassLayout查看布局 -->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>
通过运行 Start#main() 可以清晰看到所占用的字节信息,对于Customer对象,继承了User类,在创建实例的时候会把父类的属性也一并加载过来。首先,对象头是固定的8(标记字)+4(类型指针)字节,实例数据由于会加载父类属性,一共是16字节(对于引用类型,JVM是64位应该是占8字节,但是采用了指针压缩,所以只占了4字节),8+4+16=28字节,不满足对齐要求,因此还需要引入对齐填充占4个字节,一共就是32字节。
java
Customer:com.lyd.testboot.jvm.objectdemo.Customer object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0xf800c182
12 4 java.lang.String User.username null
16 4 int Customer.id 1
20 4 java.lang.String Customer.name (object)
24 4 com.lyd.testboot.jvm.objectdemo.Account Customer.account (object)
28 4 (object alignment gap)
Instance size: 32 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
从以上打印出来的日志能够看到实例中包含了父类与本类的属性以及所占字节数。下图就来介绍整个对象的实例过程。
每个方法就是一个栈帧,当我们运行Start#main(),因为是main方法,在栈帧中的局部变量表中会先加载args,里面Customer customer = new Customer();
会将customer
记录到栈帧的局部变量表中,通过地址指针指向了堆中的整个实例对象。new Customer()
这个实例的内部结构在堆空间记录了对象头、实例数据、填充字节。在该实例的对象头部中,记录了标记字(主要用与GC、线程安全等),通过类型指针指向方法区中对应Customer的klass类元信息。在实例数据中,会加载父类的属性以及本身属性,Customer类中引用了String对象,这是个引用类型,通过静态代码块赋值,这个字符串存在自负床变量池中。其中也引用了Account对象,这也是个引用类型,并不会将此对象属性都加载进来,只是记录了这个对象的引用地址,指向堆空间中的new Account()
实例,在Account实例中,也是与Customer实例一样,包含了对象头等信息,也是通过类型指针指向方法区Account的klass类元信息。
对象的访问定位
从上文介绍对象的布局就已经能够知道对象是如何获取对象实例的。对于一个Customer customer = new Customer();
我们知道其存储为三个部分,在栈帧中存储了堆区中的引用地址(reference),在堆区有个元数据指针(InstanceOopDesc)指向方法区中的InstanceKlass。总的来说,就是栈帧中记录着堆区实例化的对象地址,通过这个地址来方法对象实例。
对象访问的两种方式
对象的访问方式主要有两种方式,句柄访问和指针访问,在hotspot虚拟机中采用的是指针访问的方式。
1). 句柄访问
有些 JVM 实现可能使用了句柄访问的概念,其中对象的引用由一个句柄对象来管理,而句柄包含了对象的地址以及其他元信息。在Java堆中会开辟一个句柄池与实例池,句柄池中会通过指针指向实例对象和对象类型。
因为采用的是句柄池指向对象实例数据,在reference中存储的是稳定的句柄地址,对象如果被移动(垃圾收集时候会移动对象)只会改变句柄的实例数据指针就行,reference本身是不用做修改。但是需要开辟一个空间来充当句柄池,这就会增加堆内存的空间占用。
2). 指针访问(Hotspot采用此方法)
在栈帧中的本地变量表中记录了堆中对象实例数据的地址,通过地址引用到堆中的对象实例数据,实例对象在通过类型指针指向方法区中的类元信息。
指针方式不用额外占用堆的空间,但是如果遇到对象移动,就需要去修改reference存储的地址。
直接内存(Direct Memory)
直接内存不是JVM运行时数据区的一个部分,也不是《Java虚拟机规范》中定义的内存区域,它是Java堆外的、直接向系统获取的内存空间。
直接内存概述
在Java虚拟机(JVM)中,"直接内存" 通常指的是使用 NIO(New I/O | Non-Blocking I/O)包中的 ByteBuffer 类,以及其中的 allocateDirect 方法所分配的直接字节缓冲区(Direct ByteBuffer)。通过DirectByteBuffer操作Native内存。
java
public class BufferTest {
private static final int BUFFER_SIZE = 1024 * 1024 * 1024;
public static void main(String[] args) {
// byteBuffer 将持有一个大小为 1 GB 的直接内存缓冲区
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(BUFFER_SIZE);
System.out.println("内存分配:" + BUFFER_SIZE + "byte");
Scanner sc = new Scanner(System.in);
sc.next();
System.out.println("释放内存");
byteBuffer = null;
System.gc();
}
}
以上代码,我们可以创建出一个内存为1G的直接内存,这里通过Scanner输入进行阻塞,我们可以通过进程查看所占用的内存大小。可见使用ByteBuffer#allocateDirect()会直接分配本地内存。
直接内存的性能
通常,直接内存的速度会比Java堆更快,读写性能高。在一些频繁使用IO的场景,可能会考虑使用直接内存。Java的NIO包是允许程序使用直接内存,用来作为数据缓冲区。
对于非直接缓冲区,读写文件需要与磁盘交互,这时就需要由用户态切换到内核态,在由内核态去对物理磁盘进行交互。这样就造成了需要进行两份内存的存储重复数据,效率低。
对于直接缓冲区,使用NIO就能够直接的使用操作系统给出的缓存区,只会存储一份。
直接内存的OOM
直接内存也有可能导致OutOfMemoryError异常。因为直接内存是存在Java堆外的,他的大小不会受限于-Xmx设置的最大堆空间,系统内存是有限的,Java堆和直接内存加起来不能超过操作系统给的最大内存。它是受限于操作系统对进程的可用虚拟内存空间。
如果是超过内存的限制,就会抛出java.lang.OutOfMemoryError: Direct buffer memory
。
缺点:
- 分配回收成本比较高
- 不受JVM的内存回收管理
直接内存大小可以通过MaxDirectMemorySize设置,如果不指定,默认是与堆的最大值-Xmx参数值一致。
总结
本次学习穿插了Java对象的内存布局,更加清楚了解到对象的创建方式以及过程,最为重要的是了解对象的布局结构,包括实例对象数据存放在堆中,类元信息在方法区,栈帧通过引用去指向对应的数据信息。对比了句柄方式和指针方式。最后学习了直接内存的内容,了解了直接内存也是会出现OOM异常。
👍创作不易,如有错误请指正,感谢观看!记得点赞哦!👍