大部分的Java对象只存活一小段时间,而存活下来的小部分Java对象则会存活很长时间.
虚拟机的分代思想
将堆空间划分为两代, 分别是新生代和老年代. 新生代用来存储新建的对象, 当对象存活的时间足够长,则将其移动到老年代.
Java虚拟机可以给不同的代使用不同的回收算法.
对于老年代,大部的垃圾已经在新生代中被回收了,而在老年代中的对象有大概率会继续存活.当真正触发针对老年代回收时,堆空间已经耗尽了.
这个时候,Java虚拟机往往需要做一次全堆扫描,耗时也就不讲成本.
Java虚拟机堆的划分
1.新生代
2.Eden
3.Survivor
4.Survivor
5.老年代
需要说明的是, 新生代中的两个Survivor区大小是相同的.
默认情况下, Java虚拟机采用的是一种动态分配的策略**「对应Java虚拟机参数 -XX:UsePSAdaptiveSurvivorSizePolicy」, 根据生成对象的速率,以及Servivor区的使用情况动态调整Eden区和Survivor区的比例.**
当然也可以通过参数「-XX:SurvivorRatio」来固定这个比例.但是要特别注意的是其中一个Survivor区会一直为空,因此比例越低浪费的堆空间越高.
Java堆内存划分
当调用new指令时,它会在Eden区中划出一块作为存储对象的内存. 由于堆空间是线程共享的,因此直接在这里边划空间是需要进行同步的.
如不进行同步操作, 则有可能出现两个对象共用一段内存的事故.就好像两个司机「线程」将车同时停入一个停车位,而发生事故.
为了避免以上的事故发生,Java虚拟机的解决方法是为每个司机「线程」预先申请多个停车位,并且只允许该司机「线程」停在自己的停车位.那么如果当司机的停车位不够了怎么办?解决方案就是:再申请多个停车位就可以了.这项技术被称为TLAB「Thread Local Allocation Buffer」, 对应虚拟机参数「-XX:+UseTLAB, 默认开启状态」.
具体来说, 每个线程都可以向Java虚拟机申请一段连续的内存,比如2048字节,作为线程私有的TLAB.
这个操作需要加锁,线程需要维护两个指针(实际上可能更多, 但是重要的也就两个), 一个指向TLAB中空余内存的起始位置, 一个则指向TLAB末尾.
接下来的new指令,可以通过指针加法(碰撞)来实现,即把指向空余内存的位置指针加上所请求的字节数.
如果加法后空余内存指针的值仍然小于或者等于指向末尾的指针,则代表分配成功.否则, TLAB已经没有足够的空间来满足本次新建操作.这个时候,便需要当前线程重新申请新的TLAB.
当Eden区的空间耗尽了,这个时候Java虚拟机便会触发一次Minor GC,来收集新生代的垃圾,存活下来的,则会送到Survivor区.
前面了解到, 新生代共有两个Survivor区,分别使用from和to来指代, 其中to指向的Survivor区是空的.
当发生Minor GC时, Eden区和from指向的Survivor区中的存活对象对象会被复制到to所指向的Survivor区中, 然后交换from和to的指针,以保证下一次Minor GC时,to所指向的Survivor区还是空的.
Java虚拟机会记录Survivor区中的对象一共被来回复制了几次.如果一个对象被复制的次数为15(对应虚拟机参数-XX:+MaxTenuringThreshold), 那么该对象将被晋升「promote」至老年代.另外, 如果单个Survivor区已经被占用了50%(对应虚拟机参数-XX:TargetSurvivorRatio), 那么回较高复制次数的对象也会被晋升至老年代.
总而言之,当发生Minor GC时, 我们应用了标记-复制算法,将Survivor区中的老存活对象晋升到老年代,然后将剩下的存活对象和Eden区的存活对象复制到另一个Survivor区中.理想情况下,Eden区的对象基本都死亡了,那么需要复制的数据将非常少,因此采用这种标记-复制算法的效果极好.
Minor GC的另外一个好处是不用对整个堆进行垃圾回收.
但是会存在着一个缺点,老年代的对象可能引用新生代的对象.也就是说,在标记存活对象的时候,我们需要扫描老年代中的对象, 如果该对象拥有对新生代对象的引用,那么这个引用也会被作为GC Roots.
如果是这样的话,虚拟机又会做一次全堆扫描.那么又该如何解决呢?
卡表
HotSpot给出的解决方案是一项叫做卡表「Card Table」的技术.
将整个堆划分为一个个大小为512字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位.这个标识位代表对应的卡是否可能存有指向新生代对象的引用.如果可能存在, 那么我们就认为这张卡是脏的.
进行Minor GC的时候, 便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到Minor GC的GC Roots里.当完成所有脏卡的扫描之后, Java虚拟机便会将所有脏卡的标识位清零.
由于Minor GC伴随着存活对象的复制,而复制需要更新指向该对象的引用.因此在更新引用的同时,我们又会设置引用所在的卡的标识位.这个时候,可以确保脏卡中必须包含指向新生代对象的引用.
在Minor GC之前, 并不能确保脏卡中包含指向新生代对象的引用.其原因和如何设置卡的标识位有关.
首先, 如果想保证每个可能有指向新生代对象引用的卡都被标记为脏卡,那么Java虚拟机需要截获每个引用型实例变量的操作,并作出对应的写标识位操作.
这上操作在解释执行器中比较容易实现.但是在即时编译器生成的机器码中, 则需要插入额外的逻辑.这也是所谓的写屏障「write barrier, 注意不要和volatile字段的写屏障混淆」.
写屏蔽需要尽可能地保持简洁.这是因为并不希望在每条引用型实例变量的写指令后跟着大串注入指令.
因此, 写屏障并不会判断更新后的引用是否指向新生代中的对象,而是宁可错杀,不可放过,一律当成可能指向新生代对象的引用.
虽然写屏障会带来一些开销, 但是它能够加大Minor GC的吞吐率
转载自知乎 作者天行健.