5.2 JVM常见问题及面试题_jvm相关面试题

1 介绍一下强引用、软引用、弱引用和虚引用


强引用

我们平时new了一个对象就是强引用,例如 Object obj = new Object();即使在内存不足的情况下,JVM宁愿抛出OutOfMemory错误也不会回收这种对象。


软引用

如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。

SoftReference<String> softRef=new SoftReference<String>(str); // 软引用

用处: 软引用在实际中有重要的应用,例如浏览器的后退按钮。按后退时,这个后退时显示的网页内容是重新进行请求还是从缓存中取出呢?这就要看具体的实现策略了。

(1)如果一个网页在浏览结束时就进行内容的回收,则按后退查看前面浏览过的页面时,需要重新构

(2)如果将浏览过的网页存储到内存中会造成内存的大量浪费,甚至会造成内存溢出

如下代码:

Browser prev = new Browser(); // 获取页面进行浏览

SoftReference sr = new SoftReference(prev); // 浏览完毕后置为软引用

if(sr.get()!=null){

rev = (Browser) sr.get(); // 还没有被回收器回收,直接获取

}else{

prev = new Browser(); // 由于内存吃紧,所以对软引用的对象回收了

sr = new SoftReference(prev); // 重新构建

}

3)弱引用


具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。

String str=new String("abc");

WeakReference<String> abcWeakRef = new WeakReference<String>(str);

str=null;

等价于

str = null;

System.gc();

4)虚引用


如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。虚引用主要用来跟踪对象被垃圾回收器回收的活动。


2 JVM内存为什么要分成新生代,老年代,永久代。新生代中为什么要分为Eden和Survivor。


先讲一下JAVA堆,新生代的划分,再谈谈它们之间的转化,相互之间一些参数的配置(如: –XX:NewRatio,–XX:SurvivorRatio等),再解释为什么要这样划分,最好加一点自己的理解。


这样划分的目的是为了使 JVM 能够更好的管理堆内存中的对象,包括内存的分配以及回收。

1)共享内存区划分

共享内存区 = 永久代 + 堆永久代 = 方法区 + 其他


Java堆 = 老年代 + 新生代


新生代 = Eden + S0 + S1


2)一些参数的配置


默认的,新生代 ( Young ) 与老年代 ( Old ) 的比例的值为 1:2 ,可以通过参数 –XX:NewRatio 配置。


默认的,Eden : from : to = 8 : 1 : 1 ( 可以通过参数 –XX:SurvivorRatio 来设定)

Survivor区中的对象被复制次数为15(对应虚拟机参数 -XX:+MaxTenuringThreshold)


3)为什么要分为Eden和Survivor?为什么要设置两个Survivor区?


如果没有Survivor,Eden区每进行一次Minor GC,存活的对象就会被送到老年代。老年代很快被填满,触发Major GC.老年代的内存空间远大于新生代,进行一次Full GC消耗的时间比Minor GC长得多,所以需要分为Eden和Survivor。


Survivor的存在意义,就是减少被送到老年代的对象,进而减少Full GC的发生,Survivor的预筛选保证,只有经历16次Minor GC还能在新生代中存活的对象,才会被送到老年代。


设置两个Survivor区最大的好处就是解决了碎片化,刚刚新建的对象在Eden中,经历一次MinorGC,Eden中的存活对象就会被移动到第一块survivor space S0,Eden被清空;等Eden区再满了,就再触发一次Minor GC,Eden和S0中的存活对象又会被复制送入第二块survivor space


S1(这个过程非常重要,因为这种复制算法保证了S1中来自S0和Eden两部分的存活对象占用连续的内存空间,避免了碎片化的发生)



3 聊一聊为什么java8使用元空间


java8之前使用永久代,内存受JVM管理。而java8使用元空间是在本地内存中并不是JVM管理。因此元空间的大小受本地内存的大小限制,并不受JVM的限制。在一些复杂业务场景下永久代的内存的大小设置很难界定,所以java8使用了元空间


4 虚拟机类加载机制


java类加载

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


