关于java:从JAVA内存到垃圾回收带你深入理解JVM

39次阅读

共计 8567 个字符,预计需要花费 22 分钟才能阅读完成。

​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​【摘要】明天带你走进 JVM 的世界。

学过 Java 程序员对 JVM 应该并不生疏,如果你没有听过,没关系明天我带你走进 JVM 的世界。程序员为什么要学习 JVM 呢,其实不懂 JVM 也能够照样写出优质的代码,然而不懂 JVM 有可能别被面试官虐得遍体鳞伤。

1、JAVA 内存区域与内存溢出异样

1.1 运行时数据区域

1.1.1 程序计数器

以后线程所执行的字节码的行号指示器,是程序控制流的指示器,分支、循环、跳转、异样解决、线程复原等根底性能都须要依赖程序计数器。内存较小。

Java 虚拟机的多线程是通过线程轮流切换,调配处理器工夫的形式来实现的,所以在任何一个确定的时刻,一个处理器(即多处理器的一个内核)都只会执行一条线程中的指令。因而,为了线程切换后,能复原到正确的执行地位,每条线程都须要一个独立的程序计数器,各个线程之间不影响,独立存储,咱们称这类内存区域为“线程公有”。

此内存区域是惟一一个在《java 虚拟机标准》中没有规定任何 OOM 状况的区域。

1.1.2 java 虚拟机栈

线程公有,Java 虚拟机栈的生命周期与线程雷同。

Java 虚拟机栈形容的是 Java 办法执行的线程内存模型:每个办法被执行的时候,Java 虚拟机都会同步创立一个栈帧用于保留 局部变量表、操作数栈、动静链接、办法进口等信息。每个办法被调用直至执行结束的过程,对应了栈帧在虚拟机栈中入栈到出栈的过程。

局部变量表寄存了编译期可知的各种 Java 虚拟机根本数据类型(boolean、byte、char、short、int、float、long、double)、对象援用(reference 类型,它不同于对象自身,可能是一个指向对象起始地址的援用指针,也可能是指向一个代表对象的句柄或其余与此对象相干的地位)和 returnAddress 类型(指向一条字节码指令的地址)。

Java 虚拟机栈会呈现两种谬误:StackOverFlowError 和 OutOfMemoryError。

• StackOverflowError:若 Java 虚拟机栈的内存大小不容许动静扩大,那么当线程申请栈的深度超过以后 Java 虚拟机栈的最大深度的时候,就抛出 StackOverFlowError 谬误。

OutOfMemoryError:若 Java 虚拟机栈的内存大小容许动静扩大,且当线程申请栈时内存用完了,无奈再动静扩大了,此时抛出 OutOfMemoryError 谬误。

Java 办法有两种返回形式:return 语句;抛出异样,不论哪种返回形式都会导致栈帧被弹出。

参数 -Xss

1.1.3 本地办法栈

和虚拟机栈所施展的作用十分类似,区别是:虚拟机栈为虚拟机执行 Java 办法(也就是字节码)服务,而本地办法栈则为虚拟机应用到的 Native 办法服务。在 HotSpot 虚拟机中和 Java 虚拟机栈合二为一。和虚拟机栈一样会产生 StackOverFlowError 和 OutOfMemoryError。

1.1.4 java 堆

Java 虚拟机所治理的内存中最大的一块,Java 堆是所有线程共享的一块内存区域,在虚拟机启动时创立。此内存区域的惟一目标就是寄存对象实例,简直所有的对象实例以及数组都在这里分配内存。

Java 世界中“简直”所有的对象都在堆中调配,然而,随着 JIT 编译期的倒退与逃逸剖析技术逐步成熟,栈上调配、标量替换优化技术将会导致一些奥妙的变动,所有的对象都调配到堆上也慢慢变得不那么“相对”了。从 jdk 1.7 开始曾经默认开启逃逸剖析,如果某些办法中的对象援用没有被返回或者未被里面应用(也就是未逃逸进来),那么对象能够间接在栈上分配内存。

Java 堆是垃圾收集器治理的次要区域,因而也被称作 GC 堆(Garbage Collected Heap)。从垃圾回收的角度,因为当初收集器根本都采纳分代垃圾收集算法,所以 Java 堆还能够细分为:新生代和老年代:再粗疏一点将新生代分为:Eden 空间、From Survivor、To Survivor 空间。进一步划分的目标是更好地回收内存,或者更快地分配内存。

