关于JVM翻越内存管理的墙

2022-11-13 08:11:25 jvm 内存管理 翻越

对于Java程序员来说,在虚拟机自动内存管理机制的帮助下,不再需要为每一个new操作去写配对 的delete/free代码释放内存,也由此不容易出现内存泄漏和内存溢出问题。但凡事都有两面性,由虚拟机管理内存看起来一切都很美好,但也正是因为把控制内存的权力交给了Java虚拟机,一旦出现内存泄漏和溢出方面的问题,就不得不从Java虚拟机角度上去排查问题。因此我们需要了解虚拟机是怎样使用内存的,才能准确的定位到错误,从而正确的解决问题。

主要内容:

  • JVM运行时数据区域
  • JVM垃圾回收机制

JVM运行时数据区域

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。

线程私有内存:

由于JVM多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。

因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

程序计数器

程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址。

如果正在执行的是本地(Native)方法,这个计数器值则应为空。

Java虚拟机栈

Java虚拟机栈描述的是Java方法执行的线程内存模型,它也是线程私有内存区域,生命周期和线程一样。

栈桢

每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

1.局部变量表

局部变量表存放了编译期可知的:基本数据类型、对象引用、和returnAddress类型(指向了一条字节码指令的地址)

局部变量表中的存储空间以局部变量槽表示。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小(这里说的“大小”是指变量槽的数量,一个变量槽多大是由具体虚拟机实现的)

2.异常情况

1.StackOverflowError异常:线程请求的栈深度大于虚拟机所允许的深度

2.OutOfMemoryError异常:Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存。 在HotSpot虚拟机上是不会由于虚拟机栈无法扩展而导致OutOfMemoryError异常。只要线程申请栈空间成功了就不会有OOM,但是如果申请时就失败,仍然是会出现OOM异常的。

本地方法栈

与虚拟机栈所发挥的作用是非常相似的。本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

HotSpot虚拟机直接就把本地方法栈和虚拟机栈合二为一

Java堆

Java堆是虚拟机所管理的内存中最大的一块,被所有线程共享的一块内存区域 Java堆是垃圾收集器管理的内存区域。所以也经常被称为GC

Java堆会在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java世界里“几乎”所有的对象实例都在这里分配内存。

从回收内存的角度看,由于现代垃圾收集器大部分都是基于分代收集理论设计的,所以Java堆中经常会出现“新生代”“老年代”“永久代”“Eden空间”“From Survivor空 间”“To Survivor空间”等名词。

在之前(以G1收集器的出现为分界),作为业界绝对主流的HotSpot虚拟机,它内部的垃圾收集器全部都基于“经典分代” 来设计,需要新生代、老年代收集器搭配才能工作,在这种背景下,上述说法还算是不会产生太大歧义。但是到了今天,垃圾收集器技术与十年前已不可同日而语,HotSpot里面也出现了不采用分代设计的新垃圾收集器,再按照上面的提法就有很多需要商榷的地方了。

分配缓冲区TLAB(Thread Local Allocation Buffer)

如果从分配内存的角度看,所有线程共享的Java堆中可以划分出多个线程私有的分配缓冲区 (Thread Local Allocation Buffer,TLAB)。

无论如何划分,都不会改变Java堆中存储内容的共性,无论是哪个区域,存储的都只能是对象的实例,将Java堆细分的目的只是为了更好地回收内存,或者更快地分配内存。

Java堆的大小设定

Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx-Xms设定)。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。

方法区

方法区别名叫作“非堆”。它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。是各个线程共享的内存区域

很多人都更愿意把方法区称呼为“永久代”(PermanentGeneration),或将两者混为一谈。本质上这两者并不是等价的。因为仅仅是当时的HotSpot虚拟机设计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已,这样使得 HotSpot的垃圾收集器能够像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。

相对Java堆而言,垃圾收集行为在这个区域的确是比较少出现的,但并非数据进入了方法区就永久”存在了。 这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收有时又确实是必要的。

运行时常量池

运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译期生成的各种字面量与符号引用,在类加载后存放到方法区的运行时常量池中

运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是说,并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的 intern()方法。

深入解析String#intern

Java中,直接使用双引号声明出来的String对象会直接存储在常量池中。不是用双引号声明的String对象,可以使用String提供的intern方法。

intern 方法:如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象的引用;否则,会将此String对象包含的字符串添加 到常量池中,并且返回此String对象的引用。

小结

整理下上面介绍的JVM运行时数据区域:

JVM垃圾回收机制

上面介绍了程序计数器、虚拟机栈、本地方法栈都是线程私有区域,这三个区域随线程而生,随线程而灭。 在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。

比如栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的(尽管在运行期会由即时编译器进行一些优化,但在基于概念模型的讨论里,大体上可以认为是编译期可知的),因此这几个区域的内存分配和回收都具备确定性。

但是Java堆和方法区这两个区域则有着很显著的不确定性:

1.一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存该如何管理。

2.方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。回收废弃常量与回收 Java堆中的对象非常类似。

比如已经没有任何字符串对象引用常量池中的某常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,该常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。

方法区垃圾收集的“性价比”通常也是比较低的:在Java堆中,尤其是在新生代中,对常规应用进行一次垃圾收集通常可以回收70%至99%的内存空间,相比之下,方法区回收囿于苛刻的判定条件,其区域垃圾收集的回收成果往往远低于此。

判断对象存活

垃圾回收的是死亡的对象,所以在回收前要做的事确定这个对象是否还存活。判断对象存活的方式主流的有两种算法:引用计数算法和可达性分析算法。

