虚拟机内存管理

虚拟机内存管理

事实上,Java虚拟机可以看作一个阉割版的操作系统。C、C++运行在操作系统上,Java字节码运行在虚拟机上。虚拟机的设计几乎都可以在操作系统中找到影子,其中,内存管理是不得不说的一块。
Java与C++之间有一堵由动态内存分配和垃圾收集技术所围成的高墙。倘若说到内存分配与自动回收,主要的问题就是下面四个:

  1. 内存如何动态分配?
  2. 哪些内存需要回收?
  3. 什么时候回收?
  4. 如何回收?

内存如何动态分配

说起这个,最经典的解决方案仍然是现代操作系统,从最开始的连续分配、到非连续分配,段页式结合,操作系统的内存管理给我们展示了一个完整的解决问题的思考与实现过程。
相比而言,Java虚拟机的内存分配就显得简单粗暴,本质上是在操作系统分配的内存上再做分配,分配策略也接近于操作系统最原始的分配方案。

  • 指针碰撞
    如果Java堆中的内存是规整的,所有被使用的内存放在一边,空闲的内存放在另一边,中间放置指针作为分界点的指示器,分配内存时只需要将指针移动所需内存大小的距离。
    使用这种方法,Java堆内存必须规整,垃圾回收之后必须对内存进行整理,不能让已使用的内存和未使用的内存交错在一起。
  • 空闲列表
    这个内存不规整时,必须维护一个列表,用以记录哪部分内存已被使用,哪个内存块是空闲的,当需要分配内存时,从空闲列表中找出空闲块分配给对象,并更新列表记录。
    关于Java对象内存分配的更多内容,可以查虚拟机对象探秘

哪些内存需要回收

这个问题其实就是如何判断对象不会再被使用,即对象已经是“垃圾”了,需要被回收。

  • 引用计数法
    在对象中添加一个引用计数器,每当有地方引用他时,计数器值就加1;当引用失效时,计数值减1;当计数器值为0时,这个对象已经没有引用了,即对象已死。
    引用计数法实现简单高效,但是Java虚拟机并没有选择用引用计数法管理内存,这个看似简单的算法有很多特殊情况需要考虑,需要配合大量的额外工作才能保证正确工作,譬如循环引用等问题。
  • 可达性分析算法
    通过一系列称为“GC Roots”的根对象作为其实节点集,从这些结点开始,根据引用关系搜索,如果无法通过引用链搜索到某个对象,用图论的话来说就是从GC Roots不可达这个对象时,这个对象已死。
    显然,GC Roots是不得不说的问题,在Java中,固定可作为GC Roots对象包括以下几种:
    • 在虚拟机栈中引用的对象。
    • 在方法区类静态属性引用的对象。
    • 在方法区常量引用的对象。
    • 在本地方法栈中JNI(即通常所说的Native方法)中引用的对象。
    • Java虚拟机内部的引用,如基本数据类型对应的Class对象、常驻的异常对象等
    • 所有被同步锁(synchronized关键字)持有的对象。
    • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调,本地代码缓存等。
      除此之外,还有其他对象可以临时性地假如,共同构成GC Roots集合。

关于引用

