JVM面试题+答案

JVM内存结构

线程共享:堆,方法区

线程私有:虚拟机栈,本地方法栈,程序计数器

:新生代-老年代-元空间【本地内存】

虚拟机栈:管理JAVA方法,由栈帧组成,每一次的方法调用都会创建一个栈帧,然后压栈,当方法返回的时候对应栈帧的出栈操作

栈帧的组成

A.局部变量表:用于存放方法参数和方法内部定义的局部变量, 数据包括各类基本数据类型、对象引用,以及返回值类型

B.操作数栈:当一个方法开始执行时,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者,也就是出栈/入栈操作

C.方法返回地址:当一个方法开始执行时,可能有两种方式退出该方法:

正常完成出口:如果当前方法正常完成,则根据当前方法返回的字节码指令,这时有可能会有返回值传递给方法调用者(调用它的方法),或者无返回值。具体是否有返回值以及返回值的数据类型将根据该方法返回的字节码指令确定

异常完成出口:当方法执行过程中遇到异常,并且这个异常在方法体内部没有得到处理,导致方法退出;无论是Java虚拟机抛出的异常还是代码中使用athrow指令产生的异常,只要在本方法的异常表中没有搜索到相应的异常处理器,就会导致方法退出。

总结:无论方法采用何种方式退出,在方法退出后都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在当前栈帧中保存一些信息,用来帮他恢复它的上层方法执行状态;方法退出过程实际上就等同于把当前栈帧出栈,因此退出可以执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者的操作数栈中,调整程序计数器的值以指向方法调用指令后的下一条指令;方法正常退出时,调用者的程序计数值可以作为返回地址,栈帧中可能保存此计数值。而方法异常退出时,返回地址是通过异常处理器表确定的,栈帧中一般不会保存此部分信息;

D.动态链接:在一个class文件中,一个方法要调用其他方法,需要将其他方法的符号引用转化为其它方法在内存地址中的直接引用,而符号引用存在于方法区中的运行时常量池。

Java虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所属方法的符号引用,持有这个引用的目的是为了支持方法调用过程中的动态连接(Dynamic Linking)

这些符号引用一部分会在类加载阶段或者第一次使用时就直接转化为直接引用,这类转化称为静态解析。另一部分将在每次运行期间转化为直接引用,这类转化称为动态连接。

本地方法栈:管理native方法

程序计数器:记录当前线程执行字节码的行号指示器

程序计数器原理:解释器读取装载入内存的字节码,按照顺序读取字节码指令。读取一个指令后,将该指令“翻译”成固定的操作,并根据这些操作进行分支、循环、跳转等操作

为什么需要程序计数器:JVM的多线程是通过CPU时间片轮转(即线程轮流切换并分配处理器执行时间)算法来实现的。某个线程在执行过程中可能会因为时间片耗尽而被挂起,而另一个线程获取到时间片开始执行。当被挂起的线程重新获取到时间片时,它需要从被挂起的地方继续执行,就必须知道它上次执行到哪个位置,在JVM中,通过程序计数器记录某个线程的字节码执行位置。

程序计数器是具备线程隔离的特性,每个线程工作时都有属于自己的独立计数器

程序计数器的特点:

1.线程隔离性,每个线程工作时都有属于自己的独立计数器

2.执行java方法时,程序计数器是有值的,且记录的是正在执行的字节码指令的地址

3.执行native本地方法时,程序计数器的值为空(Undefined)。因为native方法是java通过JNI直接调用本地C/C++库,可以近似的认为native方法相当于C/C++暴露给java的一个接口,java通过调用这个接口调用到C/C++方法。由于该方法是通过C/C++而不是java进行实现,因此无法生成相应的字节码,并且C/C++执行时的内存分配是由自己语言决定的,而不是由JVM决定的

4.程序计数器占用内存很小,在进行JVM内存计算时,可以忽略不计

5.程序计数器,是唯一一个在java虚拟机规范中没有规定任何OutOfMemoryError的区域

方法区:由堆中的静态变量,字符串常量池和元空间中的类信息,运行时常量池组成

类信息的组成:类的版本,字段描述信息,方法描述信息,接口和父类等描述信息,class文件常量池(静态常量池)

静态常量池【class文件常量池】包含

字面量:文本字符串,final修饰的常量

符号引用:类和接口的全限定名【绝对路径/包名+类名】,字段的名称和描述符,方法的名称和描述符

运行时常量池

当类加载到内存中后,JVM就会将静态常量池中的内容存放到运行时的常量池中;运行时常量池里面存储的主要是编译期间生成的字面量,符号引用等等

字符串常量池【可以理解成运行时常量池分出来的一部分】:

类加载到内存的时候,字符串会存到字符串常量池里面

JVM为什么用元空间代替永久代?

为了防止内存溢出和内存浪费

类加载过程详解

具体过程:.java->.class->加载->链接->初始化->使用->卸载

加载-过程:

第一步:读取类的二进制流

第二步:将二进制流转为方法区的数据结构,并存放到方法区中

第三步:在JAVA堆中产生java.lang.Class对象

链接【验证,准备,解析】

验证-作用:验证class文件是否符合规范

A.文件格式的验证:是否以0xCAFEBABE开头;版本号是否合理

B.元数据验证:是否有父类;是否继承了final类(final类不能被继承);非抽象类实现了所有抽象方法

