【通用】Java 内存及其优化面试题 - 看这一篇就够了 II

Author: Xcourse   2023-Mar-15 20:43   Reads: 398

欢迎加入微信工作内部分享群,每天发布新的精选高薪工作。

官方邮箱:enquiry@xcourse.sg

微信分享群:@新加坡工作内部分享群

WhatsApp群:@Singapore Jobs & Internships

Telegram中文群:@新加坡工作内部分享群

Telegram英文群:@Singapore Jobs

------------------------------------------------------------------------------------------------------

 

(上一篇:Java 内存及其优化面试题 I - Classloader, Runtime, Generic, GC)

 

26. 谈谈你对内存分配的理解?大对象怎么分配?空间分配担保?       

  1. 对象优先在 Eden 区分配:大多数情况下,对象在新生代 Eden 区分配,当 Eden 区空间不够时,发起 Minor GC。
  2. 大对象直接进入老年代:大对象是指需要连续内存空间的对象,最典型的大对象是那种很长的字符串以及数组。经常出现大对象会提前触发垃圾收集以获取足够的连续空间分配给大对象。-XX:PretenureSizeThreshold,大于此值的对象直接在老年代分配,避免在 Eden 区和 Survivor 区之间的大量内存复制。
  3. 长期存活的对象将进入老年代:为对象定义年龄计数器,对象在 Eden 出生并经过 Minor GC 依然存活,将移动到 Survivor 中,年龄就增加 1 岁,增加到一定年龄则移动到老年代中。-XX:MaxTenuringThreshold 用来定义年龄的阈值。
  4. 动态对象年龄判定:为了更好的适应不同程序的内存情况,虚拟机不是永远要求对象年龄必须达到了某个值才能进入老年代,如果 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需达到要求的年龄。
  5. 空间分配担保:

(1)在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的;

(2)如果不成立的话,虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那么就要进行一次 Full GC。

 

27. 说下你用过的 JVM 监控工具?

  1. jvisualvm:虚拟机监视和故障处理平台
  2. jps :查看当前 Java 进程
  3. jstat:显示虚拟机运行数据
  4. jmap:内存监控
  5. jhat:分析 heapdump 文件
  6. jstack:线程快照
  7. jinfo:虚拟机配置信息

 

28. 如何利用监控工具调优?

1、堆信息查看

  • 可查看堆空间大小分配(年轻代、年老代、持久代分配)
  • 提供即时的垃圾回收功能
  • 垃圾监控(长时间监控回收情况)
  • 查看堆内类、对象信息查看:数量、类型等
  • 对象引用情况查看

有了堆信息查看方面的功能,我们一般可以顺利解决以下问题:

  • 年老代年轻代大小划分是否合理
  • 内存泄漏
  • 垃圾回收算法设置是否合理

2、线程监控

  • 线程信息监控:系统线程数量
  • 线程状态监控:各个线程都处在什么样的状态下
  • Dump 线程详细信息:查看线程内部运行情况
  • 死锁检查

3、 热点分析

  • CPU 热点:检查系统哪些方法占用的大量 CPU 时间;
  • 内存热点:检查哪些对象在系统中数量最大(一定时间内存活对象和销毁对象一起统计)这两个东西对于系统优化很有帮助。我们可以根据找到的热点,有针对性的进行系统的瓶颈查找和进行系统优化,而不是漫无目的的进行所有代码的优化。

4、快照

快照是系统运行到某一时刻的一个定格。在我们进行调优的时候,不可能用眼睛去跟踪所有系统变化,依赖快照功能,我们就可以进行系统两个不同运行时刻,对象(或类、线程等)的不同,以便快速找到问题。

举例说,我要检查系统进行垃圾回收以后,是否还有该收回的对象被遗漏下来的了。那么,我可以在进行垃圾回收前后,分别进行一次堆情况的快照,然后对比两次快照的对象情况。

5、内存泄露检查

内存泄漏是比较常见的问题,而且解决方法也比较通用,这里可以重点说一下,而线程、热点方面的问题则是具体问题具体分析了。

