这是悟空的第 120 篇原创文章
大家好,我是悟空。
先说下哈,这次给大家带来的大厂面试题,都来源于我的知识星球,由星球的几位嘉宾 + 我 一起完成。
嘉宾介绍:
东哥:MySQL 大牛一枚,北漂硕士。
连边哥:技术总监一枚!喜欢卷 Java 和 MySQL
灰原哥:卷王,一天可卷 16 小时。
欣哥:技术大牛,凌晨 3 点还在卷的卷王。
面试题第一时间会发到我的知识星球和技术群里面。可以在文末加我好友。
答:java 堆从GC角度可分为老年代和新生代。其中新生代又分为Eden区和两个Survivor 区(以下简称S0区和S1区)
答:因为JAVA对象90%以上的对象都是朝生夕死的,其中GC回收的成本很高,为了提高性能所以将新生成的对象放在Eden区,将扛过多次GC的“老家伙”放在老年代
答:因为Eden区的绝大部分对象寿命很短,那么Eden每次满了清理垃圾,存活的对象被迁移到老年区,老年区满了,就会触发Full GC,Full GC是非常耗时的,设立s区的一个目的就是在Eden区和老年代中增加一个缓冲池,放一些“年纪不够老”的对象,增加垃圾回收性能
答:会,但是并非主动进行的垃圾回收,是Eden区在进行垃圾回收的时候顺带回收、默认Eden区和 s0 ,s1 区的比例是 8:1:1。
答:这涉及到年轻代的垃圾回收算法,(复制算法)设置两个Survivor区最大的好处就是解决了碎片化,刚刚新建的对象在Eden中,经历一次Minor GC,Eden中的存活对象就会被移动到S0,Eden被清空;等Eden区再满了,就再触发一次Minor GC,Eden和S0中的存活对象又会被复制送入S1区(这个过程非常重要,因为这种复制算法保证了S1中来自S0和Eden两部分的存活对象占用连续的内存空间,避免了碎片化的发生),接着新对象继续分配在Eden区和另外那块开始被使用的Survivor区,然后始终保持一块Survivor区是空着的,就这样一直循环使用这三块内存区域
#每天一道面试题# 55
#悟空拧螺丝# 2021-09-08
问题:说下JVM的垃圾回收算法?
下面介绍七种回收算法:
原理:可达性分析算法是以根对象集合(GCRoots)为起始点,按照从上至下的方式搜索被根对象集合所连接的目标对象是否可达。虚拟机栈、本地方法栈、方法区、字符串常量池 等地方对堆空间进行引用的,都可以作为GC Roots进行可达性分析。
原理:当堆中的有效内存空间(available memory)被耗尽的时候,就会停止整个程序(也被称为stop the world),然后进行两项工作,第一项则是标记,第二项则是清除 标记:Collector从引用根节点开始遍历,标记所有被引用的对象。一般是在对象的Header中记录为可达对象。清除:Collector对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收
缺点:
因为标记-清除算法的缺点,由此发明了复制算法。原理:将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收。
优点:复制过去以后保证空间的连续性,不会出现“碎片”问题。
缺点:需要多余的内存空间。
背景:复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法。
原理:第一阶段和标记清除算法一样,从根节点开始标记所有被引用对象。第二阶段将所有的存活对象压缩到内存的一端,按顺序排放。之后,清理边界外所有的空间。
标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再进行一次内存碎片整理,因此,也可以把它称为标记-清除-压缩(Mark-Sweep-Compact)算法。
优点:
消除了标记-清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可。消除了复制算法当中,内存减半的高额代价。
缺点:从效率上来说,标记-整理算法要低于复制算法。移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址 移动过程中,需要全程暂停用户应用程序。即:STW。
背景:不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。
年轻代:复制算法 老年代:由标记-清除或者是标记-清除与标记-整理的混合实现。
原理:如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成。
缺点:使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降。
原理:分区算法将整个堆空间划分成连续的不同小区间。每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间。
SerialSerial 是一款用于新生代的单线程收集器,采用复制算法进行垃圾收集。Serial 进行垃圾收集时,不仅只用一条线程执行垃圾收集工作,它在收集的同时,所有的用户线程必须暂停(Stop The World)。ParNew
ParNew 就是一个 Serial 的多线程版本,其它与Serial并无区别。ParNew 在单核 CPU 环境并不会比 Serial 收集器达到更好的效果,它默认开启的收集线程数和 CPU 数量一致,可以通过 -XX:ParallelGCThreads 来设置垃圾收集的线程数。
如下是 ParNew 收集器和 Serial Old 收集器结合进行垃圾收集的示意图,当用户线程都执行到安全点时,所有线程暂停执行,ParNew 收集器以多线程,采用复制算法进行垃圾收集工作,收集完之后,用户线程继续开始执行。
ParallelScavengeParallel Scavenge 也是一款用于新生代的多线程收集器,与 ParNew 的不同之处是ParNew 的目标是尽可能缩短垃圾收集时用户线程的停顿时间,Parallel Scavenge 的目标是达到一个可控制的吞吐量。
SerialOld
Serial Old 收集器是 Serial 的老年代版本,同样是一个单线程收集器,采用标记-整理算法。
ParallelOld
Parallel Old 收集器是 Parallel Scavenge 的老年代版本,是一个多线程收集器,采用标记-整理算法。可以与 Parallel Scavenge 收集器搭配,可以充分利用多核 CPU 的计算能力。
CMS(ConcurrentMarkSweep)
CMS 收集器是一种以最短回收停顿时间为目标的收集器,以 “ 最短用户线程停顿时间 ” 著称。整个垃圾收集过程分为 4 个步骤:
① 初始标记:标记一下 GC Roots 能直接关联到的对象,速度较快。
② 并发标记:进行 GC Roots Tracing,标记出全部的垃圾对象,耗时较长。
③ 重新标记:修正并发标记阶段用户程序继续运行而导致变化的对象的标记记录,耗时较短。
④ 并发清除:用标记-清除算法清除垃圾对象,耗时较长。
整个过程耗时最长的并发标记和并发清除都是和用户线程一起工作,所以从总体上来说,CMS 收集器垃圾收集可以看做是和用户线程并发执行的。
G1 收集器是 jdk1.7 才正式引用的商用收集器,现在已经成为 jdk9 默认的收集器。前面几款收集器收集的范围都是新生代或者老年代,G1 进行垃圾收集的范围是整个堆内存,它采用 “ 化整为零 ” 的思路,把整个堆内存划分为多个大小相等的独立区域(Region),在 G1 收集器中还保留着新生代和老年代的概念。
1.CMS收集器是老年代的收集器,可以配合新生代的Serial和ParNew收 集器一起使用;G1收集器收集范围是老年代和新生代,不需要结合其他收集器使用;2.G1收集器可预测垃圾回收的停顿时间CMS收集器是使用“标记-清除”算 法进行的垃圾回收,容易产生内存碎片G1收集器使用的是“标记-整理”算法,进行了空间整合,降低了内存空间碎片。
#每天一道面试题# 56
JVM可以分为运行时数据区以及类加载器、执行引擎、本地方法库
运行时数据区包含以下部分:
1.方法区:非堆
2.虚拟机栈:本地方法
3.本地方法栈:native方法
4.堆:新生代(from、to、eden)、老年代
5.程序计数器:标记当前线程所执行的位置,方便上下文切换完成以后继续执行
非运行时数据区:
6.类加载器:启动类加载器、扩展类加载器、系统类加载器、自定义类加载器;
7.执行引擎:将字节码指令解释/编译为对应平台上的本地机器指令;
8.本地方法库:Java调用跨语言(C、C++)的接口。
答:栈指向方法区;栈指向堆、堆指向方法区、方法区指向堆;
栈:栈帧(局部变量表、操作数栈、返回地址、动态链接)
栈 -> 方法区
动态链接指向 Klass 对象在方法区的地址
栈 -> 堆
局部变量表存放的引用变量,指向真实对象存放在堆中的地址
方法区 -> 堆(JDK8以后方法区不在指向堆)
JDK7 以前静态属性在方法区中;
JDK8 及以后,存在堆中元数据类Class中;
堆 -> 方法区
klass pointer 作用去找到对象依赖的类;
答:双亲委派:沙箱安全机制,防止核心API库被随意篡改。
还有一些场景破坏了双亲委派机制,因为受类加载器受到加载范围的限制,存在某些情况下父类加载器无法加载到需要的文件。在JDBC、Tomcat、OSGI 场景就需要委托子类加载器去加载class文件破坏了双亲委派机制。
答:空闲列表:idle(空闲),used(已用),available(可用);
指针碰撞:自旋 + CAS;
1.永久代缺点
存在内存瓶颈
GC:字符串常量池,动态字节码
存储类信息
2.元空间能解决永久代问题,它本身还存在问题吗?
元空间存储应用程序的类加载器信息,当前实现回收以后会产生内存碎片
方法区是JVM的规范;元空间与永久代是规范落地实现,7叫永久代在JVM内部,8及以后叫元空间使用直接内存;容易混淆的概念:
堆空间不是全部被线程共享的,TLAB:线程私有的堆空间
对象不一定存放在堆中,方法内局部变量,存在栈中
元空间不等于方法区,方法区抛开元空间以外还包括CodeCache存储JIT的代码等。
cms 回收为什么要停顿两次?
答案:以最少的 STW成本,找出要清理的垃圾。
什么是STW 暂停用户线程 - Stop The World
如果不暂停用户线程,就意味着不断有垃圾的产生,永远也清理不干净;其次,因为清理垃圾用的标记清除算法,用户线程的运行必然会导致对象的引用关系发生变化,即标记的变化,,这样就会导致两种情况:漏标和错标。1.漏标:原来不是垃圾,但是在GC的过程中,用户线程将其引用关系修改,变成了null引用,成为了垃圾,这种情况还好,无非就是产生了一些浮动垃圾,下次GC再清理就好了;2.错标:与漏标对应的就是错标,一个对象,开始没有引用,但是GC的同时,用户线程又重新引用了它,但是这个时候,我们把它当作垃圾清理掉了,这将会导致程序运行错误。
前边讲了两点,什么是暂停用户线程和为什么要暂停用户线程,现在接着讲cms是怎么样来识别垃圾对象的。垃圾对象:简单的说,就是判断是否有引用,如果某个对象,已经没有任何引用指向它,就把该对象定义为垃圾对象,即我们要清理的对象,这个的核心就是可达性分析算法。
这个过程正确执行的前提是没有其他线程改变对象间的引用关系。
初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。初始标记的过程是需要触发STW的,不过这个过程非常快,而且初试标记的耗时不会因为堆空间的变大而变慢,是可控的,因此可以忽略这个过程导致的短暂停顿。
并发标记就是将初始标记的对象进行深度遍历,以这些对象为根,遍历整个对象图,这个过程耗时较长,而且标记的时间会随着堆空间的变大而变长。不过好在这个过程是不会触发STW的,用户线程仍然可以工作,程序依然可以响应,只是程序的性能会受到一点影响。因为GC线程会占用一定的CPU和系统资源,对处理器比较敏感。CMS默认开启的GC线程数是:(CPU核心数+3)/4,当CPU核心数超过4个时,GC线程会占用不到25%的CPU资源,如果CPU数不足4个,GC线程对程序的影响就会非常大,导致程序的性能大幅降低。
由于并发标记时,用户线程仍在运行,这意味着并发标记期间,用户线程有可能改变了对象间的引用关系,可能会发生两种情况:一种是原本不能被回收的对象,现在可以被回收了,另一种是原本可以被回收的对象,现在不能被回收了。针对这两种情况,CMS需要暂停用户线程,进行一次重新标记。
重新标记完成后,就可以并发清理了。这个过程耗时也比较长,且清理的开销会随着堆空间的变大而变大。不过好在这个过程也是不需要STW的,用户线程依然可以正常运行,程序不会卡顿,不过和并发标记一样,清理时GC线程依然要占用一定的CPU和系统资源,会导致程序的性能降低。
以最少的STW成本,找出要清理的垃圾。这里我们可以抽象的理解为全量垃圾和增量垃圾的两个概念。清理的第一步,就是为了找出产生全量垃圾根对象,并打上标记为初始标记(耗时短,STW),同时把用户访问线程打开,并让后台线程去执行第二步并发标记,这些其实就是找出我们全量垃圾。然后找出在我们执行并发标记这段时间由用户线程产生的增量垃圾进行重新标记(耗时短,STW),这个时候的GC标记,就是截止到当前时间,完整的垃圾信息,再执行并发清理。
#每天一道面试题# 61
system.gc 和 runtime. getRuntime(). gc() 会做些什么事?
System.gc() 在内部调用 Runtime.gc()。硬要说区别的话 Runtime.gc() 是 native method,而 System.gc() 是非 native method,它依次调用 Runtime.gc();调用gc方法在默认情况下,会显示触发full gc,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存。system.gc 调用附带一个免责声明,无法保证垃圾收集器的调用。即gc()函数的作用只是提醒虚拟机,程序员希望进行一次垃圾回收。但是这次回收不能保证一定进行,具体什么时候回收取决于jvm。如果每次调用gc方法后想让gc必须执行,可以追加调用system. runFinalization方法。
其实工作中,很少有机会能接触到 jvm 调优,大部分时间都是在写 CRUD 代码,但如果万一线上真的出问题了,那么再去想 jvm 调优就有点晚了,所以我们需要先把这部分知识储备起来。
面试官思路:主要是想看下你对造成 JVM 性能问题有没有思考总结过。
可以从三个方面说:
首先第一个方面,其实大部分 JVM 性能问题,并不是我们设置的参数问题,一般情况下,都是用默认参数就搞定了,而真正出问题的情况多是自己写的代码有问题,如频繁创建大对象,然后又引用它们不释放,然后这些大对象进入了老年代后,垃圾收集器有回收不了它们,老年代内存不足,造成频繁 Full GC,每次 Full GC 都会触发 STW,也就是造成卡顿现象,这样性能不就很差了吗?
这个就是为了记录日志用的,我们可以利用日志来快速定位性能问题。
-XX:+PrintGCTimeStamps -XX:+PrintGCDetails -Xloggc:gc.log
-XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D:\jvm.dump
jmap -dump:format=b,file=D:/demo.hprof pid
当然,我们最好是能把 dump 文件获取到,然后放到本地的工具中分析就好办了。
第二个方面中,拿到 dunp 文件后,就是分析:
说到常用的分析工具,当然是少不了 jvisualvm 可视化工具了,可以通过输入命令 jvisualvm 打开,然后载入之前的 dump 文件就可以了。这个工具会显示现在有哪些大对象占用着内存在。另外也可以通过 JProfiler 可视化工具来排查。
整体思路
就是拿到 dump 文件,放到可视化工具中分析一把,大部分情况都是大对象造成的,然后再结合自己的代码,看看哪个地方造成了对象创建后没有被回收,然后优化代码就好了。
有时候,我们只能在线上的服务器上通过命令排查,那么就只能使用命令行工具来排查了,其实思路也很常规:
大多数情况下,新创建的对象都会在新生代的 Eden 区中分配,当 Eden 区没有足够的空间分配时,虚拟机将会发生一次 Minor GC,也就是 YGC。频繁发生 YGC 也是会对性能造成影响的。
每5秒执行一次,执行10次,然后观察这50秒内 eden 区增加的趋势,即可知道年轻代对象增长的速率。
jstat -gc pid 5000 10
思路:如果 eden 区增长很快,那么发生 YGC 的频率也会很高,说明 Eden 区太小了,可以调大 Eden 区(调整 -Xmn 参数),然后再次进行测试,看小是否减少了 YGC 回收频率。
另外如果 YGC 后,存活的对象超过了 Survivor 的 50%,则会进入老年代。
我们的调优思路是尽量减少对象进入老年代,以减少发生 FGC 的频率。所以通过调整 Eden 区的大小,减少了对象进入老年代的频率。
第三个方面,如何进行参数调优。一般情况下,参数用默认的就好了,但是某些场景还是要进行参数调优的。
调优思路如下:
一般就分为 Minor GC 和 Full GC 两种情况。
🎯年轻代发生垃圾回收的时机(Minor GC)
🎯整堆触发垃圾回收的时机 (FULL GC)
真碰到这种面试题,即使没有在生产环境使用过这些工具排查问题,也不要惊慌。把这些基本操作记住,面试就不慌。
出现 OOM 问题后,先得找到是哪个 Java 应用程序出问题了。也就是需要找到进程 id 才行。
有两种方式找进程 id
top -c
CPU 和 内存占用率排在最前面的就是占用最高的。里面包含了进程 id 信息。
ps -ef | grep <关键字>
jstat -gc pid
会打印一堆信息,可以先初步看下 内存的使用情况和 GC 压力情况。另外有时候 dump 文件会非常大,可以先通过命令来排查,这样效率会高效点。
S0C: 第一个Survivor大小(kb)
S1C: 第二个Survivor大小
S0U: 第一个Survivor区的使用大小
S1U: 第二个Survivor区的使用大小
EC: eden区大小
EU: eden区的使用大小
OC: 老年代大小
OU: 老年代使用大小
MC: 方法区大小(元空间)
MU: 方法区使用大小
CCSC: 压缩类空间大小
CCSU: 压缩类空间使用大小
YGC: YoungGC次数
YGCT: YoungGC时间(s)
FGC: FullGC次数
FGCT: FullGC时间(s)
GCT: 总的GC时间(s)
可以指定时间间隔打印jvm的各部分空间占用,以及gc数据。命令如下:
jstat -gcutil {pid} {timeinterval}
当应用程序占用 CPU 很高,但是又没有发生 OOM,就可以通过 jstack 命令来看下到底哪里出问题了。
ps -mp <pid> -o THREAD,tid,time 定位到具体线程。
printf “%x\n” <pid>,把线程 pid 转为16进制,比如 0xf58
jstack <pid> | grep -A 10 0xf58 查看线程的堆栈日志
通过 jstack 命令可以迅速排查出来死锁问题或死循环的问题。
jmap 一般就是用来生成堆栈文件(dump 文件),然后把 dump 文件导入到可视化分析工具中,分析一把。比如jvisualvm 工具, MAT 工具。
jmap -dump:format=b,file=D:/demo.hprof <pid>
另外生产环境一般会配置内存溢出后自动打 dump 文件的命令:
-XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D:\jvm.dump
其他:
jmap -histo pid 查看实例个数以及占用内存信息。
jmap -heap pid 查看堆的使用情况。
分析 dump 文件的专业工具,查找内存泄露以及查看内存消耗情况,可以查看每个类的使用情况以及内存占用情况,从而分析问题。
eclipse 插件安装下这个工具就可以使用了。
MAT 插件会给出一份可疑的分析报告,结合源代码稍加分析就可以快速定位是哪段代码出问题了。
我的技术交流群有同学提问:
增加 Eden 区,Minor GC 的间隔变长了,会不会导致 Minor GC 的时间增加?
看到极客时间的一篇文章,分享给大家:
可能你会有这样的疑问,扩容 Eden 区虽然可以减少 Minor GC 的次数,但不会增加单次 Minor GC 的时间吗?
单次 Minor GC 时间是由两部分组成:T1(扫描新生代)和 T2(复制存活对象)。假设一个对象在 Eden 区的存活时间为 500ms,Minor GC 的时间间隔是 300ms,那么正常情况下,Minor GC 的时间为 :T1+T2。当我们增大新生代空间,Minor GC 的时间间隔可能会扩大到 600ms,此时一个存活 500ms 的对象就会在 Eden 区中被回收掉,此时就不存在复制存活对象了,所以再发生 Minor GC 的时间为:两次扫描新生代,即 2T1。
可见,扩容后,Minor GC 时增加了 T1,但省去了 T2 的时间。通常在虚拟机中,复制对象的成本要远高于扫描成本。
如果在堆内存中存在较多的长期存活的对象,此时增加年轻代空间,反而会增加 Minor GC 的时间。如果堆中的短期对象很多,那么扩容新生代,单次 Minor GC 时间不会显著增加。因此,单次 Minor GC 时间更多取决于 GC 后存活对象的数量,而非 Eden 区的大小。
- END -