目录

一、运行时数据区

1、说一下JVM的主要组成部分及其作用?

2、说一下 JVM 运行时数据区 ?

3、说一下堆栈的区别

4、成员变量、局部变量、类变量分别存储在什么地方?

5、类常量池、运行时常量池、字符串常量池有什么区别?

6、JVM为什么使用元空间替换永久代

二、垃圾回收

1、GC是什么,为什么要GC?

2、Java 中都有哪些引用类型?

3、JVM如何判断一个对象是否可以被回收?

4、GC Root有哪些?

5、讲一下新生代、老年代、永久代的区别

6、JVM 分代年龄为什么是 15 ?可以是 25 吗?

​编辑

7、Minor GC、Major GC、Full GC是什么

8、Minor GC过程

9、JVM 垃圾回收算法有哪些?

10、说一下 JVM 有哪些垃圾回收器?

11、JDK各版本默认垃圾收集器

12、G1垃圾收集器的特点

(1)并行和并发

(2)分代收集

(3)空间整合

(4)可预测的停顿时间模型

(5)缺点:

13、G1垃圾回收过程

14、JVM 中的三色标记法是什么?

15、说一下 CMS 垃圾回收器的工作原理

16、CMS收集器和G1收集器的区别

(1)使用范围不一样

(2)STW的时间

(3)垃圾碎片

(4)垃圾回收的过程不一样

17、如何选择垃圾收集器?

三、类加载器

1、JVM对象创建的流程

2、一个空Object对象占多大空间?

3、类加载机制

4、Java虚拟机中有哪些类加载器?

5、双亲委派模型

6、什么情况下我们需要破坏双亲委派模型

7、如何破坏双亲委派模型

8、Tomcat是如何打破”双亲委派”机制的?

9、反射的几种实现方式

10、反射中,Class.forName和ClassLoader区别

四、JVM调优

1、有没有JVM调优经验?JVM调优方案有哪些?

2、你们项⽬如何排查JVM问题

3、有没有排查过线上OOM的问题,如何排查的?

4、JVM调优工具及命令

5、CPU飙高系统反应慢怎么排查?

6、Java的CPU 飙升700%优化的真实案例​​​​​​​

7、常见jvm 参数有哪些?

8、每天100w次登陆请求,8G 内存该如何设置JVM参数?

9、JVM参数配置模板

10、如果对象的引用被置为 null,垃圾收集器是否会立即释放对象占用的内存?

五、JIT 即时编译

1、Java中的对象一定是在堆上分配的吗?

2、逃逸分析是什么?


一、运行时数据区

1、说一下JVM的主要组成部分及其作用?

两个子系统:

  • 类加载器:根据给定的全限定名类名(如:java.lang.Object)来装载class文件到Runtime data area中的method area

  • 执行引擎:执行classes中的指令

两个组件:

  • 运行时数据区:这就是我们常说的JVM的内存

  • 本地接口:与native libraries交互,是其它编程语言交互的接口

2、说一下 JVM 运行时数据区 ?

  • 程序计数器(Program Counter Register):当前线程所执行的字节码的行号指示器,字节码解析器的工作是通过改变这个计数器的值,来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能,都需要依赖这个计数器来完成(为什么要线程计数器?因为线程是不具备记忆功能)。

  • Java 虚拟机栈(Java Virtual Machine Stacks):每个方法在执行的同时都会在Java 虚拟机栈中创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息(栈帧就是Java虚拟机栈中的一个单位)。

  • 本地方法栈(Native Method Stack):与虚拟机栈的作用是一样的,只不过虚拟机栈是服务 Java方法的,而本地方法栈是为虚拟机调用 Native 方法服务的(Native 关键字修饰的方法是看不到的,Native 方法的源码大部分都是 C和C++ 的代码)。

  • Java 堆(Java Heap):Java 虚拟机中内存最大的一块,是被所有线程共享的,几乎所有的对象实例都在这里分配内存。

  • 方法区(Methed Area):用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译后的代码等数据。

3、说一下堆栈的区别

  • 物理地址:堆的物理地址分配对对象是不连续的。因此性能慢些 ;栈使用的是数据结构中的栈,先进后出的原则,物理地址分配是连续的。所以性能快。

  • 内存分别:堆因为是不连续的,所以分配的内存是在运行期确认的,因此大小不固定。一般堆大小远远大于栈。栈是连续的,所以分配的内存大小要在编译期就确认,大小是固定的。

  • 程序的可见度:堆对于整个应用程序都是共享、可见的。栈只对于线程是可见的。所以也是线程私有。他的生命周期和线程相同。

4、成员变量、局部变量、类变量分别存储在什么地方?

  • 类变量

类变量是静态变量,java 8之前在方法区,java8放在堆中

  • 成员变量

成员变量是类实例的一部分,放在堆上

  • 局部变量

虚拟机栈中

5、类常量池、运行时常量池、字符串常量池有什么区别?

类常量池与运行时常量池在方法区,字符串常量池1.7就从方法区移到堆中了

  • 类常量池

在类编译过程中,会把类元信息放到方法区,类元信息的其中一部分便是类常量池,主要存 放字面量和符号引用

  • 运行时常量池

在类加载时将字面量和符号引用解析为直接引用存储在运行时常量池

  • 字符串常量池

对于文本字符来说,它们会在解析时查找字符串常量池,查出这个文本字符对应的字符串

对象的直接引用,将直接引用存储在运行时常量池;字符串常量池存储的是字符串对象的

引用,而不是字符串本身

6、JVM为什么使用元空间替换永久代

  1. 永久代内存是有上限的,虽然可以通过参数来设置,但是JVM加载的class总数、大小总是很难确定的。但是元空间是存储在本地内存里面,内存上限比较大,可以很好的避免这个问题

  2. 永久代的对象是通过full gc进行垃圾收集,也就是和老年代同时实现垃圾收集。替换成元空间以后简化了full gc。可以在不进行暂停的情况下并发的释放数据,同时提升GC性能

  3. Oracle 要合并Hotspot和JRocket的代码,而JRocket是没有永久代的。

二、垃圾回收

1、GC是什么,为什么要GC?

GC 是垃圾收集的意思(Gabage Collection),内存处理是编程人员容易出现问题的地方,忘记或者错误的内存 回收会导致程序或系统的不稳定甚至崩溃,Java 提供的 GC 功能可以自动监测对象是否超过作用域从而达到自动 回收内存的目的,Java 语言没有提供释放已分配内存的显式操作方法

2、Java 中都有哪些引用类型?

  • 强引用:发生 gc 的时候不会被回收。

  • 软引用:有用但不是必须的对象,在发生内存溢出之前会被回收。

  • 弱引用有用但不是必须的对象,在下一次GC时会被回收。

  • 虚引用(幽灵引用/幻影引用):无法通过虚引用获得对象,用PhantomReference 实现虚引用,虚引用的用途是在 gc 时返回一个通知。

3、JVM如何判断一个对象是否可以被回收?

垃圾收集器在做垃圾回收的时候,首先需要判定的就是哪些内存是需要被回收的,哪些对象是存活

的,是不可以被回收的;哪些对象已经死掉了,需要被回收。

一般有两种方法来判断:

  • 引用计数器法:为每个对象创建一个引用计数,有对象引用时计数器 +1,引用被释放时计数

-1,当计数器为 0 时就可以被回收。它有一个缺点不能解决循环引用的问题

  • 可达性分析算法:从 GC Roots 开始向下搜索,搜索所走过的路径称为引用链。当一个对象

到 GC Roots 没有任何引用链相连时,则证明此对象是可以被回收的。(市场上用的非常非

常广泛)

4、GC Root有哪些” />5、讲一下新生代、老年代、永久代的区别

