二、JVM内存模型
1、Java语言跨平台特性
- java程序主要通过JVM来实现跨平台的,JVM编译器将Java源代码文件编译成字节码文件(一次编译,随处运行),然后不同的操作系统生成的机器码不同,但是JVM运行是相同的,JVM解释器将字节码文件转换为机器可执行的二进制机器码
2、JVM内存模型
- jvm包含两个子系统为类装载子系统和字节码执行引擎。两个组件为运行时数据区和本地接口。
- 类装载子系统:根据给定的全限定名类名(如:java.lang.Object来装载Class文件到Runtimedata area中的Metaspace。
字节码执行引擎:执行classes中的指令。
本地接口:与本地方法库交互,是其它编程语言交互的接口。
运行时数据区:这就是我们常说的JVM的内存。
- 类装载子系统:根据给定的全限定名类名(如:java.lang.Object来装载Class文件到Runtimedata area中的Metaspace。
- 一个Java程序的执行过程:
- 首先通过编译器把 Java 代码转换成字节码。
- 类装载子系统(ClassLoader)再把字节码加载到内存中,将其放在运行时数据区(Runtime data area)的方法区内,而字节码文件只是 JVM 的一套指令集规范,并不能直接交给底层操作系统去执行。
- 字节码执行引擎(Execution Engine),将字节码翻译成底层系统指令。
- 由 CPU 去执行,而这个过程中需要调用其他语言的本地库接口(Native Interface)来实现整个程序的功能。
示例
-
//上图示例中的代码: public class Math { public static int initData = 123; public static User user = new User(); 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("end"); } }
-
通过类加载器将类(类元信息)加载到方法区中,compute() 方法也被加载到方法区中;java代码在执行 math.compute() 的时候,通过 math 对象的对象头中的头指针找到存储在方法区中的 compute() 方法的指令码的入口地址,然后将这个入口地址放到栈中的动态链接内存区域中。
- 类装载子系统(ClassLoader),将class文件经过加载、验证、准备、解析、初始化后生产类元信息放入到方法区中(类装载子系统主要将类装载到方法区);常量池是在方法区中的。
- 栈里面的变量引用指向堆中的对象;基本类型的局部变量放到对应的栈中,对象放到堆中。
- 对象(在堆中)的对象头中有一个指针,指向的是这个对象所属类的类元信息(方法区中)。
- 静态变量是存在方法区中的,其引用指向堆中的对象。
3、JVM的内存区域
- JVM内存模型主要包括几大模块:程序计数器(Program Counter Register)、虚拟机栈(VM stack)、堆(Heap)、元空间(Metaspace,JDK8后由方法区改为元空间)、本地方法栈(Native Method Stack)。
- 其中堆、元空间是线程共享的,程序计数器、虚拟机栈、本地方法栈是线程私有的。
(1)程序计数器(Program Counter Register)
- 线程私有的。
- 存储指指向当前线程执行到的指令,由字节码执行引擎读取下一条指令。
- 字节码执行引擎通过改变程序计数器来依次读取指令,从⽽实现代码的流程控制。
- 在多线程的情况下,程序计数器⽤于记录当前线程执⾏的位置,从⽽当线程被切换回来的时候能够知道该线程上次运⾏到哪里。
- 为了线程切换后能恢复到正确的执⾏位置,每条线程都需要有⼀个独⽴的程序计数器,各线程之间计数器互不影响,独⽴存储
(2)虚拟机栈(VM Stack)
- 线程私有的。
- 虚拟机栈是由一个个栈帧组成,每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
- 局部变量表:存放局部变量。
- 操作数栈:具体的值,做操作的时候用它来作临时空间,执行完后结果会放到局部变量表中。
- 动态链接:动态链接会将方法的符号引用改为指向内存地址的直接引用,这里是用来存地址的值的。
- 方法出口:内层方法返回外层方法的时候,需要知道外层方法执行到第几行了,方法出口就是存储外层方法执行的位置。
- 存放基本类型的变量、实例方法、引用类型变量都是在函数的栈内存中分配;
- 每一次函数调用都会有一个对应的栈帧被压入虚拟机栈,每一个函数调用结束后,都会有一个栈帧被弹出。
(3)本地方法栈(Native Method Stack)
- 线程私有的。
- 本地方法栈和虚拟机栈所发挥的作用非常相似,区别是: 虚拟机栈为虚拟机执行Java方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native方法服务。
(4)堆(Heap)
- 线程共享的。
- 创建的对象和数组都保存在 Java 堆内存中。
- Java 堆是垃圾收集器管理的主要区域,因此也被称作GC堆(Garbage Collected Heap)
- 包含年轻代与老年代。年轻代分为Eden区与s0区,s1区。默认年轻代占堆的1/3,老年代2/3。Eden : s0 : s1=8 : 1 : 1。
(5)元空间(Program Counter Register)
- 线程共享的。
- 通常用来储存装载的类的元结构信息、常量、静态变量。
- 如果静态变量为对象类型,则存储一个指向堆的内存地址。
4、堆的GC回收机制
- GC 一般分为 Minor GC 和 Full GC。Minor GC会在年轻代中触发,而Full GC是在老年代中触发。
- GC回收的过程:
- 当new 一个对象时,该对象会被放入Eden区,创建的对象越来越多,Eden区快满的时候会触发一次轻量级垃圾回收(Minor GC),会从gc root开始查找,将没有对象引用的对象给回收掉。
- 未被回收的对象被放入S0区,Eden被清空,这些还存活的对象的分代年龄会+1。
- 继续 new 对象,当Eden区再次快放满的时候,又会触发一次垃圾回收机制(Minor GC),将Eden区 和 S0区从gc root开始查找,将没有对象引用的对象给回收掉。将未被回收的对象一起放入到S1区,S1区 和 Eden区 被清空,这些还存活的对象的分代年龄会+1。
- 之后Eden区再次快放满的时候,又会触发一次垃圾回收机制(Minor GC),将Eden区 和 S1区从gc root开始查找,将没有对象引用的对象给回收掉。将未被回收的对象一起放入到S0区,S1区 和 Eden区 被清空,这些还存活的对象的分代年龄会+1。
- 对象的对象头中存储了分代年龄的存储信息,当这个对象的分代年龄达到了阈值(默认15),还没有被垃圾机制回收,则会将这些对象放入到老年代。
- 以此类推,一再触发Minor GC,直到老年区存满,则会触发一次重量级垃圾回收机制(Full GC)。Full GC会回收整个堆和方法区。如果full gc后再存对象还是存不下,则会触发OOM异常。
- 在GC回收的时候会触发STW(stop the work),STW会暂停当前运行的线程,这个时候对用户来说会有明显的卡顿,因此JVM调优的目的就是减少STW的次数。
5、为什么要STW
- 因为每次gc的时候从gc root去查找对象是否存活的计算十分复杂,耗时很长。如果不暂停正在运行的线程,会出现一个对象可能在gc root检查的时候是存活对象,然后检查完后,移动到s0区 之后对象执行完毕,被释放掉了。这个时候就浪费了之前的gc root检查。会导致当前的gc root检查出来的结果不正确。
6、JVM内存参数设置
-
Spring Boot程序的JVM参数设置格式(Tomcat启动直接加在bin目录下catalina.sh文件里):
-
java -Xms2048M -Xmx2048M -Xmn1024M -Xss512K -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -jar microservice-eureka-server.jar
-
-Xss:每个线程的栈大小
-
-Xms:设置堆的初始可用大小,默认物理内存的1/64
-
-Xmx:设置堆的最大可用大小,默认物理内存的1/4
-
-Xmn:新生代大小
-
-XX:NewRatio:默认2表示新生代占年老代的1/2,占整个堆内存的1/3。
-
-XX:SurvivorRatio:默认8表示一个survivor区占用1/8的Eden内存,即1/10的新生代内存。
-
关于元空间的JVM参数有两个:-XX:MetaspaceSize=N和 -XX:MaxMetaspaceSize=N
- -XX:MaxMetaspaceSize: 设置元空间最大值, 默认是-1, 即不限制,元空间最大的大小是系统内存的大小,元空间一直扩大,虚拟机可能会消耗完所有的可用系统内存。
- -XX:MetaspaceSize: 指定元空间触发Fullgc的初始阈值(元空间无固定初始大小),如果不指定元空间的大小, 64位系统默认是21M左右,达到该值就会触发full gc进行类型卸载, 同时收集器会对该值进行调整: 如果释放了大量的空间, 就适当降低该值; 如果释放了很少的空间, 那么在不超过-XX:MaxMetaspaceSize(如果设置了的话) 的情况下, 适当提高该值。
-
-XX:PermSize代表元空间(永久代)的初始容量,JDK8及以后已经没有了。
- 由于调整元空间的大小需要Full GC,这是非常昂贵的操作,如果应用在启动的时候发生大量Full GC,通常都是由于永久代或元空间发生了大小调整,基于这种情况,一般建议在JVM参数中将MetaspaceSize和MaxMetaspaceSize设置成一样的值,并设置得比初始值要大,对于8G物理内存的机器来说,一般我会将这两个值都设置为256M。
-
-
StackOverflowError示例:
-
// JVM设置 -Xss128k(默认1M) public class StackOverflowTest { static int count = 0; static void redo() { count++; redo(); } public static void main(String[] args) { try { redo(); } catch (Throwable t) { t.printStackTrace(); System.out.println(count); } } } 运行结果: java.lang.StackOverflowError at com.tuling.jvm.StackOverflowTest.redo(StackOverflowTest.java:12) at com.tuling.jvm.StackOverflowTest.redo(StackOverflowTest.java:13) at com.tuling.jvm.StackOverflowTest.redo(StackOverflowTest.java:13) ......
-
结论:-Xss设置越小count值越小,说明一个线程栈里能分配的栈帧就越少,但是对JVM整体来说能开启的线程数会更多。
-
7、JVM内存参数大小该如何设置?
-
JVM参数大小设置并没有固定标准,需要根据实际项目情况分析,下面提供一个案例
-
日均百万级订单交易系统设置JVM参数案例
-
我们初次给服务器的内存参数设置如下
-
‐Xms3072M ‐Xmx3072M ‐Xss1M -XX:MetaspaceSize=512M ‐XX:MaxMetaspaceSize=512M # ‐Xms3072M 表示堆初始化内存为3G # -Xmx3072M 表示堆最大内存为3G # -Xss1M 表示每个线程内存为1M # -XX:MetaspaceSize=512M 表示元空间初始化内存为512M # -XX:MaxMetaspaceSize=512M 表示元空间最大内存为512M #因为堆的新生代:老年代比例大约是1:2, 即新生代为1G、老年代为2G。 在新生代中Eden区、S0区、S1区比例大约是8:1:1,即Eden区为800M,S0区为100M,S1区为100M。
-
由上图可知,生成对象首先会在Eden区,如果每秒产生60M对象,那么14秒后Eden区就会被占满,此时会触发Minor GC,第14秒产生的对象因为可能还会被引用所以没有被回收,根据垃圾回收机制存活的对象会被放到S0区,但是根据动态对象年龄判断原则,这60M对象同龄而且总和大于S0区的50%,那么这些对象都会被挪到老年代。14秒就会产生一个60M对象到老年代且1秒后就会变成垃圾,大概8分钟老年代的内存就会被沾满,就会触发Full GC。因为Full GC是重量级回收,如果每8分钟就要执行一次Full GC会导致系统性能很低。所以合理设置内存参数可以达到几乎不产生Full GC的效果。
-
我们再次给服务器的内存参数设置如下
-
-Xms3072M -Xmx3072M -Xmn2048M -Xss1M -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M # ‐Xms3072M 表示堆初始化内存为3G # -Xmx3072M 表示堆最大内存为3G # -Xmn2048M 表示新生代大小为2G # -Xss1M 表示每个线程内存为1M # -XX:MetaspaceSize=512M 表示元空间初始化内存为512M # -XX:MaxMetaspaceSize=512M 表示元空间最大内存为512M #此时设置新生代为2G,所以老年代为1G。 在新生代中Eden区、S0区、S1区比例大约是8:1:1,即Eden区大约为1638M,S0区为205M,S1区为205M。
-
在重新设置了服务器内存参数后,我们再次分析:如果每秒产生60M对象,则需要大约27秒Eden区会被占满,此时触发Minor GC,第14秒产生的对象因为可能还会被引用所以没有被回收,根据垃圾回收机制存活的对象会被放到S0区,此时由于这60M对象同龄总和没有达到S0区的50%,不会直接进入老年代,因此在下一次Minor GC就会被回收。
-
合理设置内存参数可以达到几乎不产生Full GC的效果。
-
JVM优化核心思想:就是尽可能让对象都在新生代里分配和回收,尽量别 让太多对象频繁进入老年代,避免频繁对老年代进行垃圾回收,同时给系统充足的内存大小,避免新生代频繁的进行垃圾回收。