JVM中创建对象流程学习

421 阅读8分钟

代码如下:

public class Book {
    private Long no;  // 图书的编号
    private String name = "default Name"; //书名
    private String desc; //图书的简介
    private Long readedCnt; // 这本书被读的次数

    public Book() {
    }

    public Book(Long no, String name, String desc, Long readedCnt) {
        this.no = no;
        this.name = name;
        this.desc = desc;
        this.readedCnt = readedCnt;
    }

    public Long getNo() {
        return no;
    }

    public String getName() {
        return name;
    }

    public String getDesc() {
        return desc;
    }

    public Long getReadedCnt() {
        return readedCnt;
    }
}

同时创建了一个接口类和实现类,用来处理Book类相关的事宜。

public interface BookService {
    public void printBookInfo();

    public void printLatestCnt();
}

public class BookServiceImpl implements BookService {
    public void printBookInfo() {
        Book book = new Book();
    }

    public void printLatestCnt() {
        Book book = new Book(1l, "bookName", "bookDesc", 100l);
        System.out.println(book.getReadedCnt());
    }

    public static void main(String[] args) {
        BookService bookService = new BookServiceImpl();
        bookService.printLatestCnt();
    }
}

常规JVM中对象创建

对象创建的字节码

用 javap -c BookService.class 查看 Book book = new Book() 所对应的字节码

 public void printBookInfo();
    Code:
        0: new#2            // class com/evan/Book
        3: dup
        4: invokespecial #3// Method com/evan/Book."<init>":()V
        7: astore_1
        8: return

new #2

当JVM读到这个指令,就会执行我们JVM阶段的对象创建。首先是类加载阶段,JVM会到方法区中的常量池去定位这个类的符号引用,这里就是com/evan/Book。如果能定位到,再去看看这个符号引用对应的类有没有经历完类加载的三部曲:加载、链接、初始化,如果没有就继续完成。

如果不能定位到,就启动类加载流程。这一步的结果是Book.class中的二进制数据被读入到内存里,存到JVM的方法区里,同时在堆上创建了java.lang.Class对象,封装了方法区内Book类的数据结构,从而给我们提供了访问方法区内数据接口的入口。

然后JVM会在堆区中,给Book对象分配一块内存空间,并根据Book中的变量类型对内存进行初始化,初始化为变量类型的默认值,比如Long类型的变量no就会被设置为0,此时Book对象已经完成了JVM层面的创建,并且有了一个内存中的地址。

这一行表示在堆上创建一个新的对象。这里的#2是一个常量池索引,指向常量池中的一个条目,该条目通常包含新创建对象的类型信息。在这个例子中,它指向的是类com/evan/Book的类型描述符。因此,这行代码的作用是创建一个com.evan.Book类型的对象。

dup

这一指令用于复制栈顶的元素。由于上一行代码创建了一个新的Book对象并将其引用压入操作数栈,这条指令会将这个引用复制一份,这样栈顶就有两个相同的对象引用了。

invokespecial #3

这一行执行一个特殊的方法调用(通常是一个实例构造器,即初始化方法,或者是私有方法、父类方法)。#3同样是一个指向常量池的索引,这里它引用的是com/evan/Book类的初始化方法()V。这意味着新创建的Book对象将通过调用其无参数构造函数进行初始化。

这个指令会执行变量初始化、初始化语句块、构造器方法等操作,完成语言层面的初始化,Book对象的书名属性这个时候会被设置成“default Name” 。

astore_1

这行代码将操作数栈顶的元素(即刚被初始化的Book对象的引用)存储到局部变量表的第1个槽位(索引为1的位置),使用astore_1指令。这里a代表引用类型,store表示存储,_1指明是第一个局部变量。

到这里,一个完整的对象就创建完成了,并且这个对象的引用被赋值给本地变量。

对象在JVM中存在的形态

经过了上面的创建流程,Book对象在JVM运行时中的状态是怎样的呢?你可以看一下我给出的图示

对象在内存中大小

按照我们之前讲到的JVM对象协议,Book类对象存储在JVM里,总共由3个部分组成,分别是对象头、实例数据和对齐填充。

对象头部分

通常情况下对象头包含两部分,一部分是mark word,默认为8字节,另一部分是类元数据指针Klass Pointer,4字节或8字节,这取决于JVM启动参数UseCompressedOops的设置,我们下面假设起始值为4字节,所以对象头部的大小默认是12字节。

实例数据部分

