深入理解java虚拟机阅读笔记二:java自动内存管理机制以及垃圾回收机制

本文是对JVM的经典学籍《深入理解Java虚拟机》中知识学习的总结摘抄,原书内容写的很好,所特意从中摘取自己觉得比较重要的点,不求能够全部掌握所有内容,但至少保证能够在整体轮廓上有所斩获。

  1. 1. JVM内存区域
    1. 1.1. 概述
    2. 对于Java程序员来说,在虚拟机的自动内存管理机制的帮助下,不再需要为每一个new操作去写配对的delete/free代码,而且不容易出现内存泄漏和内存溢出问题。不过也正因为Java程序员把内存控制的权利交给了Java虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那排除错误会成为一项异常简单的工作。
    3. 1.2. JVM内存数据区域
    4. java虚拟机在执行java程序时,会根据代码的特点将内存划分为不同的区域管理。

      JVM 是 Java 的核心和基础,在 Java 编译器和 os 平台之间的虚拟处理器。它是一种基于下层的操作系统和硬件平台并利用软件方法来实现的抽象的计算机,可以在上面执行 Java 的字节码程序。

      Java 编译器只需面向 JVM,生成 JVM 能理解的代码或字节码文件。Java 源文件经编译器,编译成字节码程序,通过 JVM 将每一条指令翻译成不同平台机器码,通过特定平台运行。

      简单的说,JVM 就相当于一台柴油机,它只能用 Java (柴油)运行,JVM 就是 Java 的虚拟机,有了 JVM 才能运行 Java 程序

    5. java的内存物理模型如下:
    6. 《深入理解java虚拟机阅读笔记二:java自动内存管理机制以及垃圾回收机制》
      1. 1.2.1. 程序计数器
      2. 程序计数器是一块较小的内存空间,它的作用可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型中,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常等基础功能都需要依赖这个计器来完成。

        JVM的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,因为为了线程切换后能恢复到正确的执行位置每条线程都需要一个独立的程序计数器,因此应该为“线程私有”的内存。

      3. 1.2.2. Java虚拟机栈
      4. Java栈也称作虚拟机栈(Java Vitual Machine Stack),也就是我们常常所说的栈,跟C语言的数据段中的栈类似。事实上,Java栈是Java方法执行的内存模型。为什么这么说呢?下面就来解释一下其中的原因。

        Java栈中存放的是一个个的栈帧,每个栈帧对应一个被调用的方法,在栈帧中包括局部变量表(Local Variables)、操作数栈(Operand Stack)、指向当前方法所属的类的运行时常量池(运行时常量池的概念在方法区部分会谈到)的引用(Reference to runtime constant pool)、方法返回地址(Return Address)和一些额外的附加信息。当线程执行一个方法时,就会随之创建一个对应的栈帧,并将建立的栈帧压栈。当方法执行完毕之后,便会将栈帧出栈。因此可知,线程当前执行的方法所对应的栈帧必定位于Java栈的顶部。讲到这里,大家就应该会明白为什么 在 使用 递归方法的时候容易导致栈内存溢出的现象了以及为什么栈区的空间不用程序员去管理了(当然在Java中,程序员基本不用关系到内存分配和释放的事情,因为Java有自己的垃圾回收机制),这部分空间的分配和释放都是由系统自动实施的。对于所有的程序设计语言来说,栈这部分空间对程序员来说是不透明的。

      5. 1.2.3. 本地方法栈
      6. 与虚拟机栈发挥的作用非常相似,区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。
      7. 1.2.4. Java堆
      8. Java堆一块被所有线程共享的一块内存区域,在虚拟机启动时创建。在区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。

        Java堆是垃圾收集器管理的主要区域,因此也被叫做GC堆。如果从内存回收的角度看,由于现在收集器基本都是采用的分代收集算法,所Java堆中还可以细分为:新生代和老年代;再细致一点,新生代划分为Eden空间、From Survivor空间、To Survivor空间等。无论如何划分,都与存放内容无关,无论哪个区域,都是存放对象实例。划分的目的是为了更好的回收内存或者更快的分配内存。

      9. 1.2.5. 方法区
      10. Java堆一块被所有线程共享的一块内存区域,在虚拟机启动时创建。在区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。

        Java堆是垃圾收集器管理的主要区域,因此也被叫做GC堆。如果从内存回收的角度看,由于现在收集器基本都是采用的分代收集算法,所Java堆中还可以细分为:新生代和老年代;再细致一点,新生代划分为Eden空间、From Survivor空间、To Survivor空间等。无论如何划分,都与存放内容无关,无论哪个区域,都是存放对象实例。划分的目的是为了更好的回收内存或者更快的分配内存。

      11. 1.2.6. 运行时常量池
      12. 该区域是方法区的一部分。在Class文件中除了类的字段、方法、接口等描述信息外,还有一项信息是常量池,用来存储编译期间生成的字面量和符号引用。

        运行时常量池是每一个类或接口的常量池的运行时表示形式,在类和接口被加载到JVM后,对应的运行时常量池就被创建出来。当然并非Class文件常量池中的内容才能进入运行时常量池,在运行期间也可将新的常量放入运行时常量池中,比如String的intern方法。

  2. 2. 对象访问
  3. 在Java语言中,对象访问是如何进行的?
    对象访问会涉及Java栈、Java堆、方法区这三个最重要内存区域之间的关联关系,如这条代码Object obj = new Objetc()

    假设这句代码出现在方法体中,那么Object obj这部分的语义将会反映到Java栈的本地变量表中,作为一个reference类型数据出现。而new Object()这部分的语义将会反映到Java堆中,形成一块存储了Object类型所有实例数据值的结构化内存。另外,在Java堆中,还必须包含能查找到此对象类型数据(如对象类型、父类、实现的接口、方法等)的地址信息,这些类型数据则存储在方法区中。

    由于reference类型在Java虚拟机规范里面只规定了一个指向对象的引用,并没有定义这个引用应该通过哪种方式去定位,以及访问到Java堆中对象的具体位置,因此不同虚拟机实现有两种不同的方式,主流的有两种:使用句柄和直接指针。

    如果使用句柄访问方式,Java堆中将会划出一块作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息:

    《深入理解java虚拟机阅读笔记二:java自动内存管理机制以及垃圾回收机制》

