JVM内存模型
jvm内存模型图
程序计数器(PC寄存器)
-
程序计数器是什么?
记录当前线程(线程私有)
执行字节码的偏移地址的指示器。 -
程序计数器能做什么?
在线程切换的程序中,保证cpu能记住每个线程执行的位置,使程序正确执行。 -
特点:
线程私有;
java虚拟机规范中,唯一一个没有规定任何OutOfMemoreyError
情况的区域;
原因:
程序计数器保存偏移地址,是一个固定宽度的整数存储空间,所以不会OutOfMemoreyError。
占用内存小,计算内存可忽略不计;
如果执行的是native方法,计数器的值为undefined。
保存undefined如何判断执行顺序?
虚拟机栈
什么是虚拟机栈?
虚拟机栈是描述方法执行的动态内存模型,生命周期与线程相同。
每调用一个方法,就会为这个方法生成一个栈帧。
示例:
public class Test {
public static void main(String[] args) {
A();
}
public static void A() {
System.out.println("方法A执行");
B();
System.out.println("方法A执行完毕");
}
public static void B() {
System.out.println("方法B执行");
System.out.println("方法B执行完毕");
}
}
运行结果:
图解:
通过程序的debug也可查看方法出入栈的过程:
栈帧
结构图:
局部变量表(数组结构实现的):
-
功能
存放编译期可知的各种方法参数和方法内定义的局部变量,在 Java 程序编译为 Class 文件时,就在方法的 Code 属性的 max_locals 数据项中确定了该方法所需要分配的局部变量表的最大容量(反编译后可以看到locals已经设值,即可证实这一点),方法运行期间不会改变局部变量表的大小。 -
变量槽(slot)
变量槽是什么:
局部变量表以变量槽为最小单位。
存储数据:
一个slot可存储一个32位以内的数据类型(boolean,byte,short,char,int,float,一个对象实例的引用),对于64位的数据类型(long,double),虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。
虚拟机如何使用变量槽:
虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的Slot数量。访问32位的变量,索引n就代表第n个slot;如果访问的是64位的变量,则同时使用n和n+1个slot,且不允许以任何方式单独访问这两个slot中的任何一个。非static方法的0位索引,为方法所属对象的引用(this),但静态方法并不会存储this的slot,所以静态方法中不能使用this。
图示:
slot的复用:
复用前代码:public class Test { public static void main(String[] args) { int a = 20; int b = 10; } }
javac Test.java进行编译
javap -v Test进行反编译得到如下代码:(使用idea的jclasslib插件也可查看,具体使用方法 自行百度)//其他信息省略 public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=1, locals=3, args_size=1 //locals=3表示使用了3个slot,slot0表示this对象的引用 0: bipush 20 2: istore_1 //把数字20放到slot1的位置 3: bipush 10 5: istore_2 //把数字10放到slot2的位置 6: return LineNumberTable: line 16: 0 line 17: 3 line 18: 6
复用后的代码:
public class Test { public static void main(String[] args) { { int a = 20; } int b = 10; }
反编译结果:
public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: ACC_PUBLIC, ACC_STATIC Code: stack=1, locals=2, args_size=1 //locals=2表示使用了2个slot,slot0表示this对象的引用 0: bipush 20 2: istore_1 //把数字20放到slot1的位置 3: bipush 10 5: istore_1 //把数字10放到slot1的位置,slot1进行复用 6: return LineNumberTable: line 17: 0 line 19: 3 line 20: 6
slot复用对垃圾回收的影响:
设置JVM参数-verbose:gc,运行以下代码public class Test { public static void main(String[] args) { { byte[] test = new byte[60 * 1024 * 1024]; } // int b = 10; System.gc(); } }
运行结果:
63528k -> 63332k,test并未被回收。
优化代码:public class Test { public static void main(String[] args) { { byte[] test = new byte[60 * 1024 * 1024]; } int b = 10; System.gc(); } }
63496k -> 1892k,test被回收了
原因:test能否被回收的根本原因是,局部变量表中的slot是否还存在对test对象的引用,优化前,虽然已经离开了test对象的作用域,但之后没有对局部变量表进行任何读写操作,test原本占用的slot并未被复用。此操作可用对test对象赋null值的操作代替。 -
局部变量与类变量的区别
局部变量不存在类变量的“准备阶段”,所以必须手动为局部变量赋值,否则编译不通过:public class Test { public static void main(String[] args) { int i; System.out.println(i); //此处编译不通过 } }
操作数栈(数组的结构实现的):
- 操作数栈是什么
操作数栈是虚拟机的工作区,大多数指令都从这里弹出数据,执行计算,然后把结果压回操作数栈,是数据运算的地方。在编译期,就在Class 文件的Code 属性的 max_stacks 指定了执行过程中最大的栈深度。 - 访问数据的方式
操作数栈通过压栈、出栈来访问数据,而不是通过索引访问。
动态链接:
- 什么是动态链接
动态连接是指向运行时常量池中该栈帧所属方法的引用,是方法中的代码在元数据区(方法区)的入口,比如方法A调用方法B,在方法A的栈帧中就存储方法B在运行时常量池中的符号引用。
- 符号引用与直接引用
java类编译为class文件时,虚拟机并不知道所引用类、变量、方法的真实地址,便以符号来代替,就是符号引用;直接引用是可以直接指向目标的指针、相对偏移量或句柄,有了直接引用,那么直接引用的目标一定被加载到了内存中。 - 静态链接与动态链接
在解析阶段,将符号引用转换为直接引用的引用,就是静态链接;在运行阶段,将符号引用转换为直接引用的引用,就是动态链接。
方法返回地址:
正常情况,方法调用者的PC计数器的值作为方法返回地址,如果出现异常,栈帧一般不保存此信息。
附加信息:
虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与
调试相关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再详述。在实际开发
中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。
内存溢出-栈溢出
-
栈深度不够:StackOverFlow
产生原因:
每新建一个线程,会分配给该线程栈内存一个初始值,当通过递归不断调用方法,不断创建栈帧,栈深度太深,栈内存不断被使用,当超过该线程的栈内存(但此时的物理内存是够的),就会产生StackOverflowError错误。
举例:
public class Test { public static void main(String[] args) { main(args); } }
设置:
可通过-Xss设置栈的大小 -
栈内存不足:OutOfMemory
产生原因:
创建的线程过多,导致物理内存不足。
本地方法栈
- 什么是本地方法栈
本地方法栈是为虚拟机使用到的native修饰的本地方法(非java实现的方法)提供服务的。 - 为什么要使用本地方法
与操作系统进行交互,与sun的解释器进行一些交互,与外部环境进行交互。
堆
结构
结构图
-
新生代与老年代的比例为1:2。
-
调整新生代与老年代比例的参数 -XX:NewRatio=2
默认情况:public class HeapMemoryRatio { public static void main(String[] args) { //等待线程,便于使用工具查询 new Thread(()->{ try { Thread.sleep(1000000); } catch (InterruptedException e) { e.printStackTrace(); } }).start(); } }
新生代:125.5+20.5+20.5=166.5m
老年代:333.5m
比例:1:2设置参数:-XX:NewRatio=4 运行程序:
新生代:75+12.5+12.5=100m
老年代:400m
比例:1:4另外一种查看方式:使用jps和jinfo -flag NewRatio的组合命令查看。
运行上述程序:
-
什么情况下调整此参数?
当明确知道生命周期比较长的对象比较多时,建议将老年代的比例调大。
新生代
-
描述:
新生代分为Eden区,Survivor from和Survivor to三部分,其占新生代内存容量默认比例分别为8:1:1,其中Survivor from和Survivor to总有一个区域是空白。 -
设置eden与survivord的比例的参数:-XX:SurvivorRatio=8
运行程序:public class HeapMemoryRatio { public static void main(String[] args) { //等待线程,便于使用工具查询 new Thread(()->{ try { Thread.sleep(1000000); } catch (InterruptedException e) { e.printStackTrace(); } }).start(); } }
由结果可知,比例为 75:12.5:12.5 = 6:1:1,并不是默认的8:1:1,WTF,什么情况?
其实是jvm设置了自适应的内存分配策略。使用-XX:SurvivorRatio=8参数显性的设置为8:1
此时比例为 133.5:16.5 = 8:1 -
-Xmn:100m 设置新生代的内存大小
当同时设置-Xmn与-XX:NewRatio参数使,以-Xmn设置的为准。
老年代
堆空间分代的作用
优化GC,分类GC。
堆的作用
堆在虚拟机启动时创建,唯一目的是存放对象实例。
堆内存的设置与查看
设置
- 设置参数
-Xms 设置堆空间(新生代+老年代)的初始内存大小;
-Xmx 设置堆空间(新生代+老年代)的最大内存大小。 - 设置原则
最好将初始堆内存与最大堆内存设置为相同的值,防止堆内存频繁的扩容和释放,减轻系统压力。
查看
-
默认情况
初始内存大小占物理内存的1/64,最大内存占物理内存的1/4。程序验证:
public static void main(String[] args) { //堆内存总量 long initMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024; //最大堆内存 long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024; System.out.println("-Xms:"+initMemory+"m"); System.out.println("-Xmx:"+maxMemory+"m"); System.out.println("系统内存大小:"+initMemory * 64.0/1024 + "G"); System.out.println("系统内存大小:"+maxMemory * 4.0/1024 + "G"); }
-
设置堆内存后
疑问:
为什么明明设置的是500m,程序输出却是479m?下面的内容会回答这个问题。 -
查看堆内存使用情况,方式一
使用jps+jstat命令组合查看。
运行程序:public static void main(String[] args) { //堆内存总量 long initMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024; //最大堆内存 long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024; System.out.println("-Xms:"+initMemory+"m"); System.out.println("-Xmx:"+maxMemory+"m"); // System.out.println("系统内存大小:"+initMemory * 64.0/1024 + "G"); // System.out.println("系统内存大小:"+maxMemory * 4.0/1024 + "G"); //加线程等待,使得程序可使用jps命令 new Thread(()->{ try { Thread.sleep(1000000); } catch (InterruptedException e) { e.printStackTrace(); } }).start(); }
其中C表示总量,U表示正在使用的。
内存计算:
(S0C+S1C+EC+OC)/1024 = 500m,与我们设置的内存相符。
但是实际的程序运行过程中,s0与s1只能同时使用一个,
所以实际的内存计算为(S0C或S1C+EC+OC)/1024 = 479m,与我们程序得出的结果是一致的。 -
查看堆内存使用情况,方式二
使用 -XX:+PrintGCDetails参数。
设置参数:
运行程序:public static void main(String[] args) { //堆内存总量 long initMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024; //最大堆内存 long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024; System.out.println("-Xms:"+initMemory+"m"); System.out.println("-Xmx:"+maxMemory+"m"); // System.out.println("系统内存大小:"+initMemory * 64.0/1024 + "G"); // System.out.println("系统内存大小:"+maxMemory * 4.0/1024 + "G"); //加线程等待,使得程序可使用jps命令 // new Thread(()->{ // try { // Thread.sleep(1000000); // } catch (InterruptedException e) { // e.printStackTrace(); // } // }).start(); }
与上一种查看方式得到的结果是一样的。 -
查看堆内存使用情况,方式三
使用java自带工具jvisualvm(需安装GC插件,自行百度安装方法)。
内存计算:125.0m+20.5m+333.5m=479.5m
与之前的查询结果一致。
堆溢出(OOM)
-
产生原因
对象达到最大堆内存,并且回收不了。 -
程序实例
public class HeapOutOfMemory { public static void main(String[] args) { List list = new ArrayList(); while (true) { list.add(new HeapOutOfMemory()); } } }
对象分配
对象分配流程图
- 流程图
- 在推内存模型中的分配过程图
上图中的阈值可通过-XX:MaxTenuringThreshold参数设置,默认为15。
对象分配策略
- 栈上分配
-
作用
将满足条件的对象分配到栈上,随着方法的压栈出栈,清除对象,减轻GC的压力。 -
分配条件
线程私有小对象
无逃逸(没有外部的引用)
-
逃逸分析(其实在HotSpot中并未使用逃逸分析,真正达到栈上分配效果的是标量替换)
【作用】:
分析java对象
的使用范围。
【逃逸分类】:
方法逃逸:
一个对象在方法中被定义后,可能会被外部的方法所引用,例如作为参数传递到其他方法中。
线程逃逸:
被外部线程访问到,例如赋值给类变量或能够在其他线程中访问的变量,所以静态变量一定是发生逃逸的。
【如何判断是否发生逃逸】:
new的对象是否在外部被使用。
【常见逃逸分析场景】:public class EscapeAnalyze { /** * 无逃逸 */ public void test1() { Object obj = new Object(); } /** * 有逃逸 * @return */ public Object test2() { return new Object(); } /** * 有逃逸 * @return */ public StringBuilder test3() { return new StringBuilder(); } /** * 无逃逸 * @return */ public String test4() { return new StringBuilder().toString(); } }
【举例】:
程序:public class EscapeAnalyzeExample { public static void main(String[] args) { long startTime = System.currentTimeMillis(); for (int i=0; i<1000000; i++) { test(); } long endTime = System.currentTimeMillis(); System.out.println(endTime-startTime+"ms"); //等待 new Thread(()->{ try { Thread.sleep(1000000); } catch (InterruptedException e) { e.printStackTrace(); } }).start(); } public static void test() { new EscapeAnalyzeExample(); } }
设置JVM参数:-XX:-DoEscapeAnalysis 向先关闭栈上分配,结果如下(jvm默认是开启的):
1000000个实例都存在了内存中。删除jvm参数,打开栈上分配,再次运行程序,结果如下:
可以看到并不是所有的实例都在内存中,而且运行速度更快了。
修改程序:public class EscapeAnalyzeExample { public static void main(String[] args) { long startTime = System.currentTimeMillis(); for (int i=0; i<1000000; i++) { test(); } long endTime = System.currentTimeMillis(); System.out.println(endTime-startTime+"ms"); //等待 // new Thread(()->{ // try { // Thread.sleep(1000000); // } catch (InterruptedException e) { // e.printStackTrace(); // } // }).start(); } public static void test() { new EscapeAnalyzeExample(); } }
设置参数:-Xms200m -Xmx200m -XX:-PrintGCDetails 查看GC情况,结果如下:
结果显示,关闭栈上分配后,发生了GC。
打开栈上分配,再次运行:
结果显示,开启栈上分配后,没有发生GC。 -
标量替换
【描述】:
将无外部引用的聚合变量替换为标量,默认开启。//聚合变量 class User { //标量 private String name; }
【举例】:
设置jvm参数:-Xms200m -Xmx200m -XX:-EliminateAllocations,关闭标量替换。
运行程序:public class EscapeAnalyzeExample { public static void main(String[] args) { long startTime = System.currentTimeMillis(); for (int i=0; i<1000000; i++) { test(); } long endTime = System.currentTimeMillis(); System.out.println(endTime-startTime+"ms"); //等待 // new Thread(()->{ // try { // Thread.sleep(1000000); // } catch (InterruptedException e) { // e.printStackTrace(); // } // }).start(); } public static void test() { new User(); } } class User { private String name; }
结果如下:
进行了GC,可见即使开启了逃逸分析,但关闭标量替换后,依然没有达到线上分配的效果。打开标量替换,再次运行程序,结果如下:
没有进行GC,达到了栈上分配的效果。
- 线程本地分配(TLAB)
- 什么是TLAB?
TLAB是占eden区1%的,每个线程私有的一个缓存区。 - 为什么要有TLAB?
因为堆空间是线程共享的,而且操作频繁,为保证线程安全,需要使用加锁机制,进而影响性能,使用TLAB可以解决部分此类问题,加速对象的分配,提升性能。 - JVM参数
-XX:+UseTLAB,默认是开启的。
- 动态对象年龄判断
在survivor区中,相同年龄段的对象大于survivor空间一半时,直接进入老年代。 - 空间分配担保
当进行YGC后,空白Survivor空间无法存下对象,且老年代有足够的空间,就将Survivor无法存下的对象分配到老年代。
方法区(jdk1.8之后元空间/jdk1.8之前永久代)
注意
-
方法区是虚拟机规定的结构标准,而不同的虚拟机有不同的实现,永久代是HotSpot在JDK1.8之前的实现,元空间是HotSpot在JDK1.8的实现。
-
JDK1.8后,将方法区(元空间)从虚拟机堆内存移动到了本地内存。
为什么将方法区从堆空间移动到本地内存?
- 永久代不好设置空间大小。
- 对永久代进行调优较困难。
-
字符串常量池与静态变量在JDK1.6及以前存在方法区,但1.7后存在堆空间中。
为什么将字符串常量池从永久代移动到堆空间?
因为永久代的GC效率低,只有fullGC时才触发,放到堆空间,可及时回收。
结构
-
结构图
-
存储信息
-
类型信息
类型的全限定名;
超类的全限定名;
直接超接口的全限定名;
类型标志(该类是类类型还是接口类型);
类的访问描述符(public,private,default,abstract,final,static) -
class常量池(每个常量都有一个索引)
【存储信息】:
直接常量(字面量):
文本字符串 、八种基本类型的值 、被声明为final的常量等。
对其他类型、字段、方法的符号引用
【为什么要用常量池】:
防止所有编译的信息都直接存储到字节码文件中,使得文件过于臃肿。
【在字节码文件中的位置】:
-
字段信息
字段修饰符;
字段类型;
字段名称。 -
方法的信息
方法名;
方法返回类型;
方法修饰符;
方法参数个数、类型、顺序;
方法字节码;
操作数栈和该方法在栈帧中局部变量的大小;
异常表。 -
指向类加载器的引用
每一个被JVM加载的类型,都保存这个类加载器的引用,类加载器动态链接时会用到。 -
指向class实例的引用
-
方法表
对于每个非抽象类,都创建一个存放对象可能调用的方法的直接引用的数组,用来提高访问效率。
- 运行时常量池(字节码文件中的class常量池加载到内存中的版本
jdk1.7及以后存在堆中
)
设置方法区内存大小
- jvm参数
-XX:MetaspaceSize=100m 设置初始值,默认值为21m;
-XX:MaxMetaspaceSize=100m 设置最大值。 - 当方法区的大小超过MetaspaceSize后,会触发fullGC,所以将MetaspaceSize设置为一个相对较高的值。
内存溢出(OOM)
设置参数:-XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m
运行程序:
public class MethodAreaOutOfMemory extends ClassLoader{
public static void main(String[] args) {
MethodAreaOutOfMemory outOfMemory = new MethodAreaOutOfMemory();
int j = 0;
try {
for (int i=0; i<10000; i++) {
//classWriter对象 生成二进制字节码
ClassWriter classWriter = new ClassWriter(0);
//指定版本、修饰符、类名、包名、父类、接口
classWriter.visit(Opcodes.V1_8,Opcodes.ACC_PUBLIC,"class"+i,null,"java/lang/Object",null);
//返回byte
byte[] bytes = classWriter.toByteArray();
//加载类
outOfMemory.defineClass("class"+i,bytes,0,bytes.length);
j++;
}
} finally {
System.out.println(j);
}
}
}
栈、堆、方法区的交互
执行引擎
作用
将编译后的字节码解释/编译为机器指令。
图示
HotSpot虚拟机是解释器与即时编译器结合使用的。
解释器
解释器是将字节码逐行进行解释的,每次执行都需要解释,所以效率较低,但是响应速度快,当程序启动时,解释器就能马上执行。
JIT(即时编译器)
将“热点代码”编译为机器指令,并缓存起来,当需要执行时,直接执行机器指令,效率高,但不是程序运行时立马就能使用的,响应速度较慢。
热点代码探测使用JIT
HotSopt为每个方法建立两个不同的计数器,方法调用计数器(统计方法被调用次数)和回边计数器(统计循环体循环次数)。
- 方法调用计数器
注意:计数器的值不会一直增加,超过一段时间就会衰减。 - 回边计数器