JVM简单介绍
接下来咱们对JVM做一个简单的介绍,咱不一定能把JVM里面的东西都完完全全的讲明白。但还是希望大家阅读完这篇文章之后能对JVM有一个简单的认识,同时对咱们程序员熟知OOM有一个感性的认识。希望能帮助到大家。
在开始之前,咱们先来一张Java程序执行过程图:
从上图中咱们能看出来Java程序是交由JVM(Java Virtual Machine)来执行的。JVM的重点又是在运行时数据区域(也就是经常提到的JVM内存区域)。JVM运行时数据区被分为五个部分: 方法区(Method Area)、 堆区(Heap)、栈区(stack)和。JVM调优主要就是要和堆区(heap)打交道。堆区(heap)存放的是实际的对象(JAVA虚拟机规范中的描述:所有对象实例以及数组都要在堆上分配)。
根据JVM规范,内存分为:虚拟机栈,堆,方法区,程序计数器五个部分。
一 方法区(Method Area)
1.1 方法区 – 简单介绍
方法区(Method Area)和Java堆一样是各个线程共享内存区域,它用于存储已被JVM虚拟机加载的类信息:常量、静态变量、即时编译器编译后的代码等数据以及运行时常量池。方法区的内存回收目标主要是针对运行时常量池的收缩和对类型的卸载。
当程序运行时,首先通过类装载器加载字节码文件(.class文件),经过解析后装入方法区。
关于方法区和元空间,永久代的关系。咱们可以简单的理解为方法区是标准,元空间和永久代是方法区的实现。而且JDK1.8以后是元空间,JDK1.7之前是永久代。咱们可以简单的理解为方法区是标准,元空间(永久代)是方法区的实现。所以我们可以简单的认为元空间(永久代)就是方法区。记住JDK1.8之后永久代被元空间替换了。
为啥JDK1.8之后要用元空间来替代永久代呢。元空间与永久代最大的区别在于:元空间并不在虚拟机中,而是使用本机内存。因此,元空间大小受机器内存的限制。
方法区的整个结构图如下图所示:
1.1.1 JVM已加载的类信息
-
类型信息:类的完整名称、类的直接父类的完整名称、类的直接实现接口的有序列表、类型标志(类类型还是接口类型)、类的修饰符(public、private、default、abstract、final、static)。
-
类型的常量池:存放该类所用到的常量的有序集合,包括直接常量(字符串、整数、浮点数的常量)和对其他类型、字段、方法的符号引用。所以它是动态链接(栈中对应的方法指向这个引用)的主要对象(在动态链接中起倒核心作用)。
该部分是独有的,运行时,会把该部分加载进运行时常量池,当调用方法时则从符号引用解析为直接引用,但是有些确定的方法会直接转换,比如静态方法,比如构造方法。
动态链接:即在运行时由程序决定加载和链接什么符号。这种能力让语言具备了获取在编译时尚未存在的模块和符号,以支持程序的动态扩展和实现插件机制。更加具体的例子 - 采用JNA的方式对动态链接库进行调用。
-
字段信息:该类申明的所有字段信息(字段修饰符、字段的类型、字段名称)。
-
方法信息:该类的所有方法信息,每个方法新西又包含:方法修饰符、方法返回类型、方法名。法法参数个数,类型,顺序等、方法字节码、操作数栈和该方法在栈帧中的举报变量区大小、异常表。
-
类变量: 静态变量,指该类所有对象共享的变量,即使没有任务实例对象是,也可以访问的类变量。他们与类进行绑定。
-
指向类加载器的引用:每一个被JVM加载的类型,都保存这个类加载器的引用,类加载器动态链接时会用到。
-
指向Class实例的引用:类加载的过程中,虚拟机会创建该类型的Class实例,方法区中必须保存对该对象的引用。通过Class.forName(StringclassName)来查找获得该实例的引用,然后创建该类的对象。
-
方法表:为了提高访问效率,JVM可能会对每个装载的非抽象类,都创建一个数组,数组的每个元素是实例可能调用的方法的直接引用,包括父类中继承过来的方法。这个表在抽象类或者接口总是没有的。
1.1.2 运行时常量池(Runtime Constant Pool)
Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是运行时常量池,用于存放编译器生成的各种字面常量和符号引用,这部分内容被类加载后进入方法区的运行时常量池中存放。
运行时常量池相对于Class文件常量池的另一个特征具有动态性,可以在运行期间将新的常量放入池中(典型的如String类的intern()方法)。
运行时常量池是把Class文件常量池加载进来,每个类有一个独立的。刚开始时运行的时候运行时常量池里的链接都是符号链接(只用名字没有实体),跟在Class文件里的一样;只有调用该方法时,才把常量转换成直接引用。然后就可以供给给其他方法调用了。
在Java8中,元空间(Metaspace)登上舞台,方法区存在于元空间(Metaspace)。
1.2 方法区 – JVM相关参数
方法区JVM参数 | 解释 | 实例 |
---|---|---|
-XX:MetaspaceSize | 元空间大小 | -XX:MetaspaceSize=512m |
-XX:MaxMetaspaceSize | 用于设置元空间的最大值 | -XX:MaxMetaspaceSize=512m |
1.3 方法区 – OOM
方法区的异常JDK1.8之后抛出的是java.lang.OutOfMemoryError: Metaspace(元空间内存溢出),JDK1.7之前抛出的是java.lang.OutOfMemoryError: PermGen space(永久代内存溢出)。比如如下的代码就可能产生java.lang.OutOfMemoryError: Metaspace。
/**
* 元空间内存溢出 java.lang.OutOfMemoryError: Metaspace
* (
* JDK1.7之前抛出的是java.lang.OutOfMemoryError: PermGen space.
* JDK1.8之后抛出的是java.lang.OutOfMemoryError: Metaspace
* 因为在JDK1.8中, HotSpot已经没有 “PermGen space”这个区间了,取而代之是一个叫做Metaspace(元空间)的东西
* )
* <p>
* 解决该OOM的办法是增大MaxMetaspaceSize参数值,或者干脆不设置该参数,在默认情况元空间可使用的内存会受到本地内存的限制。
*/
public class MetaspaceOutOfMemoryErrorMock {
public static void main(String[] args) throws Exception {
for (int i = 0; i < 10000; i++) {
//动态创建类
Map<String, Class<?>> propertyMap = new HashMap<String, Class<?>>();
propertyMap.put("id", Class.forName("java.lang.Integer"));
CglibBean bean = new CglibBean(propertyMap);
//给 Bean 设置值
bean.setValue("id", new Random().nextInt(100));
//打印 Bean的属性id
System.out.println("id=" + bean.getValue("id"));
}
}
}
我们应该怎么来避免方法区里面的OOM呢。增大-XX:MetaspaceSize或者-XX:MaxMetaspaceSize参数值,或者干脆不设置该参数。在默认情况元空间可使用的内存会受到本地内存的限制。
二 Java堆
2.1 Java堆 – 简单介绍
Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。这个区域是用来存放对象实例的,几乎所有对象实例都会在这里分配内存。堆是Java垃圾收集器管理的主要区(因此很多时候也被称为GC堆)。Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。Java堆被分为两部分:新生代和老年代(将对象根据存活概率进行分类,对存活时间长的对象,放到固定区,从而减少扫描垃圾时间及GC频率)。
堆的大小 = 新生代( Young ) + 老年代( Old ),可以通过参数 –Xms、-Xmx 来指定:–Xms用于设置初始分配大小,默认为物理内存的1/16;-Xmx用于设置最大分配内存,默认为物理内存的1/4。默认情况下,新生代(Young)与老年代(Old)的比例的值为 1:2 (该值可以通过参数 –XX:NewRatio 来指定)。
-
新生代(Young): 新生代(Young)被细分为Eden和两个Survivor区域,为了便于区分,两个Survivor区域分别被命名为 from和to。默认情况下,Eden : from : to = 8 : 1 : 1 (可以通过参数 –XX:SurvivorRatio来设定),即: Eden = 8/10 的新生代空间大小,from = to =1/10的新生代空间大小。JVM每次只使用Eden和其中的一块Survivor区域来为对象服务,所以无论什么时候,总是有一块 Survivor 区域是空闲着的,因此,新生代实际可用的内存空间为 9/10 ( 即90% )的新生代空间。
-
老年代(Old); 老年代里面存放的是是堆里面生存的比较久的对象。
2.2 Java堆 – 工作原理以及对GC的理解
通过上面我们知道了堆被分为了两个部分新生代和老年代。而且新生代新生代(Young)被细分为Eden和两个Survivor区域(Survivor from和Survivor to)。那问题来了。堆里面为什么要这么分呢,目的是啥呢。和GC又有什么关系呢。接下来我们以Java对象在堆里面的生存过程来做一个简单的解释。
-
Eden区为Java对象分配堆内存(对象第一次产生的地方)。随着对象越来越多,当Eden区没有足够空间分配时JVM发起一次Minor GC,将Eden区仍然存活的对象放入Survivor From区,并清空Eden区。(这个时候只有Survivor from区域有东西)
-
Eden区被清空之后继续为新的Java对象分配堆内存。
-
当Eden区再次没有足够空间分配时。JVM对Eden区和刚才的Survivor from区同时发起一次Minor GC,把存活对象放入Survivor to区。同时清空Eden区和Survivor from区。(这个时候只有Surviror to区域有东西)
-
Eden区继续为新的Java对象分配堆内存。并重复上述过程:Eden区没有足够空间分配时,把Eden区和某个Survivor区的存活对象放到另一个Survivor区。
-
JVM给每个对象设置了一个对象年龄(Age)计数器,每熬过一场Minor GC,对象年龄增加1岁,当它的年龄增加到阈值(默认为15,可以通过-XX:MaxTenuringThreshold 参数自定义该阀值),该对象将被“晋升”到老年代区域里面去,当Old区也被填满时,JVM发起一次 Major GC,对 Old 区进行垃圾回收。
从上面讲解我们需要抓到以下几点重要的内容:
-
一般对象出生在新生代的Eden区域。
-
新生代的的Survivor from和Surviror to区域同一时刻只要一个在工作,两者交替使用。
-
在每次GC的时候(不管是Minor GC还是Major GC)会把对应区域没用的对象都清除掉,释放空间。
-
Minor GC触发条件:在新生代的Eden区空间不足的时候触发。回收新生代里面的内存。
-
Major GC触发条件:在老年代空间不足的时候触发。回收老年代里面的内存。
-
Minor GC触发会引起Major GC触发。
-
关于GC,除了上面提到的Minor GC和Major GC其实JVM里面还有一个Full GC。Full GC同时回收新生代和老年代内存。Full GC的触发条件是System.gc的调用或者老年代空间不足或者方法区空间不足。
2.3 Java堆 – JVM参数
堆JVM参数 | 描述 | 实例 |
---|---|---|
-Xms | 堆内存初始大小,单位m、g | -Xms512m |
-Xmx | 堆内存最大允许大小,一般不要大于物理内存的80% | -Xmx512m |
-XX:PermSize | 非堆内存初始大小,一般应用设置初始化200m,最大1024m就够了 | -XX:PermSize=200M |
-XX:MaxPermSize | 非堆内存最大允许大小 | -XX:MaxPermSize=1024m |
-XX:NewSize(-Xns) | 年轻代内存初始大小,建议设为整个堆大小的1/3或者1/4 | -XX:NewSize=1024m |
-XX:MaxNewSize(-Xmn) | 年轻代内存最大允许大小,也可以缩写,建议设置成和-XX:NewSize一样大 | -XX:MaxNewSize=1024m |
-XX:SurvivorRatio=8 | 年轻代中Eden区与Survivor区的容量比例值,默认为8,即8:1 | -XX:SurvivorRatio=8 |
-Xss | 指设定每个线程的堆栈大小 | -Xss128k |
-XX:+PrintTenuringDistribution | 用于显示每次Minor GC时Survivor区中各个年龄段的对象的大 | |
-XX:InitialTenuringThreshol | 用于设置晋升到老年代的对象年龄的最小值 | |
-XX:MaxTenuringThreshold | 用于设置晋升到老年代的对象年龄的最大值 |
2.4 Java堆 – OOM
Java堆为什么会内存溢出。在年轻代中经过GC后还存活的对象会被复制到老年代中。当老年代空间不足时,JVM会对老年代进行完全的垃圾回收(Major GC,Full GC)。如果GC后,还是无法存放从Survivor区复制过来的对象,就会出现OOM(Out of Memory)。
java.lang.OutOfMemoryError Java heap space – 堆内存溢出,比如如下的代码我们把堆内存设置的小点就会抛出堆内存溢出。
/**
* 堆内存溢出 OutOfMemoryError Java heap space
*/
public class HeapMemoryOutOfMemoryError {
/**
* 内存申请的太大了,超过了启动限制,最后跑出 java.lang.OutOfMemoryError: Java heap space
*/
public static void main(String[] args) {
List<Byte[]> list = new ArrayList<Byte[]>();
for (int i = 0; i < 10000; i++) {
//构造1M大小的byte数值
Byte[] bytes = new Byte[1024 * 1024];
//将byte数组添加到list列表中,因为存在引用关系所以bytes数组不会被GC回收
list.add(bytes);
}
}
}
解决java.lang.OutOfMemoryError Java heap space在排除代码因素外,我们可以增加堆内存空间(-Xms,-Xmx参数的使用),在实际开发中必要的时候去掉引用关系(手动置null),使垃圾回收器尽快对无用对象进行回收。
三 程序计数器
程序计数器(Program Counter Register),线程私有。是一块较小的内存空间,可以看做当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支,循环,跳转,异常处理,线程恢复等基础功能都需要依赖这个计数器来完成。简单来说就是指示当前线程执行到哪个地方了。
由于咱们JAVA虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,每个线程之间计数器互不影响,独立存储。
这里有一点要注意,如果正在执行的是native方法,则这个计数器的值为空,不存储值。因为native方法不是java编写的,无法在java编译时生成字节码。那么当调用的是native方法的时候,切换回来怎么恢复呢。你可以这样想,比如咱们的native方法是C或C++编写的,那么一个用C或C++写的多线程程序,它在线程切换的时候是怎样的做的,Java的native方法也就是怎样做的。咱也不用太纠结。
程序计数器是JVM规范中唯一没有规定OOM情况的区域。程序计数器存储的是字节码文件的行号,而这个范围是可知晓的,在一开始分配内存时就可以分配一个绝对不会溢出的内存。
四 虚拟机栈(Java Virtual Machine Stacks)
4.1 虚拟机栈 – 简单介绍
虚拟机栈(Virtual Machine Stacks),线程私有。虚拟机栈描述的是Java方法执行的内存模型(非native方法)。每个方法被执行的时候都会创建一个栈帧用于存储局部变量表,操作数栈,动态链接,方法出口等信息,注意是每执行一个方法就创建一个栈帧,栈帧存放了当前方法的数据信息(局部变量),方法执行完毕,该方法的栈帧就会被销毁。
每个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机堆中入栈到出栈的过程。当方法调用则入栈,一旦完成调用则出栈。所有的展帧都出栈后,线程就结束了。
4.1.1 局部变量表(Local Variable Table)
局部变量表是一组变量存储空间,用于存放方法参数方法的返回地址(returnAddress)和方法内部定义的局部变量.并且在Java编译为Class文件时,就已经确定了该方法所需要分配的局部变量表的最大容量. 局部变量变存放了编译期间可知的各种几倍数据类型(boolean,byte,char,short,int,float,long,double),对象引用(reference)和returnAdress(它指向了一条字节码指令的地址).
4.1.2 操作数栈
操作数栈也常称为操作栈,它是一个后入先出(Last In Frist Out)栈,同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项之中.操作数栈的每一个元素可以是任意的Java数据类型.当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令向操作数栈中写入和提取内容,也就是入栈出栈操作.我们举一个简单的例子.比如我们有这么一个简单的方法 int a = 1; int b = 2; int c=a+b. 在方法刚开始调用的时候操作数栈里面是空的,调用的时候会先把a对应的值入到操作数栈里面去,接着把b对应的值入栈.碰到加号的时候,依次把b和a从操作数栈里面弹出来做相加处理.加完之后在入栈.
4.1.3 动态链接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。Class文件的常量池中存有大最的符号引用,字节码中的方法调用指令就以常最池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期间转化为直接引用,这部分称为动态连接。
一个方法调用另一个方法,或者一个类使用另一个类的成员变量时,需要知道其名字。符号引用就相当于名字。这些被调用者的名字就存放在Java字节码文件里(.class 文件)。名字是知道了,但是Java真正运行起来的时候,如何靠这个名字(符号引用)找到相应的类和方法。需要解析成相应的直接引用,利用直接引用来准确地找到。
4.1.4 返回地址
当一个方法被执行后,有两种方式退出这个方法:
-
一种是执行引擎执行任意一个方法返回(如:return)的字节码指令,这时候会有返回值传递给上层的方法调用者,是否有返回值和返回值的类型讲根据遇到的何种方法返回指令来决定。这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。
-
另一种退出方法是在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中产生的异常。只要在本方法的异常表中没有搜索到匹配的异常处理器。就会导致方法退出,这种退出称之为异常完成出口(Abrupt Method Inocation Completion)。
无论采用何种方式退出,在退出方法之后都需要返回到方法被调用的位置。程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它上层方法的执行状态。一般来说,方法正常退出,调用者的PC计数器值可以作为返回地址,栈帧很可能会保存这个计数器值。而方法异常退出是,返回地址是要通过异常处理起来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
4.2 虚拟机栈 – OOM
在Java虚拟机规范中,对这个虚拟机栈规定了两种异常:线程请求的栈深度大于虚拟机栈运行的深度,抛出*Error异常;如果虚拟机可以动态扩展(大部分Java虚拟机都可以动态扩展),如果扩展时无法申请到足够的内存,则抛出OutOfMemoryError异常。
4.2.1 *Error – 递归
我们在使用递归的时候要千万小心,递归的层级太深,超过了jvm规定了Java虚拟机栈的最大深度的时候抛出*Error异常。比如如下的代码:
/**
* jvm规定了Java虚拟机栈的最大深度,当执行时栈的深度大于规定的深度,就会抛出*Error错误。
* 当执行Java方法时会进行压栈操作,比如用用如下的实例来模拟这个异常.一直递归调用同一个方法(一直压栈)
*/
public class *ErrorTest {
private int stackLength = 1;
/**
* 一直调用同一个方法,会一直入栈,最终超过Java虚拟机栈规定的最大深度抛出*Error异常
*/
private void stackPush() {
stackLength++;
stackPush();
}
public static void main(String[] args) {
*ErrorTest demo = new *ErrorTest();
try {
demo.stackPush();
} catch (Throwable throwable) {
System.out.println("当前栈深度:stackLength=" + demo.stackLength);
throwable.printStackTrace();
}
}
}
4.2.2 *Error – 对象间相互引用
我们在使用两个对象相互引用的时候也要特别注意,相互引用可能存在函数相互调用的情况。比如下面的例子,toString()方法相互调用,最终超过了jvm规定了Java虚拟机栈的最大深度的时候抛出*Error异常。比如如下的代码就会抛出*Error异常。
/**
* 模拟两个对象相互引用可能产生*Error的情况
* toString()方法相互调用
*/
public class MutualReference*Error {
static class Student {
Teacher teacher;
@Override
public String toString() {
return "Student{" +
"teacher=" + teacher +
'}';
}
}
static class Teacher {
Student student;
@Override
public String toString() {
return "Teacher{" +
"student=" + student +
'}';
}
}
public static void main(String[] args) {
Student student = new Student();
Teacher teacher = new Teacher();
student.teacher = teacher;
teacher.student = student;
System.out.println(teacher.toString());
}
}
4.2.3 OutOfMemoryError – 线程太多
当创建新的线程时JVM会给每个线程分配栈内存,当同一时刻存在的线程过多,如果扩展时无法申请到足够的内存,则抛出OutOfMemoryError异常。比如如下的代码。
/**
* 统一时刻存在的线程太多的时候,如果扩展时无法申请到足够的内存,则抛出OutOfMemoryError异常。
*/
public class ThreadManyOutOfMemoryError {
public static void main(String[] args) throws Exception {
//循环创建线程
for (int i = 0; i < 1000000; i++) {
new Thread(new Runnable() {
public void run() {
try {
//线程sleep时间足够长,保证线程不销毁
Thread.sleep(200000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}).start();
System.out.println("created " + i + "threads");
}
}
}
五 本地方法栈(Native Method Stack)
本地方法栈(Native Method Stack)与虚拟机栈的作用非常相似,区别是:虚拟机栈为虚拟机执行Java方法(也就是字节码)服务;而本地方法栈则为虚拟机使用到的Native方法服务。当线程调用 Java 方法时,虚拟机会创建一个栈帧并压入 Java 虚拟机栈。然而当它调用的是 native 方法时,虚拟机会保持 Java 虚拟机栈不变,也不会向 Java 虚拟机栈中压入新的栈帧,虚拟机只是简单地动态连接并直接调用指定的 native 方法。这些native方法就是在本地方法栈里面存着的。
ps:什么是native方法,简单地讲,一个native method就是一个java调用非java代码的接口。该方法由非java语言实现,比如用C语言实现。
到此,我们把JVM的结构(方法区,堆区,程序计数器,虚拟机栈,本地方法栈)和结构里面的内容做了一个简单的介绍。并且还介绍了一些OOM,以及这些OOM产生的地方,和为什么会产生OOM。除了上面咱们介绍的方法区里面的OOM、堆里面的OOM、虚拟机栈里面的OOM之外。其实咱们还有一种内存溢出。因为这个内存直接和机器内存大交道。ByteBuffer.allocateDirect()直接申请的是机器内存。比如如下代码就可能排除直接内存溢出的错误。
/**
* 直接内存溢出 java.lang.OutOfMemoryError
* ByteBuffer.allocateDirect() 申请的是本地的直接内存,并非java堆内存
*
*/
public class DirectMemoryOutOfMemoryErrorMock {
private static final int _1MB = 1024 * 1024;
public static void main(String[] args) {
for (int i = 0; i < 1000000; i++) {
//申请堆外内存,这个内存是本地的直接内存,并非java堆内存
ByteBuffer byteBuffer = ByteBuffer.allocateDirect(1024 * 1024 * 1024);
System.out.println("created " + i + " byteBuffer");
}
}
//
// public static void main(String[] args) throws Exception {
// Field unsafeField = Unsafe.class.getDeclaredFields()[0];
// unsafeField.setAccessible(true);
// Unsafe unsafe = (Unsafe) unsafeField.get(null);
// while (true) {
// unsafe.allocateMemory(_1MB);
// }
// }
}