【学习笔记】深入理解java虚拟机之探索虚拟机内部和垃圾回收

深入理解java虚拟机

本文中主要记录的是java虚拟机的基础知识,以及java垃圾回收相关的知识。

  • 概述
    Java能获得如此广泛的认可,除了它拥有一门结构严谨、面向对象的编程语言之外,还有许多不可忽视的优点:
  • 它摆脱了硬件平台的束缚,实现了“一次编写,到处运行”的理想;
  • 它提供了一种相对安全的内存管理和访问机制,避免了绝大部分内存泄漏和指针越界问题;
  • 它实现了热点代码检测和运行时编译及优化,这使得Java应用能随着运行时间的增长而获得更高的性能;
  • 它有一套完善的应用程序接口,还有无数来自商业机构和开源社区的第三方类库来帮助用户实现各种各样的功能

Java内存区域与内存溢出异常

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域

方法区

  • 各个线程共享的内存区域
  • 它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据
  • 不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集
  • 如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常
  • 运行时常量池
    • 方法区的一部分
    • Java虚拟机对于Class文件每一部分(自然也包括常量池)的格式都有严格规定,如每一个字节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、加载和执行,但对于运行时常量池,《Java虚拟机规范》并没有做任何细节的要求,不同提供商实现的虚拟机可以按照自己的需要来实现这个内存区域,不过一般来说,除了保存Class文件中描述的符号引用外,还会把由符号引用翻译出来的直接引用也存储在运行时常量池中
    • 具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中

  • Java堆(JavaHeap)是虚拟机所管理的内存中最大的一块
  • Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建
  • 此内存区域的唯一目的就是存放对象实例,Java世界里“几乎”所有的对象实例都在这里分配内存
  • Java堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作“GC堆”
  • 如果从分配内存的角度看,所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区(ThreadLocalAllocationBuffer,TLAB),以提升对象分配时的效率
  • Java堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续
  • Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx和-Xms设定)
  • 如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常

虚拟机栈

  • 线程私有的,它的生命周期与线程相同
  • 虚拟机栈描述的是Java方法执行的线程内存模型
  • 每个方法被执行的时候Java虚拟机都会同步创建一个栈帧(StackFrame)用于存储局部变量表、操作数栈、动态连接、方法出口等信息
  • 每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程
  • 局部变量表存放了编译期可知的各种Java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)
  • 这些数据类型在局部变量表中的存储空间以局部变量槽(Slot)来表示,其中64位长度的long和double类型的数据会占用两个变量槽,其余的数据类型只占用一个
  • 局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小(槽的个数)
  • 对这个内存区域规定了两类异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常

本地方法栈

  • 与虚拟机栈类似,区别是虚拟机栈为虚拟机执行java方法(字节码)服务,本地方法栈是为虚拟机执行本地方法

程序计数器

  • 是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器
  • 在Java虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
  • 每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存
  • 如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。
  • 此内存区域是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域

直接内存

  • 在JDK1.4中新加入了NIO(NewInput/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据
  • 本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括物理内存、SWAP分区或者分页文件)大小以及处理器寻址空间的限制,一般服务器管理员配置虚拟机参数时,会根据实际内存去设置-Xmx等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常

