共计 2605 个字符,预计需要花费 7 分钟才能阅读完成。
转自:码农翻身(微信号:coderising)
在 Java 虚拟机中,我是一个位高权重的大管家,他们都很怕我,尤其是那些 Java 对象,我把他们圈到一个叫做 Heap 的“监狱”里,严格管理,生杀大权尽在掌握。
中国人把 Stack 翻译成“栈”,把 Heap 翻译成“堆”,还有人会把 Stack 翻译成“堆栈”,唉,真不知道他们是怎么想的,不过这么多年都过来了,你们明白就好。
碰巧我会对 Heap 中的 Java 对象做垃圾回收,这个“堆”总是让我联想到垃圾堆。
说起垃圾回收,这实在是一个大负担,原因很简单,那些写 Java 程序的人类只管把对象给 new 出来,扔到 Heap 中,但是从来不管把他 delete 掉,删掉这些对象的责任就落到了我的头上,我不严格管理怎么行?
有时候我挺羡慕 C 和 C ++,必须得手动地分配和释放内存,出了错都是程序员来背锅。
在我这里,如果任由这些对象肆意妄为,我那容量不高的,Java 虚拟机启动后就无法更改的 Heap“监狱”很快就会被填满,所以我必须得派出我的得力助手,专门找到并且清理那些不用的 Java 对象,把他们占据的空间给释放掉。
为了找到这些捣乱分子,我发明了一个叫做“可达性分析”的算法,这个算法估计大部分人已经知道了,我就不再啰嗦了,下面这张图说明了背后的思想,聪明的你一眼就能看出来,橙色的对象都是不可达对象,可以回收。
Heap 监狱
好吧,现在详细说一下我管理的 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 对象包装,无论如何都跳不出我的手掌去,也就释然了。