GC 工作原理

https://www.cnblogs.com/cnmenglang/p/6229254.html

https://blog.csdn.net/lzxadsl/article/details/50159939

---

为什么要学习 GC 的工作原理

       学习 Java GC 机制,可以帮助我们在日常工作中排查各种内存溢出和泄露问题,解决性能瓶颈,达到更高的并发量,写出更高效的程序

Java 内存区域

    1. 程序计数器 Program Counter Register

       程序计数器是一个比较小的内存区域,用于指示当前线程所执行的字节码执行到了第几行,可以理解为是当前线程的行号指示器。字节码解释器在工作时,会通过改变这个计数器的值来取下一条语句指令。

       每个程序计数器只用来记录一个线程的行号,所以它是线程私有(一个线程就有一个程序计数器)的。

       程序执行的是一个Java方法,则计数器记录的是正在执行的虚拟机字节码指令地址;如果正在执行的是一个本地(native,由C语言编写 完成)方法,则计数器的值为 Undefined ,由于程序计数器只是记录当前指令地址,所以不存在内存溢出的情况,因此,程序计数器也是所有 JVM 内存区域中唯一一个没有定义 OutOfMemoryError 的区域。

    2. 虚拟机栈 JVM Stack

       一个线程的每个方法在执行的同时,都会创建一个栈帧(Statck Frame),栈中只保存基础类型的对象和自定义对象的引用,栈帧中存储的有局部变量表、操作站、动态链接、方法出口等,当方法被调用时,栈帧在 JVM 栈中入栈,当方法执行完成时,栈帧出栈。

       局部变量表中存储着方法的相关局部变量,包括各种基本数据类型,对象的引用,返回地址等。在局部变量表中,只有 long 和 double 类型会占 用2个局部变量空间(Slot,对于32位机器,一个 Slot 就是 32个bit),其它都是1个 Slot。需要注意的是,局部变量表是在编译时就已经确定 好的,方法运行所需要分配的空间在栈帧中是完全确定的,在方法的生命周期内都不会改变。

       虚拟机栈中定义了两种异常,如果线程调用的栈深度大于虚拟机允许的最大深度,则抛出 StackOverFlowError(栈溢出);不过多 数Java虚拟机都允许动态扩展虚拟机栈的大小(有少部分是固定长度的),所以线程可以一直申请栈,直到内存不足,此时,会抛出  OutOfMemoryError(内存溢出)。

        每个线程对应着一个虚拟机栈,因此虚拟机栈也是线程私有的。

    3. 本地方法栈 Native Method Stack

       本地方法栈在作用,运行机制,异常类型等方面都与虚拟机栈相同,唯一的区别是:虚拟机栈是执行 Java 方法的,而本地方法栈是用来执行 native 方法的,在很多虚拟机中(如 Sun 的 JDK 默认的 HotSpot 虚拟机),会将本地方法栈与虚拟机栈放在一起使用。

        本地方法栈也是线程私有的。

    4. 堆区 Heap

       堆区是理解 Java GC 机制最重要的区域,没有之一。在 JVM 所管理的内存中,堆区是最大的一块,堆区也是 Java GC 机制所管理的主要内存区域,堆区由所有线程共享,在虚拟机启动时创建。堆区的存在是为了存储对象实例,原则上讲,所有的对象都在堆区上分配内存(不过现代技术里,也不是这么绝对的,也有栈上直接分配的)。堆中不存放基本类型和对象引用,只存放对象本身。

       一般的,根据 Java 虚拟机规范,堆内存需要在逻辑上是连续的(在物理上不需要),在实现时,可以是固定大小的,也可以是可扩展的,目前主流的虚拟机都是可扩展的。如果在执行垃圾回收之后,仍没有足够的内存分配,也不能再扩展,将会抛出OutOfMemoryError:Java heap space 异常。

       关于堆区的内容还有很多,将在下节“Java 内存分配机制”中详细介绍。

    5. 方法区 Method Area

      在Java虚拟机规范中,将方法区作为堆的一个逻辑部分来对待,但事实上,方法区并不是堆(Non-Heap);另外,不少人将 Java GC 的分代收集机制分为3个代:年轻代,老年代,永久代,并将方法区定义为“永久代”,这是因为,对于之前的 HotSpot Java 虚拟机的实现方式中,将分代收集的思想扩展到了方法区,并将方法区设计成了永久代。不过,除 HotSpot 之外的多数虚拟机,并不将方法区当做永久代,HotSpot 本身也计划取消永久代。

       方法区是各个线程共享的区域,用于存储已经被虚拟机加载的类信息(即加载类时需要加载的信息,包括版本、field、方法、接口等信息)、final 常量、静态变量、编译器即时编译的代码等。

  方法区在物理上也不需要是连续的,可以选择固定大小或可扩展大小,并且方法区比堆还多了一个限制:可以选择是否执行垃圾收集。一般的,方法区上执行的垃圾收集是很少的,这也是方法区被称为永久代的原因之一(HotSpot),但这也不代表着在方法区上完全没有垃圾收集,其上的垃圾收集主要是针对常量池的内存回收和对已加载类的卸载。

  在方法区上进行垃圾收集,条件苛刻而且相当困难,效果也不令人满意,所以一般不做太多考虑,可以留作以后进一步深入研究时使用。

    在方法区上定义了 OutOfMemoryError:PermGen space 异常,在内存不足时抛出。

    运行时常量池(Runtime Constant Pool)是方法区的一部分,用于存储编译期就生成的字面常量、符号引用、翻译出来的直接引用(符号引用就是编码是用字符串表示某个变量、接口的位置,直接引用就是根据符号引用翻译出来的地址,将在类链接阶段完成翻译);运行时常量池除了存储编译期常量外,也可以存储在运行时间产生的常量。

    6. 直接内存 Direct Memory

       直接内存并不是 JVM 管理的内存,可以这样理解,直接内存,就是 JVM 以外的机器内存,比如,你有 4G 的内存,JVM 占用了1G,则其余的 3G 就是直接内存,JDK中有一种基于通道(Channel)和缓冲区 (Buffer)的内存分配方式,将由C语言实现的 native 函数库分配在直接内存中,用存储在 JVM 堆中的 DirectByteBuffer 来引用。

        由于直接内存收到本机器内存的限制,所以也可能出现 OutOfMemoryError 的异常。