在 Java 中,堆被划分成两个不同的区域:新生代 ( Young )、老年代 ( Old ),二者比例为1:2。而新生代 ( Young )又被划分为三个区域:Eden(8)、From Survivor(1)、To Survivor(1)。这样划分的目的是为了使 JVM 能够更好的管理堆内存中的对象,包括内存的分配以及回收。

  • 新生代中一般保存新出现的对象,所以每次垃圾收集时都发现大批对象死去,只有少量对象存活,便采用了 复制算法 ,只需要付出少量存活对象的复制成本就可以完成 。

  • 老年代中一般保存存活了很久的对象,他们存活率高、没有额外空间对它进行分配担保,就必须采用 “标记-清理”或者“标记-整理” 算法。

  • 永久代就是JVM的方法区。在这里都是放着一些被虚拟机加载的类信息,静态变量,常量等数据。这个区中的东西比老年代和新生代更不容易回收。

6、JVM 分代年龄为什么是 15 ?可以是 25 吗?

一个对象的 GC 年龄,是存储在对象头里面的(如图),一个 Java 对象在 JVM内存中的布局由三个部分组成,分别是对象头、实例数据、对齐填充。

而对象头里面有 4 个 bit 位来存储 GC 年龄。而 4 个 bit 位能够存储的最大数值是 15,所以从这个角度来说,JVM 分代年龄之所以设置成 15 次是因为它最大能够存储的数值就是 15。 虽然 JVM 提供了参数来设置分代年龄的大小,但是这个大小不能超过 15。而从设计角度来看,当一个对象触发了最大值 15 次 gc,还没有办法被回收,就只能移动到 old generation 了。另外,设计者还引入了动态对象年龄判断的方式来决定把对象转移到 old generation,也就是说不管这个对象的 gc 年龄是否达到了 15 次,只要满足动态年龄判断的依据,也同样会转移到 old generation。

对象进入老年代的动态年龄判断规则(动态晋升年龄计算阈值):Minor GC 时,Survivor 中年龄 1 到N 的对象大小超过 Survivor 的 50% 时,则将大于等于年龄 N 的对象放入老年代。

7、Minor GC、Major GC、Full GC是什么

  • Minor GC是新生代GC,指的是发生在新生代的垃圾收集动作。由于java对象大都是朝生夕死的,所以Minor GC非常频繁,一般回收速度也比较快。(一般采用复制算法回收垃圾)触发条件:eden区满

  • Major GC是老年代GC,指的是发生在老年代的GC,通常执行Major GC会连着Minor GC一起执行。Major GC的速度要比Minor GC慢的多。(可采用标记清楚法和标记整理法)

  • Full GC是清理整个堆空间,包括年轻代和老年代

Major GC/Full GC触发条件:老年代区或永久代区域不足

8、Minor GC过程

大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s1(“To”),并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

经过这次GC后,Eden区和”From”区已经被清空。这个时候,“From”和”To”会交换他们的角色,也就是新的”To”就是上次GC前的“From”,新的”From”就是上次GC前的”To”。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,”To”区被填满之后,会将所有对象移动到老年代中。

9、JVM 垃圾回收算法有哪些?

  • 复制算法:按照容量划分二个大小相等的内存区域,当一块用完的时候将活着的对象复制到另一块上,然后再把已使用的内存空间一次清理掉。缺点:内存使用率不高,只有原来的一半。

  • 标记-清除算法:标记无用对象,然后进行清除回收。缺点:效率不高,无法清除垃圾碎片。

  • 标记-整理算法:标记无用对象,让所有存活的对象都向一端移动,然后直接清除掉端边界以外的内存。

  • 分代算法:根据对象存活周期的不同将内存划分为几块,一般是新生代和老年代,新生代基本采用复制算法,老年代采用标记整理算法。

10、说一下 JVM 有哪些垃圾回收器?

如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。下图展示了7种作用于不同分代的收集器,其中用于回收新生代的收集器包括Serial、ParNew、Parallel Scavenge,回收老年代的收集器包括SerialOld、Parallel Old、CMS,还有用于回收整个Java堆的G1收集器。不同收集器之间的连线表示它们可以搭配使用。

  • Serial收集器(复制算法): 新生代单线程收集器,标记和清理都是单线程,优点是简单高效;

  • ParNew收集器 (复制算法): 新生代并行收集器,实际上是Serial收集器的多线程版本,在多核CPU环境下有着比Serial更好的表现;

  • Parallel Scavenge收集器 (复制算法): 新生代并行收集器,追求高吞吐量,高效利用 CPU。吞吐量 = 用户线程时间/(用户线程时间+GC线程时间),高吞吐量可以高效率的利用CPU时间,尽快完成程序的运算任务,适合后台应用等对交互相应要求不高的场景;

  • Serial Old收集器 (标记-整理算法): 老年代单线程收集器,Serial收集器的老年代版本;

  • Parallel Old收集器 (标记-整理算法): 老年代并行收集器,吞吐量优先,Parallel Scavenge收集器的老年代版本;

  • CMS(Concurrent Mark Sweep)收集器(标记-清除算法): 老年代并行收集器,以获取最短回收停顿时间为目标的收集器,具有高并发、低停顿的特点,追求最短GC回收停顿时间。

  • G1(Garbage First)收集器 (标记-整理算法): Java堆并行收集器,G1收集器是JDK1.7提供的一个新收集器,G1收集器基于“标记-整理”算法实现,也就是说不会产生内存碎片。此外,G1收集器不同于之前的收集器的一个重要特点是:G1回收的范围是整个Java堆(包括新生代,老年代),而前六种收集器回收的范围仅限于新生代或老年代。

收集器工作方式描述回收区域算法特点
Serial串行工作线程暂停,一个线程进行垃圾回收新生代标记-复制
Serial Old串行工作线程暂停,一个线程进行垃圾回收老生代标记-整理
ParNew并行工作线程暂停,多个线程进行垃圾回收新生代标记-复制Serial 的多线程版本
CMS并行用户和垃圾回收线程同时进行老生代标记-清除低暂停
Parallel Scavenge并行工作线程暂停,一个线程进行垃圾回收新生代标记-复制和 ParNew 相比能动态调整内存分配
jdk8默认
Parallel Old并行工作线程暂停,多个线程进行垃圾回收老生代标记-整理替代串行的 Serial Old
G1并行用户和垃圾回收线程同时进行整堆分区算法延迟可控的情况下尽量提高吞吐量
jdk9默认
ZGC并行用户和垃圾回收线程同时进行整堆分页算法暂停时间不超过1ms

11、JDK各版本默认垃圾收集器

  • JDK1.7: Parallel Scavenge + Parallel Old

  • JDK1.8: Parallel Scavenge + Parallel Old

  • JDK1.9: G1

12、G1垃圾收集器的特点

(1)并行和并发

  • 并行性:G1在回收期间,可以有多个GC线程同时工作,有效利用多核计算能力。此时用户线程STW

  • 并发性:G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况

(2)分代收集

同时兼顾老年代和年轻代

(3)空间整合

G1将内存划分为一个个的region,内存的回收是以region为基本单位的。region之间是复制算法,但整体实际看做是标记-压缩算法。

(4)可预测的停顿时间模型

这是 G1 相对于 CMS 的另一大优势,G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒、可以通过参数-XX:MaxGCPauseMillis进行设置)

  • 由于分区的原因,G1可以只选取部分区域进行内存回收,这样缩小了回收的范围,因此对于全局停顿情况的发生也能得到较好的控制

  • G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。保证了G1收集器在有限的时间内可以获取尽可能高的收集效率

  • 相比于CMS GC,G1未必能做到CMS在最好情况下的延时停顿,但是最差情况要好很多