传统引用的定义:如果reference数据类型的数据中存储的数值代表的是另一块内存的起始地址,那么该reference数据代表某块内存、某个对象的引用。
在这种定义下,一个对象只有“被引用”和“引用”两种情况,对于描述一些“食之无味,弃之可惜”的对象就显得无能为力。譬如我们想描述一类对象:当内存空间还足够时,能够保留在内存之中,当内存紧张时,就抛弃这些对象。
JDK1.2后,引用的概念得到了扩充,分为以下四种:

  • 强引用(Strong Reference)
    强引用是“传统“引用的定义”,是使用最普遍的引用。如果一个对象具有强引用,那垃圾回收器绝不会回收它。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。
  • 软引用(Soft Reference)
    如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。
  • 弱引用(Weak Reference)
    弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。
    弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。当你想引用一个对象,但是这个对象有自己的生命周期,你不想介入这个对象的生命周期,这时候你就是用弱引用。这个引用不会在对象的垃圾回收判断中产生任何附加的影响。
  • 虚引用(Pathom Reference)
    虚引用”顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。
    虚引用主要用来跟踪对象被垃圾回收器回收的活动。虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列 (ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之 关联的引用队列中。

关于Java引用的更多内容

方法区垃圾回收

《Java虚拟机规范》中并没有规定要在方法区实现垃圾收集,也有未实现或未完全实现方法区垃圾收集的收集器。方法区的垃圾收集效率远低于堆区。
方法区垃圾回收主要回收两部分内容:

  • 废弃的常量
    判断一个常量是否需要回收和判定普通对象是否需要回收基本相似。
  • 不再使用的类型。
    判断一个类型是否不再被使用就复杂多了,需要同时满足以下三个条件:
    • 该类所有的实例都被回收,Java堆中不存在该类及其子类的实例。
    • 加载该类的加载器已经被回收,这个条件除非是经过精心设计的类加载器,否则很难达成。
    • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

达成这三个条件仅仅是被允许垃圾回收,并不是一定会垃圾回收。是否需要回收,HostSpot虚拟机提供了虚拟机参数进行控制。
在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常需要Java虚拟机具备类型卸载的能力,保证不会对方法区造成过大的内存压力。

垃圾收集算法

分代收集理论

分代收集理论建立在两个假说上:

  • 弱分代假说:绝大多数对象都是朝生夕死的。
  • 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。

这两个假说共同奠定了垃圾收集器设计的一致原则:收集器将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾回收的次数)分配到不同的存储区。
对于朝生夕死对象占多数的区域,我们在垃圾回收过程中关注如何保留少量存活对象而不是去标记大量将要被回收的对象。
对于难以消亡的对象,把它们集中放在一起,使用较低的频率来对这个区域进行垃圾回收。
这样同时兼顾了垃圾回收的时间开销和内存空间的有效利用。
在Java堆中划分区域之后,具体可以表现为新生代(Young Generation)和老年代(Old Genaration),因而才有了"Minor GC"、“Major GC”、"Full GC"这样的回收类型的划分,也才有针对不同区域的不同垃圾收集策略,发展出了“标记复制”、“标记清楚”、“标记整理”等方法。

但是,上述两条假说存在一个明显的缺陷:对象不是孤立的,对象之间会存在跨代引用。
假如只进行一次针对新生代的垃圾回收(Minor GC),因为新生代的对象完全有可能被老年代的所引用的,为了找出该区域的存活对象,不得不将老年代对象也加入到GC Roots中去遍历。这可能为内存回收带来很大的性能负担。
为了解决这个问题,分代收集理论出现了第三条经验法则:

  • 跨代引用假说:跨代引用相对于同代引用来说只占极少数。

事实上,根据前面两条法则可以推得第三条。
依据这条法则,不应该为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录对象是否存在以及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”),这个结构把老年代划分成若干小块,标识出老年代哪一小块会存在跨代引用。当发生Minor GC时,只有包含了跨代引用的小块内存才会被加入到GC Roots进行扫描。
虽然多了一些维护记忆集的成本,但是相对于扫描整个老年代来说仍是比较划算的。

关于各种GC

  • 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:
    • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
    • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。
    • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。
  • 整堆收集(Full GC):收集Java堆和方法区的垃圾收集。

标记清除算法

首先标记出所有需要回收/清除的对象,标记完成后,统一回收标记/未标记的对象。标记过程就是对象是否属于垃圾的判定过程。
该算法是最基础的收集算法,后续的收集算法大多是以标记清除算法为基础,对其缺点进行改进而得到的。主要缺点有两个:

  1. 执行效率不稳定,当需要回收的对象过多时,标记和清除两个过程的执行效率都随对象数量增长而降低。
  2. 内存空间碎片化问题。

标记复制算法

