上篇文章我们了解了Java虚拟机的内存模型,也知道不同区域的内存都存储的是什么,现在我们进一步来探究内存上存储的数据是怎么创建的,内存大小又是怎么分配的,数据创建成功后怎么被访问的。我们以Java虚拟机中最大的内存块Java堆为例子来探究上面的三个问题。
Java堆上对象的创建
Java虚拟机遇到一条new指令后,首先会去检查要创建的对象能否在常量池中定位到其对应类的符号引用,并且检查这个引用代表的类是否已经被加载、解析和初始化过,如果没有则先进行类加载过程。
然后就是为这个对象分配内存,最后执行init方法,这样一个可用的对象创建完毕。
Java堆对象的内存分配
在类加载检查通过之后,虚拟机就会为新生对象分配内存,分配方式因取决于Java堆中内存是否规整而分为“指针碰撞”和“空闲列表”。在Java堆中的内存是规整的情况下,内存分配方式采用“指针碰撞”,反之采用“空闲列表”。
假设Java堆中的内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,分配内存的方式就是将这个指针往指向空闲空间那边挪动一段与对象大小相等的距离。
假设Java堆中的内存不是规整的,已使用的内存和空闲的内存相互交错,这样就无法进行简单地指针碰撞了,这时候虚拟机会维护一张列表,列表中记录哪些内存块是可用的,在分配内存的时候就从列表中找到一块足够大的空间划分给对象实例,同时更新列表上的记录,这种内存分配的方式叫做“空闲列表”。
由此我们知道Java堆对象的内存分配策略是由Java堆内存是否规整来决定的,那么Java堆内存是否规整则由GC是否带有压缩整理功能决定的,所以一般在使用Serial、ParNew等带有压缩整理过程的收集器时,系统采用的是“指针碰撞”的分配策略,在使用CMS这种基于Mark-Sweep算法的收集器时,采用的是“空闲列表”的方式。
由于Java堆是线程共享的,那么内存的分配在线程并发的情况下如何保证线程安全呢?
不管采用哪种内存分配策略,在多线程下进行Java堆内存分配的时候,可能存在正在给A对象分配内存,但指针还没来得及修改而对象B又使用了原来的指针来分配内存的这种情况。
有两种解决方案:第一是对分配内存空间的动作进行同步处理以保证操作的原子性,也就是说同一时刻只有一个线程能进行分配内存的操作;第二是把内存分配的操作按照线程划分在不同的空间中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB),线程在自己的TLAB中分配内存,只有在TLAB用完了,在分配新的TLAB时才需要同步锁定。
讲完了Java堆对象的内存分配策略,那存储的对象到底存储的是什么呢?对象中都包含哪些内容呢?
对象在内存中主要存储这三个信息:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
对象头,存储对象自身的运行时数据:哈希码,GC分代年龄,锁状态标志等。
实例数据,存储对象真正有效的信息。
对齐填充,起到占位符的作用,当实例数据部分没有对齐时,就需要对齐填充来补全。
Java堆对象的访问定位
对象创建并分配好了内存,那么接下来就可以访问对象了,Java程序是通过栈中的reference数据来操作堆上的具体对象,虚拟机提供了两种机制来访问堆中的实例对象:句柄和直接指针。
使用句柄:Java堆中会划分一块区域作为句柄池,Java栈中的本地变量表中的reference数据存储的就是对象的句柄地址,而句柄中则包含的是到对象实例数据的指针和对象类型数据的指针。
直接指针:reference数据存储是的对象的实例数据的引用地址和到对象类型数据的指针。
使用句柄的好处是Java栈中的本地变量表中的reference数据是相对稳定的,不需要经常变动,比如在执行GC操作时移动对象,这个时候需要改变对象在堆中的地址,在句柄机制中,只需要改动句柄池中的实例数据的指针,而不需要修改reference数据。但缺点是增加了一次指针定位的开销,对于对象访问比较频繁的时候,这种开销累计起来也是很大的,所以对于HotSpot而言,使用的是直接指针的方式。
共同学习,写下你的评论
评论加载中...
作者其他优质文章