如果使用直接指针访问方式,Java堆对象的布局中必须考虑如何放置访问类型数据的相关信息,reference中直接存储的就是对象地址。

《深入理解java虚拟机阅读笔记二:java自动内存管理机制以及垃圾回收机制》

  1. 3. 垃圾收集器与内存分配策略
    1. 3.1. 概述
    2. 程序计数器、虚拟机栈、本地方法栈三个区域随线程而生而灭;垃圾回收主要关注的是java堆中的数据。
    3. 3.2. 垃圾回收机制中的算法
    4. 垃圾回收过程中,会涉及垃圾回收算法思路,比较常规的便是 引用计数法和可达性分析算法。
      1. 3.2.1. 引用计数法
      2. 引用计数是垃圾收集器中的早期策略。在这种方法中,堆中每个对象实例都有一个引用计数。当一个对象被创建时,且将该对象实例分配给一个变量,该变量计数设置为1。当任何其它变量被赋值为这个对象的引用时,计数加1(a = b,则b引用的对象实例的计数器+1),但当一个对象实例的某个引用超过了生命周期或者被设置为一个新值时,对象实例的引用计数器减1。任何引用计数器为0的对象实例可以被当作垃圾收集。当一个对象实例被垃圾收集时,它引用的任何对象实例的引用计数器减1。

        • 优点:引用计数收集器可以很快的执行,交织在程序运行中。对程序需要不被长时间打断的实时环境比较有利。
        • 缺点:无法检测出循环引用。如父对象有一个对子对象的引用,子对象反过来引用父对象。这样,他们的引用计数永远不可能为0.

        引用计数算法无法解决循环引用问题,例如:最后面两句将object1和object2赋值为null,也就是说object1和object2指向的对象已经不可能再被访问,但是由于它们互相引用对方,导致它们的引用计数器都不为0,那么垃圾收集器就永远不会回收它们。

        public class Main {
            public static void main(String[] args) {
                MyObject object1 = new MyObject();
                MyObject object2 = new MyObject();
        
                object1.object = object2;
                object2.object = object1;
        
                object1 = null;
                object2 = null;
            }
        }
      3. 3.2.2. 标记-清除算法(Mark-Sweep)
        1. 3.2.2.1. 根搜索算法
        2. 根搜索算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图,从一个节点GC ROOT开始,寻找对应的引用节点,找到这个节点以后,继续寻找这个节点的引用节点,当所有的引用节点寻找完毕之后,剩余的节点则被认为是没有被引用到的节点,即无用的节点。
        3. 《深入理解java虚拟机阅读笔记二:java自动内存管理机制以及垃圾回收机制》
        4. java中可作为GC Root的对象有:

          1. 虚拟机栈中引用的对象(本地变量表)
          2. 方法区中静态属性引用的对象
          3. 方法区中常量引用的对象
          4. 本地方法栈中引用的对象(Native对象)
        5. 3.2.2.2. 算法分析
        6. 《深入理解java虚拟机阅读笔记二:java自动内存管理机制以及垃圾回收机制》
  2. 如上图。标记-清除算法采用从根集合进行扫描,对存活的对象对象标记,标记完毕后,再扫描整个空间中未被标记的对象,进行回收,如上图所示。标记-清除算法不需要进行对象的移动,并且仅对不存活的对象进行处理,在存活对象比较多的情况下极为高效,但由于标记-清除算法直接回收不存活的对象,因此会造成内存碎片。

    该算法是最基础的收集算法,后续的收集算法都是基于这种思想并对其缺点进行改进得到的。它的主要缺点有两个:

    1. 效率问题,标记和清除过程的效率都不高
    2. 空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致,当程序以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
      1. 3.2.3. 复制算法
      2. 《深入理解java虚拟机阅读笔记二:java自动内存管理机制以及垃圾回收机制》