Java 对象的访问方式

       一般来说,一个 Java 的引用访问涉及到3个内存区域:JVM 栈,堆,方法区。

  以最简单的本地变量引用:Object obj = new Object() 为例:

       Object obj 表示一个本地引用,存储在 JVM 栈的本地变量表中,表示一个 reference 类型数据;

       new Object() 作为实例对象数据存储在堆中;

       堆中还记录了 Object 类的类型信息(接口、方法、field、对象类型等)的地址,这些地址所执行的数据存储在方法区中;

       在 Java 虚拟机规范中,对于通过 reference 类型引用访问具体对象的方式并未做规定,目前主流的实现方式主要有两种:

       1. 通过句柄访问(图来自于《深入理解Java虚拟机:JVM高级特效与最佳实现》):

       通过句柄访问的实现方式中,JVM 堆中会专门有一块区域用来作为句柄池,存储相关句柄所执行的实例数据地址(包括在堆中地址和在方法区中的地址)。这种实现方法由于用句柄表示地址,因此十分稳定。

       2. 通过直接指针访问:(图来自于《深入理解Java虚拟机:JVM高级特效与最佳实现》)

       通过直接指针访问的方式中,reference 中存储的就是对象在堆中的实际地址,在堆中存储的对象信息中包含了在方法区中的相应类型数据。这种方法最大的优势是速度快,在 HotSpot 虚拟机中用的就是这种方式。

