文章目录
- Q1:JVM的作用与结构
- Q2:字节码文件的组成
- Q3、JVM运行时数据区
- Q4、哪些区域会出现内存溢出,会有什么现象?
- Q5、JVM在JDK6-8之间在内存区域上有什么不同?
- Q6、类的生命周期
- Q7、类加载器有几种
- Q8、双亲委派机制
- Q9、如何打破双亲委派机制
- Q10、如何判断堆上的对象有没有被引用?
- Q11、JVM中有哪些引用类型
Q1:JVM的作用与结构
- Java虚拟机,本质上是一个运行在计算机上的程序
- JVM职责有三:把编译后的
class字节码解释成机器码
、管理内存中的对象(垃圾回收)、即时编译优化热点代码,提高执行性能 - JVM的组成:最上层由类装载器将字节码文件加载到内存中。中间层,其中方法区、堆区是所有线程共享,而栈、程序计数器、寄存器是一个线程一个。最下层,执行引擎里包括解释器、JIT即时编译器、GC垃圾回收器
Q2:字节码文件的组成
- jclasslib工具,或者用javap -v命令查看字节码文件
- 字节码文件第一块是基本信息,包括魔数、编译成字节码的Java版本号、访问标识(public final)等
- 第二块是常量池,保存了字符串常量、类或接口名、字段名
- 第三块是字段,保存了当前类和接口声明的字段信息
- 第四块是方法,里面是方法对应的一条条的字节码指令
Q3、JVM运行时数据区
运行时数据区指的是JVM所管理的内存区域,其中分成两大类:
1)线程共享 -- 方法区、堆
- 方法区:存放每一个加载的类的元信息,运行时常量池,字符串常量池(JDK7之前)。
- 堆:存放创建出来的对象。
2)线程不共享 -- 本地方法栈、虚拟机栈、程序计数器
- 本地方法栈和虚拟机栈都存放了线程中执行方法时需要使用的基础数据(一个个栈帧)。
- 程序计数器存放了当前线程执行的字节码指令在内存中的地址。
最后,直接内存主要是NIO使用,由操作系统直接管理,不属于JVM内存
Q4、哪些区域会出现内存溢出,会有什么现象?
内存溢出指的是内存中某一块区域的使用量超过了允许使用的最大值,从而使用内存时因空间不足而失败,虚拟机一般会抛出指定的错误。
-
堆:溢出之后会抛出OutOfMemoryError,并提示是Java heap Space导致的
-
栈:溢出之后会抛出StackOverflowError(在默认栈大小下,无限递归,一万次左右,会栈溢出)
-
方法区:溢出之后会抛出OutOfMemoryError,JDK7及之前提示永久代,JDK8及之后提示元空间(方法区存放的内容,如类的元信息超过了方法区空间的最大值)
-
直接内存:溢出之后会抛出OutOfMemoryError,提示Direct buffer memory导致的(超出了向操作系统申请的直接内存)
Q5、JVM在JDK6-8之间在内存区域上有什么不同?
java
方法区的变化:
方法区是《Java虚拟机规范》中设计的虚拟概念,每款Java虚拟机在实现上都各不相同。Hotspot设计如下:
- JDK7及之前的版本将
方法区存放在堆区域中的永久代空间
,堆的大小由虚拟机参数来控制。 - JDK8及之后的版本将方法区存放在
元空间中,元空间位于操作系统维护的直接内存
中,默认情况下只要不
超过操作系统承受的上限,可以一直分配。也可以手动设置最大大小。
而这个变化的原因(使用元空间替换永久代的原因):
-
1)提高内存上限:元空间使用的是操作系统内存,而不是JVM内存。如果不设置上限,只要不超过操作系统内存上限,就可以持续分配。而永久代在堆中,可使用的内存上限是有限的。所以使用元空间可以有效减少OOM情况的出现
-
2)优化垃圾回收的策略:永久代在堆上,垃圾回收机制一般使用老年代的垃圾回收方式回收方法区,不够灵活。使用元空间之后单独设计了一套适合方法区的垃圾回收机制
java
字符串常量池的位置变化
- 字符串常量池,在JDK7及以后的版本,在堆中。JDK7之前,在方法区(堆的永久代)
- 最后,运行时常量池一直在堆中放着
而这个变动的原因是:
-
垃圾回收优化:字符串常量池的回收逻辑和对象的回收逻辑类似,内存不足的情况下,如果字符串常量池中的常量不被使用就可以被回收,而方法区中回收的是类的元信息,逻辑更复杂一些。移动到堆之后,就可以利用对象的垃圾回收器,对字符串常量池进行回收。
-
让方法区大小更可控:一般在项目中,类的元信息不会占用特别大的空间,所以会给方法区设置一个比较小的上限。如果字符串常量池在方法区中,会让方法区的空间大小变得不可控。(堆区是JVM运行时内存最大的一块)
-
intern方法的优化 :JDK6版本中intern () 方法会把第一次遇到的字符串实例复制到永久代的字符串常量
池中。JDK7及之后版本中由于字符串常量池在堆上,就可以进行优化:字符串保存在堆上,把字符串的引用放入字符串常量池,不用再做之前的复制的操作
Q6、类的生命周期
类的生命周期主要包括加载、连接(验证、准备、解析)、初始化、卸载。
- 加载阶段即类加载器获取到字节码信息,在
方法区生成一个InstanceKlass对象
,保存类的所有信息,在堆中生成一份与方法区中数据类似的java.lang.Class对象, 作用是在Java代码中去获取类的信息
- 连接阶段的三步:验证阶段检验魔数、JDK版本号、元信息等。准备阶段为静态变量(static)分配内存并设置初值,final修饰的基本数据类型的静态变量,准备阶段直接会将代码中的值进行赋值。解析阶段主要是将常量池中的符号引用替换为直接引用
- 初始化阶段会执行静态代码块中的代码,并为静态变量赋值,以及执行字节码文件中clinit部分的字节码指令
- 最后的卸载则是,类A的所有对象即子类对象被回收,且类A的类加载器被回收、且Class对象没有被任何地方引用了。此时类A被卸载,方法区的InstanceKlass对象和堆区的Class对象被清除。
Q7、类加载器有几种
类加载器负责在类的加载过程中将字节码信息获取并加载到内存中:
- 启动类加载器(Bootstrap ClassLoader)加载核心类
- 扩展类加载器(Extension ClassLoader)加载扩展类
- 应用程序类加载器(Application ClassLoader)加载应用classpath中的类
- 自定义类加载器,重写findClass方法
JDK9及之后扩展类加载器(Extension ClassLoader)变成了平台类加载器(Platform ClassLoader)
Q8、双亲委派机制
当一个类加载器接收到加载类的任务时,会向上查找是否加载过,再由顶向下进行加载。向上查找、向下加载。
双亲委派机制的作用是:
- 1)保证类加载的安全性,防止恶意替换JDK的类
- 2)避免重复加载同一个类
Q9、如何打破双亲委派机制
自定义类加载器,继承ClassLoader类,并重写其loadClass方法,将其中的双亲委派机制代码去掉
因为源码中的loadClass方法有这么一段:
java
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
//类加载器的parent属性不为空,即有父加载器
if (parent != null) {
//自己调自己,这里体现的是向上查找
c = parent.loadClass(name, false);
//...
这里体现类加载的向上查找和向下加载。我重写的loadClass方法,去掉这些。直接把class读成byte数组,传给了defineClass方法。自然没有了向上查找和向下加载,被打破。
java
import org.apache.commons.io.IOUtils;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.ProtectionDomain;
import java.util.regex.Matcher;
/**
* 打破双亲委派机制 - 自定义类加载器
*/
public class BreakClassLoader1 extends ClassLoader {
private String basePath;
private final static String FILE_EXT = ".class";
public void setBasePath(String basePath) {
this.basePath = basePath;
}
private byte[] loadClassData(String name) {
try {
String tempName = name.replaceAll(".", Matcher.quoteReplacement(File.separator));
FileInputStream fis = new FileInputStream(basePath + tempName + FILE_EXT);
try {
return IOUtils.toByteArray(fis);
} finally {
IOUtils.closeQuietly(fis);
}
} catch (Exception e) {
System.out.println("自定义类加载器加载失败,错误原因:" + e.getMessage());
return null;
}
}
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
//我现在重写了loadClass方法,并把原来源码中的双亲委派机制代码那点代码去掉了(就判断parent是否为空,自己调自己的loadClass方法的那点源码)
byte[] data = loadClassData(name);
return defineClass(name, data, 0, data.length);
}
}
Q10、如何判断堆上的对象有没有被引用?
- 引用计数法
- 可达性分析法
引用计数法会为每个对象维护一个引用计数器,当对象被引用时加1,取消引用时减1,存在循环引用问题,会导致对象没法回收,所以Java没有使用这种方法。
可达性分析算法指的是如果从某个到GC Root对象是可达的,对象就不可被回收。
Q11、JVM中有哪些引用类型
强、软、弱、虚、终结器引用这五种,不同的引用类型,对应不同的回收效果:
- 强引用,默认的类型,GC时,有强引用的对象不会被回收,即使OOM了
- 软引用,当程序内存不足时,就会将软引用中的数据进行回收,软引用主要在缓存框架中使用
- 弱引用,弱引用包含的对象在垃圾回收时,不管内存够不够都会直接被回收,弱引用主要在ThreadLocal中使用
- 虚引用(幽灵引用/幻影引用),不能通过虚引用对象获取到包含的对象。虚引用唯一的用途是当对象被垃圾回收器回收时可以接收到对应的通知。直接内存中为了及时知道直接内存对象不再使用,从而回收内存,使用了虚引用来实现
- 终结器引用,终结器引用指的是在对象需要被回收时,终结器引用会关联对象并放置在Finalizer类中的引用队列中,在稍后由一条由FinalizerThread线程从队列中获取对象,然后执行对象的finalize方法,在对象第二次被回收时,该对象才真正的被回收