内存分配与垃圾回收

1、jvm简介

Java 虚拟机在执行 Java 程序的过程中会把它管理的内存划分为若干个不同的数据区域。它们各有用途,有些随着虚拟机进程的启动一直存在(堆、方法区),有些则随着用户线程的启动和结束而建立和销毁(程序计数器、虚拟机栈、本地方法栈)。

upload successful

JVM的设计者们之所以会选择将JVM的内存结构划分为多个不同的内存区,是因为每一个独立的内存区都拥有各自的用途,都会负责存储各自的数据类型。其中一些内存区的生命周期往往还会和JVM的生命周期保持一致,也就是说,会伴随着JVM的启动而创建,伴随着JVM的退出而销毁。而另一部分内存区则是与线程的生命周期保持一致,会伴随着线程的开始而创建,伴随着线程的消亡而销毁。尽管不同的内存区在存储类型和生命周期上有一定区别,却都拥有一个相同的本质,那就是存储程序的运行时数据。

2、线程共享内存区

所谓的线程共享内存区是指可以允许被所有的线程共享访问的一类内存区

堆区

  • Java堆区是一场用于存储对象实例的内存区,同时也是GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域
  • 存储在JVM中的Java对象可以被划分为两类:一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速,而另外一类对象的生命周期却非常长,在某些极端的情况下还能够与JVM的生命周期保持一致。因此对于这些不同生命周期的Java对象,应该采取不同的垃圾收集策略,分代收集由此诞生。目前几乎所有的GC都是用分代收集算法,所以Java堆区如果要更进一步细分的话,还可以划分为新生代(YoungGen)和老年代(OldGen),其中新生代又可以划分为Eden空间、From Survivor空间和To Survivor空间。
  • 堆的大小在JVM启动时就已经设置好了,我们可以使用-Xmx-Xms来进行设置。其中选项-Xms表示堆区的起始内存,而选项-Xmx则用于表示堆区的最大内存。一旦堆区中的内存大小超过-Xmx所指定的最大内存时,将会抛出OutOfMemoryError异常。

upload successful

方法区

  • 方法区和Java堆区一样,同样也是允许被所有的线程共享访问。方法区中存储了每一个Java类的结构信息,比如:运行时常量池、字段和方法数据、构造函数和普通方法的字节码内容以及类、实例、接口初始化时需要用到的特殊方法等数据。尽管Java虚拟机规范对方法区的具体实现方式并没有明确要求,但是在HotSpot中,方法区仅仅只是逻辑上的独立,实际上还是包含在Java堆区内,也就是说,方法区在物理上也是属于Java堆区的一部分。

    方法区在JVM启动的时候被创建,且它在实际的内存空间中和Java堆区一样都可以是不连续的。一些Java开发人员把方法区称为永久代/持久代(Permanent Generation),这主要是因为方法区除了可以通过选项`-XX:MaxPermSize`设置内存大小进行动态扩展外,并不会像Java堆区那样频繁地被GC执行回收,甚至还可以显式地指定是否需要在程序运行时回收方法区中的数据,如果没有显式要求不回收,那么GC的回收目标仅针对方法区中的常量池的无用的类。
    
  • 什么是无用的类

    - 1.该类所有的实例都已经被回收,也就是java堆中不存在该类的任何实例。
    - 2.加载该类的ClassLoader已经被回收。
    - 3.该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
    

运行时常量池

  • 运行时常量池属于方法区的一部分。
  • 当类装载器成功将一个类或者接口装载进JVM后,就会创建与之对应的运行时常量池。
  • 由于每一个运行时常量池所分配的内存来源于方法区,一旦所需要的内存大小超过方法区所能够提供的最大值时,运行时常量池同样也会抛出OutOfMemoryError异常

3、线程私有内存区

和线程共享内存区不同,线程私有内存区是不允许被所有线程共享访问的。线程私有内存区是只允许被所属的独立线程进行访问的一类内存区,包括PC寄存器、Java栈及本地方法栈3个内存区。

PC寄存器/PC计数器

  • 由于JVM是基于栈的架构,所以任何的操作都需要经过入栈和出栈来完成。
  • JVM中的PC寄存器是对物理PC寄存器的一种抽象模拟,它是线程私有的,生命周期与线程的生命周期保持一致。 如果当前线程所执行的方法是一个Java方法,那么PC寄存器就会存储正在执行的字节码指令地址,反之如果是native方法,这时PC寄存器的值就是空(undefined)。
  • PC寄存器是JVM的内存区中唯一一个没有明确规定需要抛出OutOfMemoryError异常的运行时内存区。