(5)缺点:

  • 相较于CMS,G1还不具备全方位、压倒性优势。比如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比CMS要

  • 从经验上来说,在小内存应用上CMS的表现大概率会优于G1,而G1在大内存应用上则发挥其优势。平衡点在6-8GB之间

G1垃圾收集器详解_Free的午后的博客-CSDN博客

13、G1垃圾回收过程

G1 GC的垃圾回收过程主要包括如下三个环节:

  • 年轻代GC(Young GC)

  • 老年代并发标记过程(Concurrent Marking)

  • 混合回收(Mixed GC)

(如果需要,单线程、独占式、高强度的Full GC还是继续存在的。它针对GC的评估失败提供了一种失败保护机制,即强力回收。)

顺时针,Young gc -> Young gc + Concurrent mark->Mixed GC顺序,进行垃圾回收。

应用程序分配内存,当年轻代的Eden区用尽时开始年轻代回收过程;G1的年轻代收集阶段是一个并行的独占式收集器。在年轻代回收期,G1GC暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到Survivor区间或者老年区间,也有可能是两个区间都会涉及。

堆内存使用达到一定值(默认45%)时,开始老年代并发标记过程。

标记完成马上开始混合回收过程。对于一个混合回收期,G1 GC从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。和年轻代不同,老年代的G1回收器和其他GC不同,G1的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的Region就可以了。同时,这个老年代Region是和年轻代一起被回收的。

举个例子:一个Web服务器,Java进程最大堆内存为4G,每分钟响应1500个请求,每45秒钟会新分配大约2G的内存。G1会每45秒钟进行一次年轻代回收,每31个小时整个堆的使用率会达到45%,会开始老年代并发标记过程,标记完成后开始四到五次的混合回收。

(1)G1回收过程一:年轻代GC

VM启动时,G1先准备好Eden区,程序在运行过程中不断创建对象到Eden区,当Eden空间耗尽时,G1会启动一次年轻代垃圾回收过程。

年轻代垃圾回收只会回收Eden区和Survivor区。

首先G1停止应用程序的执行(Stop-The-World),G1创建回收集(Collection Set),回收集是指需要被回收的内存分段的集合,年轻代回收过程的回收集包含年轻代Eden区和Survivor区所有的内存分段。

然后开始如下回收过程:

①、第一阶段,扫描根。根是指static变量指向的对象,正在执行的方法调用链条上的局部变量等。根引用连同RSet记录的外部引用作为扫描存活对象的入口。

②、第二阶段,更新RSet。处理dirty card queue(见备注)中的card,更新RSet。此阶段完成后,RSet可以准确的反映老年代对所在的内存分段中对象的引用。

③、第三阶段,处理RSet。识别被老年代对象指向的Eden中的对象,这些被指向的Eden中的对象被认为是存活的对象。

④、第四阶段,复制对象。此阶段,对象树被遍历,Eden区内存段中存活的对象会被复制到Survivor区中空的内存分段,Survivor区内存段中存活的对象如果年龄未达阈值,年龄会加1,达到阀值会被会被复制到Old区中空的内存分段。如果Survivor空间不够,Eden空间的部分数据会直接晋升到老年代空间。

⑤、第五阶段,处理引用。处理Soft,Weak,Phantom,Final,JNI Weak 等引用。最终Eden空间的数据为空,GC停止工作,而目标内存中的对象都是连续存储的,没有碎片,所以复制过程可以达到内存整理的效果,减少碎片。

(2)G1回收过程二:并发标记过程

①、初始标记阶段:标记从根节点直接可达的对象。这个阶段是STW的,并且会触发一次年轻代GC。

②、根区域扫描(Root Region Scanning):G1 GC扫描Survivor区直接可达的老年代区域对象,并标记被引用的对象。这一过程必须在YoungGC之前完成。

③、并发标记(Concurrent Marking):在整个堆中进行并发标记(和应用程序并发执行),此过程可能被YoungGC中断。在并发标记阶段,若发现区域对象中的所有对象都是垃圾,那这个区域会被立即回收。同时,并发标记过程中,会计算每个区域的对象活性(区域中存活对象的比例)。

④、再次标记(Remark):由于应用程序持续进行,需要修正上一次的标记结果。是STW的。G1中采用了比CMS更快的初始快照算法:snapshot-at-the-beginning(SATB)。

⑤、独占清理(cleanup,STW):计算各个区域的存活对象和GC回收比例,并进行排序,识别可以混合回收的区域。为下阶段做铺垫。是STW的。这个阶段并不会实际上去做垃圾的收集

⑥、并发清理阶段:识别并清理完全空闲的区域。

(3)G1回收过程三:混合回收

当越来越多的对象晋升到老年代o1d region时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即Mixed GC,该算法并不是一个Old GC,除了回收整个Young Region,还会回收一部分的Old Region。这里需要注意:是一部分老年代,而不是全部老年代。可以选择哪些Old Region进行收集,从而可以对垃圾回收的耗时时间进行控制。也要注意的是Mixed GC并不是Full GC。

并发标记结束以后,老年代中百分百为垃圾的内存分段被回收了,部分为垃圾的内存分段被计算了出来。默认情况下,这些老年代的内存分段会分8次(可以通过-XX:G1MixedGCCountTarget设置)被回收

混合回收的回收集(Collection Set)包括八分之一的老年代内存分段,Eden区内存分段,Survivor区内存分段。混合回收的算法和年轻代回收的算法完全一样,只是回收集多了老年代的内存分段。具体过程请参考上面的年轻代回收过程。

由于老年代中的内存分段默认分8次回收,G1会优先回收垃圾多的内存分段。垃圾占内存分段比例越高的,越会被先回收。并且有一个阈值会决定内存分段是否被回收,-XX:G1MixedGCLiveThresholdPercent,默认为65%,意思是垃圾占内存分段比例要达到65%才会被回收。如果垃圾占比太低,意味着存活的对象占比高,在复制的时候会花费更多的时间。

混合回收并不一定要进行8次。有一个阈值-XX:G1HeapWastePercent,默认值为10%,意思是允许整个堆内存中有10%的空间被浪费,意味着如果发现可以回收的垃圾占堆内存的比例低于10%,则不再进行混合回收。因为GC会花费很多的时间但是回收到的内存却很少。

(4)G1回收可选的过程四:Full GC

G1的初衷就是要避免Full GC的出现。但是如果上述方式不能正常工作,G1会停止应用程序的执行(Stop-The-World),使用单线程的内存回收算法进行垃圾回收,性能会非常差,应用程序停顿时间会很长。

要避免Full GC的发生,一旦发生需要进行调整。什么时候会发生Full GC呢?比如堆内存太小,当G1在复制存活对象的时候没有空的内存分段可用,则会回退到Full GC,这种情况可以通过增大内存解决。

导致G1 Full GC的原因可能有两个:

  • Evacuation的时候没有足够的to-space来存放晋升的对象;

  • 并发处理过程完成之前空间耗尽。

14、JVM 中的三色标记法是什么?

在三色标记法中,Java 虚拟机将内存中的对象分为三个颜色:

  1. 白色:表示还没有被垃圾回收器扫描的对象;

  2. 黑色:表示已经被垃圾回收器扫描过,且对象及其引用的其他对象都是存活的;

  3. 灰色:表示已经被垃圾回收器扫描过,但对象引用的其他对象尚未被扫描

在 GC 开始时先将所有对象都标记为白色,然后从根对象开始遍历内存中的对象,接着把直接引用的对象标记为灰色。再判断灰色集合中的对象是否存在子引用,不存在则放入黑色集合,如果存在,就把子引用对象放入到灰色集合。

