Java虚拟机(JVM)总结

JVM的主要组成部分及其作用

JVM包含两个子系统和两个组件
在这里插入图片描述

两个子系统为:Class Loader(类装载子系统)、Execution Engine(执行引擎)。
两个组件为:Runtime Data Area(运行时数据区)、Native Interface(本地接口)。

Class Loader:根据给定的全限定类名来装载class文件到运行时数据区的方法区中。
Execution Engine:执行classes中的指令。
Native Interface:与本地方法库交互,是与其他编程语言交互的接口。
Runtime Data Area
总体流程:
首先通过编译器把java代码转换成字节码文件,类加载器再把字节码文件加载到内存中,将其放在运行时数据区的方法区中,而字节码文件只是jvm的一套指令集规范,并不能直接交给底层操作系统执行,因此需要特定的命令解析器执行引擎把字节码翻译成底层系统指令,再交由CPU执行,而这个过程中需要调用其他语言的本地库接口来实现整个程序的功能。
在这里插入图片描述

运行时数据区

运行时数据区分为5个部分:
数据共享区:堆、方法区
数据独占区:虚拟机栈、本地方法栈、程序计数器

具体讲讲各个区域的作用:
程序计数器:当前线程所执行的字节码的行号指示器,每个线程都要有一个独立的程序计数器。字节码解析器的工作是通过改变这个计数器的值,来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能。

Java虚拟机栈:是描述java方法执行的内存模型,每个方法在执行的时候都会创建一个栈帧,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用到执行结束对应着一个栈帧在虚拟机中入栈到出栈的过程。
在这里插入图片描述

本地方法栈:本地方法栈和虚拟机栈的作用有点相似,区别是虚拟机栈为执行java方法服务,而本地方法栈为Native本地方法服务。

堆内存:Java虚拟机中内存最大的一块,是被所有线程共享,几乎所有的实例对象都是在这里分配内存。垃圾收集器进行垃圾收集的最重要的内存区域。由于现代VM采用分代收集算法进行GC,因此java堆从GC角度还可以细分为新生代(Eden、from、to)和老年代。新生代和老年代内存比例为1:2。

方法区:用于存储被JVM加载的类信息常量静态变量即时编译器编译后的代码等数据。Hotspot VM把GC分代收集扩展至方法区,即使用java堆的永久代来实现方法区,这样垃圾收集器就可以像管理堆一样管理方法区这部分内存,而不必为方法区开发专门的内存管理器(永久代的内存回收主要是针对常量池的回收和类型的卸载,因此收益很小)。
运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存储编译器生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

新生代和老年代:

新生代:
用来存放新生的对象,一般占据堆内存的3分之1的空间。由于频繁创建对象,所以新生代会频繁触发minor gc进行垃圾回收。新生代又分为Eden区、Survivor From、Survivor To区。
在这里插入图片描述

Eden区:Java新对象的出生地(如果新创建的对象占用内存很大,则直接分配到老年代),当Eden区内存不够的时候就会触发Minor GC,对新生代进行一次垃圾回收。

From区:用于存放经过minor gc后还存活的对象。

To区:用于复制的空间,经过一次gc后会和from区交换。(因为GC后存在内存碎片,通过复制算法,可以将存活下来的对象存放到连续的内存空间)

老年代:
主要存放应用程序中生命周期长的对象。
老年代的对象比较稳定,所以Major GC 不会频繁。在进行 MajorGC 前一般都先进行了一次 MinorGC,使得有新生代的对象晋身入老年代,导致空间不够用时才触发。当无法找到足够大的连续空间分配给新创建的较大对象时也会提前触发一次 MajorGC 进行垃圾回收腾出空间。
MajorGC 采用标记清除算法:首先扫描一次所有老年代,标记出存活的对象,然后回收没有标记的对象。 MajorGC 的耗时比较长,因为要扫描再回收。 MajorGC 会产生内存碎片,为了减少内存损耗,我们一般需要进行合并或者标记出来方便下次直接分配。当老年代也满了装不下的时候,就会抛出 OOM(Out of Memory)异常。

永久代:
内存的永久保存区域,主要存放 Class 和 Meta(元数据)的信息,Class 在被加载的时候被放入永久区域, 它和和存放实例的区域不同,GC 不会在主程序运行期对永久区域进行清理。所以这也导致了永久代的区域会随着加载的 Class 的增多而胀满,最终抛出 OOM 异常。

JAVA8 与元数据
在 Java8 中, 永久代已经被移除,被一个称为“元数据区”(元空间)的区域所取代。元空间的本质和永久代类似,元空间与永久代之间最大的区别在于: 元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。 类的元数据放入 native memory, 字符串池和类的静态变量放入 java 堆中, 这样可以加载多少类的元数据就不再由MaxPermSize 控制, 而由系统的实际可用空间来控制。

内存分配与回收的策略:

1、优先在Eden分配

