【面试题】JVM相关

1.堆和栈的区别

  • 栈内存是存储方法帧和局部变量(基本类型的变量、对象的引用变量),方法调用完后会释放该栈及栈中变量。存取速度比堆要快,仅次于寄存器,栈数据可以共享,多个引用可以指向同一个地址,存在栈中的数据大小与生存期必须是确定的,缺乏灵活性。

  • 堆内存用于存放由new创建的对象和数组,由JVM管理,由于要在运行时动态分配内存,存取速度较慢,

  • 栈中的变量指向堆内存中的变量,这就是 Java 中的指针

2.运行时数据区域有有哪些?

  • 程序计数器:记录当前线程所执行到的字节码的行号,若线程中执行的是一个Java方法时,程序计数器中记录的是正在执行的线程的虚拟机字节码指令的地址,若是native方法,则计数器值为空。是JVM中唯一一个不会发生OOM的区域。

  • 虚拟机栈: 每个方法被执行的时候都会创建一个栈帧,一个栈帧包含:局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法被调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

  • 本地方法栈:为JVM所调用到的Nativa(本地方法)服务的,

  • 堆:存储对象实例,更好地分配内存。堆是GC管理的主要区域。为了更好地回收内存。在物理上可以不连续,只要逻辑上连续即可。分配堆内存的方法有指针碰撞法和空闲列表法。

  • 方法区:堆的一个逻辑部分,但却是非堆,保存运行时常量池(具有动态性)、已被虚拟机加载的类信息、常量、静态变量、JIT编译后的代码等信息。

  • 运行时常量池:存放编译期生成的各种字面量和符号引用,运行期间也可以将新的常量放入池中,例如String类的intern()方法,JDK7中若常量池中无数据,则会从堆中引用。

  • 堆和方法区是线程共享的,而栈是线程独享的。OOM:给一个对象一直分配内存,超过最大大小限制或本地直接内存已满,会抛出OOM,SOF:递归调用方法,

  • 内存泄露:一个不再使用的对象或变量却被引用而一直被占据在内存中,如当 o对象的引用被置空后,若发生 GC,o对象不能被 GC 回收,因为 GC 在跟踪代码栈中的引用时,会发现 v 引用,尽管o 引用已经被置空,但是 Object 对象仍然存在其他的引用,是可以被访问到的,所以 GC 无法将其释放掉。从而导致内存泄露,还有数据库的连接、网络连接、IO连接等没有显示调用关闭,也会造成内存泄露。

3.堆内存的划分?

  • JDK8之前堆的内存区域分为新生代、老年代 和永久区,

    • 新生代:所有新生成的对象首先都是放在新生代的。以尽可能快速的收集掉那些生命周期短的对象,它被细分为 Eden 、 Survivor from 和Survivor to。默认Edem : from : to = 8 : 1 : 1 ( 可以通过参数 –XX:SurvivorRatio 来设定 ),而JVM 每次只会使用 Eden 和其中的一块 Survivor 区域来为对象服务,总有一块 Survivor 区域空闲。因此,新生代实际可用的内存空间为90% 的新生代空间。
    • 老年代:在新生代中经历了N次(默认15次)垃圾回收后仍然存活的对象,就会被放到老年代中。因此,老年代中存放的都是一些生命周期较长的对象。默认老年代与新生代的比例的值为 2:1 ( 通过参数 –XX:NewRatio 来指定 ),即:新生代 ( Young ) = 1/3 的堆空间大小。
    • 永久区:用于存放Class和Meta的信息,Class在被 Load的时候被放入永久区,它和存放Instance的Heap区域不同,
  • JDK8中移除永久区,引入元空间,元数据(类信息、常量、静态变量)都在本地内存中分配,元数据由Klass (class文件在jvm里的运行时数据结构)和NoKlass Metaspace(常量池)组成,类加载器用SpaceManager管理元空间。用元数据替代永久区的原因是永久代中容易出现性能问题和内存溢出,并且指定大小困难。

  • 使用Runtime.freeMemory() 方法可知剩余空间的字节数