按照这样一个步骤不断推导,直到灰色集合中所有的对象变黑后,本轮标记完成。最后,还处于白色标记的对象就是不可达对象,可以直接被回收。

15、说一下 CMS 垃圾回收器的工作原理

CMS (Concurrent Mark and Sweep) 是一种低停顿垃圾回收器,它主要通过初始标记阶段和并发标记阶段两个并发的阶段来实现垃圾回收 :

它的整体流程可以分成四个步骤(如图):

  • 初始标记(CMS initial mark):这个阶段需要 Stop The Word,来标记哪些对象是需要回收的,这个过程只需要标记 GC Roots 能够直接关联的对象,所以速度很快,对性能影响比较小。

  • 并发标记(CMS concurrent mark):扫描整个堆中的对象,标记所有不需要回收的对象。这个阶段不需要 Stop The Word,在应用程序运行过程中进行标记。

  • 重新标记(CMS remark):为了修正并发标记期间,应用程序同步运行导致标记产生变动的那一部分对象。这个阶段需要 Stop The Word。

  • 并发清除(CMS concurrent sweep),CMS 会并发执行清除操作,同时应用程序继续运行,最大力度减少对性能的影响。

16、CMS收集器和G1收集器的区别

(1)使用范围不一样

CMS收集器是老年代的收集器,可以配合新生代的Serial和ParNew收集器一起使用

G1收集器收集范围是老年代和新生代。不需要结合其他收集器使用

(2)STW的时间

CMS收集器以最小的停顿时间为目标的收集器。

G1收集器可预测垃圾回收的停顿时间(建立可预测的停顿时间模型)

(3)垃圾碎片

CMS收集器是使用“标记-清除”算法进行的垃圾回收,容易产生内存碎片

G1收集器使用的是“标记-整理”算法,进行了空间整合,降低了内存空间碎片。

(4)垃圾回收的过程不一样

CMS收集器 G1收集器

1. 初始标记 1. 初始标记

2. 并发标记 2. 并发标记

3. 重新标记 3. 最终标记

4. 并发清除 4. 筛选回收

17、如何选择垃圾收集器?

  1. 如果你的堆大小不是很大(比如 100MB ),选择串行收集器一般是效率最高的。参数: -XX:+UseSerialGC 。

  2. 如果你的应用运行在单核的机器上,或者你的虚拟机核数只有 单核,选择串行收集器依然是合适的,这时候启用一些并行收集器没有任何收益。参数: -XX:+UseSerialGC 。

  3. 如果你的应用是“吞吐量”优先的,并且对较长时间的停顿没有什么特别的要求。选择并行收集器是比较好的。参数: -XX:+UseParallelGC 。

  4. 如果你的应用对响应时间要求较高,想要较少的停顿。甚至 1 秒的停顿都会引起大量的请求失败,那么选择 G1 、 ZGC 、 CMS 都是合理的。虽然这些收集器的 GC 停顿通常都比较短,但它需要一些额外的资源去处理这些工作,通常吞吐量会低一些。参数: -XX:+UseConcMarkSweepGC 、 -XX:+UseG1GC 、 -XX:+UseZGC 等。

  5. 从上面这些出发点来看,我们平常的 Web 服务器,都是对响应性要求非常高的。选择性其实就集中在 CMS、G1、ZGC 上。而对于某些定时任务,使用并行收集器,是一个比较好的选择。

三、类加载器

1、JVM对象创建的流程

(1)类加载检查。在实例化一个对象的时候,JVM 首先会去检查目标对象是否已经被加载并初始化了。如果没有,JVM 需要立刻去加载目标类,然后调用目标类的构造器完成初始化。然后初始化的过程,主要是对目标类里面的静态变量、成员变量、静态代码块进行初始化

(2)分配内存。当目标类被初始化以后,就可以从常量池里面找到对应的类元信息,并且目标对象的大小在类加载之后就已经确定了,所以这个时候就需要为新创建的对象,根据目标对象的大小在堆内存里面分配内存空间。内存分配的方式一般有两种,一种指针碰撞,另一种是空闲列表,JVM 会根据 Java 堆内存是否规整来决定内存分配方式。

  • 指针碰撞:如果Java堆的内存是规整的,即所有用过的内存放在一边,而空闲的的放在另一边。分配内存时将位于中间的指针指示器向空闲的内存移动一段与对象大小相等的距离,这样便完成分配内存工作
  • 空闲列表:如果Java堆的内存不是规整的,则需要由虚拟机维护一个列表来记录那些内存是可用的,这样在分配的时候可以从列表中查询到足够大的内存分配给对象,并在分配后更新列表记录

划分内存时还需要考虑一个问题-并发,也有两种方式: CAS同步处理,或者本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)。

  • CAS同步处理: 对分配内存空间的动作进行同步处理(采用 CAS + 失败重试来保障更新操作的原子性)
  • 本地线程分配缓冲 TLAB:把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)。哪个线程要分配内存,就在哪个线程的 TLAB 上分配。只有 TLAB 用完并分配新的 TLAB 时,才需要同步锁。通过-XX:+/-UserTLAB参数来设定虚拟机是否使用TLAB

(3)初始化零值。接下来,JVM 会把目标对象里面的普通成员变量初始化为零值,比如 int 类型初始化为0,对象类型初始化为 null,(类变量在类加载的准备阶段就已经初始化过了)。这一步操作主要是保证对象里面的实例字段,不用初始化就可以直接使用,也就是程序能够获得这些字段对应数据类型的零值 。

(4)设置对象头。JVM 还需要对目标对象的对象头做一些设置,比如对象所属的类元信息、对象的 GC 分代年龄、hashcode、锁标记等等

(5)执行init方法。初始化成员变量的值、执行构造块、最后执行目标对象的构造方法,完成对象的创建。

2、一个空Object对象占多大空间?

在开启压缩指针的情况下,Object默认会占用12个字节,但是为了避免伪共享问题,JVM会按照8个字节的倍数进行填充,所以会填充4个字节变成16个字节长度。

在关闭压缩指针的情况下,Object默认会占用16个字节,16个字节刚好是8的整数倍,因此不需要填充。

在 HotSpot 虚拟机里面,(如图)一个对象在堆内存里面的内存布局是使用 OOP 结构来表示的,它主要分为三个部分。

  • 对象头,包括 Markword、类元指针、数组长度。其中 Markword 用来存储对象运行时的相关数据,比如 hashCode、gc 分代年龄等。在 64 位操作系统中占 8 个字节,32 位操作系统中占 4 个字节。类元指针指向当前实例对象所属哪个类,开启指针压缩的情况下占 4 个字节,未开启则占 8 个字节。数组长度只有对象数组才会存在,占 4 个字节

  • 实例数据,存储对象中的字段信息

  • 对齐填充,Java 对象的大小需要按照 8 个字节或者 8 个字节的倍数对齐,避免伪共享问题

3、类加载机制

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验,解析和初始化,最终形成可

以被虚拟机直接使用的java类型。类加载的过程包括:加载、验证、准备、解析、初始化,其中验证、准备、解析统称为连接。

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

  2. 验证:确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

  3. 准备:为静态变量分配内存并设置静态变量初始值,这里所说的初始值“通常情况”下是数据类型的零值。

  4. 解析:将常量池内的符号引用替换为直接引用。

  5. 初始化:到了初始化阶段,才真正开始执行类中定义的 Java 初始化程序代码。主要是静态变量赋值动作和静态语句块(static{})中的语句。

4、Java虚拟机中有哪些类加载器?