JVM加载Class文件的原理机制


Java中的所有类,都需要由类加载器装载到JVM中才能运行。类加载器本身也是一个类,而它的工作就是把class文件从硬盘读取到内存中。在写程序的时候,我们几乎不需要关心类的加载,因为这些都是隐式装载的,除非我们有特殊的用法,像是反射,就需要显式的加载所需要的类。


类装载方式,有两种 :

1.隐式装载, 程序在运行过程中当碰到通过new 等方式生成对象时,隐式调用类装载器加载对应的类到jvm中,

2.显式装载, 通过class.forname()等方法,显式加载需要的类

Java类的加载是动态的,它并不会一次性将所有类全部加载后再运行,而是保证程序运行的基础类(像是基类)完全加载到jvm中,至于其他类,则在需要的时候才加载。这当然就是为了节省内存开销。


JVM 中类的装载是由类加载器(ClassLoader)和它的子类来实现的,Java 中的类加载器是一个重要的Java 运行时系统组件,它负责在运行时查找和装入类文件中的类。

由于 Java 的跨平台性,经过编译的 Java 源程序并不是一个可执行程序,而是一个或多个类文件。当Java 程序需要使用某个类时,JVM 会确保这个类已经被加载、连接(验证、准备和解析)和初始化。类的加载是指把类的.class 文件中的数据读入到内存中,通常是创建一个字节数组读入.class 文件,然后产生与所加载类对应的 Class 对象。

加载完成后,Class 对象还不完整,所以此时的类还不可用。当类被加载后就进入连接阶段,这一阶段包括验证、准备(为静态变量分配内存并设置默认的初始值)和解析(将符号引用替换为直接引用)三个步骤。最后 JVM 对类进行初始化,包括:1)如果类存在直接的父类并且这个类还没有被初始化,那么就先初始化父类;2)


如果类中存在初始化语句,就依次执行这些初始化语句。类的加载是由类加载器完成的,类加载器包括:根加载器(BootStrap)、扩展加载器(Extension)、系统加载器(System)和用户自定义类加载器(java.lang.ClassLoader 的子类)。


从 Java 2(JDK 1.2)开始,类加载过程采取了父亲委托机制(PDM)。PDM 更好的保证了 Java 平台的安全性,在该机制中,JVM 自带的Bootstrap 是根加载器,其他的加载器都有且仅有一个父类加载器。类的加载首先请求父类加载器加载,父类加载器无能为力时才由其子类加载器自行加载。JVM 不会向 Java 程序提供对 Bootstrap 的引用。下面是关于几个类

加载器的说明:

1. Bootstrap:一般用本地代码实现,负责加载 JVM 基础核心类库(rt.jar);

2. Extension:从 java.ext.dirs 系统属性所指定的目录中加载类库,它的父加载器是 Bootstrap;

3. System:又叫应用类加载器,其父类是 Extension。它是应用最广泛的类加载器。它从环境变量

classpath 或者系统属性

java.class.path 所指定的目录中记载类,是用户自定义加载器的默认父加载器。


JVM类装载


JVM 类加载机制分为五个部分:加载,验证,准备,解析,初始化,下面我们就分别来看一下这五个过程。

  1. 加载

加载是类加载过程中的一个阶段, 这个阶段会在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的入口。注意这里不一定非得要从一个 Class 文件获取,这里既可以从ZIP 包中读取(比如从 jar 包和 war 包中读取),也可以在运行时计算生成(动态代理),也可以由其它文件生成(比如将 JSP 文件转换成对应的 Class 类)

  1. 验证

这一阶段的主要目的是为了确保 Class 文件的字节流中包含的信息是否符合当前虚拟机的要求,并且不会危害虚拟机自身的安全

  1. 准备

准备阶段是正式为类变量分配内存并设置类变量的初始值阶段,即在方法区中分配这些变量所使用的内存空间。注意这里所说的初始值概念,比如一个类变量定义为:

实际上变量 v 在准备阶段过后的初始值为 0 而不是 8080, 将 v 赋值为 8080 的 put static 指令是程序被编译后, 存放于类构造器方法之中。