内存泄漏一般可以理解为系统资源(各方面的资源,堆、栈、线程等)在错误使用的情况下,导致使用完毕的资源无法回收(或没有回收),从而导致新的资源分配请求无法完成,引起系统错误。内存泄漏对系统危害比较大,因为它可以直接导致系统的崩溃。

 

29. JVM 的一些参数?       

1. 堆设置

  • -Xms:初始堆大小
  • -Xmx:最大堆大小
  • -XX:NewSize=n:设置年轻代大小
  • -XX:NewRatio=n:设置年轻代和年老代的比值。如:为3,表示年轻代与年老代比值为 1:3,年轻代占整个年轻代年老代和的 1/4
  • -XX:SurvivorRatio=n:年轻代中 Eden 区与两个 Survivor 区的比值。注意 Survivor 区有两个。如:3,表示 Eden:Survivor=3:2,一个Survivor区占整个年轻代的 1/5
  • -XX:MaxPermSize=n:设置持久代大小

2. 收集器设置

  • -XX:+UseSerialGC:设置串行收集器
  • -XX:+UseParallelGC:设置并行收集器
  • -XX:+UseParalledlOldGC:设置并行年老代收集器
  • -XX:+UseConcMarkSweepGC:设置并发收集器

3. 垃圾回收统计信息

  • -XX:+PrintGC:开启打印 gc 信息
  • -XX:+PrintGCDetails:打印 gc 详细信息
  • -XX:+PrintGCTimeStamps
  • -Xloggc:filename

4. 并行收集器设置

  • -XX:ParallelGCThreads=n:设置并行收集器收集时使用的 CPU 数
  • -XX:MaxGCPauseMillis=n:设置并行收集最大暂停时间
  • -XX:GCTimeRatio=n:设置垃圾回收时间占程序运行时间的百分比

5. 并发收集器设置

  • -XX:+CMSIncrementalMode:设置为增量模式。适用于单 CPU 情况
  • -XX:ParallelGCThreads=n:设置并发收集器年轻代收集方式为并行收集时,使用的 CPU 数。并行收集线程数

 

30. 谈谈你对类文件结构的理解?有哪些部分组成?

Class 文件结构如下标所示:

图片

Class 文件没有任何分隔符,严格按照上面结构表中的顺序排列。无论是顺序还是数量,甚至于数据存储的字节序这样的细节,都是被严格限定的,哪个字节代表什么含义,长度是多少,先后顺序如何,都不允许改变。

  1. 魔数(magic):每个 Class 文件的头 4 个字节称为魔数(Magic  Number),它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class 文件,即判断这个文件是否符合 Class 文件规范。
  2. 文件的版本:minor_version 和 major_version。
  3. 常量池:constant_pool_count 和 constant_pool:常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic  References)。
  4. 访问标志:access_flags:用于识别一些类或者接口层次的访问信息。包括:这个 Class 是类还是接口、是否定义了 Public 类型、是否定义为 abstract 类型、如果是类,是否被声明为了 final 等等。
  5. 类索引、父类索引与接口索引集合:this_class、super_class和interfaces。
  6. 字段表集合:field_info、fields_count:字段表(field_info)用于描述接口或者类中声明的变量;fields_count 字段数目:表示Class文件的类和实例变量总数。
  7. 方法表集合:methods、methods_count
  8. 属性表集合:attributes、attributes_count

 

31. 谈谈你对类加载机制的了解?

虚拟机把描述类的数据从 Class 文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的 Java 类型,这就是虚拟机的类加载机制。

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用、卸载 7 个阶段。其中验证、准备、解析 3 个部分统称为连接,这7个阶段发生的顺序如下图所示:

image-20210604000832020

 

32. 类加载各阶段的作用分别是什么?       

1. 加载

在加载阶段,虚拟机需要完成以下三件事情:

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

2. 验证

主要是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。验证阶段大致上分为 4 个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。

  • 文件格式校验:验证字节流是否符合 class 文件的规范,并且能被当前版本的虚拟机处理。只有通过这个阶段的验证后,字节流才会进入内存的方法区进行存储,所以后面的3个阶段的全部是基于方法区的存储结构进行的,不会再直接操作字节流;
  • 元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合 Java 语言规范的要求。目的是保证不存在不符合 Java 语言规范的元数据信息;
  • 字节码验证:该阶段主要工作是进行数据流和控制流分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为;
  • 符号引用验证:最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三个阶段——解析阶段中发生。符号引用验证的目的是确保解析动作能正常执行。