在大多数情况下,对象在新生代Eden区分配,当Eden区内存空间不够时,发起Minor GC。
关于Minor GC和Full GC:
• Minor GC:发生在新生代上,因为新生代大部分对象存活时间很短,因此Minor GC会频繁执行,执行的速度一般也会比较快。
• Full GC:发生在老年代,老年代对象和新生代对象相反,对象存活时间长,因此Full GC很少执行,但执行速度非常慢。

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

大对象是指需要连续内存空间的对象,最典型的是那种很长的字符串和数组。经常出现大对象会提前触发垃圾收集器以便获得足够的内存空间分配给大对象。
提供 -XX:PretenureSizeThreshold 参数,大于此值的对象直接在老年代分配,避免在 Eden 区和 Survivor 区之间的大量内存复制。

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

JVM为对象定义年龄计数器(对象头Markword中),经过Minor GC依然存活,并且能被Survivor区容纳的,年龄就增加1岁,增加到一定年龄后则移动到老年代(默认15岁,通过-XX:MaxTenuringThreshold 设置)。

4、动态对象年龄判定

JVM并不是永远要求对象年龄必须达到MaxTenuringThreshold才能晋升老年代,如果survivor区中相同年龄所有对象大小的总和大于Survivor区的一半,则年龄大于等于该年龄的对象可以直接进入老年代,无需等待MaxTenuringThreshold中要求的年龄

5、空间分配担保

在发生 Minor GC 之前,JVM 先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的;如果不成立的话 JVM 会查看 HandlePromotionFailure 设置值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那这时也要改为进行一次 Full GC。

Full GC触发条件:

对于Minor GC,其触发条件非常简单,Eden区满了就触发一次Minor GC。而Full GC的触发就相对复杂,有以下条件:
1、调用System.gc()
此方法的调用是建议 JVM 进行 Full GC,虽然只是建议而非一定,但很多情况下它会触发 Full GC,从而增加 Full GC 的频率,也即增加了间歇性停顿的次数。因此强烈建议能不使用此方法就不要使用,让虚拟机自己去管理它的内存。可通过 -XX:+ DisableExplicitGC 来禁止 RMI 调用 System.gc()。

2、老年代空间不足
老年代空间不足的常见场景为前文所讲的大对象直接进入老年代、长期存活的对象进入老年代等,当执行 Full GC 后空间仍然不足,则抛出 Java.lang.OutOfMemoryError。为避免以上原因引起的 Full GC,调优时应尽量做到让对象在 Minor GC 阶段被回收、让对象在新生代多存活一段时间以及不要创建过大的对象及数组。

3、空间担保失败
Minor GC需要老年代的空间内存作为担保,如果出现了担保失败,则会触发Full GC。

4、JDK1.7及以前的永久代空间不足
在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些 class 的信息、常量、静态变量等数据,当系统中要加载的类、反射的类和调用的方法较多时,永久代可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么 JVM 会抛出 java.lang.OutOfMemoryError,为避免以上原因引起的 Full GC,可采用的方法为增大永久代空间或转为使用 CMS GC。

5、Concurrent Mode Failure
执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(有时候“空间不足”是 CMS GC 时当前的浮动垃圾过多导致暂时性的空间不足触发 Full GC),便会报 Concurrent Mode Failure 错误,并触发 Full GC。

垃圾收集算法

程序计数器、虚拟机栈和本地方法栈这三个区域属于线程私有的,只存在于线程的生命周期内,线程结束之后也会消失,因此不需要对这三个区域进行垃圾回收。垃圾回收主要是针对 Java 堆和方法区进行。

如何确定对象是否是垃圾

1、引用计数法

在 Java 中,引用和对象是有关联的。如果要操作对象则必须用引用进行。因此,很显然一个简单的办法是通过引用计数来判断一个对象是否可以回收。简单说,即一个对象如果没有任何与之关联的引用, 即他们的引用计数都不为 0, 则说明对象不太可能再被用到,那么这个对象就是可回收对象。给对象添加一个引用计数器,当对象增加一个引用时计数器加1,引用失效时计数器减1.引用计数器为0的对象可被回收。
两个对象循环引用时,此时计数器永远不为0,导致无法对它们进行回收。
objA.instance = objB;
objB.instance = objA;

2、可达性分析法

通过一系列的GC Roots对象作为起点搜索,如果在GC Roots和一个对象间没有可达路径,则称该对象是不可达的。要注意的是,不可达对象不等价于可回收对象,不可达对象变为可回收对象至少需要经过两次标记过程。两次过后还是不可达对象,则将面临回收。
在这里插入图片描述

GC Roots一般包含以下内容:

  1. 虚拟机栈中引用的对象
  2. 本地方法栈中引用的对象
  3. 方法区中类静态属性引用的对象
  4. 方法区中常量引用的对象

Java四种引用类型

1、强引用

在 Java 中最常见的就是强引用, 把一个对象赋给一个引用变量,这个引用变量就是一个强引用。当一个对象被强引用变量引用时,它处于可达状态,它是不可能被垃圾回收机制回收的,即使该对象以后永远都不会被用到 JVM 也不会回收。因此强引用是造成 Java 内存泄漏的主要原因之一。

Object obj = new Object();
2、软应用