虚拟机异常

  • java堆溢出

    java堆用于存储对象实例,只要不断创建对象,并且保证GC roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么随着对象数量的增加,总容量触及最大堆的容量限制就会出现内存溢出异常

    • 解决思路
    1. 通过内存映像分析工具对dump出来的堆栈信息进行分析,首先确认导致OOM的对象是否是必要的,即要分清到底是出现了内存泄漏还是内存溢出
      • 内存泄漏
        通过工具查看泄漏对象到GC ROOTS的引用链,找到为什么垃圾收集器无法回收的原因,找出内存泄漏的具体代码
      • 非内存泄漏
        即内存中的对象必须存活,需要检查虚拟机的堆参数(-Xms和-Xmx)设置,与机器的内存对比,看是否还有上升的空间;
        再从代码入手,看是否某些对象生命周期过长,持有时间过长,存储结构不合理等情况。
  • 虚拟机栈和本地方法栈溢出
    会出现两种异常 StackOverflowErrorOutOfMemoryError

    • 当线程请求的栈深度大于虚拟机所允许的最大深度时,会出现StackOverflowError
    • 虚拟机的栈内存允许拓展,当拓展栈容量无法申请到足够的内存时,会出现OutOfMemoryError

    《Java虚拟机规范》明确允许Java虚拟机实现自行选择是否支持栈的动态扩展,而HotSpot虚拟机的选择是不支持扩展,所以除非在创建线程申请内存时就因无法获得足够内存而出现OutOfMemoryError异常,否则在线程运行时是不会因为扩展而导致内存溢出的,只会因为栈容量无法容纳新的栈帧而导致StackOverflowError异常

  • 方法区和运行时常量池溢出
    在JDK8以后,永久代便完全退出了历史舞台,元空间作为其替代者登场。在默认设置下,前面列举的那些正常的动态创建新类型的测试用例已经很难再迫使虚拟机产生方法区的溢出异常了。

  • 本机直接内存溢出
    由直接内存导致的内存溢出,一个明显的特征是在HeapDump文件中不会看见有什么明显的异常情况,如果读者发现内存溢出之后产生的Dump文件很小,而程序中又直接或间接使用了DirectMemory(典型的间接使用就是NIO),那就可以考虑重点检查一下直接内存方面的原因了。

垃圾回收器与内存分配策略

概述

垃圾回收需要完成的三个工作:

  • 哪些内存需要回收
  • 什么时候回收
  • 怎么回收

java内存运行时区域中

  • 确认性区域:程序计数器,虚拟机栈,本地方法栈,无需太多考虑垃圾回收问题,当方法结束或者线程结束之后,内存自然会被回收
  • 不确定性区域:方法区和堆,我们只有在运行期间才会知道到底创建哪些对象,多少个,这部分的内存分配和回收是动态的

引用计数算法

在对象中添加一个引用计数器,每有一个地方引用,计数器加一; 当引用失效,计数器减一;计数器为零的对象就是不可再被使用。

  • 问题:
    无法解决对象相互循环使用,导致无法回收问题

Java虚拟机并不是通过引用计数算法来判断对象是否存活的

可达性分析算法

当前主流的程序语言都是通过可达性分析算法来判断对象是否存活。

  • GC roots
    可以作为GC roots 的对象作为起始点集合
  • 引用链
    从起始点根据引用关系向下搜索,搜索过程中走过的路径被称为引用链
  • 算法基本思路
    如果某个对象到GC roots间没有任何引用链相连,意味着当前对象不可达,说明此对象不可能再被使用
可作为GC roots的对象类型
  • 虚拟机栈(栈帧中的本地变量表)中引用的对象,如各线程被调用的堆栈中使用到的参数,局部变量,临时变量
  • 方法区中静态属性引用的对象,如引用类型的静态变量
  • 方法区中常量引用的对象,如字符串常量池里的引用
  • 本地方法栈中JNI引用的对象
  • 虚拟机内部的引用,如基本类型对象的Class对象,一些常驻的异常对象(如NullPointerException, OutOfMemoryError), 系统类加载器等
  • 所有被同步锁(synchronized关键字)持有的对象
  • 反应java虚拟机内部情况的JMXBean,JVMTI中注册的回调,本地代码缓存等

除了这些固定的GC roots对象,根据用户所选的垃圾回收器以及当前的内存区域不同,其他对象可能也会作为GC roots,如局部回收,如果只针对java堆中某一区域进行回收,需要考虑到其他区域的对象可能被选中区域的对象引用,此时,其他区域的对象也需要添加到GC roots当中,这样才能保证可达性的准确性

回收方法区

有些人认为方法区(如HotSpot虚拟机中的元空间或者永久代)是没有垃圾收集行为的,《Java虚拟机规范》中提到过可以不要求虚拟机在方法区中实现垃圾收集,事实上也确实有未实现或未能完整实现方法区类型卸载的收集器存在。

方法区垃圾回收性价比低

主要回收两部分内容,废弃的常量和不再使用的类型

  • 废弃的常量

    如常量池中字面量,其他类,接口,方法,字段的符号引用
  • 不再使用的类型

    需要同时满足三个条件
    • 该类所有实例都已经被回收,即java堆中不存在任何该类以及派生子类的实例
    • 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
    • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法
      同时满足这三个条件才会被允许进行回收,还要受到虚拟机的参数控制的影响