将可用内存分为相等的两块,每次只使用其中的一块。这一块的内存用完了,就将还存活的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。
如果内存中多数对象都是存活的,这种算法会产生大量的内存间复制开销,但对于多数对象都是可回收的情况下,算法需要做的就是复制少量的存活对象,而且每次都是针对整个半区进行内存回收,不需要考虑空间碎片的复杂情况,只需移动指针,按顺序分配即可。代价是将可用的内存缩小为了原来的一半。
Java虚拟机大多优先采用了这种收集算法回收新生代。

标记整理算法

标记复制算法在对象存活率较高时就要进行较多的复制操作,显然不适用于老年代的垃圾收集。
标记整理算法就是在标记清除算法的基础上做了存活对象的移动已解决内存碎片的问题。
在老年代每次回收会有大量对象存活,移动并更新所有引用的对象会是一种极为负重的操作,这种对象移动必须暂停所有用户线程来执行(Stop the World)。倘若不考虑内存碎片的话,那么后续的内存分配就会存在问题。
从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不停顿;从整个程序运行的吞吐量来看,移动对象会更划算。

基本内存分配原则

对象的内存分配,通常情况下都是在堆上(实际上也有可能经过即时编译、逃逸分析后在栈上分配)。
JDK1.8下,默认的垃圾收集器是ParallelGC,即Parallel Scanvenge + Serial Old(PS MarkSweep),这篇文章说了关于各种垃圾收集器的更多内容

/**
 * @author rufeng
 * vm参数 -verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+PrintGCDetails -XX:+PrintCommandLineFlags -XX:+UseSerialGC
 * 如果不加-XX:+UseSerialGC参数,运行结果存在差异,原因是收集器对于垃圾回收过程中的关注点不同
 */
public class Main {
    public static final int _1MB = 1024 * 1024;

    public static void main(String[] args) throws IOException {
        byte[] arr1 = new byte[2 * _1MB];
        byte[] arr2 = new byte[2 * _1MB];
        byte[] arr3 = new byte[2 * _1MB];
        byte[] arr4 = new byte[4 * _1MB];
    }
}

解释一下虚拟机参数:-Xmx20M、-Xms20M、-Xmn10M这三个参数限制了Java堆的大小为20MB,不可扩展,其中10M分配给新生代,10M分配给老年代。-XX:SurvivorRatio=8决定了新生代中Eden区与一个Survivor区的比例是8:1,Survivor区有两个,to和from。简单来说就是下图。

在这里插入图片描述
关于下述几点,读者可自行调整参数与代码进行验证。

  1. 对象优先在Eden分配
    当Eden没有足够的空间分配时将引起一次Minor GC。Minor GC时,触发Stop the World,同时将Eden区和from区存活的对象复制到to区中,清空from区和Eden区,交换from区和to区。存活的新生代GC分代年龄+1,使用的是标记复制算法。
  2. 大对象直接进入老年代
    典型的大对象便是那种很长的字符串或者数组,编写程序时,应尽量避免“朝生夕死”的“短命”大对象。在分配空间时,大对象容易导致提前GC,同时复制大对象意味着高昂的内存开销。虚拟机提供了-XX:PretenureSizeThreshould参数,指定大于该设置值的对象直接在老年代分配。
  3. 长期存活的对象进入老年代。
    每经过一次Minor GC,对象的GC分代年龄+1,当达到一定阈值(默认15),晋升到老年代。该阈值可以通过-XX:MaxTenuringThreshold参数设置。
  4. 动态对象年龄判断。
    除了对象的GC分代年龄达到阈值会晋升到老年代之外,如果Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。
  5. 空间分配担保。
    在发生Minor GC之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立,那么这次Minor GC是安全的。如果不成立,则虚拟机会先查看-XX:HandlePromotionFailure参数设置是否允许担保失败;如果允许,继续检查老年代最大可用连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次GC是有风险的;如果小于,或者-XX:HandlePromotionFailure设置不允许冒险,那这次GC就要改为进行一次Full GC。

以上参考《深入理解Java虚拟机第三版》
Oracle官方Java虚拟机规范

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值