Java教程

八、探秘HotSpot虚拟机对象与直接内存

本文主要是介绍八、探秘HotSpot虚拟机对象与直接内存,对大家解决编程问题具有一定的参考价值,需要的程序猿们随着小编来一起学习吧!

一、对象的创建

1、创建对象的几种方式

  • new:最常见的方式、单例类中调用getInstance的静态类方法,XXXFactory的静态方法。
  • Class的newInstance方法:反射的方式,在JDK9中被标记为过时的方法,因为只能调用空参的构造方法,权限必须是public。
  • Constructor的newInstance(xxx):反射的方式,可以调用空参、有参的构造器,任意权限。
  • clone():不调用任何构造器,当前类需要实现Cloneable接口,实现clone()方法
  • 反序列化:从文件中、网络中获取一个对象的二进制流。
  • 第三方库Objenesis

2、从字节码角度看对象创建过程

public class ObjectTest {
    public static void main(String[] args) {
        Object o = new Object();
    }
}
编译之后通过javap -c -p ObjectTest.class查看方法对应的字节码,-v:会输出所有字节码信息
查看main()对应的字节码,首先调用new指令后找到#2位置字符串常量池,找到了Object类,首先判断方法区中是否将类加载了,如果没有加载需要使用类加载器将类进行加载。然后调用Object类的init()方法。
Classfile /D:/IdeaProbject/JVM/target/classes/day5/ObjectTest.class
  Last modified 2021-9-11; size 430 bytes
  MD5 checksum 5eb0fa7172fbe9c882683b37b9a32a50
  Compiled from "ObjectTest.java"
public class day5.ObjectTest
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #2.#19         // java/lang/Object."<init>":()V
   #2 = Class              #20            // java/lang/Object
   #3 = Class              #21            // day5/ObjectTest
   #4 = Utf8               <init>
   #5 = Utf8               ()V
   #6 = Utf8               Code
   #7 = Utf8               LineNumberTable
   #8 = Utf8               LocalVariableTable
   #9 = Utf8               this
  #10 = Utf8               Lday5/ObjectTest;
  #11 = Utf8               main
  #12 = Utf8               ([Ljava/lang/String;)V
  #13 = Utf8               args
  #14 = Utf8               [Ljava/lang/String;
  #15 = Utf8               o
  #16 = Utf8               Ljava/lang/Object;
  #17 = Utf8               SourceFile
  #18 = Utf8               ObjectTest.java
  #19 = NameAndType        #4:#5          // "<init>":()V
  #20 = Utf8               java/lang/Object
  #21 = Utf8               day5/ObjectTest
{
  public day5.ObjectTest();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 7: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lday5/ObjectTest;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: new           #2                  // class java/lang/Object
         3: dup
         4: invokespecial #1                  // Method java/lang/Object."<init>":()V
         7: astore_1
         8: return
      LineNumberTable:
        line 9: 0
        line 10: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  args   [Ljava/lang/String;
            8       1     1     o   Ljava/lang/Object;
}

3、判断对象对应的类是否加载、链接、初始化

1、当Java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在Matespace的常量池中定位到 一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。
2、如果没有,那么在双亲委派模式下,使用当前类加载器以ClassLoader+包名+类名作为Key进行查找定位到对应的.class文件。如果没有找到class文件,则抛出ClassNotFoundException异常;如果找到,则进行类加载,并生成对应的Class类对象。

4、为新生对象分配内存空间

1、对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务实际上等同于把一块确定大小的内存块从Java堆中划分出来。如果实例成员变量是引用变量,仅分配引用变量空间即可,即4个字节大小。
2、Java堆中内存是绝对规整的:采用指针碰撞(Bump The Pointer)方式分配内存
  • 所有被使用过的内存都被放在一 边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离
3、Java堆中内存并不是规整的:采用空闲列表(Free List)方式分配内存
  • 已被使用的内存和空闲的内存相互交错在一起,就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。
4、分配方式的选择由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理(Compact)的能力决定
  • 当使用SerialParNew带压缩整理过程的收集器时,系统采用的分配算法是指针碰撞,既简单又高效;
  • 当使用CMS这种基于清除(Sweep)算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存。
5、标记清除算法与标记压缩算法区别
  • 标记清除算法:GC之后就会出现很多内存碎片。
  • 标记压缩算法:GC之后会整理内存碎片。

5、处理并发问题

1、对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。
2、两种解决方式
  • 对分配内存空间的动作进行同步处理——实际上是采用CAS配上失败重试的方式保证更新操作的原子性。
  • 把内存分配的动作按照线程划分在不同的空间中进行,在Eden区给每个线程预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,简称TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。可以通过-XX:+/-UseTLAB参数来设定是否使用TLAB

6、初始化分配到的内存空间

内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。

7、设置对象的对象头

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

8、执行init方法进行初始化

在上面步骤都完成之后,从虚拟机的角度来看,一个新的对象已经产生了。但是从Java程序角度来看,对象创建才刚刚开始——构造函数,即Class文件中的<init>()方法还没有执行,所有的字段都为默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。一般来说(由字节码流中new指令后面是否跟随invokespecial指令所决定,Java编译器会在遇到new关键字的地方同时生成这两条字节码指令,但如果直接通过其他方式产生的则不一定如此),new指令之后会接着执行<init>() 方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。
/**
 * 测试对象创建过程:
 * 1、加载类元信息;2、为对象分配内存;3、处理并发问题;4、属性的默认初始化(零值初始化)
 * 5、设置对象头信息;6、属性的显式初始化、代码块中初始化、构造器中初始化
 * 给对象属性赋值操作:
 * 1、属性的默认初始化
 * 2、显式初始化/代码块中初始化(并列关系,谁先谁后看代码编写的顺序)
 * 3、构造器初始化
 */
public class CreateObject {
    int id = 1001;
    String name;
    Users users;

    {
        name = "匿名用户";
    }
    public CreateObject() {
        users = new Users();
    }
}
1、属性的默认值初始化:id=1001
2、显式初始化/代码块初始化:neme=“匿名用户”
3、构造初始化:users=new Users()

在这里插入图片描述

二、对象的内存布局

1、概述

在HotSpot虚拟机里,对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例 数据(Instance Data)和对齐填充(Padding)。

2、对象头(Header)

HotSpot虚拟机对象的对象头部分包括两类信息:运行时元数据(Mark Word)与类型指针
运行时元数据(Mark Word):
  • 用于存储自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分的数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32个比特和64个比特,称它为“Mark Word”。
  • 对象需要存储的运行时数据很多,其实已经超出了32、64位Bitmap结构所能记录的最大限度,但对象头里的信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word被设计成一个有着动态定义的数据结构,以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。
例如在32位的HotSpot虚拟机中,如对象未被同步锁锁定的状态下,Mark Word的32个比特存储空间中的25个比特用于存储对象哈希码,4个比特用于存储对象分代年龄,2个比特用于存储锁标志位,1个比特固定为0,在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)下对象的存储内容如下图所示