软引用需要用 SoftReference 类来实现,对于只有软引用的对象来说,当系统内存足够时它不会被回收,当系统内存空间不足时它会被回收。软引用主要用来实现类似缓存的功能,在内存足够的情况下直接通过软引用取值,无需从繁忙的真实来源获取数据,提升速度;当内存不足时,自动删除这部分缓存数据,从真正的来源获取这些数据。

Object obj = new Object();
SoftReference<Object> sf = new SoftReference<Object>(obj);
3、弱引用

弱引用需要用 WeakReference 类来实现,它比软引用的生存期更短,对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管 JVM 的内存空间是否足够,总会回收该对象占用的内存。

Object obj = new Object();
WeakReference<Object> wf = new WeakReference<Object>(obj);
4、虚引用

虚引用需要 PhantomReference 类来实现,它不能单独使用,必须和引用队列联合使用。 虚引用的主要作用是跟踪对象被垃圾回收的状态。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

Object obj = new Object();
PhantomReference<Object> pf = new PhantomReference<Object>(obj);

方法区的回收

因为方法区主要存放永久代对象,而永久代对象的回收率比新生代差很多,因此在方法区上进行回收性价比不高。
主要是对常量池的回收对类的卸载

类的卸载条件很多,需要满足以下三个条件,并且满足了也不一定会被卸载:

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

可以通过 -Xnoclassgc 参数来控制是否对类进行卸载。
在大量使用反射、动态代理、CGLib 等 ByteCode 框架、动态生成 JSP 以及 OSGo 这类频繁自定义 ClassLoader 的场景都需要虚拟机具备类卸载功能,以保证不会出现内存溢出。

finalize()

finalize() 类似 C++ 的析构函数,用来做关闭外部资源等工作。但是 try-finally 等方式可以做的更好,并且该方法运行代价高昂,不确定性大,无法保证各个对象的调用顺序,因此最好不要使用。
当一个对象可被回收时,如果需要执行该对象的 finalize() 方法,那么就有可能通过在该方法中让对象重新被引用,从而实现自救。

标记清除算法:

在这里插入图片描述

将需要回收的对象进行标记,然后进行清除
不足:
1、标记和清除效率都不高
2、会产生大量的碎片,内存碎片过多可能导致无法给大对象分配内存。

复制算法:

在这里插入图片描述
将内存划分为大小相等的两块,每次只使用其中的一块,当这一块内存使用完就将还存活的对象复制到另一块内存上面,然后把使用过的内存空间进行一次清理。
不足:
只使用了一半的内存空间。

复制算法主要用于新生代,但是并不是将内存划分为大小相等的两块,而是分为一块较大的 Eden 空间和两块较小的 Survior 空间,每次使用 Eden 空间和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象一次性复制到另一块 Survivor 空间上,最后清理 Eden 和 使用过的那一块 Survivor。HotSpot 虚拟机的 Eden 和 Survivor 的大小比例默认为 8:1,保证了内存的利用率达到 90 %。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 空间就不够用了,此时需要依赖于老年代进行分配担保,也就是借用老年代的空间。

标记整理算法

在这里插入图片描述

让所有存活的对象都向一端移动,然后清理掉端边界以外的内存。标记阶段与标记清除算法相同。

分代收集算法

分代收集算法,根据对象存活周期将内存划分为几块,不同块采用适当的收集算法。
一般Java堆分为新生代和老年代
新生代:复制算法
老年代:标记清除算法或者标记整理算法

新生代与复制算法:
目前大部分 JVM 的 GC 对于新生代都采取 Copying 算法,因为新生代中每次垃圾回收都要回收大部分对象,即要复制的操作比较少,但通常并不是按照 1: 1 来划分新生代。一般将新生代划分为一块较大的 Eden 空间和两个较小的 Survivor 空间(From Space, To Space),每次使用Eden 空间和其中的一块 Survivor 空间,当进行回收时,将该两块空间中还存活的对象复制到另一块 Survivor 空间中。

老年代与标记清除算法、标记整理算法:
而老年代因为每次只回收少量对象,因而采用 Mark-Compact 算法。

  1. JAVA 虚拟机提到过的处于方法区的永生代(Permanet Generation), 它用来存储 class 类,常量,方法描述等。对永生代的回收主要包括废弃常量和无用的类。

  2. 对象的内存分配主要在新生代的 Eden Space 和 Survivor Space 的 From Space(Survivor 目前存放对象的那一块),少数情况会直接分配到老生代。

  3. 当新生代的 Eden Space 和 From Space 空间不足时就会发生一次 GC,进行 GC 后, EdenSpace 和 From Space 区的存活对象会被挪到 To Space,然后将 Eden Space 和 From Space 进行清理。、

  4. 如果 To Space 无法足够存储某个对象,则将这个对象存储到老生代。

  5. 在进行 GC 后,使用的便是 Eden Space 和 To Space 了,如此反复循环。

  6. 当对象在 Survivor 区躲过一次 GC 后,其年龄就会+1。 默认情况下年龄到达 15 的对象会被移到老生代中。

垃圾收集器

