深入理解JVM new 一个对象
有一个老梗,说java程序员最是不缺对象,没有就自己new一个。可见new一个对象在java程序员眼中确实是基本操作。不过你真的理解这个new一个对象的动作么?
说把大象装冰箱总共分三步:
1、给对象分配内存地址。
2、初始化对象。
3、将对象地址赋值给引用。
其中指令重排序可能会将第3步放在第2步之前。
下面我们详细说一下前两步:
1 分配地址:
当jvm遇到一条new指令时,首先会去检查参数类型是否在常量池中定位到一个类的符号引用,并检查符号引用代表的类对象是不是被加载、解析、初始化。
在类加载检查通过后,接下来将为对象分配内存,对象所占用空间的大小在类加载完成后便可以知道。也就是说为对象分配空间的任务,实际上等同于在堆内划分出一块确定大小的内存块。分配空间的方式有两种:
1-1 指针碰撞
如果堆中的内存时规整的,已经使用的在一边,未使用的在另一边,中间是一个指针,作为分界点。这时分配一块内存的操作就是将指针向未使用的一边移动与对象大小相同的距离,这就是指针碰撞。
1-2 空闲列表
如果堆中的空间是不规整的,也就是有内存碎片,已经使用的和未被使用的混杂在一起,那就没办法简单的使用指针碰撞了。jvm需要维护一个列表,记录哪些是未被使用的。在分配的时候,需要从列表中找出一块足够大的内存块划分给对象,并更新列表的记录。这就是空闲列表。
可以发现,使用指针碰撞简单且高效,但是需要内存区域是规整的。而空闲列表有额外操作,但不要求规整。
规整与否取决于所用的垃圾收集算法。标记整理和标记复制算法可以保证规整(serial、parNew)、而标记清除算法(CMS)不保证规整。
1-3 并发问题
很明显分配空间这个动作是线程不安全的。可能出现在给对象A分配内存,指针还未修改时,对象B又使用原来的指针来分配内存。
一个解决方案是对分配动作进行同步,实际上jvm是通过CAS配合失败重试的方式保证操作的原子性的。
另一个解决方案是,为每个线程在堆上分配一块本地分配缓冲区(TLAB),现在缓冲区内分配,缓冲区用完了,再在堆里进行同步分配。
2 初始化
内存分配完成后,虚拟机会将处对象头以外的内存赋零值。
然后呢,jvm会对对象进行必要的设置,例如这个对象是哪个类的实例,如何才能找到类的元信息,对象的hashcode(实际上是延迟到方法调用时),对象的分代年龄等消息。这些信息存放在对象头中,根据jvm的运行状态不同,比如启用偏向锁等,对象头会有不同的设置。
接下来会执行对象<init>()方法,将对象需要的其他资源和状态信息设置好。
这里我们补充一下对象的内存布局:
对象分三部分:1、对象头 2、实例数据 3、对齐填充
对象头:
HotSpot虚拟机对象头包括两类信息:
第一类是用于存储对象自身运行时数据:哈希码、gc分代年龄、锁状态、线程持有的锁、偏向线程Id、偏向时间戳的等,称为Mark World。
第二类是类型指针,即对象指向它类型元数据的指针,jvm通过这个指针来确定对象是哪个类的实例。
实例数据:
对象的有效信息,即我们在程序代码里所定义的各种类型的字段内容,无论是父类继承下来的还是子类定义的字段,都必须记录下来。存储的顺序一般为,longs/doubles、ints、shorts/chars、bytes/booleans、oops。相同宽度的字段总是被分配再一起存放,另外父类定义的字段会在子类之前。如果配置+XX:CompactField=true 那子类较窄的变量也允许插入父类变量的空隙之中,以节省空间。
对齐填充:
对象的起始地址都是8字节的整数倍,对齐填充补齐缺少部分。
3 引用赋值
这一部分内容在上篇已经有所涉及,不做赘述