对我们Java开发人员来说,因为有虚拟机自动内存管理机制帮助,所以不需要为每一个new操作去写配对的free代码,不容易出现内存泄漏和内存溢出问题。但是,正因为将管理内存的权限交给了JVM,一旦出现内存泄漏和溢出,如果不了解虚拟机的构造原理和运行机制,那么排查、修正错误将会是一项很艰难的工作。
JVM(Java Virtual Machine)也就是我们常说的java程序运行环境,有了它的存在,才保证了一次编写,到处运行。面试时也经常会遇到这样的问题:JVM、JRE、JDK有什么区别,下面这张图告诉你答案。
Java虚拟机在执行程序的时候会把内存分为若干个不同的数据区域,Java虚拟机所管理的内存包括以下几个运行时数据区域
一、程序计数器
- 什么是程序计数器
程序计数器(Program Counter Register)是用于存放下一条指令所在单元的地址的地方。
JAVA代码编译后的字节码在未经过JIT(实时编译器)编译前,其执行方式是通过“字节码解释器”进行解释执行。简单的工作原理为解释器读取装载入内存的字节码,按照顺序读取字节码指令。读取一个指令后,将该指令“翻译”成固定的操作,并根据这些操作进行分支、循环、跳转等流程。
从上面的描述中,可能会产生程序计数器是否是多余的疑问。因为沿着指令的顺序执行下去,即使是分支跳转这样的流程,跳转到指定的指令处按顺序继续执行是完全能够保证程序的执行顺序的。假设程序永远只有一个线程,这个疑问没有任何问题,也就是说并不需要程序计数器。但实际上程序是通过多个线程协同合作执行的。
首先我们要搞清楚JVM的多线程实现方式。JVM的多线程是通过CPU时间片轮转(即线程轮流切换并分配处理器执行时间)算法来实现的。也就是说,某个线程在执行过程中可能会因为时间片耗尽而被挂起,而另一个线程获取到时间片开始执行。当被挂起的线程重新获取到时间片的时候,它要想从被挂起的地方继续执行,就必须知道它上次执行到哪个位置,在JVM中,通过程序计数器来记录某个线程的字节码执行位置。因此,程序计数器是具备线程隔离的特性,也就是说,每个线程工作时都有属于自己的独立计数器。
Code:
stack=2, locals=1, args_size=1
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #3 // String Hello world!
5: invokevirtual #4 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
上面就是我们熟悉的Hello world经过反解析(javap -v Test.class
)后的片段,指令前的数字表示的是该指令相对于函数开始的偏移量,以Byte为单位。
- 程序计数器特点
线程私有,每个线程有自己独立的程序计数器;
内存中唯一不会报OutOfMemoryError的区域;
执行java方法时存储的是指令地址,执行本地方法为空(Undefined)。
二、虚拟机栈
- 虚拟机栈结构
我们常常把Java内存分为堆(Heap)和栈(Stack)(实际上内存划分要比这复杂得多)。这里的“栈”通常就是指虚拟机栈。
栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构。每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧,用于存储方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机里面从入栈到出栈的过程。
局部变量表存放了:
- 8种基本数据类型
- 对象引用(reference类型)
- returnAddress类型(指向一条字节码指令的地址)
局部变量表所需的内存空间在编译期间完成分配, 当进入一个方法时, 这个方法需要在栈帧中分配多大的局部变量空间是完全确定的, 在方法运行期间不会改变局部变量表的大小。
- 虚拟机栈特点及异常
虚拟机栈也是线程私有的,并且有可能出现两种异常,一种是如果线程请求的栈深度大于虚拟机所允许的深度, 将抛出StackOverflowError异常; 另一种是如果Java虚拟机栈容量可以动态扩展(HotSpot虚拟机的栈容量是不可以动态扩展的), 当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError异常。
通过递归调用测试栈内存溢出
public class Test{
private static int count;
public static void main(String[] args) throws Exception{
try {
method();
} catch(Throwable e) {
System.out.println(count);
e.printStackTrace();
}
}
public static void method() {
count++;
method();
}
}
//-Xss 256k:设置每个线程的堆栈大小(默认是1M)
java -Xss256k Test
输出:
2721
java.lang.StackOverflowError
at Test.method(Test.java:14)
at Test.method(Test.java:15)
三、本地方法栈
本地方法栈(Native Method Stacks) 与虚拟机栈所发挥的作用是非常相似的, 其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码) 服务, 而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。
《Java虚拟机规范》 对本地方法栈中方法使用的语言、 使用方式与数据结构并没有任何强制规定, 因此具体的虚拟机可以根据需要自由实现它, 甚至有的Java虚拟机(譬如Hot-Spot虚拟机) 直接就把本地方法栈和虚拟机栈合二为一。 与虚拟机栈一样, 本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryError异常。
四、Java堆
堆是虚拟机所管理的内存中最大的一块,其唯一目的就是存放对象实例。堆是垃圾收集器管理的内存区域,因此也被称作"GC堆",从回收内存角度来看,由于现代垃圾收集器大部分都是基于分代收集理论设计的, 所以Java堆中经常会出现“新生代”“老年代”“永久代”“Eden空间”“From Survivor空间”“To Survivor空间”等名词, 这些区域划分仅仅是一部分垃圾收集器的共同特性或者说设计风格, 并非某个Java虚拟机具体实现的固有内存布局。 下图比例为JDK1.8默认占比。
堆空间特点
线程共享,堆内对象需要考虑线程安全问题;
垃圾收集器管理的主要区域;
虚拟机启动时创建java堆
java堆的大小是可扩展的, 通过-Xmx和-Xms控制。异常和诊断
如果堆内存不够分配实例对象, 并且对也无法再扩展时, 将会抛outOfMemoryError异常。
import java.util.ArrayList;
import java.util.List;
public class Test{
public static void main(String[] args) {
int i = 0;
try {
List list = new ArrayList<>();
String a = "hello";
while(true) {
list.add(a);
a = a + a;
i++;
}
} catch(Throwable e) {
e.printStackTrace();
System.out.println(i);
}
}
}
//-Xmx8m:设定程序运行期间最大可占用的内存大小
java -Xmx8m Test
java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(Unknown Source)
at java.lang.AbstractStringBuilder.ensureCapacityInternal(Unknown Source)
at java.lang.AbstractStringBuilder.append(Unknown Source)
at java.lang.StringBuilder.append(Unknown Source)
at Test.main(Test.java:13)
17
堆内存诊断可使用
a、jps : 查看当前系统中有哪些java进程
b、jmap -heap pid : 查看堆内存占用情况 -heap 进程id
c、jconsole : 图形界面,多功能检测工具,可连续监测
d、jvisualvm : 比jconsole更好用的图形界面,可dump某个时刻的内存分析
五、方法区
方法区(Method Area) 与Java堆一样, 是各个线程共享的内存区域, 它用于存储已被虚拟机加载的类型信息、 常量、 静态变量、 即时编译器编译后的代码缓存等数据。 可使用 javap -v xxx.class 反解析查看
- 类型信息
类的完整名称(比如,java.long.String)
类的直接父类的完整名称
类的修饰符
类的直接实现接口的有序列表(一个类直接实现的接口可能不止一个,因此放到一个有序表中)
可以看做是,对一个类进行登记,这个类的名字叫啥,他粑粑是谁、有没有实现接口, 权限是啥;
- 类型的常量池 (即运行时常量池)
字面值:就是像string, 基本数据类型,以及它们的包装类的值,以及final修饰的变量,简单说就是在编译期间,就可以确定下来的值;
符号引用:不同于我们常说的引用,它们是对类型,域和方法的引用,类似于面向过程语言使用的前期绑定,对方法调用产生的引用;
每一个Class文件中,都维护着一个常量池(这个保存在类文件里面,不要与方法区的运行时常量池搞混),里面存放着编译时期生成的各种字面值和符号引用;这个常量池的内容,在类加载的时候,被复制到方法区的运行时常量池 ;
存在这里面的数据,类似于保存在数组中,外部根据索引来获得它们 ;
字段信息
声明的顺序
修饰符
类型
名字方法信息
声明的顺序
修饰符
返回值类型
名字
参数列表(有序保存)
异常表(方法抛出的异常)
方法字节码(native、abstract方法除外,)
操作数栈和局部变量表大小类变量(即static变量)
非final类变量
Tips: 在java虚拟机使用一个类之前,它必须在方法区中为每个非final类变量分配空间。非final类变量存储在定义它的类中;
final类变量(不存储在这里)
Tips: 由于final的不可改变性,因此,final类变量的值在编译期间,就被确定了,因此被保存在类的常量池里面,然后在加载类的时候,复制进方法区的运行时常量池里面 ;final类变量存储在运行时常量池里面,每一个使用它的类保存着一个对其的引用;对类加载器的引用
JVM必须知道一个类型是由启动加载器加载的还是由用户类加载器加载的。如果一个类型是由用户类加载器加载的,那么jvm会将这个类加载器的一个引用作为类型信息的一部分保存在方法区中。
- 对Class类的引用
JVM为每个加载的类都创建一个java.lang.Class的实例(存储在堆上)。而jvm必须以某种方式把Class的这个实例和存储在方法区中的类型数据(类的元数据)联系起来, 因此,类的元数据里面保存了一个Class对象的引用;
运行时常量池
运行时常量池是方法区的一部分。 Class文件中除了有类的版本、 字段、 方法、 接口等描述信息外, 还有一项信息是常量池表 , 用于存放编译期生成的各种字面量与符号引用, 这部分内容将在类加载后存放到方法区的运行时常量池中。
直接内存
并不是虚拟机运行时数据区的一部分,属于操作系统内存。 但是这部分内存也被频繁地使用, 而且也可能导致OutOfMemoryError异常出现, 所以我们放到这里一起讲解。在JDK 1.4中新加入了NIO(New Input/Output) 类, 引入了一种基于通道(Channel) 与缓冲区(Buffer) 的I/O方式, 它可以使用Native函数库直接分配堆外内存, 然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。 这样能在一些场景中显著提高性能, 因为避免了在Java堆和Native堆中来回复制数据。
虽然方法区只是用来Class的相关信息,但是在经常动态生成大量Class的应用中,如当前主流的很多框架 Spring,Hibernate对类进行增强时,都会使用到类似CGLIB这类字节码技术,增强的类越多,就需要越大的方法区来保证动态生成的Class可以加载入内存。一个类如果要被垃圾回收器回收掉,判定条件非常苛刻。
参考:(https://www.cnblogs.com/newAndHui/p/11168791.html) -- [深入理解JVM-java虚拟机栈]
(https://blog.csdn.net/dshf_1/article/details/87171171) -- [java方法区详解]
深入理解Java虚拟机 - 周志明