Jvm内存模型
1.JDK体系架构
从上面JDK体系架构图可以看出来:
- JDK包含了java的常用工具开发包和jre
- jre是java运行时环境,包括了jvm和Java核心类库
- jvm负责把字节码文件解释为操作系统可以识别的机器码
2.Java语言的跨平台性
我们的java文件经过编译形成class文件后,为什么可以放在装有jdk的操作系统上就可以运行呢?这是因为针对不同的操作系统有不同的jvm实现,而jvm负责把我们编译好的字节码文件翻译为操作系统可以识别的机器码并运行。
3.Jvm的内存模型
声明一个Math类
public class Math {
public static final int initData = 10;
public static User user = new User();
public static String s = new String();
public int compute(){
int a = 1;
int b = 2;
int c = (a + b) *10;
return c;
}
public static void main(String[] args) {
Math math = new Math();
math.compute();
System.out.println(s.getClass().getClassLoader());
}
}
Jvm的内存模型如上图,主要分为方法区,堆空间,虚拟机栈,本地方法栈,程序计数器,接下来为大家详细描述每一块区域:
3.1 虚拟机栈
栈空间是我们线程私有的,当我们每次创建一个新的线程的时候,jvm都会为它分配一块私有的栈内存,当线程执行方法的时候,每执行一个方法会形成一个栈帧,最先进入的方法被压到栈底,遵循先进后出的原则。
在栈帧中都有什么呢?它主要存放的是局部变量表,操作数栈,动态链接,方法出口,我们先把math.class文件生成反汇编文件来了解这些内存区域
javap -c Math.class > Math.txt
经过这个指令,我们的Math类生成的反汇编文件compute方法内容如下:
主要看我们的compute方法这里的指令,iconst_1是把我们的1放在操作数栈空间中,istore_1 是把1这个数存入局部变量表索引1的这个位置,也就是a=1,iconst_2是把2压入操作数栈,istore_2把2存入局部变量表索引2的位置,也就是b=2,iload_1是从局部变量表索引1位置取出操作数,iload_2 从局部变量索引2位置取出操作数,iadd就是把两个数相加,bipush 10 将一个8位带符号整数10压入操作数栈,imul是进行一次乘法运算,istore_3是将i相乘的结果存入局部变量表索引3的位置,
iload_3 从局部变量索引3的位置中中装载取值,ireturn将结果进行返回
iconst_1 将int类型常量1压入操作数栈
istore_1 将int类型值存入局部变量1
iconst_2 将int类型常量2压入操作数栈
iload_1 从局部变量1中装载int类型值
iload_2 从局部变量2中装载int类型值
iadd 执行int类型的加法
bipush 将一个8位带符号整数压入栈
imul 执行int类型的乘法
istore_3 将int类型值存入局部变量索引3位置
ireturn 从方法中返回int类型的数据
3.2 本地方法栈
本地方法栈和虚拟机栈一样,是当线程执行本地方法的时候虚拟机开辟的一片空间,只不过这里的方法是本地方法,方法实现是通过c或者c++实现的。
3.3 程序计数器
程序计数器也是线程私有的,主要目的是当线程发生上下文切换的时候,记录一下我们代码的运行位置,字节码解释器工作时,通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
3.4 方法区
方法区在JDK1.8和JDK1.7的概念是不同的,在JDK1.7中我们的方法区被称作永久代,其内存区域是由JVM提供的,而JDK1.8中被称作元空间,它使用的是我们物理机的直接内存,在方法区主要存储的是我们的常量,静态变量,类信息
3.5 堆
堆在虚拟机启动时创建,是Java 虚拟机所管理的内存中最大的一块,主要用于存放对象实例,几乎所有的对象实例都在这里分配内存,注意Java堆是垃圾收集器管理的主要区域,因此很多时候也被称做GC堆,如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError 异常。
3.6 常量池
3.6.1 Class常量池和运行时常量池
Class常量池可以理解为是Class文件中的资源仓库。 Class文件中除了包含类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(constant pool table) ,用于存放编译期生成的各种字面量(Literal)和符号引用(Symbolic References) 。
字面量:就是等号右边的数字、字符串,如:int a=1 这里的1为就是字面量
符号引用:符号引用是编译原理中的概念,是相对于直接引用来说的,主要包括了以下三类常量:
-
类和接口的全限定名
-
字段的名称和描述符
-
方法的名称和描述符
比如a=1中a就是一个符号引用,比如一个类的全路径com.lx.Math,包含类中的方法比如main、test等,()是一种UTF8格式的描述符,这些都是符号引用。
这些常量池现在是静态信息,只有到运行时被加载到内存后,这些符号才有对应的内存地址信息,这些常量池一旦被装入内存就变成运行时常量池,对应的符号引用在程序加载或运行时会被转变为被加载到内存区域的代码的直接引用,也就是我们说的动态链接了。例如,main()这个符号引用在运行时就会被转变为main()方法具体代码在内存中的地址,主要通过对象头里的类型指针去转换直接引用。
3.6.2 字符串常量池
3.6.2.1 字符串常量池的设计思想
字符串的分配,和其他的对象分配一样,耗费高昂的时间与空间代价,作为最基础的数据类型,大量频繁的创建字符串,极大程度地影响程序的性能
JVM为了提高性能和减少内存开销,在实例化字符串常量的时候进行了一些优化
- 为字符串开辟一个字符串常量池,类似于缓存区
- 创建字符串常量时,首先查询字符串常量池是否存在该字符串
- 存在该字符串,返回引用实例,不存在,实例化该字符串并放入池中
3.6.2.2 字符串常量池位置
Jdk1.6及之前: 有永久代, 运行时常量池在永久代,运行时常量池包含字符串常量池
Jdk1.7:有永久代,但已经逐步“去永久代”,字符串常量池从永久代里的运行时常量池分离到堆里
Jdk1.8及之后: 无永久代,运行时常量池在元空间,字符串常量池里依然在堆里
如何证明,jdk1.6之后我们的字符串常量池是在堆中的,请看一下程序和结果
//-Xms6M -Xmx6M
public class StringConstantPoolOOM {
public static void main(String[] args) {
ArrayList list = new ArrayList();
for (int i = 0; i < 10000000; i++) {
String str = String.valueOf(i).intern();
list.add(str);
}
}
}
运行结果:堆空间不足
接下来我们通过案例一起更进一步分析一下字符串常量池
三种字符串操作:
String s = "luoxue"; // s指向常量池中的引用
这种方式创建的字符串对象,只会在常量池中,因为有"luoxue"这个字面量,创建对象s的时候,JVM会先去常量池中通过 equals(key) 方法,判断是否有相同的对象,如果有,则直接返回该对象在常量池中的引用,如果没有,则会在常量池中创建一个新对象,再返回引用。
String s1 = new String("luoxue"); // s1指向内存中的对象引用
这种方式会保证字符串常量池和堆中都有这个对象,没有就创建,最后返回堆内存中的对象引用。解析:因为有"qianyue"这个字面量,所以会先检查字符串常量池中是否存在字符串"qianyue",不存在,先在字符串常量池里创建一个字符串对象;再去内存中创建一个字符串对象"qianyue",存在的话,就直接去堆内存中创建一个字符串对象"qianyue",最后,将内存中的引用返回。
String s1 = new String("luoxue"); //s1->指向堆中的对象
String s2 = s1.intern();//s2->常量池中的对象
System.out.println(s1);//luoxue
System.out.println(s2);//luoxue
System.out.println(s1==s2);//false
String中的intern方法是一个 native 的方法,当调用 intern方法时,如果池已经包含一个等于此String对象的字符串(用equals(oject)方法确定),则返回池中的字符串。否则,将intern返回的引用指向当前字符串 s1
//堆中创建了一个字符串hello,但是没有在字符串常量池中创建,s1指向堆中hello对象的地址
String s1 = new String("he")+new String("llo");
//发现常量池没有,直接返回堆中hello的地址
String s2 = s1.intern();
System.out.println(s1);//hello
System.out.println(s2);//hello
System.out.println(s1==s2);//true
4.JVM内存参数设置
Spring Boot程序的JVM参数设置格式(Tomcat启动直接加在bin目录下catalina.sh文件里):
java ‐Xms2048M ‐Xmx2048M ‐Xmn1024M ‐Xss512K ‐XX:MetaspaceSize=256M ‐XX:MaxMetaspaceSize=256M ‐jar microservice‐eurek
a‐server.jar
关于元空间的JVM参数有两个:-XX:MetaspaceSize=N和 -XX:MaxMetaspaceSize=N
- -XX:MaxMetaspaceSize: 设置元空间最大值, 默认是-1, 即不限制, 或者说只受限于本地内存大小。
- -XX:MetaspaceSize: 指定元空间触发Fullgc的初始阈值(元空间无固定初始大小), 以字节为单位,默认是21M,达到该值就会触发 full gc进行类型卸载, 同时收集器会对该值进行调整: 如果释放了大量的空间, 就适当降低该值; 如果释放了很少的空间, 那么在不超过-XX:MaxMetaspaceSize(如果设置了的话) 的情况下, 适当提高该值。这个跟早期jdk版本的-XX:PermSize参数意思不一样,- XX:PermSize代表永久代的初始容量。
由于调整元空间的大小需要Full GC,这是非常昂贵的操作,如果应用在启动的时候发生大量Full GC,通常都是由于永久代或元空间发生了大小调整,基于这种情况,一般建议在JVM参数中将MetaspaceSize和MaxMetaspaceSize设置成一样的值,并设置得比初始值要大, 对于8G物理内存的机器来说,一般会将这两个值都设置为256M。