Java栈

  • Java栈,也称Java虚拟机栈(Java Virtual Machine stack),它同PC寄存器一样都是线程私有,并且生命周期与线程的生命周期保持一致。Java栈用于存储栈帧(Stack Frame),而栈帧中所存储的就是局部变量表、操作数栈、以及方法出口等信息。
  • Java栈允许被实现成固定大小的内存或者是可动态扩展的内存大小,如果它被设置为固定的大小,一旦纯种请求分配的栈容量超过JVM所允许的最大值时,JVM将会抛出一个StackOverflowError异常,反之抛出一个OutOfMemoryError异常。

本地方法栈

  • 本地方法栈(Native Method Stack)用于支持本地方法,比如使用C/C++代码编写的方法。它和java栈的作用类似,也是被允许设置为固定大小,并且本地方法栈也会抛出StackOverflowError或者OutOfMemoryError异常。

4、内存分配原理

创建对象

  • 如果是在Java语法层面上创建一个对象无非就是使用一个简单的new关键字即可,但是在JVM中就没有这么简单了
  • 大体步骤:
    • 当语法层面使用new关键字创建一个Java对象时,JVM首先会检查这个new指令的参数能否在常量池中定位到一个类的符号引用,然后检查与这个符号引用相对应的类是否已经成功经历过加载、解析和初始化等步骤,当类完成装载步骤之后,就已经完成可以确定出创建对象实例时所需要的内存空间大小,接下来JVM将会对其进行内存分配,以存储所生成的对象实例。

upload successful

分配空间

  • 为新对象分配内存是一件非常严谨和复杂的任务,不仅要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑GC执行完内存回收后是否会在内存空间中产生内存碎片。 如果内存空间以规整和有序的方式分布,即已用的和未用的都各自一边,彼此之间维系着一个记录下一次分配起始点和标记指针,当为新对象分配内存时,只需要通过修改指针的偏移量将新对象分配在第一个空闲内存位置上,这种分配方式叫做指针碰撞(Bump the Pointer),反之则只能使用空闲列表(Free List)执行内存分配。
  • 基于线程安全的的考虑,如果一个类在分配内存之前已经成功完成类装载步骤之后,JVM就会优先选择在TLAB(Thread Local Allocation,本地线程分配缓冲区)中为对象实例分配内存空间,TLAB在Java堆区中是一块线程私有区域,它包含在Eden空间内,除了可以避免一系列的非线程安全问题外,同时还能够提升内存分配的吞吐量,这种分配方式称为快速分配策略。 尽管还是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实是将TLAB作为内存分配的首选,在程序中,开发人员可通过-XX:UseTLAB设置是否开启TLAB空间。 TLAB的空间内存非常小,默认仅占有Eden空间的1%,虽然我们可以通过-XX:TLABWasteTargetPercent设置TLAB空间战胜Eden空间的百分比大小。 一旦对象在TLAB空间分配内存失败时,JVM就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在Eden空间中分配内存,如果当在Eden空间中也无法分配内存时,JVM就会执行Minor GC,直至最终可以在Eden空间中分配内存为止(如果是大对象则直接在老年代中分配)。
  • 当为对象成功分配好所需的内存空间后,JVM接下来要做的事情就是初始化对象实例,JVM首先会对分配后的内存空间进行零值初始化,这一步操作确保了对象的实例字段在Java代码中可以不用赋初始值就能够直接使用,程序能够访问到这些字段的数据类型所对应的零值。如下图

upload successful

5、GC的作用

介绍

  • GC(Garbage Collector,垃圾收集器)就是JVM中自动内存管理机制的具体实现。

    在HotSpot中,GC的工作任务主要可以划分为两大块,分别是内存的动态分配和垃圾回收。
    

注意

  • 一般来说当内存空间中的内存消耗达到了一定阈值的时候,GC就会执行垃圾回收,而且回收算法必须非常精确,一定不能造成内存存活的对象被错误的回收掉,也不能造成已经死亡的对象没有被及时地回收掉国。而且GC执行内存回收的时候应该做到高效,不应该导致应用程序出现长时间的暂停,以及避免产生内存碎片。

    不过因为被回收的内存空间极有可能是一些不连续的内存块,不可避免的会产生一些内存碎片,这样一来将会导致没有足够的连续可用的肉皮分配给较大的对象,不过可以使用压缩算法消除内存碎片。
    