通过类的全限定名获取该类的二进制字节流的代码块叫做类加载器。主要有以下四种类加载器:

  • 启动类加载器(Bootstrap ClassLoader)是虚拟机自身的一部分,用来加载java核心类库Java_HOME/lib/目录中的,或者被 -Xbootclasspath 参数所指定的路径中并且被虚拟机识别的类库;

  • 扩展类加载器(extensions class loader): 它用来加载 Java 的扩展库\lib\ext目录或Java. ext.dirs系统变量指定的路径中的所有类库

  • 系统类加载器(system class loader):它根据 Java 应用的类路径(CLASSPATH)来加载Java 类。一般来说,Java 应用的类都是由它来完成加载的。可以通过ClassLoader.getSystemClassLoader()来获取它。

  • 用户自定义类加载器通过继承 java.lang.ClassLoader类的方式实现。

5、双亲委派模型

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

优势:

系统类防止内存中出现多份同样的字节码

保证Java程序安全稳定运行

6、什么情况下我们需要破坏双亲委派模型

如果我们有一个类想要通过自定义的类加载器来加载这个类,而不是通过系统默认的类加载器,说白了就是不走双亲委派那一套,而是走自定义的类加载器,这个时候就需要打破双亲委派模型

7、如何破坏双亲委派模型

两种方式来破坏双亲委派模型

  1. 第一种,继承 ClassLoader 抽象类,重写 loadClass 方法,在这个方法可以自定义要加载的类使用的类加载器。

  2. 第二种,使用线程上下文加载器,可以通过 java.lang.Thread 类的setContextClassLoader()方法来设置当前类使用的类加载器类型。

8、Tomcat是如何打破”双亲委派”机制的” />

真正实现web应用程序之间的类加载器相互隔离独立的是WebAppClassLoader类加载器。它为什么可以隔离每个web应用程序呢?原因就是它打破了”双亲委派”的机制,如果收到类加载的请求,它会先尝试自己去加载,如果找不到在交给父加载器去加载,这么做的目的就是为了优先加载Web应用程序自己定义的类来实现web应用程序相互隔离独立的。

9、反射的几种实现方式

第一种:全类名加载

第二种:类名.class

第三种:对象.getClass

注意:这三种方法所创建的对象是同一个

10、反射中,Class.forName和ClassLoader区别

主要是加载类方式的区别:

Class.forName除了将类的.class文件加载到jvm中之外,还会对类进行解释,执行类中的static块。

而classloader只干一件事情,就是将.class文件加载到jvm中,不会执行static中的内容,只有在newInstance才会去执行static块。

四、JVM调优

1、有没有JVM调优经验?JVM调优方案有哪些?

调优时机

  • heap 内存(老年代)持续上涨,达到设置的最大内存值

  • Full GC 次数频繁;

  • GC 停顿时间过长(超过1秒);

  • 应用出现OutOfMemory 等内存异常;

  • 应用中有使用本地缓存,且占用大量内存空间;

  • 系统吞吐量与响应性能不高或下降。

调优原则

  • 多数的Java应用不需要在服务器上进行JVM优化;

  • 多数导致GC问题的Java应用,都不是因为我们参数设置错误,而是代码问题;

  • 在应用上线之前,先考虑将机器的JVM参数设置到最优(最适合);

  • 减少创建对象的数量;

  • 减少使用全局变量和大对象;

  • JVM优化,是到最后不得已才采用的⼿段;

  • 在实际使用中,分析GC情况优化代码比优化JVM参数更好;

调优目标

  • GC低停顿;

  • GC低频率;

  • 低内存占用;

  • 高吞吐量;

调优步骤

  • 分析GC⽇志及dump⽂件,判断是否需要优化,确定瓶颈问题点;

  • 确定jvm调优量化目标;

  • 确定jvm调优参数(根据历史jvm参数来调整);

  • 调优⼀台服务器,对比观察调优前后的差异;

  • 不断的分析和调整,直到找到合适的jvm参数配置;

  • 找到最合适的参数,将这些参数应用到所有服务器,并进行后续跟踪;

2、你们项⽬如何排查JVM问题

对于还在正常运⾏的系统:

  1. 可以使⽤jmap来查看JVM中各个区域的使⽤情况

  2. 可以通过jstack来查看线程的运⾏情况,⽐如哪些线程阻塞、是否出现了死锁

  3. 可以通过jstat命令来查看垃圾回收的情况,特别是fullgc,如果发现fullgc⽐较频繁,那么就得进⾏调优了

  4. 通过各个命令的结果,或者jvisualvm等⼯具来进⾏分析

  5. ⾸先,初步猜测频繁发送fullgc的原因,如果频繁发⽣fullgc但是⼜⼀直没有出现内存溢出,那么表示fullgc实际上是回收了很多对象了,所以这些对象最好能在younggc过程中就直接回收掉,避免这些对象进⼊到⽼年代,对于这种情况,就要考虑这些存活时间不⻓的对象是不是⽐较⼤,导致年轻代放不下,直接进⼊到了⽼年代,尝试加⼤年轻代的⼤⼩,如果改完之后,fullgc减少,则证明修改有效

  6. 同时,还可以找到占⽤CPU最多的线程,定位到具体的⽅法,优化这个⽅法的执⾏,看是否能避免某些对象的创建,从⽽节省内存

对于已经发⽣了OOM的系统:

  1. ⼀般⽣产系统中都会设置当系统发⽣了OOM时,⽣成当时的dump⽂件(-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/usr/local/base)

  2. 我们可以利⽤jvisualvm等⼯具来分析dump⽂件

  3. 根据dump⽂件找到异常的实例对象,和异常的线程(占⽤CPU⾼),定位到具体的代码

  4. 然后再进⾏详细的分析和调试

3、有没有排查过线上OOM的问题,如何排查的?

导致OOM错误的情况一般是:

  • 给JVM分配的内存太小,实际业务需求对内存的消耗比较多

  • java应用里面存在内存泄漏的问题,或者应用中有大量占用内存的对象,并且没办法及时释放。

常见的OOM异常情况一般是:

  • java.lang.OutOfMemoryError: Java heap space ——> java堆内存溢出,此种情况最常见,一般由于内存泄漏或者堆内存大小设置不当引起。堆内存泄漏通过内存监控软件查找程序中的泄漏代码;堆内存大小通过-Xms、-Xmx来修改

  • java.lang.OutOfMemoryError: PermGen space 或 java.lang.OutOfMemoryError:MetaSpace ——>java 方法区溢出,一般出现在大量Class、或者采用cglib等反射机制的情况,因为这些情况会产生大量的class对象存储在方法区。这种情况可以通过修改方法区的大小来解决,使用类似-XX:PermSize,-XX:MaxPermSize 来修改。另外过多的常量尤其是字符串也会导致方法区溢出

遇到这类问题,通常的排查方式是先获取内存dump文件。

dump文件通常有两种方式来生成:

  • 第一种是配置JVM启动参数,当触发了OOM异常的时候自动生成

  • 第二种是使用jmap工具来生成

然后通过MAT工具来分析dump文件,

  • 如果是内存泄漏,可进一步通过工具查看泄漏对象到GC Roots的引用链,进而分析定位泄漏代码的位置。

  • 如果是普通内存溢出,确实有很多占用内存的对象,那就只需要提升堆内存空间即可

4、JVM调优工具及命令

JVM调优工具

  1. JDK自带

    1. jconsole,Java Monitoring and Management Console是从java5开始,在JDK中自带的java监控和管理控制台,用于对JVM中内存, 线程和类等的监控

    2. jvisualvm,jdk自带全能工具,可以分析内存快照、线程快照;监控内存变化、GC变化等

  2. 第三方

    1. MAT,Memory Analyzer Tool,一个基于Eclipse的内存分析工具,是一个快速、功能丰富的Javaheap分析工具,它可以帮助我们查找内存泄漏和减少内存消耗

    2. GChisto,一款专业分析gc日志的工具