但是注意如果声明为:public static final int v = 8080;

在编译阶段会为 v 生成 ConstantValue 属性,在准备阶段虚拟机会根据 ConstantValue 属性将 v赋值为 8080。

  1. 解析

解析阶段是指虚拟机将常量池中的符号引用替换为直接引用的过程。符号引用就是 class 文件中的:

public static int v = 8080;

实际上变量 v 在准备阶段过后的初始值为 0 而不是 8080, 将 v 赋值为 8080 的 put static 指令是程序被编译后, 存放于类构造器方法之中。但是注意如果声明为:

在编译阶段会为 v 生成 ConstantValue 属性,在准备阶段虚拟机会根据 ConstantValue 属性将 v赋值为 8080。解析

解析阶段是指虚拟机将常量池中的符号引用替换为直接引用的过程。符号引用就是 class 文件中的:

public static final int v = 8080;

在编译阶段会为 v 生成 ConstantValue 属性,在准备阶段虚拟机会根据 ConstantValue 属性将 v赋值为 8080。

解析

解析阶段是指虚拟机将常量池中的符号引用替换为直接引用的过程。符号引用就是 class 文件中的:

1. CONSTANT_Class_info

2. CONSTANT_Field_info

3. CONSTANT_Method_info

等类型的常量。

符号引用

符号引用与虚拟机实现的布局无关, 引用的目标并不一定要已经加载到内存中。 各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须是一致的,因为符号引用的字面量形式明确定义在 Java 虚拟机规范的 Class 文件格式中


直接引用

直接引用可以是指向目标的指针,相对偏移量或是一个能间接定位到目标的句柄。如果有了直接引用,那引用的目标必定已经在内存中存在


  1. 初始化

初始化阶段是类加载最后一个阶段,前面的类加载阶段之后,除了在加载阶段可以自定义类加载器以外,其它操作都由 JVM 主导。到了初始阶段,才开始真正执行类中定义的 Java 程序代码


类构造器


初始化阶段是执行类构造器方法的过程。 方法是由编译器自动收集类中的类变量的赋值操作和静态语句块中的语句合并而成的。虚拟机会保证子方法执行之前,父类的方法已经执行完毕, 如果一个类中没有

对静态变量赋值也没有静态语句块,那么编译器可以不为这个类生成() 方法。注意以下几种情况不会执行类初始化:

1. 通过子类引用父类的静态字段,只会触发父类的初始化,而不会触发子类的初始化。

2. 定义对象数组,不会触发该类的初始化。

3. 常量在编译期间会存入调用类的常量池中,本质上并没有直接引用定义常量的类,不会触发定义常

量所在的类。

4. 通过类名获取 Class 对象,不会触发类的初始化。

5. 通过 Class.forName 加载指定类时,如果指定参数 initialize 为 false 时,也不会触发类初始化,

其实这个参数是告诉虚拟机,是否要对类进行初始化



5 什么是Java虚拟机?为什么Java被称作是“平台无关的编程语言”?


Java虚拟机是一个可以执行Java字节码的虚拟机进程。Java源文件被编译成能被Java虚拟机执行的字节码文件。 Java被设计成允许应用程序可以运行在任意的平台,而不需要程序员为每一个平台单独重写或者是重新编译。Java虚拟机让这个变为可能,因为它知道底层硬件平台的 指令长度和其他特性。


6 JVM调优


JVM可配置参数已经达到1000多个,其中GC和内存配置相关的JVM参数就有600多个。但在绝大部分业务场景下,常用的JVM配置参数也就10来个。如下:

# JVM启动参数不换行

# 设置堆内存

-Xmx4g -Xms4g

# 指定GC算法

-XX:+UseG1GC -XX:MaxGCPauseMillis=50

# 指定GC并行线程数

-XX:ParallelGCThreads=4

# 打印GC日志

-XX:+PrintGCDetails -XX:+PrintGCDateStamps

# 指定GC日志文件

-Xloggc:gc.log

# 指定Meta区的最大值