在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常被分为上面三局部:

  1. 新生代内存(YoungGeneration)
  2. 老生代(OldGeneration)
  3. 永生代(PermanentGeneration)

堆这里最容易呈现的就是 OutOfMemoryError 谬误,比方:

  1. OutOfMemoryError: GC Overhead Limit Exceeded:当 JVM 花太多工夫执行垃圾回收并且只能回收很少的堆空间时,就会产生此谬误。
  2. java.lang.OutOfMemoryError: Java heap space : 如果在创立新的对象时, 堆内存中的空间不足以寄存新创建的对象, 就会引发 java.lang.OutOfMemoryError:Java heap space 谬误。

1.1.5 办法区

线程共享,用于保留已被虚拟机加载的类型信息、常量、动态变量、即时编译器编译后的代码缓存等数据。尽管 Java 虚拟机标准把办法区形容为堆的一个逻辑局部,然而它却有一个别名叫做 Non-Heap(非堆),目标应该是与 Java 堆辨别开来。

办法区和永恒代的关系

《Java 虚拟机标准》只是规定了有办法区这个概念和它的作用,并没有规定如何去实现它。那么,在不同的 JVM 上办法区的实现必定是不同的了。办法区和永恒代的关系很像 Java 中接口和类的关系,类实现了接口,而永恒代就是 HotSpot 虚拟机对虚拟机标准中办法区的一种实现形式,过后的 HotSpot 虚拟机设计团队抉择把收集器的分代设计扩大到办法区。也就是说,永恒代是 HotSpot 的概念,办法区是 Java 虚拟机标准中的定义,是一种标准,而永恒代是一种实现,一个是规范一个是实现,其余的虚拟机实现并没有永恒代这一说法。

为什么要将永恒代 (PermGen) 替换为元空间 (MetaSpace) 呢?

  1. 整个永恒代有一个 JVM 自身设置固定大小下限,无奈进行调整,而元空间应用的是间接存,受本机可用内存的限度,尽管元空间仍旧可能溢出,然而比原来呈现的几率会更小。

当元空间溢出时会失去如下谬误:java.lang.OutOfMemoryError:MetaSpace

你能够应用 -XX:MaxMetaspaceSize 标记设置最大元空间大小,默认值为 unlimited,这意味着它只受零碎内存的限度。-XX:MetaspaceSize 调整标记定义元空间的初始大小如果未指定此标记,则 Metaspace 将依据运行时的应用程序需要动静地从新调整大小。

  1. 元空间外面寄存的是类的元数据,这样加载多少类的元数据就不禁 MaxPermSize 管制了, 而由零碎的理论可用空间来管制,这样能加载的类就更多了。
  2. 在 JDK8,合并 HotSpot 和 JRockit 的代码时, JRockit 素来没有一个叫永恒代的概念, 合并之后就没有必要额定的设置这么一个永恒代的中央了。

办法区的倒退迁徙过程

JDK 6 时,HotSpot 团队就有放弃永恒代、逐渐改为本地内存来实现办法区的打算了。JDK 7,曾经把本来放在永恒代的字符串常量池、动态变量等移除。JDK8,终于齐全放弃了永恒代,把 JDK 7 中永恒代还残余的内容(次要是类型信息)全副移到元空间中。

依据《Java 虚拟机标准》, 如果办法区无奈满足新的内存调配需要时,将抛出 OOM 异样。

1.1.6 运行时常量池

运行时常量池是办法区的一部分。Class 文件中除了有类的版本、字段、办法、接口等形容信息外,还有常量池表(用于寄存编译期生成的各种字面量和符号援用)

既然运行时常量池是办法区的一部分,天然受到办法区内存的限度,当常量池无奈再申请到内存时会抛出 OutOfMemoryError 谬误。

JDK1.7 及之后版本的 JVM 曾经将运行时常量池从办法区中移了进去,在 Java 堆(Heap)中开拓了一块区域寄存运行时常量池。

  1. JDK1.7 之前运行时常量池逻辑蕴含字符串常量池寄存在办法区, 此时 hotspot 虚拟机对办法区的实现为永恒代
  2. JDK1.7 字符串常量池被从办法区拿到了堆中, 这里没有提到运行时常量池,也就是说字符串常量池被独自拿到堆,运行时常量池剩下的货色还在办法区, 也就是 hotspot 中的永恒代。
  3. JDK1.8 hotspot 移除了永恒代用元空间 (Metaspace) 取而代之, 这时候字符串常量池还在堆, 运行时常量池还在办法区, 只不过办法区的实现从永恒代变成了元空间(Metaspace)