Java 内存分配机制

       这里所说的内存分配,主要指的是在堆上的分配,一般的,对象的内存分配都是在堆上进行,但现代技术也支持将对象拆成标量类型(标量类型即原子类型,表示单个值,可以是基本类型或 String 等),然后在栈上分配,在栈上分配的很少见,我们这里不考虑。

  Java内存分配和回收的机制概括的说,就是:分代分配,分代回收。对象将根据存活的时间被分为:年轻代(Young Generation)、老年代(Old Generation)、永久代(Permanent Generation,也就是方法区)。如下图(来源于《成为JavaGC专家part I》:


分代分配,分代回收

年轻代 Young Generation

       对象被创建时,内存的分配首先发生在年轻代(大对象可以直接 被创建在老年代),大部分的对象在创建后很快就不再使用,因此很快变得不可达,于是被年轻代的GC机制清理掉(IBM 的研究表明,98%的对象都是很快消 亡的),这个 GC 机制被称为 Minor GC 或叫 Young GC。注意,Minor GC并不代表年轻代内存不足,它事实上只表示在Eden区上的GC。

  年轻代上的内存分配是这样的,年轻代可以分为3个区域:Eden 区(伊甸园,亚当和夏娃偷吃禁果生娃娃的地方,用来表示内存首次分配的区域,再 贴切不过)和两个存活区(Survivor 0 、Survivor 1)。内存分配过程为(来源于《成为JavaGC专家part I》,http://www.importnew.com/1993.html):

       1. 绝大多数刚创建的对象会被分配在 Eden 区,其中的大多数对象很快就会消亡。Eden 区是连续的内存空间,因此在其上分配内存极快;

       2. 当 Eden 区满的时候,执行 Minor GC,将消亡的对象清理掉,并将剩余的对象复制到一个存活区 Survivor0(此时,Survivor1 是空白的,两个 Survivor 总有一个是空白的);

       3. 此后,每次 Eden 区满了,就执行一次 Minor GC,并将剩余的对象都添加到 Survivor0;

       4. 当 Survivor0 也满的时候,将其中仍然活着的对象直接复制到 Survivor1,以后 Eden 区执行 Minor GC 后,就将剩余的对象添加 Survivor1(此时,Survivor0是空白的)。

       5. 当两个存活区切换了几次(HotSpot 虚拟机默认15次,用 -XX:MaxTenuringThreshold 控制,大于该值进入老年代)之后,仍然存活的对象(其实只有一小部分,比如,我们自己定义的对象),将被复制到老年代。

       从上面的过程可以看出,Eden 区是连续的空间,且 Survivor 总有一个为空。经过一次 GC 和复制,一个 Survivor 中保存着当前还活 着的对象,而 Eden 区和另一个 Survivor 区的内容都不再需要了,可以直接清空,到下一次 GC 时,两个 Survivor 的角色再互换。因此,这种方 式分配内存和清理内存的效率都极高,这种垃圾回收的方式就是著名的“停止-复制(Stop-and-copy)”清理法(将 Eden 区和一个 Survivor 中仍然存活的对象拷贝到另一个 Survivor 中),这不代表着停止复制清理法很高效,其实,它也只在这种情况下高效,如果在老年代采用停止复制,则挺悲剧的。

  在 Eden 区,HotSpot 虚拟机使用了两种技术来加快内存分配。分别是 bump-the-pointer 和 TLAB(Thread- Local Allocation Buffers),这两种技术的做法分别是:由于 Eden 区是连续的,因此 bump-the-pointer 技术的核心就是跟踪最后创建的一个对象,在对 象创建时,只需要检查最后一个对象后面是否有足够的内存即可,从而大大加快内存分配速度;而对于 TLAB 技术是对于多线程而言的,将 Eden 区分为若干段,每个线程使用独立的一段,避免相互影响。TLAB 结合 bump-the-pointer 技术,将保证每个线程都使用 Eden 区的一段,并快速的分配内存。

老年代 Old Generation

       对象如果在年轻代存活了足够长的时间而没有被清理掉(即在几次 Young GC 后存活了下来),则会被复制到老年代,老年代的空间一般比年轻代大,能存放更多的对象,在老年代上发生的 GC 次数也比年轻代少。当老年代内存不足时, 将执行 Major GC,也叫  Full GC。

   可以使用 -XX:+UseAdaptiveSizePolicy 开关来控制是否采用动态控制策略,如果动态控制,则动态调整 Java 堆中各个区域的大小以及进入老年代的年龄。

  如果对象比较大(比如长字符串或大数组),Young 空间不足,则大对象会直接分配到老年代上(大对象可能触发提前 GC,应少用,更应避免使用短命的大对象)。用 -XX:PretenureSizeThreshold 来控制直接升入老年代的对象大小,大于这个值的对象会直接分配在老年代上。

  可能存在老年代对象引用年轻代对象的情况,如果需要执行 Young GC,则可能需要查询整个老年代以确定是否可以清理回收,这显然是低效的。解决的方法是,老年代中维护一个 512 byte 的块——"card table",所有老年代对象引用年轻代对象的记录都记录在这里。Young GC 时,只要查这里即可,不用再去查全部老年代,因此性能大大提高。

Java GC 机制

GC机制的基本算法是:分代收集,这个不用赘述。下面阐述每个分代的收集方法。

    年轻代

       事实上,在上一节,已经介绍了年轻代的主要垃圾回收方法,在年轻代中,使用“停止-复制”算法进行清理,将年轻代内存分为两部分,一部分 Eden 区较大,一部分 Survivor 比较小,并被划分为两个等量的部分。每次进行清理时,将 Eden 区和一个 Survivor 中仍然存活的对象拷贝到另一个 Survivor 中,然后清理掉 Eden 和刚才的 Survivor。

  这里也可以发现,停止复制算法中,用来复制的两部分并不总是相等的(传统的停止复制算法两部分内存相等,但年轻代中使用一个大的 Eden 区和两个小的 Survivor 区来避免这个问题)。

  由于绝大部分的对象都是短命的,甚至存活不到 Survivor 中,所以,Eden 区与 Survivor 的比例较大,HotSpot 默认是 8:1,即分别占年轻代的80%,10%,10%。如果一次回收中,Survivor+Eden 中存活下来的内存超过了 10%,则需要将一部分对象分配到年老代。用 -XX:SurvivorRatio 参数来配置 Eden 区域 Survivor 区的容量比值,默认是8,代表 Eden:Survivor1:Survivor2=8:1:1。

    年老代

       年老代存储的对象比年轻代多得多,而且不乏大对象,对年老代进行内存清理时,如果使用停止-复制算法,则相当低效。一般,年老代用的算法是标记-整理算法,即:标记出仍然存活的对象(存在引用的),将所有存活的对象向一端移动,以保证内存的连续。

       在发生 Minor GC 时,虚拟机会检查每次晋升进入年老代的大小是否大于年老代的剩余空间大小,如果大于,则直接触发一次 Full GC,否则,就查看是否设置了 -XX:+HandlePromotionFailure(允许担保失败),如果允许,则只会进行 MinorGC ,此时可以容忍内存分配失败;如果不允许,则仍然进行 Full GC(这代表着如果设置 -XX:+Handle PromotionFailure,则触发 MinorGC 就会同时触发Full GC,哪怕年老代还有很多内存,所以,最好不要这样做)。

    永久代(方法区)

       永久代的回收有两种:常量池中的常量,无用的类信息,常量的回收很简单,没有引用了就可以被回收。对于无用的类进行回收,必须保证3点:

           1. 类的所有实例都已经被回收

           2. 加载类的 ClassLoader 已经被回收

           3. 类对象的 Class 对象没有被引用(即没有通过反射引用该类的地方)

       永久代的回收并不是必须的,可以通过参数来设置是否对类进行回收。HotSpot 提供 -Xnoclassgc 进行控制

       使用 -verbose,-XX:+TraceClassLoading、-XX:+TraceClassUnLoading 可以查看类加载和卸载信息

           1. -verbose、-XX:+TraceClassLoading 可以在 Product 版 HotSpot 中使用

           2. -XX:+TraceClassUnLoading 需要 FastDebug 版 HotSpot 支持

如果老年代的对象需要引用一个新生代的对象,会发生什么呢?

       为了解决这个问题,老年代中存在一个"card table",他是一个512 byte大小的块。所有老年代的对象指向新生代对象的引用都会被记录在这个表中。当针对新生代执行GC的时候,只需要查询card table来决定是否可以被收集,而不用查询整个老年代。这个 card table 由一个 write barrier 来管理。write barrier 给 GC 带来了很大的性能提升,虽然由此可能带来一些开销,但 GC 的整体时间被显著的减少。

垃圾收集器

       在 GC 机制中,起重要作用的是垃圾收集器,垃圾收集器是 GC 的具体实现,Java 虚拟机规范中对于垃圾收集器没有任何规定,所以不同厂商实现的垃圾 收集器各不相同,HotSpot 1.6 版使用的垃圾收集器如下图(图来源于《深入理解Java虚拟机:JVM高级特效与最佳实现》,图中两个收集器之间有连线,说明它们可以配合使用):

       在介绍垃圾收集器之前,需要明确一点,就是在年轻代采用的停止复制算法中,“停止(Stop-the-world)”的意义是在回收内存时,需要暂停其他所有线程的执行。这个是很低效的,现在的各种年轻代收集器越来越优化这一点,但仍然只是将停止的时间变短,并未彻底取消停止。GC 的优化很多时候就是指减少 Stop-the-world 的视觉。

       Serial 收集器:年轻代收集器,使用停止复制算法,使用一个线程进行 GC,其它工作线程暂停。使用 -XX:+UseSerialGC 可以使用 Serial+Serial Old 模式运行进行内存回收(这也是虚拟机在 Client 模式下运行的默认值)

        ParNew 收集器:年轻代收集器,使用停止复制算法,Serial 收集器的多线程版,用多个线程进行 GC,其它工作线程暂停,关注缩短垃圾收集时间。

            使用 -XX:+UseParNewGC 开关来控制使用 ParNew+Serial Old 收集器组合收集内存;

            使用 -XX:ParallelGCThreads 来设置执行内存回收的线程数。

        Parallel Scavenge 收集器:年轻代收集器,使用停止复制算法,关注 CPU 吞吐量,即运行用户代码的时间/总时间,比如:JVM 运行100分钟,其中运行用户代码99分钟,垃圾收集1分钟,则吞吐量是99%,这种收集器能最高效率的利用 CPU,适合运行后台运算(关注缩短垃圾收集时间的收集器,如 CMS,等待时间很少,所以适合用户交互,提高用户体验)。

            使用 -XX:+UseParallelGC 开关控制使用 Parallel Scavenge+Serial Old 收集器组合回收垃圾(这也是在 Server 模式下的默认值);

            使用 -XX:GCTimeRatio 来设置用户执行时间占总时间的比例,默认99,即 1%的时间用来进行垃圾回收。

            使用 -XX:MaxGCPauseMillis 设置GC的最大停顿时间(这个参数只对 Parallel Scavenge 有效)

       Serial Old收集器:年老代收集器,单线程收集器,使用标记整理(整理的方法是 Sweep(清理)和 Compact(压缩),清理是将废弃的对象干掉,只留幸存的对象,压缩是将移动对象,将空间填满保证内存分为2块,一块全是对象,一块空闲)算法,使用单线程进行 GC,其它工作线程暂停(注意,在年老代中进行标记整理算法清理,也需要暂停其它线程),在JDK1.5之前,Serial Old 收集器与ParallelScavenge 搭配使用。

       Parallel Old收集器:年老代收集器,多线程,多线程机制与 Parallel Scavenge 差不错,使用标记整理(与 Serial Old 不同,这里的整理是 Summary(汇总)和 Compact(压缩),汇总的意思就是将幸存的对象复制到预先准备好的区域,而不是像 Sweep(清理)那样清理废弃的对象)算法,在Parallel Old执行时,仍然需要暂停其它线程。Parallel Old 在多核计算中很有用。Parallel Old出现后(JDK 1.6),与 Parallel Scavenge 配合有很好的效果,充分体现 Parallel Scavenge 收集器吞吐量优先的效果。

            使用-XX:+UseParallelOldGC 开关控制使用 Parallel Scavenge +Parallel Old 组合收集器进行收集。

       CMS(Concurrent Mark Sweep)收集器:年老代收集器,致力于获取最短回收停顿时间,使用标记清除算法,多线程,优点是并发收集(用户线程可以和 GC 线程同时工作),停顿小。

            使用 -XX:+UseConcMarkSweepGC 进行 ParNew+CMS+Serial Old 进行内存回收,优先使用 ParNew+CMS(原因见后面),当用户线程内存不足时,采用备用方案 Serial Old 收集。

       G1 收集器:在 JDK1.7 中正式发布,首先你要忘记你所学过的新生代和老年代的概念。正如你在上图所看到的,每个对象被分配到不同的格子,随后 GC 执行。当一个区域装满之后,对象被分配到另一个区域,并执行 GC。这中间不再有从新生代移动到老年代的三个步骤。这个类型是为了替代 CMS GC 而被创建的,因为 CMS GC 在长时间持续运作时会产生很多问题。G1 最大的好处是性能,他比我们在上面讨论过的任何一种 GC 都要快。

GC 的工作内容

       Java GC(Garbage Collection, 垃圾收集,垃圾回收)机制主要完成3件事情:

       1. 确定哪些内存需要回收

       2. 确定什么时候需要指出 GC

       3. 如何执行 GC

你可能感兴趣的:(GC 工作原理)