4.GC算法有哪些?

  • 判断对象是否存活

    • 引用计数法:引用时,计数器值就加1;当引用失效时,计数器值就减1,计数器为0时回收该对象,不能解决循环引用问题
    • 可达性分析法:当对象到GC Root没有任何引用链相连时,回收该对象。GC Root包含java虚拟机栈帧中的本地变量表中的引用对象、方法区中的类静态属性引用对象、常量引用对象、本地方法的引用对象。
    • 四种引用:强引用:宁愿OOM也不回收内存;软引用:内存不够就回收;弱引用:每次GC都回收;虚引用:GC时发出一个系统通知。
    • finalize方法:只会被系统自动调用一次,如果下一次回收,该方法不会被执行,因此尽量避免使用finalize方法。
  • GC算法

    • 标记-清除:效率低,产生大量的不连续的内存碎片
    • 复制算法:可使用内存变为原来的一半,当对象存活率高时,效率低。
    • 标记-整理:标记出要清除的对象,往一端移动,过程中清除对象。
    • 分代收集:新生代采用复制算法,而老年代采用标记整理算法,永久代采用标记-整理算法(JDK8之前)
    • 分区算法:将整个内存分为N个小的独立空间,每个独立空间都可以独立使用或进行GC,减少了GC的停顿时间。

5.minor GC和Full GC的触发时机

  • 对象在内存中的初始化过程:首先加载该对象所对应的class文件,然后为将要初始化的对象分配内存空间,优先在TLAB分配大小,如果空间不足,再到eden中进行内存分配.

  • Minor GC
    触发条件:当新对象生成,并且在Eden申请空间失败时,就会触发Minor GC,查找GC Roots,拷贝所引用的对象到 to 区;会发现晋升的情况,然后清理Eden 以及 Survivor from 区,并且将这些对象的年龄设置为1,以后对象在 Survivor 区每熬过一次 Minor GC,就将对象的年龄 + 1,当对象的年龄达到某个值时 ( 默认是 15 岁,可以通过参数 -XX:MaxTenuringThreshold 来设定 ),这些对象就会成为老年代。若出现较大的对象 , 则直接进入到老年代。Eden区的GC会频繁进行。一般使用复制算法,使Eden去能尽快空闲出来。

  • Full GC
    对整个堆进行整理,包括Young、Tenured和Perm。Full GC因为需要对整个堆对进行回收,所以比Minor GC要慢,尽可能减少Full GC的次数。采用的是标记-清除算法,会产生许多的内存碎片,此后需要为较大的对象分配内存空间时,若无法找到足够的连续的内存空间,就会提
    前触发一次 GC 的收集动作。

  • 可能导致Full GC的原因

    • 老年代被写满,或空间不足;永久代被写满
    • System.gc()被显示调用,底层调用Runtime.getRuntime().gc()这个本地方法
    • Minor GC晋升到老年代的平均大小大于老年代的剩余空间
    • 堆中分配很大的对象
    • CMS GC时出现promotion failed(新生代老年代都放不下)和concurrent mode failure(同时有对象要放入老年代,而此时老年代空间不足)
  • TLAB:JVM在内存新生代Eden Space中开辟了一小块线程私有的区域。默认设定为占用Eden Space的1%。小对象通常JVM会优先分配在TLAB上,(主要就是三个指针:start,top 和 end ),TLAB满后,再分配一块TLAB,不管之前的。

6.你所知道的GC收集器

  • 新生代收集器

    • serial:是一个单线程收集器,并且在它进行垃圾收集时,必须STW。采用复制算法
    • ParNew:是Serial的多线程版本,只有serial和ParNew能与CMS配合工作
    • Parallel Scavenge:为了达到一个可控吞吐量的并行收集器,不需要STW,采用复制算法
  • 老年代收集器

    • Serial Old:采用单线程、标记-整理算法
    • Parallel Old:采用并行收集、标记-整理算法。
    • CMS:以获取最短回收停顿时间为目标的并发收集器,采用的是标记-清除算法。四个步骤:初始标记(STW)、并发标记、重新标记(STW)、并发清除。缺点是对CPU资源敏感(影响系统性能)、无法处理浮动垃圾、会产生大量空间碎片。
  • G1:JDK1.7提供的一个新收集器

    • 运行步骤:开始标记(STW)、并发标记、最终标记(STW)、筛选回收。
    • 优点:并行与并发,采用“标记 - 整理”算法,避免产生碎片,可预测的停顿(衰减标准偏差),
    • 分代收集:G1将整个Java堆划分为多个大小固定的独立区域,并且跟踪这些区域,在后台维护一个优先列表,Young GC模式:回收年轻代里的Region,Mixed GC:回收老年代Region,而G1无Full GC
  • 常用参数:Xms:初始化堆大小;Xmx:最大堆大小,Xmn:新生代堆大小,Xss:栈大小,-XX:+PrintGCDetails:输出GC详细日志;UseSerialGC:使用Serial + Serial Old的收集器组合进行内存回收;UseTLAB:开启TLAB分配,而命令java –verbose:gc :输出虚拟机发生内存回收时在输出设备显示信息