垃圾回收算法

  • 引用计数式垃圾回收
    直接垃圾回收
  • 追踪式垃圾回收
    间接垃圾回收

分代收集理论

当代商业虚拟机的垃圾收集器,大多数都遵循"分代收集"的理论进行设计,该理论是符合大部分程序运行实际情况的经验法则,其建立在两种假说之上

  • 弱分代假说: 绝大多数对象是朝生夕灭的
  • 强分代假说: 熬过越多次垃圾收集过程的对象就越难被消亡

这两个假说奠定了多款常用的垃圾收集器一致的设计原则: java堆应该被划分出不同的区域,将回收对象按照年龄(熬过垃圾回收过程的次数)分配到不同的区域存储。

这就衍生出了垃圾收集器只针对某一区域记性垃圾回收,从而会出现一个问题,如果出现跨代引用问题该怎么解决,为了解决这个问题,需要引用第三个经验法则:

  • 跨代引用假说: 跨代引用相对于同代引用来说极其少

这个其实是前面两个假说逻辑推理得出的隐含理论: 存在互相引用的两个对象,应该倾向于同时生存或同时消亡。

依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”,RememberedSet),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生MinorGC时,只有包含了跨代引用的小块内存里的对象才会被加入到GCRoots进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的。

回收类型

  • 部分收集(Partial GC)

    指不是完整回收整个java堆的垃圾回收,其中又包括

    • 新生代收集(Minor GC/ Young GC)

      指只针对新生代的垃圾回收

    • 老年代收集(Major GC/ Old GC)

      指只针对老年代的垃圾回收,目前只有CMS收集器会有单独收集老年代的行为。

    • 混合收集(Mixed GC)

      指针对整个新生代和部分老年代的垃圾回收。目前只有G1收集器会有这种行为。

  • 整堆收集(Full GC)

    收集整个java堆的垃圾回收

标记-清除算法

最早出现,最基础的垃圾回收算法

算法思路

分为标记,清除两个阶段,标记要被回收的或者不被回收的对象,清除掉要回收的对象

存在两个缺点:

  • 执行效率不稳定
  • 内存碎片化

标记-复制算法

简称复制算法

算法思路

半区复制,即将可用内存按照容量等分两部分,每次只使用一块。当内存使用完,将存活的对象复制到另外一块内存,将使用的内存一次性清理掉。

特点:

  • 实现简单
  • 运行高效
  • 浪费一半内存
Appel式回收(优化之后的复制算法)

HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局

算法思路

将新生代分为一块较大的Eden空间和两个比较小的Survivor空间,每次分配内存只使用Eden和一个Survivor空间,垃圾回收时,将Eden空间和Survivor空间中存活的对象一次性复制到另一个Survivor空间,然后清理Eden和使用的Survivor空间。

HotSpot虚拟机默认Eden空间和Survivor空间比例为8:1, 即新生代可以使用的内存为整个新生代的90%, 但是当存活对象超过新生代的10%时,Survivor内存不够,所以Appel式回收还有一个充当罕见情况的“逃生门”的安全设计。

当Survivor空间不足以容纳一次MinorGC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保。如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代。

HotSpot算法细节实现

根节点枚举

所有收集器在根节点枚举步骤时都是必须暂停用户线程的,因此根节点枚举面临着与整理内存碎片同样的"Stop the world"的困扰。根节点枚举过程中,跟节点集合中的对象引用是不断变化的,所以要保证准确性,必须停顿所有用户线程.

由于主流java虚拟机使用的都是准确式垃圾收集,所以当用户线程停下来时,并不需要一个不漏的检查完所有执行上下文和全局的引用变量,虚拟机应当是有办法直接得到哪些地方存放着对象引用的。

