内存是程序员逃不开的话题,固然Java由于有GC使得咱们不用手动申请和释放内存,可是了解Java内存分配是作内存优化的基础,若是不了解Java内存分配的知识,可能会带偏咱们内存优化的方向。因此这篇文章咱们以“一个对象占多少内存”为引子来谈谈Java内存分配。 文章基于JDK版本:1.8.0_191java
文章标题提出的问题是”一个对象到底占多少内存“,看似很简单,但想说清楚并不容易,但愿本文的探讨能让你有收获。ios
在开始以前我仍是决定先提一个曾经阴魂不散,困扰我好久的问题,了解这个问题的答案有助于咱们理解接下来的内容。程序员
想解答这个问题,须要从字节码入手,还须要咱们了解一些Java虚拟机规范的知识, 来看一个简单的例子数组
public class Apple extends Fruit{
private int color;
private String name;
private Apple brother;
private long create_time;
public void test() {
int color = this.color;
String name = this.name;
Apple brother = this.brother;
long create_time = this.create_time;
}
}
复制代码
很简单的一个Apple类,继承于Fruit,有一个test方法,将类成员变量赋值给方法本地变量,仍是老套路,javac,javap 查看字节码安全
javac Fruit.java Apple.java
javap -verbose Apple.class
// 输出Apple字节码
public class com.company.alloc.Apple extends com.company.alloc.Fruit
minor version: 0
major version: 52
flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
#1 = Methodref #9.#25 // com/company/alloc/Fruit."<init>":()V
#2 = Fieldref #8.#26 // com/company/alloc/Apple.color:I
#3 = Fieldref #8.#27 // com/company/alloc/Apple.name:Ljava/lang/String;
#4 = Fieldref #8.#28 // com/company/alloc/Apple.brother:Lcom/company/alloc/Apple;
#5 = Fieldref #8.#29 // com/company/alloc/Apple.create_time:J
// 省略......
{
// 省略......
public void test();
descriptor: ()V
flags: ACC_PUBLIC
Code:
stack=4, locals=6, args_size=1
0: aload_0
1: getfield #2 // Field color:I
4: iconst_1
5: iadd
6: istore_1
7: aload_0
8: getfield #3 // Field name:Ljava/lang/String;
11: astore_2
12: aload_0
13: getfield #4 // Field brother:Lcom/company/alloc/Apple;
16: astore_3
17: aload_0
18: getfield #5 // Field create_time:J
21: ldc2_w #6 // long 3l
24: lsub
25: lstore 4
27: return
// 省略......
}
复制代码
咱们重点看Apple类的test方法,我已经添加了注释bash
// 加载Apple对象自己到栈
0: aload_0
// 获取字段,#2 对应常量池中的序列,
// #2 = Fieldref #8.#26 // com/company/alloc/Apple.color:I
// 存储的类型是int类型
1: getfield #2 // Field color:I
// 加载1这个常量进栈
4: iconst_1
// 执行加法
5: iadd
// 将栈顶的值存到本地变量表1的位置
6: istore_1
// 加载Apple对象自己到栈
7: aload_0
// 获取字段,#3 对应常量池中的序列,
8: getfield #3 // Field name:Ljava/lang/String;
// 将栈顶的值存到本地变量表2的位置
11: astore_2
// .......
复制代码
能够看到对于对象的成员变量,会存在一个常量池,保存该对象所属类的全部字段的索引表,根据这个常量池能够查询到变量的类型,而字节码指令对于操做各类类型都有专门的指令,好比存储int是istore,存储对象是astore,存储long是lstore,因此指令是编译期已经肯定了,虚拟机只须要根据指令执行就行,根本不关心它操做的这个地址是什么类型的,因此也就不用额外的字段去存类型了,解答咱们前面提的问题!app
咱们开始步入正题,要说内存分配,首先就要了解咱们分配的对象,那Java中分配的对象有哪些类型呢?工具
在Java中数据类型分为二大类。oop
Java中基础数据类型有8种,分别是byte(1), short(2), int(4), long(8), float(4), double(8), char(2), boolean(1), 括号里面是它们占用的字节数,因此对于基础数据类型,它们所占用的内存是很肯定的,也就没什么好说的, 简单的记忆一下每种类型存储所需的字节数便可。测试
Java中基础数据类型是在栈上分配仍是在堆上分配? 咱们继续深究一下,基本数据类占用内存大小是固定的,那具体是在哪分配的呢,是在堆仍是栈仍是方法区?你们不妨想一想看! 要解答这个问题,首先要看这个数据类型在哪里定义的,有如下三种状况。
引用类型跟基础数据类型不同,除了对象自己以外,还存在一个指向它的引用(指针),指针占用的内存在64位虚拟机上8个字节,若是开启指针压缩是4个字节,默认是开启了的。 为了方便说明,仍是以代码为例
class Kata {
// str1和它指向的对象 都在堆上
String str1 = new String();
// str2和它指向的对象都在方法区上
static String str2 = new String();
public void methodTest() {
// str3 在栈上,它指向的对象在堆上(也有可能在栈上,后面会说明)
String str3 = new String();
}
}
复制代码
指针的长度是固定的,不去说它了,重点看它所指向的对象在内存中占多少内存。 Java对象有三大类
Java虚拟机规范定义了对象类型在内存中的存储规范,因为如今基本都是64位的虚拟机,因此后面的讨论都是基于64位虚拟机。 首先记住公式,对象由 对象头 + 实例数据 + padding填充字节组成,虚拟机规范要求对象所占内存必须是8的倍数,padding就是干这个的
而Java中对象头由 Markword + 类指针kclass(该指针指向该类型在方法区的元类型) 组成。
Hotspot虚拟机文档 “oops/oop.hp”有对Markword字段的定义
64 bits:
--------
unused:25 hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object)
JavaThread*:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object)
PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
size:64 ----------------------------------------------------->| (CMS free block)
复制代码
这里简单解释下这几种object
咱们主要关注normal object, 这种类型的Object的 Markword 一共是8个字节(64位),其中25位暂时没有使用,31位存储对象的hash值(注意这里存储的hash值对根据对象地址算出来的hash值,不是重写hashcode方法里面的返回值),中间有1位没有使用,还有4位存储对象的age(分代回收中对象的年龄,超过15晋升入老年代),最后三位表示偏向锁标识和锁标识,主要就是用来区分对象的锁状态(未锁定,偏向锁,轻量级锁,重量级锁)
// 无其余线程竞争的状况下,由normal object变为biased object
synchronized(object)
复制代码
biased object的对象头Markword前54位来存储持有该锁的线程id,这样就没有空间存储hashcode了,因此 对于没有重写hashcode的对象,若是hashcode被计算过并存储在对象头中,则该对象做为同步锁时,不会进入偏向锁状态,由于已经没地方存偏向thread id了,因此咱们在选择同步锁对象时,最好重写该对象的hashcode方法,使偏向锁可以生效。
kclass存储的是该对象所属的类在方法区的地址,因此是一个指针,默认Jvm对指针进行了压缩,用4个字节存储,若是不压缩就是8个字节。 关于Compressed Oops的知识,你们能够自行查阅相关资料来加深理解。 Java虚拟机规范要求对象所占空间的大小必须是8字节的倍数,之因此有这个规定是为了提升分配内存的效率,咱们经过实例来作说明
class Fruit extends Object {
private int size;
}
Object object = new Object();
Fruit fruit = new Fruit();
复制代码
有一个Fruit类继承了Object类,咱们分别新建一个object和fruit,那他们分别占用多大的内存呢?
那该如何验证咱们的结论呢?毕竟咱们仍是相信眼见为实!很幸运Jdk提供了一个工具jol-core可让咱们来分析对象头占用内存信息。 jol的使用也很简单
// 打印对象头信息代码
System.out.println(ClassLayout.parseClass(Object.class).toPrintable());
System.out.println(ClassLayout.parseClass(Fruit.class).toPrintable());
// 输出结果
java.lang.Object object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 12 (object header) N/A
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
com.aliosuwang.jol.Fruit object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 12 (object header) N/A
12 4 int Fruit.size N/A
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
复制代码
能够看到输出结果都是16 bytes,跟咱们前面的分析结果一致。 除了类类型和接口类型的对象,Java中还有数组类型的对象,数组类型的对象除了上面表述的字段外,还有4个字节存储数组的长度(因此数组的最大长度是Integer.MAX)。因此一个数组对象占用的内存是 8 + 4 + 4 = 16个字节,固然这里不包括数组内成员的内存。 咱们也运行验证一下。
String[] strArray = new String[0];
System.out.println(ClassLayout.parseClass(strArray.getClass()).toPrintable());
// 输出结果
[Ljava.lang.String; object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 16 (object header) N/A
16 0 java.lang.String String;.<elements> N/A
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total
复制代码
输出结果object header的长度也是16,跟咱们分析的一致。到这里对象头部分的内存分配咱们就了解的差很少了,接下来看对象的实例数据部分。
为了方便说明,咱们新建一个Apple类继承上面的Fruit类
public class Apple extends Fruit{
private int size;
private String name;
private Apple brother;
private long create_time;
}
// 打印Apple的对象分布信息
System.out.println(ClassLayout.parseClass(Apple.class).toPrintable());
// 输出结果
com.aliosuwang.jol.Apple object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 12 (object header) N/A
12 4 int Fruit.size N/A
16 8 long Apple.create_time N/A
24 4 int Apple.size N/A
28 4 java.lang.String Apple.name N/A
32 4 com.company.alloc.Apple Apple.brother N/A
36 4 (loss due to the next object alignment)
Instance size: 40 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
复制代码
能够看到Apple的对象头12个字节,而后分别是从Fruit类继承来的size属性(虽然Fruit的size是private的,仍是会被继承,与Apple自身的size共存),还有本身定义的4个属性,基础数据类型直接分配,对象类型都是存的指针占4个字节(默认都是开启了指针压缩),最终是40个字节,因此咱们new一个Apple对象,直接就会占用堆栈中40个字节的内存,清楚对象的内存分配,让咱们在写代码时心中有数,应当时刻有内存优化的意识! 这里又引出了一个小知识点,上面其实已经标注出来了。
答案固然是确定的,咱们上面分析的Apple类,父类Fruit有一个private类型的size成员变量,Apple自身也有一个size成员变量,它们可以共存。注意划重点了,类的成员变量的私有访问控制符private,只是编译器层面的限制,在实际内存中不管是私有的,仍是公开的,都按规则存放在一块儿,对虚拟机来讲并无什么分别!
咱们常规的认识是对象的分配是在堆上,栈上会有个引用指向该对象(即存储它的地址),究竟是不是呢,咱们来作个试验! 咱们在循环内建立一亿个Apple对象,并记录循环的执行时间,前面已经算过1个Apple对象占用40个字节,总共须要4GB的空间。
public static void main(String[] args) {
long startTime = System.currentTimeMillis();
for (int i = 0; i < 100000000; i++) {
newApple();
}
System.out.println("take time:" + (System.currentTimeMillis() - startTime) + "ms");
}
public static void newApple() {
new Apple();
}
复制代码
咱们给JVM添加上-XX:+PrintGC运行配置,让编译器执行过程当中输出GC的log日志
// 运行结果,没有输出任何gc的日志
take time:6ms
复制代码
1亿个对象,6ms就分配完成,并且没有任何GC,显然若是对象在堆上分配的话是不可能的,其实上面的实例代码,Apple对象所有都是在栈上分配的,这里要提出一个概念指针逃逸,newApple方法中新建的对象Apple并无在外部被使用,因此它被优化为在栈上分配,咱们知道方法执行完成后该栈帧就会被清空,因此也就不会有GC。 咱们能够设置虚拟机的运行参数来测试一下。
// 虚拟机关闭指针逃逸分析
-XX:-DoEscapeAnalysis
// 虚拟机关闭标量替换
-XX:-EliminateAllocations
复制代码
在VM options里面添加上面二个参数,再运行一次
[GC (Allocation Failure) 236984K->440K(459776K), 0.0003751 secs]
[GC (Allocation Failure) 284600K->440K(516608K), 0.0004272 secs]
[GC (Allocation Failure) 341432K->440K(585216K), 0.0004835 secs]
[GC (Allocation Failure) 410040K->440K(667136K), 0.0004655 secs]
[GC (Allocation Failure) 491960K->440K(645632K), 0.0003837 secs]
[GC (Allocation Failure) 470456K->440K(625152K), 0.0003598 secs]
take time:5347ms
复制代码
能够看到有不少GC的日志,并且运行的时间也比以前长了不少,由于这时候Apple对象的分配在堆上,而堆是全部线程共享的,因此分配的时候确定有同步机制,并且触发了大量的gc,因此效率低不少。 总结一下: 虚拟机指针逃逸分析是默认开启的,对象不会逃逸的时候优先在栈上分配,不然在堆上分配。 到这里,关于“一个对象占多少内存?”这个问题,已经能回答的至关全面了。可是毕竟咱们分析的只是Hotspot虚拟机,咱们不妨延伸一下,看在Android ART虚拟机上面的分配状况
咱们前面使用了jol工具来输出对象头的信息,可是这个jol工具只能用在hotspot虚拟机上,那咱们如何在Android上面获取对象头大小呢?
办法确定是有的,我这里介绍的办法,灵感的主角就是AtomicInteger,我是受到它的启发,这个类咱们知道是线程安全的int的包装类。它的实现原理是利用了Unsafe包提供的CAS能力,不妨看下它的源码实现
private static final sun.misc.Unsafe U = sun.misc.Unsafe.getUnsafe();
private static final long VALUE;
static {
try {
VALUE = U.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (ReflectiveOperationException e) {
throw new Error(e);
}
}
private volatile int value;
/**
* Atomically increments by one the current value.
*
* @return the previous value
*/
public final int getAndIncrement() {
return U.getAndAddInt(this, VALUE, 1);
}
复制代码
咱们知道普通int对象的++操做不是原子性的,AtomicInteger提供了getAndIncrement()它却能保证原子性,这一部分知识不是咱们这篇要讲的知识点,就不去说它们了。 getAndIncrement()方法内部调用了Unsafe对象的getAndAddInt()方法,第二个参数是VALUE,这个VALUE大有玄机,它表示成员变量在对象内存中的偏移地址,根据前面的知识,普通对象的结构 就是 对象头+实例数据+对齐字节,那若是咱们能获取到第一个实例数据的偏移地址,其实就是得到了对象头的字节大小。
由于Unsafe是不可见的类,并且它在初始化的时候有检查当前类的加载器,若是不是系统加载器会报错。可是好消息是,AtomicInteger中定义了一个Unsafe对象,并且是静态的,咱们能够直接经过反射来获得。
public static Object getUnsafeObject() {
Class clazz = AtomicInteger.class;
try {
Field uFiled = clazz.getDeclaredField("U");
uFiled.setAccessible(true);
return uFiled.get(null);
} catch (NoSuchFieldException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
}
return null;
}
复制代码
拿到了Unsafe,咱们就能够经过调用它的objectFieldOffset静态方法来获取成员变量的内存偏移地址。
public static long getVariableOffset(Object target, String variableName) {
Object unsafeObject = getUnsafeObject();
if (unsafeObject != null) {
try {
Method method = unsafeObject.getClass().getDeclaredMethod("objectFieldOffset", Field.class);
method.setAccessible(true);
Field targetFiled = target.getClass().getDeclaredField(variableName);
return (long) method.invoke(unsafeObject, targetFiled);
} catch (NoSuchMethodException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InvocationTargetException e) {
e.printStackTrace();
} catch (NoSuchFieldException e) {
e.printStackTrace();
}
}
return -1;
}
public static void printObjectOffsets(Object target) {
Class targetClass = target.getClass();
Field[] fields = targetClass.getDeclaredFields();
for (Field field : fields) {
String name = field.getName();
Log.d("offset", name + " offset: " + getVariableOffset(target, name));
}
}
复制代码
咱们来使用上面的工具测试打印以前的Fruit和Apple,
Log.d("offset", "------start print fruit offset!------");
Utils.printObjectOffsets(new Fruit());
Log.d("offset", "------start print apple offset!------");
Utils.printObjectOffsets(new Apple());
// 输出结果 (Android 8.0模拟器)
offset: ------start print fruit offset!------
offset: size offset: 8
offset: ------start print apple offset!------
offset: brother offset: 12
offset: create_time offset: 24
offset: id offset: 20
offset: name offset: 16
复制代码
经过输出结果,看出在 Android8.0 ART 虚拟机上,对象头的大小是8个字节,这跟hotspot虚拟机不一样(hotspot是12个字节默认开启指针压缩),根据输出的结果目前只发现这一点差异,各类数据类型占用的字节数都是同样的,好比int占4个字节,指针4个字节,long8个字节等,都同样。
全文咱们总结了如下几个知识点
了解这些并非为了装逼炫技,说实话,写代码作工程的没什么好装的,用的都是别人的轮子,我只会感谢我知道这些还不算太晚,因此我把它们写出来分享给你们。
最后仍是那句话:只有充分的了解Java的内存分配机制,才能正确的去作内存优化!!。