引用计数算法

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

该算法的缺点是:当两个对象互相引用,会导致无法回收;因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也就无法回收它们。

引用计数算法(Reference Counting)虽然占用了一些额外的内存空间来进行计数,但 它的原理简单,判定效率也很高,在大多数情况下它都是一个不错的算法。也有一些比较著名的应用 案例,例如微软COM(Component Object Model)技术、使用ActionScript 3的FlashPlayer、python语言以及在游戏脚本领域得到许多应用的Squirrel中都使用了引用计数算法进行内存管理。但是,在Java 领域,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内存。

可达性分析算法

当前主流的商用程序语言(Java、C#,Lisp)的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。

该算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连, 或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。

其中GC Root的对象有很多种,常见的有:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  • 所有被同步(synchronized)持有的对象

几种引用方式

无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判定对象是否存活都和“引用”离不开关系。

根据引起的强度从强到弱排序

  • 强引用:强引用是我们最常用的,在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象
  • 软引用:描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存, 才会抛出内存溢出异常。
  • 弱引用:用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
  • 虚引用:也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知

垃圾回收算法

标记清除算法

算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。

缺点:

  • 执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低
  • 内存空间的碎片化问题,标记、清除之后会产生大 量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作

标记复制算法

它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

优点:

解决标记清除法的缺点。每次都是对整个半区进行回收,不用考虑内存碎片浪费。

缺点:

  • 缺陷在于将可用内存缩小为了原来的一半,空间浪费未免太多了一点。

  • 如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销。

现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代。

新生代中的对象有98%熬不过第一轮收集。因此并不需要按照1∶1的比例来划分新生代的内存空间。HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了这种策略来设 计新生代的内存布局。Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的 Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍 然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空 间。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也即每次新生代中可用内存空间为整个新 生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会 被“浪费”的。

标记整理法

该算法让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

优点:不会存在标记整理内存浪费的问题。

缺点:复制收集算法在对象存活率高的情况下就会出现复制操作,移动操作多,效率会变低。

标记清除法和标记整理法的选择是一种权衡:

标记整理法,通过移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须**全程暂停用户应用程序(Stop The World)**才能进行。

如果跟标记-清除算法那样完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。譬如通过“分区空闲分配链表”来解决内存分配问题(计算机硬盘存储大文件就不要求物理连续的磁盘空间,能够在碎片化的硬盘上存储和访问就是通过硬盘分区表实现的)。内存的访问是用户程序最频繁的操作,假如在这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量。

基于以上两点,是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算。

即使不移动对象会使得收集器的效率提升一些,但因内存分配和访问相比垃圾收集频率要高得多,这部分的耗时增加,总吞吐量仍然是下降的。

HotSpot虚拟机里面关注吞吐量的Parallel Scavenge收集器是基于标记-整理算法的,而关注延迟的CMS收集器则是基于标记-清除算法的,

为了平衡二者的弊端,就有一种中和的方式。让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。比如基于标记-清除算法的CMS收集器面临空间碎片过多时采用的就是这种处理办法。

分代收集算法

当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”的理论进行设计。

多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。

这样做的优点是:

  • 如果一个区域中大多数对象都难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间。

  • 如果剩下的都是难以消亡的对象,那把它们集中放在一块, 虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。

Java堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域 。因而才有了Minor GCMajor GCFull GC这样的回收类型的划分。也才能够针对不同的区域安排与里面存储对象存亡特征相匹配的垃圾收集算法。

收集概念的区分:

新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集

老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。请注意“Major GC”这个说法现在有点混淆,在不同资料上常有不同所指, 读者需按上下文区分到底是指老年代的收集还是整堆收集。

整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。

Java堆·划分为新生代和老年代。在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。

ps: 这些区域划分仅仅是一部分垃圾收集器的共同特性或者说设计风格而已,而非某个JVM具体实现的固有内存布局,更不是《Java虚拟机规范》里对Java堆的进一步细致划分。作为业界绝对主流的HotSpot虚拟机,它内部的垃圾收集器全部都基于“经典分代” 来设计,需要新生代、老年代收集器搭配才能工作。但到了今天,HotSpot里面也出现了不采用分代设计的新垃圾收集器。

内存回收策略

下面介绍的回收策略是基于“经典分代” 设计的回收过程:

1.新生代的分配和回收

1.大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。把新生代分为一块较大的Eden空间和两块较小的 Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍 然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空 间。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1

2.大对象直接进入老年代

2.大对象直接进入老年代。大对象就是指需要大量连续内存空间的Java对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组

为什么要这么做呢?这样做的目的就是避免在Eden区及两个Survivor区之间来回复制,产生大量的内存复制操作。

大对象对虚拟机的内存分配来说是一个坏消息,比遇到一个大对象更坏的消息就是遇到一群“朝生夕灭”的短命大对象。我们写程序的时候应注意避免大对象。在Java虚拟机中要避免大对象的原因是,在分配空间时,它容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好它们,而当复 制对象时,大对象就意味着高额的内存复制开销。

3.长期存活的对象将进入老年代

如果经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,该对象会被移动到Survivor空间中,并且将其对象年龄设为1岁。对象在Survivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定的年龄阈值(默认为15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX: MaxTenuringThreshold设置。

参考

  • 《深入理解Java虚拟机(第三版)》
  • 深入解析String#intern

到此这篇关于关于JVM翻越内存管理的墙的文章就介绍到这了,更多相关JVM内存管理内容请搜索以前的文章或继续浏览下面的相关文章希望大家以后多多支持!

相关文章