JVM 内存区域和GC

JVM 内存区域和GC

Posted by 金志宏 on June 11, 2018

JVM 内存区域和GC

1. JVM 内存区域

1.1 概述

​ 对于Java程序员来说,在虚拟机自动内存管理机制的帮助下,不再需要为每个new操作去编写配对的delete/free代码,不容易出现内存的泄露和溢出问题,由虚拟机管理内存,一切看起来是非常美好。不过,也正是因为Java程序员把内存控制的权利交给了Java虚拟机,一旦出现内存泄露和溢出方面的问题,如果不了解虚拟机是怎么样使用内存的,那么排查错误会是很困难的一项工作。

1.2 运行时数据区域

Java虚拟机在执行Java程序过程中会把它所管理的内存划分为若干个不同的数据区域,这些区域各有用途,以及创建和销毁的时间,有的区域随着虚拟机进程启动而存在,有些区域依赖用户线程的启动和结束而建立销毁。根据《Java 虚拟机规范(Java SE 7)》版规定,Java虚拟机所管理的内存将会包含以下几个运行时数据区域

mpfqhT.png

  • 程序计数器

    ​ 程序计数器是一块较小的内存区域,可以看做是当前线程所执行的字节码的行号指示器。字节码解释器的工作就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支,循环,跳转,异常处理,线程回复等基础功能都要依赖这个计数器来完成。

    Java虚拟机的多线程是通过线程轮流切换并分配CPU执行时间片的方式实现,在任何一个确定的时刻,一个处理器都只会执行一条线程里的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,各线程之间计数器互不影响,独立存储,这类(程序计数器)内存区域,我们成为”线程私有“的内存区域。

    ​ 如果线程执行的是Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果执行的是Native 方法,这个计数器值为(Undefined)。此内存区域是唯一一个在Java虚拟机规范中没有规定任何OOM(OutOfMemoryError)情况的区域。

    public static void main(String[] args) {
    				// 最简单的计数器计数方式
      			// 可以想象循环,判断分支,跳转,异常 try catch 的情况程序计数器是如何工作的
            System.out.println("==== 程序计数器1 =====");  
            System.out.println("==== 程序计数器2 =====");  
            System.out.println("==== 程序计数器3 =====");  
              
    }
    
  • Java 虚拟机栈

    Java虚拟机栈也是线程私有的,它的生命周期与线程相同。虚拟机栈的描述是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用到执行完毕的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。即每个线程都有自己独立的虚拟机栈。

    ​ 很多人会把Java内存区域划分为堆内存(Heap)和栈内存(Stack),这种分法比较粗糙,Java内存区域的划分远比这个复杂,只能说明大多数程序员最关注的的,与对象内存分配关系最紧密的内存区域是这两块。其中所指的”栈“就是 现在讲的虚拟机栈。

    ​ 虚拟机栈是一个后入先出的数据结构,线程运行过程中,只有处于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法,当前活动帧栈始终是虚拟机栈的栈顶元素。局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float·、long、double)和对象引用类型(reference 类型)和 returnAddress类型(指向一条字节码指令地址)。

    ​ 局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧分配多少内存是固定的,运行期间不会改变局部变量表的大小。

    ​ 在固定大小的情况下,JVM会为每个线程的虚拟机栈分配一定的内存大小(-Xss参数),因此虚拟机栈能够容纳的栈帧数量是有限的,若栈帧不断进栈而不出栈,最终会导致当前线程虚拟机栈的内存空间耗尽,会抛出StackOverflowError异常。在动态扩展的情况下,当整个虚拟机栈内存耗尽,并且无法再申请到新的内存时,就会抛出OutOfMemoryError 异常。

  • 本地方法栈

    ​ 本地方法栈的功能和特点类似于虚拟机栈,均具有线程隔离的特点以及都能抛出StackOverflowErrorOutOfMemoryError异常,不同的是,本地方法栈服务的对象是JVM执行的native方法,而虚拟机栈服务的是JVM执行的java方法,HotSpot虚拟机不区分虚拟机栈和本地方法栈,两者是一块的

  • Java 堆

    Java堆是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,几乎所有对象的实例都在这里分配内存。它是垃圾收集器管理的主要区域,也叫”GC堆“。由于现代的收集器基本采用分代的收集算法,所以Java堆还可以分为新生代和老年代;再细致点有 Eden空间、From Survivor空间、To Survivor 空间,这个后面介绍GC具体会讲。从内存分配的角度上看,为了解决分配内存时的线程安全性问题,线程共享的JAVA堆中可能划分出多个线程私有的分配缓冲区(TLAB)。根据Java虚拟机规范规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。当前主流虚拟机按照可扩展来实现的,可以通过(-Xmx-Xms控制)大小。堆内存空间不足也会抛OutOfMemoryError异常。

  • 方法区

    ​ 方法区与Java堆一样也是线程共享区域,它用于存储已被虚拟机加载的类信息(类的版本、字段、方法、接口)、常量、静态变量、即时编译器编译后的代码等数据,方法区逻辑上属于堆的一部分,但是为了与堆进行区分,通常又叫“非堆”。

    ​ 很多人喜欢把方法区成为永久代,本质上两者不同。仅仅因为HotSpot虚拟机设计团队选择把GC分代收集扩展到方法区。或者说用永久代来实现方法区而已(Java8 开始废弃永久代,用元数据Metaspace)。可使用-XX:MaxPermSize来扩展,不过收到系统显示,比如32位系统只能用4G。当方法区无法满足内存分配需求,则会抛出OOM的异常。