7.类加载机制(字节码的编译过程)

  • 类的生命周期是分为加载、验证、准备、解析、初始化、使用、卸载七个过程

  • 加载:加载类的二进制数据。通过类名获取二进制字节流。将该流转化为方法区的运行时数据结构。在堆中生成一个代.Class对象,作为对方法区中这些数据的访问入口

  • 验证:确保加载的类信息符合JVM规范,分为文件格式的验证:是否以0xCAFEBABE开头、版本号是否合理;元数据验证:是否有父类、继承了final类;字节码验证:运行检查、栈数据类型和操作码参数吻合、跳转指令到合理的位置;符号引用验证:确保解析动作能正确执行。

  • 准备:为类变量或static变量分配内存并保存设置类变量初始值的阶段,这些值都在方法区中进行分配。一般情况(int)准备阶段为0,在初始化时才赋值,但对static final类型的数据,在准备阶段就会被赋值。

  • 解析:将虚拟机常量池内的符号引用替换为直接引用的过程

  • 初始化:是执行类构造器()方法的过程,虚拟机会保证一个类的< clinit>()方法在多线程环境中被正确加锁和同步。类初始化时机:只有当对类的主动使用的时候才会导致类的初始化。

  • 类的主动引用(一定会发生类的初始化)

    • New 一个类的对象
    • 调用类的静态成员(除了final常量)和静态方法
    • 使用java.lang.reflect包的方法对类进行反射调用
    • 当虚拟机启动时,先启动main方法所在的类
    • 若父类没有初始化则先会初始化父类
  • 类的被动引用

    • 当访问一个静态域时,通过子类引用父类的静态变量,不会导致子类的初始化
    • 通过数据定义类的引用(定义对象数组),不会触发此类的初始化,
    • 引用常量不会触发此类的初始化(常量在编译阶段就存入常量池中了)
    • 通过类名获取Class对象,不会触发类的初始化,
    • 通过ClassLoader默认的loadClass方法,也不会触发初始化动作
  • 结束生命周期的情况:执行System.exit()方法、程序正常执行结束、遇到了异常或错误而异常终止、由于操作系统出现错误而导致Java虚拟机进程终止

  • Dakvik虚拟机不能执行class文件,因为它并不是Java虚拟机,使用的是寄存器架构,但它执行的dex文件可以通过class文件转化而来。

8.双亲委派模型

  • 启动类加载器bootstrap:加载Java的核心库,是用c/c++实现的,加载扩展、应用程序类加载器,并指定它们的父类加载器 rt.jar 参数:-Xbootclasspath

  • 扩展类载器:它负责加载JDK\jre\lib\ext目录中,或者由java.ext.dirs系统变量指定的路径中的所有类库(如javax.*开头的类),开发者可以直接使用扩展类加载器

  • 应用程序类加载器:它负责加载用户类路径所指定的类,开发者可以直接使用该类加载器,

  • 自定义类加载器:通过继承java.long.Classloader类,来实现自己的类加载器。若没有被类加载器加载到命名空间,委派类加载器请求给父类加载器,加载完成返回class实例,调用本类加载器的findClass方法,获取到对应的字节码,调用defineClass方法导入类型到方法区,而loadClass方法是加载指定的类,

  • JVM类加载机制:全盘负责、父类委托、缓存机制:所有加载过的Class都会被缓存,需要时再寻找。

  • 双亲委托机制:只有父类加载器无法完成此加载任务时,自己才去加载,是代理模式的一种,父类加载器采用组合实现

  • 热替换:当一个class被替换后,系统无需重启,替换的类立即生效。OSGI:动态模块系统,有多个类加载器,谁的组件谁来加载

  • Class.forName可以进行初始化,而ClassLoader不能,只是将.class文件加载到JVM中

  • JDK命令行工具:jps:查看虚拟机进程信息、jinfo:用于查看和调整虚拟机的配置参数、jstack:虚拟机堆栈跟踪、jstat:查看虚拟机统计信息监视工具、jmap:生成Java应用程序的堆快照和对象的统计信息、 jhat:分析虚拟机转储快照信息。内存dump后的分析工具:MAT

