深入拆解JVM

深入拆解JVM

Java对象的内存布局

  1. Java创建对象的方式
    1. new语句
      1. new语句编译成的字节码包含用来请求内存的new指令以及用来调用构造器的invokespecial指令
    2. 反射机制
    3. Object.clone方法
    4. 反序列化
    5. Unsafe.allocateInstance 方法
    6. 其中Object.clone方法和反序列化是通过复制已有的数据来初始化新建对象的实例字段;new语句和反射机制是通过构造器初始化新建对象的实例字段;而Unsafe.allocateInstance 方法不会初始化新建对象的实例字段。
  2. Java对构造器的约束
    1. 如果一个类没有定义任何构造器,Java 编译器会自动添加一个无参数的构造器。
    2. 子类的构造器需要调用父类的构造器。如果父类存在无参构造器的话,该调用可以是隐式的,也就是说Java 编译器会自动添加对父类构造器的调用。但是,如果父类没有无参构造器,那么子类的构造器要显式地调用父类带参数的构造器。
      显式调用分为两种:1)一是使用“super”关键字直接调用父类构造器
      2)二是使用“this”关键字调用同一个类的其他构造器,间接调用父类构造器。无论是直接调用还是间接调用,都需要作为构造器的第一条语句,以便优先初始化父类字段。
      总而言之,当我们调用一个构造器时,它将优先调用父类的构造器,直至Object 类。这些构造器的调用者皆为new 指令新建而来的对象。
      通过new 指令新建出来的对象,它的内存其实涵盖了所有父类中的实例字段。也就是说,虽然子类无法访问父类的私有实例字段,或者子类的实例字段隐藏了父类的同名实例字段,但是子类的实例还是会为这些父类实例字段分配内存的。
  3. 压缩指针
    1. 作用
      1)在Java 虚拟机中,每个Java 对象都有一个对象头,对象头包括标记字段和类型指针。其中,标记字段用来存储该对象的运行数据,如哈希码、GC 信息以及锁信息,而类型指针则指向该对象的类。
      2)在64 位的Java 虚拟机中,对象头的标记字段占64 位,而类型指针又占了64 位。也就是说,每一个Java 对象的对象头的内存开销就是16个字节。以Integer 类为例,它仅有一个int 类型的私有字段,占4 个字节,而一个Integer对象的对象头就要占16个字节非常浪费内存,这就是Java引入基本类型的原因之一。
      3)为了减少对象的内存使用,64位的Java虚拟机引入了压缩指针的概念对应虚拟机选项-XX:+UseCompressedOops,默认开启),将对象指针、类型指针压缩成32位,这样对象头的大小就从16字节降至12字节、对象指针也从8字节降至4字节。
    2. 内存对齐(对应虚拟机选项-XX:ObjectAlignmentInBytes,默认值为8)
      默认情况下,堆中对象的起始地址需要对齐至8 的倍数。如果一个对象用不到8N 个字节,那么空白的那部分空间就浪费掉了。这些浪费掉的空间我们称之为对象间的填充。
      在默认情况下,虚拟机中的32 位压缩指针可以寻址到2 的35 (32+3)次方个字节,也就是32GB 的地址空间(超过32GB 则会关闭压缩指针)。
      此外,我们可以通过配置刚刚提到的内存对齐选项(-XX:ObjectAlignmentInBytes)来进一步提升寻址范围。但是,这同时也可能增加对象间填充,导致压缩指针没有达到原本节省空间的效果。
      内存对齐不仅存在于对象并且存在于对象中的字段。比如说,Java 虚拟机要求long 字段、double 字段,以及非压缩指针状态下的引用字段地址为8 的倍数。原因就是让字段只出现在同一CPU 的缓存行中。如果字段不是对齐的,那么就有可能出现跨缓存行的字段。也就是说,该字段的读取、存储会涉及两个缓存行,对程序的执行效率而言都是不利的。
      就算是关闭了压缩指针,Java 虚拟机还是会进行内存对齐。
    3. 字段重排列
      字段重排列,就是Java 虚拟机重新分配字段的顺序,以达到内存对齐的目的。有三种排列方法(对应Java 虚拟机选项-XX:FieldsAllocationStyle,默认值为1),但都会遵循如下两个规则。
      1)如果一个字段占据C 个字节,那么该字段的偏移量需要对齐至NC。这里偏移量指的是字段地址与对象的起始地址差值。
      2)子类继承的字段的偏移量,和父类对应字段的偏移量保持一致。

