JVM和内存调优入门
-
JVM可以运行在任何操作系统上,只要安装了JRE就可以了。
- 而我们的Java程序就是运行在JVM上。
2.JVM的体系结构
主要组件如图,后续逐个介绍。
3.类加载器
-
作用:加载Class文件
-
上图是类加载器和类的简要关系图:
-
类加载器ClassLoader会加载Car.class文件,这时候初始化了Car这个类的Class对象。
-
Class对象再通过new操作,获得了我们Car的实例car1/2/3。
-
而通过反射,我们可以通过car1调用getClass方法,可以获得Car Class对象。
-
Car Class对象通过get ClassLoader()方法,就可以获得ClassLoader这个对象。
-
-
ClassLoader这个对象本身是个抽象类,为什么可以获得它的对象?
-
查看源码,可以了解到ClassLoader是由JVM初始化,而不是由Constructer创建的。
-
-
类加载器有五个级别
-
JVM自带的加载器
-
启动类加载器(根加载器)BootstrapClassLoader---加载rt.jar包下的类,rt.jar其实是c++写的,所以我们在程序中获取不到这个加载器,因为这一层不在JVM的内部
-
扩展类加载器 ExtClassloader --加载/jre/lib/ext下的类。
-
应用程序加载器(引用类加载器)AppClassloader --我们写的引用类型的加载器
-
用户自定义类加载器 CustomClassLoader()---我们自己写的加载器
-
通过输出classLoader,我们可以查看是什么加载器:
package com.rzp.service; public class TestClass { public static void main(String[] args) { TestClass testClass = new TestClass(); Class<? extends TestClass> aClass = testClass.getClass(); String s = new String(); ClassLoader classLoader = aClass.getClassLoader(); System.out.println(classLoader);//AppClassLoader System.out.println(classLoader.getParent());//ExtClassLoader /jre/lib/ext System.out.println(classLoader.getParent().getParent());//null Java程序获取不到(C++写的) 在rt.jar System.out.println(s.getClass().getClassLoader());//null String 的类加载器也是根加载器这个级别的 } }
4.双亲委派机制
-
就是为了保证安全,类加载的一个机制:
-
类加载器收到类加载的请求。
-
类加载器委托给父类加载器去完成,一直向上委托到根加载器。
-
根加载器检查能否加载当前类,能加载就结束,否则抛出异常,通知子类加载器进行加载。
-
这样的结果就是,只要是根加载器里加载的类,被调用的优先级是最高的。
-
比如我们也可以自定义一个类:
package java.lang; public class String { }
那么这个类就和rt.jar里的java.lang.String;完全一致,如果程序调用了我们自己写的String,显然就有大问题了,而双亲委派机制可以保证程序一定会调用rt.jar里面的String。
5.沙箱安全机制
-
Java安全模型的核心就是Java沙箱(sandbox) 。
-
沙箱是一个限制程序运行的环境。 沙箱机制就是将Java代码限定在虚拟机(JVM)特定的运行范围中,并且严格限制代码对本地系统资源访问,通过这样的措施来保证对代码的有效隔离,防止对本地系统造成破坏。
-
本地系统资源:CPU、内存、文件系统、网络。不同级别的沙箱对这些资源访问的限制也可以不一样。
-
-
比如说,如果写一个死循环,那么会占用了所有的JVM内存,引起宕机,如果没有JVM,直接用了电脑内存,就会引起电脑的宕机,而沙箱机制限定在JVM里,保证了本地系统的安全。
组成沙箱的基本组件:
-
字节码校验器(bytecode verifier) :确保ava类文件遵循Java语言规范。这样可以帮助Java程序实现内存保 护。但并不是所有的类文件都会经过字节码校验,比如核心类。
-
比如我们用javac去编译一个写法错误的代码(比如一行少了;),那么编译的时候就会提示错误,而不会编译完执行再报错,这个就是字节码校验器的功能。
-
-
类装载器(class loader) :其中类装载器在3个方面对Java沙箱起作用
-
它防止恶意代码去干涉善意的代码; //双亲委派机制
-
它守护了被信任的类库边界;
-
它将代码归入保护域,确定了代码可以进行哪些操作。
-
6.Native关键字
-
Thread类中有一个例子,这个start0其实是Thread调用了JVM外部的方法去启动线程。
-
Native:
-
对于Java作用范围以外的方法,可以添加Native关键字定义,添加了Native的方法,在类加载的时候会开辟一个标记区域---本地方法栈,登记Native方法。
-
执行Native方法的时候,会通过本地方法接口(JNI:Java Native Interface),调用本地方法库中的方法。
-
本地方法库就可以是其他任何语言、程序的方法,比如C++,调用打印机,或者Robot类里操控鼠标、键盘的方法。
-
-
其实最初就是为了调用C++的方法而生成的,因为Java诞生的时候大家都用的C++。
7.PC寄存器
-
程序计数器 Program Counter Register
-
每个线程都有一个程序计数器,是线程私有的,就是一个指针,指向方法去中的方法字节码。
-
方法字节码:用来存储指向一条指令的地址,也就是即将要执行的代码
8.方法区
-
方法区是被所有线程共享,所有字段和方法字节码,以及一些特殊方法,如构造函数、接口代码都在这里定义----定义方法的信息保存在这个区域。
-
存储:
-
静态变量
-
常量
-
类信息(构造方法、接口定义)
-
运行时常量池。
-
9.栈
-
栈就是一种数据结构,像桶一样,先进后出。
-
栈的生命周期是跟随线程的生命周期的
-
比如一个Main方法,会启动1个线程,随着线程启动,会开辟一个栈,把Main方法先放进去。
-
最后Main方法执行完,从栈中弹出,这个栈也就关闭了。
-
-
栈存储8大基本类型的值、对象引用、实例的方法。
-
-
和队列相比,队列也是一种数据结构,就像管道一样,先进先出。
-
一个Class的文件,首先要执行Main方法,所以Main方法(图中栈里的1)是最新运行的。
-
Main方法里面调用其他的方法,这时候在栈里面,Main的上面就加了对应的方法(2、3)。
-
当方法3执行完以后,3就会弹出去,接着是2,最后是Main。
-
所以Main方法一定是最先运行最后出去。
理解递归
-
理解了栈以后对递归引起栈溢出就很好理解了,比如方法a调用b,方法b再调用a:
public static void main(String[] args) { new Demo().a(); } public void a(){ b(); } public void b(){ a(); }
-
那么执行main方法的时候,就会变成这样:
-
a和b反复调用,迅速就会占满了栈,导致栈溢出(StackOverFlowError)。
-
栈存储的东西
-
8大基本类型的引用和值:
int a = 8; //int a和8都会存储在栈中
-
对象引用:
User a = new User(); //对应引用类型,栈指存储a
-
实例中的方法,比如上面我们使用的方法a、b。
-
存储的方法会包括输入输出的参数、本地变量、Class File引用(这些就相当于我们的方法头还有内部对象)。
-
除此以外还会有方法索引(在栈中第几位),父帧和子帧,就是靠父帧和子帧知道下一个要执行的方法。
-
10.类加载的流程
以下内容参考自:https://blog.csdn.net/justloveyou_/article/details/72466105
-
类的生命周期包括加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using) 和 卸载(Unloading)七个阶段。
1、加载(Loading)----通过序列化与反序列化获取类的Class对象。
(1). 通过一个类的全限定名来获取定义此类的二进制字节流(双亲委派机制);
(2). 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构;
(3). 在堆中(1.7以后)生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口;
加载阶段和连接阶段(Linking)的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。
2、验证(Verification)----验证Class是否合法、是否会危害
验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。 验证阶段大致会完成4个阶段的检验动作:
文件格式验证:验证字节流是否符合Class文件格式的规范(例如,是否以魔术0xCAFEBABE开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型)
元数据验证:对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求(例如:这个类是否有父类,除了java.lang.Object之外);
字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的;
符号引用验证:确保解析动作能正确执行。
验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响。如果所引用的类经过反复验证,那么可以考虑采用-Xverifynone参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。
3、准备(Preparation)---在方法区为类变量(static 成员变量)分配内存并设置类变量初始值
准备阶段是正式为类变量(static 成员变量)分配内存并设置类变量初始值(零值)的阶段,这些变量所使用的内存都将在方法区中进行分配。这时候进行内存分配的仅包括类变量,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在堆中。 4、解析(Resolution)---将常量池内的符号引用替换为直接引用
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。
5、初始化(Initialization)----执行类构造器,对static变量赋值、执行static代码块,最后开辟内存实例化一个对象
-
类构造器不是实例化构造器!(实例化构造器是我们类里写的构造器),类构造器,在jvm第一次加载class文件时调用,只加载一次。
-
类构造器会保证父类的构造器先执行。----也就意味着父类中定义的静态语句块/静态变量的初始化要优先于子类的静态语句块/静态变量的初始化执行。
-
类构造器是线程安全的。
-
虚拟机会保证一个类的类构造器在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的类构造器,其他线程都需要阻塞等待,直到活动线程执行方法完毕。
-
在这种情形下,执行类构造器方法的线程退出,其他线程唤醒后不会再次调用类构造器,因为 在同一个类加载器下,一个类型只会被初始化一次。
-
Sun公司:HotSpot
-
DEA : JRcokit
-
IBM : J9VM
-
我们学习的堆都是基于HotSpot ,在其他引擎下是不一样的。
堆
-
Heap,一个JVM只有1个堆内存,堆内存的大小是可以调节的。
-
存储类的实例、方法、常量、变量,保存我们所有引用类型的真实对象。
-
堆内存分为:
-
新生区(伊甸园区):Eden Space ,又分为
-
伊甸园区
-
幸存0区/to区(经历过垃圾回收后仍然存在的)
-
幸存1区/from区(幸存0区满了存放区)
-
-
养老区:Old
-
永久区(原空间):Perm
-
-
GC垃圾回收主要是对伊甸园区和养老区回收。
新生区和养老区的机制
-
新生区满了之后会触发 YGC,先把存活的对象放到其中一个 Survice 区,然后进行垃圾清理。
-
因为如果仅仅清理需要删除的对象,这样会导致内存碎 片,因此一般会把 Eden 进行完全的清理,然后整理内存。那么下次 GC 的时候, 就会使用下一个 Survive,这样循环使用。
-
如果有特别大的对象,新生代放不下, 就会使用老年代的担保,接放到老年代里面。因为 JVM 认为,一般大对象的存活时间一般比较久远。
-
-
当经过一次或者多次 GC 之后,存活下来的对象会被移动到老年区,当 JVM 内存不够用的时候,会触发 Full GC,清理 JVM 老年区
永久区
-
又叫永久带(PermGen),其实就是jdk1.7及以前,方法区的一种实现方式。
-
1.8以后方法区改为了使用元空间(MetaSpace),和永久带最大的不同就是使用的是机器的内存而不是JVM的内存。
代码测试
package com.rzp.service; public class Demo { public static void main(String[] args) { //JVM试图使用的最大内存 long maxMemory = Runtime.getRuntime().maxMemory(); //返回JVM初始化的总内存 long totalMemory = Runtime.getRuntime().totalMemory(); System.out.println("max" +(maxMemory/(double)1024/1024)+"MB" ); System.out.println("totalMemory" +(totalMemory/(double)1024/1024)+"MB" ); //默认情况下,试图使用的总内存,是电脑内存1/4 //初始化的内存,是电脑内存的1/64 } }
我们可以对使用的内存进行设置,见下图
-Xms1024m -Xmx1024m -XX:+PrintGCDetails
再次输出,可以看到内存调整了,而且可以看到内存架构:
-
其中Young和Old加起来除1024正好是981.5,也说明了元空间不在JVM里面
出现OOM,内存调优入门
1.尝试扩大堆内存 2.分析内存,看一下哪个地方出现了问题
-
能够看到代码第几行出错----使用内存快照分析工具MAT,Jprofile
-
Debug,一行行分析代码。
MAT,Jprofile作用:
-
分析Dump内存文件,快速定位内存泄漏;
-
获得堆中的数据。
-
获得大的对象
测试代码
package com.rzp.service; import java.util.ArrayList; //Dump内存文件 public class DemoOom { byte[] array = new byte[1*1024*1024]; public static void main(String[] args) { ArrayList<Object> list = new ArrayList<>(); int count = 0; try{ while (true){ list.add(new DemoOom()); count = count +1; } }catch (Error e){ System.out.println("count"+count); } } }
vm option中配置
-Xms1m -Xmx8m -XX:+HeapDumpOnOutOfMemoryError
可以输出Dump文件
-
使用Jprofile查看dump文件
-
查看占用内存大的对象
-
-
查看JProfile分析的错误代码
-
垃圾回收机制。
-
GC的作用区域其实只有堆(包括方法区)
GC的具体流程:
-
Eden满了之后会触发 YGC(轻GC)。
-
根据算法,会筛选出仍然存活的对象,先把存活的对象放到Survive区,然后清空Eden区。
-
因为如果仅仅清理需要删除的对象,这样会导致内存碎片,因此一般会把 Eden 进行完全的清理,然后整理内存。那么下次 GC 的时候, 就会使用下一个 Survive,这样循环使用。
-
如果有特别大的对象,新生代放不下, 就会使用老年代的担保,接放到老年代里面。因为 JVM认为,一般大对象的存活时间一般比较久远。
-
-
-
通过参数
-XX:MaxTenuringThreshold=15
来修改,要根据具体的程序调整。
-
-
当 JVM 内存不够用的时候,会触发 Full GC(重GC),清理 JVM 老年区
GC的算法
-
标记清除法
-
扫描两次Eden,第一次扫描标记哪些对象是有用的,第二次扫描把没有标记的对象清除。
-
好处:不需要额外的空间。
-
坏处:两次扫描浪费时间,会产生内存碎片。
-
-
标记整理(压缩)
-
在标记清除法的基础上,再次扫描,把剩余的对象放在内存连续的位置,这样后续的位置就没有碎片了。
-
-
复制算法
-
把Survive区分为Survive From和Survive to区。
-
Survive to区是指两个Survive区中空的那个区。
-
执行的时候会把Eden区中存货的对象放在to区,同时会把From的内存也复制到to中,重新清空from区。
-
这个时候From区就是空的,就变成to区了。
-
好处:没有内存碎片。
-
坏处:浪费了内存空间,多了一半空间永远是空to。
-
复制算法最佳使用场景:对象存货度较低。
-
-
引用计数器
-
记录每个对象的引用的数量,这个方法执行复杂,消耗大,基本不用
-
总结
内存效率:复制算法>标记清除算法>标记压缩算法
内存整齐度:复制算法=标记压缩算法>标记清除算法
内存利用率:标记压缩算法=标记清除算法>复制算法
年轻代:存活率低,适合复制算法。
老年代:存活率高,适合标记清除法+标记压缩法混合实现(比如5次标记清除后,执行1次标记压缩)