Java虚拟机内存区域划分与垃圾回收
Java虚拟机提供了自动内存管理,垃圾收集为我们处理了很多繁琐的工作。也正是因为把内存管理的控制权交给了Java虚拟机,程序一旦出现内存泄漏和内存溢出,如果不了解虚拟机的内存管理机制,那么排查问题将变得异常困难
通过此篇文章你讲了解到一下的内容:
1.Java虚拟机的内存区域划分
2.Java堆内存的分配策略和垃圾回收机制
3.常用的垃圾回收算法
一、Java虚拟机内存区域划分
JDK后续的版本发生了很大的变化。JDK 8中元数据区取代了永久代,Java虚拟机的内存区域会分成如下所示的运行时区域,有线程私有的区域,也有所有线程共享的区域。如下图所示:
- 程序计数器:在任何一个确定的时刻,一个处理器都只会执行一条线程中的指令。在JVM中每个线程都有它自己的程序计数器,各个线程间的程序计数器都是独立存储的互不影响,并且任何时间一个线程只能有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的的方法的JVM指令,循环、跳转、异常处理、线程切换等基础功能。它是线程私有的一块内存区域。
- 虚拟机栈:虚拟机栈也是线程私有的,它的生命周期与线程相同。每个线程在创建的时候都会创建一个虚拟机栈,其内部存储着一个个的栈针(Stack Frame),对应着一次次的方法调用。在一个时间点只会有一个活动的栈帧,通常叫做当前帧,方法所在的类叫做当前类。如果该方法中调用了别的方法,则新的栈帧会被创建,成为新的当前帧,一直到它放回结果或者执行结束。JVM直接对Java栈的操作只有两个,就是对栈帧的压栈和出栈。栈帧中存储着局部变量表、操作数栈、动态链接等。
- 本地方法栈:也是每个线程都会创建一个与虚拟机栈的作用非常相似。它们之间的区别为:虚拟机栈是为虚拟机执行Java方法服务的,而本地方法栈是为虚拟机使用到的本地Native方法服务的。
- Java堆:它是Java内存管理的核心区域,用来放置Java对象实例,所有创建的Java对象和数组都会被直接分配在堆上。堆内存被所有的线程共享。堆内存也是垃圾回收器重点照顾的区域,也可以称作“GC堆”。
- 方法区:这也是所有线程共享的一块内存区域,用于存储所谓的(Meta)元数据,例如类的结构信息,以及对应的运行时常量池、字段、方法代码等。很多人习惯将方法区叫做“永久代”,在JDK 8 中移除了方法区。
- 运行时常量池:这是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期各种字面量和符号引用,JDK 7以前的版本这部分内容将在类加载后进入方法区的运行时常量池存放。后续的JDk中已经没有了运行时常量池的说法。
- 元数据区:这是JDK 8中添加的一块内存区域,用来取代方法区。它不在虚拟机运行时区域内,而是存在于本地内存,将原先存储在方法区的符合引用迁移到了元数据区。
二、Java堆内存分配策略
前面了解到Java虚拟机对内存区域的划分,其中程序计数器、虚拟机栈、本地方法栈都是线程私有的内存区域,它们随着线程的创建而创建,随线程的销毁而销毁。我们日常所说的内存分配大体上来说都指的是堆内存。Java堆是虚拟机内存中最大的一块内存,虚拟机中所有的对象和数组都是分配在堆内存。也是GC重点照顾的区域。
Java虚拟机堆内存的划分:
- 从内存回收角度来看:由于现在收集器基本上都采用分代收集算法,所以Java堆中还可以细分为:新生代、老年代。
- 从内存分配角度来看:线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(TLAB)。
2.1 新生代
新生代是大部分对象的创建和销毁区域,新生代可以进一步划分为:Eden空间、两个Survivor空间,其中一个Survivor区会一直为空。
- 对象优先分配在新生代的Eden区上,当Eden区没有足够的内存空间用来分配的时候,虚拟机将发起一次Minor GC。仍然被引用的对象将存活下来,将被复制到其中一个 Survivor空间。没有被引用的对象则会被回收。
- 经过一次Minor GC,Eden空间将会空闲下来,直到再次达到Minor GC触发条件,在这个时候另外一个Survivor区域会成为To区域,Eden区域存活的对象和From Survivor区域的对象都会被复制到To区域,并且存活的对象年龄计数器会被加1。
- 类似第二部的过程将会发生很多次,直到有对象的年龄计数达到阈值被晋升到老年代。
新生代GC(Monior GC):指发生在新生代的垃圾收集动作,因为Java对象大多生命周期很短暂,所以Monior GC非常频繁,一般回收速度也比较快。垃圾收集算法采用的是“复制算法”。
2.2 老年代
老年代主要包含:长期存活的对象以及需要大量连续内存空间的对象。
当一个对象在新生代经历过几轮GC以后仍然不能进行回收,则被复制到老年代。当对象太大在新生代不能找到足够多的连续内存的时候则直接进入老年代,最典型的大对象就是那种很长的字符串以及数组。
2.3 分配缓冲区(TLAB)
TLAB是线程私有的一块区域,主要目的是为了解决多线程引起的并发问题,因为对象创建在虚拟机中是非常频繁的,即使仅仅修改指针的位置也不是线程安全的。因此每个线程都可以向虚拟机申请一段连续的内存,作为线程私有的TLAB,线程需要维护两个指针(一个指向TLAB空余内存的起始位置,一个是指向TLAB内存末尾的位置),当遇到new指令创建对象时则通过指针加法来实现,即把指向空余内存位置的指针加上所请求的字节数。如果相加后的指针位置仍小于指向末尾的指针则代表分配成功,否则TLAB已经没有足够的连续内存来分配对象,这个时候则需要线程重新申请新的TLAB。
三、垃圾回收
Java是面向对象的编程语言,对象的创建无时无刻都在发生。当虚拟机没有足够的内存空间为新生对象分配时,虚拟机就会发生内存溢出异常。将已分配出去,但却不再使用的内存空间回收回来,以便能够再次分配。这样一个过程就叫做垃圾回收。所说的垃圾就是指死亡的对象占据的堆内存空间。
在虚拟机进行垃圾回收时会涉及到几个问题:
- 问题1:如何辨别一个对象已经死亡了?
- 问题2:找到死亡对象后又如何进行回收?
3.1 如何辨别一个对象是存是亡
引用计数法
这是一种古老的辨别方式。它的做法是为每一个对象添加一个引用计数器,用来统计指向该对象的引用个数。一旦某个对象的引用计数器为0,则说明该对象已经死亡可以进行回收了。
引用计数法除了需要额外的空间来存储计数器,以及繁琐的更新操作,还有一个重大的漏洞:无法处理循环引用的对象。假设现在有两个对象A和B,它们互相引用,除此之外没有其他的引用指向A和B。这种情况下,A和B实际上已经死亡了,但由于它们的引用计数器都不为0,在引用计数法看来,这两个对象都还活着,对它们占据的内存空间不进行回收,因此产生内存泄漏的情况。
可达性分析算法
目前Java虚拟机的主流垃圾回收器都采用的是可达性分析算法来判定对象是否存活的。这个算法的基本思路是:通过一系列的称为“GC Roots”的对象作为初始存活对象合集,从该合集开始向下搜索所有能被该合集引用到的对象,并将其加入到该合集中,这个过程我们也称之为(标记),搜索走过的路径称为引用链,当一个对象到GC Roots没有任何的引用链相连(就是从GC Roots到这个对象不可达)时,则证明此对象是死亡的,可以进行回收的。
在Java语言中,可以作为GC Roots的对象包括(但不限于)如下几种:
- Java虚拟机栈中的局部变量引用的对象
- 已加载类的静态变量引用的对象
- 本地方法栈中JNI引用的对象
- 已启动切未停止的java线程
死亡对象两次标记过程
不可达对象在被判定为死亡之前,至少要经理两次标记过程:当对象不可达时它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用,虚拟机将这两种情况都视为“没有必要执行”。如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置到叫做F-Queue队列中,并由一个虚拟机自动创建低优先级的线程去执行队列中对象的finalize()方法。在执行对象的finalize()方法时,对象只要与引用链上的任何一个对象建立关联,则在第二次标记的时候将把此对象移出“即将回收”集合;如果对象在第一次标记的时候还没有逃脱,那它就被判定为“真的死亡”。
虽然可达性分析算法本身很简明,但是在使用中还是有不少问题需要解决。比如说在多线程环境下,其他线程可能会更新已访问过的对象的引用,从而造成误报和漏报。为了解决这一问题,传统的垃圾回收器采用了一种简单而粗暴的方式:Stop-the-World,停止其他非垃圾回收线程的工作,直到完成垃圾回收。
3.2 垃圾回收算法
垃圾收集的算法的实现涉及大量的程序细节,而且各个平台的虚拟机操作内存的方法又各不相同。常见的几种算法思想如下所示:
- 标记-清除算法:
标记-清除算法是最基础的收集算法,算法分为“标记”和“清除”两个阶段:首先标出所有需要回收的对象,把需要回收的对象的内存地址记录在一个空闲列表(free list),当需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,并划分给新建的对象。后续的算法都是基于这种思路并对其不足进行完善而得到的。它的主要不足有两个:一个是效率问题标记和清除的效率都不高;另一个是空间问题,标记清除以后会造成大量的内存碎片。 - 复制算法:
复制算法它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完后,就将存活的对象复制到另外一块上边,然后再把已使用的内存空间一次清理掉。这样使得每次只对整个内存半区进行内存回收,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半。 - 标记-整理算法:
标记过程仍然和“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象聚集在内存区域的起始位置,从而留下一块连续的内存空间。这种做法能够解决内存碎片化的问题。但是性能开销很大 - 分代收集算法:
当前商业虚拟机的垃圾收集都采用“分代收集”算法。一般是把Java堆内存分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。