JVM调优命令

  1. jps,JVM Process Status Tool,显示指定系统内所有的HotSpot虚拟机进程。

  2. jstat,JVM statistics Monitoring是用于监视虚拟机运行时状态信息的命令,它可以显示出虚拟机进程中的类装载、内存、垃圾收集、JIT编译等运行数据。

  3. jmap,JVM Memory Map命令用于生成heap dump文件

  4. jhat,JVM Heap Analysis Tool命令是与jmap搭配使用,用来分析jmap生成的dump,jhat内置了一个微型的HTTP/HTML服务器,生成dump的分析结果后,可以在浏览器中查看

  5. jstack,用于生成java虚拟机当前时刻的线程快照

  6. jinfo,JVM Configuration info 这个命令作用是实时查看和调整虚拟机运行参数

5、CPU飙高系统反应慢怎么排查?

导致 CPU 飙高的原因有几个方面

  1. CPU 上下文切换频繁

  2. CPU 资源过度消耗,也就是在程序中创建了大量的线程,或者有线程一直占用 CPU 资源无法被释放,比如死循环!

既然是这两个问题导致的 CPU 利用率较高,于是我们可以通过 top 命令,找到 CPU 利用率较高的进程,在通过 Shift+H 找到进程中 CPU 消耗过高的线程,这里有两 种情况。

  1. CPU 利用率过高的线程一直是同一个,说明程序中存在线程长期占用 CPU 没 有释放的情况,这种情况直接通过 jstack 获得线程的 Dump 日志,定位到线程日志后就可以找到问题的代码。

  2. CPU 利用率过高的线程 id 不断变化,说明线程创建过多,需要挑选几个线程 id,通过 jstack 去线程 dump 日志中排查。最有可能是系统一切正常,瞬时流量导致系统资源不足。

6、Java的CPU 飙升700%优化的真实案例

最近负责的一个项目上线,运行一段时间后发现对应的进程竟然占用了700%的CPU,导致公司的物理服务器都不堪重负,频繁宕机。那么,针对这类java进程CPU飙升的问题,我们一般要怎么去定位解决呢?

(1)采用top命令定位进程

登录服务器,执行top命令,查看CPU占用情况,找到进程的pid

top

(2)使用top -Hp命令定位线程

使用 top -Hp 命令(为Java进程的id号)查看该Java进程内所有线程的资源占用情况(按shft+p按照cpu占用进行排序,按shift+m按照内存占用进行排序)

此处按照cpu排序

top -Hp 23602

很容易发现,多个线程的CPU占用达到了90%多。我们挑选线程号为30309的线程继续分析。

(3)使用jstack命令定位代码

1. 线程号转换为16进制

printf “%x\n” 命令(tid指线程的id号)将以上10进制的线程号转换为16进制:

转换后的结果分别为7665,由于导出的线程快照中线程的nid是16进制的,而16进制以0x开头,所以对应的16进制的线程号nid为0x7665

2. 采用jstack命令导出线程快照

通过使用dk自带命令jstack获取该java进程的线程快照并输入到文件中:

jstack -l 29706 > ./jstack_result.txt

3. 根据线程号定位具体代码

在jstack_result.txt 文件中根据线程好nid搜索对应的线程描述

cat jstack_result.txt |grep -A 100 7665

根据搜索结果,判断应该是ImageConverter.run()方法中的代码出现问题

4. 分析代码解决问题

下面是ImageConverter.run()方法中的部分核心代码。

逻辑说明:

/*存储minicap的socket连接返回的数据 出现内存溢出(改用消息队列存储读到的流数据) ,设置阻塞队列长度,防止出现内存溢出 *///全局变量private BlockingQueue dataQueue = new LinkedBlockingQueue(100000);//消费线程@Overridepublic void run() {//long start = System.currentTimeMillis();while (isRunning) {//分析这里从LinkedBlockingQueueif (dataQueue.isEmpty()) {continue;} byte[] buffer = device.getMinicap().dataQueue.poll();int len = buffer.length;}

在while循环中,不断读取堵塞队列dataQueue中的数据,如果数据为空,则执行continue进行下一次循环。

如果不为空,则通过poll()方法读取数据,做相关逻辑处理。

初看这段代码好像每什么问题,但是如果dataQueue对象长期为空的话,这里就会一直空循环,导致CPU飙升。

那么如何解决呢?

分析LinkedBlockingQueue阻塞队列的API发现:

//取出队列中的头部元素,如果队列为空则调用此方法的线程被阻塞等待,直到有元素能被取出,如果等待过程被中断则抛出InterruptedExceptionE take() throws InterruptedException;//取出队列中的头部元素,如果队列为空返回nullE poll(); 

这两种取值的API,显然take方法更时候这里的场景。

代码修改为:

while (isRunning) {/* if (device.getMinicap().dataQueue.isEmpty()) {continue;}*/byte[] buffer = new byte[0];try {buffer = device.getMinicap().dataQueue.take();} catch (InterruptedException e) {e.printStackTrace();}……}

重启项目后,测试发现项目运行稳定,对应项目进程的CPU消耗占比不到10%。

7、常见jvm 参数有哪些?

#常用的设置

-Xms:初始堆大小,JVM 启动的时候,给定堆空间大小。

-Xmx:最大堆大小,JVM 运行过程中,如果初始堆空间不足的时候,最大可以扩展到多少。

-Xmn:设置堆中年轻代大小。整个堆大小=年轻代大小+年老代大小+持久代大小。

-XX:NewSize=n 设置年轻代初始化大小大小

-XX:MaxNewSize=n 设置年轻代最大值

-XX:NewRatio=n 设置年轻代和年老代的比值。如: -XX:NewRatio=3,表示年轻代与年老代比值为 1: 3,年轻代占整个年轻代+年老代和的 1/4

-XX:SurvivorRatio=n 年轻代中 Eden 区与两个 Survivor 区的比值。注意 Survivor 区有两个。8 表示两个Survivor :eden=2:8 ,即一个Survivor占年轻代的1/10,默认就为8

-Xss:设置每个线程的堆栈大小。JDK5后每个线程 Java 栈大小为 1M,以前每个线程堆栈大小为 256K。

-XX:ThreadStackSize=n 线程堆栈大小

-XX:PermSize=n 设置持久代初始值

-XX:MaxPermSize=n 设置持久代大小

-XX:MaxTenuringThreshold=n 设置年轻带垃圾对象最大年龄。如果设置为 0 的话,则年轻代对象不经 过 Survivor 区,直接进入年老代。

#下面是一些不常用的

-XX:LargePageSizeInBytes=n 设置堆内存的内存页大小

-XX:+UseFastAccessorMethods 优化原始类型的getter方法性能

-XX:+DisableExplicitGC 禁止在运行期显式地调用System.gc(),默认启用

-XX:+AggressiveOpts 是否启用JVM开发团队最新的调优成果。例如编译优化,偏向锁,并行年老代收集 等,jdk6纸之后默认启动

-XX:+UseBiasedLocking 是否启用偏向锁,JDK6默认启用

-Xnoclassgc 是否禁用垃圾回收

-XX:+UseThreadPriorities 使用本地线程的优先级,默认启用 等等等…

8、每天100w次登陆请求,8G 内存该如何设置JVM参数?

Step1:新系统上线如何规划容量?

(1)套路总结