C.字节码验证:运行检查;栈数据类型,操作码和操作参数是否吻合【比如栈空间只有2字节,但实际用的空间大于2字节,此时就认为这个字节码有问题】;跳转指令是否指向合理的位置

D.符号引用验证:常量池中的描述类是否存在;询问的方法或字段是否存在且有足够的权限【可以使用-Xverify:none关闭验证】

准备-作用:为类的静态变量分配内存,初始化为系统的初始值

Final static修饰的变量:直接赋值为用户定义的值,比如private final static int value = 123,直接赋值为123

Private static int value = 123,该阶段的值依然是0

解析-作用:符号引用转换成直接引用(注:解析不一定在初始化前,也可能在初始化后发生)

符号引用:以一组符号来描述要引用的目标;使用符号引用的时候,引用的目标可能不一定在JVM内存中

直接引用:

A.直接指向目标的指针(比如,指向“类型”【Class对象】、类变量、类方法的直接引用可能是指向方法区的指针)

B.相对偏移量(比如,指向实例变量、实例方法的直接引用都是偏移量)

C.一个能间接定位到目标的句柄

使用直接引用的时候,引用的目标必须已经加载到JVM内存中

初始化:

第一步:执行<clinit>方法,clinit方法由编译器自动收集类里面的所有静态变量的赋值动作及静态语句块合并而成,也叫类构造器方法

clinit方法执行的顺序和源文件中的顺序一致

子类的<clinit>被调用前,会先调用父类的<clinit>

JVM会保证clinit方法的线程安全性

第二步:初始化时,如果实例化一个新对象,会调用<init>方法对实例变量进行初始化,并执行对应的构造方法内的代码【先执行代码块,再执行初始化方法】

编译器优化

字节码是如何运行的?

A.解释执行:由解释器一行一行翻译执行

优点:没有编译的等待时间

缺点:性能相对差一些【需要一行一行的翻译】

B.编译执行:把字节码编译成机器码,直接执行机器码

优点:运行效率较高,一般比解释执行快一个数量级

缺点:带来了额外的开销【额外的内存开销,额外的CPU开销】

查询运行模式:Java -version【默认是混合模式】

设置JVM的执行模式为解释执行模式:java -Xint -version

JVM优先以编译模式运行,不能编译的,以解释模式运行:java -Xcomp -version

混合模式运行:-Xmixed

一般情况下,刚开始都是由解释器解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁的时候,就会认为这些代码是“热点代码”。为了提高热点代码的执行效率,会用即时编译器【JIT】把这些热点代码编译成与本地平台相关的机器码,并进行各层次的优化

hotspot虚拟机-JAVA应用最广的虚拟机

即时编译器-C1:【Client Compiler】

C1编译器是一个简单快速的编译器,主要关注局部性的优化,适用于执行时间较短或对启动性能有要求的程序【例如:GUI应用对界面启动速度就有一定要求】

即时编译器-C2:【Server Compiler】

C2编译器是为长期运行的服务器端应用程序做性能调优的编译器,适用于执行时间较长或对峰值性能有要求的程序

各层次的优化:JDK7正式引入分层编译的概念

分层编译的级别:【级别越高,应用启动会越慢,优化的开销会越高,峰值性能也会越高】

0:解释执行

1:简单C1编译【会用C1编译器进行一些简单的优化,不开启Profiling(JVM性能监控)】

2:受限的C1编译【仅会执行带有方法调用次数以及循环回边执行次数Profiling的C1编译】

3:完全C1编译【会执行所有带有Profiling的C1代码】

4:C2编译【使用C2编译器进行优化,该级别会启用一些编译耗时较长的优化,一些情况下会根据性能监控信息进行一些非常激进的性能优化】

JVM默认开启分层编译

只开启C2:-XX:-TieredCompilation(禁用中间编译层1,2,3层)

只开启C1:-XX:+TieredCompilation -XX:TieredStopAtLevel=1(如果设置成3,则只是用0,1,2)

如何找到热点代码?思路?

A.基于采样的热点探测【周期性检查各个线程的栈顶,如果发现某些方法总是出现在栈顶,说明这个方法是热点方法】

B.基于计数器的热点探测【hotspot使用的是基于计数器的热点探测】

Hotspot内置的两类计数器

A.方法调用计数器:

用于统计方法被调用的次数,在不开启分层编译的情况下,C1编译器的默认阈值是1500次,C2编译器的默认阈值是10000次;可以使用-XX:CompileThreshold=指定阈值

B.回边计数器:用于统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为回边,C1编译器的默认阈值是13995,C2编译器的默认阈值是10700,可使用-XX:OnStackReplacePercentage=指定阈值

建立回边计数器的主要目的是为了触发OSR(OnStackReplacement)编译【ORS是一种在运行时替换正在运行的函数/方法栈帧的技术】

当开启分层编译时,JVM会根据当前待编译的方法数以及编译线程数来动态调整阈值,-XX:CompileThreshold、-XX:OnStackReplacePercentage都会

剩余60%内容,订阅专栏后可继续查看/也可单篇购买

Java之项目解析+八股文 文章被收录于专栏

针对Java简历中项目的功能进行提问,大家可以在评论区中解答/讨论;同时提供八股文

全部评论
实用jvm知识全在了
1 回复
分享
发布于 02-27 19:51 浙江

相关推荐

4 14 评论
分享
牛客网
牛客企业服务