Book类有四个字段,分别为Long型的no、String型的name、String型的desc以及Long型的readedCnt。因为这四个字段的类型都是引用类型,占用的内存取决于平台,64位JVM下默认开启压缩指针压缩Oops的占4字节,未开启则占8字节。所以实例数据部分的大小是16字节或32字节。

对齐填充部分

由于JVM自动内存管理系统要求对象起始地址必须是8字节的整数倍,也就是说一个对象的总大小必须是8的倍数,所以可能存在对齐填充。在已经开启压缩Oops的情况下,对象头部占12字节,实例数据占16字节,共28字节。由于必须是8字节的整数倍,所以需要填充4个字节,变成32字节。而在未开启压缩Oops的情况下,对象头部12字节,实例数据32字节,一共44字节,需要填充4字节,变成48字节。

因此,Book类的对象在JVM里占用的大小是32字节或者48字节。对象的大小是我们平时很容易忽视的内容,但你设想一下百万级TPS的系统,如果每秒都需要创建百万级的Book大小,那么仅仅一个指针压缩都能给我们带来巨大的收益,不仅可以降低成本、提升性能,还能有效避免FULL GC,也许这就是我们需要了解JVM底层实现的意义。

JVM中另一种方式创建对象

栈上分配

观察下面的代码,你会发现Book book对象的使用范围并没有超出 printBookInfo() 方法的范围。

    public void printBookInfo() {
        Book book = new Book();
    }

所以这种情况下,可以用栈上分配,通过配置以下JVM参数。

-XX:+DoEscapeAnalysis -XX:+EliminateAllocations -XX:+EliminateLocks

Book对象在栈上就完成了使用和回收,节省了在堆上创建并回收的性能消耗。那这个时候JVM运行时的内存状况又是怎样的呢?你可以看一下示意图。

TLAB

假设需要对图书信息进行初始化,我们决定写一个多线程程序来完成这个任务,每个线程负责一百万册图书,我们计划用10个线程来完成任务。

package com.evn;

import java.util.concurrent.CountDownLatch;

public class BookRecorder implements Runnable {

    private int count;
    private CountDownLatch latch;


    public BookRecorder(int count, CountDownLatch latch) {
        this.count = count;
        this.latch = latch;

    }

    @Override
    public void run() {
        for (int i = 0; i < count; i++) {
            Book book = new Book((long) i, "name" + i, "desc" + i, (long) i);
            // do something with book...
        }
        latch.countDown();
    }

    public static void main(String[] args) {
        int threadCount = 10; // the number of threads
        int bookCount = 1000000; // the number of books each thread will create
        CountDownLatch latch = new CountDownLatch(threadCount);
        long start = System.currentTimeMillis();


        for (int i = 0; i < threadCount; i++) {
            Thread thread = new Thread(new BookRecorder(bookCount, latch));
            thread.start();
        }

        try {
            latch.await();
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }

        // 查看执行时间
        long end = System.currentTimeMillis();
        System.out.println("花费的时间为: " + (end - start) + " ms");

    }
}

这个时候我们发现为了申请到本线程内Book对象的内存空间,CPU在频繁地竞争堆内存空间的分配。这个时候TLAB就排上用场了。

我们可以用-XX:+UseTLAB来启用TLAB,它在JDK8中是默认开启的。开启TLAB后,每个线程在创建对象时都会在自己的TLAB中进行,从而避免了不同线程间的内存分配竞争,提升了系统性能。反之,如果我们没有开启TLAB,那么这10个线程都将在共有的Eden区进行内存分配,会存在较大的竞争和同步开销,系统性能将大大降低。

-Xms60M -Xmx60M -XX:+PrintGCDetails -XX:-UseTLAB
-Xms60M -Xmx60M -XX:+PrintGCDetails -XX:+UseTLAB

耗时差距有好几百倍。

反射创建对象

除了用上面的 Book book = new Book()方式来创建对象, 我们也可以用反射机制来动态创建对象。

Class<?> clazz = Class.forName("com.evan.Book");
Constructor<?> cons = clazz.getConstructor(Long.class, String.class, String.class, Long.class);
Book book = (Book) cons.newInstance(Long.valueOf(1), "Book1", "Desc1", Long.valueOf(1));

这种方式的优势在于它的动态性,在运行时通过判断对象所属的类,我们可以构造任意一个类的对象。但是反射的性能及权限问题,比如一些私有变量和方法没办法直接访问,也是在实际生产环境中需要考虑的。所以这种方式主要用在框架开发的场景,其他场景还是慎重使用。