在这里插入图片描述
以上是Hotspot虚拟机中的7个垃圾收集器,连线代表垃圾收集器可以配合使用。

1、Serial收集器

在这里插入图片描述
它是单线程的收集器(复制算法),不仅意味着只会使用一个线程来进行垃圾收集工作,更重要的是它在进行垃圾收集时,必须暂停所有其他工作线程,往往造成过长的等待时间。

它的优点是简单高效,对于当个CPU环境来说,由于没有线程交互的开销,因此拥有最高的单线程收集效率。
在 Client 应用场景中,分配给虚拟机管理的内存一般来说不会很大,该收集器收集几十兆甚至一两百兆的新生代停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿是可以接受的。

2、ParNew收集器

在这里插入图片描述

它是serial收集器的多线程版本。(复制算法
是 Server 模式下的虚拟机首选新生代收集器,除了性能原因外,主要是因为除了 Serial 收集器,只有它能与 CMS 收集器配合工作。
默认开始的线程数量与 CPU 数量相同,可以使用 -XX:ParallelGCThreads 参数来设置线程数。

3、Parallel Scavenge收集器

Parallel Scavenge 收集器也是一个新生代垃圾收集器,同样使用复制算法,也是一个多线程的垃圾收集器, 它重点关注的是程序达到一个可控制的吞吐量(Thoughput, CPU 用于运行用户代码的时间/CPU 总消耗时间,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)),高吞吐量可以最高效率地利用 CPU 时间,尽快地完成程序的运算任务,主要适用于在后台运算而不需要太多交互的任务。 自适应调节策略也是 ParallelScavenge 收集器与 ParNew 收集器的一个重要区别。

提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间 -XX:MaxGCPauseMillis 参数以及直接设置吞吐量大小的 -XX:GCTimeRatio 参数(值为大于 0 且小于 100 的整数)。缩短停顿时间是以牺牲吞吐量和新生代空间来换取的:新生代空间变小,垃圾回收变得频繁,导致吞吐量下降。

还提供了一个参数 -XX:+UseAdaptiveSizePolicy,这是一个开关参数,打开参数后,就不需要手工指定新生代的大小(-Xmn)、Eden 和 Survivor 区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种方式称为 GC 自适应的调节策略(GC Ergonomics)。自适应调节策略也是它与 ParNew 收集器的一个重要区别。

4、Serial Old收集器

在这里插入图片描述
Serial Old是Serial收集器的老年代版本单线程标记整理算法),也是给Client模式下虚拟机使用。
在Server模式下,主要有两个用途:
1、在JDK1.5之前版本中与新生代的Parallel Scavenge收集器配合使用。
2、作为老年代中使用CMS收集器的后备垃圾收集方案。

5、Parallel Old收集器

在这里插入图片描述

