订阅更新

一个对象到底占多少内存?——Java对象内存布局和大数据内存估算

Java手册 第 5 题 约 8 分钟读完

本文目录
  1. 先说结论
  2. 对象的三部分内存布局
  3. 常见对象的内存占用
  4. Java集合的内存开销——为什么比你想象的大?
  5. 指针压缩(CompressedOops)
  6. 大数据场景的内存估算方法
  7. 实战经验:32GB堆内存反而比28GB慢
  8. 面试高频追问
  9. 总结

先说结论

一个Java对象由三部分组成:对象头(12-16字节)+ 实例数据(字段大小之和)+ 对齐填充(补到8的倍数)。在64位JVM开启指针压缩时(默认),一个空的Object占16字节。一个只有一个int字段的对象占16字节。一个有一个long和一个String引用的对象占32字节。实际的Java集合(HashMap/ArrayList)因为链表节点和数组的开销,内存占用是'纯数据'的3-10倍。

先把这段讲顺;面试官追问时,再展开下面的原理和细节。

详细讲解

'一个Java对象占多少内存'看起来是个纯理论的问题,但在大数据场景中非常实用。在某知名出海硬件品牌做Spark调优时,需要估算广播变量能不能放进内存——一个500万行的维度表(每行10个字段),Java对象存到内存里大概多大?200MB还是2GB?估错了要么OOM要么浪费内存。理解Java对象的内存布局是做内存估算的基础。

对象的三部分内存布局

# Java对象的内存布局(64位JVM,开启指针压缩)
#
# ┌─────────────────────────────────────────┐
# │ 对象头(Object Header):12字节            │
# │   Mark Word: 8字节(hashCode/锁/GC年龄)  │
# │   Class Pointer: 4字节(指向类元数据)     │
# │   (数组还多4字节存长度 ==> 数组头16字节)  │
# ├─────────────────────────────────────────┤
# │ 实例数据(Instance Data)                  │
# │   boolean/byte: 1字节                      │
# │   char/short:   2字节                      │
# │   int/float:    4字节                      │
# │   long/double:  8字节                      │
# │   引用(reference): 4字节(压缩指针)     │
# ├─────────────────────────────────────────┤
# │ 对齐填充(Padding)                        │
# │   对象总大小必须是8字节的倍数               │
# │   不够就补齐                                │
# └─────────────────────────────────────────┘

常见对象的内存占用

对象类型计算过程实际占用
new Object()12(头) + 0(无字段) + 4(对齐) = 1616字节
new Integer(1)12(头) + 4(int) + 0(刚好16) = 1616字节
new Long(1L)12(头) + 8(long) + 4(对齐) = 2424字节
new String("hello")12(头) + 4(char[]引用) + 4(hash) + 4(coder) + 4(对齐) = ~4040字节+char数组
int[10]16(数组头) + 10*4(数据) + 0(对齐) = 5656字节
一行10字段的POJO12(头) + 约60(字段) + 对齐约80字节

关键洞察:一个int基本类型只占4字节,但包装成Integer对象就要16字节——4倍的开销。一个long基本类型8字节,包装成Long对象24字节——3倍开销。这就是为什么大数据框架(Spark Tungsten、Flink)大量使用基本类型和堆外内存来避免Java对象的开销。

Java集合的内存开销——为什么比你想象的大?

// 一个HashMap<Long, String>存100万条数据占多少内存?
 
// HashMap内部结构:
// 桶数组:Entry[] table,100万条数据扩容后约131万个桶
// 每个桶:一个Entry对象
 
// 每条Entry的内存:
// Entry对象头:12字节
// hash字段(int):4字节
// key引用:4字节
// value引用:4字节
// next引用:4字节
// 对齐:4字节
// 合计:32字节/Entry
 
// Key (Long对象):24字节
// Value (String对象,假设平均20字符):约64字节
 
// 每条数据总计:32(Entry) + 24(Long) + 64(String) = 120字节
// 100万条:120MB
// 加上桶数组本身:131万 × 4字节 = 5MB
// 总计约125MB
 
// 如果纯数据(long + 20字符String):约28MB
// Java对象开销:125MB / 28MB = 4.5倍!

指针压缩(CompressedOops)

# 指针压缩是JVM的优化,默认在堆小于32GB时开启
# 开启后引用类型从8字节压缩到4字节
 
# 开启指针压缩(默认,堆<=32GB)
-XX:+UseCompressedOops
 
# 关闭指针压缩(堆>32GB时自动关闭)
-XX:-UseCompressedOops
 