1.1.7 间接内存

间接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机标准中定义的内存区域,然而这部分内存也被频繁地应用。而且也可能导致 OutOfMemoryError 谬误呈现。

JDK1.4 中新退出的 NIO(NewInput/Output) 类,引入了一种基于通道(Channel)与缓存区(Buffer)的 I/O 形式,它能够间接应用 Native 函数库间接调配堆外内存,而后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的援用进行操作。这样就能在一些场景中显著进步性能,因为防止了在 Java 堆和 Native 堆之间来回复制数据。

本机间接内存的调配不会受到 Java 堆的限度,然而,既然是内存就会受到本机总内存大小以及处理器寻址空间的限度。

2、垃圾回收

2.1 虚拟机如何判断对象是否存活?

1. 援用计数算法

给对象中增加一个援用计数器,每当有一个中央援用它时,计数器就加 1;当援用生效时,计数器值就减 1;任何时刻计数器为 0 的对象就是不可能再被应用的。

思考一种情景:对象 objA 和 objB 都有字段 instance, 赋值令 objA.instance=objB 和 objB.instance=objA; 除此之外,这两个对象再无任何援用,实际上这两个对象以及不可能再被拜访,然而它们因为相互援用着对方,导致它们的援用计数都不为 0,于是援用计数算法无奈告诉 GC 收集器回收它们。如果这个对象特地大,则会造成重大的内存泄露。

2. 可达性剖析算法

根本思维:通过一系列的称为”GC Roots”的对象作为起始点,从这些节点开始向下搜寻,搜寻所走过的门路称为援用链(ReferenceChain),当一个对象到 GC Roots 没有任何援用链时,则证实此对象是不可用的。

GC Roots 的对象包含上面几种:

• 虚拟机栈(栈帧中的本地变量表)中援用的对象。

• 办法区中类动态属性援用的对象。

• 办法区中常量援用的对象。

• 本地办法栈中 JNI 援用的对象。

垃圾收集算法

1. 标记 - 革除算法(Mark-Sweep)

最根底的收集算法,分为”标记”和”革除”两个阶段:首先标记处所有须要回收的对象,在标记实现后对立回收所有被标记的对象。

次要有余有两个:一是效率问题,标记和革除两个过程的效率都不高;另一个是空间问题,标记革除之后会产生大量不间断的内存碎片,空间碎片大多可能会导致当前再程序运行过程中须要调配较大对象时,无奈找到足够的间断内存而不得不提前登程另一次垃圾收集动作。

2. 复制算法(Copying)

为了解决效率问题,一种称为”复制“的收集算法呈现,它将可用内存按容量划分为大小相等的两块,每次只应用其中的一块。当这一块的内存用完了,就将还存活的对象复制到另外一块下面,而后再把已应用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存调配时也就不必思考内存碎片等简单状况,只有挪动堆顶斧正按程序分配内存即可,实现简略,运行高效。只是这种算法的代价是将内存放大为原来的个别,未免太高了一点。

古代的商业虚拟机都采纳这种收集算法来回收新生代,IBM 公司钻研表明,新生代的对象 98% 都是”朝生夕死“的,所以并不需要 1:1 的比例来划分内存空间,而是将内存划分为一块较大的 Eden 空间和两块较小的 Survivor 空间。HotSpot 默认 Eden 和 Survivor 的大小比例是 8:1. 如果 Survivor 空间不够用时,须要依赖其余内存(老年代)进行调配担保(HandlePromotion)。

3. 标记 - 整顿算法(Mark-Compact)

复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。依据老年代的特点,提出此种算法,标记过程依然与”标记 - 革除“算法一样,但后续步骤不是间接对可回收对象进行清理,而是让所有存活的对象都向一端挪动,而后间接清理掉端边界意外的内存。

4. 分代收集算(GenerationalCollection)