是 Parallel Scavenge 收集器的老年代版本。(多线程标记整理算法
在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。

6、CMS收集器

在这里插入图片描述
CMS(Concurrent Mark Sweep),基于标记清除算法实现。
特点:并发收集、低停顿。

分为以下四个流程:

  1. 初始标记:仅仅标记一下GC Roots能直接关联到的对象,速度很快,需要停顿。
  2. 并发标记:进行GC Roots Tracing的过程,它在整个回收过程中耗时最长,不需要停顿。
  3. 重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
  4. 并发清除:不需要停顿。
    在整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,不需要进行停顿。

具有以下缺点:
1、对 CPU 资源敏感。CMS 默认启动的回收线程数是 (CPU 数量 + 3) / 4,当 CPU 不足 4 个时,CMS 对用户程序的影响就可能变得很大,如果本来 CPU 负载就比较大,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然降低了 50%,其实也让人无法接受。并且低停顿时间是以牺牲吞吐量为代价的,导致 CPU 利用率变低。

2、无法处理浮动垃圾。由于并发清除阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生。这一部分垃圾出现在标记过程之后,CMS 无法在当次收集中处理掉它们,只好留到下一次 GC 时再清理掉,这一部分垃圾就被称为“浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此它不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。可以使用 -XX:CMSInitiatingOccupancyFraction 的值来改变触发收集器工作的内存占用百分比,JDK 1.5 默认设置下该值为 68,也就是当老年代使用了 68% 的空间之后会触发收集器工作。如果该值设置的太高,导致浮动垃圾无法保存,那么就会出现 Concurrent Mode Failure,此时虚拟机将启动后备预案:临时启用 Serial Old 收集器来重新进行老年代的垃圾收集。

3、标记 - 清除算法导致的空间碎片,给大对象分配带来很大麻烦,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象,不得不提前触发一次 Full GC。

7、G1收集器

G1(Garbage-First)收集器是当今收集器技术发展最前沿的成果之一,它是一款面向服务端应用的垃圾收集器,HotSpot 开发团队赋予它的使命是(在比较长期的)未来可以替换掉 JDK 1.5 中发布的 CMS 收集器。

具备如下特点:
• 并行与并发:能充分利用多 CPU 环境下的硬件优势,使用多个 CPU 来缩短停顿时间。
• 分代收集:分代概念依然得以保留,虽然它不需要其它收集器配合就能独立管理整个 GC 堆,但它能够采用不同方式去处理新创建的对象和已存活一段时间、熬过多次 GC 的旧对象来获取更好的收集效果。
• 空间整合:整体来看是基于“标记 - 整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的,这意味着运行期间不会产生内存空间碎片。
• 可预测的停顿:这是它相对 CMS 的一大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了降低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在 GC 上的时间不得超过 N 毫秒,这几乎已经是实时 Java(RTSJ)的垃圾收集器的特征了。

在 G1 之前的其他收集器进行收集的范围都是整个新生代或者老生代,而 G1 不再是这样,Java 堆的内存布局与其他收集器有很大区别,将整个 Java 堆划分为多个大小相等的独立区域(Region)。虽然还保留新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,而都是一部分 Region(不需要连续)的集合。

之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个 Java 堆中进行全区域的垃圾收集。它跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region(这也就是 Garbage-First 名称的来由)。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了它在有限的时间内可以获取尽可能高的收集效率。

Region 不可能是孤立的,一个对象分配在某个 Region 中,可以与整个 Java 堆任意的对象发生引用关系。在做可达性分析确定对象是否存活的时候,需要扫描整个 Java 堆才能保证准确性,这显然是对 GC 效率的极大伤害。为了避免全堆扫描的发生,每个 Region 都维护了一个与之对应的 Remembered Set。虚拟机发现程序在对 Reference 类型的数据进行写操作时,会产生一个 Write Barrier 暂时中断写操作,检查 Reference 引用的对象是否处于不同的 Region 之中,如果是,便通过 CardTable 把相关引用信息记录到被引用对象所属的 Region 的 Remembered Set 之中。当进行内存回收时,在 GC 根节点的枚举范围中加入 Remembered Set 即可保证不对全堆扫描也不会有遗漏。

如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致可划分为以下几个步骤:
1、初始标记
2、并发标记
3、最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录**,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面**,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。
4、筛选回收:首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。

7种垃圾收集器的对比
在这里插入图片描述

类加载机制

类是在运行期间动态加载的。

类的生命周期

在这里插入图片描述
包括以下7个阶段:
• 加载
• 验证
• 准备
• 解析
• 初始化
• 使用
• 卸载
其中解析过程在某些情况下可以在初始化阶段后再执行,这是为了支持Java的动态绑定。

类初始化时机

虚拟机规范中并没有强制约束何时进行加载,但是规范严格规定了有且只有下列五种情况必须对类进行初始化(加载、验证、准备都会随着发生):
1、遇到 new、getstatic、putstatic、invokestatic 这四条字节码指令时,如果类没有进行过初始化,则必须先触发其初始化。最常见的生成这 4 条指令的场景是:使用 new 关键字实例化对象的时候;读取或设置一个类的静态字段(被 final 修饰、已在编译器把结果放入常量池的静态字段除外)的时候;以及调用一个类的静态方法的时候。

2、使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行初始化,则需要先触发其初始化。

3、当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。

4、当虚拟机启动时,用户需要指定一个要执行的主类(包含 main() 方法的那个类),虚拟机会先初始化这个主类;

5、当使用 JDK1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果为 REF_getStatic, REF_putStatic, REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化;

以上 5 种场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用。被动引用的常见例子包括:
1、通过子类引用父类的静态字段,不会导致子类初始化。

System.out.println(SubClass.value); // value 字段在 SuperClass 中定义

2、通过数组定义来引用类,不会触发此类的初始化。该过程会对数组类进行初始化,数组类是一个由虚拟机自动生成的、直接继承自 Object 的子类,其中包含了数组的属性和方法。

SuperClass[] sca = new SuperClass[10];

3、常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化。

System.out.println(ConstClass.HELLOWORLD);

类加载过程

包含了加载、验证、准备、解析和初始化这5个阶段。

1.加载

加载是类加载的一个阶段,注意不要混淆。
加载过程完成以下三件事:

  1. 通过一个类的全限定名来获取定义此类的二进制字节流。
  2. 将这个字节流所代表的静态存储结构转化为方法区的运行时存储结构。
  3. 在内存中生成一个代表这个类的 Class 对象,作为方法区这个类的各种数据的访问入口。

其中二进制字节流可以从以下方式中获取:
• 从 ZIP 包读取,这很常见,最终成为日后 JAR、EAR、WAR 格式的基础。
• 从网络中获取,这种场景最典型的应用是 Applet。
• 运行时计算生成,这种场景使用得最多得就是动态代理技术,在 java.lang.reflect.Proxy 中,就是用了 ProxyGenerator.generateProxyClass 的代理类的二进制字节流。
• 由其他文件生成,典型场景是 JSP 应用,即由 JSP 文件生成对应的 Class 类。
• 从数据库读取,这种场景相对少见,例如有些中间件服务器(如 SAP Netweaver)可以选择把程序安装到数据库中来完成程序代码在集群间的分发。

2. 验证

确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。
主要有以下 4 个阶段:
(一)文件格式验证
验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理。
(二)元数据验证
对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求。
(三)字节码验证
通过数据流和控制流分析,确保程序语义是合法、符合逻辑的。
(四)符号引用验证
发生在虚拟机将符号引用转换为直接引用的时候,对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验。

3. 准备

类变量是被 static 修饰的变量,准备阶段为类变量分配内存并设置初始值,使用的是方法区的内存。
实例变量不会在这阶段分配内存,它将会在对象实例化时随着对象一起分配在 Java 堆中。
初始值一般为 0 值,例如下面的类变量 value 被初始化为 0 而不是 123。
public static int value = 123;
如果类变量是常量,那么会按照表达式来进行初始化,而不是赋值为 0。
public static final int value = 123;

4. 解析

将常量池的符号引用替换为直接引用的过程。
符号引用就是class文件中的:
1. CONSTANT_Class_info
2. CONSTANT_Field_info
3. CONSTANT_Method_info
等类型的常量。

符号引用:
符号引用与虚拟机实现的布局无关, 引用的目标并不一定要已经加载到内存中。 各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须是一致的,因为符号引用的字面量形式明确定义在 Java 虚拟机规范的Class 文件格式中。

直接引用:
直接引用可以是指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。如果有了直接引用,那引用的目标必定已经在内存中存在。

5.初始化

初始化阶段是类加载最后一个阶段,前面的类加载阶段之后,除了在加载阶段可以自定义类加载器以外,其它操作都由 JVM 主导。到了初始阶段,才开始真正执行类中定义的 Java 程序代码。

初始化阶段是执行类构造器方法的过程。 方法是由编译器自动收集类中的类变量的赋值操作和静态语句块中的语句合并而成的。虚拟机会保证子方法执行之前,父类的方法已经执行完毕, 如果一个类中没有对静态变量赋值也没有静态语句块,那么编译器可以不为这个类生成()方法。

注意以下几种情况不会执行类初始化:

  1. 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。
  2. 定义对象数组,不会触发该类的初始化。
  3. 常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常量所在的类。
  4. 通过类名获取 Class 对象,不会触发类的初始化。
  5. 通过 Class.forName 加载指定类时,如果指定参数 initialize 为 false 时,也不会触发类初始化,其实这个参数是告诉虚拟机,是否要对类进行初始化。
  6. 通过 ClassLoader 默认的 loadClass 方法,也不会触发初始化动作。

类加载器

启动类加载器(Bootstrap ClassLoader)

此类加载器负责将存放在 <JAVA_HOME>\lib 目录中的,或者被 -Xbootclasspath 参数所指定的路径中的,并且是虚拟机识别的(仅按照文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机内存中。 启动类加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,直接使用 null 代替即可。

扩展类加载器(Extension ClassLoader)

这个类加载器是由 ExtClassLoader (sun.misc.Launcher$ExtClassLoader)实现的。它负责将 <JAVA_HOME>/lib/ext 或者被 java.ext.dir 系统变量所指定路径中的所有类库加载到内存中,开发者可以直接使用扩展类加载器。

应用程序类加载器(Application ClassLoader)

这个类加载器是由 AppClassLoader(sun.misc.Launcher$AppClassLoader)实现的。由于这个类加载器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,因此一般称为系统类加载器。它负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

双亲委派机制

应用程序都是由三种类加载器相互配合进行加载的,如果有必要,还可以加入自己定义的类加载器。下图展示的类加载器之间的层次关系,称为类加载器的双亲委派模型(Parents Delegation Model)。该模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器,这里类加载器之间的父子关系一般通过组合(Composition)关系来实现,而不是通过继承(Inheritance)的关系实现。
在这里插入图片描述
(一)工作过程
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载,而是把这个请求委派给父类加载器,每一个层次的加载器都是如此,依次递归。因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成此加载请求(它搜索范围中没有找到所需类)时,子加载器才会尝试自己加载。

(二)好处
使用双亲委派模型来组织类加载器之间的关系,使得 Java 类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类 java.lang.Object,它存放在 rt.jar 中,无论哪个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此 Object 类在程序的各种类加载器环境中都是同一个类。相反,如果没有双亲委派模型,由各个类加载器自行加载的话,如果用户编写了一个称为java.lang.Object 的类,并放在程序的 ClassPath 中,那系统中将会出现多个不同的 Object 类,程序将变得一片混乱。如果开发者尝试编写一个与 rt.jar 类库中已有类重名的 Java 类,将会发现可以正常编译,但是永远无法被加载运行

(三)实现

protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException{
    //check the class has been loaded or not
    Class c = findLoadedClass(name);
    if(c == null) {
        try{
            if(parent != null) {
                c = parent.loadClass(name, false);
            } else{
                c = findBootstrapClassOrNull(name);
            }
        } catch(ClassNotFoundException e) {
            //if throws the exception , the father can not complete the load
        }
        if(c == null) {
            c = findClass(name);
        }
    }
    if(resolve) {
        resolveClass(c);
    }
    return c;
}

对象访问

直接指针访问

如果使用直接指针访问,引用中存储的直接就是对象地址,那么Java堆对象内部的布局中就必须考虑如何放置访问类型数据的相关信息。
在这里插入图片描述

优势:速度更快,节省了一次指针定位的时间开销。由于对象的访问在Java中非常频繁,因此这类开销积少成多后也是非常可观的执行成本。HotSpot 中采用的就是这种方式。

句柄访问

Java堆中划分出一块内存来作为句柄池,引用中存储对象的句柄地址,而句柄中包含了对象实例数据与对象类型数据各自的具体地址信息,具体构造如下图所示:
在这里插入图片描述

优势:引用中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而引用本身不需要修改。

JVM参数详解

在这里插入图片描述

收集器相关

在这里插入图片描述

辅助信息

在这里插入图片描述

JVM调优

常见问题

内存泄漏

内存泄漏一般可以理解为系统资源(各方面的资源,堆、栈、线程等)在错误使用的情况下,导致使用完毕的资源无法回收(或没有回收),从而导致新的资源分配请求无法完成,引起系统错误。整个JVM内存大小=年轻代大小 + 年老代大小 + 持久代大小,目前来说,常遇到的泄漏问题如下:

年老代堆空间被占满

 年老代堆空间被占满
 异常: java.lang.OutOfMemoryError: Java heap space

这是最典型的内存泄漏方式,简单说就是所有堆空间都被无法回收的垃圾对象占满,虚拟机无法再在分配新空间。这种情况一般来说是因为内存泄漏或者内存不足造成的。某些情况因为长期的无法释放对象,运行时间长了以后导致对象数量增多,从而导致的内存泄漏。另外一种就是因为系统的原因,大并发加上大对象,Survivor Space区域内存不够,大量的对象进入到了老年代,然而老年代的内存也不足时,从而产生了Full GC,但是这个时候Full GC也无发回收。这个时候就会产生java.lang.OutOfMemoryError: Java heap space

解决方案如下:
1、代码内的内存泄漏可以通过一些分析工具进行分析,然后找出泄漏点进行改善。
2、第二种原因导致的OutOfMemoryError可以通过,优化代码和增加Survivor Space等方式去优化。

持久代被占满

持久代被占满
异常:java.lang.OutOfMemoryError: PermGen space

Perm空间被占满。无法为新的class分配存储空间而引发的异常。这个异常以前是没有的,但是在Java反射大量使用的今天这个异常比较常见了。主要原因就是大量动态反射生成的类不断被加载,最终导致Perm区被占满。 解决方案:
1、增加持久代的空间 -XX:MaxPermSize=100M。
2、如果有自定义类加载的需要排查下自己的代码问题。

堆栈溢出

堆栈溢出
异常:java.lang.StackOverflowError

一般就是递归没返回,或者循环调用造成

线程堆栈满

线程堆栈满
异常:Fatal: Stack size too small

java中一个线程的空间大小是有限制的。JDK5.0以后这个值是1M。与这个线程相关的数据将会保存在其中。但是当线程空间满了以后,将会出现上面异常。 解决:增加线程栈大小。-Xss2m。但这个配置无法解决根本问题,还要看代码部分是否有造成泄漏的部分。

系统内存被占满

系统内存被占满
异常:java.lang.OutOfMemoryError: unable to create new native thread

这个异常是由于操作系统没有足够的资源来产生这个线程造成的。系统创建线程时,除了要在Java堆中分配内存外,操作系统本身也需要分配资源来创建线程。因此,当线程数量大到一定程度以后,堆中或许还有空间,但是操作系统分配不出资源来了,就出现这个异常了。 分配给Java虚拟机的内存愈多,系统剩余的资源就越少,因此,当系统内存固定时,分配给Java虚拟机的内存越多,那么,系统总共能够产生的线程也就越少,两者成反比的关系。同时,可以通过修改-Xss来减少分配给单个线程的空间,也可以增加系统总共内生产的线程数。 解决:

1、重新设计系统减少线程数量。
2、线程数量不能减少的情况下,通过-Xss减小单个线程大小。以便能生产更多的线程。

优化方法

优化目标

优化jvm其实是为了满足高吞吐,低延迟需求来优化GC,之前遇到的情况中,其实不优化GC也是可以正常运行的,只不过偶尔会因为高并发给压垮,但是也可以通过其他方式来解决这个问题。

优化GC步骤

1、首先需要观察目前垃圾回收的情况,分析出老年代和年轻代回收的情况,适当的去调整内存大小和-XX:SurvivorRatio的比例。

2、根据垃圾收集器的特性,选择适合自己业务的垃圾收集器,一般来说现在的WEB服务都是CMS+ParNew收集器。根据CMS收集器一般来说就会产生大量碎片,根据自己的需求悬着相应的压缩频率即可。

3、不断的调整jvm内存比例,老年代、年轻代、以及持久代的比例,直到测试出一个比较满意的值。

优化总结

总的来说GC优化不仅仅是加大内存可以解决的。需要综合下业务特征和GC的时间,减少新生代大小可以缩短新生代GC停顿时间,因为这样被复制到survivor区域或者被提升的数据更少,但是这样一来minor GC的频率就会很高,而且会有更多的垃圾进入到了老年代。如果增加新生代大小又会导致回收的时间和复制的时间变高,所以一般来说需要在这个中间进行折中。

像是针对大部分数据都在Eden区域被回收,并且几乎没有对象在survivor区域死亡的场景,完全可以减少-XX:MaxTenuringThreshold这个参数,让数据提早进入Old,减少survivor区域的复制,来提高效率。

案例分析

案例1 Intellij IDEA 2016优化

公司系统参数

-server 
-Xms2g 
-Xmx2g
-Xmn768m 
-XX:+UseConcMarkSweepGC 
-XX:+UseParNewGC 
-XX:CMSInitiatingOccupancyFraction=60 
-XX:CMSTriggerRatio=70 
-Xloggc:/data/bpm.coffee.session/logs/gc_20160704_110306.log 
-XX:+PrintGCDateStamps 
-XX:+PrintGCDetails 
-XX:+HeapDumpOnOutOfMemoryError 
-XX:HeapDumpPath=/data/bpm.coffee.session/tmp/heapdump_20160704_110306.hprof

网上给的配置参数

-server
-Xms6000M
-Xmx6000M
-Xmn500M
-XX:PermSize=500M
-XX:MaxPermSize=500M
-XX:SurvivorRatio=65536
-XX:MaxTenuringThreshold=0
-Xnoclassgc
-XX:+DisableExplicitGC
-XX:+UseParNewGC
-XX:+UseConcMarkSweepGC
-XX:+UseCMSCompactAtFullCollection
-XX:CMSFullGCsBeforeCompaction=0
-XX:+CMSClassUnloadingEnabled
-XX:-CMSParallelRemarkEnabled
-XX:CMSInitiatingOccupancyFraction=90
-XX:SoftRefLRUPolicyMSPerMB=0
-XX:+PrintClassHistogram
-XX:+PrintGCDetails
-XX:+PrintGCTimeStamps
-XX:+PrintHeapAtGC
-Xloggc:log/gc.log

Intellij IDEA 2016 默认参数

-Xms158m
-Xmx750m
-XX:MaxPermSize=350m
-XX:ReservedCodeCacheSize=240m
-XX:+UseCompressedOops

Intellij IDEA 2016
情景分析:
1、-Xms2g -Xmx2g 这两个主要是设置初始堆大小和最大堆的大小,对比目前公司系统,网上参数和Intellij IDEA默认参数,总的来说在于场景问题,-Xms初始堆针对于服务器端的来说一般会设置跟最大堆一样大,为什么呢?因为对于服务器来说启动时间并不是最重要的,如果不够了再去申请,这个对于大并发的场景来说是不合适的。而针对于客户端Intellij IDEA来说,重点是启动时间,所以初始堆设置比较小,这样启动的时间比较快。

2、-server 这个参数主要是用来指定运行模式的,指定了-server那么启动的速度就会慢一些,所以针对于IDEA这种编辑器来说一般都使用默认的client模式

3、-XX:+UseCompressedOops 使用compressed pointers。这个参数默认在64bit的环境下默认启动,但是如果JVM的内存达到32G后,这个参数就会默认为不启动,因为32G内存后,压缩就没有多大必要了,要管理那么大的内存指针也需要很大的宽度了

4、-XX:ReservedCodeCacheSize=240m 这个主要是用来编译代码的,针对server来说用处不是很大。

5、针对于服务端来说-XX:+UseConcMarkSweepGC 老年代基本都是使用cms收集器,而年轻代都是使用-XX:+UseParNewGC进行收集的。

6、-XX:CMSInitiatingOccupancyFraction=60 这个参数主要是告诉cms收集器,内存到60%的时候进行回收,这个比例主要还是针对系统业务来决定的,太低容易内存溢出,太高容易内存浪费,而且老年代垃圾回收频率高。一般来说都是在70到90之间,60过低了。

7、-Xloggc 这个参数一般都会打开,能够很好的排查jvm的内存溢出.

8、-Xnoclassgc 这个针对一般业务来说都会打开,不让class进行gc.

9、-XX:+DisableExplicitGC web业务来说,由于大家开发水平不一致,难保有人会使用System.gc(),高并发的场景这个肯定会是一个很大的影响,所以一般都关掉。

10、-XX+UseCMSCompactAtFullCollection消除cms碎片

11、-XX:CMSFullGCsBeforeCompaction 这个上面填的0,如果没有特殊场景,最好设置成1或者2.

12、-XX:-CMSParallelRemarkEnabled 主要用来降低cms标记的停顿,但是这个参数会增加碎片化。

13、SoftRefLRUPolicyMSPerMB 这个参数指定了软饮用停留的时间,一般来说设置为0就好了,感觉生产中这个参数影响并不大,如果是针对单机内存使用缓存比较多的场景,这个值可以设置到1s左右,增加加吞吐量。

总结

jvm的调优需要根据自己的业务场景进行调整,没有万能的参数,但是总的来说,在生产环境中都会打开这几个必须的参数

verbose: gc
Xloggc:<pathtofile>
XX:+PrintGCDetails
XX:+PrintTenuringDistribution
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值