战狂粗人张

  博客园 :: 首页 :: 博问 :: 闪存 :: 新随笔 :: 联系 :: 订阅 订阅 :: 管理 ::

一.内存管理的分代机制

Java语言与C语言相比,最大的特点是编程人员无需过多的关心Java的内存分配和回收,因为所有这一切,Java的虚拟机都帮我们实现了。

JVM的内存管理,大大降低了开发人员对内存管理的要求,也不容易出现C语言中的内存泄漏和溢出。但一旦应用内存发生问题,也会导致程序员难以定位。

所以对于Java程序员来说认识和了解JVM的内存分配和回收对于代码的编写和应用的优化都有非常重要的意思。

Java技术体系中所提倡的自动内存管理最终可以归结为自动化地解决两个问题:给对象分配内存以及回收分配给对象的内存。

对象的内存分配,从大方向讲就是在堆上分配,对象主要分配在新生代的Eden区上,当然分配的规则并不是固定的,

其细节取决于使用的是哪一种收集器组合,还有虚拟机中与内存相关的参数的设置。

垃圾收集器组合一般就是Serial+Serial Old和Parallel+Serial Old,前者是Client模式下的默认垃圾收集器组合,后者是Server模式下的默认垃圾收集器组合。

JVM中,目前使用的内配管理是分代方式,即把内存分成新生代、老生代和永久代。这里我们讲的分代管理机制是针对线程共享的内存区域,主要是堆,也包括方法区。

JAVA分代机制的好处是可以根据Java的实际对象创建和销毁时机,在不同的生代中可以采用不同的垃圾回收策略,已提高垃圾回收的效率。

在Java中,几乎所有对象的实例都分配与新生代,而大部分对象的存活时间都不长,新生代中的对象回收会比较频繁。

而老年代中的存放是那些存活时间较长,或者对象过大导致无法在新生代中分配的对象。

而永久代比较特殊,它一般是指内存区域中的方法区,HotSpot在实现方法区时作为永久代来处理,避免了额外来管理方法区。

这块区域的内存回收我们一般不做考虑,因为效果不会很明显,而且回收的条件也非常苛刻。

1、内存分配:

整个内存: 堆内存(年轻代大小 + 年老代大小)+ 非堆(持久代)。

(1)堆参数:

-Xms:初始内存,默认是物理内存的1/64。

-Xmx:最大内存,默认是物理内存的1/4。默认空余堆内存小于40%时,JVM就会增大堆直到-Xmx的最大限制;空余堆内存大于70%时,JVM会减少堆直到 -Xms的最小限制。

因此服务器一般设置-Xms、-Xmx相等以避免在每次GC 后调整堆的大小。 

-Xmn:指定年轻代: 包括Eden和两个Survivor区。

-XX:NewRatio:年轻代(-Xmn)与年老代的比值(除去持久代),默认值4表示年轻代与年老代所占比值为1:4,年轻代占整个堆栈的1/5,Xms=Xmx并且设置了Xmn的情况下,不需要设置-XX:NewRatio。

(2)非堆就是JVM留给自己用的,所以方法区(虚拟机规范未规定此区域的具体数据结构,由虚拟机厂商自行实现)、

JVM内部处理或优化所需的内存(如JIT编译后的代码缓存)、每个类结构(如运行时常数池、字段和方法数据)以及方法和构造方法 的代码都在非堆内存中,是各个线程共享的内存区域。

非堆(持久代)内存分配 

-XX:PermSize:初始值,默认是物理内存的1/64;

-XX:MaxPermSize:最大值,默认是物理内存的1/4。

 

2、内存分配方式:指针碰撞和空闲列表

(1)指针碰撞:如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,

那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump the Pointer)。

(2)空闲列表:如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,

记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。

选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。因此,在使用Serial、ParNew等带Compact过程的收集器时,

系统采用的分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表。

 

3、内存分配的并发控制:CAS和TLAB

对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,

可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况。

主要通过两种方式解决:

(1)CAS加上失败重试分配内存地址。

(2)TLAB 为每个线程分配一块缓冲区域进行对象分配,new对象内存的分配均需要进行加锁,这也是new开销比较大的原因,

所以Sun Hotspot JVM为了提升对象内存分配的效率,对于所创建的线程都会分配一块独立的空间,这块空间又称为TLAB,TLAB仅作用于新生代的Eden,

因此在编写Java程序时,通常多个小的对象比大的对象分配起来更加高效。使用-XX:+/-UseTLAB。

 

4、内存分配完成后操作

虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。

接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。