在HotSpot的解决方案中,是使用了一组称为OopMap (普通对象指针(Ordinary Object Pointer)。一旦类加载动作完成时,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,会在特定的位置记录下栈里和寄存器哪些位置是引用,这样在收集器扫描时,就可以直接得知。

安全点

OopsMap可以协助hotSpot快速准确地完成GC Roots枚举,但是会导致:

  • 可能导致引用关系的改变

    或者说导致OopMap内容变化的指令非常多,如果为每一个指令都生成OopMap,将会需要大量的额外存储空间,这样垃圾回收的空间成本就变大了。

实际上HotSpot也并没有为每条指令都生成OopMap,只有在"特定的位置"记录了这些信息,这些位置称为安全点。

安全点决定了用户程序执行时,并非在代码指令流的任何位置都能停顿来进行垃圾收集,而是强制要求必须执行到达安全点后才能暂停。

安全点位置的选取以"是否具有让程序长时间执行的特性"为标准选定, "长时间执行"的最明显特征就是指令序列的复用,例如方法的调用,循环跳转,异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。

对于安全点,另外一个需要考虑的问题是:如何让垃圾收集发生时,所有线程都停顿在安全点。有两种方案可以选择:

  • 抢先式中断 (几乎没有虚拟机实现此方法来响应GC事件)

    不需要线程的执行代码主动去配合,在垃圾收集发生时,中断所有用户线程,如果有中断的地方不在安全点,恢复该线程执行,直到跑到安全点。

  • 主动式终端

    当垃圾收集需要中断线程时,不对线程进行操作,设置一个标志位,各个线程执行时会不断轮询这个标志,一旦发现中断标志为真就将自己在最近的安全点主动挂起。
    轮询位置与安全点重合,除此之外还要加上所有创建对象,以及其他需要在堆上分配内存的地方,这个是为了检查是否即将要发生垃圾收集,避免没有足够的内存分配。

    轮询操作会频繁出现,所以要求必须足够高效,HotSpot使用内存保护陷阱的方式,把轮询操作精简至只有一条汇编指令的程度。

    当执行到轮询指令时,会产生一个自陷异常信号,然后在预先注册的异常处理器中挂起线程实现等待,这样就实现了仅使用一条指令完成安全点轮询和触发线程中断。

安全区域

我们也可以把安全区域看作被扩展拉伸了的安全点。

安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。但是,程序“不执行”的时候呢?所谓的程序不执行就是没有分配处理器时间,典型的场景便是用户线程处于Sleep状态或者Blocked状态,这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。

对于这种情况,就必须引入安全区域(SafeRegion)来解决。安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。

当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。

当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止。
q

记忆集与卡表

记忆集是一种用于记录非收集区域指向收集区域的指针集合的抽象数据结构。

记忆集不关心对象内部结构,节约维护储存成本,选择更加粗犷的记录粒度:

  • 字长精度:

    每个记录精确到一个机器字长(即处理器的寻址位数,32位,64位),该字包含跨代指针。
  • 对象精度:

    每个记录精确到一个对象,该对象里有字段包含跨代指针
  • 卡精度:

    每个记录精确到一个内存区域,该区域有对象包含跨代指针

卡精度是用一种叫卡表(Card Table)的方式实现记忆集,是目前最常见的实现记忆集的方法。

记忆集是一个抽象的数据结构;卡表是记忆集的一个具体实现,定义了记忆集的记忆精度、与堆内存的映射关系等。类比HashMap和Map。

卡表最简单的形式就是一个字节数组,HotSpot就是这么实现的,数组中的每一个元素都对应着其标识的内存区域的特定大小的内存,这个内存块称为卡页。卡页大小通常是2的整次幂的字节数。

卡页中通常不止一个对象,如果有一个对象存在跨代指针,将对应的数组元素值变为1,称为该元素变脏,待垃圾收集时将所有变脏元素加入到GC Roots中一并扫描。

写屏障

我们已经解决了如何使用记忆集来缩减GCRoots扫描范围的问题,但还没有解决卡表元素如何维护的问题,例如它们何时变脏、谁来把它们变脏等。

卡表元素何时变脏的答案是很明确的——有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。

在HotSpot虚拟机里是通过写屏障(WriteBarrier)技术维护卡表状态的。写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-WriteBarrier),在赋值后的则叫作写后屏障(Post-WriteBarrier)。

除了写屏障的开销外,卡表在高并发场景下还面临着“伪共享”(FalseSharing)问题。伪共享是处理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以缓存行(CacheLine)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值