垃圾回收-上

  1. 如何判断对象是死是活?
    垃圾指的是死亡对象占据的堆空间。
    1. 引用计数法
      1. 思想
        为每个对象添加一个引用计数器,用来统计指向该对象的引用个数。一旦某个对象的引用计数器为0,则说明该对象已经死亡,便可以被回收了。
      2. 具体实现:如果有一个引用,被赋值为某一对象,那么将该对象的引用计数器+1。如果一个指向某一对象的引用,被赋值为其他值,那么将该对象的引用计数器-1。也就是说,我们需要截获所有的引用更新操作,并且相应地增减目标对象的引用计数器。
      3. 坏处:除了需要额外的空间来存储计数器,以及频繁的更新计数器的值。引用计数法还有一个重大的漏洞,那便是无法处理循环引用对象。
        循环引用举例:假设对象a 与b 相互引用,除此之外没有其他引用指向a 或者b。在这种情况下,我们无法操作a和b,a和b实际上已经死亡,但是由于它们的计数器都不为0所以JVM不认为它们死了不会进行回收,这就造成了内存泄漏。
    2. 可达性分析算法
      1. 思想
        将一系列GCRoots 作为初始的存活对象集合,然后从该集合出发探索所有能够被该集合中的对象引用的对象,并将其加入到该集合中

垃圾回收-下

  1. 大量数据表明,大部分Java对象只存活一小段时间,而存活下来的小部分Java 对象则会存活很长一段时间。
    根据该情况,提出了分代回收思想,就是将堆分为新生代和老年代。新生代用来存储新建的对象。当对象存活时间够长时,则将其移动到老年代。
  2. Java 虚拟机的堆划分
    1. Java 虚拟机将堆划分为新生代和老年代。其中,新生代又被划分为Eden 区,以及两个大小相同的Survivor 区。
    2. 默认情况下,Java 虚拟机采取的是一种动态分配的策略(对应Java 虚拟机参数-XX:+UsePSAdaptiveSurvivorSizePolicy),根据对象的生成速率,以及Survivor 区的使用情况动态调整Eden 区和Survivor 区的比例。也可以通过参数-XX:SurvivorRatio 来固定这个比例,但是需要注意其实一个Survivor 区会一直为空,因此比例越低浪费的堆空间越多。
    3. 当我们调用new 指令时,JVM会在Eden 区中划出一块内存用来存储对象,但是堆内存是线程共享的,如果两个线程同时在Eden区划分内存,可能划分到同一块,这就出问题了。JVM的解决方法是预先为每个线程分配一些内存,如果分配的内存不够,线程可以找JVM继续申请,这项技术被称之为TLAB(Thread Local Allocation Buffer,对应虚拟机参数-XX:+UseTLAB,默认开启)。
    4. 每个线程需要维护两个指针(实际可能更多,但重要的就两个),一个指向TLAB中剩余内存的起始位置,一个指向TLAB的末尾。
      执行new指令的时候,直接通过指针加法来实现,即把指向剩余空间起始位置的指针加上对象需要的空间字节数,如果加法后指向剩余内存起始地址的指针仍然小于等于末尾的指针,则代表分配成功。否则,说明TLAB内存不足,需要当前线程申请新的TLAB。
  3. 新生代垃圾回收
    1. 当Eden 区的空间满了的时候,JVM会触发一次Minor GC,对新生代的垃圾对象进行回收。存活下来的对象,会被送到Survivor 区。
    2. 新生代有两个Survivor 区,分别用from 和to 来指代。其中to 指向的Survivior区是空的。
      当发生Minor GC 时,Eden 区和from 指向的Survivor 区中的存活对象会被复制到to 指向的Survivor 区中,然后交换from 和to 指针,以保证下一次Minor GC 时,to 指向的Survivor 区还是空的。
    3. JVM会记录Survivor 区中的对象一共被来回复制了几次。如果一个对象被复制的次数为15(对应虚拟机参数-XX:+MaxTenuringThreshold),那么该对象将被晋升至老年代。另外,如果单个Survivor 区已经被占用了50%(对应虚拟机参数-XX:TargetSurvivorRatio),那么较高复制次数的对象也会被晋升至老年代。
    4. 总而言之,当发生Minor GC 时,JVM采用标记-复制算法,将Survivor 区中的老存活对象晋升到老年代,然后将剩下的存活对象和Eden 区的存活对象复制到另一个Survivor 区中。
    5. Minor GC 的另外一个好处是不用对整个堆进行垃圾回收。但是,它却有一个问题,那就是老年代的对象可能引用新生代的对象。也就是说,在新生代标记可达对象的时候,需要扫描老年代中的对象,如果老年代的对象引用了新生代的对象,那么这个老年代对象也会作为GC Roots。
  4. 解决全堆扫描问题-卡表
    1. HotSpot 给出的解决方案是一种叫做卡表的技术。该技术将整个堆划分为一个个大小为512 字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。这个标识位代表对应的卡是否存在指向新生代对象的引用,如果可能存在,那么就认为这张卡是脏的。
    2. 在进行Minor GC 的时候,就不需要扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到Minor GC 的GC Roots 里。当完成所有脏卡的扫描之后,JVM会将所有脏卡的标识位清零。
    3. 由于Minor GC 伴随着存活对象的复制,而复制需要更新指向该对象的引用。因此,在更新引用的同时,我们又会设置引用所在的卡的标识位。这个时候,我们可以确保脏卡中必定包含指向新生代对象的引用。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值