该算法的提出是为了克服句柄的开销和解决堆碎片的垃圾回收。它开始时把堆分成 一个对象 面和多个空闲面, 程序从对象面为对象分配空间,当对象满了,基于copying算法的垃圾 收集就从根集中扫描活动对象,并将每个 活动对象复制到空闲面(使得活动对象所占的内存之间没有空闲洞),这样空闲面变成了对象面,原来的对象面变成了空闲面,程序会在新的对象面中分配内存。

现在的商业虚拟机都采用这种收集算法来回收新生代。更加详细的信息将会在后边介绍。

      1. 3.2.4. 标记-整理算法
    1. 《深入理解java虚拟机阅读笔记二:java自动内存管理机制以及垃圾回收机制》
  1. 标记-整理算法采用标记-清除算法一样的方式进行对象的标记,但在清除时不同,在回收不存活的对象占用的空间后,会将所有的存活对象往左端空闲空间移动,并更新对应的指针。标记-整理算法是在标记-清除算法的基础上,又进行了对象的移动,因此成本更高,但是却解决了内存碎片的问题。
      1. 3.2.5. 分代收集算法
      2. 分代的垃圾回收策略,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的回收算法,以便提高回收效率。
        当前的商业虚拟机的垃圾收集都采用“分代收集”算法,该算法并没有什么新的思想,只是根据对象的存活周期的不同将内存划分为几块。一般为新生代、老年代和永久代,这样就可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都会有大批对象死去,那就选用复制算法。而老年代因为对象存活率高,而没有额外空间对它进行分配担保,必须采用“标记-清除”或者“标记-清理”算法。
    1. 3.3. 垃圾收集器
    2. 垃圾收集器的图示:
    3. 《深入理解java虚拟机阅读笔记二:java自动内存管理机制以及垃圾回收机制》
      • Serial收集器(复制算法):新生代单线程收集器,标记和清理都是单线程,优点是简单高效。
      • Serial Old收集器(标记-整理算法):老年代单线程收集器,Serial收集器的老年代版本。
      • ParNew收集器(停止-复制算法):新生代收集器,可以认为是Serial收集器的多线程版本,在多核CPU环境下有着比Serial更好的表现。
      • Parallel Scavenge收集器(停止-复制算法):并行收集器,追求高吞吐量,高效利用CPU。吞吐量一般为99%, 吞吐量= 用户线程时间/(用户线程时间+GC线程时间)。适合后台应用等对交互相应要求不高的场景。
      • Parallel Old收集器(停止-复制算法):Parallel Scavenge收集器的老年代版本,并行收集器,吞吐量优先
      • CMS(Concurrent Mark Sweep)收集器(标记-清理算法):高并发、低停顿,追求最短GC回收停顿时间,cpu占用比较高,响应时间快,停顿时间短,多核cpu 追求高响应时间的选择
    4. 3.4. 内存分配策略
    5. Java所提倡的自动内存管理最终可以归结为自动化的解决了两个问题:给对象分配内存以及回收分配给对象的内存。
      下面来讨论一下给对象分配内存的策略。

      下面是几条最普遍的内存分配规则:

      • 对象优先在Eden分配:大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够的空间进行分配的时候,虚拟机将发起一次Minor GC。
      • 大对象直接进入老年代:所谓大对象指的是需要大量连续内存空间的Java对象,最典型的的大对象就是那种很长的字符串及数组。
      • 长期存活的对象将进入老年代:虚拟机给每个对象定义了一个对象年龄计数器。如果对象在Eden每熬过一次Minor GC,年龄增加1,当年龄增加到一定成都,就会晋升到老年代中
  2. 4. Java有了GC同样会出现内存泄露问题