任何新的业务系统在上线以前都需要去估算服务器配置和JVM的内存参数,这个容量与资源规划并不仅仅是系统架构师的随意估算的,需要根据系统所在业务场景去估算,推断出来一个系统运行模型,评估JVM性能和GC频率等等指标。以下是我结合大牛经验以及自身实践来总结出来的一个建模步骤:

  • 计算业务系统每秒钟创建的对象会占用多大的内存空间,然后计算集群下的每个系统每秒的内存占用空间(对象创建速度)

  • 设置一个机器配置,估算新生代的空间,比较不同新生代大小之下,多久触发一次MinorGC。

  • 为了避免频繁GC,就可以重新估算需要多少机器配置,部署多少台机器,给JVM多大内存空间,新生代多大空间。

  • 根据这套配置,基本可以推算出整个系统的运行模型,每秒创建多少对象,1s以后成为垃圾,系统运行多久新生代会触发一次GC,频率多高。

(2)套路实战——以登录系统为例

有些同学看到这些步骤还是发憷,说的好像是那么回事,一到实际项目中到底怎麽做我还是不知道!光说不练假把式,以登录系统为例模拟一下推演过程:

  • 假设每天100w次登陆请求,登陆峰值在早上,预估峰值时期每秒100次登陆请求。

  • 假设部署3台服务器,每台机器每秒处理30次登陆请求,假设一个登陆请求需要处理1秒钟,JVM新生代里每秒就要生成30个登陆对象,1s之后请求完毕这些对象成为了垃圾。

  • 一个登陆请求对象假设20个字段,一个对象估算500字节,30个登陆佔用大约15kb,考虑到RPC和DB操作,网络通信、写库、写缓存一顿操作下来,可以扩大到20-50倍,大约1s产生几百k-1M数据。

  • 假设2C4G机器部署,分配2G堆内存,新生代则只有几百M,按照1s1M的垃圾产生速度,几百秒就会触发一次MinorGC了。

  • 假设4C8G机器部署,分配4G堆内存,新生代分配2G,如此需要几个小时才会触发一次MinorGC。

  • 所以,可以粗略的推断出来一个每天100w次请求的登录系统,按照4C8G的3实例集群配置,分配4G堆内存、2G新生代的JVM,可以保障系统的一个正常负载。基本上把一个新系统的资源评估了出来,所以搭建新系统要每个实例需要多少容量多少配置,集群配置多少个实例等等这些,并不是拍拍脑袋和胸脯就可以决定的下来的。

Step2:该如何进行垃圾回收器的选择?

(1)吞吐量还是响应时间

首先引入两个概念:吞吐量和低延迟

吞吐量 = CPU在用户应用程序运行的时间 / (CPU在用户应用程序运行的时间 + CPU垃圾回收的时间)

响应时间 = 平均每次的GC的耗时

通常,吞吐优先还是响应优先这个在JVM中是一个两难之选。

堆内存增大,gc一次能处理的数量变大,吞吐量大;但是gc一次的时间会变长,导致后面排队的线程等待时间变长;相反,如果堆内存小,gc一次时间短,排队等待的线程等待时间变短,延迟减少,但一次请求的数量变小(并不绝对符合)。无法同时兼顾,是吞吐优先还是响应优先,这是一个需要权衡的问题。

(2)垃圾回收器设计上的考量

JVM在GC时不允许一边垃圾回收,一边还创建新对象(就像不能一边打扫卫生,还在一边扔垃圾)。

JVM需要一段Stop the world的暂停时间,而STW会造成系统短暂停顿不能处理任何请求;新生代收集频率高,性能优先,常用复制算法;老年代频次低,空间敏感,避免复制方式。所有垃圾回收器的涉及目标都是要让GC频率更少,时间更短,减少GC对系统影响!

CMS和G1 目前主流的垃圾回收器配置是新生代采用ParNew,老年代采用CMS组合的方式,或者是完全采用G1回收器,从未来的趋势来看,G1是官方维护和更为推崇的垃圾回收器。

业务系统:

  • 延迟敏感的推荐CMS;

  • 大内存服务,要求高吞吐的,采用G1回收器!

Step3:如何对各个分区的比例、大小进行规划

一般的思路为:

首先,JVM最重要最核心的参数是去评估内存和分配,第一步需要指定堆内存的大小,这个是系统上线必须要做的,-Xms 初始堆大小,-Xmx 最大堆大小,后台Java服务中一般都指定为系统内存的一半,过大会占用服务器的系统资源,过小则无法发挥JVM的最佳性能。

其次,需要指定-Xmn新生代的大小,这个参数非常关键,灵活度很大,虽然sun官方推荐为3/8大小,但是要根业务场景来定,针对于无状态或者轻状态服务(现在最常见的业务系统如Web应用)来说, 一般新生代甚至可以给到堆内存的3/4大小;而对于有状态服务(常见如IM服务、网关接入层等系统)新生代可以按照默认比例1/3来设置。服务有状态,则意味著会有更多的本地缓存和会话状态信息常驻内存,因为要给老年代设置更大的空间来存放这些对象。

JVM参
描述默认推荐
-XmsJava堆内存的大小OS内存
64/1
OS内存
一半
-XmxJava堆内存的最大大小OS内存
4/1
OS内存
一半
-XmnJava堆内存中的新生代大小,扣除新生代剩下的就是老年代
的内存大小了
默认认堆的
1/3
sun推荐
3/8
-Xss每个线程的栈内存大小和idk有
sun

Step4:栈内存大小多少比较合适?

-Xss栈内存大小,设置单个线程栈大小,默认值和JDK版本、系统有关,一般默认512~1024kb。一个后台服务如果常驻线程有几百个,那麽栈内存这边也会佔用了几百M的大小。

Step5:对象年龄应该为多少才移动到老年代比较合适?

假设一次minor gc要间隔二三十秒,并且,大多数对象一般在几秒内就会变为垃圾,如果对象这么长时间都没被回收,比如2分钟没有回收,可以认为这些对象是会存活的比较长的对象,从而移动到老年代,而不是继续一直占用survivor区空间。

所以,可以将默认的15岁改小一点,比如改为5,那么意味着对象要经过5次minor gc才会进入老年代,整个时间也有一两分钟了(5*30s= 150s),和几秒的时间相比,对象已经存活了足够长时间了。

所以:可以适当调整JVM参数如下:

-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5

Step6:多大的对象,可以直接到老年代比较合适?

对于多大的对象直接进入老年代(参数-XX:PretenureSizeThreshold),一般可以结合自己系统看下有没有什么大对象 生成,预估下大对象的大小,一般来说设置为1M就差不多了,很少有超过1M的大对象,

所以:可以适当调整JVM参数如下:

-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5 -XX:PretenureSizeThreshold=1M

Step7:垃圾回收器CMS老年代的参数优化

JDK8默认的垃圾回收器是-XX:+UseParallelGC(年轻代)和-XX:+UseParallelOldGC(老年代),如果内存较大(超过4个G,只是经验 值),还是建议使用G1.

这里是4G以内,又是主打“低延时” 的业务系统,可以使用下面的组合:

ParNew+CMS(-XX:+UseParNewGC -XX:+UseConcMarkSweepGC)

新生代的采用ParNew回收器,工作流程就是经典复制算法,在三块区中进行流转回收,只不过采用多线程并行的方式加快了MinorGC速度。

老生代的采用CMS。再去优化老年代参数:比如老年代默认在标记清除以后会做整理,还可以在CMS的增加GC频次还是增加GC时长上做些取舍,

如下是响应优先的参数调优:

XX:CMSInitiatingOccupancyFraction=70

设定CMS在对内存占用率达到70%的时候开始GC(因为CMS会有浮动垃圾,所以一般都较早启动GC)

XX:+UseCMSInitiatinpOccupancyOnly

和上面搭配使用,否则只生效一次

-XX:+AlwaysPreTouch