3. 准备

  • 准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配**。这时候进行内存分配的仅包括类变量(被 static 修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。实例化不是类加载的一个过程,类加载发生在所有实例化操作之前,并且类加载只进行一次,实例化可以进行多次。
  • 初始值是默认值 0 或 false 或 null。如果类变量是常量(final),那么会按照表达式来进行初始化,而不是赋值为 0。public static final int value = 123;

4. 解析

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

5. 初始化

  • 在准备阶段,变量已经赋过一次系统要求的初始值了,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,或者可以从另外一个角度来表达:初始化阶段是执行类构造器 () 方法的过程。

 

33. 有哪些类加载器?分别有什么作用?       

  1. 启动类加载器(Bootstrap  ClassLoader):这个类加载器是由 C++ 语言实现的,是虚拟机自身的一部分。负责将存在 \lib 目录中的,或者被 -Xbootclasspath 参数所指定的路径中的类库加载到虚拟机内存中。启动内加载器无法被 Java 程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给启动类加载器,直接使用 null 即可;
  2. 其他类加载器:由 Java 语言实现,独立于虚拟机外部,并且全都继承自抽象类 java.lang.ClassLoader。如扩展类加载器和应用程序类加载器:
    1. 扩展类加载器(Extension  ClassLoader):这个类加载器由sun.misc.Launcher$ExtClassLoader 实现,它负责加载\lib\ext目录中的,或者被 java.ext.dirs 系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
    2. 应用程序类加载器 (Application  ClassLoader):这个类加载器由 sun.misc.Launcher$AppClassLoder 实现。由于个类加载器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,所以一般也称之为系统类加载器。它负责加载用户路径(ClassPath)所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

 

34. 类与类加载器的关系?

类加载器虽然只用于实现类的加载动作,但它在 Java 程序中起到的作用却远远不限于类加载阶段。对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,每个类加载器,都拥有一个独立的类名称空间。换句话说:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那么这两个类就必定不相等。

 

35. 谈谈你对双亲委派模型的理解?工作过程?为什么要使用

应用程序一般是由上诉的三种类加载器相互配合进行加载的,如果有必要,还可以加入自己定义的类加载器,它们的关系如下图所示:

image-20210604001108695
  • 双亲委派模型的工作过程:

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

  • 使用双亲委派模型的好处:

Java 类随着它的类加载器一起具备了一种带有优先级的层次关系。例如:类 java.lang.Object,它存放在 rt.jar 中,无论哪一个类加载器需要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此 Object 类在程序的各种类加载器环境中都是同一个类(使用的是同一个类加载器加载的)。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个 java.lang.Object 类,并放在程序的 ClassPath 中,那么系统将会出现多个不同的 Object 类,Java 类型体系中最基础的行为也就无法保证,应用程序也将变得一片混乱。

  • 双亲委派模型的主要代码实现:

实现双亲委派的代码都集中在 java.lang.ClassLoader 的 loadClass() 方法中,逻辑清晰易懂:先检查是否已经被加载过,若没有加载则调用父加载器的 loadClass() 方法,若父加载器为空则默认使用启动类加载器作为父类加载器。如果父类加载失败,抛出 ClassNotFoundException 异常后,再调用自己的 findClass() 方法进行加载。

 

36. 怎么实现一个自定义的类加载器?需要注意什么?

若要实现自定义类加载器,只需要继承 java.lang.ClassLoader 类,并且重写其 findClass() 方法即可。

 

37. 怎么打破双亲委派模型?       

  1. 自己写一个类加载器;
  2. 重写 loadClass() 方法
  3. 重写 findClass() 方法

这里最主要的是重写 loadClass 方法,因为双亲委派机制的实现都是通过这个方法实现的,先找父加载器进行加载,如果父加载器无法加载再由自己来进行加载,源码里会直接找到根加载器,重写了这个方法以后就能自己定义加载的方式了。

 

38. 有哪些实际场景是需要打破双亲委派模型的?

JNDI 服务,它的代码由启动类加载器去加载,但 JNDI 的目的就是对资源进行集中管理和查找,它需要调用独立厂商实现并部署在应用程序的 classpath 下的 JNDI 接口提供者(SPI, Service Provider Interface) 的代码,但启动类加载器不可能“认识”之些代码,该怎么办?

为了解决这个困境,Java 设计团队只好引入了一个不太优雅的设计:**线程上下文类加载器(Thread Context ClassLoader)。这个类加载器可以通过 java.lang.Thread 类的 setContextClassLoader() 方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个;如果在应用程序的全局范围内都没有设置过,那么这个类加载器默认就是应用程序类加载器。有了线程上下文类加载器,JNDI 服务使用这个线程上下文类加载器去加载所需要的 SPI 代码,也就是父类加载器请求子类加载器去完成类加载动作,这种行为实际上就是打通了双亲委派模型的层次结构来逆向使用类加载器,已经违背了双亲委派模型,但这也是无可奈何的事情。Java 中所有涉及 SPI 的加载动作基本上都采用这种方式,例如 JNDI、JDBC、JCE、JAXB 和 JBI 等。

 

39. 谈谈你对编译期优化和运行期优化的理解?

 1、编译期优化:

  • 解析与填充符号表的过程
  • 插入式注解处理器的注解处理过程
  • 分析与字节码生成过程

2、编译优化:

  • 方法内联
  • 公共子表达式消除
  • 数组范围检查消除
  • 逃逸分析

 

40. 为何 HotSpot 虚拟机要使用解释器与编译器并存的架构?

解释器:程序可以迅速启动和执行,消耗内存小 (类似人工,成本低,到后期效率低);

编译器:随着代码频繁执行会将代码编译成本地机器码 (类似机器,成本高,到后期效率高)。

在整个虚拟机执行架构中,解释器与编译器经常配合工作,两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。当程序运行环境中内存资源限制较大(如部分嵌入式系统),可以使用解释执行节约内存,反之可以使用编译执行来提升效率。

解释执行可以节约内存,而编译执行可以提升效率。因此,在整个虚拟机执行架构中,解释器与编译器经常配合工作。

 

41. 内存间的交互操作有哪些?需要满足什么规则?

关于主内存与工作内存之间的具体的交互协议,即:一个变量如何从主内存拷贝到工作内存、如何从工作内存同步主内存之类的实现细节,Java内存模型中定义一下八种操作来完成

  1. lock(锁定):作用于主内存的变量。它把一个变量标志为一个线程独占的状态;
  2. unlock(解锁):作用于主内存的变量,它把处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定;
  3. read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用;
  4. load(载入):作用于工作内存的变量,它把read操作从主内存中得到变量值放入工作内存的变量的副本中;
  5. use(使用):作用于工作内存的变量, 它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作;
  6. assign(赋值):作用于工作内存的变量。它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到需要给一个变量赋值的字节码时执行这个操作;
  7. store(存储):作用于工作内存的变量。它把一个工作内存中一个变量的值传递到主内存中,以便随后的write操作使用;
  8. write(写入):作用于主内存的变量。它把store操作从工作内存中得到的变量的值放入主内存的变量中。

如果要把一个变量从主存内存复制到工作内存,那就要按顺序执行 read 和 load 操作,如果要把变量从工作内存同步回主内存,就要按顺序执行 store 和 write 操作。

上诉 8 种基本操作必须满足的规则:

  1. 不允许 read 和 load、store 和 write 操作之一单独出现;
  2. 不允许一个线程丢弃它的最近的 assign 操作,即变量在工作内存中改变之后必须把该变化同步回主内存;
  3. 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中;
  4. 一个新的变量只能在主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化(load 或 assign)的变量,换句话说就是对一个变量实施 use 和 store 操作之前,必须执行过了 assign 和 load 操作;
  5. 一个变量在同一时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock,变量才会被解锁;
  6. 如果对一个变量执行 lock 操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 load 或 assign 操作初始化变量的值;
  7. 如果一个变量事先没有被 lock 操作锁定,则不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定主的变量;
  8. 对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 store 和 write 操作)。

Tags: interview java jvm

Topics: 面经