以后商业虚拟机的垃圾收集都采纳”分代收集“算法。个别是把 java 堆分成新生代和老年代。在新生代中,每次垃圾收集时都发现有少量对象死去,只有大量存活,那就选用复制算法,只须要付出大量存活对象的复制老本就能够实现收集。而老年代中因为对象存活率高、没有额定空间对它进行调配担保,就必须应用”标记——清理“或者”标记——整顿“算法来进行回收。

垃圾收集器

Serial 收集器

这是一个单线程的收集器,但它的“单线程”的意义并不仅仅阐明它只会应用一个 CPU 或一条手机线程去实现垃圾手机工作,更重要的是在它进行垃圾收集时,必须暂停其余所有的工作线程,直到它收集完结。“Stop theworld”, 由虚拟机在后盾主动发动和主动实现的,在用户不可见的状况下把用户失常工作的线程全副停掉,这对很多利用来说都是难以承受的。它是虚拟机运行在 Client 模式下的默认新生代收集器。

长处:简略而高效(与其余收集器的单线程比),对于限定单个 CPU 的环境来说,Serial 收集器因为没有线程交互的开销,分心做垃圾收集天然能够取得最高的单线程收集效率。

ParNew 收集器

ParNew 收集器其实就是 serial 收集器的多线程版本,除了应用多条线程进行垃圾收集之外,其余行为与 Serial 收集器一样。ParNew 收集器也是应用 -XX:+UseConcMarkSweepGC 选项后的默认新生代收集器,也能够应用 -XX:+UseParNewGC 选项来强制指定它。

ParallelScavenge 收集器

Parallel Scavenge 收集器也是一个新生代收集器,它也是应用复制算法的收集器,又是并行多线程收集器。parallelScavenge 收集器的特点是它的关注点与其余收集器不同,CMS 等收集器的关注点是尽可能地缩短垃圾收集时用户线程的进展工夫,而 parallelScavenge 收集器的指标则是达到一个可管制的吞吐量。吞吐量 = 程序运行工夫 /(程序运行工夫 + 垃圾收集工夫),虚拟机总共运行了 100 分钟。其中垃圾收集花掉 1 分钟,那吞吐量就是 99%。

Parallel Scavenge 收集器提供了两个参数用于准确管制吞吐量,别离是管制最大垃圾收集进展工夫的 -XX:MaxGCPauseMillis 参数以及间接设置吞吐量大小的 -XX:GCTimeRatio 参数。

Serial Old 收集器

Serial Old 是 Serial 收集器的老年代版本,它同样应用一个单线程执行收集,应用“标记 - 整顿”算法。次要应用在 Client 模式下的虚拟机。

ParallelOld 收集器

Parallel Old 是 ParallelScavenge 收集器的老年代版本,应用多线程和“标记 - 整顿”算法。

CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收进展工夫为指标的收集器。CMS 收集器是基于“标记 - 革除”算法实现的,整个收集过程大抵分为 4 个步骤:

• 初始标记(CMSinitial mark)

• 并发标记(CMSconcurrenr mark)

• 从新标记(CMSremark)

• 并发革除(CMSconcurrent sweep)

其中初始标记、从新标记这两个步骤任然须要进展其余用户线程。初始标记仅仅只是标记出 GC ROOTS 能间接关联到的对象,速度很快,并发标记阶段是进行 GC ROOTS 根搜索算法阶段,会断定对象是否存活。而从新标记阶段则是为了修改并发标记期间,因用户程序持续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的进展工夫会被初始标记阶段稍长,但比并发标记阶段要短。

因为整个过程中耗时最长的并发标记和并发革除过程中,收集器线程都能够与用户线程一起工作,所以整体来说,CMS 收集器的内存回收过程是与用户线程一起并发执行的。

CMS 收集器的长处:并发收集、低进展,然而 CMS 还远远达不到完满,器次要有三个显著毛病:

CMS 收集器对 CPU 资源十分敏感。在并发阶段,尽管不会导致用户线程进展,然而会占用 CPU 资源而导致援用程序变慢,总吞吐量降落。CMS 默认启动的回收线程数是:(CPU 数量 +3) / 4。

