Java面试突击每日十题【Day05】——JVM篇

Notes:先思考再看参考答案,答案在图片下面!!!

  1. JVM类加载器及类加载原理?如何打破双亲委派机制?
  2. 描述一下Java类加载和初始化过程?
  3. 描述一下JVM内存模型,以及这些空间存放的内容?
  4. 聊一下堆的分区及特点、GC过程、空间分配担保机制?常用的GC算法有哪些,分别用在什么时候?
  5. GC收集器有哪些,你们项目使用的垃圾回收器?
  6. G1垃圾收集器有什么特点?
  7. JVM对象已死垃圾判定算法,变量什么时候下会被垃圾回收?
  8. 什么是OOM?什么是Stackoverflowerror?产生的原因?
  9. Java的四种引用?
  10. 有没有调优经验,说说你是怎么JVM调优的?

Java面试突击每日十题【Day05】——JVM篇_第1张图片
答案:
一、
类加载器负责加载class文件,class 文件在文件开头由特定的文件标识,并且ClassLoader只负责class文件的加载,至于它是否可以运行,则由Execution Engine决定。
类加载器分为四种:前三种为Java虚拟机自带的加载器

启动类加载器(BootStrap)C++:负责加载$JAVA_HOME中的jre/lib/rt.jar里所有的class,由C++实现,不是ClassLoader的子类。
扩展类加载器(Extension)Java:负责加载java平台中除了rt.jar之外的扩展功能的一些jar包,包括JAVA_HOME中jre/lib/*.jar或-Djava.ext.dirs指定目录下的jar包。
应用程序加载器(AppClassLoader)Java:主要加载应用程序的类。
用户自定义加载器:继承Java.lang.ClassLoader后,用户可以定制类的加载方式。

类加载器工作过程(双亲委派模式):
当AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成。
当ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader去完成。
如果BootStrapClassLoader加载失败(例如在$JAVA_HOME/jre/lib里未查找到该class),会使用ExtClassLoader来尝试加载。
若ExtClassLoader也加载失败,则会使用AppClassLoader来加载
如果AppClassLoader也加载失败,则会报出异常ClassNotFoundException。

双亲委派的好处:
每一个类都只会被加载一次,避免了重复加载。
每一个类都会被尽可能的加载(从引导类加载器往下,每个加载器都可能会根据优先次序尝试 加载它)
有效避免了某些恶意类的加载,防止内存中出现多份字节码。(比如自定义了Java.lang.Object类,一般而言在双亲委派模型 下会加载系统的Object类而不是自定义的Object类)

打破双亲委派机制:
我们知道双亲委派机制是从AppClassLoader->ExtClassLoader->BootstrapClassLoader这个顺序找,打破就是不让它按着这个顺序找,因为加载class的核心方法在LoaderClass类的的loadClass方法上,所以可以自定义ClassLoader,重写LoadClass方法(不依照原来的查找顺序寻找类加载器加载),那就算是打破了。
何时打破过:
JDK1.2之前,自定义ClassLoader都必须重写loadClass。
Tomcat给每个Web应用创建一个类加载实例(WebAppClassLoader),该加载器重写了loadClassLoader方法,优先加载当前目录下的类如果找不到了,才一层一层往上找。
JDBC获取连接的时候,通过使用线程上下文加载器去加载。

二、
Java类加载分为5个过程,分别为:加载,链接(验证,准备,解析),初始化,使用,卸载。
加载主要是将.class文件通过二进制字节流读入到JVM中。 在加载阶段,JVM需要完成3件事:
1)通过classloader在classpath中获取XXX.class文件,将其以二进制流的形式读入内存。
2)将字节流所代表的静态存储结构转化为方法区的运行时数据结构;
3)在内存中生成一个该类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
链接:链接又分为验证、准备、解析
验证:主要确保加载进来的字节流符合JVM规范。验证阶段会完成以下4个阶段的检验动作:
1)文件格式验证
2)元数据验证(是否符合Java语言规范)
3)字节码验证(确定程序语义合法,符合逻辑)
4)符号引用验证(确保下一步的解析能正常执行)
准备:是链接阶段的第二步,主要为静态变量在方法区分配内存,并设置默认初始值。
解析:是链接阶段的第三步,是虚拟机将常量池内的符号引用替换为直接引用的过程。
初始化:
初始化阶段是类加载过程的最后一步,主要是根据程序中的赋值语句主动为类变量赋值。
当有继承关系时,先初始化父类再初始化子类,所以创建一个子类时其实内存中存在两个对象实
例。
使用:程序之间的相互调用。
卸载:即销毁一个对象,一般情况下中有JVM垃圾回收器完成。代码层面的销毁只是将引用置为null。

三、
首先JVM由类装载器、运行时数据区、执行引擎、本地方法接口和本地方法库组成。

  • 类加载器:见第1题

  • 运行时数据区主要包括:

    1. 方法区:存储已经被虚拟机加载的类元数据信息(元空间)(在方法区中,存储了每个类的信息(包括类的名称,方法信息,字段信息)、静态变量、常量以及编译器编译后的代码等。 在Class文件中除了类的字段、方法、接口等描述信息外,还有一项信息是常量池,用来存储编译期间生成的字面量和符号引用。在方法区中有一个非常重要的部分就是运行时常量池,它是每一个类或接口的常量池的运行时表示形式,在类和接口被加载到JVM后,对应的运行时常量池就被创建出来。当然并非只有Class文件常量池中的内容才能进入运行时常量池,在运行期间也可将新的常量放入运行时常量池中,比如String的intern方法。)从JDK8开始,方法区被元数据区替代了。(静态变量+常量+类信息(构造方法/接口定义)+运行时常量池存在方法区中,但是实例变量存在堆内存中,和方法区无关)

    2. 堆:存放对象实例,几乎所用的对象实例都在这里分配内存

    3. Java虚拟机栈:虚拟机栈描述的是Java方法执行的内存模型,每个方法被执行的时候都会同时创建一个栈帧,栈帧中主要保存3 类数据:本地变量(输入参数和输出参数以及方法内的变量。)、栈操作(记录出栈、入栈的操作。)、栈帧数据(包括类文件、方法等)(8种基本类型的变量+对象的引用变量+实例方法都是在函数的栈内存中分配。)

    4. 程序计数器:每个线程都有一个程序计数器,是线程私有的,就是一个指针,指向方法区中的方法字节码(用来存储指向下一条指令的地址,即 将要执行的指令代码),由执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不记。

    5. 本地方法栈:Native Method Stack中登记native方法,在Execution Engine 执行时通过Native Interface加载本地方法库。

  • 执行引擎Execution Engine:如果想让一个Java程序运行起来,执行引擎Execution Engine将字节码指令解释/编译为对应平台上的本地机器指令才可以,提交操作系统执行。(通常java跨平台是因为jvm本质是执行引擎)
  • 本地接口Native Interface:主要是为了融合不同编程语言为Java所用(主要是C/C++),于是在内存开辟了一块区域处理标记为native的代码,具体做法是在Native Method Stack中登记native方法,在Execution Engine执行时加载native libraies.
  • 本地方法库:

四、
堆区域分为新生代和老年代(1:2),其中新生代又分为Eden、Survivalfrom/to区。
刚开始对象都分配在eden区,如果eden区快满了就触发MinorGC,把eden区中的存活对象转移到一块空着的survivor区,eden区清空,然后再次分配新对象到eden区,再触发垃圾回收,就把eden区存活的和survivor区存活的转移到另一块空着的survivor。
经过多次MInorGC仍然存在的对象(默认是15次)会被放入老年代,大对象也会直接被放入老年代。当Survival放不下时,通过空间分配担保的对象进入老年代。当老年代没有足够空间存放对象时,会触发一次FullGC,fullGC之前伴随着一次monorGC。FullGC一般是两个原因引起的,要么是老年代内存过小,要么是老年代连续内存过小,存放不下对象了,如果元空间区域的内存达到了所设定的阈值-XX:MetaspaceSize=,也会触发FullGC。
空间分配担保:
在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,
如果大于,则此次Minor GC是安全的。如果小于,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;如果小于或者HandlePromotionFailure=false,则改为进行一次Full GC。
假如在发生gc的时候,eden区里有150MB对象存活,而Survivor区只有100MB,无法全部放入,这时就必须把这些对象全部直接转移到老年代里。
如果 Full gc后老年代还是没有足够的空间存放剩余的存活对象,那么就会导致 “OOM” (out of memory) 内存溢出。

常用的GC的三种收集方法:复制算法、标记清除、标记整理。

  • 复制算法:把内存平均分成两份,只使用其中一份,这一份内存满了之后,把存活对象copy到另一半内存。清空满了的那一半内存。
    • 优点:对象存活率不高的情况下简单高效 不会产生内存碎片
    • 缺点:浪费一般内存空间 对象存活率较高的情况下,所有对象都要copy,并重置指针,效率不高
    • 新生代(对象存活率不高,不会超过10%)使用的垃圾回收算法就是复制算法。
  • 标记清除算法:分成两个阶段:
    • 标记阶段:标记出需要回收的对象,使用的标记算法均为可达性分析算法
    • 清除阶段:清除死亡的对象,回收被标记的对象。
    • 缺点:经过两次遍历,效率不高,产生大量的内存碎片
  • 标记整理(压缩算法):标记-整理法是标记-清除法的一个改进版。同样,在标记阶段,该算法也将所有对象标记为存活和死亡两种状态;不同的是,在第二个阶段,该算法并没有直接对死亡的对象进行清理,而是通过所有存活对像都向一端移动,然后直接清除边界以外的内存。
    • 优点:避免了一半内存的浪费,避免了内存碎片的产生
    • 缺点:两次遍历效率不高,移动对象并重置指针,效率更低。

老年代一般是由标记清除或者是标记清除与标记整理的混合实现。
五、
Serial 年轻代 串行回收
ParallelScavenge(PS) 年轻代 并行回收
ParNew年轻代,配合CMS的并行回收
SerialOld
ParallelOld
ConcurrentMarkSweep (CMS):老年代并发的,垃圾回收和应用程序同时运行,降低STW的时间(200ms),但是CMS问题较多,所以没有一个版本默认是CMS只能手工指定,CMS既然是标记清除就一定会有碎片化问题,碎片到达一定程度,CMS的老年代分配对象分配不下的时候使用SerialOld进行老年代回收。这是内存比较大的话可能会有几个小时甚至几天的STW。使用的算法:三色标记 + incremetal Update。
G1(10ms):算法:三色标记 + STAB
ZGC(1ms)
Shenandoah:
我们项目中使用的默认的PS+PO,有的项目使用的G1(内存较大推荐使用)
六、

  1. G1的设计原则是"首先收集尽可能多的垃圾(Garbage First)"。因此,G1并不会等内存耗尽(串
    行、并行)或者快耗尽(CMS)的时候开始垃圾收集,而是在内部采用了启发式算法,在老年代找
    出具有高收集收益的分区进行收集。同时G1可以根据用户设置的暂停时间目标自动调整年轻
    代和总堆大小,暂停目标越短年轻代空间越小、总空间就越大;
  2. G1采用内存分区(Region)的思路,将内存划分为一个个相等大小的内存分区,回收时则以分
    区为单位进行回收,存活的对象复制到另一个空闲分区中。由于都是以相等大小的分区为单位
    进行操作,因此G1天然就是一种压缩方案(局部压缩);
  3. G1虽然也是分代收集器,但整个内存分区不存在物理上的年轻代与老年代的区别,也不需要
    完全独立的survivor(to space)堆做复制准备。G1只有逻辑上的分代概念,或者说每个分区都
    可能随G1的运行在不同代之间前后切换;
  4. G1的收集都是STW的,但年轻代和老年代的收集界限比较模糊,采用了混合(mixed)收集的方
    式。即每次收集既可能只收集年轻代分区(年轻代收集),也可能在收集年轻代的同时,包含部
    分老年代分区(混合收集),这样即使堆内存很大时,也可以限制收集范围,从而降低停顿。
  5. 因为G1建立可预测的停顿时间模型,所以每一次的垃圾回收时间都可控,那么对于大堆
    (16G左右)的垃圾收集会有明显优势。

七、

  • 引用计数法(Reference-Counting)

    • 引用计数算法是通过判断对象的引用数量来决定对象是否可以被回收。

    • 优点:简单,高效,现在的objective-c、python等用的就是这种算法。

    • 缺点:引用和去引用伴随着加减算法,影响性能

    • 很难处理循环引用,相互引用的两个对象则无法释放,可能产生内存泄漏(两个对象相互引用)。

      因此目前主流的Java虚拟机都摒弃掉了这种算法

  • 可达性分析算法(根搜索算法)

    • 这个算法的基本思想就是通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的。
    • 在Java语言中,可以作为GC Roots的对象包括下面几种:
      • 虚拟机栈(栈帧中的本地变量表)中的引用对象。
      • 方法区中的类静态属性引用的对象。
      • 方法区中的常量引用的对象。
      • 本地方法栈中JNI(Native方法)的引用对象

八、

  • OOM(OutOfMemoryError)出现在养老区和永久区,若养老区执行了两次Full GC之后发现依然无法进行对象的保存,就会产生OOM异常,如果出现java.lang.OutOfMemoryError: Java heap space异常,说明Java虚拟机的堆内存不够。原因有二:
    (1)Java虚拟机的堆内存设置不够,可以通过参数-Xms、-Xmx来调整。
    (2)代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)。 如果出现java.lang.OutOfMemoryError: PermGen space可能是Java虚拟机对永久代Perm内存设置不够、程序启动加载大量的第三方jar包。tomcat中运行了太多的项目、或者大量动态反射生成的类不断被加载,最终导致Perm区被占满。 Jdk1.6及之前: 有永久代,常量池1.6在方法区,Jdk1.7: 有永久代,但已经逐步“去永久代”,常量池1.7在堆。Jdk1.8及之后: 无永久代,常量池1.8在元空间(Metaspace)
  • StackOverflowError一般出现在方法的递归调用中。
  • 分析方法:在Eclipse中MAT工具的使用,在idea中修改配置参数生成dump文件后缀为**.hprof,使用jdk自带的文件解读工具jvisualvm.exe分析。

九、

  • 强:new User(),强引用只要存在引用就不会被垃圾回收器回收。
  • 软:通过SoftReference初始化一个软引用,一旦将要发生OOM都会被垃圾回收器回收。高速缓存
  • 弱:通过WeakReference初始化,一旦发生GC就会被垃圾回收器回收。
  • PhantomReference 类实现虚引用。无法通过虚引用获取一个对象的实例,为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

十、
有,根据我自己的理解JVM调优不是一上来就进行各种JVM参数优化;
1、首先我们会排查是不是关系型数据库遇到了瓶颈,这个过程会分析自己建的索引是否合理、是否需要引入缓存(分布式缓存)、(是否需要分库分表等)。
2、然后我们会考虑是不是需要扩容(横向和纵向都会考虑),这个过程中会去排查是不是系统压力过大,或者是系统的硬件能力不足导致的系统频繁出现问题。
3、其实出现问题很可能是代码的问题,这就需要从代码层面进行排查和优化,扩容虽然能解决大部分问题,可是都需要花钱的,这个过程我们回去排查自己写的代码是否存在资源浪费的问题,又或者在逻辑上存在可以优化的地方,比如通过并行的方式处理某些请求。
4、再接着,JVM层面上进行优化,这个过程我们会去观察JVM是否存在多次GC的问题
5、最后,网络和操作系统层面排查,这个过程查看内存/cpu/网络/硬盘读写指标是否正常等等。
在我的理解下,调优JVM其实就是理解JVM内存结构以及各种垃圾收集器前提下,结合自己现有的业务来调整参数,对服务器性能进行压榨,使应用稳定运行。调优主要参考指标有吞吐量、响应时间(停顿时间)、和垃圾收集频率,基于这些指标我们可能需要调整:
1、内存区域大小以及相关策略(比如整块堆内存占多少、新生代占多少、老年代占多少、Survivor占多少、晋升老年代的条件等,主要参数-Xms:设置堆的初始值、-Xmx:设置堆的最大值、-Xmn:表示年轻代的大小、-XX:SurvivorRatio:伊甸区和幸存区的比例等等,按照经验来说IO密集型可以稍微把年轻代空间加大点,因为大多数对象在年轻代就会灭亡,内存计算密集型的可以稍微把老年代空间加大点,对象存活时间会长些)
2、垃圾回收器(选择合适的垃圾回收器,以及各个垃圾回收器的各种调优参数,比如-XX:+UseG1GC:指定JVM使用的垃圾回收器为G1、-XX:MaxGCPauseMills:设置目标停顿时间、-XX:InitiatingHeapOccupancyPercent:当整个堆内存达到一定比例全局并发标记阶段就会被启动等等)
一般来说使用JVM默认的参数就能解决大多数问题,一般是遇到问题进行调优的,遇到问题需要利用一些工具进行排查:
1、top命令观察到问题:内存不断增长CPU占用率居高不下
2、top -Hp观察进程中的线程,哪个线程CPU和内存占比高
3、jps定位具体的java进程,jstack定位线程情况,重点关注WAITING、BLOCKED
4、jstat -gc 动态观察GC情况
5、jmap -histo 进程号 | head -20,查找有多少对象产生(定位占内存多的对象),也可以用jmap将jvm内存信息dump到文件,使用MAT或者JvisualVM等工具分析,不过dump过程不能轻易去操作,过程会造成服务卡顿,影响使用。
也可以使用阿里的Arthas工具,里面也涵盖了一些命令,有助于分析。
我曾经遇到的JVM调优问题:
1、当时做hbase数据迁移的时候,同步数据很慢,把垃圾回收器改成了G1,停顿时间设置了好像是2ms后,速度有明显的提升。
2、当时有一个应用往磁盘写入数据量巨大,动不动就OOM,调整了JVM堆的大小,由于偏向于IO操作,把新生代调大了,问题得到了解决。
3、还有一个问题当时我们的系统频繁FullGC,服务勉强能够使用,每次在晚上都重启一下服务,最后加内存并更换了G1,问题得到了解决。

调优参数:

  • -Xms :初始堆大小。只要启动,就占用的堆大小,默认是内存的1/64
  • -Xmx:最大堆大小。默认是内存的1/4
  • -Xmn:新生区堆大小
  • -XX:+PrintGCDetails:输出详细的GC处理日志
  • 命令:java -Xms20m -Xmx50m xx.class
    jps -l:查看服务器的所有java进程
    jinfo -flags 进程号:查看进程的jvm参数设置
    jstat -gc 进程号:查看jvm结构,GC情况

坚持很难很苦逼,可是,放弃比坚持更难。当你放弃时,你就要彻底承认自己的懦弱,放弃自己的期望,更要离开曾经和你一起坚持的伙伴。放弃之后,又怎知不是无尽的煎熬呢?

你可能感兴趣的:(Java,面试,java,面试,JVM)