大部分的Java对象只存活一小段时间,而存活下来的小部分Java对象则会存活很长一段时间。
JVM的分代回收思想:将堆空间划分为两代,分别叫做新生代和老年代。新生代用来存储新建的对象。当对象存活时间够长时,则将其移动到老年代。
JVM可以给不同代使用不同的回收算法。
- 对于新生代,大部分的Java对象只存活一小段时间,可以频繁地采用耗时较短的垃圾回收算法,让大部分的垃圾都能够在新生代被回收掉。
- 对于老年代,大部分的垃圾已经在新生代中被回收了,而在老年代中的对象有大概率会继续存活。当真正触发针对老年代的回收时,说明堆空间已经耗尽了。JVM需要做一次全盘扫描,耗时也将不计成本。(现代垃圾回收器采用并发收集,避免这种全堆扫描的情况)
JVM的堆划分
JVM将堆划分为新生代和老年代。其中新生代又被划分为Eden区,以及两个大小相同的Survivor区。
默认情况下,JVM采取的是一种动态分配的策略(对应JVM参数-XX:+UserPSAdaptiveSurvivorSizePolicy),根据生成对象的速率,以及Survivor区的使用情况动态调整Eden区和Survivor区的比例。
也可以通过参数-XX:SurvivorRatio来固定这个比例。其中一个Survivor区会一直为空,因此比例越低,浪费的堆空间将越高。
通常来说,当调用new指令时,它会在Eden区中划出一块作为存储对象的内存。由于堆空间是线程共享的,因此直接在这里边划空间是需要进行同步的。否则,将有可能出现两个对象共用一段内存的事故。
TLAB
TLAB(Thread Local Allocation Buffer,对应虚拟机参数-XX:+UseTLAB,默认开启)。每个线程可以一次性向JVM申请一段连续的内存,比如2048字节,作为线程私有的TLAB。
MySQL,批量生成自增id,一次性生成多个。
数据结构与算法,令牌桶算法优化,一次性放入多个令牌
这个操作需要加锁,线程需要维护两个指针(实际上可能更多,最重要的有两个),一个指向TLAB中空余内存的起始位置,一个则指向TLAB末尾。
接下来的new指令,便可以直接通过指针加法(bump the pointer)来实现,即把指向空余内存位置的指针加上所请求的字节数。如果加法后空余内存指针的值仍小于或等于指向末尾的指针,则代表分配成功。否则,TLAB已经没有足够的空间来满足本次新建操作。这个时候,便需要当前线程重新申请新的TLAB。
Minor GC
当Eden区的空间耗尽了,JVM便会触发一次Minor GC,来收集新生代的垃圾。存活下来的对象,则会被送到Survivor区。
新生代共有两个Survivor区,分别用from和to代替,其中to指向的Survivor区是空的。
当发生Minor GC时,Eden区和from指向的Survivor区中的存活对象会被复制到to指向的Survivor区中,然后交换from和to指针,以保证下一次Minor GC时,to指向的Survivor区还是空的。
JVM会记录Survivor区中的对象一共被来回复制了几次。如果一个对象被复制的次数为15(对应虚拟机参数-XX:+MaxTenuringThreshold)(对象头中标记字段记录年龄,分配的空间只有4位,最大值15),那么该对象将被晋升(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里。当完成所有脏卡的扫描之后,JVM便会将所有脏卡的标识位清零。
由于Minor GC伴随着存活对象的复制,而复制需要更新指向该对象的引用。因此,在更新引用的同时,又会设置引用所在的卡的标识位。这个时候,可以确保脏卡中必定包含指向新生代对象的引用。
在Minor GC之前,并不能确保脏卡中包含指向新生代对象的引用。其原因和如何设置卡的标识位有关。
写屏障
如果要保证每个可能有指向新生代对象引用的卡都被标记为脏卡,那么JVM需要截获每个引用型实例变量的写操作,并作出对应的写标识位操作。
这个操作在解释执行器中比较容易实现。但是在即时编译器生成的机器码中,则需要插入额外的逻辑。写屏障(write barrier;不同于volatile字段的写屏障)
写屏障需要尽可能的保持简洁。并不希望在每条引用型实例变量的写指令后跟着一大串注入的指令。写屏障并不会判断更新后的引用是否指向新生代中的对象,而是宁可错杀,不可放过,一律当成可能指向新生代对象的引用。
1 | CARD_TABLE [this address >> 9] = DIRIY;//写屏障伪代码 |
右移9位相当于除以512,JVM便是通过这种方式来从地址映射到卡表中的索引的。最终这段代码会被编译成一条移位指令和一条存储指令。
虚共享
写屏障不可避免的带来一些开销,但是它能够加大Minor GC的吞吐率(应用运行时间/(应用运行时间+垃圾回收时间))。总的来说是值得的。但在高并发环境下,写屏障又带来了虚共享问题。
对象内存布局中虚共享问题,几个volatile字段出现在同一缓存行里造成的虚共享。这里的虚共享是指卡表中不同卡的标识位之间的虚共享问题。
在HotSpot中,卡表是通过byte数组来实现的。对于一个64字节的缓存行来说,如果用它来加载部分卡表,那么它将对应64张卡(>>9后一个字节可以表示512字节),也就是32KB的内存。
如果同时有两个Java线程,在这32KB内存中进行引用更新操作,那么也将造成存储卡表的同一部分的缓存行的写回、无效化或者同步操作,因而间接影响程序性能。
为此,HotSpot引入了一个新的参数-XX:+UseCondCardMark,来尽量减少写卡表的操作。
1 | if(CARD_TABLE [this address >> 9] != DIRIY) |
总结
JVM将堆分为新生代和老年代,并且对不同代采用不同的垃圾回收算法。其中,新生代分为Eden区和两个大小一致的Survivor区,并且其中一个Survivor 区是空的。
在只针对新生代的Mintor GC中,Eden区和非空Survivor区的存活对象会被复制到空的Survivor区中,当Survivor区中的存活对象复制次数超过一定数值时,它将被晋升至老年代。
因为Mintor GC只针对新生代进行垃圾回收,所以在枚举GC Roots的时候,它需要考虑从老年代到新生代的引用。为了避免扫描整个老年代,JVM引入了名为卡表的技术,大致地标出可能存在老年代到新生代引用的内存区域。
JVM中的垃圾回收器
针对新生代的垃圾回收器:Serial、Parallel New、Parallel Scavenge。这三个采用的都是标记-复制算法。其中Serial是一个单线程的,Parallel New可以看成Serial的多线程版本。Parallel Scavenge和Parallel New类似,但更加注重吞吐率,Parallel Scavenge不能与CMS一起使用。
针对老年代的垃圾回收器:Serial Old、Parallel Old、CMS。Serial Old和Parallel Old都是标记-压缩算法。前者是单线程的,后者可以看成前者的多线程版本。
CMS(Concurrent Mark Sweep)采用的是标记-清除算法,并且是并发的。除了少数几个操作需要Stop-the-world之外,它可以在应用程序运行过程中进行垃圾回收。在并发收集失败的情况下,JVM会使用其他两个压缩型垃圾回收器进行一次垃圾回收。有G1的出现,CMS在Java 9中已经被废弃。
G1(Garbage First)是一个横跨新生代和老年代的垃圾回收器。实际上它已经打乱了堆结构,直接将堆分成极其多个区域。每个区域都可以充当Eden区、Survivor区或者老年代中的一个。它采用的是标记-压缩算法,而且和CMS一样都能够在应用程序运行过程中并发的进行垃圾回收。
G1能够针对每个细分的区域来进行垃圾回收。在选择进行垃圾回收的区域时,它会优先回收死亡对象较多的区域。名字的由来。
Java11引入了ZGC。