在这里插入图片描述

64位Mark Word存储内容如下:

在这里插入图片描述

类型指针:
  • 对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身。
  • 此外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小。

3、实例数据(Instance Data)

它是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。
这部分的存储顺序会受到虚拟机分配策略参数(-XX:FieldsAllocationStyle参数)和字段在Java源码中定义顺序的影响。
HotSpot虚拟机默认的分配顺序为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers,OOPs),从以上默认的分配策略中可以看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。如果HotSpot虚拟机的+XX:CompactFields参数值为true(默认就为true)那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点点空间。

4、对象填充(Padding)

这并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数(1倍或者2倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全

5、图解内存布局

public class CreateObject {
    int id = 1001;
    String name;
    Users users;

    {
        name = "匿名用户";
    }
    public CreateObject() {
        users = new Users();
    }
}

public class Users {
}

public class CreateObjTest {
    public static void main(String[] args) {
        CreateObject create = new CreateObject();
    }
}
内存布局图解

在这里插入图片描述

三、对象的访问定位

1、概述

创建对象自然是为了后续使用该对象,Java程序会通过栈上的reference数据来操作堆上的具 体对象。由于reference类型在《Java虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实现而定的,主流的访问方式主要有使用句柄直接指针两种。

2、句柄方式访问

句柄访问就是说栈的局部变量表中,记录的对象的引用,Java堆中将可能会划分出一块内存来作为句柄池reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息
优点:
  • reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改。
缺点:
  • 需要单独开辟空间存储句柄,有点浪费空间。
  • 访问对象需要先通过引用找到句柄,再通过句柄中的变量找到对象实体,访问效率比较低。

在这里插入图片描述

3、直接指针方式访问(HotSpot采用)

直接指针是局部变量表中reference中存储的直接就是对象地址,直接指向堆中的实例,在对象实例中有类型指针,指向的是方法区中的对象类型数据。
优点:
  • 访问速度更快,它节省了一次指针定位的时间开销,由于对象访问在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本。

在这里插入图片描述

四、直接内存(Direct Memory)

1、概述

不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。
直接内存是在Java堆外的、直接向系统申请的内存区间。
来源于NIO,通过存在堆中的DirectByteBuffer操作Native内存
通常,访问直接内存的速度会优于Java堆。即读写性能高。
  • 因此出于性能考虑,读写频繁的场合可能会考虑使用直接内存。
  • Java的NIO库允许Java程序使用直接内存,用于数据缓冲区
/**
 * 直接内存的占用与释放
 */
public class BufferTest {
    private static final int BUFFER = 1024 * 1024 * 1024;//1GB

    public static void main(String[] args){
        //直接分配本地内存空间
        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(BUFFER);
        System.out.println("直接内存分配完毕,请求指示!");

        Scanner scanner = new Scanner(System.in);
        scanner.next();

        System.out.println("直接内存开始释放!");
        byteBuffer = null;
        System.gc();
        scanner.next();
    }
}

2、非直接缓存区

原来采用BIO的架构,在读写本地文件时,需要与磁盘交互,需要由用户态切换到内核态。这里需要两份内存存储重复数据,效率低。

在这里插入图片描述

3、直接缓冲区

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

在这里插入图片描述

4、分别使用NIO和BIO复制大文件

import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

/**
 * 分别使用NIO和BIO复制大文件
 */
public class CopyFileWithNio {
    private static final String TO = "D:\\Project\\lifecycle-lib.zip";
    private static final int _100Mb = 1024 * 1024 * 100;

    public static void main(String[] args) {
        long sum = 0;
        String src = "D:\\Project\\lifecycle-lib.zip";
        for (int i = 0; i < 3; i++) {
            String dest = "D:\\lifecycle-lib_" + i + ".zip";
            //使用传统方式复制文件
            // sum += io(src,dest);//1673
            //使用NIO方式复制文件
            sum += directBuffer(src, dest);//957
        }
        System.out.println("总花费的时间为:" + sum);
    }

    /**
     * 使用NIO方式
     * @param src
     * @param dest
     * @return
     */
    private static long directBuffer(String src, String dest) {
        long start = System.currentTimeMillis();
        FileChannel inChannel = null;
        FileChannel outChannel = null;
        try {
            inChannel = new FileInputStream(src).getChannel();
            outChannel = new FileOutputStream(dest).getChannel();
            ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_100Mb);
            while (inChannel.read(byteBuffer) != -1) {
                //修改为读数据模式
                byteBuffer.flip();
                outChannel.write(byteBuffer);
                //清空
                byteBuffer.clear();
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (inChannel != null) {
                try {
                    inChannel.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            if (outChannel != null) {
                try {
                    outChannel.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        long end = System.currentTimeMillis();
        return end - start;
    }

    /**
     * 使用传统方式
     * @param src
     * @param dest
     * @return
     */
    private static long io(String src, String dest) {
        long start = System.currentTimeMillis();
        FileInputStream fis = null;
        FileOutputStream fos = null;
        try {
            fis = new FileInputStream(src);
            fos = new FileOutputStream(dest);
            byte[] buffer = new byte[_100Mb];
            while (true) {
                int len = fis.read(buffer);
                if (len == -1) {
                    break;
                }
                fos.write(buffer, 0, len);
            }
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (fis != null) {
                try {
                    fis.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            if (fos != null) {
                try {
                    fos.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
        long end = System.currentTimeMillis();
        return end - start;
    }
}

5、直接内存与OOM

直接内存也可能导致OutofMemoryError异常
由于直接内存在Java堆外,因此它的大小不会直接受限于-Xmx指定的最大堆大小,但是系统内存是有限的,Java堆和直接内存的总和依然受限于操作系统能给出的最大内存
缺点:
  • 分配回收成本较高
  • 不受JVM内存回收管理
直接内存大小可以通过-XX:MaxDirectMemorySize设置,如果不指定,默认与堆的最大值-Xmx参数值一致
/**
 * 测试本地内存的OOM
 * java.lang.OutOfMemoryError: Direct buffer memory
 */
public class DirectOOM {
    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);
        }
    }
}

6、通过Unsafe类直接申请本地内存

ByteBuffer.allocateDirect()方法中new DirectByteBuffer()中使用Unsafe类分配本地内存
/**
 * -Xmx20m -XX:MaxDirectMemorySize=10m
 * 设置最大堆内存20M,直接内存10M
 */
public class MaxDirectMemorySizeTest {
    private static final long _1MB = 1024 * 1024;

    public static void main(String[] args) throws IllegalAccessException {
        Field unsafeField = Unsafe.class.getDeclaredFields()[0];
        unsafeField.setAccessible(true);
        Unsafe unsafe = (Unsafe) unsafeField.get(null);
        while(true){
            unsafe.allocateMemory(_1MB);
        }
    }
}
这篇关于八、探秘HotSpot虚拟机对象与直接内存的文章就介绍到这儿,希望我们推荐的文章对大家有所帮助,也希望大家多多支持为之网!