Java 虚拟机的堆划分
Java 虚拟机将堆划分为新生代和老年代。其中新生代又被划分为 Eden 区,以及两个大小相同的 Survivor 区。
默认情况下,Java 虚拟机采取一种动态分配的策略,根据对象生成的速率,以及 Survivor 区的使用情况动态调整 Eden 区和 Survivor 区的比例。也可以通过参数 -XX:SurvivorRatio 来固定这个比例。需要注意的是,其中一个 Survivor 区会一直为空,因此比例越低浪费的空间越高。
当调用 new 指令时,它会在 Eden 区中划出一块作为存储对象的内存。由于堆内存是线程共享的,因此直接在这里划分空间是需要进行同步的。否则会出现两个对象公用一段内存的事故。
Java 虚拟机的解决方法是:每个线程可以向 Java 虚拟机申请一段连续的内存,比如 2048 字节,作为线程私有的 TLAB。这个操作需要加锁,线程需要维护两个重要的指针,一个指向 TLAB 中空余内存的起始位置,一个则指向 TLAB 末尾。
然后通过 new 指令,便可以直接通过指针加法来实现,即把指向空余内存位置的指针加上所请求的字节数。如果加法后空余内存指针的值扔小于或等于指向末尾的指针,则代表分配成功。否则,TLAB 以及没有足够的空间来满足本次新建操作。这个时候,便需要当前线程重新申请新的 TLAB。
当 Eden 区的空间耗尽了,这个时候 Java 虚拟机便会触发一次 Minor GC,来收集新生代的垃圾。存活下来的对象,则会被送到 Survivor 区。当发生 MinorGC时,Eden 区和 from 指向的 Survivor 区中的存活对象会被复制到 to 指向的 Survivor 区中,然后交换 from 和 to 指针,以保证下一次 Minor GC 时,to 指向的 Survivor 区还是空的。
Java 虚拟机会记录 Survivor 区中的对象一共被来回复制了几次。如果一个对象被复制 15 次,那么该对象将被晋升至老年代。如果单个 Survivor 区已经被占用了 50%,那么较高复制次数的对象也会被晋升至老年代。
Minor GC 有一个问题,那就是老年代的对象可能引用新生代的对象。在标记存活对象的时候,我们需要扫描老年代中的对象。如果该对象拥有对新生代对象的引用,那么这个引用也会被作为 GC Roots。这样的话,就相当于进行了一次全堆扫描。
卡表
针对上述的问题,HotSpot 给出了一种解决方案叫做卡表。该技术将整个堆划分为一个个大小为 512 字节的卡,并且维护一个卡表,用来存储每张卡的一个标示位。这个标示位代表对应的卡是否可能存在指向新生代对象的引用。如果可能存在,那么我们就认为这张卡是脏的。
在进行 Minor GC 的时候,我们不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到 Minor GC 的 GC Roots 里。当完成所有脏卡的扫描之后,Java 虚拟机便会将所有脏卡的标示位清零。
上述总结介绍了用卡表这种方式解决全堆扫描效率低下的问题,置于如何标记脏卡,如何更新脏卡就不做深入总结了。
问答
Q:请问JVM分代收集新生代对象进入老年代,年龄为什么是15而不是其他的?
HotSpot会在对象头中的标记字段里记录年龄,分配到的空间只有4位,最多只能记录到15
Q:GC ROOT到底指的是对象本身,还是引用?
严格来说应该是对象。像局部变量中存放的引用只是导致对象成为GC roots的原因。我个人倾向于将这些引用作为GC roots,因为GC是从这些地方出发开始探索的。看各人理解方便吧。
总结
本文创作灵感来源于 极客时间 郑雨迪老师的《深入拆解 Java 虚拟机》课程,通过课后反思以及借鉴各位学友的发言总结,现整理出自己的知识架构,以便日后温故知新,查漏补缺。