这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从Java程序的视角来看,对象创建才刚刚开始,<init>方法还没有执行,所有的字段都还为零。

执行new指令之后会接着执行<init>方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

 

5、对象头

(1)官方称它为“Mark Word”,包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,

另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身。

(2)这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit。 

(3)如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机可以通过普通Java对象的元数据信息确定Java对象的大小,但是从数组的元数据中却无法确定数组的大小。

(4)对象需要存储的运行时数据很多,其实已经超出了32位、64位Bitmap结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额外存储成本,

考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。

例如:

在32位的HotSpot虚拟机中,如果对象处于未被锁定的状态下,那么Mark Word的32bit空间中的25bit用于存储对象哈希码,

4bit用于存储对象分代年龄,2bit用于存储锁标志位,1bit固定为0,而在其他状态(轻量级锁定、重量级锁定、GC标记、可偏向)复用其它空间。

 

6、对象实例数据:

(1)实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。

(2)无论是从父类继承下来的,还是在子类中定义的,都需要记录起来。

(3)这部分的存储顺序会受到虚拟机分配策略参数(FieldsAllocationStyle)和字段在Java源码中定义顺序的影响。

HotSpot虚拟机默认的分配策略为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers),

从分配策略中可以看出,相同宽度的字段总是被分配到一起。在满足这个前提条件的情况下,在父类中定义的变量会出现在子类之前。

如果CompactFields参数值为true(默认为true),那么子类之中较窄的变量也可能会插入到父类变量的空隙之中。

(4)由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,就是对象的大小必须是8字节的整数倍。