如何评估一款GC的性能

  • 吞吐量:程序的运行时间/(程序的运行时间+内存回收的时间)
  • 垃圾收集开销:吞吐量的补数,垃圾收集器所战胜时间与总时间的比例
  • 暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间
  • 收集频率:相对于应用程序的执行,收集操作发生的频率
  • 堆空间:Java堆区所占的内存大小
  • 快速:一个对象从诞生到被回收所经历的时间

垃圾标记算法

  • 垃圾标记阶段

    • 在GC执行垃圾回收之前,首先需要区分出内存中哪些是存活对象,哪些是已经死亡的对象,只有被标记为已经死亡的对象,GC才会在执行垃圾回收时,释放掉其所占用的内存空间。

      那么JVM如何确定一个对象死亡呢?
      简单来说当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡。
      
  • 引用计数算法

    • 引用计数算法会为程序中的每一个对象都创建一个私有的引用计数器,当目标对象被其他存活对象引用时,引用计数器中的值则会加1,不再引用则会减1,当引用计数的值为0的时候认为它已经死亡。

    • 弊端

      如果一些明显已经死亡的对象尽管没有被任何的存活对象引用,但是它们彼此之间却存在相互引用时,引用计数器中的值则永远不为0,这样便会导致GC永远无法释放掉无用对象所占用的内存空间,极有可能引发内存泄露。

  • 根搜索算法

    • 根搜索算法是以根对象集合作为起点,按照从上到下的方式搜索被根对象集合所连接的目标对象是否可达(使用根搜索算法后,内存中的戚对象都会被根对象集合直接或者间接连接着),如果目标对象不可达时,就认为它已经死亡, 便可以在instanceOopDesc的Mark World中将其标记为垃圾对象
    • 根对象集合中都有什么
      • Java栈中的对象引用
      • 本地方法中的对象引用
      • 运行时常量池中的对象引用
      • 方法区中类静态属性的对象引用
      • 与一个类对应的唯一数据类型的Class对象

垃圾回收算法

  • 标记-清除算法

    标记-清除算法是一种非常基础和常见的垃圾收集算法,它将垃圾回收任务划分为两个阶段执行,分别是垃圾标记和内存释放。相对于另两种算法,标记-清除算法不仅执行效率低下,更重要的是由于被执行内存回收的无用对象所占用的内存空间有可能是一些不连续的内存块,不可避免的会产生一些内存碎片,从而导致后续没有足够的内存空间分配给较大的对象。

  • 复制算法

    Eden空间和另外两个Survivor空间默认所占比例为8:1,当然我们可以通过-XX:SurvivorRatio调整这个比例 当执行一次Minor GC(新生代垃圾回收)时,Eden空间中的存活对象会被复制到To空间内,且前一次Minor GC后还在From空间中存活下来的对象如果还年轻也会被复制到To空间。 当所有存活对象都被复制到To空间或晋升到老年代后,剩下的均为垃圾对象 PS:下两种情况Eden和From中的对象不会复制到To空间,而是会晋升到老年代中 1、存活对象的分代年龄超过-XX:MaxTenuringThreshold所指定的阈值 2、当To空间的容量达到阈值时,存活对象也直接晋升到老年代

  • 标记-压缩算法

    • 复制算法不能用在老年代中,因为老年代中对象的生命周期都比较长,甚至某些极端情况下与JVM的生命周期保持一致,所以如果老年代也采用复制算法执行内存回收不仅需要额外的时间和空间,而且还会导致较多的复制操作影响到GC的执行效率
    • 标记-清除算法的确可以应用在老年代中,但是该算法不仅效率低下,而且会产生内存碎片,所以标记-压缩算法由此诞生。
    • 当成功标记出内存中的垃圾对象后,该算法会将所有的存活对象都移动到一个规整且连续的内存空间中,然后执行Full GC(老年代的垃圾回收,或者称为Major GC)回收无用对象所占用的内存空间。当成功执行压缩之后,已用的未用的内存都各自一边,彼此之间维系着一个记录下一次分配起始点的标记指针,当为新对象分配内存时,则可以使用指针碰撞(Bump the Pointer)技术修改指针的偏移量将新对象分配在第一个空闲内存位置上,为新对象分配内存带来便捷。

6、HotSpot源码下载

点击下面链接即可下载,版本为 OpenJDK / jdk8u / jdk8u60 / hotspot

http://hg.openjdk.java.net/jdk8u/jdk8u60/hotspot/archive/37240c1019fd.zip