Java内存:“败家的”对象
文章目录
1. 面试题目
new一个HashMap,向其中添加Long,Boolean,主要是手机号是否可用的数据。添加一条,添加两条,一直添加到1000W条。在这个过程中,HashMap的数据结构是怎么变化的?1000W个数据添加完之后,HashMap占用了多少内存?
2. 问题:一个Java对象占用多大内存?
一个java对象的内存布局包括三部分:对象头Header、实例数据Instance Data、对齐填充Padding
不同的环境会有所差异【32bit / 64bit】,本文所用JDK环境:jdk1.8.0_191 HotSpot 64bit
2.1 对象头
具体对象头内容可以阅读《深入理解java虚拟机》,此处简单列出32位虚拟机和64位虚拟机下的Java对象头内存模型
64位虚拟机默认开启指针压缩
Java对象头主要包括两部分:
- Mark Word 涉及到锁的相关内容,本文不做展开
- Klass World
- 是虚拟机设计的一个oop-klass model模型,oop【Ordinary Object Pointer 普通对象指针】看起来像个指针,实际上是藏在指针里的对象
- Klass包含元数据和方法信息,用来描述Java类
- 在64位虚拟机开启压缩指针的情况下占用32bit
总结:
只要是java对象,就肯定/必须包括对象头,这部分内存在虚拟机当中是避免不掉的。在jdk1.8 64bit 开启指针压缩的环境下,任何一个对象,什么都不做,只要声明一个类,那么占用的内存至少是96bit,即12字节
2.2 工具验证
openjdk的jol工具,可以帮助查看内存占用情况
Maven依赖坐标
<!-- https://mvnrepository.com/artifact/org.openjdk.jol/jol-core -->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.16</version>
<scope>provided</scope>
</dependency>
新建一个普通java类,不添加任何属性,测试占用空间是多少
public class NullObject {
}
按照 2.1 对象头 的内容分析,一个空对象,只有一个对象头部,在64bit jdk 1.8 ,默认指针压缩,会占用12字节
测试:
import org.openjdk.jol.info.ClassLayout;
public class TestNull {
public static void main(String[] args) {
System.out.println(ClassLayout.parseInstance(new NullObject()).toPrintable());
}
}
控制台输出:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0xf800c143
12 4 (object alignment gap)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
发现结果显示:Instance size:16bytes,也就是16字节,接下来解析具体的内存占用情况:
- 第2行和第3行,两个对象头:8+4=12bytes 和我们推测的对象头的占用内存大小一直
- 第4行 Object alignment gap 对准间隙,也就是填充了4bytes 。12+4=16
2.3 对齐填充
为什么虚拟机要填充4字节?什么事内存对齐填充?
程序员眼中的内存:一个萝卜一个坑
CPU内存读写:
CPU不会以一个一个字节的方式区读取和写入内存,CPU读取内存是一块块读取的,块的大小为偶数类型的 2/4/6/8/16字节等大小,成为内存访问粒度。
假设32位CPU,以4字节为访问粒度去读取内存,为什么需要内存对齐填充呢,原因有二:
- 平台移植性:不是所有的硬件平台都能够访问任意地址上的任意数据。例如:特定的硬件平台只允许在特定地址获取特定类型的数据,否则会导致异常情况
- 性能原因:若访问未对齐的内存,将会导致CPU进行两次内存访问,花费额外的时间来处理对齐及运算,而对齐的内存仅需要一次访问就可以完成读取动作
访问非对齐内存的过程:
如上图,假设CPU是一次读取4字节,在连续的8字节的内存空间中,数据没有对齐,存储的内存块在地址1,2,3,4中,那么CPU会进行两次读取,还有额外的计算操作:
- CPU首次读取未对齐的第一个内存块,读取0-3字节,并移除不需要的字节0
- CPU再次读取未对齐地址的第二内存块,读取4-7字节,并移除不需要的字节5、6、7字节
- 合并1-4字节的数据
- 合并后放入寄存器
总结:
没有进行内存对齐就会导致CPU进行额外的读取操作,并且需要额外的计算,如果做了内存对齐,CPU可以直接从地址0开始读取,一次就读取到想要的数据,不需要进行额外的读取操作和运算操作,节省了运行时间。
为什么要内存对齐填充?空间换时间
为什么填充4字节?在64位机器下,内存对齐的话就是对象所占用内存是8的倍数,所以填充16-12=4字节
2.4 实例数据
非空对象占用内存计算
一个空对象占用16字节,那么非空对象占用多少字节?
public class NotNullObject {
private NullObject obj = new NullObject();
private int a;
}
测试
import org.openjdk.jol.info.ClassLayout;
import org.openjdk.jol.info.GraphLayout;
public class TestNotNull {
public static void main(String[] args) {
//打印实例的内存布局
System.out.println(ClassLayout.parseInstance(new NotNullObject()).toPrintable());
//打印对象的所有相关内存占用
System.out.println(GraphLayout.parseInstance(new NotNullObject()).toPrintable());
//打印对象的所有内存结果并统计
System.out.println(GraphLayout.parseInstance(new NotNullObject()).toFootprint());
}
}
控制台输出
xxx.NotNullObject object internals:
OFF SZ TYPE DESCRIPTION VALUE
0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0)
8 4 (object header: class) 0xf800c143
12 4 int NotNullObject.a 0
16 4 xxx.NullObject NotNullObject.obj (object)
20 4 (object alignment gap)
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
xxx.NotNullObject@504bae78d object externals:
ADDRESS SIZE TYPE PATH VALUE
76c3aa708 24 xxx.NotNullObject (object)
76c3aa720 16 xxx.NullObject .obj (object)
Addresses are stable after 1 tries.
xxx.NotNullObject@6bf2d08ed footprint:
COUNT AVG SUM DESCRIPTION
1 24 24 xxx.NotNullObject
1 16 16 xxx.NullObject
2 40 (total)
可以看到:
- NotNullObject类的占用空间【24字节】:
- 头部:12字节
- int a:4字节
- NullObject对象的引用:4字节
- 对齐:4字节
- 实例化NotNullObject类所用空间【40字节】:
- NotNullObject类所用空间:24字节
- NullObject类所用空间:16字节
总结:
- 一共有三种对象头模型:32位、64位:【压缩指针、不压缩指针】
- 内存对齐的原因:平台移植性、性能
- 空对象内存计算注意要计算内存对齐,非空对象的内存计算注意加上引用内存占用和原实例对象的空间占用
基本数据类型内存占用:
boolean | byte | short | char | int | float | long | double |
---|---|---|---|---|---|---|---|
1 | 1 | 2 | 2 | 4 | 4 | 8 | 8 |
引用数据类型,32位平台占用4字节,64位平台占用8字节
3. 问题:存储1000W条Long==Boolean键值对的HashMap占用多少内存?
Q1:一个HashMap的占用大小
查看HashMap的源码,统计成员属性【非静态,从父类继承】,如下:
HashMap内部结构比较复杂,除基本数据类型,还有引用数据类型,table是一个Entry数组,用来存放键值对,所有put进map的key-value都会被封装成一个entry放进table中。还包含一些辅助对象,继承自AbstractMap的KeySet,Values,这两个属性是在遍历Map集合时用到的集合,他们主要的功能是通过在自己内部维护一个迭代器向外输出table中的数据,并不储存key-value数据。
成员属性占用内存大小:
-
一个空的HashMap:
- 对象头:12字节 【压缩指针】
- 实例数据:table【4】 + entrySet【4】 + size【4】 + modCount【4】 + threshold【4】 + loadFactor【4】 + keySet【4】 + values【4】 =32字节【基本数据类型+引用】
- 对齐填充:12+32=44字节,最接近44的8的倍数是48,填充4字节,44+4=48字节
-
添加一个元素的HashMap:
- 相关类型所占内存【基本数据类型+所有引用类型对象所占内存】:
- 引用数据类型:
- Long:对象头12 + 实例数据 8 + 对齐填充 4 = 24字节
- Boolean:对象头12 + 实例数据 1 + 对齐填充 3 = 16字节
- Node<K,V>:
- 对象头:12字节
- 实例数据:hash-4 + K-4 + V-1 + next-4 = 13字节
- 对齐填充:7字节
- 总和:12+13+7=32字节
- Node<K,V>[]:【数组、初始空间16位置】
- 对象头:12字节
- 实例数据:16*4 + length = 64+4=68字节
- 总和:12+68=80字节
- 总和:HashMap空对象48 + Long 24 + Boolean 16 + Node 32 + Node[] 80 = 200字节
- 引用数据类型:
- 添加一个元素的HashMap占用200字节
验证如下:
import java.util.HashMap; import org.openjdk.jol.info.ClassLayout; import org.openjdk.jol.info.GraphLayout; public class TestHashMapSize { public static void main(String[] args) { // 打印实例的内存布局 System.out.println(ClassLayout.parseInstance(new HashMap()).toPrintable()); // 打印对象的所有相关内存占用 System.out.println(GraphLayout.parseInstance(new HashMap()).toPrintable()); // 打印对象的所有内存结果并统计 System.out.println(GraphLayout.parseInstance(new HashMap()).toFootprint()); HashMap<Long, Boolean> hashMap = new HashMap<Long, Boolean>(); hashMap.put(17353661799L, true); // 打印实例的内存布局 System.out.println(ClassLayout.parseInstance(hashMap).toPrintable()); // 打印对象的所有相关内存占用 System.out.println(GraphLayout.parseInstance(hashMap).toPrintable()); // 打印对象的所有内存结果并统计 System.out.println(GraphLayout.parseInstance(hashMap).toFootprint()); // 打印实例的内存布局 System.out.println(ClassLayout.parseInstance(new Boolean(true)).toPrintable()); } }
控制台输出:
java.util.HashMap object internals: OFF SZ TYPE DESCRIPTION VALUE 0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0) 8 4 (object header: class) 0xf80037a8 12 4 java.util.Set AbstractMap.keySet null 16 4 java.util.Collection AbstractMap.values null 20 4 int HashMap.size 0 24 4 int HashMap.modCount 0 28 4 int HashMap.threshold 0 32 4 float HashMap.loadFactor 0.75 36 4 java.util.HashMap.Node[] HashMap.table null 40 4 java.util.Set HashMap.entrySet null 44 4 (object alignment gap) Instance size: 48 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total java.util.HashMap@5387f9e0d object externals: ADDRESS SIZE TYPE PATH VALUE 76ba05d10 48 java.util.HashMap (object) Addresses are stable after 1 tries. java.util.HashMap@1698c449d footprint: COUNT AVG SUM DESCRIPTION 1 48 48 java.util.HashMap 1 48 (total) java.util.HashMap object internals: OFF SZ TYPE DESCRIPTION VALUE 0 8 (object header: mark) 0x0000000000000001 (non-biasable; age: 0) 8 4 (object header: class) 0xf80037a8 12 4 java.util.Set AbstractMap.keySet null 16 4 java.util.Collection AbstractMap.values null 20 4 int HashMap.size 1 24 4 int HashMap.modCount 1 28 4 int HashMap.threshold 12 32 4 float HashMap.loadFactor 0.75 36 4 java.util.HashMap.Node[] HashMap.table [null, null, null, null, null, null, null, null, (object), null, null, null, null, null, null, null] 40 4 java.util.Set HashMap.entrySet null 44 4 (object alignment gap) Instance size: 48 bytes Space losses: 0 bytes internal + 4 bytes external = 4 bytes total java.util.HashMap@69663380d object externals: ADDRESS SIZE TYPE PATH VALUE 76b5fa2b0 16 java.lang.Boolean .table[8].value true 76b5fa2c0 4521400 (something else) (somewhere else) (something else) 76ba4a078 48 java.util.HashMap (object) 76ba4a0a8 24 java.lang.Long .table[8].key 17353661799 76ba4a0c0 80 [Ljava.util.HashMap$Node; .table [null, null, null, null, null, null, null, null, (object), null, null, null, null, null, null, null] 76ba4a110 32 java.util.HashMap$Node .table[8] (object) Addresses are stable after 1 tries. java.util.HashMap@69663380d footprint: COUNT AVG SUM DESCRIPTION 1 80 80 [Ljava.util.HashMap$Node; 1 16 16 java.lang.Boolean 1 24 24 java.lang.Long 1 48 48 java.util.HashMap 1 32 32 java.util.HashMap$Node 5 200 (total)
- 相关类型所占内存【基本数据类型+所有引用类型对象所占内存】:
Q2:1000W扩容多少次
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;//按照添加第17个值来读源码
int oldCap = (oldTab == null) ? 0 : oldTab.length;//旧数组的长度16
int oldThr = threshold;//旧的阈值 = 16*0.75 = 12
int newCap, newThr = 0;//新容量,新阈值
if (oldCap > 0) {
//MAXIMUM_CAPACITY = 1<<30 =2^30=1073741824 10亿,远远超出1000W,这个条件不成立
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
//如果旧容量扩大1倍的结果小于MAXIMUM_CAPACITY=1<<30=10亿,并且旧容量大于DEFAULT_INITIAL_CAPACITY=1<<4 = 16,那么新的阈值变成旧的阈值的一倍
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
//不走
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
//不走
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//不走
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//变量赋值 成员属性threshold = newThr = 2* oldThr
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//新数组的长度 = newCap = 2 * oldCap
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
//数据迁移
if (oldTab != null) {
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
if ((e = oldTab[j]) != null) {
oldTab[j] = null;
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // preserve order
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else {
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
//返回新数组
return newTab;
}
总结:
每次扩容,扩大一倍容量
阈值是容量的0.75是不变的
1000W数据存放扩容多少次?最终扩容的数组长度是多少?
-
总容量 = 10000000/0.75=13333333.3
-
找到总容量所在的这个区间即可
int x = 16; int y = 13333333; int count = 0; while(true) { count++; x*=2; if(x>y) { System.out.println(x);//16777216 System.out.println(count);//20 return; } }
即最终数组的长度为16777216
Q3:总占用内存
- 引用数据类型:
- Long:对象头12 + 实例数据 8 + 对齐填充 4 = 24字节
- Boolean:对象头12 + 实例数据 1 + 对齐填充 3 = 16字节
- Node<K,V>:
- 对象头:12字节
- 实例数据:hash-4 + K-4 + V-1 + next-4 = 13字节
- 对齐填充:7字节
- 总和:12+13+7=32字节
- Node<K,V>[]:【数组、初始空间16位置】
- 对象头:12字节
- 实例数据:16*4 + length = 64+4=68字节
- 总和:12+68=80字节
- HashMap:48字节
总和:
Long * 1000W + Boolean * 1000W + Node * 1000W + HashMap + Node[]
= 24000W + 16000W + 32000W + 48 + (12 + 16777216*4+4)
=72000W + 48 + 67108880
=787108928 byte ≈ 750.6456M