强制操作系统把内存真正分配给IVM,而不是用时才分配。

综上,只要年轻代参数设置合理,老年代CMS的参数设置基本都可以用默认值,如下所示:

-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=5 -XX:PretenureSizeThreshold=1M -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=70 -XX:+UseCMSInitiatingOccupancyOnly -XX:+AlwaysPreTouch

参数解释

  1. -Xms3072M -Xmx3072M 最小最大堆设置为3g,最大最小设置为一致防止内存抖动

  2. -Xss1M 线程栈1m

  3. -Xmn2048M -XX:SurvivorRatio=8 年轻代大小2g,eden与survivor的比例为8:1:1,也就是1.6g:0.2g:0.2g

  4. -XX:MaxTenuringThreshold=5 年龄为5进入老年代 5.‐ XX:PretenureSizeThreshold=1M 大于1m的大对象直接在老年代生成

  5. -XX:+UseParNewGC -XX:+UseConcMarkSweepGC 使用ParNew+cms垃圾回收器组合

  6. -XX:CMSInitiatingOccupancyFraction=70 老年代中对象达到这个比例后触发fullgc

  7. -XX:+UseCMSInitiatinpOccupancyOnly 老年代中对象达到这个比例后触发fullgc,每次

  8. -XX:+AlwaysPreTouch 强制操作系统把内存真正分配给IVM,而不是用时才分配。

Step8:配置OOM时候的内存dump文件和GC日志

额外增加了GC日志打印、OOM自动dump等配置内容,帮助进行问题排查

1 -XX:+HeapDumpOnOutOfMemoryError

在Out Of Memory,JVM快死掉的时候,输出Heap Dump到指定文件。不然开发很多时候还真不知道怎么重现错误。路径只指向目录,JVM会保持文件名的唯一性,叫java_pid${pid}.hprof。

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=${LOGDIR}/

因为如果指向特定的文件,而文件已存在,反而不能写入。输出4G的HeapDump,会导致IO性能问题,在普通硬盘上,会造成20秒以上的硬盘IO跑满,需要注意一下,但在容器环境下,这个也会影响同一宿主机上的其他容器。

GC的日志的输出也很重要:

-Xloggc:/dev/xxx/gc.log -XX:+PrintGCDateStamps -XX:+PrintGCDetails

GC的日志实际上对系统性能影响不大,打日志对排查GC问题很重要。

9、JVM参数配置模板

(1)ParNew +CMS

基于4C8G系统的ParNew+CMS回收器模板(响应优先),新生代大小根据业务灵活调整!

-Xms4g -Xmx4g -Xmn2g -Xss1m -XX:SurvivorRatio=8 -XX:MaxTenuringThreshold=10 -XX:+UseConcMarkSweepGC -XX:CMSInitiatingOccupancyFraction=70 -XX:+UseCMSInitiatingOccupancyOnly -XX:+AlwaysPreTouch -XX:+HeapDumpOnOutOfMemoryError -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps -Xloggc:gc.log

(2)G1

如果是GC的吞吐优先,推荐使用G1,基于8C16G系统的G1回收器模板:

-Xms8g -Xmx8g -Xss1m -XX:+UseG1GC -XX:MaxGCPauseMillis=150 -XX:InitiatingHeapOccupancyPercent=40 -XX:+HeapDumpOnOutOfMemoryError -verbose:gc -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps -Xloggc:gc.log

10、如果对象的引用被置为 null,垃圾收集器是否会立即释放对象占用的内存?

不会立即释放对象占用的内存。 如果对象的引用被置为null,只是断开了当前线程栈帧中对该对象的引用关系,而 垃圾收集器是运行在后台的线程,只有当用户线程运行到安全点(safe point)或者安全区域才会扫描对象引用关系,扫描到对象没有被引用则会标记对象,这时候仍然不会立即释放该对象内存,因为有些对象是可恢复的(在finalize方法中恢复引用 )。只有确定了对象无法恢复引用的时候才会清除对象内存。

五、JIT 即时编译

1、Java中的对象一定是在堆上分配的吗?

一般情况下JVM运行时的数据都是存在栈和堆上的。

  • 栈用来存放一些基本变量和对象的引用,

  • 堆用来存放数组和对象,也就是说new出来的实例。

但是:凡事都有例外。随着JIT编译器的发展和逃逸分析的技术成熟,栈上分配、标量替换等优化技术,使对象不一定全都分配在堆中 。随着JIT编译器的发展和逃逸分析的技术成熟,栈上分配、标量替换等优化技术,使对象不一定全都分配在堆中

2、逃逸分析是什么?

(1)逃逸分析 的本质:

主要就是分析对象的动态作用域,分析一个对象的动态作用域是否会逃逸出方法范围、或者线程范围 。如果一个对象在一个方法内定义,如果被方法外部的引用所指向,那认为它逃逸了。否则,这个对象,没有发生逃逸。

(2)逃逸分析的类型

逃逸分析的类型有两种:

  • 方法逃逸: 当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其它方法中。

  • 线程逃逸:这个对象甚至可能被其它线程访问到,例如赋值给类变量或可以在其它线程中访问的实例变量

(3)逃逸分析后的代码优化

从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。如果能够证明一个对象不会逃逸到方法外或者线程外,或者说逃逸程度比较低,则可以对这个对象采用不同程度的优化:

  • 栈上分配:对象不分配在堆上,而是分配在栈内存上

  • 标量替换:JVM将一个大的对象打散成若干变量的过程,叫做标量替换,也称之为 分离对象

  • 消除同步锁:锁对象只能够被一个线程访问,根本用不着同步,那么,JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步锁。所以:如果程序中使用了synchronized内置锁锁,则JVM会将synchronized内置锁消除。

注意:这种情况针对的是synchronized锁,而对于非内置锁,比如 Lock 显式锁、CAS乐观锁等等,则JVM并不能消除。

(4)逃逸分析的底层原理

在Java的编译体系中,一个Java的源代码文件变成计算机可执行的机器指令的过程中,需要经过两段编译:

  • 第一段编译,指前端编译器把.java文件转换成 .class文件(字节码文件)。前端编译器产品可以是JDK的Javac、Eclipse JDT中的增量式编译器。

  • 第二编译阶段,JVM 通过解释字节码将其翻译成对应的机器指令,逐条读入字节码,逐条解释翻译成机器码。很显然,由于有一个解释的中间过程,其执行速度必然会比可执行的二进制字节码程序慢很多。

这就是传统的JVM的解释器(Interpreter)的功能。

如何去掉中间商,提升效率?

为了解决这种效率问题,引入了JIT(即时编译器,Just In Time Compiler)技术。

引入了 JIT 技术后,Java程序还是通过解释器进行解释执行,也就是说,主体还是解释执行,只是局部去掉中间环节。

怎么做局部去掉中间环节呢?

当JVM发现某个方法或代码块运行特别频繁的时候,就会认为这是“热点代码”(Hot Spot Code)。

然后JIT会把部分“热点代码”翻译成本地机器相关的机器码,并进行优化,然后再把翻译后的机器码缓存起来,以备下次使用。

把翻译后的机器码缓存在哪里呢? 这个 缓存,叫做 Code Cache。 可见,JVM和WEB应用实现高并发的手段是类似的,还是使用了缓存架构。

当JVM下次遇到相同的热点代码时,跳过解释的中间环节,直接从 Code Cache加载机器码,直接执行,无需再编译。

所以,JVM总的策略为:

  • 对于占据大部分的不常用的代码,我们无需耗费时间将其编译成机器码,而是采取解释执行的方式运行;

  • 另一方面,对于仅占据小部分的热点代码,我们则可以将其编译成机器码,以达到理想的运行速度。