新生代内存管理
新生代内存管理包含了内存的分配和回收,这与新生代内存布局密切相关。
新生代被划分为3个空间:Eden、From和To空间。
这3个空间的作用如下:
1)Eden:仅用于应用程序对象分配;GC工作线程不会在该空间进行对象分配。
2)From:用于GC工作线程在执行垃圾回收时,在前一轮垃圾回收后活跃对象的存储。在特殊情况下,From空间也可以用于应用程序对象的分配(这是JVM在实现对象分配时的一种优化),但GC工作线程不会在该空间进行对象分配。
3)To:用于在GC工作线程执行垃圾回收时,存储本轮垃圾回收过程中活跃的对象。垃圾回收过程将Eden空间和From空间中的活跃对象放入To空间。
只有GC工作线程能在该空间进行对象分配,应用程序不能使用该空间进行对象分配。
串行回收使用单线程进行垃圾回收。Java语言支持多线程应用,应用分配对象的空间通常是Eden空间(此处暂不讨论JVM中From空间的优化使用),多个线程同时在一个空间中分配对象,需要设计高效的分配算法来提高应用程序的运行效率。新生代的高速分配算法实际上不仅包含在堆空间中进行对象分配,还包含对新生代堆空间进行垃圾回收后内存的再访问机制(主要指回收后访存的效率)。整体分配算法包含:高速无锁分配、加锁慢速分配、内存不足情况下的垃圾回收后再分配。JVM的内存分配流程图如图3-5所示。
图3-5 JVM内存分配流程示意图
设计3种分配方式的目的如下:
1)优先进行高速无锁分配,这是我们期望的情况,在这种场景中效率最高,具体内容在3.2.1节讨论。
2)当内存不足时,会在进行垃圾回收之后重用内存空间并再次进行分配,这将在3.2.2~3.2.6节讨论。
3)加锁的慢速分配是一个中间状态,主要用于解决:当Mutator直接在堆空间进行内存分配时需要互斥锁(同时也要保证多个Mutator之间竞争的公平性,防止某一个Mutator因为并发锁一直无法成功分配);在整个JVM运行期间可能已经有其他Mutator因内存不足触发了垃圾回收,通常进行垃圾回收之后有大量可以使用的内存,在这种情况下,Mutator可以在加锁的情况下直接完成分配,该状态是设计和实现的一个优化点。
新生代内存分配
堆内存中供应用分配对象的空间只有一个(即Eden),而Mutator是多个同时执行,这意味着存在多个Mutator同时在Eden中分配对象的情况,因为Eden属于临界资源,在使用临界资源时需要互斥锁。使用互斥锁的结果就是多个Mutator需要按照内存分配请求的顺序串行执行,而这样的设计将导致Mutator的运行效率较低,所以JVM需要寻找一种高速的无锁内存分配方法来解决多个Mutator互斥访问Eden的问题。这种高速无锁分配在JVM中称为TLAB(
Thread-Local-Allocation-Buffer),在其他资料中也称为TLS(Thread-Local-Storage)或者TLH(Thread-Local-Heap)。
TLAB的设计思路就是为每个Mutator分配一个专有的本地缓冲区,每个Mutator在对象分配的时候,优先从本地的缓冲区进行分配,只有在第一次从堆空间中初始化TLAB时才需要加锁分配,这样将大大减少多个Mutator之间分配时的互斥问题。多个Mutator使用TLAB的示意图如图3-6所示。
图3-6 多Mutator使用TLAB进行对象分配示意图
线程(Thread1和Thread2)分别从Eden中分配一个TLAB,Thread1和Thread2的内存分配都是从自己的TLAB中分配的。
虽然使用TLAB的分配方式能减少多个Mutator之间的互斥锁,但是也带来了设计上的复杂性。有两个需要特别注意的地方:
1)TLAB的大小。如果TLAB太小,那么缓冲区很快被填满,需要再次从堆空间请求一个新的TLAB。频繁地从堆空间请求TLAB将导致潜在的锁冲突,从而导致性能下降。如果TLAB过大,虽然不会导致频繁的锁冲突,但是可能导致TLAB一直填不满,存在潜在的空间浪费。
2)何时申请新的TLAB。简单的回答是在TLAB使用完了之后就申请一个新的TLAB。但是判断TLAB是否使用完毕并不容易,原因在于TLAB的大小是固定的,而应用中请求的对象大小并不固定,这就意味着TLAB通常无法完美地被使用完毕,在TLAB即将使用完毕的时候,剩余的大小并不固定。也就是说在TLAB即将用完的时候,需要一个机制判断是否需要申请新的TLAB。通过一个简单的示意图演示该问题,如图3-7所示。
图3-7 TLAB无法满足分配请求示意图
图3-7中演示了TLAB剩余的空间不满足Mutator新对象的分配场景,此时该如何处理?这就需要一个机制来判断是否申请新的TLAB。通常做法如下:
当剩余空间比较少时,直接申请一个新的TLAB,丢弃原来TLAB中剩余的空间。
当剩余空间比较多时,如果直接申请一个新的TLAB,放弃原来的TLAB将导致空间浪费。在这种情况下,为了减少空间的浪费,通常不会申请一个新的TLAB,而是直接在堆空间进行对象分配(当然分配时需要对堆空间进行加锁)。这是典型的用时间(加锁分配)换空间(TLAB剩余空间)的做法。
Mutator从堆空间直接分配TLAB并使用TLAB响应应用的分配,当TLAB满了以后,无须进行额外的处理。因为TLAB来自堆空间,在进行垃圾回收的时候会对堆空间进行回收,所以无须进行额外的处理。唯一需要额外处理的是在丢弃TLAB中尚未使用的空间时,需要给剩余空间填充一个垃圾对象(也称为Dummy对象),这样做的目的是保持堆的可解析性(Heap Parsability)。
多线程使用TLAB过程中TLAB满的例子如图3-8所示。假设有两个线程Thread1(简称T1)和Thread2(简称T2),它们都是应用程序线程,在运行时都需要一个TLAB,应用程序线程分配对象都在TLAB中,T1的TLAB在分配对象的时候,因为剩余空间不足以满足对象的大小,所以直接在堆空间Eden中直接分配;此时T1的TLAB仍然指向最初的TLAB;T2的第一个TLAB已经满了(或者说剩余空间比较少,填充Dummy对象之后满了),重新分配一个新的TLAB供新的分配。
图3-8 多线程使用TLAB过程中TLAB满的例子
在JVM的实现中,TLAB的初始大小可以通过参数(TLABSize)调整。另外,JVM也可以通过反馈机制动态调整TLAB的大小(如果允许动态调整TLAB的大小,则需要确保参数ResizeTLAB为true),从而在时间(加锁耗时)和空间(高速无锁分配、空间浪费)之间寻找一个平衡。在判断是否可以丢弃当前TLAB剩余空间的时候,当发现剩余空间小于TLAB的一定比例时,就认为浪费比较少了,可以直接丢弃(参数为TLABRefillWasteFraction,默认值为64,即剩余空间小于等于TLAB的1/64时可以丢弃)。
最后简单解释一下JVM中堆可解性的概念。在JVM运行过程中存在很多需要对堆空间进行遍历的情况,遍历时会从一个起始地址(假设起始地址为heap_start)遍历到终止地址(假设终止地址为heap_end)之间的内存空间。假设在遍历堆空间时进行一些额外的处理,其具体的工作由do_object处理(具体的处理省略)。一个典型的代码如下所示:
HeapWord* cur = heap_start;
while (cur < heap_end) { //遍历整个空间
object o = (object)cur;
do_object(o);
cur = cur + o->size(); //在这里需要空间里面的对象连续
//如果存在空洞,将在此处导致遍历错误
}
在遍历的时候要求堆空间中的对象是连续分配的,如果堆空间中存在空洞(hole),那么上述代码就不能正常工作(空洞会被转化为对象,导致内存访问错误)。所以在处理TLAB剩余空间的时候必须填充一个对象让上述代码能正常运行,这种机制称为堆可解析性(通常填充一个int[]的对象,这个对象是JVM内部产生的,读者可能遇到应用中根本没有分配int[]对象,但是在转存(dump)堆内存时看到很多int[]对象的情况,原因之一就是JVM在处理TLAB时填充了大量死亡的int[]对象)。
本文给大家讲解的内容是JVM垃圾回收器详解:串行回收,新生代内存管理内存分配
- 下篇文章给大家讲解的内容是JVM垃圾回收器详解:串行回收,新生代内存管理,垃圾回收的触发机制
- 感谢大家的支持!
- 本文就是愿天堂没有BUG给大家分享的内容,大家有收获的话可以分享下,想学习更多的话可以到微信公众号里找我,我等你哦。