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