Java - 对象内存计算以及String类型的相关注意事项
前言
本篇文章介绍的方式:jol-core
这种方法,只能计算当前Object
对象所占的内存大小,而Object
里面如果有内嵌的对象,是不计入其中的,主要看的的是对象的一个内存布局。而更详细的内存占用则需要采取另外的方式计算,后续会讲到。
一. 基础知识复习
我们知道,Java
中,对象在内存中的内存布局分为三个部分:
- 对象头。
- 实例数据。
- 对齐填充。
可以复习下相关知识深入理解Java虚拟机系列(一)–Java内存区域和内存溢出异常
而对象头又可以分为三个部分:
Mark Word
,64位操作系统下占8字节,32位系统下占用4字节。- 类型指针:在开启指针压缩的状况下占 4 字节,未开启状况下占 8 字节,默认开启指针压缩,因此占用4字节。
- 数组长度(只有数组对象才会有,本篇文章都是普通对象为例)
1.1 对齐填充占用内存
然后说下对齐填充。需要注意的是:Java
对象的大小默认按照8字节来对齐。即为8字节的整数倍大小。倘若对象大小不足,则由对齐填充部分来补充。
提问:为什么要进行8字节的对齐?
回答:
CPU
进行内存访问时,一次寻址的指针大小是 8 字节,正好也是L1
缓存行的大小。- 如果不进行内存对齐,则可能出现跨缓存行的情况,即缓存行污染。 如图:
缓存污染是指操作系统将不常用的数据从内存移到缓存,降低了缓存效率的现象
解释:
- 我们主要访问
obj1
这个对象,CPU
就将对应的L1
缓存读取过来。里面包含了obj1
和obj2
两个对象。 - 在后续对
obj1
进行修改的时候。倘若CPU
在访问obj2
对象时。由于obj2
所在的缓存行中数据被修改了。因此此时CPU必须将其重新加载到缓存行中。影响程序的执行效率。 - 倘若
obj2
有自己的L1 Cache
空间。那么在修改obj1
对象的时候,就不会对obj2
产生影响。
因此,采用8字节的对齐填充,是一种用空间换时间的一种方案。
那么总的来说,一个对象所占的内存,记住2点即可:
- 对象内存 = 对象头 + 实例数据 +
padding
填充。 - 对象内存为8字节的整数倍。
1.2 基础数据类型占用内存表
类型 | 占用空间(B) |
---|---|
boolean | 1 |
byte | 1 |
short | 2 |
char | 2 |
int | 4 |
float | 4 |
long | 8 |
double | 8 |
1.3 指针压缩
上文中提到了类型指针的内存占用情况,在开启指针压缩功能的情况下,占用4个字节,否则是8个字节。这个功能在JDK1.6
版本之后就开始支持了。JDK1.8
里面则是默认开启状态的。
启用 CompressOops
后,会压缩的对象包括:
- 对象的全局静态变量(即类属性)。
- 对象头信息。
- 对象的引用类型:64 位系统下,引用类型本身大小为 8 字节,压缩后为 4 字节。
二. String类型所占内存
我们以String
为例,做一个小测试。首先我们引入一个pom
依赖:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.16</version>
</dependency>
Java
代码:
System.out.println(ClassLayout.parseInstance("a").toPrintable())
结果如下:
分析:
首先我们来看下object header
,上图一共有2个,即对象头部分总共加起来消耗了12kb
。
-
首先我的机器是64位的。使用
java -version
命令即可查看:
-
其次,由于是64位的机器,因此对象头中的
Mark Word
部分占用的8个字节大小。对应的是图中的object header: mark
部分。而object header: class
则指的是类型指针,占用4个字节大小。因此这里一共是12字节。
其次我们来看下这两个部分:
这里我们看下String
类中包含了哪些成员变量:
public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
/** The value is used for character storage. */
private final char value[];
/** Cache the hash code for the string */
private int hash; // Default to 0
}
分别对应了:
char[]
数组。用来存储字符串的一个引用。64 位系统下,引用类型本身大小为 8 字节,压缩后为 4 字节。int
类型的hash
变量。根据1.2中基础数据类的内存占用表得知,int
类型占用了4个字节。对得上。
到这里为止,总共的大小为12 + 4 + 4 = 20字节。但是其并不是8的整数倍。因此对齐填充会额外占用4个字节的大小,因此一个String
类型的字符串占用了24个字节。
以防万一,我在拿一个自定义类当例子:
public class SizeObject {
public int size;
public double money;
public byte[] bytes;
}
计算其所占内存:
System.out.println(ClassLayout.parseInstance(new SizeObject()).toPrintable());
结果如下:
- 对象头+类型指针,依旧是固定的8+4=12个字节。
int
类型占用4个字节,double
占用8个字节,byte[]
数组属于引用类型,4个字节。- 到这里一共12 + 4 + 8 + 4 = 28个字节。然后并不是8的整数倍,通过对齐填充,再补4个字节。最终得到32字节。
2.1 String类型的易错点
首先,我们依旧用上述的例子:我们增长了这个字符串对象,我们看看它占用了多少的内存。
System.out.println(ClassLayout.parseInstance("adfadsfdsfdsafas").toPrintable());
结果如下:
可见,它还是24个字节大小。为什么我字符内容变长了,这个对象的占用内存还是24B呢?这要说到Java
的内存数据结构了,这是我在深入理解Java虚拟机系列(一)–Java内存区域和内存溢出异常中贴出的图:
我们看到,有一个运行时常量池和字符串常量池。Java
中对于String
类型,在实例化字符串的时候做了对应的优化操作:
- 每当创建字符串常量时,
JVM
会首先检查字符串常量池,如果该字符串已经存在常量池中,那么就直接返回常量池中的实例引用。 - 如果字符串不在常量池中,就会实例化该字符串,并将其放在常量池中。
文章提到过,String
类型中的char[]
数组保存的是这个字符串的引用地址,真正的实例对象则是在堆中另外开辟一块空间来存储的。 因此,无论我这个字符串的内容有多少,并不会改变char[]
这个引用数组所占的内存。因此在计算String
这个实例所占内存的时候,char[]
占用的字节数永远是4个字节。
测试:
String a = "hello";
String b = "world";
String c= "helloworld";
String res = a + b;
String res2 = "hello" + "world";
System.out.println(c == res);
System.out.println(c == res2);
System.out.println(res == res2);
结果如下:
分析:
- 栈中开辟了一块空间引用(
String
类中的char[]
数组),“hello”
放入到常量池中,a
指向它。 - 栈中又开辟了一块空间引用(
String
类中的char[]
数组),“world”
放入到常量池中,b
指向它。 - 栈中又又开辟了一块空间引用(
String
类中的char[]
数组),“helloworld”
放入到常量池中,c
指向它。 String res = a + b;
这段代码本质上调用的是StringBuilder().toString()
方法,会返回一个新的String
实例,因此此时会在堆中生成一个对象来保存。运行时执行。String res2 = "hello" + "world";
由于“hello”
和“world”
都是常量池中的常量,当字符串由多个字符串常量拼接而成的时候,其本身也是字符串常量。c==res -->false
:res
为堆中的一个对象,和常量相比,必定为false
。c==res2 -->true
:因此res2
在创建的时候,发现常量池中已经存在同样的字符串helloworld
。返回对应的实例。因此两者本质是一个东西。res2==res -->false
:res
为堆中的一个对象,和常量相比,必定为false
。同理第六点。
2.2 真实内存占用计算
我这里给出两种方式,两种都是仅供参考。第一种比较简单,也不打算详细说:
System.setProperty("java.vm.name", "Java HotSpot(TM) ");
String str = "a33adsasdasdas";
System.out.println(ObjectSizeCalculator.getObjectSize(str));
结果如下:
这里主要讲下第二种方式,首先添加个pom
依赖:
<dependency>
<groupId>com.carrotsearch</groupId>
<artifactId>java-sizeof</artifactId>
<version>0.0.3</version>
</dependency>
代码如下:
// 长度14
String str = "a33adsasdasdas";
// 计算这个对象本身在堆中的占用内存大小,单位字节。这里计算出的值应该和上述一致,也是24B
System.out.println(RamUsageEstimator.shallowSizeOf(str));
// 计算指定对象及其引用树上的所有对象的综合大小
System.out.println(RamUsageEstimator.sizeOf(str));
结果如图:
可以发现,24B就是当前这个字符串对象本身所占用的内存,而里面真实数据所占用的内存确实不算入其中。至于后者的RamUsageEstimator.sizeOf(str)
,计算的是指定对象包括其引用树上所有的对象占用的内存总和,计算出72B。我们看下代码做了什么操作,大致分析一下:
RamUsageEstimator.sizeOf(str);
↓↓↓↓↓↓↓↓
public static long sizeOf(Object obj) {
// 相当于引用链,从自身开始,自己当做root去往下延伸。
ArrayList<Object> stack = new ArrayList();
stack.add(obj);
return measureSizeOf(stack);
}
↓↓↓↓↓↓↓↓
private static long measureSizeOf(ArrayList<Object> stack) {
IdentityHashSet<Object> seen = new IdentityHashSet();
IdentityHashMap<Class<?>, RamUsageEstimator.ClassCache> classCache = new IdentityHashMap();
long totalSize = 0L;
while(true) {
while(true) {
Object ob;
do {
do {
// 如果引用链中没有对象了,说明遍历完毕了,可以返回计算的总内存大小了
if (stack.isEmpty()) {
seen.clear();
stack.clear();
classCache.clear();
return totalSize;
}
// 每从引用链中遍历一个,就剔除一个对象。
ob = stack.remove(stack.size() - 1);
} while(ob == null);
} while(seen.contains(ob));
// 表面这个对象已经遍历过
seen.add(ob);
Class<?> obClazz = ob.getClass();
int len$;
Object o;
// 对于数组的内存计算
if (obClazz.isArray()) {
// 数组的对象头,占用16个字节,Mark Word:8B,压缩指针4B,数组长度4B(int类型)。8 + 4 + 4 = 16
long size = (long)NUM_BYTES_ARRAY_HEADER;
len$ = Array.getLength(ob);
if (len$ > 0) {
Class<?> componentClazz = obClazz.getComponentType();
// 确定这个类的类型是否是基本数据类型
if (componentClazz.isPrimitive()) {
// 如果是的话,内存占用大小 = 数组长度 * 单个基本数据类型对于的占用大小
size += (long)len$ * (long)(Integer)primitiveSizes.get(componentClazz);
} else {
// 否则,这个数组的每个对象,先计算它的引用大小。固定是4B。这里的类型就是Object[]
size += (long)NUM_BYTES_OBJECT_REF * (long)len$;
int i = len$;
while(true) {
--i;
if (i < 0) {
break;
}
o = Array.get(ob, i);
if (o != null && !seen.contains(o)) {
stack.add(o);
}
}
}
}
totalSize += alignObjectSize(size);
}
// 普通对象的计算
else {
try {
// 一个缓存,如果存在两个实例,但是他们本质是同一个对象,那么内存占用肯定也就只有一块了。
RamUsageEstimator.ClassCache cachedInfo = (RamUsageEstimator.ClassCache)classCache.get(obClazz);
// 缓存不存在,就放进去。
if (cachedInfo == null) {
classCache.put(obClazz, cachedInfo = createCacheEntry(obClazz));
}
Field[] arr$ = cachedInfo.referenceFields;
len$ = arr$.length;
for(int i$ = 0; i$ < len$; ++i$) {
Field f = arr$[i$];
o = f.get(ob);
if (o != null && !seen.contains(o)) {
stack.add(o);
}
}
// 这里都是计算每个对象的本身占用内存。
totalSize += cachedInfo.alignedShallowInstanceSize;
} catch (IllegalAccessException var13) {
throw new RuntimeException("Reflective field access failed?", var13);
}
}
}
}
}
String
中用一个char[]
数组保存了相关的数据,这部分的内存我们重点关注这段代码:
long size = (long)NUM_BYTES_ARRAY_HEADER;
// ...
size += (long)len$ * (long)(Integer)primitiveSizes.get(componentClazz);
我们来看下primitiveSizes
是什么,它相当于一个Map
映射。保存了每个基本数据类型对应的内存占用大小,可以看到char
类型对应的是2个字节。即16位。由于本次的案例字符串,长度为14,因此这里计算出的结果是 14 * 2 = 28B
。但是我们知道,Java
对象内存占用大小为8的整数倍。因此由对其填充的存在,此时最终的内存占用为32B。
那么String
类型中,char[]
数组占用的总内存就是:数组对象头 + 实例数据占用的内存。即16B + 32B = 48B
。
你可以看做每个对象的真实内存占用大小分为两个部分:
- 第一部分:对象本身占用。这一部分中,对象中的属性,如果是引用类型,例如
Object[]
,或者Object
类型的成员,其占用内存统一为4B。如果是基础数据类型,就按照1.2节中的表格来计算。 - 和该对象有关的引用链上所有对象的内存总和。计算
Object
类型占用的内存。也可能递归整个操作。因为无论什么类型的对象,其最底层最底层肯定是由基础数据类型的成员构建而成的,而最终要计算的内存占用大小就是针对这块来进行的。
那么这个str
对象,占用的总内存计算为:
String
本身的占用内存为24B,这部分可以复习2.1节,主要是看他的内存布局。本身对象头12B,char[]
数组引用(注意这里相对于String
类型本身而言,只是一个引用对象 )占用4B,int
类型的hash
属性占用4B。对其填充占用4B,共24B。char[]
数组占用48B,因此总共24B + 48B = 72B
。