-XX:MaxMetaspaceSize=2g

# 设置单个线程栈的大小

-Xss1m

# 指定堆内存溢出时自动进行Dump

-XX:+HeapDumpOnOutOfMemoryError

-XX:HeapDumpPath=/usr/local/

# 指定默认的连接超时时间

-Dsun.net.client.defaultConnectTimeout=2000

-Dsun.net.client.defaultReadTimeout=2000

# 指定时区

-Duser.timezone=GMT+08

# 设置默认的文件编码为UTF-8

-Dfile.encoding=UTF-8

# 指定随机数熵源(Entropy Source)

-Djava.security.egd=file:/dev/./urandom


7 OOM后JVM一定会退出吗


在Java中,发生了OutOfMemoryError(OOM)不一定会导致整个JVM退出。是否退出取决于发生OOM错误的线程和错误处理逻辑。这是一个复杂的问题,具体行为会因应用程序实现方式、错误发生的情境以及错误处理策略而异。


1. 主线程中未处理的OOM:

如果在主线程中发生OOM且没有被捕获,JVM通常会终止程序并退出。这是因为VM中没有其他存活的非守护线程来保持程序运行。


2. 子线程中未处理的OOM:

在非主线程中,如果OOM发生且未被捕获,该线程会停止执行。但如果其他非守护线程仍在运行,JVM不会退出。


3. 捕获并处理OOM:

如果在代码中捕获并正确处理了OOM错误,JVM则可以继续执行其余的程序代码。合适的错误处理可能包括释放内存资源或提示用户进行适当的操作。



public class OOMExample {

public static void main(String[] args) throws InterruptedException{

// 子线程中发生OOM并及时处理

Thread thread1 = new Thread(() -> {

try {

oomMethod();

} catch (OutOfMemoryError e) {

System.out.println("Handled OOM in thread. JVM will not exit.");

}

});


// 子线程中发生OOM不处理

Thread thread2 = new Thread(() -> {

oomMethod();

});


thread1.start();

Thread.sleep(3000);


thread2.start();

Thread.sleep(3000);

// 主线程中发生OOM并且及时处理,JVM不会退出

try {

oomMethod();

} catch (OutOfMemoryError e) {

System.out.println("Handled OOM in main. JVM will continue.");

}


// 主线程中发生OOM而未处理,JVM会马上退出

System.out.println("JVM退出前");

oomMethod();

System.out.println("JVM退出后");

}


public static void oomMethod() {

int[] array = new int[Integer.MAX_VALUE]; // 试图分配过大的数组,触发OOM

}

}


输出结果:

Handled OOM in thread. JVM will not exit.

Exception in thread "Thread-1" java.lang.OutOfMemoryError: Requested array size exceeds VM limit

at com.zhuge.controller.StockController.oomMethod(StockController.java:120)

at com.zhuge.controller.StockController.lambda$main$1(StockController.java:98)

at com.zhuge.controller.StockController$Lambda$2/648129364.run(Unknown Source)

at java.lang.Thread.run(Thread.java:745)

Handled OOM in main. JVM will continue.

JVM退出前

Exception in thread "main" java.lang.OutOfMemoryError: Requested array size exceeds VM limit

at com.zhuge.controller.StockController.oomMethod(StockController.java:120)

at com.zhuge.controller.StockController.main(StockController.java:115)


在主线程中,如果未捕获的OOM发生,程序将立即终止。

在线程thread中,我们捕获了OOM并进行了打印处理。即使发生了OOM,该子线程停止,但主线程继续执行主线程外的任务。如果没有捕获,子线程停止,但JVM不会退出,因为主线程仍在运行。此示例代码通过捕获异常展示了如何使程序在发生OOM时继续执行,但开发者应合理处理这些错误以避免不必要的错误传播和程序行为失常。

不建议频繁捕获OOM并继续执行程序,因为这样可能表明程序有严重的内存管理问题,应尽量优化内存使用。在关键路径中发生OOM时,通常应记录日志并考虑安全停机,因为无法保证系统在内存压力下的正确性。