CMS 收集器无奈解决浮动垃圾,可能呈现“ConcurrentMode Failure“,失败后而导致另一次 Full GC 的产生。因为 CMS 并发清理阶段用户线程还在运行,随同程序的运行自热会有新的垃圾一直产生,这一部分垃圾呈现在标记过程之后,CMS 无奈在本次收集中解决它们,只好留待下一次 GC 时将其清理掉。这一部分垃圾称为“浮动垃圾”。也是因为在垃圾收集阶段用户线程还须要运行,即须要预留足够的内存空间给用户线程应用,因而 CMS 收集器不能像其余收集器那样等到老年代简直齐全被填满了再进行收集,须要预留一部分内存空间提供并发收集时的程序运作应用。在默认设置下,CMS 收集器在老年代应用了 68% 的空间时就会被激活,也能够通过参数 -XX:CMSInitiatingOccupancyFraction 的值来提供触发百分比,以升高内存回收次数进步性能。要是 CMS 运行期间预留的内存无奈满足程序其余线程须要,就会呈现“ConcurrentMode Failure”失败,这时候虚拟机将启动后备预案:长期启用 Serial Old 收集器来从新进行老年代的垃圾收集,这样进展工夫就很长了。所以说参数 -XX:CMSInitiatingOccupancyFraction 设置的过高将会很容易导致“ConcurrentMode Failure”失败,性能反而升高。

最初一个毛病,CMS 是基于“标记 - 革除”算法实现的收集器,应用“标记 - 革除”算法收集后,会产生大量碎片。空间碎片太多时,将会给对象调配带来很多麻烦,比如说大对象,内存空间找不到间断的空间来调配不得不提前触发一次 Full GC。为了解决这个问题,CMS 收集器提供了一个 -XX:UseCMSCompactAtFullCollection 开关参数,用于在 Full GC 之后减少一个碎片整顿过程,还可通过 -XX:CMSFullGCBeforeCompaction 参数设置执行多少次不压缩的 Full GC 之后,跟着来一次碎片整顿过程。

G1 收集器(Garbage-First)

G1 是一款面向服务器利用垃圾收集器,与其余 GC 收集器想必,G1 具备以下特点:

• 并行与并发:G1 能充分利用多 CPU、多核环境下的硬件劣势,应用多个 CPU 来缩短 Stop-The-World 进展的工夫,局部其余收集器本来须要进展 Java 线程执行的 GC 动作,G1 收集器依然能够通过并发的形式让 Java 程序继续执行。

• 分代收集:与其余收集器一样,分代概念在 G1 中仍然得以保留。尽管 G1 能够不要其余收集器配合就能独立治理整个 GC 堆,但它可能采纳不同的形式去解决新创建的对象和曾经存活了一半工夫、熬过屡次 GC 的旧对象以获取更好的收集成果。

• 空间整合:与 CMS 的“标记 - 清理”算法不同,G1 从整体上看是基于“标记 - 整顿”算法实现的收集器,从部分(两个 Region 之间)上来看是基于“复制”算法实现,无论如何,这两种算法都意味着 G1 运行期间不会产生内存空间碎片,收集后能提供规整的可用内存。

• 可预测的进展:这是 G1 绝对于 CMS 的另一个大劣势,升高进展工夫是 G1 和 CMS 独特的关注点,但 G1 除了谋求低进展外,还能建设可预测的进展工夫模型,能让使用者明确指定在一个长度为 M 毫秒的工夫片段内,小号在垃圾收集上的工夫不能超过 N 毫秒,这简直曾经是实时 Java(RTSJ)的垃圾收集器的特色了。

在 G1 收集器中,Region 之间的对象援用以及其余收集器中的新生代与老年代之间的对象援用,虚拟机都是应用 RememberedSet 来防止全堆扫描的。G1 中每个 Region 都有一个与之对应的 Remebered Set, 虚拟机发现程序在对 Reference 类型的数据进行写操作时,会产生一个 WriteBarrier 临时中断写操作,查看 Reference 援用的对象是否处于不同的 Region 之中(在分代的例子中,就是查看是否老年代中的读写援用了新生代中的对象)。如果是,便通过 CardTable 把相干援用信息记录到被援用对象所属的 Region 的 Remembered Set 之中。当进行内存回收时,在 GC 根节点的枚举范畴中退出 RememeredSet 即可保障不对全队扫描也不会有脱漏。

如果不计算保护 Remembered Set 的操作,G1 收集器的运作大抵可划分为以下几个步骤:

• 初始标记

• 并发标记

• 最终标记

• 筛选标记

本文分享自华为云社区《深刻了解 JVM 浏览笔记一》,原文作者:ayin。

点击关注,第一工夫理解华为云陈腐技术~

正文完
 0