1、静态集合类像HashMap、Vector等的使用最容易出现内存泄露,这些静态变量的生命周期和应用程序一致,所有的对象Object也不能被释放,因为他们也将一直被Vector等应用着。

1
2
3
4
5
6
7
Static Vector v = new Vector();
for (int i = 1; i<100; i++)
{
Object o = new Object();
v.add(o);
o = null;
}

在这个例子中,代码栈中存在Vector 对象的引用 v 和 Object 对象的引用 o 。在 For 循环中,我们不断的生成新的对象,然后将其添加到 Vector 对象中,之后将 o 引用置空。问题是当 o 引用被置空后,如果发生 GC,我们创建的 Object 对象是否能够被 GC 回收呢?答案是否定的。因为, GC 在跟踪代码栈中的引用时,会发现 v 引用,而继续往下跟踪,就会发现 v 引用指向的内存空间中又存在指向 Object 对象的引用。也就是说尽管o 引用已经被置空,但是 Object 对象仍然存在其他的引用,是可以被访问到的,所以 GC 无法将其释放掉。如果在此循环之后, Object 对象对程序已经没有任何作用,那么我们就认为此 Java 程序发生了内存泄漏。

2、各种连接,数据库连接,网络连接,IO连接等没有显示调用close关闭,不被GC回收导致内存泄露。

3、监听器的使用,在释放对象的同时没有相应删除监听器的时候也可能导致内存泄露。

参考:http://tracylihui.github.io/2015/07/13/%E6%B7%B1%E5%85%A5%E5%AD%A6%E4%B9%A0JVM2%EF%BC%9A%E8%87%AA%E5%8A%A8%E5%86%85%E5%AD%98%E7%AE%A1%E7%90%86%E6%9C%BA%E5%88%B6/

    原文作者:java虚拟机
    原文地址: https://blog.csdn.net/wenjieyatou/article/details/79005318
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