# 大数据场景的影响:
# Spark Executor配12GB堆内存:指针压缩生效,对象更小
# 如果配33GB堆内存:指针压缩失效!
#   每个引用从4字节变8字节
#   对象开销增加20-30%
#   实际可用的内存可能反而比32GB少!
#
# 所以Spark/Flink的堆内存建议不超过32GB
# 如果需要更多内存,用堆外内存而不是加大堆

大数据场景的内存估算方法

在某知名出海硬件品牌做Spark广播变量选型时,需要估算一个500万行的设备维度表能不能放进内存做Broadcast Join:

# 设备维度表:500万行,每行10个字段
# 字段:device_id(long), device_model(String 20char),
#        brand(String 10char), os(String 7char),
#        country(String 5char), 还有5个int/boolean字段
 
# 每行的纯数据量:8+20+10+7+5+20 = 约70字节
# Java对象开销(POJO):约70 × 3倍 = 210字节/行
# 如果存在HashMap里:约70 × 5倍 = 350字节/行
 
# 500万行 × 350字节 = 约1.6GB
 
# 结论:用HashMap<String, DeviceInfo>存500万行约需1.6GB
# Executor内存8GB的话可以放(但有些吃力)
# 更好的方案:用Spark的autoBroadcastJoinThreshold
# Spark用自己的InternalRow格式存储(比Java对象紧凑得多)
# 实际内存可能只需要400-500MB

估算公式:Java对象存储 = 纯数据量 × 3-5倍(取决于集合类型)。HashMap最费内存(约5倍),数组最省(约1.5-2倍)。大数据框架(Spark DataFrame、Flink Table)的内部存储格式比Java对象紧凑得多(约1.2-1.5倍),这就是为什么推荐用DataFrame而不是RDD。

实战经验:32GB堆内存反而比28GB慢

在某知名出海硬件品牌,有个同事把Spark Executor的堆内存从28GB调到33GB,想着'内存越大越好'。结果作业反而慢了15%。

原因:33GB超过了32GB的指针压缩阈值,JVM自动关闭了指针压缩。所有引用从4字节变成8字节,对象膨胀了20-30%。33GB的实际可用内存反而比28GB少。

教训:Spark/Flink的堆内存不要超过32GB。如果需要更多内存,用堆外内存(spark.memory.offHeap.size)。或者加更多的Executor而不是加大单个Executor的堆。

面试高频追问

追问一:为什么大数据框架要用堆外内存?

三个原因:① 避免GC——堆外内存不受GC管理,不会因为Full GC暂停;② 减少对象开销——堆外存的是紧凑的字节数组而不是Java对象,省去了对象头和对齐填充;③ 零拷贝——堆外内存可以直接交给OS发送网络,不需要先拷贝到堆内。Flink的网络缓冲、Spark的Tungsten引擎、Kafka的零拷贝都是基于堆外内存的。

追问二:怎么查看一个对象的实际内存大小?

用JOL(Java Object Layout)工具:ClassLayout.parseInstance(obj).toPrintable()。它会打印对象的每个字段的偏移量、大小和总大小。排查内存问题时非常有用。

追问三:Spark的DataFrame为什么比RDD省内存?

RDD存的是Java/Scala对象,有对象头和装箱开销。DataFrame底层用Tungsten的InternalRow格式——把数据存成紧凑的字节数组,字段按偏移量直接访问,不需要Java对象的包装。同样的数据DataFrame比RDD省2-5倍内存,这也是Spark推荐用DataFrame/Dataset而不是RDD的核心原因之一。

总结

Java对象内存面试讲清楚三层:① 对象布局(对象头12字节+实例数据+对齐到8倍数);② 集合的内存开销(HashMap约纯数据的5倍,对象包装比基本类型大3-4倍);③ 大数据的内存优化(指针压缩32GB上限、堆外内存避免GC和对象开销、DataFrame比RDD省内存)。能讲出32GB堆内存反而不如28GB的踩坑经历和内存估算的方法,面试官会觉得你对JVM内存有深度理解。

下一篇聊一个函数式编程的话题:Spark的RDD为什么设计成不可变的?Java的不可变性和函数式编程怎么理解?

你们估算过大数据作业的内存需求吗?用什么方法?评论区聊聊~

关注「Wood聊大数据」| 9年大数据老兵,聊技术、聊职场、聊成长

Java 面试通关手册

第6-10篇:函数式编程与并发模型

Wood聊大数据

9年大数据老兵,聊技术、聊职场、聊成长

刷题进度保存在当前浏览器