9.Java的内存模型

  • Java内存模型中规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程使用到的变量到主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要在主内存来完成

  • 内存间交互操作

    • lock:作用于主内存的变量,把一个变量标识为一条线程独占状态。
    • unlock:作用主存,把一个处于锁定状态的变量释放出来
    • read:作用主存,把变量值从主内存传输到工作内存中,以便之后load
    • load:作用工作内存,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
    • use:作用工作内存,把工作内存中的一个变量值传递给执行引擎,
    • assign:作用工作内存,它把一个从执行引擎接收到的值赋值给工作内存的变量
    • store:作用工作内存,把工作内存中变量的值传送到主内存中,以便之后write
    • write:作用主存,它把store操作从工作内存中一个变量的值传送到主内存的变量中
    • 读写操作必须按顺序执行、lock和unlock必须成对出现、不允许read和load、store和write操作之一单独出现、对一个变量执行unlock操作之前,必须先把此变量同步到主内存中
  • 执行程序时为了提高性能,编译器和处理器经常会对指令进行重排序

    • as-if-serial:不管怎么重排序,程序的执行结果不能被改变
    • 为了保证内存的可见性,编译器在生成指令序列的适当位置会插入内存屏障指令来禁止处理器重排序
    • 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存

10.类文件结构

  • class文件主要由魔数、Class文件的版本号、常量池、访问标志、类索引(还包括父类索引和接口索引集合)、字段表集合、方法表集合、属性表集合组成。

  • 魔数:每个Class文件的头4个字节,用于确定Class文件是否能被虚拟机接受

  • 版本号:前2字节表示次版本号,后2个字节表示主版本号。从45开始,JDK8是50
  • 常量池:存放常量和符号引用。constant_pool_count:占2字节,表示常量池中有几个常量,constant_pool:表类型数据集合,即常量池中每一项常量都是一个表
  • 访问标志:用于识别类或接口的访问信息
  • 类索引、父类索引和接口索引集合:用于确定这个类的继承关系。
  • 字段表集合:用于描述接口或类中声明的变量
  • 方法表集合和属性表集合。

11.字节码执行引擎

  • 每一个栈帧都包含了局部变量表、操作数栈、动态连接、方法返回地址等

  • 局部变量表:是存放一组变量的存储空间。存放方法参数和方法内部定义的局部变量表

  • 操作数栈:用来存放操作数的栈结构

  • 动态连接:每个栈帧都包含一个指向运行时常量池的引用,持有这个引用是为了支持动态连接

  • 方法返回地址:当执行引擎遇到方法返回的字节码指令,或出现异常,则退出该方法,即把当前栈帧出栈

  • 常用字节码:Xload_n:将第几个局部变量的值压栈,Xstore_n(n为0 1 2 3):出栈,将值存入第n个局部变量;i2l表示将int转为long;方法调用:Invokevirtual;Iconst_m1 int:常量-1入栈

12.一次JVM调优的经历

  • JVM参数:-Xmn=40M,-Xmx=100M,-Xms=100M,-XX:+UseConcMarkSweepGC,-XX:CMSInitiatingOccupancyFraction=80 ,(内存碎片,默认68)-XX:+UseCMSInitiatingOccupancyOnly,

  • 代码:用ArrayList集合,list.add(byte[]=5M),出现了OOM,

  • 解决思路:通过查看GC日志,dump之后,发现这是我的低级失误,给数组分配内存太多了,但我意外的发现虽然一直在CMS GC ,但老年代一直很大的,我试着主动触发一次full gc之后老年代却下降了,我查看ArrayList扩容源码后发现,是因为扩容的System.arrayCopy方法是native且是浅拷贝,我认为复制的新数组是在新生代分配的,而通过老年代使用率达到了阈值触发的CMS GC,会把新生代里的对象作为GC ROOT的一部分,从而阻止了老年代byte数组被回收。增加了-XX:+CMSScavengeBeforeRemark参数可以解决这个问题(remark前触发YGC)。我之后查找资料验证了我的解决方法。其实主要就是新生代指向老生代的跨代引用问题。



本人才疏学浅,若有错,请指出,谢谢!
如果你有更好的建议,可以留言我们一起讨论,共同进步!
衷心的感谢您能耐心的读完本篇博文!

猜你喜欢

转载自blog.csdn.net/baiye_xing/article/details/76652995