而对象头部分正好是8字节的倍数(1倍或者2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

 

二.分配回收策略(什么情况下触发垃圾回收

JVM采用分代的垃圾回收策略:不同对象的生命周期是不一样的。目前JVM分代主要是分三个年代:

(1)新生代:所有新创建的对象都首先在新生代进行内存分配。新生代具体又分为3个区,一个Eden区、一个From Survivor区和一个To Sruvivor区。

大部分对象都被分配在Eden区,当Eden区满时,还存活的对象将被复制到From Survivor区,当From Survivor区满时,此区还存活的对象将被复制到To Survivor区。

最后,当To Survivor区也满时,这时从From Survivor区复制过来并且还存活的对象将被复制到老年代。

(2)老年代:在年轻代中经历了N次(一般是15次)GC后依然存活的对象,就会被放到老年代当中。因此,可以认为老年代是存放一些生命周期较长的对象。

(3)持久代:用于存放静态文件,如Java类等。

在JVM的内存空间中把堆空间分为年老代和年轻代。

将大量(据说是90%以上)创建了没多久就会消亡的对象存储在年轻代,而年老代中存放生命周期长久的实例对象。

年轻代中又被分为Eden区(圣经中的伊甸园)、和两个Survivor区。新的对象分配是首先放在Eden区,Survivor区作为Eden区和Old区的缓冲,

在Survivor区的对象经历若干次收集仍然存活的,就会被转移到年老区。

简单讲,就是生命期短的对象放在一起,将少数生命期长的对象放在一起,分别采用不同的回收策略。

生命期短的对象回收频率比较高,生命期长的对象采用比较低回收频率,生命期短的对象被尝试回收几次发现还存活,则被移到另外一个地方去存起来。

 

为什么要分代:

分代的垃圾回收策略,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。

在Java程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如Http请求中的Session对象、线程、Socket连接,这类对象跟业务直接挂钩,因此生命周期比较长。

但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如:String对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。

试想,在不进行对象存活时间区分的情况下,每次垃圾回收都是对整个堆空间进行回收,花费时间相对会长,同时,因为每次回收都需要遍历所有存活对象,

但实际上,对于生命周期长的对象而言,这种遍历是没有效果的,因为可能进行了很多次遍历,但是他们依旧存在。

因此,分代垃圾回收采用分治的思想,进行代的划分,把不同生命周期的对象放在不同代上,不同代上采用最适合它的垃圾回收方式进行回收。

 

TLAB:

首先讲讲什么是TLAB。内存分配的动作,可以按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。

哪个线程需要分配内存,就在哪个线程的TLAB上分配。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。

这么做的目的之一,也是为了并发创建一个对象时,保证创建对象的线程安全性。TLAB比较小,直接在TLAB上分配内存的方式称为快速分配方式,

而TLAB大小不够,导致内存被分配在Eden区的内存分配方式称为慢速分配方式。

 

1、对象优化分配在Eden区

Java的对象优先分配在Eden区中,当Eden区中没有足够的内存分配时,JVM会进行一次MinorGC。

所以JVM中MinorGC会是比较频繁的垃圾回收动作,一般回收速度也比较快。对象分配在Eden区也不是绝对的,有一种例外是大对象会直接进入老年代。

这里的大对象是指需要连续内存空间的Java对象,比如说很长的字符串和数组等。大对象直接进入老年代非常不适合垃圾回收策略,

特别是这些大对象也是那些朝生夕死的对象,这会造成比较频繁的FullGC,导致系统性能降低。

 

如下代码中:

尝试分配3个2MB大小和1个4MB大小的对象。在运行通过-Xms20M、-Xmx20M和-Xmn10M这三个参数限制Java堆大小为20MB,切不可扩展,其中10MB分配给新生代剩下的10MB分配给老年代。

-XX:SurvivorRatio=8决定了新生代中Eden区与一个Survivor区的空间比例是8比1,新生代总可用空间为9216KB(Eden区+1个Survivor区的总容量)。

public class EdenAllocationTest
{
    private static final int _1MB = 1024 * 1024;
    
    public static void testAllocation()
    {
        byte[] allocation1 = new byte[2 * _1MB];
        byte[] allocation2 = new byte[2 * _1MB];
        byte[] allocation3 = new byte[2 * _1MB];
        byte[] allocation4 = new byte[4 * _1MB];
    }
}

执行testAllocation()中分配allocation4对象的语句会发生一次Minor GC,这次GC发生的原因是给allocation4分配内存的时候,

发现Eden已经被占用了6MB剩下的空间已经不足够分配allocation4所需要的4MB内存,

因此发生Minor GC.GC期间虚拟机又发生已有的3个2MB大小全部无法放入Survivor空间(Survivor空间只有1MB大小),所以只好通过分配担保机制提前转移到老年代中去。

这次GC结束后,4MB的allocation4对象被顺利分配在Eden,因此程序执行完的结果是Eden占用4MB(被alloction4占用),Survivor空闲,老年代被占用6MB(被alloction1、2、3占用)。

2、大对象直接进入老年代

所谓大对象就是需要大量连续空间的Java对象,最典型的大对象就是那种很长的字符串及数组。

每一个对象都有一个对象年龄,对象在新生代中每经过一次垃圾回收,对象年龄增长1,当对象年龄超过某个阈值时,该对象会进入老年代。

所以这里就有一个问题,如果在非常频繁的进行垃圾回收时,对象的对象年龄就会快速增长,一个对象会非常容易的进行老年代,造成FullGC的次数增长。

虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这些设置值的对象直接在老年代中分配。

这样做的目的是在避免Eden区及两个Survivor区之间发生大量的内存拷贝(复习一下:新生代采用复制算法手机内存)。

执行下面代码后,可以看到Eden空间几乎没有被使用,而老年代10MB的空间被使用了40%,也就是4MB的allocation对象直接就分配在老年代中,

这是因为PretenureSizeThreshold被设置为3MB,因此超过3MB的对象都会直接在老年代中进行分配。

public class EdenAllocationTest
{
    private static final int _1MB = 1024 * 1024;
    
    public static void testAllocation()
    {
        byte[] allocation = new byte[4 * _1MB];
    }

 

3、长期存活的对象将进入老年代

虚拟机既然采用分代收集的思想来管理内存,那么内存回收时就必须能够识别哪些对象应当放在新生代,哪些对象应该放在老年代。

为了做到这一点,虚拟机给每个对象定义了一个对象年龄计数器。

如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能够被Survivor容纳的话,将被移动到Survivor空间中,

并将对象年龄设置为1.对象在Survivor区每熬过一次Minor GC,年龄就增加一岁,当它的年龄增加到一定程度(默认15岁)时,就会被晋升到老年代中。

对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold来设置。

我们可以分别以-XX:MaxTenuringThreshold=1和-XX:MaxTenuringThreshold=15两种设置来执行下面的代码中的testTenuringThreshould()方法,

此方法中allocation1对象需要分配256KB的内存空间,Survivor空间可以容纳。当MaxTenuringThreshold=1时,allocation1对象在第二次GC发生时进入老年代,

新生代已使用的内存GC后会非常干净地变成0KB.而MaxTenuringThreshold=15,第二次GC发生后,allocation1对象则还留在新生代Survivor空间。

public class EdenAllocationTest
{
    private static final int _1MB = 1024 * 1024;
    
    public static void testTenuringThreshold()
    {
        byte[] allocation1 = new byte[_1MB/4];
        byte[] allocation2 = new byte[4* _1MB];
        byte[] allocation3 = new byte[4 * _1MB];
        allocation3 =null;
        allocation3 = new byte[4 * _1MB];
    }
}

 

4、对象死亡的判断算法

最简单的对象判断的算法是采用计数法。

当对象被引用时,计数加1,当一个对象的引用计数为0时,表示该对象已经死亡,可以进行回收。

计数的方法虽然简单,易实现,但是却不能解决相互引用的问题,比如说对象A引用B,B也引用A,而A和B不再被其他对象引用,这种情况下,如果AB对象是可以被回收的,但是计数确不为0。

目前,通用的判断对象死亡的方法是可达性分析算法。可达性分析是指从对象起点开始,如果该对象可以被引用到,则该对象是活着的,否则,该对象则死亡了。

那么该算法中最基本的对象起点是哪些呢?这些对象是指虚拟机栈中引用的对象、方法区中引用的对象和本地方法栈中引用的对象。

为了能更好地适应不同程序的内存状况,虚拟机并不总是要求对象的年龄必须达到MaxTenuringThreshold才能晋升老年代,

如果在Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。

执行代码中的testTenuringThreshold2()方法,并设置参数-XX:MaxTenuringThreshould=15,会发现运行结果中的Survivor的空间占用仍然为0%,

二老年代比预期增加6%,也就是说allocation1、allocation2对象都直接进入老年代,而没有等到15岁的临界年龄。

因为这两个对象加起来达到512KB,并且他们是同年的,满足同年对象达到Survivor空间的一般规则。我们只要注释掉一个对象的new操作,就会发现另一个不会晋升到老年代中去了。

public class EdenAllocationTest
{
    private static final int _1MB = 1024 * 1024;
    
    public static void testTenuringThreshold2()
    {
        byte[] allocation1 = new byte[_1MB/4];
        byte[] allocation2 = new byte[_1MB/4];
        byte[] allocation3 = new byte[4 * _1MB];
        byte[] allocation4 = new byte[4 * _1MB];
        allocation4=null;
        allocation4 = new byte[4 * _1MB];
    }
}

 

5、空间分配担保

在发生Minor GC时,虚拟机就会检测之前每次晋升到老年代的平均大小是否大于老年代的剩余空间,

如果大于,则改为直接进行一次Full GC.如果小于,则查看HandlePromotionFailure设置是否允许担保失败;如果允许,那只会进行Minor GC:如果不允许,则也要改为进行一次Full GC.

前面提到过,新生代使用复制收集算法,但为了内存利用率,只使用了其中一个Survivor空间来作为轮换备份,

因此当出现大量对象在Minor GC后仍然存活的情况下,就需要老年代进行分配担保,让Survivor无法容纳的对象直接进入老年代。

但是前提老年代本身还有足够空间容纳这些对象。但是实际完成内存回收前是无法知道多少对象存活,所以只好取之前每一次回收晋升到老年代对象容量的平均值作为经验值,

与老年代的剩余空间进行比较,决定是否进行Full GC来让老年代腾出更多的空间。

取平均值进行比较其实仍然是一种动态概率手段,也就是说如果某次Minor GC存活后的对象突增,远远高于平均值的话,依然会导致担保失败(HandlePronotion Failuer)。

如果出现担保失败,那就只好在失败后重新发起一次Full GC.

6、Minor GC and Full GC

Minor GC:从年轻代空间(包括Eden和Survivor区域)回收内存成为Minor GC。在发生Minor GC时候,有两处需要注意的地方:

(1)当JVM无法为一个新的对象分配空间时会触发Minor GC,例如当Eden区满了,所以分配的频率越高,执行Minor GC的频率也可能越频繁。

(2)所有的Minor GC都会触发“stop-the-world”,停止应用程序的线程。对于大部分应用程序,停顿导致的延迟都是可以忽略不计的。

Full GC:对整个堆进行整理,包括Young Generation、Old Generation、Permanent Generation。

Full GC因为需要对整个区进行回收,所以比Minor GC要慢,因此应该尽可能减少Full GC的次数。

Minor GC和Full GC的区别:

(1)新生代Minor GC:指发生在新生代的垃圾收集动作,因为Java对象大多数都具有朝生夕灭的特性,多以Minor GC非常频繁,一般回收速度也比较快。

(2)老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC,经常都伴随着至少一次的Minor GC(但并非绝对的,

在ParallelScavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。MajorGC的速度一般会比Minor GC慢10倍以上。

 

三.关于对象

1、对象创建过程

(1)类加载检查

虚拟机遇到一条new指令时,首先将去检查这个指令的参数能否在常量池中定位到一个类的符号引用,

并检查这个符号引用代表的类是否已经被加载、解析和初始化过,如果没有,那么必须先执行相应的类加载过程。

(2)分配内存

在类加载检查通过后,接下来虚拟机将会为新生的对象分配内存。对象所需要的内存大小在类加载完成后便可完全确定,为对象分配空间等同于把一块确定大小的内存从java堆中划分出来。

分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择那种分配方式由 Java 堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

a、指针碰撞法

假设Java堆中内存是完整的,已分配的内存和空闲内存分别在不同的一侧,通过一个指针作为分界点,需要分配内存时,仅仅需要把指针往空闲的一端移动与对象大小相等的距离。

使用的GC收集器:Serial、ParNew,适用堆内存规整(即没有内存碎片)的情况下。

b、空闲列表法

事实上,Java堆的内存并不是完整的,已分配的内存和空闲内存相互交错,JVM通过维护一个空闲列表,记录可用的内存块信息,

当分配操作发生时,从列表中找到一个足够大的内存块分配给对象实例,并更新列表上的记录。使用的GC收集器:CMS,适用堆内存不规整的情况下。

Java 堆内存是否规整,取决于 GC 收集器的算法是”标记-清除”,还是”标记-整理”(也称作”标记-压缩”),值得注意的是,复制算法内存也是规整的。

在使用Serial、ParNew等待整理过程的收集器时,采用的是指针碰撞,在使用CMS这种mark-sweep算法的收集器时,使用的是空闲列表。

内存分配并发问题

在创建对象的时候有一个很重要的问题,就是线程安全,因为在实际开发过程中,创建对象是很频繁的事情,

例如正在给A对象分配内存,但是指针还没修改,这时候对象B可能使用原来的指针来分配内存的情况。作为虚拟机来说,必须要保证线程是安全的,通常来讲,虚拟机采用两种方式来保证线程安全:

CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是,每次不加锁而是假设没有冲突而去完成某项操作,如果因为冲突失败就重试,直到成功为止。虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性。

TLAB: 为每一个线程预先在 Eden 区分配一块内存。JVM 在给线程中的对象分配内存时,首先在各个线程的TLAB 分配,当对象大于TLAB 中的剩余内存或 TLAB 的内存已用尽时,再采用上述的 CAS 进行内存分配。虚拟机是否启用TLAB,可以通过-XX:+/-UseTLAB参数来设定。

(3)初始零值

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头),这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,

程序能访问到这些字段的数据类型所对应的零值。如果使用TLAB,这一工作过程也可以提前到TLAB分配时进行。

(4)设置对象头

接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例,如何才能找到类的元数据信息,对象的哈希吗,

对象的GC分代年龄等信息,这些信息存放在对象的对象头中。根据虚拟机当前的运行状态的不同,对象头会有不同的设置方式。

(5)执行init方法

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚开始,<init> 方法还没有执行,所有的字段都还为零。

所以一般来说,执行 new 指令之后会接着执行 <init> 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

 

2、对象的内存布局

在HotSpot虚拟机中,对象在内存中存储的布局可以分为三个区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

(1)HotSpot虚拟机的对象头包括两部分信息:

第一部分用来存储对象自身的运行时数据,例如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据在32位和64的虚拟机中分别为32bit和64bit,成为Mark Word。

另一部分是类型指针,即对象指向他的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。 

(2)实例数据部分存储的是对象真正有效的信息,也是在程序代码中定义的各种类型的字段内容。无论从父类中继承下来的,还是在子类中定义的都需要记录下来。

(3)对齐填充并不是必须的部分,没有特别的含义,仅仅起着占位符的作用,因为 Hotspot 虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍,

换句话说就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

 

3、对象的访问定位

建立对象就是为了使用对象,Java程序中需要通过栈上的reference引用数据来操作堆上的具体对象。对象的访问方式取决于虚拟机的实现,主流的方式有句柄池和直接指针两种。

(1)句柄池。如果使用句柄池的话,java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。

 

 (2)直接指针。如果使用直接指针,那么Java堆中的对象的布局就必须考虑如何放置访问类型数据的相关信息,而reference中存储的就是对象的直接地址。

使用句柄访问最大的好处就是reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要修改。

使用直接指针最大的好处就是速度更快,节省了一次指针定位的时间开销。

 

posted on 2020-05-21 17:11  战狂粗人张  阅读(736)  评论(1编辑  收藏  举报