在 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 对象包装, 无论如何都跳不出我的手掌去, 也就释然了.
来源: http://zhuanlan.51cto.com/art/201806/576518.htm