JVM经典内存布局
JVM经典内存布局

内存布局

这一块很重要,要着重看!(在JVM这本书里面有更详细的介绍)

  • 方法区(公有):
    用户存储已被虚拟机加载的类信息,常量,静态常量,即时编译器编译后的代码等数据。
    其中包含常量池:用户存放编译器生成的各种字面量和符号引用。
  • 堆(公有):
    是JVM所管理的内存中最大的一块。唯一目的就是存放实例对象,几乎所有的对象实例都在这里分配。Java堆是垃圾收集器管理的主要区域,因此很多时候也被称为“GC堆”。
    堆分为两大块:新生代和老年代。
  • 虚拟机栈(线程私有):
    描述的是java方法执行的内存模型:每个方法在执行时都会创建一个栈帧,用户存储局部变量表,操作数栈,动态连接,方法出口等信息。每一个方法从调用直至完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。对这个区域定义了两种异常状态OutOfMemoryError、StackOverflowError
  • 本地方法栈(线程私有):
    与虚拟机栈所发挥的作用相似。它们之间的区别不过是虚拟机栈为虚拟机执行java方法,而本地方法栈为虚拟机使用到的Native方法服务。
  • 程序计数器(线程私有):
    一块较小的内存,当前线程所执行的字节码的行号指示器。字节码解释器工作时,就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。

Java内存模型规定了所有的变量都存储在主内存中。每条线程中还有自己的工作内存,线程的工作内存中保存了被该线程所使用到的变量(这些变量是从主内存中拷贝而来)。线程对变量的所有操作(读取,赋值)都必须在工作内存中进行。不同线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。

对象已死?

引用计数器

主流的JVM里面没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决对象间的互循环引用的问题。

可达性分析算法

在Java语言中,可以作为GC Roots的对象包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象;
  • 方法区中类静态属性引用的对象;
  • 方法区中常量引用的对象;
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象;

在可达性分析算法中,要真正宣告一个对象死亡,至少要经历两次标记过程:

  1. 如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize(),或者finalize()已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。
  2. 如果这个对象被判定为有必要执行finalize(),那么这个对象将会放置在一个叫做F-Queue队列之中,并在稍后由一个由虚拟机自动建立的、低优先级的 Finalizer 线程去执行它。finalize()是对象逃脱死亡命运的最后一次机会,稍候GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalie()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将会被移除出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的被回收了。

注意:Java并不推荐使用finalize()函数,Java中这个表示建议回收,实际开发者并不能掌握到底什么时候JVM才会回收这部分内存,这和c++的析构不一样,运行代价高昂、不确定性大。

引用再划分

  1. 强引用
    特点:我们平常典型编码Object obj = new Object()中的 obj 就是强引用。通过关键字new创建的对象所关联的引用就是强引用。 当JVM内存空间不足,JVM 宁愿抛出 OutOfMemoryError 运行时错误(OOM),使程序异常终止,也不会靠随意回收具有强引用的“存活”对象来解决内存不足的问题。对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为 null,就是可以被垃圾收集的了,具体回收时机还是要看垃圾收集策略。

  2. 软引用
    特点:软引用通过SoftReference类实现。 软引用的生命周期比强引用短一些。只有当 JVM认为内存不足时,才会去试图回收软引用指向的对象:即JVM 会确保在抛出 OutOfMemoryError 之前,清理软引用指向的对象。软引用可以和一个引用队列(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。后续,我们可以调用 ReferenceQueue 的poll()方法来检查是否有它所关心的对象被回收。如果队列为空,将返回一个 null ,否则该方法返回队列中前面的一个 Reference 对象。

    应用场景:软引用通常用来实现内存敏感的缓存。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。

  3. 弱引用
    特点:弱引用通过 WeakReference 类实现。 弱引用的生命周期比软引用短。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。由于垃圾回收器是一个优先级很低的线程,因此不一定会很快回收弱引用的对象。弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。

    应用场景:弱应用同样可用于内存敏感的缓存。

  4. 虚引用
    特点:虚引用也叫幻象引用,通过 PhantomReference 类来实现。无法通过虚引用访问对象的任何属性或函数。幻象引用仅仅是提供了一种确保对象被 finalize 以后,做某些事情的机制。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。虚引用必须和引用队列 (ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。

    1
    2
    ReferenceQueue queue = new ReferenceQueue ();
    PhantomReference pr = new PhantomReference (object, queue);

    程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要被垃圾回收。如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取一些程序行动。

    应用场景:可用来跟踪对象被垃圾回收器回收的活动,当一个虚引用关联的对象被垃圾收集器回收之前会收到一条系统通知。

垃圾回收算法

标记-清除算法

最基础的收集算法是“标记-清除”(Mark-Sweep)算法,如同它的名字一样,算法分为“标记”和“清除”两个阶段。

  1. 首先标记出所有需要回收的对象
  2. 在标记完成后统一回收所有被标记的对象。
    不足
    效率问题:标记和清除两个过程的效率都不高
    空间问题:标记清除之后产生大量不连续的内存碎片,空间碎片太多可能会导致以后程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

复制算法

目的:为了解决效率问题。
将可用内存按容量大小划分为大小相等的两块,每次只使用其中的一块。当一块内
存使用完了,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空
间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用
考虑内存碎片等复杂情况。
缺点:将内存缩小为了原来的一半。

标记-整理算法

复制收集算法在对象存活率较高时,就要进行较多的复制操作,效率就会变低。根据老年代的特点,提出了“标记-整理”算法。标记过程仍然与”标记-清除“算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。

分代收集算法

一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当
的收集算法。
新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法。
老年代中,因为对象存活率高、没有额外空间对它进行分配担保,就必须采用“标记-清除”或“标记-整理”算法来进行回收。

垃圾回收机制知识点

JVM中的年代

JVM中分为年轻代(Young generation)和老年代(Tenured generation)。
HotSpot JVM把年轻代分为了三部分:1个Eden区和2个Survivor区(分别叫from和to)。

JVM年代内存图
JVM年代内存图

一般情况下,新创建的对象都会被分配到 Eden 区(注意1:一些大对象特殊处理),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到 Survivor 区。对象在 Survivor 区中每熬过一次 Minor GC ,年龄就会增加 1 岁,当它的年龄增加到一定程度时,就会被移动到年老代中。因为年轻代中的对象基本都是朝生夕死的(80%以上),所以在年轻代的垃圾回收算法使用的是复制算法,复制算法的基本思想就是将内存分为两块,每次只用其中一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。复制算法不会产生内存碎片。

在 GC 开始的时候,对象只会存在于Eden区和名为“From”的 Survivor 区,Survivor 区“To”是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过 -XX:MaxTenuringThreshold 来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次 GC 前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。

![对象分配与简要GC流程图]

注意1:所谓大对象是指,需要大量连续内存空间的Java对象,最典型的大对象就是很长的字符串以及数组。

不同年代的GC

新生代GC(Minor GC):指发生在新生代的垃圾收集动作,该动作非常频繁。
老年代GC(Full GC/Major GC):指发生在老年代的垃圾收集动作,出现了Major GC,经常会伴
随至少一次的Minor GC。Major GC的速度一般会比Minor GC慢10倍以上。

空间分配担保

在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败。如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,则将尝试进行一次Minor GC,尽管这个 Minor GC 是有风险的。如果小于,或者 HandlePromotionFailure 设置不允许冒险,那这时也要改为进行一次Full GC。