在Java虚拟机中,我是一个位高权重的大管家,他们都很怕我,尤其是那些Java 对象,我把他们圈到一个叫做Heap的“监狱”里,严格管理,生杀大权尽在掌握。
中国人把Stack翻译成“栈”,把Heap翻译成“堆”, 还有人会把Stack翻译成“堆栈”,唉,真不知道他们是怎么想的, 不过这么多年都过来了,你们明白就好。
碰巧我会对Heap中的Java 对象做垃圾回收,这个“堆”总是让我联想到垃圾堆。
说起垃圾回收,这实在是一个大负担,原因很简单,那些写Java程序的人类只管把对象给new出来,扔到Heap 中, 但是从来不管把他delete 掉, 删掉这些对象的责任就落到了我的头上,我不严格管理怎么行?
有时候我挺羡慕C和C++, 必须得手动地分配和释放内存,出了错都是程序员来背锅。
在我这里,如果任由这些对象对象肆意妄为,我那容量不高的,Java虚拟机启动后就无法更改的Heap“监狱”很快就会被填满, 所以我必须得派出我的得力助手,专门找到并且清理那些不用的Java 对象, 把他们占据的空间给释放掉。
为了找到这些捣乱分子,我发明了一个叫做“可达性分析”的算法,这个算法估计大部分人已经知道了,我就不再啰嗦了,下面这张图说明了背后的思想,聪明的你一眼就能看出来, 橙色的对象都是不可达对象,可以回收。
我抗议了很多次,让他修改, 他说微信公众号只能改五个字,改不了,唉,真是没辙。
好吧,现在详细说一下我管理的Heap“监狱”。
你可以把它想象成一大片空间,为了方便管理, 我把Heap“监狱”划分成多个区域,然后把那些Java对象在其中搬来搬去。
我定的规矩就是: 新来的家伙们都要进入新生代待着,新生代住不下了,我就派出清理者进行垃圾回收(Minor GC),回收以后还住不下,那就把年龄大的老家伙们赶到养老院(老年代)去。
每个在Heap中的Java对象我都会设置一个年龄计数器,每次Java对象熬过一次GC,就把年龄加1, 如果老到一定程度,对不起,请进入养老院(老年代)。 实际上我还会做动态的年龄判断,这里按下不表。
你可能会觉得奇怪,为什么在新生代里分出了Eden, Survivor1, Survivor2这样奇怪的区域?
那是因为我想在这里实现一个所谓的“复制”算法。
最早的时候, 我是把一个内存的区域划分成大小相当的两个区域,每次只用其中的一个。
区域1用完了,我就做垃圾回收,把存活的都搬到另外一个区域。
注意:搬过去以后,他们都会紧紧地挨在一起居住,这样以来,被清理掉的那些红色碎片就会重新平整成一大块空间,方便后续使用,尤其是针对大块头对象来了以后。
这么来回颠倒着使用两个区域,虽然效率高,没有碎片,但是浪费的空间很巨大:每次只能用一半。
后来人类发现,大部分在新生代的对象都活不了多长时间,基本上一次垃圾回收就删除得差不多了。
所以就改进了这个只用一半的复制算法, 把新生代分成三个部分:Eden , Survivor1, Survivor2 , 他们的比例是8:1:1。
每次只使用Eden 和其中一个Survivor , 当垃圾回收时,把这两块区域中还活着的对象复制到另外一个Survivor, 如果Survivor放不下,请进养老院(老年代)吧。
如果很不幸, 连养老院都住满了,那只好搞一次Full GC了,这是个很慢的操作,你们最好祈祷它不要频繁发生。
虽然我可以在Heap监狱内作威作福,有时候我也得接触下监狱之外的世界。
有一次要通过Socket向外发送数据,我明明把数据准备好了,就在我的Heap中,可是JVM老大竟然把数据复制了一份到Heap之外的内存中去,然后才能通过Socket发送。
我问他这到底是怎么回事,为什么要多此一举,难道是对我这个Heap监狱的大管家不放心?
JVM老大说确实是不放心,人家底层的Socket都是C语言写的, 关注的是物理内存的地址, 你垃圾回收的时候把Java对象在什么Eden, Survivor, 老年代之间挪来挪去,对象的地址也会变来变去, 我怎么告诉人家到底发哪个地址的数据啊?
想想也是这个理儿,有得必有失,你程序员不用管理内存,但是底层还得和内存打交道,并且还额外多了一道工序:Copy 。
老大还说:“可能你还不知道,除了你的Heap监狱,其实我在Java进程中还有一块儿叫做“Off-Heap内存’的地方,数据就会复制到这里。 为了和你区分开,我把它叫做堆外内存。”
没想到这里还有一块我都管不着的“飞地”!
不过它和我也没有什么竞争关系,由它去吧。
可是没过几天,JVM老大再次给我带来了“惊喜”。
他说:“复制数据太麻烦了,我想了个办法,可以在Java代码中直接分配一块属于Off-Heap的内存。”
我觉得头皮发蒙:“直接在堆外内存分配?到底怎么分配?”
老大给了我一段代码:“看看,这不就分配了128M的堆外存吗? 对这个buffer的读写操作会直接写入堆外内存, 不用再经过你来复制了。”
ByteBuffer buffer = ByteBuffer.allocateDirect(1024*1024*128);
该死的面向接口编程,这个ByteBuffer分配出来的堆外内存,就像一个普通的Java对象在使用,丝毫看不出它在堆内还是在堆外。
完了,这块内存我是彻底管不了了。
老大看出我情绪不对,安慰道: “这个buffer也是个Java对象啊, 就在你的Heap中存着,只不过它保存了那128M内存的信息而已。”
这还差不多 ! 既然它是个Java对象,那就得放到我的Heap监狱中,被我控制!
可以想象,这个对象被垃圾回收的时候, 它指向的直接内存才会被释放。
我突然有了一个邪恶的想法:如果这样的对象越来越多,并且一直不被垃圾回收,那对应的直接内存岂不也是不能释放,然后Out of Memory ?
老大似乎看穿了我的思想:“对于这些对象,得特别小心,一定得确保能释放。”
直接分配堆外内存的功能正式推出了,我发现分配起堆外内存要比堆内内存要慢一点,心想估计没有多少人使用吧。 可没想到的是它特别适合那些分配次数少,读写操作很频繁的场景。于是就受到了Netty这些通信类系统的热烈欢迎。
为了减少创建堆外内存的开销,Netty 还引入了对象池的技术,就像数据库连接池一样,先分配一些堆外内存, 然后不断地复用他们。
我没想到堆外内存能玩出这么多的花样,但是一想到他们还是Java程序,还得用Java对象包装,无论如何都跳不出我的手掌去,也就释然了。
【本文为51CTO专栏作者“刘欣”的原创稿件,转载请通过作者微信公众号coderising获取授权】
戳这里,看该作者更多好文