目录
JVM的内存模型
JVM的内存分为5个区域:
-
堆区
-
虚拟机栈
-
方法区
-
本地方法栈
-
程序计数器
程序计数器
线程执行上下文切换时会把当前的代码行数记录下来,线程获得cpu时间片后继续从该代码行执行
程序计数器(Program Counter Register)是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器。
在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。 由于Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。 如果线程正在执行的是一个Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Natvie方法,这个计数器值则为空(Undefined)
此内存区域是唯一一个在Java 虚拟机规范中没有规定任何OutOfMemoryError 情况的区域。
Java 虚拟机栈
保存方法调用的栈帧,每个栈帧中保存当前方法的:局部变量表、操作数栈、动态链接、方法出口
局部变量表: 方法中用到的局部变量,如: int x = 10;
操作数栈: 方法中出现的常量值,如:100、22.2等
动态链接: 链接的外部资源或库
方法出口:方法执行完后,回到哪个位置继续执行
栈的数据结构: 先入后出
class Test{
void test1(){
int x = 10;
String s = "hello";
}
void test2(){
test1();
}
void test3(){
test2();
}
public static void main(){
new Test().test3();
}
}
与程序计数器一样,Java 虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。
虚拟机栈描述的是Java 方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame )用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。 在Java 虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展,当扩展时无法申请到足够的内存时会抛出OutOfMemoryError 异常。
本地方法栈
Java语言底层由c语言实现,Java通过native方法调用c语言方法实现底层功能,如:内存分配、IO、操作系统相关操作
本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。
本地方法栈区域也会抛出StackOverflowError 和OutOfMemoryError异常。
堆区
JVM中最大的内存区域,主要保存创建出来的对象实例
ava 堆(Java Heap)是Java 虚拟机所管理的内存中最大的一块。
Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。 根据Java 虚拟机规范的规定,Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms 控制)。 如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError 异常。
方法区
保存Java中类的信息、常量、静态变量、编译后的代码,线程共享
方法区(Method Area)与Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
虽然Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java 堆区分开来。
Java 虚拟机规范对这个区域的限制非常宽松,除了和Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。
这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收确实是有必要的。在Sun 公司的BUG 列表中,曾出现过的若干个严重的BUG 就是由于低版本的HotSpot 虚拟机对此区域未完全回收而导致内存泄漏。
根据Java 虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError 异常。
注意:
jdk1.8之前,JVM存在永久代
jdk1.8之后,JVM去掉永久代,替换为元空间(Metaspace)在堆区中
Java对象的内存分配
Test test = new Test();
对象回收的算法
GC(Garbage Collection)垃圾收集机制是Java一个重要特性。不同于C/C++语言需要程序员自己管理内存的回收,而且这样做往往容易出错,导致内存泄漏等严重问题。
Java程序员不用编写回收内存的代码,因为Java有GC机制,它是一个特殊的后台线程,该线程对JVM中的内存进行标记,并确定哪些需要回收,再通过一定的回收策略自动回收内存,它在后台一直运行,保证JVM不会出现内存溢出的问题。
那么GC是如何判断某个对象的内存需要回收呢?
GC需要判断该对象已死,也就是不再被调用
如何判断对象不再被调用呢?这里有两种算法:
1、引用计数算法
2、可达性分析算法
引用计数算法
该算法给每个对象分配一个计数器,当有引用指向这个对象时,计数器加1,当指向该对象的引用失效时,计数器减一。最后如果该对象的计数器为0时,java垃圾回收器会认为该对象是可回收的。
Object obj1 = new Object();
Object obj2 = obj1;
obj1 = null; 1
obj2 = null; 0
优点:
1、实时性高,只要对象计数器为0就进行回收,不用等到内存不足的时候。
2、在垃圾回收过程中,应用无需挂起。
3、更新对象的计数器时,只是影响到该对象,不会扫描全部对象。
缺点:
1、每次引用对象时,都会更新计数器,有时间消耗
2、不能解决循环引用问题
那什么是循环引用问题呢?我们看下面这段代码:
1. class ClassA{
2. ClassB b;
3. }
4. class ClassB{
5. ClassA a;
6. }
7. public static void main(String[] args){
8. ClassA a = new ClassA(); 1
9. ClassB b = new ClassB(); 1
10. a.b = b;
11. b.a = a;
12. a = null;
13. b = null;
14. }
上面的a、b两个对象虽然都赋值为null,但是都不能回收,因为存在循环引用,它们的计数器不为0.
可达性分析算法
该算法通过一种被称作“GC Root”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明此对象时不可用的。
在Java语言中,可作为GC Roots对象包括下面几种:
1)虚拟机栈中引用的对象
2)方法区中类静态属性引用的对象
3)方法区常量池中引用的对象
3)本地方法栈中JNI引用的对象
再回头看前面这段代码,虽然a和b对象的引用计数都不为0,但是它们作为GC Root对象,最后都赋值为null,导致引用不可达,这样两个对象都是可以被回收的。
优点:解决对象相互引用问题
缺点:进行GC搜索时,停止整个JVM,导致系统暂停(STW Stop The World)
堆的分代
Java的堆是JVM中最大的一块内存区域,主要保存Java中各种类的实例。为了更好的管理堆中各个对象的内存,包括分配内存和回收内存。
JVM将堆分成了几块区域:
-
新生代(Young)
-
新生代又分为:
-
Eden 伊甸区
-
From Survivor 幸存区1
-
To Survivor 幸存区2
-
-
-
老年代(Old)
其中新生代占堆的1/3空间,老年代占堆的2/3空间。 而新生代中的Eden占新生代的8/10,From Survivor和To Survivor各占新生代的1/10。
堆模型如图:
从上图中我们可以看出:堆是由新生代和老年代组成,默认情况下,新生代 ( Young ) 与老年代 ( Old ) 的比例为 1:2 。 其中,新生代 ( Young ) 又分为 Eden 和 From Survivor 、To Survivor区域。 默认情况下,Eden 和from、to的比例为 :8 : 1 : 1 。 JVM 每次只会使用 Eden 和其中的一块 Survivor 区域来为对象服务,所以无论什么时候,总是有一块 Survivor 区域是空闲着的。 因此,新生代实际可用的内存空间为 9/10 ( 即90% )的新生代空间。
堆的GC机制
堆中的GC分为两种:
-
Minor GC 新生代
-
Major GC 老年代
-
Full GC 新生代 + 老年代
Minor GC发生在新生代,采用的算法是复制算法。
Java中新创建的对象都在新生代中,当对象被判定为死亡时(也就是无法访问),就会被GC回收内存,发生Minor GC时,会将Eden和From Survivor区域中的存活的对象复制到To Survivor区域中,然后将Eden和From survivor区域进行清理。
当一个对象活过了一次Minor GC后,它的年龄就加1,当对象的年龄达到了15时,对象就会被放入老年代。
清理时发生暂停时间较短
同时也有直接进入老年代的情况
对象进入老年代的情况
-
年龄达到15
-
对象大小超过配置 -XX:PretenureSizeThreshold=???
-
年龄相同的一批对象,大小超过Survivor区的1/2
MajorGC 发生在老年代,采用的是标记-清除算法。
标记:标记的过程其实就是,遍历所有的GCRoots,然后将所有GCRoots可达的对象标记为存活的对象。
清除:清除的过程将遍历堆中所有的对象,将没有标记的对象全部清除掉。
当程序运行期间,若可以使用的内存被耗尽的时候,GC线程就会被触发并将程序暂停,随后将依旧存活的对象标记一遍,最终再将堆中所有没被标记的对象全部清除掉,接下来便让程序恢复运行。
标记-清除算法存在比较大的缺点:
-
进行GC时需要暂停时间较长
-
会产生许多不连续的内存空间
当JVM堆内存不足时,也会发生Full GC,发生时会引发MinorGC和MajorGC
耗时最长,所以我们一般会避免出现Full GC。
JVM参数
堆的初始大小、新生代、老年代的大小都可以通过JVM的参数进行配置。
下面是一些常用的JVM参数:
参数名 | 作用 |
---|---|
-Xms | 初始堆大小。如:-Xms256m、-Xms1G |
-Xmx | 最大堆大小。如:-Xmx512m、-Xmx2G 推荐初始堆大小和最大堆大小一样,避免频繁扩容给服务器压力 |
-Xmn | 新生代大小。通常为 Xmx 的 1/3 或 1/4。新生代 = Eden + 2 个 Survivor 空间。实际可用空间为90% |
-Xss | 线程的堆栈大小 |
-XX:NewRatio | 新生代与老年代的比例,如 –XX:NewRatio=2,则新生代占整个堆空间的1/3,老年代占2/3 |
-XX:SurvivorRatio | 新生代中 Eden 与 Survivor 的比值。默认值为 8。即 Eden 占新生代空间的 8/10,另外两个 Survivor 各占 1/10 |
-XX:PermSize | 永久代(方法区)的初始大小 |
-XX:MaxPermSize | 永久代(方法区)的最大值 |
-XX:MetaspaceSize | 元空间(方法区)初始大小(jdk1.8后永久代改为元空间) |
-XX:MaxMetaspaceSize | 元空间(方法区)最大大小 |
-XX:+PrintGCDetails | 打印 GC 信息 |
-XX:PretenureSizeThreshold | 超过该值的对象直接进入老年代 |
-XX:+HeapDumpOnOutOfMemoryError | 设置发生OOM对堆进行保存,以便对错误问题进行分析 |
JVM优化案例
电商系统做活动期间经常出现卡顿,经分析每秒订单为1000,产生大概60M对象
运行参数如下
java -Xms3G -Xmx3G -Xss1M -XX:MetaspaceSize=512M -XX:MaxMetaspaceSize=512M -jar xxx.jar
如何优化?
分析:
-
堆中的新生代、伊甸区、幸存区、老年代分别有多少空间?
新生代:1G
伊甸区:0.8 G
幸存区:0.1 G
老年代:2G
-
每秒产生的对象分配在哪里?
正常情况在伊甸区,60M的对象超过幸存区一半,直接放入老年代
-
为什么频繁卡顿
对象直接放入老年代,老年代内存不够,频繁发生majorGC或fullGC,停顿时间较长
如何解决?
-
将堆的大小设置大一点
-
将新生代的大小或比例调大
-
将幸存区的比例调大
JVM加载类的过程
我们执行Java程序开发出来后,需要先编译再执行,JVM就负责加载类的过程。
类加载的过程分为:
-
加载
-
验证
-
准备
-
解析
-
初始化
加载
在加载类的过程要完成:
-
根据类的全名限定符,获取class二进制流,这个流可以从磁盘上的class、jar文件获得,也可以从网络中获得。
-
将类的静态存储结构转化为方法区的运行时动态存储结构
-
在内存的堆中生成对应的java.lang.Class对象,作为方法区的入口
概述:读取.class文件二进制流
验证
加载类完成后,就进入了验证过程,这个过程保证了前面生成的Class对象中的信息,不会危害JVM的安全。 需要验证的方面有:
-
文件格式验证,是要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。如验证魔数是否0xCAFEBABE;主、次版本号是否正在当前虚拟机处理范围之内;常量池的常量中是否有不被支持的常量类型等等,该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区中,经过这个阶段的验证后,字节流才会进入内存的方法区中存储,所以后面的三个验证阶段都是基于方法区的存储结构进行的。
-
元数据验证,是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求。可能包括的验证如:这个类是否有父类;这个类的父类是否继承了不允许被继承的类;如果这个类不是抽象类,是否实现了其父类或接口中要求实现的所有方法。
-
字节码验证,主要工作是进行数据流和控制流分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。如果一个类方法体的字节码没有通过字节码验证,那肯定是有问题的;但如果一个方法体通过了字节码验证,也不能说明其一定就是安全的。
-
符号引用验证,发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在“解析阶段”中发生。验证符号引用中通过字符串描述的权限定名是否能找到对应的类;在指定类中是否存在符合方法字段的描述符及简单名称所描述的方法和字段;符号引用中的类、字段和方法的访问性(private、protected、public、default)是否可被当前类访问。
概述:确保Class文件符合虚拟机规定的Class文件格式,保证该数据不会对jvm造成影响
准备
准备阶段会在方法区中为类的静态变量分配内存,并赋给默认值。
public static int count = 100;
如:上面的count变量在准备阶段会赋值为0,在初始化时再赋值为100;
int类型会给0,string类型会给null
这里不包含final
修饰的静态变量,因为final
修饰的静态变量是在编译期分配。
解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。
整数常量存在常量池,出现-128~127整数常量后,会创建Integer对象保存在常量池中
Integer a = 100;
Integer b = 100;
a和b都指向常量池中的同一个对象
a == b true
-128~127范围外的整数,会创建新对象new Integer(160)
Integer a = 160;
Integer b = 160;
a == b false
String s1 = "aaa"
String s2 = "aaa"
-
符号引用(Symbolic Reference) 符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。
-
直接引用(Direct Reference) 直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是与虚拟机实现的内存布局相关的,如果有了直接引用,那么引用的目标必定已经在内存中存在。
概述:当对象调用常量时,会先创建对象保存在常量池中
初始化
执行类构造方法,给静态变量赋值,调用静态代码块
类初始化是类加载过程的最后一步,前面的类加载过程,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的Java程序代码。
初始化阶段是执行类构造器<clinit>()方法的过程。<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static{}块)中的语句合并产生的。
那么何时执行初始化呢?
-
创建类的实例
-
访问类的静态变量(除常量外,final修饰的) 原因:常量一种特殊的变量,因为编译器把他们当作值而不是属性来对待。
-
访问类的静态方法
-
反射如(Class.forName("com.test.Person"))
-
当初始化一个类时,发现其父类还未初始化,则先调用父类的初始化
-
虚拟机启动时,定义了main()方法的那个类先初始化
类加载器
Java文件编译成class文件后,由类加载器(ClassLoader)负责加载到内存中。
ClassLoader分为三类:
-
BootstrapClassLoader:主要负责加载核心的类库(java.lang.*等),构造ExtClassLoader和APPClassLoader。
-
ExtClassLoader:主要负责加载jre/lib/ext目录下的一些扩展的jar。
-
AppClassLoader:主要负责加载应用程序的主函数类
-
自定义类加载器
双亲委派模型
从AppClassLoader开始,判断是否加载了该类,加载过就返回,否则继续向上查找;
到了顶层的BootstrapClassLoader后,开始加载类路径,加载成功就返回,否则继续向下查找。
没有加载成功,抛出ClassNotFoundException
优点:
父类加载器成功加载则返回,子类加载器不会再加载,防止了重复加载。
防止核心API库被随意篡改。比如有一个要加载java.lang.Integer类的请求,通过双亲委派进制加载传递到启动类加载器,在在核心Java API发现这个名字的类,发现该类已被加载,并不会重新加载传递的过来的java.lang.Integer,而直接返回已加载过的Integer.class,可以防止核心API被随意篡改。
JVM调试工具
JVM调试工具: JConsole 、JVisualVM
面试场景:如何解决线上项目内存溢出问题
-
获得内存溢出时堆的快照
-XX:+HeapDumpOnOutOfMemoryError 当内存溢出时保存堆快照
-XX:HeapDumpPath 保存路径
-
通过工具分析
打开JVisualVM 导入快照进行分析
-
解决问题
import java.util.*;
public class OOM{
private byte[] data = new byte[1024 * 1024];
public static void main(String[] args) throws Exception{
ArrayList<OOM> list = new ArrayList<>();
while(true){
list.add(new OOM());
Thread.sleep(50);
}
}
}
java -Xms1G -Xmx2G -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=D:\\ OOM