JVM系列整体栏目
内容 | 链接地址 |
---|---|
【一】初识虚拟机与java虚拟机 | https://blog.csdn.net/zhenghuishengq/article/details/129544460 |
【二】jvm的类加载子系统以及jclasslib的基本使用 | https://blog.csdn.net/zhenghuishengq/article/details/129610963 |
【三】运行时私有区域之虚拟机栈、程序计数器、本地方法栈 | https://blog.csdn.net/zhenghuishengq/article/details/129684076 |
【四】运行时数据区共享区域之堆、逃逸分析 | https://blog.csdn.net/zhenghuishengq/article/details/129796509 |
【五】运行时数据区共享区域之方法区、常量池 | https://blog.csdn.net/zhenghuishengq/article/details/129958466 |
【六】对象实例化、内存布局和访问定位 | https://blog.csdn.net/zhenghuishengq/article/details/130057210 |
对象实例化、内存布局和访问定位
一,对象实例化、内存布局和访问定位
1,对象的实例化
创建对象的方式和创建对象的步骤主要有以下几种方式
1.1,创建对象的几种方式
在日常开发中,创建对象的方式主要有以下几种:
- 最常见的方式:new 加构造器,如果构造器私有,可以通过静态访问,如单例模式,或者通过工厂加载
//new 构造器 创建对象
Object object = new Object();
//构造器静态私有,如典型的单例模式
Object object = Object.getObject();
//工厂加载,SpringBean,SqlSessionBean
Object object = ObjectFactory.getObject();
- 反射的方式:类的newInstance或者构造器的newInstance·
public class Invoke {
public static void main(String[] args) {
try {
Class<?> clazz1 = Class.forName("com.tky.jvm.Invoke");
//通过类构造器获取对象
Constructor<?> constructor = clazz1.getConstructor();
Invoke invoke1 = (Invoke)constructor.newInstance();
//通过类名获取
Class<Invoke> clazz2 = Invoke.class;
Invoke invoke2 = clazz2.newInstance();
//通过对象获取
Invoke in = new Invoke();
Class<? extends Invoke> clazz3 = in.getClass();
Invoke invoke3 = clazz3.newInstance();
} catch (Exception e) {
e.printStackTrace();
}
}
}
- 克隆的方式:clone的方式,不调用任何构造器,当前类需要实现Cloneable接口以及clone方法
/**
* @author zhenghuisheng
* @date : 2023/4/10
*/
@Data
public class Clone implements Cloneable {
private Long id;
private String username;
private String password;
@Override
protected Clone clone() throws CloneNotSupportedException {
return (Clone)super.clone();
}
}
class TestClone{
public static void main(String[] args) {
Clone clone1 = new Clone();
clone1.setId(1L);
clone1.setUsername("zhenghuisheng");
clone1.setUsername("123456");
try {
Clone clone2 = clone1.clone();
System.out.println(clone2.getId());
} catch (Exception e) {
e.printStackTrace();
}
}
}
- 反序列化的方式:从文件或者网络中获取二进制流,将二进制流转换成对象
//对象序列化
Student s = new Student("1","zhenghuisheng","18");
ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("d:/a.txt"));
objectOutputStream.writeObject(s);
objectOutputStream.close();
//对象反序列化
ObjectInputStream ois = new ObjectInputStream(new FileInputStream("d:/a.txt"));
Student student = (Student) inputStream.readObject();
- 第三方库Objenesis
//构建 Objenesis 对象 Objenesis需要对应的pom依赖对象
Objenesis objenesis = new ObjenesisStd();
ObjectInstantiator<Student> instantiator = objenesis.getInstantiatorOf(Student.class);
Student student = instantiator.newInstance();
1.2,对象创建的步骤
这里主要从执行的角度来分析这个对象创建的步骤,如上图所示,主要分为六个步骤来创建对象
1.2.1,判断对象对应的类是否加载,验证,准备,解析和初始化
虚拟机在遇到一条new指令的时候,首先会去检查这个指令的参数是否能在元空间的常量池中定位到一个类的符号,并且检查这个类是否经历过了加载、验证、准备、解析和初始化这个几个步骤。如果没有,那么类加载器还在双亲委派的模式下,使用当前类加载器的以 ClassLoader + package + class
为key进行查找对应的.class文件,如果没有找到对应的文件,则会抛出 ClassNotFoundException
异常,如果找到,则进行类加载,并生成对应的Class对象
1.2.2,为对象开辟空间,分配内存
首先需要计算对象占用空间的大小,接着在堆中划分一块内存给新对象,如果实例成员变量时引用变量,那么仅分配引用变量空间即可,即四个字节大小。如根据不同的基本数据类型其所占用的字节数,从而得知每个变量占多大的空间,最后将这些变量所需要的空间全部叠加在一起,得到的就是这个总空间的字节数。
而内存如果是规整的,那么虚拟机将采用的是 指针碰撞 的方式来为对象分配内存。如下图,就是将用过的内存放在一边,空闲的内存放在另外一边,中间放着一个分界点的指示器,内存分配就是将指针向空闲那边挪动,挪动的距离就是对象所需要的大小 ,而指针碰撞这种方式,取决于虚拟机的垃圾回收算法是否具有压缩功能。
如果内存内部不是规整的,虚拟机内部就得维护一个列表来管理已使用的内存和未使用的内存,其方式被称为 空闲列表 。如下图,虚拟机内部维护了一张表,记录的是哪块内存时可用的,哪块内存时不可用的,然后在分配的时候,就从列表中找到一块足够打的空间划分给对象实例,并更新表上的内容。
1.2.3,处理并发问题
由于对象是在堆中创建,而堆又是共享区域,因此避免不了会出现这个并发的问题,而在堆内部,主要采用了两种方式来保证实例的安全性。
一种是采用CAS比较与交换的方式,失败则重试,区域加锁来保证更新的原子性,另一种是 每个线程预先分配一个 TLAB。主要是通过这两种方式来解决并发安全的问题。
1.2.4,对象初始赋值
这里进行一个默认的初始化。这样所有属性都有一个默认值,保证对象实例字段在不赋值时就可以使用。因此在方法内部,静态变量在准备阶段就进行了初始赋值,实例变量在分配空间的时候也进行了初始赋值,因此这两个可以直接使用变量,其他的变量如果没有进行显示的初始化,那么会出现直接编译失败的情况。
1.2.5,设置对象的对象头
将对象所属的类、对象的hashCode、GC信息、年龄、锁信息等存储在对象的对象头中。
1.2.6,执行init方法进行初始化
这里就行一个显示初始化,初始化工作才正式开始。初始化成员变量,执行实例化代码块,调用类的构造方法,并把堆对象的首地址赋值给引用对象。因此一般来说,new 指令之后会接着就是执行方法,将对象按照程序员的意愿进行初始化,这样真正可用的对象才算完整的创建出来。
2,对象的内存布局
对象的内存布局中,主要包括对象头、实例数据和对其填充
2.1,对象头(Header)
在对象头中,又可以分为两部分,一部分是运行时的元数据,另一部分就是类型指针。
运行时元数据包括哈希码、GC年龄分代、线程持有的锁、持有锁标志、线程id、线程时间戳。
由下图可知,在对象的年龄分代为4bit,因此最大为1111,即15,又由于是从0开始,因此其最大年龄为15,所以在设置这个年龄的时候,只能往小设置。锁的标志位对应的字节码用01、10、11表示,其值分别对应着1、2、3,并且这段锁升级的过程是不可逆的。
而类型指针指向的是元数据InstanceKlass,确定该对象所属的类型。如果是数组,还需要记录数组的长度
2.2,实例数据(Instance Data)
对象真正存储的有效信息,包括代码中定义的各种类型的字段,以及父类继承下来的和本身拥有的字段 。并且在这些对象中,父类定义的变量会出现在子类之前,并且相同的字段总是会被分配在一起,如果CompactFields参数为true,子类的窄变量可能插入到父类变量的空隙。
2.3,代码示例
接下来分析一下以下这段代码
/**
* @author zhenghuisheng
* @date : 2023/4/7
*/
public class Customer {
Integer id = 1001;
String name = "zhenghuisheng";
public Customer(){
Account account = new Account();
}
}
public class Test{
public static void main(String[] args){
Customer cust = new Customer();
}
}
然后其对应的内存结构如下图所示,在这个main方法中,由于是静态方法,因此局部变量表的第一个slot不是this,而局部变量表中的第二个cust是引用着堆中 new Customer()的实例地址,该实例对象中,主要就是上面的运行时元数据、类型指针、对其填充等组成。运行时数据区就包括唯一地址哈希值、结果多次GC后的年龄、是否获得锁等标志;类型指针对应的就是Customer的Klass类元信息;实例数据就包括自身的属性以及父类属性
3,对象的访问定位
创建对象主要是为了更好的去使用他,JVM内部主要是通过两种方式实现对象引用访问到内部对象的,一种是直接指针,一种是句柄访问。
如下代码所示,一个创建对象需要涉及到堆,栈和方法区,因此对象的定位以及访问也需要设计这三个地方
//第一个User存在方法区,主要是存储类信息和运行时常量池
//第二个user在栈中,作为变量存储
//最后的 new User存储在堆中
User user = new User();
句柄访问 的方式如下,在Java堆中有一个句柄池,然后句柄池中保存指向堆中的实例的地址和指向方法区中保存类信息的地址,而在栈中只需保存句柄池的地址即可。
直接指针 就是不需要使用句柄池,在栈的局部变量表中直接保存堆中实例的地址,而在堆中会有一个指针去指向方法区中保留类信息的地址。在Hotspot虚拟机中,主要采用的是这种方式
句柄指针需要在堆空间中开辟空间存储句柄池,因此会有一定的空间浪费,并且效率相对较低,但是如果出现对象的位置发生改变,如出现垃圾回收的情况,或者使用标记整理算法的时候 ,这个栈中指向堆中的句柄池的指针可以不用发生改变,只需改变句柄池只向实例数据和方法区的指针。而这个直接指针的优缺点就是就和句柄指针相反。
4,直接内存初体验(了解)
在JDK8中,方法区的具体实现从永久代变成了元空间,而元空间使用的是本地内存,又名直接内存,这部分不属于运行时数据区的一部分,也不是《java虚拟机规范》中定义的内存区域
在java代码中,可以直接通过这个 ByteBuffer.allocateDirect()
来进行本地内存空间的分配, 即直接通过这个NIO来进行操作,并且在通常直接内存的速度会直接优于Java堆,其读写性能相对较高。因此处于性能考虑,读写频繁的场合可以使用直接内存,并且Java的NIO库,也允许Java程序使用直接内存。
也可能会出现 OutOfMemoryError
异常,由于直接内存在Java堆之外,因此其大小不会受限于 -Xmx 指定的最大堆大小,但又由于系统的内存始终是有限的,因此堆和直接内存的总和依然受限于操作系统给出的最大内存,但是在直接内存中,也存在一定的缺点:分配回收成本较高,并且不受JVM内存回收管理。
因此可以直接内存可以通过 MaxDirectMemorySize
进行大小的设置,如果未指定,那么默认和堆的最大值 -Xmx
参数值一致
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(100 * 1024);