前言:
JVM是面试中必问到题,大部分应届生也只是在背八股文的时候才对JVM有所了解,我也是一样。
除了面试会问到之外,JVM也是初、中、高级程序员必备的技能(初级可能接触的少,不过也是为以后打下了基础)。学习JVM可以让程序员更深入的理解Java语言,对于排查问题也很重要。
一、什么是JVM?
Java虚拟机 (Java Virtual Machine),用来保证Java语言的跨平台。
Java虚拟机可以看做是一台抽象的计算机,如同真实的计算机那样,有自己的指令集和各种运行时内存区域。
Java虚拟机只与特定的二进制文件格式(class文件格式)有所关联,与Java语言没有必然联系。
Java虚拟机就是一台字节码翻译器,将字节码文件翻译成目标机(系统)对应的机器码,确保字节码文件能在各个系统正确运行。
二、JVM体系结构
如下图所示:
JVM体系结构分为:
- 类装载器子系统(类加载器)Class Loader SubSystem
- 运行时数据区
- 执行引擎
2.1、类加载器子系统
- 加载阶段(Loading),加载class文件
- 连接阶段(Linking):验证、准备、解析
- 初始化阶段(Initialization),初始化
2.2、运行时数据区
- 堆:存储Java对象的地方,保存了所有的对象实例和数组,也是GC管理的主要区域。
- 方法区:用于存储虚拟机加载的类信息、常量、静态变量、代码等数据。
- 虚拟机栈:每个线程在创建时都会创建一个虚拟机栈,虚拟机栈内部保存了一个个的栈帧,对应着一次次的方法调用。
- 本地方法栈:与虚拟机栈作用相似,区别在于虚拟机栈为虚拟机执行Java方法服务,而本地方法栈为虚拟机的Native方法服务。
- 程序计数器(pc寄存器):用于存储下一条指令的地址,由执行引擎读取下一条指令。
线程共享(堆、方法区),线程不安全的。
线程私有(虚拟机栈、本地方法栈、程序计数器),线程安全。
2.3、执行引擎
解释器:
主要作用是读取字节码,对字节码进行解释并逐一执行。解释字节码的速度较快,但是执行的速度较慢。如果一个方法被多次调用,它每次都要进行解释,这时候就需要即时编译器。即时编译器(JIT编译器):
用来解决解释器的缺点,当它发现重复代码的时候,将采用即时编译器,编译整个字节码并将其更改为本地代码,使用本地代码直接用于重复的方法调用,提高系统的性能。垃圾回收器:
收集或删除未被引用的对象,可以通过System.gc来触发垃圾回收,但是并不能保证执行,Java垃圾收集器只收集用关键字创建的对象(new出来的对象)。其他形式创建的对象,可以使用finalize方法清理。
三、堆
堆是用来存储对象的区域,堆是JVM中最大的一块内存区域。
特点:
- 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。
- 堆是用来存放对象实例的,因此也是垃圾收集器管理的主要区域。
- 堆的逻辑上划分为 “新生代” 和 “老年代”,新生代分为Eden区、ServivorFrom、ServivorTo三个区。
- 堆一般实现成大小是可扩展的,使用 “-Xms” 与 “-Xmx” 控制堆的最小与最大内存。
3.1、堆内存溢出
如果我们不断的去产生新的对象,而产生的对象又一直在被使用,这些对象就没办法被回收,长此以往下去,可能就会导致内存耗尽,也就是内存溢出
。
代码演示:
1、堆内存大小不够而造成的堆内存溢出。
public class Test1{
public static void main(){
int count = 0;
try{
List<String> list = new ArrayList<>();
String s = "test1";
for(int i=0; i<20; i++){
list.add(s);
s += s;
count++;
}
}catch(Throwable e){
e.printStackTrace();
System.out.prinln(count);
}
}
}
2、业务问题引起的堆内存溢出
public class Test2{
public static void main(){
int count = 0;
try{
List<String> list = new ArrayList<>();
String s = "test2";
while(true){
list.add(s);
s += s;
count++;
}
}catch(Throwable e){
e.printStackTrace();
System.out.prinln(count);
}
}
}
总结:
错误提示:java.lang.OutOfMemoryError:Java heap space
错误原因:
- 内存不够,需要调整堆内存大小。
- 代码中存在死循环,需要通过修改代码解决。
四、方法区
用于存储已经被虚拟机加载的类型信息,常量,静态变量,及时编译后的代码缓存等数据
在jdk1.8之前,方法区又称为“永久代”,jdk1.8开始,使用“元空间” 来代替 “永久代”。
三者关系:
方法区是JVM规范的一部分,而元空间和永久代是一个具体的实现,都是对JVM规范方法区的实现。
元空间和永久代最大区别在于:元空间不在虚拟机中设置内存,而是使用本地内存。
五、虚拟机栈
每个线程运行时所需要的内存空间,称为虚拟机栈。
每个栈由栈帧(Frame)组成,对应着每次方法调用时所占用的内存。
每个线程只有一个活动栈帧,对应着当前正在执行的那个方法。
每个栈帧包括局部变量表、操作数栈、动态链接、方法返回地址。
5.1、栈帧
局部变量表:
存放局部变量的列表。
一个局部变量可以保存类型为boolean、byte、char、short、float、reference和returnAddress的数据。
两个局部变量可以保存一个类型为long和double的数据。
局部变量使用索引来进行定位访问,第一个局部变量的索引值为0。
操作数栈:
也称为操作栈,是一个后进先出(先进后出)的栈。
操作数栈就是线程实际的操作台。
当一个方法刚刚开始执行时,其操作数栈是空的,随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者,也就是入栈/出栈操作。
一个完整的方法执行期间往往包含多个这样的入栈/出栈的过程。
动态链接:
指向运行时常量池的引用。
在class文件里面,描述一个方法调用了其他方法,或者访问其成员变量是通过符号引用来表示的,动态链接的作用就是将这些符号引用所表示的方法转换为实际方法的直接引用。
方法返回地址:
方法调用的返回,包括正常返回(有返回值)和异常返回(无返回值),不同的返回类型有不同的指令。
无论方法采用何种方式退出,在方法退出后都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在当前栈帧中保存一些信息,用来帮他恢复它的上层方法执行状态。
5.2、栈内存溢出:
- 栈帧过多导致栈内存溢出
- 栈帧过大导致栈内存溢出
演示栈内存溢出:
1、栈帧过多
/*
java.lang.StackOverflowError
-Xss256k:次数减少
-Xss10m:次数变多
*/
public class Demo01{
private static int count;
//递归调用,没有出口,最终导致栈内存溢出
public static void method(){
count++;
method();
}
public static void main(String[] args){
try{
method();
}catch(Throwable e){
e.printStackTrace();
System.out.println(count);
}
}
}
2、栈帧过大
public class Demo02{
public static void main(String[] args) throws JsonProcessingException{
Dept d = new Dept();
d.setName("研发");
Emp e1 = new Emp();
e1.setName("LinYD");
e1.setDept(d);
Emp e2 = new Emp();
e2.setName("Lisi");
e2.setDept(d);
d.setEmps(Arrays.asList(e1,e2));
ObjectMapper mapper = new ObjectMapper();
System.out.println(mapper.writeValueAsString(d));
}
}
class Emp{
private String name;
private Dept dept;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public Dept getDept() { return dept; }
public void setDept(Dept dept) { this.dept = dept; }
}
class Dept{
private String name;
private List<Emp> emps;
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public List<Emp> getEmps() { return emps; }
public void setEmps(List<Emp> emps) { this.emps = emps; }
}
员工和部门来回引用,循环引用问题,栈帧不断增大。
解决方法:@JsonIgnore
class Emp{
private String name;
@JsonIgnore
private Dept dept;
}
注:栈是运行时的单位,堆是存储的单位。
栈解决程序运行时的问题,即程序如何执行,或如何处理数据。
堆解决数据存储问题,即数据怎么放,放在哪。
具体可以看这一篇:你了解JVM的虚拟机栈吗
六、本地方法栈
本地方法栈的作用和特点类似于虚拟机栈。
不同的是:
本地方法栈服务的对象是JVM执行的native方法,而虚拟机栈服务的是JVM执行的Java方法。
JVM规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此不同的虚拟机可以自由的进行实现
七、程序计数器
用于存储下一条指令的地址。详细的说PC寄存器是用来存储指向下一条指令的地址,也就是即将将要执行的指令代码。由执行引擎读取下一条指令。
比如分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖程序计数器来完成。
JVM虚拟机为了保证线程切换后还能恢复到上次正确执行的位置,给每条Java线程都分配了一个独立的程序计数器,各线程之间计数器互不影响,与对应的Java线程生命周期保持一致。
特点:
- 每个Java线程独占,与对应的Java线程生命周期保持一致。
- 占用JVM内存很小,读取速度快。
- 负责准确记录每个线程当前执行到的具体位置。
- 执行本地方法,程序计数器会存储undefined。
- JVM中唯一不会出现内存溢出的区域,也不需要进行垃圾回收。