2. 垃圾回收 GC

2.1 哪些内存需要回收

​ 在堆内存中放着java世界中几乎所有的实例对象,垃圾收集器在对堆进行回收前,第一是要判断哪些对象还存活,哪些对象已经死去(即不可能再被任何途径使用的对象)。前面介绍了运行时区域的各个部分。程序计数器、虚拟机栈、本地方法栈是线程独占的,随着线程退出,内存就随着回收,无需关心。而Java堆和方法区却不同,我们只有在程序处于运行期间才能知道会创建哪些对象,这部分内存分配回收都是动态的也是GC所关心的。

​ 而 Java堆和方法区不太一样,方法区存放着类加载信息,但是一个接口中多个实现类需要的内存可能不太一样,一个方法中多个分支需要的内存也不同,只有在运行时才能确定这个方法创建了哪些对象,需要多少内存。这部分内存的分配和回收都是动态的,也是 GC 所关心的。

2.2 怎么确认对象还活着

  • 引用计数法:给对象添加一个引用计数器,每当有一个对象引用它时,计数器的值就加 1 ; 当引用失效时,计数器的值就减 1;任何时刻计数器为 0的对象就是不可能再被使用的。比如 Python 就是使用了引用计数算法进行的内存管理。但是至少主流的Java 虚拟机里面没有选用引用计数算法来管理内存,主要原因就是难以解决对象间的循环引用问题。

  • 可达性分析算法:通过一系列称之为”GC Roots“ 的对象作为起始点,从这些节点开始往下搜索,搜索走过的路径叫引用链,当一个对象到 GC ROOT 没有任何引用链相连(GC ROOT到这些对象不可达),则证明对象不可用,判定为可回收对象。

以上无论哪种方式判断对象是否存活,都与”引用“有关,JDK1.2 以后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用,这4种引用一次逐渐减弱。

  • 强引用:程序代码中普遍存在,类似Object obj = new Object()这类引用,只要强引用存在,垃圾收集器永不回收;
  • 软引用:用来描述一些还有用,但并非必须的对象。对于软引用关联的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围中进行第二次回收。如果这次回收还没有足够的内存,才会抛出OOM。JDK1.2后提供了SoftReference实现;
  • 弱引用:也是非必须对象,但是强度更弱,被弱引用的对象只能生存到下次GC发生之前。当GC收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。JDK1.2 后使用 WeakReference来实现。
  • 虚引用:也成为幽灵引用或幻影引用,它是最弱的引用关系。为一个对象设置虚引用的唯一目的就是能在这个对象被GC时收到一个通知。PhantomReference实现。

2.3 垃圾收集算法

2.3.1 标记-清楚算法

  • 首先标记出所有需要回收的对象;
  • 标记完成后统一回收所有被标记对象;

缺点:效率问题,标记和清除两个操作效率都很低。空间问题,标记清除后会产生大量不连续的内存碎片,碎片太多导致后续要分配大对象时,找不到连续内存,不得不再进行一次GC。

2.3.2 复制算法(年轻代算法)

  • 将内存划分为1:1两块;
  • 每次使用其中一块,当用完了,将活跃的对象复制到另一块,然后整个清除;

缺点:显而易见等于内存减半了。

不过研究表明新生代中的对象”98“都是朝生夕死,所以不需要1:1分配。而是分为Eden,From Suvivor,To Survivor,三块内存,比例8:1:1,回收时,Enen,From Suvivor中存活对象,一次性复制到To Suvivor中去,然后清理掉Enen,From Suvivor。如果 From Suvivor 空间不够,可以借用老年代(分配担保)。

2.3.3 标记-整理算法(老年代使用)

老年代对象存活率高,根据老年代特点,使用标记-整理算法。

  • 标记回收对象;
  • 将存活对象向一端移动;
  • 清理边界外内存;

为了高效回收 JVM 将堆分成三个区域

  • 新生代 NewSize和MaxNewSize分别可以控制年轻代的初始大小和最大的大小
  • 老年代
  • 永久代 (1.8后使用元空间)

2.4 GC 为什么要分代

简单来说,分代是为了根据性质不同而实行不同的GC算法

  • 年轻代(Young Generation)
  • 老年代(Old Generation)
  • 永久代(Permanent Generation,也就是方法区)

年轻代每次回收还存活的对象,年轻代分为三个区:Eden和两个存活区(From Survivor和To Survivor),分别占内存的80%、10%、10%。Eden满的话执行fullGC,存活对象进 From Survivor,From Survivor满了后,执行fullGC,存活对象进入From Survivor,且计数器+1,15次后,升级为老年代。

[][https://www.liangzl.com/get-article-detail-121504.html]

JVM内存模型——JAVA的根基